fix(test): deterministic temp-dir cleanup across node --test lanes (#1661)
* fix(test): deterministic temp-dir cleanup across node --test lanes
node --test has no global setup/teardown hook, so unlike Vitest (#1593) every
node --test package.json script (maestro:conformance, mutation:test,
check:affected:test, check:coverage-changed:test, check:layering,
depgraph:test, check:tmpdir-leaks:test, check:contention-retry,
test:fixture-cache, test:smoke(:web), test:integration:node,
test:concurrency-torture) still created scratch directories against the
real, unredirected os.tmpdir(), with cleanup only as reliable as each call
site's own try/finally — which a crash, OOM, or timeout kill bypasses
entirely.
Add scripts/node-test-tmpdir.ts: it wraps the whole `node --test`
invocation as a child process, redirecting TMPDIR to one disposable,
pid-tagged directory (shared root/prefix with the Vitest lane) and removing
it from the process 'exit' event, which fires on normal completion, a
thrown error, or a forwarded SIGINT/SIGTERM alike. Every node --test script
now runs through it. check-tmpdir-leaks.ts already scans by root/prefix, so
it covers both mechanisms with no changes to its detection logic.
Verified: a node --test process that mkdtemp's then gets SIGKILL'd leaves a
directory behind unwrapped; wrapped and SIGTERM'd, TMPDIR is redirected and
the directory is gone with no orphaned processes. All 13 wrapped lanes and
the full Vitest suite (5,591 tests) pass with zero residual
agent-device-test-run-* directories after the run.
Fixes #1595
* test(tmpdir): ratchet every node --test script through the wrapper
The 13 lanes wrapped in package.json were a one-time hand sweep with
nothing enforcing the pattern going forward — a 14th node --test script
added later without scripts/node-test-tmpdir.ts would silently reopen
#1595 for that one lane.
Add a structural check to scripts/node-test-tmpdir.test.ts (now part of
check:tmpdir-leaks:test) that reads package.json and fails if any script
invokes `node ... --test` without routing through the wrapper. Dumb
string matching over the scripts map, no shell parsing, with an explicit
(currently empty) NODE_TEST_WRAPPER_BYPASS_ALLOWLIST for any lane that
must legitimately bypass it. Verified it both passes on the current
package.json and fails when a synthetic unwrapped `node --test` script is
added.
* fix(test): preserve the Swift cache and close the raw node --test bypasses
Review on #1661 found two gaps:
1. The wrapper only overrode TMPDIR, so it discarded and forced a
recompile of the durable Swift compiler cache every run instead of
mirroring vitest-tmpdir-global-setup.ts's carve-out for it. Read
os.tmpdir() before the child's TMPDIR redirect takes effect and set
AGENT_DEVICE_SWIFT_CACHE_DIR from that (only when unset), same as the
Vitest lane — the two now share one durable cache instead of each
discarding and recompiling their own. Added a probe assertion
(scripts/node-test-tmpdir.test.ts) that fails without the fix and
passes with it (verified both ways).
2. docs/agents/testing.md documented raw `node --test` commands for the
iOS smoke files, and the android/ios/conformance-regenerate/nightly
workflows invoked `node --test` directly outside package.json. Routed
all of them through scripts/node-test-tmpdir.ts so the documented
local commands and CI lanes get the same crash/timeout-safe cleanup
the package.json scripts already have.
2026-08-07 13:26:20 +02:00
|
|
|
|
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';
|
refactor(substrate): split host mechanics into @agent-device/host-kit capability ports (#2088)
* 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>
2026-08-28 07:46:48 +02:00
|
|
|
|
import { runCmd, runCmdBackground } from '@agent-device/host-kit/command';
|
test: prune abandoned test-run tmp directories at run setup (#1834)
* test: prune abandoned test-run tmp directories at run setup
A run killed before its teardown (tool-timeout SIGKILL, OOM, cancelled job)
left /tmp/agent-device-test-run-<pid>-* behind, and check:tmpdir-leaks — which
runs after test:unit in check:unit — flagged every dead-pid directory it
found. It could not tell this run's leak from a historical one, so one killed
run made every later, otherwise-green gate on the host fail.
Both TMPDIR redirection entry points (the Vitest global setup and the
node --test wrapper) now prune dead-pid run directories before creating their
own, printing one [tmpdir] line when they did; the post-run check keeps its
semantics and can now only ever name the run that just finished. Live owners
(a concurrent run in another worktree) are never touched.
The root/prefix constants move into check-tmpdir-leaks-model.ts, next to the
liveness classification, so the setup can import the prune without a cycle.
* test(tmpdir): a run directory is live while any process still holds it as TMPDIR, not only while its owner runs
Review (P1): owner-pid liveness alone would prune a directory out from under
the orphaned children of a SIGKILLed run — the node --test chain, Vitest forks,
or a daemon a test spawned all keep running with that TMPDIR. The liveness
model now reads every process's TMPDIR (ps -E on macOS, /proc/<pid>/environ
on Linux) and treats a run directory as live while its owner pid is alive OR
any process's TMPDIR points into it; both the prune and the post-run leak
check use it. Regression: a wrapped probe spawns a detached long-lived child,
only the wrapper is SIGKILLed, the next prune preserves the directory; after
every consumer exits, the next prune removes it. Planted red with owner-only
liveness: the orphaned directory is pruned.
2026-08-18 17:48:12 +02:00
|
|
|
|
import {
|
|
|
|
|
|
liveRunDirectoryConsumers,
|
|
|
|
|
|
pruneAbandonedRunDirectories,
|
|
|
|
|
|
TEST_RUN_TMP_PREFIX,
|
|
|
|
|
|
TEST_RUN_TMP_ROOT,
|
|
|
|
|
|
} from './check-tmpdir-leaks-model.ts';
|
fix(test): deterministic temp-dir cleanup across node --test lanes (#1661)
* fix(test): deterministic temp-dir cleanup across node --test lanes
node --test has no global setup/teardown hook, so unlike Vitest (#1593) every
node --test package.json script (maestro:conformance, mutation:test,
check:affected:test, check:coverage-changed:test, check:layering,
depgraph:test, check:tmpdir-leaks:test, check:contention-retry,
test:fixture-cache, test:smoke(:web), test:integration:node,
test:concurrency-torture) still created scratch directories against the
real, unredirected os.tmpdir(), with cleanup only as reliable as each call
site's own try/finally — which a crash, OOM, or timeout kill bypasses
entirely.
Add scripts/node-test-tmpdir.ts: it wraps the whole `node --test`
invocation as a child process, redirecting TMPDIR to one disposable,
pid-tagged directory (shared root/prefix with the Vitest lane) and removing
it from the process 'exit' event, which fires on normal completion, a
thrown error, or a forwarded SIGINT/SIGTERM alike. Every node --test script
now runs through it. check-tmpdir-leaks.ts already scans by root/prefix, so
it covers both mechanisms with no changes to its detection logic.
Verified: a node --test process that mkdtemp's then gets SIGKILL'd leaves a
directory behind unwrapped; wrapped and SIGTERM'd, TMPDIR is redirected and
the directory is gone with no orphaned processes. All 13 wrapped lanes and
the full Vitest suite (5,591 tests) pass with zero residual
agent-device-test-run-* directories after the run.
Fixes #1595
* test(tmpdir): ratchet every node --test script through the wrapper
The 13 lanes wrapped in package.json were a one-time hand sweep with
nothing enforcing the pattern going forward — a 14th node --test script
added later without scripts/node-test-tmpdir.ts would silently reopen
#1595 for that one lane.
Add a structural check to scripts/node-test-tmpdir.test.ts (now part of
check:tmpdir-leaks:test) that reads package.json and fails if any script
invokes `node ... --test` without routing through the wrapper. Dumb
string matching over the scripts map, no shell parsing, with an explicit
(currently empty) NODE_TEST_WRAPPER_BYPASS_ALLOWLIST for any lane that
must legitimately bypass it. Verified it both passes on the current
package.json and fails when a synthetic unwrapped `node --test` script is
added.
* fix(test): preserve the Swift cache and close the raw node --test bypasses
Review on #1661 found two gaps:
1. The wrapper only overrode TMPDIR, so it discarded and forced a
recompile of the durable Swift compiler cache every run instead of
mirroring vitest-tmpdir-global-setup.ts's carve-out for it. Read
os.tmpdir() before the child's TMPDIR redirect takes effect and set
AGENT_DEVICE_SWIFT_CACHE_DIR from that (only when unset), same as the
Vitest lane — the two now share one durable cache instead of each
discarding and recompiling their own. Added a probe assertion
(scripts/node-test-tmpdir.test.ts) that fails without the fix and
passes with it (verified both ways).
2. docs/agents/testing.md documented raw `node --test` commands for the
iOS smoke files, and the android/ios/conformance-regenerate/nightly
workflows invoked `node --test` directly outside package.json. Routed
all of them through scripts/node-test-tmpdir.ts so the documented
local commands and CI lanes get the same crash/timeout-safe cleanup
the package.json scripts already have.
2026-08-07 13:26:20 +02:00
|
|
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-10 12:51:59 +02:00
|
|
|
|
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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
fix(test): deterministic temp-dir cleanup across node --test lanes (#1661)
* fix(test): deterministic temp-dir cleanup across node --test lanes
node --test has no global setup/teardown hook, so unlike Vitest (#1593) every
node --test package.json script (maestro:conformance, mutation:test,
check:affected:test, check:coverage-changed:test, check:layering,
depgraph:test, check:tmpdir-leaks:test, check:contention-retry,
test:fixture-cache, test:smoke(:web), test:integration:node,
test:concurrency-torture) still created scratch directories against the
real, unredirected os.tmpdir(), with cleanup only as reliable as each call
site's own try/finally — which a crash, OOM, or timeout kill bypasses
entirely.
Add scripts/node-test-tmpdir.ts: it wraps the whole `node --test`
invocation as a child process, redirecting TMPDIR to one disposable,
pid-tagged directory (shared root/prefix with the Vitest lane) and removing
it from the process 'exit' event, which fires on normal completion, a
thrown error, or a forwarded SIGINT/SIGTERM alike. Every node --test script
now runs through it. check-tmpdir-leaks.ts already scans by root/prefix, so
it covers both mechanisms with no changes to its detection logic.
Verified: a node --test process that mkdtemp's then gets SIGKILL'd leaves a
directory behind unwrapped; wrapped and SIGTERM'd, TMPDIR is redirected and
the directory is gone with no orphaned processes. All 13 wrapped lanes and
the full Vitest suite (5,591 tests) pass with zero residual
agent-device-test-run-* directories after the run.
Fixes #1595
* test(tmpdir): ratchet every node --test script through the wrapper
The 13 lanes wrapped in package.json were a one-time hand sweep with
nothing enforcing the pattern going forward — a 14th node --test script
added later without scripts/node-test-tmpdir.ts would silently reopen
#1595 for that one lane.
Add a structural check to scripts/node-test-tmpdir.test.ts (now part of
check:tmpdir-leaks:test) that reads package.json and fails if any script
invokes `node ... --test` without routing through the wrapper. Dumb
string matching over the scripts map, no shell parsing, with an explicit
(currently empty) NODE_TEST_WRAPPER_BYPASS_ALLOWLIST for any lane that
must legitimately bypass it. Verified it both passes on the current
package.json and fails when a synthetic unwrapped `node --test` script is
added.
* fix(test): preserve the Swift cache and close the raw node --test bypasses
Review on #1661 found two gaps:
1. The wrapper only overrode TMPDIR, so it discarded and forced a
recompile of the durable Swift compiler cache every run instead of
mirroring vitest-tmpdir-global-setup.ts's carve-out for it. Read
os.tmpdir() before the child's TMPDIR redirect takes effect and set
AGENT_DEVICE_SWIFT_CACHE_DIR from that (only when unset), same as the
Vitest lane — the two now share one durable cache instead of each
discarding and recompiling their own. Added a probe assertion
(scripts/node-test-tmpdir.test.ts) that fails without the fix and
passes with it (verified both ways).
2. docs/agents/testing.md documented raw `node --test` commands for the
iOS smoke files, and the android/ios/conformance-regenerate/nightly
workflows invoked `node --test` directly outside package.json. Routed
all of them through scripts/node-test-tmpdir.ts so the documented
local commands and CI lanes get the same crash/timeout-safe cleanup
the package.json scripts already have.
2026-08-07 13:26:20 +02:00
|
|
|
|
// 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.`,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
test: prune abandoned test-run tmp directories at run setup (#1834)
* test: prune abandoned test-run tmp directories at run setup
A run killed before its teardown (tool-timeout SIGKILL, OOM, cancelled job)
left /tmp/agent-device-test-run-<pid>-* behind, and check:tmpdir-leaks — which
runs after test:unit in check:unit — flagged every dead-pid directory it
found. It could not tell this run's leak from a historical one, so one killed
run made every later, otherwise-green gate on the host fail.
Both TMPDIR redirection entry points (the Vitest global setup and the
node --test wrapper) now prune dead-pid run directories before creating their
own, printing one [tmpdir] line when they did; the post-run check keeps its
semantics and can now only ever name the run that just finished. Live owners
(a concurrent run in another worktree) are never touched.
The root/prefix constants move into check-tmpdir-leaks-model.ts, next to the
liveness classification, so the setup can import the prune without a cycle.
* test(tmpdir): a run directory is live while any process still holds it as TMPDIR, not only while its owner runs
Review (P1): owner-pid liveness alone would prune a directory out from under
the orphaned children of a SIGKILLed run — the node --test chain, Vitest forks,
or a daemon a test spawned all keep running with that TMPDIR. The liveness
model now reads every process's TMPDIR (ps -E on macOS, /proc/<pid>/environ
on Linux) and treats a run directory as live while its owner pid is alive OR
any process's TMPDIR points into it; both the prune and the post-run leak
check use it. Regression: a wrapped probe spawns a detached long-lived child,
only the wrapper is SIGKILLed, the next prune preserves the directory; after
every consumer exits, the next prune removes it. Planted red with owner-only
liveness: the orphaned directory is pruned.
2026-08-18 17:48:12 +02:00
|
|
|
|
|
|
|
|
|
|
// 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';
|
refactor(substrate): split host mechanics into @agent-device/host-kit capability ports (#2088)
* 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>
2026-08-28 07:46:48 +02:00
|
|
|
|
import { runCmdDetached } from '@agent-device/host-kit/command';
|
test: prune abandoned test-run tmp directories at run setup (#1834)
* test: prune abandoned test-run tmp directories at run setup
A run killed before its teardown (tool-timeout SIGKILL, OOM, cancelled job)
left /tmp/agent-device-test-run-<pid>-* behind, and check:tmpdir-leaks — which
runs after test:unit in check:unit — flagged every dead-pid directory it
found. It could not tell this run's leak from a historical one, so one killed
run made every later, otherwise-green gate on the host fail.
Both TMPDIR redirection entry points (the Vitest global setup and the
node --test wrapper) now prune dead-pid run directories before creating their
own, printing one [tmpdir] line when they did; the post-run check keeps its
semantics and can now only ever name the run that just finished. Live owners
(a concurrent run in another worktree) are never touched.
The root/prefix constants move into check-tmpdir-leaks-model.ts, next to the
liveness classification, so the setup can import the prune without a cycle.
* test(tmpdir): a run directory is live while any process still holds it as TMPDIR, not only while its owner runs
Review (P1): owner-pid liveness alone would prune a directory out from under
the orphaned children of a SIGKILLed run — the node --test chain, Vitest forks,
or a daemon a test spawned all keep running with that TMPDIR. The liveness
model now reads every process's TMPDIR (ps -E on macOS, /proc/<pid>/environ
on Linux) and treats a run directory as live while its owner pid is alive OR
any process's TMPDIR points into it; both the prune and the post-run leak
check use it. Regression: a wrapped probe spawns a detached long-lived child,
only the wrapper is SIGKILLed, the next prune preserves the directory; after
every consumer exits, the next prune removes it. Planted red with owner-only
liveness: the orphaned directory is pruned.
2026-08-18 17:48:12 +02:00
|
|
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|