@zgeoff/atc 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +6 -0
  2. package/package.json +4 -3
  3. package/src/build-own-daemon-process.ts +16 -0
  4. package/src/build-restart-failure.ts +24 -0
  5. package/src/cli.ts +96 -0
  6. package/src/client/boot-daemon.ts +5 -32
  7. package/src/collect-restart-env.ts +18 -0
  8. package/src/collect-restart-plan.ts +157 -0
  9. package/src/daemon/daemon.ts +7 -3
  10. package/src/daemon-process.ts +12 -0
  11. package/src/find-restart-unit.ts +45 -0
  12. package/src/format-restart-preflight.ts +74 -0
  13. package/src/format-restart-report.ts +45 -0
  14. package/src/is-daemon-command-line.ts +33 -0
  15. package/src/is-daemon-process.ts +33 -0
  16. package/src/parse-daemon-flags.ts +33 -0
  17. package/src/parse-mismatch-build.ts +19 -0
  18. package/src/parse-restart-result.ts +65 -0
  19. package/src/parse-systemd-unit.ts +25 -0
  20. package/src/read-daemon-process.ts +50 -0
  21. package/src/read-fleet-snapshot.ts +37 -0
  22. package/src/read-restart-result.ts +17 -0
  23. package/src/read-stored-fleet-file.ts +74 -0
  24. package/src/read-stored-rows.ts +36 -0
  25. package/src/remove-restart-result.ts +11 -0
  26. package/src/restart-daemon.ts +149 -0
  27. package/src/restart-plan.ts +33 -0
  28. package/src/run-daemon-restart-worker.ts +77 -0
  29. package/src/run-daemon-restart.ts +228 -0
  30. package/src/run-systemctl.ts +33 -0
  31. package/src/shared/build-atc-command.ts +13 -0
  32. package/src/shared/config.ts +5 -0
  33. package/src/shared/find-pid-file-pid.ts +15 -0
  34. package/src/shared/get-build.ts +4 -3
  35. package/src/shared/is-process-alive.ts +21 -0
  36. package/src/shared/spawn-atc-detached.ts +34 -0
  37. package/src/start-replacement-daemon.ts +31 -0
  38. package/src/stop-daemon-process.ts +46 -0
  39. package/src/store/state-store.ts +2 -2
  40. package/src/test-utils/can-bind-addresses.ts +19 -0
  41. package/src/test-utils/create-migrated-state-db.ts +38 -0
  42. package/src/test-utils/env-originals.ts +6 -0
  43. package/src/test-utils/fixture-dir-provider.ts +131 -0
  44. package/src/test-utils/fixture-imp-port.ts +1692 -0
  45. package/src/test-utils/get-agent-entry.ts +17 -0
  46. package/src/test-utils/get-gateway-config.ts +18 -0
  47. package/src/test-utils/isolate-home.ts +60 -0
  48. package/src/test-utils/read-json-record.ts +20 -0
  49. package/src/test-utils/remove-env-overrides.ts +18 -0
  50. package/src/test-utils/run-legacy-daemon.ts +38 -0
  51. package/src/test-utils/run-listener-daemon.ts +51 -0
  52. package/src/test-utils/run-mcp-authorization.ts +118 -0
  53. package/src/test-utils/run-source-daemon.ts +110 -0
  54. package/src/test-utils/setup-fake-systemd.ts +125 -0
  55. package/src/test-utils/setup-mcp-http.ts +126 -0
  56. package/src/test-utils/setup-temp-dir.ts +33 -0
  57. package/src/test-utils/spawn-named-session.ts +27 -0
  58. package/src/test-utils/start-cut-proxy.ts +175 -0
  59. package/src/test-utils/start-git-http-server.ts +102 -0
  60. package/src/test-utils/start-legacy-daemon.ts +111 -0
  61. package/src/test-utils/subscribe-to-socket-lines.ts +60 -0
  62. package/src/test-utils/update-env.ts +19 -0
  63. package/src/test-utils/wait-for.ts +38 -0
  64. package/src/verify-restored-fleet.ts +155 -0
  65. package/src/wait-for-replacement.ts +112 -0
  66. package/src/write-restart-result.ts +17 -0
  67. /package/src/{daemon → shared}/claim-daemon-lock.ts +0 -0
  68. /package/src/{daemon → shared}/load-listener-tokens.ts +0 -0
@@ -0,0 +1,77 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { buildRestartFailure } from './build-restart-failure';
3
+ import { formatRestartReport } from './format-restart-report';
4
+ import type { RestartResult } from './parse-restart-result';
5
+ import { readRestartResult } from './read-restart-result';
6
+ import { removeRestartResult } from './remove-restart-result';
7
+ import { restartDaemon } from './restart-daemon';
8
+ import type { RestartOptions } from './restart-daemon';
9
+ import { claimDaemonLock } from './shared/claim-daemon-lock';
10
+ import { restartLockFile, restartsDir } from './shared/config';
11
+ import { writeRestartResult } from './write-restart-result';
12
+
13
+ // How long a restart that finds another in flight waits for it to finish.
14
+ const JOIN_WAIT_MS = 15 * 60_000;
15
+
16
+ /**
17
+ * Runs one restart as the handoff worker: it takes the restart lock and
18
+ * restarts the daemon, or, when another restart holds the lock, waits for
19
+ * that restart to finish and reports its result without touching the
20
+ * daemon. Prints each step to stdout, which the caller follows in the run
21
+ * log, and ends the log with the result record. Resolves the exit code.
22
+ */
23
+ export async function runDaemonRestartWorker(options: RestartOptions): Promise<number> {
24
+ console.log(`worker pid ${process.pid}`);
25
+
26
+ mkdirSync(restartsDir, { recursive: true });
27
+
28
+ const lock = await claimDaemonLock(restartLockFile, 0);
29
+
30
+ let result: RestartResult;
31
+
32
+ if (lock === null) {
33
+ result = await waitForRestartInFlight(options.runID);
34
+ } else {
35
+ try {
36
+ // A joiner reads last.json once it gets the lock, so a result left by
37
+ // an earlier restart must not outlive this run's start.
38
+ removeRestartResult();
39
+
40
+ result = await restartDaemon(options).catch((error: unknown) => {
41
+ const reason = error instanceof Error ? error.message : String(error);
42
+
43
+ return buildRestartFailure(options.runID, reason);
44
+ });
45
+
46
+ writeRestartResult(result);
47
+ } finally {
48
+ lock.dispose();
49
+ }
50
+ }
51
+
52
+ for (const line of formatRestartReport(result)) {
53
+ console.log(line);
54
+ }
55
+
56
+ console.log(JSON.stringify(result));
57
+
58
+ return result.code;
59
+ }
60
+
61
+ async function waitForRestartInFlight(runID: string): Promise<RestartResult> {
62
+ console.log('another restart is in flight; this run joins it and reports its result');
63
+
64
+ const held = await claimDaemonLock(restartLockFile, JOIN_WAIT_MS);
65
+
66
+ if (held === null) {
67
+ return buildRestartFailure(runID, 'timed out waiting for the restart in flight to finish');
68
+ }
69
+
70
+ const finished = readRestartResult();
71
+
72
+ held.dispose();
73
+
74
+ return finished === null
75
+ ? buildRestartFailure(runID, 'the restart in flight left no result')
76
+ : { ...finished, runID };
77
+ }
@@ -0,0 +1,228 @@
1
+ import {
2
+ closeSync,
3
+ mkdirSync,
4
+ openSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ rmSync,
8
+ statSync,
9
+ } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { collectRestartEnv } from './collect-restart-env';
12
+ import { collectRestartPlan } from './collect-restart-plan';
13
+ import { formatRestartPreflight } from './format-restart-preflight';
14
+ import { parseRestartResult } from './parse-restart-result';
15
+ import { buildATCCommand } from './shared/build-atc-command';
16
+ import { restartsDir } from './shared/config';
17
+ import { isProcessAlive } from './shared/is-process-alive';
18
+ import { spawnATCDetached } from './shared/spawn-atc-detached';
19
+
20
+ interface DaemonRestartOptions {
21
+ readonly listen: string | null;
22
+ readonly tokenFile: string | null;
23
+ readonly timeoutSeconds: number | null;
24
+ readonly dryRun: boolean;
25
+ }
26
+
27
+ const LOG_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
28
+
29
+ // How long the follower waits for the worker to report its pid before it
30
+ // gives up on a worker that never started.
31
+ const WORKER_START_WAIT_MS = 30_000;
32
+
33
+ /**
34
+ * Runs `atc daemon restart` in the caller: a dry run prints the preflight
35
+ * and stops, and a restart hands the work to a detached worker, because the
36
+ * restart can end the process that asked for it. The worker writes a run
37
+ * log, which this follows line by line until the worker's result record
38
+ * ends it. Resolves the exit code: the worker's, or 1 when the worker
39
+ * vanishes without a result.
40
+ */
41
+ export async function runDaemonRestart(options: DaemonRestartOptions): Promise<number> {
42
+ const callerSession = process.env['ATC_SESSION_ID'] ?? null;
43
+
44
+ const plan = await collectRestartPlan(callerSession);
45
+
46
+ if (options.dryRun) {
47
+ for (const line of formatRestartPreflight(plan)) {
48
+ console.log(line);
49
+ }
50
+
51
+ return 0;
52
+ }
53
+
54
+ mkdirSync(restartsDir, { recursive: true });
55
+ removeStaleRestartLogs();
56
+
57
+ const runID = mintRunID();
58
+ const logPath = join(restartsDir, `${runID}.log`);
59
+ const workerArgs = buildWorkerArgs(runID, callerSession, options);
60
+ const logFD = openSync(logPath, 'a');
61
+ let workerPID: number | null = null;
62
+
63
+ try {
64
+ if (plan.replacement.kind === 'unit') {
65
+ const started = await startWorkerInUnit(runID, logPath, workerArgs);
66
+
67
+ if (started !== 0) {
68
+ console.error(`atc daemon restart: systemd-run failed with exit code ${started}`);
69
+
70
+ return 1;
71
+ }
72
+ } else {
73
+ workerPID =
74
+ spawnATCDetached(workerArgs, {
75
+ env: collectRestartEnv(process.env),
76
+ stdio: ['ignore', logFD, logFD],
77
+ }).pid ?? null;
78
+ }
79
+ } finally {
80
+ closeSync(logFD);
81
+ }
82
+
83
+ console.log(`progress: ${logPath}`);
84
+
85
+ return printRunLog(runID, logPath, workerPID);
86
+ }
87
+
88
+ function mintRunID(): string {
89
+ return `${Date.now().toString(36)}-${crypto.randomUUID().slice(0, 6)}`;
90
+ }
91
+
92
+ function buildWorkerArgs(
93
+ runID: string,
94
+ callerSession: string | null,
95
+ options: DaemonRestartOptions,
96
+ ): string[] {
97
+ return [
98
+ 'daemon',
99
+ 'restart-worker',
100
+ runID,
101
+ ...(callerSession === null ? [] : ['--session', callerSession]),
102
+ ...(options.listen === null ? [] : ['--listen', options.listen]),
103
+ ...(options.tokenFile === null ? [] : ['--token-file', options.tokenFile]),
104
+ ...(options.timeoutSeconds === null ? [] : ['--timeout', String(options.timeoutSeconds)]),
105
+ ];
106
+ }
107
+
108
+ // A restart through a unit stops the unit's whole cgroup, which holds a
109
+ // hosted caller and its children, so the worker starts as a transient unit
110
+ // of its own. It gets HOME, XDG_RUNTIME_DIR, PATH and every ATC_ variable
111
+ // explicitly, since a transient unit otherwise inherits the user manager's
112
+ // environment and with it the real state directory.
113
+ function startWorkerInUnit(
114
+ runID: string,
115
+ logPath: string,
116
+ workerArgs: readonly string[],
117
+ ): Promise<number> {
118
+ const env = collectRestartEnv(process.env);
119
+
120
+ const setenvs = Object.entries(env)
121
+ .filter(
122
+ ([key]) =>
123
+ key === 'HOME' || key === 'XDG_RUNTIME_DIR' || key === 'PATH' || key.startsWith('ATC_'),
124
+ )
125
+ .map(([key, value]) => `--setenv=${key}=${value}`);
126
+
127
+ const proc = Bun.spawn(
128
+ [
129
+ 'systemd-run',
130
+ '--user',
131
+ '--collect',
132
+ '--quiet',
133
+ '--unit',
134
+ `atc-daemon-restart-${runID}`,
135
+ `--property=StandardOutput=append:${logPath}`,
136
+ `--property=StandardError=append:${logPath}`,
137
+ ...setenvs,
138
+ '--',
139
+ ...buildATCCommand(workerArgs),
140
+ ],
141
+ { env, stdin: 'ignore', stdout: 'ignore', stderr: 'inherit' },
142
+ );
143
+
144
+ return proc.exited;
145
+ }
146
+
147
+ async function printRunLog(
148
+ runID: string,
149
+ logPath: string,
150
+ workerPIDAtStart: number | null,
151
+ ): Promise<number> {
152
+ const startedAt = Date.now();
153
+ let workerPID = workerPIDAtStart;
154
+ let printed = 0;
155
+ let pending = '';
156
+ let vanished = false;
157
+
158
+ for (;;) {
159
+ const text = readLog(logPath);
160
+
161
+ pending += text.slice(printed);
162
+ printed = text.length;
163
+
164
+ const lines = pending.split('\n');
165
+
166
+ pending = lines.pop() ?? '';
167
+
168
+ for (const line of lines) {
169
+ const result = parseRestartResult(line);
170
+
171
+ if (result !== null && result.runID === runID) {
172
+ return result.code;
173
+ }
174
+
175
+ const pidMatch = /^worker pid (?<pid>\d+)$/.exec(line);
176
+
177
+ if (pidMatch !== null) {
178
+ workerPID = Number(pidMatch.groups?.['pid']);
179
+ }
180
+
181
+ console.log(line);
182
+ }
183
+
184
+ if (vanished) {
185
+ return printVanishedWorker(logPath);
186
+ }
187
+
188
+ // One more read after the worker is gone, in case its last lines landed
189
+ // between the read above and its exit.
190
+ vanished =
191
+ (workerPID !== null && !isProcessAlive(workerPID)) ||
192
+ (workerPID === null && Date.now() - startedAt > WORKER_START_WAIT_MS);
193
+
194
+ await Bun.sleep(100);
195
+ }
196
+ }
197
+
198
+ function printVanishedWorker(logPath: string): number {
199
+ console.error(`atc daemon restart: the restart worker ended without a result; see ${logPath}`);
200
+
201
+ return 1;
202
+ }
203
+
204
+ function readLog(logPath: string): string {
205
+ try {
206
+ return readFileSync(logPath, 'utf8');
207
+ } catch {
208
+ return '';
209
+ }
210
+ }
211
+
212
+ function removeStaleRestartLogs(): void {
213
+ const cutoff = Date.now() - LOG_RETENTION_MS;
214
+
215
+ for (const entry of readdirSync(restartsDir)) {
216
+ if (!entry.endsWith('.log')) {
217
+ continue;
218
+ }
219
+
220
+ const path = join(restartsDir, entry);
221
+
222
+ try {
223
+ if (statSync(path).mtimeMs < cutoff) {
224
+ rmSync(path, { force: true });
225
+ }
226
+ } catch {}
227
+ }
228
+ }
@@ -0,0 +1,33 @@
1
+ interface SystemctlResult {
2
+ readonly code: number;
3
+ readonly stdout: string;
4
+ readonly stderr: string;
5
+ }
6
+
7
+ /**
8
+ * Runs `systemctl --user` with `args` and returns its exit code and output.
9
+ * A machine without systemctl reads as exit code 127.
10
+ */
11
+ export async function runSystemctl(args: readonly string[]): Promise<SystemctlResult> {
12
+ try {
13
+ const proc = Bun.spawn(['systemctl', '--user', ...args], {
14
+ stdin: 'ignore',
15
+ stdout: 'pipe',
16
+ stderr: 'pipe',
17
+ });
18
+
19
+ const [stdout, stderr, code] = await Promise.all([
20
+ new Response(proc.stdout).text(),
21
+ new Response(proc.stderr).text(),
22
+ proc.exited,
23
+ ]);
24
+
25
+ return { code, stdout: stdout.trim(), stderr: stderr.trim() };
26
+ } catch (error) {
27
+ return {
28
+ code: 127,
29
+ stdout: '',
30
+ stderr: error instanceof Error ? error.message : String(error),
31
+ };
32
+ }
33
+ }
@@ -0,0 +1,13 @@
1
+ import { join } from 'node:path';
2
+ import { isCompiledBinary } from './is-compiled-binary';
3
+
4
+ /**
5
+ * The argv that runs this build of atc with `args`: the compiled binary is
6
+ * its own entry, and a source run is bun with the CLI entrypoint ahead of
7
+ * the arguments.
8
+ */
9
+ export function buildATCCommand(args: readonly string[]): string[] {
10
+ return isCompiledBinary()
11
+ ? [process.execPath, ...args]
12
+ : [process.execPath, join(import.meta.dir, '..', 'cli.ts'), ...args];
13
+ }
@@ -141,6 +141,11 @@ export const daemonPidFile = join(process.env['XDG_RUNTIME_DIR'] ?? stateDir, 'a
141
141
  // on, for a client whose environment computes other socket paths.
142
142
  export const daemonRecordFile = join(stateDir, 'daemon.json');
143
143
 
144
+ // Where `atc daemon restart` keeps its run logs and the record of the last
145
+ // finished restart, and the lock that admits one restart at a time.
146
+ export const restartsDir = join(stateDir, 'restarts');
147
+ export const restartLockFile = join(stateDir, 'daemon-restart.lock');
148
+
144
149
  // A user-written config.json's top-level keys. An absent or wrong-typed
145
150
  // field parses to undefined rather than failing the file, so a bad config
146
151
  // falls back to a default instead of refusing to start atc.
@@ -0,0 +1,15 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ /**
4
+ * The pid a daemon's pid file holds, or null when the file is missing or
5
+ * holds anything but a pid above 1.
6
+ */
7
+ export function findPidFilePID(pidFile: string): number | null {
8
+ try {
9
+ const pid = Number(readFileSync(pidFile, 'utf8'));
10
+
11
+ return Number.isInteger(pid) && pid > 1 ? pid : null;
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
@@ -12,13 +12,14 @@ const SRC_ROOT = join(import.meta.dir, '..');
12
12
  * different checkouts of the same version still read as different builds,
13
13
  * which is what triggers the stale-daemon restart. Any source file counts:
14
14
  * a stamp taken from a single file misses every change that lands
15
- * elsewhere and leaves stale daemons in service.
15
+ * elsewhere and leaves stale daemons in service. The walk starts at this
16
+ * checkout's src/ root unless the caller passes another root.
16
17
  */
17
- export function getBuild(): string {
18
+ export function getBuild(srcRoot: string = SRC_ROOT): string {
18
19
  let stamp = 0;
19
20
 
20
21
  try {
21
- stamp = getNewestTSMtime(SRC_ROOT);
22
+ stamp = getNewestTSMtime(srcRoot);
22
23
  } catch {}
23
24
 
24
25
  if (stamp === 0) {
@@ -0,0 +1,21 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ /**
4
+ * Whether a process with this pid is running. A zombie awaiting its parent's
5
+ * wait call has exited, so it reads as not running where `/proc` shows it.
6
+ */
7
+ export function isProcessAlive(pid: number): boolean {
8
+ try {
9
+ process.kill(pid, 0);
10
+ } catch {
11
+ return false;
12
+ }
13
+
14
+ try {
15
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
16
+
17
+ return !stat.slice(stat.lastIndexOf(')') + 2).startsWith('Z');
18
+ } catch {
19
+ return true;
20
+ }
21
+ }
@@ -0,0 +1,34 @@
1
+ import { spawn as spawnChild } from 'node:child_process';
2
+ import type { ChildProcess, StdioOptions } from 'node:child_process';
3
+ import { buildATCCommand } from './build-atc-command';
4
+
5
+ interface SpawnATCDetachedOptions {
6
+ readonly env?: Readonly<Record<string, string | undefined>>;
7
+ readonly cwd?: string;
8
+ readonly stdio?: StdioOptions;
9
+ }
10
+
11
+ /**
12
+ * Starts this build of atc with `args` in a session of its own, so it
13
+ * outlives the caller, and releases the handle so the caller can exit. The
14
+ * child's stdio is discarded unless `options` routes it elsewhere.
15
+ */
16
+ export function spawnATCDetached(
17
+ args: readonly string[],
18
+
19
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- the stdio option takes a mutable array
20
+ options: SpawnATCDetachedOptions = {},
21
+ ): ChildProcess {
22
+ const [bin = process.execPath, ...rest] = buildATCCommand(args);
23
+
24
+ const child = spawnChild(bin, rest, {
25
+ detached: true,
26
+ stdio: options.stdio ?? 'ignore',
27
+ ...(options.env === undefined ? {} : { env: options.env }),
28
+ ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
29
+ });
30
+
31
+ child.unref();
32
+
33
+ return child;
34
+ }
@@ -0,0 +1,31 @@
1
+ import type { DaemonProcess } from './daemon-process';
2
+ import { spawnATCDetached } from './shared/spawn-atc-detached';
3
+
4
+ interface ReplacementOverrides {
5
+ readonly listen: string | null;
6
+ readonly tokenFile: string | null;
7
+ }
8
+
9
+ /**
10
+ * Starts one daemon, detached, the way the one it replaces was started: its
11
+ * environment, its working directory, and its `--listen` and `--token-file`
12
+ * values, except where `overrides` sets them.
13
+ */
14
+ export function startReplacementDaemon(
15
+ old: DaemonProcess,
16
+ overrides: ReplacementOverrides,
17
+ ): number | undefined {
18
+ const listen = overrides.listen ?? old.flags.listen;
19
+ const tokenFile = overrides.tokenFile ?? old.flags.tokenFile;
20
+
21
+ const child = spawnATCDetached(
22
+ [
23
+ 'daemon',
24
+ ...(listen === null ? [] : ['--listen', listen]),
25
+ ...(tokenFile === null ? [] : ['--token-file', tokenFile]),
26
+ ],
27
+ { env: old.env, ...(old.cwd === null ? {} : { cwd: old.cwd }) },
28
+ );
29
+
30
+ return child.pid;
31
+ }
@@ -0,0 +1,46 @@
1
+ import { isProcessAlive } from './shared/is-process-alive';
2
+
3
+ const EXIT_WAIT_MS = 10_000;
4
+
5
+ /**
6
+ * Sends SIGTERM to the daemon and waits up to 10 s for it to exit, then
7
+ * sends SIGKILL when it is still running. Resolves `killed` when SIGKILL was
8
+ * needed and `exited` when SIGTERM was enough.
9
+ */
10
+ export async function stopDaemonProcess(pid: number): Promise<'exited' | 'killed'> {
11
+ try {
12
+ process.kill(pid, 'SIGTERM');
13
+ } catch {
14
+ return 'exited';
15
+ }
16
+
17
+ const exited = await waitForExit(pid, EXIT_WAIT_MS);
18
+
19
+ if (exited) {
20
+ return 'exited';
21
+ }
22
+
23
+ try {
24
+ process.kill(pid, 'SIGKILL');
25
+ } catch {
26
+ return 'exited';
27
+ }
28
+
29
+ await waitForExit(pid, EXIT_WAIT_MS);
30
+
31
+ return 'killed';
32
+ }
33
+
34
+ async function waitForExit(pid: number, ms: number): Promise<boolean> {
35
+ const deadline = Date.now() + ms;
36
+
37
+ while (Date.now() < deadline) {
38
+ if (!isProcessAlive(pid)) {
39
+ return true;
40
+ }
41
+
42
+ await Bun.sleep(50);
43
+ }
44
+
45
+ return !isProcessAlive(pid);
46
+ }
@@ -539,14 +539,14 @@ export class StateStore {
539
539
  return row?.ts === null || row?.ts === undefined ? null : Date.parse(row.ts);
540
540
  }
541
541
 
542
- async recordSpawnDir(cwd: string, grant: SpawnTarget): Promise<void> {
542
+ async recordSpawnDir(cwd: string, grant: SpawnTarget, at: number): Promise<void> {
543
543
  await this.db
544
544
  .insertInto('spawn_history')
545
545
  .values({
546
546
  cwd,
547
547
  target: grant.target,
548
548
  target_identity: grant.targetIdentity,
549
- last_spawn: Date.now(),
549
+ last_spawn: at,
550
550
  })
551
551
  .onConflict((oc) =>
552
552
  oc
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Whether this host can bind a socket to every given address, tried by
3
+ * listening on a kernel-chosen port of each and closing it at once. A host
4
+ * without an address, such as stock macOS for any loopback address past
5
+ * 127.0.0.1, refuses the bind with EADDRNOTAVAIL.
6
+ */
7
+ export function canBindAddresses(hostnames: readonly string[]): boolean {
8
+ for (const hostname of hostnames) {
9
+ try {
10
+ const listener = Bun.listen({ hostname, port: 0, socket: { data() {} } });
11
+
12
+ listener.stop(true);
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ return true;
19
+ }
@@ -0,0 +1,38 @@
1
+ import { copyFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { StateStore } from '../store/state-store';
4
+
5
+ let template: Promise<string> | undefined;
6
+
7
+ /**
8
+ * Creates a state database at the path that is already at the latest schema,
9
+ * by copying a database the store migrated once per test process. A store
10
+ * opened on the copy finds every migration applied and runs none, so a test
11
+ * that needs a ready store skips the migration ladder. Each copy is a file of
12
+ * its own; every copy holds the same daemon id, minted when the template was
13
+ * migrated. The template sits in the test home, which the test script removes
14
+ * on exit.
15
+ */
16
+ export async function createMigratedStateDB(dbPath: string): Promise<void> {
17
+ template ??= createTemplateStateDB();
18
+
19
+ const templatePath = await template;
20
+
21
+ copyFileSync(templatePath, dbPath);
22
+ }
23
+
24
+ async function createTemplateStateDB(): Promise<string> {
25
+ const home = process.env['ATC_TEST_HOME'];
26
+
27
+ if (home === undefined) {
28
+ throw new Error('ATC_TEST_HOME is unset; run the tests through `bun run test`');
29
+ }
30
+
31
+ const path = join(home, 'migrated-state.db');
32
+
33
+ const store = await StateStore.open(path);
34
+
35
+ await store.stop();
36
+
37
+ return path;
38
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Holds each environment variable a test has overridden, keyed by name, with
3
+ * the value it held before its first override in that test; `undefined`
4
+ * records a variable that was unset.
5
+ */
6
+ export const envOriginals = new Map<string, string | undefined>();