mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
e832325e87
* refactor: split generic host mechanics into @agent-device/host-kit (#2082 W1) The shared src/utils closure that blocked the platform-family moves lands on declared owners: generic host mechanics form a new private @agent-device/host-kit package between kernel and capture-kit, and capture-kit keeps capture, snapshot, and recording behavior, depending on host-kit for the mechanics it needs. tar-stream and yauzl move with the archive code. Every seam's exported subpaths are pinned in package-boundaries.test.ts, the layering model ranks the new zone, R13's allow-list names it, and each seam carries an exact eager-closure row. ADR-0019's substrate amendment describes the layout. Tests that mocked two of the moved modules separately became duplicate same-seam vi.mock factories, where the second silently replaced the first; those are merged, and the mocks that production code reaches past are pinned at their injection points instead. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * refactor(host-kit): one narrow capability port per export The four technical barrels (exec/fs/values/request) grouped by category rather than by capability, so a consumer needing one mechanic evaluated unrelated ones. Each export is now a single capability over the host machine: command, process, diagnostics, retry, archive, file, request, version. A port re-exports only what a consumer of that capability uses, and every port carries its own eager-closure row. Most of the old values barrel was never host mechanics. Pure record readers, config-source values, result text, memoization, async scoping, coordinate validation, and device-scope parsing touch no process, file, or environment, so they join kernel's other primitives instead. Closures fall accordingly: capture-kit's png-worker-client from 20 to 10, png-resize from 28 to 18, session-teardown from 79 to 68, and the CLI from 386 to 380. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * chore: drop the migration inventories and trim the touched comments Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: trim the touched host-kit and mutation-lane comments Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: keep tool directives only in the touched files Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: keep tool directives only across the touched tree Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * fix: point the Swift parity comment at the real TS twin and test The W1 move rewrote this citation to packages/contracts/src/mobile-snapshot-semantics.ts, which does not exist: the module went to capture-kit while isTapPointInsideViewport itself went to packages/contracts/src/snapshot-visibility.ts. The TS test line was left pointing at the pre-move path. Both now resolve. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * fix: repoint comment citations at the homes this refactor moved them to The W1 move left ~20 comment citations pointing at src/utils/*.ts and src/request/*.ts paths that no longer exist. Each now names the capability port that owns the symbol, which survives further file moves: exec -> host-kit/command host-process, owner-identity -> host-kit/process diagnostics -> host-kit/diagnostics atomic-file, process-lock -> host-kit/file retry -> host-kit/retry request progress/cancel -> host-kit/request version -> host-kit/version ttl-memo, source-value, parsing, device-isolation, keyed-lock, success-text -> kernel subpaths Comment-only; no closure, budget, or behavior change. ADR citations are left as written, being dated records of the decision rather than live references. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH --------- Co-authored-by: Claude <noreply@anthropic.com>
411 lines
17 KiB
TypeScript
411 lines
17 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import crypto from 'node:crypto';
|
||
import fs from 'node:fs';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { test } from 'node:test';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { runCmd, runCmdBackground } from '@agent-device/host-kit/command';
|
||
import {
|
||
liveRunDirectoryConsumers,
|
||
pruneAbandonedRunDirectories,
|
||
TEST_RUN_TMP_PREFIX,
|
||
TEST_RUN_TMP_ROOT,
|
||
} from './check-tmpdir-leaks-model.ts';
|
||
|
||
const REPOSITORY_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||
const WRAPPER = path.join(REPOSITORY_ROOT, 'scripts', 'node-test-tmpdir.ts');
|
||
|
||
// package.json scripts that may legitimately invoke `node --test` without
|
||
// routing through the wrapper below. Empty on purpose: every current
|
||
// node --test lane is wrapped (#1595). Add a script name here only alongside
|
||
// a comment explaining why that lane can't be wrapped — the ratchet test
|
||
// below fails closed on anything else, so a 14th `node --test` script added
|
||
// later without the wrapper fails CI instead of silently leaking again.
|
||
const NODE_TEST_WRAPPER_BYPASS_ALLOWLIST = new Set<string>([]);
|
||
|
||
// Dumb string matching on purpose (no shell parsing, per the scripts map's
|
||
// own style: '&&'-chained commands, nothing fancier). True when a `node ...`
|
||
// segment enables the built-in test runner via a word-bounded `--test` flag
|
||
// and hasn't already been routed through the wrapper.
|
||
function isUnwrappedNodeTestSegment(segment: string): boolean {
|
||
const trimmed = segment.trim();
|
||
if (!/^node(\s|$)/.test(trimmed)) return false;
|
||
if (trimmed.includes('node-test-tmpdir.ts')) return false;
|
||
return /(^|\s)--test(\s|$)/.test(trimmed);
|
||
}
|
||
|
||
// Date.now() alone collides when this file's top-level tests happen to start
|
||
// within the same millisecond (observed in practice), which overwrites one
|
||
// probe's source with another's; the random suffix makes each probe path
|
||
// unique regardless of scheduling.
|
||
function writeProbe(evidencePath: string): string {
|
||
const probeName = `node-test-tmpdir-probe-${process.pid}-${crypto.randomUUID()}.test.ts`;
|
||
const probePath = path.join(REPOSITORY_ROOT, 'scripts', probeName);
|
||
fs.writeFileSync(
|
||
probePath,
|
||
`import fs from 'node:fs';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { test } from 'node:test';
|
||
|
||
test('probe records its inherited TMPDIR', () => {
|
||
fs.writeFileSync(${JSON.stringify(evidencePath)}, os.tmpdir());
|
||
});
|
||
`,
|
||
);
|
||
return probePath;
|
||
}
|
||
|
||
test('the wrapper redirects a node --test child TMPDIR and removes it after the run', async () => {
|
||
const evidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-test-tmpdir-lifecycle-test-'));
|
||
const evidencePath = path.join(evidenceRoot, 'child-tmpdir.txt');
|
||
const probePath = writeProbe(evidencePath);
|
||
let childTmpDir: string | undefined;
|
||
|
||
try {
|
||
const result = await runCmd(
|
||
process.execPath,
|
||
['--experimental-strip-types', WRAPPER, '--experimental-strip-types', '--test', probePath],
|
||
{ cwd: REPOSITORY_ROOT, timeoutMs: 30_000 },
|
||
);
|
||
assert.equal(result.exitCode, 0, `probe run failed:\n${result.stdout}\n${result.stderr}`);
|
||
|
||
childTmpDir = fs.readFileSync(evidencePath, 'utf8');
|
||
assert.equal(path.dirname(childTmpDir), TEST_RUN_TMP_ROOT);
|
||
assert.match(path.basename(childTmpDir), new RegExp(`^${TEST_RUN_TMP_PREFIX}\\d+-`));
|
||
assert.equal(
|
||
fs.existsSync(childTmpDir),
|
||
false,
|
||
'the wrapper must remove the run directory after the child exits',
|
||
);
|
||
} finally {
|
||
if (childTmpDir) fs.rmSync(childTmpDir, { recursive: true, force: true });
|
||
fs.rmSync(probePath, { force: true });
|
||
fs.rmSync(evidenceRoot, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('the wrapper still cleans up and forwards a nonzero exit code when the child fails', async () => {
|
||
// Diffing the whole shared TEST_RUN_TMP_ROOT listing is racy here: this
|
||
// file itself runs as one of several node --test files/workers sharing
|
||
// that root (e.g. alongside vitest-tmpdir-global-setup.test.ts in
|
||
// check:tmpdir-leaks:test), any of which can create and remove their own
|
||
// sibling run directory mid-diff. Recording the probe's actual TMPDIR (the
|
||
// same technique as the test above) and checking only that one path avoids
|
||
// the shared-directory race entirely.
|
||
const evidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-test-tmpdir-lifecycle-fail-'));
|
||
const evidencePath = path.join(evidenceRoot, 'child-tmpdir.txt');
|
||
const probeName = `node-test-tmpdir-probe-fail-${process.pid}-${crypto.randomUUID()}.test.ts`;
|
||
const probePath = path.join(REPOSITORY_ROOT, 'scripts', probeName);
|
||
fs.writeFileSync(
|
||
probePath,
|
||
`import assert from 'node:assert/strict';
|
||
import fs from 'node:fs';
|
||
import os from 'node:os';
|
||
import { test } from 'node:test';
|
||
|
||
test('deliberately failing probe', () => {
|
||
fs.writeFileSync(${JSON.stringify(evidencePath)}, os.tmpdir());
|
||
assert.fail('intentional failure to verify exit-code forwarding');
|
||
});
|
||
`,
|
||
);
|
||
|
||
let childTmpDir: string | undefined;
|
||
try {
|
||
const result = await runCmd(
|
||
process.execPath,
|
||
['--experimental-strip-types', WRAPPER, '--experimental-strip-types', '--test', probePath],
|
||
{ cwd: REPOSITORY_ROOT, timeoutMs: 30_000, allowFailure: true },
|
||
);
|
||
assert.notEqual(result.exitCode, 0, 'a failing child must propagate a nonzero exit code');
|
||
|
||
childTmpDir = fs.readFileSync(evidencePath, 'utf8');
|
||
assert.equal(path.dirname(childTmpDir), TEST_RUN_TMP_ROOT);
|
||
assert.match(path.basename(childTmpDir), new RegExp(`^${TEST_RUN_TMP_PREFIX}\\d+-`));
|
||
assert.equal(fs.existsSync(childTmpDir), false, `wrapper left behind: ${childTmpDir}`);
|
||
} finally {
|
||
if (childTmpDir) fs.rmSync(childTmpDir, { recursive: true, force: true });
|
||
fs.rmSync(probePath, { force: true });
|
||
fs.rmSync(evidenceRoot, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('the wrapper keeps AGENT_DEVICE_SWIFT_CACHE_DIR outside the disposable TMPDIR', async () => {
|
||
// Mirrors vitest-tmpdir-global-setup.ts's own carve-out (and its test's
|
||
// technique below): the Swift compiler cache must survive across runs, so
|
||
// it must resolve outside whichever directory this invocation's TMPDIR
|
||
// redirect will remove afterward. Passing '' forces the "unset" branch
|
||
// regardless of what this test process itself inherited.
|
||
const evidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-test-tmpdir-swift-cache-'));
|
||
const evidencePath = path.join(evidenceRoot, 'swift-cache-dir.txt');
|
||
const probeName = `node-test-tmpdir-probe-swift-cache-${process.pid}-${crypto.randomUUID()}.test.ts`;
|
||
const probePath = path.join(REPOSITORY_ROOT, 'scripts', probeName);
|
||
fs.writeFileSync(
|
||
probePath,
|
||
`import fs from 'node:fs';
|
||
import { test } from 'node:test';
|
||
|
||
test('probe records its inherited AGENT_DEVICE_SWIFT_CACHE_DIR', () => {
|
||
fs.writeFileSync(${JSON.stringify(evidencePath)}, process.env.AGENT_DEVICE_SWIFT_CACHE_DIR ?? '');
|
||
});
|
||
`,
|
||
);
|
||
|
||
// Computed the same way the wrapper computes it: from THIS process's
|
||
// os.tmpdir(), before the child's TMPDIR gets redirected. If this file is
|
||
// itself already running nested inside another wrapper's redirect (e.g.
|
||
// as part of check:tmpdir-leaks:test), that's the correct anchor too — the
|
||
// cache chains to whichever temp scope was current right before this
|
||
// specific invocation, exactly like vitest's setup() would if nested the
|
||
// same way.
|
||
const expectedCacheDir = path.join(os.tmpdir(), 'agent-device-swift-cache');
|
||
|
||
try {
|
||
const result = await runCmd(
|
||
process.execPath,
|
||
['--experimental-strip-types', WRAPPER, '--experimental-strip-types', '--test', probePath],
|
||
{
|
||
cwd: REPOSITORY_ROOT,
|
||
timeoutMs: 30_000,
|
||
env: { ...process.env, AGENT_DEVICE_SWIFT_CACHE_DIR: '' },
|
||
},
|
||
);
|
||
assert.equal(result.exitCode, 0, `probe run failed:\n${result.stdout}\n${result.stderr}`);
|
||
|
||
const cacheDir = fs.readFileSync(evidencePath, 'utf8');
|
||
assert.equal(
|
||
cacheDir,
|
||
expectedCacheDir,
|
||
'the wrapper must set AGENT_DEVICE_SWIFT_CACHE_DIR from the pre-redirect os.tmpdir(), ' +
|
||
'not leave it to default inside the disposable TMPDIR it is about to remove',
|
||
);
|
||
} finally {
|
||
fs.rmSync(probePath, { force: true });
|
||
fs.rmSync(evidenceRoot, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('the wrapper isolates advisory device claims inside its disposable run directory', async () => {
|
||
const evidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-test-claims-dir-'));
|
||
const evidencePath = path.join(evidenceRoot, 'claims-dir.txt');
|
||
const probeName = `node-test-claims-dir-probe-${process.pid}-${crypto.randomUUID()}.test.ts`;
|
||
const probePath = path.join(REPOSITORY_ROOT, 'scripts', probeName);
|
||
fs.writeFileSync(
|
||
probePath,
|
||
`import fs from 'node:fs';
|
||
import { test } from 'node:test';
|
||
|
||
test('probe records its inherited AGENT_DEVICE_CLAIMS_DIR', () => {
|
||
fs.writeFileSync(${JSON.stringify(evidencePath)}, process.env.AGENT_DEVICE_CLAIMS_DIR ?? '');
|
||
});
|
||
`,
|
||
);
|
||
|
||
try {
|
||
const result = await runCmd(
|
||
process.execPath,
|
||
['--experimental-strip-types', WRAPPER, '--experimental-strip-types', '--test', probePath],
|
||
{
|
||
cwd: REPOSITORY_ROOT,
|
||
timeoutMs: 30_000,
|
||
env: { ...process.env, AGENT_DEVICE_CLAIMS_DIR: '/host/device-claims' },
|
||
},
|
||
);
|
||
assert.equal(result.exitCode, 0, `probe run failed:\n${result.stdout}\n${result.stderr}`);
|
||
|
||
const claimsDir = fs.readFileSync(evidencePath, 'utf8');
|
||
assert.equal(path.basename(claimsDir), 'device-claims');
|
||
assert.equal(
|
||
fs.existsSync(path.dirname(claimsDir)),
|
||
false,
|
||
'the claims directory must be removed with the wrapper-owned run directory',
|
||
);
|
||
assert.notEqual(claimsDir, '/host/device-claims');
|
||
} finally {
|
||
fs.rmSync(probePath, { force: true });
|
||
fs.rmSync(evidenceRoot, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
// The 13 lanes wrapped in package.json when this fix landed were a one-time
|
||
// hand sweep; nothing stopped a 14th `node --test` script from being added
|
||
// later without the wrapper, silently reopening #1595 for that one lane.
|
||
// This turns the sweep into an invariant instead.
|
||
test('every node --test package.json script routes through scripts/node-test-tmpdir.ts', () => {
|
||
const manifest = JSON.parse(
|
||
fs.readFileSync(path.join(REPOSITORY_ROOT, 'package.json'), 'utf8'),
|
||
) as {
|
||
scripts?: Record<string, string>;
|
||
};
|
||
const scripts = manifest.scripts ?? {};
|
||
|
||
const unwrapped = Object.entries(scripts)
|
||
.filter(([name]) => !NODE_TEST_WRAPPER_BYPASS_ALLOWLIST.has(name))
|
||
.filter(([, command]) => command.split('&&').some(isUnwrappedNodeTestSegment))
|
||
.map(([name]) => name);
|
||
|
||
assert.deepEqual(
|
||
unwrapped,
|
||
[],
|
||
`these package.json scripts invoke \`node --test\` directly instead of through ` +
|
||
`scripts/node-test-tmpdir.ts, so a crash/timeout kill during their run leaks a scratch ` +
|
||
`directory again: ${unwrapped.join(', ')}. Route them through the wrapper, or add to ` +
|
||
`NODE_TEST_WRAPPER_BYPASS_ALLOWLIST above with a reason if one must legitimately bypass 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.
|
||
const NEVER_A_PID = 2_147_483_647;
|
||
|
||
test('the wrapper prunes a run directory abandoned by an earlier killed run and keeps a live one', async () => {
|
||
const stamp = crypto.randomUUID();
|
||
const abandoned = path.join(
|
||
TEST_RUN_TMP_ROOT,
|
||
`${TEST_RUN_TMP_PREFIX}${NEVER_A_PID}-planted-${stamp}`,
|
||
);
|
||
const live = path.join(
|
||
TEST_RUN_TMP_ROOT,
|
||
`${TEST_RUN_TMP_PREFIX}${process.pid}-planted-${stamp}`,
|
||
);
|
||
const evidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-test-tmpdir-prune-'));
|
||
const probePath = writeProbe(path.join(evidenceRoot, 'child-tmpdir.txt'));
|
||
fs.mkdirSync(path.join(abandoned, 'nested'), { recursive: true });
|
||
fs.writeFileSync(path.join(abandoned, 'nested', 'leftover.txt'), 'from a killed run');
|
||
fs.mkdirSync(live);
|
||
|
||
try {
|
||
const result = await runCmd(
|
||
process.execPath,
|
||
['--experimental-strip-types', WRAPPER, '--experimental-strip-types', '--test', probePath],
|
||
{ cwd: REPOSITORY_ROOT, timeoutMs: 30_000 },
|
||
);
|
||
assert.equal(result.exitCode, 0, `probe run failed:\n${result.stdout}\n${result.stderr}`);
|
||
assert.equal(
|
||
fs.existsSync(abandoned),
|
||
false,
|
||
'the wrapper must prune the abandoned run directory',
|
||
);
|
||
assert.equal(
|
||
fs.existsSync(live),
|
||
true,
|
||
'the wrapper must never touch a live owner’s run directory',
|
||
);
|
||
} finally {
|
||
fs.rmSync(abandoned, { recursive: true, force: true });
|
||
fs.rmSync(live, { recursive: true, force: true });
|
||
fs.rmSync(probePath, { force: true });
|
||
fs.rmSync(evidenceRoot, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('a run whose owner alone was killed keeps its directory while a child still uses it, and loses it once the child exits', async () => {
|
||
// The motivating case for consumer-aware pruning: a tool timeout SIGKILLs the wrapper (the
|
||
// owner pid in the directory name) while something it started — here a detached child the
|
||
// probe spawned, the shape of a daemon a test brought up — is still running with TMPDIR
|
||
// pointing into the run directory. Owner-pid liveness alone would prune the directory out
|
||
// from under that child on the next run.
|
||
const evidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-test-tmpdir-orphan-'));
|
||
const evidencePath = path.join(evidenceRoot, 'evidence.json');
|
||
const readyPath = path.join(evidenceRoot, 'ready');
|
||
const probeName = `node-test-tmpdir-probe-orphan-${process.pid}-${crypto.randomUUID()}.test.ts`;
|
||
const probePath = path.join(REPOSITORY_ROOT, 'scripts', probeName);
|
||
fs.writeFileSync(
|
||
probePath,
|
||
`import fs from 'node:fs';
|
||
import os from 'node:os';
|
||
import { test } from 'node:test';
|
||
import { runCmdDetached } from '@agent-device/host-kit/command';
|
||
|
||
test('probe starts a long-lived detached child that inherits TMPDIR, then waits to be killed', async () => {
|
||
const childPid = runCmdDetached(process.execPath, ['-e', 'setTimeout(() => {}, 60_000)']);
|
||
fs.writeFileSync(
|
||
${JSON.stringify(evidencePath)},
|
||
JSON.stringify({ tmpdir: os.tmpdir(), childPid, probePid: process.pid, runnerPid: process.ppid }),
|
||
);
|
||
fs.writeFileSync(${JSON.stringify(readyPath)}, '');
|
||
await new Promise((resolve) => setTimeout(resolve, 60_000));
|
||
});
|
||
`,
|
||
);
|
||
|
||
let evidence:
|
||
| { tmpdir: string; childPid: number; probePid: number; runnerPid: number }
|
||
| undefined;
|
||
const consumersOf = (e: NonNullable<typeof evidence>) => [e.childPid, e.probePid, e.runnerPid];
|
||
const wrapper = runCmdBackground(
|
||
process.execPath,
|
||
['--experimental-strip-types', WRAPPER, '--experimental-strip-types', '--test', probePath],
|
||
{ cwd: REPOSITORY_ROOT, captureOutput: false, stdio: 'ignore', allowFailure: true },
|
||
);
|
||
try {
|
||
await waitFor(() => fs.existsSync(readyPath), 20_000, 'probe never reported ready');
|
||
evidence = JSON.parse(fs.readFileSync(evidencePath, 'utf8'));
|
||
const runDir = evidence!.tmpdir;
|
||
assert.equal(path.dirname(runDir), TEST_RUN_TMP_ROOT);
|
||
assert.equal(isAlive(evidence!.childPid), true, 'the detached child must be running');
|
||
|
||
// Kill only the owner. Its exit handler cannot run on SIGKILL, so the directory survives it.
|
||
process.kill(wrapper.child.pid!, 'SIGKILL');
|
||
await wrapper.wait;
|
||
assert.equal(fs.existsSync(runDir), true, 'SIGKILL of the owner leaves the directory behind');
|
||
assert.equal(isAlive(evidence!.childPid), true, 'the detached child outlives its owner');
|
||
|
||
// The next run's prune must see the child holding TMPDIR and leave the directory alone.
|
||
assert.deepEqual(
|
||
pruneAbandonedRunDirectories(TEST_RUN_TMP_ROOT).filter((name) => runDir.endsWith(name)),
|
||
[],
|
||
);
|
||
assert.equal(fs.existsSync(runDir), true, 'a directory with a live consumer is not pruned');
|
||
assert.equal(fs.existsSync(path.join(runDir)), true);
|
||
|
||
// Once every consumer is gone — the detached child AND the orphaned node --test chain,
|
||
// which holds the same TMPDIR — it is an ordinary abandoned directory.
|
||
for (const pid of consumersOf(evidence!)) if (isAlive(pid)) process.kill(pid, 'SIGKILL');
|
||
await waitFor(
|
||
() => !liveRunDirectoryConsumers().has(path.basename(runDir)),
|
||
20_000,
|
||
'the run directory still shows a live consumer after every child exited',
|
||
);
|
||
assert.deepEqual(
|
||
pruneAbandonedRunDirectories(TEST_RUN_TMP_ROOT).filter((name) => runDir.endsWith(name)),
|
||
[path.basename(runDir)],
|
||
);
|
||
assert.equal(fs.existsSync(runDir), false, 'with no owner and no consumer it is pruned');
|
||
} finally {
|
||
for (const pid of evidence ? consumersOf(evidence) : []) {
|
||
if (isAlive(pid)) process.kill(pid, 'SIGKILL');
|
||
}
|
||
if (wrapper.child.exitCode === null && wrapper.child.signalCode === null) {
|
||
wrapper.child.kill('SIGKILL');
|
||
}
|
||
if (evidence) fs.rmSync(evidence.tmpdir, { recursive: true, force: true });
|
||
fs.rmSync(probePath, { force: true });
|
||
fs.rmSync(evidenceRoot, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
function isAlive(pid: number): boolean {
|
||
try {
|
||
process.kill(pid, 0);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function waitFor(
|
||
condition: () => boolean,
|
||
timeoutMs: number,
|
||
message: string,
|
||
): Promise<void> {
|
||
const deadline = Date.now() + timeoutMs;
|
||
while (!condition()) {
|
||
if (Date.now() > deadline) throw new Error(message);
|
||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||
}
|
||
}
|