Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/atomic-rstest-generated-modules.md
Original file line number Diff line number Diff line change
@@ -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 (#844)
9 changes: 3 additions & 6 deletions packages/agent-bundle/src/rstest/browser-setup-module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<CompiledBrowserTestApp> => {
const html = await readFile(app.output, 'utf8');
Expand Down Expand Up @@ -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));
};
50 changes: 50 additions & 0 deletions packages/agent-bundle/src/rstest/generated-module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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<void> => {
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`
* 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<string> => {
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 replaceTarget(temporary, target);
} finally {
await rm(temporary, { force: true });
}
return target;
};
13 changes: 3 additions & 10 deletions packages/agent-bundle/src/rstest/meta-module.ts
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down Expand Up @@ -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<string> => {
const target = resolve(projectRoot, '.agent-bundle', 'test', 'meta.mjs');
await mkdir(dirname(target), { recursive: true });
await writeFile(target, testMetaModuleSource(manifest), 'utf8');
return target;
};
): Promise<string> => 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 } =>
Expand Down
13 changes: 4 additions & 9 deletions packages/agent-bundle/src/rstest/setup-module.ts
Original file line number Diff line number Diff line change
@@ -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<string> = new Set<RenderableRouteKind>([
'cli',
Expand Down Expand Up @@ -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<string> => {
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<string> => writeGeneratedTestModule(projectRoot, 'route-setup.mjs', routeTestSetupSource(manifest));
Original file line number Diff line number Diff line change
@@ -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<string> => {
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([]);

Check failure on line 41 in packages/agent-bundle/tests/rstest-generated-module-win32-rename.test.ts

View workflow job for this annotation

GitHub Actions / Verify (fast, Node 24)

packages/agent-bundle/tests/rstest-generated-module-win32-rename.test.ts > retries a transient Windows rename rejection until the module lands

expected [ 'EPERM'%2C 'EBUSY' ] to deeply equal [] - Expected + Received - [] + [ + "EPERM"%2C + "EBUSY"%2C + ]

Check failure on line 41 in packages/agent-bundle/tests/rstest-generated-module-win32-rename.test.ts

View workflow job for this annotation

GitHub Actions / Verify (fast, Node 26)

packages/agent-bundle/tests/rstest-generated-module-win32-rename.test.ts > retries a transient Windows rename rejection until the module lands

expected [ 'EPERM'%2C 'EBUSY' ] to deeply equal [] - Expected + Received - [] + [ + "EPERM"%2C + "EBUSY"%2C + ]
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' });

Check failure on line 49 in packages/agent-bundle/tests/rstest-generated-module-win32-rename.test.ts

View workflow job for this annotation

GitHub Actions / Verify (fast, Node 24)

packages/agent-bundle/tests/rstest-generated-module-win32-rename.test.ts > rejects a POSIX rename failure without retrying or leaving the temp file

promise resolved "'/tmp/ab-rstest-1933d75b74c96fae/agent…'" instead of rejecting - Expected%3A Error { "message"%3A "rejected promise"%2C } + Received%3A "/tmp/ab-rstest-1933d75b74c96fae/agent-bundle-generated-module-win32-Ae8WAy/.agent-bundle/test/meta.mjs"

Check failure on line 49 in packages/agent-bundle/tests/rstest-generated-module-win32-rename.test.ts

View workflow job for this annotation

GitHub Actions / Verify (fast, Node 26)

packages/agent-bundle/tests/rstest-generated-module-win32-rename.test.ts > rejects a POSIX rename failure without retrying or leaving the temp file

promise resolved "'/tmp/ab-rstest-b195029c4ff9d1ee/agent…'" instead of rejecting - Expected%3A Error { "message"%3A "rejected promise"%2C } + Received%3A "/tmp/ab-rstest-b195029c4ff9d1ee/agent-bundle-generated-module-win32-5gC725/.agent-bundle/test/meta.mjs"
expect(renameFailures).toEqual(['EPERM']);
expect(await readdir(join(root, '.agent-bundle', 'test'))).toEqual([]);
});
88 changes: 88 additions & 0 deletions packages/agent-bundle/tests/rstest-generated-module-write.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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<readonly [string, (root: string) => Promise<() => Promise<string>>]> = [
['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<void> => {
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<void> => {
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)]);
});
});
Loading