stream: expose WritableStream state to node:stream

RO
2mo ago
+122-1

Problem

  • isWritable(new WritableStream()) from node:stream returns null. isErrored() on an errored WritableStream returns false. Node v26.3.0 returns true for both.
  • The helpers in src/js/internal/streams/utils.ts first read stream[Symbol.for("nodejs.stream.writable")] and stream[Symbol.for("nodejs.stream.errored")]. Node defines these as getters on WritableStream.prototype. Bun's native WritableStream does not, so the helpers fall through to the checks for Node streams. #42416 added the getters to ReadableStream.prototype only.

Fix

  • Add both getters to WritableStream.prototype in JSWritableStream.cpp. They return m_state == Writable and m_state == Errored. The code mirrors #42416.
  • Correct because Node's getters are state === 'writable' and state === 'errored' (writablestream.js#L181-L187). A lock or a pending close() does not change the result. The erroring state reports false for 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). Also node-stream.test.js, streams.test.js, the WPT streams suite, and 23 Node test-stream-* / test-webstreams-* files.

Background

  • A web WritableStream has a [[state]] slot: writable, erroring, errored or closed. Bun keeps it in JSWritableStream::m_state.
  • erroring is the wait after abort() or controller.error() until the in-flight write or start() settles. Then the state is errored.
  • node:stream exports isWritable and isErrored. They accept Node streams and web streams. For a web stream, Node reads the state through Symbol.for("nodejs.stream.*") properties. The readable-stream package 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:

stateisWritableisErrored
fresh, locked, after a write, write in flighttruefalse
close() called, sink close() still pendingtruefalse
closedfalsefalse
controller.error() before start() settles (erroring)falsefalse
abort() with a write in flight (erroring)falsefalse
errored (after controller.error(), abort(), or a sink write() that throws)falsetrue
transformStream.writabletruefalse
the TransformStream itself (Node does not brand it)nullfalse
subclass of WritableStreamtruefalse
  • A getter called on an object that is not a WritableStream throws ERR_INVALID_THIS (a TypeError). Node also throws a TypeError there.
  • 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.ts and webstreams_adapters.ts call isWritable() on Node streams only, or they already gate on isWritableStream() 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
warning: binary `bun_shim_impl` should have a kebab-case name
   |
 1 | /workspace/bun/build/release/rust-target/.../bun_shim_impl
   |                                              ^^^^^^^^^^^^^
   |
   = note: `cargo::non_kebab_case_bins` is set to `warn` by default
help: to change the binary name to `bun-shim-impl`, convert `bin.name`
  --> src/install/windows-shim/Cargo.toml:41:8
   |
41 - name = "bun_shim_impl"
41 + name = "bun-shim-impl"
   |
warning: `bun_shim_impl` (manifest) generated 1 warning
warning: `feature(generic_const_exprs)` is not supported with the next-generation trait solver
 [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