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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@
"check:xctest-selection": "node --experimental-strip-types scripts/check-xctest-selection.ts",
"check:packaged-runner-swift": "node --experimental-strip-types scripts/check-packaged-runner-swift.ts",
"check:tmpdir-leaks": "node --experimental-strip-types scripts/check-tmpdir-leaks.ts",
"check:tmpdir-leaks:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/check-tmpdir-leaks-model.test.ts scripts/vitest-tmpdir-global-setup.test.ts scripts/node-test-tmpdir.test.ts scripts/swift-toolchain-tmpdir.test.ts",
"check:tmpdir-leaks:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test --test-concurrency=1 scripts/check-tmpdir-leaks-model.test.ts scripts/vitest-tmpdir-global-setup.test.ts scripts/node-test-tmpdir.test.ts scripts/swift-toolchain-tmpdir.test.ts",
"check:freerange": "fr",
"check:quick": "pnpm lint && pnpm typecheck",
"sync:mcp-metadata": "node scripts/sync-mcp-metadata.mjs",
Expand Down
18 changes: 14 additions & 4 deletions packages/platform-web/src/agent-browser-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,16 @@ test('cleanup does not treat the shared socket directory mtime as browser activi
}
});

// INT32_MAX is above any pid a real host mints in practice — Linux pid_max caps
// at 2^22, macOS at 99999, and a Windows pid counter would need hundreds of
// millions of process creations to get near 2^31 — so on every host this suite
// runs on the owner is absent and cannot be recycled mid-test. A literal like
// 101 is not: on a Mac with a simulator runtime mounted, PID 101 is a live
// `appleaccountd`, the reaper reads it as ownership-lost, retains the record
// instead of clearing it, and this test goes red on that machine while staying
// green on CI.
const NEVER_A_PID = 2_147_483_647;

test('cleanup reads recorded browser identities without reconstructing a process tree', async () => {
const stateDir = mkdtempForTestSync('agent-device-web-life-');
const originalIdleTimeout = process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS;
Expand All @@ -216,9 +226,9 @@ test('cleanup reads recorded browser identities without reconstructing a process
status: 'decoded' as const,
records: [
{
pid: 101,
startTime: 'start-101',
command: 'command-101',
pid: NEVER_A_PID,
startTime: `start-${NEVER_A_PID}`,
command: `command-${NEVER_A_PID}`,
purpose: 'managed-web-browser',
},
],
Expand All @@ -235,7 +245,7 @@ test('cleanup reads recorded browser identities without reconstructing a process
ownedProcessRecords,
});

assert.deepEqual(result.pids, [101]);
assert.deepEqual(result.pids, [NEVER_A_PID]);
assert.deepEqual(result.signalPids, []);
assert.equal(mockRunCmd.mock.calls.length, 0);
expect(ownedProcessRecords.clear).toHaveBeenCalledOnce();
Expand Down
71 changes: 71 additions & 0 deletions scripts/node-test-tmpdir.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,77 @@ test('every node --test package.json script routes through scripts/node-test-tmp
);
});

// These two files each sweep the real shared root: this file asserts
// `pruneAbandonedRunDirectories(TEST_RUN_TMP_ROOT)` both keeps and removes a
// planted directory, and the Vitest lifecycle file drives a real `vitest` whose
// global setup sweeps the same root. `node --test` runs files in parallel by
// default, so one file's sweep can remove the directory the other is mid-flight
// on: the orphan test's "the next run prunes it" assertion then observes `[]`
// and fails. Whether it does depends on process interleaving, not the code —
// it passed 5/5 locally and failed on a CI runner with `actual: []`.
// Serialization is the fix; this keeps it an invariant rather than a flag
// someone can drop while "cleaning up" the lane.
const GLOBAL_SWEEP_TEST_FILES = [
'scripts/node-test-tmpdir.test.ts',
'scripts/vitest-tmpdir-global-setup.test.ts',
];

// Serialization must reach the CHILD runner, and must mean one file at a time.
// The wrapper spawns `node <args after its own path> --test`, so a flag written
// before the wrapper path is parsed by the wrapper's own node process and never
// gets there; a bare substring test would also accept `--test-concurrency=10`,
// which is ten files in flight and the same race. Read the forwarded tokens of
// the one segment that runs both sweep files, the way the wrapper test above
// segments `&&` chains.
const TEST_CONCURRENCY_ONE = '--test-concurrency=1';
const WRAPPER_SCRIPT_TOKEN = 'scripts/node-test-tmpdir.ts';

function sweepSegmentRunsParallel(command: string): boolean {
const segment = command
.split('&&')
.find(
(part) =>
part.includes(WRAPPER_SCRIPT_TOKEN) &&
GLOBAL_SWEEP_TEST_FILES.every((file) => part.includes(file)),
);
if (segment === undefined) {
return false;
}

const tokens = segment.trim().split(/\s+/);
const wrapperIndex = tokens.indexOf(WRAPPER_SCRIPT_TOKEN);
if (wrapperIndex === -1) {
// The wrapper is only ever invoked with its path as a standalone argument;
// any other shape is one this check cannot reason about, so report it as
// unsynchronized rather than guessing.
return true;
}

const forwarded = tokens.slice(wrapperIndex + 1);
const declared = forwarded.filter((token) => token.startsWith('--test-concurrency='));
return declared.length !== 1 || declared[0] !== TEST_CONCURRENCY_ONE;
}

test('lanes that sweep the shared run-directory root serialize their files', () => {
const manifest = JSON.parse(
fs.readFileSync(path.join(REPOSITORY_ROOT, 'package.json'), 'utf8'),
) as { scripts?: Record<string, string> };

const unsynchronized = Object.entries(manifest.scripts ?? {})
.filter(([, command]) => GLOBAL_SWEEP_TEST_FILES.every((file) => command.includes(file)))
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
.filter(([, command]) => sweepSegmentRunsParallel(command))
.map(([name]) => name);

assert.deepEqual(
unsynchronized,
[],
`these scripts run the global run-directory sweep from several test files in parallel, so a ` +
`sweep from one file can remove the directory another file is asserting on: ` +
`${unsynchronized.join(', ')}. Add --test-concurrency=1 after scripts/node-test-tmpdir.ts ` +
`so the child runner receives it.`,
);
});

// INT32_MAX exceeds every platform's pid range (Linux pid_max caps at 2^22,
// macOS at 99999), so kill(pid, 0) is ESRCH by construction — an owner that
// is dead and can never be reused mid-test, unlike a freshly exited child's pid.
Expand Down
Loading