From 01ab065840e2e5b9998c3285130c08fe49838aca Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 02:24:32 +0000 Subject: [PATCH 1/4] test: rstest generated modules survive concurrent rewrites (#843) --- .../rstest-generated-module-write.test.ts | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 packages/agent-bundle/tests/rstest-generated-module-write.test.ts diff --git a/packages/agent-bundle/tests/rstest-generated-module-write.test.ts b/packages/agent-bundle/tests/rstest-generated-module-write.test.ts new file mode 100644 index 000000000..79a9e0b10 --- /dev/null +++ b/packages/agent-bundle/tests/rstest-generated-module-write.test.ts @@ -0,0 +1,88 @@ +import { mkdtemp, readdir, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; + +import { afterEach, describe, expect, it } from '@rstest/core'; + +import type { CompiledMcpApp } from '../src/build/mcp-apps.ts'; +import { writeBrowserTestSetup } from '../src/rstest/browser-setup-module.ts'; +import { writeTestMetaModule } from '../src/rstest/meta-module.ts'; +import { writeRouteTestSetup } from '../src/rstest/setup-module.ts'; +import { testManifestFromRouteGraph } from '../src/test/manifest.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; +import { removeTree } from './support/remove-tree.ts'; + +const payload = 'x'.repeat(1024 * 1024); +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => removeTree(root))); +}); + +const projectRoot = async (): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-generated-module-')); + roots.push(root); + return root; +}; + +const manifestFor = (root: string) => testManifestFromRouteGraph({ + diagnostics: [{ code: 'AB0000', message: payload, severity: 'error' }], + graph: emptyCompiledRouteGraph, + projectRoot: root, +}); + +const writers: ReadonlyArray Promise<() => Promise>]> = [ + ['writeTestMetaModule', async (root) => { + const manifest = manifestFor(root); + return () => writeTestMetaModule(root, manifest); + }], + ['writeRouteTestSetup', async (root) => { + const manifest = manifestFor(root); + return () => writeRouteTestSetup(root, manifest); + }], + ['writeBrowserTestSetup', async (root) => { + const output = join(root, 'dashboard.html'); + await writeFile(output, payload, 'utf8'); + const app: CompiledMcpApp = { + id: 'dashboard', + mimeType: 'text/html;profile=mcp-app', + name: 'dashboard', + output, + resourceUri: 'ui://demo/dashboard', + serverIds: ['demo'], + size: { bytes: payload.length, gzipBytes: 0 }, + source: output, + sourceInputs: [], + target: 'web', + }; + return () => writeBrowserTestSetup(root, [app], { dashboard: 'claude' }); + }], +]; + +describe('generated Rstest modules under concurrent rewrites (#843)', () => { + it.each(writers)('%s never exposes a partial module to a concurrent reader', async (_name, prepare) => { + const write = await prepare(await projectRoot()); + const target = await write(); + const complete = await readFile(target, 'utf8'); + expect(complete).toContain(payload); + + const reads: string[] = []; + let writing = true; + const read = async (): Promise => { + while (writing) { + const content = await readFile(target, 'utf8'); + reads.push(content === complete ? 'complete' : `partial: ${String(content.length)} of ${String(complete.length)} chars`); + } + }; + const rewriteLoop = async (): Promise => { + for (let round = 0; round < 16; round += 1) await write(); + }; + const rewrite = Promise.all([rewriteLoop(), rewriteLoop()]).finally(() => { + writing = false; + }); + await Promise.all([rewrite, read(), read(), read()]); + + expect([...new Set(reads)]).toEqual(['complete']); + expect(await readdir(dirname(target))).toEqual([basename(target)]); + }); +}); From e2e1a72d8f1e92c422c4beee30a35c6ae1728e7c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 02:26:21 +0000 Subject: [PATCH 2/4] fix(rstest): write generated test modules atomically (#843) --- .changeset/atomic-rstest-generated-modules.md | 5 ++++ .../src/rstest/browser-setup-module.ts | 9 ++---- .../src/rstest/generated-module.ts | 28 +++++++++++++++++++ .../agent-bundle/src/rstest/meta-module.ts | 13 ++------- .../agent-bundle/src/rstest/setup-module.ts | 13 +++------ 5 files changed, 43 insertions(+), 25 deletions(-) create mode 100644 .changeset/atomic-rstest-generated-modules.md create mode 100644 packages/agent-bundle/src/rstest/generated-module.ts diff --git a/.changeset/atomic-rstest-generated-modules.md b/.changeset/atomic-rstest-generated-modules.md new file mode 100644 index 000000000..662ebe2ac --- /dev/null +++ b/.changeset/atomic-rstest-generated-modules.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Write the `agentBundleRstest()` and `agentBundleBrowserRstest()` generated modules under `.agent-bundle/test` (`meta.mjs`, `route-setup.mjs`, `browser-app-setup.mjs`) atomically, so concurrent Rstest processes never load a partial `agent-bundle/meta` or setup module. diff --git a/packages/agent-bundle/src/rstest/browser-setup-module.ts b/packages/agent-bundle/src/rstest/browser-setup-module.ts index 4a5a8f335..aab6813f1 100644 --- a/packages/agent-bundle/src/rstest/browser-setup-module.ts +++ b/packages/agent-bundle/src/rstest/browser-setup-module.ts @@ -1,6 +1,5 @@ import { Buffer } from 'node:buffer'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { dirname, resolve } from 'node:path'; +import { readFile } from 'node:fs/promises'; import type { CompiledMcpApp } from '../build/mcp-apps.ts'; import { MAX_APP_HTML_BYTES } from '../core/mcp-app-limits.ts'; @@ -11,6 +10,7 @@ import { type CompiledBrowserTestApp, } from '../test/browser-registry.ts'; import { BROWSER_APP_PROOF_LEVEL, proofLevelLabel } from '../test/manifest.ts'; +import { writeGeneratedTestModule } from './generated-module.ts'; const compiledEntry = async (app: CompiledMcpApp, host: string): Promise => { const html = await readFile(app.output, 'utf8'); @@ -65,8 +65,5 @@ export const writeBrowserTestSetup = async ( apps, version: AGENT_BROWSER_TEST_REGISTRY_VERSION, }); - const target = resolve(projectRoot, '.agent-bundle', 'test', 'browser-app-setup.mjs'); - await mkdir(dirname(target), { recursive: true }); - await writeFile(target, browserTestSetupSource(registry), 'utf8'); - return target; + return writeGeneratedTestModule(projectRoot, 'browser-app-setup.mjs', browserTestSetupSource(registry)); }; diff --git a/packages/agent-bundle/src/rstest/generated-module.ts b/packages/agent-bundle/src/rstest/generated-module.ts new file mode 100644 index 000000000..189d7f7ca --- /dev/null +++ b/packages/agent-bundle/src/rstest/generated-module.ts @@ -0,0 +1,28 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, rename, rm, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +/** + * Writes one generated module into the project's `.agent-bundle/test` + * directory and returns its path. Concurrent Rstest processes regenerate the + * same modules, so the source lands in a unique sibling that is renamed over + * the target: a reader loads the previous module or the new one, never a + * truncated file (#843). + */ +export const writeGeneratedTestModule = async ( + projectRoot: string, + fileName: string, + source: string, +): Promise => { + const directory = resolve(projectRoot, '.agent-bundle', 'test'); + const target = join(directory, fileName); + const temporary = join(directory, `.${fileName}.${String(process.pid)}.${randomUUID()}.tmp`); + await mkdir(directory, { recursive: true }); + try { + await writeFile(temporary, source, { encoding: 'utf8', flag: 'wx' }); + await rename(temporary, target); + } finally { + await rm(temporary, { force: true }); + } + return target; +}; diff --git a/packages/agent-bundle/src/rstest/meta-module.ts b/packages/agent-bundle/src/rstest/meta-module.ts index 6cac64d0e..cddd661a9 100644 --- a/packages/agent-bundle/src/rstest/meta-module.ts +++ b/packages/agent-bundle/src/rstest/meta-module.ts @@ -1,12 +1,10 @@ -import { mkdir, writeFile } from 'node:fs/promises'; -import { dirname, resolve } from 'node:path'; - import { generatedMetaModuleSource, metaModuleSpecifier, projectMeta } from '../build/meta.ts'; import { META_UNAVAILABLE_CODE, META_UNAVAILABLE_MESSAGE, } from '../meta-diagnostic.ts'; import { type AgentBundleTestManifest, isFallbackPluginIdentity } from '../test/manifest.ts'; +import { writeGeneratedTestModule } from './generated-module.ts'; /** * The `resolve.alias` key both Rstest presets set for `agent-bundle/meta`. @@ -84,15 +82,10 @@ export const testMetaModuleSource = (manifest: AgentBundleTestManifest): string * project's `.agent-bundle/test` directory, which Rstest bundles like * project source, and returns its path for the alias. */ -export const writeTestMetaModule = async ( +export const writeTestMetaModule = ( projectRoot: string, manifest: AgentBundleTestManifest, -): Promise => { - const target = resolve(projectRoot, '.agent-bundle', 'test', 'meta.mjs'); - await mkdir(dirname(target), { recursive: true }); - await writeFile(target, testMetaModuleSource(manifest), 'utf8'); - return target; -}; +): Promise => writeGeneratedTestModule(projectRoot, 'meta.mjs', testMetaModuleSource(manifest)); /** The `resolve.alias` record routing the reserved specifier to a written identity module. */ export const metaModuleAlias = (metaModulePath: string): { [specifier: string]: string } => diff --git a/packages/agent-bundle/src/rstest/setup-module.ts b/packages/agent-bundle/src/rstest/setup-module.ts index b1ec56138..769bd839f 100644 --- a/packages/agent-bundle/src/rstest/setup-module.ts +++ b/packages/agent-bundle/src/rstest/setup-module.ts @@ -1,9 +1,9 @@ -import { mkdir, writeFile } from 'node:fs/promises'; -import { dirname, resolve } from 'node:path'; +import { resolve } from 'node:path'; import { AGENT_TEST_REGISTRY_SYMBOL_KEY, AGENT_TEST_REGISTRY_VERSION } from '../test/registry.ts'; import type { AgentBundleTestManifest, TestableRouteDescriptor } from '../test/manifest.ts'; import type { RenderableRouteKind } from '../test/types.ts'; +import { writeGeneratedTestModule } from './generated-module.ts'; const renderableKinds: ReadonlySet = new Set([ 'cli', @@ -90,12 +90,7 @@ export const routeTestSetupSource = (manifest: AgentBundleTestManifest): string * project's route modules, so it loads identically however the consumer * resolved `agent-bundle`. */ -export const writeRouteTestSetup = async ( +export const writeRouteTestSetup = ( projectRoot: string, manifest: AgentBundleTestManifest, -): Promise => { - const target = resolve(projectRoot, '.agent-bundle', 'test', 'route-setup.mjs'); - await mkdir(dirname(target), { recursive: true }); - await writeFile(target, routeTestSetupSource(manifest), 'utf8'); - return target; -}; +): Promise => writeGeneratedTestModule(projectRoot, 'route-setup.mjs', routeTestSetupSource(manifest)); From 3a4adfc5d1167dd1561a5400cab330365b180917 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 02:41:54 +0000 Subject: [PATCH 3/4] chore: reference #844 in the changeset --- .changeset/atomic-rstest-generated-modules.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/atomic-rstest-generated-modules.md b/.changeset/atomic-rstest-generated-modules.md index 662ebe2ac..b0e505104 100644 --- a/.changeset/atomic-rstest-generated-modules.md +++ b/.changeset/atomic-rstest-generated-modules.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Write the `agentBundleRstest()` and `agentBundleBrowserRstest()` generated modules under `.agent-bundle/test` (`meta.mjs`, `route-setup.mjs`, `browser-app-setup.mjs`) atomically, so concurrent Rstest processes never load a partial `agent-bundle/meta` or setup module. +Write the `agentBundleRstest()` and `agentBundleBrowserRstest()` generated modules under `.agent-bundle/test` (`meta.mjs`, `route-setup.mjs`, `browser-app-setup.mjs`) atomically, so concurrent Rstest processes never load a partial `agent-bundle/meta` or setup module (#844) From 7bae3927f074510fc5248de05cfac899bb6b17be Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 04:23:17 +0000 Subject: [PATCH 4/4] fix(rstest): retry transient Windows rename rejections for generated modules (#844) --- .../src/rstest/generated-module.ts | 24 ++++++++- ...test-generated-module-win32-rename.test.ts | 52 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 packages/agent-bundle/tests/rstest-generated-module-win32-rename.test.ts diff --git a/packages/agent-bundle/src/rstest/generated-module.ts b/packages/agent-bundle/src/rstest/generated-module.ts index 189d7f7ca..334bc4593 100644 --- a/packages/agent-bundle/src/rstest/generated-module.ts +++ b/packages/agent-bundle/src/rstest/generated-module.ts @@ -1,6 +1,28 @@ import { randomUUID } from 'node:crypto'; import { mkdir, rename, rm, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; + +import { isErrno } from '../core/errors.ts'; + +const RENAME_ATTEMPTS = 10; + +// Windows rejects a replacing rename with EPERM, EACCES, or EBUSY while a +// concurrent writer's rename or a reader briefly holds the target. +const isTransientWin32RenameError = (error: unknown): boolean => + process.platform === 'win32' && ['EACCES', 'EBUSY', 'EPERM'].some((code) => isErrno(error, code)); + +const replaceTarget = async (temporary: string, target: string): Promise => { + for (let attempt = 1; ; attempt += 1) { + try { + await rename(temporary, target); + return; + } catch (error) { + if (attempt === RENAME_ATTEMPTS || !isTransientWin32RenameError(error)) throw error; + await sleep(attempt * 10); + } + } +}; /** * Writes one generated module into the project's `.agent-bundle/test` @@ -20,7 +42,7 @@ export const writeGeneratedTestModule = async ( await mkdir(directory, { recursive: true }); try { await writeFile(temporary, source, { encoding: 'utf8', flag: 'wx' }); - await rename(temporary, target); + await replaceTarget(temporary, target); } finally { await rm(temporary, { force: true }); } diff --git a/packages/agent-bundle/tests/rstest-generated-module-win32-rename.test.ts b/packages/agent-bundle/tests/rstest-generated-module-win32-rename.test.ts new file mode 100644 index 000000000..73f01ea07 --- /dev/null +++ b/packages/agent-bundle/tests/rstest-generated-module-win32-rename.test.ts @@ -0,0 +1,52 @@ +import * as actualFs from 'node:fs/promises' with { rstest: 'importActual' }; +import { mkdtemp, readdir, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, expect, it, rs } from '@rstest/core'; + +import { writeGeneratedTestModule } from '../src/rstest/generated-module.ts'; +import { removeTree } from './support/remove-tree.ts'; + +const renameFailures: string[] = []; + +rs.mock('node:fs/promises', () => ({ + ...actualFs, + rename: async (from: string, to: string) => { + const code = renameFailures.shift(); + if (code !== undefined) throw Object.assign(new Error(`${code}: rename`), { code }); + return actualFs.rename(from, to); + }, +})); + +const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); +const roots: string[] = []; + +afterEach(async () => { + if (platformDescriptor !== undefined) Object.defineProperty(process, 'platform', platformDescriptor); + renameFailures.splice(0); + await Promise.all(roots.splice(0).map((root) => removeTree(root))); +}); + +const projectRoot = async (): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-generated-module-win32-')); + roots.push(root); + return root; +}; + +it('retries a transient Windows rename rejection until the module lands', async () => { + Object.defineProperty(process, 'platform', { ...platformDescriptor, value: 'win32' }); + renameFailures.push('EPERM', 'EBUSY'); + const target = await writeGeneratedTestModule(await projectRoot(), 'meta.mjs', 'export {};\n'); + expect(renameFailures).toEqual([]); + expect(await readFile(target, 'utf8')).toBe('export {};\n'); +}); + +it('rejects a POSIX rename failure without retrying or leaving the temp file', async () => { + Object.defineProperty(process, 'platform', { ...platformDescriptor, value: 'linux' }); + renameFailures.push('EPERM', 'EPERM'); + const root = await projectRoot(); + await expect(writeGeneratedTestModule(root, 'meta.mjs', 'export {};\n')).rejects.toMatchObject({ code: 'EPERM' }); + expect(renameFailures).toEqual(['EPERM']); + expect(await readdir(join(root, '.agent-bundle', 'test'))).toEqual([]); +});