Skip to content

🐛 Bug Report — Runtime APIs: a patched Object.prototype.then breaks arrayBuffer(), bytes() and blob() with typescript_implemented_streams #8043

Closed

Description

@jasnell

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(), and blob(), which Body::blob() builds on arrayBuffer(): the C++ continuation receives the patched value and fails KJ_REQUIRE_NONNULL(JSG_TRY_CAST_ARRAYBUFFER(value)).
  • bytes(): consumeReadableStreamAsUint8Array awaits consumeReadableStreamAsArrayBuffer itself, so the patched value reaches new Uint8Array(...), which yields an empty array. Because the WPT-style then removes itself, that empty array then reaches C++ as the result. A then that 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 of text() 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().

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions