Repository navigation
🐛 Bug Report — Runtime APIs: a patched Object.prototype.then breaks arrayBuffer(), bytes() and blob() with typescript_implemented_streams #8043
Description
Description
With typescript_implemented_streams, a patched Object.prototype.then can intercept the internal promises the C++ bridge awaits while consuming a body. Body.arrayBuffer() and Body.blob() then reject with an internal error, and Body.bytes() silently resolves with an empty Uint8Array. With the C++ streams implementation, the patched then is consulted only when the promise returned to the caller is resolved, which is the one lookup the spec performs.
Patching Object.prototype.then is unusual, but WPT's response-stream-with-broken-then.any.js checks that it can't intercept text(), and the failure here is reported as jsgInternalError. Found while fixing #8037. #4980 covered the text() cases.
Reproduction
worker.js:
function describe(value) {
if (value instanceof ArrayBuffer) return `ArrayBuffer(${value.byteLength})`;
if (value instanceof Uint8Array) return `Uint8Array(${value.byteLength})`;
if (value instanceof Blob) return `Blob(${value.size})`;
if (value instanceof FormData) return 'FormData';
return JSON.stringify(value);
}
// Patches Object.prototype.then as the WPT does: it removes itself on first use.
async function probe(name, consume) {
let calls = 0;
Object.prototype.then = function (onFulfilled) {
calls++;
delete Object.prototype.then;
onFulfilled('intercepted');
};
try {
return `${name}: resolved with ${describe(await consume())} (then calls: ${calls})`;
} catch (e) {
return `${name}: rejected with ${e} (then calls: ${calls})`;
} finally {
delete Object.prototype.then;
}
}
export default {
async test() {
const form = { headers: { 'content-type': 'application/x-www-form-urlencoded' } };
console.log([
await probe('arrayBuffer()', () => new Response('hello').arrayBuffer()),
await probe('bytes()', () => new Response('hello').bytes()),
await probe('blob()', () => new Response('hello').blob()),
await probe('text()', () => new Response('hello').text()),
await probe('json()', () => new Response('{"a":1}').json()),
await probe('formData()', () => new Response('a=1', form).formData()),
].join('\n'));
},
};config.capnp:
using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
services = [(name = "main", worker = .worker)],
);
const worker :Workerd.Worker = (
modules = [(name = "worker", esModule = embed "worker.js")],
compatibilityDate = "2026-02-12",
compatibilityFlags = ["typescript_implemented_streams"],
);workerd test --experimental config.capnp
With the flag (workerd 2026-10-27):
arrayBuffer(): rejected with Error: internal error; reference = gnvijvm0meb14n8nb5bebjs8 (then calls: 1)
bytes(): resolved with Uint8Array(0) (then calls: 1)
blob(): rejected with Error: internal error; reference = s3t98gm9ah6hsr93v5fkn6f2 (then calls: 1)
text(): resolved with "hello" (then calls: 0)
json(): resolved with "intercepted" (then calls: 1)
formData(): resolved with "intercepted" (then calls: 1)
and, for each internal error:
workerd/api/js-readable-stream.c++:109: failed: expected JSG_TRY_CAST_ARRAYBUFFER(value) != nullptr
; sentryErrorContext = jsgInternalError
With compatibilityFlags = [] (C++ streams):
arrayBuffer(): resolved with "intercepted" (then calls: 1)
bytes(): resolved with "intercepted" (then calls: 1)
blob(): resolved with "intercepted" (then calls: 1)
text(): resolved with "hello" (then calls: 0)
json(): resolved with "intercepted" (then calls: 1)
formData(): resolved with "intercepted" (then calls: 1)
Expected behavior
arrayBuffer(), bytes() and blob() behave as they do with the C++ implementation: the runtime's internal steps can't be intercepted, and a patched then is consulted only when the promise returned to the caller is resolved.
Cause
getReadableStreamArrayBuffer(), getReadableStreamBytes(), getReadableStreamJson() and getReadableStreamBlob() (js-readable-stream.c++) await the promises of consumeReadableStreamAsArrayBuffer, consumeReadableStreamAsUint8Array and consumeReadableStreamAsJSON (readable.ts). These async functions return an ArrayBuffer, a Uint8Array or a parsed JSON value. Resolving a promise with an object looks up its then, which a patched Object.prototype.then supplies.
arrayBuffer(), andblob(), whichBody::blob()builds onarrayBuffer(): the C++ continuation receives the patched value and failsKJ_REQUIRE_NONNULL(JSG_TRY_CAST_ARRAYBUFFER(value)).bytes():consumeReadableStreamAsUint8ArrayawaitsconsumeReadableStreamAsArrayBufferitself, so the patched value reachesnew Uint8Array(...), which yields an empty array. Because the WPT-stylethenremoves itself, that empty array then reaches C++ as the result. Athenthat stays installed would intercept again and produce the internal error instead.JsReadableStream::json()has the same exposure, but no production code calls it:Body::json()parses the result oftext()in C++.text()is unaffected because its internal result is a string.
The same paths serve R2's GetResult.arrayBuffer(), bytes() and blob(), and container stdout/stderr capture (container.c++).
collectChunks() already avoids this for its own result by giving CollectedBytes a null prototype (readable.ts). Resolving the exported functions with a null-prototype record holding the result would do the same; the fix being prepared for #8037 takes that approach for text().
Activity
Keep a patched then out of TypeScript body consumption
Keep a patched then out of TypeScript body consumption