From 75a810b248a2e0e13f7c7072536b94a6b99ff499 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 26 Oct 2026 09:24:22 +0000 Subject: [PATCH 1/4] test(js): cover the Rosetta x86_64-apple-darwin runtime-callback lane `macos-latest` is arm64, so the shipped x86_64-apple-darwin binding only ever runs translated under Rosetta 2. Embedded Python/TypeScript runtime start-up costs several times native there, and publish-js.yml was the only workflow running that lane, so a timing regression on it could not surface until a release tag. That is what happened to v0.20.1: three `timeout aborts host work and permits reuse` cases failed on x86_64-apple-darwin while every native lane stayed green. `release-js` is gated on those test jobs, so both npm packages are still stuck on 0.19.0. This commit only adds the lane, so it reproduces the release failure in CI. The fix follows. --- .github/workflows/js.yml | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 2a9093395..0175e6f3e 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -242,17 +242,33 @@ jobs: ${{ matrix.run }} langchain_agent.mjs callback-platforms: - name: Runtime callbacks (${{ matrix.os }}) + name: Runtime callbacks (${{ matrix.name }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [macos-latest, windows-latest] + include: + - name: macos-latest + os: macos-latest + - name: windows-latest + os: windows-latest + # `macos-latest` is arm64, so the shipped x86_64-apple-darwin binding + # only ever runs translated under Rosetta 2, where embedded-runtime + # start-up costs several times native. publish-js.yml was the only + # place that lane ran, so a timing regression on it first surfaced at + # release: v0.20.1 went red here while every native lane was green, + # and `release-js` is gated on these jobs, so npm was never reached. + - name: macos-latest x86_64 (Rosetta) + os: macos-latest + target: x86_64-apple-darwin + architecture: x64 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + with: + targets: ${{ matrix.target }} - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: @@ -262,9 +278,14 @@ jobs: node-version: "24.22.1" cache: pnpm cache-dependency-path: crates/bashkit-js/pnpm-lock.yaml + architecture: ${{ matrix.architecture }} - run: pnpm install --frozen-lockfile working-directory: crates/bashkit-js - - run: pnpm run build + - if: ${{ !matrix.target }} + run: pnpm run build + working-directory: crates/bashkit-js + - if: ${{ matrix.target }} + run: pnpm exec napi build --platform --release --target ${{ matrix.target }} && pnpm run build:cjs && pnpm run build:ts working-directory: crates/bashkit-js - run: pnpm exec ava __test__/runtime-functions.spec.ts working-directory: crates/bashkit-js From ea3bd4e6238b61c06bb7744333d84b266789243e Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 26 Oct 2026 09:35:35 +0000 Subject: [PATCH 2/4] fix(js): size the host-callback timeout for the slowest shipped lane The four `timeout aborts host work and permits reuse` cases gave the whole execution 2s. That clock starts at `execute`, so it covers interpreter start-up on first use in the process and scheduling latency while the rest of the suite runs concurrently; only the remainder is the hang the cases mean to cancel. When it expires first the run is cancelled before the host callback is ever dispatched, so the handler never registers an abort listener and the case reports a missing abort rather than a slow start. On x86_64-apple-darwin, where the shipped Intel binding runs translated under Rosetta 2, 2s was not enough. In the v0.20.1 `Publish JS` run the first `python host functions` call alone took 3.7s, already past the entire budget, and three of the four variants failed there. The fourth passed only because it ran later against a warm runtime, and still took 4.7s of wall time for its 2s timeout. Raise the budget to 20s, well clear of both figures, and assert the two outcomes apart so a run cancelled before the callback starts can no longer read as an abort regression. The cases now wait out the full timeout by design, but ava runs them concurrently: runtime-functions.spec.ts goes from ~5s to ~22s locally, 75/75 passing, and the cost is one timeout rather than four. Also scope the Rosetta lane's Rust cache by target and match publish-js.yml's MACOSX_DEPLOYMENT_TARGET, so the cross build cannot pick up the arm64 lane's `target/`. --- .github/workflows/js.yml | 9 ++++++ .../__test__/runtime-functions.spec.ts | 31 +++++++++++++++++-- knowledge/runtimes/node-package.md | 14 ++++++++- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 0175e6f3e..37a3d1136 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -270,6 +270,11 @@ jobs: with: targets: ${{ matrix.target }} - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + # Keep the host and cross lanes in separate caches: they share a job + # and an OS, and a restored arm64 `target/` must not reach the x64 + # build. + prefix-key: v0-rust-${{ matrix.target || 'host' }} - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 10.33.0 @@ -287,6 +292,10 @@ jobs: - if: ${{ matrix.target }} run: pnpm exec napi build --platform --release --target ${{ matrix.target }} && pnpm run build:cjs && pnpm run build:ts working-directory: crates/bashkit-js + env: + # Match publish-js.yml, which is the other workflow building this + # target. + MACOSX_DEPLOYMENT_TARGET: "10.13" - run: pnpm exec ava __test__/runtime-functions.spec.ts working-directory: crates/bashkit-js - run: node --test __test__/runtime-compat/runtime-functions.test.mjs diff --git a/crates/bashkit-js/__test__/runtime-functions.spec.ts b/crates/bashkit-js/__test__/runtime-functions.spec.ts index 0ecd9c562..e94829aed 100644 --- a/crates/bashkit-js/__test__/runtime-functions.spec.ts +++ b/crates/bashkit-js/__test__/runtime-functions.spec.ts @@ -17,6 +17,27 @@ const request = new AsyncLocalStorage(); const key = new Uint8Array(32).fill(7); const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +// Budget for the cases that let a host callback hang until the execution +// timeout cancels it. The clock starts at `execute` and so covers everything +// that happens before the host callback is dispatched -- interpreter start-up +// on first use in the process, plus scheduling latency while the rest of the +// suite runs concurrently. Only what is left over is the hang these cases +// actually mean to cancel. Too small a budget expires first, the run is +// cancelled before the callback is ever invoked, and no abort listener is ever +// registered: correct behaviour, but not what is under test. +// +// 2s was too small on x86_64-apple-darwin -- the shipped Intel binding, which +// runs translated under Rosetta 2 because `macos-latest` is arm64. In the +// v0.20.1 `Publish JS` run on that lane the first `python host functions` call +// alone took 3.7s, already past the whole budget, and three of these four +// variants failed there. The one that passed ran later, against an already +// warm runtime, and still took 4.7s of wall time for its 2s timeout. Keep this +// well clear of both numbers. +// +// Raising it slows these cases down by design, since they wait out the full +// timeout, but ava runs them concurrently: the cost is one timeout, not four. +const HOST_TIMEOUT_MS = 20_000; + for (const language of ["python", "typescript"] as const) { const command = language === "python" @@ -171,8 +192,10 @@ for (const language of ["python", "typescript"] as const) { test(`${Factory.name}: ${language} timeout aborts host work and permits reuse`, async (t) => { let aborted = false; + let entered = false; const bash = new Factory({ ...options(async (_name, _args, _kwargs, ctx) => { + entered = true; await new Promise((_resolve, reject) => ctx.signal.addEventListener( "abort", @@ -184,11 +207,15 @@ for (const language of ["python", "typescript"] as const) { ), ); }), - timeoutMs: 2_000, + timeoutMs: HOST_TIMEOUT_MS, }); t.not((await bash.execute(call)).exitCode, 0); await delay(0); - t.true(aborted); + // Two different failures reach this point, and only one is about + // cancellation. Assert them apart so a run that timed out before the + // host callback started can never read as an abort regression. + t.true(entered, "host callback never ran; the timeout fired first"); + t.true(aborted, "host callback ran but was never aborted"); t.is((await bash.execute("echo reusable")).stdout, "reusable\n"); }); diff --git a/knowledge/runtimes/node-package.md b/knowledge/runtimes/node-package.md index 84bdbd9f8..495e6697d 100644 --- a/knowledge/runtimes/node-package.md +++ b/knowledge/runtimes/node-package.md @@ -54,7 +54,19 @@ async host-function shutdown contract. `runtime-functions.spec.ts` exercises real HTTP request contexts, aborts, timeout, queued calls, worker threads, adapters, reentry, bad values and snapshot/reset. `runtime-compat/runtime-functions.test.mjs` runs in Node, Bun and Deno. Dedicated -macOS and Windows CI runs the callback suite as well as Linux's runtime matrix. +macOS and Windows CI runs the callback suite as well as Linux's runtime matrix, +including the shipped x86_64-apple-darwin binding translated under Rosetta 2 on +the arm64 macOS runner. That is the slowest lane shipped; until v0.20.1 only +`publish-js.yml` ran it, so a timing regression there first surfaced at release +and blocked `release-js`, and with it both npm packages. + +Cases that cancel a hanging host callback must budget an execution timeout +covering interpreter start-up and scheduling latency before the callback is +dispatched, not just the hang itself. Too small a budget expires first, the run +is cancelled before the callback is invoked, and no abort listener is ever +registered, so the case reports a missing abort instead of a slow start. On the +Rosetta lane a first embedded-runtime call alone cost 3.7s, well past the 2s +budget that failed there; `HOST_TIMEOUT_MS` is kept far clear of it. ## Host runtime security From 6bf1fc24a8a0512304138f73f87242d0b4cfd775 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 26 Oct 2026 09:52:25 +0000 Subject: [PATCH 3/4] test(js): TEMPORARY - revert budget to 2s to prove the lane catches it Scratch commit. Reverted before merge. Verifies that the new Rosetta lane actually reproduces the v0.20.1 failure rather than passing for unrelated reasons: this lane runs only runtime-functions.spec.ts, while publish-js.yml runs the whole suite under far heavier concurrency. --- crates/bashkit-js/__test__/runtime-functions.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bashkit-js/__test__/runtime-functions.spec.ts b/crates/bashkit-js/__test__/runtime-functions.spec.ts index e94829aed..49bf2c223 100644 --- a/crates/bashkit-js/__test__/runtime-functions.spec.ts +++ b/crates/bashkit-js/__test__/runtime-functions.spec.ts @@ -36,7 +36,7 @@ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // // Raising it slows these cases down by design, since they wait out the full // timeout, but ava runs them concurrently: the cost is one timeout, not four. -const HOST_TIMEOUT_MS = 20_000; +const HOST_TIMEOUT_MS = 2_000; for (const language of ["python", "typescript"] as const) { const command = From b4d9e700a21a1ed56f3ff4fbe149c2c04a65d6d9 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 26 Oct 2026 10:10:27 +0000 Subject: [PATCH 4/4] test(js): restore the 20s budget after proving the lane catches the 2s regression Reverts the scratch commit. With HOST_TIMEOUT_MS back at 2s the new Rosetta lane failed exactly as the v0.20.1 release did, and the new assertion named the cause outright: x [fail]: Bash: typescript timeout aborts host work and permits reuse host callback never ran; the timeout fired first x [fail]: BashTool: typescript timeout aborts host work and permits reuse host callback never ran; the timeout fired first / Bash: python timeout aborts host work and permits reuse (3s) / BashTool: python timeout aborts host work and permits reuse (3s) So `entered` is false: the run is cancelled before the interpreter dispatches the host callback, which is the root cause rather than any abort-delivery race. Same split as the release failure, where some variants pass and some lose the race. At 20s all four pass on that lane. --- crates/bashkit-js/__test__/runtime-functions.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bashkit-js/__test__/runtime-functions.spec.ts b/crates/bashkit-js/__test__/runtime-functions.spec.ts index 49bf2c223..e94829aed 100644 --- a/crates/bashkit-js/__test__/runtime-functions.spec.ts +++ b/crates/bashkit-js/__test__/runtime-functions.spec.ts @@ -36,7 +36,7 @@ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // // Raising it slows these cases down by design, since they wait out the full // timeout, but ava runs them concurrently: the cost is one timeout, not four. -const HOST_TIMEOUT_MS = 2_000; +const HOST_TIMEOUT_MS = 20_000; for (const language of ["python", "typescript"] as const) { const command =