webcore: propagate exceptions from readableStreamTee instead of reporting them as uncaught
Problem
Calling .clone() on a Request or Response whose body stream is locked is supposed to throw a single, catchable TypeError. In ASSERT_ENABLED builds (debug, and the asan release lane) it instead did three wrong things at once:
const q = new Request("http://example.com/", {
method: "POST",
body: new ReadableStream({ start() {} }),
duplex: "half",
});
q.body.getReader(); // lock the body stream
try {
q.clone();
} catch (e) {
console.log("caught:", e.message);
}
TypeError: ReadableStream is locked <- reported as UNCAUGHT even though it was caught
at repro.ts:8:5
caught: Value is not a sequence <- the catch block gets the wrong error
(exit code 1) <- process fails despite the try/catch
Release builds behaved correctly (caught: ReadableStream is locked, exit 0). Response.clone() hits the same path.
Cause
ReadableStream__tee invokes the readableStreamTee builtin through a local helper in src/jsc/bindings/webcore/ReadableStream.cpp. That builtin throws a TypeError("ReadableStream is locked") synchronously when the stream is already locked, which is exactly what Request.clone() relies on.
The helper contained this after the call:
#if ASSERT_ENABLED
if (scope.exception()) [[unlikely]] {
Bun__reportError(lexicalGlobalObject, JSC::JSValue::encode(scope.exception()));
}
#endif
EXCEPTION_ASSERT(!scope.exception() || vm.hasPendingTerminationException());
RETURN_IF_EXCEPTION(scope, {});
Bun__reportError routes through VirtualMachine::uncaught_exception, which prints the error, sets the process exit code to 1, and clears the pending exception. With the exception gone, RETURN_IF_EXCEPTION no longer fired, so control reached convert<IDLSequence<...>> on the empty JSValue returned by the failed JSC::call, which is where the bogus "Value is not a sequence" came from.
The EXCEPTION_ASSERT on the next line encodes the same false invariant (that readableStreamTee can never throw a non-termination exception); without the Bun__reportError clearing the exception it would have aborted instead.
Fix
Drop both lines and let RETURN_IF_EXCEPTION propagate the exception. ReadableStream__tee already returns false with the exception pending, and the Rust caller (from_js_host_call_generic in ReadableStream::tee) already converts that into an Err that reaches the user's catch. This makes debug and asan builds match what release builds already did.
ReadableStream.prototype.tee() was never affected: it goes through the JS builtin directly and does not use this C++ wrapper.
Verification
New test.each(["Request", "Response"]) cases in test/js/web/fetch/body-clone.test.ts spawn a child, lock the body, clone(), and assert the exact caught message and exit code 0.
Before (debug build):
{
- "exitCode": 0,
+ "exitCode": 1,
"stdout": [
- "caught TypeError: ReadableStream is locked",
+ "caught TypeError: Value is not a sequence",
"done",
],
}
After: both pass, and the existing body-clone.test.ts (27), body.test.ts / body-stream.test.ts / body-mixin-errors.test.ts (9434), and streams.test.js suites still pass.