diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 2a9093395..37a3d1136 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -242,18 +242,39 @@ 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 + 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 @@ -262,10 +283,19 @@ 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 + 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