stream: expose WritableStream state to node:stream
Problem
isWritable(new WritableStream())fromnode:streamreturnsnull.isErrored()on an erroredWritableStreamreturnsfalse. Node v26.3.0 returnstruefor both.- The helpers in
src/js/internal/streams/utils.tsfirst readstream[Symbol.for("nodejs.stream.writable")]andstream[Symbol.for("nodejs.stream.errored")]. Node defines these as getters onWritableStream.prototype. Bun's nativeWritableStreamdoes not, so the helpers fall through to the checks for Node streams. #42416 added the getters toReadableStream.prototypeonly.
Fix
- Add both getters to
WritableStream.prototypeinJSWritableStream.cpp. They returnm_state == Writableandm_state == Errored. The code mirrors #42416. - Correct because Node's getters are
state === 'writable'andstate === 'errored'(writablestream.js#L181-L187). A lock or a pendingclose()does not change the result. Theerroringstate reportsfalsefor both. - The property descriptors match Node: getter only, not enumerable, configurable.
- Verified:
test/js/node/stream/web-stream-state.test.ts(5 new tests, main fails all 5). Alsonode-stream.test.js,streams.test.js, the WPT streams suite, and 23 Nodetest-stream-*/test-webstreams-*files.
Background
- A web
WritableStreamhas a[[state]]slot:writable,erroring,erroredorclosed. Bun keeps it inJSWritableStream::m_state. erroringis the wait afterabort()orcontroller.error()until the in-flight write orstart()settles. Then the state iserrored.node:streamexportsisWritableandisErrored. They accept Node streams and web streams. For a web stream, Node reads the state throughSymbol.for("nodejs.stream.*")properties. Thereadable-streampackage reads the same symbols directly.
Notes
Repro (the WritableStream rows of the report that led to #34624 and #42416):
import { isWritable, isErrored } from "node:stream";
console.log(isWritable(new WritableStream({ write() {} }))); // node: true bun main: null
const ws = new WritableStream({ start(c) { c.error(new Error("boom")); } });
await ws.getWriter().closed.catch(() => {});
console.log(isErrored(ws)); // node: true bun main: false
State matrix compared with Node v26.3.0, 22 rows, identical output with this branch:
| state | isWritable | isErrored |
|---|---|---|
| fresh, locked, after a write, write in flight | true | false |
close() called, sink close() still pending | true | false |
| closed | false | false |
controller.error() before start() settles (erroring) | false | false |
abort() with a write in flight (erroring) | false | false |
errored (after controller.error(), abort(), or a sink write() that throws) | false | true |
transformStream.writable | true | false |
the TransformStream itself (Node does not brand it) | null | false |
subclass of WritableStream | true | false |
- A getter called on an object that is not a
WritableStreamthrowsERR_INVALID_THIS(aTypeError). Node also throws aTypeErrorthere. - An instance has no own
nodejs.stream.*keys. The getters are on the prototype only, as in Node. - No internal caller changes behavior.
compose.ts,duplexify.ts,end-of-stream.tsandwebstreams_adapters.tscallisWritable()on Node streams only, or they already gate onisWritableStream()first. - #34624 was an earlier attempt that added a private intrinsic and special cases inside the helpers. It was closed in favor of the prototype getters from #42416. This PR completes that approach for
WritableStream.
Suites run with the debug build of this branch: test/js/node/stream/web-stream-state.test.ts, test/js/node/stream/node-stream.test.js, test/js/node/stream/node-stream-uint8array.test.ts, test/js/web/streams/streams.test.js (549 pass), test/js/third_party/wpt-streams/wpt-streams.test.ts (1175 pass), test/js/bun/util/inspect.test.js -t prototype, and these files from test/js/node/test/parallel/: test-stream-add-abort-signal, test-stream-compose, test-stream-compose-operator, test-stream-duplex-from, test-stream-finished, test-stream-pipeline, test-stream-promises, test-webstreams-abort-controller, test-webstreams-adapters-sync-write-error, test-webstreams-adapters-writable-buffer-sources, test-webstreams-compose, test-webstreams-duplex-fromweb-writev-unhandled-rejection, test-webstreams-finished, test-webstreams-pipeline, test-whatwg-webstreams-adapters-to-readablewritablepair, test-whatwg-webstreams-adapters-to-streamduplex, test-whatwg-webstreams-adapters-to-streamwritable, test-whatwg-webstreams-adapters-to-writablestream, test-whatwg-webstreams-coverage, test-whatwg-webstreams-transform-stream-members, test-whatwg-writablestream-close, test-whatwg-transformstream-cancel-write-race, test-webstream-string-tag.
[human-review] gate passed · iteration 9 · 2 files touched
fails on main (without fix)
ASAN without fix: 5 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/stream/web-stream-state.test.ts
bun test v1.4.3 (c6b7fcb5b)
test/js/node/stream/web-stream-state.test.ts:
(pass) node:stream observes ReadableStream state [21.94ms]
(pass) node:stream observes errored ReadableStreams [3.92ms]
52 | close() {
53 | closeStarted.resolve();
54 | return closeFinished.promise;
55 | },
56 | });
57 | expect(writableState(stream)).toEqual({ isWritable: true, isErrored: false });
^
error: expect(received).toEqual(expected)
{
"isErrored": false,
- "isWritable": true,
+ "isWritable": null,
}
- Expected - 1
+ Received + 1
at <anonymous> (/workspace/bun/test/js/node/stream/web-stream-state.test.ts:57:33)
at <anonymous> (/workspace/bun/test/js/node/stream/web-stream-state.test.ts:47:51)
(fail) node:stream observes WritableStream state [15.79ms]
83 | },
84 | });
85 |
86 | // start() has not settled yet, so the stream waits in "erroring".
87 | controller.error(new Error("fixture stream error"));
88 | expec
... (truncated)
release without fix: 5 FAILED
bun test v1.4.3-canary.1 (c6b7fcb5b)
test/js/node/stream/web-stream-state.test.ts:
(pass) node:stream observes ReadableStream state [0.41ms]
(pass) node:stream observes errored ReadableStreams [0.07ms]
52 | close() {
53 | closeStarted.resolve();
54 | return closeFinished.promise;
55 | },
56 | });
57 | expect(writableState(stream)).toEqual({ isWritable: true, isErrored: false });
^
error: expect(received).toEqual(expected)
{
"isErrored": false,
- "isWritable": true,
+ "isWritable": null,
}
- Expected - 1
+ Received + 1
at <anonymous> (/workspace/bun/test/js/node/stream/web-stream-state.test.ts:57:33)
(fail) node:stream observes WritableStream state [0.56ms]
83 | },
84 | });
85 |
86 | // start() has not settled yet, so the stream waits in "erroring".
87 | controller.error(new Error("fixture stream error"));
88 | expect(writableState(stream)).toEqual({ isWritable: false, isErrored: false });
^
error: expect(received).toEqual(expected)
{
"isErrored": false,
- "isWritable": false,
+ "isWritable": null,
}
- Expected - 1
+ Re
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/stream/web-stream-state.test.ts
bun test v1.4.3 (c6b7fcb5b)
test/js/node/stream/web-stream-state.test.ts:
(pass) node:stream observes ReadableStream state [17.72ms]
(pass) node:stream observes errored ReadableStreams [3.98ms]
(pass) node:stream observes WritableStream state [15.66ms]
(pass) node:stream observes errored WritableStreams [7.45ms]
(pass) node:stream reports an erroring WritableStream as errored only after the in-flight write settles [10.29ms]
(pass) node:stream observes the writable side of a TransformStream [3.30ms]
(pass) WritableStream.prototype state getters have the same shape as Node's [8.00ms]
(pass) a stream that a native consumer takes > closes after Bun.write() takes it to the end [340.04ms]
(pass) a stream that a native consumer takes > closes after HTMLRewriter takes it to the end [385.19ms]
(pass) a stream that a native consumer takes > a JS stream > closes after Bun.write() takes it to the end [11.54ms]
(pass) a stream that a native consumer takes > closes after a Bun.serve() response body takes
... (truncated)
release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 717ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/8] cxx obj/src/jsc/bindings/webcore/streams/JSWritableStream.cpp.o
[2/8] gen cpp.rs (cppbind)
[2/8] cargo bun_runtime → libbun_runtime.a
[1m[33mwarning[0m[1m: binary `bun_shim_impl` should have a kebab-case name[0m
[1m[94m|[0m
[1m[94m 1[0m [1m[94m|[0m /workspace/bun/build/release/rust-target/.../bun_shim_impl
[1m[94m|[0m [1m[33m^^^^^^^^^^^^^[0m
[1m[94m|[0m
[1m[94m= [0m[1mnote[0m: `cargo::non_kebab_case_bins` is set to `warn` by default
[1m[96mhelp[0m: to change the binary name to `bun-shim-impl`, convert `bin.name`
[1m[94m--> [0msrc/install/windows-shim/Cargo.toml:41:8
[1m[94m|[0m
[1m[94m41[0m [91m- [0mname = [91m"bun_shim_impl"[0m
[1m[94m41[0m [92m+ [0mname = [92m"bun-shim-impl"[0m
[1m[94m|[0m
[1m[33mwarning[0m: `bun_shim_impl` (manifest) generated 1 warning
[1m[33mwarning[0m[1m: `feature(generic_const_exprs)` is not supported with the next-generation trait solver[0m
[1m[9
... (truncated)
diff hotspot
.../bindings/webcore/streams/JSWritableStream.cpp | 29 +++++++
test/js/node/stream/web-stream-state.test.ts | 94 +++++++++++++++++++++-
2 files changed, 122 insertions(+), 1 deletion(-)
gate history · 2 passed · 0 rejected · iteration 9
evidence per changed file
file reads edits tests
src/jsc/bindings/webcore/streams/JSWritableStream.cpp 1 1 17
test/js/node/stream/web-stream-state.test.ts 1 1 17