From b9c81b2e056df68c7a207142e832fa4c8cc319d8 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 4 Jun 2026 20:17:07 +0000 Subject: [PATCH 01/61] Report Node.js 26.3.0 (V8 14.6.202.34, ABI 147) Bump the compat target from Node 24.3.0 to 26.3.0: - nodejs-headers 26.3.0, NODE_MODULE_VERSION 147, bootstrap/flake pins - process.versions.v8 = 14.6.202.34-node.20, modules = 147 - V8 shim updated for 14.6: Isolate roots layout, flattened FunctionCallbackInfo exit frame, String Write/WriteOneByte/WriteUtf8 V2 + Utf8LengthV2 (legacy exports kept), External::New pointer-tag overload, Number::NewFromInt32/NewFromUint32, HandleScope::Extend/DeleteExtensions - napi_get_value_string_* no longer forms a slice from a null buffer pointer when only querying the encoded length - v8/napi test fixtures migrated to the V2 string APIs --- src/jsc/bindings/v8/v8_handle_scope_data.h | 39 ++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/jsc/bindings/v8/v8_handle_scope_data.h diff --git a/src/jsc/bindings/v8/v8_handle_scope_data.h b/src/jsc/bindings/v8/v8_handle_scope_data.h new file mode 100644 index 00000000000..2c6a74f5cf7 --- /dev/null +++ b/src/jsc/bindings/v8/v8_handle_scope_data.h @@ -0,0 +1,39 @@ +#pragma once + +// Access to the v8::internal::HandleScopeData that V8 14's inline HandleScope code +// (v8-local-handle.h) reads and writes directly at a fixed offset inside the Isolate +// (internal::Internals::GetHandleScopeData). That offset lands inside our Isolate's padding, +// which the Isolate constructor zeroes (matching real V8's HandleScopeData::Initialize()). +// +// The same warning as in real_v8.h applies: only include this in source files in the v8 +// directory, never in headers. + +#include "real_v8.h" +#include "V8Isolate.h" + +#include + +namespace v8 { +namespace shim { + +// Use the real V8 struct directly so the layout cannot drift: +// { Address* next; Address* limit; int level; int sealed_level; } where Address is uintptr_t. +using HandleScopeData = real_v8::internal::HandleScopeData; + +static_assert(std::is_same_v, + "V8's Address type is expected to be uintptr_t"); +static_assert(real_v8::internal::Internals::kIsolateHandleScopeDataOffset + >= offsetof(::v8::Isolate, m_padding), + "HandleScopeData would overlap the Isolate's leading fields"); +static_assert(real_v8::internal::Internals::kIsolateHandleScopeDataOffset + sizeof(HandleScopeData) + <= offsetof(::v8::Isolate, m_roots), + "HandleScopeData does not fit inside the Isolate's padding"); + +inline HandleScopeData* getHandleScopeData(Isolate* isolate) +{ + return reinterpret_cast( + reinterpret_cast(isolate) + real_v8::internal::Internals::kIsolateHandleScopeDataOffset); +} + +} // namespace shim +} // namespace v8 From a914c2719ac566e5d63a22f33253dec120781ea7 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 4 Jun 2026 20:17:13 +0000 Subject: [PATCH 02/61] webstreams: settle pending BYOB reads when the reader is cancelled Per https://streams.spec.whatwg.org/#readable-stream-cancel step 5, a BYOB reader's pending read requests must be closed with {value: undefined, done: true} when the stream is cancelled. readableStreamClose only settles default-reader read requests, so a pending byob read() hung forever after reader.cancel(). --- src/js/builtins/ReadableStreamInternals.ts | 13 +++++++++++++ test/js/web/streams/streams.test.js | 14 ++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/js/builtins/ReadableStreamInternals.ts b/src/js/builtins/ReadableStreamInternals.ts index 5ea53f67fa7..c790ffe5f6b 100644 --- a/src/js/builtins/ReadableStreamInternals.ts +++ b/src/js/builtins/ReadableStreamInternals.ts @@ -1606,6 +1606,19 @@ export function readableStreamCancel(stream: ReadableStream, reason: any) { if (state === $streamErrored) return Promise.$reject($getByIdDirectPrivate(stream, "storedError")); $readableStreamClose(stream); + // https://streams.spec.whatwg.org/#readable-stream-cancel step 5: a BYOB + // reader's pending read requests are closed with undefined ($readableStreamClose + // only settles default-reader read requests; respond(0) is not coming after cancel). + const reader = $getByIdDirectPrivate(stream, "reader"); + if (reader && $isReadableStreamBYOBReader(reader)) { + const requests = $getByIdDirectPrivate(reader, "readIntoRequests"); + if (requests.isNotEmpty()) { + $putByIdDirectPrivate(reader, "readIntoRequests", $createFIFO()); + for (var request = requests.shift(); request; request = requests.shift()) + $fulfillPromise(request, { value: undefined, done: true }); + } + } + const controller = $getByIdDirectPrivate(stream, "readableStreamController"); if (controller === null) return Promise.$resolve(); diff --git a/test/js/web/streams/streams.test.js b/test/js/web/streams/streams.test.js index 5256b7502f1..8299c4bd504 100644 --- a/test/js/web/streams/streams.test.js +++ b/test/js/web/streams/streams.test.js @@ -1276,3 +1276,17 @@ it("auto-allocated byte stream chunks are zero-filled before being exposed to th expect(value.subarray(1).every(b => b === 0)).toBe(true); reader.cancel(); }); + +it("reader.cancel() settles a pending BYOB read with done: true (whatwg ReadableStreamCancel step 5)", async () => { + const stream = new ReadableStream({ + type: "bytes", + pull() {}, + cancel() {}, + }); + const reader = stream.getReader({ mode: "byob" }); + const pendingRead = reader.read(new Uint8Array(16)); + await reader.cancel("test reason"); + const result = await pendingRead; + expect(result.done).toBe(true); + expect(result.value).toBeUndefined(); +}); From f950dcebed4949aa411fdd1005bbac4e426e96ca Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 4 Jun 2026 20:17:20 +0000 Subject: [PATCH 03/61] node:stream, node:http: sync behavioral changes from Node 24.x to 26.x Port the observable behavior changes between Node v24.3.0 and v26.3.0: streams: - read() with no size returns one buffered chunk at a time in paused mode - pause()/resume() are no-ops on destroyed streams; compose() moved to the prototype and returns the composed Duplex directly - Duplex.from(async generator) destroy aborts with the error and unblocks a parked iterator; duplexPair destroy propagates to the other side - eos()/finished(): conditional AsyncLocalStorage binding, immediate-result precompute for already-settled streams, real errors override AbortError in pipeline() - webstreams adapters: Writable.toWeb no longer hangs on synchronous drain, fromWeb writev failures error the stream instead of an unhandled rejection, Readable.toWeb supports type 'bytes' (BYOB), Duplex.toWeb readableType (DEP0201 for the old alias), ArrayBuffer/SharedArrayBuffer chunk handling, brotli decoder errors surface as TypeError with the original code - write(string, 'buffer') throws ERR_UNKNOWN_ENCODING http: - ServerResponse.writeHeader removed (DEP0063 end-of-life) - upgrade requests with no 'upgrade' listener no longer vanish - getHeader('set-cookie') returns undefined when absent, and setHeader('set-cookie', []) round-trips as a present-but-empty array - remove dead half-ported proxy symbols in Agent, https.Agent no longer shadows createConnection, drain is only emitted when needed - http2: respond() rejects raw-header arrays instead of sending garbage frames, request() on closed sessions uses the right error codes, option range errors use the options. prefix, initialWindowSize is capped Vendored the matching upstream tests and updated the v24-era ones whose expectations changed. --- src/js/builtins/CompressionStream.ts | 21 +- src/js/builtins/DecompressionStream.ts | 21 +- src/js/internal/http.ts | 4 + src/js/internal/primordials.js | 19 + src/js/internal/streams/duplex.ts | 4 +- src/js/internal/streams/duplexify.ts | 2 +- src/js/internal/streams/end-of-stream.ts | 227 ++++--- src/js/internal/streams/operators.ts | 48 +- src/js/internal/streams/pipeline.ts | 2 +- src/js/internal/streams/readable.ts | 39 +- src/js/internal/streams/writable.ts | 3 + src/js/internal/webstreams_adapters.ts | 275 ++++++--- src/js/node/_http_common.ts | 7 +- src/js/node/_http_outgoing.ts | 89 ++- src/js/node/_http_server.ts | 9 +- src/js/node/http2.ts | 35 +- src/js/node/https.ts | 31 +- src/jsc/bindings/NodeHTTP.cpp | 6 + test/js/node/http/node-http-parser.test.ts | 27 + test/js/node/http/node-http.test.ts | 173 ++++++ test/js/node/http2/node-http2.test.js | 152 +++++ .../stream/node-stream-uint8array.test.ts | 4 +- test/js/node/stream/node-stream.test.js | 566 ++++++++++++++++++ .../test-crypto-cipheriv-decipheriv.js | 4 +- .../parallel/test-http2-getpackedsettings.js | 4 +- .../node/test/parallel/test-stream-compose.js | 3 +- .../test/parallel/test-stream-push-strings.js | 2 +- .../test-stream-readable-emittedReadable.js | 2 +- .../test-stream-readable-infinite-read.js | 13 +- .../test-stream-readable-needReadable.js | 2 +- .../test-stream-readable-to-web-byob.js | 49 ++ ...stream-readable-to-web-termination-byob.js | 15 + ...test-stream-readable-to-web-termination.js | 36 +- .../test/parallel/test-stream-typedarray.js | 5 +- .../test/parallel/test-stream-uint8array.js | 4 +- .../test/parallel/test-stream2-transform.js | 5 +- ...treams-adapters-writable-buffer-sources.js | 95 +++ .../test-webstreams-compression-bad-chunks.js | 75 +++ ...st-webstreams-compression-buffer-source.js | 42 ++ ...plex-fromweb-writev-unhandled-rejection.js | 55 ++ .../test-whatwg-webstreams-compression.js | 4 +- .../test-zlib-flush-write-sync-interleaved.js | 4 +- test/js/web/streams/compression.test.ts | 86 +++ 43 files changed, 1998 insertions(+), 271 deletions(-) create mode 100644 test/js/node/test/parallel/test-stream-readable-to-web-byob.js create mode 100644 test/js/node/test/parallel/test-stream-readable-to-web-termination-byob.js create mode 100644 test/js/node/test/parallel/test-webstreams-adapters-writable-buffer-sources.js create mode 100644 test/js/node/test/parallel/test-webstreams-compression-bad-chunks.js create mode 100644 test/js/node/test/parallel/test-webstreams-compression-buffer-source.js create mode 100644 test/js/node/test/parallel/test-webstreams-duplex-fromweb-writev-unhandled-rejection.js diff --git a/src/js/builtins/CompressionStream.ts b/src/js/builtins/CompressionStream.ts index a777b35a531..201956a0973 100644 --- a/src/js/builtins/CompressionStream.ts +++ b/src/js/builtins/CompressionStream.ts @@ -1,6 +1,11 @@ export function initializeCompressionStream(this, format) { const zlib = require("node:zlib"); - const stream = require("node:stream"); + const { + newReadableWritablePairFromDuplex, + kValidateChunk, + kDestroyOnSyncError, + } = require("internal/webstreams_adapters"); + const { isArrayBufferView, isSharedArrayBuffer } = require("node:util/types"); const builders = { "deflate": zlib.createDeflate, @@ -14,8 +19,18 @@ export function initializeCompressionStream(this, format) { throw $ERR_INVALID_ARG_VALUE("format", format, "must be one of: " + Object.keys(builders).join(", ")); const handle = builders[format](); - $putByIdDirectPrivate(this, "readable", stream.Readable.toWeb(handle)); - $putByIdDirectPrivate(this, "writable", stream.Writable.toWeb(handle)); + const transform = newReadableWritablePairFromDuplex(handle, { + // Per the Compression Streams spec, chunks must be BufferSource + // (ArrayBuffer or ArrayBufferView not backed by SharedArrayBuffer). + [kValidateChunk]: function validateBufferSourceChunk(chunk) { + if (isSharedArrayBuffer(isArrayBufferView(chunk) ? chunk.buffer : chunk)) { + throw $ERR_INVALID_ARG_TYPE("chunk", ["ArrayBuffer", "Buffer", "TypedArray", "DataView"], chunk); + } + }, + [kDestroyOnSyncError]: true, + }); + $putByIdDirectPrivate(this, "readable", transform.readable); + $putByIdDirectPrivate(this, "writable", transform.writable); return this; } diff --git a/src/js/builtins/DecompressionStream.ts b/src/js/builtins/DecompressionStream.ts index bf608d03fdc..9658a7e1091 100644 --- a/src/js/builtins/DecompressionStream.ts +++ b/src/js/builtins/DecompressionStream.ts @@ -1,6 +1,11 @@ export function initializeDecompressionStream(this, format) { const zlib = require("node:zlib"); - const stream = require("node:stream"); + const { + newReadableWritablePairFromDuplex, + kValidateChunk, + kDestroyOnSyncError, + } = require("internal/webstreams_adapters"); + const { isArrayBufferView, isSharedArrayBuffer } = require("node:util/types"); const builders = { "deflate": zlib.createInflate, @@ -14,8 +19,18 @@ export function initializeDecompressionStream(this, format) { throw $ERR_INVALID_ARG_VALUE("format", format, "must be one of: " + Object.keys(builders).join(", ")); const handle = builders[format](); - $putByIdDirectPrivate(this, "readable", stream.Readable.toWeb(handle)); - $putByIdDirectPrivate(this, "writable", stream.Writable.toWeb(handle)); + const transform = newReadableWritablePairFromDuplex(handle, { + // Per the Compression Streams spec, chunks must be BufferSource + // (ArrayBuffer or ArrayBufferView not backed by SharedArrayBuffer). + [kValidateChunk]: function validateBufferSourceChunk(chunk) { + if (isSharedArrayBuffer(isArrayBufferView(chunk) ? chunk.buffer : chunk)) { + throw $ERR_INVALID_ARG_TYPE("chunk", ["ArrayBuffer", "Buffer", "TypedArray", "DataView"], chunk); + } + }, + [kDestroyOnSyncError]: true, + }); + $putByIdDirectPrivate(this, "readable", transform.readable); + $putByIdDirectPrivate(this, "writable", transform.writable); return this; } diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index ecd92d4c4f0..c13fb12d213 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -353,6 +353,8 @@ function emitErrorNt(msg, err, callback) { const setMaxHTTPHeaderSize = $newZigFunction("node_http_binding.zig", "setMaxHTTPHeaderSize", 1); const getMaxHTTPHeaderSize = $newZigFunction("node_http_binding.zig", "getMaxHTTPHeaderSize", 0); const kOutHeaders = Symbol("kOutHeaders"); +const kProxyConfig = Symbol("kProxyConfig"); +const kWaitForProxyTunnel = Symbol("kWaitForProxyTunnel"); function ipToInt(ip) { const octets = ip.split("."); @@ -532,6 +534,7 @@ export { kPendingCallbacks, kPort, kProtocol, + kProxyConfig, kRealListen, kRequest, kRes, @@ -542,6 +545,7 @@ export { kTls, kUpgradeOrConnect, kUseDefaultPort, + kWaitForProxyTunnel, noBodySymbol, optionsSymbol, parseProxyConfigFromEnv, diff --git a/src/js/internal/primordials.js b/src/js/internal/primordials.js index f0b3f598eca..a140a351ec3 100644 --- a/src/js/internal/primordials.js +++ b/src/js/internal/primordials.js @@ -96,6 +96,24 @@ const arrayToSafePromiseIterable = (promises, mapFn) => const PromiseAll = Promise.all; const PromiseResolve = Promise.$resolve.bind(Promise); const SafePromiseAll = (promises, mapFn) => PromiseAll(arrayToSafePromiseIterable(promises, mapFn)); +const SafePromiseAllReturnVoid = (promises, mapFn) => + new Promise((resolve, reject) => { + const { length } = promises; + + if (length === 0) resolve(); + + let pendingPromises = length; + for (let i = 0; i < length; i++) { + const promise = mapFn != null ? mapFn(promises[i], i) : promises[i]; + PromisePrototypeThen.$call( + PromiseResolve(promise), + () => { + if (--pendingPromises === 0) resolve(); + }, + reject, + ); + } + }); const SafePromiseAllReturnArrayLike = (promises, mapFn) => new Promise((resolve, reject) => { const { length } = promises; @@ -136,6 +154,7 @@ export default { ), SafePromiseAll, SafePromiseAllReturnArrayLike, + SafePromiseAllReturnVoid, SafeSet: makeSafe( Set, class SafeSet extends Set { diff --git a/src/js/internal/streams/duplex.ts b/src/js/internal/streams/duplex.ts index 69754017b66..1637355d1dc 100644 --- a/src/js/internal/streams/duplex.ts +++ b/src/js/internal/streams/duplex.ts @@ -140,8 +140,8 @@ Duplex.fromWeb = function (pair, options) { return lazyWebStreams().newStreamDuplexFromReadableWritablePair(pair, options); }; -Duplex.toWeb = function (duplex) { - return lazyWebStreams().newReadableWritablePairFromDuplex(duplex); +Duplex.toWeb = function (duplex, options) { + return lazyWebStreams().newReadableWritablePairFromDuplex(duplex, options); }; let duplexify; diff --git a/src/js/internal/streams/duplexify.ts b/src/js/internal/streams/duplexify.ts index cb193e7895b..43d405ede05 100644 --- a/src/js/internal/streams/duplexify.ts +++ b/src/js/internal/streams/duplexify.ts @@ -312,7 +312,7 @@ function _duplexify(pair) { eos(r, err => { readable = false; if (err) { - destroyer(r, err); + destroyer(w, err); } onfinished(err); }); diff --git a/src/js/internal/streams/end-of-stream.ts b/src/js/internal/streams/end-of-stream.ts index 0ba0a43eeb6..fffa5461bd1 100644 --- a/src/js/internal/streams/end-of-stream.ts +++ b/src/js/internal/streams/end-of-stream.ts @@ -26,7 +26,7 @@ const SymbolDispose = Symbol.dispose; const PromisePrototypeThen = $Promise.prototype.$then; let addAbortListener; -let AsyncLocalStorage; +let AsyncResource; function isRequest(stream) { return stream.setHeader && typeof stream.abort === "function"; @@ -34,6 +34,57 @@ function isRequest(stream) { const nop = () => {}; +function bindAsyncResource(fn, type) { + AsyncResource ??= require("node:async_hooks").AsyncResource; + const resource = new AsyncResource(type); + return function (...args) { + return resource.runInAsyncScope(fn, this, ...args); + }; +} + +// Returns true when an AsyncLocalStorage context is currently active, in +// which case eos() must snapshot it so the callback observes the context +// from registration time (matching Node's AsyncContextFrame.current()). +function hasAsyncContext() { + return $getInternalField($asyncContext, 0) !== undefined; +} + +/** + * Returns the current stream error tracked by eos(), if any. + */ +function getEosErrored(stream) { + const errored = isWritableErrored(stream) || isReadableErrored(stream); + return (typeof errored !== "boolean" && errored) || null; +} + +/** + * Returns the error eos() would report from an immediate close, including + * premature close detection for unfinished readable or writable sides. + */ +function getEosOnCloseError(stream, readable, readableFinished, writable, writableFinished) { + const errored = getEosErrored(stream); + if (errored) { + return errored; + } + + if (readable && !readableFinished && isReadableNodeStream(stream, true)) { + if (!isReadableFinished(stream, false)) { + return $ERR_STREAM_PREMATURE_CLOSE(); + } + } + if (writable && !writableFinished) { + if (!isWritableFinished(stream, false)) { + return $ERR_STREAM_PREMATURE_CLOSE(); + } + } + + return null; +} + +// Internal only: if eos() can settle immediately, invoke the callback before +// returning cleanup. Callers must tolerate cleanup yet to be assigned. +const kEosNodeSynchronousCallback = Symbol("kEosNodeSynchronousCallback"); + function eos(stream, options, callback) { if (arguments.length === 2) { callback = options; @@ -46,9 +97,6 @@ function eos(stream, options, callback) { validateFunction(callback, "callback"); validateAbortSignal(options.signal, "options.signal"); - AsyncLocalStorage ??= require("node:async_hooks").AsyncLocalStorage; - callback = once(AsyncLocalStorage.bind(callback)); - if (isReadableStream(stream) || isWritableStream(stream)) { return eosWeb(stream, options, callback); } @@ -60,22 +108,92 @@ function eos(stream, options, callback) { const readable = options.readable ?? isReadableNodeStream(stream); const writable = options.writable ?? isWritableNodeStream(stream); + // TODO (ronag): Improve soft detection to include core modules and + // common ecosystem modules that do properly emit 'close' but fail + // this generic check. + let willEmitClose = + _willEmitClose(stream) && isReadableNodeStream(stream) === readable && isWritableNodeStream(stream) === writable; + let writableFinished = isWritableFinished(stream, false); + let readableFinished = isReadableFinished(stream, false); + const wState = stream._writableState; const rState = stream._readableState; + /** + * undefined: to be determined + * null: no error + * Error: an error occurred + */ + let immediateResult; + if (isClosed(stream)) { + immediateResult = getEosOnCloseError(stream, readable, readableFinished, writable, writableFinished); + } else if (wState?.errorEmitted || rState?.errorEmitted) { + if (!willEmitClose) { + immediateResult = getEosErrored(stream); + } + } else if ( + !readable && + (!willEmitClose || isReadable(stream)) && + (writableFinished || isWritable(stream) === false) && + (wState == null || wState.pendingcb === undefined || wState.pendingcb === 0) + ) { + immediateResult = getEosErrored(stream); + } else if ( + !writable && + (!willEmitClose || isWritable(stream)) && + (readableFinished || isReadable(stream) === false) + ) { + immediateResult = getEosErrored(stream); + } else if (rState && stream.req && stream.aborted) { + immediateResult = getEosErrored(stream); + } + + let cleanup = () => { + callback = nop; + }; + if (immediateResult !== undefined) { + if (options.error !== false) { + stream.on("error", nop); + cleanup = () => { + callback = nop; + stream.removeListener("error", nop); + }; + } + } else if (options.signal?.aborted) { + immediateResult = $makeAbortError(undefined, { cause: options.signal.reason }); + } + if (immediateResult !== undefined && options[kEosNodeSynchronousCallback]) { + if (immediateResult === null) { + callback.$call(stream); + } else { + callback.$call(stream, immediateResult); + } + return cleanup; + } + + if (hasAsyncContext()) { + callback = bindAsyncResource(callback, "STREAM_END_OF_STREAM"); + } + + if (immediateResult !== undefined) { + process.nextTick(() => { + if (immediateResult === null) { + callback.$call(stream); + } else { + callback.$call(stream, immediateResult); + } + }); + return cleanup; + } + + callback = once(callback); + const onlegacyfinish = () => { if (!stream.writable) { onfinish(); } }; - // TODO (ronag): Improve soft detection to include core modules and - // common ecosystem modules that do properly emit 'close' but fail - // this generic check. - let willEmitClose = - _willEmitClose(stream) && isReadableNodeStream(stream) === readable && isWritableNodeStream(stream) === writable; - - let writableFinished = isWritableFinished(stream, false); const onfinish = () => { writableFinished = true; // Stream should not be destroyed here. If it is that @@ -94,7 +212,6 @@ function eos(stream, options, callback) { } }; - let readableFinished = isReadableFinished(stream, false); const onend = () => { readableFinished = true; // Stream should not be destroyed here. If it is that @@ -117,37 +234,13 @@ function eos(stream, options, callback) { callback.$call(stream, err); }; - let closed = isClosed(stream); - const onclose = () => { - closed = true; - - const errored = isWritableErrored(stream) || isReadableErrored(stream); - - if (errored && typeof errored !== "boolean") { - return callback.$call(stream, errored); - } - - if (readable && !readableFinished && isReadableNodeStream(stream, true)) { - if (!isReadableFinished(stream, false)) return callback.$call(stream, $ERR_STREAM_PREMATURE_CLOSE()); - } - if (writable && !writableFinished) { - if (!isWritableFinished(stream, false)) return callback.$call(stream, $ERR_STREAM_PREMATURE_CLOSE()); - } - - callback.$call(stream); - }; - - const onclosed = () => { - closed = true; - - const errored = isWritableErrored(stream) || isReadableErrored(stream); - - if (errored && typeof errored !== "boolean") { - return callback.$call(stream, errored); + const error = getEosOnCloseError(stream, readable, readableFinished, writable, writableFinished); + if (error === null) { + callback.$call(stream); + } else { + callback.$call(stream, error); } - - callback.$call(stream); }; const onrequest = () => { @@ -182,30 +275,7 @@ function eos(stream, options, callback) { } stream.on("close", onclose); - if (closed) { - process.nextTick(onclose); - } else if (wState?.errorEmitted || rState?.errorEmitted) { - if (!willEmitClose) { - process.nextTick(onclosed); - } - } else if ( - !readable && - (!willEmitClose || isReadable(stream)) && - (writableFinished || isWritable(stream) === false) && - (wState == null || wState.pendingcb === undefined || wState.pendingcb === 0) - ) { - process.nextTick(onclosed); - } else if ( - !writable && - (!willEmitClose || isWritable(stream)) && - (readableFinished || isReadable(stream) === false) - ) { - process.nextTick(onclosed); - } else if (rState && stream.req && stream.aborted) { - process.nextTick(onclosed); - } - - const cleanup = () => { + cleanup = () => { callback = nop; stream.removeListener("aborted", onclose); stream.removeListener("complete", onfinish); @@ -220,30 +290,32 @@ function eos(stream, options, callback) { stream.removeListener("close", onclose); }; - if (options.signal && !closed) { + if (options.signal) { const abort = () => { // Keep it because cleanup removes it. const endCallback = callback; cleanup(); endCallback.$call(stream, $makeAbortError(undefined, { cause: options.signal.reason })); }; - if (options.signal.aborted) { - process.nextTick(abort); - } else { - addAbortListener ??= require("internal/abort_listener").addAbortListener; - const disposable = addAbortListener(options.signal, abort); - const originalCallback = callback; - callback = once((...args) => { - disposable[SymbolDispose](); - originalCallback.$apply(stream, args); - }); - } + addAbortListener ??= require("internal/abort_listener").addAbortListener; + const disposable = addAbortListener(options.signal, abort); + const originalCallback = callback; + callback = once((...args) => { + disposable[SymbolDispose](); + originalCallback.$apply(stream, args); + }); } return cleanup; } function eosWeb(stream, options, callback) { + if (hasAsyncContext()) { + callback = once(bindAsyncResource(callback, "STREAM_END_OF_STREAM")); + } else { + callback = once(callback); + } + let isAborted = false; let abort = nop; if (options.signal) { @@ -296,4 +368,5 @@ function finished(stream, opts) { } eos.finished = finished; +eos.kEosNodeSynchronousCallback = kEosNodeSynchronousCallback; export default eos; diff --git a/src/js/internal/streams/operators.ts b/src/js/internal/streams/operators.ts index 802c9c8c24e..1508a66480a 100644 --- a/src/js/internal/streams/operators.ts +++ b/src/js/internal/streams/operators.ts @@ -1,11 +1,8 @@ "use strict"; -const { validateAbortSignal, validateInteger, validateObject } = require("internal/validators"); +const { validateAbortSignal, validateFunction, validateInteger, validateObject } = require("internal/validators"); const { kWeakHandler, kResistStopPropagation } = require("internal/shared"); const { finished } = require("internal/streams/end-of-stream"); -const staticCompose = require("internal/streams/compose"); -const { addAbortSignalNoValidate } = require("internal/streams/add-abort-signal"); -const { isWritable, isNodeStream } = require("internal/streams/utils"); const MathFloor = Math.floor; const PromiseResolve = Promise.$resolve.bind(Promise); @@ -18,32 +15,8 @@ const ObjectDefineProperty = Object.defineProperty; const kEmpty = Symbol("kEmpty"); const kEof = Symbol("kEof"); -function compose(stream, options) { - if (options != null) { - validateObject(options, "options"); - } - if (options?.signal != null) { - validateAbortSignal(options.signal, "options.signal"); - } - - if (isNodeStream(stream) && !isWritable(stream)) { - throw $ERR_INVALID_ARG_VALUE("stream", stream, "must be writable"); - } - - const composedStream = staticCompose(this, stream); - - if (options?.signal) { - // Not validating as we already validated before - addAbortSignalNoValidate(options.signal, composedStream); - } - - return composedStream; -} - function map(fn, options) { - if (typeof fn !== "function") { - throw $ERR_INVALID_ARG_TYPE("fn", ["Function", "AsyncFunction"], fn); - } + validateFunction(fn, "fn"); if (options != null) { validateObject(options, "options"); } @@ -192,9 +165,7 @@ async function some(fn, options = undefined) { } async function every(fn, options = undefined) { - if (typeof fn !== "function") { - throw $ERR_INVALID_ARG_TYPE("fn", ["Function", "AsyncFunction"], fn); - } + validateFunction(fn, "fn"); // https://en.wikipedia.org/wiki/De_Morgan's_laws return !(await some.$call( this, @@ -213,9 +184,7 @@ async function find(fn, options) { } async function forEach(fn, options) { - if (typeof fn !== "function") { - throw $ERR_INVALID_ARG_TYPE("fn", ["Function", "AsyncFunction"], fn); - } + validateFunction(fn, "fn"); async function forEachFn(value, options) { await fn(value, options); return kEmpty; @@ -225,9 +194,7 @@ async function forEach(fn, options) { } function filter(fn, options) { - if (typeof fn !== "function") { - throw $ERR_INVALID_ARG_TYPE("fn", ["Function", "AsyncFunction"], fn); - } + validateFunction(fn, "fn"); async function filterFn(value, options) { if (await fn(value, options)) { return value; @@ -248,9 +215,7 @@ class ReduceAwareErrMissingArgs extends TypeError { } async function reduce(reducer, initialValue, options) { - if (typeof reducer !== "function") { - throw $ERR_INVALID_ARG_TYPE("reducer", ["Function", "AsyncFunction"], reducer); - } + validateFunction(reducer, "reducer"); if (options != null) { validateObject(options, "options"); } @@ -397,7 +362,6 @@ export default { flatMap, map, take, - compose, }, promiseReturningOperators: { every, diff --git a/src/js/internal/streams/pipeline.ts b/src/js/internal/streams/pipeline.ts index c771436558a..8d51817773e 100644 --- a/src/js/internal/streams/pipeline.ts +++ b/src/js/internal/streams/pipeline.ts @@ -207,7 +207,7 @@ function pipelineImpl(streams, callback, opts?) { } function finishImpl(err, final?) { - if (err && (!error || error.code === "ERR_STREAM_PREMATURE_CLOSE")) { + if (err && (!error || error.code === "ERR_STREAM_PREMATURE_CLOSE" || error.name === "AbortError")) { error = err; } diff --git a/src/js/internal/streams/readable.ts b/src/js/internal/streams/readable.ts index 67fb16b260c..d307e26df5b 100644 --- a/src/js/internal/streams/readable.ts +++ b/src/js/internal/streams/readable.ts @@ -2,7 +2,7 @@ const EE = require("node:events"); const { Stream, prependListener } = require("internal/streams/legacy"); -const { addAbortSignal } = require("internal/streams/add-abort-signal"); +const { addAbortSignal, addAbortSignalNoValidate } = require("internal/streams/add-abort-signal"); const eos = require("internal/streams/end-of-stream"); const destroyImpl = require("internal/streams/destroy"); const { getHighWaterMark, getDefaultHighWaterMark } = require("internal/streams/state"); @@ -21,7 +21,7 @@ const { kConstructed, } = require("internal/streams/utils"); const { aggregateTwoErrors } = require("internal/errors"); -const { validateObject } = require("internal/validators"); +const { validateAbortSignal, validateObject } = require("internal/validators"); const { StringDecoder } = require("node:string_decoder"); const from = require("internal/streams/from"); const { SafeSet } = require("internal/primordials"); @@ -561,8 +561,12 @@ function howMuchToRead(n, state) { if (n <= 0 || (state.length === 0 && (state[kState] & kEnded) !== 0)) return 0; if ((state[kState] & kObjectMode) !== 0) return 1; if (NumberIsNaN(n)) { + // Fast path for buffers. + if ((state[kState] & kDecoder) === 0 && state.length) return state.buffer[state.bufferIndex].length; + // Only flow one buffer at a time. if ((state[kState] & kFlowing) !== 0 && state.length) return state.buffer[state.bufferIndex].length; + return state.length; } if (n <= state.length) return n; @@ -768,7 +772,7 @@ function emitReadable_(stream) { // However, if we're not ended, or reading, and the length < hwm, // then go ahead and try to read some more preemptively. function maybeReadMore(stream, state) { - if ((state[kState] & (kReadingMore | kConstructed)) === kConstructed) { + if ((state[kState] & (kReadingMore | kReading | kConstructed)) === kConstructed) { state[kState] |= kReadingMore; process.nextTick(maybeReadMore_, stream, state); } @@ -1128,6 +1132,9 @@ function nReadingNextTick(self) { // If the user uses them, then switch into old mode. Readable.prototype.resume = function () { const state = this._readableState; + if ((state[kState] & kDestroyed) !== 0) { + return this; + } if ((state[kState] & kFlowing) === 0) { $debug("resume"); // We flow only if there is no one listening @@ -1167,6 +1174,9 @@ function resume_(stream, state) { Readable.prototype.pause = function () { const state = this._readableState; + if ((state[kState] & kDestroyed) !== 0) { + return this; + } $debug("call pause"); if ((state[kState] & (kHasFlowing | kFlowing)) !== kHasFlowing) { $debug("pause"); @@ -1247,6 +1257,27 @@ Readable.prototype.iterator = function (options) { return streamToAsyncIterator(this, options); }; +let composeImpl; + +Readable.prototype.compose = function compose(stream, options) { + if (options != null) { + validateObject(options, "options"); + } + if (options?.signal != null) { + validateAbortSignal(options.signal, "options.signal"); + } + + composeImpl ??= require("internal/streams/compose"); + const composedStream = composeImpl(this, stream); + + if (options?.signal) { + // Not validating as we already validated before + addAbortSignalNoValidate(options.signal, composedStream); + } + + return composedStream; +}; + function streamToAsyncIterator(stream, options?) { if (typeof stream.read !== "function") { stream = Readable.wrap(stream, { objectMode: true }); @@ -1528,7 +1559,7 @@ function fromList(n, state) { n -= str.length; buf[idx++] = null; } else { - if (n === buf.length) { + if (n === str.length) { ret += str; buf[idx++] = null; } else { diff --git a/src/js/internal/streams/writable.ts b/src/js/internal/streams/writable.ts index 05bc6c594aa..af5d7b7d4f5 100644 --- a/src/js/internal/streams/writable.ts +++ b/src/js/internal/streams/writable.ts @@ -431,6 +431,9 @@ function _write(stream, chunk, encoding, cb?) { } if (typeof chunk === "string") { + if (encoding === "buffer") { + throw $ERR_UNKNOWN_ENCODING(encoding); + } if ((state[kState] & kDecodeStrings) !== 0) { chunk = Buffer.from(chunk, encoding); encoding = "buffer"; diff --git a/src/js/internal/webstreams_adapters.ts b/src/js/internal/webstreams_adapters.ts index 86bc91551f7..9c969ea2dd9 100644 --- a/src/js/internal/webstreams_adapters.ts +++ b/src/js/internal/webstreams_adapters.ts @@ -1,7 +1,7 @@ "use strict"; const { - SafePromiseAll, + SafePromiseAllReturnVoid, SafeSet, TypedArrayPrototypeGetBuffer, TypedArrayPrototypeGetByteOffset, @@ -14,14 +14,18 @@ const Duplex = require("internal/streams/duplex"); const { destroyer } = require("internal/streams/destroy"); const { isDestroyed, isReadable, isWritable, isWritableEnded } = require("internal/streams/utils"); const { kEmptyObject } = require("internal/shared"); -const { validateBoolean, validateObject } = require("internal/validators"); -const finished = require("internal/streams/end-of-stream"); +const { validateBoolean, validateObject, validateOneOf } = require("internal/validators"); +const { isAnyArrayBuffer } = require("node:util/types"); +const eos = require("internal/streams/end-of-stream"); +const { kEosNodeSynchronousCallback } = eos; const normalizeEncoding = $newZigFunction("node_util_binding.zig", "normalizeEncoding", 1); const ArrayPrototypeFilter = Array.prototype.filter; const ArrayPrototypeMap = Array.prototype.map; const ObjectEntries = Object.entries; +const ObjectDefineProperty = Object.defineProperty; +const StringPrototypeStartsWith = String.prototype.startsWith; const PromiseWithResolvers = Promise.withResolvers.bind(Promise); const PromiseResolve = Promise.$resolve.bind(Promise); const PromisePrototypeThen = $Promise.prototype.$then; @@ -29,6 +33,9 @@ const SafePromisePrototypeFinally = $Promise.prototype.finally; const constants_zlib = $processBindingConstants.zlib; +const kValidateChunk = Symbol("kValidateChunk"); +const kDestroyOnSyncError = Symbol("kDestroyOnSyncError"); + function tryTransferToNativeReadable(stream, options) { const ptr = stream.$bunNativePtr; if (!ptr || ptr === -1) { @@ -172,13 +179,42 @@ const ZLIB_FAILURES: Set = new SafeSet([ ]); function handleKnownInternalErrors(cause: Error | null): Error | null { + const causeCode = cause?.code; switch (true) { - case cause?.code === "ERR_STREAM_PREMATURE_CLOSE": { + case causeCode === "ERR_STREAM_PREMATURE_CLOSE": { return $makeAbortError(undefined, { cause }); } - case ZLIB_FAILURES.has(cause?.code): { - const error = new TypeError(undefined, { cause }); - error.code = cause.code; + case ZLIB_FAILURES.has(causeCode): + // Brotli decoder errors carry the BrotliDecoderErrorString() name. In + // Node these are formatted as 'ERR_' + '_ERROR_...' (= 'ERR__ERROR_*'); + // Bun's native brotli formats them as 'ERR_BROTLI_DECODER_' + + // 'ERROR_...' (= 'ERR_BROTLI_DECODER_ERROR_*'). Match both shapes. + // Falls through + case causeCode != null && + (StringPrototypeStartsWith.$call(causeCode, "ERR__ERROR_") || + StringPrototypeStartsWith.$call(causeCode, "ERR_BROTLI_DECODER_ERROR_")): { + // Upstream uses `new TypeError(undefined, { cause })`, but the builtins + // codegen rewrites `new TypeError` to $makeTypeError, which only accepts + // a message and silently drops the options bag. Pass an explicit empty + // message (matching the `undefined` message upstream produces) and + // define `cause` manually with the same attributes + // `new Error(msg, { cause })` would produce: own, writable, + // configurable, non-enumerable. + const error = new TypeError(""); + ObjectDefineProperty(error, "cause", { + __proto__: null, + configurable: true, + enumerable: false, + value: cause, + writable: true, + }); + ObjectDefineProperty(error, "code", { + __proto__: null, + configurable: true, + enumerable: true, + value: causeCode, + writable: true, + }); return error; } default: @@ -186,7 +222,9 @@ function handleKnownInternalErrors(cause: Error | null): Error | null { } } -function newWritableStreamFromStreamWritable(streamWritable) { +const noop = () => {}; + +function newWritableStreamFromStreamWritable(streamWritable, options = kEmptyObject) { // Not using the internal/streams/utils isWritableNodeStream utility // here because it will return false if streamWritable is a Duplex // whose writable option is false. For a Duplex that is not writable, @@ -215,7 +253,7 @@ function newWritableStreamFromStreamWritable(streamWritable) { if (backpressurePromise !== undefined) backpressurePromise.resolve(); } - const cleanup = finished(streamWritable, error => { + const cleanup = eos(streamWritable, error => { error = handleKnownInternalErrors(error); cleanup(); @@ -254,11 +292,31 @@ function newWritableStreamFromStreamWritable(streamWritable) { }, write(chunk) { - if (streamWritable.writableNeedDrain || !streamWritable.write(chunk)) { - backpressurePromise = PromiseWithResolvers(); - return SafePromisePrototypeFinally.$call(backpressurePromise.promise, () => { - backpressurePromise = undefined; - }); + try { + options[kValidateChunk]?.(chunk); + if (!streamWritable.writableObjectMode && isAnyArrayBuffer(chunk)) { + chunk = new Uint8Array(chunk); + } + if (streamWritable.writableNeedDrain || !streamWritable.write(chunk)) { + backpressurePromise = PromiseWithResolvers(); + if (!streamWritable.writableNeedDrain) { + backpressurePromise.resolve(); + } + return SafePromisePrototypeFinally.$call(backpressurePromise.promise, () => { + backpressurePromise = undefined; + }); + } + } catch (error) { + // When the kDestroyOnSyncError flag is set (e.g. for + // CompressionStream), a sync throw must also destroy the + // stream so the readable side is errored too. Without this + // the readable side hangs forever. This replicates the + // TransformStream semantics: error both sides on any throw + // in the transform path. + if (options[kDestroyOnSyncError]) { + destroyer(streamWritable, error); + } + throw error; } }, @@ -303,9 +361,8 @@ function newStreamWritableFromWritableStream(writableStream, options = kEmptyObj writev(chunks, callback) { function done(error) { - error = error.filter(e => e); try { - callback(error.length === 0 ? undefined : error); + callback(error); } catch (error) { // In a next tick because this is happening within // a promise context, and if there are any errors @@ -320,7 +377,7 @@ function newStreamWritableFromWritableStream(writableStream, options = kEmptyObj writer.ready, () => { return PromisePrototypeThen.$call( - SafePromiseAll(chunks, data => writer.write(data.chunk)), + SafePromiseAllReturnVoid(chunks, data => writer.write(data.chunk)), done, done, ); @@ -429,6 +486,8 @@ function newStreamWritableFromWritableStream(writableStream, options = kEmptyObj return writable; } +const kErrorSentinelAttached = Symbol("kErrorSentinelAttached"); + function newReadableStreamFromStreamReadable(streamReadable, options = kEmptyObject) { // Not using the internal/streams/utils isReadableNodeStream utility // here because it will return false if streamReadable is a Duplex @@ -437,77 +496,89 @@ function newReadableStreamFromStreamReadable(streamReadable, options = kEmptyObj if (typeof streamReadable?._readableState !== "object") { throw $ERR_INVALID_ARG_TYPE("streamReadable", "stream.Readable", streamReadable); } - - if (isDestroyed(streamReadable) || !isReadable(streamReadable)) { - const readable = new ReadableStream(); - readable.cancel(); - return readable; + validateObject(options, "options"); + if (options.type !== undefined) { + validateOneOf(options.type, "options.type", ["bytes", undefined]); } - const objectMode = streamReadable.readableObjectMode; - const highWaterMark = streamReadable.readableHighWaterMark; - - const evaluateStrategyOrFallback = strategy => { - // If there is a strategy available, use it - if (strategy) return strategy; - - if (objectMode) { - // When running in objectMode explicitly but no strategy, we just fall - // back to CountQueuingStrategy - return new CountQueuingStrategy({ highWaterMark }); - } - - return new ByteLengthQueuingStrategy({ highWaterMark }); - }; - - const strategy = evaluateStrategyOrFallback(options?.strategy); - + const isBYOB = options.type === "bytes"; let controller; let wasCanceled = false; + let strategy; - function onData(chunk) { - // Copy the Buffer to detach it from the pool. - if (Buffer.isBuffer(chunk) && !objectMode) chunk = new Uint8Array(chunk); - controller.enqueue(chunk); - if (controller.desiredSize <= 0) streamReadable.pause(); - } - - streamReadable.pause(); - - const cleanup = finished(streamReadable, error => { - error = handleKnownInternalErrors(error); - - cleanup(); - // This is a protection against non-standard, legacy streams - // that happen to emit an error event again after finished is called. - streamReadable.on("error", () => {}); - if (error) return controller.error(error); - // Was already canceled - if (wasCanceled) { - return; - } - controller.close(); - }); + const underlyingSource = { + __proto__: null, + type: isBYOB ? "bytes" : undefined, + start(c) { + controller = c; + }, + cancel(reason) { + wasCanceled = true; + destroyer(streamReadable, reason); + }, + }; - streamReadable.on("data", onData); + const readable = isReadable(streamReadable); + const objectMode = streamReadable.readableObjectMode; + if (readable) { + underlyingSource.pull = function pull() { + streamReadable.resume(); + }; + + const highWaterMark = streamReadable.readableHighWaterMark; + strategy = isBYOB + ? { highWaterMark } + : (options.strategy ?? new (objectMode ? CountQueuingStrategy : ByteLengthQueuingStrategy)({ highWaterMark })); + } + const readableStream = new ReadableStream(underlyingSource, strategy); - return new ReadableStream( + // When adapting a Duplex as a ReadableStream, readable completion should not + // wait for a half-open writable side to finish as well. + let cleanup = noop; + cleanup = eos( + streamReadable, { - start(c) { - controller = c; - }, + __proto__: null, + writable: false, + [kEosNodeSynchronousCallback]: true, + }, + error => { + error = handleKnownInternalErrors(error); - pull() { - streamReadable.resume(); - }, + // If eos calls the callback synchronously, cleanup is still a no-op here. + cleanup(); - cancel(reason) { - wasCanceled = true; - destroyer(streamReadable, reason); - }, + if (!(kErrorSentinelAttached in streamReadable)) { + // This is a protection against non-standard, legacy streams + // that happen to emit an error event again after finished is called. + streamReadable.on("error", noop); + streamReadable[kErrorSentinelAttached] = true; + } + if (wasCanceled) { + return; + } + wasCanceled = true; + if (error) return controller.error(error); + controller.close(); + if (isBYOB) controller.byobRequest?.respond(0); }, - strategy, ); + + if (wasCanceled) { + // `eos` called the callback synchronously + cleanup(); + } else if (readable) { + streamReadable.pause(); + + streamReadable.on("data", function onData(chunk) { + // Copy the Buffer to detach it from the pool. + if (Buffer.isBuffer(chunk) && !objectMode) chunk = new Uint8Array(chunk); + controller.enqueue(chunk); + if (controller.desiredSize <= 0) streamReadable.pause(); + }); + } + + return readableStream; } function newStreamReadableFromReadableStream(readableStream, options: Record = kEmptyObject) { @@ -538,7 +609,19 @@ function newStreamReadableFromReadableStream(readableStream, options: Record e); try { - callback(error.length === 0 ? undefined : error); + callback(error); } catch (error) { // In a next tick because this is happening within // a promise context, and if there are any errors @@ -618,7 +723,7 @@ function newStreamDuplexFromReadableWritablePair(pair = kEmptyObject, options = writer.ready, () => { return PromisePrototypeThen.$call( - SafePromiseAll(chunks, data => writer.write(data.chunk)), + SafePromiseAllReturnVoid(chunks, data => writer.write(data.chunk)), done, done, ); @@ -718,7 +823,7 @@ function newStreamDuplexFromReadableWritablePair(pair = kEmptyObject, options = } if (!writableClosed || !readableClosed) { - PromisePrototypeThen.$call(SafePromiseAll([closeWriter(), closeReader()]), done, done); + PromisePrototypeThen.$call(SafePromiseAllReturnVoid([closeWriter(), closeReader()]), done, done); return; } @@ -761,5 +866,7 @@ export default { newStreamReadableFromReadableStream, newReadableWritablePairFromDuplex, newStreamDuplexFromReadableWritablePair, + kValidateChunk, + kDestroyOnSyncError, _ReadableFromWeb: ReadableFromWeb, }; diff --git a/src/js/node/_http_common.ts b/src/js/node/_http_common.ts index de665293995..1a5256a0906 100644 --- a/src/js/node/_http_common.ts +++ b/src/js/node/_http_common.ts @@ -59,8 +59,11 @@ const MAX_HEADER_PAIRS = 2000; // called to process trailing HTTP headers. function parserOnHeaders(headers, url) { // Once we exceeded headers limit - stop collecting them - if (this.maxHeaderPairs <= 0 || this._headers.length < this.maxHeaderPairs) { + const capacity = this.maxHeaderPairs - this._headers.length; + if (this.maxHeaderPairs <= 0 || capacity >= headers.length) { this._headers.push(...headers); + } else if (capacity > 0) { + this._headers.push(...headers.slice(0, capacity)); } this._url += url; } @@ -185,8 +188,8 @@ function closeParserInstance(parser) { function freeParser(parser, req, socket) { if (parser) { if (parser._consumed) parser.unconsume(); - cleanParser(parser); parser.remove(); + cleanParser(parser); if (parsers.free(parser) === false) { // Make sure the parser's stack has unwound before deleting the // corresponding C++ object through .close(). diff --git a/src/js/node/_http_outgoing.ts b/src/js/node/_http_outgoing.ts index 4ae81798e03..3b53d817dc1 100644 --- a/src/js/node/_http_outgoing.ts +++ b/src/js/node/_http_outgoing.ts @@ -27,6 +27,10 @@ const { _checkInvalidHeaderChar: checkInvalidHeaderChar, } = require("node:_http_common"); const kUniqueHeaders = Symbol("kUniqueHeaders"); +// Tracks setHeader("set-cookie", []): the FetchHeaders backing store cannot +// represent a present-but-empty set-cookie header, but Node keeps the raw [] +// and returns it from getHeader (nodejs/node#59734). +const kEmptySetCookie = Symbol("kEmptySetCookie"); const kBytesWritten = Symbol("kBytesWritten"); const kRejectNonStandardBodyWrites = Symbol("kRejectNonStandardBodyWrites"); const kCorked = Symbol("corked"); @@ -190,6 +194,8 @@ function OutgoingMessage(options) { this._closed = false; this._header = null; this._headerSent = false; + this.outputData = []; + this.outputSize = 0; this[kHighWaterMark] = options?.highWaterMark ?? (process.platform === "win32" ? 16 * 1024 : 64 * 1024); } const OutgoingMessagePrototype = { @@ -202,7 +208,27 @@ const OutgoingMessagePrototype = { shouldKeepAlive: true, _onPendingData: function nop() {}, outputSize: 0, - outputData: [], + // The constructor creates a per-instance array; a plain array default here + // would be shared (and mutated) across every instance. This accessor lazily + // creates an own array for subclasses that don't chain the constructor. + get outputData() { + const value = []; + ObjectDefineProperty(this, "outputData", { + value, + writable: true, + enumerable: true, + configurable: true, + }); + return value; + }, + set outputData(value) { + ObjectDefineProperty(this, "outputData", { + value, + writable: true, + enumerable: true, + configurable: true, + }); + }, strictContentLength: false, _removedTE: false, _removedContLen: false, @@ -223,7 +249,11 @@ const OutgoingMessagePrototype = { flushHeaders() {}, getHeader(name) { validateString(name, "name"); - return getHeader(this[headersSymbol], name); + const value = getHeader(this[headersSymbol], name); + if (value === undefined && this[kEmptySetCookie] && name.toLowerCase() === "set-cookie") { + return []; + } + return value; }, // Overridden by ClientRequest and ServerResponse; this version will be called only if the user constructs OutgoingMessage directly. @@ -244,7 +274,11 @@ const OutgoingMessagePrototype = { getHeaderNames() { var headers = this[headersSymbol]; if (!headers) return []; - return Array.from(headers.keys()); + const names = Array.from(headers.keys()); + if (this[kEmptySetCookie] && !names.includes("set-cookie")) { + names.push("set-cookie"); + } + return names; }, getRawHeaderNames() { @@ -256,12 +290,19 @@ const OutgoingMessagePrototype = { getHeaders() { const headers = this[headersSymbol]; if (!headers) return kEmptyObject; - return headers.toJSON(); + const json = headers.toJSON(); + if (this[kEmptySetCookie] && json["set-cookie"] === undefined) { + json["set-cookie"] = []; + } + return json; }, removeHeader(name) { validateString(name, "name"); throwHeadersSentIfNecessary(this, "remove"); + if (this[kEmptySetCookie] && name.toLowerCase() === "set-cookie") { + this[kEmptySetCookie] = false; + } const headers = this[headersSymbol]; if (!headers) return; headers.delete(name); @@ -272,6 +313,16 @@ const OutgoingMessagePrototype = { validateHeaderName(name); validateHeaderValue(name, value); const headers = (this[headersSymbol] ??= new Headers()); + if (name.toLowerCase() === "set-cookie") { + if ($isArray(value) && value.length === 0) { + // Present-but-empty: nothing to store in the backing Headers (and + // nothing goes on the wire), but getHeader must return []. + headers.delete(name); + this[kEmptySetCookie] = true; + return this; + } + this[kEmptySetCookie] = false; + } setHeader(headers, name, value); return this; }, @@ -288,20 +339,22 @@ const OutgoingMessagePrototype = { // We also cannot safely split by comma. // To avoid setHeader overwriting the previous value we push // set-cookie values in array and set them all at once. - const cookies = []; + let cookies = null; for (const { 0: key, 1: value } of headers) { if (key === "set-cookie") { if ($isArray(value)) { + cookies ??= []; cookies.push(...value); } else { + cookies ??= []; cookies.push(value); } continue; } this.setHeader(key, value); } - if (cookies.length) { + if (cookies != null) { this.setHeader("set-cookie", cookies); } @@ -309,6 +362,7 @@ const OutgoingMessagePrototype = { }, hasHeader(name) { validateString(name, "name"); + if (this[kEmptySetCookie] && name.toLowerCase() === "set-cookie") return true; const headers = this[headersSymbol]; if (!headers) return false; return headers.has(name); @@ -487,6 +541,29 @@ const OutgoingMessagePrototype = { this._onPendingData(data.length); return this.outputSize < this[kHighWaterMark]; }, + _flushOutput(socket) { + const outputLength = this.outputData.length; + if (outputLength <= 0) return undefined; + + const outputData = this.outputData; + socket.cork(); + let ret; + // Retain for(;;) loop for performance reasons + // Refs: https://github.com/nodejs/node/pull/30958 + for (let i = 0; i < outputLength; i++) { + const { data, encoding, callback } = outputData[i]; + // Avoid any potential ref to Buffer in new generation from old generation + outputData[i].data = null; + ret = socket.write(data, encoding, callback); + } + socket.uncork(); + + this.outputData = []; + this._onPendingData(-this.outputSize); + this.outputSize = 0; + + return ret; + }, end(_chunk, _encoding, _callback) { return this; diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index c7c54c5582c..05f41e3ad12 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -600,7 +600,10 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort } socket[kRequest] = http_req; - const is_upgrade = http_req.headers.upgrade; + // Like Node.js, only treat this as an upgrade when there is an + // 'upgrade' listener; otherwise the request falls through to the + // regular 'request' event. + const is_upgrade = !!http_req.headers.upgrade && server.listenerCount("upgrade") > 0; if (!is_upgrade) { if (canUseInternalAssignSocket) { // ~10% performance improvement in JavaScriptCore due to avoiding .once("close", ...) and removing a listener @@ -1524,8 +1527,6 @@ ServerResponse.prototype.write = function (chunk, encoding, callback) { if (callback) { process.nextTick(callback); } - this.emit("drain"); - return true; }; @@ -1820,8 +1821,6 @@ function ServerResponse_finalDeprecated(chunk, encoding, callback) { // ServerResponse.prototype._final = ServerResponse_finalDeprecated; -ServerResponse.prototype.writeHeader = ServerResponse.prototype.writeHead; - OriginalWriteHeadFn = ServerResponse.prototype.writeHead; OriginalImplicitHeadFn = ServerResponse.prototype._implicitHeader; diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 19afd513bb6..7f5e6630f0d 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -138,7 +138,7 @@ function validateSettings(settings: any) { if (settings.initialWindowSize !== undefined) { const v = settings.initialWindowSize; - if (typeof v !== "number" || v < 0 || v > kMaxInt || Number.isNaN(v)) { + if (typeof v !== "number" || v < 0 || v > kMaxWindowSize || Number.isNaN(v)) { throwSettingRangeError("initialWindowSize", v); } } @@ -380,6 +380,13 @@ function emitErrorNT(self: any, error: any, destroy: boolean) { function emitOutofStreamErrorNT(self: any) { self.destroy($ERR_HTTP2_OUT_OF_STREAMS()); } + +function goawaySessionError() { + const err = new Error("New streams cannot be created after receiving a GOAWAY"); + (err as any).code = "ERR_HTTP2_GOAWAY_SESSION"; + return err; +} +hideFromStack(goawaySessionError); function cache() { const d = new Date(); utcCache = d.toUTCString(); @@ -2478,7 +2485,7 @@ class ServerHttp2Stream extends Http2Stream { if (headers == undefined) { headers = {}; - } else if (!$isObject(headers)) { + } else if (!$isObject(headers) || $isArray(headers)) { throw $ERR_INVALID_ARG_TYPE("headers", "object", headers); } else { headers = { ...headers }; @@ -2519,7 +2526,7 @@ class ServerHttp2Stream extends Http2Stream { if (headers == undefined) { headers = {}; - } else if (!$isObject(headers)) { + } else if (!$isObject(headers) || $isArray(headers)) { throw $ERR_INVALID_ARG_TYPE("headers", "object", headers); } else { headers = { ...headers }; @@ -2638,7 +2645,10 @@ class ServerHttp2Stream extends Http2Stream { if (headers == undefined) { headers = {}; - } else if (!$isObject(headers)) { + } else if (!$isObject(headers) || $isArray(headers)) { + // TODO: support the v26 raw-headers array form ([name1, value1, name2, value2, ...]). + // Until then, reject arrays instead of spreading them into numeric-string keys + // and sending garbage header frames. throw $ERR_INVALID_ARG_TYPE("headers", "object", headers); } else { headers = { ...headers }; @@ -3898,8 +3908,11 @@ class ClientHttp2Session extends Http2Session { request(headers: any, options?: any) { try { - if (this.destroyed || this.closed) { - throw $ERR_HTTP2_INVALID_STREAM(); + if (this.destroyed) { + throw $ERR_HTTP2_INVALID_SESSION(); + } + if (this.closed) { + throw goawaySessionError(); } if (this.sentTrailers) { @@ -4109,14 +4122,14 @@ function initializeOptions(options) { } if (options.maxSessionInvalidFrames !== undefined) - validateUint32(options.maxSessionInvalidFrames, "maxSessionInvalidFrames"); + validateUint32(options.maxSessionInvalidFrames, "options.maxSessionInvalidFrames"); if (options.maxSessionRejectedStreams !== undefined) { - validateUint32(options.maxSessionRejectedStreams, "maxSessionRejectedStreams"); + validateUint32(options.maxSessionRejectedStreams, "options.maxSessionRejectedStreams"); } if (options.unknownProtocolTimeout !== undefined) - validateUint32(options.unknownProtocolTimeout, "unknownProtocolTimeout"); + validateUint32(options.unknownProtocolTimeout, "options.unknownProtocolTimeout"); else options.unknownProtocolTimeout = 10000; // Used only with allowHTTP1 @@ -4237,10 +4250,10 @@ class Http2SecureServer extends tls.Server { validateObject(settings, "options.settings"); } if (options.maxSessionInvalidFrames !== undefined) - validateUint32(options.maxSessionInvalidFrames, "maxSessionInvalidFrames"); + validateUint32(options.maxSessionInvalidFrames, "options.maxSessionInvalidFrames"); if (options.maxSessionRejectedStreams !== undefined) { - validateUint32(options.maxSessionRejectedStreams, "maxSessionRejectedStreams"); + validateUint32(options.maxSessionRejectedStreams, "options.maxSessionRejectedStreams"); } super(options, connectionListener); this[kSessions] = new SafeSet(); diff --git a/src/js/node/https.ts b/src/js/node/https.ts index f140f458396..3a2c0eda82f 100644 --- a/src/js/node/https.ts +++ b/src/js/node/https.ts @@ -35,14 +35,39 @@ function get(input, options, cb) { function Agent(options) { if (!(this instanceof Agent)) return new Agent(options); + options = { __proto__: null, ...options }; + options.defaultPort ??= 443; + options.protocol ??= "https:"; http.Agent.$apply(this, [options]); - this.defaultPort = 443; - this.protocol = "https:"; + this.maxCachedSessions = this.options.maxCachedSessions; if (this.maxCachedSessions === undefined) this.maxCachedSessions = 100; } $toClass(Agent, "Agent", http.Agent); -Agent.prototype.createConnection = http.createConnection; +Agent.prototype.createConnection = function createConnection(...args) { + // XXX: This signature (port, host, options) is different from all the other + // createConnection() methods. + let options; + if (args[0] !== null && typeof args[0] === "object") { + options = args[0]; + } else if (args[1] !== null && typeof args[1] === "object") { + options = { ...args[1] }; + } else if (args[2] === null || typeof args[2] !== "object") { + options = {}; + } else { + options = { ...args[2] }; + } + + if (typeof args[0] === "number") { + options.port = args[0]; + } + + if (typeof args[1] === "string") { + options.host = args[1]; + } + + return require("node:tls").connect(options); +}; var https = { Agent, diff --git a/src/jsc/bindings/NodeHTTP.cpp b/src/jsc/bindings/NodeHTTP.cpp index 287742270eb..37babada090 100644 --- a/src/jsc/bindings/NodeHTTP.cpp +++ b/src/jsc/bindings/NodeHTTP.cpp @@ -1030,6 +1030,12 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPGetHeader, (JSGlobalObject * globalObject, CallFr WebCore::HTTPHeaderName headerName; if (WebCore::findHTTPHeaderName(name, headerName)) { if (headerName == WebCore::HTTPHeaderName::SetCookie) { + // Node's getHeader returns undefined for an absent header; + // Headers.getSetCookie()'s empty array is only correct once + // at least one Set-Cookie value exists. + if (impl->getSetCookieHeaders().isEmpty()) { + return JSValue::encode(jsUndefined()); + } RELEASE_AND_RETURN(scope, fetchHeadersGetSetCookie(globalObject, vm, impl)); } diff --git a/test/js/node/http/node-http-parser.test.ts b/test/js/node/http/node-http-parser.test.ts index dd8adfe8f91..9d1a700d2d8 100644 --- a/test/js/node/http/node-http-parser.test.ts +++ b/test/js/node/http/node-http-parser.test.ts @@ -248,3 +248,30 @@ describe("ConnectionsList", () => { expect(list.all()).toEqual([p1, p4, p3]); }); }); + +describe("parserOnHeaders maxHeaderPairs clamp (nodejs/node#61285)", () => { + test("only fills remaining capacity instead of pushing the whole batch", () => { + const { parsers } = require("node:_http_common"); + const parser = parsers.alloc(); + try { + const onHeaders = parser[kOnHeaders]; + parser._headers = ["x", "1"]; + parser._url = ""; + parser.maxHeaderPairs = 4; + + onHeaders.call(parser, ["a", "2", "b", "3"], ""); + expect(parser._headers).toEqual(["x", "1", "a", "2"]); + + // At capacity: nothing more is collected. + onHeaders.call(parser, ["c", "4"], ""); + expect(parser._headers).toEqual(["x", "1", "a", "2"]); + + // maxHeaderPairs <= 0 means no limit. + parser.maxHeaderPairs = 0; + onHeaders.call(parser, ["c", "4"], ""); + expect(parser._headers).toEqual(["x", "1", "a", "2", "c", "4"]); + } finally { + parser.close(); + } + }); +}); diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 6c3b2a1d25a..57f5319a97f 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -2252,3 +2252,176 @@ it("http.request rejects an options.port that is not a valid port number", async server.close(); } }); + +// Node.js v26 removed res.writeHeader (DEP0063 end-of-life, nodejs/node#60635). +it("ServerResponse.prototype.writeHeader was removed (DEP0063 EOL)", () => { + expect("writeHeader" in ServerResponse.prototype).toBe(false); +}); + +it("setHeaders stores an empty set-cookie array (nodejs/node#59734)", () => { + const msg = new OutgoingMessage(); + msg.setHeaders(new Map([["set-cookie", []]])); + expect(msg.getHeader("set-cookie")).toEqual([]); + expect(msg.hasHeader("set-cookie")).toBe(true); + expect(msg.getHeaders()["set-cookie"]).toEqual([]); + expect(msg.getHeaderNames()).toContain("set-cookie"); + msg.removeHeader("set-cookie"); + expect(msg.getHeader("set-cookie")).toBeUndefined(); + expect(msg.hasHeader("set-cookie")).toBe(false); + + // Headers without a set-cookie entry never call setHeader("set-cookie", ...) + const msg2 = new OutgoingMessage(); + msg2.setHeaders(new Map([["x-test", "1"]])); + expect(msg2.getHeader("set-cookie")).toBeUndefined(); + expect(msg2.getHeader("x-test")).toBe("1"); +}); + +it("https.Agent applies defaultPort/protocol through options (nodejs/node#58980)", () => { + const a = new https.Agent(); + try { + expect(a.defaultPort).toBe(443); + expect(a.protocol).toBe("https:"); + // v26 sets the defaults on the (null-prototype) options object before + // calling the base constructor. + expect(a.options.defaultPort).toBe(443); + expect(a.options.protocol).toBe("https:"); + expect(Object.getPrototypeOf(a.options)).toBe(null); + } finally { + a.destroy(); + } + + const b = new https.Agent({ defaultPort: 8443 }); + try { + expect(b.defaultPort).toBe(8443); + expect(b.protocol).toBe("https:"); + } finally { + b.destroy(); + } +}); + +it("upgrade request with no 'upgrade' listener falls through to 'request'", async () => { + // Mirrors Node.js behavior (see Node's _http_server.js shouldUpgradeCallback + // default): when the server has no 'upgrade' listener, an Upgrade request is + // handled as a regular request instead of disappearing. + const server = createServer((req, res) => { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("regular response"); + }); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + + const result = await new Promise((resolve, reject) => { + const socket = connect(port, "127.0.0.1", () => { + socket.write("GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"); + }); + let data = ""; + socket.setEncoding("utf8"); + socket.on("data", chunk => { + data += chunk; + if (data.includes("regular response")) { + socket.destroy(); + resolve(data); + } + }); + socket.on("error", reject); + socket.on("close", () => resolve(data)); + }); + + expect(result).toContain("HTTP/1.1 200"); + expect(result).toContain("regular response"); + } finally { + server.close(); + } +}); + +it("ServerResponse does not emit 'drain' after a successful (non-backpressured) write", async () => { + // Node.js only emits 'drain' after a write() that returned false. + let drains = 0; + let writeReturned: boolean | undefined; + const server = createServer((req, res) => { + res.on("drain", () => drains++); + writeReturned = res.write("hello"); + // Give a synchronously-emitted 'drain' a chance to fire before ending. + process.nextTick(() => { + res.end(" world"); + }); + }); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + + const body = await new Promise((resolve, reject) => { + const req = http.request({ host: "127.0.0.1", port }, res => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", chunk => (data += chunk)); + res.on("end", () => resolve(data)); + }); + req.on("error", reject); + req.end(); + }); + + expect(body).toBe("hello world"); + expect(writeReturned).toBe(true); + expect(drains).toBe(0); + } finally { + server.close(); + } +}); + +it("https.Agent.prototype.createConnection creates a TLS connection", async () => { + expect(typeof https.Agent.prototype.createConnection).toBe("function"); + + const server = createHttpsServer({ key: tlsCert.key, cert: tlsCert.cert }, (req, res) => { + res.end("secure"); + }); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + + const socket: any = https.globalAgent.createConnection({ + host: "127.0.0.1", + port, + rejectUnauthorized: false, + }); + try { + await once(socket, "secureConnect"); + // It's a TLS socket, not a plain net.Socket. + expect(socket.encrypted).toBe(true); + } finally { + socket.destroy(); + } + } finally { + server.close(); + } +}); + +it("http.Agent with proxyEnv does not write to a literal 'undefined' property", () => { + // Regression: the kProxyConfig symbol destructured from internal/http was + // undefined, so the proxy config was stored as agent["undefined"]. + const agent = new Agent({ proxyEnv: { http_proxy: "http://localhost:4873" } } as any); + try { + expect(Object.hasOwn(agent, "undefined")).toBe(false); + } finally { + agent.destroy(); + } +}); + +it("OutgoingMessage outputData is per-instance and _flushOutput is defined", () => { + expect(typeof OutgoingMessage.prototype._flushOutput).toBe("function"); + + const a = new OutgoingMessage(); + const b = new OutgoingMessage(); + expect(a.outputData).not.toBe(b.outputData); + + // Buffered writes on one message must not leak into other instances + // (outputData used to be a shared array on the prototype). + a.outputData.push({ data: "x", encoding: "utf8", callback: null }); + expect(a.outputData.length).toBe(1); + expect(b.outputData.length).toBe(0); + expect(new OutgoingMessage().outputData.length).toBe(0); +}); diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 84b0ee3cd44..c991d3644f4 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -2711,3 +2711,155 @@ it("http2 client keeps parsing a socket chunk whose ArrayBuffer is transferred b expect(stdout).toContain('PINGS:["4141414141414141","4242424242424242"]'); expect(exitCode).toBe(0); }); + +it("http2 option range error messages use the options. prefix", () => { + for (const opt of ["maxSessionInvalidFrames", "maxSessionRejectedStreams", "unknownProtocolTimeout"]) { + let error; + try { + http2.createServer({ [opt]: -1 }); + } catch (e) { + error = e; + } + expect(error?.code).toBe("ERR_OUT_OF_RANGE"); + expect(error?.message).toContain(`"options.${opt}"`); + } +}); + +it("getPackedSettings caps initialWindowSize at 2**31-1", () => { + // The cap itself is valid. + http2.getPackedSettings({ initialWindowSize: 2 ** 31 - 1 }); + + let error; + try { + http2.getPackedSettings({ initialWindowSize: 2 ** 31 }); + } catch (e) { + error = e; + } + expect(error?.code).toBe("ERR_HTTP2_INVALID_SETTING_VALUE"); + expect(error?.message).toBe('Invalid value for setting "initialWindowSize": 2147483648'); + + error = undefined; + try { + http2.getUnpackedSettings(Buffer.from([0x00, 0x04, 0xff, 0xff, 0xff, 0xff]), { validate: true }); + } catch (e) { + error = e; + } + expect(error?.code).toBe("ERR_HTTP2_INVALID_SETTING_VALUE"); +}); + +it("http2 stream.respond/respondWithFD/respondWithFile reject raw-headers arrays", async () => { + // Passing an array of headers used to be spread into an object with + // numeric-string keys ("0", "1", ...) and sent as garbage header frames. + // Node v24 rejects arrays with ERR_INVALID_ARG_TYPE; v26 added support for + // the [name1, value1, ...] raw form for respond() (not yet implemented here) + // while still rejecting arrays in respondWithFD/respondWithFile. + const errors = []; + const server = http2.createServer(); + server.on("stream", stream => { + for (const invoke of [ + () => stream.respond([":status", "200", "x-foo", "bar"]), + () => stream.respondWithFD(0, ["x-foo", "bar"]), + () => stream.respondWithFile(import.meta.path, ["x-foo", "bar"]), + ]) { + try { + invoke(); + errors.push(null); + } catch (e) { + errors.push(e); + } + } + stream.respond({ ":status": 200 }); + stream.end("ok"); + }); + + await new Promise(resolve => server.listen(0, resolve)); + const port = server.address().port; + const client = http2.connect(`http://localhost:${port}`); + client.on("error", () => {}); + + try { + const req = client.request({ ":path": "/" }); + const response = await new Promise((resolve, reject) => { + req.on("error", reject); + req.on("response", resolve); + req.end(); + }); + let body = ""; + req.on("data", chunk => (body += chunk)); + await new Promise(resolve => req.on("end", resolve)); + + expect(errors).toHaveLength(3); + for (const err of errors) { + expect(err).not.toBeNull(); + expect(err.code).toBe("ERR_INVALID_ARG_TYPE"); + expect(err).toBeInstanceOf(TypeError); + } + // The real respond() afterwards still worked and no bogus "0"/"1" headers leaked. + expect(response[":status"]).toBe(200); + expect(response["0"]).toBeUndefined(); + expect(body).toBe("ok"); + } finally { + client.close(); + server.close(); + } +}); + +it("http2 client.request() on a destroyed or closed session uses the right error codes", async () => { + // Node: destroyed session -> ERR_HTTP2_INVALID_SESSION, + // closed (GOAWAY-pending) session -> ERR_HTTP2_GOAWAY_SESSION. + // The error may surface synchronously or on the returned stream. + function captureRequestError(session) { + try { + const req = session.request({ ":path": "/" }); + return new Promise(resolve => req.on("error", resolve)); + } catch (e) { + return Promise.resolve(e); + } + } + + const server = http2.createServer(); + let endHangingStream; + server.on("stream", (stream, headers) => { + stream.respond({ ":status": 200 }); + if (headers[":path"] === "/hang") { + endHangingStream = () => stream.end("done"); + } else { + stream.end("ok"); + } + }); + await new Promise(resolve => server.listen(0, resolve)); + const port = server.address().port; + + try { + // Closed session (graceful close with a stream still in flight). + const client = http2.connect(`http://localhost:${port}`); + client.on("error", () => {}); + await new Promise(resolve => client.on("connect", resolve)); + const inflight = client.request({ ":path": "/hang" }); + inflight.on("error", () => {}); + inflight.resume(); + await new Promise(resolve => inflight.on("response", resolve)); + client.close(); + expect(client.closed).toBe(true); + expect(client.destroyed).toBe(false); + + const goawayError = await captureRequestError(client); + expect(goawayError.code).toBe("ERR_HTTP2_GOAWAY_SESSION"); + expect(goawayError.message).toBe("New streams cannot be created after receiving a GOAWAY"); + + endHangingStream(); + await new Promise(resolve => inflight.on("close", resolve)); + + // Destroyed session. + const client2 = http2.connect(`http://localhost:${port}`); + client2.on("error", () => {}); + await new Promise(resolve => client2.on("connect", resolve)); + client2.destroy(); + + const destroyedError = await captureRequestError(client2); + expect(destroyedError.code).toBe("ERR_HTTP2_INVALID_SESSION"); + expect(destroyedError.message).toBe("The session has been destroyed"); + } finally { + server.close(); + } +}); diff --git a/test/js/node/stream/node-stream-uint8array.test.ts b/test/js/node/stream/node-stream-uint8array.test.ts index 5072706bd94..981fb8cff9e 100644 --- a/test/js/node/stream/node-stream-uint8array.test.ts +++ b/test/js/node/stream/node-stream-uint8array.test.ts @@ -91,9 +91,11 @@ describe("Readable", () => { readable.push(DEF); readable.unshift(ABC); + // read() with no size returns one buffered chunk at a time. const buf = readable.read(); expect(buf instanceof Buffer).toBe(true); - expect([...buf]).toEqual([...ABC, ...DEF]); + expect([...buf]).toEqual([...ABC]); + expect([...readable.read()]).toEqual([...DEF]); }); it("should work with setEncoding()", () => { diff --git a/test/js/node/stream/node-stream.test.js b/test/js/node/stream/node-stream.test.js index 3312d054796..b9978f6eb48 100644 --- a/test/js/node/stream/node-stream.test.js +++ b/test/js/node/stream/node-stream.test.js @@ -545,6 +545,233 @@ it("should emit prefinish on current tick", done => { }); }); +describe("webstreams adapters (Node v26 sync)", () => { + // Upstream: test-whatwg-webstreams-adapters-to-writablestream.js + // (nodejs/node#61197, fixes nodejs/node#61145) + it("Writable.toWeb does not hang when 'drain' is emitted synchronously during write()", async () => { + const writable = new Writable({ + write(chunk, encoding, callback) { + callback(); + }, + }); + + // Force synchronous 'drain' emission during write() to simulate a + // stream that doesn't have Node.js's built-in kSync protection. + writable.write = function (chunk) { + this.emit("drain"); + return false; + }; + + const writableStream = Writable.toWeb(writable); + const writer = writableStream.getWriter(); + await writer.write(new Uint8Array([1, 2, 3])); + await writer.write(new Uint8Array([4, 5, 6])); + }); + + // Upstream: v26 newStreamWritableFromWritableStream writev done() shape — + // a rejected chunk write during a corked writev must error the stream with + // the original error and must not produce an unhandled rejection. + it("Writable.fromWeb writev rejection errors the stream with the original error", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { Writable } = require("node:stream"); + const theError = new Error("boom"); + const ws = new WritableStream({ + write() { + return Promise.reject(theError); + }, + }); + const w = Writable.fromWeb(ws); + process.on("unhandledRejection", () => { + console.log("UNHANDLED"); + process.exit(2); + }); + w.on("error", e => { + console.log("error-is-original:" + (e === theError)); + }); + w.cork(); + w.write("a"); + w.write("b"); + process.nextTick(() => w.uncork()); + `, + ], + env: bunEnv, + }); + + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout.trim()).toBe("error-is-original:true"); + expect(exitCode).toBe(0); + }); + + // Upstream: test-stream-readable-to-web.js (v26) — options.type: 'bytes' + it("Readable.toWeb supports options.type: 'bytes' (BYOB)", async () => { + const readable = Readable.from([new Uint8Array([1, 2, 3])]); + const rs = Readable.toWeb(readable, { type: "bytes" }); + const reader = rs.getReader({ mode: "byob" }); + + const first = await reader.read(new Uint8Array(10)); + expect(first.done).toBe(false); + expect(Array.from(first.value)).toEqual([1, 2, 3]); + + const second = await reader.read(new Uint8Array(10)); + expect(second.done).toBe(true); + }); + + it("Readable.toWeb validates options", () => { + const readable = Readable.from(["x"]); + expect(() => Readable.toWeb(readable, null)).toThrow(); + try { + Readable.toWeb(readable, null); + } catch (e) { + expect(e.code).toBe("ERR_INVALID_ARG_TYPE"); + } + try { + Readable.toWeb(readable, { type: "banana" }); + expect.unreachable(); + } catch (e) { + expect(e.code).toBe("ERR_INVALID_ARG_VALUE"); + } + readable.destroy(); + }); + + // Upstream: test-stream-readable-to-web-termination.js (v26) — a readable + // already destroyed with an error must produce an errored ReadableStream, + // not a canceled empty one. + it("Readable.toWeb propagates the destroy error of an already-destroyed readable", async () => { + const readable = new Readable({ read() {} }); + const theError = new Error("destroy-err"); + readable.on("error", () => {}); + readable.destroy(theError); + await new Promise(resolve => readable.on("close", resolve)); + + const rs = Readable.toWeb(readable); + await expect(rs.getReader().read()).rejects.toBe(theError); + }); + + it("Readable.toWeb closes cleanly for an already-ended readable", async () => { + const readable = new Readable({ read() {} }); + readable.push(null); + readable.read(); + await new Promise(resolve => readable.on("close", resolve)); + + const rs = Readable.toWeb(readable); + const { done } = await rs.getReader().read(); + expect(done).toBe(true); + }); + + // Upstream: v26 adapters use eos(stream, { writable: false }) so a Duplex + // readable side completes without waiting for the half-open writable side. + it("Readable.toWeb of a half-open Duplex closes when the readable side ends", async () => { + const duplex = new Duplex({ + read() { + this.push(null); + }, + write(chunk, encoding, callback) { + callback(); + }, + }); + const rs = Readable.toWeb(duplex); + const { done } = await rs.getReader().read(); + expect(done).toBe(true); + expect(duplex.writable).toBe(true); + }); + + // Upstream: Duplex.toWeb(duplex, { readableType: 'bytes' }) + it("Duplex.toWeb supports options.readableType: 'bytes'", async () => { + const duplex = new PassThrough(); + const pair = Duplex.toWeb(duplex, { readableType: "bytes" }); + duplex.end(new Uint8Array([5, 6])); + + const reader = pair.readable.getReader({ mode: "byob" }); + const { value } = await reader.read(new Uint8Array(4)); + expect(Array.from(value)).toEqual([5, 6]); + }); + + // Upstream: DEP0201 — options.type is a deprecated alias for options.readableType + it("Duplex.toWeb emits DEP0201 for the deprecated options.type alias", async () => { + const warning = new Promise(resolve => process.once("warning", resolve)); + const duplex = new PassThrough(); + Duplex.toWeb(duplex, { type: "bytes" }); + const w = await warning; + expect(w.name).toBe("DeprecationWarning"); + expect(w.code).toBe("DEP0201"); + duplex.destroy(); + }); + + // Upstream: v26 Writable.toWeb wraps (Shared)ArrayBuffer chunks in a + // Uint8Array before writing to the Node stream. + it("Writable.toWeb accepts ArrayBuffer chunks", async () => { + const chunks = []; + const writable = new Writable({ + write(chunk, encoding, callback) { + chunks.push(chunk); + callback(); + }, + }); + const writer = Writable.toWeb(writable).getWriter(); + await writer.write(new TextEncoder().encode("ab").buffer); + await writer.close(); + expect(chunks.length).toBe(1); + expect(Buffer.concat(chunks).toString()).toBe("ab"); + }); + + // Upstream: v26 end-of-stream only snapshots the AsyncLocalStorage context + // when one is active at registration time; a callback registered outside + // any context observes the context active when the stream settles. + it("finished() callback registered outside an ALS context observes the firing context", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { Readable, finished } = require("node:stream"); + const { AsyncLocalStorage } = require("node:async_hooks"); + const als = new AsyncLocalStorage(); + const r = new Readable({ read() {} }); + finished(r, () => { + console.log("store:" + als.getStore()); + }); + als.run("ctx", () => r.destroy()); + `, + ], + env: bunEnv, + }); + + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout.trim()).toBe("store:ctx"); + expect(exitCode).toBe(0); + }); + + it("finished() callback registered inside an ALS context observes the registration context", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { Readable, finished } = require("node:stream"); + const { AsyncLocalStorage } = require("node:async_hooks"); + const als = new AsyncLocalStorage(); + const r = new Readable({ read() {} }); + als.run("reg-ctx", () => { + finished(r, () => { + console.log("store:" + als.getStore()); + }); + }); + r.destroy(); + `, + ], + env: bunEnv, + }); + + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout.trim()).toBe("store:reg-ctx"); + expect(exitCode).toBe(0); + }); +}); + for (const size of [0x10, 0xffff, 0x10000, 0x1f000, 0x20000, 0x20010, 0x7ffff, 0x80000, 0xa0000, 0xa0010]) { it(`should emit 'readable' with null data and 'close' exactly once each, 0x${size.toString(16)} bytes`, async () => { const path = `${tmpdir()}/${Date.now()}.readable_and_close.txt`; @@ -567,3 +794,342 @@ for (const size of [0x10, 0xffff, 0x10000, 0x1f000, 0x20000, 0x20010, 0x7ffff, 0 await Promise.all([close_resolvers.promise, readable_resolvers.promise]); }); } + + +// Node.js v26 semver-major stream semantics. +describe("node v26 stream semantics", () => { + // Upstream: v26 howMuchToRead() fast path; covered upstream by the updated + // test-stream2-readable-non-empty-end.js / test-stream-readable-emittedReadable.js. + it("read() with no size returns one buffered chunk at a time in paused mode", async () => { + const r = new Readable({ read() {} }); + r.push(Buffer.from("abc")); + r.push(Buffer.from("de")); + r.push(null); + await new Promise(resolve => setImmediate(resolve)); + expect(r.read().toString()).toBe("abc"); + expect(r.read().toString()).toBe("de"); + expect(r.read()).toBeNull(); + }); + + it("read() with no size still concatenates when setEncoding is active", async () => { + const r = new Readable({ read() {} }); + r.setEncoding("utf8"); + r.push("abc"); + r.push("de"); + r.push(null); + await new Promise(resolve => setImmediate(resolve)); + expect(r.read()).toBe("abcde"); + expect(r.read()).toBeNull(); + }); + + // Upstream: nodejs/node#62557 (test-stream-readable-pause-and-resume.js). + it("pause() and resume() are no-ops on destroyed streams", async () => { + const r = new Readable({ read() {} }); + r.destroy(); + const emitted = []; + r.on("pause", () => emitted.push("pause")); + r.on("resume", () => emitted.push("resume")); + expect(r.resume()).toBe(r); + expect(r.readableFlowing).toBeNull(); + expect(r.pause()).toBe(r); + expect(r.readableFlowing).toBeNull(); + await new Promise(resolve => setImmediate(resolve)); + expect(emitted).toEqual([]); + }); + + // Upstream: nodejs/node#60907 (test-stream-compose-operator.js). + it("compose returns the composed Duplex directly", () => { + expect(Object.hasOwn(Readable.prototype, "compose")).toBe(true); + const composed = Readable.from(["a"]).compose( + new Transform({ + transform(chunk, encoding, callback) { + callback(null, chunk); + }, + }), + ); + expect(composed).toBeInstanceOf(Duplex); + }); + + it("compose rejects a non-writable destination with the streams[1] arg name", () => { + let err; + try { + Readable.from(["a"]).compose(new Readable({ read() {} })); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_INVALID_ARG_VALUE"); + expect(err?.message).toContain("streams[1]"); + }); + + it("compose validates the options argument", () => { + let err; + try { + Readable.from(["a"]).compose(new PassThrough(), 42); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_INVALID_ARG_TYPE"); + }); + + // Upstream: v26 test-stream-writable-decoded-encoding.js. + it("write(string, 'buffer') throws ERR_UNKNOWN_ENCODING", () => { + for (const opts of [{ decodeStrings: false }, {}]) { + const w = new Writable({ + ...opts, + write(chunk, encoding, callback) { + callback(); + }, + }); + let err; + try { + w.write("hi", "buffer"); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_UNKNOWN_ENCODING"); + + // Buffer chunks with 'buffer' encoding still work. + const w2 = new Writable({ + ...opts, + write(chunk, encoding, callback) { + callback(); + }, + }); + expect(w2.write(Buffer.from("x"), "buffer")).toBe(true); + } + }); +}); + +// Node.js v26 semver-major stream semantics. +describe("node v26 stream semantics", () => { + // Upstream: v26 howMuchToRead() fast path; covered upstream by the updated + // test-stream2-readable-non-empty-end.js / test-stream-readable-emittedReadable.js. + it("read() with no size returns one buffered chunk at a time in paused mode", async () => { + const r = new Readable({ read() {} }); + r.push(Buffer.from("abc")); + r.push(Buffer.from("de")); + r.push(null); + await new Promise(resolve => setImmediate(resolve)); + expect(r.read().toString()).toBe("abc"); + expect(r.read().toString()).toBe("de"); + expect(r.read()).toBeNull(); + }); + + it("read() with no size still concatenates when setEncoding is active", async () => { + const r = new Readable({ read() {} }); + r.setEncoding("utf8"); + r.push("abc"); + r.push("de"); + r.push(null); + await new Promise(resolve => setImmediate(resolve)); + expect(r.read()).toBe("abcde"); + expect(r.read()).toBeNull(); + }); + + // Upstream: nodejs/node#62557 (test-stream-readable-pause-and-resume.js). + it("pause() and resume() are no-ops on destroyed streams", async () => { + const r = new Readable({ read() {} }); + r.destroy(); + const emitted = []; + r.on("pause", () => emitted.push("pause")); + r.on("resume", () => emitted.push("resume")); + expect(r.resume()).toBe(r); + expect(r.readableFlowing).toBeNull(); + expect(r.pause()).toBe(r); + expect(r.readableFlowing).toBeNull(); + await new Promise(resolve => setImmediate(resolve)); + expect(emitted).toEqual([]); + }); + + // Upstream: nodejs/node#60907 (test-stream-compose-operator.js). + it("compose returns the composed Duplex directly", () => { + expect(Object.hasOwn(Readable.prototype, "compose")).toBe(true); + const composed = Readable.from(["a"]).compose( + new Transform({ + transform(chunk, encoding, callback) { + callback(null, chunk); + }, + }), + ); + expect(composed).toBeInstanceOf(Duplex); + }); + + it("compose rejects a non-writable destination with the streams[1] arg name", () => { + let err; + try { + Readable.from(["a"]).compose(new Readable({ read() {} })); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_INVALID_ARG_VALUE"); + expect(err?.message).toContain("streams[1]"); + }); + + it("compose validates the options argument", () => { + let err; + try { + Readable.from(["a"]).compose(new PassThrough(), 42); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_INVALID_ARG_TYPE"); + }); + + // Upstream: v26 test-stream-writable-decoded-encoding.js. + it("write(string, 'buffer') throws ERR_UNKNOWN_ENCODING", () => { + for (const opts of [{ decodeStrings: false }, {}]) { + const w = new Writable({ + ...opts, + write(chunk, encoding, callback) { + callback(); + }, + }); + let err; + try { + w.write("hi", "buffer"); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_UNKNOWN_ENCODING"); + + // Buffer chunks with 'buffer' encoding still work. + const w2 = new Writable({ + ...opts, + write(chunk, encoding, callback) { + callback(); + }, + }); + expect(w2.write(Buffer.from("x"), "buffer")).toBe(true); + } + }); +}); + +describe("fromList string chunk boundary (nodejs/node#61884)", () => { + it("read(n) with setEncoding does not over-read when n equals the buffered array length", () => { + const r = new Readable({ read() {} }); + r.setEncoding("utf8"); + r.push("a"); + r.push("bcd"); + // With the v24 bug (`n === buf.length` instead of `n === str.length`), + // read(3) returned "abcd". + expect(r.read(3)).toBe("abc"); + expect(r.read(1)).toBe("d"); + }); +}); + +describe("maybeReadMore is a no-op while a read is in flight (nodejs/node#60454)", () => { + it("does not schedule a redundant _read while kReading is set", async () => { + let reads = 0; + const r = new Readable({ + highWaterMark: 1024, + read() { + reads++; + }, + }); + + r.read(10); // _read #1 is now in flight (kReading set, no sync push) + expect(reads).toBe(1); + + // Old gate ((kReadingMore | kConstructed) === kConstructed) scheduled + // maybeReadMore_ HERE, while the read was still in flight. + r.unshift("x"); + + // Queued between the buggy (unshift-time) and fixed (push-time) schedule + // points: with the old gate maybeReadMore_ ran BEFORE this tick, saw the + // read completed and the stream not yet ended, and issued a redundant + // stream.read(0) -> _read #2. With the v26 gate the schedule happens at + // push("y") below, so this tick ends the stream first and no extra _read + // is issued. Verified against node v26.3.0 (reads === 1) and the old + // gate (reads === 2). + process.nextTick(() => r.push(null)); + + r.push("y"); // completes the in-flight read; v26 schedules maybeReadMore_ here + + // All process.nextTick callbacks (including maybeReadMore_) run before + // setImmediate fires, so this is a deterministic ordering, not a timeout. + await new Promise(resolve => setImmediate(resolve)); + expect(reads).toBe(1); + }); +}); + +describe("Duplex.from({ readable, writable }) destroy propagation (nodejs/node#62824)", () => { + it("destroys the writable side when the readable side errors", async () => { + const r = new Readable({ read() {} }); + const w = new Writable({ + write(chunk, enc, cb) { + cb(); + }, + }); + const d = Duplex.from({ readable: r, writable: w }); + + const writableError = Promise.withResolvers(); + const writableClose = Promise.withResolvers(); + const duplexError = Promise.withResolvers(); + w.on("error", writableError.resolve); + w.on("close", writableClose.resolve); + d.on("error", duplexError.resolve); + + const err = new Error("boom"); + r.destroy(err); + + expect(await writableError.promise).toBe(err); + await writableClose.promise; + expect(w.destroyed).toBe(true); + expect(await duplexError.promise).toBe(err); + }); +}); + +describe("pipeline real error overrides AbortError (nodejs/node#62113)", () => { + it("reports the real error when a destroy callback errors after abort", async () => { + const ac = new AbortController(); + const r = new Readable({ read() {} }); + const w = new Writable({ + write(chunk, enc, cb) { + cb(); + }, + destroy(err, cb) { + cb(new Error("realboom")); + }, + }); + const p = Stream.promises.pipeline(r, w, { signal: ac.signal }); + setImmediate(() => ac.abort()); + let caught; + await p.catch(e => { + caught = e; + }); + expect(caught.name).toBe("Error"); + expect(caught.message).toBe("realboom"); + }); +}); + +describe("stream operators argument validation (nodejs/node#59529)", () => { + it("map/filter throw synchronously with the validateFunction message", () => { + for (const method of ["map", "filter"]) { + const r = Readable.from([1]); + expect(() => r[method](123)).toThrow( + expect.objectContaining({ + code: "ERR_INVALID_ARG_TYPE", + message: 'The "fn" argument must be of type function. Received type number (123)', + }), + ); + r.destroy(); + } + }); + + it("forEach/every/reduce reject asynchronously with the validateFunction message", async () => { + for (const [method, name] of [ + ["forEach", "fn"], + ["every", "fn"], + ["reduce", "reducer"], + ]) { + const r = Readable.from([1]); + let caught; + await r[method](123).catch(e => { + caught = e; + }); + expect(caught.code).toBe("ERR_INVALID_ARG_TYPE"); + expect(caught.message).toBe(`The "${name}" argument must be of type function. Received type number (123)`); + r.destroy(); + } + }); +}); diff --git a/test/js/node/test/parallel/test-crypto-cipheriv-decipheriv.js b/test/js/node/test/parallel/test-crypto-cipheriv-decipheriv.js index 9a6440b63ca..2e0a72fbce8 100644 --- a/test/js/node/test/parallel/test-crypto-cipheriv-decipheriv.js +++ b/test/js/node/test/parallel/test-crypto-cipheriv-decipheriv.js @@ -31,11 +31,11 @@ function testCipher1(key, iv) { // quite small, so there's no harm. const cStream = crypto.createCipheriv('des-ede3-cbc', key, iv); cStream.end(plaintext); - ciph = cStream.read(); + ciph = cStream.read(cStream.readableLength); const dStream = crypto.createDecipheriv('des-ede3-cbc', key, iv); dStream.end(ciph); - txt = dStream.read().toString('utf8'); + txt = dStream.read(dStream.readableLength).toString('utf8'); assert.strictEqual(txt, plaintext, `streaming cipher with key ${key} and iv ${iv}`); diff --git a/test/js/node/test/parallel/test-http2-getpackedsettings.js b/test/js/node/test/parallel/test-http2-getpackedsettings.js index 77a8640587c..872e66b0ede 100644 --- a/test/js/node/test/parallel/test-http2-getpackedsettings.js +++ b/test/js/node/test/parallel/test-http2-getpackedsettings.js @@ -20,7 +20,7 @@ assert.deepStrictEqual(val, check); ['headerTableSize', 0], ['headerTableSize', 2 ** 32 - 1], ['initialWindowSize', 0], - ['initialWindowSize', 2 ** 32 - 1], + ['initialWindowSize', 2 ** 31 - 1], ['maxFrameSize', 16384], ['maxFrameSize', 2 ** 24 - 1], ['maxConcurrentStreams', 0], @@ -42,7 +42,7 @@ http2.getPackedSettings({ enablePush: false }); ['headerTableSize', -1], ['headerTableSize', 2 ** 32], ['initialWindowSize', -1], - ['initialWindowSize', 2 ** 32], + ['initialWindowSize', 2 ** 31], ['maxFrameSize', 16383], ['maxFrameSize', 2 ** 24], ['maxConcurrentStreams', -1], diff --git a/test/js/node/test/parallel/test-stream-compose.js b/test/js/node/test/parallel/test-stream-compose.js index d7a54e17766..a4517a294b0 100644 --- a/test/js/node/test/parallel/test-stream-compose.js +++ b/test/js/node/test/parallel/test-stream-compose.js @@ -490,7 +490,8 @@ const assert = require('assert'); newStream.end(); - assert.deepStrictEqual(await newStream.toArray(), [Buffer.from('Steve RogersOn your left')]); + assert.deepStrictEqual(await newStream.toArray(), + [Buffer.from('Steve Rogers'), Buffer.from('On your left')]); })().then(common.mustCall()); } diff --git a/test/js/node/test/parallel/test-stream-push-strings.js b/test/js/node/test/parallel/test-stream-push-strings.js index d582c8add00..5fece74a115 100644 --- a/test/js/node/test/parallel/test-stream-push-strings.js +++ b/test/js/node/test/parallel/test-stream-push-strings.js @@ -59,7 +59,7 @@ ms.on('readable', function() { results.push(String(chunk)); }); -const expect = [ 'first chunksecond to last chunk', 'last chunk' ]; +const expect = [ 'first chunk', 'second to last chunk', 'last chunk' ]; process.on('exit', function() { assert.strictEqual(ms._chunks, -1); assert.deepStrictEqual(results, expect); diff --git a/test/js/node/test/parallel/test-stream-readable-emittedReadable.js b/test/js/node/test/parallel/test-stream-readable-emittedReadable.js index ba613f9e9ff..ffaf1d5b943 100644 --- a/test/js/node/test/parallel/test-stream-readable-emittedReadable.js +++ b/test/js/node/test/parallel/test-stream-readable-emittedReadable.js @@ -10,7 +10,7 @@ const readable = new Readable({ // Initialized to false. assert.strictEqual(readable._readableState.emittedReadable, false); -const expected = [Buffer.from('foobar'), Buffer.from('quo'), null]; +const expected = [Buffer.from('foo'), Buffer.from('bar'), Buffer.from('quo'), null]; readable.on('readable', common.mustCall(() => { // emittedReadable should be true when the readable event is emitted assert.strictEqual(readable._readableState.emittedReadable, true); diff --git a/test/js/node/test/parallel/test-stream-readable-infinite-read.js b/test/js/node/test/parallel/test-stream-readable-infinite-read.js index df88d78b74c..9d39b1fc6cd 100644 --- a/test/js/node/test/parallel/test-stream-readable-infinite-read.js +++ b/test/js/node/test/parallel/test-stream-readable-infinite-read.js @@ -10,7 +10,7 @@ const readable = new Readable({ highWaterMark: 16 * 1024, read: common.mustCall(function() { this.push(buf); - }, 31) + }, 12) }); let i = 0; @@ -18,16 +18,11 @@ let i = 0; readable.on('readable', common.mustCall(function() { if (i++ === 10) { // We will just terminate now. - process.removeAllListeners('readable'); + readable.removeAllListeners('readable'); return; } const data = readable.read(); - // TODO(mcollina): there is something odd in the highWaterMark logic - // investigate. - if (i === 1) { - assert.strictEqual(data.length, 8192 * 2); - } else { - assert.strictEqual(data.length, 8192 * 3); - } + // read() with no size returns a single buffered chunk at a time. + assert.strictEqual(data.length, 8192); }, 11)); diff --git a/test/js/node/test/parallel/test-stream-readable-needReadable.js b/test/js/node/test/parallel/test-stream-readable-needReadable.js index c4bc90bb19d..3f26db791c7 100644 --- a/test/js/node/test/parallel/test-stream-readable-needReadable.js +++ b/test/js/node/test/parallel/test-stream-readable-needReadable.js @@ -32,7 +32,7 @@ const asyncReadable = new Readable({ }); asyncReadable.on('readable', common.mustCall(() => { - if (asyncReadable.read() !== null) { + if (asyncReadable.read(asyncReadable.readableLength) !== null) { // After each read(), the buffer is empty. // If the stream doesn't end now, // then we need to notify the reader on future changes. diff --git a/test/js/node/test/parallel/test-stream-readable-to-web-byob.js b/test/js/node/test/parallel/test-stream-readable-to-web-byob.js new file mode 100644 index 00000000000..8e5f10efee1 --- /dev/null +++ b/test/js/node/test/parallel/test-stream-readable-to-web-byob.js @@ -0,0 +1,49 @@ +'use strict'; +require('../common'); +const { Readable } = require('stream'); +const assert = require('assert'); +const common = require('../common'); + +let count = 0; + +const nodeStream = new Readable({ + read(size) { + if (this.destroyed) { + return; + } + // Simulate a stream that pushes sequences of 16 bytes + const buffer = Buffer.alloc(size); + for (let i = 0; i < size; i++) { + buffer[i] = count++ % 16; + } + this.push(buffer); + } +}); + +// Test validation of 'type' option +assert.throws( + () => { + Readable.toWeb(nodeStream, { type: 'wrong type' }); + }, + { + code: 'ERR_INVALID_ARG_VALUE' + } +); + +// Test normal operation with ReadableByteStream +const webStream = Readable.toWeb(nodeStream, { type: 'bytes' }); +const reader = webStream.getReader({ mode: 'byob' }); +const expected = new Uint8Array(16); +for (let i = 0; i < 16; i++) { + expected[i] = count++; +} + +for (let i = 0; i < 1000; i++) { + // Read 16 bytes of data from the stream + const receive = new Uint8Array(16); + reader.read(receive).then(common.mustCall((result) => { + // Verify the data received + assert.ok(!result.done); + assert.deepStrictEqual(result.value, expected); + })); +} diff --git a/test/js/node/test/parallel/test-stream-readable-to-web-termination-byob.js b/test/js/node/test/parallel/test-stream-readable-to-web-termination-byob.js new file mode 100644 index 00000000000..8b1f8d1817c --- /dev/null +++ b/test/js/node/test/parallel/test-stream-readable-to-web-termination-byob.js @@ -0,0 +1,15 @@ +'use strict'; +require('../common'); +const { Readable } = require('stream'); +const assert = require('assert'); +const common = require('../common'); +{ + const r = Readable.from([]); + // Cancelling reader while closing should not cause uncaught exceptions + r.on('close', common.mustCall(() => reader.cancel())); + + const reader = Readable.toWeb(r, { type: 'bytes' }).getReader({ mode: 'byob' }); + reader.read(new Uint8Array(16)).then(common.mustCall((result) => { + assert.ok(result.done); + })); +} diff --git a/test/js/node/test/parallel/test-stream-readable-to-web-termination.js b/test/js/node/test/parallel/test-stream-readable-to-web-termination.js index 13fce9bc715..f30cf721e14 100644 --- a/test/js/node/test/parallel/test-stream-readable-to-web-termination.js +++ b/test/js/node/test/parallel/test-stream-readable-to-web-termination.js @@ -1,6 +1,8 @@ 'use strict'; -require('../common'); -const { Readable } = require('stream'); +const common = require('../common'); +const assert = require('assert'); +const { Duplex, Readable } = require('stream'); +const { setTimeout: delay } = require('timers/promises'); { const r = Readable.from([]); @@ -10,3 +12,33 @@ const { Readable } = require('stream'); const reader = Readable.toWeb(r).getReader(); reader.read(); } + +{ + const duplex = new Duplex({ + read() { + this.push(Buffer.from('x')); + this.push(null); + }, + write(_chunk, _encoding, callback) { + callback(); + }, + }); + + const reader = Readable.toWeb(duplex).getReader(); + + (async () => { + const result = await reader.read(); + assert.deepStrictEqual(result, { + value: new Uint8Array(Buffer.from('x')), + done: false, + }); + + const closeResult = await Promise.race([ + reader.read(), + delay(common.platformTimeout(100)).then(() => 'timeout'), + ]); + + assert.notStrictEqual(closeResult, 'timeout'); + assert.deepStrictEqual(closeResult, { value: undefined, done: true }); + })().then(common.mustCall()); +} diff --git a/test/js/node/test/parallel/test-stream-typedarray.js b/test/js/node/test/parallel/test-stream-typedarray.js index ae5846da09d..55d92aa31ed 100644 --- a/test/js/node/test/parallel/test-stream-typedarray.js +++ b/test/js/node/test/parallel/test-stream-typedarray.js @@ -83,9 +83,12 @@ const views = common.getArrayBufferViews(buffer); readable.push(views[2]); readable.unshift(views[0]); + // read() with no size returns one buffered chunk at a time. const buf = readable.read(); assert(buf instanceof Buffer); - assert.deepStrictEqual([...buf], [...views[0], ...views[1], ...views[2]]); + assert.deepStrictEqual([...buf], [...views[0]]); + assert.deepStrictEqual([...readable.read()], [...views[1]]); + assert.deepStrictEqual([...readable.read()], [...views[2]]); } { diff --git a/test/js/node/test/parallel/test-stream-uint8array.js b/test/js/node/test/parallel/test-stream-uint8array.js index f1de4c873fd..5ffdbbbc54b 100644 --- a/test/js/node/test/parallel/test-stream-uint8array.js +++ b/test/js/node/test/parallel/test-stream-uint8array.js @@ -80,9 +80,11 @@ const GHI = new Uint8Array([0x47, 0x48, 0x49]); readable.push(DEF); readable.unshift(ABC); + // read() with no size returns one buffered chunk at a time. const buf = readable.read(); assert(buf instanceof Buffer); - assert.deepStrictEqual([...buf], [...ABC, ...DEF]); + assert.deepStrictEqual([...buf], [...ABC]); + assert.deepStrictEqual([...readable.read()], [...DEF]); } { diff --git a/test/js/node/test/parallel/test-stream2-transform.js b/test/js/node/test/parallel/test-stream2-transform.js index f222f1c03b4..a7d0f236d78 100644 --- a/test/js/node/test/parallel/test-stream2-transform.js +++ b/test/js/node/test/parallel/test-stream2-transform.js @@ -282,7 +282,10 @@ const { PassThrough, Transform } = require('stream'); pt.write(Buffer.from('ef'), common.mustCall(function() { pt.end(); })); - assert.strictEqual(pt.read().toString(), 'abcdef'); + // read() with no size returns one buffered chunk at a time. + assert.strictEqual(pt.read().toString(), 'abc'); + assert.strictEqual(pt.read().toString(), 'd'); + assert.strictEqual(pt.read().toString(), 'ef'); assert.strictEqual(pt.read(), null); }); }); diff --git a/test/js/node/test/parallel/test-webstreams-adapters-writable-buffer-sources.js b/test/js/node/test/parallel/test-webstreams-adapters-writable-buffer-sources.js new file mode 100644 index 00000000000..995db97e747 --- /dev/null +++ b/test/js/node/test/parallel/test-webstreams-adapters-writable-buffer-sources.js @@ -0,0 +1,95 @@ +'use strict'; +const common = require('../common'); + +const assert = require('assert'); +const { Buffer } = require('buffer'); +const { Duplex, Writable } = require('stream'); +const { suite, test } = require('node:test'); + +const ctors = [ArrayBuffer, SharedArrayBuffer]; + +suite('underlying Writable', () => { + suite('in non-object mode', () => { + for (const ctor of ctors) { + test(`converts ${ctor.name} chunks`, async () => { + const buffer = new ctor(4); + const writable = new Writable({ + objectMode: false, + write: common.mustCall((chunk, encoding, callback) => { + assert(Buffer.isBuffer(chunk)); + assert.strictEqual(chunk.buffer, buffer); + callback(); + }), + }); + writable.on('error', common.mustNotCall()); + const writer = Writable.toWeb(writable).getWriter(); + await writer.write(buffer); + }); + } + }); + + suite('in object mode', () => { + for (const ctor of ctors) { + test(`passes through ${ctor.name} chunks`, async () => { + const buffer = new ctor(4); + const writable = new Writable({ + objectMode: true, + write: common.mustCall((chunk, encoding, callback) => { + assert(chunk instanceof ctor); + assert.strictEqual(chunk, buffer); + callback(); + }), + }); + writable.on('error', common.mustNotCall()); + const writer = Writable.toWeb(writable).getWriter(); + await writer.write(buffer); + }); + } + }); +}); + +suite('underlying Duplex', () => { + suite('in non-object mode', () => { + for (const ctor of ctors) { + test(`converts ${ctor.name} chunks`, async () => { + const buffer = new ctor(4); + const duplex = new Duplex({ + writableObjectMode: false, + write: common.mustCall((chunk, encoding, callback) => { + assert(Buffer.isBuffer(chunk)); + assert.strictEqual(chunk.buffer, buffer); + callback(); + }), + read() { + this.push(null); + }, + }); + duplex.on('error', common.mustNotCall()); + const writer = Duplex.toWeb(duplex).writable.getWriter(); + await writer.write(buffer); + }); + } + }); + + suite('in object mode', () => { + for (const ctor of ctors) { + test(`passes through ${ctor.name} chunks`, async () => { + const buffer = new ctor(4); + const duplex = new Duplex({ + writableObjectMode: true, + write: common.mustCall((chunk, encoding, callback) => { + assert(chunk instanceof ctor); + assert.strictEqual(chunk, buffer); + callback(); + }), + read() { + this.push(null); + }, + }); + duplex.on('error', common.mustNotCall()); + const writer = Duplex.toWeb(duplex).writable.getWriter(); + await writer.write(buffer); + }); + } + }); +}); diff --git a/test/js/node/test/parallel/test-webstreams-compression-bad-chunks.js b/test/js/node/test/parallel/test-webstreams-compression-bad-chunks.js new file mode 100644 index 00000000000..4a8ca3cff8a --- /dev/null +++ b/test/js/node/test/parallel/test-webstreams-compression-bad-chunks.js @@ -0,0 +1,75 @@ +'use strict'; +require('../common'); +const assert = require('assert'); +const test = require('node:test'); +const { CompressionStream, DecompressionStream } = require('stream/web'); + +// Verify that writing invalid (non-BufferSource) chunks to +// CompressionStream and DecompressionStream properly rejects +// on both the write and the read side, instead of hanging. + +const badChunks = [ + { name: 'undefined', value: undefined, code: 'ERR_INVALID_ARG_TYPE' }, + { name: 'null', value: null, code: 'ERR_STREAM_NULL_VALUES' }, + { name: 'number', value: 3.14, code: 'ERR_INVALID_ARG_TYPE' }, + { name: 'object', value: {}, code: 'ERR_INVALID_ARG_TYPE' }, + { name: 'array', value: [65], code: 'ERR_INVALID_ARG_TYPE' }, + { + name: 'SharedArrayBuffer', + value: new SharedArrayBuffer(1), + code: 'ERR_INVALID_ARG_TYPE', + }, + { + name: 'Uint8Array backed by SharedArrayBuffer', + value: new Uint8Array(new SharedArrayBuffer(1)), + code: 'ERR_INVALID_ARG_TYPE', + }, +]; + +for (const format of ['deflate', 'deflate-raw', 'gzip', 'brotli']) { + for (const { name, value, code } of badChunks) { + const expected = { name: 'TypeError', code }; + + test(`CompressionStream rejects bad chunk (${name}) for ${format}`, async () => { + const cs = new CompressionStream(format); + const writer = cs.writable.getWriter(); + const reader = cs.readable.getReader(); + + const writePromise = writer.write(value); + const readPromise = reader.read(); + + await assert.rejects(writePromise, expected); + await assert.rejects(readPromise, expected); + }); + + test(`DecompressionStream rejects bad chunk (${name}) for ${format}`, async () => { + const ds = new DecompressionStream(format); + const writer = ds.writable.getWriter(); + const reader = ds.readable.getReader(); + + const writePromise = writer.write(value); + const readPromise = reader.read(); + + await assert.rejects(writePromise, expected); + await assert.rejects(readPromise, expected); + }); + } +} + +// Verify that decompression errors (e.g. corrupt data) are surfaced as +// TypeError, not plain Error, per the Compression Streams spec. +for (const format of ['deflate', 'deflate-raw', 'gzip', 'brotli']) { + test(`DecompressionStream surfaces corrupt data as TypeError for ${format}`, async () => { + const ds = new DecompressionStream(format); + const writer = ds.writable.getWriter(); + const reader = ds.readable.getReader(); + + const corruptData = new Uint8Array([0, 1, 2, 3, 4, 5]); + + writer.write(corruptData).catch(() => {}); + reader.read().catch(() => {}); + + await assert.rejects(writer.close(), { name: 'TypeError' }); + await assert.rejects(reader.closed, { name: 'TypeError' }); + }); +} diff --git a/test/js/node/test/parallel/test-webstreams-compression-buffer-source.js b/test/js/node/test/parallel/test-webstreams-compression-buffer-source.js new file mode 100644 index 00000000000..3304a8e64f3 --- /dev/null +++ b/test/js/node/test/parallel/test-webstreams-compression-buffer-source.js @@ -0,0 +1,42 @@ +'use strict'; +require('../common'); +const assert = require('assert'); +const test = require('node:test'); +const { DecompressionStream, CompressionStream } = require('stream/web'); + +// Minimal gzip-compressed bytes for "hello" +const compressedGzip = new Uint8Array([ + 31, 139, 8, 0, 0, 0, 0, 0, 0, 3, + 203, 72, 205, 201, 201, 7, 0, 134, 166, 16, 54, 5, 0, 0, 0, +]); + +test('DecompressionStream accepts ArrayBuffer chunks', async () => { + const ds = new DecompressionStream('gzip'); + const writer = ds.writable.getWriter(); + + const writePromise = writer.write(compressedGzip.buffer); + writer.close(); + + const chunks = await Array.fromAsync(ds.readable); + await writePromise; + const out = Buffer.concat(chunks.map((c) => Buffer.from(c))); + assert.strictEqual(out.toString(), 'hello'); +}); + +test('CompressionStream round-trip with ArrayBuffer input', async () => { + const cs = new CompressionStream('gzip'); + const ds = new DecompressionStream('gzip'); + + const csWriter = cs.writable.getWriter(); + + const input = new TextEncoder().encode('hello').buffer; + + await csWriter.write(input); + csWriter.close(); + + await cs.readable.pipeTo(ds.writable); + + const out = await Array.fromAsync(ds.readable); + const result = Buffer.concat(out.map((c) => Buffer.from(c))); + assert.strictEqual(result.toString(), 'hello'); +}); diff --git a/test/js/node/test/parallel/test-webstreams-duplex-fromweb-writev-unhandled-rejection.js b/test/js/node/test/parallel/test-webstreams-duplex-fromweb-writev-unhandled-rejection.js new file mode 100644 index 00000000000..5367b2a09e1 --- /dev/null +++ b/test/js/node/test/parallel/test-webstreams-duplex-fromweb-writev-unhandled-rejection.js @@ -0,0 +1,55 @@ +'use strict'; + +// Regression test for https://github.com/nodejs/node/issues/62199 +// +// When Duplex.fromWeb is corked, writes are batched into _writev. If destroy() +// is called in the same microtask (after uncork()), writer.ready rejects with a +// non-array value. The done() callback inside _writev unconditionally called +// error.filter(), which throws TypeError on non-arrays. This TypeError became +// an unhandled rejection that crashed the process. +// +// The same bug exists in newStreamWritableFromWritableStream (Writable.fromWeb). + +const common = require('../common'); +const { Duplex, Writable } = require('stream'); +const { TransformStream, WritableStream } = require('stream/web'); + +// Exact reproduction from the issue report (davidje13). +// Before the fix: process crashes with unhandled TypeError. +// After the fix: stream closes cleanly with no unhandled rejection. +{ + const output = Duplex.fromWeb(new TransformStream()); + + output.on('close', common.mustCall()); + + output.cork(); + output.write('test'); + output.write('test'); + output.uncork(); + output.destroy(); +} + +// Same bug in Writable.fromWeb (newStreamWritableFromWritableStream). +{ + const writable = Writable.fromWeb(new WritableStream()); + + writable.on('close', common.mustCall()); + + writable.cork(); + writable.write('test'); + writable.write('test'); + writable.uncork(); + writable.destroy(); +} + +// Regression: normal cork/uncork/_writev success path must still work. +// Verifies that () => done() correctly signals success via callback(). +{ + const writable = Writable.fromWeb(new WritableStream({ write() {} })); + + writable.cork(); + writable.write('foo'); + writable.write('bar'); + writable.uncork(); + writable.end(common.mustCall()); +} diff --git a/test/js/node/test/parallel/test-whatwg-webstreams-compression.js b/test/js/node/test/parallel/test-whatwg-webstreams-compression.js index a6f2e1b425d..f168a0ca846 100644 --- a/test/js/node/test/parallel/test-whatwg-webstreams-compression.js +++ b/test/js/node/test/parallel/test-whatwg-webstreams-compression.js @@ -24,13 +24,13 @@ async function test(format) { const writer = gzip.writable.getWriter(); const compressed_data = []; - const reader_function = ({ value, done }) => { + const reader_function = common.mustCallAtLeast(({ value, done }) => { if (value) compressed_data.push(value); if (!done) return reader.read().then(reader_function); assert.strictEqual(dec.decode(Buffer.concat(compressed_data)), 'hello'); - }; + }); const reader_promise = reader.read().then(reader_function); await Promise.all([ diff --git a/test/js/node/test/parallel/test-zlib-flush-write-sync-interleaved.js b/test/js/node/test/parallel/test-zlib-flush-write-sync-interleaved.js index f8387f40069..87ca9fe1e9a 100644 --- a/test/js/node/test/parallel/test-zlib-flush-write-sync-interleaved.js +++ b/test/js/node/test/parallel/test-zlib-flush-write-sync-interleaved.js @@ -19,7 +19,7 @@ for (const chunk of ['abc', 'def', 'ghi']) { compress.write(chunk, common.mustCall(() => events.push({ written: chunk }))); compress.flush(Z_PARTIAL_FLUSH, common.mustCall(() => { events.push('flushed'); - const chunk = compress.read(); + const chunk = compress.read(compress.readableLength); if (chunk !== null) compressedChunks.push(chunk); })); @@ -36,7 +36,7 @@ function writeToDecompress() { const chunk = compressedChunks.shift(); if (chunk === undefined) return decompress.end(); decompress.write(chunk, common.mustCall(() => { - events.push({ read: decompress.read() }); + events.push({ read: decompress.read(decompress.readableLength) }); writeToDecompress(); })); } diff --git a/test/js/web/streams/compression.test.ts b/test/js/web/streams/compression.test.ts index 63fae21bf4a..f182e968b66 100644 --- a/test/js/web/streams/compression.test.ts +++ b/test/js/web/streams/compression.test.ts @@ -249,3 +249,89 @@ describe("CompressionStream and DecompressionStream", () => { }); }); }); + +// Ported behaviors from Node v26's webstreams adapters +// (upstream: test-whatwg-webstreams-compression.js and +// lib/internal/webstreams/compression.js validateBufferSourceChunk). +describe("CompressionStream chunk handling (Node v26 semantics)", () => { + test("accepts ArrayBuffer chunks", async () => { + const input = "hello arraybuffer world"; + const data = new TextEncoder().encode(input); + + const cs = new CompressionStream("gzip"); + const writer = cs.writable.getWriter(); + writer.write(data.buffer); + writer.close(); + + const compressedChunks: Uint8Array[] = []; + const reader = cs.readable.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + compressedChunks.push(value); + } + expect(compressedChunks.length).toBeGreaterThan(0); + + const ds = new DecompressionStream("gzip"); + const dWriter = ds.writable.getWriter(); + for (const chunk of compressedChunks) dWriter.write(chunk); + dWriter.close(); + + const out: Uint8Array[] = []; + const dReader = ds.readable.getReader(); + while (true) { + const { done, value } = await dReader.read(); + if (done) break; + out.push(value); + } + expect(new TextDecoder().decode(Buffer.concat(out))).toBe(input); + }); + + test("rejects SharedArrayBuffer chunks with ERR_INVALID_ARG_TYPE", async () => { + const cs = new CompressionStream("gzip"); + const writer = cs.writable.getWriter(); + expect.assertions(1); + try { + await writer.write(new SharedArrayBuffer(8)); + } catch (e: any) { + expect(e.code).toBe("ERR_INVALID_ARG_TYPE"); + } + }); + + test("a synchronously-invalid chunk errors both sides instead of hanging the readable", async () => { + const cs = new CompressionStream("gzip"); + const writer = cs.writable.getWriter(); + const reader = cs.readable.getReader(); + + const writeError = writer.write(42).catch(e => e); + // Without the kDestroyOnSyncError handling the readable side hangs + // forever here. + const readError = reader.read().catch(e => e); + + const [we, re] = await Promise.all([writeError, readError]); + expect(we.code).toBe("ERR_INVALID_ARG_TYPE"); + expect(re.code).toBe("ERR_INVALID_ARG_TYPE"); + }); + + test("brotli decoder errors surface as TypeError with the original code as own property", async () => { + const ds = new DecompressionStream("brotli"); + const writer = ds.writable.getWriter(); + const reader = ds.readable.getReader(); + + writer.write(new Uint8Array([0xff, 0xff, 0xff, 0xff, 0xff, 0xff])).catch(() => {}); + writer.close().catch(() => {}); + + expect.assertions(4); + try { + while (true) { + const { done } = await reader.read(); + if (done) break; + } + } catch (e: any) { + expect(e).toBeInstanceOf(TypeError); + expect(Object.hasOwn(e, "code")).toBe(true); + expect(e.code).toStartWith("ERR_BROTLI_DECODER_ERROR_"); + expect(e.cause.code).toBe(e.code); + } + }); +}); From 22df8d4cba52f20829291308a4f8c55219fb519d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:19:37 +0000 Subject: [PATCH 04/61] [autofix.ci] apply automated fixes --- test/js/node/stream/node-stream.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/js/node/stream/node-stream.test.js b/test/js/node/stream/node-stream.test.js index b9978f6eb48..d1697bb41ed 100644 --- a/test/js/node/stream/node-stream.test.js +++ b/test/js/node/stream/node-stream.test.js @@ -795,7 +795,6 @@ for (const size of [0x10, 0xffff, 0x10000, 0x1f000, 0x20000, 0x20010, 0x7ffff, 0 }); } - // Node.js v26 semver-major stream semantics. describe("node v26 stream semantics", () => { // Upstream: v26 howMuchToRead() fast path; covered upstream by the updated From 4decf804dfc86a64edef6d1a13fb89c13ff34716 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 4 Jun 2026 20:20:49 +0000 Subject: [PATCH 05/61] Bump bootstrap image versions for Node.js 26.3.0 [build images] --- scripts/bootstrap.ps1 | 2 +- scripts/bootstrap.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/bootstrap.ps1 b/scripts/bootstrap.ps1 index 30052b92c79..89f07fc5784 100755 --- a/scripts/bootstrap.ps1 +++ b/scripts/bootstrap.ps1 @@ -1,4 +1,4 @@ -# Version: 19 +# Version: 20 # A script that installs the dependencies needed to build and test Bun on Windows. # Supports both x64 and ARM64 using Scoop for package management. # Used by Azure [build images] pipeline. diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index a0bbd7920a4..c546bce9be0 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -1,5 +1,5 @@ #!/bin/sh -# Version: 34 +# Version: 35 # A script that installs the dependencies needed to build and test Bun. # This should work on macOS and Linux with a POSIX shell. From 73d49319e0562b8fe0e6ed4a1e521c0d453584de Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 4 Jun 2026 21:17:39 +0000 Subject: [PATCH 06/61] Node 26: restore native upgrade pieces (V8 14.6 shim, ABI 147) [build images] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit's message described the full compat bump but the tracked file changes were lost to a stray hard reset before committing — only the new header made it in. This restores them: - nodejs-headers 26.3.0 / NODE_MODULE_VERSION 147, bootstrap + flake pins, process.versions.v8 = 14.6.202.34-node.20 - V8 shim for 14.6: Isolate roots layout, flattened FunctionCallbackInfo exit frame, String Write/WriteOneByte/WriteUtf8 V2 + Utf8LengthV2 (legacy exports kept), External::New pointer-tag overload, Number::NewFromInt32/NewFromUint32, HandleScope::Extend/DeleteExtensions; export lists updated for all platforms - napi_get_value_string_* no longer forms a slice from a null buffer pointer when only querying the encoded length - v8/napi test fixtures migrated to the V2 string APIs On top of the restoration, review fixes to the shim: - DeleteExtensions now actually reclaims the slots Extend granted inside a closing scope (per-iteration v8::HandleScopes in a long native call no longer accumulate handles until the callback returns), with a debug assert against out-of-LIFO scope destruction - WriteUtf8V2 encodes UTF-16 in chunks so the 32-bit counts from the encoder can't wrap for >4GB outputs, and uses view() instead of flattening ropes - Utf8LengthV2 skips the scalar surrogate scan for valid UTF-16 (SIMD validate first); WriteV2/WriteOneByteV2 use vectorized copies - FunctionCallbackInfo exit frame stays on the stack for up to 16 arguments - version strings come from a single source of truth: REPORTED_NODEJS_V8_VERSION is defined from scripts/build/deps/nodejs-headers.ts and used by both process-report objects, alongside the existing REPORTED_NODEJS_ABI_VERSION --- flake.nix | 4 +- scripts/bootstrap.ps1 | 6 +- scripts/bootstrap.sh | 2 +- scripts/build/config.ts | 7 +- scripts/build/deps/nodejs-headers.ts | 7 +- scripts/build/flags.ts | 6 +- src/jsc/bindings/BunProcess.cpp | 2 +- .../BunProcessReportObjectWindows.cpp | 7 +- src/jsc/bindings/napi.cpp | 17 ++ src/jsc/bindings/v8/V8Array.cpp | 4 +- .../v8/V8EscapableHandleScopeBase.cpp | 46 ++++-- .../bindings/v8/V8EscapableHandleScopeBase.h | 19 ++- src/jsc/bindings/v8/V8External.cpp | 14 ++ src/jsc/bindings/v8/V8External.h | 10 ++ .../bindings/v8/V8FunctionCallbackInfo.cpp | 41 +++-- src/jsc/bindings/v8/V8FunctionCallbackInfo.h | 69 ++++++--- src/jsc/bindings/v8/V8HandleScope.cpp | 79 +++++++++- src/jsc/bindings/v8/V8HandleScope.h | 17 ++ src/jsc/bindings/v8/V8Isolate.cpp | 4 + src/jsc/bindings/v8/V8Isolate.h | 19 ++- src/jsc/bindings/v8/V8Number.cpp | 10 ++ src/jsc/bindings/v8/V8Number.h | 7 + src/jsc/bindings/v8/V8String.cpp | 145 +++++++++++++++++- src/jsc/bindings/v8/V8String.h | 41 +++++ src/jsc/bindings/v8/shim/FunctionTemplate.cpp | 60 +++++--- src/jsc/bindings/v8/shim/Handle.h | 6 + .../bindings/v8/shim/HandleScopeBuffer.cpp | 29 ++++ src/jsc/bindings/v8/shim/HandleScopeBuffer.h | 19 +++ src/runtime/napi/napi_body.rs | 49 ++++++ src/symbols.def | 10 ++ src/symbols.dyn | 10 ++ src/symbols.txt | 10 ++ test/js/node/process/process.test.js | 8 +- test/napi/napi.test.ts | 7 +- test/napi/node-napi-tests/harness.ts | 2 +- .../v8/bad-modules/mismatched_abi_version.cpp | 2 +- test/v8/bad-modules/no_entrypoint.cpp | 2 +- test/v8/v8-module/main.cpp | 89 +++++++---- test/v8/v8.test.ts | 19 ++- 39 files changed, 757 insertions(+), 148 deletions(-) diff --git a/flake.nix b/flake.nix index 175b7c584d7..9168e0c7a36 100644 --- a/flake.nix +++ b/flake.nix @@ -31,8 +31,8 @@ clang = pkgs.clang_21; lld = pkgs.lld_21; - # Node.js 24 - matching the bootstrap script (targets 24.3.0, actual version from nixpkgs-unstable) - nodejs = pkgs.nodejs_24; + # Node.js 26 - matching the bootstrap script (targets 26.3.0, actual version from nixpkgs-unstable) + nodejs = pkgs.nodejs_26; # Build tools and dependencies packages = [ diff --git a/scripts/bootstrap.ps1 b/scripts/bootstrap.ps1 index 89f07fc5784..335bedb0f87 100755 --- a/scripts/bootstrap.ps1 +++ b/scripts/bootstrap.ps1 @@ -215,9 +215,9 @@ function Install-Git { } function Install-NodeJs { - # Pin to match the ABI version Bun expects (NODE_MODULE_VERSION 137). - # Latest Node (25.x) uses ABI 141 which breaks node-gyp tests. - $nodejsVersion = "24.3.0" + # Pin to match the ABI version Bun expects (NODE_MODULE_VERSION 147). + # A mismatched Node ABI breaks node-gyp tests. + $nodejsVersion = "26.3.0" Install-Scoop-Package "nodejs@$nodejsVersion" -Command node # Seed node-gyp's cache so napi tests don't re-download headers + node.lib diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index c546bce9be0..13047162125 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -780,7 +780,7 @@ install_common_software() { } nodejs_version_exact() { - print "24.3.0" + print "26.3.0" } nodejs_version() { diff --git a/scripts/build/config.ts b/scripts/build/config.ts index 38057593946..54d554b9e13 100644 --- a/scripts/build/config.ts +++ b/scripts/build/config.ts @@ -10,7 +10,7 @@ import { execSync } from "node:child_process"; import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, symlinkSync } from "node:fs"; import { homedir, arch as hostArch, platform as hostPlatform } from "node:os"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; -import { NODEJS_ABI_VERSION, NODEJS_VERSION } from "./deps/nodejs-headers.ts"; +import { NODEJS_ABI_VERSION, NODEJS_V8_VERSION, NODEJS_VERSION } from "./deps/nodejs-headers.ts"; import { WEBKIT_VERSION } from "./deps/webkit.ts"; import { assert, BuildError } from "./error.ts"; import { resolveMacosSdkPath } from "./macos-sdk.ts"; @@ -61,6 +61,7 @@ export interface Host { const versionDefaults = { nodejsVersion: NODEJS_VERSION, nodejsAbiVersion: NODEJS_ABI_VERSION, + nodejsV8Version: NODEJS_V8_VERSION, webkitVersion: WEBKIT_VERSION, }; @@ -307,6 +308,7 @@ export interface Config { /** Node.js compat version. Default in versions.ts; override to test a bump. */ nodejsVersion: string; nodejsAbiVersion: string; + nodejsV8Version: string; /** WebKit commit. Default in versions.ts; override to test a WebKit branch. */ webkitVersion: string; } @@ -368,6 +370,7 @@ export interface PartialConfig { // Version pins (defaults in versions.ts). nodejsVersion?: string; nodejsAbiVersion?: string; + nodejsV8Version?: string; webkitVersion?: string; } @@ -1019,6 +1022,7 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con // to test a branch before bumping the pinned default. const nodejsVersion = partial.nodejsVersion ?? versionDefaults.nodejsVersion; const nodejsAbiVersion = partial.nodejsAbiVersion ?? versionDefaults.nodejsAbiVersion; + const nodejsV8Version = partial.nodejsV8Version ?? versionDefaults.nodejsV8Version; const webkitVersion = partial.webkitVersion ?? versionDefaults.webkitVersion; // ─── macOS SDK ─── @@ -1180,6 +1184,7 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con version, revision, nodejsVersion, + nodejsV8Version, nodejsAbiVersion, canaryRevision, webkitVersion, diff --git a/scripts/build/deps/nodejs-headers.ts b/scripts/build/deps/nodejs-headers.ts index bd68ed83395..3bad3fea27e 100644 --- a/scripts/build/deps/nodejs-headers.ts +++ b/scripts/build/deps/nodejs-headers.ts @@ -14,10 +14,13 @@ import type { Dependency } from "../source.ts"; * download URL, and passed to zig as -Dreported_nodejs_version. * Override via `--nodejs-version=X.Y.Z` to test a bump. */ -export const NODEJS_VERSION = "24.3.0"; +export const NODEJS_VERSION = "26.3.0"; /** Node.js NODE_MODULE_VERSION — for native addon ABI compat. */ -export const NODEJS_ABI_VERSION = "137"; +export const NODEJS_ABI_VERSION = "147"; + +/** V8 version reported by process.versions.v8 — must match the pinned Node.js version's. */ +export const NODEJS_V8_VERSION = "14.6.202.34-node.20"; export const nodejsHeaders: Dependency = { name: "nodejs", diff --git a/scripts/build/flags.ts b/scripts/build/flags.ts index 7759c7576d6..966f56f77bc 100644 --- a/scripts/build/flags.ts +++ b/scripts/build/flags.ts @@ -742,7 +742,7 @@ export const defines: Flag[] = [ }, { // Shell-escaped quotes so clang receives literal quotes in the define - // (the preprocessor needs the string to be "24.3.0", not bare 24.3.0). + // (the preprocessor needs the string to be "26.3.0", not bare 26.3.0). flag: c => `REPORTED_NODEJS_VERSION=\\"${c.nodejsVersion}\\"`, desc: "Node.js version string reported by process.version", }, @@ -750,6 +750,10 @@ export const defines: Flag[] = [ flag: c => `REPORTED_NODEJS_ABI_VERSION=${c.nodejsAbiVersion}`, desc: "Node.js ABI version (process.versions.modules)", }, + { + flag: c => `REPORTED_NODEJS_V8_VERSION=\\"${c.nodejsV8Version}\\"`, + desc: "V8 version string (process.versions.v8)", + }, { // Hardcoded ON — experimental flag not exposed in config flag: "USE_BUN_MIMALLOC=1", diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 6935f0cbbce..b8bd26e2501 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -245,7 +245,7 @@ static JSValue constructVersions(VM& vm, JSObject* processObject) // Use commit hash for zstd (semantic version extraction not working yet) object->putDirect(vm, JSC::Identifier::fromString(vm, "zstd"_s), JSC::jsOwnedString(vm, ASCIILiteral::fromLiteralUnsafe(BUN_VERSION_ZSTD_HASH)), 0); - object->putDirect(vm, JSC::Identifier::fromString(vm, "v8"_s), JSValue(JSC::jsOwnedString(vm, String("13.6.233.10-node.18"_s))), 0); + object->putDirect(vm, JSC::Identifier::fromString(vm, "v8"_s), JSValue(JSC::jsOwnedString(vm, String(ASCIILiteral::fromLiteralUnsafe(REPORTED_NODEJS_V8_VERSION)))), 0); #if OS(WINDOWS) object->putDirect(vm, JSC::Identifier::fromString(vm, "uv"_s), JSValue(JSC::jsOwnedString(vm, String::fromLatin1(uv_version_string()))), 0); #else diff --git a/src/jsc/bindings/BunProcessReportObjectWindows.cpp b/src/jsc/bindings/BunProcessReportObjectWindows.cpp index 48e24d14b64..6d6e4428f3e 100644 --- a/src/jsc/bindings/BunProcessReportObjectWindows.cpp +++ b/src/jsc/bindings/BunProcessReportObjectWindows.cpp @@ -16,6 +16,9 @@ #include "JavaScriptCore/VM.h" #include "JavaScriptCore/NumberPrototype.h" #include "wtf-bindings.h" + +#define STRINGIFY_IMPL(x) #x +#define STRINGIFY(x) STRINGIFY_IMPL(x) #include "wtf/Scope.h" #include "wtf/text/WTFString.h" #include "wtf/text/StringView.h" @@ -97,9 +100,9 @@ JSValue constructReportObjectWindows(VM& vm, Zig::GlobalObject* globalObject, Pr // Component versions - just add the minimum needed JSObject* versions = constructEmptyObject(globalObject, globalObject->objectPrototype()); versions->putDirect(vm, Identifier::fromString(vm, "node"_s), jsString(vm, String(REPORTED_NODEJS_VERSION ""_s)), 0); - versions->putDirect(vm, Identifier::fromString(vm, "v8"_s), jsString(vm, String("13.6.233.10-node.18"_s)), 0); + versions->putDirect(vm, Identifier::fromString(vm, "v8"_s), jsString(vm, String(ASCIILiteral::fromLiteralUnsafe(REPORTED_NODEJS_V8_VERSION))), 0); versions->putDirect(vm, Identifier::fromString(vm, "uv"_s), jsString(vm, String::fromLatin1(uv_version_string())), 0); - versions->putDirect(vm, Identifier::fromString(vm, "modules"_s), jsString(vm, String("137"_s)), 0); + versions->putDirect(vm, Identifier::fromString(vm, "modules"_s), jsString(vm, String(ASCIILiteral::fromLiteralUnsafe(STRINGIFY(REPORTED_NODEJS_ABI_VERSION)))), 0); header->putDirect(vm, Identifier::fromString(vm, "componentVersions"_s), versions, 0); RETURN_IF_EXCEPTION(scope, {}); diff --git a/src/jsc/bindings/napi.cpp b/src/jsc/bindings/napi.cpp index 6e7749b54e1..d6785763747 100644 --- a/src/jsc/bindings/napi.cpp +++ b/src/jsc/bindings/napi.cpp @@ -2285,6 +2285,23 @@ napi_status napi_get_value_string_any_encoding(napi_env env, napi_value napiValu return napi_set_last_error(env, napi_ok); } + // An over-large bufsize (in particular NAPI_AUTO_LENGTH == SIZE_MAX) means the + // caller promises the buffer is big enough for the whole string; Node forwards + // such sizes to V8's WriteUtf8V2, which simply stops at the end of the string. + // Clamp to the worst-case number of code units the encoder can produce so that + // `bufsize - 1` (and `2 * (bufsize - 1)` for UTF-16, which would otherwise wrap + // around size_t) stays within the destination the caller actually guarantees. + // The encoders already stop at min(input, output), so this never changes how + // many code units get written for buffers that really are this large. + const size_t max_encoded_units = EncodeTo == NapiStringEncoding::utf8 + // Latin-1 → UTF-8 expands at most 2x per byte; UTF-16 → UTF-8 at most 3x per code unit + ? (view->is8Bit() ? 2 : 3) * static_cast(view->length()) + // latin1/utf16 destinations: at most one code unit per source code unit + : static_cast(view->length()); + if (bufsize - 1 > max_encoded_units) [[unlikely]] { + bufsize = max_encoded_units + 1; + } + size_t written; std::span writable_byte_slice(reinterpret_cast(buf), EncodeTo == NapiStringEncoding::utf16 diff --git a/src/jsc/bindings/v8/V8Array.cpp b/src/jsc/bindings/v8/V8Array.cpp index 2e6dcf09eea..4ab770f33d4 100644 --- a/src/jsc/bindings/v8/V8Array.cpp +++ b/src/jsc/bindings/v8/V8Array.cpp @@ -94,7 +94,9 @@ MaybeLocal Array::New(Local context, size_t length, JSArray* array = JSC::constructArray(globalObject, static_cast(nullptr), args); RETURN_IF_EXCEPTION(scope, MaybeLocal()); - Local result = handleScope.createLocal(vm, array); + // Note: createLocal must not be called on an EscapableHandleScope -- it does not own a + // buffer (its constructor does not push a Bun handle scope; see V8EscapableHandleScopeBase). + Local result = isolate->currentHandleScope()->createLocal(vm, array); return handleScope.Escape(result); } diff --git a/src/jsc/bindings/v8/V8EscapableHandleScopeBase.cpp b/src/jsc/bindings/v8/V8EscapableHandleScopeBase.cpp index e9c3c019688..a3616f40d7c 100644 --- a/src/jsc/bindings/v8/V8EscapableHandleScopeBase.cpp +++ b/src/jsc/bindings/v8/V8EscapableHandleScopeBase.cpp @@ -1,29 +1,51 @@ #include "V8EscapableHandleScopeBase.h" +#include "shim/GlobalInternals.h" #include "v8_compatibility_assertions.h" +#include "v8_handle_scope_data.h" ASSERT_V8_TYPE_LAYOUT_MATCHES(v8::EscapableHandleScopeBase) namespace v8 { EscapableHandleScopeBase::EscapableHandleScopeBase(Isolate* isolate) - : HandleScope(isolate) { - // at this point isolate->currentHandleScope() would just be this, so instead we have to get the - // previous one - auto& handle = m_previousHandleScope->m_buffer->createEmptyHandle(); - m_escapeSlot = &handle; + // This constructor must be ABI-neutral between header generations (see the comment in + // V8EscapableHandleScopeBase.h): with Node 26 headers the object is destroyed by V8's inline + // ~HandleScope, with older headers by Bun's exported ~HandleScope, and neither path can pop a + // Bun handle scope. So do not push one. Instead initialize the three V8-visible base words + // exactly like V8 14's inline HandleScope::Initialize (v8-local-handle.h): + // isolate_ <- isolate + // prev_next_ <- HandleScopeData::next + // prev_limit_ <- HandleScopeData::limit + // HandleScopeData::level++ + // The inline destructor then restores HandleScopeData from those words (our exported + // ~HandleScope does the same for old-ABI frames, see V8HandleScope.cpp). Outside of a running + // inline CreateHandle, next == limit always holds (Extend hands out one slot at a time and + // CreateHandle advances next past it), so the snapshot we restore preserves that invariant. + auto* data = shim::getHandleScopeData(isolate); + m_isolate = isolate; + m_previousHandleScope = reinterpret_cast(data->next); + m_buffer = reinterpret_cast(data->limit); + data->level++; + + // Handles created while this scope is alive land in the surrounding Bun scope's buffer (we + // did not push), so they outlive this scope; that is safe, just slightly longer-lived than + // real V8. An Escape()d value must survive this scope, which that same buffer provides -- + // capture it now so Escape still targets it even if (with old-ABI addons) a deeper scope is + // current by then. + auto* current = isolate->globalInternals()->currentHandleScope(); + RELEASE_ASSERT(current, "EscapableHandleScope created without an active handle scope"); + m_escapeBuffer = current->m_buffer; } -// Store the handle escape_value in the escape slot that we have allocated from the parent -// HandleScope, and return the escape slot +// Create a handle for escape_value in the scope this object escapes to, and return its slot uintptr_t* EscapableHandleScopeBase::EscapeSlot(uintptr_t* escape_value) { - RELEASE_ASSERT(m_escapeSlot != nullptr, "EscapableHandleScope::Escape called multiple times"); - TaggedPointer* newHandle = m_previousHandleScope->m_buffer->createHandleFromExistingObject( + RELEASE_ASSERT(m_escapeBuffer != nullptr, "EscapableHandleScope::Escape called multiple times"); + TaggedPointer* newHandle = m_escapeBuffer->createHandleFromExistingObject( TaggedPointer::fromRaw(*escape_value), - m_isolate, - m_escapeSlot); - m_escapeSlot = nullptr; + m_isolate); + m_escapeBuffer = nullptr; return newHandle->asRawPtrLocation(); } diff --git a/src/jsc/bindings/v8/V8EscapableHandleScopeBase.h b/src/jsc/bindings/v8/V8EscapableHandleScopeBase.h index 097f7aa0a57..9903cfd045f 100644 --- a/src/jsc/bindings/v8/V8EscapableHandleScopeBase.h +++ b/src/jsc/bindings/v8/V8EscapableHandleScopeBase.h @@ -6,6 +6,19 @@ namespace v8 { +// In Node 26 (V8 14) headers, this class's constructor is the only out-of-line piece of an +// EscapableHandleScope's lifetime: ~EscapableHandleScopeBase and ~EscapableHandleScope are +// inline-defaulted, so destruction runs V8's inline ~HandleScope (v8-local-handle.h), which +// unwinds the isolate's HandleScopeData using this object's three base words as +// { isolate_, prev_next_, prev_limit_ }. Older Node headers (<= 24) instead reach Bun's exported +// ~HandleScope through their inline-defaulted destructors. Therefore this constructor must NOT +// push a Bun handle scope (nothing on either path would pop it); it initializes the base words +// V8-style, and Bun's exported ~HandleScope detects such frames and unwinds them the same way the +// inline destructor would. See V8EscapableHandleScopeBase.cpp and V8HandleScope.cpp. +// +// Consequently the inherited m_previousHandleScope/m_buffer words do NOT hold Bun pointers here, +// so inherited HandleScope methods that use them (like createLocal) must not be called on these +// objects; Bun-internal code should use isolate->currentHandleScope()->createLocal instead. class EscapableHandleScopeBase : public HandleScope { public: BUN_EXPORT EscapableHandleScopeBase(Isolate* isolate); @@ -14,7 +27,11 @@ class EscapableHandleScopeBase : public HandleScope { BUN_EXPORT uintptr_t* EscapeSlot(uintptr_t* escape_value); private: - shim::Handle* m_escapeSlot; + // The buffer of the Bun handle scope that was current when this scope was constructed (the + // scope an Escape()d value escapes to). Occupies the slot V8 uses for escape_slot_; like + // escape_slot_, it is only ever touched by out-of-line (Bun-compiled) code, and doubles as + // the "Escape called twice" flag. + shim::HandleScopeBuffer* m_escapeBuffer; }; } // namespace v8 diff --git a/src/jsc/bindings/v8/V8External.cpp b/src/jsc/bindings/v8/V8External.cpp index 23581c2ecf6..221790e23f7 100644 --- a/src/jsc/bindings/v8/V8External.cpp +++ b/src/jsc/bindings/v8/V8External.cpp @@ -17,6 +17,13 @@ Local External::New(Isolate* isolate, void* value) return isolate->currentHandleScope()->createLocal(vm, val); } +Local External::New(Isolate* isolate, void* value, uint16_t tag) +{ + // see V8External.h for why the tag is ignored + (void)tag; + return New(isolate, value); +} + void* External::Value() const { auto* external = localToObjectPointer(); @@ -26,4 +33,11 @@ void* External::Value() const return external->value(); } +void* External::Value(uint16_t tag) const +{ + // see V8External.h for why the tag is ignored + (void)tag; + return Value(); +} + } // namespace v8 diff --git a/src/jsc/bindings/v8/V8External.h b/src/jsc/bindings/v8/V8External.h index 3d9a0fccae3..fb99e33ffaf 100644 --- a/src/jsc/bindings/v8/V8External.h +++ b/src/jsc/bindings/v8/V8External.h @@ -9,8 +9,18 @@ namespace v8 { class External : public Value { public: + // Kept for addons compiled against older Node headers, where this overload was out-of-line. + // In V8 14 it is an inline wrapper around the tagged overload below. BUN_EXPORT static Local New(Isolate* isolate, void* value); + // The tag is a v8::ExternalPointerTypeTag (uint16_t), used to type entries in V8's sandbox + // external pointer table so that sandboxed code cannot type-confuse one external pointer for + // another. We have no V8 sandbox and no external pointer table -- the pointer is stored + // directly in a NapiExternal cell -- so there is nothing for the tag to tag and it is ignored. + BUN_EXPORT static Local New(Isolate* isolate, void* value, uint16_t tag); BUN_EXPORT void* Value() const; + // Same deal as New: the tag selects the external pointer table tag to validate against, which + // does not exist here. V8 14's inline Value() forwards to this overload. + BUN_EXPORT void* Value(uint16_t tag) const; }; } // namespace v8 diff --git a/src/jsc/bindings/v8/V8FunctionCallbackInfo.cpp b/src/jsc/bindings/v8/V8FunctionCallbackInfo.cpp index f044d67ffdd..6c6d4646cad 100644 --- a/src/jsc/bindings/v8/V8FunctionCallbackInfo.cpp +++ b/src/jsc/bindings/v8/V8FunctionCallbackInfo.cpp @@ -2,22 +2,35 @@ #include "real_v8.h" #include "v8_compatibility_assertions.h" -// Check that the offset of a field in our ImplicitArgs struct matches the array index -// that V8 uses to access that field -#define CHECK_IMPLICIT_ARG(BUN_NAME, V8_NAME) \ - static_assert(offsetof(v8::ImplicitArgs, BUN_NAME) \ - == sizeof(void*) * real_v8::FunctionCallbackInfo::V8_NAME, \ - "Position of `" #BUN_NAME "` in implicit arguments does not match V8"); +// Check that a slot index in our FunctionCallbackInfo matches the index V8's +// inline accessors use to read that slot of the ApiCallbackExitFrame +#define CHECK_FRAME_INDEX(NAME) \ + static_assert(static_cast(v8::FunctionCallbackInfo::NAME) \ + == static_cast(real_v8::FunctionCallbackInfo::NAME), \ + "Index of `" #NAME "` in the callback exit frame does not match V8"); -CHECK_IMPLICIT_ARG(unused, kUnusedIndex) -CHECK_IMPLICIT_ARG(isolate, kIsolateIndex) -CHECK_IMPLICIT_ARG(context, kContextIndex) -CHECK_IMPLICIT_ARG(return_value, kReturnValueIndex) -CHECK_IMPLICIT_ARG(target, kTargetIndex) -CHECK_IMPLICIT_ARG(new_target, kNewTargetIndex) +CHECK_FRAME_INDEX(kNewTargetIndex) +CHECK_FRAME_INDEX(kArgcIndex) +CHECK_FRAME_INDEX(kFrameSPIndex) +CHECK_FRAME_INDEX(kFrameTypeIndex) +CHECK_FRAME_INDEX(kFrameFPIndex) +CHECK_FRAME_INDEX(kFramePCIndex) +CHECK_FRAME_INDEX(kIsolateIndex) +CHECK_FRAME_INDEX(kReturnValueIndex) +CHECK_FRAME_INDEX(kContextIndex) +CHECK_FRAME_INDEX(kTargetIndex) +CHECK_FRAME_INDEX(kReceiverIndex) +CHECK_FRAME_INDEX(kFirstJSArgumentIndex) + +// Our enum folds kFrameConstantPoolIndex into kFrameFPIndex, which is only +// valid when no constant pool slot is present (true everywhere but PPC64) +static_assert(real_v8::internal::Internals::kFrameCPSlotCount == 0, + "Bun's v8::FunctionCallbackInfo assumes no constant pool slot in the exit frame"); + +static_assert(v8::FunctionCallbackInfo::kFrameTypeApiCallExit + == real_v8::internal::Internals::kFrameTypeApiCallExit, + "Frame type for API callback exit frames does not match V8"); ASSERT_V8_TYPE_LAYOUT_MATCHES(v8::FunctionCallbackInfo) -ASSERT_V8_TYPE_FIELD_OFFSET_MATCHES(v8::FunctionCallbackInfo, implicit_args, implicit_args_) ASSERT_V8_TYPE_FIELD_OFFSET_MATCHES(v8::FunctionCallbackInfo, values, values_) -ASSERT_V8_TYPE_FIELD_OFFSET_MATCHES(v8::FunctionCallbackInfo, length, length_) diff --git a/src/jsc/bindings/v8/V8FunctionCallbackInfo.h b/src/jsc/bindings/v8/V8FunctionCallbackInfo.h index bfb9f977798..7baa3dbb76f 100644 --- a/src/jsc/bindings/v8/V8FunctionCallbackInfo.h +++ b/src/jsc/bindings/v8/V8FunctionCallbackInfo.h @@ -8,32 +8,57 @@ class Isolate; class Context; class Value; -struct ImplicitArgs { - // v8-function-callback.h:149-154 - void* unused; // kUnusedIndex = 0 - Isolate* isolate; // kIsolateIndex = 1 - void* context; // kContextIndex = 2 - TaggedPointer return_value; // kReturnValueIndex = 3 - TaggedPointer target; // kTargetIndex = 4 - void* new_target; // kNewTargetIndex = 5 -}; - // T = return value +// +// Since V8 13.8 (crbug.com/326505377), FunctionCallbackInfo is no longer a +// {implicit_args, values, length} triple. It is a single-pointer-sized view +// into an ApiCallbackExitFrame: `this` points directly at the argc slot of a +// contiguous array of pointer-sized slots, and V8's inline accessors index +// `values_` both backwards (new.target) and forwards (frame words, API +// arguments, receiver, JS arguments) relative to that slot. template class FunctionCallbackInfo { public: - // V8 treats this as an array of pointers - ImplicitArgs* implicit_args; - // index -1 is this - TaggedPointer* values; - int length; - - FunctionCallbackInfo(ImplicitArgs* implicit_args_, TaggedPointer* values_, int length_) - : implicit_args(implicit_args_) - , values(values_) - , length(length_) - { - } + // Slot indices relative to `values`. These must match the private enum in + // V8's v8-function-callback.h (checked by static_asserts in + // V8FunctionCallbackInfo.cpp). kFrameConstantPoolIndex is folded into + // kFrameFPIndex because Internals::kFrameCPSlotCount == 0 on every + // architecture Bun supports (it is only 1 on PPC64). + enum { + // Optional frame arguments block (only for API_CONSTRUCT_EXIT frames). + kNewTargetIndex = -1, + + // Mandatory part. + kArgcIndex = 0, // raw integer, not a Smi + kFrameSPIndex = 1, + kFrameTypeIndex = 2, // Smi-encoded frame type + kFrameFPIndex = 3, + kFramePCIndex = 4, + + // API arguments block. + kIsolateIndex = 5, // raw Isolate* + kReturnValueIndex = 6, + kContextIndex = 7, // raw context pointer + kTargetIndex = 8, + + // JS arguments block. + kReceiverIndex = 9, + kFirstJSArgumentIndex = 10, + }; + + // v8::internal::Internals::kFrameTypeApiCallExit. Stored Smi-encoded in + // the kFrameTypeIndex slot; IsConstructCall() compares against it. + static constexpr int kFrameTypeApiCallExit = 18; + + // V8 declares this as `internal::Address values_[1]` and indexes it + // out-of-bounds in both directions; the object provides a view of the + // frame rather than owning any storage. Mutable for parity with V8 (GC + // may rewrite slots through a const view). + mutable TaggedPointer values[1]; + + FunctionCallbackInfo() = delete; + FunctionCallbackInfo(const FunctionCallbackInfo&) = delete; + FunctionCallbackInfo& operator=(const FunctionCallbackInfo&) = delete; }; using FunctionCallback = void (*)(const FunctionCallbackInfo&); diff --git a/src/jsc/bindings/v8/V8HandleScope.cpp b/src/jsc/bindings/v8/V8HandleScope.cpp index 9071d9621c5..233189c2f91 100644 --- a/src/jsc/bindings/v8/V8HandleScope.cpp +++ b/src/jsc/bindings/v8/V8HandleScope.cpp @@ -1,10 +1,18 @@ #include "V8HandleScope.h" #include "shim/GlobalInternals.h" #include "v8_compatibility_assertions.h" +#include "v8_handle_scope_data.h" -// I haven't found an inlined function which accesses HandleScope fields, so I'm assuming the field -// offsets do *not* need to match (also, our fields have different types and meanings anyway). -// But the size must match, because if our HandleScope is too big it'll clobber other stack variables. +// The size must match, because if our HandleScope is too big it'll clobber other stack variables. +// The field offsets matter too since Node 26 (V8 14): the headers fully inline +// HandleScope's constructor, destructor and CreateHandle, so addon code reads and writes the +// three words of a HandleScope frame directly as { Isolate* isolate_; Address* prev_next_; +// Address* prev_limit_; }. Frames constructed by our exported HandleScope(Isolate*) constructor +// are never destroyed by that inline code (old-ABI addons call our exported destructor), so those +// keep Bun meanings for words 1 and 2 (m_previousHandleScope/m_buffer). Frames constructed by the +// exported EscapableHandleScopeBase constructor *are* unwound by the inline destructor, so that +// constructor initializes them with V8's meanings instead -- see V8EscapableHandleScopeBase.cpp +// and the comments in ~HandleScope below. ASSERT_V8_TYPE_LAYOUT_MATCHES(v8::HandleScope) namespace v8 { @@ -21,6 +29,36 @@ HandleScope::HandleScope(Isolate* isolate) HandleScope::~HandleScope() { + if (m_isolate->globalInternals()->currentHandleScope() != this) { + // This frame was not pushed onto Bun's handle scope stack, so it must have been + // initialized in V8's inline ABI style by the exported EscapableHandleScopeBase + // constructor (which is the only exported constructor that does not push; plain + // HandleScope frames built by the exported constructor above always have + // currentHandleScope() == this here under correct nesting). Old-ABI addons reach this + // destructor for such frames because their inline-defaulted ~EscapableHandleScopeBase / + // ~EscapableHandleScope call the out-of-line ~HandleScope. Unwind exactly like V8 14's + // inline ~HandleScope would: words 1 and 2 hold the constructor-time snapshot of + // HandleScopeData::next/limit, not Bun pointers. +#if ASSERT_ENABLED + // A Bun-pushed frame destroyed out of LIFO order would also land here and have its + // m_previousHandleScope/m_buffer pointers written into HandleScopeData below, silently + // corrupting the next inline CreateHandle. Fail loudly in debug builds instead. + for (auto* scope = m_isolate->globalInternals()->currentHandleScope(); scope; scope = scope->m_previousHandleScope) { + ASSERT_WITH_MESSAGE(scope != this, "v8::HandleScope destroyed out of LIFO order"); + } +#endif + auto* data = shim::getHandleScopeData(m_isolate); + data->next = reinterpret_cast(m_previousHandleScope); + data->limit = reinterpret_cast(m_buffer); + data->level--; + // Mirror V8 14's inline ~HandleScope: reclaim the slots Extend granted inside this + // frame (a no-op when the frame created no handles, since the newest remaining grant + // then already matches the restored limit). + if (auto* current = m_isolate->globalInternals()->currentHandleScope()) { + current->m_buffer->deleteGrantsBack(data->limit); + } + return; + } m_isolate->globalInternals()->setCurrentHandleScope(m_previousHandleScope); m_buffer->clear(); m_buffer = nullptr; @@ -35,4 +73,39 @@ uintptr_t* HandleScope::CreateHandle(internal::Isolate* i_isolate, uintptr_t val return newSlot->asRawPtrLocation(); } +uintptr_t* HandleScope::Extend(Isolate* isolate) +{ + // V8 14's inline HandleScope::CreateHandle (v8-local-handle.h) calls Extend when + // data->next == data->limit, then stores the value into the returned slot itself and sets + // data->next to one past the slot. The Isolate's HandleScopeData starts zeroed + // (next == limit == nullptr), and we always hand out exactly one slot with + // limit == slot + 1 == the next value the caller will store, so next == limit is reestablished + // after every inline allocation and every inline handle creation takes this path. The slots + // come from the current Bun handle scope's buffer, so the values stay alive (and GC-visited, + // see Handle::isCell) until that scope closes. + auto* handleScope = isolate->globalInternals()->currentHandleScope(); + RELEASE_ASSERT(handleScope); + TaggedPointer* slot = handleScope->m_buffer->createRawHandleSlot(); + uintptr_t* address = slot->asRawPtrLocation(); + auto* data = shim::getHandleScopeData(isolate); + data->next = address; + data->limit = address + 1; + return address; +} + +void HandleScope::DeleteExtensions(Isolate* isolate) +{ + // Called by V8 14's inline ~HandleScope after it restored HandleScopeData::next/limit, when + // the scope changed the limit (which Extend always does). Free the slots Extend granted inside + // the closing scope — without this, per-iteration v8::HandleScopes in a long native call never + // reclaim memory (everything would otherwise live until the enclosing Bun scope closes). + // `this` is the addon's V8-layout HandleScope, so our members must not be touched. + auto* handleScope = isolate->globalInternals()->currentHandleScope(); + if (!handleScope) { + return; + } + auto* data = shim::getHandleScopeData(isolate); + handleScope->m_buffer->deleteGrantsBack(data->limit); +} + } // namespace v8 diff --git a/src/jsc/bindings/v8/V8HandleScope.h b/src/jsc/bindings/v8/V8HandleScope.h index 0f91c2234d6..bee48002fa5 100644 --- a/src/jsc/bindings/v8/V8HandleScope.h +++ b/src/jsc/bindings/v8/V8HandleScope.h @@ -44,6 +44,11 @@ class HandleScope { friend class EscapableHandleScopeBase; protected: + // Used by EscapableHandleScopeBase, whose constructor must initialize the fields itself + // (V8-style, without pushing a Bun handle scope). Mirrors V8's protected + // `HandleScope() = default`. + HandleScope() = default; + // must be 24 bytes to match V8 layout Isolate* m_isolate; HandleScope* m_previousHandleScope; @@ -51,6 +56,18 @@ class HandleScope { // is protected in v8, which matters on windows BUN_EXPORT static uintptr_t* CreateHandle(internal::Isolate* isolate, uintptr_t value); + +private: + // Out-of-line slow path of V8 14's fully-inline HandleScope (v8-local-handle.h). The inline + // CreateHandle calls Extend whenever HandleScopeData::next == HandleScopeData::limit, and the + // inline destructor calls DeleteExtensions whenever the scope changed HandleScopeData::limit. + // Private to match V8's declarations, which affects the mangled name on MSVC. + // + // Note that when these are called, `this` (for DeleteExtensions) is a V8-layout HandleScope + // living in the addon's stack frame -- not one of ours -- so they must not touch our members + // through `this`. + BUN_EXPORT static uintptr_t* Extend(Isolate* isolate); + BUN_EXPORT void DeleteExtensions(Isolate* isolate); }; static_assert(sizeof(HandleScope) == 24, "HandleScope has wrong layout"); diff --git a/src/jsc/bindings/v8/V8Isolate.cpp b/src/jsc/bindings/v8/V8Isolate.cpp index 2f6928b49a8..80f740bbcc5 100644 --- a/src/jsc/bindings/v8/V8Isolate.cpp +++ b/src/jsc/bindings/v8/V8Isolate.cpp @@ -43,6 +43,10 @@ Local Isolate::GetCurrentContext() Isolate::Isolate(shim::GlobalInternals* globalInternals) : m_globalInternals(globalInternals) , m_globalObject(globalInternals->m_globalObject) + // Zero the padding: V8 14's inline HandleScope code keeps the isolate's HandleScopeData + // (next/limit/level, see HandleScope::Extend) inside this region, and relies on it starting + // out zeroed just like real V8's HandleScopeData::Initialize() leaves it. + , m_padding {} { m_roots[kUndefinedValueRootIndex] = TaggedPointer(&globalInternals->m_undefinedValue); m_roots[kNullValueRootIndex] = TaggedPointer(&globalInternals->m_nullValue); diff --git a/src/jsc/bindings/v8/V8Isolate.h b/src/jsc/bindings/v8/V8Isolate.h index 5069cbd3e94..784c93e77bc 100644 --- a/src/jsc/bindings/v8/V8Isolate.h +++ b/src/jsc/bindings/v8/V8Isolate.h @@ -17,12 +17,12 @@ class GlobalInternals; // they need to have the correct layout. class Isolate final { public: - // v8-internal.h:775 - static constexpr int kUndefinedValueRootIndex = 4; - static constexpr int kTheHoleValueRootIndex = 5; - static constexpr int kNullValueRootIndex = 6; - static constexpr int kTrueValueRootIndex = 7; - static constexpr int kFalseValueRootIndex = 8; + // v8-internal.h:1107 + static constexpr int kUndefinedValueRootIndex = 0; + static constexpr int kTheHoleValueRootIndex = 1; + static constexpr int kNullValueRootIndex = 2; + static constexpr int kTrueValueRootIndex = 3; + static constexpr int kFalseValueRootIndex = 4; Isolate(shim::GlobalInternals* globalInternals); @@ -50,9 +50,12 @@ class Isolate final { shim::GlobalInternals* m_globalInternals; Zig::GlobalObject* m_globalObject; - uintptr_t m_padding[78]; + // Padding so that m_roots is at Internals::kIsolateRootsOffset (688 on 64-bit: 16 bytes of + // fields above plus 84 words). V8 14.x inserted kIsolateJSDispatchTableOffset + // (kExternalEntityTableSize) into the isolate-data layout ahead of the roots array. + uintptr_t m_padding[84]; - std::array m_roots; + std::array m_roots; }; } // namespace v8 diff --git a/src/jsc/bindings/v8/V8Number.cpp b/src/jsc/bindings/v8/V8Number.cpp index c870d1ef387..ab52f2ed9aa 100644 --- a/src/jsc/bindings/v8/V8Number.cpp +++ b/src/jsc/bindings/v8/V8Number.cpp @@ -11,6 +11,16 @@ Local Number::New(Isolate* isolate, double value) return isolate->currentHandleScope()->createLocal(isolate->vm(), JSC::jsNumber(value)); } +Local Number::NewFromInt32(Isolate* isolate, int32_t value) +{ + return isolate->currentHandleScope()->createLocal(isolate->vm(), JSC::jsNumber(value)); +} + +Local Number::NewFromUint32(Isolate* isolate, uint32_t value) +{ + return isolate->currentHandleScope()->createLocal(isolate->vm(), JSC::jsNumber(value)); +} + double Number::Value() const { return localToJSValue().asNumber(); diff --git a/src/jsc/bindings/v8/V8Number.h b/src/jsc/bindings/v8/V8Number.h index e85c3ae5ec9..e3a02e27c17 100644 --- a/src/jsc/bindings/v8/V8Number.h +++ b/src/jsc/bindings/v8/V8Number.h @@ -12,6 +12,13 @@ class Number : public Primitive { BUN_EXPORT static Local New(Isolate* isolate, double value); BUN_EXPORT double Value() const; + +private: + // Out-of-line targets of the inline templated Number::New integer overloads in + // v8-primitive.h. Private to match V8's declarations, which affects the mangled + // name on MSVC. + BUN_EXPORT static Local NewFromInt32(Isolate* isolate, int32_t value); + BUN_EXPORT static Local NewFromUint32(Isolate* isolate, uint32_t value); }; } // namespace v8 diff --git a/src/jsc/bindings/v8/V8String.cpp b/src/jsc/bindings/v8/V8String.cpp index 83154798225..8f61fd57ffb 100644 --- a/src/jsc/bindings/v8/V8String.cpp +++ b/src/jsc/bindings/v8/V8String.cpp @@ -8,11 +8,12 @@ ASSERT_V8_TYPE_LAYOUT_MATCHES(v8::String) ASSERT_V8_ENUM_MATCHES(NewStringType, kNormal) ASSERT_V8_ENUM_MATCHES(NewStringType, kInternalized) -ASSERT_V8_ENUM_MATCHES(String::WriteOptions, NO_OPTIONS) -ASSERT_V8_ENUM_MATCHES(String::WriteOptions, HINT_MANY_WRITES_EXPECTED) -ASSERT_V8_ENUM_MATCHES(String::WriteOptions, NO_NULL_TERMINATION) -ASSERT_V8_ENUM_MATCHES(String::WriteOptions, PRESERVE_ONE_BYTE_NULL) -ASSERT_V8_ENUM_MATCHES(String::WriteOptions, REPLACE_INVALID_UTF8) +// V8 14 removed String::WriteOptions along with the legacy Write/WriteOneByte/WriteUtf8 +// APIs (crbug.com/373485796), so it can no longer be checked against the real headers. +// The replacement V2 write APIs take String::WriteFlags. +ASSERT_V8_ENUM_MATCHES(String::WriteFlags, kNone) +ASSERT_V8_ENUM_MATCHES(String::WriteFlags, kNullTerminate) +ASSERT_V8_ENUM_MATCHES(String::WriteFlags, kReplaceInvalidUtf8) using JSC::JSString; @@ -189,6 +190,140 @@ int String::WriteUtf8(Isolate* isolate, char* buffer, int length, int* nchars_re return written; } +void String::WriteV2(Isolate* isolate, uint32_t offset, uint32_t length, uint16_t* buffer, int flags) const +{ + auto jsString = localToObjectPointer(); + RELEASE_ASSERT(static_cast(offset) + length <= jsString->length()); + if (length > 0) { + auto str = jsString->view(isolate->globalObject()); + if (str->is8Bit()) { + WTF::copyElements(std::span(buffer, length), str->span8().subspan(offset, length)); + } else { + memcpy(buffer, str->span16().subspan(offset, length).data(), static_cast(length) * sizeof(uint16_t)); + } + } + if (flags & WriteFlags::kNullTerminate) { + buffer[length] = 0; + } +} + +void String::WriteOneByteV2(Isolate* isolate, uint32_t offset, uint32_t length, uint8_t* buffer, int flags) const +{ + auto jsString = localToObjectPointer(); + RELEASE_ASSERT(static_cast(offset) + length <= jsString->length()); + if (length > 0) { + auto str = jsString->view(isolate->globalObject()); + if (str->is8Bit()) { + memcpy(buffer, str->span8().subspan(offset, length).data(), length); + } else { + // like V8, only the least significant byte of each code unit is written + WTF::copyElements(std::span(buffer, length), str->span16().subspan(offset, length)); + } + } + if (flags & WriteFlags::kNullTerminate) { + buffer[length] = 0; + } +} + +size_t String::WriteUtf8V2(Isolate* isolate, char* buffer, size_t capacity, int flags, size_t* processed_characters_return) const +{ + auto jsString = localToObjectPointer(); + auto str = jsString->view(isolate->globalObject()); + + size_t writableCapacity = capacity; + if (flags & WriteFlags::kNullTerminate) { + RELEASE_ASSERT(capacity >= 1); + writableCapacity--; + } + + size_t read = 0; + size_t written = 0; + if (!str->isEmpty()) { + // TextEncoder__encodeInto never writes partial UTF-8 sequences, and replaces + // unpaired surrogates with U+FFFD (same byte length as the WTF-8 encoding V8 + // uses when kReplaceInvalidUtf8 is not set, so the result size matches either + // way). + if (str->is8Bit()) { + // Latin-1 expands at most 2x: 2 * (2^31 - 1) < 2^32, so the packed + // 32-bit counts cannot wrap. + const auto span = str->span8(); + uint64_t result = TextEncoder__encodeInto8(span.data(), span.size(), buffer, writableCapacity); + read = static_cast(result); + written = static_cast(result >> 32); + } else { + // UTF-16 expands up to 3x, which can exceed the 32-bit counts + // TextEncoder__encodeInto packs its result into (3 * (2^31 - 1) > + // 2^32). Encode in chunks small enough that each chunk's counts + // fit, accumulating in size_t. + const auto span = str->span16(); + const size_t total = span.size(); + constexpr size_t maxChunk = static_cast(1) << 30; // <= 3 GiB UTF-8 per chunk + while (read < total) { + size_t chunkLength = std::min(maxChunk, total - read); + // Never split a surrogate pair across chunks: the encoder + // would see two unpaired halves and write U+FFFD twice. + if (read + chunkLength < total && U16_IS_LEAD(span[read + chunkLength - 1])) { + chunkLength--; + } + uint64_t result = TextEncoder__encodeInto16(span.data() + read, chunkLength, buffer + written, writableCapacity - written); + const uint32_t chunkRead = static_cast(result); + const uint32_t chunkWritten = static_cast(result >> 32); + read += chunkRead; + written += chunkWritten; + if (chunkRead < chunkLength) { + // Ran out of output capacity. + break; + } + } + } + } + + if (processed_characters_return) { + *processed_characters_return = read; + } + if (flags & WriteFlags::kNullTerminate) { + buffer[written] = '\0'; + written++; + } + return written; +} + +size_t String::Utf8LengthV2(Isolate* isolate) const +{ + auto jsString = localToObjectPointer(); + if (jsString->length() == 0) { + return 0; + } + + auto str = jsString->view(isolate->globalObject()); + if (str->is8Bit()) { + const auto span = str->span8(); + return simdutf::utf8_length_from_latin1(reinterpret_cast(span.data()), span.size()); + } + + const auto span = str->span16(); + size_t len = simdutf::utf8_length_from_utf16(span.data(), span.size()); + // simdutf counts every surrogate code unit as 2 bytes, so a valid pair + // totals 4 (matching its UTF-8 encoding) but an unpaired surrogate only + // counts 2. V8 replaces each unpaired surrogate with U+FFFD, which + // encodes as 3 bytes (the same size WriteUtf8V2's replacement behavior + // produces), so add one byte for each unpaired surrogate code unit. + // Valid UTF-16 (the overwhelmingly common case) needs no adjustment; + // check with SIMD before falling back to the scalar surrogate count. + if (simdutf::validate_utf16(span.data(), span.size())) { + return len; + } + for (size_t i = 0; i < span.size(); i++) { + const char16_t c = span[i]; + if (U16_IS_LEAD(c) && i + 1 < span.size() && U16_IS_TRAIL(span[i + 1])) { + i++; + } else if (U16_IS_SURROGATE(c)) { + len++; + } + } + return len; +} + int String::Length() const { auto jsString = localToObjectPointer(); diff --git a/src/jsc/bindings/v8/V8String.h b/src/jsc/bindings/v8/V8String.h index 6c0ff9eed64..bddf0d6f6ac 100644 --- a/src/jsc/bindings/v8/V8String.h +++ b/src/jsc/bindings/v8/V8String.h @@ -14,6 +14,8 @@ enum class NewStringType { class String : Primitive { public: + // V8 14 removed WriteOptions and the legacy Write/WriteOneByte/WriteUtf8 APIs + // (crbug.com/373485796). Kept for addons compiled against older Node headers. enum WriteOptions { NO_OPTIONS = 0, HINT_MANY_WRITES_EXPECTED = 1, @@ -22,6 +24,14 @@ class String : Primitive { REPLACE_INVALID_UTF8 = 8, }; + struct WriteFlags { + enum { + kNone = 0, + kNullTerminate = 1, + kReplaceInvalidUtf8 = 2, + }; + }; + BUN_EXPORT static MaybeLocal NewFromUtf8(Isolate* isolate, char const* data, NewStringType type, int length = -1); BUN_EXPORT static MaybeLocal NewFromOneByte(Isolate* isolate, const uint8_t* data, NewStringType type, int length); @@ -32,6 +42,30 @@ class String : Primitive { // if string ends in a surrogate pair, but buffer is one byte too small to store it, instead // endcode the unpaired lead surrogate with WTF-8 BUN_EXPORT int WriteUtf8(Isolate* isolate, char* buffer, int length = -1, int* nchars_ref = nullptr, int options = NO_OPTIONS) const; + + /** + * Write the contents of the string to an external buffer. + * + * Copies length characters into the output buffer starting at offset. The + * output buffer must have sufficient space for all characters and the null + * terminator if null termination is requested through the flags. + */ + BUN_EXPORT void WriteV2(Isolate* isolate, uint32_t offset, uint32_t length, uint16_t* buffer, int flags = WriteFlags::kNone) const; + BUN_EXPORT void WriteOneByteV2(Isolate* isolate, uint32_t offset, uint32_t length, uint8_t* buffer, int flags = WriteFlags::kNone) const; + + /** + * Encode the contents of the string as Utf8 into an external buffer. + * + * Encodes the characters of this string as Utf8 and writes them into the + * output buffer until either all characters were encoded or the buffer is + * full. Will not write partial UTF-8 sequences, preferring to stop before + * the end of the buffer. If null termination is requested, the output + * buffer will always be null terminated even if not all characters fit. In + * that case, the capacity must be at least one. Returns the number of + * bytes copied to the buffer including the null terminator (if written). + */ + BUN_EXPORT size_t WriteUtf8V2(Isolate* isolate, char* buffer, size_t capacity, int flags = WriteFlags::kNone, size_t* processed_characters_return = nullptr) const; + BUN_EXPORT int Length() const; /** @@ -40,6 +74,13 @@ class String : Primitive { */ BUN_EXPORT int Utf8Length(Isolate* isolate) const; + /** + * Returns the number of bytes needed for the Utf8 encoding of this string. + * Unpaired surrogates are counted as the 3-byte U+FFFD replacement + * character, matching the Write*V2 replacement behavior. + */ + BUN_EXPORT size_t Utf8LengthV2(Isolate* isolate) const; + /** * Returns whether this string is known to contain only one byte data, * i.e. ISO-8859-1 code points. diff --git a/src/jsc/bindings/v8/shim/FunctionTemplate.cpp b/src/jsc/bindings/v8/shim/FunctionTemplate.cpp index 6de66fabebf..baa7de3f3c0 100644 --- a/src/jsc/bindings/v8/shim/FunctionTemplate.cpp +++ b/src/jsc/bindings/v8/shim/FunctionTemplate.cpp @@ -62,8 +62,6 @@ JSC::EncodedJSValue FunctionTemplate::functionCall(JSC::JSGlobalObject* globalOb auto* isolate = uncheckedDowncast(globalObject)->V8GlobalInternals()->isolate(); auto& vm = JSC::getVM(globalObject); - WTF::Vector args(callFrame->argumentCount() + 1); - HandleScope hs(isolate); // V8 function calls always run in "sloppy mode," even if the JS side is in strict mode. So if @@ -75,36 +73,58 @@ JSC::EncodedJSValue FunctionTemplate::functionCall(JSC::JSGlobalObject* globalOb jscThis = callFrame->thisValue().toObject(globalObject); } Local thisObject = hs.createLocal(vm, jscThis); - args[0] = thisObject.tagged(); - - for (size_t i = 0; i < callFrame->argumentCount(); i++) { - Local argValue = hs.createLocal(vm, callFrame->argument(i)); - args[i + 1] = argValue.tagged(); - } // In V8, the target is the function being called Local target = hs.createLocal(vm, callee); - ImplicitArgs implicit_args = { - .unused = nullptr, - .isolate = isolate, - // Context is always a reinterpret pointer to Zig::GlobalObject - .context = reinterpret_cast(globalObject), - .return_value = TaggedPointer(), - // target holds the Function being called, which contains the FunctionTemplate - .target = target.tagged(), - .new_target = nullptr, + // Build a synthetic ApiCallbackExitFrame: one contiguous array of + // pointer-sized slots that V8's inline FunctionCallbackInfo accessors index + // relative to the argc slot. The view starts one slot into the array so + // that kNewTargetIndex (-1) stays in bounds. + using Info = FunctionCallbackInfo; + constexpr size_t viewOffset = 1; + const size_t argc = callFrame->argumentCount(); + WTF::Vector frame(viewOffset + Info::kFirstJSArgumentIndex + argc); + auto slot = [&](ptrdiff_t index) -> TaggedPointer& { + return frame[viewOffset + index]; }; - FunctionCallbackInfo info(&implicit_args, args.begin() + 1, callFrame->argumentCount()); + // Bun never reports a construct call here, so V8's NewTarget() always + // returns undefined without reading this slot + slot(Info::kNewTargetIndex) = TaggedPointer(); + // Length() reads this as a raw integer, not a Smi + slot(Info::kArgcIndex) = TaggedPointer::fromRaw(argc); + // SP/FP/PC are only used by V8's stack walker, which never sees this frame + slot(Info::kFrameSPIndex) = TaggedPointer::fromRaw(0); + // IsConstructCall() compares this Smi against kFrameTypeApiConstructExit + slot(Info::kFrameTypeIndex) = TaggedPointer(Info::kFrameTypeApiCallExit); + slot(Info::kFrameFPIndex) = TaggedPointer::fromRaw(0); + slot(Info::kFramePCIndex) = TaggedPointer::fromRaw(0); + // GetIsolate() reads this slot as a raw, untagged pointer + slot(Info::kIsolateIndex) = TaggedPointer::fromRaw(reinterpret_cast(isolate)); + slot(Info::kReturnValueIndex) = TaggedPointer(); + // Context is always a reinterpret pointer to Zig::GlobalObject + slot(Info::kContextIndex) = TaggedPointer::fromRaw(reinterpret_cast(globalObject)); + // target holds the Function being called, which contains the FunctionTemplate + slot(Info::kTargetIndex) = target.tagged(); + slot(Info::kReceiverIndex) = thisObject.tagged(); + + for (size_t i = 0; i < argc; i++) { + Local argValue = hs.createLocal(vm, callFrame->argument(i)); + slot(Info::kFirstJSArgumentIndex + i) = argValue.tagged(); + } + + // The FunctionCallbackInfo object is a view located at the argc slot + const auto& info = *reinterpret_cast(&slot(Info::kArgcIndex)); functionTemplate->m_callback(info); - if (implicit_args.return_value.isEmpty()) { + TaggedPointer& return_value = slot(Info::kReturnValueIndex); + if (return_value.isEmpty()) { // callback forgot to set a return value, so return undefined return JSValue::encode(JSC::jsUndefined()); } else { - Local local_ret(&implicit_args.return_value); + Local local_ret(&return_value); return JSValue::encode(local_ret->localToJSValue()); } } diff --git a/src/jsc/bindings/v8/shim/Handle.h b/src/jsc/bindings/v8/shim/Handle.h index 9164adce3cc..66716c9fd53 100644 --- a/src/jsc/bindings/v8/shim/Handle.h +++ b/src/jsc/bindings/v8/shim/Handle.h @@ -78,6 +78,12 @@ struct Handle { if (m_toV8Object.tag() == TaggedPointer::Tag::Smi) { return false; } + if (m_toV8Object.getPtr() != &m_object) { + // This slot was written directly by V8's inline CreateHandle code (see + // HandleScope::Extend): it aliases an ObjectLayout owned by some other handle (or an + // oddball/root), and that owner is the one responsible for keeping the cell alive. + return false; + } const Map* map_ptr = m_object.map(); // TODO(@190n) exhaustively switch on InstanceType if (map_ptr == &Map::object_map() || map_ptr == &Map::string_map()) { diff --git a/src/jsc/bindings/v8/shim/HandleScopeBuffer.cpp b/src/jsc/bindings/v8/shim/HandleScopeBuffer.cpp index cea327117c7..a992e843262 100644 --- a/src/jsc/bindings/v8/shim/HandleScopeBuffer.cpp +++ b/src/jsc/bindings/v8/shim/HandleScopeBuffer.cpp @@ -69,6 +69,34 @@ TaggedPointer* HandleScopeBuffer::createDoubleHandle(double value) return handle.slot(); } +TaggedPointer* HandleScopeBuffer::createRawHandleSlot() +{ + auto& handle = createEmptyHandle(); + TaggedPointer* slot = handle.slot(); + { + WTF::Locker locker { m_gcLock }; + m_rawGrants.append({ slot, m_storage.size() - 1 }); + } + return slot; +} + +void HandleScopeBuffer::deleteGrantsBack(const uintptr_t* limit) +{ + WTF::Locker locker { m_gcLock }; + // Pop grants (and every handle created after each, which V8 semantics also + // scope to the closing inline HandleScope) until the newest remaining grant + // is the one the restored limit points one past — i.e. the last grant made + // before the closing scope opened. A null/foreign limit pops all grants. + while (!m_rawGrants.isEmpty() && m_rawGrants.last().first->asRawPtrLocation() + 1 != limit) { + size_t position = m_rawGrants.last().second; + m_rawGrants.removeLast(); + while (m_storage.size() > position) { + m_storage.last() = Handle(); + m_storage.removeLast(); + } + } +} + TaggedPointer* HandleScopeBuffer::createHandleFromExistingObject(TaggedPointer address, Isolate* isolate, Handle* reuseHandle) { int32_t smi; @@ -115,6 +143,7 @@ void HandleScopeBuffer::clear() handle = Handle(); } m_storage.clear(); + m_rawGrants.clear(); } } // namespace shim diff --git a/src/jsc/bindings/v8/shim/HandleScopeBuffer.h b/src/jsc/bindings/v8/shim/HandleScopeBuffer.h index f30c535c231..269a9aab295 100644 --- a/src/jsc/bindings/v8/shim/HandleScopeBuffer.h +++ b/src/jsc/bindings/v8/shim/HandleScopeBuffer.h @@ -43,6 +43,19 @@ class HandleScopeBuffer : public JSC::JSCell { TaggedPointer* createSmiHandle(int32_t smi); TaggedPointer* createDoubleHandle(double value); + // Reserve a slot whose value will be written directly by V8's inline CreateHandle code after + // HandleScope::Extend returns it. The written value is either a Smi or a pointer to an + // ObjectLayout owned by some other handle, so the handle backing this slot does not own (or + // visit) anything itself (see Handle::isCell). + TaggedPointer* createRawHandleSlot(); + + // Free every handle created after the raw slot whose address + 1 equals `limit` (the + // HandleScopeData::limit value V8's inline ~HandleScope just restored). Called from + // HandleScope::DeleteExtensions so per-iteration inline v8::HandleScopes inside a single + // native call reclaim their handles instead of accumulating until the enclosing Bun scope + // closes. + void deleteGrantsBack(const uintptr_t* limit); + // Given a tagged pointer from V8, create a handle around the same object or the same // numeric value // @@ -62,6 +75,12 @@ class HandleScopeBuffer : public JSC::JSCell { private: WTF::Lock m_gcLock; WTF::SegmentedVector m_storage; + // (slot, index in m_storage) for every createRawHandleSlot grant, in creation order. + // No inline capacity: in-cell inline Vector storage would leave stale ASAN + // container annotations behind (this cell type is swept without running + // C++ destructors), tripping container-overflow on cell reuse. The heap + // buffer is released in clear(). + WTF::Vector> m_rawGrants; Handle& createEmptyHandle(); diff --git a/src/runtime/napi/napi_body.rs b/src/runtime/napi/napi_body.rs index 772096e7c2b..353b99858a2 100644 --- a/src/runtime/napi/napi_body.rs +++ b/src/runtime/napi/napi_body.rs @@ -2981,6 +2981,8 @@ mod v8_api { pub(super) fn _ZN4node28RemoveEnvironmentCleanupHookEPN2v87IsolateEPFvPvES3_() -> *mut c_void; pub(super) fn _ZN2v86Number3NewEPNS_7IsolateEd() -> *mut c_void; pub(super) fn _ZNK2v86Number5ValueEv() -> *mut c_void; + pub(super) fn _ZN2v86Number12NewFromInt32EPNS_7IsolateEi() -> *mut c_void; + pub(super) fn _ZN2v86Number13NewFromUint32EPNS_7IsolateEj() -> *mut c_void; pub(super) fn _ZN2v86String11NewFromUtf8EPNS_7IsolateEPKcNS_13NewStringTypeEi() -> *mut c_void; pub(super) fn _ZNK2v86String9WriteUtf8EPNS_7IsolateEPciPii() -> *mut c_void; @@ -2988,6 +2990,8 @@ mod v8_api { pub(super) fn _ZNK2v86String6LengthEv() -> *mut c_void; pub(super) fn _ZN2v88External3NewEPNS_7IsolateEPv() -> *mut c_void; pub(super) fn _ZNK2v88External5ValueEv() -> *mut c_void; + pub(super) fn _ZN2v88External3NewEPNS_7IsolateEPvt() -> *mut c_void; + pub(super) fn _ZNK2v88External5ValueEt() -> *mut c_void; pub(super) fn _ZN2v86Object3NewEPNS_7IsolateE() -> *mut c_void; pub(super) fn _ZN2v86Object3SetENS_5LocalINS_7ContextEEENS1_INS_5ValueEEES5_() -> *mut c_void; pub(super) fn _ZN2v86Object3SetENS_5LocalINS_7ContextEEEjNS1_INS_5ValueEEE() -> *mut c_void; @@ -2996,6 +3000,8 @@ mod v8_api { pub(super) fn _ZN2v86Object3GetENS_5LocalINS_7ContextEEENS1_INS_5ValueEEE() -> *mut c_void; pub(super) fn _ZN2v86Object3GetENS_5LocalINS_7ContextEEEj() -> *mut c_void; pub(super) fn _ZN2v811HandleScope12CreateHandleEPNS_8internal7IsolateEm() -> *mut c_void; + pub(super) fn _ZN2v811HandleScope6ExtendEPNS_7IsolateE() -> *mut c_void; + pub(super) fn _ZN2v811HandleScope16DeleteExtensionsEPNS_7IsolateE() -> *mut c_void; pub(super) fn _ZN2v811HandleScopeC1EPNS_7IsolateE() -> *mut c_void; pub(super) fn _ZN2v811HandleScopeD1Ev() -> *mut c_void; pub(super) fn _ZN2v811HandleScopeD2Ev() -> *mut c_void; @@ -3047,6 +3053,10 @@ mod v8_api { pub(super) fn _ZNK2v86String17IsExternalTwoByteEv() -> *mut c_void; pub(super) fn _ZNK2v86String9IsOneByteEv() -> *mut c_void; pub(super) fn _ZNK2v86String19ContainsOnlyOneByteEv() -> *mut c_void; + pub(super) fn _ZNK2v86String7WriteV2EPNS_7IsolateEjjPti() -> *mut c_void; + pub(super) fn _ZNK2v86String14WriteOneByteV2EPNS_7IsolateEjjPhi() -> *mut c_void; + pub(super) fn _ZNK2v86String11WriteUtf8V2EPNS_7IsolateEPcmiPm() -> *mut c_void; + pub(super) fn _ZNK2v86String12Utf8LengthV2EPNS_7IsolateE() -> *mut c_void; pub(super) fn _ZN2v812api_internal18GlobalizeReferenceEPNS_8internal7IsolateEm() -> *mut c_void; pub(super) fn _ZN2v812api_internal13DisposeGlobalEPm() -> *mut c_void; @@ -3095,6 +3105,10 @@ mod v8_api { pub(super) fn v8_Number_New() -> *mut c_void; #[link_name = "?Value@Number@v8@@QEBANXZ"] pub(super) fn v8_Number_Value() -> *mut c_void; + #[link_name = "?NewFromInt32@Number@v8@@CA?AV?$Local@VNumber@v8@@@2@PEAVIsolate@2@H@Z"] + pub(super) fn v8_Number_NewFromInt32() -> *mut c_void; + #[link_name = "?NewFromUint32@Number@v8@@CA?AV?$Local@VNumber@v8@@@2@PEAVIsolate@2@I@Z"] + pub(super) fn v8_Number_NewFromUint32() -> *mut c_void; #[link_name = "?NewFromUtf8@String@v8@@SA?AV?$MaybeLocal@VString@v8@@@2@PEAVIsolate@2@PEBDW4NewStringType@2@H@Z"] pub(super) fn v8_String_NewFromUtf8() -> *mut c_void; #[link_name = "?WriteUtf8@String@v8@@QEBAHPEAVIsolate@2@PEADHPEAHH@Z"] @@ -3107,6 +3121,10 @@ mod v8_api { pub(super) fn v8_External_New() -> *mut c_void; #[link_name = "?Value@External@v8@@QEBAPEAXXZ"] pub(super) fn v8_External_Value() -> *mut c_void; + #[link_name = "?New@External@v8@@SA?AV?$Local@VExternal@v8@@@2@PEAVIsolate@2@PEAXG@Z"] + pub(super) fn v8_External_New_tagged() -> *mut c_void; + #[link_name = "?Value@External@v8@@QEBAPEAXG@Z"] + pub(super) fn v8_External_Value_tagged() -> *mut c_void; #[link_name = "?New@Object@v8@@SA?AV?$Local@VObject@v8@@@2@PEAVIsolate@2@@Z"] pub(super) fn v8_Object_New() -> *mut c_void; #[link_name = "?Set@Object@v8@@QEAA?AV?$Maybe@_N@2@V?$Local@VContext@v8@@@2@V?$Local@VValue@v8@@@2@1@Z"] @@ -3123,6 +3141,10 @@ mod v8_api { pub(super) fn v8_Object_Get_key() -> *mut c_void; #[link_name = "?CreateHandle@HandleScope@v8@@KAPEA_KPEAVIsolate@internal@2@_K@Z"] pub(super) fn v8_HandleScope_CreateHandle() -> *mut c_void; + #[link_name = "?Extend@HandleScope@v8@@CAPEA_KPEAVIsolate@2@@Z"] + pub(super) fn v8_HandleScope_Extend() -> *mut c_void; + #[link_name = "?DeleteExtensions@HandleScope@v8@@AEAAXPEAVIsolate@2@@Z"] + pub(super) fn v8_HandleScope_DeleteExtensions() -> *mut c_void; #[link_name = "??0HandleScope@v8@@QEAA@PEAVIsolate@1@@Z"] pub(super) fn v8_HandleScope_ctor() -> *mut c_void; #[link_name = "??1HandleScope@v8@@QEAA@XZ"] @@ -3213,6 +3235,14 @@ mod v8_api { pub(super) fn v8_String_Utf8Length() -> *mut c_void; #[link_name = "?ContainsOnlyOneByte@String@v8@@QEBA_NXZ"] pub(super) fn v8_String_ContainsOnlyOneByte() -> *mut c_void; + #[link_name = "?WriteV2@String@v8@@QEBAXPEAVIsolate@2@IIPEAGH@Z"] + pub(super) fn v8_String_WriteV2() -> *mut c_void; + #[link_name = "?WriteOneByteV2@String@v8@@QEBAXPEAVIsolate@2@IIPEAEH@Z"] + pub(super) fn v8_String_WriteOneByteV2() -> *mut c_void; + #[link_name = "?WriteUtf8V2@String@v8@@QEBA_KPEAVIsolate@2@PEAD_KHPEA_K@Z"] + pub(super) fn v8_String_WriteUtf8V2() -> *mut c_void; + #[link_name = "?Utf8LengthV2@String@v8@@QEBA_KPEAVIsolate@2@@Z"] + pub(super) fn v8_String_Utf8LengthV2() -> *mut c_void; #[link_name = "?GlobalizeReference@api_internal@v8@@YAPEA_KPEAVIsolate@internal@2@_K@Z"] pub(super) fn v8_api_internal_GlobalizeReference() -> *mut c_void; #[link_name = "?DisposeGlobal@api_internal@v8@@YAXPEA_K@Z"] @@ -4091,10 +4121,13 @@ pub fn fix_dead_code_elimination() { _ZN4node25AddEnvironmentCleanupHookEPN2v87IsolateEPFvPvES3_, _ZN4node28RemoveEnvironmentCleanupHookEPN2v87IsolateEPFvPvES3_, _ZN2v86Number3NewEPNS_7IsolateEd, _ZNK2v86Number5ValueEv, + _ZN2v86Number12NewFromInt32EPNS_7IsolateEi, + _ZN2v86Number13NewFromUint32EPNS_7IsolateEj, _ZN2v86String11NewFromUtf8EPNS_7IsolateEPKcNS_13NewStringTypeEi, _ZNK2v86String9WriteUtf8EPNS_7IsolateEPciPii, _ZN2v812api_internal12ToLocalEmptyEv, _ZNK2v86String6LengthEv, _ZN2v88External3NewEPNS_7IsolateEPv, _ZNK2v88External5ValueEv, _ZN2v86Object3NewEPNS_7IsolateE, + _ZN2v88External3NewEPNS_7IsolateEPvt, _ZNK2v88External5ValueEt, _ZN2v86Object3SetENS_5LocalINS_7ContextEEENS1_INS_5ValueEEES5_, _ZN2v86Object3SetENS_5LocalINS_7ContextEEEjNS1_INS_5ValueEEE, _ZN2v86Object16SetInternalFieldEiNS_5LocalINS_4DataEEE, @@ -4102,6 +4135,8 @@ pub fn fix_dead_code_elimination() { _ZN2v86Object3GetENS_5LocalINS_7ContextEEENS1_INS_5ValueEEE, _ZN2v86Object3GetENS_5LocalINS_7ContextEEEj, _ZN2v811HandleScope12CreateHandleEPNS_8internal7IsolateEm, + _ZN2v811HandleScope6ExtendEPNS_7IsolateE, + _ZN2v811HandleScope16DeleteExtensionsEPNS_7IsolateE, _ZN2v811HandleScopeC1EPNS_7IsolateE, _ZN2v811HandleScopeD1Ev, _ZN2v811HandleScopeD2Ev, _ZN2v816FunctionTemplate11GetFunctionENS_5LocalINS_7ContextEEE, @@ -4132,6 +4167,10 @@ pub fn fix_dead_code_elimination() { _ZNK2v86String10Utf8LengthEPNS_7IsolateE, _ZNK2v86String10IsExternalEv, _ZNK2v86String17IsExternalOneByteEv, _ZNK2v86String17IsExternalTwoByteEv, _ZNK2v86String9IsOneByteEv, _ZNK2v86String19ContainsOnlyOneByteEv, + _ZNK2v86String7WriteV2EPNS_7IsolateEjjPti, + _ZNK2v86String14WriteOneByteV2EPNS_7IsolateEjjPhi, + _ZNK2v86String11WriteUtf8V2EPNS_7IsolateEPcmiPm, + _ZNK2v86String12Utf8LengthV2EPNS_7IsolateE, _ZN2v812api_internal18GlobalizeReferenceEPNS_8internal7IsolateEm, _ZN2v812api_internal13DisposeGlobalEPm, _ZN2v812api_internal23GetFunctionTemplateDataEPNS_7IsolateENS_5LocalINS_4DataEEE, @@ -4151,12 +4190,16 @@ pub fn fix_dead_code_elimination() { node_RemoveEnvironmentCleanupHook, v8_Number_New, v8_Number_Value, + v8_Number_NewFromInt32, + v8_Number_NewFromUint32, v8_String_NewFromUtf8, v8_String_WriteUtf8, v8_api_internal_ToLocalEmpty, v8_String_Length, v8_External_New, v8_External_Value, + v8_External_New_tagged, + v8_External_Value_tagged, v8_Object_New, v8_Object_Set_key, v8_Object_Set_index, @@ -4165,6 +4208,8 @@ pub fn fix_dead_code_elimination() { v8_Object_Get_index, v8_Object_Get_key, v8_HandleScope_CreateHandle, + v8_HandleScope_Extend, + v8_HandleScope_DeleteExtensions, v8_HandleScope_ctor, v8_HandleScope_dtor, v8_FunctionTemplate_GetFunction, @@ -4210,6 +4255,10 @@ pub fn fix_dead_code_elimination() { v8_String_IsOneByte, v8_String_Utf8Length, v8_String_ContainsOnlyOneByte, + v8_String_WriteV2, + v8_String_WriteOneByteV2, + v8_String_WriteUtf8V2, + v8_String_Utf8LengthV2, v8_api_internal_GlobalizeReference, v8_api_internal_DisposeGlobal, v8_api_internal_GetFunctionTemplateData, diff --git a/src/symbols.def b/src/symbols.def index dc727488e86..6205f32ae00 100644 --- a/src/symbols.def +++ b/src/symbols.def @@ -580,12 +580,20 @@ EXPORTS ?RemoveEnvironmentCleanupHook@node@@YAXPEAVIsolate@v8@@P6AXPEAX@Z1@Z ?New@Number@v8@@SA?AV?$Local@VNumber@v8@@@2@PEAVIsolate@2@N@Z ?Value@Number@v8@@QEBANXZ + ?NewFromInt32@Number@v8@@CA?AV?$Local@VNumber@v8@@@2@PEAVIsolate@2@H@Z + ?NewFromUint32@Number@v8@@CA?AV?$Local@VNumber@v8@@@2@PEAVIsolate@2@I@Z ?NewFromUtf8@String@v8@@SA?AV?$MaybeLocal@VString@v8@@@2@PEAVIsolate@2@PEBDW4NewStringType@2@H@Z ?WriteUtf8@String@v8@@QEBAHPEAVIsolate@2@PEADHPEAHH@Z + ?WriteV2@String@v8@@QEBAXPEAVIsolate@2@IIPEAGH@Z + ?WriteOneByteV2@String@v8@@QEBAXPEAVIsolate@2@IIPEAEH@Z + ?WriteUtf8V2@String@v8@@QEBA_KPEAVIsolate@2@PEAD_KHPEA_K@Z + ?Utf8LengthV2@String@v8@@QEBA_KPEAVIsolate@2@@Z ?ToLocalEmpty@api_internal@v8@@YAXXZ ?Length@String@v8@@QEBAHXZ ?New@External@v8@@SA?AV?$Local@VExternal@v8@@@2@PEAVIsolate@2@PEAX@Z ?Value@External@v8@@QEBAPEAXXZ + ?New@External@v8@@SA?AV?$Local@VExternal@v8@@@2@PEAVIsolate@2@PEAXG@Z + ?Value@External@v8@@QEBAPEAXG@Z ?New@Object@v8@@SA?AV?$Local@VObject@v8@@@2@PEAVIsolate@2@@Z ?Set@Object@v8@@QEAA?AV?$Maybe@_N@2@V?$Local@VContext@v8@@@2@V?$Local@VValue@v8@@@2@1@Z ?Set@Object@v8@@QEAA?AV?$Maybe@_N@2@V?$Local@VContext@v8@@@2@IV?$Local@VValue@v8@@@2@@Z @@ -594,6 +602,8 @@ EXPORTS ?SetInternalField@Object@v8@@QEAAXHV?$Local@VData@v8@@@2@@Z ?SlowGetInternalField@Object@v8@@AEAA?AV?$Local@VData@v8@@@2@H@Z ?CreateHandle@HandleScope@v8@@KAPEA_KPEAVIsolate@internal@2@_K@Z + ?Extend@HandleScope@v8@@CAPEA_KPEAVIsolate@2@@Z + ?DeleteExtensions@HandleScope@v8@@AEAAXPEAVIsolate@2@@Z ??0HandleScope@v8@@QEAA@PEAVIsolate@1@@Z ??1HandleScope@v8@@QEAA@XZ ?GetFunction@FunctionTemplate@v8@@QEAA?AV?$MaybeLocal@VFunction@v8@@@2@V?$Local@VContext@v8@@@2@@Z diff --git a/src/symbols.dyn b/src/symbols.dyn index b44c02651bd..93483288ec9 100644 --- a/src/symbols.dyn +++ b/src/symbols.dyn @@ -1,5 +1,7 @@ { __ZN2v811HandleScope12CreateHandleEPNS_8internal7IsolateEm; + __ZN2v811HandleScope16DeleteExtensionsEPNS_7IsolateE; + __ZN2v811HandleScope6ExtendEPNS_7IsolateE; __ZN2v811HandleScopeC1EPNS_7IsolateE; __ZN2v811HandleScopeD1Ev; __ZN2v811HandleScopeD2Ev; @@ -25,6 +27,8 @@ __ZN2v85Array3NewENS_5LocalINS_7ContextEEEmSt8functionIFNS_10MaybeLocalINS_5ValueEEEvEE; __ZN2v85Array7IterateENS_5LocalINS_7ContextEEEPFNS0_14CallbackResultEjNS1_INS_5ValueEEEPvES7_; __ZN2v85Array9CheckCastEPNS_5ValueE; + __ZN2v86Number12NewFromInt32EPNS_7IsolateEi; + __ZN2v86Number13NewFromUint32EPNS_7IsolateEj; __ZN2v86Number3NewEPNS_7IsolateEd; __ZN2v86Object16GetInternalFieldEi; __ZN2v86Object16SetInternalFieldEiNS_5LocalINS_4DataEEE; @@ -42,6 +46,7 @@ __ZN2v87Isolate13TryGetCurrentEv; __ZN2v87Isolate17GetCurrentContextEv; __ZN2v88External3NewEPNS_7IsolateEPv; + __ZN2v88External3NewEPNS_7IsolateEPvt; __ZN2v88Function7SetNameENS_5LocalINS_6StringEEE; __ZN2v88internal35IsolateFromNeverReadOnlySpaceObjectEm; __ZN3JSC9CallFrame13describeFrameEv; @@ -70,13 +75,18 @@ __ZNK2v86Number5ValueEv; __ZNK2v86String10IsExternalEv; __ZNK2v86String10Utf8LengthEPNS_7IsolateE; + __ZNK2v86String11WriteUtf8V2EPNS_7IsolateEPcmiPm; + __ZNK2v86String12Utf8LengthV2EPNS_7IsolateE; + __ZNK2v86String14WriteOneByteV2EPNS_7IsolateEjjPhi; __ZNK2v86String17IsExternalOneByteEv; __ZNK2v86String17IsExternalTwoByteEv; __ZNK2v86String19ContainsOnlyOneByteEv; __ZNK2v86String6LengthEv; + __ZNK2v86String7WriteV2EPNS_7IsolateEjjPti; __ZNK2v86String9IsOneByteEv; __ZNK2v86String9WriteUtf8EPNS_7IsolateEPciPii; __ZNK2v87Boolean5ValueEv; + __ZNK2v88External5ValueEt; __ZNK2v88External5ValueEv; __ZNK2v88Function7GetNameEv; _dumpBtjsTrace; diff --git a/src/symbols.txt b/src/symbols.txt index 462b099dd07..2a617b63302 100644 --- a/src/symbols.txt +++ b/src/symbols.txt @@ -1,4 +1,6 @@ __ZN2v811HandleScope12CreateHandleEPNS_8internal7IsolateEm +__ZN2v811HandleScope16DeleteExtensionsEPNS_7IsolateE +__ZN2v811HandleScope6ExtendEPNS_7IsolateE __ZN2v811HandleScopeC1EPNS_7IsolateE __ZN2v811HandleScopeD1Ev __ZN2v811HandleScopeD2Ev @@ -24,6 +26,8 @@ __ZN2v85Array3NewEPNS_7IsolateEi __ZN2v85Array3NewENS_5LocalINS_7ContextEEEmNSt3__18functionIFNS_10MaybeLocalINS_5ValueEEEvEEE __ZN2v85Array7IterateENS_5LocalINS_7ContextEEEPFNS0_14CallbackResultEjNS1_INS_5ValueEEEPvES7_ __ZN2v85Array9CheckCastEPNS_5ValueE +__ZN2v86Number12NewFromInt32EPNS_7IsolateEi +__ZN2v86Number13NewFromUint32EPNS_7IsolateEj __ZN2v86Number3NewEPNS_7IsolateEd __ZN2v86Object16GetInternalFieldEi __ZN2v86Object16SetInternalFieldEiNS_5LocalINS_4DataEEE @@ -41,6 +45,7 @@ __ZN2v87Isolate10GetCurrentEv __ZN2v87Isolate13TryGetCurrentEv __ZN2v87Isolate17GetCurrentContextEv __ZN2v88External3NewEPNS_7IsolateEPv +__ZN2v88External3NewEPNS_7IsolateEPvt __ZN2v88Function7SetNameENS_5LocalINS_6StringEEE __ZN2v88internal35IsolateFromNeverReadOnlySpaceObjectEm __ZN3JSC9CallFrame13describeFrameEv @@ -69,13 +74,18 @@ __ZNK2v85Value12StrictEqualsENS_5LocalIS0_EE __ZNK2v86Number5ValueEv __ZNK2v86String10IsExternalEv __ZNK2v86String10Utf8LengthEPNS_7IsolateE +__ZNK2v86String11WriteUtf8V2EPNS_7IsolateEPcmiPm +__ZNK2v86String12Utf8LengthV2EPNS_7IsolateE +__ZNK2v86String14WriteOneByteV2EPNS_7IsolateEjjPhi __ZNK2v86String17IsExternalOneByteEv __ZNK2v86String17IsExternalTwoByteEv __ZNK2v86String19ContainsOnlyOneByteEv __ZNK2v86String6LengthEv +__ZNK2v86String7WriteV2EPNS_7IsolateEjjPti __ZNK2v86String9IsOneByteEv __ZNK2v86String9WriteUtf8EPNS_7IsolateEPciPii __ZNK2v87Boolean5ValueEv +__ZNK2v88External5ValueEt __ZNK2v88External5ValueEv __ZNK2v88Function7GetNameEv _dumpBtjsTrace diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index f8389d0401a..0a1804213ea 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -426,7 +426,7 @@ describe.concurrent(() => { }); let [out, exited] = await Promise.all([new Response(subprocess.stdout).text(), subprocess.exited]); - expect(out.trim()).toEqual("v24.3.0"); + expect(out.trim()).toEqual("v26.3.0"); expect(exited).toBe(0); }); @@ -1175,10 +1175,10 @@ it.each(["stdin", "stdout", "stderr"])("%s stream accessor should handle excepti }); it("process.versions", () => { - expect(process.versions.node).toEqual("24.3.0"); - expect(process.versions.v8).toEqual("13.6.233.10-node.18"); + expect(process.versions.node).toEqual("26.3.0"); + expect(process.versions.v8).toEqual("14.6.202.34-node.20"); expect(process.versions.napi).toEqual("10"); - expect(process.versions.modules).toEqual("137"); + expect(process.versions.modules).toEqual("147"); }); // On Windows, env var names are case-insensitive. The proxy-related vars diff --git a/test/napi/napi.test.ts b/test/napi/napi.test.ts index cdf548e5a72..284028e3e31 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -751,8 +751,11 @@ describe.concurrent("napi", () => { expect(bunStderr).toContain("FATAL ERROR"); expect(bunStdout + bunStderr).toContain("TEST PASSED: Process crashed as expected"); - // The error message should NOT contain "Did not crash" - expect(bunStdout + bunStderr).not.toContain("ERROR: Did not crash"); + // The marker must NOT have actually been printed. Only check stdout: the + // fixture prints the marker via console.log (stdout), while stderr contains + // the debug-build panic report whose "Args:" line echoes the full -e script + // source, including the literal "ERROR: Did not crash! Test failed!". + expect(bunStdout).not.toContain("ERROR: Did not crash"); }, 25_000, ); diff --git a/test/napi/node-napi-tests/harness.ts b/test/napi/node-napi-tests/harness.ts index 2befe7ed970..1f64717d6a4 100644 --- a/test/napi/node-napi-tests/harness.ts +++ b/test/napi/node-napi-tests/harness.ts @@ -14,7 +14,7 @@ export async function build(dir: string) { stdin: "inherit", env: { ...bunEnv, - npm_config_target: "v24.3.0", + npm_config_target: "v26.3.0", CXXFLAGS: (bunEnv.CXXFLAGS ?? "") + (process.platform == "win32" ? " -std=c++20" : " -std=gnu++20"), // on linux CI, node-gyp will default to g++ and the version installed there is very old, // so we make it use clang instead diff --git a/test/v8/bad-modules/mismatched_abi_version.cpp b/test/v8/bad-modules/mismatched_abi_version.cpp index 73b2e78c03f..96fc590ea01 100644 --- a/test/v8/bad-modules/mismatched_abi_version.cpp +++ b/test/v8/bad-modules/mismatched_abi_version.cpp @@ -8,7 +8,7 @@ void init(v8::Local exports, v8::Local module, extern "C" { static node::node_module _module = { - // bun expects 137 (Node.js 24.3.0) + // bun expects 147 (Node.js 26.3.0) 42, // nm_version 0, // nm_flags nullptr, // nm_dso_handle diff --git a/test/v8/bad-modules/no_entrypoint.cpp b/test/v8/bad-modules/no_entrypoint.cpp index 2ebb9ae7c45..fa3872f20ad 100644 --- a/test/v8/bad-modules/no_entrypoint.cpp +++ b/test/v8/bad-modules/no_entrypoint.cpp @@ -2,7 +2,7 @@ extern "C" { static node::node_module _module = { - 137, // nm_version (Node.js 24.3.0) + 147, // nm_version (Node.js 26.3.0) 0, // nm_flags nullptr, // nm_dso_handle "no_entrypoint.cpp", // nm_filename diff --git a/test/v8/v8-module/main.cpp b/test/v8/v8-module/main.cpp index 3cc8e33f0f7..2591fb9478c 100644 --- a/test/v8/v8-module/main.cpp +++ b/test/v8/v8-module/main.cpp @@ -45,15 +45,15 @@ static std::string describe(Isolate *isolate, Local value) { return "false"; } else if (value->IsString()) { char buf[1024] = {0}; - value.As()->WriteUtf8(isolate, buf, sizeof(buf) - 1); + value.As()->WriteUtf8V2(isolate, buf, sizeof(buf) - 1); std::string result = "\""; result += buf; result += "\""; return result; } else if (value->IsFunction()) { char buf[1024] = {0}; - value.As()->GetName().As()->WriteUtf8(isolate, buf, - sizeof(buf) - 1); + value.As()->GetName().As()->WriteUtf8V2( + isolate, buf, sizeof(buf) - 1); std::string result = "function "; result += buf; result += "()"; @@ -131,36 +131,45 @@ static void perform_string_test(const FunctionCallbackInfo &info, Local v8_string) { Isolate *isolate = info.GetIsolate(); char buf[256] = {0x7f}; - int retval; - int nchars; + size_t retval; + size_t nchars; LOG_VALUE_KIND(v8_string); LOG_EXPR(v8_string->Length()); - LOG_EXPR(v8_string->Utf8Length(isolate)); + LOG_EXPR(v8_string->Utf8LengthV2(isolate)); LOG_EXPR(v8_string->IsOneByte()); LOG_EXPR(v8_string->ContainsOnlyOneByte()); LOG_EXPR(v8_string->IsExternal()); LOG_EXPR(v8_string->IsExternalTwoByte()); LOG_EXPR(v8_string->IsExternalOneByte()); - // check string has the right contents - LOG_EXPR(retval = v8_string->WriteUtf8(isolate, buf, sizeof buf, &nchars)); + // check string has the right contents. The legacy WriteUtf8 null-terminated + // by default; with WriteUtf8V2 that behavior is requested explicitly via + // kNullTerminate so the buffer contents stay the same. + LOG_EXPR(retval = v8_string->WriteUtf8V2(isolate, buf, sizeof buf, + String::WriteFlags::kNullTerminate, + &nchars)); LOG_EXPR(nchars); - log_buffer(buf, retval + 1); + log_buffer(buf, static_cast(retval) + 1); memset(buf, 0x7f, sizeof buf); - // try with assuming the buffer is large enough - LOG_EXPR(retval = v8_string->WriteUtf8(isolate, buf, -1, &nchars)); + // legacy WriteUtf8 accepted length = -1 to assume the buffer is large + // enough; WriteUtf8V2 always takes an explicit capacity + LOG_EXPR(retval = v8_string->WriteUtf8V2(isolate, buf, sizeof buf, + String::WriteFlags::kNullTerminate, + &nchars)); LOG_EXPR(nchars); - log_buffer(buf, retval + 1); + log_buffer(buf, static_cast(retval) + 1); memset(buf, 0x7f, sizeof buf); // try with ignoring nchars (it should not try to store anything in a // nullptr) - LOG_EXPR(retval = v8_string->WriteUtf8(isolate, buf, sizeof buf, nullptr)); - log_buffer(buf, retval + 1); + LOG_EXPR(retval = v8_string->WriteUtf8V2(isolate, buf, sizeof buf, + String::WriteFlags::kNullTerminate, + nullptr)); + log_buffer(buf, static_cast(retval) + 1); memset(buf, 0x7f, sizeof buf); @@ -232,10 +241,16 @@ void test_v8_string_write_utf8(const FunctionCallbackInfo &info) { Local s = String::NewFromUtf8(isolate, utf8_data).ToLocalChecked(); for (int i = buf_size; i >= 0; i--) { memset(buf, 0xaa, buf_size); - int nchars; - int retval = s->WriteUtf8(isolate, buf, i, &nchars); - printf("buffer size = %2d, nchars = %2d, returned = %2d, data =", i, nchars, - retval); + size_t nchars; + // WriteUtf8V2 requires capacity >= 1 when null termination is requested, + // so only ask for it when the buffer is non-empty (legacy WriteUtf8 also + // wrote nothing for a zero-sized buffer). + size_t retval = s->WriteUtf8V2(isolate, buf, static_cast(i), + i > 0 ? String::WriteFlags::kNullTerminate + : String::WriteFlags::kNone, + &nchars); + printf("buffer size = %2d, nchars = %2zu, returned = %2zu, data =", i, + nchars, retval); for (int j = 0; j < buf_size; j++) { printf("%c%02x", j == i ? '|' : ' ', reinterpret_cast(buf)[j]); @@ -245,10 +260,11 @@ void test_v8_string_write_utf8(const FunctionCallbackInfo &info) { return ok(info); } -// Regression test for WriteUtf8 when a valid surrogate pair (astral character) -// does not fit in the remaining buffer. V8's legacy WriteUtf8 encodes the -// unpaired lead surrogate as WTF-8 (3 bytes, 0xED 0xA0-0xAF ...) in that case -// rather than leaving the buffer untouched. The encoder that backs this on Bun +// Regression test for writing UTF-8 when a valid surrogate pair (astral +// character) does not fit in the remaining buffer. V8's legacy WriteUtf8 +// encoded the unpaired lead surrogate as WTF-8 (3 bytes, 0xED 0xA0-0xAF ...) +// in that case; WriteUtf8V2 instead refuses to write partial sequences and +// stops before the astral character. The encoder that backs this on Bun // previously wrote U+FFFD (0xEF 0xBF 0xBD) here, diverging from V8. void test_v8_string_write_utf8_surrogate(const FunctionCallbackInfo &info) { Isolate *isolate = info.GetIsolate(); @@ -269,10 +285,13 @@ void test_v8_string_write_utf8_surrogate(const FunctionCallbackInfo &info Local s = String::NewFromUtf8(isolate, in.utf8).ToLocalChecked(); for (int i = total; i >= 0; i--) { memset(buf, 0xaa, total); - int nchars; - int retval = s->WriteUtf8(isolate, buf, i, &nchars); - printf("%-7s size = %d, nchars = %d, returned = %d, data =", in.label, i, - nchars, retval); + size_t nchars; + size_t retval = s->WriteUtf8V2(isolate, buf, static_cast(i), + i > 0 ? String::WriteFlags::kNullTerminate + : String::WriteFlags::kNone, + &nchars); + printf("%-7s size = %d, nchars = %zu, returned = %zu, data =", in.label, + i, nchars, retval); for (int j = 0; j < total; j++) { printf("%c%02x", j == i ? '|' : ' ', reinterpret_cast(buf)[j]); @@ -479,12 +498,14 @@ static void examine_object_fields(Isolate *isolate, Local o, int expected_field0, int expected_field1) { char buf[16]; HandleScope hs(isolate); - o->GetInternalField(0).As()->WriteUtf8(isolate, buf); + o->GetInternalField(0).As()->WriteUtf8V2( + isolate, buf, sizeof buf, String::WriteFlags::kNullTerminate); assert(atoi(buf) == expected_field0); Local field1 = o->GetInternalField(1).As(); if (field1->IsString()) { - field1.As()->WriteUtf8(isolate, buf); + field1.As()->WriteUtf8V2(isolate, buf, sizeof buf, + String::WriteFlags::kNullTerminate); assert(atoi(buf) == expected_field1); } else { assert(field1->IsUndefined()); @@ -542,7 +563,8 @@ void test_handle_scope_gc(const FunctionCallbackInfo &info) { // try to use all mini strings for (size_t j = 0; j < num_small_allocs; j++) { char buf[16]; - mini_strings[j]->WriteUtf8(isolate, buf); + mini_strings[j]->WriteUtf8V2(isolate, buf, sizeof buf, + String::WriteFlags::kNullTerminate); assert(atoi(buf) == (int)j); } @@ -569,7 +591,8 @@ void test_handle_scope_gc(const FunctionCallbackInfo &info) { memset(string_data, 0, string_size); for (size_t i = 0; i < num_strings; i++) { - huge_strings[i]->WriteUtf8(isolate, string_data); + huge_strings[i]->WriteUtf8V2(isolate, string_data, string_size, + String::WriteFlags::kNullTerminate); for (size_t j = 0; j < string_size - 1; j++) { assert(string_data[j] == (char)(i + 1)); } @@ -611,7 +634,7 @@ void test_v8_escapable_handle_scope(const FunctionCallbackInfo &info) { LOG_VALUE_KIND(t); char buf[16]; - s->WriteUtf8(isolate, buf); + s->WriteUtf8V2(isolate, buf, sizeof buf, String::WriteFlags::kNullTerminate); LOG_EXPR(buf); LOG_EXPR(n->Value()); } @@ -1233,7 +1256,9 @@ void initialize(Local exports, Local module, test_v8_value_type_checks); // without this, node hits a UAF deleting the Global - node::AddEnvironmentCleanupHook(context->GetIsolate(), + // (Context::GetIsolate was removed in V8 14.6; the module initializer runs + // with the isolate entered, so take the current one) + node::AddEnvironmentCleanupHook(Isolate::GetCurrent(), GlobalTestWrapper::cleanup, nullptr); } diff --git a/test/v8/v8.test.ts b/test/v8/v8.test.ts index 8363e2745e6..e7b2d3a5441 100644 --- a/test/v8/v8.test.ts +++ b/test/v8/v8.test.ts @@ -21,7 +21,7 @@ enum BuildMode { delete bunEnv.CC; delete bunEnv.CXX; -// Node.js 24.3.0 requires C++20 +// Node.js 26.3.0 requires C++20 bunEnv.CXXFLAGS ??= ""; if (process.platform == "darwin") { bunEnv.CXXFLAGS += " -std=gnu++20"; @@ -399,11 +399,13 @@ async function runOn(runtime: Runtime, buildMode: BuildMode, testName: string, j describe.todoIf(isBroken && isMusl)("String::Utf8Length bounds", () => { it( - "saturates at INT32_MAX for strings whose UTF-8 size exceeds it", + "reports sizes beyond INT32_MAX without wrapping", async () => { - // Build a tiny standalone V8-API addon that just reports String::Utf8Length of its + // Build a tiny standalone V8-API addon that just reports String::Utf8LengthV2 of its // argument, then feed it a Latin-1 string whose UTF-8 expansion is larger than INT32_MAX. - // The reported length must stay positive and saturate at INT32_MAX instead of wrapping. + // Utf8LengthV2 returns size_t, so the reported length must be the exact byte count + // instead of wrapping to a negative or small value (the legacy int-returning Utf8Length + // saturated at INT32_MAX here). using dir = tempDir("v8-utf8-length", { "package.json": JSON.stringify({ name: "v8-utf8-length-test", @@ -434,7 +436,7 @@ namespace utf8len_test { void string_utf8_length(const FunctionCallbackInfo &info) { Isolate *isolate = info.GetIsolate(); Local s = info[0].As(); - printf("Utf8Length = %d\\n", s->Utf8Length(isolate)); + printf("Utf8Length = %zu\\n", s->Utf8LengthV2(isolate)); fflush(stdout); } @@ -507,9 +509,10 @@ addon.string_utf8_length("\\u00ff".repeat(2 ** 30 + 1)); .trim() .split(/\r?\n/) .filter(Boolean); - // The small string reports its exact UTF-8 size; the oversized string saturates at - // INT32_MAX (2147483647) instead of wrapping to a negative or small value. - expect(lines, `stderr:\n${err}`).toEqual(["Utf8Length = 6", "Utf8Length = 2147483647"]); + // Both strings report their exact UTF-8 size: Utf8LengthV2 returns size_t, so the + // oversized string's 2**31 + 2 bytes are reported exactly instead of wrapping or + // saturating at INT32_MAX like the legacy Utf8Length did. + expect(lines, `stderr:\n${err}`).toEqual(["Utf8Length = 6", "Utf8Length = 2147483650"]); expect(exitCode).toBe(0); }, 10 * 60 * 1000, From 1474e49f94e9cc4ff3e582329da28e48f2c00602 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 4 Jun 2026 21:17:47 +0000 Subject: [PATCH 07/61] http, streams: review fixes for the Node 26 sync [build images] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _http_outgoing: drop the lazy outputData prototype accessor — reading it with `this` set to the prototype (spread, Object.assign, inspection) defineProperty'd a single shared array onto the prototype, reinstating the cross-instance leak it was meant to prevent, and reads on frozen instances threw. Like Node, the prototype now has no outputData property; methods lazily init for subclasses that don't chain the constructor. - _http_outgoing: getRawHeaderNames() now reports a present-but-empty set-cookie under its original casing (kEmptySetCookie stores the name); setHeader skips the per-call toLowerCase for names that can't be set-cookie. - ERR_HTTP2_GOAWAY_SESSION moved into the ErrorCode table instead of a hand-rolled Error factory. Appended at the end of the list: the table's discriminants are index-aligned with the checked-in Rust mirror (src/jsc/ErrorCode.rs). Also declared ErrorCode.ts as an input of the bundle-modules codegen step — error indices are baked into the bundled JS by replacements.ts, and an ErrorCode.ts edit previously left stale numbers in the bundles (errors thrown from builtins came out as the wrong code). - CompressionStream/DecompressionStream share one buffer-source validator via newBufferSourceTransformPairFromDuplex instead of two verbatim copies. - primordials: SafePromiseAllReturnVoid/ReturnArrayLike share one scheduler. - end-of-stream: deduplicate the immediate-result callback invocation. - node-stream.test.js: remove a byte-identical duplicate of the "node v26 stream semantics" describe block. - flake.nix: fix stale Node 24 comment. --- scripts/build/codegen.ts | 6 +- src/js/builtins/CompressionStream.ts | 19 +---- src/js/builtins/DecompressionStream.ts | 19 +---- src/js/internal/primordials.js | 36 +++----- src/js/internal/streams/end-of-stream.ts | 16 ++-- src/js/internal/webstreams_adapters.ts | 17 ++++ src/js/node/_http_outgoing.ts | 45 ++++------ src/js/node/http2.ts | 8 +- src/jsc/ErrorCode.rs | 7 +- src/jsc/bindings/ErrorCode.cpp | 2 + src/jsc/bindings/ErrorCode.ts | 3 + test/js/node/http/node-http.test.ts | 17 ++++ test/js/node/stream/node-stream.test.js | 104 ----------------------- 13 files changed, 91 insertions(+), 208 deletions(-) diff --git a/scripts/build/codegen.ts b/scripts/build/codegen.ts index 83c5c0f91ed..49c57c61e46 100644 --- a/scripts/build/codegen.ts +++ b/scripts/build/codegen.ts @@ -763,6 +763,10 @@ function emitJsModules({ n, cfg, sources, o, dirStamp }: Ctx): void { // InternalModuleRegistry.cpp is read by the script (for a sanity check). const extraInput = resolve(cfg.cwd, "src", "jsc", "bindings", "InternalModuleRegistry.cpp"); + // replacements.ts bakes ErrorCode.ts indices into every bundled module + // ($makeErrorWithCode(N, ...)); without this dep an ErrorCode.ts edit leaves + // stale error numbers in the JS bundles while the C++ enum regenerates. + const errorCodeInput = resolve(cfg.cwd, "src", "jsc", "bindings", "ErrorCode.ts"); // Written into src/ (not codegenDir) — see zigFilesGeneratedIntoSrc at top. const js2nativeZig = resolve(cfg.cwd, zigFilesGeneratedIntoSrc[1]); @@ -791,7 +795,7 @@ function emitJsModules({ n, cfg, sources, o, dirStamp }: Ctx): void { n.build({ outputs, rule: "codegen", - inputs: [script, ...sources.js, ...sources.jsCodegen, extraInput], + inputs: [script, ...sources.js, ...sources.jsCodegen, extraInput, errorCodeInput], orderOnlyInputs: [dirStamp], vars: { cwd: cfg.cwd, diff --git a/src/js/builtins/CompressionStream.ts b/src/js/builtins/CompressionStream.ts index 201956a0973..5ca7520ddc4 100644 --- a/src/js/builtins/CompressionStream.ts +++ b/src/js/builtins/CompressionStream.ts @@ -1,11 +1,6 @@ export function initializeCompressionStream(this, format) { const zlib = require("node:zlib"); - const { - newReadableWritablePairFromDuplex, - kValidateChunk, - kDestroyOnSyncError, - } = require("internal/webstreams_adapters"); - const { isArrayBufferView, isSharedArrayBuffer } = require("node:util/types"); + const { newBufferSourceTransformPairFromDuplex } = require("internal/webstreams_adapters"); const builders = { "deflate": zlib.createDeflate, @@ -18,17 +13,7 @@ export function initializeCompressionStream(this, format) { if (!(format in builders)) throw $ERR_INVALID_ARG_VALUE("format", format, "must be one of: " + Object.keys(builders).join(", ")); - const handle = builders[format](); - const transform = newReadableWritablePairFromDuplex(handle, { - // Per the Compression Streams spec, chunks must be BufferSource - // (ArrayBuffer or ArrayBufferView not backed by SharedArrayBuffer). - [kValidateChunk]: function validateBufferSourceChunk(chunk) { - if (isSharedArrayBuffer(isArrayBufferView(chunk) ? chunk.buffer : chunk)) { - throw $ERR_INVALID_ARG_TYPE("chunk", ["ArrayBuffer", "Buffer", "TypedArray", "DataView"], chunk); - } - }, - [kDestroyOnSyncError]: true, - }); + const transform = newBufferSourceTransformPairFromDuplex(builders[format]()); $putByIdDirectPrivate(this, "readable", transform.readable); $putByIdDirectPrivate(this, "writable", transform.writable); diff --git a/src/js/builtins/DecompressionStream.ts b/src/js/builtins/DecompressionStream.ts index 9658a7e1091..0df175dc69f 100644 --- a/src/js/builtins/DecompressionStream.ts +++ b/src/js/builtins/DecompressionStream.ts @@ -1,11 +1,6 @@ export function initializeDecompressionStream(this, format) { const zlib = require("node:zlib"); - const { - newReadableWritablePairFromDuplex, - kValidateChunk, - kDestroyOnSyncError, - } = require("internal/webstreams_adapters"); - const { isArrayBufferView, isSharedArrayBuffer } = require("node:util/types"); + const { newBufferSourceTransformPairFromDuplex } = require("internal/webstreams_adapters"); const builders = { "deflate": zlib.createInflate, @@ -18,17 +13,7 @@ export function initializeDecompressionStream(this, format) { if (!(format in builders)) throw $ERR_INVALID_ARG_VALUE("format", format, "must be one of: " + Object.keys(builders).join(", ")); - const handle = builders[format](); - const transform = newReadableWritablePairFromDuplex(handle, { - // Per the Compression Streams spec, chunks must be BufferSource - // (ArrayBuffer or ArrayBufferView not backed by SharedArrayBuffer). - [kValidateChunk]: function validateBufferSourceChunk(chunk) { - if (isSharedArrayBuffer(isArrayBufferView(chunk) ? chunk.buffer : chunk)) { - throw $ERR_INVALID_ARG_TYPE("chunk", ["ArrayBuffer", "Buffer", "TypedArray", "DataView"], chunk); - } - }, - [kDestroyOnSyncError]: true, - }); + const transform = newBufferSourceTransformPairFromDuplex(builders[format]()); $putByIdDirectPrivate(this, "readable", transform.readable); $putByIdDirectPrivate(this, "writable", transform.writable); diff --git a/src/js/internal/primordials.js b/src/js/internal/primordials.js index a140a351ec3..9ad7d3e8001 100644 --- a/src/js/internal/primordials.js +++ b/src/js/internal/primordials.js @@ -96,31 +96,13 @@ const arrayToSafePromiseIterable = (promises, mapFn) => const PromiseAll = Promise.all; const PromiseResolve = Promise.$resolve.bind(Promise); const SafePromiseAll = (promises, mapFn) => PromiseAll(arrayToSafePromiseIterable(promises, mapFn)); -const SafePromiseAllReturnVoid = (promises, mapFn) => +// Shared scheduler for SafePromiseAllReturnVoid/ReturnArrayLike: `returnVal` +// is null for the void variant (no result bookkeeping, resolves with nothing). +const safePromiseAllCollect = (promises, mapFn, returnVal) => new Promise((resolve, reject) => { const { length } = promises; - if (length === 0) resolve(); - - let pendingPromises = length; - for (let i = 0; i < length; i++) { - const promise = mapFn != null ? mapFn(promises[i], i) : promises[i]; - PromisePrototypeThen.$call( - PromiseResolve(promise), - () => { - if (--pendingPromises === 0) resolve(); - }, - reject, - ); - } - }); -const SafePromiseAllReturnArrayLike = (promises, mapFn) => - new Promise((resolve, reject) => { - const { length } = promises; - - const returnVal = Array(length); - ObjectSetPrototypeOf(returnVal, null); - if (length === 0) resolve(returnVal); + if (length === 0) resolve(returnVal ?? undefined); let pendingPromises = length; for (let i = 0; i < length; i++) { @@ -128,13 +110,19 @@ const SafePromiseAllReturnArrayLike = (promises, mapFn) => PromisePrototypeThen.$call( PromiseResolve(promise), result => { - returnVal[i] = result; - if (--pendingPromises === 0) resolve(returnVal); + if (returnVal !== null) returnVal[i] = result; + if (--pendingPromises === 0) resolve(returnVal ?? undefined); }, reject, ); } }); +const SafePromiseAllReturnVoid = (promises, mapFn) => safePromiseAllCollect(promises, mapFn, null); +const SafePromiseAllReturnArrayLike = (promises, mapFn) => { + const returnVal = Array(promises.length); + ObjectSetPrototypeOf(returnVal, null); + return safePromiseAllCollect(promises, mapFn, returnVal); +}; export default { Array, diff --git a/src/js/internal/streams/end-of-stream.ts b/src/js/internal/streams/end-of-stream.ts index fffa5461bd1..99a66dd5ac7 100644 --- a/src/js/internal/streams/end-of-stream.ts +++ b/src/js/internal/streams/end-of-stream.ts @@ -162,12 +162,18 @@ function eos(stream, options, callback) { } else if (options.signal?.aborted) { immediateResult = $makeAbortError(undefined, { cause: options.signal.reason }); } - if (immediateResult !== undefined && options[kEosNodeSynchronousCallback]) { + // null means "finished without error": invoke with no error argument at all, + // not an explicit null/undefined. + const invokeImmediate = () => { if (immediateResult === null) { callback.$call(stream); } else { callback.$call(stream, immediateResult); } + }; + + if (immediateResult !== undefined && options[kEosNodeSynchronousCallback]) { + invokeImmediate(); return cleanup; } @@ -176,13 +182,7 @@ function eos(stream, options, callback) { } if (immediateResult !== undefined) { - process.nextTick(() => { - if (immediateResult === null) { - callback.$call(stream); - } else { - callback.$call(stream, immediateResult); - } - }); + process.nextTick(invokeImmediate); return cleanup; } diff --git a/src/js/internal/webstreams_adapters.ts b/src/js/internal/webstreams_adapters.ts index 9c969ea2dd9..1772e726ead 100644 --- a/src/js/internal/webstreams_adapters.ts +++ b/src/js/internal/webstreams_adapters.ts @@ -859,6 +859,22 @@ function newStreamDuplexFromReadableWritablePair(pair = kEmptyObject, options = return duplex; } +// Shared by CompressionStream and DecompressionStream: per the Compression +// Streams spec, chunks must be BufferSource (ArrayBuffer or ArrayBufferView +// not backed by SharedArrayBuffer), and an invalid chunk must error both +// sides of the pair synchronously. +function newBufferSourceTransformPairFromDuplex(duplex) { + const { isArrayBufferView, isSharedArrayBuffer } = require("node:util/types"); + return newReadableWritablePairFromDuplex(duplex, { + [kValidateChunk]: function validateBufferSourceChunk(chunk) { + if (isSharedArrayBuffer(isArrayBufferView(chunk) ? chunk.buffer : chunk)) { + throw $ERR_INVALID_ARG_TYPE("chunk", ["ArrayBuffer", "Buffer", "TypedArray", "DataView"], chunk); + } + }, + [kDestroyOnSyncError]: true, + }); +} + export default { newWritableStreamFromStreamWritable, newReadableStreamFromStreamReadable, @@ -866,6 +882,7 @@ export default { newStreamReadableFromReadableStream, newReadableWritablePairFromDuplex, newStreamDuplexFromReadableWritablePair, + newBufferSourceTransformPairFromDuplex, kValidateChunk, kDestroyOnSyncError, _ReadableFromWeb: ReadableFromWeb, diff --git a/src/js/node/_http_outgoing.ts b/src/js/node/_http_outgoing.ts index 3b53d817dc1..94113ec0b36 100644 --- a/src/js/node/_http_outgoing.ts +++ b/src/js/node/_http_outgoing.ts @@ -208,27 +208,10 @@ const OutgoingMessagePrototype = { shouldKeepAlive: true, _onPendingData: function nop() {}, outputSize: 0, - // The constructor creates a per-instance array; a plain array default here - // would be shared (and mutated) across every instance. This accessor lazily - // creates an own array for subclasses that don't chain the constructor. - get outputData() { - const value = []; - ObjectDefineProperty(this, "outputData", { - value, - writable: true, - enumerable: true, - configurable: true, - }); - return value; - }, - set outputData(value) { - ObjectDefineProperty(this, "outputData", { - value, - writable: true, - enumerable: true, - configurable: true, - }); - }, + // No outputData default on the prototype (a shared array would leak buffered + // writes across instances, and a lazy accessor would self-destruct when read + // directly off the prototype). The constructor creates the per-instance + // array; methods lazily init for subclasses that don't chain the constructor. strictContentLength: false, _removedTE: false, _removedContLen: false, @@ -283,8 +266,11 @@ const OutgoingMessagePrototype = { getRawHeaderNames() { var headers = this[headersSymbol]; - if (!headers) return []; - return getRawKeys.$call(headers); + const emptySetCookie = this[kEmptySetCookie]; + if (!headers) return emptySetCookie ? [emptySetCookie] : []; + const names = getRawKeys.$call(headers); + if (emptySetCookie) names.push(emptySetCookie); + return names; }, getHeaders() { @@ -313,12 +299,13 @@ const OutgoingMessagePrototype = { validateHeaderName(name); validateHeaderValue(name, value); const headers = (this[headersSymbol] ??= new Headers()); - if (name.toLowerCase() === "set-cookie") { + if (name.length === 10 && name.toLowerCase() === "set-cookie") { if ($isArray(value) && value.length === 0) { // Present-but-empty: nothing to store in the backing Headers (and // nothing goes on the wire), but getHeader must return []. headers.delete(name); - this[kEmptySetCookie] = true; + // Remember the original-case name so getRawHeaderNames can report it. + this[kEmptySetCookie] = name; return this; } this[kEmptySetCookie] = false; @@ -502,7 +489,7 @@ const OutgoingMessagePrototype = { data = this._header + data; } else { const header = this._header; - this.outputData.unshift({ + (this.outputData ??= []).unshift({ data: header, encoding: "latin1", callback: null, @@ -529,20 +516,20 @@ const OutgoingMessagePrototype = { if (conn && conn._httpMessage === this && conn.writable) { // There might be pending data in the this.output buffer. - if (this.outputData.length) { + if (this.outputData?.length) { this._flushOutput(conn); } // Directly write to socket. return conn.write(data, encoding, callback); } // Buffer, as long as we're not destroyed. - this.outputData.push({ data, encoding, callback }); + (this.outputData ??= []).push({ data, encoding, callback }); this.outputSize += data.length; this._onPendingData(data.length); return this.outputSize < this[kHighWaterMark]; }, _flushOutput(socket) { - const outputLength = this.outputData.length; + const outputLength = this.outputData?.length ?? 0; if (outputLength <= 0) return undefined; const outputData = this.outputData; diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 7f5e6630f0d..a5e6dbe159f 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -381,12 +381,6 @@ function emitOutofStreamErrorNT(self: any) { self.destroy($ERR_HTTP2_OUT_OF_STREAMS()); } -function goawaySessionError() { - const err = new Error("New streams cannot be created after receiving a GOAWAY"); - (err as any).code = "ERR_HTTP2_GOAWAY_SESSION"; - return err; -} -hideFromStack(goawaySessionError); function cache() { const d = new Date(); utcCache = d.toUTCString(); @@ -3912,7 +3906,7 @@ class ClientHttp2Session extends Http2Session { throw $ERR_HTTP2_INVALID_SESSION(); } if (this.closed) { - throw goawaySessionError(); + throw $ERR_HTTP2_GOAWAY_SESSION(); } if (this.sentTrailers) { diff --git a/src/jsc/ErrorCode.rs b/src/jsc/ErrorCode.rs index 43f0c78ed2a..824a239cfa0 100644 --- a/src/jsc/ErrorCode.rs +++ b/src/jsc/ErrorCode.rs @@ -682,8 +682,11 @@ impl ErrorCode { /// `ERR_SECRETS_INTERACTION_REQUIRED` (instanceof Error) pub const SECRETS_INTERACTION_REQUIRED: ErrorCode = ErrorCode(311); + /// `ERR_HTTP2_GOAWAY_SESSION` + pub const HTTP2_GOAWAY_SESSION: ErrorCode = ErrorCode(312); + /// == C++ `NODE_ERROR_COUNT`. - pub const COUNT: u16 = 312; + pub const COUNT: u16 = 313; } // ────────────────────────────────────────────────────────────────────────── @@ -1038,6 +1041,7 @@ impl ErrorCode { ErrorCode::SECRETS_INTERACTION_NOT_ALLOWED; pub const ERR_SECRETS_AUTH_FAILED: ErrorCode = ErrorCode::SECRETS_AUTH_FAILED; pub const ERR_SECRETS_INTERACTION_REQUIRED: ErrorCode = ErrorCode::SECRETS_INTERACTION_REQUIRED; + pub const ERR_HTTP2_GOAWAY_SESSION: ErrorCode = ErrorCode::HTTP2_GOAWAY_SESSION; // NOTE: `ERR_SYSTEM_ERROR` / `ERR_CHILD_CLOSED_BEFORE_REPLY` intentionally // do NOT live here. They belong to the unrelated Zig enum @@ -1368,6 +1372,7 @@ static CODE_STR: [&str; ErrorCode::COUNT as usize] = [ "ERR_SECRETS_INTERACTION_NOT_ALLOWED", "ERR_SECRETS_AUTH_FAILED", "ERR_SECRETS_INTERACTION_REQUIRED", + "ERR_HTTP2_GOAWAY_SESSION", ]; // ────────────────────────────────────────────────────────────────────────── diff --git a/src/jsc/bindings/ErrorCode.cpp b/src/jsc/bindings/ErrorCode.cpp index 8aef2ccca1b..02810d77a1d 100644 --- a/src/jsc/bindings/ErrorCode.cpp +++ b/src/jsc/bindings/ErrorCode.cpp @@ -2483,6 +2483,8 @@ JSC_DEFINE_HOST_FUNCTION(Bun::jsFunctionMakeErrorWithCode, (JSC::JSGlobalObject return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP2_PING_LENGTH, "HTTP2 ping payload must be 8 bytes"_s)); case ErrorCode::ERR_HTTP2_OUT_OF_STREAMS: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP2_OUT_OF_STREAMS, "No stream ID is available because maximum stream ID has been reached"_s)); + case ErrorCode::ERR_HTTP2_GOAWAY_SESSION: + return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP2_GOAWAY_SESSION, "New streams cannot be created after receiving a GOAWAY"_s)); case ErrorCode::ERR_HTTP_BODY_NOT_ALLOWED: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP_BODY_NOT_ALLOWED, "Adding content for this request method or response status is not allowed."_s)); case ErrorCode::ERR_HTTP_SOCKET_ASSIGNED: diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index d721b2a4494..1afd51a695d 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -322,5 +322,8 @@ const errors: ErrorCodeMapping = [ ["ERR_SECRETS_INTERACTION_NOT_ALLOWED", Error], ["ERR_SECRETS_AUTH_FAILED", Error], ["ERR_SECRETS_INTERACTION_REQUIRED", Error], + // Appended (not alphabetical): discriminants are index-aligned with the + // checked-in Rust mirror (src/jsc/ErrorCode.rs) — only ever append here. + ["ERR_HTTP2_GOAWAY_SESSION", Error], ]; export default errors; diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 57f5319a97f..a9cc7ad1799 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -2265,6 +2265,7 @@ it("setHeaders stores an empty set-cookie array (nodejs/node#59734)", () => { expect(msg.hasHeader("set-cookie")).toBe(true); expect(msg.getHeaders()["set-cookie"]).toEqual([]); expect(msg.getHeaderNames()).toContain("set-cookie"); + expect(msg.getRawHeaderNames()).toContain("set-cookie"); msg.removeHeader("set-cookie"); expect(msg.getHeader("set-cookie")).toBeUndefined(); expect(msg.hasHeader("set-cookie")).toBe(false); @@ -2274,6 +2275,12 @@ it("setHeaders stores an empty set-cookie array (nodejs/node#59734)", () => { msg2.setHeaders(new Map([["x-test", "1"]])); expect(msg2.getHeader("set-cookie")).toBeUndefined(); expect(msg2.getHeader("x-test")).toBe("1"); + + // getRawHeaderNames preserves the original casing, like Node. + const msg3 = new OutgoingMessage(); + msg3.setHeader("Set-Cookie", []); + expect(msg3.getRawHeaderNames()).toEqual(["Set-Cookie"]); + expect(msg3.getHeaderNames()).toEqual(["set-cookie"]); }); it("https.Agent applies defaultPort/protocol through options (nodejs/node#58980)", () => { @@ -2424,4 +2431,14 @@ it("OutgoingMessage outputData is per-instance and _flushOutput is defined", () expect(a.outputData.length).toBe(1); expect(b.outputData.length).toBe(0); expect(new OutgoingMessage().outputData.length).toBe(0); + + // Like Node, the prototype has no outputData property at all; reading it off + // the prototype (e.g. a spread or inspection) must not materialize shared + // state on the prototype. + expect(Object.getOwnPropertyDescriptor(OutgoingMessage.prototype, "outputData")).toBeUndefined(); + void { ...OutgoingMessage.prototype }; + const c = new OutgoingMessage(); + const d = new OutgoingMessage(); + c.outputData.push({ data: "y", encoding: "utf8", callback: null }); + expect(d.outputData.length).toBe(0); }); diff --git a/test/js/node/stream/node-stream.test.js b/test/js/node/stream/node-stream.test.js index d1697bb41ed..8c03bb2b133 100644 --- a/test/js/node/stream/node-stream.test.js +++ b/test/js/node/stream/node-stream.test.js @@ -899,110 +899,6 @@ describe("node v26 stream semantics", () => { }); }); -// Node.js v26 semver-major stream semantics. -describe("node v26 stream semantics", () => { - // Upstream: v26 howMuchToRead() fast path; covered upstream by the updated - // test-stream2-readable-non-empty-end.js / test-stream-readable-emittedReadable.js. - it("read() with no size returns one buffered chunk at a time in paused mode", async () => { - const r = new Readable({ read() {} }); - r.push(Buffer.from("abc")); - r.push(Buffer.from("de")); - r.push(null); - await new Promise(resolve => setImmediate(resolve)); - expect(r.read().toString()).toBe("abc"); - expect(r.read().toString()).toBe("de"); - expect(r.read()).toBeNull(); - }); - - it("read() with no size still concatenates when setEncoding is active", async () => { - const r = new Readable({ read() {} }); - r.setEncoding("utf8"); - r.push("abc"); - r.push("de"); - r.push(null); - await new Promise(resolve => setImmediate(resolve)); - expect(r.read()).toBe("abcde"); - expect(r.read()).toBeNull(); - }); - - // Upstream: nodejs/node#62557 (test-stream-readable-pause-and-resume.js). - it("pause() and resume() are no-ops on destroyed streams", async () => { - const r = new Readable({ read() {} }); - r.destroy(); - const emitted = []; - r.on("pause", () => emitted.push("pause")); - r.on("resume", () => emitted.push("resume")); - expect(r.resume()).toBe(r); - expect(r.readableFlowing).toBeNull(); - expect(r.pause()).toBe(r); - expect(r.readableFlowing).toBeNull(); - await new Promise(resolve => setImmediate(resolve)); - expect(emitted).toEqual([]); - }); - - // Upstream: nodejs/node#60907 (test-stream-compose-operator.js). - it("compose returns the composed Duplex directly", () => { - expect(Object.hasOwn(Readable.prototype, "compose")).toBe(true); - const composed = Readable.from(["a"]).compose( - new Transform({ - transform(chunk, encoding, callback) { - callback(null, chunk); - }, - }), - ); - expect(composed).toBeInstanceOf(Duplex); - }); - - it("compose rejects a non-writable destination with the streams[1] arg name", () => { - let err; - try { - Readable.from(["a"]).compose(new Readable({ read() {} })); - } catch (e) { - err = e; - } - expect(err?.code).toBe("ERR_INVALID_ARG_VALUE"); - expect(err?.message).toContain("streams[1]"); - }); - - it("compose validates the options argument", () => { - let err; - try { - Readable.from(["a"]).compose(new PassThrough(), 42); - } catch (e) { - err = e; - } - expect(err?.code).toBe("ERR_INVALID_ARG_TYPE"); - }); - - // Upstream: v26 test-stream-writable-decoded-encoding.js. - it("write(string, 'buffer') throws ERR_UNKNOWN_ENCODING", () => { - for (const opts of [{ decodeStrings: false }, {}]) { - const w = new Writable({ - ...opts, - write(chunk, encoding, callback) { - callback(); - }, - }); - let err; - try { - w.write("hi", "buffer"); - } catch (e) { - err = e; - } - expect(err?.code).toBe("ERR_UNKNOWN_ENCODING"); - - // Buffer chunks with 'buffer' encoding still work. - const w2 = new Writable({ - ...opts, - write(chunk, encoding, callback) { - callback(); - }, - }); - expect(w2.write(Buffer.from("x"), "buffer")).toBe(true); - } - }); -}); - describe("fromList string chunk boundary (nodejs/node#61884)", () => { it("read(n) with setEncoding does not over-read when n equals the buffered array length", () => { const r = new Readable({ read() {} }); From 40bf49095b425b0e73fdf7a49f98276850bbcfe9 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 4 Jun 2026 21:19:40 +0000 Subject: [PATCH 08/61] bootstrap: keep the xwin cache on the same filesystem as the splat output [build images] xwin's splat phase moves unpacked SDK files into place with rename(2). With the cache under the download dir on tmpfs and /opt/winsysroot on the root filesystem, every move fails with EXDEV ("Cross-device link"), killing the linux image bakes. Cache next to the output instead and clean it up after. --- scripts/bootstrap.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 13047162125..e76f4429a44 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -1450,6 +1450,12 @@ install_windows_sysroot() { execute_sudo rm -rf "$sysroot" execute_sudo mkdir -p "$sysroot" + # The cache must live on the same filesystem as the output: splat moves + # unpacked files with rename(2), which fails with EXDEV (cross-device + # link) when the download dir is on tmpfs and /opt is not. + xwin_cache="$sysroot.cache" + execute_sudo rm -rf "$xwin_cache" + execute_sudo mkdir -p "$xwin_cache" # Both target arches in one splat; --include-debug-libs so /MTd (debug # CRT) links work; --include-atl for (rescle.cpp); # winsysroot-style + MS arch notation so clang-cl and lld-link resolve it @@ -1457,14 +1463,14 @@ install_windows_sysroot() { # include/lib casing on a case-sensitive filesystem. # stdout is dropped: xwin draws progress bars there even without a TTY, # which floods the image-build log. Errors stay on stderr. - execute_sudo "$xwin_dir/xwin" --accept-license --arch x86_64,aarch64 --sdk-version 10.0.26100 --crt-version 14.44.17.14 --include-atl --cache-dir "$xwin_dir/cache" \ + execute_sudo "$xwin_dir/xwin" --accept-license --arch x86_64,aarch64 --sdk-version 10.0.26100 --crt-version 14.44.17.14 --include-atl --cache-dir "$xwin_cache" \ splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs \ --output "$sysroot" >/dev/null # clang-cl/lld-link compose SDK paths as "Include"/"Lib" (title case); # the winsysroot-style splat writes lowercase — alias both spellings. execute_sudo ln -s include "$sysroot/Windows Kits/10/Include" execute_sudo ln -s lib "$sysroot/Windows Kits/10/Lib" - execute_sudo rm -rf "$xwin_dir" + execute_sudo rm -rf "$xwin_dir" "$xwin_cache" # No WINDOWS_SYSROOT export — detectWindowsSysroot() picks up # /opt/winsysroot by well-known path. } From 2bffbf739719a9ce60a6fcc6b4b20426cad015ca Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:21:45 +0000 Subject: [PATCH 09/61] [autofix.ci] apply automated fixes --- src/jsc/bindings/v8/V8FunctionCallbackInfo.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/jsc/bindings/v8/V8FunctionCallbackInfo.cpp b/src/jsc/bindings/v8/V8FunctionCallbackInfo.cpp index 6c6d4646cad..194e33ce4e9 100644 --- a/src/jsc/bindings/v8/V8FunctionCallbackInfo.cpp +++ b/src/jsc/bindings/v8/V8FunctionCallbackInfo.cpp @@ -4,8 +4,8 @@ // Check that a slot index in our FunctionCallbackInfo matches the index V8's // inline accessors use to read that slot of the ApiCallbackExitFrame -#define CHECK_FRAME_INDEX(NAME) \ - static_assert(static_cast(v8::FunctionCallbackInfo::NAME) \ +#define CHECK_FRAME_INDEX(NAME) \ + static_assert(static_cast(v8::FunctionCallbackInfo::NAME) \ == static_cast(real_v8::FunctionCallbackInfo::NAME), \ "Index of `" #NAME "` in the callback exit frame does not match V8"); From 1646e94732ce750abfeaa9ad221bddce89fcf174 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 4 Jun 2026 21:23:28 +0000 Subject: [PATCH 10/61] Rebuild CI images for the Node 26.3.0 toolchain [build images] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous head commit came from the formatting bot, whose message lacks the [build images] tag — the pipeline then asked for published v35 images that don't exist yet. From 4e86a1caf0df49e446e616c7b9759254d724b5e0 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 4 Jun 2026 23:29:03 +0000 Subject: [PATCH 11/61] bootstrap: install musl Node.js from unofficial-builds.nodejs.org [build images] The private S3 mirror has no v26.3.0 musl tarballs, which 403'd the Alpine image bakes. nodejs/unofficial-builds publishes both linux-x64-musl and linux-arm64-musl for current releases (the mirror predates arm64-musl being available there), so pull straight from it and skip the manual re-upload on every Node bump. --- scripts/bootstrap.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index e76f4429a44..a61b1e1b3c0 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -819,7 +819,11 @@ install_nodejs() { case "$abi" in musl) - nodejs_mirror="https://bun-nodejs-release.s3.us-west-1.amazonaws.com" + # nodejs.org doesn't publish musl binaries; the unofficial-builds + # project (nodejs/unofficial-builds) ships both x64-musl and + # arm64-musl for current releases. (The old private S3 mirror at + # bun-nodejs-release predates arm64-musl being available there.) + nodejs_mirror="https://unofficial-builds.nodejs.org/download/release" nodejs_foldername="node-v$nodejs_version-$nodejs_platform-$nodejs_arch-musl" ;; *) From af1bac6ff1012545578ca6c25443a1e161971b5c Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Fri, 5 Jun 2026 06:06:30 +0000 Subject: [PATCH 12/61] Fix node-gyp on Windows and stale cipher stream tests for Node 26 [build images] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - process.config.variables: add enable_thin_lto=false and lto_jobs="" to match Node 26. Its common.gypi evaluates these in MSVS settings conditions and gyp hard-fails on undefined variables, breaking every node-gyp build on Windows (v8/napi/dlopen suites). - The cipher streaming tests assumed read() with no size returns the whole buffered ciphertext; since Node 26 it returns one chunk at a time, so the fixtures truncated the ciphertext and OpenSSL reported BAD_DECRYPT — real Node 26 fails the old fixtures identically. Drain read() in a loop and add the second cipher data event Node 26 emits (output verified against Node 26.3.0 byte-for-byte modulo quote style); drop the stale TODO claiming the readable/prefinish order was a bug. --- src/jsc/bindings/BunProcess.cpp | 16 ++++++++++++++++ test/js/bun/crypto/cipheriv-decipheriv.test.ts | 12 ++++++++++-- test/js/node/crypto/crypto.test.ts | 17 +++++++++++++---- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index b8bd26e2501..805b487ccb1 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -2498,6 +2498,10 @@ static JSValue constructProcessConfigObject(VM& vm, JSObject* processObject) JSC::JSObject* variables = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), 2); variables->putDirect(vm, JSC::Identifier::fromString(vm, "v8_enable_i8n_support"_s), JSC::jsNumber(1), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_lto"_s), JSC::jsBoolean(false), 0); + // Node 26's common.gypi evaluates enable_thin_lto/lto_jobs conditions; gyp + // hard-fails on undefined variables, so node-gyp builds need them present. + variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_thin_lto"_s), JSC::jsBoolean(false), 0); + variables->putDirect(vm, JSC::Identifier::fromString(vm, "lto_jobs"_s), JSC::jsString(vm, String(""_s)), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "node_module_version"_s), JSC::jsNumber(REPORTED_NODEJS_ABI_VERSION), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "napi_build_version"_s), JSC::jsNumber(Napi::DEFAULT_NAPI_VERSION), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "node_builtin_shareable_builtins"_s), JSC::constructEmptyArray(globalObject, nullptr), 0); @@ -2514,6 +2518,10 @@ static JSValue constructProcessConfigObject(VM& vm, JSObject* processObject) variables->putDirect(vm, JSC::Identifier::fromString(vm, "debug_nghttp2"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "debug_node"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_lto"_s), JSC::jsBoolean(false), 0); + // Node 26's common.gypi evaluates enable_thin_lto/lto_jobs conditions; gyp + // hard-fails on undefined variables, so node-gyp builds need them present. + variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_thin_lto"_s), JSC::jsBoolean(false), 0); + variables->putDirect(vm, JSC::Identifier::fromString(vm, "lto_jobs"_s), JSC::jsString(vm, String(""_s)), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_pgo_generate"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_pgo_use"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "error_on_warn"_s), JSC::jsBoolean(false), 0); @@ -2527,6 +2535,10 @@ static JSValue constructProcessConfigObject(VM& vm, JSObject* processObject) variables->putDirect(vm, JSC::Identifier::fromString(vm, "debug_nghttp2"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "debug_node"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_lto"_s), JSC::jsBoolean(false), 0); + // Node 26's common.gypi evaluates enable_thin_lto/lto_jobs conditions; gyp + // hard-fails on undefined variables, so node-gyp builds need them present. + variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_thin_lto"_s), JSC::jsBoolean(false), 0); + variables->putDirect(vm, JSC::Identifier::fromString(vm, "lto_jobs"_s), JSC::jsString(vm, String(""_s)), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_pgo_generate"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_pgo_use"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "error_on_warn"_s), JSC::jsBoolean(false), 0); @@ -2541,6 +2553,10 @@ static JSValue constructProcessConfigObject(VM& vm, JSObject* processObject) variables->putDirect(vm, JSC::Identifier::fromString(vm, "debug_nghttp2"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "debug_node"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_lto"_s), JSC::jsBoolean(false), 0); + // Node 26's common.gypi evaluates enable_thin_lto/lto_jobs conditions; gyp + // hard-fails on undefined variables, so node-gyp builds need them present. + variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_thin_lto"_s), JSC::jsBoolean(false), 0); + variables->putDirect(vm, JSC::Identifier::fromString(vm, "lto_jobs"_s), JSC::jsString(vm, String(""_s)), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_pgo_generate"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_pgo_use"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "error_on_warn"_s), JSC::jsBoolean(false), 0); diff --git a/test/js/bun/crypto/cipheriv-decipheriv.test.ts b/test/js/bun/crypto/cipheriv-decipheriv.test.ts index fd9cb7f2733..af8211d0234 100644 --- a/test/js/bun/crypto/cipheriv-decipheriv.test.ts +++ b/test/js/bun/crypto/cipheriv-decipheriv.test.ts @@ -65,13 +65,21 @@ it("should encrypt & decrypt using streaming interface", () => { const key = randomBytes(32); const iv = randomBytes(16); + // Since Node 26, read() with no size returns one buffered chunk at a time, + // so drain the stream instead of assuming a single read returns everything. + const readAll = stream => { + const chunks = []; + for (let chunk; (chunk = stream.read()) !== null; ) chunks.push(chunk); + return Buffer.concat(chunks); + }; + const cipher = createCipheriv("aes-256-cbc", key, iv); cipher.end(plaintext); - let ciph = cipher.read(); + let ciph = readAll(cipher); const decipher = createDecipheriv("aes-256-cbc", key, iv); decipher.end(ciph); - let txt = decipher.read().toString("utf8"); + let txt = readAll(decipher).toString("utf8"); expect(txt).toBe(plaintext); }); diff --git a/test/js/node/crypto/crypto.test.ts b/test/js/node/crypto/crypto.test.ts index 16336cdcdaa..16b4be3a91a 100644 --- a/test/js/node/crypto/crypto.test.ts +++ b/test/js/node/crypto/crypto.test.ts @@ -258,16 +258,24 @@ it("should send cipher events in the right order", async () => { const key = Buffer.from("3fad401bb178066f201b55368712530229d6329a5e2c05f48ff36ca65792d21d", "hex"); const iv = Buffer.from("22371787d3e04a6589d8a1de50c81208", "hex"); + // Since Node 26, read() with no size returns one buffered chunk at a time, + // so drain the stream instead of assuming a single read returns everything. + function readAll(stream) { + const chunks = []; + for (let chunk; (chunk = stream.read()) !== null; ) chunks.push(chunk); + return Buffer.concat(chunks); + } + const cipher = crypto.createCipheriv("aes-256-cbc", key, iv); patchEmitter(cipher, "cipher"); cipher.end(plaintext); - let ciph = cipher.read(); + let ciph = readAll(cipher); console.log([1, ciph.toString("hex")]); const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv); patchEmitter(decipher, "decipher"); decipher.end(ciph); - let dciph = decipher.read(); + let dciph = readAll(decipher); console.log([2, dciph.toString("hex")]); let txt = dciph.toString("utf8"); @@ -286,12 +294,13 @@ it("should send cipher events in the right order", async () => { const err = await stderr.text(); expect(err).toBeEmpty(); const out = await stdout.text(); - // TODO: prefinish and readable (on both cipher and decipher) should be flipped - // This seems like a bug in our crypto code, which + // Matches Node 26 output for the same fixture (verified byte-for-byte + // modulo quote style). expect(out.split("\n")).toEqual([ `[ "cipher", "readable" ]`, `[ "cipher", "prefinish" ]`, `[ "cipher", "data" ]`, + `[ "cipher", "data" ]`, `[ 1, "dfb6b7e029be3ad6b090349ed75931f28f991b52ca9a89f5bf6f82fa1c87aa2d624bd77701dcddfcceaf3add7d66ce06ced17aebca4cb35feffc4b8b9008b3c4" ]`, `[ "decipher", "readable" ]`, `[ "decipher", "prefinish" ]`, From f8aec41b9e52c2f4a00e04fd60632abf8defc0a6 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Fri, 5 Jun 2026 13:27:48 +0000 Subject: [PATCH 13/61] streams: let destroyed-flagged readables still flush to piped destinations [build images] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deliberate divergence from Node 26 (nodejs/node#62557 made pause()/resume() early-return on destroyed streams). Legacy Readable subclasses like fd-slicer assign `this.destroyed = true` — which hits the prototype setter on modern streams — right before push(null). With the upstream guard, a piped destination's 'drain' can no longer resume the source, so the last buffered chunk is silently dropped and the pipeline never finishes. In practice that breaks yauzl → extract-zip → puppeteer browser installs and other zip/tar tooling, and the same hang reproduces on Node 26.3.0 itself. Keep the Node 24 behavior of letting such streams flush their buffer, and replace the no-op assertion test with a regression test for the fd-slicer pattern (verified: extract-zip now extracts a full chrome-headless-shell archive instead of stopping after the first entries). --- src/js/internal/streams/readable.ts | 14 ++++--- test/js/node/stream/node-stream.test.js | 54 +++++++++++++++++++------ 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/src/js/internal/streams/readable.ts b/src/js/internal/streams/readable.ts index d307e26df5b..b18458c4213 100644 --- a/src/js/internal/streams/readable.ts +++ b/src/js/internal/streams/readable.ts @@ -1132,9 +1132,13 @@ function nReadingNextTick(self) { // If the user uses them, then switch into old mode. Readable.prototype.resume = function () { const state = this._readableState; - if ((state[kState] & kDestroyed) !== 0) { - return this; - } + // Deliberate divergence from Node 26: upstream early-returns here (and in + // pause()) when the stream is destroyed. Legacy Readable subclasses like + // fd-slicer assign `this.destroyed = true` (the prototype setter) right + // before push(null), so with the guard a piped destination's drain can no + // longer resume the source and the final buffered chunk is never delivered — + // silently truncating yauzl/extract-zip/puppeteer downloads. Keep the + // Node 24 behavior of letting destroyed streams flush their buffer. if ((state[kState] & kFlowing) === 0) { $debug("resume"); // We flow only if there is no one listening @@ -1174,9 +1178,7 @@ function resume_(stream, state) { Readable.prototype.pause = function () { const state = this._readableState; - if ((state[kState] & kDestroyed) !== 0) { - return this; - } + // No destroyed early-return: see the comment in resume() above. $debug("call pause"); if ((state[kState] & (kHasFlowing | kFlowing)) !== kHasFlowing) { $debug("pause"); diff --git a/test/js/node/stream/node-stream.test.js b/test/js/node/stream/node-stream.test.js index 8c03bb2b133..9a19bec76d8 100644 --- a/test/js/node/stream/node-stream.test.js +++ b/test/js/node/stream/node-stream.test.js @@ -821,19 +821,47 @@ describe("node v26 stream semantics", () => { expect(r.read()).toBeNull(); }); - // Upstream: nodejs/node#62557 (test-stream-readable-pause-and-resume.js). - it("pause() and resume() are no-ops on destroyed streams", async () => { - const r = new Readable({ read() {} }); - r.destroy(); - const emitted = []; - r.on("pause", () => emitted.push("pause")); - r.on("resume", () => emitted.push("resume")); - expect(r.resume()).toBe(r); - expect(r.readableFlowing).toBeNull(); - expect(r.pause()).toBe(r); - expect(r.readableFlowing).toBeNull(); - await new Promise(resolve => setImmediate(resolve)); - expect(emitted).toEqual([]); + // Deliberate divergence from Node 26 (nodejs/node#62557 made pause/resume + // no-ops on destroyed streams): legacy Readable subclasses like fd-slicer + // (yauzl → extract-zip → puppeteer/electron tooling) assign + // `this.destroyed = true` via the prototype setter right before push(null). + // With the upstream guard, a piped destination's drain can no longer resume + // the source, so the final buffered chunk is silently dropped and the + // pipeline never finishes. We keep the Node 24 behavior: a destroyed-flagged + // stream still flushes its buffered data to a piped destination. + it("drain still resumes a source that flagged itself destroyed before EOF (fd-slicer pattern)", async () => { + const { Transform } = require("node:stream"); + const chunks = [Buffer.alloc(65536, 1), Buffer.alloc(65536, 2), Buffer.alloc(40000, 3)]; + const src = new Readable({ + read() { + const chunk = chunks.shift(); + if (chunk) { + this.push(chunk); + } else { + // fd-slicer's ReadStream._read: sets the destroyed flag (which hits + // the prototype setter on modern streams) and then pushes EOF. + this.destroyed = true; + this.push(null); + } + }, + }); + // Small writableHighWaterMark forces write() to return false so the pipe + // pauses and must be revived by 'drain' → src.resume(). + const slow = new Transform({ + writableHighWaterMark: 1024, + transform(chunk, encoding, callback) { + setImmediate(() => callback(null, chunk)); + }, + }); + let received = 0; + slow.on("data", c => (received += c.length)); + const ended = new Promise((resolve, reject) => { + slow.on("end", resolve); + slow.on("error", reject); + }); + src.pipe(slow); + await ended; + expect(received).toBe(65536 * 2 + 40000); }); // Upstream: nodejs/node#60907 (test-stream-compose-operator.js). From e194e98056fa4e71b0308608a2a8eaa459fbe166 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Fri, 5 Jun 2026 18:49:47 +0000 Subject: [PATCH 14/61] next-pages tests: skip puppeteer browser download when it can't be used [build images] The dev-server-puppeteer launcher already prefers a system Chromium when one is installed (CI bootstraps one on every Linux flavor), and several CI platforms have no Chrome for Testing build at all (linux-arm64, windows-arm64). The unconditional download during bun install wasted ~150MB per run and, worse, a half-finished download left in the shared agent cache by an earlier failed run makes @puppeteer/browsers refuse every later install ("browser folder exists but the executable is missing"). Set PUPPETEER_SKIP_DOWNLOAD for the install step when a system browser exists or the platform can't run the downloaded one. --- .../next-pages/test/dev-server-ssr-100.test.ts | 16 +++++++++++++++- .../next-pages/test/dev-server.test.ts | 16 +++++++++++++++- .../next-pages/test/next-build.test.ts | 16 +++++++++++++++- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/test/integration/next-pages/test/dev-server-ssr-100.test.ts b/test/integration/next-pages/test/dev-server-ssr-100.test.ts index 2dbef083eb4..d3b3c6f66dd 100644 --- a/test/integration/next-pages/test/dev-server-ssr-100.test.ts +++ b/test/integration/next-pages/test/dev-server-ssr-100.test.ts @@ -13,6 +13,20 @@ expect.extend({ toMatchNodeModulesAt }); let root = tmpdirSync(); +// The dev-server-puppeteer launcher prefers a system Chromium when one is +// installed (CI bootstraps one on every Linux flavor), and several platforms +// have no Chrome for Testing build at all (linux-arm64, windows-arm64). Skip +// puppeteer's browser download in those cases: it wastes ~150MB per run and a +// half-extracted download left in the shared agent cache by an earlier failed +// run otherwise fails every later install ("browser folder exists but the +// executable is missing"). +const hasSystemChromium = !!(Bun.which("chromium-browser") || Bun.which("chromium") || Bun.which("chrome")); +const skipBrowserDownload = + hasSystemChromium || + (process.platform === "linux" && process.arch === "arm64") || + (process.platform === "win32" && (!!process.env.CI || !!process.env.BUILDKITE)); +const puppeteerInstallEnv = skipBrowserDownload ? { PUPPETEER_SKIP_DOWNLOAD: "1" } : {}; + beforeAll(async () => { await rm(root, { recursive: true, force: true }); await cp(join(import.meta.dir, "../"), root, { recursive: true, force: true }); @@ -93,7 +107,7 @@ async function startDevServer() { const install = Bun.spawnSync([bunExe(), "i"], { cwd: root, - env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(root, "bunstall") }, + env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(root, "bunstall"), ...puppeteerInstallEnv }, stdout: "inherit", stderr: "inherit", stdin: "inherit", diff --git a/test/integration/next-pages/test/dev-server.test.ts b/test/integration/next-pages/test/dev-server.test.ts index b5948474463..45dd98ef161 100644 --- a/test/integration/next-pages/test/dev-server.test.ts +++ b/test/integration/next-pages/test/dev-server.test.ts @@ -12,6 +12,20 @@ expect.extend({ toMatchNodeModulesAt }); let root = tmpdirSync(); +// The dev-server-puppeteer launcher prefers a system Chromium when one is +// installed (CI bootstraps one on every Linux flavor), and several platforms +// have no Chrome for Testing build at all (linux-arm64, windows-arm64). Skip +// puppeteer's browser download in those cases: it wastes ~150MB per run and a +// half-extracted download left in the shared agent cache by an earlier failed +// run otherwise fails every later install ("browser folder exists but the +// executable is missing"). +const hasSystemChromium = !!(Bun.which("chromium-browser") || Bun.which("chromium") || Bun.which("chrome")); +const skipBrowserDownload = + hasSystemChromium || + (process.platform === "linux" && process.arch === "arm64") || + (process.platform === "win32" && (!!process.env.CI || !!process.env.BUILDKITE)); +const puppeteerInstallEnv = skipBrowserDownload ? { PUPPETEER_SKIP_DOWNLOAD: "1" } : {}; + beforeAll(async () => { await rm(root, { recursive: true, force: true }); await cp(join(import.meta.dir, "../"), root, { recursive: true, force: true }); @@ -92,7 +106,7 @@ beforeAll(async () => { const install = Bun.spawnSync([bunExe(), "i"], { cwd: root, - env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(root, ".bun-install") }, + env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(root, ".bun-install"), ...puppeteerInstallEnv }, stdout: "inherit", stderr: "inherit", stdin: "inherit", diff --git a/test/integration/next-pages/test/next-build.test.ts b/test/integration/next-pages/test/next-build.test.ts index 6f3bbb3ab32..10fb5188999 100644 --- a/test/integration/next-pages/test/next-build.test.ts +++ b/test/integration/next-pages/test/next-build.test.ts @@ -10,6 +10,20 @@ expect.extend({ toMatchNodeModulesAt }); const root = join(import.meta.dir, "../"); +// The dev-server-puppeteer launcher prefers a system Chromium when one is +// installed (CI bootstraps one on every Linux flavor), and several platforms +// have no Chrome for Testing build at all (linux-arm64, windows-arm64). Skip +// puppeteer's browser download in those cases: it wastes ~150MB per run and a +// half-extracted download left in the shared agent cache by an earlier failed +// run otherwise fails every later install ("browser folder exists but the +// executable is missing"). +const hasSystemChromium = !!(Bun.which("chromium-browser") || Bun.which("chromium") || Bun.which("chrome")); +const skipBrowserDownload = + hasSystemChromium || + (process.platform === "linux" && process.arch === "arm64") || + (process.platform === "win32" && (!!process.env.CI || !!process.env.BUILDKITE)); +const puppeteerInstallEnv = skipBrowserDownload ? { PUPPETEER_SKIP_DOWNLOAD: "1" } : {}; + async function tempDirToBuildIn() { const dir = tmpdirSync( "next-" + Math.ceil(performance.now() * 1000).toString(36) + Math.random().toString(36).substring(2, 8), @@ -32,7 +46,7 @@ async function tempDirToBuildIn() { const install = Bun.spawnSync([bunExe(), "i"], { cwd: dir, - env: bunEnv, + env: { ...bunEnv, ...puppeteerInstallEnv }, stdin: "inherit", stdout: "inherit", stderr: "inherit", From 3771d7a02b5708e062f37a1165600d98b8ff4e9d Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Fri, 5 Jun 2026 21:49:19 +0000 Subject: [PATCH 15/61] Address review feedback: escape-slot lifetime, set-cookie consistency, goaway semantics [build images] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - v8: reserve the EscapableHandleScope escape slot at construction (like real V8) instead of allocating it inside Escape(). Allocated at Escape() time it sits above any in-scope inline handle grants, so the inline ~HandleScope's DeleteExtensions swept the just-escaped handle together with the scope. The reservation lives in a side registry keyed by the scope's address because the V8 ABI leaves exactly one usable word in the object; entries are purged when their buffer clears, and a reused stack address overwrites its stale entry. New fixture test creates inline Local copies inside the scope before escaping. Also: createRawHandleSlot now does its two bookkeeping steps under one lock. - _http_outgoing: the headers getter/setter, appendHeader and getRawHeaderNames now agree with the kEmptySetCookie marker — the getter mirrors getHeaders(), replacing the header bag or appending a cookie drops the marker, and getRawHeaderNames can't report set-cookie twice. - FetchHeaders.getRawKeys: the HTTPHeaderMap iterator only walks the common and uncommon segments while size() also counts set-cookie values, so the returned array had trailing holes whenever set-cookie was present; size for unique names and append "Set-Cookie" explicitly. - http2: receiving GOAWAY now mirrors Node — NGHTTP2_NO_ERROR begins a graceful close() (request() then throws ERR_HTTP2_GOAWAY_SESSION), any other code destroys the session with ERR_HTTP2_SESSION_ERROR. The rejected-stream test asserted the old behavior; its expectation now matches what a real Node 26 client reports for the same goaway. - tests: hoist the _http_common require to module scope, drop a redundant in-test require, probe the prototype's outputData directly instead of spreading the prototype (which invoked unrelated deprecated getters), and share the puppeteer skip-download env via a harness helper. --- src/js/node/_http_outgoing.ts | 19 +++++++++-- src/js/node/http2.ts | 14 ++++++-- .../v8/V8EscapableHandleScopeBase.cpp | 16 +++++++-- src/jsc/bindings/v8/V8HandleScope.cpp | 6 ++++ src/jsc/bindings/v8/shim/GlobalInternals.h | 20 +++++++++++ .../bindings/v8/shim/HandleScopeBuffer.cpp | 15 ++++---- src/jsc/bindings/v8/shim/HandleScopeBuffer.h | 6 ++++ src/jsc/bindings/webcore/JSFetchHeaders.cpp | 14 ++++++-- test/harness.ts | 21 +++++++++++- .../test/dev-server-ssr-100.test.ts | 16 ++------- .../next-pages/test/dev-server.test.ts | 24 ++++++------- .../next-pages/test/next-build.test.ts | 16 ++------- test/js/node/http/node-http-parser.test.ts | 2 +- test/js/node/http/node-http.test.ts | 22 ++++++++++-- test/js/node/http2/node-http2.test.js | 6 +++- test/js/node/stream/node-stream.test.js | 1 - test/napi/napi-app/bun.lock | 1 + test/v8/v8-module/main.cpp | 34 +++++++++++++++++++ test/v8/v8.test.ts | 4 +++ 19 files changed, 192 insertions(+), 65 deletions(-) diff --git a/src/js/node/_http_outgoing.ts b/src/js/node/_http_outgoing.ts index 94113ec0b36..632edfbe4d4 100644 --- a/src/js/node/_http_outgoing.ts +++ b/src/js/node/_http_outgoing.ts @@ -221,6 +221,10 @@ const OutgoingMessagePrototype = { _headerNames: undefined, appendHeader(name, value) { validateString(name, "name"); + if (this[kEmptySetCookie] && name.length === 10 && name.toLowerCase() === "set-cookie") { + // An appended cookie supersedes the present-but-empty marker. + this[kEmptySetCookie] = false; + } var headers = (this[headersSymbol] ??= new Headers()); headers.append(name, value); return this; @@ -269,7 +273,9 @@ const OutgoingMessagePrototype = { const emptySetCookie = this[kEmptySetCookie]; if (!headers) return emptySetCookie ? [emptySetCookie] : []; const names = getRawKeys.$call(headers); - if (emptySetCookie) names.push(emptySetCookie); + if (emptySetCookie && !names.some(name => typeof name === "string" && name.toLowerCase() === "set-cookie")) { + names.push(emptySetCookie); + } return names; }, @@ -357,10 +363,17 @@ const OutgoingMessagePrototype = { get headers() { const headers = this[headersSymbol]; - if (!headers) return kEmptyObject; - return headers.toJSON(); + if (!headers) return this[kEmptySetCookie] ? { "set-cookie": [] } : kEmptyObject; + const json = headers.toJSON(); + if (this[kEmptySetCookie] && json["set-cookie"] === undefined) { + json["set-cookie"] = []; + } + return json; }, set headers(value) { + // Replacing the whole header bag drops the present-but-empty set-cookie + // marker; the new Headers determines set-cookie state from here on. + this[kEmptySetCookie] = false; this[headersSymbol] = new Headers(value); }, diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index a5e6dbe159f..5f599a7f426 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -3526,9 +3526,19 @@ class ClientHttp2Session extends Http2Session { }, goaway(self: ClientHttp2Session, errorCode: number, lastStreamId: number, opaqueData: Buffer) { if (!self) return; + if (self.destroyed) return; self.emit("goaway", errorCode, lastStreamId, opaqueData || Buffer.allocUnsafe(0)); - if (self.closed) return; - self.destroy(undefined, errorCode); + if (errorCode === NGHTTP2_NO_ERROR) { + // A no-error GOAWAY begins a graceful shutdown: no new streams + // permitted (request() throws ERR_HTTP2_GOAWAY_SESSION while the + // session is closed-but-not-destroyed), but existing streams may + // finish naturally. + self.close(); + } else { + // Mirror Node: destroy immediately with an error, but send our own + // goaway with NGHTTP2_NO_ERROR since this side had no error. + self.destroy($ERR_HTTP2_SESSION_ERROR(errorCode), NGHTTP2_NO_ERROR); + } }, end(self: ClientHttp2Session, errorCode: number, lastStreamId: number, opaqueData: Buffer) { if (!self) return; diff --git a/src/jsc/bindings/v8/V8EscapableHandleScopeBase.cpp b/src/jsc/bindings/v8/V8EscapableHandleScopeBase.cpp index a3616f40d7c..c9fdd48d01d 100644 --- a/src/jsc/bindings/v8/V8EscapableHandleScopeBase.cpp +++ b/src/jsc/bindings/v8/V8EscapableHandleScopeBase.cpp @@ -33,18 +33,30 @@ EscapableHandleScopeBase::EscapableHandleScopeBase(Isolate* isolate) // real V8. An Escape()d value must survive this scope, which that same buffer provides -- // capture it now so Escape still targets it even if (with old-ABI addons) a deeper scope is // current by then. + // + // Reserve the escape slot NOW, like real V8: its storage index must be below every handle + // created inside this scope, or HandleScope::DeleteExtensions (run by V8 14's inline + // ~HandleScope) would sweep the just-escaped handle together with the scope's grants. The + // reservation is kept in a side registry keyed by `this` because the V8 ABI leaves exactly + // one Bun-usable word in this object (m_escapeBuffer). auto* current = isolate->globalInternals()->currentHandleScope(); RELEASE_ASSERT(current, "EscapableHandleScope created without an active handle scope"); m_escapeBuffer = current->m_buffer; + shim::Handle* reserved = current->m_buffer->reserveEscapeHandle(); + isolate->globalInternals()->escapeReservations().set(this, shim::GlobalInternals::EscapeReservation { reserved, current->m_buffer }); } -// Create a handle for escape_value in the scope this object escapes to, and return its slot +// Fill the escape slot reserved at construction with escape_value and return its location. uintptr_t* EscapableHandleScopeBase::EscapeSlot(uintptr_t* escape_value) { RELEASE_ASSERT(m_escapeBuffer != nullptr, "EscapableHandleScope::Escape called multiple times"); + auto reservation = m_isolate->globalInternals()->escapeReservations().take(this); + RELEASE_ASSERT(reservation.handle && reservation.buffer == m_escapeBuffer, + "EscapableHandleScope escape reservation missing"); TaggedPointer* newHandle = m_escapeBuffer->createHandleFromExistingObject( TaggedPointer::fromRaw(*escape_value), - m_isolate); + m_isolate, + reservation.handle); m_escapeBuffer = nullptr; return newHandle->asRawPtrLocation(); } diff --git a/src/jsc/bindings/v8/V8HandleScope.cpp b/src/jsc/bindings/v8/V8HandleScope.cpp index 233189c2f91..093a20ff92b 100644 --- a/src/jsc/bindings/v8/V8HandleScope.cpp +++ b/src/jsc/bindings/v8/V8HandleScope.cpp @@ -57,9 +57,15 @@ HandleScope::~HandleScope() if (auto* current = m_isolate->globalInternals()->currentHandleScope()) { current->m_buffer->deleteGrantsBack(data->limit); } + // This frame is an escapable scope going through the exported destructor (old ABI); + // drop its escape reservation if Escape() was never called. + m_isolate->globalInternals()->escapeReservations().remove(this); return; } m_isolate->globalInternals()->setCurrentHandleScope(m_previousHandleScope); + // Escape reservations in this buffer belong to scopes that are dead or dying (their slots + // are about to be cleared); purge them so stale stack-address keys can't alias new scopes. + m_isolate->globalInternals()->purgeEscapeReservations(m_buffer); m_buffer->clear(); m_buffer = nullptr; } diff --git a/src/jsc/bindings/v8/shim/GlobalInternals.h b/src/jsc/bindings/v8/shim/GlobalInternals.h index e55ac4af20f..2387e6d7f6a 100644 --- a/src/jsc/bindings/v8/shim/GlobalInternals.h +++ b/src/jsc/bindings/v8/shim/GlobalInternals.h @@ -1,6 +1,7 @@ #pragma once #include "BunClientData.h" +#include #include "../V8Isolate.h" #include "Oddball.h" @@ -12,6 +13,7 @@ class HandleScope; namespace shim { class HandleScopeBuffer; +struct Handle; class GlobalInternals : public JSC::JSCell { public: @@ -61,6 +63,23 @@ class GlobalInternals : public JSC::JSCell { HandleScope* currentHandleScope() const { return m_currentHandleScope; } + // Escape-slot reservations for live EscapableHandleScopes, keyed by the + // scope's stack address. The slot is reserved at scope construction (so it + // sits below any handles created inside the scope and survives + // HandleScope::DeleteExtensions) and consumed by EscapeSlot(). Entries are + // purged when their owning buffer clears (scope close) — a scope destroyed + // by V8's inline destructor without calling Escape() has no other hook — + // and a reused stack address simply overwrites the stale entry. + struct EscapeReservation { + Handle* handle { nullptr }; + HandleScopeBuffer* buffer { nullptr }; + }; + WTF::HashMap& escapeReservations() { return m_escapeReservations; } + void purgeEscapeReservations(HandleScopeBuffer* buffer) + { + m_escapeReservations.removeIf([buffer](auto& entry) { return entry.value.buffer == buffer; }); + } + void setCurrentHandleScope(HandleScope* handleScope) { m_currentHandleScope = handleScope; } Isolate* isolate() { return &m_isolate; } @@ -78,6 +97,7 @@ class GlobalInternals : public JSC::JSCell { JSC::LazyClassStructure m_functionTemplateStructure; JSC::LazyClassStructure m_v8FunctionStructure; HandleScope* m_currentHandleScope; + WTF::HashMap m_escapeReservations; JSC::LazyProperty m_globalHandles; Oddball m_undefinedValue; diff --git a/src/jsc/bindings/v8/shim/HandleScopeBuffer.cpp b/src/jsc/bindings/v8/shim/HandleScopeBuffer.cpp index a992e843262..d67369990c8 100644 --- a/src/jsc/bindings/v8/shim/HandleScopeBuffer.cpp +++ b/src/jsc/bindings/v8/shim/HandleScopeBuffer.cpp @@ -71,15 +71,18 @@ TaggedPointer* HandleScopeBuffer::createDoubleHandle(double value) TaggedPointer* HandleScopeBuffer::createRawHandleSlot() { - auto& handle = createEmptyHandle(); - TaggedPointer* slot = handle.slot(); - { - WTF::Locker locker { m_gcLock }; - m_rawGrants.append({ slot, m_storage.size() - 1 }); - } + WTF::Locker locker { m_gcLock }; + m_storage.append(Handle {}); + TaggedPointer* slot = m_storage.last().slot(); + m_rawGrants.append({ slot, m_storage.size() - 1 }); return slot; } +Handle* HandleScopeBuffer::reserveEscapeHandle() +{ + return &createEmptyHandle(); +} + void HandleScopeBuffer::deleteGrantsBack(const uintptr_t* limit) { WTF::Locker locker { m_gcLock }; diff --git a/src/jsc/bindings/v8/shim/HandleScopeBuffer.h b/src/jsc/bindings/v8/shim/HandleScopeBuffer.h index 269a9aab295..d3354be61bf 100644 --- a/src/jsc/bindings/v8/shim/HandleScopeBuffer.h +++ b/src/jsc/bindings/v8/shim/HandleScopeBuffer.h @@ -56,6 +56,12 @@ class HandleScopeBuffer : public JSC::JSCell { // closes. void deleteGrantsBack(const uintptr_t* limit); + // Reserve an empty handle for an EscapableHandleScope's escape slot. + // Called from the scope's constructor so the slot's storage index is below + // every handle created inside the scope (deleteGrantsBack then can't sweep + // it); EscapeSlot() fills it via createHandleFromExistingObject(reuseHandle). + Handle* reserveEscapeHandle(); + // Given a tagged pointer from V8, create a handle around the same object or the same // numeric value // diff --git a/src/jsc/bindings/webcore/JSFetchHeaders.cpp b/src/jsc/bindings/webcore/JSFetchHeaders.cpp index 2d3531e7ced..9df23192caa 100644 --- a/src/jsc/bindings/webcore/JSFetchHeaders.cpp +++ b/src/jsc/bindings/webcore/JSFetchHeaders.cpp @@ -597,11 +597,19 @@ JSC_DEFINE_HOST_FUNCTION(jsFetchHeaders_getRawKeys, (JSC::JSGlobalObject * lexic } FetchHeaders& headers = thisObject->wrapped(); - JSArray* outArray = JSC::JSArray::create(vm, lexicalGlobalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous), headers.size()); - - for (unsigned int i = 0; const auto& header : headers.internalHeaders()) { + // HTTPHeaderMap's iterator covers only the common and uncommon segments; + // set-cookie values live in their own segment, so size() (which counts + // every cookie) used to leave trailing holes in the array. Size for one + // entry per unique name and append "set-cookie" explicitly. + JSArray* outArray = JSC::JSArray::create(vm, lexicalGlobalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous), headers.sizeAfterJoiningSetCookieHeader()); + + unsigned int i = 0; + for (const auto& header : headers.internalHeaders()) { outArray->putDirectIndex(lexicalGlobalObject, i++, jsString(vm, header.name())); } + if (!headers.internalHeaders().getSetCookieHeaders().isEmpty()) { + outArray->putDirectIndex(lexicalGlobalObject, i++, jsString(vm, WTF::httpHeaderNameDefaultCaseStringImpl(HTTPHeaderName::SetCookie))); + } RELEASE_AND_RETURN(scope, JSValue::encode(outArray)); } diff --git a/test/harness.ts b/test/harness.ts index ba13c607199..2b7e5caf926 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -5,6 +5,7 @@ * without always needing to run `bun install` in development. */ +import * as numeric from "_util/numeric.ts"; import { gc as bunGC, sleepSync, spawnSync, unsafe, which, write } from "bun"; import { heapStats } from "bun:jsc"; import { beforeAll, describe, expect } from "bun:test"; @@ -13,7 +14,6 @@ import { readdir, rm, writeFile } from "fs/promises"; import fs, { closeSync, openSync, rmSync } from "node:fs"; import os from "node:os"; import { dirname, isAbsolute, join } from "path"; -import * as numeric from "_util/numeric.ts"; export const BREAKING_CHANGES_BUN_1_2 = false; @@ -1997,3 +1997,22 @@ export function nodeModulesPackages(nodeModulesPath: string): string { return packages.join("\n"); } + +/** + * Env additions for `bun install` in tests whose dependencies trigger + * puppeteer's browser download. The dev-server-puppeteer launcher prefers a + * system Chromium when one is installed (CI bootstraps one on every Linux + * flavor), and several platforms have no Chrome for Testing build at all + * (linux-arm64, windows-arm64 CI). Skip the download there: it wastes ~150MB + * per run, and a half-extracted download left in the shared agent cache by an + * earlier failed run makes @puppeteer/browsers refuse every later install + * ("browser folder exists but the executable is missing"). + */ +export function getPuppeteerInstallEnv(): Record { + const hasSystemChromium = !!(Bun.which("chromium-browser") || Bun.which("chromium") || Bun.which("chrome")); + const skipBrowserDownload = + hasSystemChromium || + (process.platform === "linux" && process.arch === "arm64") || + (process.platform === "win32" && (!!process.env.CI || !!process.env.BUILDKITE)); + return skipBrowserDownload ? { PUPPETEER_SKIP_DOWNLOAD: "1" } : {}; +} diff --git a/test/integration/next-pages/test/dev-server-ssr-100.test.ts b/test/integration/next-pages/test/dev-server-ssr-100.test.ts index d3b3c6f66dd..0e24bc99432 100644 --- a/test/integration/next-pages/test/dev-server-ssr-100.test.ts +++ b/test/integration/next-pages/test/dev-server-ssr-100.test.ts @@ -6,26 +6,14 @@ import { cp, rm } from "fs/promises"; import PQueue from "p-queue"; import { join } from "path"; import { StringDecoder } from "string_decoder"; -import { bunEnv, bunExe, tmpdirSync, toMatchNodeModulesAt } from "../../../harness"; +import { bunEnv, bunExe, getPuppeteerInstallEnv, tmpdirSync, toMatchNodeModulesAt } from "../../../harness"; const { parseLockfile } = install_test_helpers; expect.extend({ toMatchNodeModulesAt }); let root = tmpdirSync(); -// The dev-server-puppeteer launcher prefers a system Chromium when one is -// installed (CI bootstraps one on every Linux flavor), and several platforms -// have no Chrome for Testing build at all (linux-arm64, windows-arm64). Skip -// puppeteer's browser download in those cases: it wastes ~150MB per run and a -// half-extracted download left in the shared agent cache by an earlier failed -// run otherwise fails every later install ("browser folder exists but the -// executable is missing"). -const hasSystemChromium = !!(Bun.which("chromium-browser") || Bun.which("chromium") || Bun.which("chrome")); -const skipBrowserDownload = - hasSystemChromium || - (process.platform === "linux" && process.arch === "arm64") || - (process.platform === "win32" && (!!process.env.CI || !!process.env.BUILDKITE)); -const puppeteerInstallEnv = skipBrowserDownload ? { PUPPETEER_SKIP_DOWNLOAD: "1" } : {}; +const puppeteerInstallEnv = getPuppeteerInstallEnv(); beforeAll(async () => { await rm(root, { recursive: true, force: true }); diff --git a/test/integration/next-pages/test/dev-server.test.ts b/test/integration/next-pages/test/dev-server.test.ts index 45dd98ef161..7faf20f938d 100644 --- a/test/integration/next-pages/test/dev-server.test.ts +++ b/test/integration/next-pages/test/dev-server.test.ts @@ -5,26 +5,22 @@ import { copyFileSync } from "fs"; import { cp, rm } from "fs/promises"; import { join } from "path"; import { StringDecoder } from "string_decoder"; -import { bunEnv, bunExe, isCI, isWindows, tmpdirSync, toMatchNodeModulesAt } from "../../../harness"; +import { + bunEnv, + bunExe, + getPuppeteerInstallEnv, + isCI, + isWindows, + tmpdirSync, + toMatchNodeModulesAt, +} from "../../../harness"; const { parseLockfile } = install_test_helpers; expect.extend({ toMatchNodeModulesAt }); let root = tmpdirSync(); -// The dev-server-puppeteer launcher prefers a system Chromium when one is -// installed (CI bootstraps one on every Linux flavor), and several platforms -// have no Chrome for Testing build at all (linux-arm64, windows-arm64). Skip -// puppeteer's browser download in those cases: it wastes ~150MB per run and a -// half-extracted download left in the shared agent cache by an earlier failed -// run otherwise fails every later install ("browser folder exists but the -// executable is missing"). -const hasSystemChromium = !!(Bun.which("chromium-browser") || Bun.which("chromium") || Bun.which("chrome")); -const skipBrowserDownload = - hasSystemChromium || - (process.platform === "linux" && process.arch === "arm64") || - (process.platform === "win32" && (!!process.env.CI || !!process.env.BUILDKITE)); -const puppeteerInstallEnv = skipBrowserDownload ? { PUPPETEER_SKIP_DOWNLOAD: "1" } : {}; +const puppeteerInstallEnv = getPuppeteerInstallEnv(); beforeAll(async () => { await rm(root, { recursive: true, force: true }); diff --git a/test/integration/next-pages/test/next-build.test.ts b/test/integration/next-pages/test/next-build.test.ts index 10fb5188999..5245f80720e 100644 --- a/test/integration/next-pages/test/next-build.test.ts +++ b/test/integration/next-pages/test/next-build.test.ts @@ -3,26 +3,14 @@ import { expect, test } from "bun:test"; import { copyFileSync, cpSync, promises as fs, readFileSync, rmSync } from "fs"; import { cp } from "fs/promises"; import { join } from "path"; -import { bunEnv, bunExe, isDebug, tmpdirSync, toMatchNodeModulesAt } from "../../../harness"; +import { bunEnv, bunExe, getPuppeteerInstallEnv, isDebug, tmpdirSync, toMatchNodeModulesAt } from "../../../harness"; const { parseLockfile } = install_test_helpers; expect.extend({ toMatchNodeModulesAt }); const root = join(import.meta.dir, "../"); -// The dev-server-puppeteer launcher prefers a system Chromium when one is -// installed (CI bootstraps one on every Linux flavor), and several platforms -// have no Chrome for Testing build at all (linux-arm64, windows-arm64). Skip -// puppeteer's browser download in those cases: it wastes ~150MB per run and a -// half-extracted download left in the shared agent cache by an earlier failed -// run otherwise fails every later install ("browser folder exists but the -// executable is missing"). -const hasSystemChromium = !!(Bun.which("chromium-browser") || Bun.which("chromium") || Bun.which("chrome")); -const skipBrowserDownload = - hasSystemChromium || - (process.platform === "linux" && process.arch === "arm64") || - (process.platform === "win32" && (!!process.env.CI || !!process.env.BUILDKITE)); -const puppeteerInstallEnv = skipBrowserDownload ? { PUPPETEER_SKIP_DOWNLOAD: "1" } : {}; +const puppeteerInstallEnv = getPuppeteerInstallEnv(); async function tempDirToBuildIn() { const dir = tmpdirSync( diff --git a/test/js/node/http/node-http-parser.test.ts b/test/js/node/http/node-http-parser.test.ts index 9d1a700d2d8..e8a4687cc26 100644 --- a/test/js/node/http/node-http-parser.test.ts +++ b/test/js/node/http/node-http-parser.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; const { HTTPParser, ConnectionsList } = process.binding("http_parser"); +const { parsers } = require("node:_http_common"); const kOnHeaders = HTTPParser.kOnHeaders; const kOnHeadersComplete = HTTPParser.kOnHeadersComplete; @@ -251,7 +252,6 @@ describe("ConnectionsList", () => { describe("parserOnHeaders maxHeaderPairs clamp (nodejs/node#61285)", () => { test("only fills remaining capacity instead of pushing the whole batch", () => { - const { parsers } = require("node:_http_common"); const parser = parsers.alloc(); try { const onHeaders = parser[kOnHeaders]; diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index a9cc7ad1799..d2963164d58 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -2281,6 +2281,23 @@ it("setHeaders stores an empty set-cookie array (nodejs/node#59734)", () => { msg3.setHeader("Set-Cookie", []); expect(msg3.getRawHeaderNames()).toEqual(["Set-Cookie"]); expect(msg3.getHeaderNames()).toEqual(["set-cookie"]); + // The Bun-specific headers accessor agrees with getHeaders(). + expect(msg3.headers).toEqual({ "set-cookie": [] }); + + // Appending a cookie supersedes the present-but-empty marker (no duplicate + // name in getRawHeaderNames, value visible everywhere). + msg3.appendHeader("Set-Cookie", "a=1"); + expect(msg3.getHeader("set-cookie")).toEqual(["a=1"]); + expect(msg3.getRawHeaderNames().filter(n => n.toLowerCase() === "set-cookie")).toHaveLength(1); + expect(msg3.getHeaders()["set-cookie"]).toEqual(["a=1"]); + + // Replacing the whole header bag drops the marker. + const msg4 = new OutgoingMessage(); + msg4.setHeader("set-cookie", []); + (msg4 as any).headers = { "x-test": "1" }; + expect(msg4.getHeader("set-cookie")).toBeUndefined(); + expect(msg4.hasHeader("set-cookie")).toBe(false); + expect(msg4.getHeaderNames()).toEqual(["x-test"]); }); it("https.Agent applies defaultPort/protocol through options (nodejs/node#58980)", () => { @@ -2433,10 +2450,9 @@ it("OutgoingMessage outputData is per-instance and _flushOutput is defined", () expect(new OutgoingMessage().outputData.length).toBe(0); // Like Node, the prototype has no outputData property at all; reading it off - // the prototype (e.g. a spread or inspection) must not materialize shared - // state on the prototype. + // the prototype must not materialize shared state on the prototype. expect(Object.getOwnPropertyDescriptor(OutgoingMessage.prototype, "outputData")).toBeUndefined(); - void { ...OutgoingMessage.prototype }; + void (OutgoingMessage.prototype as any).outputData; const c = new OutgoingMessage(); const d = new OutgoingMessage(); c.outputData.push({ data: "y", encoding: "utf8", callback: null }); diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index c991d3644f4..99112601a2f 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -1787,9 +1787,13 @@ it("http2 client receives 'goaway' when the server rejects a stream", async () = const { code } = await goawayReceived; expect(code).toBe(http2.constants.NGHTTP2_ENHANCE_YOUR_CALM); - expect(sessionError?.code).not.toBe("ERR_HTTP2_SESSION_ERROR"); await clientClosed; + // Like Node, a non-NO_ERROR GOAWAY destroys the session with + // ERR_HTTP2_SESSION_ERROR (verified against Node 26: a server-sent + // ENHANCE_YOUR_CALM goaway yields exactly this error on the client). + expect(sessionError?.code).toBe("ERR_HTTP2_SESSION_ERROR"); + expect(sessionError?.message).toBe("Session closed with error code 11"); } finally { server.close(); } diff --git a/test/js/node/stream/node-stream.test.js b/test/js/node/stream/node-stream.test.js index 9a19bec76d8..81248e31d89 100644 --- a/test/js/node/stream/node-stream.test.js +++ b/test/js/node/stream/node-stream.test.js @@ -830,7 +830,6 @@ describe("node v26 stream semantics", () => { // pipeline never finishes. We keep the Node 24 behavior: a destroyed-flagged // stream still flushes its buffered data to a piped destination. it("drain still resumes a source that flagged itself destroyed before EOF (fd-slicer pattern)", async () => { - const { Transform } = require("node:stream"); const chunks = [Buffer.alloc(65536, 1), Buffer.alloc(65536, 2), Buffer.alloc(40000, 3)]; const src = new Readable({ read() { diff --git a/test/napi/napi-app/bun.lock b/test/napi/napi-app/bun.lock index 605f6d47777..099875ae95c 100644 --- a/test/napi/napi-app/bun.lock +++ b/test/napi/napi-app/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "napi-buffer-bug", diff --git a/test/v8/v8-module/main.cpp b/test/v8/v8-module/main.cpp index 2591fb9478c..b97efd71352 100644 --- a/test/v8/v8-module/main.cpp +++ b/test/v8/v8-module/main.cpp @@ -639,6 +639,38 @@ void test_v8_escapable_handle_scope(const FunctionCallbackInfo &info) { LOG_EXPR(n->Value()); } +// Regression test: the escape slot must be reserved when the escapable scope +// opens, not when Escape() is called. With Node 26 headers the inline +// ~HandleScope calls DeleteExtensions, which frees every handle created +// inside the scope — including, before the fix, an escape handle allocated at +// Escape() time after in-scope Local copies. +Local escape_after_inline_handles(Isolate *isolate) { + EscapableHandleScope ehs(isolate); + Local value = + String::NewFromUtf8(isolate, "escaped-after-inline").ToLocalChecked(); + // These go through the headers' inline CreateHandle (HandleScope::Extend + // grants) and are swept by DeleteExtensions when the scope closes. + Local copy1 = Local::New(isolate, Local::Cast(value)); + Local copy2 = Local::New(isolate, copy1); + (void)copy2; + return ehs.Escape(value); +} + +void test_v8_escapable_handle_scope_inline_grants( + const FunctionCallbackInfo &info) { + Isolate *isolate = info.GetIsolate(); + Local s = escape_after_inline_handles(isolate); + // Create more handles so a freed escape slot would be overwritten before we + // read it back. + for (int i = 0; i < 16; i++) { + (void)Number::New(isolate, i * 1.5); + } + LOG_VALUE_KIND(s); + char buf[32]; + s->WriteUtf8V2(isolate, buf, sizeof buf, String::WriteFlags::kNullTerminate); + LOG_EXPR(buf); +} + void test_uv_os_getpid(const FunctionCallbackInfo &info) { #ifndef _WIN32 assert(getpid() == uv_os_getpid()); @@ -1230,6 +1262,8 @@ void initialize(Local exports, Local module, NODE_SET_METHOD(exports, "test_handle_scope_gc", test_handle_scope_gc); NODE_SET_METHOD(exports, "test_v8_escapable_handle_scope", test_v8_escapable_handle_scope); + NODE_SET_METHOD(exports, "test_v8_escapable_handle_scope_inline_grants", + test_v8_escapable_handle_scope_inline_grants); NODE_SET_METHOD(exports, "test_uv_os_getpid", test_uv_os_getpid); NODE_SET_METHOD(exports, "test_uv_os_getppid", test_uv_os_getppid); NODE_SET_METHOD(exports, "test_v8_object_get_by_key", diff --git a/test/v8/v8.test.ts b/test/v8/v8.test.ts index e7b2d3a5441..76d03bb39e0 100644 --- a/test/v8/v8.test.ts +++ b/test/v8/v8.test.ts @@ -305,6 +305,10 @@ describe.todoIf(isBroken && isMusl)("node:v8", () => { it("keeps handles alive in the outer scope", async () => { await checkSameOutput("test_v8_escapable_handle_scope"); }); + + it("escaped handles survive in-scope inline handle creation", async () => { + await checkSameOutput("test_v8_escapable_handle_scope_inline_grants"); + }); }); describe("MaybeLocal", () => { From 71e4ae0472c5ccb6c043451f56ff0b8e57e247d4 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 6 Jun 2026 03:36:33 +0000 Subject: [PATCH 16/61] http2: destroy streams on socket close; never RST a never-started request [build images] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fallouts from mirroring Node's GOAWAY handling surfaced by CI: - After a graceful close(), the socket dying left active streams hanging forever: #onClose ran stream.close(NGHTTP2_CANCEL) and then detached the parser, so the writable side could never finish and 'close' never fired (test-http2-server-shutdown-options-errors timed out). Mirror Node's closeSession: hard-destroy every stream that survives the cancel pass, on both client and server sessions. - A request created with an already-aborted signal went through the native parser, which RST'd a stream the peer never saw — a connection error that makes conforming servers reply GOAWAY(INTERNAL_ERROR). The old code swallowed that session error; now that goaway destroys the session with ERR_HTTP2_SESSION_ERROR like Node, the noise became visible. Like Node, never touch the wire: create an id-less stream and destroy it with an AbortError on the next tick (the stream reports aborted=true and rstCode CANCEL but does not emit 'aborted' since the request never started). Also for CI on Windows: pass -Denable_lto=false -Denable_thin_lto=false -Dlto_jobs= to node-gyp in the v8/napi/dlopen test builds. A clang-cl-built Node 26 reports thin-LTO build flags in process.config, node-gyp copies them into addon builds, and MSVC's link.exe hard-fails on /opt:lldltojobs. The defines are no-ops elsewhere. And when puppeteer's browser download can't be skipped, install into a fresh per-run PUPPETEER_CACHE_DIR (forwarded to the launcher) so a half-extracted download left in the shared agent cache by an earlier failed run can't block the install. --- src/js/node/http2.ts | 27 ++++++++++++++ test/harness.ts | 9 ++++- .../next-pages/test/dev-server-puppeteer.ts | 10 +++-- .../next-pages/test/dev-server.test.ts | 2 +- .../process/dlopen-duplicate-load.test.ts | 2 +- .../process/dlopen-non-object-exports.test.ts | 2 +- test/napi/napi-app/package.json | 4 +- test/napi/node-napi-tests/harness.ts | 2 +- test/v8/v8.test.ts | 37 ++++++++++++++++++- 9 files changed, 83 insertions(+), 12 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 5f599a7f426..f99eb18f7b2 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -3041,6 +3041,7 @@ class ServerHttp2Session extends Http2Session { const parser = this.#parser; if (parser) { parser.emitAbortToAllStreams(); + parser.forEachStream(streamSocketClosed); parser.detach(); this.#parser = null; } @@ -3356,6 +3357,17 @@ function emitTimeout(session: ClientHttp2Session) { function streamCancel(stream: Http2Stream) { stream.close(NGHTTP2_CANCEL); } + +// After the socket is gone a graceful close can never complete — the parser +// is detached, so the stream's writable side has nothing left to flush +// through and 'finish'/'close' would never fire. Mirror Node's closeSession, +// which hard-destroys every stream that is still alive after the +// close(NGHTTP2_CANCEL) pass. +function streamSocketClosed(stream: Http2Stream) { + if (!stream.destroyed) { + stream.destroy(); + } +} class ClientHttp2Session extends Http2Session { /// close indicates that we called closed #closed: boolean = false; @@ -3616,6 +3628,7 @@ class ClientHttp2Session extends Http2Session { const err = this.connecting ? $ERR_SOCKET_CLOSED() : null; if (parser) { parser.forEachStream(streamCancel); + parser.forEachStream(streamSocketClosed); parser.detach(); this.#parser = null; } @@ -3992,6 +4005,20 @@ class ClientHttp2Session extends Http2Session { options = { ...options, endStream: true }; } } + // Like Node, a request whose signal is already aborted never touches the + // wire: the stream is created without an id and destroyed with an + // AbortError on the next tick (_destroy skips the RST for id-less + // streams). Sending an RST for a stream the peer never saw is a + // connection error that makes conforming servers reply with GOAWAY. + if ($isObject(options) && options.signal && options.signal.aborted) { + const req = new ClientHttp2Stream(undefined, this, headers); + const signal = options.signal; + // The request never started, so the stream counts as aborted but the + // 'aborted' event is not emitted — only the AbortError. + req[kAborted] = true; + process.nextTick(() => req.destroy($makeAbortError(undefined, { cause: signal.reason }))); + return req; + } let stream_id: number = this.#parser.getNextStream(); if (stream_id < 0) { const req = new ClientHttp2Stream(undefined, this, headers); diff --git a/test/harness.ts b/test/harness.ts index 2b7e5caf926..626a9787a19 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -2014,5 +2014,12 @@ export function getPuppeteerInstallEnv(): Record { hasSystemChromium || (process.platform === "linux" && process.arch === "arm64") || (process.platform === "win32" && (!!process.env.CI || !!process.env.BUILDKITE)); - return skipBrowserDownload ? { PUPPETEER_SKIP_DOWNLOAD: "1" } : {}; + if (skipBrowserDownload) { + return { PUPPETEER_SKIP_DOWNLOAD: "1" }; + } + // No system browser: download into a fresh per-run cache instead of the + // shared agent-global one — a half-extracted download left there by an + // earlier failed run otherwise blocks every later install. Pass the same + // env to whatever later launches puppeteer so it finds the browser. + return { PUPPETEER_CACHE_DIR: tmpdirSync("puppeteer-cache") }; } diff --git a/test/integration/next-pages/test/dev-server-puppeteer.ts b/test/integration/next-pages/test/dev-server-puppeteer.ts index 9d4c0feaa8f..35d2775b019 100644 --- a/test/integration/next-pages/test/dev-server-puppeteer.ts +++ b/test/integration/next-pages/test/dev-server-puppeteer.ts @@ -24,12 +24,16 @@ if (!browserPath) { if (process.platform === "darwin") { try { const { execSync } = require("child_process"); - const cachePath = join(process.env.HOME || "~", ".cache", "puppeteer"); + const cachePath = process.env.PUPPETEER_CACHE_DIR || join(process.env.HOME || "~", ".cache", "puppeteer"); // Remove quarantine from the entire puppeteer cache execSync(`xattr -rd com.apple.quarantine "${cachePath}" 2>/dev/null || true`, { stdio: "ignore" }); // Also ensure all chrome/chromium binaries in the cache are executable - execSync(`find "${cachePath}" -type f -name "Google Chrome for Testing" -exec chmod +x {} + 2>/dev/null || true`, { stdio: "ignore" }); - execSync(`find "${cachePath}" -type f -name "chrome-headless-shell" -exec chmod +x {} + 2>/dev/null || true`, { stdio: "ignore" }); + execSync(`find "${cachePath}" -type f -name "Google Chrome for Testing" -exec chmod +x {} + 2>/dev/null || true`, { + stdio: "ignore", + }); + execSync(`find "${cachePath}" -type f -name "chrome-headless-shell" -exec chmod +x {} + 2>/dev/null || true`, { + stdio: "ignore", + }); execSync(`find "${cachePath}" -type f -name "chrome" -exec chmod +x {} + 2>/dev/null || true`, { stdio: "ignore" }); } catch {} } diff --git a/test/integration/next-pages/test/dev-server.test.ts b/test/integration/next-pages/test/dev-server.test.ts index 7faf20f938d..f5de4dae037 100644 --- a/test/integration/next-pages/test/dev-server.test.ts +++ b/test/integration/next-pages/test/dev-server.test.ts @@ -160,7 +160,7 @@ test.skipIf(puppeteer_unsupported || (isWindows && isCI))( ({ exited, pid } = Bun.spawn([bunExe(), "test/dev-server-puppeteer.ts", baseUrl], { cwd: root, - env: bunEnv, + env: { ...bunEnv, ...puppeteerInstallEnv }, stdio: ["ignore", "inherit", "inherit"], })); diff --git a/test/js/node/process/dlopen-duplicate-load.test.ts b/test/js/node/process/dlopen-duplicate-load.test.ts index 5d9d5951d3b..bce2737bb44 100644 --- a/test/js/node/process/dlopen-duplicate-load.test.ts +++ b/test/js/node/process/dlopen-duplicate-load.test.ts @@ -60,7 +60,7 @@ NODE_MODULE_CONTEXT_AWARE(addon, demo::Initialize) version: "1.0.0", gypfile: true, scripts: { - install: "node-gyp rebuild", + install: "node-gyp rebuild -- -Denable_lto=false -Denable_thin_lto=false -Dlto_jobs=", }, devDependencies: { "node-gyp": "^11.2.0", diff --git a/test/js/node/process/dlopen-non-object-exports.test.ts b/test/js/node/process/dlopen-non-object-exports.test.ts index a1772a008c4..f38dc12f54a 100644 --- a/test/js/node/process/dlopen-non-object-exports.test.ts +++ b/test/js/node/process/dlopen-non-object-exports.test.ts @@ -59,7 +59,7 @@ NODE_MODULE_CONTEXT_AWARE(addon, demo::Initialize) version: "1.0.0", gypfile: true, scripts: { - install: "node-gyp rebuild", + install: "node-gyp rebuild -- -Denable_lto=false -Denable_thin_lto=false -Dlto_jobs=", }, devDependencies: { "node-gyp": "^11.2.0", diff --git a/test/napi/napi-app/package.json b/test/napi/napi-app/package.json index c3a3ff5a6db..1aa957058cf 100644 --- a/test/napi/napi-app/package.json +++ b/test/napi/napi-app/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "gypfile": true, "scripts": { - "install": "node-gyp rebuild --debug -j max", - "build": "node-gyp rebuild --debug -j max", + "install": "node-gyp rebuild --debug -j max -- -Denable_lto=false -Denable_thin_lto=false -Dlto_jobs=", + "build": "node-gyp rebuild --debug -j max -- -Denable_lto=false -Denable_thin_lto=false -Dlto_jobs=", "clean": "node-gyp clean" }, "devDependencies": { diff --git a/test/napi/node-napi-tests/harness.ts b/test/napi/node-napi-tests/harness.ts index 1f64717d6a4..369ac7b7c78 100644 --- a/test/napi/node-napi-tests/harness.ts +++ b/test/napi/node-napi-tests/harness.ts @@ -7,7 +7,7 @@ const abortingJsNativeApiTests = ["test_finalizer/test_fatal_finalize.js"]; export async function build(dir: string) { const child = spawn({ - cmd: [bunExe(), "x", "node-gyp@11", "rebuild", "--debug", "-j", "max", "--verbose"], + cmd: [bunExe(), "x", "node-gyp@11", "rebuild", "--debug", "-j", "max", "--verbose", "--", "-Denable_lto=false", "-Denable_thin_lto=false", "-Dlto_jobs="], cwd: dir, stderr: "pipe", stdout: "ignore", diff --git a/test/v8/v8.test.ts b/test/v8/v8.test.ts index 76d03bb39e0..c63d4c3a209 100644 --- a/test/v8/v8.test.ts +++ b/test/v8/v8.test.ts @@ -76,8 +76,28 @@ async function build( buildMode == BuildMode.debug ? "--debug" : "--release", "-j", "max", + "--", + "-Denable_lto=false", + "-Denable_thin_lto=false", + "-Dlto_jobs=", ] - : [bunExe(), "run", "node-gyp", "rebuild", "--release", "-j", "max"], // for node.js we don't bother with debug mode + : // for node.js we don't bother with debug mode. The trailing gyp defines + // neutralize LTO flags that leak out of a clang-cl-built Node's + // process.config into MSVC addon builds (link.exe chokes on + // /opt:lldltojobs); they're no-ops elsewhere. + [ + bunExe(), + "run", + "node-gyp", + "rebuild", + "--release", + "-j", + "max", + "--", + "-Denable_lto=false", + "-Denable_thin_lto=false", + "-Dlto_jobs=", + ], cwd: tmpDir, env: bunEnv, stdin: "inherit", @@ -477,7 +497,20 @@ addon.string_utf8_length("\\u00ff".repeat(2 ** 30 + 1)); { const build = spawn({ - cmd: [bunExe(), "--bun", "run", "node-gyp", "rebuild", "--release", "-j", "max"], + cmd: [ + bunExe(), + "--bun", + "run", + "node-gyp", + "rebuild", + "--release", + "-j", + "max", + "--", + "-Denable_lto=false", + "-Denable_thin_lto=false", + "-Dlto_jobs=", + ], cwd, env: bunEnv, stdin: "inherit", From f8c00f11e401c3c2768c71ae48862646e0d2a140 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 6 Jun 2026 04:46:06 +0000 Subject: [PATCH 17/61] v8: restore HandleScopeData when a Bun handle scope pops [build images] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exported ~HandleScope popped the Bun scope stack and cleared its buffer but left the isolate's HandleScopeData alone. If Extend had granted slots from that buffer while the scope was current (an addon's inline Local::New inside e.g. an Array::Iterate callback), next/limit kept pointing into the cleared buffer; the next inline v8::HandleScope then snapshotted that stale limit as prev_limit_, and its DeleteExtensions ran deleteGrantsBack against the enclosing buffer where no grant matches a foreign address — popping every grant, including handles of still-open outer scopes. Snapshot next/limit at scope push (stored in the scope's buffer cell — the 3-word V8 ABI leaves no room in the frame itself) and write them back on pop. New fixture test drives exactly this through Array::Iterate. Also gate the duckdb smoke test on NODE_MODULE_VERSION <= 137: the deprecated duckdb package publishes no prebuilts past ABI 137 (checked npm.duckdb.org for 1.3.1-1.4.1), and node-pre-gyp's fallback source build is not viable in CI. The gate can go away if the test migrates to the N-API based @duckdb/node-api. --- src/jsc/bindings/v8/V8HandleScope.cpp | 13 ++++++ src/jsc/bindings/v8/shim/HandleScopeBuffer.h | 15 +++++++ .../duckdb/duckdb-basic-usage.test.ts | 8 ++++ test/v8/v8-module/main.cpp | 41 +++++++++++++++++++ test/v8/v8.test.ts | 4 ++ 5 files changed, 81 insertions(+) diff --git a/src/jsc/bindings/v8/V8HandleScope.cpp b/src/jsc/bindings/v8/V8HandleScope.cpp index 093a20ff92b..40fa386c1d3 100644 --- a/src/jsc/bindings/v8/V8HandleScope.cpp +++ b/src/jsc/bindings/v8/V8HandleScope.cpp @@ -25,6 +25,10 @@ HandleScope::HandleScope(Isolate* isolate) isolate->globalInternals()->handleScopeBufferStructure(isolate->globalObject()))) { m_isolate->globalInternals()->setCurrentHandleScope(this); + // Snapshot the isolate's HandleScopeData so the pop can restore it; see + // the comment on HandleScopeBuffer::saveHandleScopeData. + auto* data = shim::getHandleScopeData(isolate); + m_buffer->saveHandleScopeData(data->next, data->limit); } HandleScope::~HandleScope() @@ -66,6 +70,15 @@ HandleScope::~HandleScope() // Escape reservations in this buffer belong to scopes that are dead or dying (their slots // are about to be cleared); purge them so stale stack-address keys can't alias new scopes. m_isolate->globalInternals()->purgeEscapeReservations(m_buffer); + // Restore HandleScopeData to its push-time snapshot. If Extend granted + // slots from this buffer while this scope was current, next/limit would + // otherwise keep pointing into the buffer we are about to clear, and the + // next inline v8::HandleScope would capture that stale limit as its + // prev_limit_ — its DeleteExtensions would then pop every grant in the + // (foreign) enclosing buffer, killing handles of still-open outer scopes. + auto* data = shim::getHandleScopeData(m_isolate); + data->next = m_buffer->savedNext(); + data->limit = m_buffer->savedLimit(); m_buffer->clear(); m_buffer = nullptr; } diff --git a/src/jsc/bindings/v8/shim/HandleScopeBuffer.h b/src/jsc/bindings/v8/shim/HandleScopeBuffer.h index d3354be61bf..fe7caeda2b0 100644 --- a/src/jsc/bindings/v8/shim/HandleScopeBuffer.h +++ b/src/jsc/bindings/v8/shim/HandleScopeBuffer.h @@ -62,6 +62,19 @@ class HandleScopeBuffer : public JSC::JSCell { // it); EscapeSlot() fills it via createHandleFromExistingObject(reuseHandle). Handle* reserveEscapeHandle(); + // HandleScopeData::{next,limit} as they were when the owning Bun + // HandleScope was pushed. ~HandleScope writes them back when it pops so + // the isolate's HandleScopeData never dangles into this (cleared) buffer + // — otherwise the next inline v8::HandleScope would snapshot a stale + // limit and its DeleteExtensions would sweep a foreign buffer's grants. + void saveHandleScopeData(uintptr_t* next, uintptr_t* limit) + { + m_savedNext = next; + m_savedLimit = limit; + } + uintptr_t* savedNext() const { return m_savedNext; } + uintptr_t* savedLimit() const { return m_savedLimit; } + // Given a tagged pointer from V8, create a handle around the same object or the same // numeric value // @@ -87,6 +100,8 @@ class HandleScopeBuffer : public JSC::JSCell { // C++ destructors), tripping container-overflow on cell reuse. The heap // buffer is released in clear(). WTF::Vector> m_rawGrants; + uintptr_t* m_savedNext { nullptr }; + uintptr_t* m_savedLimit { nullptr }; Handle& createEmptyHandle(); diff --git a/test/js/third_party/duckdb/duckdb-basic-usage.test.ts b/test/js/third_party/duckdb/duckdb-basic-usage.test.ts index 2861ada7155..8ee3bae518f 100644 --- a/test/js/third_party/duckdb/duckdb-basic-usage.test.ts +++ b/test/js/third_party/duckdb/duckdb-basic-usage.test.ts @@ -7,6 +7,14 @@ if (process.platform === "win32" && process.arch === "arm64") { // duckdb does not distribute win32-arm64 binaries process.exit(0); } +if (Number(process.versions.modules) > 137) { + // The deprecated `duckdb` package only publishes prebuilts up to + // NODE_MODULE_VERSION 137 (checked npm.duckdb.org for 1.3.1-1.4.1: no + // node-v141/-v147 binaries), and node-pre-gyp's --fallback-to-build source + // compile is not viable in CI. Drop this gate if the test migrates to + // @duckdb/node-api, which is N-API based and ABI-independent. + process.exit(0); +} import { describe, expect, test } from "bun:test"; // Must be CJS require so that the above code can exit before we attempt to import DuckDB diff --git a/test/v8/v8-module/main.cpp b/test/v8/v8-module/main.cpp index b97efd71352..ccfab523d3b 100644 --- a/test/v8/v8-module/main.cpp +++ b/test/v8/v8-module/main.cpp @@ -671,6 +671,45 @@ void test_v8_escapable_handle_scope_inline_grants( LOG_EXPR(buf); } +// Regression test: handles created through the headers' inline CreateHandle +// must survive a Bun-internal HandleScope push/pop (Array::Iterate pushes one +// around the iteration callback). If the pop leaves the isolate's +// HandleScopeData pointing into the popped scope's buffer, a later inline +// v8::HandleScope snapshots that stale limit and its DeleteExtensions sweeps +// the enclosing buffer's grants — including `kept`. +void test_v8_locals_survive_nested_call( + const FunctionCallbackInfo &info) { + Isolate *isolate = info.GetIsolate(); + Local context = isolate->GetCurrentContext(); + Local value = + String::NewFromUtf8(isolate, "kept-across-call").ToLocalChecked(); + // Inline grant before the nested scope push. + Local kept = Local::New(isolate, Local::Cast(value)); + Local array = Array::New(isolate, 3); + // Array::Iterate pushes (and pops) a Bun-internal handle scope around the + // callback; the inline Local::New inside makes Extend run while that scope + // is current. + (void)array->Iterate( + context, + [](uint32_t index, Local element, void *data) { + Isolate *iso = static_cast(data); + Local copy = Local::New(iso, element); + (void)copy; + return Array::CallbackResult::kContinue; + }, + isolate); + // Inline scope after the pop: snapshots whatever HandleScopeData now holds. + { + HandleScope inner(isolate); + Local tmp = Local::New(isolate, kept); + (void)tmp; + } // ~HandleScope → DeleteExtensions + char buf[32]; + Local::Cast(kept)->WriteUtf8V2(isolate, buf, sizeof buf, + String::WriteFlags::kNullTerminate); + LOG_EXPR(buf); +} + void test_uv_os_getpid(const FunctionCallbackInfo &info) { #ifndef _WIN32 assert(getpid() == uv_os_getpid()); @@ -1264,6 +1303,8 @@ void initialize(Local exports, Local module, test_v8_escapable_handle_scope); NODE_SET_METHOD(exports, "test_v8_escapable_handle_scope_inline_grants", test_v8_escapable_handle_scope_inline_grants); + NODE_SET_METHOD(exports, "test_v8_locals_survive_nested_call", + test_v8_locals_survive_nested_call); NODE_SET_METHOD(exports, "test_uv_os_getpid", test_uv_os_getpid); NODE_SET_METHOD(exports, "test_uv_os_getppid", test_uv_os_getppid); NODE_SET_METHOD(exports, "test_v8_object_get_by_key", diff --git a/test/v8/v8.test.ts b/test/v8/v8.test.ts index c63d4c3a209..4cf194b47d4 100644 --- a/test/v8/v8.test.ts +++ b/test/v8/v8.test.ts @@ -329,6 +329,10 @@ describe.todoIf(isBroken && isMusl)("node:v8", () => { it("escaped handles survive in-scope inline handle creation", async () => { await checkSameOutput("test_v8_escapable_handle_scope_inline_grants"); }); + + it("inline handles survive a nested call's scope push/pop", async () => { + await checkSameOutput("test_v8_locals_survive_nested_call"); + }); }); describe("MaybeLocal", () => { From 694c0ff9b23f5ca2ec21b8492cf7739ca00a902d Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 6 Jun 2026 06:10:38 +0000 Subject: [PATCH 18/61] http2: validate options.signal before the pre-aborted fast path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches node's ordering and semantics (verified against v26.3.0): validateAbortSignal accepts any object with an 'aborted' property, so a duck-typed { aborted: true } takes the abort fast path — but objects without 'aborted' and non-objects throw ERR_INVALID_ARG_TYPE synchronously, before the fast path can read .aborted. --- src/js/node/http2.ts | 22 ++++++++++++++-------- test/js/node/http2/node-http2.test.js | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index f99eb18f7b2..243ec8a3c5d 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -352,6 +352,7 @@ const { validateInt32, validateBuffer, validateNumber, + validateAbortSignal, } = require("internal/validators"); let utcCache; @@ -4010,14 +4011,19 @@ class ClientHttp2Session extends Http2Session { // AbortError on the next tick (_destroy skips the RST for id-less // streams). Sending an RST for a stream the peer never saw is a // connection error that makes conforming servers reply with GOAWAY. - if ($isObject(options) && options.signal && options.signal.aborted) { - const req = new ClientHttp2Stream(undefined, this, headers); - const signal = options.signal; - // The request never started, so the stream counts as aborted but the - // 'aborted' event is not emitted — only the AbortError. - req[kAborted] = true; - process.nextTick(() => req.destroy($makeAbortError(undefined, { cause: signal.reason }))); - return req; + if ($isObject(options) && options.signal) { + // Node validates the signal before reading .aborted — a duck-typed + // { aborted: true } must throw ERR_INVALID_ARG_TYPE, not short-circuit. + validateAbortSignal(options.signal, "options.signal"); + if (options.signal.aborted) { + const req = new ClientHttp2Stream(undefined, this, headers); + const signal = options.signal; + // The request never started, so the stream counts as aborted but the + // 'aborted' event is not emitted — only the AbortError. + req[kAborted] = true; + process.nextTick(() => req.destroy($makeAbortError(undefined, { cause: signal.reason }))); + return req; + } } let stream_id: number = this.#parser.getNextStream(); if (stream_id < 0) { diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 99112601a2f..3281f44dfed 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -747,6 +747,30 @@ for (const nodeExecutable of [nodeExe(), bunExe()]) { expect(req.aborted).toBeTrue(); // will be true in this case }); + it("signal validation matches node: non-signal objects throw, duck-typed { aborted } is accepted", async () => { + const client = http2.connect(HTTPS_SERVER, TLS_OPTIONS); + client.on("error", () => {}); + try { + // node's validateAbortSignal accepts any object with an 'aborted' + // property ('aborted' in signal), so a duck-typed { aborted: true } + // takes the pre-aborted fast path instead of throwing... + const { promise, resolve, reject } = Promise.withResolvers(); + const req = client.request({ ":path": "/" }, { signal: { aborted: true } }); + req.on("error", err => (err.name === "AbortError" ? resolve() : reject(err))); + await promise; + // ...while objects without 'aborted' (and non-objects) throw + // ERR_INVALID_ARG_TYPE synchronously, before the fast path. + expect(() => client.request({ ":path": "/" }, { signal: {} })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + expect(() => client.request({ ":path": "/" }, { signal: 42 })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + } finally { + client.close(); + } + }); + it("state should work", async () => { const { promise, resolve, reject } = Promise.withResolvers(); const client = http2.connect(HTTPS_SERVER, TLS_OPTIONS); From 2b5e85bd56a13c64cfdc1060e057805b6346a38a Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 6 Jun 2026 06:12:15 +0000 Subject: [PATCH 19/61] packer: switch the windows-x64 bake VM to Standard_D4as_v7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Azure repeatedly failed to allocate Standard_D4ds_v6 in-region (AllocationFailed) during image bakes; D4as_v7 is one of the alternatives Azure's allocation guidance suggests. Build-only VM — CI runner sizes are unchanged in ci.mjs. --- scripts/packer/windows-x64.pkr.hcl | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/packer/windows-x64.pkr.hcl b/scripts/packer/windows-x64.pkr.hcl index 250c7f0ffc7..10fab1c6099 100644 --- a/scripts/packer/windows-x64.pkr.hcl +++ b/scripts/packer/windows-x64.pkr.hcl @@ -14,7 +14,11 @@ source "azure-arm" "windows-x64" { // Build VM — only used during image creation, not for CI runners. // CI runner VM sizes are set in ci.mjs (azureVmSizes). - vm_size = "Standard_D4ds_v6" + // D4as_v7 (AMD): D4ds_v6 hit repeated AllocationFailed (no capacity for + // that size in the region); Azure's allocation-guidance suggested this + // size as an in-region alternative. Build-only VM, so the CPU vendor + // doesn't affect the produced image. + vm_size = "Standard_D4as_v7" // Use existing resource group instead of creating a temp one build_resource_group_name = var.resource_group From e256d3d96ed085e5d61fb902c550469d1212d2a4 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 6 Jun 2026 06:49:25 +0000 Subject: [PATCH 20/61] bootstrap: bump image versions past stale v35 tags [build images] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A build without the [build images] tag fell back to published v35 images, and stale v35 alpine images exist from before the Node 26.3.0 install landed (bootstrap already said Version: 35 while still installing 24.3.0) — agents came up with Node 24, failing the version check and building ABI-137 addons that an ABI-147 runtime rightly refuses to load. Move to v36/v21 so the poisoned tags can never be selected again. --- scripts/bootstrap.ps1 | 2 +- scripts/bootstrap.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/bootstrap.ps1 b/scripts/bootstrap.ps1 index 335bedb0f87..4025149ef27 100755 --- a/scripts/bootstrap.ps1 +++ b/scripts/bootstrap.ps1 @@ -1,4 +1,4 @@ -# Version: 20 +# Version: 21 # A script that installs the dependencies needed to build and test Bun on Windows. # Supports both x64 and ARM64 using Scoop for package management. # Used by Azure [build images] pipeline. diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index a61b1e1b3c0..9c4d7ca7dfa 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -1,5 +1,5 @@ #!/bin/sh -# Version: 35 +# Version: 36 # A script that installs the dependencies needed to build and test Bun. # This should work on macOS and Linux with a POSIX shell. From abd2649267a173fe476dbbca60e606f760616123 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 6 Jun 2026 13:26:12 +0000 Subject: [PATCH 21/61] http2: send GOAWAY on graceful close(); run test node-gyp builds under bun [build images] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Http2Session.close() (client and server) now sends GOAWAY immediately like Node (core.js marks the session closed and calls goaway() right away), so peers stop routing new work to a draining session; the client goaway() gained Node's default arguments. The grpc-js unbind test additionally accepts CANCELLED: a call that wins the race onto the draining session before the GOAWAY/socket-close is processed is torn down with NGHTTP2_CANCEL — the same in-flight teardown Node performs at socket close; which of the three statuses you get is pick-timing. - Run every test-suite node-gyp build under bun (--bun): the gyp -D defines could not neutralize the thin-LTO flags a clang-cl-built Node 26 carries in process.config.target_defaults (node-gyp copies them into config.gypi and MSVC's link.exe fails on /opt:lldltojobs), and the system Node's ABI may not match ours at all. Bun reports the same ABI (147) with clean target_defaults, so the produced modules load in node 26 unchanged. The dlopen fixtures invoke the bun under test explicitly and their beforeAll hooks get a real timeout — the builds legitimately exceed the 5s default under a debug/ASAN binary. --- src/js/node/http2.ts | 13 +++++++++++-- test/js/node/process/dlopen-duplicate-load.test.ts | 10 ++++++++-- .../node/process/dlopen-non-object-exports.test.ts | 10 ++++++++-- test/js/third_party/grpc-js/test-server.test.ts | 11 +++++++++-- test/napi/napi-app/package.json | 4 ++-- test/napi/node-napi-tests/harness.ts | 2 +- 6 files changed, 39 insertions(+), 11 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 243ec8a3c5d..cd3b0f030aa 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -3310,11 +3310,16 @@ class ServerHttp2Session extends Http2Session { // Gracefully closes the Http2Session, allowing any existing streams to complete on their own and preventing new Http2Stream instances from being created. Once closed, http2session.destroy() might be called if there are no open Http2Stream instances. // If specified, the callback function is registered as a handler for the 'close' event. close(callback?: Function) { + if (this.closed || this.destroyed) return; this.#closed = true; if (typeof callback === "function") { - this.on("close", callback); + this.once("close", callback); } + // Like Node, a graceful close sends GOAWAY immediately so the peer stops + // routing new work to this session; the session is destroyed once the + // existing streams finish. + this.goaway(); if (this.#connections === 0) { this.destroy(); } @@ -3740,7 +3745,7 @@ class ClientHttp2Session extends Http2Session { parser.ping(payload); return true; } - goaway(errorCode, lastStreamId, opaqueData) { + goaway(errorCode = NGHTTP2_NO_ERROR, lastStreamId = 0, opaqueData) { return this.#parser?.goaway(errorCode, lastStreamId, opaqueData); } @@ -3889,11 +3894,15 @@ class ClientHttp2Session extends Http2Session { // Gracefully closes the Http2Session, allowing any existing streams to complete on their own and preventing new Http2Stream instances from being created. Once closed, http2session.destroy() might be called if there are no open Http2Stream instances. // If specified, the callback function is registered as a handler for the 'close' event. close(callback: Function) { + if (this.closed || this.destroyed) return; this.#closed = true; if (typeof callback === "function") { this.once("close", callback); } + // Like Node, a graceful close sends GOAWAY immediately so the peer stops + // routing new work to this session. + this.goaway(); if (this.#connections === 0) { this.destroy(); } diff --git a/test/js/node/process/dlopen-duplicate-load.test.ts b/test/js/node/process/dlopen-duplicate-load.test.ts index bce2737bb44..19323124689 100644 --- a/test/js/node/process/dlopen-duplicate-load.test.ts +++ b/test/js/node/process/dlopen-duplicate-load.test.ts @@ -60,7 +60,13 @@ NODE_MODULE_CONTEXT_AWARE(addon, demo::Initialize) version: "1.0.0", gypfile: true, scripts: { - install: "node-gyp rebuild -- -Denable_lto=false -Denable_thin_lto=false -Dlto_jobs=", + // Run node-gyp under the bun being tested: the system Node on Windows + // is built with clang-cl and its process.config leaks thin-LTO flags + // into addon builds (link.exe fails on /opt:lldltojobs), and the + // system Node's ABI may not match ours at all (e.g. older macOS CI + // machines). gyp -D defines can't override target_defaults, so use + // bun's clean process.config instead. + install: `${JSON.stringify(bunExe())} --bun node-gyp rebuild`, }, devDependencies: { "node-gyp": "^11.2.0", @@ -82,7 +88,7 @@ NODE_MODULE_CONTEXT_AWARE(addon, demo::Initialize) } addonPath = join(dir, "build", "Release", "addon.node"); - }); + }, 180_000); test("should load the same module twice successfully", async () => { const testScript = ` diff --git a/test/js/node/process/dlopen-non-object-exports.test.ts b/test/js/node/process/dlopen-non-object-exports.test.ts index f38dc12f54a..9175d80e6e7 100644 --- a/test/js/node/process/dlopen-non-object-exports.test.ts +++ b/test/js/node/process/dlopen-non-object-exports.test.ts @@ -59,7 +59,13 @@ NODE_MODULE_CONTEXT_AWARE(addon, demo::Initialize) version: "1.0.0", gypfile: true, scripts: { - install: "node-gyp rebuild -- -Denable_lto=false -Denable_thin_lto=false -Dlto_jobs=", + // Run node-gyp under the bun being tested: the system Node on Windows + // is built with clang-cl and its process.config leaks thin-LTO flags + // into addon builds (link.exe fails on /opt:lldltojobs), and the + // system Node's ABI may not match ours at all (e.g. older macOS CI + // machines). gyp -D defines can't override target_defaults, so use + // bun's clean process.config instead. + install: `${JSON.stringify(bunExe())} --bun node-gyp rebuild`, }, devDependencies: { "node-gyp": "^11.2.0", @@ -81,7 +87,7 @@ NODE_MODULE_CONTEXT_AWARE(addon, demo::Initialize) } addonPath = join(dir, "build", "Release", "addon.node"); - }); + }, 180_000); test("should throw error when exports is null", async () => { const testScript = ` diff --git a/test/js/third_party/grpc-js/test-server.test.ts b/test/js/third_party/grpc-js/test-server.test.ts index e1dd4c2933b..607afbda763 100644 --- a/test/js/third_party/grpc-js/test-server.test.ts +++ b/test/js/third_party/grpc-js/test-server.test.ts @@ -194,9 +194,16 @@ describe("Server", () => { { deadline: deadline }, (callError2, result) => { assert(callError2); - // DEADLINE_EXCEEDED means that the server is unreachable + // DEADLINE_EXCEEDED means the server was unreachable; + // UNAVAILABLE means the connection dropped before the call. + // CANCELLED happens when the call wins the race onto the + // draining session before the GOAWAY/socket-close is + // processed and is then torn down with NGHTTP2_CANCEL — the + // same in-flight teardown path Node takes at socket close. assert( - callError2.code === grpc.status.DEADLINE_EXCEEDED || callError2.code === grpc.status.UNAVAILABLE, + callError2.code === grpc.status.DEADLINE_EXCEEDED || + callError2.code === grpc.status.UNAVAILABLE || + callError2.code === grpc.status.CANCELLED, ); done(); }, diff --git a/test/napi/napi-app/package.json b/test/napi/napi-app/package.json index 1aa957058cf..b820a510fee 100644 --- a/test/napi/napi-app/package.json +++ b/test/napi/napi-app/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "gypfile": true, "scripts": { - "install": "node-gyp rebuild --debug -j max -- -Denable_lto=false -Denable_thin_lto=false -Dlto_jobs=", - "build": "node-gyp rebuild --debug -j max -- -Denable_lto=false -Denable_thin_lto=false -Dlto_jobs=", + "install": "bun --bun node-gyp rebuild --debug -j max", + "build": "bun --bun node-gyp rebuild --debug -j max", "clean": "node-gyp clean" }, "devDependencies": { diff --git a/test/napi/node-napi-tests/harness.ts b/test/napi/node-napi-tests/harness.ts index 369ac7b7c78..98c99d992f5 100644 --- a/test/napi/node-napi-tests/harness.ts +++ b/test/napi/node-napi-tests/harness.ts @@ -7,7 +7,7 @@ const abortingJsNativeApiTests = ["test_finalizer/test_fatal_finalize.js"]; export async function build(dir: string) { const child = spawn({ - cmd: [bunExe(), "x", "node-gyp@11", "rebuild", "--debug", "-j", "max", "--verbose", "--", "-Denable_lto=false", "-Denable_thin_lto=false", "-Dlto_jobs="], + cmd: [bunExe(), "--bun", "x", "node-gyp@11", "rebuild", "--debug", "-j", "max", "--verbose"], cwd: dir, stderr: "pipe", stdout: "ignore", From 5f5c532162964e9fdd65cf7152a7cf4069f69c93 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 6 Jun 2026 13:28:54 +0000 Subject: [PATCH 22/61] v8 tests: run the node-side fixture build under bun too [build images] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same rationale as the previous commit's dlopen/napi change — this invocation was missed: gyp -D defines can't override the thin-LTO flags a clang-cl-built Node carries in process.config.target_defaults, so build the node-side module with bun (--bun) as well; it targets the same ABI (147) and loads in node 26 unchanged. Also drop the now-redundant defines from the bun-side invocation. --- test/v8/v8.test.ts | 29 ++++++++--------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/test/v8/v8.test.ts b/test/v8/v8.test.ts index 4cf194b47d4..fb1d0e0e940 100644 --- a/test/v8/v8.test.ts +++ b/test/v8/v8.test.ts @@ -76,28 +76,15 @@ async function build( buildMode == BuildMode.debug ? "--debug" : "--release", "-j", "max", - "--", - "-Denable_lto=false", - "-Denable_thin_lto=false", - "-Dlto_jobs=", ] - : // for node.js we don't bother with debug mode. The trailing gyp defines - // neutralize LTO flags that leak out of a clang-cl-built Node's - // process.config into MSVC addon builds (link.exe chokes on - // /opt:lldltojobs); they're no-ops elsewhere. - [ - bunExe(), - "run", - "node-gyp", - "rebuild", - "--release", - "-j", - "max", - "--", - "-Denable_lto=false", - "-Denable_thin_lto=false", - "-Dlto_jobs=", - ], + : // for node.js we don't bother with debug mode. Run node-gyp under bun + // (--bun) here too: a clang-cl-built Node carries thin-LTO flags in + // process.config.target_defaults that node-gyp copies into + // config.gypi and MSVC's link.exe chokes on (/opt:lldltojobs) — gyp + // -D defines can't override target_defaults. Bun reports the same + // ABI (147) with clean target_defaults, so the module loads in + // node 26 all the same. + [bunExe(), "--bun", "run", "node-gyp", "rebuild", "--release", "-j", "max"], cwd: tmpDir, env: bunEnv, stdin: "inherit", From 537a29c39a6fe6594bdefe2d3a125ea478e7b1f0 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 6 Jun 2026 18:41:17 +0000 Subject: [PATCH 23/61] process.config: report clang=1 on macOS; surface exit codes in v8 harness [build images] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit common.gypi only applies CLANG_CXX_LANGUAGE_STANDARD (gnu++20) to addon builds when the clang variable is 1, which real Node reports on macOS. With bun running node-gyp and reporting clang=0, Apple clang compiled addons at its ancient default standard and every node-gyp build on darwin failed on (std::is_enum_v) in the Node 26 headers. The v8 harness now includes the child's exit code in crash messages — the Windows fixture crashes currently report nothing, and the code distinguishes an access violation from a DLL load failure. --- src/jsc/bindings/BunProcess.cpp | 4 ++++ test/v8/v8.test.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 805b487ccb1..e6fb665f9c8 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -2529,6 +2529,10 @@ static JSValue constructProcessConfigObject(VM& vm, JSObject* processObject) variables->putDirect(vm, JSC::Identifier::fromString(vm, "napi_build_version"_s), JSC::jsNumber(Napi::DEFAULT_NAPI_VERSION), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "nasm_version"_s), JSC::jsNumber(2), 0); #elif OS(MACOS) + // Real Node on macOS reports clang=1; common.gypi only applies + // CLANG_CXX_LANGUAGE_STANDARD (gnu++20) to addon builds when clang==1, + // and Apple clang's default standard is far older. + variables->putDirect(vm, JSC::Identifier::fromString(vm, "clang"_s), JSC::jsNumber(1), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "control_flow_guard"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "coverage"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "dcheck_always_on"_s), JSC::jsNumber(0), 0); diff --git a/test/v8/v8.test.ts b/test/v8/v8.test.ts index fb1d0e0e940..ab786f72645 100644 --- a/test/v8/v8.test.ts +++ b/test/v8/v8.test.ts @@ -404,7 +404,7 @@ async function runOn(runtime: Runtime, buildMode: BuildMode, testName: string, j stdio: ["inherit", "pipe", "pipe"], }); const [exitCode, out, err] = await Promise.all([proc.exited, proc.stdout.text(), proc.stderr.text()]); - const crashMsg = `test ${testName} crashed under ${Runtime[runtime]} in ${BuildMode[buildMode]} mode`; + const crashMsg = `test ${testName} crashed under ${Runtime[runtime]} in ${BuildMode[buildMode]} mode (exit code ${exitCode}${exitCode && exitCode > 256 ? ` / 0x${exitCode.toString(16)}` : ""})`; if (exitCode !== 0) { throw new Error(`${crashMsg}: ${err}\n${out}`.trim()); } From 326cf3ba04519cfc48fa20992c10d55336da6c55 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 6 Jun 2026 18:45:58 +0000 Subject: [PATCH 24/61] complex-workspace test: skip lifecycle scripts on Windows [build images] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sharp has no win32-arm64 prebuilt, so its install script falls back to a node-gyp source build under the system Node — which the clang-cl-built Node 26 breaks (process.config leaks thin-LTO flags that MSVC's link.exe rejects with LNK1117). The test exercises package-lock.json migration, not lifecycle scripts, so pass --ignore-scripts there. --- test/cli/install/migration/complex-workspace.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/cli/install/migration/complex-workspace.test.ts b/test/cli/install/migration/complex-workspace.test.ts index ce411c328d1..2c8eb4f5f8d 100644 --- a/test/cli/install/migration/complex-workspace.test.ts +++ b/test/cli/install/migration/complex-workspace.test.ts @@ -52,7 +52,13 @@ test("the install succeeds", async () => { throw new Error("Failed to install"); } - subprocess = Bun.spawn([bunExe(), "install"], { + // On Windows CI, sharp's install script falls back to a node-gyp source + // build (no win32-arm64 prebuilt), which the system clang-cl-built Node 26 + // breaks (its process.config leaks thin-LTO flags that MSVC's link.exe + // rejects). This test exercises lockfile migration, not lifecycle scripts, + // so skip them there. + const installArgs = process.platform === "win32" ? [bunExe(), "install", "--ignore-scripts"] : [bunExe(), "install"]; + subprocess = Bun.spawn(installArgs, { env: bunEnv, cwd, stdio: ["inherit", "inherit", "inherit"], From 573816304cf9aed67560bbdafe7dc764ec40bf28 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 6 Jun 2026 18:48:59 +0000 Subject: [PATCH 25/61] bootstrap: install Chrome on x64 apt images; recognize google-chrome [build images] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The puppeteer-based next-pages tests still hit flaky per-run Chrome for Testing downloads on ubuntu/debian x64 (the only CI platforms with neither a system browser nor a skip gate). Bake google-chrome-stable into those images and teach the browser lookups about its binary names — with a system browser present the tests skip the ~300MB download entirely and launch the system binary, like they already do on alpine. --- scripts/bootstrap.sh | 9 +++++++++ test/harness.ts | 8 +++++++- test/integration/next-pages/test/dev-server-puppeteer.ts | 8 +++++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 9c4d7ca7dfa..2e7ca8b2d58 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -1778,6 +1778,15 @@ install_chromium() { else install_packages libasound2 fi + + # Install Chrome itself on x64 (no arm64 build exists): with a system + # browser present, puppeteer-based tests skip their per-run ~300MB + # Chrome for Testing download entirely (see + # test/harness.ts getPuppeteerInstallEnv). + if [ "$arch" = "x64" ]; then + chrome_deb=$(download_file "https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb") + execute_sudo apt-get install -y "$chrome_deb" || execute_sudo dpkg -i "$chrome_deb" || true + fi ;; dnf | yum) install_packages \ diff --git a/test/harness.ts b/test/harness.ts index 626a9787a19..a83331e2b75 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -2009,7 +2009,13 @@ export function nodeModulesPackages(nodeModulesPath: string): string { * ("browser folder exists but the executable is missing"). */ export function getPuppeteerInstallEnv(): Record { - const hasSystemChromium = !!(Bun.which("chromium-browser") || Bun.which("chromium") || Bun.which("chrome")); + const hasSystemChromium = !!( + Bun.which("chromium-browser") || + Bun.which("chromium") || + Bun.which("chrome") || + Bun.which("google-chrome-stable") || + Bun.which("google-chrome") + ); const skipBrowserDownload = hasSystemChromium || (process.platform === "linux" && process.arch === "arm64") || diff --git a/test/integration/next-pages/test/dev-server-puppeteer.ts b/test/integration/next-pages/test/dev-server-puppeteer.ts index 35d2775b019..6be1242b16f 100644 --- a/test/integration/next-pages/test/dev-server-puppeteer.ts +++ b/test/integration/next-pages/test/dev-server-puppeteer.ts @@ -13,7 +13,13 @@ if (process.argv.length > 2) { url = process.argv[2]; } -const browserPath = which("chromium-browser") || which("chromium") || which("chrome") || undefined; +const browserPath = + which("chromium-browser") || + which("chromium") || + which("chrome") || + which("google-chrome-stable") || + which("google-chrome") || + undefined; if (!browserPath) { console.warn("Since a Chromium browser was not found, it will be downloaded by Puppeteer."); } From b3f5e6104cf82bbaafc88f88ef073f97c0c5f1c4 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 6 Jun 2026 23:45:16 +0000 Subject: [PATCH 26/61] symbols.def: export Value::IsArray/IsBigInt/IsInt32/IsMap [build images] These were in symbols.txt/.dyn (so Linux and macOS resolved them) but never in the Windows .def. Node addons delay-load the host's exports, so the gap only surfaces at the first call: the v8 fixture tests that exercise these type checks died with the delay-load "procedure not found" SEH status (0xC06D007F, reported as exit code 127) after printing their first lines. Manglings verified against clang-cl and the remaining 83 v8 exports audited for further gaps (none). --- src/symbols.def | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/symbols.def b/src/symbols.def index 6205f32ae00..b980d8e88b5 100644 --- a/src/symbols.def +++ b/src/symbols.def @@ -630,6 +630,10 @@ EXPORTS ??0EscapableHandleScope@v8@@QEAA@PEAVIsolate@1@@Z ?IsObject@Value@v8@@QEBA_NXZ ?IsNumber@Value@v8@@QEBA_NXZ + ?IsArray@Value@v8@@QEBA_NXZ + ?IsBigInt@Value@v8@@QEBA_NXZ + ?IsInt32@Value@v8@@QEBA_NXZ + ?IsMap@Value@v8@@QEBA_NXZ ?IsUint32@Value@v8@@QEBA_NXZ ?Uint32Value@Value@v8@@QEBA?AV?$Maybe@I@2@V?$Local@VContext@v8@@@2@@Z ?IsUndefined@Value@v8@@QEBA_NXZ From a6bc225e2cc60b7b9cf364409b832806ff7d5ee9 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sun, 7 Jun 2026 05:16:09 +0000 Subject: [PATCH 27/61] v8 fixture: add stderr step markers to the tests that die on Windows [build images] The exit-127 crashes on windows-aarch64 print nothing: stdout shows the test started and stderr is empty. The exe exports every referenced v8 symbol (checked both pre- and post-61197 artifacts against the addon's full import set), so the delay-load theory needs narrowing. Markers go to stderr, which checkSameOutput never compares but does include in crash messages, so the next CI run names the exact call that dies. --- test/v8/v8-module/main.cpp | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/test/v8/v8-module/main.cpp b/test/v8/v8-module/main.cpp index ccfab523d3b..7db276b123b 100644 --- a/test/v8/v8-module/main.cpp +++ b/test/v8/v8-module/main.cpp @@ -426,14 +426,31 @@ class GlobalTestWrapper { Global GlobalTestWrapper::value; void GlobalTestWrapper::set(const FunctionCallbackInfo &info) { + // Step markers on stderr (checkSameOutput only compares stdout): the + // Windows fixture dies with a silent exit 127 somewhere in here and the + // markers in the captured stderr pinpoint the call. + fprintf(stderr, "[set] enter\n"); + fflush(stderr); Isolate *isolate = info.GetIsolate(); + fprintf(stderr, "[set] isolate=%p\n", (void *)isolate); + fflush(stderr); if (value.IsEmpty()) { + fprintf(stderr, "[set] empty -> Undefined\n"); + fflush(stderr); info.GetReturnValue().Set(Undefined(isolate)); } else { + fprintf(stderr, "[set] non-empty -> Get\n"); + fflush(stderr); info.GetReturnValue().Set(value.Get(isolate)); } + fprintf(stderr, "[set] return value set\n"); + fflush(stderr); const auto new_value = info[0]; + fprintf(stderr, "[set] before Reset\n"); + fflush(stderr); value.Reset(isolate, new_value); + fprintf(stderr, "[set] after Reset\n"); + fflush(stderr); } void GlobalTestWrapper::get(const FunctionCallbackInfo &info) { @@ -645,15 +662,28 @@ void test_v8_escapable_handle_scope(const FunctionCallbackInfo &info) { // inside the scope — including, before the fix, an escape handle allocated at // Escape() time after in-scope Local copies. Local escape_after_inline_handles(Isolate *isolate) { + fprintf(stderr, "[esc] enter\n"); + fflush(stderr); EscapableHandleScope ehs(isolate); + fprintf(stderr, "[esc] scope constructed\n"); + fflush(stderr); Local value = String::NewFromUtf8(isolate, "escaped-after-inline").ToLocalChecked(); + fprintf(stderr, "[esc] string created\n"); + fflush(stderr); // These go through the headers' inline CreateHandle (HandleScope::Extend // grants) and are swept by DeleteExtensions when the scope closes. Local copy1 = Local::New(isolate, Local::Cast(value)); + fprintf(stderr, "[esc] copy1 (inline Extend) done\n"); + fflush(stderr); Local copy2 = Local::New(isolate, copy1); (void)copy2; - return ehs.Escape(value); + fprintf(stderr, "[esc] copy2 done, escaping\n"); + fflush(stderr); + Local escaped = ehs.Escape(value); + fprintf(stderr, "[esc] escaped\n"); + fflush(stderr); + return escaped; } void test_v8_escapable_handle_scope_inline_grants( From 0a144f1ca3adb3168172f30939a19996b5955fe6 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sun, 7 Jun 2026 11:05:35 +0000 Subject: [PATCH 28/61] v8: export the V8_INLINE members MSVC debug builds import [build images] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the Windows --debug fixture crashes (exit 127), found by reproducing under wine with the CI binary and a clang-cl-built debug addon: V8_INLINE members whose bodies live at namespace scope in the headers — HandleScope::CreateHandle(v8::Isolate*, Address), HandleScope::Initialize, and Value::QuickIsUndefined/QuickIsNull/QuickIsNullOrUndefined/QuickIsString — are imported rather than emitted by MSVC debug builds of a dllimport class. Real Node exports its entire V8 surface so this works there; Bun's curated export list didn't include them, and the delay-load helper's "procedure not found" SEH status (0xC06D007F) surfaces as exit code 127 at the first call. That also explains the seemingly random crash points across tests: each died at its own first imported-inline call. Implementations are thin: the CreateHandle overload forwards to the internal::Isolate* variant (same object), Initialize performs V8's inline frame setup (snapshot next/limit, level++ — never pushing a Bun scope, like EscapableHandleScopeBase), and the QuickIs* checks are the corresponding JSC value checks. Exported on all platforms for symmetry; ELF builds emit inline copies locally so only Windows strictly needs them. Verified under wine: with the missing import resolved, test_v8_global, test_v8_escapable_handle_scope_inline_grants and test_v8_locals_survive_nested_call all pass with a debug-CRT fixture. --- src/jsc/bindings/v8/V8HandleScope.cpp | 20 ++++++++++++++++++++ src/jsc/bindings/v8/V8HandleScope.h | 10 ++++++++++ src/jsc/bindings/v8/V8Value.cpp | 25 +++++++++++++++++++++++++ src/jsc/bindings/v8/V8Value.h | 7 +++++++ src/runtime/napi/napi_body.rs | 12 ++++++++++++ src/symbols.def | 6 ++++++ src/symbols.dyn | 6 ++++++ src/symbols.txt | 6 ++++++ 8 files changed, 92 insertions(+) diff --git a/src/jsc/bindings/v8/V8HandleScope.cpp b/src/jsc/bindings/v8/V8HandleScope.cpp index 40fa386c1d3..aaecbe0f2af 100644 --- a/src/jsc/bindings/v8/V8HandleScope.cpp +++ b/src/jsc/bindings/v8/V8HandleScope.cpp @@ -92,6 +92,26 @@ uintptr_t* HandleScope::CreateHandle(internal::Isolate* i_isolate, uintptr_t val return newSlot->asRawPtrLocation(); } +uintptr_t* HandleScope::CreateHandle(Isolate* isolate, uintptr_t value) +{ + // Same object underneath; v8::Isolate* and internal::Isolate* are nominal + // views of our Isolate. + return CreateHandle(reinterpret_cast(isolate), value); +} + +void HandleScope::Initialize(Isolate* isolate) +{ + // Mirror V8 14's inline HandleScope::Initialize (v8-local-handle.h): + // stash the HandleScopeData snapshot in the V8-visible words and bump + // level. The frame is addon-owned and V8-laid-out — do not push a Bun + // scope and do not touch Bun-meaning members beyond the three words. + auto* data = shim::getHandleScopeData(isolate); + m_isolate = isolate; + m_previousHandleScope = reinterpret_cast(data->next); + m_buffer = reinterpret_cast(data->limit); + data->level++; +} + uintptr_t* HandleScope::Extend(Isolate* isolate) { // V8 14's inline HandleScope::CreateHandle (v8-local-handle.h) calls Extend when diff --git a/src/jsc/bindings/v8/V8HandleScope.h b/src/jsc/bindings/v8/V8HandleScope.h index bee48002fa5..eae0556fa81 100644 --- a/src/jsc/bindings/v8/V8HandleScope.h +++ b/src/jsc/bindings/v8/V8HandleScope.h @@ -56,6 +56,16 @@ class HandleScope { // is protected in v8, which matters on windows BUN_EXPORT static uintptr_t* CreateHandle(internal::Isolate* isolate, uintptr_t value); + // V8 14's headers also declare a V8_INLINE overload taking v8::Isolate* + // with an out-of-class body (v8-local-handle.h); MSVC debug builds import + // it instead of emitting it, so it must exist as a real export. Protected + // in V8 (affects the MSVC mangling). + BUN_EXPORT static uintptr_t* CreateHandle(Isolate* isolate, uintptr_t value); + // Same story for the inline constructor's Initialize: under MSVC /Ob0 the + // addon-side inline HandleScope constructor calls an imported Initialize. + // Initializes the frame in V8's inline style (snapshot next/limit, + // level++) — never pushes a Bun scope, mirroring EscapableHandleScopeBase. + BUN_EXPORT void Initialize(Isolate* isolate); private: // Out-of-line slow path of V8 14's fully-inline HandleScope (v8-local-handle.h). The inline diff --git a/src/jsc/bindings/v8/V8Value.cpp b/src/jsc/bindings/v8/V8Value.cpp index 469f86ca441..3ccf3a830cb 100644 --- a/src/jsc/bindings/v8/V8Value.cpp +++ b/src/jsc/bindings/v8/V8Value.cpp @@ -34,6 +34,31 @@ bool Value::IsUndefined() const return localToJSValue().isUndefined(); } +// The QuickIs* functions are V8_INLINE with out-of-class bodies in +// v8-value.h. MSVC debug builds (/Ob0) import such members of a dllimport +// class instead of emitting them, so addons compiled --debug on Windows +// need them as real exports. Semantically they are the corresponding Is* +// checks (the "quick" part only matters for real V8's object layout). +bool Value::QuickIsUndefined() const +{ + return localToJSValue().isUndefined(); +} + +bool Value::QuickIsNull() const +{ + return localToJSValue().isNull(); +} + +bool Value::QuickIsNullOrUndefined() const +{ + return localToJSValue().isUndefinedOrNull(); +} + +bool Value::QuickIsString() const +{ + return localToJSValue().isString(); +} + bool Value::IsNull() const { return localToJSValue().isNull(); diff --git a/src/jsc/bindings/v8/V8Value.h b/src/jsc/bindings/v8/V8Value.h index 265ba925b72..0027496503e 100644 --- a/src/jsc/bindings/v8/V8Value.h +++ b/src/jsc/bindings/v8/V8Value.h @@ -35,6 +35,13 @@ class Value : public Data { // non-inlined versions of these BUN_EXPORT bool FullIsTrue() const; BUN_EXPORT bool FullIsFalse() const; + // V8_INLINE in the headers but with out-of-class bodies, which MSVC debug + // builds import instead of emitting locally; private to match V8's + // declarations (affects the MSVC mangling). + BUN_EXPORT bool QuickIsUndefined() const; + BUN_EXPORT bool QuickIsNull() const; + BUN_EXPORT bool QuickIsNullOrUndefined() const; + BUN_EXPORT bool QuickIsString() const; }; } // namespace v8 diff --git a/src/runtime/napi/napi_body.rs b/src/runtime/napi/napi_body.rs index 353b99858a2..17cac46e374 100644 --- a/src/runtime/napi/napi_body.rs +++ b/src/runtime/napi/napi_body.rs @@ -3000,6 +3000,12 @@ mod v8_api { pub(super) fn _ZN2v86Object3GetENS_5LocalINS_7ContextEEENS1_INS_5ValueEEE() -> *mut c_void; pub(super) fn _ZN2v86Object3GetENS_5LocalINS_7ContextEEEj() -> *mut c_void; pub(super) fn _ZN2v811HandleScope12CreateHandleEPNS_8internal7IsolateEm() -> *mut c_void; + pub(super) fn _ZN2v811HandleScope12CreateHandleEPNS_7IsolateEm() -> *mut c_void; + pub(super) fn _ZN2v811HandleScope10InitializeEPNS_7IsolateE() -> *mut c_void; + pub(super) fn _ZNK2v85Value16QuickIsUndefinedEv() -> *mut c_void; + pub(super) fn _ZNK2v85Value11QuickIsNullEv() -> *mut c_void; + pub(super) fn _ZNK2v85Value22QuickIsNullOrUndefinedEv() -> *mut c_void; + pub(super) fn _ZNK2v85Value13QuickIsStringEv() -> *mut c_void; pub(super) fn _ZN2v811HandleScope6ExtendEPNS_7IsolateE() -> *mut c_void; pub(super) fn _ZN2v811HandleScope16DeleteExtensionsEPNS_7IsolateE() -> *mut c_void; pub(super) fn _ZN2v811HandleScopeC1EPNS_7IsolateE() -> *mut c_void; @@ -4135,6 +4141,12 @@ pub fn fix_dead_code_elimination() { _ZN2v86Object3GetENS_5LocalINS_7ContextEEENS1_INS_5ValueEEE, _ZN2v86Object3GetENS_5LocalINS_7ContextEEEj, _ZN2v811HandleScope12CreateHandleEPNS_8internal7IsolateEm, + _ZN2v811HandleScope12CreateHandleEPNS_7IsolateEm, + _ZN2v811HandleScope10InitializeEPNS_7IsolateE, + _ZNK2v85Value16QuickIsUndefinedEv, + _ZNK2v85Value11QuickIsNullEv, + _ZNK2v85Value22QuickIsNullOrUndefinedEv, + _ZNK2v85Value13QuickIsStringEv, _ZN2v811HandleScope6ExtendEPNS_7IsolateE, _ZN2v811HandleScope16DeleteExtensionsEPNS_7IsolateE, _ZN2v811HandleScopeC1EPNS_7IsolateE, _ZN2v811HandleScopeD1Ev, diff --git a/src/symbols.def b/src/symbols.def index b980d8e88b5..ce1042f2ff9 100644 --- a/src/symbols.def +++ b/src/symbols.def @@ -602,6 +602,12 @@ EXPORTS ?SetInternalField@Object@v8@@QEAAXHV?$Local@VData@v8@@@2@@Z ?SlowGetInternalField@Object@v8@@AEAA?AV?$Local@VData@v8@@@2@H@Z ?CreateHandle@HandleScope@v8@@KAPEA_KPEAVIsolate@internal@2@_K@Z + ?CreateHandle@HandleScope@v8@@KAPEA_KPEAVIsolate@2@_K@Z + ?Initialize@HandleScope@v8@@IEAAXPEAVIsolate@2@@Z + ?QuickIsUndefined@Value@v8@@AEBA_NXZ + ?QuickIsNull@Value@v8@@AEBA_NXZ + ?QuickIsNullOrUndefined@Value@v8@@AEBA_NXZ + ?QuickIsString@Value@v8@@AEBA_NXZ ?Extend@HandleScope@v8@@CAPEA_KPEAVIsolate@2@@Z ?DeleteExtensions@HandleScope@v8@@AEAAXPEAVIsolate@2@@Z ??0HandleScope@v8@@QEAA@PEAVIsolate@1@@Z diff --git a/src/symbols.dyn b/src/symbols.dyn index 93483288ec9..ded971cdc0e 100644 --- a/src/symbols.dyn +++ b/src/symbols.dyn @@ -1,5 +1,11 @@ { __ZN2v811HandleScope12CreateHandleEPNS_8internal7IsolateEm; + __ZN2v811HandleScope12CreateHandleEPNS_7IsolateEm; + __ZN2v811HandleScope10InitializeEPNS_7IsolateE; + __ZNK2v85Value16QuickIsUndefinedEv; + __ZNK2v85Value11QuickIsNullEv; + __ZNK2v85Value22QuickIsNullOrUndefinedEv; + __ZNK2v85Value13QuickIsStringEv; __ZN2v811HandleScope16DeleteExtensionsEPNS_7IsolateE; __ZN2v811HandleScope6ExtendEPNS_7IsolateE; __ZN2v811HandleScopeC1EPNS_7IsolateE; diff --git a/src/symbols.txt b/src/symbols.txt index 2a617b63302..d314660ec93 100644 --- a/src/symbols.txt +++ b/src/symbols.txt @@ -1,4 +1,10 @@ __ZN2v811HandleScope12CreateHandleEPNS_8internal7IsolateEm +__ZN2v811HandleScope12CreateHandleEPNS_7IsolateEm +__ZN2v811HandleScope10InitializeEPNS_7IsolateE +__ZNK2v85Value16QuickIsUndefinedEv +__ZNK2v85Value11QuickIsNullEv +__ZNK2v85Value22QuickIsNullOrUndefinedEv +__ZNK2v85Value13QuickIsStringEv __ZN2v811HandleScope16DeleteExtensionsEPNS_7IsolateE __ZN2v811HandleScope6ExtendEPNS_7IsolateE __ZN2v811HandleScopeC1EPNS_7IsolateE From ae74b3141afe7bd75b2483b8745dd596fdbbc658 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sun, 7 Jun 2026 17:26:10 +0000 Subject: [PATCH 29/61] http2: lastStreamId <= 0 in goaway means the actual last stream [build images] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A graceful close() sends GOAWAY through the JS default lastStreamId = 0, and the native parser only fell back to the session's real last stream id for undefined/null — a literal 0 went on the wire, telling the peer per RFC 9113 §6.8 that no streams were processed and everything is retriable. Node's JS layer has the same 0 default and relies on its native correction (lastStreamID <= 0 → nghttp2_session_get_last_proc_stream_id); mirror that in the parser so every caller (close(), destroy(), user goaway) is covered. Also: the server session's goaway handler now matches the client's (and Node's onGoawayData) — NO_ERROR begins a graceful close, any other code destroys with ERR_HTTP2_SESSION_ERROR; corrected a comment that claimed duck-typed { aborted } signals throw (validateAbortSignal accepts any object with an 'aborted' property); and stripped the v8 fixture's temporary stderr step markers now that the Windows exit-127 is root-caused. --- src/js/node/http2.ts | 18 +++++++++++----- src/runtime/api/bun/h2_frame_parser.rs | 10 +++++++-- test/v8/v8-module/main.cpp | 29 -------------------------- 3 files changed, 21 insertions(+), 36 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index cd3b0f030aa..06cb089356f 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -3015,11 +3015,17 @@ class ServerHttp2Session extends Http2Session { }, goaway(self: ServerHttp2Session, errorCode: number, lastStreamId: number, opaqueData: Buffer) { if (!self) return; + if (self.destroyed) return; self.emit("goaway", errorCode, lastStreamId, opaqueData || Buffer.allocUnsafe(0)); - if (errorCode !== 0) { - self.#parser.emitErrorToAllStreams(errorCode); + if (errorCode === NGHTTP2_NO_ERROR) { + // Graceful shutdown: no new streams, existing ones may finish. + self.close(); + } else { + self.#parser?.emitErrorToAllStreams(errorCode); + // Like Node, destroy with an error but send our own goaway with + // NGHTTP2_NO_ERROR since this side had no error. + self.destroy($ERR_HTTP2_SESSION_ERROR(errorCode), NGHTTP2_NO_ERROR); } - self.close(); }, end(self: ServerHttp2Session, errorCode: number, lastStreamId: number, opaqueData: Buffer) { if (!self) return; @@ -4021,8 +4027,10 @@ class ClientHttp2Session extends Http2Session { // streams). Sending an RST for a stream the peer never saw is a // connection error that makes conforming servers reply with GOAWAY. if ($isObject(options) && options.signal) { - // Node validates the signal before reading .aborted — a duck-typed - // { aborted: true } must throw ERR_INVALID_ARG_TYPE, not short-circuit. + // Node validates the signal before reading .aborted: any object with an + // 'aborted' property passes (so a duck-typed { aborted: true } takes + // the abort fast path), while objects without one and non-objects + // throw ERR_INVALID_ARG_TYPE synchronously. validateAbortSignal(options.signal, "options.signal"); if (options.signal.aborted) { const req = new ClientHttp2Stream(undefined, this, headers); diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 14ce1890208..ab96a01605b 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -5225,12 +5225,18 @@ impl H2FrameParser { ); } let id = last_stream_arg.to_int32(); - if id < 0 && id as u32 > MAX_STREAM_ID { + if id as u32 > MAX_STREAM_ID { return Err(global_object.throw(format_args!( "Expected lastStreamId to be a number between 1 and 2147483647" ))); } - last_stream_id = u32::try_from(id).expect("int cast"); + // Like Node's native goaway, a lastStreamId <= 0 means "use the + // actual last processed stream id" — Node's JS layer defaults the + // argument to 0 and relies on this correction, and sending a + // literal 0 would tell the peer no streams were processed. + if id > 0 { + last_stream_id = id as u32; + } } if args_list.len >= 3 { let opaque_data_arg = args_list.ptr[2]; diff --git a/test/v8/v8-module/main.cpp b/test/v8/v8-module/main.cpp index 7db276b123b..9eaa5329534 100644 --- a/test/v8/v8-module/main.cpp +++ b/test/v8/v8-module/main.cpp @@ -426,31 +426,14 @@ class GlobalTestWrapper { Global GlobalTestWrapper::value; void GlobalTestWrapper::set(const FunctionCallbackInfo &info) { - // Step markers on stderr (checkSameOutput only compares stdout): the - // Windows fixture dies with a silent exit 127 somewhere in here and the - // markers in the captured stderr pinpoint the call. - fprintf(stderr, "[set] enter\n"); - fflush(stderr); Isolate *isolate = info.GetIsolate(); - fprintf(stderr, "[set] isolate=%p\n", (void *)isolate); - fflush(stderr); if (value.IsEmpty()) { - fprintf(stderr, "[set] empty -> Undefined\n"); - fflush(stderr); info.GetReturnValue().Set(Undefined(isolate)); } else { - fprintf(stderr, "[set] non-empty -> Get\n"); - fflush(stderr); info.GetReturnValue().Set(value.Get(isolate)); } - fprintf(stderr, "[set] return value set\n"); - fflush(stderr); const auto new_value = info[0]; - fprintf(stderr, "[set] before Reset\n"); - fflush(stderr); value.Reset(isolate, new_value); - fprintf(stderr, "[set] after Reset\n"); - fflush(stderr); } void GlobalTestWrapper::get(const FunctionCallbackInfo &info) { @@ -662,27 +645,15 @@ void test_v8_escapable_handle_scope(const FunctionCallbackInfo &info) { // inside the scope — including, before the fix, an escape handle allocated at // Escape() time after in-scope Local copies. Local escape_after_inline_handles(Isolate *isolate) { - fprintf(stderr, "[esc] enter\n"); - fflush(stderr); EscapableHandleScope ehs(isolate); - fprintf(stderr, "[esc] scope constructed\n"); - fflush(stderr); Local value = String::NewFromUtf8(isolate, "escaped-after-inline").ToLocalChecked(); - fprintf(stderr, "[esc] string created\n"); - fflush(stderr); // These go through the headers' inline CreateHandle (HandleScope::Extend // grants) and are swept by DeleteExtensions when the scope closes. Local copy1 = Local::New(isolate, Local::Cast(value)); - fprintf(stderr, "[esc] copy1 (inline Extend) done\n"); - fflush(stderr); Local copy2 = Local::New(isolate, copy1); (void)copy2; - fprintf(stderr, "[esc] copy2 done, escaping\n"); - fflush(stderr); Local escaped = ehs.Escape(value); - fprintf(stderr, "[esc] escaped\n"); - fflush(stderr); return escaped; } From c5124049938cd0ddd3f00e13da452546bc6cebb0 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Mon, 8 Jun 2026 18:02:11 +0000 Subject: [PATCH 30/61] tests: tolerate machines whose Node or toolchain lags the reported ABI [build images] - harness: add nodeExeMatchingAbi(), which returns the system node when its NODE_MODULE_VERSION matches the version Bun reports and otherwise downloads the matching official build into a cached temp directory - harness: add canBuildNodeAddons(), a macOS-only probe for a libc++ with , which Node 26 headers include unconditionally; skip addon-building suites where the system toolchain cannot compile them - v8/napi tests: resolve node through nodeExeMatchingAbi() so addon fixtures compiled against the reported headers load in the comparison runtime - napi: pin the wrapped object in test_deferred_exceptions so its throwing finalizer runs at env teardown instead of from GC, where Node 26 aborts with 'Finalizer is calling a function that may affect GC state' --- test/harness.ts | 132 ++++++++++++++++++ .../process/dlopen-duplicate-load.test.ts | 4 +- .../process/dlopen-non-object-exports.test.ts | 4 +- test/napi/napi-app/standalone_tests.cpp | 16 ++- test/napi/napi.test.ts | 29 +++- test/v8/v8.test.ts | 25 +++- 6 files changed, 194 insertions(+), 16 deletions(-) diff --git a/test/harness.ts b/test/harness.ts index a83331e2b75..164938e4d2c 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -126,6 +126,138 @@ export function nodeExe(): string | null { return which("node") || null; } +let abiMatchingNode: Promise | undefined; + +/** + * Path to a Node.js executable whose native-addon ABI (NODE_MODULE_VERSION, + * `process.versions.modules`) matches the Node version Bun reports. Addons + * that node-gyp compiles against Bun's reported headers can only load in such + * a Node. When the system Node's ABI differs (e.g. a machine whose installed + * Node lags the version Bun reports), the matching official build is + * downloaded once into a per-version directory under the OS temp dir and + * reused. + */ +export function nodeExeMatchingAbi(): Promise { + return (abiMatchingNode ??= findOrDownloadAbiMatchingNode()); +} + +async function findOrDownloadAbiMatchingNode(): Promise { + const system = nodeExe(); + if (system) { + const probe = Bun.spawnSync({ + cmd: [system, "-p", "process.versions.modules"], + env: bunEnv, + stdout: "pipe", + stderr: "ignore", + }); + if (probe.exitCode === 0 && probe.stdout.toString().trim() === process.versions.modules) { + return system; + } + } + + const version = process.versions.node; + const name = `node-v${version}-${isWindows ? "win" : process.platform}-${process.arch}`; + const baseDir = join(os.tmpdir(), "bun-test-node"); + const dir = join(baseDir, name); + const exe = isWindows ? join(dir, "node.exe") : join(dir, "bin", "node"); + if (fs.existsSync(exe)) { + return exe; + } + + const archiveExt = isWindows ? "zip" : "tar.gz"; + const url = `https://nodejs.org/dist/v${version}/${name}.${archiveExt}`; + console.warn(`System node does not match ABI ${process.versions.modules}, downloading ${url}`); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`); + } + // Download and extract under unique names, then atomically rename into + // place so concurrent test files (or a previous interrupted run) can't + // observe a half-extracted directory. + const stagingDir = join(os.tmpdir(), "bun-test-node", `staging-${process.pid}-${Date.now()}`); + fs.mkdirSync(stagingDir, { recursive: true }); + try { + const archive = join(stagingDir, `${name}.${archiveExt}`); + await write(archive, response); + // Verify against the official checksum manifest before executing anything + // from the archive. + const shasumsUrl = `https://nodejs.org/dist/v${version}/SHASUMS256.txt`; + const shasumsResponse = await fetch(shasumsUrl); + if (!shasumsResponse.ok) { + throw new Error(`Failed to download ${shasumsUrl}: ${shasumsResponse.status} ${shasumsResponse.statusText}`); + } + const shasums = await shasumsResponse.text(); + const expectedHash = shasums + .split("\n") + .find(line => line.endsWith(` ${name}.${archiveExt}`)) + ?.split(" ")[0]; + if (!expectedHash) { + throw new Error(`No checksum for ${name}.${archiveExt} in ${shasumsUrl}`); + } + const actualHash = new Bun.CryptoHasher("sha256").update(await Bun.file(archive).arrayBuffer()).digest("hex"); + if (actualHash !== expectedHash) { + throw new Error(`SHA-256 mismatch for ${url}: expected ${expectedHash}, got ${actualHash}`); + } + // bsdtar (shipped with Windows 10+) extracts zip archives too. + const tar = Bun.spawnSync({ + cmd: ["tar", "-xf", archive, "-C", stagingDir], + env: bunEnv, + stdout: "ignore", + stderr: "pipe", + }); + if (tar.exitCode !== 0) { + throw new Error(`Failed to extract ${archive}: ${tar.stderr.toString()}`); + } + try { + fs.renameSync(join(stagingDir, name), dir); + } catch (error) { + // A concurrent download may have won the rename; that copy is as good. + if (!fs.existsSync(exe)) throw error; + } + } finally { + fs.rmSync(stagingDir, { recursive: true, force: true }); + } + return exe; +} + +let canBuildNodeAddonsCached: boolean | undefined; + +/** + * Whether the system C++ toolchain can compile native addons against the + * Node headers Bun reports. Node >= 26 headers unconditionally include + * C++20's ``, which older Apple Xcode/CLT libc++ versions + * do not ship — real Node 26 has the same minimum-toolchain requirement, so + * addon-building tests should skip (not fail) on such machines. + */ +export function canBuildNodeAddons(): boolean { + if (canBuildNodeAddonsCached === undefined) { + if (!isMacOS) { + // Linux and Windows CI toolchains are provisioned by the bootstrap + // scripts in lockstep with the reported Node version; only macOS test + // boxes have independently-managed Xcode installs. + canBuildNodeAddonsCached = true; + } else { + const dir = fs.mkdtempSync(join(os.tmpdir(), "bun-addon-toolchain-probe-")); + try { + const probeFile = join(dir, "probe.cpp"); + fs.writeFileSync(probeFile, "#include \nint main() { return 0; }\n"); + const probe = Bun.spawnSync({ + cmd: ["c++", "-std=gnu++20", "-fsyntax-only", probeFile], + env: bunEnv, + stdout: "ignore", + stderr: "ignore", + }); + canBuildNodeAddonsCached = probe.exitCode === 0; + } catch { + canBuildNodeAddonsCached = false; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + } + return canBuildNodeAddonsCached; +} + export function shellExe(): string { return isWindows ? "pwsh" : "bash"; } diff --git a/test/js/node/process/dlopen-duplicate-load.test.ts b/test/js/node/process/dlopen-duplicate-load.test.ts index 19323124689..0827e1aff18 100644 --- a/test/js/node/process/dlopen-duplicate-load.test.ts +++ b/test/js/node/process/dlopen-duplicate-load.test.ts @@ -1,13 +1,13 @@ import { spawnSync } from "bun"; import { beforeAll, describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDirWithFiles } from "harness"; +import { bunEnv, bunExe, canBuildNodeAddons, tempDirWithFiles } from "harness"; import { join } from "path"; // This test verifies that Bun can load the same native module multiple times // Previously, the second load would fail with "symbol 'napi_register_module_v1' not found" // because static constructors only run once, so the module registration wasn't replayed -describe("process.dlopen duplicate loads", () => { +describe.skipIf(!canBuildNodeAddons())("process.dlopen duplicate loads", () => { let addonPath: string; beforeAll(() => { diff --git a/test/js/node/process/dlopen-non-object-exports.test.ts b/test/js/node/process/dlopen-non-object-exports.test.ts index 9175d80e6e7..06bc1313029 100644 --- a/test/js/node/process/dlopen-non-object-exports.test.ts +++ b/test/js/node/process/dlopen-non-object-exports.test.ts @@ -1,12 +1,12 @@ import { spawnSync } from "bun"; import { beforeAll, describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDirWithFiles } from "harness"; +import { bunEnv, bunExe, canBuildNodeAddons, tempDirWithFiles } from "harness"; import { join } from "path"; // This test verifies that Bun properly handles non-object exports when loading native modules // Previously, this would cause a segfault when exports was null, undefined, or a primitive -describe("process.dlopen with non-object exports", () => { +describe.skipIf(!canBuildNodeAddons())("process.dlopen with non-object exports", () => { let addonPath: string; beforeAll(() => { diff --git a/test/napi/napi-app/standalone_tests.cpp b/test/napi/napi-app/standalone_tests.cpp index 23050e9d2fc..af93cd1064b 100644 --- a/test/napi/napi-app/standalone_tests.cpp +++ b/test/napi/napi-app/standalone_tests.cpp @@ -1169,6 +1169,7 @@ static napi_value test_deferred_exceptions(const Napi::CallbackInfo &info) { clear(); + napi_ref object_ref; status = napi_wrap( env, object, nullptr, +[](napi_env env, void *data, void *finalize_hint) { @@ -1176,13 +1177,26 @@ static napi_value test_deferred_exceptions(const Napi::CallbackInfo &info) { printf("napi_throw status: %d\n", napi_throw(env, ok(env))); puts("finalizer end"); }, - nullptr, nullptr); + nullptr, &object_ref); if (status != napi_ok) { printf("napi_wrap failed: %d\n", status); return nullptr; } + // Pin the wrapped object for the rest of the process. Under Node >= 26 a + // finalizer that calls napi_throw aborts if it runs from GC (it would need + // node_api_post_finalizer), but running it at env teardown is allowed and + // prints napi_cannot_run_js. Keeping the object strongly referenced makes + // the finalizer timing deterministic on both runtimes. + uint32_t refcount; + status = napi_reference_ref(env, object_ref, &refcount); + + if (status != napi_ok) { + printf("napi_reference_ref failed: %d\n", status); + return nullptr; + } + clear(); puts("ok"); diff --git a/test/napi/napi.test.ts b/test/napi/napi.test.ts index 284028e3e31..5709b2b6397 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -1,11 +1,24 @@ import { spawn, spawnSync } from "bun"; import { beforeAll, describe, expect, it } from "bun:test"; import { readdirSync } from "fs"; -import { bunEnv, bunExe, isCI, isMacOS, isMusl, isWindows, tempDirWithFiles } from "harness"; +import { + bunEnv, + bunExe, + canBuildNodeAddons, + isCI, + isMacOS, + isMusl, + isWindows, + nodeExeMatchingAbi, + tempDirWithFiles, +} from "harness"; import { join } from "path"; -describe.concurrent("napi", () => { - beforeAll(() => { +describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { + beforeAll(async () => { + // Resolve (and possibly download) the ABI-matching node here, under the + // generous hook timeout, instead of inside the first test that needs it. + await nodeExeMatchingAbi(); // build gyp console.time("Building node-gyp"); const install = spawnSync({ @@ -53,7 +66,7 @@ describe.concurrent("napi", () => { }); expect(build.success).toBeTrue(); - for (let exec of target === "bun" ? [bunExe()] : [bunExe(), "node"]) { + for (let exec of target === "bun" ? [bunExe()] : [bunExe(), await nodeExeMatchingAbi()]) { const result = spawnSync({ cmd: [exec, join(dir, "main.js"), "self"], env: bunEnv, @@ -134,7 +147,7 @@ describe.concurrent("napi", () => { expect(build.logs).toBeEmpty(); - for (let exec of target === "bun" ? [bunExe()] : [bunExe(), "node"]) { + for (let exec of target === "bun" ? [bunExe()] : [bunExe(), await nodeExeMatchingAbi()]) { const result = spawnSync({ cmd: [exec, join(dir, "main.js"), "self"], env: bunEnv, @@ -779,6 +792,9 @@ async function checkSameOutput(test: string, args: any[] | string, envArgs: Reco async function runOn(executable: string, test: string, args: any[] | string, envArgs: Record = {}) { const env = { ...bunEnv, ...envArgs }; + // "node" means a Node whose addon ABI matches the headers the fixture was + // compiled against (the system node may lag the version Bun reports). + if (executable === "node") executable = await nodeExeMatchingAbi(); const exec = spawn({ cmd: [ executable, @@ -808,6 +824,7 @@ async function runOn(executable: string, test: string, args: any[] | string, env async function checkBothFail(test: string, args: any[] | string, envArgs: Record = {}) { const [node, bun] = await Promise.all( ["node", bunExe()].map(async executable => { + if (executable === "node") executable = await nodeExeMatchingAbi(); const { BUN_INSPECT_CONNECT_TO: _, ...rest } = bunEnv; const env = { ...rest, BUN_INTERNAL_SUPPRESS_CRASH_ON_NAPI_ABORT: "1", ...envArgs }; const exec = spawn({ @@ -832,7 +849,7 @@ async function checkBothFail(test: string, args: any[] | string, envArgs: Record expect(!!node.signalCode).toEqual(!!bun.signalCode); } -describe("cleanup hooks", () => { +describe.skipIf(!canBuildNodeAddons())("cleanup hooks", () => { describe("execution order", () => { it("executes in reverse insertion order like Node.js", async () => { // Test that cleanup hooks execute in reverse insertion order (LIFO) diff --git a/test/v8/v8.test.ts b/test/v8/v8.test.ts index ab786f72645..42347201c97 100644 --- a/test/v8/v8.test.ts +++ b/test/v8/v8.test.ts @@ -1,7 +1,18 @@ import { spawn } from "bun"; import { jscDescribe } from "bun:jsc"; import { beforeAll, describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, isASAN, isBroken, isMusl, isWindows, nodeExe, tempDir, tmpdirSync } from "harness"; +import { + bunEnv, + bunExe, + canBuildNodeAddons, + isASAN, + isBroken, + isMusl, + isWindows, + nodeExeMatchingAbi, + tempDir, + tmpdirSync, +} from "harness"; import assert from "node:assert"; import fs from "node:fs/promises"; import { basename, join } from "path"; @@ -111,7 +122,7 @@ async function build( console.log(err); } -describe.todoIf(isBroken && isMusl)("node:v8", () => { +describe.skipIf(!canBuildNodeAddons()).todoIf(isBroken && isMusl)("node:v8", () => { beforeAll(async () => { // set up clean directories for our 4 builds directories.bunRelease = tmpdirSync(); @@ -128,7 +139,11 @@ describe.todoIf(isBroken && isMusl)("node:v8", () => { await build(srcDir, directories.bunDebug, Runtime.bun, BuildMode.debug); await build(srcDir, directories.node, Runtime.node, BuildMode.release); await build(join(__dirname, "bad-modules"), directories.badModules, Runtime.node, BuildMode.release); - }); + + // Resolve (and possibly download) the ABI-matching node here, under the + // generous hook timeout, instead of inside the first test that needs it. + await nodeExeMatchingAbi(); + }, 300_000); describe("module lifecycle", () => { it("can call a basic native function", async () => { @@ -383,7 +398,7 @@ async function runOn(runtime: Runtime, buildMode: BuildMode, testName: string, j : buildMode == BuildMode.debug ? directories.bunDebug : directories.bunRelease; - const exe = runtime == Runtime.node ? (nodeExe() ?? "node") : bunExe(); + const exe = runtime == Runtime.node ? await nodeExeMatchingAbi() : bunExe(); const cmd = [ exe, @@ -412,7 +427,7 @@ async function runOn(runtime: Runtime, buildMode: BuildMode, testName: string, j return out.trim(); } -describe.todoIf(isBroken && isMusl)("String::Utf8Length bounds", () => { +describe.skipIf(!canBuildNodeAddons()).todoIf(isBroken && isMusl)("String::Utf8Length bounds", () => { it( "reports sizes beyond INT32_MAX without wrapping", async () => { From 3693c9992d6c8c584eb14688258f9975f7b49f0f Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Mon, 8 Jun 2026 18:12:10 +0000 Subject: [PATCH 31/61] ci: rebuild bootstrap images for the Node 26 toolchain [build images] From a3b9f185faeec3ef5d10ca7d4e05a84403663928 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Mon, 8 Jun 2026 21:11:43 +0000 Subject: [PATCH 32/61] test harness: download the ABI-matching node with curl, cache in ~/.cache [build images] The fetch()-based download stalled on the darwin agents (and locally), eating the napi suite's 120s beforeAll timeout before the fixture build ever ran, which cascaded into every cleanup-hooks test failing with 'Cannot find module napitests.node'. curl bounds the transfer (--retry 3 --max-time 180), ships on every CI platform, and finishes in seconds. Cache the unpacked node under ~/.cache so the persistent macOS agents download once ever, and give the napi/v8 hooks headroom. --- test/harness.ts | 36 ++++++++++++++++++++++++------------ test/napi/napi.test.ts | 7 ++++--- test/v8/v8.test.ts | 2 +- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/test/harness.ts b/test/harness.ts index 164938e4d2c..5f1538db5de 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -157,7 +157,9 @@ async function findOrDownloadAbiMatchingNode(): Promise { const version = process.versions.node; const name = `node-v${version}-${isWindows ? "win" : process.platform}-${process.arch}`; - const baseDir = join(os.tmpdir(), "bun-test-node"); + // Cache under the home directory: the machines that need the download (the + // persistent macOS fleet) then pay for it once ever, not once per boot. + const baseDir = join(os.homedir() || os.tmpdir(), ".cache", "bun-test-node"); const dir = join(baseDir, name); const exe = isWindows ? join(dir, "node.exe") : join(dir, "bin", "node"); if (fs.existsSync(exe)) { @@ -167,27 +169,37 @@ async function findOrDownloadAbiMatchingNode(): Promise { const archiveExt = isWindows ? "zip" : "tar.gz"; const url = `https://nodejs.org/dist/v${version}/${name}.${archiveExt}`; console.warn(`System node does not match ABI ${process.versions.modules}, downloading ${url}`); - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`); - } // Download and extract under unique names, then atomically rename into // place so concurrent test files (or a previous interrupted run) can't // observe a half-extracted directory. - const stagingDir = join(os.tmpdir(), "bun-test-node", `staging-${process.pid}-${Date.now()}`); + const stagingDir = join(baseDir, `staging-${process.pid}-${Date.now()}`); fs.mkdirSync(stagingDir, { recursive: true }); try { const archive = join(stagingDir, `${name}.${archiveExt}`); - await write(archive, response); + // Download with curl (ships on every CI platform, including Windows + // System32) rather than streaming through the runtime under test, and + // bound it so a stalled transfer fails instead of eating the hook + // timeout of whichever test file got here first. + const curl = (...args: string[]) => + Bun.spawnSync({ + cmd: ["curl", "-fsSL", "--retry", "3", "--max-time", "180", ...args], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const download = curl("-o", archive, url); + if (download.exitCode !== 0) { + throw new Error(`Failed to download ${url}: ${download.stderr.toString()}`); + } // Verify against the official checksum manifest before executing anything // from the archive. const shasumsUrl = `https://nodejs.org/dist/v${version}/SHASUMS256.txt`; - const shasumsResponse = await fetch(shasumsUrl); - if (!shasumsResponse.ok) { - throw new Error(`Failed to download ${shasumsUrl}: ${shasumsResponse.status} ${shasumsResponse.statusText}`); + const shasumsResult = curl(shasumsUrl); + if (shasumsResult.exitCode !== 0) { + throw new Error(`Failed to download ${shasumsUrl}: ${shasumsResult.stderr.toString()}`); } - const shasums = await shasumsResponse.text(); - const expectedHash = shasums + const expectedHash = shasumsResult.stdout + .toString() .split("\n") .find(line => line.endsWith(` ${name}.${archiveExt}`)) ?.split(" ")[0]; diff --git a/test/napi/napi.test.ts b/test/napi/napi.test.ts index 5709b2b6397..8312c944788 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -34,9 +34,10 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { process.exit(1); } console.timeEnd("Building node-gyp"); - // node-gyp rebuild can take a while under a debug/ASAN binary; default - // 5s hook timeout kills the install subprocess mid-build. - }, 120_000); + // node-gyp rebuild can take a while under a debug/ASAN binary (and the + // hook may first download an ABI-matching node); default 5s hook timeout + // kills the install subprocess mid-build. + }, 300_000); describe.each(["esm", "cjs"])("bundle .node files to %s via", format => { describe.each(["node", "bun"])("target %s", target => { diff --git a/test/v8/v8.test.ts b/test/v8/v8.test.ts index 42347201c97..77a4575fc1e 100644 --- a/test/v8/v8.test.ts +++ b/test/v8/v8.test.ts @@ -143,7 +143,7 @@ describe.skipIf(!canBuildNodeAddons()).todoIf(isBroken && isMusl)("node:v8", () // Resolve (and possibly download) the ABI-matching node here, under the // generous hook timeout, instead of inside the first test that needs it. await nodeExeMatchingAbi(); - }, 300_000); + }, 600_000); describe("module lifecycle", () => { it("can call a basic native function", async () => { From c4fe0c91d265937aa74eb9a210e2059f4c12cf1b Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Mon, 8 Jun 2026 23:13:52 +0000 Subject: [PATCH 33/61] DEBUG: instrument http2 serverrequest-pipe timeout on darwin-aarch64 --- .buildkite/ci.mjs | 6 +- .../test/parallel/test-http2-debug-pipe.js | 109 ++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 test/js/node/test/parallel/test-http2-debug-pipe.js diff --git a/.buildkite/ci.mjs b/.buildkite/ci.mjs index b93470f5a6c..49f06abec5f 100755 --- a/.buildkite/ci.mjs +++ b/.buildkite/ci.mjs @@ -1380,8 +1380,10 @@ async function getPipelineOptions() { dryRun: parseOption(/\[(dry run)\]/i), publishImages: parseOption(/\[(publish (?:(?:windows|linux) )?images?)\]/i), imageFilter: (commitMessage.match(/\[(?:build|publish) (windows|linux) images?\]/i) || [])[1]?.toLowerCase(), - buildPlatforms: Array.from(buildPlatformsMap.values()), - testPlatforms: Array.from(testPlatformsMap.values()), + // DEBUG BRANCH ONLY: darwin-aarch64 pipeline running a single test file. + buildPlatforms: Array.from(buildPlatformsMap.values()).filter(p => p.os === "darwin" && p.arch === "aarch64"), + testPlatforms: Array.from(testPlatformsMap.values()).filter(p => p.os === "darwin" && p.arch === "aarch64"), + testFiles: ["test/js/node/test/parallel/test-http2-debug-pipe.js"], }; } diff --git a/test/js/node/test/parallel/test-http2-debug-pipe.js b/test/js/node/test/parallel/test-http2-debug-pipe.js new file mode 100644 index 00000000000..2cf6e6397e4 --- /dev/null +++ b/test/js/node/test/parallel/test-http2-debug-pipe.js @@ -0,0 +1,109 @@ +'use strict'; + +// Instrumented copy of test-http2-compat-serverrequest-pipe.js to diagnose a +// darwin-aarch64-only timeout. Logs every lifecycle event and dumps stream +// state from a watchdog before the runner's 20s kill. + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const fixtures = require('../common/fixtures'); +const assert = require('assert'); +const http2 = require('http2'); +const fs = require('fs'); + +const t0 = Date.now(); +const log = m => console.error(`[+${Date.now() - t0}ms] ${m}`); + +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); +const loc = fixtures.path('person-large.jpg'); +const fn = tmpdir.resolve('http2-url-tests.js'); +log(`src size = ${fs.statSync(loc).size}`); + +const server = http2.createServer(); +let serverReq, serverRes, serverDest, clientReq, clientStr, clientSession; + +server.on('request', (req, res) => { + serverReq = req; + serverRes = res; + log('server: request received'); + let bytes = 0; + req.on('data', c => { bytes += c.length; }); + req.on('end', () => log(`server: req end (bytes=${bytes})`)); + req.on('aborted', () => log('server: req aborted')); + req.on('error', e => log(`server: req error ${e}`)); + req.on('close', () => log('server: req close')); + res.on('close', () => log('server: res close')); + const dest = (serverDest = req.pipe(fs.createWriteStream(fn))); + dest.on('error', e => log(`server: dest error ${e}`)); + dest.on('finish', () => { + log('server: dest finish'); + assert.strictEqual(req.complete, true); + assert.strictEqual(fs.readFileSync(loc).length, fs.readFileSync(fn).length); + fs.unlinkSync(fn); + res.end(); + log('server: res.end() called'); + }); +}); + +server.listen(0, () => { + const port = server.address().port; + log(`server listening on ${port}`); + const client = (clientSession = http2.connect(`http://localhost:${port}`)); + client.on('error', e => log(`client: session error ${e}`)); + client.on('goaway', (code, last) => log(`client: session goaway code=${code} last=${last}`)); + client.on('close', () => log('client: session close')); + + let remaining = 2; + function maybeClose() { + log(`maybeClose remaining=${remaining - 1}`); + if (--remaining === 0) { + server.close(() => log('server.close() completed')); + client.close(() => log('client.close() completed')); + log('server.close() + client.close() called'); + } + } + + const req = (clientReq = client.request({ ':method': 'POST' })); + req.on('response', () => log('client: response')); + req.resume(); + req.on('end', () => { log('client: req end'); maybeClose(); }); + req.on('close', () => log('client: req close')); + req.on('error', e => log(`client: req error ${e}`)); + const str = (clientStr = fs.createReadStream(loc)); + str.on('end', () => { log('client: str end'); maybeClose(); }); + str.on('error', e => log(`client: str error ${e}`)); + str.pipe(req); + log('client: str.pipe(req) started'); +}); + +const watchdog = setTimeout(() => { + log('WATCHDOG: still alive after 15s, dumping state'); + const dump = (name, s) => { + if (!s) return log(` ${name}: `); + const pick = {}; + for (const k of [ + 'readable', 'readableEnded', 'readableFlowing', 'readableLength', + 'writable', 'writableEnded', 'writableFinished', 'writableLength', 'writableNeedDrain', + 'destroyed', 'closed', 'complete', 'aborted', 'rstCode', 'pending', 'bytesWritten', + ]) { + try { + const v = s[k]; + if (v !== undefined) pick[k] = v; + } catch {} + } + log(` ${name}: ${JSON.stringify(pick)}`); + }; + dump('serverReq', serverReq); + dump('serverRes', serverRes); + dump('serverDest', serverDest); + dump('clientReq', clientReq); + dump('clientStr', clientStr); + dump('clientSession', clientSession); + try { + log(` clientSession.state: ${JSON.stringify(clientSession?.state)}`); + } catch {} + process.exit(7); +}, 15000); +watchdog.unref(); From ee0abaf7ddd37f7d94f69cf93a782fde80774066 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Mon, 8 Jun 2026 23:19:59 +0000 Subject: [PATCH 34/61] DEBUG: drop binary-size step on trimmed pipeline --- .buildkite/ci.mjs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.buildkite/ci.mjs b/.buildkite/ci.mjs index 49f06abec5f..e3b6e683fb9 100755 --- a/.buildkite/ci.mjs +++ b/.buildkite/ci.mjs @@ -1510,11 +1510,8 @@ async function getPipeline(options = {}) { } } - // Binary-size tracking covers the artifacts that ship. - const strippedPlatforms = buildPlatforms.filter(p => (p.profile ?? "release") === "release"); - if (!buildId && strippedPlatforms.length) { - steps.push(getBinarySizeStep(strippedPlatforms, options, { recordOnly: isMainBranch() })); - } + // DEBUG BRANCH ONLY: binary-size depends on every build target; the trimmed + // platform list breaks its dependencies and cancels the whole build. // Sign Windows builds on release (non-canary main) or when [sign windows] // is in the commit message (for testing the sign step on a branch). From 0822855963a67282b174924893022d9f0d5eb827 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Mon, 8 Jun 2026 23:24:32 +0000 Subject: [PATCH 35/61] DEBUG: bake host image for trimmed pipeline [build images] From 55adb92d12bb3c42c0a54d53a9a263e5540b8a60 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Mon, 8 Jun 2026 23:29:59 +0000 Subject: [PATCH 36/61] DEBUG: include linux-aarch64 platforms so host images get baked [build images] --- .buildkite/ci.mjs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.buildkite/ci.mjs b/.buildkite/ci.mjs index e3b6e683fb9..492188f534b 100755 --- a/.buildkite/ci.mjs +++ b/.buildkite/ci.mjs @@ -1381,7 +1381,16 @@ async function getPipelineOptions() { publishImages: parseOption(/\[(publish (?:(?:windows|linux) )?images?)\]/i), imageFilter: (commitMessage.match(/\[(?:build|publish) (windows|linux) images?\]/i) || [])[1]?.toLowerCase(), // DEBUG BRANCH ONLY: darwin-aarch64 pipeline running a single test file. - buildPlatforms: Array.from(buildPlatformsMap.values()).filter(p => p.os === "darwin" && p.arch === "aarch64"), + // The two linux-aarch64 entries are kept because the darwin cross-build + // runs on their host images (amazonlinux-with-docker for cpp/link, + // alpine-musl for rust), and image steps are only generated from + // non-crossCompile linux platforms. + buildPlatforms: Array.from(buildPlatformsMap.values()).filter( + p => + p.arch === "aarch64" && + p.abi !== "android" && + (p.os === "darwin" || (p.os === "linux" && (p.distro === "amazonlinux" || p.abi === "musl"))), + ), testPlatforms: Array.from(testPlatformsMap.values()).filter(p => p.os === "darwin" && p.arch === "aarch64"), testFiles: ["test/js/node/test/parallel/test-http2-debug-pipe.js"], }; From 7c3f545b511a9ff1fc22f4043e1b9b5dbd97ab96 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 9 Jun 2026 01:15:03 +0000 Subject: [PATCH 37/61] DEBUG: loop the instrumented pipe scenario with 3-way concurrency --- .../test/parallel/test-http2-debug-pipe.js | 195 ++++++++++-------- 1 file changed, 108 insertions(+), 87 deletions(-) diff --git a/test/js/node/test/parallel/test-http2-debug-pipe.js b/test/js/node/test/parallel/test-http2-debug-pipe.js index 2cf6e6397e4..978288edf6d 100644 --- a/test/js/node/test/parallel/test-http2-debug-pipe.js +++ b/test/js/node/test/parallel/test-http2-debug-pipe.js @@ -1,8 +1,9 @@ 'use strict'; -// Instrumented copy of test-http2-compat-serverrequest-pipe.js to diagnose a -// darwin-aarch64-only timeout. Logs every lifecycle event and dumps stream -// state from a watchdog before the runner's 20s kill. +// Instrumented, looped copy of test-http2-compat-serverrequest-pipe.js to +// diagnose a darwin-aarch64-only timeout that reproduces under full-suite +// load but not in single runs. Repeats the scenario until a soft deadline; +// any iteration stalling >4s dumps state and exits 7. const common = require('../common'); if (!common.hasCrypto) @@ -12,98 +13,118 @@ const assert = require('assert'); const http2 = require('http2'); const fs = require('fs'); -const t0 = Date.now(); -const log = m => console.error(`[+${Date.now() - t0}ms] ${m}`); - const tmpdir = require('../common/tmpdir'); tmpdir.refresh(); const loc = fixtures.path('person-large.jpg'); -const fn = tmpdir.resolve('http2-url-tests.js'); -log(`src size = ${fs.statSync(loc).size}`); +const SOFT_DEADLINE_MS = 14_000; +const STALL_MS = 4_000; +const start = Date.now(); +let iteration = 0; -const server = http2.createServer(); -let serverReq, serverRes, serverDest, clientReq, clientStr, clientSession; +function log(m) { + console.error(`[iter ${iteration} +${Date.now() - start}ms] ${m}`); +} -server.on('request', (req, res) => { - serverReq = req; - serverRes = res; - log('server: request received'); - let bytes = 0; - req.on('data', c => { bytes += c.length; }); - req.on('end', () => log(`server: req end (bytes=${bytes})`)); - req.on('aborted', () => log('server: req aborted')); - req.on('error', e => log(`server: req error ${e}`)); - req.on('close', () => log('server: req close')); - res.on('close', () => log('server: res close')); - const dest = (serverDest = req.pipe(fs.createWriteStream(fn))); - dest.on('error', e => log(`server: dest error ${e}`)); - dest.on('finish', () => { - log('server: dest finish'); - assert.strictEqual(req.complete, true); - assert.strictEqual(fs.readFileSync(loc).length, fs.readFileSync(fn).length); - fs.unlinkSync(fn); - res.end(); - log('server: res.end() called'); - }); -}); +function dump(name, s) { + if (!s) return log(` ${name}: `); + const pick = {}; + for (const k of [ + 'readable', 'readableEnded', 'readableFlowing', 'readableLength', + 'writable', 'writableEnded', 'writableFinished', 'writableLength', 'writableNeedDrain', + 'destroyed', 'closed', 'complete', 'aborted', 'rstCode', 'pending', 'bytesWritten', + ]) { + try { + const v = s[k]; + if (v !== undefined) pick[k] = v; + } catch {} + } + log(` ${name}: ${JSON.stringify(pick)}`); +} -server.listen(0, () => { - const port = server.address().port; - log(`server listening on ${port}`); - const client = (clientSession = http2.connect(`http://localhost:${port}`)); - client.on('error', e => log(`client: session error ${e}`)); - client.on('goaway', (code, last) => log(`client: session goaway code=${code} last=${last}`)); - client.on('close', () => log('client: session close')); +function runOnce() { + iteration++; + const fn = tmpdir.resolve(`http2-pipe-${iteration}.bin`); + const events = []; + const ev = m => events.push(`+${Date.now() - start}ms ${m}`); - let remaining = 2; - function maybeClose() { - log(`maybeClose remaining=${remaining - 1}`); - if (--remaining === 0) { - server.close(() => log('server.close() completed')); - client.close(() => log('client.close() completed')); - log('server.close() + client.close() called'); - } - } + const state = {}; + const server = http2.createServer(); - const req = (clientReq = client.request({ ':method': 'POST' })); - req.on('response', () => log('client: response')); - req.resume(); - req.on('end', () => { log('client: req end'); maybeClose(); }); - req.on('close', () => log('client: req close')); - req.on('error', e => log(`client: req error ${e}`)); - const str = (clientStr = fs.createReadStream(loc)); - str.on('end', () => { log('client: str end'); maybeClose(); }); - str.on('error', e => log(`client: str error ${e}`)); - str.pipe(req); - log('client: str.pipe(req) started'); -}); + server.on('request', (req, res) => { + state.serverReq = req; + state.serverRes = res; + ev('server: request'); + req.on('end', () => ev('server: req end')); + req.on('error', e => ev(`server: req error ${e}`)); + const dest = (state.serverDest = req.pipe(fs.createWriteStream(fn))); + dest.on('error', e => ev(`server: dest error ${e}`)); + dest.on('finish', () => { + ev('server: dest finish'); + assert.strictEqual(req.complete, true); + assert.strictEqual(fs.readFileSync(loc).length, fs.readFileSync(fn).length); + fs.unlinkSync(fn); + res.end(); + ev('server: res.end()'); + }); + }); -const watchdog = setTimeout(() => { - log('WATCHDOG: still alive after 15s, dumping state'); - const dump = (name, s) => { - if (!s) return log(` ${name}: `); - const pick = {}; - for (const k of [ - 'readable', 'readableEnded', 'readableFlowing', 'readableLength', - 'writable', 'writableEnded', 'writableFinished', 'writableLength', 'writableNeedDrain', - 'destroyed', 'closed', 'complete', 'aborted', 'rstCode', 'pending', 'bytesWritten', - ]) { + return new Promise(resolve => { + const stall = setTimeout(() => { + log('STALL detected, event trail:'); + for (const e of events) log(` ${e}`); + dump('serverReq', state.serverReq); + dump('serverRes', state.serverRes); + dump('serverDest', state.serverDest); + dump('clientReq', state.clientReq); + dump('clientStr', state.clientStr); + dump('clientSession', state.clientSession); try { - const v = s[k]; - if (v !== undefined) pick[k] = v; + log(` clientSession.state: ${JSON.stringify(state.clientSession?.state)}`); } catch {} - } - log(` ${name}: ${JSON.stringify(pick)}`); - }; - dump('serverReq', serverReq); - dump('serverRes', serverRes); - dump('serverDest', serverDest); - dump('clientReq', clientReq); - dump('clientStr', clientStr); - dump('clientSession', clientSession); - try { - log(` clientSession.state: ${JSON.stringify(clientSession?.state)}`); - } catch {} - process.exit(7); -}, 15000); -watchdog.unref(); + process.exit(7); + }, STALL_MS); + stall.unref(); + + server.listen(0, () => { + const port = server.address().port; + const client = (state.clientSession = http2.connect(`http://localhost:${port}`)); + client.on('error', e => ev(`client: session error ${e}`)); + client.on('goaway', (code, last) => ev(`client: goaway code=${code} last=${last}`)); + client.on('close', () => ev('client: session close')); + + let remaining = 2; + let closesPending = 2; + function closed() { + if (--closesPending === 0) { + clearTimeout(stall); + resolve(); + } + } + function maybeClose() { + ev(`maybeClose remaining=${remaining - 1}`); + if (--remaining === 0) { + server.close(() => { ev('server.close() completed'); closed(); }); + client.close(() => { ev('client.close() completed'); closed(); }); + } + } + + const req = (state.clientReq = client.request({ ':method': 'POST' })); + req.on('response', () => ev('client: response')); + req.resume(); + req.on('end', () => { ev('client: req end'); maybeClose(); }); + req.on('error', e => ev(`client: req error ${e}`)); + const str = (state.clientStr = fs.createReadStream(loc)); + str.on('end', () => { ev('client: str end'); maybeClose(); }); + str.on('error', e => ev(`client: str error ${e}`)); + str.pipe(req); + ev('client: pipe started'); + }); + }); +} + +(async () => { + while (Date.now() - start < SOFT_DEADLINE_MS) { + await Promise.all([runOnce(), runOnce(), runOnce()]); + } + console.error(`completed ${iteration} iterations without a stall`); +})(); From 1bfde828523c555adbf2bf35e558be147f7875d4 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 9 Jun 2026 01:19:06 +0000 Subject: [PATCH 38/61] DEBUG: rerun with images [build images] From b3fda1ed7b3b05748a8188a0903f7bfa413cf951 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 9 Jun 2026 03:12:53 +0000 Subject: [PATCH 39/61] DEBUG: run full http2 family; instrument the real pipe test in place [build images] --- .buildkite/ci.mjs | 2 +- .../test-http2-compat-serverrequest-pipe.js | 71 ++++++++++++++++--- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/.buildkite/ci.mjs b/.buildkite/ci.mjs index 492188f534b..349758f30e0 100755 --- a/.buildkite/ci.mjs +++ b/.buildkite/ci.mjs @@ -1392,7 +1392,7 @@ async function getPipelineOptions() { (p.os === "darwin" || (p.os === "linux" && (p.distro === "amazonlinux" || p.abi === "musl"))), ), testPlatforms: Array.from(testPlatformsMap.values()).filter(p => p.os === "darwin" && p.arch === "aarch64"), - testFiles: ["test/js/node/test/parallel/test-http2-debug-pipe.js"], + testFiles: ["js/node/test/parallel/test-http2-"], }; } diff --git a/test/js/node/test/parallel/test-http2-compat-serverrequest-pipe.js b/test/js/node/test/parallel/test-http2-compat-serverrequest-pipe.js index 64beb6472b9..43ab3dadcaa 100644 --- a/test/js/node/test/parallel/test-http2-compat-serverrequest-pipe.js +++ b/test/js/node/test/parallel/test-http2-compat-serverrequest-pipe.js @@ -9,6 +9,44 @@ const http2 = require('http2'); const fs = require('fs'); // Piping should work as expected with createWriteStream +// DEBUG BRANCH: instrumented with an event trail + stall watchdog to diagnose +// a darwin-aarch64 suite-context timeout. Logic is unchanged from upstream. + +const t0 = Date.now(); +const events = []; +const ev = m => events.push(`+${Date.now() - t0}ms ${m}`); +const state = {}; + +const watchdog = setTimeout(() => { + console.error('WATCHDOG: stalled after 15s, event trail:'); + for (const e of events) console.error(` ${e}`); + const dump = (name, s) => { + if (!s) return console.error(` ${name}: `); + const pick = {}; + for (const k of [ + 'readable', 'readableEnded', 'readableFlowing', 'readableLength', + 'writable', 'writableEnded', 'writableFinished', 'writableLength', 'writableNeedDrain', + 'destroyed', 'closed', 'complete', 'aborted', 'rstCode', 'pending', 'bytesWritten', + ]) { + try { + const v = s[k]; + if (v !== undefined) pick[k] = v; + } catch {} + } + console.error(` ${name}: ${JSON.stringify(pick)}`); + }; + dump('serverReq', state.serverReq); + dump('serverRes', state.serverRes); + dump('serverDest', state.serverDest); + dump('clientReq', state.clientReq); + dump('clientStr', state.clientStr); + dump('clientSession', state.clientSession); + try { + console.error(` clientSession.state: ${JSON.stringify(state.clientSession?.state)}`); + } catch {} + process.exit(7); +}, 15000); +watchdog.unref(); const tmpdir = require('../common/tmpdir'); tmpdir.refresh(); @@ -18,32 +56,47 @@ const fn = tmpdir.resolve('http2-url-tests.js'); const server = http2.createServer(); server.on('request', common.mustCall((req, res) => { - const dest = req.pipe(fs.createWriteStream(fn)); + state.serverReq = req; + state.serverRes = res; + ev('server: request'); + req.on('end', () => ev('server: req end')); + req.on('error', e => ev(`server: req error ${e}`)); + const dest = (state.serverDest = req.pipe(fs.createWriteStream(fn))); + dest.on('error', e => ev(`server: dest error ${e}`)); dest.on('finish', common.mustCall(() => { + ev('server: dest finish'); assert.strictEqual(req.complete, true); assert.strictEqual(fs.readFileSync(loc).length, fs.readFileSync(fn).length); fs.unlinkSync(fn); res.end(); + ev('server: res.end()'); })); })); server.listen(0, common.mustCall(() => { const port = server.address().port; - const client = http2.connect(`http://localhost:${port}`); + const client = (state.clientSession = http2.connect(`http://localhost:${port}`)); + client.on('error', e => ev(`client: session error ${e}`)); + client.on('goaway', (code, last) => ev(`client: goaway code=${code} last=${last}`)); + client.on('close', () => ev('client: session close')); let remaining = 2; function maybeClose() { + ev(`maybeClose remaining=${remaining - 1}`); if (--remaining === 0) { - server.close(); - client.close(); + server.close(() => ev('server.close() completed')); + client.close(() => ev('client.close() completed')); } } - const req = client.request({ ':method': 'POST' }); - req.on('response', common.mustCall()); + const req = (state.clientReq = client.request({ ':method': 'POST' })); + req.on('response', common.mustCall(() => ev('client: response'))); req.resume(); - req.on('end', common.mustCall(maybeClose)); - const str = fs.createReadStream(loc); - str.on('end', common.mustCall(maybeClose)); + req.on('end', common.mustCall(() => { ev('client: req end'); maybeClose(); })); + req.on('error', e => ev(`client: req error ${e}`)); + const str = (state.clientStr = fs.createReadStream(loc)); + str.on('end', common.mustCall(() => { ev('client: str end'); maybeClose(); })); + str.on('error', e => ev(`client: str error ${e}`)); str.pipe(req); + ev('client: pipe started'); })); From f9c4521b4c51c14c266b8999c906248f47dfad67 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 9 Jun 2026 05:10:07 +0000 Subject: [PATCH 40/61] DEBUG: instrument server socket/session lifecycle [build images] --- .../test-http2-compat-serverrequest-pipe.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/js/node/test/parallel/test-http2-compat-serverrequest-pipe.js b/test/js/node/test/parallel/test-http2-compat-serverrequest-pipe.js index 43ab3dadcaa..1cd24dafb2d 100644 --- a/test/js/node/test/parallel/test-http2-compat-serverrequest-pipe.js +++ b/test/js/node/test/parallel/test-http2-compat-serverrequest-pipe.js @@ -35,6 +35,9 @@ const watchdog = setTimeout(() => { } console.error(` ${name}: ${JSON.stringify(pick)}`); }; + dump('serverSock', state.serverSock); + dump('serverSession', state.serverSession); + try { console.error(` server._connections: ${server._connections}`); } catch {} dump('serverReq', state.serverReq); dump('serverRes', state.serverRes); dump('serverDest', state.serverDest); @@ -55,6 +58,21 @@ const fn = tmpdir.resolve('http2-url-tests.js'); const server = http2.createServer(); +server.on('connection', sock => { + state.serverSock = sock; + ev('server: connection'); + sock.on('end', () => ev('server: socket end')); + sock.on('close', () => ev('server: socket close')); + sock.on('error', e => ev('server: socket error ' + e)); +}); +server.on('session', session => { + state.serverSession = session; + ev('server: session'); + session.on('close', () => ev('server: session close')); + session.on('goaway', (c, l) => ev('server: session goaway code=' + c + ' last=' + l)); + session.on('error', e => ev('server: session error ' + e)); +}); + server.on('request', common.mustCall((req, res) => { state.serverReq = req; state.serverRes = res; From 886a1b644ce420c145a90a3a34c4f07922b5be15 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 9 Jun 2026 05:19:21 +0000 Subject: [PATCH 41/61] http2: destroy the server session when its socket closes [build images] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A server session that received a graceful GOAWAY marks itself closed and defers destruction to the moment its last stream closes. If the peer's socket disappears before that stream-close arrives (the client sends GOAWAY and ends the socket in the same tick, and on macOS the frame and the EOF are processed in one read batch), that moment never comes: the socket-close handler only called close(), whose closed/destroyed early-return now makes it a no-op, so the session — and the server's open-connection count — stayed alive forever and server.close() never completed. Node's socketOnClose unconditionally tears the session down (close() + closeSession()); do the same by following close() with destroy(), mirroring what the client session's #onClose already does. Fixes the test-http2-compat-serverrequest-pipe.js timeout on the darwin CI fleet (diagnosed with an instrumented build: all request/response events completed, server.close() never fired). --- src/js/node/http2.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 06cb089356f..8f8fca8ce5f 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -3052,7 +3052,14 @@ class ServerHttp2Session extends Http2Session { parser.detach(); this.#parser = null; } + // Like Node's socketOnClose, a dead socket always tears the session down + // (close() followed by closeSession() upstream). close() alone is not + // enough: it early-returns once a received GOAWAY has already marked the + // session closed, and the destroy it deferred to the last stream's close + // never comes once the peer is gone — leaving the session (and the + // server's open-connection count) alive forever. this.close(); + this.destroy(); } #onError(error: Error) { this.destroy(error); From a33c1fc12f94f58f7a188b7c42d67a924c056750 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 9 Jun 2026 07:16:12 +0000 Subject: [PATCH 42/61] DEBUG: revert maybeReadMore kReading guard [build images] --- src/js/internal/streams/readable.ts | 3 +- test/js/third_party/grpc-js/bun.lock | 80 ++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 test/js/third_party/grpc-js/bun.lock diff --git a/src/js/internal/streams/readable.ts b/src/js/internal/streams/readable.ts index b18458c4213..e4ffdd8bd1e 100644 --- a/src/js/internal/streams/readable.ts +++ b/src/js/internal/streams/readable.ts @@ -772,7 +772,8 @@ function emitReadable_(stream) { // However, if we're not ended, or reading, and the length < hwm, // then go ahead and try to read some more preemptively. function maybeReadMore(stream, state) { - if ((state[kState] & (kReadingMore | kReading | kConstructed)) === kConstructed) { + // DEBUG: revert the Node 26 kReading guard to test the socket-EOF-stall hypothesis. + if ((state[kState] & (kReadingMore | kConstructed)) === kConstructed) { state[kState] |= kReadingMore; process.nextTick(maybeReadMore_, stream, state); } diff --git a/test/js/third_party/grpc-js/bun.lock b/test/js/third_party/grpc-js/bun.lock new file mode 100644 index 00000000000..99036591a7d --- /dev/null +++ b/test/js/third_party/grpc-js/bun.lock @@ -0,0 +1,80 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "grpc-js", + "dependencies": { + "@grpc/grpc-js": "1.9.9", + "@grpc/proto-loader": "0.7.10", + }, + }, + }, + "packages": { + "@grpc/grpc-js": ["@grpc/grpc-js@1.9.9", "", { "dependencies": { "@grpc/proto-loader": "^0.7.8", "@types/node": ">=12.12.47" } }, "sha512-vQ1qwi/Kiyprt+uhb1+rHMpyk4CVRMTGNUGGPRGS7pLNfWkdCHrGEnT6T3/JyC2VZgoOX/X1KwdoU0WYQAeYcQ=="], + + "@grpc/proto-loader": ["@grpc/proto-loader@0.7.10", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.4", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-CAqDfoaQ8ykFd9zqBDn4k6iWT9loLAlc2ETmDFS9JCD70gDcnA4L3AFEo2iV7KyAtAAHFW9ftq1Fz+Vsgq80RQ=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.2", "", {}, "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="], + + "@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + } +} From 59a4a879bc2ceddaa028d9645b3f74631ff5d671 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 9 Jun 2026 09:28:31 +0000 Subject: [PATCH 43/61] DEBUG: capture client socket state; add plain-net EOF probe [build images] --- .../test-http2-compat-serverrequest-pipe.js | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/js/node/test/parallel/test-http2-compat-serverrequest-pipe.js b/test/js/node/test/parallel/test-http2-compat-serverrequest-pipe.js index 1cd24dafb2d..45fd22e747e 100644 --- a/test/js/node/test/parallel/test-http2-compat-serverrequest-pipe.js +++ b/test/js/node/test/parallel/test-http2-compat-serverrequest-pipe.js @@ -43,6 +43,7 @@ const watchdog = setTimeout(() => { dump('serverDest', state.serverDest); dump('clientReq', state.clientReq); dump('clientStr', state.clientStr); + dump('clientSock', state.clientSock); dump('clientSession', state.clientSession); try { console.error(` clientSession.state: ${JSON.stringify(state.clientSession?.state)}`); @@ -56,6 +57,25 @@ tmpdir.refresh(); const loc = fixtures.path('person-large.jpg'); const fn = tmpdir.resolve('http2-url-tests.js'); +// Plain-net same-tick write+end EOF delivery probe (mirrors the h2 client's +// GOAWAY+GOAWAY+FIN burst at the socket level). +const net = require('net'); +{ + const ns = net.createServer(sock => { + sock.on('data', d => ev(`net-probe: server data ${d.length}`)); + sock.on('end', () => ev('net-probe: server end')); + sock.on('close', () => { ev('net-probe: server close'); ns.close(); }); + }); + ns.listen(0, () => { + const c = net.connect(ns.address().port, '127.0.0.1', () => { + c.write(Buffer.alloc(17)); + c.write(Buffer.alloc(17)); + c.end(); + }); + c.on('close', () => ev('net-probe: client close')); + }); +} + const server = http2.createServer(); server.on('connection', sock => { @@ -94,6 +114,13 @@ server.on('request', common.mustCall((req, res) => { server.listen(0, common.mustCall(() => { const port = server.address().port; const client = (state.clientSession = http2.connect(`http://localhost:${port}`)); + client.on('connect', () => { + const cs = (state.clientSock = client.socket); + ev('client: connected'); + cs.on('end', () => ev('client: socket end')); + cs.on('close', () => ev('client: socket close')); + cs.on('finish', () => ev('client: socket finish')); + }); client.on('error', e => ev(`client: session error ${e}`)); client.on('goaway', (code, last) => ev(`client: goaway code=${code} last=${last}`)); client.on('close', () => ev('client: session close')); From d3f2ae405c458995fe4f02a40a13e7edd74eddbd Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 9 Jun 2026 11:44:48 +0000 Subject: [PATCH 44/61] DEBUG: stderr prints in socket teardown paths [build images] --- src/runtime/socket/socket_body.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index e29bfb98a03..85760b6de28 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -960,6 +960,9 @@ impl NewSocket { } pub fn close_and_detach(&self, code: uws::CloseCode) { + eprintln!("[h2dbg] close_and_detach server={} detached={} native_cb={}", + self.get_handlers().mode == super::SocketMode::Server, self.socket.get().is_detached(), + !matches!(self.native_callback.get(), NativeCallbacks::None)); let socket = self.socket.get(); self.buffered_data_for_node_net .with_mut(|b| b.clear_and_free()); @@ -971,6 +974,9 @@ impl NewSocket { } pub fn mark_inactive(&self) { + eprintln!("[h2dbg] mark_inactive server={} active={} closed={}", + self.get_handlers().mode == super::SocketMode::Server, self.flags.get().contains(Flags::IS_ACTIVE), + self.socket.get().is_closed()); if self.flags.get().contains(Flags::IS_ACTIVE) { // we have to close the socket before the socket context is closed // otherwise we will get a segfault @@ -1219,10 +1225,14 @@ impl NewSocket { jsc::mark_binding!(); // SAFETY: per fn contract; R-2 shared reborrow. let this: &Self = unsafe { &*this }; + eprintln!("[h2dbg] on_end detached={} native_cb={}", + this.socket.get().is_detached(), + !matches!(this.native_callback.get(), NativeCallbacks::None)); if this.socket.get().is_detached() { return; } let handlers = this.get_handlers(); + eprintln!("[h2dbg] on_end server={} cb_empty={}", handlers.mode == super::SocketMode::Server, handlers.on_end.is_empty()); log!( "onEnd {}", if handlers.mode == super::SocketMode::Server { @@ -1414,6 +1424,9 @@ impl NewSocket { jsc::mark_binding!(); // SAFETY: per fn contract; R-2 shared reborrow. let this: &Self = unsafe { &*this }; + eprintln!("[h2dbg] on_close server={} detached={} native_cb={}", + this.get_handlers().mode == super::SocketMode::Server, this.socket.get().is_detached(), + !matches!(this.native_callback.get(), NativeCallbacks::None)); let handlers = this.get_handlers(); log!( "onClose {}", From 52d77a158d4be2d5bfdbd8ae83c2a4ecb8bf20d9 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:46:55 +0000 Subject: [PATCH 45/61] [autofix.ci] apply automated fixes --- src/runtime/socket/socket_body.rs | 39 +++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 85760b6de28..0d2c6b6a462 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -960,9 +960,12 @@ impl NewSocket { } pub fn close_and_detach(&self, code: uws::CloseCode) { - eprintln!("[h2dbg] close_and_detach server={} detached={} native_cb={}", - self.get_handlers().mode == super::SocketMode::Server, self.socket.get().is_detached(), - !matches!(self.native_callback.get(), NativeCallbacks::None)); + eprintln!( + "[h2dbg] close_and_detach server={} detached={} native_cb={}", + self.get_handlers().mode == super::SocketMode::Server, + self.socket.get().is_detached(), + !matches!(self.native_callback.get(), NativeCallbacks::None) + ); let socket = self.socket.get(); self.buffered_data_for_node_net .with_mut(|b| b.clear_and_free()); @@ -974,9 +977,12 @@ impl NewSocket { } pub fn mark_inactive(&self) { - eprintln!("[h2dbg] mark_inactive server={} active={} closed={}", - self.get_handlers().mode == super::SocketMode::Server, self.flags.get().contains(Flags::IS_ACTIVE), - self.socket.get().is_closed()); + eprintln!( + "[h2dbg] mark_inactive server={} active={} closed={}", + self.get_handlers().mode == super::SocketMode::Server, + self.flags.get().contains(Flags::IS_ACTIVE), + self.socket.get().is_closed() + ); if self.flags.get().contains(Flags::IS_ACTIVE) { // we have to close the socket before the socket context is closed // otherwise we will get a segfault @@ -1225,14 +1231,20 @@ impl NewSocket { jsc::mark_binding!(); // SAFETY: per fn contract; R-2 shared reborrow. let this: &Self = unsafe { &*this }; - eprintln!("[h2dbg] on_end detached={} native_cb={}", + eprintln!( + "[h2dbg] on_end detached={} native_cb={}", this.socket.get().is_detached(), - !matches!(this.native_callback.get(), NativeCallbacks::None)); + !matches!(this.native_callback.get(), NativeCallbacks::None) + ); if this.socket.get().is_detached() { return; } let handlers = this.get_handlers(); - eprintln!("[h2dbg] on_end server={} cb_empty={}", handlers.mode == super::SocketMode::Server, handlers.on_end.is_empty()); + eprintln!( + "[h2dbg] on_end server={} cb_empty={}", + handlers.mode == super::SocketMode::Server, + handlers.on_end.is_empty() + ); log!( "onEnd {}", if handlers.mode == super::SocketMode::Server { @@ -1424,9 +1436,12 @@ impl NewSocket { jsc::mark_binding!(); // SAFETY: per fn contract; R-2 shared reborrow. let this: &Self = unsafe { &*this }; - eprintln!("[h2dbg] on_close server={} detached={} native_cb={}", - this.get_handlers().mode == super::SocketMode::Server, this.socket.get().is_detached(), - !matches!(this.native_callback.get(), NativeCallbacks::None)); + eprintln!( + "[h2dbg] on_close server={} detached={} native_cb={}", + this.get_handlers().mode == super::SocketMode::Server, + this.socket.get().is_detached(), + !matches!(this.native_callback.get(), NativeCallbacks::None) + ); let handlers = this.get_handlers(); log!( "onClose {}", From 971e41caf71f0bdf8e1fa69d9bd9cc7f99189d47 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 9 Jun 2026 11:48:34 +0000 Subject: [PATCH 46/61] DEBUG: rerun after autofix [build images] From 87ae012a9549c150f67b084a976775daa4ccade5 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 9 Jun 2026 14:05:08 +0000 Subject: [PATCH 47/61] DEBUG: null-safe teardown prints [build images] --- src/runtime/socket/socket_body.rs | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 0d2c6b6a462..5792e7ef0e2 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -706,6 +706,16 @@ impl NewSocket { /// per expression — same Stacked-Borrows footprint as the previous manual /// `unsafe { (*p).field }`). Mutating sites use `.as_ptr()` and reborrow /// `&mut` explicitly. + fn h2dbg_server(&self) -> &'static str { + match self.handlers.get() { + Some(h) => { + // SAFETY: debug-only read; handlers outlive the socket callbacks. + if unsafe { h.as_ref() }.mode == super::SocketMode::Server { "S" } else { "C" } + } + None => "none", + } + } + pub fn get_handlers(&self) -> bun_ptr::BackRef { self.handlers .get() @@ -961,8 +971,8 @@ impl NewSocket { pub fn close_and_detach(&self, code: uws::CloseCode) { eprintln!( - "[h2dbg] close_and_detach server={} detached={} native_cb={}", - self.get_handlers().mode == super::SocketMode::Server, + "[h2dbg] close_and_detach who={} detached={} native_cb={}", + self.h2dbg_server(), self.socket.get().is_detached(), !matches!(self.native_callback.get(), NativeCallbacks::None) ); @@ -978,8 +988,8 @@ impl NewSocket { pub fn mark_inactive(&self) { eprintln!( - "[h2dbg] mark_inactive server={} active={} closed={}", - self.get_handlers().mode == super::SocketMode::Server, + "[h2dbg] mark_inactive who={} active={} closed={}", + self.h2dbg_server(), self.flags.get().contains(Flags::IS_ACTIVE), self.socket.get().is_closed() ); @@ -1241,8 +1251,8 @@ impl NewSocket { } let handlers = this.get_handlers(); eprintln!( - "[h2dbg] on_end server={} cb_empty={}", - handlers.mode == super::SocketMode::Server, + "[h2dbg] on_end who={} cb_empty={}", + this.h2dbg_server(), handlers.on_end.is_empty() ); log!( @@ -1437,8 +1447,8 @@ impl NewSocket { // SAFETY: per fn contract; R-2 shared reborrow. let this: &Self = unsafe { &*this }; eprintln!( - "[h2dbg] on_close server={} detached={} native_cb={}", - this.get_handlers().mode == super::SocketMode::Server, + "[h2dbg] on_close who={} detached={} native_cb={}", + this.h2dbg_server(), this.socket.get().is_detached(), !matches!(this.native_callback.get(), NativeCallbacks::None) ); From 3f89df418dc51d028cc531093d227ba32f0c2580 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:07:22 +0000 Subject: [PATCH 48/61] [autofix.ci] apply automated fixes --- src/runtime/socket/socket_body.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 5792e7ef0e2..7307c3d51ca 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -710,7 +710,11 @@ impl NewSocket { match self.handlers.get() { Some(h) => { // SAFETY: debug-only read; handlers outlive the socket callbacks. - if unsafe { h.as_ref() }.mode == super::SocketMode::Server { "S" } else { "C" } + if unsafe { h.as_ref() }.mode == super::SocketMode::Server { + "S" + } else { + "C" + } } None => "none", } From c6efd6f2ac7e4617f3b5843afc81deb10af06569 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 9 Jun 2026 14:09:00 +0000 Subject: [PATCH 49/61] DEBUG: rerun after autofix [build images] From 68f2d553a32042171febd3816151cd63b136d4de Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 9 Jun 2026 16:08:47 +0000 Subject: [PATCH 50/61] DEBUG: pause/resume + native on_data prints [build images] --- packages/bun-usockets/src/socket.c | 3 +++ src/runtime/socket/socket_body.rs | 4 +++- .../test/parallel/test-http2-compat-serverrequest-pipe.js | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index a294fc59fed..1da8eddd4fc 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -16,6 +16,7 @@ */ // clang-format off +#include #include "libusockets.h" #include "internal/internal.h" #include @@ -656,6 +657,7 @@ struct us_loop_t *us_connecting_socket_get_loop(struct us_connecting_socket_t *c } void us_socket_pause(struct us_socket_t *s) { + fprintf(stderr, "[h2dbg-c] us_socket_pause fd=%d already=%d\n", us_poll_fd((struct us_poll_t *)s), s->flags.is_paused); if (s->flags.is_paused) return; // closed cannot be paused because it is already closed if (us_socket_is_closed(s)) return; @@ -665,6 +667,7 @@ void us_socket_pause(struct us_socket_t *s) { } void us_socket_resume(struct us_socket_t *s) { + fprintf(stderr, "[h2dbg-c] us_socket_resume fd=%d paused=%d\n", us_poll_fd((struct us_poll_t *)s), s->flags.is_paused); if (!s->flags.is_paused) return; s->flags.is_paused = 0; // closed cannot be resumed diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 7307c3d51ca..9aa44f54808 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -1601,7 +1601,9 @@ impl NewSocket { }, data.len() ); - if this.native_callback.get().on_data(data) { + let native_consumed = this.native_callback.get().on_data(data); + if native_consumed { + eprintln!("[h2dbg] on_data native len={}", data.len()); return; } diff --git a/test/js/node/test/parallel/test-http2-compat-serverrequest-pipe.js b/test/js/node/test/parallel/test-http2-compat-serverrequest-pipe.js index 45fd22e747e..47d53850784 100644 --- a/test/js/node/test/parallel/test-http2-compat-serverrequest-pipe.js +++ b/test/js/node/test/parallel/test-http2-compat-serverrequest-pipe.js @@ -35,6 +35,7 @@ const watchdog = setTimeout(() => { } console.error(` ${name}: ${JSON.stringify(pick)}`); }; + try { console.error(` serverSock.isPaused(): ${state.serverSock?.isPaused?.()}`); } catch {} dump('serverSock', state.serverSock); dump('serverSession', state.serverSession); try { console.error(` server._connections: ${server._connections}`); } catch {} From 63a86eff19206feef090d91bee7e22cc97d2c06a Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 9 Jun 2026 18:09:28 +0000 Subject: [PATCH 51/61] DEBUG: per-poll and per-recv prints in loop.c [build images] --- packages/bun-usockets/src/loop.c | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index f237edf57d2..2ceea2cf0cd 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -367,6 +367,11 @@ void us_internal_loop_post(struct us_loop_t *loop) { #endif void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, int events) { + { + int pt = us_internal_poll_type(p); + if (pt == POLL_TYPE_SOCKET || pt == POLL_TYPE_SOCKET_SHUT_DOWN) + fprintf(stderr, "[h2dbg-c] poll fd=%d err=%d eof=%d ev=%d\n", us_poll_fd(p), error, eof, events); + } switch (us_internal_poll_type(p)) { case POLL_TYPE_CALLBACK: { struct us_internal_callback_t *cb = (struct us_internal_callback_t *) p; @@ -583,6 +588,9 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in } #endif + fprintf(stderr, "[h2dbg-c] recv fd=%d len=%d errno=%d shut=%d closed=%d\n", + us_poll_fd(&s->p), length, length < 0 ? errno : 0, + us_socket_is_shut_down(s), us_socket_is_closed(s)); if (length > 0) { s = s->ssl ? us_internal_ssl_on_data(s, loop->data.recv_buf + LIBUS_RECV_BUFFER_PADDING, length) : us_dispatch_data(s, loop->data.recv_buf + LIBUS_RECV_BUFFER_PADDING, length); @@ -651,6 +659,10 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in } while (s); } + if (eof) fprintf(stderr, "[h2dbg-c] eof fd=%d s=%d closed=%d shut=%d halfopen=%d\n", + s ? us_poll_fd(&s->p) : -1, s != NULL, + s ? us_socket_is_closed(s) : -1, s ? us_socket_is_shut_down(s) : -1, + s ? s->flags.allow_half_open : -1); if(eof && s) { if (UNLIKELY(us_socket_is_closed(s))) { // Do not call on_end after the socket has been closed From d2767d3dbedc98bc177b2a60416e4aa84b99d702 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Mon, 8 Jun 2026 23:24:32 +0000 Subject: [PATCH 52/61] DEBUG: bake host image for trimmed pipeline [build images] From aac6c95d4a7699af7153e79c7603ec6b7d816fe2 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 9 Jun 2026 01:19:06 +0000 Subject: [PATCH 53/61] DEBUG: rerun with images [build images] From c4be3b1126ca15ce50b4bbfbea4d2ef9c7bc5e5b Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 9 Jun 2026 11:48:34 +0000 Subject: [PATCH 54/61] DEBUG: rerun after autofix [build images] From 469c227b04425542388410f365491c8a46017341 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 9 Jun 2026 14:09:00 +0000 Subject: [PATCH 55/61] DEBUG: rerun after autofix [build images] From ee8547c695cf04275a649bf0ae738584e90efd58 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 9 Jun 2026 21:37:05 +0000 Subject: [PATCH 56/61] DEBUG: stream end-of-life prints [build images] --- src/js/node/http2.ts | 2 ++ src/runtime/api/bun/h2_frame_parser.rs | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 8f8fca8ce5f..dbed5464f23 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -2910,6 +2910,7 @@ class ServerHttp2Session extends Http2Session { process.nextTick(emitStreamErrorNT, self, stream, error, true, self.#connections === 0 && self.#closed); }, streamEnd(self: ServerHttp2Session, stream: ServerHttp2Stream, state: number) { + console.error(`[h2dbg-js] S streamEnd state=${state}`); if (!self || typeof stream !== "object") return; if (state == 6 || state == 7) { if (stream.readable) { @@ -3001,6 +3002,7 @@ class ServerHttp2Session extends Http2Session { self.destroy(errorCode); }, wantTrailers(self: ServerHttp2Session, stream: ServerHttp2Stream) { + console.error(`[h2dbg-js] S wantTrailers stream=${stream?.id}`); if (!self || typeof stream !== "object") return; const status = stream[bunHTTP2StreamStatus]; if ((status & StreamState.WantTrailer) !== 0) return; diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 0637183286b..e235583f33f 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -1780,6 +1780,8 @@ impl Stream { } if self.data_frame_queue.is_empty() { if _frame.end_stream { + eprintln!("[h2dbg-r] flush end_stream id={} wait_trailers={} state={:?}", + self.id, self.wait_for_trailers, self.state as u8); if self.wait_for_trailers { client.dispatch(JSH2FrameParser::Gc::onWantTrailers, self.get_identifier()); } else { @@ -5865,6 +5867,7 @@ impl H2FrameParser { // SAFETY: stream is a *mut Stream from self.streams (heap::alloc); valid while the map entry exists let stream = unsafe { &mut *stream }; + eprintln!("[h2dbg-r] no_trailers id={}", stream_id); stream.wait_for_trailers = false; this.send_data(stream, b"", true, JSValue::UNDEFINED); Ok(JSValue::UNDEFINED) From d653ca42337ed60d1fbaae2617539404c3c8824b Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:39:58 +0000 Subject: [PATCH 57/61] [autofix.ci] apply automated fixes --- src/runtime/api/bun/h2_frame_parser.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index e235583f33f..29a40050882 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -1780,8 +1780,10 @@ impl Stream { } if self.data_frame_queue.is_empty() { if _frame.end_stream { - eprintln!("[h2dbg-r] flush end_stream id={} wait_trailers={} state={:?}", - self.id, self.wait_for_trailers, self.state as u8); + eprintln!( + "[h2dbg-r] flush end_stream id={} wait_trailers={} state={:?}", + self.id, self.wait_for_trailers, self.state as u8 + ); if self.wait_for_trailers { client.dispatch(JSH2FrameParser::Gc::onWantTrailers, self.get_identifier()); } else { From eaa7f1e50b33609ef28ac0edb95e42babfee6ca6 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 9 Jun 2026 21:41:41 +0000 Subject: [PATCH 58/61] DEBUG: rerun after autofix [build images] From bc0c717d16448c91e2de14d3ccfeab6f8c86a1c2 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Wed, 10 Jun 2026 00:04:36 +0000 Subject: [PATCH 59/61] DEBUG: strip prints; fix headers-only END_STREAM stream leak [build images] --- packages/bun-usockets/src/loop.c | 12 ------- packages/bun-usockets/src/socket.c | 2 -- src/runtime/api/bun/h2_frame_parser.rs | 24 +++++++++++++- src/runtime/socket/socket_body.rs | 46 +------------------------- 4 files changed, 24 insertions(+), 60 deletions(-) diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index 2ceea2cf0cd..f237edf57d2 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -367,11 +367,6 @@ void us_internal_loop_post(struct us_loop_t *loop) { #endif void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, int events) { - { - int pt = us_internal_poll_type(p); - if (pt == POLL_TYPE_SOCKET || pt == POLL_TYPE_SOCKET_SHUT_DOWN) - fprintf(stderr, "[h2dbg-c] poll fd=%d err=%d eof=%d ev=%d\n", us_poll_fd(p), error, eof, events); - } switch (us_internal_poll_type(p)) { case POLL_TYPE_CALLBACK: { struct us_internal_callback_t *cb = (struct us_internal_callback_t *) p; @@ -588,9 +583,6 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in } #endif - fprintf(stderr, "[h2dbg-c] recv fd=%d len=%d errno=%d shut=%d closed=%d\n", - us_poll_fd(&s->p), length, length < 0 ? errno : 0, - us_socket_is_shut_down(s), us_socket_is_closed(s)); if (length > 0) { s = s->ssl ? us_internal_ssl_on_data(s, loop->data.recv_buf + LIBUS_RECV_BUFFER_PADDING, length) : us_dispatch_data(s, loop->data.recv_buf + LIBUS_RECV_BUFFER_PADDING, length); @@ -659,10 +651,6 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in } while (s); } - if (eof) fprintf(stderr, "[h2dbg-c] eof fd=%d s=%d closed=%d shut=%d halfopen=%d\n", - s ? us_poll_fd(&s->p) : -1, s != NULL, - s ? us_socket_is_closed(s) : -1, s ? us_socket_is_shut_down(s) : -1, - s ? s->flags.allow_half_open : -1); if(eof && s) { if (UNLIKELY(us_socket_is_closed(s))) { // Do not call on_end after the socket has been closed diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index 1da8eddd4fc..bc4f47690c6 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -657,7 +657,6 @@ struct us_loop_t *us_connecting_socket_get_loop(struct us_connecting_socket_t *c } void us_socket_pause(struct us_socket_t *s) { - fprintf(stderr, "[h2dbg-c] us_socket_pause fd=%d already=%d\n", us_poll_fd((struct us_poll_t *)s), s->flags.is_paused); if (s->flags.is_paused) return; // closed cannot be paused because it is already closed if (us_socket_is_closed(s)) return; @@ -667,7 +666,6 @@ void us_socket_pause(struct us_socket_t *s) { } void us_socket_resume(struct us_socket_t *s) { - fprintf(stderr, "[h2dbg-c] us_socket_resume fd=%d paused=%d\n", us_poll_fd((struct us_poll_t *)s), s->flags.is_paused); if (!s->flags.is_paused) return; s->flags.is_paused = 0; // closed cannot be resumed diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 29a40050882..1233a331dce 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -7290,12 +7290,34 @@ impl H2FrameParser { if end_stream { stream.end_after_headers = true; - stream.state = StreamState::HALF_CLOSED_LOCAL; if wait_for_trailers { + stream.state = StreamState::HALF_CLOSED_LOCAL; this.dispatch(JSH2FrameParser::Gc::onWantTrailers, stream.get_identifier()); return Ok(JSValue::js_number(stream_id as f64)); } + + // A HEADERS frame carrying END_STREAM half-closes our side; when + // the peer already half-closed (a server responding after the + // request body finished) the stream is now fully closed. Mirror + // send_data / send_trailers: transition the state forward and + // dispatch onStreamEnd — without this a headers-only END_STREAM + // response regressed the state to HALF_CLOSED_LOCAL and never + // told JS, leaking the stream (and the session's connection + // count) until socket close. + let identifier = stream.get_identifier(); + identifier.ensure_still_alive(); + if stream.state == StreamState::HALF_CLOSED_REMOTE { + stream.state = StreamState::CLOSED; + stream.free_resources::(this); + } else { + stream.state = StreamState::HALF_CLOSED_LOCAL; + } + this.dispatch_with_extra( + JSH2FrameParser::Gc::onStreamEnd, + identifier, + JSValue::js_number(stream.state as u8 as f64), + ); } else { stream.wait_for_trailers = wait_for_trailers; } diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 9aa44f54808..e29bfb98a03 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -706,20 +706,6 @@ impl NewSocket { /// per expression — same Stacked-Borrows footprint as the previous manual /// `unsafe { (*p).field }`). Mutating sites use `.as_ptr()` and reborrow /// `&mut` explicitly. - fn h2dbg_server(&self) -> &'static str { - match self.handlers.get() { - Some(h) => { - // SAFETY: debug-only read; handlers outlive the socket callbacks. - if unsafe { h.as_ref() }.mode == super::SocketMode::Server { - "S" - } else { - "C" - } - } - None => "none", - } - } - pub fn get_handlers(&self) -> bun_ptr::BackRef { self.handlers .get() @@ -974,12 +960,6 @@ impl NewSocket { } pub fn close_and_detach(&self, code: uws::CloseCode) { - eprintln!( - "[h2dbg] close_and_detach who={} detached={} native_cb={}", - self.h2dbg_server(), - self.socket.get().is_detached(), - !matches!(self.native_callback.get(), NativeCallbacks::None) - ); let socket = self.socket.get(); self.buffered_data_for_node_net .with_mut(|b| b.clear_and_free()); @@ -991,12 +971,6 @@ impl NewSocket { } pub fn mark_inactive(&self) { - eprintln!( - "[h2dbg] mark_inactive who={} active={} closed={}", - self.h2dbg_server(), - self.flags.get().contains(Flags::IS_ACTIVE), - self.socket.get().is_closed() - ); if self.flags.get().contains(Flags::IS_ACTIVE) { // we have to close the socket before the socket context is closed // otherwise we will get a segfault @@ -1245,20 +1219,10 @@ impl NewSocket { jsc::mark_binding!(); // SAFETY: per fn contract; R-2 shared reborrow. let this: &Self = unsafe { &*this }; - eprintln!( - "[h2dbg] on_end detached={} native_cb={}", - this.socket.get().is_detached(), - !matches!(this.native_callback.get(), NativeCallbacks::None) - ); if this.socket.get().is_detached() { return; } let handlers = this.get_handlers(); - eprintln!( - "[h2dbg] on_end who={} cb_empty={}", - this.h2dbg_server(), - handlers.on_end.is_empty() - ); log!( "onEnd {}", if handlers.mode == super::SocketMode::Server { @@ -1450,12 +1414,6 @@ impl NewSocket { jsc::mark_binding!(); // SAFETY: per fn contract; R-2 shared reborrow. let this: &Self = unsafe { &*this }; - eprintln!( - "[h2dbg] on_close who={} detached={} native_cb={}", - this.h2dbg_server(), - this.socket.get().is_detached(), - !matches!(this.native_callback.get(), NativeCallbacks::None) - ); let handlers = this.get_handlers(); log!( "onClose {}", @@ -1601,9 +1559,7 @@ impl NewSocket { }, data.len() ); - let native_consumed = this.native_callback.get().on_data(data); - if native_consumed { - eprintln!("[h2dbg] on_data native len={}", data.len()); + if this.native_callback.get().on_data(data) { return; } From 71b8b7631c9dd0658ab51047ab93f6214f2c1583 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Wed, 10 Jun 2026 00:05:03 +0000 Subject: [PATCH 60/61] DEBUG: drop leftover include [build images] --- packages/bun-usockets/src/socket.c | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index bc4f47690c6..a294fc59fed 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -16,7 +16,6 @@ */ // clang-format off -#include #include "libusockets.h" #include "internal/internal.h" #include From fb91e220f1dbfe7f615a5a335009b12a1776af9d Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Wed, 10 Jun 2026 00:07:50 +0000 Subject: [PATCH 61/61] DEBUG: strip JS prints [build images] --- src/js/node/http2.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index dbed5464f23..8f8fca8ce5f 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -2910,7 +2910,6 @@ class ServerHttp2Session extends Http2Session { process.nextTick(emitStreamErrorNT, self, stream, error, true, self.#connections === 0 && self.#closed); }, streamEnd(self: ServerHttp2Session, stream: ServerHttp2Stream, state: number) { - console.error(`[h2dbg-js] S streamEnd state=${state}`); if (!self || typeof stream !== "object") return; if (state == 6 || state == 7) { if (stream.readable) { @@ -3002,7 +3001,6 @@ class ServerHttp2Session extends Http2Session { self.destroy(errorCode); }, wantTrailers(self: ServerHttp2Session, stream: ServerHttp2Stream) { - console.error(`[h2dbg-js] S wantTrailers stream=${stream?.id}`); if (!self || typeof stream !== "object") return; const status = stream[bunHTTP2StreamStatus]; if ((status & StreamState.WantTrailer) !== 0) return;