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>
161 lines
6.5 KiB
TypeScript
161 lines
6.5 KiB
TypeScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { runCmdSync } from '@agent-device/host-kit/command';
|
|
|
|
// Every test run (Vitest via scripts/vitest-tmpdir-global-setup.ts, node --test
|
|
// via scripts/node-test-tmpdir.ts) redirects TMPDIR into one disposable,
|
|
// pid-tagged directory under this root and removes it at teardown.
|
|
//
|
|
// Rooted at /tmp rather than nested inside the current os.tmpdir(): macOS's
|
|
// per-user TMPDIR (/var/folders/.../T/) is already close to the 104-byte
|
|
// sun_path limit AF_UNIX sockets need, and tests that bind real sockets
|
|
// (e.g. runner-usbmux.test.ts) started hitting EINVAL once nested one level
|
|
// deeper. /tmp is short enough to leave headroom for those.
|
|
//
|
|
// Both redirection mechanisms and check-tmpdir-leaks.ts import these two from
|
|
// here rather than recomputing them, so they can't drift onto different
|
|
// directories (os.tmpdir() != /tmp on macOS, where TMPDIR is a deep per-user
|
|
// path).
|
|
export const TEST_RUN_TMP_ROOT = '/tmp';
|
|
export const TEST_RUN_TMP_PREFIX = 'agent-device-test-run-';
|
|
|
|
const PID_SUFFIX = new RegExp(`^${TEST_RUN_TMP_PREFIX}(\\d+)-`);
|
|
|
|
function isProcessAlive(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (error) {
|
|
// EPERM means the pid exists but we lack permission to signal it — still alive.
|
|
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The run directories some live process is still using: every process whose TMPDIR points
|
|
* into one. That is the ownership signal every consumer actually carries — the run's own
|
|
* setup exports it and every child (Vitest forks, the node --test chain, daemons a test
|
|
* spawned) inherits it — so a run whose owner was SIGKILLed while its children kept running
|
|
* is still "in use" until the last of them exits. Read from `ps -E` on macOS (environment
|
|
* is shown for the caller's own processes) and /proc/<pid>/environ on Linux; on either, a
|
|
* process this user cannot inspect contributes nothing, and its directory is then judged by
|
|
* its owner pid alone.
|
|
*/
|
|
export function liveRunDirectoryConsumers(): ReadonlySet<string> {
|
|
const consumers = new Set<string>();
|
|
for (const value of readAllProcessTmpdirs()) {
|
|
const name = runDirectoryNameOf(value);
|
|
if (name !== undefined) consumers.add(name);
|
|
}
|
|
return consumers;
|
|
}
|
|
|
|
/** `/tmp/agent-device-test-run-123-abc/nested` → `agent-device-test-run-123-abc`; else undefined. */
|
|
export function runDirectoryNameOf(tmpdir: string): string | undefined {
|
|
const rootPrefix = `${TEST_RUN_TMP_ROOT}/`;
|
|
if (!tmpdir.startsWith(rootPrefix)) return undefined;
|
|
const name = tmpdir.slice(rootPrefix.length).split('/')[0] ?? '';
|
|
return name.startsWith(TEST_RUN_TMP_PREFIX) ? name : undefined;
|
|
}
|
|
|
|
function readAllProcessTmpdirs(): string[] {
|
|
return process.platform === 'linux' ? readProcTmpdirs() : readPsTmpdirs();
|
|
}
|
|
|
|
function readProcTmpdirs(): string[] {
|
|
return fs
|
|
.readdirSync('/proc')
|
|
.filter((entry) => /^\d+$/.test(entry))
|
|
.flatMap((pid) => tmpdirsOfEnviron(readEnvironOrEmpty(pid)));
|
|
}
|
|
|
|
/** Another user's process, or one that exited mid-scan, contributes nothing. */
|
|
function readEnvironOrEmpty(pid: string): string {
|
|
try {
|
|
return fs.readFileSync(`/proc/${pid}/environ`, 'latin1');
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function tmpdirsOfEnviron(environ: string): string[] {
|
|
return environ
|
|
.split('\0')
|
|
.filter((pair) => pair.startsWith('TMPDIR='))
|
|
.map((pair) => pair.slice('TMPDIR='.length));
|
|
}
|
|
|
|
// macOS (and other BSDs): -E appends the environment to each command line. Every process's
|
|
// environment is a few MB on a busy host — well past spawnSync's 1 MB default.
|
|
function readPsTmpdirs(): string[] {
|
|
const listing = runCmdSync('ps', ['-axEww', '-o', 'command='], {
|
|
allowFailure: true,
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
});
|
|
if (listing.exitCode !== 0) return [];
|
|
return [...listing.stdout.matchAll(/(?:^|\s)TMPDIR=(\S+)/g)].map((match) => match[1] as string);
|
|
}
|
|
|
|
export type RunDirectoryLiveness = Readonly<{
|
|
isAlive?: (pid: number) => boolean;
|
|
consumers?: ReadonlySet<string>;
|
|
}>;
|
|
|
|
/**
|
|
* A run directory is live while its owning process (the pid in its name) runs, OR while any
|
|
* process still holds it as TMPDIR — a concurrent run in another worktree, or the orphaned
|
|
* children of a killed run. Only the rest are leaks: nobody owns them and nobody uses them.
|
|
*/
|
|
export function findLeakedRunDirectories(
|
|
root: string,
|
|
liveness: RunDirectoryLiveness = {},
|
|
): string[] {
|
|
const isAlive = liveness.isAlive ?? isProcessAlive;
|
|
const consumers = liveness.consumers ?? liveRunDirectoryConsumers();
|
|
return fs
|
|
.readdirSync(root, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory() && entry.name.startsWith(TEST_RUN_TMP_PREFIX))
|
|
.filter((entry) => {
|
|
if (consumers.has(entry.name)) return false;
|
|
const match = PID_SUFFIX.exec(entry.name);
|
|
// No parseable pid means it didn't come from setup() as written — treat it as a leak.
|
|
if (!match) return true;
|
|
return !isAlive(Number(match[1]));
|
|
})
|
|
.map((entry) => entry.name);
|
|
}
|
|
|
|
/**
|
|
* Removes the run directories an earlier, already-exited run left behind and
|
|
* returns their names. A run's setup calls this before creating its own
|
|
* directory, so the post-run leak check (check-tmpdir-leaks.ts) can only ever
|
|
* report the run that just finished: a directory abandoned by an earlier run
|
|
* that was killed before its teardown (SIGKILL on a tool timeout, OOM, a
|
|
* cancelled CI job) is by construction the same thing that teardown would have
|
|
* removed, and leaving it in place made every later, otherwise-green gate on
|
|
* the host fail for a run it never ran. Live owners are never touched, so a
|
|
* concurrent run in another worktree keeps its directory.
|
|
*/
|
|
export function pruneAbandonedRunDirectories(
|
|
root: string,
|
|
liveness: RunDirectoryLiveness = {},
|
|
): string[] {
|
|
const abandoned = findLeakedRunDirectories(root, liveness);
|
|
for (const name of abandoned) {
|
|
fs.rmSync(path.join(root, name), { recursive: true, force: true });
|
|
}
|
|
return abandoned;
|
|
}
|
|
|
|
/**
|
|
* One stderr line, only when something was pruned: an earlier run on this
|
|
* host died before its teardown, which the operator should know (a tool
|
|
* timeout killed it, say) without it being a failure of this run.
|
|
*/
|
|
export function reportPrunedRunDirectories(pruned: readonly string[]): void {
|
|
if (pruned.length === 0) return;
|
|
process.stderr.write(
|
|
`[tmpdir] pruned ${pruned.length} abandoned ${TEST_RUN_TMP_PREFIX}* director${pruned.length === 1 ? 'y' : 'ies'} left by an earlier killed run\n`,
|
|
);
|
|
}
|