From e007924c5a840b0004dc118f9344c869b24cb15d Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 4 Jun 2026 20:17:07 +0000 Subject: [PATCH 01/29] 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 000000000000..2c6a74f5cf73 --- /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 93fbd322daf2b761367441a067ed910015ab97b1 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 4 Jun 2026 20:17:13 +0000 Subject: [PATCH 02/29] 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 5ea53f67fa7d..c790ffe5f6bc 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 5256b7502f17..8299c4bd504d 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 0e69614f46d51e58e8761b7018173d4ed44e585e Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 4 Jun 2026 20:17:20 +0000 Subject: [PATCH 03/29] 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 a777b35a531e..201956a0973f 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 bf608d03fdcf..9658a7e10916 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 ecd92d4c4f01..c13fb12d2139 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 f0b3f598ecad..a140a351ec3c 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 69754017b663..1637355d1dce 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 cb193e7895b4..43d405ede059 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 0ba0a43eeb60..fffa5461bd13 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 802c9c8c24e6..1508a66480a9 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 c771436558a9..8d51817773e4 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 67fb16b260c0..d307e26df5bc 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 05bc6c594aa5..af5d7b7d4f52 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 86bc91551f7b..9c969ea2dd98 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 de6652939953..1a5256a09060 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 4ae81798e034..3b53d817dc14 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 c7c54c5582ca..05f41e3ad120 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 19afd513bb63..7f5e6630f0da 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 f140f4583968..3a2c0eda82fb 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 287742270eb1..37babada0901 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 dd8adfe8f916..9d1a700d2d8c 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 6c3b2a1d25ae..57f5319a97f2 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 84b0ee3cd448..c991d3644f40 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 5072706bd948..981fb8cff9e9 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 3312d054796d..b9978f6eb481 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 9a6440b63ca8..2e0a72fbce83 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 77a8640587c8..872e66b0ede7 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 d7a54e177668..a4517a294b0d 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 d582c8add005..5fece74a115c 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 ba613f9e9ff1..ffaf1d5b9433 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 df88d78b74c3..9d39b1fc6cdb 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 c4bc90bb19d3..3f26db791c7b 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 000000000000..8e5f10efee1b --- /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 000000000000..8b1f8d1817c0 --- /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 13fce9bc715e..f30cf721e14c 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 ae5846da09db..55d92aa31eda 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 f1de4c873fd3..5ffdbbbc54b4 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 f222f1c03b48..a7d0f236d787 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 000000000000..995db97e7473 --- /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 000000000000..4a8ca3cff8a2 --- /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 000000000000..3304a8e64f31 --- /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 000000000000..5367b2a09e1f --- /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 a6f2e1b425dc..f168a0ca846f 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 f8387f40069b..87ca9fe1e9a2 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 63fae21bf4a6..f182e968b665 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 03930154e5e7dcf3b59c6e34ff2516f3d3371eba 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/29] [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 b9978f6eb481..d1697bb41ed7 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 f6fd2e040dab9ca8185f2b5c764fe5d5ce92130b Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 4 Jun 2026 20:20:49 +0000 Subject: [PATCH 05/29] 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 30052b92c79e..89f07fc57847 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 a0bbd7920a44..c546bce9be0e 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 0eb7699e3bc95f23eb5b1c7e590b3381f3b1a4cf Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 4 Jun 2026 21:17:39 +0000 Subject: [PATCH 06/29] 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 175b7c584d76..9168e0c7a363 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 89f07fc57847..335bedb0f878 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 c546bce9be0e..130471621256 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 380575939466..54d554b9e131 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 bd68ed833951..3bad3fea27e6 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 7759c7576d6f..966f56f77bcb 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 6935f0cbbcee..b8bd26e2501a 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 48e24d14b64d..6d6e4428f3e6 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 6e7749b54e10..d6785763747e 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 2e6dcf09eea5..4ab770f33d4a 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 e9c3c0196880..a3616f40d7c9 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 097f7aa0a572..9903cfd045fc 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 23581c2ecf60..221790e23f75 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 3d9a0fccae3c..fb99e33ffafd 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 f044d67ffdd8..6c6d4646cad5 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 bfb9f977798b..7baa3dbb76fe 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 9071d9621c5c..233189c2f916 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 0f91c2234d63..bee48002fa54 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 2f6928b49a8f..80f740bbcc50 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 5069cbd3e948..784c93e77bce 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 c870d1ef3876..ab52f2ed9aa4 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 e85c3ae5ec93..e3a02e27c170 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 831547982252..8f61fd57ffb2 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 6c0ff9eed641..bddf0d6f6acf 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 6de66fabebf3..baa7de3f3c04 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 9164adce3cce..66716c9fd537 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 cea327117c72..a992e8432623 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 f30c535c2315..269a9aab2953 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 772096e7c2b5..353b99858a29 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 dc727488e86e..6205f32ae00c 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 b44c02651bd0..93483288ec93 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 462b099dd071..2a617b633025 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 f8389d0401a5..0a1804213eaf 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 cdf548e5a72f..284028e3e319 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 2befe7ed970b..1f64717d6a4c 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 73b2e78c03fe..96fc590ea01e 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 2ebb9ae7c453..fa3872f20ad9 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 3cc8e33f0f7d..2591fb9478c1 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 8363e2745e6f..e7b2d3a5441a 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 e18ae491be7605d4d6d744f05eaf8ff6591843bf Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 4 Jun 2026 21:17:47 +0000 Subject: [PATCH 07/29] 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 83c5c0f91edf..49c57c61e462 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 201956a0973f..5ca7520ddc4d 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 9658a7e10916..0df175dc69fd 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 a140a351ec3c..9ad7d3e80013 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 fffa5461bd13..99a66dd5ac79 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 9c969ea2dd98..1772e726ead5 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 3b53d817dc14..94113ec0b368 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 7f5e6630f0da..a5e6dbe159f0 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 43f0c78ed2a3..824a239cfa04 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 8aef2ccca1b1..02810d77a1d3 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 d721b2a4494e..1afd51a695dd 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 57f5319a97f2..a9cc7ad1799e 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 d1697bb41ed7..8c03bb2b133f 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 406f8161bd3958311a1055ca0159eaa27b2e5f3c Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 4 Jun 2026 21:19:40 +0000 Subject: [PATCH 08/29] 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 130471621256..e76f4429a44a 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 85610352a6283894f60b0ef49b180fdb9df0cd7c 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/29] [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 6c6d4646cad5..194e33ce4e97 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 bc2e101e3213459afafd20a40a4b22263adabf18 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 4 Jun 2026 21:23:28 +0000 Subject: [PATCH 10/29] 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 a53ae4c5543c4a27d624cabd5e554d6d364f1ade Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 4 Jun 2026 23:29:03 +0000 Subject: [PATCH 11/29] 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 e76f4429a44a..a61b1e1b3c0d 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 09f94065fbff4b1a2afbd1362324c13460cb88ed Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Fri, 5 Jun 2026 06:06:30 +0000 Subject: [PATCH 12/29] 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 b8bd26e2501a..805b487ccb12 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 fd9cb7f2733e..af8211d02348 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 16336cdcdaa2..16b4be3a91a9 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 6abf57e204cda57be545964e529ece88f65dd897 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Fri, 5 Jun 2026 13:27:48 +0000 Subject: [PATCH 13/29] 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 d307e26df5bc..b18458c4213a 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 8c03bb2b133f..9a19bec76d8f 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 95d710721e7e88d4913324833b1abdd94bfc2e43 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Fri, 5 Jun 2026 18:49:47 +0000 Subject: [PATCH 14/29] 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 2dbef083eb4b..d3b3c6f66dd5 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 b59484744637..45dd98ef161e 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 6f3bbb3ab321..10fb51889990 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 7f3e8d3feded189357dc201be21e65781cd4acf7 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Fri, 5 Jun 2026 21:49:19 +0000 Subject: [PATCH 15/29] 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 94113ec0b368..632edfbe4d4e 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 a5e6dbe159f0..5f599a7f4267 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 a3616f40d7c9..c9fdd48d01db 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 233189c2f916..093a20ff92be 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 e55ac4af20f5..2387e6d7f6a6 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 a992e8432623..d67369990c8e 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 269a9aab2953..d3354be61bfc 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 2d3531e7cedf..9df23192caa3 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 ba13c607199b..2b7e5caf9268 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 d3b3c6f66dd5..0e24bc994324 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 45dd98ef161e..7faf20f938d0 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 10fb51889990..5245f80720eb 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 9d1a700d2d8c..e8a4687cc262 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 a9cc7ad1799e..d2963164d58f 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 c991d3644f40..99112601a2fb 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 9a19bec76d8f..81248e31d890 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 605f6d477770..099875ae95ce 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 2591fb9478c1..b97efd71352c 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 e7b2d3a5441a..76d03bb39e09 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 b42f9b9d07bca3ca89706341169deb8719c4f043 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 6 Jun 2026 03:36:33 +0000 Subject: [PATCH 16/29] 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 5f599a7f4267..f99eb18f7b22 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 2b7e5caf9268..626a9787a19f 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 9d4c0feaa8fd..35d2775b0194 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 7faf20f938d0..f5de4dae0371 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 5d9d5951d3b2..bce2737bb440 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 a1772a008c4d..f38dc12f54a6 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 c3a3ff5a6db0..1aa957058cf3 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 1f64717d6a4c..369ac7b7c78b 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 76d03bb39e09..c63d4c3a2093 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 a00465fc766d526854d4290b107d55aaf340001e Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 6 Jun 2026 04:46:06 +0000 Subject: [PATCH 17/29] 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 093a20ff92be..40fa386c1d3b 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 d3354be61bfc..fe7caeda2b03 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 2861ada7155d..8ee3bae518f5 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 b97efd71352c..ccfab523d3bc 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 c63d4c3a2093..4cf194b47d4a 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 33e74832dbb9173caf4b1b5839ecf6129e682924 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 6 Jun 2026 06:10:38 +0000 Subject: [PATCH 18/29] 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 f99eb18f7b22..243ec8a3c5d9 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 99112601a2fb..3281f44dfed5 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 ac7e1ce547155fa0b3a494c6539fd0929bf42f32 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 6 Jun 2026 06:12:15 +0000 Subject: [PATCH 19/29] 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 250c7f0ffc7d..10fab1c60992 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 f38abb215c74f968376b3232f7596afb6a2f7731 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 6 Jun 2026 06:49:25 +0000 Subject: [PATCH 20/29] 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 335bedb0f878..4025149ef27a 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 a61b1e1b3c0d..9c4d7ca7dfad 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 44a873f3da8a9d53627aa1f3f871a0d0a549a5ca Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 6 Jun 2026 13:26:12 +0000 Subject: [PATCH 21/29] 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 243ec8a3c5d9..cd3b0f030aac 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 bce2737bb440..193231246890 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 f38dc12f54a6..9175d80e6e70 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 e1dd4c2933b2..607afbda7636 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 1aa957058cf3..b820a510fee2 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 369ac7b7c78b..98c99d992f5f 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 f983083b60dbba5c02871137f5e1d069536e1682 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 6 Jun 2026 13:28:54 +0000 Subject: [PATCH 22/29] 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 4cf194b47d4a..fb1d0e0e940e 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 5f70d0336437308465d390e284538e2a9db35dac Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 6 Jun 2026 18:41:17 +0000 Subject: [PATCH 23/29] 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 805b487ccb12..e6fb665f9c84 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 fb1d0e0e940e..ab786f726452 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 629258768bd473a311d3ca6dc16356372252549a Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 6 Jun 2026 18:45:58 +0000 Subject: [PATCH 24/29] 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 ce411c328d1a..2c8eb4f5f8d5 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 7082a16817031ed51457d1c31c36b72734bf4bb9 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 6 Jun 2026 18:48:59 +0000 Subject: [PATCH 25/29] 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 9c4d7ca7dfad..2e7ca8b2d585 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 626a9787a19f..a83331e2b754 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 35d2775b0194..6be1242b16f3 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 e8b24a33f0302c372281dd14a67740e0b8a59cbe Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 6 Jun 2026 23:45:16 +0000 Subject: [PATCH 26/29] 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 6205f32ae00c..b980d8e88b5e 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 38eadf7deea5ddadf4ab8b5bfc1ea03f2dc188f7 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sun, 7 Jun 2026 05:16:09 +0000 Subject: [PATCH 27/29] 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 ccfab523d3bc..7db276b123bd 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 beaee5270210f94897792571b8695fb6da97e80e Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sun, 7 Jun 2026 11:05:35 +0000 Subject: [PATCH 28/29] 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 40fa386c1d3b..aaecbe0f2afc 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 bee48002fa54..eae0556fa81f 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 469f86ca4414..3ccf3a830cb3 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 265ba925b72e..0027496503e2 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 353b99858a29..17cac46e3742 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 b980d8e88b5e..ce1042f2ff97 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 93483288ec93..ded971cdc0e0 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 2a617b633025..d314660ec934 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 6077779585aea82b90428f20efbe72b15251674f Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sun, 7 Jun 2026 17:26:10 +0000 Subject: [PATCH 29/29] 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 cd3b0f030aac..06cb089356f3 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 14ce18902081..ab96a01605bf 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 7db276b123bd..9eaa53295342 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; }