From 7ff85d1613cd215671281d10ee066bd61324641e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 22:14:18 +0000 Subject: [PATCH 01/14] fix(test): sequence script-playground drain scenarios instead of racing fixed timers Three contention races in one file. The timeout-drain test armed a 100ms service timeout against two sequential Node process startups, so under CPU contention the tree kill landed before the descendant wrote its ready file; the termination now waits for observable descendant readiness through the processTree seam before delegating to the real cleanup. The wrapper scripts published descendant pids with a plain writeFile, so a poll could read the created-but-empty file, parse Number('') === 0, and probe the test runner's own process group; pid files now appear atomically via staged rename. The file's eventually() budget was a fixed 500ms; it now follows the suite time scale like every other polling budget. Under a 4-busy-loop taskset reproducer the unfixed file failed 12 of 12 completed runs; the fixed file failed 0. --- .../tests/script-playground-service.test.ts | 57 ++++++++++++++++--- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/packages/agent-bundle/tests/script-playground-service.test.ts b/packages/agent-bundle/tests/script-playground-service.test.ts index 9ea13c2c1..fb1c247a5 100644 --- a/packages/agent-bundle/tests/script-playground-service.test.ts +++ b/packages/agent-bundle/tests/script-playground-service.test.ts @@ -7,6 +7,7 @@ import { join } from 'node:path'; import { expect, it } from '@rstest/core'; import { ScriptPlaygroundService } from '../src/dev/playground/script-playground-service.ts'; +import { taskkill, terminateProcessTree, waitForProcessTreeExit } from '../src/services/process-tree.ts'; import { timeScale } from './support/time-scale.ts'; const temporaryScript = async (source: string): Promise Promise; readonly path: string }>> => { @@ -18,7 +19,10 @@ const temporaryScript = async (source: string): Promise Promise | void): Promise => { let failure: unknown; - for (let attempt = 0; attempt < 50; attempt += 1) { + // Child-process startup is what these polls usually wait on, and it slows + // roughly with worker contention, so the budget follows the suite's + // time-scale convention. + for (let attempt = 0; attempt < 100 * timeScale; attempt += 1) { try { await assertion(); return; @@ -392,11 +396,17 @@ it('reports a stable interpreter-unavailable failure without exposing a command it('cancels and drains the emitted script process group before its workspace is released', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-script-playground-tree-')); const pidPath = join(root, 'descendant.pid'); + // The pid file must appear atomically (write staged, then rename): a plain + // writeFile creates the file before its bytes land, and a poll that reads + // the empty window parses Number('') === 0 — and process.kill(0, 0) probes + // this test runner's own process group, which always exists. + const pidStagingPath = `${pidPath}.staging`; const emitted = await temporaryScript([ "import { spawn } from 'node:child_process';", - "import { writeFile } from 'node:fs/promises';", + "import { rename, writeFile } from 'node:fs/promises';", `const descendant = spawn(process.execPath, ['--eval', 'setInterval(() => undefined, 1_000)'], { stdio: 'ignore' });`, - `await writeFile(${JSON.stringify(pidPath)}, String(descendant.pid));`, + `await writeFile(${JSON.stringify(pidStagingPath)}, String(descendant.pid));`, + `await rename(${JSON.stringify(pidStagingPath)}, ${JSON.stringify(pidPath)});`, 'setInterval(() => undefined, 1_000);', '', ].join('\n')); @@ -438,9 +448,11 @@ it('keeps SIGKILL process-group cleanup alive after the direct child closes', as const descendantProgram = "require('node:fs').writeFileSync(" + JSON.stringify(readyPath) + ", 'ready'); process.on('SIGTERM', () => undefined); setInterval(() => undefined, 1_000);"; const emitted = await temporaryScript([ "import { spawn } from 'node:child_process';", - "import { writeFile } from 'node:fs/promises';", + "import { rename, writeFile } from 'node:fs/promises';", 'const descendant = spawn(process.execPath, [\'--eval\', ' + JSON.stringify(descendantProgram) + '], { stdio: \'ignore\' });', - 'await writeFile(' + JSON.stringify(pidPath) + ', String(descendant.pid));', + // Staged rename: the pid file must never be observable empty. + 'await writeFile(' + JSON.stringify(`${pidPath}.staging`) + ', String(descendant.pid));', + 'await rename(' + JSON.stringify(`${pidPath}.staging`) + ', ' + JSON.stringify(pidPath) + ');', 'setInterval(() => undefined, 1_000);', '', ].join('\n')); @@ -480,18 +492,47 @@ const assertStubbornDescendantIsGoneAtSettlement = async ( const descendantProgram = "require('node:fs').writeFileSync(" + JSON.stringify(readyPath) + ", 'ready'); process.on('SIGTERM', () => undefined); setInterval(() => undefined, 1_000);"; const emitted = await temporaryScript([ "import { spawn } from 'node:child_process';", - "import { readFile, writeFile } from 'node:fs/promises';", + "import { readFile, rename, writeFile } from 'node:fs/promises';", 'const descendant = spawn(process.execPath, [\'--eval\', ' + JSON.stringify(descendantProgram) + '], { stdio: \'ignore\' });', - 'await writeFile(' + JSON.stringify(pidPath) + ', String(descendant.pid));', + // Staged rename: the pid file must never be observable empty. + 'await writeFile(' + JSON.stringify(`${pidPath}.staging`) + ', String(descendant.pid));', + 'await rename(' + JSON.stringify(`${pidPath}.staging`) + ', ' + JSON.stringify(pidPath) + ');', 'while (true) { try { await readFile(' + JSON.stringify(readyPath) + '); break; } catch { await new Promise((resolvePromise) => setTimeout(resolvePromise, 1)); } }', trigger === 'output-limit' ? "process.stdout.write('x'.repeat(512));" : 'setInterval(() => undefined, 1_000);', trigger === 'output-limit' ? 'setInterval(() => undefined, 1_000);' : '', '', ].join('\n')); + // The service arms its timeout timer the moment it spawns the wrapper, so a + // fixed timeoutMs races the descendant's startup (two sequential Node + // process launches) under CPU contention: the tree kill can land before the + // descendant installs its SIGTERM handler and writes the ready file, and + // the test then polls for a file that will never exist. Sequence the + // scenario instead of racing it: hold the first termination signal until + // the descendant is observably ready, then delegate to the same + // process-tree cleanup the service uses in production. + const descendantReady = async (): Promise => { + for (;;) { + try { + await readFile(readyPath); + return; + } catch { + await new Promise((resolvePromise) => { setTimeout(resolvePromise, 10); }); + } + } + }; + const readinessGatedProcessTree = Object.freeze({ + terminate: async (child: ChildProcess, signal: NodeJS.Signals): Promise => { + await descendantReady(); + return terminateProcessTree(child, signal, { onTreeTerminationFailure: () => undefined, platform: process.platform, taskkill }); + }, + // Mirrors the service's default exit-settlement parameters. + waitForExit: (child: ChildProcess): Promise => + waitForProcessTreeExit(child, { platform: process.platform, pollMilliseconds: 10, timeoutMilliseconds: 250 }), + }); let descendant: number | undefined; try { const service = new ScriptPlaygroundService({ - ...(trigger === 'output-limit' ? { outputLimit: 128 } : { timeoutMs: 100 }), + ...(trigger === 'output-limit' ? { outputLimit: 128 } : { processTree: readinessGatedProcessTree, timeoutMs: 100 }), resolveScript: async () => Object.freeze({ interpreter: Object.freeze({ args: Object.freeze([]), command: process.execPath }), name: 'review', path: emitted.path, }), From 5130a0ee1174e82e3c6e11fb792a090f69b6cc85 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 22:14:18 +0000 Subject: [PATCH 02/14] fix(test): build the rsc-agent-runtime example payload once before pool workers start Four e2e files copy the shared examples/rsc-agent-runtime/dist tree through runtime-playground-fixture.ts, and its ensure-build ran inside whichever worker got there first. On a cold tree (every CI runner) two parallel workers could race the same build and one could copy a torn payload. The ensure now also runs as the integration pool's globalSetup, once in the orchestrator, before any worker exists; the per-fixture call remains as a warm no-op for single-worker configs. --- .../tests/helpers/runtime-example-payload.ts | 37 +++++++++++++++++++ .../helpers/runtime-playground-fixture.ts | 23 +----------- rstest.integration.setup.ts | 13 +++++++ 3 files changed, 52 insertions(+), 21 deletions(-) create mode 100644 packages/workbench/tests/helpers/runtime-example-payload.ts create mode 100644 rstest.integration.setup.ts diff --git a/packages/workbench/tests/helpers/runtime-example-payload.ts b/packages/workbench/tests/helpers/runtime-example-payload.ts new file mode 100644 index 000000000..1fc9be484 --- /dev/null +++ b/packages/workbench/tests/helpers/runtime-example-payload.ts @@ -0,0 +1,37 @@ +import { execFile as executeFile } from 'node:child_process'; +import { access } from 'node:fs/promises'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +const execFile = promisify(executeFile); +const workspaceRoot = process.cwd(); +const runtimeExample = join(workspaceRoot, 'examples', 'rsc-agent-runtime'); + +/** The example's prebuilt payload directories its declared artifacts package. */ +export const runtimeExamplePayloads = ['app', 'runtime'] as const; + +/** + * The rsc-agent-runtime example declares its Rsbuild output trees as prebuilt + * payloads, so the workbench dev artifact epoch needs them to exist. Build + * them once when absent (Rsbuild only — the framework packaging step is what + * the fixtures exercise live). + * + * This must not run concurrently with itself: two racing builds write the + * same `examples/rsc-agent-runtime/dist` tree, and a fixture copying that + * tree mid-build ships a torn payload. The integration pool therefore runs it + * once in the orchestrator via `globalSetup` (rstest.integration.setup.ts) + * before any worker starts; the per-fixture call in + * runtime-playground-fixture.ts is then a warm no-op and only builds when a + * file is run through a single-worker config with a cold tree. + */ +export const ensureRuntimeExamplePayload = async (): Promise => { + const probes = await Promise.allSettled(runtimeExamplePayloads.map(async (payload) => + access(join(runtimeExample, 'dist', payload)))); + if (probes.every((probe) => probe.status === 'fulfilled')) return; + const { RSTEST: _rstest, ...environment } = process.env; + await execFile('pnpm', ['--filter', '@agent-bundle/rsc-agent-runtime-demo', 'exec', 'rsbuild', 'build', '--mode', 'production'], { + cwd: workspaceRoot, + env: { ...environment, NODE_ENV: 'production' }, + maxBuffer: 64 * 1024 * 1024, + }); +}; diff --git a/packages/workbench/tests/helpers/runtime-playground-fixture.ts b/packages/workbench/tests/helpers/runtime-playground-fixture.ts index d0101844c..54d51e09a 100644 --- a/packages/workbench/tests/helpers/runtime-playground-fixture.ts +++ b/packages/workbench/tests/helpers/runtime-playground-fixture.ts @@ -1,5 +1,5 @@ import { execFile as executeFile } from 'node:child_process'; -import { access, cp, mkdtemp, rm, symlink } from 'node:fs/promises'; +import { cp, mkdtemp, rm, symlink } from 'node:fs/promises'; import { join } from 'node:path'; import { promisify } from 'node:util'; @@ -8,6 +8,7 @@ import type { ProjectEventHub } from '../../../agent-bundle/src/dev/events.ts'; import { startForegroundServer, type ForegroundProjectEventStreamHandle } from '../../../agent-bundle/src/dev/foreground-server.ts'; import type { DevRuntimeClientSurfaceProxyBinding } from '../../../agent-bundle/src/dev/runtime-provider.ts'; import { startDevServer } from '../../../agent-bundle/src/dev/workbench-server.ts'; +import { ensureRuntimeExamplePayload, runtimeExamplePayloads } from './runtime-example-payload.ts'; const execFile = promisify(executeFile); const workspaceRoot = process.cwd(); @@ -44,26 +45,6 @@ const buildWorkbench = async (): Promise => { }); }; -/** The example's prebuilt payload directories its declared artifacts package. */ -const runtimeExamplePayloads = ['app', 'runtime'] as const; - -/** - * The example declares its Rsbuild output trees as prebuilt payloads, so the - * workbench dev artifact epoch needs them to exist. Build them once when - * absent (Rsbuild only — the framework packaging step is what the fixture - * exercises live). - */ -const ensureRuntimeExamplePayload = async (): Promise => { - const probes = await Promise.allSettled(runtimeExamplePayloads.map(async (payload) => - access(join(runtimeExample, 'dist', payload)))); - if (probes.every((probe) => probe.status === 'fulfilled')) return; - const { RSTEST: _rstest, ...environment } = process.env; - await execFile('pnpm', ['--filter', '@agent-bundle/rsc-agent-runtime-demo', 'exec', 'rsbuild', 'build', '--mode', 'production'], { - cwd: workspaceRoot, - env: { ...environment, NODE_ENV: 'production' }, - maxBuffer: 64 * 1024 * 1024, - }); -}; /** Starts the real RSC example in an isolated workspace-local copy. */ export const startRuntimePlaygroundFixture = async ( diff --git a/rstest.integration.setup.ts b/rstest.integration.setup.ts new file mode 100644 index 000000000..0e04bb2cb --- /dev/null +++ b/rstest.integration.setup.ts @@ -0,0 +1,13 @@ +import { ensureRuntimeExamplePayload } from './packages/workbench/tests/helpers/runtime-example-payload.ts'; + +/** + * Builds the rsc-agent-runtime example's prebuilt payload trees once, in the + * orchestrator, before any pool worker starts. Four e2e files copy that + * shared `examples/rsc-agent-runtime/dist` tree through + * runtime-playground-fixture.ts; on a cold tree (every CI runner) two + * parallel workers would otherwise race the same ensure-build and one of + * them could copy a torn payload. + */ +export const setup = async (): Promise => { + await ensureRuntimeExamplePayload(); +}; From 73b3ad6ac8b69940aef9a9073c446b51dee6bd1f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 22:14:18 +0000 Subject: [PATCH 03/14] perf(test): derive CI integration workers from cores like local (remove the serial pin) Hosted runners report 4 cores, so CI now runs the integration pool with 2 workers instead of 1. The rotating contention flakes that motivated the pin were fixed at their sources (readiness sequencing, staged-rename pid and watched-file publications, orchestrator-owned cold builds, ephemeral ports, time-scaled budgets); the config comment records that history and the no-re-pin policy. mcp-app-real's request/response waiters also follow the suite time scale now instead of a fixed 30s default that ignored worker contention. --- .../workbench/tests/mcp-app-real.e2e.test.ts | 16 ++++----- rstest.integration.config.ts | 33 ++++++++++++------- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/packages/workbench/tests/mcp-app-real.e2e.test.ts b/packages/workbench/tests/mcp-app-real.e2e.test.ts index 5dead3b7f..260e8a3b3 100644 --- a/packages/workbench/tests/mcp-app-real.e2e.test.ts +++ b/packages/workbench/tests/mcp-app-real.e2e.test.ts @@ -252,7 +252,7 @@ e2e('runs a generated SDK-v2 App through the real foreground session and separat await page.locator('#mcp-target').selectOption('portable'); await page.locator('#mcp-server-name').fill('fixture'); const opened = page.waitForResponse((response) => - response.url() === `${foregroundOrigin}/api/mcp/sessions` && response.request().method() === 'POST'); + response.url() === `${foregroundOrigin}/api/mcp/sessions` && response.request().method() === 'POST', { timeout: 30_000 * timeScale }); await page.getByRole('button', { name: 'Open MCP session' }).click(); const openedResponse = await opened; const foregroundToken = await openedResponse.request().headerValue('x-agent-bundle-session'); @@ -278,7 +278,7 @@ e2e('runs a generated SDK-v2 App through the real foreground session and separat expect({ request: invocation.request, result: invocation.result }).toEqual({ request: originalInput, result: originalResult }); const createdPreview = page.waitForRequest((request) => - request.url() === `${foregroundOrigin}/api/mcp/sessions/${openedSession.session.id}/apps` && request.method() === 'POST'); + request.url() === `${foregroundOrigin}/api/mcp/sessions/${openedSession.session.id}/apps` && request.method() === 'POST', { timeout: 30_000 * timeScale }); await page.getByRole('button', { name: 'Open App preview for mcp-page-1' }).click(); const createRequest = requestBody((await createdPreview).postData()) as Readonly<{ readonly input: unknown; @@ -410,7 +410,7 @@ e2e('runs a generated SDK-v2 App through the real foreground session and separat }), { timeout: browserTimeout }).toBe(true); expect(await appFrame.content()).not.toContain(foregroundToken); - const firstClose = page.waitForRequest((request) => request.url().startsWith(`${foregroundOrigin}/api/mcp/apps/`) && request.url().endsWith('/close')); + const firstClose = page.waitForRequest((request) => request.url().startsWith(`${foregroundOrigin}/api/mcp/apps/`) && request.url().endsWith('/close'), { timeout: 30_000 * timeScale }); await page.getByRole('button', { name: 'Close App preview' }).click(); const firstCloseBody = requestBody((await firstClose).postData()) as Readonly<{ readonly id: string }>; await expect.poll(() => appRequests.some((request) => { @@ -424,9 +424,9 @@ e2e('runs a generated SDK-v2 App through the real foreground session and separat await page.getByRole('button', { name: 'Open App preview for mcp-page-1' }).click(); await expect(outerFrame).toBeVisible({ timeout: browserTimeout }); - const secondClose = page.waitForRequest((request) => request.url().startsWith(`${foregroundOrigin}/api/mcp/apps/`) && request.url().endsWith('/close')); + const secondClose = page.waitForRequest((request) => request.url().startsWith(`${foregroundOrigin}/api/mcp/apps/`) && request.url().endsWith('/close'), { timeout: 30_000 * timeScale }); const closedSession = page.waitForRequest((request) => - request.url() === `${foregroundOrigin}/api/mcp/sessions/${openedSession.session.id}` && request.method() === 'DELETE'); + request.url() === `${foregroundOrigin}/api/mcp/sessions/${openedSession.session.id}` && request.method() === 'DELETE', { timeout: 30_000 * timeScale }); await page.getByRole('button', { name: 'Close MCP session' }).click(); await secondClose; await closedSession; @@ -592,9 +592,9 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', await page.getByRole('radio', { name: 'Raw JSON' }).check(); await page.locator('#runtime-input-raw').fill('{}'); const createRequest = page.waitForRequest((request) => - request.url() === `${fixture.url}/api/runtime/apps` && request.method() === 'POST'); + request.url() === `${fixture.url}/api/runtime/apps` && request.method() === 'POST', { timeout: 30_000 * timeScale }); const createResponse = page.waitForResponse((response) => - response.url() === `${fixture.url}/api/runtime/apps` && response.request().method() === 'POST'); + response.url() === `${fixture.url}/api/runtime/apps` && response.request().method() === 'POST', { timeout: 30_000 * timeScale }); await page.getByRole('button', { name: 'Run', exact: true }).click(); const [createdRequest, createdResponse] = await Promise.all([createRequest, createResponse]); const history = page.getByRole('region', { name: 'Runtime run history' }).locator('ol > li'); @@ -1691,7 +1691,7 @@ e2e('renders a compiler-bundled App template through the canonical sandbox URL', const request = response.request(); const url = new URL(response.url()); return request.method() === 'DELETE' && url.origin === foregroundOrigin && /^\/api\/mcp\/apps\/[^/]+$/u.test(url.pathname); - }); + }, { timeout: 30_000 * timeScale }); await page.getByRole('button', { name: 'Close App preview' }).click(); expect((await fallbackClosed).status()).toBe(200); await expect(outerFrame).toBeHidden({ timeout: browserTimeout }); diff --git a/rstest.integration.config.ts b/rstest.integration.config.ts index d5b3d89a2..74a7bfb7d 100644 --- a/rstest.integration.config.ts +++ b/rstest.integration.config.ts @@ -6,22 +6,27 @@ import { integrationTestFiles } from './rstest.integration-tests.ts'; import { withAgentBundleRslibConfig } from './rstest.rslib.ts'; /** - * Worker count for the parallel integration pool. Half the cores keeps - * browser + dev-server pairs from starving each other and the cap of 4 bounds - * memory on large machines. CI pins one worker explicitly: hosted runners - * report 4 cores (which would compute 2 workers), but each Chrome + - * dev-server + rsbuild pair already saturates them, and 2-worker matrix runs - * flaked on a rotating test per leg even at timeScale 4. Parallelism is a - * development-machine speedup; CI keeps the serialized shape it was tuned - * for. AGENT_BUNDLE_INTEGRATION_MAX_WORKERS overrides the computed value - * (e.g. to measure a parallel CI run or bisect locally in serial). + * Worker count for the parallel integration pool, CI and local alike: half + * the cores (hosted runners report 4, so CI runs 2 workers), clamped to at + * least 1 and at most 4 — halving keeps browser + dev-server pairs from + * starving each other and the cap bounds memory on large machines. + * + * History: CI briefly pinned 1 worker because early 2-worker matrix runs + * flaked on a rotating test per leg. The causes were since fixed at the + * source rather than by keeping the serial shape: contention-sensitive tests + * now sequence readiness instead of racing fixed timers (e.g. the + * script-playground descendant-drain suites), shared cold artifacts are + * built once in the orchestrator (see globalSetup below), watched-file and + * pid publications use staged renames, dev ports are ephemeral, and polling + * budgets follow AGENT_BUNDLE_TEST_TIME_SCALE. Burn-ins of the 2-worker, + * 4-core CI shape back the unpin; if a new contention flake appears, fix its + * race — do not re-pin. AGENT_BUNDLE_INTEGRATION_MAX_WORKERS overrides the + * computed value (e.g. to bisect locally in serial). */ const overrideWorkers = Number(process.env['AGENT_BUNDLE_INTEGRATION_MAX_WORKERS'] ?? ''); const maxWorkers = Number.isSafeInteger(overrideWorkers) && overrideWorkers >= 1 ? overrideWorkers - : process.env['CI'] !== undefined - ? 1 - : Math.max(1, Math.min(4, Math.floor(availableParallelism() / 2))); + : Math.max(1, Math.min(4, Math.floor(availableParallelism() / 2))); /** * Polling budgets scale with contention. A multi-worker pool needs at least @@ -45,6 +50,10 @@ const timeScale = Number.isSafeInteger(externalTimeScale) && externalTimeScale > export default defineConfig({ extends: withAgentBundleRslibConfig(), include: [...integrationTestFiles], + // Builds the rsc-agent-runtime example payload once before workers start; + // parallel workers must never race that shared ensure-build (see + // rstest.integration.setup.ts). + globalSetup: ['./rstest.integration.setup.ts'], pool: { maxWorkers }, // Concurrent Chrome + dev-server + rsbuild pairs contend for cores, so // parallel runs double the polling budgets (see tests/support/time-scale.ts) From 7578a38aa31a7834a87af479a3beadd21ab0e639 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:50:51 +0000 Subject: [PATCH 04/14] perf(test): drop rstest maxWorkers pins and isolate shared roots per RSTEST_WORKER_ID --- .../tests/dev-workbench-packaging.test.ts | 47 ++++++++++--------- .../tests/helpers/project-fixture.ts | 5 +- .../tests/public-api-packed.test.ts | 13 ++--- .../agent-bundle/tests/release-audit.test.ts | 3 +- .../rsc-runtime-optional-packaging.test.ts | 11 +++-- .../agent-bundle/tests/support/shared-pack.ts | 12 +++-- .../agent-bundle/tests/support/time-scale.ts | 5 +- rstest.config.ts | 4 +- rstest.integration-tests.ts | 4 +- rstest.integration.config.ts | 44 +++++++---------- rstest.packed.config.ts | 9 ++-- rstest.runtime-playground.browser.config.ts | 2 +- rstest.runtime-playground.config.ts | 2 +- rstest.setup.ts | 3 ++ rstest.unit.config.ts | 3 ++ rstest.worker-isolation.ts | 46 ++++++++++++++++++ 16 files changed, 133 insertions(+), 80 deletions(-) create mode 100644 rstest.setup.ts create mode 100644 rstest.worker-isolation.ts diff --git a/packages/agent-bundle/tests/dev-workbench-packaging.test.ts b/packages/agent-bundle/tests/dev-workbench-packaging.test.ts index 326ffde08..e82454bac 100644 --- a/packages/agent-bundle/tests/dev-workbench-packaging.test.ts +++ b/packages/agent-bundle/tests/dev-workbench-packaging.test.ts @@ -1,5 +1,5 @@ import { execFile as executeFile } from 'node:child_process'; -import { access, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { access, cp, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -16,13 +16,7 @@ const workbenchRoot = join(workspaceRoot, 'packages', 'workbench'); const appRendererLicense = join('src', 'mcp', 'APP-RENDERER-LICENSE'); let built: Promise | undefined; -const buildPackage = async (force = false): Promise => { - if (force) { - // The stale-asset pruning test rebuilds on purpose; the prebuilt seam - // never skips it because the rebuild itself is the behavior under test. - await execFile('pnpm', ['build'], { cwd: workspaceRoot }); - return; - } +const buildPackage = async (): Promise => { if (process.env['AGENT_BUNDLE_PACKAGE_PREBUILT'] === '1') return; built ??= execFile('pnpm', ['build'], { cwd: workspaceRoot }).then(() => undefined); await built; @@ -57,17 +51,26 @@ it('copies stable prebuilt workbench assets and the exact app-renderer license i it('prunes stale copied workbench assets without removing the package library output', async () => { await buildPackage(); - const workbench = join(packageRoot, 'dist', 'workbench'); - const stale = join(workbench, 'static', 'js', 'async', 'stale-nested.js'); - await mkdir(join(workbench, 'static', 'js', 'async'), { recursive: true }); - await writeFile(stale, 'obsolete workbench output\n'); - await expect(access(stale)).resolves.toBeUndefined(); - - await buildPackage(true); - - await expect(access(stale)).rejects.toThrow(); - await expect(access(join(packageRoot, 'dist', 'cli.js'))).resolves.toBeUndefined(); - expect(await readdir(workbench, { recursive: true })).not.toContain('index.js.map'); + const isolatedRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-workbench-prune-')); + const isolatedDist = join(isolatedRoot, 'dist'); + try { + await cp(join(packageRoot, 'dist'), isolatedDist, { recursive: true }); + const workbench = join(isolatedDist, 'workbench'); + const stale = join(workbench, 'static', 'js', 'async', 'stale-nested.js'); + await mkdir(join(workbench, 'static', 'js', 'async'), { recursive: true }); + await writeFile(stale, 'obsolete workbench output\n'); + await expect(access(stale)).resolves.toBeUndefined(); + await execFile(join(workspaceRoot, 'node_modules', '.bin', 'rslib'), [ + 'build', + '--config', join(packageRoot, 'rslib.config.ts'), + '--dist-path', isolatedDist, + ], { cwd: workspaceRoot }); + await expect(access(stale)).rejects.toThrow(); + await expect(access(join(isolatedDist, 'cli.js'))).resolves.toBeUndefined(); + expect(await readdir(workbench, { recursive: true })).not.toContain('index.js.map'); + } finally { + await rm(isolatedRoot, { force: true, recursive: true }); + } }, 60_000); it('serves prebuilt workbench assets from an installed tarball without the repository source tree', async () => { @@ -83,7 +86,7 @@ it('serves prebuilt workbench assets from an installed tarball without the repos expect(listing.stdout).not.toMatch(/package\/dist\/workbench\/.*-[a-f0-9]{8,}/iu); await writeFile(join(consumer, 'package.json'), '{"type":"module"}\n'); - await execFile('npm', ['install', ...npmInstallArguments, tarball], { cwd: consumer }); + await execFile('npm', ['install', ...npmInstallArguments, tarball], { cwd: consumer, env: installedEnvironment() }); await mkdir(join(project, 'skills', 'review'), { recursive: true }); await Promise.all([ writeFile(join(project, 'package.json'), '{"type":"module"}\n'), @@ -99,7 +102,7 @@ it('serves prebuilt workbench assets from an installed tarball without the repos ' console.log(JSON.stringify({ body: await response.text(), status: response.status }));', '} finally { await session.close(); }', ].join('\n'); - const served = await execFile(process.execPath, ['--input-type=module', '--eval', script], { cwd: consumer }); + const served = await execFile(process.execPath, ['--input-type=module', '--eval', script], { cwd: consumer, env: installedEnvironment() }); expect(JSON.parse(served.stdout)).toMatchObject({ body: expect.stringContaining('Agent Bundle workbench'), status: 200, @@ -115,7 +118,7 @@ it('runs the Agent API from an omit-dev installed tarball with its runtime MCP d const project = join(consumer, 'project'); try { await writeFile(join(consumer, 'package.json'), '{"type":"module"}\n'); - await execFile('npm', ['install', '--omit=dev', ...npmInstallArguments, tarball], { cwd: consumer }); + await execFile('npm', ['install', '--omit=dev', ...npmInstallArguments, tarball], { cwd: consumer, env: installedEnvironment() }); await mkdir(join(project, 'skills', 'review'), { recursive: true }); await Promise.all([ writeFile(join(project, 'package.json'), '{"type":"module"}\n'), diff --git a/packages/agent-bundle/tests/helpers/project-fixture.ts b/packages/agent-bundle/tests/helpers/project-fixture.ts index 6393a6fcf..4452fdb09 100644 --- a/packages/agent-bundle/tests/helpers/project-fixture.ts +++ b/packages/agent-bundle/tests/helpers/project-fixture.ts @@ -1,7 +1,8 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; +import { rstestWorkerRoot } from '../../../../rstest.worker-isolation.ts'; + export interface ProjectFixture { configPath: string; imagePath: string; @@ -33,7 +34,7 @@ const sourceEntryPoint = resolve( export const createProjectFixture = async ( options: ProjectFixtureOptions = {}, ): Promise => { - const root = await mkdtemp(join(tmpdir(), options.prefix ?? 'agent-bundle-config-')); + const root = await mkdtemp(join(rstestWorkerRoot(), options.prefix ?? 'agent-bundle-config-')); const skillDir = join(root, 'skills/review'); const skillSource = join(skillDir, 'SKILL.md'); const imagePath = join(skillDir, 'assets/diagram.png'); diff --git a/packages/agent-bundle/tests/public-api-packed.test.ts b/packages/agent-bundle/tests/public-api-packed.test.ts index 5db4ae6bf..e1764b404 100644 --- a/packages/agent-bundle/tests/public-api-packed.test.ts +++ b/packages/agent-bundle/tests/public-api-packed.test.ts @@ -6,6 +6,7 @@ import { promisify } from 'node:util'; import { expect, it } from '@rstest/core'; +import { isolatedCommandEnvironment } from '../../../rstest.worker-isolation.ts'; import { writeFixtureManifest } from './support/manifest.ts'; import { npmInstallArguments, sharedPackedTarball } from './support/shared-pack.ts'; @@ -59,7 +60,7 @@ it('writes the package version as the producer of a packed CLI manifest', async await writeFile(join(consumerRoot, 'package.json'), '{"type":"module"}\n'); await execFile( 'npm', ['install', ...npmInstallArguments, tarball], - { cwd: consumerRoot }, + { cwd: consumerRoot, env: isolatedCommandEnvironment() }, ); const project = await createBuildProject(consumerRoot); @@ -91,7 +92,7 @@ it('imports the externalized config entry from a packed npm consumer', async () await execFile( 'npm', ['install', ...npmInstallArguments, tarball], - { cwd: consumerRoot }, + { cwd: consumerRoot, env: isolatedCommandEnvironment() }, ); expect((await stat(join(packageRoot, 'dist/config.js'))).size).toBeLessThan( @@ -106,7 +107,7 @@ it('imports the externalized config entry from a packed npm consumer', async () "import { defineConfig } from 'agent-bundle/config';", 'if (defineConfig !== rootDefineConfig) throw new Error(\'config factory identity mismatch\');', ].join('\n'), - ], { cwd: consumerRoot }), + ], { cwd: consumerRoot, env: isolatedCommandEnvironment() }), ).resolves.toMatchObject({ stderr: '', stdout: '' }); await symlink( join(workspaceRoot, 'node_modules', '@types'), @@ -138,7 +139,7 @@ it('imports the externalized config entry from a packed npm consumer', async () '--target', 'es2022', '--types', 'node', 'config.mts', - ], { cwd: consumerRoot })).resolves.toMatchObject({ stderr: '', stdout: '' }); + ], { cwd: consumerRoot, env: isolatedCommandEnvironment() })).resolves.toMatchObject({ stderr: '', stdout: '' }); } finally { await rm(consumerRoot, { force: true, recursive: true }); } @@ -199,7 +200,7 @@ it('invokes a prebuilt MCP server from a clean packed consumer', async () => { await execFile( 'npm', ['install', ...npmInstallArguments, tarball], - { cwd: consumerRoot }, + { cwd: consumerRoot, env: isolatedCommandEnvironment() }, ); const { stdout } = await execFile(process.execPath, [ '--input-type=module', @@ -209,7 +210,7 @@ it('invokes a prebuilt MCP server from a clean packed consumer', async () => { "const result = await new McpService().invoke({ artifact: './artifact', input: {}, server: 'fixture', target: 'portable', tool: 'inspect' });", 'console.log(JSON.stringify(result));', ].join('\n'), - ], { cwd: consumerRoot }); + ], { cwd: consumerRoot, env: isolatedCommandEnvironment() }); expect(JSON.parse(stdout)).toMatchObject({ result: { content: [{ text: 'packed result', type: 'text' }], diff --git a/packages/agent-bundle/tests/release-audit.test.ts b/packages/agent-bundle/tests/release-audit.test.ts index d8d8a70ba..e57c5420f 100644 --- a/packages/agent-bundle/tests/release-audit.test.ts +++ b/packages/agent-bundle/tests/release-audit.test.ts @@ -6,13 +6,14 @@ import { promisify } from 'node:util'; import { expect, it } from '@rstest/core'; +import { isolatedCommandEnvironment } from '../../../rstest.worker-isolation.ts'; import { npmInstallArguments, sharedPackedTarball } from './support/shared-pack.ts'; const execFile = promisify(executeFile); const workspaceRoot = process.cwd(); const packageRoot = join(workspaceRoot, 'packages', 'agent-bundle'); -const releaseEnvironment = (): NodeJS.ProcessEnv => ({ ...process.env, NODE_ENV: 'production' }); +const releaseEnvironment = (): NodeJS.ProcessEnv => isolatedCommandEnvironment({ ...process.env, NODE_ENV: 'production' }); it('audits an externally installed production tarball and generates its CycloneDX SBOM', async () => { const { stdout } = await execFile(process.execPath, ['scripts/audit-packed-release.mjs'], { diff --git a/packages/agent-bundle/tests/rsc-runtime-optional-packaging.test.ts b/packages/agent-bundle/tests/rsc-runtime-optional-packaging.test.ts index ee8477592..e8fd26cb7 100644 --- a/packages/agent-bundle/tests/rsc-runtime-optional-packaging.test.ts +++ b/packages/agent-bundle/tests/rsc-runtime-optional-packaging.test.ts @@ -1,12 +1,12 @@ import { execFile as executeFile } from 'node:child_process'; -import { cp, mkdtemp, readdir, rm } from 'node:fs/promises'; +import { cp, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; import { describe, expect, it } from '@rstest/core'; -import { npmInstallArguments, sharedPackedTarball } from './support/shared-pack.ts'; +import { installedEnvironment, npmInstallArguments, sharedPackedTarball } from './support/shared-pack.ts'; const execFile = promisify(executeFile); const workspaceRoot = process.cwd(); @@ -42,11 +42,12 @@ describe.sequential('optional RSC runtime package boundary', () => { const project = join(consumer, 'project'); const artifact = join(project, '.agent-bundle', 'artifact'); try { + await writeFile(join(consumer, 'package.json'), '{"name":"rsc-optional-consumer","type":"module"}\n'); const tarListing = (await execFile('tar', ['-tf', tarball])).stdout; expect(tarListing).not.toMatch(/examples\/rsc-agent-runtime|react-server-dom-rspack|rsbuild-plugin-rsc/u); - await execFile('npm', ['install', ...npmInstallArguments, tarball], { cwd: consumer }); - const dependencyTree = JSON.parse((await execFile('npm', ['ls', '--all', '--json'], { cwd: consumer })).stdout) as InstalledDependencyTree; + await execFile('npm', ['install', ...npmInstallArguments, tarball], { cwd: consumer, env: installedEnvironment() }); + const dependencyTree = JSON.parse((await execFile('npm', ['ls', '--all', '--json'], { cwd: consumer, env: installedEnvironment() })).stdout) as InstalledDependencyTree; const installedNames = installedDependencyNames(dependencyTree); for (const name of ['react', 'react-dom', 'react-server-dom-rspack', 'rsbuild-plugin-rsc']) { expect(installedNames).not.toContain(name); @@ -69,7 +70,7 @@ describe.sequential('optional RSC runtime package boundary', () => { " process.stdout.write(JSON.stringify({ diagnostics: validated.diagnostics, runtimeBody: await runtimeResponse.json(), runtimeStatus: runtimeResponse.status, status: session.status(), surfacesBody: await surfacesResponse.json(), surfacesStatus: surfacesResponse.status, targets: inspected.model.targets.map(({ name }) => name) }));", '} finally { await session.close(); }', ].join('\n'); - const result = JSON.parse((await execFile(process.execPath, ['--input-type=module', '--eval', script], { cwd: consumer })).stdout) as Readonly<{ + const result = JSON.parse((await execFile(process.execPath, ['--input-type=module', '--eval', script], { cwd: consumer, env: installedEnvironment() })).stdout) as Readonly<{ readonly diagnostics: unknown; readonly runtimeBody: unknown; readonly runtimeStatus: number; diff --git a/packages/agent-bundle/tests/support/shared-pack.ts b/packages/agent-bundle/tests/support/shared-pack.ts index 124584daf..a434e143e 100644 --- a/packages/agent-bundle/tests/support/shared-pack.ts +++ b/packages/agent-bundle/tests/support/shared-pack.ts @@ -5,6 +5,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; +import { isolatedCommandEnvironment } from '../../../../rstest.worker-isolation.ts'; + const execFile = promisify(executeFile); const workspaceRoot = process.cwd(); @@ -21,10 +23,12 @@ export interface SharedPack { export type SharedPackPackage = 'agent-bundle' | 'create-agent-bundle'; -export const installedEnvironment = (): NodeJS.ProcessEnv => { - const { NODE_PATH: _nodePath, ...environment } = process.env; - return environment; -}; +/** + * NODE_PATH-free environment with per-command npm cache and tmp roots under + * the worker's RSTEST_WORKER_ID directory (see rstest.worker-isolation.ts), + * so concurrent workers never contend on shared npm or tmp state. + */ +export const installedEnvironment = (): NodeJS.ProcessEnv => isolatedCommandEnvironment(); /** Canonical flags for installing a packed tarball into a consumer fixture. */ export const npmInstallArguments = ['--ignore-scripts', '--no-audit', '--no-fund'] as const; diff --git a/packages/agent-bundle/tests/support/time-scale.ts b/packages/agent-bundle/tests/support/time-scale.ts index 7ba558ada..2279cc5b5 100644 --- a/packages/agent-bundle/tests/support/time-scale.ts +++ b/packages/agent-bundle/tests/support/time-scale.ts @@ -5,9 +5,10 @@ * costs nothing on green runs - polling assertions return on success - and * the workflow-level timeout-minutes still bounds real hangs. * - * AGENT_BUNDLE_TEST_TIME_SCALE (set by rstest.integration.config.ts when the - * pool runs multiple workers) covers the same contention on development + * AGENT_BUNDLE_TEST_TIME_SCALE covers the same contention on development * machines, where concurrent Chrome + dev-server + rsbuild pairs share cores. + * rstest.integration.config.ts sets it locally from core count without pinning + * workers. CI always uses 4, independent of pool size. */ const localScale = Number(process.env['AGENT_BUNDLE_TEST_TIME_SCALE'] ?? ''); export const timeScale = process.env['CI'] !== undefined diff --git a/rstest.config.ts b/rstest.config.ts index c295aef3d..9bedf50a8 100644 --- a/rstest.config.ts +++ b/rstest.config.ts @@ -9,9 +9,7 @@ export default defineConfig({ 'packages/**/tests/**/*.test.ts', ], exclude: [...templateTestFiles], - // Several integration tests run Rslib, whose build cache and configured - // output paths are process-shared. Keep those builds from racing each other. - pool: { maxWorkers: 1 }, + setupFiles: ['./rstest.setup.ts'], // isolate: false would cut Playwright startup cost, but the log pipeline // suites rely on per-file module isolation (verified: logs-real.e2e fails // when sharing a worker with the other log suites). diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index ab2a6dacb..11e96cea6 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -73,8 +73,8 @@ export const integrationTestFiles: readonly string[] = [ * native-host-smoke workflow keep them covered — and stay excluded from the * parallel unit pool. packed-release.e2e lives here (not in the integration * pool) so `pnpm test` and the release gates don't each run the same long - * packed-browser suite; `rstest.packed.config.ts` keeps `test:packed` on one - * worker. + * packed-browser suite. `rstest.packed.config.ts` does not cap `test:packed` + * workers; pack destinations and tmp roots are per RSTEST_WORKER_ID. */ export const packedTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/dev-workbench-packaging.test.ts', diff --git a/rstest.integration.config.ts b/rstest.integration.config.ts index d5b3d89a2..c01fa1877 100644 --- a/rstest.integration.config.ts +++ b/rstest.integration.config.ts @@ -6,33 +6,22 @@ import { integrationTestFiles } from './rstest.integration-tests.ts'; import { withAgentBundleRslibConfig } from './rstest.rslib.ts'; /** - * Worker count for the parallel integration pool. Half the cores keeps - * browser + dev-server pairs from starving each other and the cap of 4 bounds - * memory on large machines. CI pins one worker explicitly: hosted runners - * report 4 cores (which would compute 2 workers), but each Chrome + - * dev-server + rsbuild pair already saturates them, and 2-worker matrix runs - * flaked on a rotating test per leg even at timeScale 4. Parallelism is a - * development-machine speedup; CI keeps the serialized shape it was tuned - * for. AGENT_BUNDLE_INTEGRATION_MAX_WORKERS overrides the computed value - * (e.g. to measure a parallel CI run or bisect locally in serial). + * Rstest computes worker count from CPU and command mode when pool.maxWorkers + * is omitted. Shared cache, tmp, and pack roots are isolated per worker via + * RSTEST_WORKER_ID (see rstest.setup.ts). */ -const overrideWorkers = Number(process.env['AGENT_BUNDLE_INTEGRATION_MAX_WORKERS'] ?? ''); -const maxWorkers = Number.isSafeInteger(overrideWorkers) && overrideWorkers >= 1 - ? overrideWorkers - : process.env['CI'] !== undefined - ? 1 - : Math.max(1, Math.min(4, Math.floor(availableParallelism() / 2))); /** - * Polling budgets scale with contention. A multi-worker pool needs at least - * 2 (see the env comment below); an externally set - * AGENT_BUNDLE_TEST_TIME_SCALE raises it further when the machine is shared — - * scripts/local-ci.mjs passes 4 (hosted CI's own scale) because it runs - * three Node legs plus the release gates concurrently. The external value - * never lowers the scale below what the pool shape requires. + * Polling budgets scale with contention. The auto-sized pool runs multiple + * workers on any multi-core machine, which needs at least 2 (see the env + * comment below); an externally set AGENT_BUNDLE_TEST_TIME_SCALE raises it + * further when the machine is shared — scripts/local-ci.mjs passes 4 (hosted + * CI's own scale) because it runs three Node legs plus the release gates + * concurrently. The external value never lowers the scale below what the + * pool shape requires. */ const externalTimeScale = Number(process.env['AGENT_BUNDLE_TEST_TIME_SCALE'] ?? ''); -const poolTimeScale = maxWorkers > 1 ? 2 : 1; +const poolTimeScale = availableParallelism() > 1 ? 2 : 1; const timeScale = Number.isSafeInteger(externalTimeScale) && externalTimeScale >= 1 ? Math.max(externalTimeScale, poolTimeScale) : poolTimeScale; @@ -40,19 +29,20 @@ const timeScale = Number.isSafeInteger(externalTimeScale) && externalTimeScale > /** * Build- and process-running tests that only read workspace-shared artifacts; * files that WRITE shared locations (root builds, `npm pack`) run through the - * single-worker `test:packed` script instead (see rstest.integration-tests.ts). + * `test:packed` script instead (see rstest.integration-tests.ts). */ export default defineConfig({ extends: withAgentBundleRslibConfig(), include: [...integrationTestFiles], - pool: { maxWorkers }, + setupFiles: ['./rstest.setup.ts'], + // isolate: false would cut Playwright startup cost, but the log pipeline + // suites rely on per-file module isolation (verified: logs-real.e2e fails + // when sharing a worker with the other log suites). + isolate: true, // Concurrent Chrome + dev-server + rsbuild pairs contend for cores, so // parallel runs double the polling budgets (see tests/support/time-scale.ts) // and raise the 5s default test timeout, which real in-process builds can // exceed when workers share the machine. Explicit per-test timeouts win. env: { AGENT_BUNDLE_TEST_TIME_SCALE: String(timeScale) }, testTimeout: 30_000, - // isolate: false would cut Playwright startup cost, but the log pipeline - // suites rely on per-file module isolation (verified: logs-real.e2e fails - // when sharing a worker with the other log suites). }); diff --git a/rstest.packed.config.ts b/rstest.packed.config.ts index ad15c6c0b..696f8570d 100644 --- a/rstest.packed.config.ts +++ b/rstest.packed.config.ts @@ -8,9 +8,10 @@ import { withAgentBundleRslibConfig } from './rstest.rslib.ts'; * `scripts/run-packed-tests.mjs` so every file consumes one shared tarball * per public package. `--release` (AGENT_BUNDLE_PACKED_RELEASE=1) adds the * release-boundary-only files — the scaffolder template matrix — on top of - * the per-PR set. The pool stays on one worker: dev-workbench-packaging - * rebuilds the workspace `dist` in place while release-audit's audit script - * packs it, so the files still contend on workspace-shared writes. + * the per-PR set. Rstest sizes the pool itself: no file writes the workspace + * `dist` in place anymore (dev-workbench-packaging's prune test rebuilds + * into an isolated copy), and shared tmp/npm/cache roots are per + * RSTEST_WORKER_ID (see rstest.setup.ts). */ export default defineConfig({ extends: withAgentBundleRslibConfig(), @@ -18,5 +19,5 @@ export default defineConfig({ ...packedTestFiles, ...(process.env['AGENT_BUNDLE_PACKED_RELEASE'] === '1' ? packedReleaseOnlyTestFiles : []), ], - pool: { maxWorkers: 1 }, + setupFiles: ['./rstest.setup.ts'], }); diff --git a/rstest.runtime-playground.browser.config.ts b/rstest.runtime-playground.browser.config.ts index 6cb104f61..8127c0d5d 100644 --- a/rstest.runtime-playground.browser.config.ts +++ b/rstest.runtime-playground.browser.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ extends: withRslibConfig(), include: ['packages/workbench/tests/runtime-playground.browser.test.tsx'], plugins: [pluginReact()], - pool: { maxWorkers: 1 }, + setupFiles: ['./rstest.setup.ts'], resolve: { alias: { react: browserReactRoot, diff --git a/rstest.runtime-playground.config.ts b/rstest.runtime-playground.config.ts index 426d018b8..0e9d3d3eb 100644 --- a/rstest.runtime-playground.config.ts +++ b/rstest.runtime-playground.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ reporters: ['text', 'json'], thresholds: { branches: 85, functions: 90, lines: 90, statements: 90 }, }, - pool: { maxWorkers: 1 }, + setupFiles: ['./rstest.setup.ts'], projects: [ defineInlineProject({ extends: withRslibConfig(), diff --git a/rstest.setup.ts b/rstest.setup.ts new file mode 100644 index 000000000..d5d552c8d --- /dev/null +++ b/rstest.setup.ts @@ -0,0 +1,3 @@ +import { isolateWorkerEnvironment } from './rstest.worker-isolation.ts'; + +isolateWorkerEnvironment(); diff --git a/rstest.unit.config.ts b/rstest.unit.config.ts index f8b8c9f17..c5a4ad9d4 100644 --- a/rstest.unit.config.ts +++ b/rstest.unit.config.ts @@ -10,4 +10,7 @@ export default defineConfig({ 'packages/**/tests/**/*.test.ts', ], exclude: [...integrationTestFiles, ...packedTestFiles, ...templateTestFiles], + setupFiles: ['./rstest.setup.ts'], + // Unit files construct per-test services; logs-real.e2e is not in this pool. + isolate: false, }); diff --git a/rstest.worker-isolation.ts b/rstest.worker-isolation.ts new file mode 100644 index 000000000..6d7372d51 --- /dev/null +++ b/rstest.worker-isolation.ts @@ -0,0 +1,46 @@ +import { mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export const rstestWorkerId = (): string => process.env['RSTEST_WORKER_ID'] ?? '0'; + +const hostTemporaryRoot = tmpdir(); + +export const rstestWorkerRoot = (): string => { + const root = join(hostTemporaryRoot, 'agent-bundle-rstest-w' + rstestWorkerId()); + mkdirSync(root, { recursive: true }); + return root; +}; + +export const rstestWorkerCacheDirectory = (name: string): string => { + const directory = join(rstestWorkerRoot(), 'cache', name); + mkdirSync(directory, { recursive: true }); + return directory; +}; + +export const isolateWorkerEnvironment = (): void => { + const root = rstestWorkerRoot(); + const cache = rstestWorkerCacheDirectory('xdg'); + const env = process['env']; + env['TMPDIR'] = root; + env['TMP'] = root; + env['TEMP'] = root; + env['XDG_CACHE_HOME'] = cache; +}; + +let commandSerial = 0; + +export const isolatedCommandEnvironment = (base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv => { + commandSerial += 1; + const stamp = String(process.pid) + '-' + String(commandSerial); + const cache = rstestWorkerCacheDirectory('cmd-' + stamp); + const tmp = join(rstestWorkerRoot(), 'cmd-tmp-' + stamp); + mkdirSync(tmp, { recursive: true }); + const { NODE_PATH: _nodePath, ...rest } = base; + const environment: NodeJS.ProcessEnv = { ...rest }; + environment['npm_config_cache'] = cache; + environment['TMPDIR'] = tmp; + environment['TMP'] = tmp; + environment['TEMP'] = tmp; + return environment; +}; From cecd9878d2d55314483a0982fc1e5d9d81d2b34f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 22:46:01 +0000 Subject: [PATCH 05/14] fix(test): await reload-channel socket observations instead of asserting them synchronously The hmr-client-count attribute is the server's view of the reload channel, read through the main page's CDP session, while Playwright's websocket events arrive on the observing page's session; under worker contention the server can count a client before the event reaches the test process, so the socket lists must be awaited. Burn-in of the unpinned 2-worker CI shape caught the empty-array assertion once at mcp-app-real.e2e.test.ts:579. The file's remaining fixed 3s expect.poll budgets now follow the suite time scale too. --- .../workbench/tests/mcp-app-real.e2e.test.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/workbench/tests/mcp-app-real.e2e.test.ts b/packages/workbench/tests/mcp-app-real.e2e.test.ts index 260e8a3b3..5f9b149b8 100644 --- a/packages/workbench/tests/mcp-app-real.e2e.test.ts +++ b/packages/workbench/tests/mcp-app-real.e2e.test.ts @@ -566,7 +566,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', const bootstrap = await clientPage.goto(clientSurface.bootstrapUrl, { waitUntil: 'domcontentloaded' }); expect(bootstrap?.status()).toBe(200); try { - await expect.poll(() => runtimeIdentity.getAttribute('data-runtime-hmr-client-count'), { timeout: 3_000 }).toBe('1'); + await expect.poll(() => runtimeIdentity.getAttribute('data-runtime-hmr-client-count'), { timeout: 3_000 * timeScale }).toBe('1'); } catch { throw new Error(`Runtime App HMR proxy did not connect: ${JSON.stringify({ console: clientSurfaceConsole, @@ -576,12 +576,16 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', sockets: clientSurfaceSockets, })}`); } - expect(clientSurfaceSockets).toEqual([`${clientSurface.origin.replace('http:', 'ws:')}${runtimeClientSurfaceReloadChannelPath}`]); + // The hmr-client-count attribute is the server's view, read through the + // main page's CDP session; Playwright's websocket event arrives on the + // client page's session and can lag it, so the socket list must be + // awaited rather than asserted synchronously. + await expect.poll(() => clientSurfaceSockets, { timeout: 3_000 * timeScale }).toEqual([`${clientSurface.origin.replace('http:', 'ws:')}${runtimeClientSurfaceReloadChannelPath}`]); expect(clientSurfaceSockets.every((socket) => new URL(socket).search.length === 0)).toBe(true); expect(clientSurfaceHmrRequests.every((request) => new URL(request.url).search.length === 0)).toBe(true); await clientPage.close(); clientPage = undefined; - await expect.poll(() => runtimeIdentity.getAttribute('data-runtime-hmr-client-count'), { timeout: 3_000 }).toBe('0'); + await expect.poll(() => runtimeIdentity.getAttribute('data-runtime-hmr-client-count'), { timeout: 3_000 * timeScale }).toBe('0'); await page.routeWebSocket((url) => url.pathname === runtimeClientSurfaceReloadChannelPath, (route) => { runtimePreviewHmrRoutes.push(route); route.connectToServer(); @@ -638,7 +642,9 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', await expect(outerFrame).toHaveAttribute('sandbox', 'allow-scripts allow-same-origin'); await expect(outerFrame).toHaveAttribute('referrerpolicy', 'no-referrer'); await expect.poll(() => runtimeIdentity.getAttribute('data-runtime-hmr-client-count'), { timeout: 15_000 * timeScale }).toBe('1'); - expect(runtimePreviewSockets).toEqual([`${created.preview.clientSurface.origin.replace('http:', 'ws:')}${runtimeClientSurfaceReloadChannelPath}`]); + // Awaited for the same reason as clientSurfaceSockets above: the + // websocket event can arrive after the server already counts the client. + await expect.poll(() => runtimePreviewSockets, { timeout: 3_000 * timeScale }).toEqual([`${created.preview.clientSurface.origin.replace('http:', 'ws:')}${runtimeClientSurfaceReloadChannelPath}`]); const runtimeAppFrame = async () => { for (const frame of page.frames()) { if (await frame.getByRole('heading', { name: 'Runtime edit timeline' }).count() === 1) return frame; @@ -946,7 +952,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', await appFrame.getByRole('button', { name: 'Refresh' }).click(); try { - await expect.poll(() => consentRequests('action'), { timeout: 3_000 }).toHaveLength(1); + await expect.poll(() => consentRequests('action'), { timeout: 3_000 * timeScale }).toHaveLength(1); } catch { throw new Error(`Runtime App call relay did not reach consent: ${JSON.stringify({ console: browserConsole, From 92df9b27b91652a39f434c68d6bf50f616983be5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 22:55:17 +0000 Subject: [PATCH 06/14] fix(test): scale the request/response capture polls in mcp-app-real to the suite time scale Five expect.poll calls captured consent and operation route traffic with the default 1s budget while their sibling waits scale by timeScale; a contended 2-worker run lost that race once at line 1038 (the tools/call response arrives after a real RSC operation round trip). --- packages/workbench/tests/mcp-app-real.e2e.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/workbench/tests/mcp-app-real.e2e.test.ts b/packages/workbench/tests/mcp-app-real.e2e.test.ts index 5f9b149b8..8a3a08dd1 100644 --- a/packages/workbench/tests/mcp-app-real.e2e.test.ts +++ b/packages/workbench/tests/mcp-app-real.e2e.test.ts @@ -981,7 +981,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', await expect(denyRuntimeConsent).toBeFocused({ timeout: 15_000 * timeScale }); await page.keyboard.press('Shift+Tab'); await expect(allowRuntimeConsent).toBeFocused({ timeout: 15_000 * timeScale }); - await expect.poll(() => consentResponses('action')).toHaveLength(1); + await expect.poll(() => consentResponses('action'), { timeout: 15_000 * timeScale }).toHaveLength(1); const consentCreated = consentResponses('action')[0]; const challenge = (consentCreated?.response as Readonly<{ readonly challenge?: Readonly<{ readonly id?: unknown }> }> | undefined)?.challenge; if (typeof challenge?.id !== 'string') throw new Error('Runtime App consent create response omitted its challenge id.'); @@ -1035,7 +1035,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', kind: 'tools/call', name: 'render_edit_timeline', }); - await expect.poll(() => operationResponses('tools/call')).toHaveLength(1); + await expect.poll(() => operationResponses('tools/call'), { timeout: 15_000 * timeScale }).toHaveLength(1); const operated = operationResponses('tools/call')[0]; const operationResult = (operated?.response as Readonly<{ readonly result?: unknown }> | undefined)?.result; expect(operationResult).toEqual({ @@ -1093,7 +1093,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', scope: 'action', summary: 'Call MCP App tool', }); - await expect.poll(() => consentResponses('action')).toHaveLength(2); + await expect.poll(() => consentResponses('action'), { timeout: 15_000 * timeScale }).toHaveLength(2); const deniedConsentCreated = consentResponses('action')[1]; const deniedChallenge = (deniedConsentCreated?.response as Readonly<{ readonly challenge?: Readonly<{ readonly id?: unknown }> }> | undefined)?.challenge; if (typeof deniedChallenge?.id !== 'string') throw new Error('Denied Runtime App consent create response omitted its challenge id.'); @@ -1157,8 +1157,8 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', body: { diagnostic: { code: 'AB8023', message: 'MCP App operation could not be completed.' } }, status: 502, }); - await expect.poll(() => operationRequests('tools/call')).toHaveLength(2); - await expect.poll(() => operationResponses('tools/call')).toHaveLength(2); + await expect.poll(() => operationRequests('tools/call'), { timeout: 15_000 * timeScale }).toHaveLength(2); + await expect.poll(() => operationResponses('tools/call'), { timeout: 15_000 * timeScale }).toHaveLength(2); expect(operationResponses('tools/call').filter((entry) => { const response = entry.response; return response !== null && typeof response === 'object' && Object.hasOwn(response, 'result'); From 745ef40a1a8da4ab9251384a7e5c03ade6f2d0a0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 23:06:11 +0000 Subject: [PATCH 07/14] fix(workbench): give the MCP App frame force-close timer a load-tolerant default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timer exists to bound a hung or hostile app's teardown, but the 1s default also lost races against healthy teardown handshakes on loaded hosts: when it fired, the forced DELETE superseded the queued graceful close (the completed force clears the relay queue), so the /close POST never happened and close accounting showed an extra force entry. Both burn-in flakes in the close seam — mcp-page-app-browser's 8-of-7 closes and mcp-app-real's never-observed second /close request — trace to this misfire. 5s still bounds hostility; callers that want a tight budget pass closeTimeoutMs. --- packages/workbench/src/mcp/mcp-app-frame.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/workbench/src/mcp/mcp-app-frame.tsx b/packages/workbench/src/mcp/mcp-app-frame.tsx index e56e27e78..c84cfe73c 100644 --- a/packages/workbench/src/mcp/mcp-app-frame.tsx +++ b/packages/workbench/src/mcp/mcp-app-frame.tsx @@ -128,7 +128,12 @@ const messageForResource = (frame: McpAppRelayFrame, value: CanonicalResource): }); const positiveTimeout = (value: number | undefined): number => { - const timeout = value ?? 1_000; + // The force-close timer bounds a hung or hostile app's teardown, so its + // budget only needs to be finite, not tight. A tight budget misfires on a + // healthy app when the host is loaded (the teardown handshake is a route + // round trip plus iframe processing): the forced DELETE then supersedes the + // queued graceful close, discarding app-side teardown work. + const timeout = value ?? 5_000; if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 30_000) { throw new RangeError('MCP App frame close timeout must be an integer from 1 to 30000 ms.'); } From cf0d100d8f237f46d7f00941be40b9d7388abe43 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 23:06:11 +0000 Subject: [PATCH 08/14] fix(test): write watched dev-workbench configs atomically and scale the finalize poll Live config updates in dev-workbench.test.ts used plain writeFile on a path the coordinator re-reads while rebuilds settle, so a starved prepare could read the truncated window, see a missing dev runtime, and tear the provider session down (the burn-in's extra reconcile/unsubscribe/close). The writes now go through the staged-rename helper, moved to agent-bundle test support with a workbench re-export so both suites share one implementation. The playground finalize poll also had a fixed 250ms budget; it follows the suite time scale now. --- .../agent-bundle/tests/dev-workbench.test.ts | 22 +++++++++++-------- .../tests/support/watched-files.ts | 17 ++++++++++++++ .../workbench/tests/support/watched-files.ts | 19 +--------------- 3 files changed, 31 insertions(+), 27 deletions(-) create mode 100644 packages/agent-bundle/tests/support/watched-files.ts diff --git a/packages/agent-bundle/tests/dev-workbench.test.ts b/packages/agent-bundle/tests/dev-workbench.test.ts index 226697ef7..a842c48be 100644 --- a/packages/agent-bundle/tests/dev-workbench.test.ts +++ b/packages/agent-bundle/tests/dev-workbench.test.ts @@ -20,6 +20,8 @@ import { import type { ForegroundCoordinator, ForegroundServerOptions } from '../src/dev/foreground-server.ts'; import { createProjectFixture, removeProjectFixture } from './helpers/project-fixture.ts'; import { agentBundleNodeModules } from './helpers/workspace-paths.ts'; +import { timeScale } from './support/time-scale.ts'; +import { replaceWatchedSource } from './support/watched-files.ts'; const readToEnd = async (reader: ReadableStreamDefaultReader): Promise => { const decoder = new TextDecoder(); @@ -437,7 +439,7 @@ it('latches a runtime declaration added to an ordinary Workbench session as rest if (cookie === null) throw new Error('Expected foreground session bootstrap cookie.'); events = openProjectEventStream(server.url, cookie); await events.opened; - await writeFile(project.configPath, [ + await replaceWatchedSource(project.root, project.configPath, [ "import { defineConfig } from 'agent-bundle';", '', 'export default defineConfig({', @@ -610,7 +612,7 @@ it('retains Runtime App routes through invalid config updates and reconciles onl }); expect(runtimeState.subscribes).toBe(0); - await writeFile(project.configPath, config(['portable'], 'valid-first', '{}')); + await replaceWatchedSource(project.root, project.configPath, config(['portable'], 'valid-first', '{}')); await within((async () => { for (let attempt = 0; attempt < 100; attempt += 1) { const response = await create(); @@ -634,7 +636,7 @@ it('retains Runtime App routes through invalid config updates and reconciles onl unsubscribes: runtimeState.unsubscribes, }; - await writeFile(project.configPath, config(['portable'], 'invalid-nonfinite', 'Number.NaN')); + await replaceWatchedSource(project.root, project.configPath, config(['portable'], 'invalid-nonfinite', 'Number.NaN')); const invalid = await fetch(`${server.url}/api/project/rebuild`, { body: JSON.stringify({ paths: ['agent-bundle.config.ts'] }), headers, @@ -669,7 +671,7 @@ it('retains Runtime App routes through invalid config updates and reconciles onl }).then((response) => response.status)).resolves.toBe(200); expect(runtimeState).toEqual(stableRuntime); - await writeFile(project.configPath, config(['portable'], 'valid-repair', '{}')); + await replaceWatchedSource(project.root, project.configPath, config(['portable'], 'valid-repair', '{}')); await expect(fetch(`${server.url}/api/project/rebuild`, { body: JSON.stringify({ paths: ['agent-bundle.config.ts'] }), headers, @@ -683,7 +685,7 @@ it('retains Runtime App routes through invalid config updates and reconciles onl unsubscribes: stableRuntime.unsubscribes, }); - await writeFile(project.configPath, config(['portable'], 'valid-removal', undefined, false)); + await replaceWatchedSource(project.root, project.configPath, config(['portable'], 'valid-removal', undefined, false)); await expect(fetch(`${server.url}/api/project/rebuild`, { body: JSON.stringify({ paths: ['agent-bundle.config.ts'] }), headers, @@ -788,7 +790,7 @@ it('fences a closing foreground before a held valid runtime reconcile can attach const bootstrap = await fetch(`${server.url}/api/project/session`, { headers: { 'sec-fetch-site': 'same-origin' } }); const { token } = await bootstrap.json() as { readonly token: string }; const headers = { 'content-type': 'application/json', origin: server.url, 'x-agent-bundle-session': token }; - await writeFile(project.configPath, config(['portable'])); + await replaceWatchedSource(project.root, project.configPath, config(['portable'])); const rebuilding = fetch(`${server.url}/api/project/rebuild`, { body: JSON.stringify({ paths: ['agent-bundle.config.ts'] }), headers, @@ -913,7 +915,7 @@ it('does not reconcile a valid preparation released after foreground close begin subscribes: runtimeState.subscribes, }; - await writeFile(project.configPath, config('held-after-close', true)); + await replaceWatchedSource(project.root, project.configPath, config('held-after-close', true)); const rebuilding = fetch(`${server.url}/api/project/rebuild`, { body: JSON.stringify({ paths: ['agent-bundle.config.ts'] }), headers, @@ -976,7 +978,7 @@ it('does not publish a prepared runtime topology after foreground close begins', }, }, }); - await writeFile(project.configPath, [ + await replaceWatchedSource(project.root, project.configPath, [ "import { defineConfig } from 'agent-bundle';", `const state = globalThis[${JSON.stringify(stateKey)}];`, "if (state === undefined) throw new Error('Missing prepared topology close state.');", @@ -1529,7 +1531,9 @@ it('records a durable playground trace and promotes it through the packaged fore expect(run.id).not.toBe(binding.hook); expect(run.session.state).toBe('open'); let terminal: string | undefined; - for (let attempt = 0; attempt < 25; attempt += 1) { + // Finalization settles asynchronously after the run settles, so the poll + // budget follows the suite time scale like every other readiness wait. + for (let attempt = 0; attempt < 250 * timeScale; attempt += 1) { const session = await fetch(`${server.url}/api/playground/sessions/${run.session.id}`, { headers }); const body = await session.json() as { readonly session: { readonly state: string } }; terminal = body.session.state; diff --git a/packages/agent-bundle/tests/support/watched-files.ts b/packages/agent-bundle/tests/support/watched-files.ts new file mode 100644 index 000000000..35fe2a1fc --- /dev/null +++ b/packages/agent-bundle/tests/support/watched-files.ts @@ -0,0 +1,17 @@ +import { rename, writeFile } from 'node:fs/promises'; +import { basename, join } from 'node:path'; + +/** + * Replaces a file the dev server may read concurrently (watcher events or an + * in-flight prepare) with one atomic rename, so no reader ever observes a + * truncated or partially written source. A plain writeFile truncates first: + * a watcher can read the empty window, or coalesce the truncate and append + * events within one mtime tick and drop the content. The temp file lives in + * the project's parent (same filesystem, never watched) so the rename into + * place is the only event a watcher observes. + */ +export const replaceWatchedSource = async (projectRoot: string, path: string, content: string): Promise => { + const temporary = join(projectRoot, '..', `.${basename(path)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`); + await writeFile(temporary, content); + await rename(temporary, path); +}; diff --git a/packages/workbench/tests/support/watched-files.ts b/packages/workbench/tests/support/watched-files.ts index c8d88e55e..e9b770a62 100644 --- a/packages/workbench/tests/support/watched-files.ts +++ b/packages/workbench/tests/support/watched-files.ts @@ -1,18 +1 @@ -import { rename, writeFile } from 'node:fs/promises'; -import { basename, join } from 'node:path'; - -/** - * Replaces a watched source atomically through a rename staged OUTSIDE the - * watched project. An in-place write is truncate-then-append: the dev - * compiler can start a compile off the truncation event, read incomplete - * content, and then drop the append event because both operations land - * within the same mtime tick, so the final content never compiles and the - * expected revision never activates. The temp file lives in the project's - * parent (same filesystem, never watched) so the rename into place is the - * only event the watcher observes. - */ -export const replaceWatchedSource = async (projectRoot: string, path: string, content: string): Promise => { - const temporary = join(projectRoot, '..', `.${basename(path)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`); - await writeFile(temporary, content); - await rename(temporary, path); -}; +export { replaceWatchedSource } from '../../../agent-bundle/tests/support/watched-files.ts'; From 007e2d75c8b9efdd9ff2bcad0197f21ff05bd32f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 23:31:35 +0000 Subject: [PATCH 09/14] fix(dev): make the graceful-close receipt window dominate the relay's force-close cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The receipt TTL was 5s — exactly the frame relay's new force-close default — so the designed fallback DELETE raced the receipt expiry on a knife edge and could 404 after an accepted graceful close (seen once in the re-gate burn-in on the bundled-template close path). The receipt exists precisely to keep that late DELETE idempotent, so its window now exceeds the relay's 30s closeTimeoutMs cap rather than sitting equal to one particular default. --- packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts index 09fca2d1d..bc12648de 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts @@ -15,7 +15,11 @@ import type { McpAppConsentRequest } from './mcp-app-sandbox.ts'; import { runtimeAppMessageLimits } from '../runtime-app-message-limits.ts'; const bodyLimit = 64 * 1024; -const gracefulCloseReceiptTimeoutMs = 5_000; +// A force-close DELETE that lands after an accepted graceful close must stay +// idempotent (200, not 404), so this window has to dominate the frame relay's +// force-close budget — clients may fall back as late as their closeTimeoutMs, +// which mcp-app-frame.tsx caps at 30s. +const gracefulCloseReceiptTimeoutMs = 35_000; interface RequestDiagnostic { readonly code: string; From 2462715793388cb0bee6d68d7289c8f8917193ca Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 00:03:54 +0000 Subject: [PATCH 10/14] fix(dev): restart preparation when the config changes between load and snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadConfig evaluates the config from one file read while snapshotProjectSource hashes it in a second read. A config replacement landing between those reads produced a torn PreparedProject: its model and devRuntime belonged to the old bytes while its source revision hashed the new tree. Consumers dedupe prepared deliveries by revision, so the torn preparation reconciled a stale runtime declaration under a fresh revision — observed as an extra reconcile in dev-workbench's Runtime App reconciliation suite whenever a watcher- or POST-driven build straddled the test's config replacement under CPU contention. Prepare now fingerprints the config before load and after snapshot and restarts itself when the two disagree, so every delivered preparation is internally consistent. --- .../agent-bundle/src/dev/project-service.ts | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/agent-bundle/src/dev/project-service.ts b/packages/agent-bundle/src/dev/project-service.ts index 5b1482151..6c9a7b979 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -629,7 +629,7 @@ export class ProjectService { } } - async #prepare(command: ProjectCommand): Promise { + async #prepare(command: ProjectCommand, tornRetries = 0): Promise { const requestedRoot = resolve(this.#options.root); const registry = this.#registry; const requestedConfigPath = resolve(requestedRoot, this.#options.configPath ?? 'agent-bundle.config.ts'); @@ -664,6 +664,14 @@ export class ProjectService { return failedPreparation('AB7002', 'Unable to prepare project paths.', requestedConfigPath, 'project.invalid-source'); } const configPath = resolve(root, this.#options.configPath ?? 'agent-bundle.config.ts'); + const configIdentity = async (): Promise => { + try { + return createHash('sha256').update(await readFile(configPath)).digest('hex'); + } catch { + return undefined; + } + }; + const configIdentityBeforeLoad = await configIdentity(); log(this.#options.logger, 'project.load', { command, root }); let loaded; @@ -710,6 +718,24 @@ export class ProjectService { } catch { return failedPreparation('AB7003', 'Unable to snapshot project source.', loaded.configPath, 'project.invalid-source'); } + // loadConfig evaluated the config from one read while the snapshot hashed + // it in another; a config replacement landing between the two reads would + // otherwise produce a torn preparation whose model belongs to the old + // bytes while its revision hashes the new tree. Consumers dedupe prepared + // deliveries by revision, so a torn preparation reconciles a stale model + // under a fresh revision. When the config changed mid-prepare, restart the + // preparation so both reads agree; the retry cap only yields once writes + // outpace prepares for several consecutive rounds, which no real editor + // or test harness sustains. + if (tornRetries < 3) { + const configIdentityAfterSnapshot = await configIdentity(); + if ( + configIdentityBeforeLoad !== undefined && configIdentityAfterSnapshot !== undefined && + configIdentityBeforeLoad !== configIdentityAfterSnapshot + ) { + return this.#prepare(command, tornRetries + 1); + } + } const runtime = runtimeDeclaration(this.#options.includeDevRuntime === true, loaded.config, loaded.configPath); const runtimeMetadata = runtime.declaration === undefined ? Object.freeze({ changed: false, config: loaded.config }) From 107b242d5b45e86360cb4784532c1b2e6dc93d78 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 00:12:54 +0000 Subject: [PATCH 11/14] test(dev): shorten the graceful-close receipt expiry probe through an injectable window The receipt window grew to 35s so it dominates the frame relay's 30s force-close cap, which made the expiry unit test's real 5.1s sleep assert against a window that no longer expires in-test. Expiry semantics are unchanged, so the routes now accept a test-only window override and the probe sleeps 1.1s against a 1s window instead of tracking the production constant with a real 35s wait. --- .../src/dev/mcp-apps/mcp-app-routes.ts | 10 +++++++++- .../agent-bundle/tests/mcp-app-routes.test.ts | 18 ++++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts index bc12648de..543712562 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts @@ -88,6 +88,12 @@ export interface McpAppRoutePreviewService { export interface McpAppRoutesOptions { readonly authorize: (request: IncomingMessage) => void; + /** + * Test-only override for the graceful-close receipt window. Production + * callers must leave this unset so the window keeps dominating the frame + * relay's force-close budget. + */ + readonly gracefulCloseReceiptTimeoutMs?: number; readonly service?: McpAppRoutePreviewService; } @@ -449,6 +455,7 @@ const bridgeHostContext = (host: McpAppPreviewHostContext): McpAppBridgeJsonReco /** Authenticated HTTP boundary for already-bound MCP App previews. */ export class McpAppRoutes { readonly #authorize: (request: IncomingMessage) => void; + readonly #gracefulCloseReceiptTimeoutMs: number; readonly #service: McpAppRoutePreviewService | undefined; readonly #tails = new Map>(); readonly #teardowns = new Map>(); @@ -456,6 +463,7 @@ export class McpAppRoutes { constructor(options: McpAppRoutesOptions) { this.#authorize = options.authorize; + this.#gracefulCloseReceiptTimeoutMs = options.gracefulCloseReceiptTimeoutMs ?? gracefulCloseReceiptTimeoutMs; this.#service = options.service; } @@ -648,7 +656,7 @@ export class McpAppRoutes { if (this.#closed) return; const receipt = setTimeout(() => { if (this.#teardowns.get(bindingId) === receipt) this.#teardowns.delete(bindingId); - }, gracefulCloseReceiptTimeoutMs); + }, this.#gracefulCloseReceiptTimeoutMs); this.#teardowns.set(bindingId, receipt); } diff --git a/packages/agent-bundle/tests/mcp-app-routes.test.ts b/packages/agent-bundle/tests/mcp-app-routes.test.ts index 48661fdbc..dafabc933 100644 --- a/packages/agent-bundle/tests/mcp-app-routes.test.ts +++ b/packages/agent-bundle/tests/mcp-app-routes.test.ts @@ -147,8 +147,15 @@ class RecordingPreviewService implements McpAppRoutePreviewService { } } -const startRoutes = async (service = new RecordingPreviewService()): Promise => { - const routes = new McpAppRoutes({ authorize, service }); +const startRoutes = async ( + service = new RecordingPreviewService(), + gracefulCloseReceiptTimeoutMs?: number, +): Promise => { + const routes = new McpAppRoutes({ + authorize, + ...(gracefulCloseReceiptTimeoutMs === undefined ? {} : { gracefulCloseReceiptTimeoutMs }), + service, + }); const server = createServer((request, response) => { void routes.handle(request, response).then((handled) => { if (!handled) response.writeHead(404).end(); @@ -911,7 +918,10 @@ it('serializes a fallback DELETE behind an accepted graceful close', async () => }); it('expires a graceful-close receipt before a later fallback DELETE', async () => { - const started = await startRoutes(); + // The production window is 35s (it must dominate the relay's 30s force-close + // cap); expiry semantics are what matters here, so the window is shortened + // through the injectable seam instead of sleeping for real. + const started = await startRoutes(undefined, 1_000); try { const closing = await fetch(`${started.url}/api/mcp/apps/binding-a/close`, { body: JSON.stringify({ id: 'close-a' }), @@ -921,7 +931,7 @@ it('expires a graceful-close receipt before a later fallback DELETE', async () = expect(closing.status).toBe(200); started.service.forceCloseResult = false; - await new Promise((resolvePromise) => setTimeout(resolvePromise, 5_100)); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 1_100)); const fallback = await fetch(`${started.url}/api/mcp/apps/binding-a`, { headers: headers(), method: 'DELETE', From 9128116ef0604b70b54cdf9dad08bd495f67eee4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 00:49:52 +0000 Subject: [PATCH 12/14] fix(test): sequence the overview HMR edit through the owned reload channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two-file live edit was written in one Promise.all, leaving the App compile count to the watcher's aggregation window: one coalesced compile or two split ones. On a split, the dev middleware holds asset requests during the second compile, so the refreshed frame can already show both edits while the second reload announcement is still in flight — the test then captured its reload-frame baseline without it and the late frame poisoned the exact-equality reconcile assertions (the intermittent extra generation:2 runtime-app-reload). The edits are now written sequentially, each barriered on its own announced generation. Frames arrive in order on one socket, so after the second barrier no edit-driven announcement can be outstanding and the baseline is race-free. Verified 0/10 failures under a 4-core contention harness where the simultaneous write failed 3/10. --- packages/workbench/tests/overview.e2e.test.ts | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/workbench/tests/overview.e2e.test.ts b/packages/workbench/tests/overview.e2e.test.ts index 58024e4a5..8eed7f04a 100644 --- a/packages/workbench/tests/overview.e2e.test.ts +++ b/packages/workbench/tests/overview.e2e.test.ts @@ -356,10 +356,6 @@ e2e('offers the host-owned MCP playground handoff only after a selected Runtime ); const editedStyles = `${styles}\n.timeline__header [data-testid="runtime-hmr-marker"] { color: rgb(1, 2, 3); }\n`; expect(editedSource).not.toBe(source); - await Promise.all([ - replaceWatchedSource(fixture.root, fixture.widgetAppSource, editedSource), - replaceWatchedSource(fixture.root, fixture.appStyles, editedStyles), - ]); // The owned reload channel carries provider-authored frames only; a // changed App compile advances the generation past the connect replay. const ownedReloadFrames = (): readonly number[] => runtimePreviewHmrMessages.flatMap((message) => { @@ -370,8 +366,23 @@ e2e('offers the host-owned MCP playground handoff only after a selected Runtime return []; } }); - await expect.poll(() => ownedReloadFrames().some((generation) => generation > 0), { timeout: browserTimeout }) - .toBe(true); + const maxOwnedReloadGeneration = (): number => + ownedReloadFrames().reduce((max, generation) => Math.max(max, generation), 0); + // The two edits are sequenced through the reload channel rather than + // written together: a simultaneous write leaves the compile count to the + // watcher's aggregation window (one coalesced compile or two split ones), + // and a split's second announcement can land after the DOM waits below — + // the dev middleware holds asset requests during a compile, so the + // refreshed frame can already show both edits while the second frame is + // still in flight, poisoning the baseline captured for the reconcile + // assertions. Frames arrive in order on one socket, so barriering each + // write on its own announced generation pins the edit to exactly one + // announcement per write with none outstanding afterwards. + await replaceWatchedSource(fixture.root, fixture.appStyles, editedStyles); + await expect.poll(maxOwnedReloadGeneration, { timeout: browserTimeout }).toBeGreaterThan(0); + const stylesReloadGeneration = maxOwnedReloadGeneration(); + await replaceWatchedSource(fixture.root, fixture.widgetAppSource, editedSource); + await expect.poll(maxOwnedReloadGeneration, { timeout: browserTimeout }).toBeGreaterThan(stylesReloadGeneration); const refreshedWidget = async () => { for (const frame of page.frames()) { if (await frame.getByTestId('runtime-hmr-marker').count() === 1) return frame; From 453331964258f2200e2b0cb1c6f7b34d74b96d3d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 01:08:36 +0000 Subject: [PATCH 13/14] fix(test): scale the CLI suite's fixed budgets by the suite time scale cli.test.ts was the last integration-pool file whose it-budgets were fixed literals: every test packs or spawns child CLI processes, yet the budgets ignored AGENT_BUNDLE_TEST_TIME_SCALE while the rest of the pool scales with it. Under the 2-worker CI shape the packed-consumer test blew its fixed 60s budget on a cold, heavily loaded pass. Scaled budgets cost nothing on green runs and still bound real hangs. --- packages/agent-bundle/tests/cli.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index 0d3b36410..00a8269df 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -7,6 +7,7 @@ import { promisify } from 'node:util'; import { expect, it } from '@rstest/core'; import { runCli as runSourceCli } from '../src/cli.ts'; +import { timeScale } from './support/time-scale.ts'; const execFile = promisify(executeFile); const workspaceRoot = process.cwd(); @@ -162,7 +163,7 @@ it('builds a selected target through the built executable from a path containing } finally { await rm(resolve(project.root, '..'), { force: true, recursive: true }); } -}, 30_000); +}, 30_000 * timeScale); it('runs MCP and hook operations from a packed consumer with explicit and temporary artifacts', async () => { await buildCliPackage(); @@ -293,7 +294,7 @@ it('runs MCP and hook operations from a packed consumer with explicit and tempor rm(consumer.root, { force: true, recursive: true }), ]); } -}, 60_000); +}, 60_000 * timeScale); it('keeps inspect JSON stable and validates only the supplied artifact', async () => { await buildCliPackage(); @@ -351,7 +352,7 @@ it('keeps inspect JSON stable and validates only the supplied artifact', async ( } finally { await rm(resolve(project.root, '..'), { force: true, recursive: true }); } -}, 30_000); +}, 30_000 * timeScale); it('prints a complete invalid inspection on JSON and human output', async () => { const project = await createCliProject(); @@ -380,7 +381,7 @@ it('prints a complete invalid inspection on JSON and human output', async () => } finally { await rm(resolve(project.root, '..'), { force: true, recursive: true }); } -}, 30_000); +}, 30_000 * timeScale); it('reports an unselected inspect target on JSON and human output', async () => { const project = await createCliProject(); @@ -414,7 +415,7 @@ it('reports an unselected inspect target on JSON and human output', async () => } finally { await rm(resolve(project.root, '..'), { force: true, recursive: true }); } -}, 30_000); +}, 30_000 * timeScale); it('dumps the synthesized bundler configuration with inspect --bundler', async () => { const project = await createCliProject(); @@ -475,7 +476,7 @@ it('dumps the synthesized bundler configuration with inspect --bundler', async ( } finally { await rm(resolve(project.root, '..'), { force: true, recursive: true }); } -}, 30_000); +}, 30_000 * timeScale); it('reports source validation diagnostics on stderr before staging an artifact', async () => { await buildCliPackage(); @@ -507,4 +508,4 @@ it('reports source validation diagnostics on stderr before staging an artifact', } finally { await rm(resolve(project.root, '..'), { force: true, recursive: true }); } -}, 30_000); +}, 30_000 * timeScale); From 0298dae67c859d5a5c5df89db4d023621ece0ad1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 01:26:00 +0000 Subject: [PATCH 14/14] fix(test): browser pools load a browser-safe setup without node: builtins rstest.setup.ts imports rstest.worker-isolation.ts (node:fs/os/path), which browser pools bundle into the page where node: is an unhandled scheme -- the runtime-playground browser pool failed to build with zero tests run. Browser projects now load rstest.setup.browser.ts (empty; worker isolation is Node-only), and the multi-project runtime-playground config scopes the Node setup to its runtime-node project. --- rstest.runtime-playground.browser.config.ts | 2 +- rstest.runtime-playground.config.ts | 3 ++- rstest.setup.browser.ts | 5 +++++ 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 rstest.setup.browser.ts diff --git a/rstest.runtime-playground.browser.config.ts b/rstest.runtime-playground.browser.config.ts index 8127c0d5d..c066ce0c4 100644 --- a/rstest.runtime-playground.browser.config.ts +++ b/rstest.runtime-playground.browser.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ extends: withRslibConfig(), include: ['packages/workbench/tests/runtime-playground.browser.test.tsx'], plugins: [pluginReact()], - setupFiles: ['./rstest.setup.ts'], + setupFiles: ['./rstest.setup.browser.ts'], resolve: { alias: { react: browserReactRoot, diff --git a/rstest.runtime-playground.config.ts b/rstest.runtime-playground.config.ts index 0e9d3d3eb..bccb49df8 100644 --- a/rstest.runtime-playground.config.ts +++ b/rstest.runtime-playground.config.ts @@ -16,7 +16,6 @@ export default defineConfig({ reporters: ['text', 'json'], thresholds: { branches: 85, functions: 90, lines: 90, statements: 90 }, }, - setupFiles: ['./rstest.setup.ts'], projects: [ defineInlineProject({ extends: withRslibConfig(), @@ -27,6 +26,7 @@ export default defineConfig({ 'packages/workbench/tests/runtime-playground.test.ts', ], name: 'runtime-node', + setupFiles: ['./rstest.setup.ts'], testEnvironment: 'node', }), defineInlineProject({ @@ -40,6 +40,7 @@ export default defineConfig({ extends: withRslibConfig(), include: ['packages/workbench/tests/runtime-playground.browser.test.tsx'], name: 'runtime-browser', + setupFiles: ['./rstest.setup.browser.ts'], plugins: [pluginReact()], resolve: { alias: { diff --git a/rstest.setup.browser.ts b/rstest.setup.browser.ts new file mode 100644 index 000000000..064dae7a4 --- /dev/null +++ b/rstest.setup.browser.ts @@ -0,0 +1,5 @@ +// Browser pools bundle setup files into the page bundle, where node: builtins +// are an unhandled scheme. Worker isolation (rstest.setup.ts) redirects +// TMPDIR/XDG caches for Node test processes and has no browser equivalent, so +// browser projects load this empty setup instead. +export {};