diff --git a/.buildkite/ci.mjs b/.buildkite/ci.mjs index b93470f5a6c..349758f30e0 100755 --- a/.buildkite/ci.mjs +++ b/.buildkite/ci.mjs @@ -1380,8 +1380,19 @@ async function getPipelineOptions() { dryRun: parseOption(/\[(dry run)\]/i), publishImages: parseOption(/\[(publish (?:(?:windows|linux) )?images?)\]/i), imageFilter: (commitMessage.match(/\[(?:build|publish) (windows|linux) images?\]/i) || [])[1]?.toLowerCase(), - buildPlatforms: Array.from(buildPlatformsMap.values()), - testPlatforms: Array.from(testPlatformsMap.values()), + // DEBUG BRANCH ONLY: darwin-aarch64 pipeline running a single test file. + // The two linux-aarch64 entries are kept because the darwin cross-build + // runs on their host images (amazonlinux-with-docker for cpp/link, + // alpine-musl for rust), and image steps are only generated from + // non-crossCompile linux platforms. + buildPlatforms: Array.from(buildPlatformsMap.values()).filter( + p => + p.arch === "aarch64" && + p.abi !== "android" && + (p.os === "darwin" || (p.os === "linux" && (p.distro === "amazonlinux" || p.abi === "musl"))), + ), + testPlatforms: Array.from(testPlatformsMap.values()).filter(p => p.os === "darwin" && p.arch === "aarch64"), + testFiles: ["js/node/test/parallel/test-http2-"], }; } @@ -1508,11 +1519,8 @@ async function getPipeline(options = {}) { } } - // Binary-size tracking covers the artifacts that ship. - const strippedPlatforms = buildPlatforms.filter(p => (p.profile ?? "release") === "release"); - if (!buildId && strippedPlatforms.length) { - steps.push(getBinarySizeStep(strippedPlatforms, options, { recordOnly: isMainBranch() })); - } + // DEBUG BRANCH ONLY: binary-size depends on every build target; the trimmed + // platform list breaks its dependencies and cancels the whole build. // Sign Windows builds on release (non-canary main) or when [sign windows] // is in the commit message (for testing the sign step on a branch). diff --git a/flake.nix b/flake.nix index 175b7c584d7..9168e0c7a36 100644 --- a/flake.nix +++ b/flake.nix @@ -31,8 +31,8 @@ clang = pkgs.clang_21; lld = pkgs.lld_21; - # Node.js 24 - matching the bootstrap script (targets 24.3.0, actual version from nixpkgs-unstable) - nodejs = pkgs.nodejs_24; + # Node.js 26 - matching the bootstrap script (targets 26.3.0, actual version from nixpkgs-unstable) + nodejs = pkgs.nodejs_26; # Build tools and dependencies packages = [ diff --git a/scripts/bootstrap.ps1 b/scripts/bootstrap.ps1 index f222ef8137d..93619c5b8cc 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. @@ -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 a0bbd7920a4..2e7ca8b2d58 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -1,5 +1,5 @@ #!/bin/sh -# Version: 34 +# 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. @@ -780,7 +780,7 @@ install_common_software() { } nodejs_version_exact() { - print "24.3.0" + print "26.3.0" } nodejs_version() { @@ -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" ;; *) @@ -1450,6 +1454,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 +1467,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. } @@ -1768,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/scripts/build/codegen.ts b/scripts/build/codegen.ts index 83c5c0f91ed..49c57c61e46 100644 --- a/scripts/build/codegen.ts +++ b/scripts/build/codegen.ts @@ -763,6 +763,10 @@ function emitJsModules({ n, cfg, sources, o, dirStamp }: Ctx): void { // InternalModuleRegistry.cpp is read by the script (for a sanity check). const extraInput = resolve(cfg.cwd, "src", "jsc", "bindings", "InternalModuleRegistry.cpp"); + // replacements.ts bakes ErrorCode.ts indices into every bundled module + // ($makeErrorWithCode(N, ...)); without this dep an ErrorCode.ts edit leaves + // stale error numbers in the JS bundles while the C++ enum regenerates. + const errorCodeInput = resolve(cfg.cwd, "src", "jsc", "bindings", "ErrorCode.ts"); // Written into src/ (not codegenDir) — see zigFilesGeneratedIntoSrc at top. const js2nativeZig = resolve(cfg.cwd, zigFilesGeneratedIntoSrc[1]); @@ -791,7 +795,7 @@ function emitJsModules({ n, cfg, sources, o, dirStamp }: Ctx): void { n.build({ outputs, rule: "codegen", - inputs: [script, ...sources.js, ...sources.jsCodegen, extraInput], + inputs: [script, ...sources.js, ...sources.jsCodegen, extraInput, errorCodeInput], orderOnlyInputs: [dirStamp], vars: { cwd: cfg.cwd, diff --git a/scripts/build/config.ts b/scripts/build/config.ts index 38057593946..54d554b9e13 100644 --- a/scripts/build/config.ts +++ b/scripts/build/config.ts @@ -10,7 +10,7 @@ import { execSync } from "node:child_process"; import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, symlinkSync } from "node:fs"; import { homedir, arch as hostArch, platform as hostPlatform } from "node:os"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; -import { NODEJS_ABI_VERSION, NODEJS_VERSION } from "./deps/nodejs-headers.ts"; +import { NODEJS_ABI_VERSION, NODEJS_V8_VERSION, NODEJS_VERSION } from "./deps/nodejs-headers.ts"; import { WEBKIT_VERSION } from "./deps/webkit.ts"; import { assert, BuildError } from "./error.ts"; import { resolveMacosSdkPath } from "./macos-sdk.ts"; @@ -61,6 +61,7 @@ export interface Host { const versionDefaults = { nodejsVersion: NODEJS_VERSION, nodejsAbiVersion: NODEJS_ABI_VERSION, + nodejsV8Version: NODEJS_V8_VERSION, webkitVersion: WEBKIT_VERSION, }; @@ -307,6 +308,7 @@ export interface Config { /** Node.js compat version. Default in versions.ts; override to test a bump. */ nodejsVersion: string; nodejsAbiVersion: string; + nodejsV8Version: string; /** WebKit commit. Default in versions.ts; override to test a WebKit branch. */ webkitVersion: string; } @@ -368,6 +370,7 @@ export interface PartialConfig { // Version pins (defaults in versions.ts). nodejsVersion?: string; nodejsAbiVersion?: string; + nodejsV8Version?: string; webkitVersion?: string; } @@ -1019,6 +1022,7 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con // to test a branch before bumping the pinned default. const nodejsVersion = partial.nodejsVersion ?? versionDefaults.nodejsVersion; const nodejsAbiVersion = partial.nodejsAbiVersion ?? versionDefaults.nodejsAbiVersion; + const nodejsV8Version = partial.nodejsV8Version ?? versionDefaults.nodejsV8Version; const webkitVersion = partial.webkitVersion ?? versionDefaults.webkitVersion; // ─── macOS SDK ─── @@ -1180,6 +1184,7 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con version, revision, nodejsVersion, + nodejsV8Version, nodejsAbiVersion, canaryRevision, webkitVersion, diff --git a/scripts/build/deps/nodejs-headers.ts b/scripts/build/deps/nodejs-headers.ts index bd68ed83395..3bad3fea27e 100644 --- a/scripts/build/deps/nodejs-headers.ts +++ b/scripts/build/deps/nodejs-headers.ts @@ -14,10 +14,13 @@ import type { Dependency } from "../source.ts"; * download URL, and passed to zig as -Dreported_nodejs_version. * Override via `--nodejs-version=X.Y.Z` to test a bump. */ -export const NODEJS_VERSION = "24.3.0"; +export const NODEJS_VERSION = "26.3.0"; /** Node.js NODE_MODULE_VERSION — for native addon ABI compat. */ -export const NODEJS_ABI_VERSION = "137"; +export const NODEJS_ABI_VERSION = "147"; + +/** V8 version reported by process.versions.v8 — must match the pinned Node.js version's. */ +export const NODEJS_V8_VERSION = "14.6.202.34-node.20"; export const nodejsHeaders: Dependency = { name: "nodejs", diff --git a/scripts/build/flags.ts b/scripts/build/flags.ts index 7759c7576d6..966f56f77bc 100644 --- a/scripts/build/flags.ts +++ b/scripts/build/flags.ts @@ -742,7 +742,7 @@ export const defines: Flag[] = [ }, { // Shell-escaped quotes so clang receives literal quotes in the define - // (the preprocessor needs the string to be "24.3.0", not bare 24.3.0). + // (the preprocessor needs the string to be "26.3.0", not bare 26.3.0). flag: c => `REPORTED_NODEJS_VERSION=\\"${c.nodejsVersion}\\"`, desc: "Node.js version string reported by process.version", }, @@ -750,6 +750,10 @@ export const defines: Flag[] = [ flag: c => `REPORTED_NODEJS_ABI_VERSION=${c.nodejsAbiVersion}`, desc: "Node.js ABI version (process.versions.modules)", }, + { + flag: c => `REPORTED_NODEJS_V8_VERSION=\\"${c.nodejsV8Version}\\"`, + desc: "V8 version string (process.versions.v8)", + }, { // Hardcoded ON — experimental flag not exposed in config flag: "USE_BUN_MIMALLOC=1", diff --git a/scripts/packer/windows-x64.pkr.hcl b/scripts/packer/windows-x64.pkr.hcl index 250c7f0ffc7..10fab1c6099 100644 --- a/scripts/packer/windows-x64.pkr.hcl +++ b/scripts/packer/windows-x64.pkr.hcl @@ -14,7 +14,11 @@ source "azure-arm" "windows-x64" { // Build VM — only used during image creation, not for CI runners. // CI runner VM sizes are set in ci.mjs (azureVmSizes). - vm_size = "Standard_D4ds_v6" + // D4as_v7 (AMD): D4ds_v6 hit repeated AllocationFailed (no capacity for + // that size in the region); Azure's allocation-guidance suggested this + // size as an in-region alternative. Build-only VM, so the CPU vendor + // doesn't affect the produced image. + vm_size = "Standard_D4as_v7" // Use existing resource group instead of creating a temp one build_resource_group_name = var.resource_group diff --git a/src/js/builtins/CompressionStream.ts b/src/js/builtins/CompressionStream.ts index a777b35a531..5ca7520ddc4 100644 --- a/src/js/builtins/CompressionStream.ts +++ b/src/js/builtins/CompressionStream.ts @@ -1,6 +1,6 @@ export function initializeCompressionStream(this, format) { const zlib = require("node:zlib"); - const stream = require("node:stream"); + const { newBufferSourceTransformPairFromDuplex } = require("internal/webstreams_adapters"); const builders = { "deflate": zlib.createDeflate, @@ -13,9 +13,9 @@ 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](); - $putByIdDirectPrivate(this, "readable", stream.Readable.toWeb(handle)); - $putByIdDirectPrivate(this, "writable", stream.Writable.toWeb(handle)); + const transform = newBufferSourceTransformPairFromDuplex(builders[format]()); + $putByIdDirectPrivate(this, "readable", transform.readable); + $putByIdDirectPrivate(this, "writable", transform.writable); return this; } diff --git a/src/js/builtins/DecompressionStream.ts b/src/js/builtins/DecompressionStream.ts index bf608d03fdc..0df175dc69f 100644 --- a/src/js/builtins/DecompressionStream.ts +++ b/src/js/builtins/DecompressionStream.ts @@ -1,6 +1,6 @@ export function initializeDecompressionStream(this, format) { const zlib = require("node:zlib"); - const stream = require("node:stream"); + const { newBufferSourceTransformPairFromDuplex } = require("internal/webstreams_adapters"); const builders = { "deflate": zlib.createInflate, @@ -13,9 +13,9 @@ 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](); - $putByIdDirectPrivate(this, "readable", stream.Readable.toWeb(handle)); - $putByIdDirectPrivate(this, "writable", stream.Writable.toWeb(handle)); + const transform = newBufferSourceTransformPairFromDuplex(builders[format]()); + $putByIdDirectPrivate(this, "readable", transform.readable); + $putByIdDirectPrivate(this, "writable", transform.writable); return this; } diff --git a/src/js/builtins/ReadableStreamInternals.ts b/src/js/builtins/ReadableStreamInternals.ts index 4675347c1a2..1698c3b2eb5 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/src/js/internal/http.ts b/src/js/internal/http.ts index ecd92d4c4f0..c13fb12d213 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -353,6 +353,8 @@ function emitErrorNt(msg, err, callback) { const setMaxHTTPHeaderSize = $newZigFunction("node_http_binding.zig", "setMaxHTTPHeaderSize", 1); const getMaxHTTPHeaderSize = $newZigFunction("node_http_binding.zig", "getMaxHTTPHeaderSize", 0); const kOutHeaders = Symbol("kOutHeaders"); +const kProxyConfig = Symbol("kProxyConfig"); +const kWaitForProxyTunnel = Symbol("kWaitForProxyTunnel"); function ipToInt(ip) { const octets = ip.split("."); @@ -532,6 +534,7 @@ export { kPendingCallbacks, kPort, kProtocol, + kProxyConfig, kRealListen, kRequest, kRes, @@ -542,6 +545,7 @@ export { kTls, kUpgradeOrConnect, kUseDefaultPort, + kWaitForProxyTunnel, noBodySymbol, optionsSymbol, parseProxyConfigFromEnv, diff --git a/src/js/internal/primordials.js b/src/js/internal/primordials.js index f0b3f598eca..9ad7d3e8001 100644 --- a/src/js/internal/primordials.js +++ b/src/js/internal/primordials.js @@ -96,13 +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 SafePromiseAllReturnArrayLike = (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; - 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++) { @@ -110,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, @@ -136,6 +142,7 @@ export default { ), SafePromiseAll, SafePromiseAllReturnArrayLike, + SafePromiseAllReturnVoid, SafeSet: makeSafe( Set, class SafeSet extends Set { diff --git a/src/js/internal/streams/duplex.ts b/src/js/internal/streams/duplex.ts index 69754017b66..1637355d1dc 100644 --- a/src/js/internal/streams/duplex.ts +++ b/src/js/internal/streams/duplex.ts @@ -140,8 +140,8 @@ Duplex.fromWeb = function (pair, options) { return lazyWebStreams().newStreamDuplexFromReadableWritablePair(pair, options); }; -Duplex.toWeb = function (duplex) { - return lazyWebStreams().newReadableWritablePairFromDuplex(duplex); +Duplex.toWeb = function (duplex, options) { + return lazyWebStreams().newReadableWritablePairFromDuplex(duplex, options); }; let duplexify; diff --git a/src/js/internal/streams/duplexify.ts b/src/js/internal/streams/duplexify.ts index cb193e7895b..43d405ede05 100644 --- a/src/js/internal/streams/duplexify.ts +++ b/src/js/internal/streams/duplexify.ts @@ -312,7 +312,7 @@ function _duplexify(pair) { eos(r, err => { readable = false; if (err) { - destroyer(r, err); + destroyer(w, err); } onfinished(err); }); diff --git a/src/js/internal/streams/end-of-stream.ts b/src/js/internal/streams/end-of-stream.ts index 0ba0a43eeb6..99a66dd5ac7 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 }); + } + // 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; + } + + if (hasAsyncContext()) { + callback = bindAsyncResource(callback, "STREAM_END_OF_STREAM"); + } + + if (immediateResult !== undefined) { + process.nextTick(invokeImmediate); + return cleanup; + } + + callback = once(callback); + const onlegacyfinish = () => { if (!stream.writable) { onfinish(); } }; - // TODO (ronag): Improve soft detection to include core modules and - // common ecosystem modules that do properly emit 'close' but fail - // this generic check. - let willEmitClose = - _willEmitClose(stream) && isReadableNodeStream(stream) === readable && isWritableNodeStream(stream) === writable; - - let writableFinished = isWritableFinished(stream, false); const onfinish = () => { writableFinished = true; // Stream should not be destroyed here. If it is that @@ -94,7 +212,6 @@ function eos(stream, options, callback) { } }; - let readableFinished = isReadableFinished(stream, false); const onend = () => { readableFinished = true; // Stream should not be destroyed here. If it is that @@ -117,37 +234,13 @@ function eos(stream, options, callback) { callback.$call(stream, err); }; - let closed = isClosed(stream); - const onclose = () => { - closed = true; - - const errored = isWritableErrored(stream) || isReadableErrored(stream); - - if (errored && typeof errored !== "boolean") { - return callback.$call(stream, errored); - } - - if (readable && !readableFinished && isReadableNodeStream(stream, true)) { - if (!isReadableFinished(stream, false)) return callback.$call(stream, $ERR_STREAM_PREMATURE_CLOSE()); - } - if (writable && !writableFinished) { - if (!isWritableFinished(stream, false)) return callback.$call(stream, $ERR_STREAM_PREMATURE_CLOSE()); - } - - callback.$call(stream); - }; - - const onclosed = () => { - closed = true; - - const errored = isWritableErrored(stream) || isReadableErrored(stream); - - if (errored && typeof errored !== "boolean") { - return callback.$call(stream, errored); + const error = getEosOnCloseError(stream, readable, readableFinished, writable, writableFinished); + if (error === null) { + callback.$call(stream); + } else { + callback.$call(stream, error); } - - callback.$call(stream); }; const onrequest = () => { @@ -182,30 +275,7 @@ function eos(stream, options, callback) { } stream.on("close", onclose); - if (closed) { - process.nextTick(onclose); - } else if (wState?.errorEmitted || rState?.errorEmitted) { - if (!willEmitClose) { - process.nextTick(onclosed); - } - } else if ( - !readable && - (!willEmitClose || isReadable(stream)) && - (writableFinished || isWritable(stream) === false) && - (wState == null || wState.pendingcb === undefined || wState.pendingcb === 0) - ) { - process.nextTick(onclosed); - } else if ( - !writable && - (!willEmitClose || isWritable(stream)) && - (readableFinished || isReadable(stream) === false) - ) { - process.nextTick(onclosed); - } else if (rState && stream.req && stream.aborted) { - process.nextTick(onclosed); - } - - const cleanup = () => { + cleanup = () => { callback = nop; stream.removeListener("aborted", onclose); stream.removeListener("complete", onfinish); @@ -220,30 +290,32 @@ function eos(stream, options, callback) { stream.removeListener("close", onclose); }; - if (options.signal && !closed) { + if (options.signal) { const abort = () => { // Keep it because cleanup removes it. const endCallback = callback; cleanup(); endCallback.$call(stream, $makeAbortError(undefined, { cause: options.signal.reason })); }; - if (options.signal.aborted) { - process.nextTick(abort); - } else { - addAbortListener ??= require("internal/abort_listener").addAbortListener; - const disposable = addAbortListener(options.signal, abort); - const originalCallback = callback; - callback = once((...args) => { - disposable[SymbolDispose](); - originalCallback.$apply(stream, args); - }); - } + addAbortListener ??= require("internal/abort_listener").addAbortListener; + const disposable = addAbortListener(options.signal, abort); + const originalCallback = callback; + callback = once((...args) => { + disposable[SymbolDispose](); + originalCallback.$apply(stream, args); + }); } return cleanup; } function eosWeb(stream, options, callback) { + if (hasAsyncContext()) { + callback = once(bindAsyncResource(callback, "STREAM_END_OF_STREAM")); + } else { + callback = once(callback); + } + let isAborted = false; let abort = nop; if (options.signal) { @@ -296,4 +368,5 @@ function finished(stream, opts) { } eos.finished = finished; +eos.kEosNodeSynchronousCallback = kEosNodeSynchronousCallback; export default eos; diff --git a/src/js/internal/streams/operators.ts b/src/js/internal/streams/operators.ts index 802c9c8c24e..1508a66480a 100644 --- a/src/js/internal/streams/operators.ts +++ b/src/js/internal/streams/operators.ts @@ -1,11 +1,8 @@ "use strict"; -const { validateAbortSignal, validateInteger, validateObject } = require("internal/validators"); +const { validateAbortSignal, validateFunction, validateInteger, validateObject } = require("internal/validators"); const { kWeakHandler, kResistStopPropagation } = require("internal/shared"); const { finished } = require("internal/streams/end-of-stream"); -const staticCompose = require("internal/streams/compose"); -const { addAbortSignalNoValidate } = require("internal/streams/add-abort-signal"); -const { isWritable, isNodeStream } = require("internal/streams/utils"); const MathFloor = Math.floor; const PromiseResolve = Promise.$resolve.bind(Promise); @@ -18,32 +15,8 @@ const ObjectDefineProperty = Object.defineProperty; const kEmpty = Symbol("kEmpty"); const kEof = Symbol("kEof"); -function compose(stream, options) { - if (options != null) { - validateObject(options, "options"); - } - if (options?.signal != null) { - validateAbortSignal(options.signal, "options.signal"); - } - - if (isNodeStream(stream) && !isWritable(stream)) { - throw $ERR_INVALID_ARG_VALUE("stream", stream, "must be writable"); - } - - const composedStream = staticCompose(this, stream); - - if (options?.signal) { - // Not validating as we already validated before - addAbortSignalNoValidate(options.signal, composedStream); - } - - return composedStream; -} - function map(fn, options) { - if (typeof fn !== "function") { - throw $ERR_INVALID_ARG_TYPE("fn", ["Function", "AsyncFunction"], fn); - } + validateFunction(fn, "fn"); if (options != null) { validateObject(options, "options"); } @@ -192,9 +165,7 @@ async function some(fn, options = undefined) { } async function every(fn, options = undefined) { - if (typeof fn !== "function") { - throw $ERR_INVALID_ARG_TYPE("fn", ["Function", "AsyncFunction"], fn); - } + validateFunction(fn, "fn"); // https://en.wikipedia.org/wiki/De_Morgan's_laws return !(await some.$call( this, @@ -213,9 +184,7 @@ async function find(fn, options) { } async function forEach(fn, options) { - if (typeof fn !== "function") { - throw $ERR_INVALID_ARG_TYPE("fn", ["Function", "AsyncFunction"], fn); - } + validateFunction(fn, "fn"); async function forEachFn(value, options) { await fn(value, options); return kEmpty; @@ -225,9 +194,7 @@ async function forEach(fn, options) { } function filter(fn, options) { - if (typeof fn !== "function") { - throw $ERR_INVALID_ARG_TYPE("fn", ["Function", "AsyncFunction"], fn); - } + validateFunction(fn, "fn"); async function filterFn(value, options) { if (await fn(value, options)) { return value; @@ -248,9 +215,7 @@ class ReduceAwareErrMissingArgs extends TypeError { } async function reduce(reducer, initialValue, options) { - if (typeof reducer !== "function") { - throw $ERR_INVALID_ARG_TYPE("reducer", ["Function", "AsyncFunction"], reducer); - } + validateFunction(reducer, "reducer"); if (options != null) { validateObject(options, "options"); } @@ -397,7 +362,6 @@ export default { flatMap, map, take, - compose, }, promiseReturningOperators: { every, diff --git a/src/js/internal/streams/pipeline.ts b/src/js/internal/streams/pipeline.ts index c771436558a..8d51817773e 100644 --- a/src/js/internal/streams/pipeline.ts +++ b/src/js/internal/streams/pipeline.ts @@ -207,7 +207,7 @@ function pipelineImpl(streams, callback, opts?) { } function finishImpl(err, final?) { - if (err && (!error || error.code === "ERR_STREAM_PREMATURE_CLOSE")) { + if (err && (!error || error.code === "ERR_STREAM_PREMATURE_CLOSE" || error.name === "AbortError")) { error = err; } diff --git a/src/js/internal/streams/readable.ts b/src/js/internal/streams/readable.ts index 67fb16b260c..e4ffdd8bd1e 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,6 +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) { + // DEBUG: revert the Node 26 kReading guard to test the socket-EOF-stall hypothesis. if ((state[kState] & (kReadingMore | kConstructed)) === kConstructed) { state[kState] |= kReadingMore; process.nextTick(maybeReadMore_, stream, state); @@ -1128,6 +1133,13 @@ function nReadingNextTick(self) { // If the user uses them, then switch into old mode. Readable.prototype.resume = function () { const state = this._readableState; + // 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 @@ -1167,6 +1179,7 @@ function resume_(stream, state) { Readable.prototype.pause = function () { const state = this._readableState; + // No destroyed early-return: see the comment in resume() above. $debug("call pause"); if ((state[kState] & (kHasFlowing | kFlowing)) !== kHasFlowing) { $debug("pause"); @@ -1247,6 +1260,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 +1562,7 @@ function fromList(n, state) { n -= str.length; buf[idx++] = null; } else { - if (n === buf.length) { + if (n === str.length) { ret += str; buf[idx++] = null; } else { diff --git a/src/js/internal/streams/writable.ts b/src/js/internal/streams/writable.ts index 05bc6c594aa..af5d7b7d4f5 100644 --- a/src/js/internal/streams/writable.ts +++ b/src/js/internal/streams/writable.ts @@ -431,6 +431,9 @@ function _write(stream, chunk, encoding, cb?) { } if (typeof chunk === "string") { + if (encoding === "buffer") { + throw $ERR_UNKNOWN_ENCODING(encoding); + } if ((state[kState] & kDecodeStrings) !== 0) { chunk = Buffer.from(chunk, encoding); encoding = "buffer"; diff --git a/src/js/internal/webstreams_adapters.ts b/src/js/internal/webstreams_adapters.ts index 86bc91551f7..1772e726ead 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; } @@ -754,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, @@ -761,5 +882,8 @@ export default { newStreamReadableFromReadableStream, newReadableWritablePairFromDuplex, newStreamDuplexFromReadableWritablePair, + newBufferSourceTransformPairFromDuplex, + kValidateChunk, + kDestroyOnSyncError, _ReadableFromWeb: ReadableFromWeb, }; diff --git a/src/js/node/_http_common.ts b/src/js/node/_http_common.ts index de665293995..1a5256a0906 100644 --- a/src/js/node/_http_common.ts +++ b/src/js/node/_http_common.ts @@ -59,8 +59,11 @@ const MAX_HEADER_PAIRS = 2000; // called to process trailing HTTP headers. function parserOnHeaders(headers, url) { // Once we exceeded headers limit - stop collecting them - if (this.maxHeaderPairs <= 0 || this._headers.length < this.maxHeaderPairs) { + const capacity = this.maxHeaderPairs - this._headers.length; + if (this.maxHeaderPairs <= 0 || capacity >= headers.length) { this._headers.push(...headers); + } else if (capacity > 0) { + this._headers.push(...headers.slice(0, capacity)); } this._url += url; } @@ -185,8 +188,8 @@ function closeParserInstance(parser) { function freeParser(parser, req, socket) { if (parser) { if (parser._consumed) parser.unconsume(); - cleanParser(parser); parser.remove(); + cleanParser(parser); if (parsers.free(parser) === false) { // Make sure the parser's stack has unwound before deleting the // corresponding C++ object through .close(). diff --git a/src/js/node/_http_outgoing.ts b/src/js/node/_http_outgoing.ts index 4ae81798e03..632edfbe4d4 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,10 @@ const OutgoingMessagePrototype = { shouldKeepAlive: true, _onPendingData: function nop() {}, outputSize: 0, - outputData: [], + // 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, @@ -212,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; @@ -223,7 +236,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,24 +261,40 @@ 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() { 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.some(name => typeof name === "string" && name.toLowerCase() === "set-cookie")) { + names.push(emptySetCookie); + } + return names; }, 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 +305,17 @@ const OutgoingMessagePrototype = { validateHeaderName(name); validateHeaderValue(name, value); const headers = (this[headersSymbol] ??= new Headers()); + 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); + // Remember the original-case name so getRawHeaderNames can report it. + this[kEmptySetCookie] = name; + return this; + } + this[kEmptySetCookie] = false; + } setHeader(headers, name, value); return this; }, @@ -288,20 +332,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 +355,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); @@ -316,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); }, @@ -448,7 +502,7 @@ const OutgoingMessagePrototype = { data = this._header + data; } else { const header = this._header; - this.outputData.unshift({ + (this.outputData ??= []).unshift({ data: header, encoding: "latin1", callback: null, @@ -475,18 +529,41 @@ 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 ?? 0; + if (outputLength <= 0) return undefined; + + const outputData = this.outputData; + socket.cork(); + let ret; + // Retain for(;;) loop for performance reasons + // Refs: https://github.com/nodejs/node/pull/30958 + for (let i = 0; i < outputLength; i++) { + const { data, encoding, callback } = outputData[i]; + // Avoid any potential ref to Buffer in new generation from old generation + outputData[i].data = null; + ret = socket.write(data, encoding, callback); + } + socket.uncork(); + + this.outputData = []; + this._onPendingData(-this.outputSize); + this.outputSize = 0; + + return ret; + }, end(_chunk, _encoding, _callback) { return this; diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index c7c54c5582c..05f41e3ad12 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -600,7 +600,10 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort } socket[kRequest] = http_req; - const is_upgrade = http_req.headers.upgrade; + // Like Node.js, only treat this as an upgrade when there is an + // 'upgrade' listener; otherwise the request falls through to the + // regular 'request' event. + const is_upgrade = !!http_req.headers.upgrade && server.listenerCount("upgrade") > 0; if (!is_upgrade) { if (canUseInternalAssignSocket) { // ~10% performance improvement in JavaScriptCore due to avoiding .once("close", ...) and removing a listener @@ -1524,8 +1527,6 @@ ServerResponse.prototype.write = function (chunk, encoding, callback) { if (callback) { process.nextTick(callback); } - this.emit("drain"); - return true; }; @@ -1820,8 +1821,6 @@ function ServerResponse_finalDeprecated(chunk, encoding, callback) { // ServerResponse.prototype._final = ServerResponse_finalDeprecated; -ServerResponse.prototype.writeHeader = ServerResponse.prototype.writeHead; - OriginalWriteHeadFn = ServerResponse.prototype.writeHead; OriginalImplicitHeadFn = ServerResponse.prototype._implicitHeader; diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 19afd513bb6..8f8fca8ce5f 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); } } @@ -352,6 +352,7 @@ const { validateInt32, validateBuffer, validateNumber, + validateAbortSignal, } = require("internal/validators"); let utcCache; @@ -380,6 +381,7 @@ function emitErrorNT(self: any, error: any, destroy: boolean) { function emitOutofStreamErrorNT(self: any) { self.destroy($ERR_HTTP2_OUT_OF_STREAMS()); } + function cache() { const d = new Date(); utcCache = d.toUTCString(); @@ -2478,7 +2480,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 +2521,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 +2640,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 }; @@ -3010,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; @@ -3037,10 +3048,18 @@ class ServerHttp2Session extends Http2Session { const parser = this.#parser; if (parser) { parser.emitAbortToAllStreams(); + parser.forEachStream(streamSocketClosed); parser.detach(); this.#parser = null; } + // Like Node's socketOnClose, a dead socket always tears the session down + // (close() followed by closeSession() upstream). close() alone is not + // enough: it early-returns once a received GOAWAY has already marked the + // session closed, and the destroy it deferred to the last stream's close + // never comes once the peer is gone — leaving the session (and the + // server's open-connection count) alive forever. this.close(); + this.destroy(); } #onError(error: Error) { this.destroy(error); @@ -3304,11 +3323,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(); } @@ -3352,6 +3376,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; @@ -3522,9 +3557,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; @@ -3602,6 +3647,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; } @@ -3712,7 +3758,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); } @@ -3861,11 +3907,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(); } @@ -3898,8 +3948,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 $ERR_HTTP2_GOAWAY_SESSION(); } if (this.sentTrailers) { @@ -3975,6 +4028,27 @@ 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) { + // 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); + 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); @@ -4109,14 +4183,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 +4311,10 @@ class Http2SecureServer extends tls.Server { validateObject(settings, "options.settings"); } if (options.maxSessionInvalidFrames !== undefined) - validateUint32(options.maxSessionInvalidFrames, "maxSessionInvalidFrames"); + validateUint32(options.maxSessionInvalidFrames, "options.maxSessionInvalidFrames"); if (options.maxSessionRejectedStreams !== undefined) { - validateUint32(options.maxSessionRejectedStreams, "maxSessionRejectedStreams"); + validateUint32(options.maxSessionRejectedStreams, "options.maxSessionRejectedStreams"); } super(options, connectionListener); this[kSessions] = new SafeSet(); diff --git a/src/js/node/https.ts b/src/js/node/https.ts index f140f458396..3a2c0eda82f 100644 --- a/src/js/node/https.ts +++ b/src/js/node/https.ts @@ -35,14 +35,39 @@ function get(input, options, cb) { function Agent(options) { if (!(this instanceof Agent)) return new Agent(options); + options = { __proto__: null, ...options }; + options.defaultPort ??= 443; + options.protocol ??= "https:"; http.Agent.$apply(this, [options]); - this.defaultPort = 443; - this.protocol = "https:"; + this.maxCachedSessions = this.options.maxCachedSessions; if (this.maxCachedSessions === undefined) this.maxCachedSessions = 100; } $toClass(Agent, "Agent", http.Agent); -Agent.prototype.createConnection = http.createConnection; +Agent.prototype.createConnection = function createConnection(...args) { + // XXX: This signature (port, host, options) is different from all the other + // createConnection() methods. + let options; + if (args[0] !== null && typeof args[0] === "object") { + options = args[0]; + } else if (args[1] !== null && typeof args[1] === "object") { + options = { ...args[1] }; + } else if (args[2] === null || typeof args[2] !== "object") { + options = {}; + } else { + options = { ...args[2] }; + } + + if (typeof args[0] === "number") { + options.port = args[0]; + } + + if (typeof args[1] === "string") { + options.host = args[1]; + } + + return require("node:tls").connect(options); +}; var https = { Agent, diff --git a/src/jsc/ErrorCode.rs b/src/jsc/ErrorCode.rs index 9ccb182425d..acd134345bd 100644 --- a/src/jsc/ErrorCode.rs +++ b/src/jsc/ErrorCode.rs @@ -680,8 +680,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; } // ────────────────────────────────────────────────────────────────────────── @@ -1036,6 +1039,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 enum @@ -1365,6 +1369,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/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 86a9622c33a..a4c96685d46 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -244,7 +244,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 @@ -2495,6 +2495,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); @@ -2511,6 +2515,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); @@ -2518,12 +2526,20 @@ 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); 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); @@ -2538,6 +2554,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/src/jsc/bindings/BunProcessReportObjectWindows.cpp b/src/jsc/bindings/BunProcessReportObjectWindows.cpp index 48e24d14b64..6d6e4428f3e 100644 --- a/src/jsc/bindings/BunProcessReportObjectWindows.cpp +++ b/src/jsc/bindings/BunProcessReportObjectWindows.cpp @@ -16,6 +16,9 @@ #include "JavaScriptCore/VM.h" #include "JavaScriptCore/NumberPrototype.h" #include "wtf-bindings.h" + +#define STRINGIFY_IMPL(x) #x +#define STRINGIFY(x) STRINGIFY_IMPL(x) #include "wtf/Scope.h" #include "wtf/text/WTFString.h" #include "wtf/text/StringView.h" @@ -97,9 +100,9 @@ JSValue constructReportObjectWindows(VM& vm, Zig::GlobalObject* globalObject, Pr // Component versions - just add the minimum needed JSObject* versions = constructEmptyObject(globalObject, globalObject->objectPrototype()); versions->putDirect(vm, Identifier::fromString(vm, "node"_s), jsString(vm, String(REPORTED_NODEJS_VERSION ""_s)), 0); - versions->putDirect(vm, Identifier::fromString(vm, "v8"_s), jsString(vm, String("13.6.233.10-node.18"_s)), 0); + versions->putDirect(vm, Identifier::fromString(vm, "v8"_s), jsString(vm, String(ASCIILiteral::fromLiteralUnsafe(REPORTED_NODEJS_V8_VERSION))), 0); versions->putDirect(vm, Identifier::fromString(vm, "uv"_s), jsString(vm, String::fromLatin1(uv_version_string())), 0); - versions->putDirect(vm, Identifier::fromString(vm, "modules"_s), jsString(vm, String("137"_s)), 0); + versions->putDirect(vm, Identifier::fromString(vm, "modules"_s), jsString(vm, String(ASCIILiteral::fromLiteralUnsafe(STRINGIFY(REPORTED_NODEJS_ABI_VERSION)))), 0); header->putDirect(vm, Identifier::fromString(vm, "componentVersions"_s), versions, 0); RETURN_IF_EXCEPTION(scope, {}); diff --git a/src/jsc/bindings/ErrorCode.cpp b/src/jsc/bindings/ErrorCode.cpp index 8aef2ccca1b..02810d77a1d 100644 --- a/src/jsc/bindings/ErrorCode.cpp +++ b/src/jsc/bindings/ErrorCode.cpp @@ -2483,6 +2483,8 @@ JSC_DEFINE_HOST_FUNCTION(Bun::jsFunctionMakeErrorWithCode, (JSC::JSGlobalObject return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP2_PING_LENGTH, "HTTP2 ping payload must be 8 bytes"_s)); case ErrorCode::ERR_HTTP2_OUT_OF_STREAMS: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP2_OUT_OF_STREAMS, "No stream ID is available because maximum stream ID has been reached"_s)); + case ErrorCode::ERR_HTTP2_GOAWAY_SESSION: + return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP2_GOAWAY_SESSION, "New streams cannot be created after receiving a GOAWAY"_s)); case ErrorCode::ERR_HTTP_BODY_NOT_ALLOWED: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP_BODY_NOT_ALLOWED, "Adding content for this request method or response status is not allowed."_s)); case ErrorCode::ERR_HTTP_SOCKET_ASSIGNED: diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index d721b2a4494..1afd51a695d 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -322,5 +322,8 @@ const errors: ErrorCodeMapping = [ ["ERR_SECRETS_INTERACTION_NOT_ALLOWED", Error], ["ERR_SECRETS_AUTH_FAILED", Error], ["ERR_SECRETS_INTERACTION_REQUIRED", Error], + // Appended (not alphabetical): discriminants are index-aligned with the + // checked-in Rust mirror (src/jsc/ErrorCode.rs) — only ever append here. + ["ERR_HTTP2_GOAWAY_SESSION", Error], ]; export default errors; diff --git a/src/jsc/bindings/NodeHTTP.cpp b/src/jsc/bindings/NodeHTTP.cpp index 287742270eb..37babada090 100644 --- a/src/jsc/bindings/NodeHTTP.cpp +++ b/src/jsc/bindings/NodeHTTP.cpp @@ -1030,6 +1030,12 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPGetHeader, (JSGlobalObject * globalObject, CallFr WebCore::HTTPHeaderName headerName; if (WebCore::findHTTPHeaderName(name, headerName)) { if (headerName == WebCore::HTTPHeaderName::SetCookie) { + // Node's getHeader returns undefined for an absent header; + // Headers.getSetCookie()'s empty array is only correct once + // at least one Set-Cookie value exists. + if (impl->getSetCookieHeaders().isEmpty()) { + return JSValue::encode(jsUndefined()); + } RELEASE_AND_RETURN(scope, fetchHeadersGetSetCookie(globalObject, vm, impl)); } diff --git a/src/jsc/bindings/napi.cpp b/src/jsc/bindings/napi.cpp index 8fef5dcfa30..aa4b5a78df5 100644 --- a/src/jsc/bindings/napi.cpp +++ b/src/jsc/bindings/napi.cpp @@ -2285,6 +2285,23 @@ napi_status napi_get_value_string_any_encoding(napi_env env, napi_value napiValu return napi_set_last_error(env, napi_ok); } + // An over-large bufsize (in particular NAPI_AUTO_LENGTH == SIZE_MAX) means the + // caller promises the buffer is big enough for the whole string; Node forwards + // such sizes to V8's WriteUtf8V2, which simply stops at the end of the string. + // Clamp to the worst-case number of code units the encoder can produce so that + // `bufsize - 1` (and `2 * (bufsize - 1)` for UTF-16, which would otherwise wrap + // around size_t) stays within the destination the caller actually guarantees. + // The encoders already stop at min(input, output), so this never changes how + // many code units get written for buffers that really are this large. + const size_t max_encoded_units = EncodeTo == NapiStringEncoding::utf8 + // Latin-1 → UTF-8 expands at most 2x per byte; UTF-16 → UTF-8 at most 3x per code unit + ? (view->is8Bit() ? 2 : 3) * static_cast(view->length()) + // latin1/utf16 destinations: at most one code unit per source code unit + : static_cast(view->length()); + if (bufsize - 1 > max_encoded_units) [[unlikely]] { + bufsize = max_encoded_units + 1; + } + size_t written; std::span writable_byte_slice(reinterpret_cast(buf), EncodeTo == NapiStringEncoding::utf16 diff --git a/src/jsc/bindings/v8/V8Array.cpp b/src/jsc/bindings/v8/V8Array.cpp index 2e6dcf09eea..4ab770f33d4 100644 --- a/src/jsc/bindings/v8/V8Array.cpp +++ b/src/jsc/bindings/v8/V8Array.cpp @@ -94,7 +94,9 @@ MaybeLocal Array::New(Local context, size_t length, JSArray* array = JSC::constructArray(globalObject, static_cast(nullptr), args); RETURN_IF_EXCEPTION(scope, MaybeLocal()); - Local result = handleScope.createLocal(vm, array); + // Note: createLocal must not be called on an EscapableHandleScope -- it does not own a + // buffer (its constructor does not push a Bun handle scope; see V8EscapableHandleScopeBase). + Local result = isolate->currentHandleScope()->createLocal(vm, array); return handleScope.Escape(result); } diff --git a/src/jsc/bindings/v8/V8EscapableHandleScopeBase.cpp b/src/jsc/bindings/v8/V8EscapableHandleScopeBase.cpp index e9c3c019688..c9fdd48d01d 100644 --- a/src/jsc/bindings/v8/V8EscapableHandleScopeBase.cpp +++ b/src/jsc/bindings/v8/V8EscapableHandleScopeBase.cpp @@ -1,29 +1,63 @@ #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. + // + // 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 }); } -// Store the handle escape_value in the escape slot that we have allocated from the parent -// HandleScope, and return the escape 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_escapeSlot != nullptr, "EscapableHandleScope::Escape called multiple times"); - TaggedPointer* newHandle = m_previousHandleScope->m_buffer->createHandleFromExistingObject( + 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_escapeSlot); - m_escapeSlot = nullptr; + reservation.handle); + m_escapeBuffer = nullptr; return newHandle->asRawPtrLocation(); } diff --git a/src/jsc/bindings/v8/V8EscapableHandleScopeBase.h b/src/jsc/bindings/v8/V8EscapableHandleScopeBase.h index 097f7aa0a57..9903cfd045f 100644 --- a/src/jsc/bindings/v8/V8EscapableHandleScopeBase.h +++ b/src/jsc/bindings/v8/V8EscapableHandleScopeBase.h @@ -6,6 +6,19 @@ namespace v8 { +// In Node 26 (V8 14) headers, this class's constructor is the only out-of-line piece of an +// EscapableHandleScope's lifetime: ~EscapableHandleScopeBase and ~EscapableHandleScope are +// inline-defaulted, so destruction runs V8's inline ~HandleScope (v8-local-handle.h), which +// unwinds the isolate's HandleScopeData using this object's three base words as +// { isolate_, prev_next_, prev_limit_ }. Older Node headers (<= 24) instead reach Bun's exported +// ~HandleScope through their inline-defaulted destructors. Therefore this constructor must NOT +// push a Bun handle scope (nothing on either path would pop it); it initializes the base words +// V8-style, and Bun's exported ~HandleScope detects such frames and unwinds them the same way the +// inline destructor would. See V8EscapableHandleScopeBase.cpp and V8HandleScope.cpp. +// +// Consequently the inherited m_previousHandleScope/m_buffer words do NOT hold Bun pointers here, +// so inherited HandleScope methods that use them (like createLocal) must not be called on these +// objects; Bun-internal code should use isolate->currentHandleScope()->createLocal instead. class EscapableHandleScopeBase : public HandleScope { public: BUN_EXPORT EscapableHandleScopeBase(Isolate* isolate); @@ -14,7 +27,11 @@ class EscapableHandleScopeBase : public HandleScope { BUN_EXPORT uintptr_t* EscapeSlot(uintptr_t* escape_value); private: - shim::Handle* m_escapeSlot; + // The buffer of the Bun handle scope that was current when this scope was constructed (the + // scope an Escape()d value escapes to). Occupies the slot V8 uses for escape_slot_; like + // escape_slot_, it is only ever touched by out-of-line (Bun-compiled) code, and doubles as + // the "Escape called twice" flag. + shim::HandleScopeBuffer* m_escapeBuffer; }; } // namespace v8 diff --git a/src/jsc/bindings/v8/V8External.cpp b/src/jsc/bindings/v8/V8External.cpp index 23581c2ecf6..221790e23f7 100644 --- a/src/jsc/bindings/v8/V8External.cpp +++ b/src/jsc/bindings/v8/V8External.cpp @@ -17,6 +17,13 @@ Local External::New(Isolate* isolate, void* value) return isolate->currentHandleScope()->createLocal(vm, val); } +Local External::New(Isolate* isolate, void* value, uint16_t tag) +{ + // see V8External.h for why the tag is ignored + (void)tag; + return New(isolate, value); +} + void* External::Value() const { auto* external = localToObjectPointer(); @@ -26,4 +33,11 @@ void* External::Value() const return external->value(); } +void* External::Value(uint16_t tag) const +{ + // see V8External.h for why the tag is ignored + (void)tag; + return Value(); +} + } // namespace v8 diff --git a/src/jsc/bindings/v8/V8External.h b/src/jsc/bindings/v8/V8External.h index 3d9a0fccae3..fb99e33ffaf 100644 --- a/src/jsc/bindings/v8/V8External.h +++ b/src/jsc/bindings/v8/V8External.h @@ -9,8 +9,18 @@ namespace v8 { class External : public Value { public: + // Kept for addons compiled against older Node headers, where this overload was out-of-line. + // In V8 14 it is an inline wrapper around the tagged overload below. BUN_EXPORT static Local New(Isolate* isolate, void* value); + // The tag is a v8::ExternalPointerTypeTag (uint16_t), used to type entries in V8's sandbox + // external pointer table so that sandboxed code cannot type-confuse one external pointer for + // another. We have no V8 sandbox and no external pointer table -- the pointer is stored + // directly in a NapiExternal cell -- so there is nothing for the tag to tag and it is ignored. + BUN_EXPORT static Local New(Isolate* isolate, void* value, uint16_t tag); BUN_EXPORT void* Value() const; + // Same deal as New: the tag selects the external pointer table tag to validate against, which + // does not exist here. V8 14's inline Value() forwards to this overload. + BUN_EXPORT void* Value(uint16_t tag) const; }; } // namespace v8 diff --git a/src/jsc/bindings/v8/V8FunctionCallbackInfo.cpp b/src/jsc/bindings/v8/V8FunctionCallbackInfo.cpp index f044d67ffdd..194e33ce4e9 100644 --- a/src/jsc/bindings/v8/V8FunctionCallbackInfo.cpp +++ b/src/jsc/bindings/v8/V8FunctionCallbackInfo.cpp @@ -2,22 +2,35 @@ #include "real_v8.h" #include "v8_compatibility_assertions.h" -// Check that the offset of a field in our ImplicitArgs struct matches the array index -// that V8 uses to access that field -#define CHECK_IMPLICIT_ARG(BUN_NAME, V8_NAME) \ - static_assert(offsetof(v8::ImplicitArgs, BUN_NAME) \ - == sizeof(void*) * real_v8::FunctionCallbackInfo::V8_NAME, \ - "Position of `" #BUN_NAME "` in implicit arguments does not match V8"); +// Check that a slot index in our FunctionCallbackInfo matches the index V8's +// inline accessors use to read that slot of the ApiCallbackExitFrame +#define CHECK_FRAME_INDEX(NAME) \ + static_assert(static_cast(v8::FunctionCallbackInfo::NAME) \ + == static_cast(real_v8::FunctionCallbackInfo::NAME), \ + "Index of `" #NAME "` in the callback exit frame does not match V8"); -CHECK_IMPLICIT_ARG(unused, kUnusedIndex) -CHECK_IMPLICIT_ARG(isolate, kIsolateIndex) -CHECK_IMPLICIT_ARG(context, kContextIndex) -CHECK_IMPLICIT_ARG(return_value, kReturnValueIndex) -CHECK_IMPLICIT_ARG(target, kTargetIndex) -CHECK_IMPLICIT_ARG(new_target, kNewTargetIndex) +CHECK_FRAME_INDEX(kNewTargetIndex) +CHECK_FRAME_INDEX(kArgcIndex) +CHECK_FRAME_INDEX(kFrameSPIndex) +CHECK_FRAME_INDEX(kFrameTypeIndex) +CHECK_FRAME_INDEX(kFrameFPIndex) +CHECK_FRAME_INDEX(kFramePCIndex) +CHECK_FRAME_INDEX(kIsolateIndex) +CHECK_FRAME_INDEX(kReturnValueIndex) +CHECK_FRAME_INDEX(kContextIndex) +CHECK_FRAME_INDEX(kTargetIndex) +CHECK_FRAME_INDEX(kReceiverIndex) +CHECK_FRAME_INDEX(kFirstJSArgumentIndex) + +// Our enum folds kFrameConstantPoolIndex into kFrameFPIndex, which is only +// valid when no constant pool slot is present (true everywhere but PPC64) +static_assert(real_v8::internal::Internals::kFrameCPSlotCount == 0, + "Bun's v8::FunctionCallbackInfo assumes no constant pool slot in the exit frame"); + +static_assert(v8::FunctionCallbackInfo::kFrameTypeApiCallExit + == real_v8::internal::Internals::kFrameTypeApiCallExit, + "Frame type for API callback exit frames does not match V8"); ASSERT_V8_TYPE_LAYOUT_MATCHES(v8::FunctionCallbackInfo) -ASSERT_V8_TYPE_FIELD_OFFSET_MATCHES(v8::FunctionCallbackInfo, implicit_args, implicit_args_) ASSERT_V8_TYPE_FIELD_OFFSET_MATCHES(v8::FunctionCallbackInfo, values, values_) -ASSERT_V8_TYPE_FIELD_OFFSET_MATCHES(v8::FunctionCallbackInfo, length, length_) diff --git a/src/jsc/bindings/v8/V8FunctionCallbackInfo.h b/src/jsc/bindings/v8/V8FunctionCallbackInfo.h index bfb9f977798..7baa3dbb76f 100644 --- a/src/jsc/bindings/v8/V8FunctionCallbackInfo.h +++ b/src/jsc/bindings/v8/V8FunctionCallbackInfo.h @@ -8,32 +8,57 @@ class Isolate; class Context; class Value; -struct ImplicitArgs { - // v8-function-callback.h:149-154 - void* unused; // kUnusedIndex = 0 - Isolate* isolate; // kIsolateIndex = 1 - void* context; // kContextIndex = 2 - TaggedPointer return_value; // kReturnValueIndex = 3 - TaggedPointer target; // kTargetIndex = 4 - void* new_target; // kNewTargetIndex = 5 -}; - // T = return value +// +// Since V8 13.8 (crbug.com/326505377), FunctionCallbackInfo is no longer a +// {implicit_args, values, length} triple. It is a single-pointer-sized view +// into an ApiCallbackExitFrame: `this` points directly at the argc slot of a +// contiguous array of pointer-sized slots, and V8's inline accessors index +// `values_` both backwards (new.target) and forwards (frame words, API +// arguments, receiver, JS arguments) relative to that slot. template class FunctionCallbackInfo { public: - // V8 treats this as an array of pointers - ImplicitArgs* implicit_args; - // index -1 is this - TaggedPointer* values; - int length; - - FunctionCallbackInfo(ImplicitArgs* implicit_args_, TaggedPointer* values_, int length_) - : implicit_args(implicit_args_) - , values(values_) - , length(length_) - { - } + // Slot indices relative to `values`. These must match the private enum in + // V8's v8-function-callback.h (checked by static_asserts in + // V8FunctionCallbackInfo.cpp). kFrameConstantPoolIndex is folded into + // kFrameFPIndex because Internals::kFrameCPSlotCount == 0 on every + // architecture Bun supports (it is only 1 on PPC64). + enum { + // Optional frame arguments block (only for API_CONSTRUCT_EXIT frames). + kNewTargetIndex = -1, + + // Mandatory part. + kArgcIndex = 0, // raw integer, not a Smi + kFrameSPIndex = 1, + kFrameTypeIndex = 2, // Smi-encoded frame type + kFrameFPIndex = 3, + kFramePCIndex = 4, + + // API arguments block. + kIsolateIndex = 5, // raw Isolate* + kReturnValueIndex = 6, + kContextIndex = 7, // raw context pointer + kTargetIndex = 8, + + // JS arguments block. + kReceiverIndex = 9, + kFirstJSArgumentIndex = 10, + }; + + // v8::internal::Internals::kFrameTypeApiCallExit. Stored Smi-encoded in + // the kFrameTypeIndex slot; IsConstructCall() compares against it. + static constexpr int kFrameTypeApiCallExit = 18; + + // V8 declares this as `internal::Address values_[1]` and indexes it + // out-of-bounds in both directions; the object provides a view of the + // frame rather than owning any storage. Mutable for parity with V8 (GC + // may rewrite slots through a const view). + mutable TaggedPointer values[1]; + + FunctionCallbackInfo() = delete; + FunctionCallbackInfo(const FunctionCallbackInfo&) = delete; + FunctionCallbackInfo& operator=(const FunctionCallbackInfo&) = delete; }; using FunctionCallback = void (*)(const FunctionCallbackInfo&); diff --git a/src/jsc/bindings/v8/V8HandleScope.cpp b/src/jsc/bindings/v8/V8HandleScope.cpp index 9071d9621c5..aaecbe0f2af 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 { @@ -17,11 +25,60 @@ 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() { + 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); + } + // 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); + // 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; } @@ -35,4 +92,59 @@ 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 + // data->next == data->limit, then stores the value into the returned slot itself and sets + // data->next to one past the slot. The Isolate's HandleScopeData starts zeroed + // (next == limit == nullptr), and we always hand out exactly one slot with + // limit == slot + 1 == the next value the caller will store, so next == limit is reestablished + // after every inline allocation and every inline handle creation takes this path. The slots + // come from the current Bun handle scope's buffer, so the values stay alive (and GC-visited, + // see Handle::isCell) until that scope closes. + auto* handleScope = isolate->globalInternals()->currentHandleScope(); + RELEASE_ASSERT(handleScope); + TaggedPointer* slot = handleScope->m_buffer->createRawHandleSlot(); + uintptr_t* address = slot->asRawPtrLocation(); + auto* data = shim::getHandleScopeData(isolate); + data->next = address; + data->limit = address + 1; + return address; +} + +void HandleScope::DeleteExtensions(Isolate* isolate) +{ + // Called by V8 14's inline ~HandleScope after it restored HandleScopeData::next/limit, when + // the scope changed the limit (which Extend always does). Free the slots Extend granted inside + // the closing scope — without this, per-iteration v8::HandleScopes in a long native call never + // reclaim memory (everything would otherwise live until the enclosing Bun scope closes). + // `this` is the addon's V8-layout HandleScope, so our members must not be touched. + auto* handleScope = isolate->globalInternals()->currentHandleScope(); + if (!handleScope) { + return; + } + auto* data = shim::getHandleScopeData(isolate); + handleScope->m_buffer->deleteGrantsBack(data->limit); +} + } // namespace v8 diff --git a/src/jsc/bindings/v8/V8HandleScope.h b/src/jsc/bindings/v8/V8HandleScope.h index 0f91c2234d6..eae0556fa81 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,28 @@ 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 + // CreateHandle calls Extend whenever HandleScopeData::next == HandleScopeData::limit, and the + // inline destructor calls DeleteExtensions whenever the scope changed HandleScopeData::limit. + // Private to match V8's declarations, which affects the mangled name on MSVC. + // + // Note that when these are called, `this` (for DeleteExtensions) is a V8-layout HandleScope + // living in the addon's stack frame -- not one of ours -- so they must not touch our members + // through `this`. + BUN_EXPORT static uintptr_t* Extend(Isolate* isolate); + BUN_EXPORT void DeleteExtensions(Isolate* isolate); }; static_assert(sizeof(HandleScope) == 24, "HandleScope has wrong layout"); diff --git a/src/jsc/bindings/v8/V8Isolate.cpp b/src/jsc/bindings/v8/V8Isolate.cpp index 2f6928b49a8..80f740bbcc5 100644 --- a/src/jsc/bindings/v8/V8Isolate.cpp +++ b/src/jsc/bindings/v8/V8Isolate.cpp @@ -43,6 +43,10 @@ Local Isolate::GetCurrentContext() Isolate::Isolate(shim::GlobalInternals* globalInternals) : m_globalInternals(globalInternals) , m_globalObject(globalInternals->m_globalObject) + // Zero the padding: V8 14's inline HandleScope code keeps the isolate's HandleScopeData + // (next/limit/level, see HandleScope::Extend) inside this region, and relies on it starting + // out zeroed just like real V8's HandleScopeData::Initialize() leaves it. + , m_padding {} { m_roots[kUndefinedValueRootIndex] = TaggedPointer(&globalInternals->m_undefinedValue); m_roots[kNullValueRootIndex] = TaggedPointer(&globalInternals->m_nullValue); diff --git a/src/jsc/bindings/v8/V8Isolate.h b/src/jsc/bindings/v8/V8Isolate.h index 5069cbd3e94..784c93e77bc 100644 --- a/src/jsc/bindings/v8/V8Isolate.h +++ b/src/jsc/bindings/v8/V8Isolate.h @@ -17,12 +17,12 @@ class GlobalInternals; // they need to have the correct layout. class Isolate final { public: - // v8-internal.h:775 - static constexpr int kUndefinedValueRootIndex = 4; - static constexpr int kTheHoleValueRootIndex = 5; - static constexpr int kNullValueRootIndex = 6; - static constexpr int kTrueValueRootIndex = 7; - static constexpr int kFalseValueRootIndex = 8; + // v8-internal.h:1107 + static constexpr int kUndefinedValueRootIndex = 0; + static constexpr int kTheHoleValueRootIndex = 1; + static constexpr int kNullValueRootIndex = 2; + static constexpr int kTrueValueRootIndex = 3; + static constexpr int kFalseValueRootIndex = 4; Isolate(shim::GlobalInternals* globalInternals); @@ -50,9 +50,12 @@ class Isolate final { shim::GlobalInternals* m_globalInternals; Zig::GlobalObject* m_globalObject; - uintptr_t m_padding[78]; + // Padding so that m_roots is at Internals::kIsolateRootsOffset (688 on 64-bit: 16 bytes of + // fields above plus 84 words). V8 14.x inserted kIsolateJSDispatchTableOffset + // (kExternalEntityTableSize) into the isolate-data layout ahead of the roots array. + uintptr_t m_padding[84]; - std::array m_roots; + std::array m_roots; }; } // namespace v8 diff --git a/src/jsc/bindings/v8/V8Number.cpp b/src/jsc/bindings/v8/V8Number.cpp index c870d1ef387..ab52f2ed9aa 100644 --- a/src/jsc/bindings/v8/V8Number.cpp +++ b/src/jsc/bindings/v8/V8Number.cpp @@ -11,6 +11,16 @@ Local Number::New(Isolate* isolate, double value) return isolate->currentHandleScope()->createLocal(isolate->vm(), JSC::jsNumber(value)); } +Local Number::NewFromInt32(Isolate* isolate, int32_t value) +{ + return isolate->currentHandleScope()->createLocal(isolate->vm(), JSC::jsNumber(value)); +} + +Local Number::NewFromUint32(Isolate* isolate, uint32_t value) +{ + return isolate->currentHandleScope()->createLocal(isolate->vm(), JSC::jsNumber(value)); +} + double Number::Value() const { return localToJSValue().asNumber(); diff --git a/src/jsc/bindings/v8/V8Number.h b/src/jsc/bindings/v8/V8Number.h index e85c3ae5ec9..e3a02e27c17 100644 --- a/src/jsc/bindings/v8/V8Number.h +++ b/src/jsc/bindings/v8/V8Number.h @@ -12,6 +12,13 @@ class Number : public Primitive { BUN_EXPORT static Local New(Isolate* isolate, double value); BUN_EXPORT double Value() const; + +private: + // Out-of-line targets of the inline templated Number::New integer overloads in + // v8-primitive.h. Private to match V8's declarations, which affects the mangled + // name on MSVC. + BUN_EXPORT static Local NewFromInt32(Isolate* isolate, int32_t value); + BUN_EXPORT static Local NewFromUint32(Isolate* isolate, uint32_t value); }; } // namespace v8 diff --git a/src/jsc/bindings/v8/V8String.cpp b/src/jsc/bindings/v8/V8String.cpp index 83154798225..8f61fd57ffb 100644 --- a/src/jsc/bindings/v8/V8String.cpp +++ b/src/jsc/bindings/v8/V8String.cpp @@ -8,11 +8,12 @@ ASSERT_V8_TYPE_LAYOUT_MATCHES(v8::String) ASSERT_V8_ENUM_MATCHES(NewStringType, kNormal) ASSERT_V8_ENUM_MATCHES(NewStringType, kInternalized) -ASSERT_V8_ENUM_MATCHES(String::WriteOptions, NO_OPTIONS) -ASSERT_V8_ENUM_MATCHES(String::WriteOptions, HINT_MANY_WRITES_EXPECTED) -ASSERT_V8_ENUM_MATCHES(String::WriteOptions, NO_NULL_TERMINATION) -ASSERT_V8_ENUM_MATCHES(String::WriteOptions, PRESERVE_ONE_BYTE_NULL) -ASSERT_V8_ENUM_MATCHES(String::WriteOptions, REPLACE_INVALID_UTF8) +// V8 14 removed String::WriteOptions along with the legacy Write/WriteOneByte/WriteUtf8 +// APIs (crbug.com/373485796), so it can no longer be checked against the real headers. +// The replacement V2 write APIs take String::WriteFlags. +ASSERT_V8_ENUM_MATCHES(String::WriteFlags, kNone) +ASSERT_V8_ENUM_MATCHES(String::WriteFlags, kNullTerminate) +ASSERT_V8_ENUM_MATCHES(String::WriteFlags, kReplaceInvalidUtf8) using JSC::JSString; @@ -189,6 +190,140 @@ int String::WriteUtf8(Isolate* isolate, char* buffer, int length, int* nchars_re return written; } +void String::WriteV2(Isolate* isolate, uint32_t offset, uint32_t length, uint16_t* buffer, int flags) const +{ + auto jsString = localToObjectPointer(); + RELEASE_ASSERT(static_cast(offset) + length <= jsString->length()); + if (length > 0) { + auto str = jsString->view(isolate->globalObject()); + if (str->is8Bit()) { + WTF::copyElements(std::span(buffer, length), str->span8().subspan(offset, length)); + } else { + memcpy(buffer, str->span16().subspan(offset, length).data(), static_cast(length) * sizeof(uint16_t)); + } + } + if (flags & WriteFlags::kNullTerminate) { + buffer[length] = 0; + } +} + +void String::WriteOneByteV2(Isolate* isolate, uint32_t offset, uint32_t length, uint8_t* buffer, int flags) const +{ + auto jsString = localToObjectPointer(); + RELEASE_ASSERT(static_cast(offset) + length <= jsString->length()); + if (length > 0) { + auto str = jsString->view(isolate->globalObject()); + if (str->is8Bit()) { + memcpy(buffer, str->span8().subspan(offset, length).data(), length); + } else { + // like V8, only the least significant byte of each code unit is written + WTF::copyElements(std::span(buffer, length), str->span16().subspan(offset, length)); + } + } + if (flags & WriteFlags::kNullTerminate) { + buffer[length] = 0; + } +} + +size_t String::WriteUtf8V2(Isolate* isolate, char* buffer, size_t capacity, int flags, size_t* processed_characters_return) const +{ + auto jsString = localToObjectPointer(); + auto str = jsString->view(isolate->globalObject()); + + size_t writableCapacity = capacity; + if (flags & WriteFlags::kNullTerminate) { + RELEASE_ASSERT(capacity >= 1); + writableCapacity--; + } + + size_t read = 0; + size_t written = 0; + if (!str->isEmpty()) { + // TextEncoder__encodeInto never writes partial UTF-8 sequences, and replaces + // unpaired surrogates with U+FFFD (same byte length as the WTF-8 encoding V8 + // uses when kReplaceInvalidUtf8 is not set, so the result size matches either + // way). + if (str->is8Bit()) { + // Latin-1 expands at most 2x: 2 * (2^31 - 1) < 2^32, so the packed + // 32-bit counts cannot wrap. + const auto span = str->span8(); + uint64_t result = TextEncoder__encodeInto8(span.data(), span.size(), buffer, writableCapacity); + read = static_cast(result); + written = static_cast(result >> 32); + } else { + // UTF-16 expands up to 3x, which can exceed the 32-bit counts + // TextEncoder__encodeInto packs its result into (3 * (2^31 - 1) > + // 2^32). Encode in chunks small enough that each chunk's counts + // fit, accumulating in size_t. + const auto span = str->span16(); + const size_t total = span.size(); + constexpr size_t maxChunk = static_cast(1) << 30; // <= 3 GiB UTF-8 per chunk + while (read < total) { + size_t chunkLength = std::min(maxChunk, total - read); + // Never split a surrogate pair across chunks: the encoder + // would see two unpaired halves and write U+FFFD twice. + if (read + chunkLength < total && U16_IS_LEAD(span[read + chunkLength - 1])) { + chunkLength--; + } + uint64_t result = TextEncoder__encodeInto16(span.data() + read, chunkLength, buffer + written, writableCapacity - written); + const uint32_t chunkRead = static_cast(result); + const uint32_t chunkWritten = static_cast(result >> 32); + read += chunkRead; + written += chunkWritten; + if (chunkRead < chunkLength) { + // Ran out of output capacity. + break; + } + } + } + } + + if (processed_characters_return) { + *processed_characters_return = read; + } + if (flags & WriteFlags::kNullTerminate) { + buffer[written] = '\0'; + written++; + } + return written; +} + +size_t String::Utf8LengthV2(Isolate* isolate) const +{ + auto jsString = localToObjectPointer(); + if (jsString->length() == 0) { + return 0; + } + + auto str = jsString->view(isolate->globalObject()); + if (str->is8Bit()) { + const auto span = str->span8(); + return simdutf::utf8_length_from_latin1(reinterpret_cast(span.data()), span.size()); + } + + const auto span = str->span16(); + size_t len = simdutf::utf8_length_from_utf16(span.data(), span.size()); + // simdutf counts every surrogate code unit as 2 bytes, so a valid pair + // totals 4 (matching its UTF-8 encoding) but an unpaired surrogate only + // counts 2. V8 replaces each unpaired surrogate with U+FFFD, which + // encodes as 3 bytes (the same size WriteUtf8V2's replacement behavior + // produces), so add one byte for each unpaired surrogate code unit. + // Valid UTF-16 (the overwhelmingly common case) needs no adjustment; + // check with SIMD before falling back to the scalar surrogate count. + if (simdutf::validate_utf16(span.data(), span.size())) { + return len; + } + for (size_t i = 0; i < span.size(); i++) { + const char16_t c = span[i]; + if (U16_IS_LEAD(c) && i + 1 < span.size() && U16_IS_TRAIL(span[i + 1])) { + i++; + } else if (U16_IS_SURROGATE(c)) { + len++; + } + } + return len; +} + int String::Length() const { auto jsString = localToObjectPointer(); diff --git a/src/jsc/bindings/v8/V8String.h b/src/jsc/bindings/v8/V8String.h index 6c0ff9eed64..bddf0d6f6ac 100644 --- a/src/jsc/bindings/v8/V8String.h +++ b/src/jsc/bindings/v8/V8String.h @@ -14,6 +14,8 @@ enum class NewStringType { class String : Primitive { public: + // V8 14 removed WriteOptions and the legacy Write/WriteOneByte/WriteUtf8 APIs + // (crbug.com/373485796). Kept for addons compiled against older Node headers. enum WriteOptions { NO_OPTIONS = 0, HINT_MANY_WRITES_EXPECTED = 1, @@ -22,6 +24,14 @@ class String : Primitive { REPLACE_INVALID_UTF8 = 8, }; + struct WriteFlags { + enum { + kNone = 0, + kNullTerminate = 1, + kReplaceInvalidUtf8 = 2, + }; + }; + BUN_EXPORT static MaybeLocal NewFromUtf8(Isolate* isolate, char const* data, NewStringType type, int length = -1); BUN_EXPORT static MaybeLocal NewFromOneByte(Isolate* isolate, const uint8_t* data, NewStringType type, int length); @@ -32,6 +42,30 @@ class String : Primitive { // if string ends in a surrogate pair, but buffer is one byte too small to store it, instead // endcode the unpaired lead surrogate with WTF-8 BUN_EXPORT int WriteUtf8(Isolate* isolate, char* buffer, int length = -1, int* nchars_ref = nullptr, int options = NO_OPTIONS) const; + + /** + * Write the contents of the string to an external buffer. + * + * Copies length characters into the output buffer starting at offset. The + * output buffer must have sufficient space for all characters and the null + * terminator if null termination is requested through the flags. + */ + BUN_EXPORT void WriteV2(Isolate* isolate, uint32_t offset, uint32_t length, uint16_t* buffer, int flags = WriteFlags::kNone) const; + BUN_EXPORT void WriteOneByteV2(Isolate* isolate, uint32_t offset, uint32_t length, uint8_t* buffer, int flags = WriteFlags::kNone) const; + + /** + * Encode the contents of the string as Utf8 into an external buffer. + * + * Encodes the characters of this string as Utf8 and writes them into the + * output buffer until either all characters were encoded or the buffer is + * full. Will not write partial UTF-8 sequences, preferring to stop before + * the end of the buffer. If null termination is requested, the output + * buffer will always be null terminated even if not all characters fit. In + * that case, the capacity must be at least one. Returns the number of + * bytes copied to the buffer including the null terminator (if written). + */ + BUN_EXPORT size_t WriteUtf8V2(Isolate* isolate, char* buffer, size_t capacity, int flags = WriteFlags::kNone, size_t* processed_characters_return = nullptr) const; + BUN_EXPORT int Length() const; /** @@ -40,6 +74,13 @@ class String : Primitive { */ BUN_EXPORT int Utf8Length(Isolate* isolate) const; + /** + * Returns the number of bytes needed for the Utf8 encoding of this string. + * Unpaired surrogates are counted as the 3-byte U+FFFD replacement + * character, matching the Write*V2 replacement behavior. + */ + BUN_EXPORT size_t Utf8LengthV2(Isolate* isolate) const; + /** * Returns whether this string is known to contain only one byte data, * i.e. ISO-8859-1 code points. diff --git a/src/jsc/bindings/v8/V8Value.cpp b/src/jsc/bindings/v8/V8Value.cpp index 469f86ca441..3ccf3a830cb 100644 --- a/src/jsc/bindings/v8/V8Value.cpp +++ b/src/jsc/bindings/v8/V8Value.cpp @@ -34,6 +34,31 @@ bool Value::IsUndefined() const return localToJSValue().isUndefined(); } +// The QuickIs* functions are V8_INLINE with out-of-class bodies in +// v8-value.h. MSVC debug builds (/Ob0) import such members of a dllimport +// class instead of emitting them, so addons compiled --debug on Windows +// need them as real exports. Semantically they are the corresponding Is* +// checks (the "quick" part only matters for real V8's object layout). +bool Value::QuickIsUndefined() const +{ + return localToJSValue().isUndefined(); +} + +bool Value::QuickIsNull() const +{ + return localToJSValue().isNull(); +} + +bool Value::QuickIsNullOrUndefined() const +{ + return localToJSValue().isUndefinedOrNull(); +} + +bool Value::QuickIsString() const +{ + return localToJSValue().isString(); +} + bool Value::IsNull() const { return localToJSValue().isNull(); diff --git a/src/jsc/bindings/v8/V8Value.h b/src/jsc/bindings/v8/V8Value.h index 265ba925b72..0027496503e 100644 --- a/src/jsc/bindings/v8/V8Value.h +++ b/src/jsc/bindings/v8/V8Value.h @@ -35,6 +35,13 @@ class Value : public Data { // non-inlined versions of these BUN_EXPORT bool FullIsTrue() const; BUN_EXPORT bool FullIsFalse() const; + // V8_INLINE in the headers but with out-of-class bodies, which MSVC debug + // builds import instead of emitting locally; private to match V8's + // declarations (affects the MSVC mangling). + BUN_EXPORT bool QuickIsUndefined() const; + BUN_EXPORT bool QuickIsNull() const; + BUN_EXPORT bool QuickIsNullOrUndefined() const; + BUN_EXPORT bool QuickIsString() const; }; } // namespace v8 diff --git a/src/jsc/bindings/v8/shim/FunctionTemplate.cpp b/src/jsc/bindings/v8/shim/FunctionTemplate.cpp index 6de66fabebf..baa7de3f3c0 100644 --- a/src/jsc/bindings/v8/shim/FunctionTemplate.cpp +++ b/src/jsc/bindings/v8/shim/FunctionTemplate.cpp @@ -62,8 +62,6 @@ JSC::EncodedJSValue FunctionTemplate::functionCall(JSC::JSGlobalObject* globalOb auto* isolate = uncheckedDowncast(globalObject)->V8GlobalInternals()->isolate(); auto& vm = JSC::getVM(globalObject); - WTF::Vector args(callFrame->argumentCount() + 1); - HandleScope hs(isolate); // V8 function calls always run in "sloppy mode," even if the JS side is in strict mode. So if @@ -75,36 +73,58 @@ JSC::EncodedJSValue FunctionTemplate::functionCall(JSC::JSGlobalObject* globalOb jscThis = callFrame->thisValue().toObject(globalObject); } Local thisObject = hs.createLocal(vm, jscThis); - args[0] = thisObject.tagged(); - - for (size_t i = 0; i < callFrame->argumentCount(); i++) { - Local argValue = hs.createLocal(vm, callFrame->argument(i)); - args[i + 1] = argValue.tagged(); - } // In V8, the target is the function being called Local target = hs.createLocal(vm, callee); - ImplicitArgs implicit_args = { - .unused = nullptr, - .isolate = isolate, - // Context is always a reinterpret pointer to Zig::GlobalObject - .context = reinterpret_cast(globalObject), - .return_value = TaggedPointer(), - // target holds the Function being called, which contains the FunctionTemplate - .target = target.tagged(), - .new_target = nullptr, + // Build a synthetic ApiCallbackExitFrame: one contiguous array of + // pointer-sized slots that V8's inline FunctionCallbackInfo accessors index + // relative to the argc slot. The view starts one slot into the array so + // that kNewTargetIndex (-1) stays in bounds. + using Info = FunctionCallbackInfo; + constexpr size_t viewOffset = 1; + const size_t argc = callFrame->argumentCount(); + WTF::Vector frame(viewOffset + Info::kFirstJSArgumentIndex + argc); + auto slot = [&](ptrdiff_t index) -> TaggedPointer& { + return frame[viewOffset + index]; }; - FunctionCallbackInfo info(&implicit_args, args.begin() + 1, callFrame->argumentCount()); + // Bun never reports a construct call here, so V8's NewTarget() always + // returns undefined without reading this slot + slot(Info::kNewTargetIndex) = TaggedPointer(); + // Length() reads this as a raw integer, not a Smi + slot(Info::kArgcIndex) = TaggedPointer::fromRaw(argc); + // SP/FP/PC are only used by V8's stack walker, which never sees this frame + slot(Info::kFrameSPIndex) = TaggedPointer::fromRaw(0); + // IsConstructCall() compares this Smi against kFrameTypeApiConstructExit + slot(Info::kFrameTypeIndex) = TaggedPointer(Info::kFrameTypeApiCallExit); + slot(Info::kFrameFPIndex) = TaggedPointer::fromRaw(0); + slot(Info::kFramePCIndex) = TaggedPointer::fromRaw(0); + // GetIsolate() reads this slot as a raw, untagged pointer + slot(Info::kIsolateIndex) = TaggedPointer::fromRaw(reinterpret_cast(isolate)); + slot(Info::kReturnValueIndex) = TaggedPointer(); + // Context is always a reinterpret pointer to Zig::GlobalObject + slot(Info::kContextIndex) = TaggedPointer::fromRaw(reinterpret_cast(globalObject)); + // target holds the Function being called, which contains the FunctionTemplate + slot(Info::kTargetIndex) = target.tagged(); + slot(Info::kReceiverIndex) = thisObject.tagged(); + + for (size_t i = 0; i < argc; i++) { + Local argValue = hs.createLocal(vm, callFrame->argument(i)); + slot(Info::kFirstJSArgumentIndex + i) = argValue.tagged(); + } + + // The FunctionCallbackInfo object is a view located at the argc slot + const auto& info = *reinterpret_cast(&slot(Info::kArgcIndex)); functionTemplate->m_callback(info); - if (implicit_args.return_value.isEmpty()) { + TaggedPointer& return_value = slot(Info::kReturnValueIndex); + if (return_value.isEmpty()) { // callback forgot to set a return value, so return undefined return JSValue::encode(JSC::jsUndefined()); } else { - Local local_ret(&implicit_args.return_value); + Local local_ret(&return_value); return JSValue::encode(local_ret->localToJSValue()); } } diff --git a/src/jsc/bindings/v8/shim/GlobalInternals.h b/src/jsc/bindings/v8/shim/GlobalInternals.h index e55ac4af20f..2387e6d7f6a 100644 --- a/src/jsc/bindings/v8/shim/GlobalInternals.h +++ b/src/jsc/bindings/v8/shim/GlobalInternals.h @@ -1,6 +1,7 @@ #pragma once #include "BunClientData.h" +#include #include "../V8Isolate.h" #include "Oddball.h" @@ -12,6 +13,7 @@ class HandleScope; namespace shim { class HandleScopeBuffer; +struct Handle; class GlobalInternals : public JSC::JSCell { public: @@ -61,6 +63,23 @@ class GlobalInternals : public JSC::JSCell { HandleScope* currentHandleScope() const { return m_currentHandleScope; } + // Escape-slot reservations for live EscapableHandleScopes, keyed by the + // scope's stack address. The slot is reserved at scope construction (so it + // sits below any handles created inside the scope and survives + // HandleScope::DeleteExtensions) and consumed by EscapeSlot(). Entries are + // purged when their owning buffer clears (scope close) — a scope destroyed + // by V8's inline destructor without calling Escape() has no other hook — + // and a reused stack address simply overwrites the stale entry. + struct EscapeReservation { + Handle* handle { nullptr }; + HandleScopeBuffer* buffer { nullptr }; + }; + WTF::HashMap& escapeReservations() { return m_escapeReservations; } + void purgeEscapeReservations(HandleScopeBuffer* buffer) + { + m_escapeReservations.removeIf([buffer](auto& entry) { return entry.value.buffer == buffer; }); + } + void setCurrentHandleScope(HandleScope* handleScope) { m_currentHandleScope = handleScope; } Isolate* isolate() { return &m_isolate; } @@ -78,6 +97,7 @@ class GlobalInternals : public JSC::JSCell { JSC::LazyClassStructure m_functionTemplateStructure; JSC::LazyClassStructure m_v8FunctionStructure; HandleScope* m_currentHandleScope; + WTF::HashMap m_escapeReservations; JSC::LazyProperty m_globalHandles; Oddball m_undefinedValue; diff --git a/src/jsc/bindings/v8/shim/Handle.h b/src/jsc/bindings/v8/shim/Handle.h index 9164adce3cc..66716c9fd53 100644 --- a/src/jsc/bindings/v8/shim/Handle.h +++ b/src/jsc/bindings/v8/shim/Handle.h @@ -78,6 +78,12 @@ struct Handle { if (m_toV8Object.tag() == TaggedPointer::Tag::Smi) { return false; } + if (m_toV8Object.getPtr() != &m_object) { + // This slot was written directly by V8's inline CreateHandle code (see + // HandleScope::Extend): it aliases an ObjectLayout owned by some other handle (or an + // oddball/root), and that owner is the one responsible for keeping the cell alive. + return false; + } const Map* map_ptr = m_object.map(); // TODO(@190n) exhaustively switch on InstanceType if (map_ptr == &Map::object_map() || map_ptr == &Map::string_map()) { diff --git a/src/jsc/bindings/v8/shim/HandleScopeBuffer.cpp b/src/jsc/bindings/v8/shim/HandleScopeBuffer.cpp index cea327117c7..d67369990c8 100644 --- a/src/jsc/bindings/v8/shim/HandleScopeBuffer.cpp +++ b/src/jsc/bindings/v8/shim/HandleScopeBuffer.cpp @@ -69,6 +69,37 @@ TaggedPointer* HandleScopeBuffer::createDoubleHandle(double value) return handle.slot(); } +TaggedPointer* HandleScopeBuffer::createRawHandleSlot() +{ + 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 }; + // 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 +146,7 @@ void HandleScopeBuffer::clear() handle = Handle(); } m_storage.clear(); + m_rawGrants.clear(); } } // namespace shim diff --git a/src/jsc/bindings/v8/shim/HandleScopeBuffer.h b/src/jsc/bindings/v8/shim/HandleScopeBuffer.h index f30c535c231..fe7caeda2b0 100644 --- a/src/jsc/bindings/v8/shim/HandleScopeBuffer.h +++ b/src/jsc/bindings/v8/shim/HandleScopeBuffer.h @@ -43,6 +43,38 @@ 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); + + // 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(); + + // 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 // @@ -62,6 +94,14 @@ 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; + uintptr_t* m_savedNext { nullptr }; + uintptr_t* m_savedLimit { nullptr }; Handle& createEmptyHandle(); diff --git a/src/jsc/bindings/v8/v8_handle_scope_data.h b/src/jsc/bindings/v8/v8_handle_scope_data.h new file mode 100644 index 00000000000..2c6a74f5cf7 --- /dev/null +++ b/src/jsc/bindings/v8/v8_handle_scope_data.h @@ -0,0 +1,39 @@ +#pragma once + +// Access to the v8::internal::HandleScopeData that V8 14's inline HandleScope code +// (v8-local-handle.h) reads and writes directly at a fixed offset inside the Isolate +// (internal::Internals::GetHandleScopeData). That offset lands inside our Isolate's padding, +// which the Isolate constructor zeroes (matching real V8's HandleScopeData::Initialize()). +// +// The same warning as in real_v8.h applies: only include this in source files in the v8 +// directory, never in headers. + +#include "real_v8.h" +#include "V8Isolate.h" + +#include + +namespace v8 { +namespace shim { + +// Use the real V8 struct directly so the layout cannot drift: +// { Address* next; Address* limit; int level; int sealed_level; } where Address is uintptr_t. +using HandleScopeData = real_v8::internal::HandleScopeData; + +static_assert(std::is_same_v, + "V8's Address type is expected to be uintptr_t"); +static_assert(real_v8::internal::Internals::kIsolateHandleScopeDataOffset + >= offsetof(::v8::Isolate, m_padding), + "HandleScopeData would overlap the Isolate's leading fields"); +static_assert(real_v8::internal::Internals::kIsolateHandleScopeDataOffset + sizeof(HandleScopeData) + <= offsetof(::v8::Isolate, m_roots), + "HandleScopeData does not fit inside the Isolate's padding"); + +inline HandleScopeData* getHandleScopeData(Isolate* isolate) +{ + return reinterpret_cast( + reinterpret_cast(isolate) + real_v8::internal::Internals::kIsolateHandleScopeDataOffset); +} + +} // namespace shim +} // namespace v8 diff --git a/src/jsc/bindings/webcore/JSFetchHeaders.cpp b/src/jsc/bindings/webcore/JSFetchHeaders.cpp index 2d3531e7ced..9df23192caa 100644 --- a/src/jsc/bindings/webcore/JSFetchHeaders.cpp +++ b/src/jsc/bindings/webcore/JSFetchHeaders.cpp @@ -597,11 +597,19 @@ JSC_DEFINE_HOST_FUNCTION(jsFetchHeaders_getRawKeys, (JSC::JSGlobalObject * lexic } FetchHeaders& headers = thisObject->wrapped(); - JSArray* outArray = JSC::JSArray::create(vm, lexicalGlobalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous), headers.size()); - - for (unsigned int i = 0; const auto& header : headers.internalHeaders()) { + // HTTPHeaderMap's iterator covers only the common and uncommon segments; + // set-cookie values live in their own segment, so size() (which counts + // every cookie) used to leave trailing holes in the array. Size for one + // entry per unique name and append "set-cookie" explicitly. + JSArray* outArray = JSC::JSArray::create(vm, lexicalGlobalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous), headers.sizeAfterJoiningSetCookieHeader()); + + unsigned int i = 0; + for (const auto& header : headers.internalHeaders()) { outArray->putDirectIndex(lexicalGlobalObject, i++, jsString(vm, header.name())); } + if (!headers.internalHeaders().getSetCookieHeaders().isEmpty()) { + outArray->putDirectIndex(lexicalGlobalObject, i++, jsString(vm, WTF::httpHeaderNameDefaultCaseStringImpl(HTTPHeaderName::SetCookie))); + } RELEASE_AND_RETURN(scope, JSValue::encode(outArray)); } diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 7e8b9c096c2..1233a331dce 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -1780,6 +1780,10 @@ impl Stream { } if self.data_frame_queue.is_empty() { if _frame.end_stream { + eprintln!( + "[h2dbg-r] flush end_stream id={} wait_trailers={} state={:?}", + self.id, self.wait_for_trailers, self.state as u8 + ); if self.wait_for_trailers { client.dispatch(JSH2FrameParser::Gc::onWantTrailers, self.get_identifier()); } else { @@ -5144,12 +5148,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]; @@ -5859,6 +5869,7 @@ impl H2FrameParser { // SAFETY: stream is a *mut Stream from self.streams (heap::alloc); valid while the map entry exists let stream = unsafe { &mut *stream }; + eprintln!("[h2dbg-r] no_trailers id={}", stream_id); stream.wait_for_trailers = false; this.send_data(stream, b"", true, JSValue::UNDEFINED); Ok(JSValue::UNDEFINED) @@ -7279,12 +7290,34 @@ impl H2FrameParser { if end_stream { stream.end_after_headers = true; - stream.state = StreamState::HALF_CLOSED_LOCAL; if wait_for_trailers { + stream.state = StreamState::HALF_CLOSED_LOCAL; this.dispatch(JSH2FrameParser::Gc::onWantTrailers, stream.get_identifier()); return Ok(JSValue::js_number(stream_id as f64)); } + + // A HEADERS frame carrying END_STREAM half-closes our side; when + // the peer already half-closed (a server responding after the + // request body finished) the stream is now fully closed. Mirror + // send_data / send_trailers: transition the state forward and + // dispatch onStreamEnd — without this a headers-only END_STREAM + // response regressed the state to HALF_CLOSED_LOCAL and never + // told JS, leaking the stream (and the session's connection + // count) until socket close. + let identifier = stream.get_identifier(); + identifier.ensure_still_alive(); + if stream.state == StreamState::HALF_CLOSED_REMOTE { + stream.state = StreamState::CLOSED; + stream.free_resources::(this); + } else { + stream.state = StreamState::HALF_CLOSED_LOCAL; + } + this.dispatch_with_extra( + JSH2FrameParser::Gc::onStreamEnd, + identifier, + JSValue::js_number(stream.state as u8 as f64), + ); } else { stream.wait_for_trailers = wait_for_trailers; } diff --git a/src/runtime/napi/napi_body.rs b/src/runtime/napi/napi_body.rs index 600d9dcfac1..c85396748d4 100644 --- a/src/runtime/napi/napi_body.rs +++ b/src/runtime/napi/napi_body.rs @@ -2974,6 +2974,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; @@ -2981,6 +2983,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; @@ -2989,6 +2993,14 @@ 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; pub(super) fn _ZN2v811HandleScopeD1Ev() -> *mut c_void; pub(super) fn _ZN2v811HandleScopeD2Ev() -> *mut c_void; @@ -3040,6 +3052,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; @@ -3087,6 +3103,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"] @@ -3099,6 +3119,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"] @@ -3115,6 +3139,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"] @@ -3205,6 +3233,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"] @@ -4082,10 +4118,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, @@ -4093,6 +4132,14 @@ 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, _ZN2v811HandleScopeD2Ev, _ZN2v816FunctionTemplate11GetFunctionENS_5LocalINS_7ContextEEE, @@ -4123,6 +4170,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, @@ -4142,12 +4193,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, @@ -4156,6 +4211,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, @@ -4201,6 +4258,10 @@ pub fn fix_dead_code_elimination() { v8_String_IsOneByte, v8_String_Utf8Length, v8_String_ContainsOnlyOneByte, + v8_String_WriteV2, + v8_String_WriteOneByteV2, + v8_String_WriteUtf8V2, + v8_String_Utf8LengthV2, v8_api_internal_GlobalizeReference, v8_api_internal_DisposeGlobal, v8_api_internal_GetFunctionTemplateData, diff --git a/src/symbols.def b/src/symbols.def index dc727488e86..ce1042f2ff9 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,14 @@ 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 ??1HandleScope@v8@@QEAA@XZ ?GetFunction@FunctionTemplate@v8@@QEAA?AV?$MaybeLocal@VFunction@v8@@@2@V?$Local@VContext@v8@@@2@@Z @@ -620,6 +636,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 diff --git a/src/symbols.dyn b/src/symbols.dyn index b44c02651bd..ded971cdc0e 100644 --- a/src/symbols.dyn +++ b/src/symbols.dyn @@ -1,5 +1,13 @@ { __ZN2v811HandleScope12CreateHandleEPNS_8internal7IsolateEm; + __ZN2v811HandleScope12CreateHandleEPNS_7IsolateEm; + __ZN2v811HandleScope10InitializeEPNS_7IsolateE; + __ZNK2v85Value16QuickIsUndefinedEv; + __ZNK2v85Value11QuickIsNullEv; + __ZNK2v85Value22QuickIsNullOrUndefinedEv; + __ZNK2v85Value13QuickIsStringEv; + __ZN2v811HandleScope16DeleteExtensionsEPNS_7IsolateE; + __ZN2v811HandleScope6ExtendEPNS_7IsolateE; __ZN2v811HandleScopeC1EPNS_7IsolateE; __ZN2v811HandleScopeD1Ev; __ZN2v811HandleScopeD2Ev; @@ -25,6 +33,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 +52,7 @@ __ZN2v87Isolate13TryGetCurrentEv; __ZN2v87Isolate17GetCurrentContextEv; __ZN2v88External3NewEPNS_7IsolateEPv; + __ZN2v88External3NewEPNS_7IsolateEPvt; __ZN2v88Function7SetNameENS_5LocalINS_6StringEEE; __ZN2v88internal35IsolateFromNeverReadOnlySpaceObjectEm; __ZN3JSC9CallFrame13describeFrameEv; @@ -70,13 +81,18 @@ __ZNK2v86Number5ValueEv; __ZNK2v86String10IsExternalEv; __ZNK2v86String10Utf8LengthEPNS_7IsolateE; + __ZNK2v86String11WriteUtf8V2EPNS_7IsolateEPcmiPm; + __ZNK2v86String12Utf8LengthV2EPNS_7IsolateE; + __ZNK2v86String14WriteOneByteV2EPNS_7IsolateEjjPhi; __ZNK2v86String17IsExternalOneByteEv; __ZNK2v86String17IsExternalTwoByteEv; __ZNK2v86String19ContainsOnlyOneByteEv; __ZNK2v86String6LengthEv; + __ZNK2v86String7WriteV2EPNS_7IsolateEjjPti; __ZNK2v86String9IsOneByteEv; __ZNK2v86String9WriteUtf8EPNS_7IsolateEPciPii; __ZNK2v87Boolean5ValueEv; + __ZNK2v88External5ValueEt; __ZNK2v88External5ValueEv; __ZNK2v88Function7GetNameEv; _dumpBtjsTrace; diff --git a/src/symbols.txt b/src/symbols.txt index 462b099dd07..d314660ec93 100644 --- a/src/symbols.txt +++ b/src/symbols.txt @@ -1,4 +1,12 @@ __ZN2v811HandleScope12CreateHandleEPNS_8internal7IsolateEm +__ZN2v811HandleScope12CreateHandleEPNS_7IsolateEm +__ZN2v811HandleScope10InitializeEPNS_7IsolateE +__ZNK2v85Value16QuickIsUndefinedEv +__ZNK2v85Value11QuickIsNullEv +__ZNK2v85Value22QuickIsNullOrUndefinedEv +__ZNK2v85Value13QuickIsStringEv +__ZN2v811HandleScope16DeleteExtensionsEPNS_7IsolateE +__ZN2v811HandleScope6ExtendEPNS_7IsolateE __ZN2v811HandleScopeC1EPNS_7IsolateE __ZN2v811HandleScopeD1Ev __ZN2v811HandleScopeD2Ev @@ -24,6 +32,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 +51,7 @@ __ZN2v87Isolate10GetCurrentEv __ZN2v87Isolate13TryGetCurrentEv __ZN2v87Isolate17GetCurrentContextEv __ZN2v88External3NewEPNS_7IsolateEPv +__ZN2v88External3NewEPNS_7IsolateEPvt __ZN2v88Function7SetNameENS_5LocalINS_6StringEEE __ZN2v88internal35IsolateFromNeverReadOnlySpaceObjectEm __ZN3JSC9CallFrame13describeFrameEv @@ -69,13 +80,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/cli/install/migration/complex-workspace.test.ts b/test/cli/install/migration/complex-workspace.test.ts index ce411c328d1..2c8eb4f5f8d 100644 --- a/test/cli/install/migration/complex-workspace.test.ts +++ b/test/cli/install/migration/complex-workspace.test.ts @@ -52,7 +52,13 @@ test("the install succeeds", async () => { throw new Error("Failed to install"); } - subprocess = Bun.spawn([bunExe(), "install"], { + // On Windows CI, sharp's install script falls back to a node-gyp source + // build (no win32-arm64 prebuilt), which the system clang-cl-built Node 26 + // breaks (its process.config leaks thin-LTO flags that MSVC's link.exe + // rejects). This test exercises lockfile migration, not lifecycle scripts, + // so skip them there. + const installArgs = process.platform === "win32" ? [bunExe(), "install", "--ignore-scripts"] : [bunExe(), "install"]; + subprocess = Bun.spawn(installArgs, { env: bunEnv, cwd, stdio: ["inherit", "inherit", "inherit"], diff --git a/test/harness.ts b/test/harness.ts index ba13c607199..5f1538db5de 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; @@ -126,6 +126,150 @@ export function nodeExe(): string | null { return which("node") || null; } +let abiMatchingNode: Promise | undefined; + +/** + * Path to a Node.js executable whose native-addon ABI (NODE_MODULE_VERSION, + * `process.versions.modules`) matches the Node version Bun reports. Addons + * that node-gyp compiles against Bun's reported headers can only load in such + * a Node. When the system Node's ABI differs (e.g. a machine whose installed + * Node lags the version Bun reports), the matching official build is + * downloaded once into a per-version directory under the OS temp dir and + * reused. + */ +export function nodeExeMatchingAbi(): Promise { + return (abiMatchingNode ??= findOrDownloadAbiMatchingNode()); +} + +async function findOrDownloadAbiMatchingNode(): Promise { + const system = nodeExe(); + if (system) { + const probe = Bun.spawnSync({ + cmd: [system, "-p", "process.versions.modules"], + env: bunEnv, + stdout: "pipe", + stderr: "ignore", + }); + if (probe.exitCode === 0 && probe.stdout.toString().trim() === process.versions.modules) { + return system; + } + } + + const version = process.versions.node; + const name = `node-v${version}-${isWindows ? "win" : process.platform}-${process.arch}`; + // Cache under the home directory: the machines that need the download (the + // persistent macOS fleet) then pay for it once ever, not once per boot. + const baseDir = join(os.homedir() || os.tmpdir(), ".cache", "bun-test-node"); + const dir = join(baseDir, name); + const exe = isWindows ? join(dir, "node.exe") : join(dir, "bin", "node"); + if (fs.existsSync(exe)) { + return exe; + } + + const archiveExt = isWindows ? "zip" : "tar.gz"; + const url = `https://nodejs.org/dist/v${version}/${name}.${archiveExt}`; + console.warn(`System node does not match ABI ${process.versions.modules}, downloading ${url}`); + // Download and extract under unique names, then atomically rename into + // place so concurrent test files (or a previous interrupted run) can't + // observe a half-extracted directory. + const stagingDir = join(baseDir, `staging-${process.pid}-${Date.now()}`); + fs.mkdirSync(stagingDir, { recursive: true }); + try { + const archive = join(stagingDir, `${name}.${archiveExt}`); + // Download with curl (ships on every CI platform, including Windows + // System32) rather than streaming through the runtime under test, and + // bound it so a stalled transfer fails instead of eating the hook + // timeout of whichever test file got here first. + const curl = (...args: string[]) => + Bun.spawnSync({ + cmd: ["curl", "-fsSL", "--retry", "3", "--max-time", "180", ...args], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const download = curl("-o", archive, url); + if (download.exitCode !== 0) { + throw new Error(`Failed to download ${url}: ${download.stderr.toString()}`); + } + // Verify against the official checksum manifest before executing anything + // from the archive. + const shasumsUrl = `https://nodejs.org/dist/v${version}/SHASUMS256.txt`; + const shasumsResult = curl(shasumsUrl); + if (shasumsResult.exitCode !== 0) { + throw new Error(`Failed to download ${shasumsUrl}: ${shasumsResult.stderr.toString()}`); + } + const expectedHash = shasumsResult.stdout + .toString() + .split("\n") + .find(line => line.endsWith(` ${name}.${archiveExt}`)) + ?.split(" ")[0]; + if (!expectedHash) { + throw new Error(`No checksum for ${name}.${archiveExt} in ${shasumsUrl}`); + } + const actualHash = new Bun.CryptoHasher("sha256").update(await Bun.file(archive).arrayBuffer()).digest("hex"); + if (actualHash !== expectedHash) { + throw new Error(`SHA-256 mismatch for ${url}: expected ${expectedHash}, got ${actualHash}`); + } + // bsdtar (shipped with Windows 10+) extracts zip archives too. + const tar = Bun.spawnSync({ + cmd: ["tar", "-xf", archive, "-C", stagingDir], + env: bunEnv, + stdout: "ignore", + stderr: "pipe", + }); + if (tar.exitCode !== 0) { + throw new Error(`Failed to extract ${archive}: ${tar.stderr.toString()}`); + } + try { + fs.renameSync(join(stagingDir, name), dir); + } catch (error) { + // A concurrent download may have won the rename; that copy is as good. + if (!fs.existsSync(exe)) throw error; + } + } finally { + fs.rmSync(stagingDir, { recursive: true, force: true }); + } + return exe; +} + +let canBuildNodeAddonsCached: boolean | undefined; + +/** + * Whether the system C++ toolchain can compile native addons against the + * Node headers Bun reports. Node >= 26 headers unconditionally include + * C++20's ``, which older Apple Xcode/CLT libc++ versions + * do not ship — real Node 26 has the same minimum-toolchain requirement, so + * addon-building tests should skip (not fail) on such machines. + */ +export function canBuildNodeAddons(): boolean { + if (canBuildNodeAddonsCached === undefined) { + if (!isMacOS) { + // Linux and Windows CI toolchains are provisioned by the bootstrap + // scripts in lockstep with the reported Node version; only macOS test + // boxes have independently-managed Xcode installs. + canBuildNodeAddonsCached = true; + } else { + const dir = fs.mkdtempSync(join(os.tmpdir(), "bun-addon-toolchain-probe-")); + try { + const probeFile = join(dir, "probe.cpp"); + fs.writeFileSync(probeFile, "#include \nint main() { return 0; }\n"); + const probe = Bun.spawnSync({ + cmd: ["c++", "-std=gnu++20", "-fsyntax-only", probeFile], + env: bunEnv, + stdout: "ignore", + stderr: "ignore", + }); + canBuildNodeAddonsCached = probe.exitCode === 0; + } catch { + canBuildNodeAddonsCached = false; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + } + return canBuildNodeAddonsCached; +} + export function shellExe(): string { return isWindows ? "pwsh" : "bash"; } @@ -1997,3 +2141,35 @@ 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") || + Bun.which("google-chrome-stable") || + Bun.which("google-chrome") + ); + const skipBrowserDownload = + hasSystemChromium || + (process.platform === "linux" && process.arch === "arm64") || + (process.platform === "win32" && (!!process.env.CI || !!process.env.BUILDKITE)); + if (skipBrowserDownload) { + return { PUPPETEER_SKIP_DOWNLOAD: "1" }; + } + // No system browser: download into a fresh per-run cache instead of the + // shared agent-global one — a half-extracted download left there by an + // earlier failed run otherwise blocks every later install. Pass the same + // env to whatever later launches puppeteer so it finds the browser. + return { PUPPETEER_CACHE_DIR: tmpdirSync("puppeteer-cache") }; +} diff --git a/test/integration/next-pages/test/dev-server-puppeteer.ts b/test/integration/next-pages/test/dev-server-puppeteer.ts index 9d4c0feaa8f..6be1242b16f 100644 --- a/test/integration/next-pages/test/dev-server-puppeteer.ts +++ b/test/integration/next-pages/test/dev-server-puppeteer.ts @@ -13,7 +13,13 @@ if (process.argv.length > 2) { url = process.argv[2]; } -const browserPath = which("chromium-browser") || which("chromium") || which("chrome") || undefined; +const browserPath = + which("chromium-browser") || + which("chromium") || + which("chrome") || + which("google-chrome-stable") || + which("google-chrome") || + undefined; if (!browserPath) { console.warn("Since a Chromium browser was not found, it will be downloaded by Puppeteer."); } @@ -24,12 +30,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-ssr-100.test.ts b/test/integration/next-pages/test/dev-server-ssr-100.test.ts index 2dbef083eb4..0e24bc99432 100644 --- a/test/integration/next-pages/test/dev-server-ssr-100.test.ts +++ b/test/integration/next-pages/test/dev-server-ssr-100.test.ts @@ -6,13 +6,15 @@ 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(); +const puppeteerInstallEnv = getPuppeteerInstallEnv(); + beforeAll(async () => { await rm(root, { recursive: true, force: true }); await cp(join(import.meta.dir, "../"), root, { recursive: true, force: true }); @@ -93,7 +95,7 @@ async function startDevServer() { const install = Bun.spawnSync([bunExe(), "i"], { cwd: root, - env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(root, "bunstall") }, + env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(root, "bunstall"), ...puppeteerInstallEnv }, stdout: "inherit", stderr: "inherit", stdin: "inherit", diff --git a/test/integration/next-pages/test/dev-server.test.ts b/test/integration/next-pages/test/dev-server.test.ts index b5948474463..f5de4dae037 100644 --- a/test/integration/next-pages/test/dev-server.test.ts +++ b/test/integration/next-pages/test/dev-server.test.ts @@ -5,13 +5,23 @@ 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(); +const puppeteerInstallEnv = getPuppeteerInstallEnv(); + beforeAll(async () => { await rm(root, { recursive: true, force: true }); await cp(join(import.meta.dir, "../"), root, { recursive: true, force: true }); @@ -92,7 +102,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", @@ -150,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/integration/next-pages/test/next-build.test.ts b/test/integration/next-pages/test/next-build.test.ts index 6f3bbb3ab32..5245f80720e 100644 --- a/test/integration/next-pages/test/next-build.test.ts +++ b/test/integration/next-pages/test/next-build.test.ts @@ -3,13 +3,15 @@ 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, "../"); +const puppeteerInstallEnv = getPuppeteerInstallEnv(); + async function tempDirToBuildIn() { const dir = tmpdirSync( "next-" + Math.ceil(performance.now() * 1000).toString(36) + Math.random().toString(36).substring(2, 8), @@ -32,7 +34,7 @@ async function tempDirToBuildIn() { const install = Bun.spawnSync([bunExe(), "i"], { cwd: dir, - env: bunEnv, + env: { ...bunEnv, ...puppeteerInstallEnv }, stdin: "inherit", stdout: "inherit", stderr: "inherit", diff --git a/test/js/bun/crypto/cipheriv-decipheriv.test.ts b/test/js/bun/crypto/cipheriv-decipheriv.test.ts index fd9cb7f2733..af8211d0234 100644 --- a/test/js/bun/crypto/cipheriv-decipheriv.test.ts +++ b/test/js/bun/crypto/cipheriv-decipheriv.test.ts @@ -65,13 +65,21 @@ it("should encrypt & decrypt using streaming interface", () => { const key = randomBytes(32); const iv = randomBytes(16); + // Since Node 26, read() with no size returns one buffered chunk at a time, + // so drain the stream instead of assuming a single read returns everything. + const readAll = stream => { + const chunks = []; + for (let chunk; (chunk = stream.read()) !== null; ) chunks.push(chunk); + return Buffer.concat(chunks); + }; + const cipher = createCipheriv("aes-256-cbc", key, iv); cipher.end(plaintext); - let ciph = cipher.read(); + let ciph = readAll(cipher); const decipher = createDecipheriv("aes-256-cbc", key, iv); decipher.end(ciph); - let txt = decipher.read().toString("utf8"); + let txt = readAll(decipher).toString("utf8"); expect(txt).toBe(plaintext); }); diff --git a/test/js/node/crypto/crypto.test.ts b/test/js/node/crypto/crypto.test.ts index 16336cdcdaa..16b4be3a91a 100644 --- a/test/js/node/crypto/crypto.test.ts +++ b/test/js/node/crypto/crypto.test.ts @@ -258,16 +258,24 @@ it("should send cipher events in the right order", async () => { const key = Buffer.from("3fad401bb178066f201b55368712530229d6329a5e2c05f48ff36ca65792d21d", "hex"); const iv = Buffer.from("22371787d3e04a6589d8a1de50c81208", "hex"); + // Since Node 26, read() with no size returns one buffered chunk at a time, + // so drain the stream instead of assuming a single read returns everything. + function readAll(stream) { + const chunks = []; + for (let chunk; (chunk = stream.read()) !== null; ) chunks.push(chunk); + return Buffer.concat(chunks); + } + const cipher = crypto.createCipheriv("aes-256-cbc", key, iv); patchEmitter(cipher, "cipher"); cipher.end(plaintext); - let ciph = cipher.read(); + let ciph = readAll(cipher); console.log([1, ciph.toString("hex")]); const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv); patchEmitter(decipher, "decipher"); decipher.end(ciph); - let dciph = decipher.read(); + let dciph = readAll(decipher); console.log([2, dciph.toString("hex")]); let txt = dciph.toString("utf8"); @@ -286,12 +294,13 @@ it("should send cipher events in the right order", async () => { const err = await stderr.text(); expect(err).toBeEmpty(); const out = await stdout.text(); - // TODO: prefinish and readable (on both cipher and decipher) should be flipped - // This seems like a bug in our crypto code, which + // Matches Node 26 output for the same fixture (verified byte-for-byte + // modulo quote style). expect(out.split("\n")).toEqual([ `[ "cipher", "readable" ]`, `[ "cipher", "prefinish" ]`, `[ "cipher", "data" ]`, + `[ "cipher", "data" ]`, `[ 1, "dfb6b7e029be3ad6b090349ed75931f28f991b52ca9a89f5bf6f82fa1c87aa2d624bd77701dcddfcceaf3add7d66ce06ced17aebca4cb35feffc4b8b9008b3c4" ]`, `[ "decipher", "readable" ]`, `[ "decipher", "prefinish" ]`, diff --git a/test/js/node/http/node-http-parser.test.ts b/test/js/node/http/node-http-parser.test.ts index dd8adfe8f91..e8a4687cc26 100644 --- a/test/js/node/http/node-http-parser.test.ts +++ b/test/js/node/http/node-http-parser.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; const { HTTPParser, ConnectionsList } = process.binding("http_parser"); +const { parsers } = require("node:_http_common"); const kOnHeaders = HTTPParser.kOnHeaders; const kOnHeadersComplete = HTTPParser.kOnHeadersComplete; @@ -248,3 +249,29 @@ 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 parser = parsers.alloc(); + try { + const onHeaders = parser[kOnHeaders]; + parser._headers = ["x", "1"]; + parser._url = ""; + parser.maxHeaderPairs = 4; + + onHeaders.call(parser, ["a", "2", "b", "3"], ""); + expect(parser._headers).toEqual(["x", "1", "a", "2"]); + + // At capacity: nothing more is collected. + onHeaders.call(parser, ["c", "4"], ""); + expect(parser._headers).toEqual(["x", "1", "a", "2"]); + + // maxHeaderPairs <= 0 means no limit. + parser.maxHeaderPairs = 0; + onHeaders.call(parser, ["c", "4"], ""); + expect(parser._headers).toEqual(["x", "1", "a", "2", "c", "4"]); + } finally { + parser.close(); + } + }); +}); diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 6c3b2a1d25a..d2963164d58 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -2252,3 +2252,209 @@ 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"); + expect(msg.getRawHeaderNames()).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"); + + // 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"]); + // 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)", () => { + 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); + + // Like Node, the prototype has no outputData property at all; reading it off + // the prototype must not materialize shared state on the prototype. + expect(Object.getOwnPropertyDescriptor(OutgoingMessage.prototype, "outputData")).toBeUndefined(); + void (OutgoingMessage.prototype as any).outputData; + 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/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 84b0ee3cd44..3281f44dfed 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -747,6 +747,30 @@ for (const nodeExecutable of [nodeExe(), bunExe()]) { expect(req.aborted).toBeTrue(); // will be true in this case }); + it("signal validation matches node: non-signal objects throw, duck-typed { aborted } is accepted", async () => { + const client = http2.connect(HTTPS_SERVER, TLS_OPTIONS); + client.on("error", () => {}); + try { + // node's validateAbortSignal accepts any object with an 'aborted' + // property ('aborted' in signal), so a duck-typed { aborted: true } + // takes the pre-aborted fast path instead of throwing... + const { promise, resolve, reject } = Promise.withResolvers(); + const req = client.request({ ":path": "/" }, { signal: { aborted: true } }); + req.on("error", err => (err.name === "AbortError" ? resolve() : reject(err))); + await promise; + // ...while objects without 'aborted' (and non-objects) throw + // ERR_INVALID_ARG_TYPE synchronously, before the fast path. + expect(() => client.request({ ":path": "/" }, { signal: {} })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + expect(() => client.request({ ":path": "/" }, { signal: 42 })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + } finally { + client.close(); + } + }); + it("state should work", async () => { const { promise, resolve, reject } = Promise.withResolvers(); const client = http2.connect(HTTPS_SERVER, TLS_OPTIONS); @@ -1787,9 +1811,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(); } @@ -2711,3 +2739,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/process/dlopen-duplicate-load.test.ts b/test/js/node/process/dlopen-duplicate-load.test.ts index 5d9d5951d3b..0827e1aff18 100644 --- a/test/js/node/process/dlopen-duplicate-load.test.ts +++ b/test/js/node/process/dlopen-duplicate-load.test.ts @@ -1,13 +1,13 @@ import { spawnSync } from "bun"; import { beforeAll, describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDirWithFiles } from "harness"; +import { bunEnv, bunExe, canBuildNodeAddons, tempDirWithFiles } from "harness"; import { join } from "path"; // This test verifies that Bun can load the same native module multiple times // Previously, the second load would fail with "symbol 'napi_register_module_v1' not found" // because static constructors only run once, so the module registration wasn't replayed -describe("process.dlopen duplicate loads", () => { +describe.skipIf(!canBuildNodeAddons())("process.dlopen duplicate loads", () => { let addonPath: string; beforeAll(() => { @@ -60,7 +60,13 @@ NODE_MODULE_CONTEXT_AWARE(addon, demo::Initialize) version: "1.0.0", gypfile: true, scripts: { - install: "node-gyp rebuild", + // 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 a1772a008c4..06bc1313029 100644 --- a/test/js/node/process/dlopen-non-object-exports.test.ts +++ b/test/js/node/process/dlopen-non-object-exports.test.ts @@ -1,12 +1,12 @@ import { spawnSync } from "bun"; import { beforeAll, describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDirWithFiles } from "harness"; +import { bunEnv, bunExe, canBuildNodeAddons, tempDirWithFiles } from "harness"; import { join } from "path"; // This test verifies that Bun properly handles non-object exports when loading native modules // Previously, this would cause a segfault when exports was null, undefined, or a primitive -describe("process.dlopen with non-object exports", () => { +describe.skipIf(!canBuildNodeAddons())("process.dlopen with non-object exports", () => { let addonPath: string; beforeAll(() => { @@ -59,7 +59,13 @@ NODE_MODULE_CONTEXT_AWARE(addon, demo::Initialize) version: "1.0.0", gypfile: true, scripts: { - install: "node-gyp rebuild", + // 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/node/process/process.test.js b/test/js/node/process/process.test.js index f8389d0401a..0a1804213ea 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -426,7 +426,7 @@ describe.concurrent(() => { }); let [out, exited] = await Promise.all([new Response(subprocess.stdout).text(), subprocess.exited]); - expect(out.trim()).toEqual("v24.3.0"); + expect(out.trim()).toEqual("v26.3.0"); expect(exited).toBe(0); }); @@ -1175,10 +1175,10 @@ it.each(["stdin", "stdout", "stderr"])("%s stream accessor should handle excepti }); it("process.versions", () => { - expect(process.versions.node).toEqual("24.3.0"); - expect(process.versions.v8).toEqual("13.6.233.10-node.18"); + expect(process.versions.node).toEqual("26.3.0"); + expect(process.versions.v8).toEqual("14.6.202.34-node.20"); expect(process.versions.napi).toEqual("10"); - expect(process.versions.modules).toEqual("137"); + expect(process.versions.modules).toEqual("147"); }); // On Windows, env var names are case-insensitive. The proxy-related vars diff --git a/test/js/node/stream/node-stream-uint8array.test.ts b/test/js/node/stream/node-stream-uint8array.test.ts index 5072706bd94..981fb8cff9e 100644 --- a/test/js/node/stream/node-stream-uint8array.test.ts +++ b/test/js/node/stream/node-stream-uint8array.test.ts @@ -91,9 +91,11 @@ describe("Readable", () => { readable.push(DEF); readable.unshift(ABC); + // read() with no size returns one buffered chunk at a time. const buf = readable.read(); expect(buf instanceof Buffer).toBe(true); - expect([...buf]).toEqual([...ABC, ...DEF]); + expect([...buf]).toEqual([...ABC]); + expect([...readable.read()]).toEqual([...DEF]); }); it("should work with setEncoding()", () => { diff --git a/test/js/node/stream/node-stream.test.js b/test/js/node/stream/node-stream.test.js index 3312d054796..81248e31d89 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,264 @@ 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(); + }); + + // 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 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). + it("compose returns the composed Duplex directly", () => { + expect(Object.hasOwn(Readable.prototype, "compose")).toBe(true); + const composed = Readable.from(["a"]).compose( + new Transform({ + transform(chunk, encoding, callback) { + callback(null, chunk); + }, + }), + ); + expect(composed).toBeInstanceOf(Duplex); + }); + + it("compose rejects a non-writable destination with the streams[1] arg name", () => { + let err; + try { + Readable.from(["a"]).compose(new Readable({ read() {} })); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_INVALID_ARG_VALUE"); + expect(err?.message).toContain("streams[1]"); + }); + + it("compose validates the options argument", () => { + let err; + try { + Readable.from(["a"]).compose(new PassThrough(), 42); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_INVALID_ARG_TYPE"); + }); + + // Upstream: v26 test-stream-writable-decoded-encoding.js. + it("write(string, 'buffer') throws ERR_UNKNOWN_ENCODING", () => { + for (const opts of [{ decodeStrings: false }, {}]) { + const w = new Writable({ + ...opts, + write(chunk, encoding, callback) { + callback(); + }, + }); + let err; + try { + w.write("hi", "buffer"); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_UNKNOWN_ENCODING"); + + // Buffer chunks with 'buffer' encoding still work. + const w2 = new Writable({ + ...opts, + write(chunk, encoding, callback) { + callback(); + }, + }); + expect(w2.write(Buffer.from("x"), "buffer")).toBe(true); + } + }); +}); + +describe("fromList string chunk boundary (nodejs/node#61884)", () => { + it("read(n) with setEncoding does not over-read when n equals the buffered array length", () => { + const r = new Readable({ read() {} }); + r.setEncoding("utf8"); + r.push("a"); + r.push("bcd"); + // With the v24 bug (`n === buf.length` instead of `n === str.length`), + // read(3) returned "abcd". + expect(r.read(3)).toBe("abc"); + expect(r.read(1)).toBe("d"); + }); +}); + +describe("maybeReadMore is a no-op while a read is in flight (nodejs/node#60454)", () => { + it("does not schedule a redundant _read while kReading is set", async () => { + let reads = 0; + const r = new Readable({ + highWaterMark: 1024, + read() { + reads++; + }, + }); + + r.read(10); // _read #1 is now in flight (kReading set, no sync push) + expect(reads).toBe(1); + + // Old gate ((kReadingMore | kConstructed) === kConstructed) scheduled + // maybeReadMore_ HERE, while the read was still in flight. + r.unshift("x"); + + // Queued between the buggy (unshift-time) and fixed (push-time) schedule + // points: with the old gate maybeReadMore_ ran BEFORE this tick, saw the + // read completed and the stream not yet ended, and issued a redundant + // stream.read(0) -> _read #2. With the v26 gate the schedule happens at + // push("y") below, so this tick ends the stream first and no extra _read + // is issued. Verified against node v26.3.0 (reads === 1) and the old + // gate (reads === 2). + process.nextTick(() => r.push(null)); + + r.push("y"); // completes the in-flight read; v26 schedules maybeReadMore_ here + + // All process.nextTick callbacks (including maybeReadMore_) run before + // setImmediate fires, so this is a deterministic ordering, not a timeout. + await new Promise(resolve => setImmediate(resolve)); + expect(reads).toBe(1); + }); +}); + +describe("Duplex.from({ readable, writable }) destroy propagation (nodejs/node#62824)", () => { + it("destroys the writable side when the readable side errors", async () => { + const r = new Readable({ read() {} }); + const w = new Writable({ + write(chunk, enc, cb) { + cb(); + }, + }); + const d = Duplex.from({ readable: r, writable: w }); + + const writableError = Promise.withResolvers(); + const writableClose = Promise.withResolvers(); + const duplexError = Promise.withResolvers(); + w.on("error", writableError.resolve); + w.on("close", writableClose.resolve); + d.on("error", duplexError.resolve); + + const err = new Error("boom"); + r.destroy(err); + + expect(await writableError.promise).toBe(err); + await writableClose.promise; + expect(w.destroyed).toBe(true); + expect(await duplexError.promise).toBe(err); + }); +}); + +describe("pipeline real error overrides AbortError (nodejs/node#62113)", () => { + it("reports the real error when a destroy callback errors after abort", async () => { + const ac = new AbortController(); + const r = new Readable({ read() {} }); + const w = new Writable({ + write(chunk, enc, cb) { + cb(); + }, + destroy(err, cb) { + cb(new Error("realboom")); + }, + }); + const p = Stream.promises.pipeline(r, w, { signal: ac.signal }); + setImmediate(() => ac.abort()); + let caught; + await p.catch(e => { + caught = e; + }); + expect(caught.name).toBe("Error"); + expect(caught.message).toBe("realboom"); + }); +}); + +describe("stream operators argument validation (nodejs/node#59529)", () => { + it("map/filter throw synchronously with the validateFunction message", () => { + for (const method of ["map", "filter"]) { + const r = Readable.from([1]); + expect(() => r[method](123)).toThrow( + expect.objectContaining({ + code: "ERR_INVALID_ARG_TYPE", + message: 'The "fn" argument must be of type function. Received type number (123)', + }), + ); + r.destroy(); + } + }); + + it("forEach/every/reduce reject asynchronously with the validateFunction message", async () => { + for (const [method, name] of [ + ["forEach", "fn"], + ["every", "fn"], + ["reduce", "reducer"], + ]) { + const r = Readable.from([1]); + let caught; + await r[method](123).catch(e => { + caught = e; + }); + expect(caught.code).toBe("ERR_INVALID_ARG_TYPE"); + expect(caught.message).toBe(`The "${name}" argument must be of type function. Received type number (123)`); + r.destroy(); + } + }); +}); diff --git a/test/js/node/test/parallel/test-crypto-cipheriv-decipheriv.js b/test/js/node/test/parallel/test-crypto-cipheriv-decipheriv.js index 9a6440b63ca..2e0a72fbce8 100644 --- a/test/js/node/test/parallel/test-crypto-cipheriv-decipheriv.js +++ b/test/js/node/test/parallel/test-crypto-cipheriv-decipheriv.js @@ -31,11 +31,11 @@ function testCipher1(key, iv) { // quite small, so there's no harm. const cStream = crypto.createCipheriv('des-ede3-cbc', key, iv); cStream.end(plaintext); - ciph = cStream.read(); + ciph = cStream.read(cStream.readableLength); const dStream = crypto.createDecipheriv('des-ede3-cbc', key, iv); dStream.end(ciph); - txt = dStream.read().toString('utf8'); + txt = dStream.read(dStream.readableLength).toString('utf8'); assert.strictEqual(txt, plaintext, `streaming cipher with key ${key} and iv ${iv}`); diff --git a/test/js/node/test/parallel/test-http2-compat-serverrequest-pipe.js b/test/js/node/test/parallel/test-http2-compat-serverrequest-pipe.js index 64beb6472b9..47d53850784 100644 --- a/test/js/node/test/parallel/test-http2-compat-serverrequest-pipe.js +++ b/test/js/node/test/parallel/test-http2-compat-serverrequest-pipe.js @@ -9,41 +9,140 @@ const http2 = require('http2'); const fs = require('fs'); // Piping should work as expected with createWriteStream +// DEBUG BRANCH: instrumented with an event trail + stall watchdog to diagnose +// a darwin-aarch64 suite-context timeout. Logic is unchanged from upstream. + +const t0 = Date.now(); +const events = []; +const ev = m => events.push(`+${Date.now() - t0}ms ${m}`); +const state = {}; + +const watchdog = setTimeout(() => { + console.error('WATCHDOG: stalled after 15s, event trail:'); + for (const e of events) console.error(` ${e}`); + const dump = (name, s) => { + if (!s) return console.error(` ${name}: `); + const pick = {}; + for (const k of [ + 'readable', 'readableEnded', 'readableFlowing', 'readableLength', + 'writable', 'writableEnded', 'writableFinished', 'writableLength', 'writableNeedDrain', + 'destroyed', 'closed', 'complete', 'aborted', 'rstCode', 'pending', 'bytesWritten', + ]) { + try { + const v = s[k]; + if (v !== undefined) pick[k] = v; + } catch {} + } + console.error(` ${name}: ${JSON.stringify(pick)}`); + }; + try { console.error(` serverSock.isPaused(): ${state.serverSock?.isPaused?.()}`); } catch {} + dump('serverSock', state.serverSock); + dump('serverSession', state.serverSession); + try { console.error(` server._connections: ${server._connections}`); } catch {} + dump('serverReq', state.serverReq); + dump('serverRes', state.serverRes); + dump('serverDest', state.serverDest); + dump('clientReq', state.clientReq); + dump('clientStr', state.clientStr); + dump('clientSock', state.clientSock); + dump('clientSession', state.clientSession); + try { + console.error(` clientSession.state: ${JSON.stringify(state.clientSession?.state)}`); + } catch {} + process.exit(7); +}, 15000); +watchdog.unref(); const tmpdir = require('../common/tmpdir'); tmpdir.refresh(); const loc = fixtures.path('person-large.jpg'); const fn = tmpdir.resolve('http2-url-tests.js'); +// Plain-net same-tick write+end EOF delivery probe (mirrors the h2 client's +// GOAWAY+GOAWAY+FIN burst at the socket level). +const net = require('net'); +{ + const ns = net.createServer(sock => { + sock.on('data', d => ev(`net-probe: server data ${d.length}`)); + sock.on('end', () => ev('net-probe: server end')); + sock.on('close', () => { ev('net-probe: server close'); ns.close(); }); + }); + ns.listen(0, () => { + const c = net.connect(ns.address().port, '127.0.0.1', () => { + c.write(Buffer.alloc(17)); + c.write(Buffer.alloc(17)); + c.end(); + }); + c.on('close', () => ev('net-probe: client close')); + }); +} + const server = http2.createServer(); +server.on('connection', sock => { + state.serverSock = sock; + ev('server: connection'); + sock.on('end', () => ev('server: socket end')); + sock.on('close', () => ev('server: socket close')); + sock.on('error', e => ev('server: socket error ' + e)); +}); +server.on('session', session => { + state.serverSession = session; + ev('server: session'); + session.on('close', () => ev('server: session close')); + session.on('goaway', (c, l) => ev('server: session goaway code=' + c + ' last=' + l)); + session.on('error', e => ev('server: session error ' + e)); +}); + server.on('request', common.mustCall((req, res) => { - const dest = req.pipe(fs.createWriteStream(fn)); + state.serverReq = req; + state.serverRes = res; + ev('server: request'); + req.on('end', () => ev('server: req end')); + req.on('error', e => ev(`server: req error ${e}`)); + const dest = (state.serverDest = req.pipe(fs.createWriteStream(fn))); + dest.on('error', e => ev(`server: dest error ${e}`)); dest.on('finish', common.mustCall(() => { + ev('server: dest finish'); assert.strictEqual(req.complete, true); assert.strictEqual(fs.readFileSync(loc).length, fs.readFileSync(fn).length); fs.unlinkSync(fn); res.end(); + ev('server: res.end()'); })); })); server.listen(0, common.mustCall(() => { const port = server.address().port; - const client = http2.connect(`http://localhost:${port}`); + const client = (state.clientSession = http2.connect(`http://localhost:${port}`)); + client.on('connect', () => { + const cs = (state.clientSock = client.socket); + ev('client: connected'); + cs.on('end', () => ev('client: socket end')); + cs.on('close', () => ev('client: socket close')); + cs.on('finish', () => ev('client: socket finish')); + }); + client.on('error', e => ev(`client: session error ${e}`)); + client.on('goaway', (code, last) => ev(`client: goaway code=${code} last=${last}`)); + client.on('close', () => ev('client: session close')); let remaining = 2; function maybeClose() { + ev(`maybeClose remaining=${remaining - 1}`); if (--remaining === 0) { - server.close(); - client.close(); + server.close(() => ev('server.close() completed')); + client.close(() => ev('client.close() completed')); } } - const req = client.request({ ':method': 'POST' }); - req.on('response', common.mustCall()); + const req = (state.clientReq = client.request({ ':method': 'POST' })); + req.on('response', common.mustCall(() => ev('client: response'))); req.resume(); - req.on('end', common.mustCall(maybeClose)); - const str = fs.createReadStream(loc); - str.on('end', common.mustCall(maybeClose)); + req.on('end', common.mustCall(() => { ev('client: req end'); maybeClose(); })); + req.on('error', e => ev(`client: req error ${e}`)); + const str = (state.clientStr = fs.createReadStream(loc)); + str.on('end', common.mustCall(() => { ev('client: str end'); maybeClose(); })); + str.on('error', e => ev(`client: str error ${e}`)); str.pipe(req); + ev('client: pipe started'); })); diff --git a/test/js/node/test/parallel/test-http2-debug-pipe.js b/test/js/node/test/parallel/test-http2-debug-pipe.js new file mode 100644 index 00000000000..978288edf6d --- /dev/null +++ b/test/js/node/test/parallel/test-http2-debug-pipe.js @@ -0,0 +1,130 @@ +'use strict'; + +// Instrumented, looped copy of test-http2-compat-serverrequest-pipe.js to +// diagnose a darwin-aarch64-only timeout that reproduces under full-suite +// load but not in single runs. Repeats the scenario until a soft deadline; +// any iteration stalling >4s dumps state and exits 7. + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const fixtures = require('../common/fixtures'); +const assert = require('assert'); +const http2 = require('http2'); +const fs = require('fs'); + +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); +const loc = fixtures.path('person-large.jpg'); +const SOFT_DEADLINE_MS = 14_000; +const STALL_MS = 4_000; +const start = Date.now(); +let iteration = 0; + +function log(m) { + console.error(`[iter ${iteration} +${Date.now() - start}ms] ${m}`); +} + +function dump(name, s) { + if (!s) return log(` ${name}: `); + const pick = {}; + for (const k of [ + 'readable', 'readableEnded', 'readableFlowing', 'readableLength', + 'writable', 'writableEnded', 'writableFinished', 'writableLength', 'writableNeedDrain', + 'destroyed', 'closed', 'complete', 'aborted', 'rstCode', 'pending', 'bytesWritten', + ]) { + try { + const v = s[k]; + if (v !== undefined) pick[k] = v; + } catch {} + } + log(` ${name}: ${JSON.stringify(pick)}`); +} + +function runOnce() { + iteration++; + const fn = tmpdir.resolve(`http2-pipe-${iteration}.bin`); + const events = []; + const ev = m => events.push(`+${Date.now() - start}ms ${m}`); + + const state = {}; + const server = http2.createServer(); + + server.on('request', (req, res) => { + state.serverReq = req; + state.serverRes = res; + ev('server: request'); + req.on('end', () => ev('server: req end')); + req.on('error', e => ev(`server: req error ${e}`)); + const dest = (state.serverDest = req.pipe(fs.createWriteStream(fn))); + dest.on('error', e => ev(`server: dest error ${e}`)); + dest.on('finish', () => { + ev('server: dest finish'); + assert.strictEqual(req.complete, true); + assert.strictEqual(fs.readFileSync(loc).length, fs.readFileSync(fn).length); + fs.unlinkSync(fn); + res.end(); + ev('server: res.end()'); + }); + }); + + return new Promise(resolve => { + const stall = setTimeout(() => { + log('STALL detected, event trail:'); + for (const e of events) log(` ${e}`); + dump('serverReq', state.serverReq); + dump('serverRes', state.serverRes); + dump('serverDest', state.serverDest); + dump('clientReq', state.clientReq); + dump('clientStr', state.clientStr); + dump('clientSession', state.clientSession); + try { + log(` clientSession.state: ${JSON.stringify(state.clientSession?.state)}`); + } catch {} + process.exit(7); + }, STALL_MS); + stall.unref(); + + server.listen(0, () => { + const port = server.address().port; + const client = (state.clientSession = http2.connect(`http://localhost:${port}`)); + client.on('error', e => ev(`client: session error ${e}`)); + client.on('goaway', (code, last) => ev(`client: goaway code=${code} last=${last}`)); + client.on('close', () => ev('client: session close')); + + let remaining = 2; + let closesPending = 2; + function closed() { + if (--closesPending === 0) { + clearTimeout(stall); + resolve(); + } + } + function maybeClose() { + ev(`maybeClose remaining=${remaining - 1}`); + if (--remaining === 0) { + server.close(() => { ev('server.close() completed'); closed(); }); + client.close(() => { ev('client.close() completed'); closed(); }); + } + } + + const req = (state.clientReq = client.request({ ':method': 'POST' })); + req.on('response', () => ev('client: response')); + req.resume(); + req.on('end', () => { ev('client: req end'); maybeClose(); }); + req.on('error', e => ev(`client: req error ${e}`)); + const str = (state.clientStr = fs.createReadStream(loc)); + str.on('end', () => { ev('client: str end'); maybeClose(); }); + str.on('error', e => ev(`client: str error ${e}`)); + str.pipe(req); + ev('client: pipe started'); + }); + }); +} + +(async () => { + while (Date.now() - start < SOFT_DEADLINE_MS) { + await Promise.all([runOnce(), runOnce(), runOnce()]); + } + console.error(`completed ${iteration} iterations without a stall`); +})(); diff --git a/test/js/node/test/parallel/test-http2-getpackedsettings.js b/test/js/node/test/parallel/test-http2-getpackedsettings.js index 77a8640587c..872e66b0ede 100644 --- a/test/js/node/test/parallel/test-http2-getpackedsettings.js +++ b/test/js/node/test/parallel/test-http2-getpackedsettings.js @@ -20,7 +20,7 @@ assert.deepStrictEqual(val, check); ['headerTableSize', 0], ['headerTableSize', 2 ** 32 - 1], ['initialWindowSize', 0], - ['initialWindowSize', 2 ** 32 - 1], + ['initialWindowSize', 2 ** 31 - 1], ['maxFrameSize', 16384], ['maxFrameSize', 2 ** 24 - 1], ['maxConcurrentStreams', 0], @@ -42,7 +42,7 @@ http2.getPackedSettings({ enablePush: false }); ['headerTableSize', -1], ['headerTableSize', 2 ** 32], ['initialWindowSize', -1], - ['initialWindowSize', 2 ** 32], + ['initialWindowSize', 2 ** 31], ['maxFrameSize', 16383], ['maxFrameSize', 2 ** 24], ['maxConcurrentStreams', -1], diff --git a/test/js/node/test/parallel/test-stream-compose.js b/test/js/node/test/parallel/test-stream-compose.js index d7a54e17766..a4517a294b0 100644 --- a/test/js/node/test/parallel/test-stream-compose.js +++ b/test/js/node/test/parallel/test-stream-compose.js @@ -490,7 +490,8 @@ const assert = require('assert'); newStream.end(); - assert.deepStrictEqual(await newStream.toArray(), [Buffer.from('Steve RogersOn your left')]); + assert.deepStrictEqual(await newStream.toArray(), + [Buffer.from('Steve Rogers'), Buffer.from('On your left')]); })().then(common.mustCall()); } diff --git a/test/js/node/test/parallel/test-stream-push-strings.js b/test/js/node/test/parallel/test-stream-push-strings.js index d582c8add00..5fece74a115 100644 --- a/test/js/node/test/parallel/test-stream-push-strings.js +++ b/test/js/node/test/parallel/test-stream-push-strings.js @@ -59,7 +59,7 @@ ms.on('readable', function() { results.push(String(chunk)); }); -const expect = [ 'first chunksecond to last chunk', 'last chunk' ]; +const expect = [ 'first chunk', 'second to last chunk', 'last chunk' ]; process.on('exit', function() { assert.strictEqual(ms._chunks, -1); assert.deepStrictEqual(results, expect); diff --git a/test/js/node/test/parallel/test-stream-readable-emittedReadable.js b/test/js/node/test/parallel/test-stream-readable-emittedReadable.js index ba613f9e9ff..ffaf1d5b943 100644 --- a/test/js/node/test/parallel/test-stream-readable-emittedReadable.js +++ b/test/js/node/test/parallel/test-stream-readable-emittedReadable.js @@ -10,7 +10,7 @@ const readable = new Readable({ // Initialized to false. assert.strictEqual(readable._readableState.emittedReadable, false); -const expected = [Buffer.from('foobar'), Buffer.from('quo'), null]; +const expected = [Buffer.from('foo'), Buffer.from('bar'), Buffer.from('quo'), null]; readable.on('readable', common.mustCall(() => { // emittedReadable should be true when the readable event is emitted assert.strictEqual(readable._readableState.emittedReadable, true); diff --git a/test/js/node/test/parallel/test-stream-readable-infinite-read.js b/test/js/node/test/parallel/test-stream-readable-infinite-read.js index df88d78b74c..9d39b1fc6cd 100644 --- a/test/js/node/test/parallel/test-stream-readable-infinite-read.js +++ b/test/js/node/test/parallel/test-stream-readable-infinite-read.js @@ -10,7 +10,7 @@ const readable = new Readable({ highWaterMark: 16 * 1024, read: common.mustCall(function() { this.push(buf); - }, 31) + }, 12) }); let i = 0; @@ -18,16 +18,11 @@ let i = 0; readable.on('readable', common.mustCall(function() { if (i++ === 10) { // We will just terminate now. - process.removeAllListeners('readable'); + readable.removeAllListeners('readable'); return; } const data = readable.read(); - // TODO(mcollina): there is something odd in the highWaterMark logic - // investigate. - if (i === 1) { - assert.strictEqual(data.length, 8192 * 2); - } else { - assert.strictEqual(data.length, 8192 * 3); - } + // read() with no size returns a single buffered chunk at a time. + assert.strictEqual(data.length, 8192); }, 11)); diff --git a/test/js/node/test/parallel/test-stream-readable-needReadable.js b/test/js/node/test/parallel/test-stream-readable-needReadable.js index c4bc90bb19d..3f26db791c7 100644 --- a/test/js/node/test/parallel/test-stream-readable-needReadable.js +++ b/test/js/node/test/parallel/test-stream-readable-needReadable.js @@ -32,7 +32,7 @@ const asyncReadable = new Readable({ }); asyncReadable.on('readable', common.mustCall(() => { - if (asyncReadable.read() !== null) { + if (asyncReadable.read(asyncReadable.readableLength) !== null) { // After each read(), the buffer is empty. // If the stream doesn't end now, // then we need to notify the reader on future changes. diff --git a/test/js/node/test/parallel/test-stream-readable-to-web-byob.js b/test/js/node/test/parallel/test-stream-readable-to-web-byob.js new file mode 100644 index 00000000000..8e5f10efee1 --- /dev/null +++ b/test/js/node/test/parallel/test-stream-readable-to-web-byob.js @@ -0,0 +1,49 @@ +'use strict'; +require('../common'); +const { Readable } = require('stream'); +const assert = require('assert'); +const common = require('../common'); + +let count = 0; + +const nodeStream = new Readable({ + read(size) { + if (this.destroyed) { + return; + } + // Simulate a stream that pushes sequences of 16 bytes + const buffer = Buffer.alloc(size); + for (let i = 0; i < size; i++) { + buffer[i] = count++ % 16; + } + this.push(buffer); + } +}); + +// Test validation of 'type' option +assert.throws( + () => { + Readable.toWeb(nodeStream, { type: 'wrong type' }); + }, + { + code: 'ERR_INVALID_ARG_VALUE' + } +); + +// Test normal operation with ReadableByteStream +const webStream = Readable.toWeb(nodeStream, { type: 'bytes' }); +const reader = webStream.getReader({ mode: 'byob' }); +const expected = new Uint8Array(16); +for (let i = 0; i < 16; i++) { + expected[i] = count++; +} + +for (let i = 0; i < 1000; i++) { + // Read 16 bytes of data from the stream + const receive = new Uint8Array(16); + reader.read(receive).then(common.mustCall((result) => { + // Verify the data received + assert.ok(!result.done); + assert.deepStrictEqual(result.value, expected); + })); +} diff --git a/test/js/node/test/parallel/test-stream-readable-to-web-termination-byob.js b/test/js/node/test/parallel/test-stream-readable-to-web-termination-byob.js new file mode 100644 index 00000000000..8b1f8d1817c --- /dev/null +++ b/test/js/node/test/parallel/test-stream-readable-to-web-termination-byob.js @@ -0,0 +1,15 @@ +'use strict'; +require('../common'); +const { Readable } = require('stream'); +const assert = require('assert'); +const common = require('../common'); +{ + const r = Readable.from([]); + // Cancelling reader while closing should not cause uncaught exceptions + r.on('close', common.mustCall(() => reader.cancel())); + + const reader = Readable.toWeb(r, { type: 'bytes' }).getReader({ mode: 'byob' }); + reader.read(new Uint8Array(16)).then(common.mustCall((result) => { + assert.ok(result.done); + })); +} diff --git a/test/js/node/test/parallel/test-stream-readable-to-web-termination.js b/test/js/node/test/parallel/test-stream-readable-to-web-termination.js index 13fce9bc715..f30cf721e14 100644 --- a/test/js/node/test/parallel/test-stream-readable-to-web-termination.js +++ b/test/js/node/test/parallel/test-stream-readable-to-web-termination.js @@ -1,6 +1,8 @@ 'use strict'; -require('../common'); -const { Readable } = require('stream'); +const common = require('../common'); +const assert = require('assert'); +const { Duplex, Readable } = require('stream'); +const { setTimeout: delay } = require('timers/promises'); { const r = Readable.from([]); @@ -10,3 +12,33 @@ const { Readable } = require('stream'); const reader = Readable.toWeb(r).getReader(); reader.read(); } + +{ + const duplex = new Duplex({ + read() { + this.push(Buffer.from('x')); + this.push(null); + }, + write(_chunk, _encoding, callback) { + callback(); + }, + }); + + const reader = Readable.toWeb(duplex).getReader(); + + (async () => { + const result = await reader.read(); + assert.deepStrictEqual(result, { + value: new Uint8Array(Buffer.from('x')), + done: false, + }); + + const closeResult = await Promise.race([ + reader.read(), + delay(common.platformTimeout(100)).then(() => 'timeout'), + ]); + + assert.notStrictEqual(closeResult, 'timeout'); + assert.deepStrictEqual(closeResult, { value: undefined, done: true }); + })().then(common.mustCall()); +} diff --git a/test/js/node/test/parallel/test-stream-typedarray.js b/test/js/node/test/parallel/test-stream-typedarray.js index ae5846da09d..55d92aa31ed 100644 --- a/test/js/node/test/parallel/test-stream-typedarray.js +++ b/test/js/node/test/parallel/test-stream-typedarray.js @@ -83,9 +83,12 @@ const views = common.getArrayBufferViews(buffer); readable.push(views[2]); readable.unshift(views[0]); + // read() with no size returns one buffered chunk at a time. const buf = readable.read(); assert(buf instanceof Buffer); - assert.deepStrictEqual([...buf], [...views[0], ...views[1], ...views[2]]); + assert.deepStrictEqual([...buf], [...views[0]]); + assert.deepStrictEqual([...readable.read()], [...views[1]]); + assert.deepStrictEqual([...readable.read()], [...views[2]]); } { diff --git a/test/js/node/test/parallel/test-stream-uint8array.js b/test/js/node/test/parallel/test-stream-uint8array.js index f1de4c873fd..5ffdbbbc54b 100644 --- a/test/js/node/test/parallel/test-stream-uint8array.js +++ b/test/js/node/test/parallel/test-stream-uint8array.js @@ -80,9 +80,11 @@ const GHI = new Uint8Array([0x47, 0x48, 0x49]); readable.push(DEF); readable.unshift(ABC); + // read() with no size returns one buffered chunk at a time. const buf = readable.read(); assert(buf instanceof Buffer); - assert.deepStrictEqual([...buf], [...ABC, ...DEF]); + assert.deepStrictEqual([...buf], [...ABC]); + assert.deepStrictEqual([...readable.read()], [...DEF]); } { diff --git a/test/js/node/test/parallel/test-stream2-transform.js b/test/js/node/test/parallel/test-stream2-transform.js index f222f1c03b4..a7d0f236d78 100644 --- a/test/js/node/test/parallel/test-stream2-transform.js +++ b/test/js/node/test/parallel/test-stream2-transform.js @@ -282,7 +282,10 @@ const { PassThrough, Transform } = require('stream'); pt.write(Buffer.from('ef'), common.mustCall(function() { pt.end(); })); - assert.strictEqual(pt.read().toString(), 'abcdef'); + // read() with no size returns one buffered chunk at a time. + assert.strictEqual(pt.read().toString(), 'abc'); + assert.strictEqual(pt.read().toString(), 'd'); + assert.strictEqual(pt.read().toString(), 'ef'); assert.strictEqual(pt.read(), null); }); }); diff --git a/test/js/node/test/parallel/test-webstreams-adapters-writable-buffer-sources.js b/test/js/node/test/parallel/test-webstreams-adapters-writable-buffer-sources.js new file mode 100644 index 00000000000..995db97e747 --- /dev/null +++ b/test/js/node/test/parallel/test-webstreams-adapters-writable-buffer-sources.js @@ -0,0 +1,95 @@ +'use strict'; +const common = require('../common'); + +const assert = require('assert'); +const { Buffer } = require('buffer'); +const { Duplex, Writable } = require('stream'); +const { suite, test } = require('node:test'); + +const ctors = [ArrayBuffer, SharedArrayBuffer]; + +suite('underlying Writable', () => { + suite('in non-object mode', () => { + for (const ctor of ctors) { + test(`converts ${ctor.name} chunks`, async () => { + const buffer = new ctor(4); + const writable = new Writable({ + objectMode: false, + write: common.mustCall((chunk, encoding, callback) => { + assert(Buffer.isBuffer(chunk)); + assert.strictEqual(chunk.buffer, buffer); + callback(); + }), + }); + writable.on('error', common.mustNotCall()); + const writer = Writable.toWeb(writable).getWriter(); + await writer.write(buffer); + }); + } + }); + + suite('in object mode', () => { + for (const ctor of ctors) { + test(`passes through ${ctor.name} chunks`, async () => { + const buffer = new ctor(4); + const writable = new Writable({ + objectMode: true, + write: common.mustCall((chunk, encoding, callback) => { + assert(chunk instanceof ctor); + assert.strictEqual(chunk, buffer); + callback(); + }), + }); + writable.on('error', common.mustNotCall()); + const writer = Writable.toWeb(writable).getWriter(); + await writer.write(buffer); + }); + } + }); +}); + +suite('underlying Duplex', () => { + suite('in non-object mode', () => { + for (const ctor of ctors) { + test(`converts ${ctor.name} chunks`, async () => { + const buffer = new ctor(4); + const duplex = new Duplex({ + writableObjectMode: false, + write: common.mustCall((chunk, encoding, callback) => { + assert(Buffer.isBuffer(chunk)); + assert.strictEqual(chunk.buffer, buffer); + callback(); + }), + read() { + this.push(null); + }, + }); + duplex.on('error', common.mustNotCall()); + const writer = Duplex.toWeb(duplex).writable.getWriter(); + await writer.write(buffer); + }); + } + }); + + suite('in object mode', () => { + for (const ctor of ctors) { + test(`passes through ${ctor.name} chunks`, async () => { + const buffer = new ctor(4); + const duplex = new Duplex({ + writableObjectMode: true, + write: common.mustCall((chunk, encoding, callback) => { + assert(chunk instanceof ctor); + assert.strictEqual(chunk, buffer); + callback(); + }), + read() { + this.push(null); + }, + }); + duplex.on('error', common.mustNotCall()); + const writer = Duplex.toWeb(duplex).writable.getWriter(); + await writer.write(buffer); + }); + } + }); +}); diff --git a/test/js/node/test/parallel/test-webstreams-compression-bad-chunks.js b/test/js/node/test/parallel/test-webstreams-compression-bad-chunks.js new file mode 100644 index 00000000000..4a8ca3cff8a --- /dev/null +++ b/test/js/node/test/parallel/test-webstreams-compression-bad-chunks.js @@ -0,0 +1,75 @@ +'use strict'; +require('../common'); +const assert = require('assert'); +const test = require('node:test'); +const { CompressionStream, DecompressionStream } = require('stream/web'); + +// Verify that writing invalid (non-BufferSource) chunks to +// CompressionStream and DecompressionStream properly rejects +// on both the write and the read side, instead of hanging. + +const badChunks = [ + { name: 'undefined', value: undefined, code: 'ERR_INVALID_ARG_TYPE' }, + { name: 'null', value: null, code: 'ERR_STREAM_NULL_VALUES' }, + { name: 'number', value: 3.14, code: 'ERR_INVALID_ARG_TYPE' }, + { name: 'object', value: {}, code: 'ERR_INVALID_ARG_TYPE' }, + { name: 'array', value: [65], code: 'ERR_INVALID_ARG_TYPE' }, + { + name: 'SharedArrayBuffer', + value: new SharedArrayBuffer(1), + code: 'ERR_INVALID_ARG_TYPE', + }, + { + name: 'Uint8Array backed by SharedArrayBuffer', + value: new Uint8Array(new SharedArrayBuffer(1)), + code: 'ERR_INVALID_ARG_TYPE', + }, +]; + +for (const format of ['deflate', 'deflate-raw', 'gzip', 'brotli']) { + for (const { name, value, code } of badChunks) { + const expected = { name: 'TypeError', code }; + + test(`CompressionStream rejects bad chunk (${name}) for ${format}`, async () => { + const cs = new CompressionStream(format); + const writer = cs.writable.getWriter(); + const reader = cs.readable.getReader(); + + const writePromise = writer.write(value); + const readPromise = reader.read(); + + await assert.rejects(writePromise, expected); + await assert.rejects(readPromise, expected); + }); + + test(`DecompressionStream rejects bad chunk (${name}) for ${format}`, async () => { + const ds = new DecompressionStream(format); + const writer = ds.writable.getWriter(); + const reader = ds.readable.getReader(); + + const writePromise = writer.write(value); + const readPromise = reader.read(); + + await assert.rejects(writePromise, expected); + await assert.rejects(readPromise, expected); + }); + } +} + +// Verify that decompression errors (e.g. corrupt data) are surfaced as +// TypeError, not plain Error, per the Compression Streams spec. +for (const format of ['deflate', 'deflate-raw', 'gzip', 'brotli']) { + test(`DecompressionStream surfaces corrupt data as TypeError for ${format}`, async () => { + const ds = new DecompressionStream(format); + const writer = ds.writable.getWriter(); + const reader = ds.readable.getReader(); + + const corruptData = new Uint8Array([0, 1, 2, 3, 4, 5]); + + writer.write(corruptData).catch(() => {}); + reader.read().catch(() => {}); + + await assert.rejects(writer.close(), { name: 'TypeError' }); + await assert.rejects(reader.closed, { name: 'TypeError' }); + }); +} diff --git a/test/js/node/test/parallel/test-webstreams-compression-buffer-source.js b/test/js/node/test/parallel/test-webstreams-compression-buffer-source.js new file mode 100644 index 00000000000..3304a8e64f3 --- /dev/null +++ b/test/js/node/test/parallel/test-webstreams-compression-buffer-source.js @@ -0,0 +1,42 @@ +'use strict'; +require('../common'); +const assert = require('assert'); +const test = require('node:test'); +const { DecompressionStream, CompressionStream } = require('stream/web'); + +// Minimal gzip-compressed bytes for "hello" +const compressedGzip = new Uint8Array([ + 31, 139, 8, 0, 0, 0, 0, 0, 0, 3, + 203, 72, 205, 201, 201, 7, 0, 134, 166, 16, 54, 5, 0, 0, 0, +]); + +test('DecompressionStream accepts ArrayBuffer chunks', async () => { + const ds = new DecompressionStream('gzip'); + const writer = ds.writable.getWriter(); + + const writePromise = writer.write(compressedGzip.buffer); + writer.close(); + + const chunks = await Array.fromAsync(ds.readable); + await writePromise; + const out = Buffer.concat(chunks.map((c) => Buffer.from(c))); + assert.strictEqual(out.toString(), 'hello'); +}); + +test('CompressionStream round-trip with ArrayBuffer input', async () => { + const cs = new CompressionStream('gzip'); + const ds = new DecompressionStream('gzip'); + + const csWriter = cs.writable.getWriter(); + + const input = new TextEncoder().encode('hello').buffer; + + await csWriter.write(input); + csWriter.close(); + + await cs.readable.pipeTo(ds.writable); + + const out = await Array.fromAsync(ds.readable); + const result = Buffer.concat(out.map((c) => Buffer.from(c))); + assert.strictEqual(result.toString(), 'hello'); +}); diff --git a/test/js/node/test/parallel/test-webstreams-duplex-fromweb-writev-unhandled-rejection.js b/test/js/node/test/parallel/test-webstreams-duplex-fromweb-writev-unhandled-rejection.js new file mode 100644 index 00000000000..5367b2a09e1 --- /dev/null +++ b/test/js/node/test/parallel/test-webstreams-duplex-fromweb-writev-unhandled-rejection.js @@ -0,0 +1,55 @@ +'use strict'; + +// Regression test for https://github.com/nodejs/node/issues/62199 +// +// When Duplex.fromWeb is corked, writes are batched into _writev. If destroy() +// is called in the same microtask (after uncork()), writer.ready rejects with a +// non-array value. The done() callback inside _writev unconditionally called +// error.filter(), which throws TypeError on non-arrays. This TypeError became +// an unhandled rejection that crashed the process. +// +// The same bug exists in newStreamWritableFromWritableStream (Writable.fromWeb). + +const common = require('../common'); +const { Duplex, Writable } = require('stream'); +const { TransformStream, WritableStream } = require('stream/web'); + +// Exact reproduction from the issue report (davidje13). +// Before the fix: process crashes with unhandled TypeError. +// After the fix: stream closes cleanly with no unhandled rejection. +{ + const output = Duplex.fromWeb(new TransformStream()); + + output.on('close', common.mustCall()); + + output.cork(); + output.write('test'); + output.write('test'); + output.uncork(); + output.destroy(); +} + +// Same bug in Writable.fromWeb (newStreamWritableFromWritableStream). +{ + const writable = Writable.fromWeb(new WritableStream()); + + writable.on('close', common.mustCall()); + + writable.cork(); + writable.write('test'); + writable.write('test'); + writable.uncork(); + writable.destroy(); +} + +// Regression: normal cork/uncork/_writev success path must still work. +// Verifies that () => done() correctly signals success via callback(). +{ + const writable = Writable.fromWeb(new WritableStream({ write() {} })); + + writable.cork(); + writable.write('foo'); + writable.write('bar'); + writable.uncork(); + writable.end(common.mustCall()); +} diff --git a/test/js/node/test/parallel/test-whatwg-webstreams-compression.js b/test/js/node/test/parallel/test-whatwg-webstreams-compression.js index a6f2e1b425d..f168a0ca846 100644 --- a/test/js/node/test/parallel/test-whatwg-webstreams-compression.js +++ b/test/js/node/test/parallel/test-whatwg-webstreams-compression.js @@ -24,13 +24,13 @@ async function test(format) { const writer = gzip.writable.getWriter(); const compressed_data = []; - const reader_function = ({ value, done }) => { + const reader_function = common.mustCallAtLeast(({ value, done }) => { if (value) compressed_data.push(value); if (!done) return reader.read().then(reader_function); assert.strictEqual(dec.decode(Buffer.concat(compressed_data)), 'hello'); - }; + }); const reader_promise = reader.read().then(reader_function); await Promise.all([ diff --git a/test/js/node/test/parallel/test-zlib-flush-write-sync-interleaved.js b/test/js/node/test/parallel/test-zlib-flush-write-sync-interleaved.js index f8387f40069..87ca9fe1e9a 100644 --- a/test/js/node/test/parallel/test-zlib-flush-write-sync-interleaved.js +++ b/test/js/node/test/parallel/test-zlib-flush-write-sync-interleaved.js @@ -19,7 +19,7 @@ for (const chunk of ['abc', 'def', 'ghi']) { compress.write(chunk, common.mustCall(() => events.push({ written: chunk }))); compress.flush(Z_PARTIAL_FLUSH, common.mustCall(() => { events.push('flushed'); - const chunk = compress.read(); + const chunk = compress.read(compress.readableLength); if (chunk !== null) compressedChunks.push(chunk); })); @@ -36,7 +36,7 @@ function writeToDecompress() { const chunk = compressedChunks.shift(); if (chunk === undefined) return decompress.end(); decompress.write(chunk, common.mustCall(() => { - events.push({ read: decompress.read() }); + events.push({ read: decompress.read(decompress.readableLength) }); writeToDecompress(); })); } diff --git a/test/js/third_party/duckdb/duckdb-basic-usage.test.ts b/test/js/third_party/duckdb/duckdb-basic-usage.test.ts index 2861ada7155..8ee3bae518f 100644 --- a/test/js/third_party/duckdb/duckdb-basic-usage.test.ts +++ b/test/js/third_party/duckdb/duckdb-basic-usage.test.ts @@ -7,6 +7,14 @@ if (process.platform === "win32" && process.arch === "arm64") { // duckdb does not distribute win32-arm64 binaries process.exit(0); } +if (Number(process.versions.modules) > 137) { + // The deprecated `duckdb` package only publishes prebuilts up to + // NODE_MODULE_VERSION 137 (checked npm.duckdb.org for 1.3.1-1.4.1: no + // node-v141/-v147 binaries), and node-pre-gyp's --fallback-to-build source + // compile is not viable in CI. Drop this gate if the test migrates to + // @duckdb/node-api, which is N-API based and ABI-independent. + process.exit(0); +} import { describe, expect, test } from "bun:test"; // Must be CJS require so that the above code can exit before we attempt to import DuckDB diff --git a/test/js/third_party/grpc-js/bun.lock b/test/js/third_party/grpc-js/bun.lock new file mode 100644 index 00000000000..99036591a7d --- /dev/null +++ b/test/js/third_party/grpc-js/bun.lock @@ -0,0 +1,80 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "grpc-js", + "dependencies": { + "@grpc/grpc-js": "1.9.9", + "@grpc/proto-loader": "0.7.10", + }, + }, + }, + "packages": { + "@grpc/grpc-js": ["@grpc/grpc-js@1.9.9", "", { "dependencies": { "@grpc/proto-loader": "^0.7.8", "@types/node": ">=12.12.47" } }, "sha512-vQ1qwi/Kiyprt+uhb1+rHMpyk4CVRMTGNUGGPRGS7pLNfWkdCHrGEnT6T3/JyC2VZgoOX/X1KwdoU0WYQAeYcQ=="], + + "@grpc/proto-loader": ["@grpc/proto-loader@0.7.10", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.4", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-CAqDfoaQ8ykFd9zqBDn4k6iWT9loLAlc2ETmDFS9JCD70gDcnA4L3AFEo2iV7KyAtAAHFW9ftq1Fz+Vsgq80RQ=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.2", "", {}, "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="], + + "@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + } +} diff --git a/test/js/third_party/grpc-js/test-server.test.ts b/test/js/third_party/grpc-js/test-server.test.ts index e1dd4c2933b..607afbda763 100644 --- a/test/js/third_party/grpc-js/test-server.test.ts +++ b/test/js/third_party/grpc-js/test-server.test.ts @@ -194,9 +194,16 @@ describe("Server", () => { { deadline: deadline }, (callError2, result) => { assert(callError2); - // DEADLINE_EXCEEDED means that the server is unreachable + // DEADLINE_EXCEEDED means the server was unreachable; + // UNAVAILABLE means the connection dropped before the call. + // CANCELLED happens when the call wins the race onto the + // draining session before the GOAWAY/socket-close is + // processed and is then torn down with NGHTTP2_CANCEL — the + // same in-flight teardown path Node takes at socket close. assert( - callError2.code === grpc.status.DEADLINE_EXCEEDED || callError2.code === grpc.status.UNAVAILABLE, + callError2.code === grpc.status.DEADLINE_EXCEEDED || + callError2.code === grpc.status.UNAVAILABLE || + callError2.code === grpc.status.CANCELLED, ); done(); }, diff --git a/test/js/web/streams/compression.test.ts b/test/js/web/streams/compression.test.ts index 63fae21bf4a..f182e968b66 100644 --- a/test/js/web/streams/compression.test.ts +++ b/test/js/web/streams/compression.test.ts @@ -249,3 +249,89 @@ describe("CompressionStream and DecompressionStream", () => { }); }); }); + +// Ported behaviors from Node v26's webstreams adapters +// (upstream: test-whatwg-webstreams-compression.js and +// lib/internal/webstreams/compression.js validateBufferSourceChunk). +describe("CompressionStream chunk handling (Node v26 semantics)", () => { + test("accepts ArrayBuffer chunks", async () => { + const input = "hello arraybuffer world"; + const data = new TextEncoder().encode(input); + + const cs = new CompressionStream("gzip"); + const writer = cs.writable.getWriter(); + writer.write(data.buffer); + writer.close(); + + const compressedChunks: Uint8Array[] = []; + const reader = cs.readable.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + compressedChunks.push(value); + } + expect(compressedChunks.length).toBeGreaterThan(0); + + const ds = new DecompressionStream("gzip"); + const dWriter = ds.writable.getWriter(); + for (const chunk of compressedChunks) dWriter.write(chunk); + dWriter.close(); + + const out: Uint8Array[] = []; + const dReader = ds.readable.getReader(); + while (true) { + const { done, value } = await dReader.read(); + if (done) break; + out.push(value); + } + expect(new TextDecoder().decode(Buffer.concat(out))).toBe(input); + }); + + test("rejects SharedArrayBuffer chunks with ERR_INVALID_ARG_TYPE", async () => { + const cs = new CompressionStream("gzip"); + const writer = cs.writable.getWriter(); + expect.assertions(1); + try { + await writer.write(new SharedArrayBuffer(8)); + } catch (e: any) { + expect(e.code).toBe("ERR_INVALID_ARG_TYPE"); + } + }); + + test("a synchronously-invalid chunk errors both sides instead of hanging the readable", async () => { + const cs = new CompressionStream("gzip"); + const writer = cs.writable.getWriter(); + const reader = cs.readable.getReader(); + + const writeError = writer.write(42).catch(e => e); + // Without the kDestroyOnSyncError handling the readable side hangs + // forever here. + const readError = reader.read().catch(e => e); + + const [we, re] = await Promise.all([writeError, readError]); + expect(we.code).toBe("ERR_INVALID_ARG_TYPE"); + expect(re.code).toBe("ERR_INVALID_ARG_TYPE"); + }); + + test("brotli decoder errors surface as TypeError with the original code as own property", async () => { + const ds = new DecompressionStream("brotli"); + const writer = ds.writable.getWriter(); + const reader = ds.readable.getReader(); + + writer.write(new Uint8Array([0xff, 0xff, 0xff, 0xff, 0xff, 0xff])).catch(() => {}); + writer.close().catch(() => {}); + + expect.assertions(4); + try { + while (true) { + const { done } = await reader.read(); + if (done) break; + } + } catch (e: any) { + expect(e).toBeInstanceOf(TypeError); + expect(Object.hasOwn(e, "code")).toBe(true); + expect(e.code).toStartWith("ERR_BROTLI_DECODER_ERROR_"); + expect(e.cause.code).toBe(e.code); + } + }); +}); diff --git a/test/js/web/streams/streams.test.js b/test/js/web/streams/streams.test.js index 5256b7502f1..8299c4bd504 100644 --- a/test/js/web/streams/streams.test.js +++ b/test/js/web/streams/streams.test.js @@ -1276,3 +1276,17 @@ it("auto-allocated byte stream chunks are zero-filled before being exposed to th expect(value.subarray(1).every(b => b === 0)).toBe(true); reader.cancel(); }); + +it("reader.cancel() settles a pending BYOB read with done: true (whatwg ReadableStreamCancel step 5)", async () => { + const stream = new ReadableStream({ + type: "bytes", + pull() {}, + cancel() {}, + }); + const reader = stream.getReader({ mode: "byob" }); + const pendingRead = reader.read(new Uint8Array(16)); + await reader.cancel("test reason"); + const result = await pendingRead; + expect(result.done).toBe(true); + expect(result.value).toBeUndefined(); +}); diff --git a/test/napi/napi-app/bun.lock b/test/napi/napi-app/bun.lock index 605f6d47777..099875ae95c 100644 --- a/test/napi/napi-app/bun.lock +++ b/test/napi/napi-app/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "napi-buffer-bug", diff --git a/test/napi/napi-app/package.json b/test/napi/napi-app/package.json index c3a3ff5a6db..b820a510fee 100644 --- a/test/napi/napi-app/package.json +++ b/test/napi/napi-app/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "gypfile": true, "scripts": { - "install": "node-gyp rebuild --debug -j max", - "build": "node-gyp rebuild --debug -j max", + "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/napi-app/standalone_tests.cpp b/test/napi/napi-app/standalone_tests.cpp index 23050e9d2fc..af93cd1064b 100644 --- a/test/napi/napi-app/standalone_tests.cpp +++ b/test/napi/napi-app/standalone_tests.cpp @@ -1169,6 +1169,7 @@ static napi_value test_deferred_exceptions(const Napi::CallbackInfo &info) { clear(); + napi_ref object_ref; status = napi_wrap( env, object, nullptr, +[](napi_env env, void *data, void *finalize_hint) { @@ -1176,13 +1177,26 @@ static napi_value test_deferred_exceptions(const Napi::CallbackInfo &info) { printf("napi_throw status: %d\n", napi_throw(env, ok(env))); puts("finalizer end"); }, - nullptr, nullptr); + nullptr, &object_ref); if (status != napi_ok) { printf("napi_wrap failed: %d\n", status); return nullptr; } + // Pin the wrapped object for the rest of the process. Under Node >= 26 a + // finalizer that calls napi_throw aborts if it runs from GC (it would need + // node_api_post_finalizer), but running it at env teardown is allowed and + // prints napi_cannot_run_js. Keeping the object strongly referenced makes + // the finalizer timing deterministic on both runtimes. + uint32_t refcount; + status = napi_reference_ref(env, object_ref, &refcount); + + if (status != napi_ok) { + printf("napi_reference_ref failed: %d\n", status); + return nullptr; + } + clear(); puts("ok"); diff --git a/test/napi/napi.test.ts b/test/napi/napi.test.ts index cdf548e5a72..8312c944788 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -1,11 +1,24 @@ import { spawn, spawnSync } from "bun"; import { beforeAll, describe, expect, it } from "bun:test"; import { readdirSync } from "fs"; -import { bunEnv, bunExe, isCI, isMacOS, isMusl, isWindows, tempDirWithFiles } from "harness"; +import { + bunEnv, + bunExe, + canBuildNodeAddons, + isCI, + isMacOS, + isMusl, + isWindows, + nodeExeMatchingAbi, + tempDirWithFiles, +} from "harness"; import { join } from "path"; -describe.concurrent("napi", () => { - beforeAll(() => { +describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { + beforeAll(async () => { + // Resolve (and possibly download) the ABI-matching node here, under the + // generous hook timeout, instead of inside the first test that needs it. + await nodeExeMatchingAbi(); // build gyp console.time("Building node-gyp"); const install = spawnSync({ @@ -21,9 +34,10 @@ describe.concurrent("napi", () => { process.exit(1); } console.timeEnd("Building node-gyp"); - // node-gyp rebuild can take a while under a debug/ASAN binary; default - // 5s hook timeout kills the install subprocess mid-build. - }, 120_000); + // node-gyp rebuild can take a while under a debug/ASAN binary (and the + // hook may first download an ABI-matching node); default 5s hook timeout + // kills the install subprocess mid-build. + }, 300_000); describe.each(["esm", "cjs"])("bundle .node files to %s via", format => { describe.each(["node", "bun"])("target %s", target => { @@ -53,7 +67,7 @@ describe.concurrent("napi", () => { }); expect(build.success).toBeTrue(); - for (let exec of target === "bun" ? [bunExe()] : [bunExe(), "node"]) { + for (let exec of target === "bun" ? [bunExe()] : [bunExe(), await nodeExeMatchingAbi()]) { const result = spawnSync({ cmd: [exec, join(dir, "main.js"), "self"], env: bunEnv, @@ -134,7 +148,7 @@ describe.concurrent("napi", () => { expect(build.logs).toBeEmpty(); - for (let exec of target === "bun" ? [bunExe()] : [bunExe(), "node"]) { + for (let exec of target === "bun" ? [bunExe()] : [bunExe(), await nodeExeMatchingAbi()]) { const result = spawnSync({ cmd: [exec, join(dir, "main.js"), "self"], env: bunEnv, @@ -751,8 +765,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, ); @@ -776,6 +793,9 @@ async function checkSameOutput(test: string, args: any[] | string, envArgs: Reco async function runOn(executable: string, test: string, args: any[] | string, envArgs: Record = {}) { const env = { ...bunEnv, ...envArgs }; + // "node" means a Node whose addon ABI matches the headers the fixture was + // compiled against (the system node may lag the version Bun reports). + if (executable === "node") executable = await nodeExeMatchingAbi(); const exec = spawn({ cmd: [ executable, @@ -805,6 +825,7 @@ async function runOn(executable: string, test: string, args: any[] | string, env async function checkBothFail(test: string, args: any[] | string, envArgs: Record = {}) { const [node, bun] = await Promise.all( ["node", bunExe()].map(async executable => { + if (executable === "node") executable = await nodeExeMatchingAbi(); const { BUN_INSPECT_CONNECT_TO: _, ...rest } = bunEnv; const env = { ...rest, BUN_INTERNAL_SUPPRESS_CRASH_ON_NAPI_ABORT: "1", ...envArgs }; const exec = spawn({ @@ -829,7 +850,7 @@ async function checkBothFail(test: string, args: any[] | string, envArgs: Record expect(!!node.signalCode).toEqual(!!bun.signalCode); } -describe("cleanup hooks", () => { +describe.skipIf(!canBuildNodeAddons())("cleanup hooks", () => { describe("execution order", () => { it("executes in reverse insertion order like Node.js", async () => { // Test that cleanup hooks execute in reverse insertion order (LIFO) diff --git a/test/napi/node-napi-tests/harness.ts b/test/napi/node-napi-tests/harness.ts index 2befe7ed970..98c99d992f5 100644 --- a/test/napi/node-napi-tests/harness.ts +++ b/test/napi/node-napi-tests/harness.ts @@ -7,14 +7,14 @@ 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(), "--bun", "x", "node-gyp@11", "rebuild", "--debug", "-j", "max", "--verbose"], cwd: dir, stderr: "pipe", stdout: "ignore", stdin: "inherit", env: { ...bunEnv, - npm_config_target: "v24.3.0", + npm_config_target: "v26.3.0", CXXFLAGS: (bunEnv.CXXFLAGS ?? "") + (process.platform == "win32" ? " -std=c++20" : " -std=gnu++20"), // on linux CI, node-gyp will default to g++ and the version installed there is very old, // so we make it use clang instead diff --git a/test/v8/bad-modules/mismatched_abi_version.cpp b/test/v8/bad-modules/mismatched_abi_version.cpp index 73b2e78c03f..96fc590ea01 100644 --- a/test/v8/bad-modules/mismatched_abi_version.cpp +++ b/test/v8/bad-modules/mismatched_abi_version.cpp @@ -8,7 +8,7 @@ void init(v8::Local exports, v8::Local module, extern "C" { static node::node_module _module = { - // bun expects 137 (Node.js 24.3.0) + // bun expects 147 (Node.js 26.3.0) 42, // nm_version 0, // nm_flags nullptr, // nm_dso_handle diff --git a/test/v8/bad-modules/no_entrypoint.cpp b/test/v8/bad-modules/no_entrypoint.cpp index 2ebb9ae7c45..fa3872f20ad 100644 --- a/test/v8/bad-modules/no_entrypoint.cpp +++ b/test/v8/bad-modules/no_entrypoint.cpp @@ -2,7 +2,7 @@ extern "C" { static node::node_module _module = { - 137, // nm_version (Node.js 24.3.0) + 147, // nm_version (Node.js 26.3.0) 0, // nm_flags nullptr, // nm_dso_handle "no_entrypoint.cpp", // nm_filename diff --git a/test/v8/v8-module/main.cpp b/test/v8/v8-module/main.cpp index 3cc8e33f0f7..9eaa5329534 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,11 +634,83 @@ 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()); } +// 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; + Local escaped = ehs.Escape(value); + return escaped; +} + +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); +} + +// 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()); @@ -1207,6 +1302,10 @@ 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_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", @@ -1233,7 +1332,9 @@ void initialize(Local exports, Local module, test_v8_value_type_checks); // without this, node hits a UAF deleting the Global - node::AddEnvironmentCleanupHook(context->GetIsolate(), + // (Context::GetIsolate was removed in V8 14.6; the module initializer runs + // with the isolate entered, so take the current one) + node::AddEnvironmentCleanupHook(Isolate::GetCurrent(), GlobalTestWrapper::cleanup, nullptr); } diff --git a/test/v8/v8.test.ts b/test/v8/v8.test.ts index 8363e2745e6..77a4575fc1e 100644 --- a/test/v8/v8.test.ts +++ b/test/v8/v8.test.ts @@ -1,7 +1,18 @@ import { spawn } from "bun"; import { jscDescribe } from "bun:jsc"; import { beforeAll, describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, isASAN, isBroken, isMusl, isWindows, nodeExe, tempDir, tmpdirSync } from "harness"; +import { + bunEnv, + bunExe, + canBuildNodeAddons, + isASAN, + isBroken, + isMusl, + isWindows, + nodeExeMatchingAbi, + tempDir, + tmpdirSync, +} from "harness"; import assert from "node:assert"; import fs from "node:fs/promises"; import { basename, join } from "path"; @@ -21,7 +32,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"; @@ -77,7 +88,14 @@ async function build( "-j", "max", ] - : [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. 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", @@ -104,7 +122,7 @@ async function build( console.log(err); } -describe.todoIf(isBroken && isMusl)("node:v8", () => { +describe.skipIf(!canBuildNodeAddons()).todoIf(isBroken && isMusl)("node:v8", () => { beforeAll(async () => { // set up clean directories for our 4 builds directories.bunRelease = tmpdirSync(); @@ -121,7 +139,11 @@ describe.todoIf(isBroken && isMusl)("node:v8", () => { await build(srcDir, directories.bunDebug, Runtime.bun, BuildMode.debug); await build(srcDir, directories.node, Runtime.node, BuildMode.release); await build(join(__dirname, "bad-modules"), directories.badModules, Runtime.node, BuildMode.release); - }); + + // Resolve (and possibly download) the ABI-matching node here, under the + // generous hook timeout, instead of inside the first test that needs it. + await nodeExeMatchingAbi(); + }, 600_000); describe("module lifecycle", () => { it("can call a basic native function", async () => { @@ -305,6 +327,14 @@ 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"); + }); + + it("inline handles survive a nested call's scope push/pop", async () => { + await checkSameOutput("test_v8_locals_survive_nested_call"); + }); }); describe("MaybeLocal", () => { @@ -368,7 +398,7 @@ async function runOn(runtime: Runtime, buildMode: BuildMode, testName: string, j : buildMode == BuildMode.debug ? directories.bunDebug : directories.bunRelease; - const exe = runtime == Runtime.node ? (nodeExe() ?? "node") : bunExe(); + const exe = runtime == Runtime.node ? await nodeExeMatchingAbi() : bunExe(); const cmd = [ exe, @@ -389,7 +419,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()); } @@ -397,13 +427,15 @@ async function runOn(runtime: Runtime, buildMode: BuildMode, testName: string, j return out.trim(); } -describe.todoIf(isBroken && isMusl)("String::Utf8Length bounds", () => { +describe.skipIf(!canBuildNodeAddons()).todoIf(isBroken && isMusl)("String::Utf8Length bounds", () => { it( - "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 +466,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); } @@ -471,7 +503,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", @@ -507,9 +552,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,