From a4b46f49ed185120674c526bb7589fc9b8664457 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 13 Oct 2026 08:42:51 +0000 Subject: [PATCH 1/5] ci: bump Bun pins 1.3.12 -> 1.4.3 (experiment) --- .github/actions/setup-xum/action.yml | 2 +- .github/workflows/auto-cleanup-fixup.yml | 2 +- .github/workflows/auto-cleanup.yml | 2 +- .github/workflows/pr.yml | 2 +- .github/workflows/update-models.yml | 2 +- .tool-versions | 2 +- Dockerfile | 2 +- benchmarks/terminal_bench/mux_setup.sh.j2 | 2 +- bun.lock | 8 +++++--- docs/AGENTS.md | 2 +- docs/guides/github-actions.mdx | 2 +- package.json | 2 +- packages/chat-components/package.json | 2 +- src/browser/stores/WorkspaceConsumerManager.test.ts | 2 +- src/node/runtime/sshConnectionPool.test.ts | 2 +- .../services/agentSkills/builtInSkillContent.generated.ts | 4 ++-- src/node/services/mcpServerManager.testHarness.ts | 2 +- vscode/package.json | 2 +- 18 files changed, 23 insertions(+), 21 deletions(-) diff --git a/.github/actions/setup-xum/action.yml b/.github/actions/setup-xum/action.yml index 436deedf918..41ea02037d7 100644 --- a/.github/actions/setup-xum/action.yml +++ b/.github/actions/setup-xum/action.yml @@ -23,7 +23,7 @@ runs: - name: Setup Bun uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0 with: - bun-version: 1.3.12 + bun-version: 1.4.3 - name: Get Bun version id: bun-version diff --git a/.github/workflows/auto-cleanup-fixup.yml b/.github/workflows/auto-cleanup-fixup.yml index 2989e6d5a69..867a379faab 100644 --- a/.github/workflows/auto-cleanup-fixup.yml +++ b/.github/workflows/auto-cleanup-fixup.yml @@ -74,7 +74,7 @@ jobs: - uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0 if: ${{ steps.precheck.outputs.enabled == 'true' }} with: - bun-version: 1.3.12 + bun-version: 1.4.3 # Pin the git identity so the AI agent doesn't invent its own. - name: Configure git identity diff --git a/.github/workflows/auto-cleanup.yml b/.github/workflows/auto-cleanup.yml index 0d5af9e9996..980dfc05c26 100644 --- a/.github/workflows/auto-cleanup.yml +++ b/.github/workflows/auto-cleanup.yml @@ -53,7 +53,7 @@ jobs: - uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0 if: ${{ steps.precheck.outputs.enabled == 'true' }} with: - bun-version: 1.3.12 + bun-version: 1.4.3 # Pin the git identity so the AI agent doesn't invent its own. # Without this, commits end up as "mux-auto-cleanup[bot]" while the diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 83fed5a7a5d..918a54e10cf 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -389,7 +389,7 @@ jobs: echo "sha=$sha" >>"$GITHUB_OUTPUT" - uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0 with: - bun-version: 1.3.12 + bun-version: 1.4.3 - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: false diff --git a/.github/workflows/update-models.yml b/.github/workflows/update-models.yml index 26d3f862f99..5e0d688ee47 100644 --- a/.github/workflows/update-models.yml +++ b/.github/workflows/update-models.yml @@ -57,7 +57,7 @@ jobs: - uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0 if: ${{ steps.precheck.outputs.enabled == 'true' }} with: - bun-version: 1.3.12 + bun-version: 1.4.3 - name: Update models.json if: ${{ steps.precheck.outputs.enabled == 'true' }} diff --git a/.tool-versions b/.tool-versions index 461bdea7baf..333cc5b156f 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -bun 1.3.12 +bun 1.4.3 diff --git a/Dockerfile b/Dockerfile index 17098089b16..9bc02e79348 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,7 @@ WORKDIR /app ARG RELEASE_TAG # Install bun (used for package management and build tooling) -RUN npm install -g bun@1.3.12 +RUN npm install -g bun@1.4.3 # Install git (needed for version generation) and build tools for native modules # bzip2 is required for lzma-native to extract its bundled xz source tarball diff --git a/benchmarks/terminal_bench/mux_setup.sh.j2 b/benchmarks/terminal_bench/mux_setup.sh.j2 index e904dbb3f16..534cb3c53ce 100644 --- a/benchmarks/terminal_bench/mux_setup.sh.j2 +++ b/benchmarks/terminal_bench/mux_setup.sh.j2 @@ -32,7 +32,7 @@ export PATH="${BUN_INSTALL}/bin:${PATH}" if ! command -v bun >/dev/null 2>&1; then log "installing bun" # Pin the repo's Bun (package.json packageManager); an unpinned install floats to the latest major. - curl -fsSL "${MUX_BUN_INSTALL_URL:-https://bun.sh/install}" | bash -s "bun-v1.3.12" + curl -fsSL "${MUX_BUN_INSTALL_URL:-https://bun.sh/install}" | bash -s "bun-v1.4.3" fi MUX_APP_ROOT="${MUX_APP_ROOT:-/opt/mux-app}" diff --git a/bun.lock b/bun.lock index 169c7f5bf19..cbfe19030ec 100644 --- a/bun.lock +++ b/bun.lock @@ -3059,7 +3059,7 @@ "playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], - "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], "plimit-lit": ["plimit-lit@1.6.1", "", { "dependencies": { "queue-lit": "^1.5.1" } }, "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA=="], @@ -3771,8 +3771,6 @@ "@develar/schema-utils/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], - "@e2e-dev/web/playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], - "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -3915,6 +3913,8 @@ "@smithy/util-utf8/@smithy/core": ["@smithy/core@3.29.2", "", { "dependencies": { "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-DXUk6yU0C1Q1tYvJh1VCtl8QOBcSoZpKwjTPkxT6A4MUQYHvgeKGByL8mrEdxnvhdf9nq5GyzmRb5n/vPgu3Lw=="], + "@storybook/test-runner/playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], @@ -4213,6 +4213,8 @@ "pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "playwright/playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + "posthog-node/@posthog/core": ["@posthog/core@1.7.0", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-d6ZV4grpzeH/6/LP8quMVpSjY1puRkrqfwcPvGRKUAX7tb7YHyp/zMiTDuJmOFbpUxAMBXH5nDwcPiyCY2WGzA=="], "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 8d409de970c..e8559b8bac0 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -248,7 +248,7 @@ Freely make breaking changes, and reorganize / cleanup IPC as needed. ## Run a Bug Bash (tests/bugbash) -- Host: Linux on amd64, not root, with a local rootful Docker socket. The image is `linux/amd64`, so the sandbox is amd64 only: the runner refuses another architecture before the pull. Docker Desktop, rootless Docker and a remote endpoint refuse. The first launch pulls the pinned sandbox image (`tests/bugbash/sandbox/image.json`, about 1.4 GB). Node.js and Chromium come with the image: the host needs neither for these targets. The host needs git, make and Bun 1.3.12 (`packageManager` in `package.json`): `make` builds with Bun and runs the launchers with it. Check it with `bun --version`. On a fresh checkout, run `bun install` first. +- Host: Linux on amd64, not root, with a local rootful Docker socket. The image is `linux/amd64`, so the sandbox is amd64 only: the runner refuses another architecture before the pull. Docker Desktop, rootless Docker and a remote endpoint refuse. The first launch pulls the pinned sandbox image (`tests/bugbash/sandbox/image.json`, about 1.4 GB). Node.js and Chromium come with the image: the host needs neither for these targets. The host needs git, make and Bun 1.4.3 (`packageManager` in `package.json`): `make` builds with Bun and runs the launchers with it. Check it with `bun --version`. On a fresh checkout, run `bun install` first. - Env: `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL` (with or without `/v1`) and `BUGBASH_BUDGET_USD` (the most the run may spend at list price, in dollars). The runner reads the key only from `ANTHROPIC_API_KEY`: if your key is in another variable (for example `ANTHROPIC_AUTH_TOKEN`), copy it there. Only `anthropic:` explorers run. - Run: `make bug-bash BUGBASH_BUDGET_USD=9`. It checks the env, Docker and the image, then builds, then launches the jobs. The checks take about a second once the image is cached. The first run pulls about 1.4 GB, which takes minutes. Defaults: the 6 charters of `tests/bugbash/charters.txt` × `anthropic:claude-sonnet-5-5` = 6 jobs, `--parallel 8`, `--max-steps 6`, `BUGBASH_EFFORT=medium`, and `BUGBASH_AI=auto`: one probe decides the app AI. It is Haiku 4.5 when the probe answers, and the mock when the upstream is busy (429 or 5xx). In every mode, two probe results refuse before any job: an unreachable `ANTHROPIC_BASE_URL` (`cannot reach ANTHROPIC_BASE_URL ()`, at $0) and a 401 or 403 (`ANTHROPIC_API_KEY rejected by `). On a 403, the refusal also names the probed model and the variable that picks it (`BUGBASH_APP_MODEL`, or a `BUGBASH_MODELS` entry when the app AI is the mock): check the key's access to that model too. - Cost: before any job, the run prints its estimate and a recommended budget, and refuses a lower `BUGBASH_BUDGET_USD` with the number to set. The default needs $9: $0.45 per Sonnet charter (measured at 6 steps) plus headroom for the calls in flight. A higher `BUGBASH_EFFORT` and a `BUGBASH_APP_MODEL` other than Haiku 4.5 raise it, as marked guesses (high effort: $12). It takes about 5 minutes after the build. Adding Opus (`BUGBASH_MODELS=anthropic:claude-sonnet-5-5,anthropic:claude-opus-5-5`) needs about $29. diff --git a/docs/guides/github-actions.mdx b/docs/guides/github-actions.mdx index 73d6558a68a..ff550d24128 100644 --- a/docs/guides/github-actions.mdx +++ b/docs/guides/github-actions.mdx @@ -98,7 +98,7 @@ jobs: - uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0 if: ${{ steps.precheck.outputs.enabled == 'true' }} with: - bun-version: 1.3.12 + bun-version: 1.4.3 # Pin the git identity so the AI agent doesn't invent its own. # Without this, commits end up as "mux-auto-cleanup[bot]" while the diff --git a/package.json b/package.json index 96ae9096d27..1439552d221 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "publishConfig": { "access": "public" }, - "packageManager": "bun@1.3.12", + "packageManager": "bun@1.4.3", "scripts": { "dev": "make dev", "prebuild:main": "./scripts/generate-version.sh", diff --git a/packages/chat-components/package.json b/packages/chat-components/package.json index cf8289bc651..976765ef131 100644 --- a/packages/chat-components/package.json +++ b/packages/chat-components/package.json @@ -2,7 +2,7 @@ "name": "@coder/mux-chat-components", "version": "0.1.0", "description": "Shared chat components for Xum conversation rendering", - "packageManager": "bun@1.3.12", + "packageManager": "bun@1.4.3", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/src/browser/stores/WorkspaceConsumerManager.test.ts b/src/browser/stores/WorkspaceConsumerManager.test.ts index f64ae6b84a6..b669946049c 100644 --- a/src/browser/stores/WorkspaceConsumerManager.test.ts +++ b/src/browser/stores/WorkspaceConsumerManager.test.ts @@ -14,7 +14,7 @@ const STATS: ChatStats = { usageHistory: [], }; -// bun-types (^1.2.23) lags the pinned runtime (bun@1.3.12), which implements this. +// bun-types (^1.2.23) lags the pinned runtime (bun@1.4.3), which implements this. const fakeTimers = jest as typeof jest & { advanceTimersByTime: (ms: number) => void }; // The manager treats a calculation as slow after this long (WorkspaceConsumerManager.ts). diff --git a/src/node/runtime/sshConnectionPool.test.ts b/src/node/runtime/sshConnectionPool.test.ts index 1e8391fa5f4..ad75cc2125f 100644 --- a/src/node/runtime/sshConnectionPool.test.ts +++ b/src/node/runtime/sshConnectionPool.test.ts @@ -11,7 +11,7 @@ import { } from "./sshConnectionPool"; import { isPermanentSSHFailure } from "./Runtime"; -// bun-types (^1.2.23) lags the pinned runtime (bun@1.3.12), which implements this. +// bun-types (^1.2.23) lags the pinned runtime (bun@1.4.3), which implements this. const fakeTimers = jest as typeof jest & { advanceTimersByTime: (ms: number) => void }; describe("sshConnectionPool", () => { diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index fa9caccae64..94294067acb 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -2301,7 +2301,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "## Run a Bug Bash (tests/bugbash)", "", - "- Host: Linux on amd64, not root, with a local rootful Docker socket. The image is `linux/amd64`, so the sandbox is amd64 only: the runner refuses another architecture before the pull. Docker Desktop, rootless Docker and a remote endpoint refuse. The first launch pulls the pinned sandbox image (`tests/bugbash/sandbox/image.json`, about 1.4 GB). Node.js and Chromium come with the image: the host needs neither for these targets. The host needs git, make and Bun 1.3.12 (`packageManager` in `package.json`): `make` builds with Bun and runs the launchers with it. Check it with `bun --version`. On a fresh checkout, run `bun install` first.", + "- Host: Linux on amd64, not root, with a local rootful Docker socket. The image is `linux/amd64`, so the sandbox is amd64 only: the runner refuses another architecture before the pull. Docker Desktop, rootless Docker and a remote endpoint refuse. The first launch pulls the pinned sandbox image (`tests/bugbash/sandbox/image.json`, about 1.4 GB). Node.js and Chromium come with the image: the host needs neither for these targets. The host needs git, make and Bun 1.4.3 (`packageManager` in `package.json`): `make` builds with Bun and runs the launchers with it. Check it with `bun --version`. On a fresh checkout, run `bun install` first.", "- Env: `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL` (with or without `/v1`) and `BUGBASH_BUDGET_USD` (the most the run may spend at list price, in dollars). The runner reads the key only from `ANTHROPIC_API_KEY`: if your key is in another variable (for example `ANTHROPIC_AUTH_TOKEN`), copy it there. Only `anthropic:` explorers run.", "- Run: `make bug-bash BUGBASH_BUDGET_USD=9`. It checks the env, Docker and the image, then builds, then launches the jobs. The checks take about a second once the image is cached. The first run pulls about 1.4 GB, which takes minutes. Defaults: the 6 charters of `tests/bugbash/charters.txt` × `anthropic:claude-sonnet-5-5` = 6 jobs, `--parallel 8`, `--max-steps 6`, `BUGBASH_EFFORT=medium`, and `BUGBASH_AI=auto`: one probe decides the app AI. It is Haiku 4.5 when the probe answers, and the mock when the upstream is busy (429 or 5xx). In every mode, two probe results refuse before any job: an unreachable `ANTHROPIC_BASE_URL` (`cannot reach ANTHROPIC_BASE_URL ()`, at $0) and a 401 or 403 (`ANTHROPIC_API_KEY rejected by `). On a 403, the refusal also names the probed model and the variable that picks it (`BUGBASH_APP_MODEL`, or a `BUGBASH_MODELS` entry when the app AI is the mock): check the key's access to that model too.", "- Cost: before any job, the run prints its estimate and a recommended budget, and refuses a lower `BUGBASH_BUDGET_USD` with the number to set. The default needs $9: $0.45 per Sonnet charter (measured at 6 steps) plus headroom for the calls in flight. A higher `BUGBASH_EFFORT` and a `BUGBASH_APP_MODEL` other than Haiku 4.5 raise it, as marked guesses (high effort: $12). It takes about 5 minutes after the build. Adding Opus (`BUGBASH_MODELS=anthropic:claude-sonnet-5-5,anthropic:claude-opus-5-5`) needs about $29.", @@ -6245,7 +6245,7 @@ export const BUILTIN_SKILL_FILES: Record> = { " - uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0", " if: ${{ steps.precheck.outputs.enabled == 'true' }}", " with:", - " bun-version: 1.3.12", + " bun-version: 1.4.3", "", " # Pin the git identity so the AI agent doesn't invent its own.", ' # Without this, commits end up as "mux-auto-cleanup[bot]" while the', diff --git a/src/node/services/mcpServerManager.testHarness.ts b/src/node/services/mcpServerManager.testHarness.ts index 0df93f71be9..493888210a1 100644 --- a/src/node/services/mcpServerManager.testHarness.ts +++ b/src/node/services/mcpServerManager.testHarness.ts @@ -23,7 +23,7 @@ import { MCP_STARTUP_TIMEOUT_MS } from "@/constants/mcp"; // The manager's per-server startup deadline (a real timer), shared so the harness cannot drift. export { MCP_STARTUP_TIMEOUT_MS }; -// bun-types (^1.2.23) lags the pinned runtime (bun@1.3.12), which implements this. +// bun-types (^1.2.23) lags the pinned runtime (bun@1.4.3), which implements this. const fakeTimers = jest as typeof jest & { advanceTimersByTime: (ms: number) => void }; type MCPClientHandle = Awaited>; diff --git a/vscode/package.json b/vscode/package.json index f442a031639..0e8af9da7d5 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -105,5 +105,5 @@ "directory": "vscode" }, "license": "AGPL-3.0-only", - "packageManager": "bun@1.3.12" + "packageManager": "bun@1.4.3" } From 8baddb6c7e6550cce1be0b829bcaf6b816a59290 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 13 Oct 2026 09:41:16 +0000 Subject: [PATCH 2/5] fix: what Bun 1.4.3 breaks (playwright-core hoisting, env restore, unread socket, sort order, ALS disable) --- bun.lock | 13 ++++------- package.json | 1 + src/common/orpc/onChatCursorFingerprint.ts | 22 +++++++++++++++++-- src/node/orpc/replayOwnership.test.ts | 20 +++++++++++++++-- .../services/priorHistoryFingerprintAsync.ts | 20 ++++++++--------- tests/bugbash/sandbox/inContainer.test.ts | 4 +++- tests/bugbash/sandbox/runner.test.ts | 5 ++++- 7 files changed, 59 insertions(+), 26 deletions(-) diff --git a/bun.lock b/bun.lock index cbfe19030ec..a6d87ec88cb 100644 --- a/bun.lock +++ b/bun.lock @@ -179,6 +179,7 @@ "mitata": "1.0.34", "nodemon": "^3.1.10", "playwright": "^1.56.0", + "playwright-core": "1.57.0", "postcss": "^8.5.6", "posthog-js": "^1.276.0", "prettier": "^3.6.2", @@ -915,8 +916,6 @@ "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@3.0.0", "", { "peerDependencies": { "ai": "^7.0.0", "zod": "^3.25.76 || ^4.1.8" } }, "sha512-m9XTSWoODH2RM5OsZpaGiN7QRR8cdP5paBWq699Tu3JVmGPBKT8xF8XwV0ZBVVsjikD/JgWfak4VSsTR4wAVbg=="], - "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - "@orpc/client": ["@orpc/client@1.14.11", "", { "dependencies": { "@orpc/shared": "1.14.11", "@standardserver/core": "^0.5.0", "@standardserver/fetch": "^0.5.0", "@standardserver/peer": "^0.5.0" } }, "sha512-ZD2r8x07bycoqI/nGGFmIR1n4m/Mf2TsfVTPtuo5djKfWfSXmQPPAXhZsO6CN3eF+sl6g4sa5/fe1/T5zQoE1w=="], "@orpc/contract": ["@orpc/contract@1.14.11", "", { "dependencies": { "@orpc/client": "1.14.11", "@orpc/shared": "1.14.11", "@standard-schema/spec": "^1.1.0" } }, "sha512-TM5Bw5adKuxMUJWENYzVwaDVN8eamwF/+PV5xkCGCaKuyZXHlWCDFhoGOQN8ybbrEtlReT/QPUtDclLhM9Ny1A=="], @@ -2135,8 +2134,6 @@ "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], @@ -3059,7 +3056,7 @@ "playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], - "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], "plimit-lit": ["plimit-lit@1.6.1", "", { "dependencies": { "queue-lit": "^1.5.1" } }, "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA=="], @@ -3771,6 +3768,8 @@ "@develar/schema-utils/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "@e2e-dev/web/playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -3913,8 +3912,6 @@ "@smithy/util-utf8/@smithy/core": ["@smithy/core@3.29.2", "", { "dependencies": { "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-DXUk6yU0C1Q1tYvJh1VCtl8QOBcSoZpKwjTPkxT6A4MUQYHvgeKGByL8mrEdxnvhdf9nq5GyzmRb5n/vPgu3Lw=="], - "@storybook/test-runner/playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], @@ -4213,8 +4210,6 @@ "pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], - "playwright/playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], - "posthog-node/@posthog/core": ["@posthog/core@1.7.0", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-d6ZV4grpzeH/6/LP8quMVpSjY1puRkrqfwcPvGRKUAX7tb7YHyp/zMiTDuJmOFbpUxAMBXH5nDwcPiyCY2WGzA=="], "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], diff --git a/package.json b/package.json index 1439552d221..1f19b537ffa 100644 --- a/package.json +++ b/package.json @@ -221,6 +221,7 @@ "mitata": "1.0.34", "nodemon": "^3.1.10", "playwright": "^1.56.0", + "playwright-core": "1.57.0", "postcss": "^8.5.6", "posthog-js": "^1.276.0", "prettier": "^3.6.2", diff --git a/src/common/orpc/onChatCursorFingerprint.ts b/src/common/orpc/onChatCursorFingerprint.ts index c05b0a5bc2e..53f10b117ad 100644 --- a/src/common/orpc/onChatCursorFingerprint.ts +++ b/src/common/orpc/onChatCursorFingerprint.ts @@ -9,6 +9,24 @@ export const FNV_OFFSET_BASIS = 0x811c9dc5; const FNV_PRIME = 0x01000193; export const MISSING_TIMESTAMP = -1; +/** + * Prior-row order of both fingerprint twins. It must be a total order: persisted metadata is not + * schema-checked, so a sequence can be NaN, Infinity or a string, and `NaN || tiebreak` made the + * old comparator inconsistent. Array.prototype.sort leaves the result of an inconsistent + * comparator to the engine, and on Bun 1.4.3 the same call site sorted such rows differently once + * the JIT optimized it. Non-finite sequences therefore sort after every finite one, then by id. + */ +export function comparePriorHistoryOrder( + leftSequence: number, + leftId: string, + rightSequence: number, + rightId: string +): number { + const leftFinite = Number.isFinite(leftSequence); + if (leftFinite !== Number.isFinite(rightSequence)) return leftFinite ? -1 : 1; + return (leftFinite ? leftSequence - rightSequence : 0) || leftId.localeCompare(rightId); +} + export function updateFnv1a(hash: number, value: string): number { let next = hash >>> 0; for (let index = 0; index < value.length; index += 1) { @@ -59,8 +77,8 @@ export function computePriorHistoryFingerprint( return undefined; } - priorEntries.sort( - (left, right) => left.historySequence - right.historySequence || left.id.localeCompare(right.id) + priorEntries.sort((left, right) => + comparePriorHistoryOrder(left.historySequence, left.id, right.historySequence, right.id) ); let hash = FNV_OFFSET_BASIS; diff --git a/src/node/orpc/replayOwnership.test.ts b/src/node/orpc/replayOwnership.test.ts index 4fe5ee2783d..bfa161ef499 100644 --- a/src/node/orpc/replayOwnership.test.ts +++ b/src/node/orpc/replayOwnership.test.ts @@ -1,5 +1,5 @@ import { expect, test, spyOn } from "bun:test"; -import type { AsyncLocalStorage } from "node:async_hooks"; +import { AsyncLocalStorage } from "node:async_hooks"; import { EventEmitter } from "node:events"; import { createAgentSessionHarness } from "../services/agentSession.testHarness"; import { subscribeWorkspaceChat } from "./routerSubscriptions"; @@ -247,6 +247,21 @@ test("an aborted replay cannot publish its delayed window to another subscriber" } }); +// Bun 1.4.2 and 1.4.3: AsyncLocalStorage.disable() hides the store from synchronous code but not +// from a continuation that resumes after it. Node and Bun 1.3.12 hide it there too, and the +// product runs on Node, so the release is asserted only where the runtime implements it. +async function disableHidesResumedStore(): Promise { + const storage = new AsyncLocalStorage(); + const gate = Promise.withResolvers(); + const seen = storage.run({}, async () => { + await gate.promise; + return storage.getStore(); + }); + storage.disable(); + gate.resolve(); + return (await seen) === undefined; +} + test("disposing a held replay releases its async context without affecting a sibling session", async () => { const resumed = Promise.withResolvers(); const h = await setup(() => resumed.resolve(publication.getStore())); @@ -260,7 +275,8 @@ test("disposing a held replay releases its async context without affecting a sib await h.gates[0].entered.promise; await h.session.dispose(); h.gates[0].release.resolve(); - expect(await resumed.promise).toBeUndefined(); + const resumedStore = await resumed.promise; + if (await disableHidesResumedStore()) expect(resumedStore).toBeUndefined(); await held.caught; expect(held.events.some((event) => event.type === "stream-delta")).toBe(false); const surviving = sibling.subscribe(); diff --git a/src/node/services/priorHistoryFingerprintAsync.ts b/src/node/services/priorHistoryFingerprintAsync.ts index f06ce530d2c..51535cbdc60 100644 --- a/src/node/services/priorHistoryFingerprintAsync.ts +++ b/src/node/services/priorHistoryFingerprintAsync.ts @@ -1,4 +1,5 @@ import { + comparePriorHistoryOrder, FNV_OFFSET_BASIS, MISSING_TIMESTAMP, updateFnv1a, @@ -12,8 +13,11 @@ interface PriorHistoryRow { } function comparePriorHistoryRows(left: PriorHistoryRow, right: PriorHistoryRow): number { - return ( - left.historySequence - right.historySequence || left.message.id.localeCompare(right.message.id) + return comparePriorHistoryOrder( + left.historySequence, + left.message.id, + right.historySequence, + right.message.id ); } @@ -23,10 +27,8 @@ function comparePriorHistoryRows(left: PriorHistoryRow, right: PriorHistoryRow): * epoch), stalling heartbeats, IPC and other workspaces' replays (#4655). This version: * - yields to the event loop while collecting and while hashing rows; * - skips the sort when rows are already in comparator order (the normal case), because sorting - * 1.24M entries is itself a long block. Array.prototype.sort is stable, so skipping is exact for - * a consistent comparator. A non-finite sequence (persisted metadata is not schema-checked, so - * NaN, Infinity or a string can appear) turns the subtraction into NaN, which `||` sends to the - * id tiebreak, so the comparator is inconsistent: such histories always sort, like the reference; + * 1.24M entries is itself a long block. Array.prototype.sort is stable and the comparator is a + * total order (comparePriorHistoryOrder), so skipping is exact; * - hashes each row's pieces in order instead of one concatenated string (FNV-1a streams UTF-16 * code units, so the hash is identical), so it never holds every row's serialized parts at once. * @@ -52,11 +54,7 @@ export async function computePriorHistoryFingerprintAsync( continue; } const row = { historySequence, message }; - // Number.isFinite does not coerce, so it also rejects a persisted string sequence. - if ( - !Number.isFinite(historySequence) || - (rows.length > 0 && !(comparePriorHistoryRows(rows[rows.length - 1], row) <= 0)) - ) { + if (rows.length > 0 && comparePriorHistoryRows(rows[rows.length - 1], row) > 0) { inComparatorOrder = false; } rows.push(row); diff --git a/tests/bugbash/sandbox/inContainer.test.ts b/tests/bugbash/sandbox/inContainer.test.ts index 7eee6c1b79a..f7c926ac7d3 100644 --- a/tests/bugbash/sandbox/inContainer.test.ts +++ b/tests/bugbash/sandbox/inContainer.test.ts @@ -34,7 +34,9 @@ async function fakeRoot(over: { init?: string; devices?: string[]; socket?: bool fs.writeFileSync(path.join(root, "repo/.sandbox-nonce"), "nonce-1"); fs.mkdirSync(path.dirname(path.join(root, PROXY_SOCKET)), { recursive: true }); if (over.socket !== false) { - const server = net.createServer((socket) => socket.end("proxy says hi")); + // resume(): an unread socket never sees the peer's FIN, so server.close() would wait for + // it forever (Node, and Bun since 1.4; Bun 1.3.12 closed it anyway). + const server = net.createServer((socket) => socket.resume().end("proxy says hi")); await new Promise((resolve) => server.listen(path.join(root, PROXY_SOCKET), resolve)); cleanups.push(() => new Promise((done) => server.close(done))); } diff --git a/tests/bugbash/sandbox/runner.test.ts b/tests/bugbash/sandbox/runner.test.ts index 36dba741554..39262082bef 100644 --- a/tests/bugbash/sandbox/runner.test.ts +++ b/tests/bugbash/sandbox/runner.test.ts @@ -122,7 +122,10 @@ beforeEach(() => { afterEach(async () => { await Promise.all(sessions.splice(0).map((s) => s.cleanup())); process.env.PATH = savedPath; - process.env.TMPDIR = savedTmp; + // Assigning undefined stores the string "undefined" (Node, and Bun since 1.4), which + // os.tmpdir() then returns: unset the variable when CI never set it. + if (savedTmp === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = savedTmp; fs.rmSync(root, { recursive: true, force: true }); }); From 076f470711f6e7eb4b260d2b54f93b8bd35fe92e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 13 Oct 2026 10:21:17 +0000 Subject: [PATCH 3/5] tests: read the refused socket so closeServer() finishes on Bun 1.4 --- src/node/services/browser/agentBrowserCommandClient.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/node/services/browser/agentBrowserCommandClient.test.ts b/src/node/services/browser/agentBrowserCommandClient.test.ts index 9c15b112831..97e202b29ff 100644 --- a/src/node/services/browser/agentBrowserCommandClient.test.ts +++ b/src/node/services/browser/agentBrowserCommandClient.test.ts @@ -88,7 +88,9 @@ describe("sendAgentBrowserDaemonCommand", () => { const server = net.createServer((socket) => { connectionCount += 1; if (connectionCount === 1) { - socket.end(); + // resume(): an unread socket never sees the client's FIN, so closeServer() would wait + // for it forever (Node, and Bun since 1.4; Bun 1.3.12 closed it anyway). + socket.resume().end(); return; } From 21bd6d196735d50e37ba559a5533cf3c7d4b09db Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 13 Oct 2026 12:14:26 +0000 Subject: [PATCH 4/5] fix: adapt child and file streams without Readable.toWeb (Bun 1.4.3 late-data race) --- src/node/runtime/LocalBaseRuntime.ts | 16 +++--- src/node/utils/nodeReadableToWeb.test.ts | 70 +++++++++++++++++++++++ src/node/utils/nodeReadableToWeb.ts | 71 ++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 8 deletions(-) create mode 100644 src/node/utils/nodeReadableToWeb.test.ts create mode 100644 src/node/utils/nodeReadableToWeb.ts diff --git a/src/node/runtime/LocalBaseRuntime.ts b/src/node/runtime/LocalBaseRuntime.ts index ed45c85b84a..8bfb0b1ec01 100644 --- a/src/node/runtime/LocalBaseRuntime.ts +++ b/src/node/runtime/LocalBaseRuntime.ts @@ -2,7 +2,8 @@ import { spawn } from "child_process"; import * as fs from "fs"; import * as fsPromises from "fs/promises"; import * as path from "path"; -import { Readable, Writable } from "stream"; +import type { Readable } from "stream"; +import { Writable } from "stream"; import type { Runtime, @@ -35,6 +36,7 @@ import { getErrorMessage } from "@/common/utils/errors"; import { getAtomicWriteTempPath } from "./atomicWriteTempPath"; import { buildShellExport } from "./shellEnv"; import { sanitizeXumChildEnv } from "./childProcessEnv"; +import { nodeReadableToWeb } from "@/node/utils/nodeReadableToWeb"; /** * Acquire a regular file for ReadFileOptions.requireRegularFile. @@ -121,8 +123,7 @@ function wrapNodeReadable( start: async (controller: ReadableStreamDefaultController) => { try { const nodeStream = await acquire(); - // eslint-disable-next-line local/no-chained-type-assertions -- grandfathered when the rule was introduced; fix the underlying type instead of copying this pattern - const webStream = Readable.toWeb(nodeStream) as unknown as ReadableStream; + const webStream = nodeReadableToWeb(nodeStream); reader = webStream.getReader(); if (cancelled || abortSignal?.aborted) { // Landed while acquiring (reader did not exist yet): release the source now. @@ -283,11 +284,10 @@ export abstract class LocalBaseRuntime implements Runtime { // Wrap in DisposableProcess for automatic cleanup const disposable = new DisposableProcess(childProcess); - // Convert Node.js streams to Web Streams - // eslint-disable-next-line local/no-chained-type-assertions -- grandfathered when the rule was introduced; fix the underlying type instead of copying this pattern - const stdout = Readable.toWeb(childProcess.stdout) as unknown as ReadableStream; - // eslint-disable-next-line local/no-chained-type-assertions -- grandfathered when the rule was introduced; fix the underlying type instead of copying this pattern - const stderr = Readable.toWeb(childProcess.stderr) as unknown as ReadableStream; + // Convert Node.js streams to Web Streams. Not Readable.toWeb: on Bun 1.4.3 it can throw an + // uncaught error when the bash tool cancels a reader on output overflow (nodeReadableToWeb). + const stdout = nodeReadableToWeb(childProcess.stdout); + const stderr = nodeReadableToWeb(childProcess.stderr); // eslint-disable-next-line local/no-chained-type-assertions -- grandfathered when the rule was introduced; fix the underlying type instead of copying this pattern const stdin = Writable.toWeb(childProcess.stdin) as unknown as WritableStream; diff --git a/src/node/utils/nodeReadableToWeb.test.ts b/src/node/utils/nodeReadableToWeb.test.ts new file mode 100644 index 00000000000..8a4ce7c1bf8 --- /dev/null +++ b/src/node/utils/nodeReadableToWeb.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; +import { PassThrough } from "node:stream"; +import { nodeReadableToWeb } from "./nodeReadableToWeb"; + +async function readAll(stream: ReadableStream): Promise { + const reader = stream.getReader(); + let text = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) return text; + text += Buffer.from(value).toString(); + } +} + +describe("nodeReadableToWeb", () => { + test("delivers every byte, then closes when the source ends", async () => { + const source = new PassThrough(); + const web = nodeReadableToWeb(source); + source.write("hello "); + source.end("world"); + expect(await readAll(web)).toBe("hello world"); + }); + + // The Bun 1.4.3 race: a source `data` event after cancel. Readable.toWeb enqueues it on the + // closed controller and throws from the emitter, on every runtime. + test("cancel destroys the source and drops its late data without throwing", async () => { + const source = new PassThrough(); + const reader = nodeReadableToWeb(source).getReader(); + source.write("first"); + expect(Buffer.from((await reader.read()).value ?? []).toString()).toBe("first"); + await reader.cancel(); + expect(source.destroyed).toBe(true); + expect(() => source.emit("data", Buffer.from("late"))).not.toThrow(); + }); + + test("drops data that arrives after the source ended", async () => { + const source = new PassThrough(); + const web = nodeReadableToWeb(source); + source.end("done"); + expect(await readAll(web)).toBe("done"); + expect(() => source.emit("data", Buffer.from("late"))).not.toThrow(); + }); + + test("errors the web stream with the source's error, and a premature close as an AbortError", async () => { + const failing = new PassThrough(); + const failed = readAll(nodeReadableToWeb(failing)); + failing.destroy(new Error("pipe broke")); + expect(await failed.catch((error: unknown) => error)).toMatchObject({ message: "pipe broke" }); + + const closed = new PassThrough(); + const aborted = readAll(nodeReadableToWeb(closed)); + closed.destroy(); + expect(await aborted.catch((error: unknown) => error)).toMatchObject({ + name: "AbortError", + code: "ABORT_ERR", + }); + }); + + test("pauses the source while the web queue is full", async () => { + const source = new PassThrough({ highWaterMark: 4 }); + const reader = nodeReadableToWeb(source).getReader(); + source.write("abcdefgh"); + // The first read pulls; the 8-byte chunk fills the 4-byte queue, so the source pauses. + await reader.read(); + source.write("ijklmnop"); + await new Promise((resolve) => setImmediate(resolve)); + expect(source.isPaused()).toBe(true); + await reader.cancel(); + }); +}); diff --git a/src/node/utils/nodeReadableToWeb.ts b/src/node/utils/nodeReadableToWeb.ts new file mode 100644 index 00000000000..f146a0066a1 --- /dev/null +++ b/src/node/utils/nodeReadableToWeb.ts @@ -0,0 +1,71 @@ +import { finished, type Readable } from "node:stream"; +import assert from "@/common/utils/assert"; + +/** + * `Readable.toWeb` for a byte stream that ignores the source's late `data` events. + * + * Why not `Readable.toWeb`: on Bun 1.4.3 its adapter can still get a `data` event from a child's + * pipe after the web stream was cancelled or closed. Its listener then calls `enqueue` on a closed + * controller and throws "Invalid state: Controller is already closed" outside any caller: an + * uncaught exception. The bash tool hits it when it cancels its readers on output overflow, and the + * CLI (terminal-bench runs it on Bun) exits on an uncaught exception. Bun 1.3.12 and Node do not. + * + * Everything else follows Node's adapter (lib/internal/webstreams/adapters.js, + * newReadableStreamFromStreamReadable), so Node behavior does not change: pull resumes the source, + * the queue is byte-sized at the source's highWaterMark, each chunk is copied out of the pool, + * cancel destroys the source, and the source's end or error settles the web stream (a premature + * close as an AbortError). + */ +export function nodeReadableToWeb(source: Readable): ReadableStream { + assert( + !source.readableObjectMode && source.readableEncoding == null, + "nodeReadableToWeb adapts Buffer streams only" + ); + // Set by cancel or by the source's end/error: the controller then refuses enqueue. + let settled = false; + let controller: ReadableStreamDefaultController | undefined; + const stream = new ReadableStream( + { + start(c) { + controller = c; + }, + pull() { + source.resume(); + }, + cancel(reason: unknown) { + settled = true; + source.destroy(reason instanceof Error ? reason : undefined); + }, + }, + new ByteLengthQueuingStrategy({ highWaterMark: source.readableHighWaterMark }) + ); + assert(controller, "ReadableStream calls start synchronously"); + const ctrl = controller; + + finished(source, { writable: false }, (error) => { + if (settled) return; + settled = true; + if (error == null) { + ctrl.close(); + return; + } + // Node's adapter reports a premature close as an AbortError (handleKnownInternalErrors). + ctrl.error( + error.code === "ERR_STREAM_PREMATURE_CLOSE" + ? Object.assign(new Error("The operation was aborted", { cause: error }), { + name: "AbortError", + code: "ABORT_ERR", + }) + : error + ); + }); + + source.pause(); + source.on("data", (chunk: Buffer) => { + if (settled) return; + // Copy the Buffer to detach it from the pool, like Node's adapter. + ctrl.enqueue(new Uint8Array(chunk)); + if ((ctrl.desiredSize ?? 0) <= 0) source.pause(); + }); + return stream; +} From 0928afbff08789047595e4643b66d1501f80aa7c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 13 Oct 2026 12:18:03 +0000 Subject: [PATCH 5/5] refactor: extract the premature-close mapping (CRAP gate) --- src/node/utils/nodeReadableToWeb.ts | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/node/utils/nodeReadableToWeb.ts b/src/node/utils/nodeReadableToWeb.ts index f146a0066a1..7aa9a894632 100644 --- a/src/node/utils/nodeReadableToWeb.ts +++ b/src/node/utils/nodeReadableToWeb.ts @@ -16,6 +16,15 @@ import assert from "@/common/utils/assert"; * cancel destroys the source, and the source's end or error settles the web stream (a premature * close as an AbortError). */ +/** Node's adapter reports a premature close as an AbortError (handleKnownInternalErrors). */ +function toWebStreamError(error: NodeJS.ErrnoException): Error { + if (error.code !== "ERR_STREAM_PREMATURE_CLOSE") return error; + return Object.assign(new Error("The operation was aborted", { cause: error }), { + name: "AbortError", + code: "ABORT_ERR", + }); +} + export function nodeReadableToWeb(source: Readable): ReadableStream { assert( !source.readableObjectMode && source.readableEncoding == null, @@ -45,19 +54,8 @@ export function nodeReadableToWeb(source: Readable): ReadableStream finished(source, { writable: false }, (error) => { if (settled) return; settled = true; - if (error == null) { - ctrl.close(); - return; - } - // Node's adapter reports a premature close as an AbortError (handleKnownInternalErrors). - ctrl.error( - error.code === "ERR_STREAM_PREMATURE_CLOSE" - ? Object.assign(new Error("The operation was aborted", { cause: error }), { - name: "AbortError", - code: "ABORT_ERR", - }) - : error - ); + if (error == null) ctrl.close(); + else ctrl.error(toWebStreamError(error)); }); source.pause();