@zgeoff/atc 3.1.1 → 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 (78) 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/build-scoped-context.ts +4 -2
  10. package/src/daemon/daemon-connection.ts +4 -1
  11. package/src/daemon/daemon-context.ts +12 -1
  12. package/src/daemon/daemon.ts +26 -4
  13. package/src/daemon/local-pty-provider.ts +83 -10
  14. package/src/daemon/read-native-env-keys.ts +74 -0
  15. package/src/daemon-process.ts +12 -0
  16. package/src/find-restart-unit.ts +45 -0
  17. package/src/format-restart-preflight.ts +74 -0
  18. package/src/format-restart-report.ts +45 -0
  19. package/src/is-daemon-command-line.ts +33 -0
  20. package/src/is-daemon-process.ts +33 -0
  21. package/src/mcp/require-daemon-features.ts +1 -0
  22. package/src/mcp/run-tool.ts +70 -15
  23. package/src/parse-daemon-flags.ts +33 -0
  24. package/src/parse-mismatch-build.ts +19 -0
  25. package/src/parse-restart-result.ts +65 -0
  26. package/src/parse-systemd-unit.ts +25 -0
  27. package/src/protocol/daemon-features.ts +5 -0
  28. package/src/protocol/protocol.ts +2 -0
  29. package/src/protocol/request-param-schemas.ts +6 -0
  30. package/src/read-daemon-process.ts +50 -0
  31. package/src/read-fleet-snapshot.ts +37 -0
  32. package/src/read-restart-result.ts +17 -0
  33. package/src/read-stored-fleet-file.ts +74 -0
  34. package/src/read-stored-rows.ts +36 -0
  35. package/src/remove-restart-result.ts +11 -0
  36. package/src/restart-daemon.ts +149 -0
  37. package/src/restart-plan.ts +33 -0
  38. package/src/run-daemon-restart-worker.ts +77 -0
  39. package/src/run-daemon-restart.ts +228 -0
  40. package/src/run-systemctl.ts +33 -0
  41. package/src/shared/build-atc-command.ts +13 -0
  42. package/src/shared/config.ts +5 -0
  43. package/src/shared/find-pid-file-pid.ts +15 -0
  44. package/src/shared/get-build.ts +4 -3
  45. package/src/shared/is-process-alive.ts +21 -0
  46. package/src/shared/spawn-atc-detached.ts +34 -0
  47. package/src/start-replacement-daemon.ts +31 -0
  48. package/src/stop-daemon-process.ts +46 -0
  49. package/src/store/state-store.ts +2 -2
  50. package/src/test-utils/can-bind-addresses.ts +19 -0
  51. package/src/test-utils/create-migrated-state-db.ts +38 -0
  52. package/src/test-utils/env-originals.ts +6 -0
  53. package/src/test-utils/fixture-dir-provider.ts +131 -0
  54. package/src/test-utils/fixture-imp-port.ts +1692 -0
  55. package/src/test-utils/get-agent-entry.ts +17 -0
  56. package/src/test-utils/get-gateway-config.ts +18 -0
  57. package/src/test-utils/isolate-home.ts +60 -0
  58. package/src/test-utils/read-json-record.ts +20 -0
  59. package/src/test-utils/remove-env-overrides.ts +18 -0
  60. package/src/test-utils/run-legacy-daemon.ts +38 -0
  61. package/src/test-utils/run-listener-daemon.ts +51 -0
  62. package/src/test-utils/run-mcp-authorization.ts +118 -0
  63. package/src/test-utils/run-source-daemon.ts +110 -0
  64. package/src/test-utils/setup-fake-systemd.ts +125 -0
  65. package/src/test-utils/setup-mcp-http.ts +126 -0
  66. package/src/test-utils/setup-temp-dir.ts +33 -0
  67. package/src/test-utils/spawn-named-session.ts +27 -0
  68. package/src/test-utils/start-cut-proxy.ts +175 -0
  69. package/src/test-utils/start-git-http-server.ts +102 -0
  70. package/src/test-utils/start-legacy-daemon.ts +111 -0
  71. package/src/test-utils/subscribe-to-socket-lines.ts +60 -0
  72. package/src/test-utils/update-env.ts +19 -0
  73. package/src/test-utils/wait-for.ts +38 -0
  74. package/src/verify-restored-fleet.ts +155 -0
  75. package/src/wait-for-replacement.ts +112 -0
  76. package/src/write-restart-result.ts +17 -0
  77. /package/src/{daemon → shared}/claim-daemon-lock.ts +0 -0
  78. /package/src/{daemon → shared}/load-listener-tokens.ts +0 -0
@@ -0,0 +1,155 @@
1
+ import type { DaemonClient } from './client/daemon-client';
2
+ import type { RestartFailedRow } from './parse-restart-result';
3
+ import type { StoredRow } from './read-stored-rows';
4
+ import { readStoredRows } from './read-stored-rows';
5
+ import { isRecord } from './shared/report';
6
+
7
+ interface FleetVerdict {
8
+ // The rows the daemon stored, exited ones included.
9
+ readonly total: number;
10
+ readonly failed: readonly RestartFailedRow[];
11
+ }
12
+
13
+ // The rows that are not restored yet, and whether waiting longer can still
14
+ // change that.
15
+ interface FailedRows {
16
+ readonly failed: readonly RestartFailedRow[];
17
+ readonly pending: boolean;
18
+ }
19
+
20
+ const UNSIZED_READ_MS = 30_000;
21
+
22
+ /**
23
+ * Restores the stored fleet on a daemon and waits for every stored row to
24
+ * show up. `snapshot` holds the rows read from the old daemon before it
25
+ * stopped, with their original exit status; without one, the new daemon's
26
+ * rows are read instead. The call joins a restore the daemon already started by itself. A
27
+ * row that is not exited must be listed with a live terminal before the deadline
28
+ * passes (`timeoutSeconds` when set, else the rows that are not exited times the restore boot cap, plus 30 s), and an exited row must be listed. A stored row that is not listed
29
+ * failed to restore, and a listed row that is still without a live terminal
30
+ * at the deadline failed to revive. The deadline counts from the call, and
31
+ * every request to the daemon is held to it.
32
+ */
33
+ export async function verifyRestoredFleet(
34
+ client: Pick<DaemonClient, 'sendRequest'>,
35
+ timeoutSeconds: number | null,
36
+ snapshot: readonly StoredRow[] | null,
37
+ ): Promise<FleetVerdict> {
38
+ const startedAt = Date.now();
39
+
40
+ // Without a snapshot the rows come from the new daemon, and its count is
41
+ // not known until they are read, so that read gets the fixed allowance.
42
+ const read =
43
+ snapshot === null
44
+ ? await sendBounded(
45
+ () => readStoredRows(client),
46
+ startedAt + (timeoutSeconds === null ? UNSIZED_READ_MS : timeoutSeconds * 1000),
47
+ )
48
+ : null;
49
+
50
+ const stored = snapshot ?? read ?? [];
51
+ const deadline = startedAt + pickDeadlineMs(stored, timeoutSeconds);
52
+
53
+ const restored = await tryRestore(client, deadline);
54
+ let found = await sendBounded(() => collectFailedRows(client, stored, restored), deadline);
55
+
56
+ while (found.pending && Date.now() < deadline) {
57
+ await Bun.sleep(250);
58
+
59
+ found = await sendBounded(() => collectFailedRows(client, stored, restored), deadline);
60
+ }
61
+
62
+ return { total: stored.length, failed: found.failed };
63
+ }
64
+
65
+ /**
66
+ * Runs a request and rejects once the deadline passes, so a daemon that
67
+ * stops answering cannot hold the restart, and its lock, open.
68
+ */
69
+ async function sendBounded<T>(send: () => Promise<T>, deadline: number): Promise<T> {
70
+ let timer: ReturnType<typeof setTimeout> | undefined;
71
+
72
+ const expired = new Promise<never>((_resolve, reject) => {
73
+ timer = setTimeout(
74
+ () => {
75
+ reject(new Error('the new daemon stopped answering before the restore deadline'));
76
+ },
77
+ Math.max(0, deadline - Date.now()),
78
+ );
79
+ });
80
+
81
+ try {
82
+ return await Promise.race([send(), expired]);
83
+ } finally {
84
+ clearTimeout(timer);
85
+ }
86
+ }
87
+
88
+ function pickDeadlineMs(stored: readonly StoredRow[], timeoutSeconds: number | null): number {
89
+ if (timeoutSeconds !== null) {
90
+ return timeoutSeconds * 1000;
91
+ }
92
+
93
+ const cap = Number(process.env['ATC_RESTORE_BOOT_TIMEOUT_MS']);
94
+ const bootMs = Number.isFinite(cap) && cap >= 0 ? cap : 15_000;
95
+
96
+ return stored.filter((row) => !row.exited).length * bootMs + 30_000;
97
+ }
98
+
99
+ async function tryRestore(
100
+ client: Pick<DaemonClient, 'sendRequest'>,
101
+ deadline: number,
102
+ ): Promise<boolean> {
103
+ try {
104
+ await sendBounded(() => client.sendRequest('fleet.restore', { cols: 80, rows: 24 }), deadline);
105
+
106
+ return true;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ async function collectFailedRows(
113
+ client: Pick<DaemonClient, 'sendRequest'>,
114
+ stored: readonly StoredRow[],
115
+ restored: boolean,
116
+ ): Promise<FailedRows> {
117
+ const listed = await client.sendRequest('session.list');
118
+
119
+ const sessions = Array.isArray(listed['sessions']) ? listed['sessions'].filter(isRecord) : [];
120
+
121
+ const byID = new Map(sessions.map((session) => [String(session['id']), session]));
122
+
123
+ const byAgentID = new Map(
124
+ sessions
125
+ .filter((session) => typeof session['agentSessionID'] === 'string')
126
+ .map((session) => [String(session['agentSessionID']), session]),
127
+ );
128
+
129
+ const failed: RestartFailedRow[] = [];
130
+ let pending = false;
131
+
132
+ for (const row of stored) {
133
+ const session =
134
+ byID.get(row.id) ??
135
+ (row.agentSessionID === null ? undefined : byAgentID.get(row.agentSessionID));
136
+
137
+ if (session === undefined) {
138
+ failed.push({ name: row.name, id: row.id, reason: 'not listed after the restore' });
139
+
140
+ // A finished restore lists every row it registers, so a row still
141
+ // missing never appears; after a failed restore it may yet.
142
+ pending ||= !restored;
143
+ } else if (!row.exited && !(session['alive'] === true && session['kind'] === 'pty')) {
144
+ failed.push({
145
+ name: row.name,
146
+ id: row.id,
147
+ reason: `listed in state ${String(session['state'])} without a terminal: ${String(session['lastMsg'])}`,
148
+ });
149
+
150
+ pending = true;
151
+ }
152
+ }
153
+
154
+ return { failed, pending };
155
+ }
@@ -0,0 +1,112 @@
1
+ import { DaemonClient } from './client/daemon-client';
2
+ import { DaemonError } from './protocol/daemon-error';
3
+ import { daemonRecordFile } from './shared/config';
4
+ import { findDaemonRecord } from './shared/find-daemon-record';
5
+ import { getBuild } from './shared/get-build';
6
+ import { isProcessAlive } from './shared/is-process-alive';
7
+
8
+ type ReplacementOutcome =
9
+ | {
10
+ readonly ok: true;
11
+ readonly client: DaemonClient;
12
+ readonly pid: number;
13
+ readonly build: string;
14
+ readonly listenPort: number | null;
15
+ }
16
+ | { readonly ok: false; readonly reason: string };
17
+
18
+ /**
19
+ * Waits up to `waitMs` for a daemon to answer whose pid differs from the one
20
+ * that was stopped and whose record in the state directory matches it. With
21
+ * `expectedBuild`, the answering daemon must run that build: another client
22
+ * that won the start with a different build is a failure.
23
+ */
24
+ export async function waitForReplacement(
25
+ oldPID: number | null,
26
+ expectedBuild: string | null,
27
+ waitMs: number,
28
+ ): Promise<ReplacementOutcome> {
29
+ const deadline = Date.now() + waitMs;
30
+ let refusal: string | null = null;
31
+
32
+ while (Date.now() < deadline) {
33
+ const record = findDaemonRecord(daemonRecordFile);
34
+
35
+ if (record !== null && record.pid !== oldPID && isProcessAlive(record.pid)) {
36
+ const attempt = await tryHello(record.socketPath, deadline);
37
+
38
+ if (attempt.kind === 'ok') {
39
+ if (expectedBuild !== null && attempt.build !== expectedBuild) {
40
+ attempt.client.stop();
41
+
42
+ return {
43
+ ok: false,
44
+ reason: `the daemon that came up (pid ${record.pid}) runs build ${attempt.build}, not ${expectedBuild}; another client started it first`,
45
+ };
46
+ }
47
+
48
+ return {
49
+ ok: true,
50
+ client: attempt.client,
51
+ pid: record.pid,
52
+ build: attempt.build,
53
+ listenPort: record.listenPort,
54
+ };
55
+ }
56
+
57
+ if (attempt.kind === 'refused') {
58
+ refusal = attempt.message;
59
+ }
60
+ }
61
+
62
+ await Bun.sleep(100);
63
+ }
64
+
65
+ return {
66
+ ok: false,
67
+ reason:
68
+ refusal === null
69
+ ? `no replacement daemon answered within ${Math.round(waitMs / 1000)} s`
70
+ : `the replacement daemon refused the handshake: ${refusal}`,
71
+ };
72
+ }
73
+
74
+ type Hello =
75
+ | { readonly kind: 'ok'; readonly client: DaemonClient; readonly build: string }
76
+ | { readonly kind: 'refused'; readonly message: string }
77
+ | { readonly kind: 'none' };
78
+
79
+ async function tryHello(socketPath: string, deadline: number): Promise<Hello> {
80
+ let client: DaemonClient;
81
+
82
+ try {
83
+ client = await DaemonClient.open(socketPath);
84
+ } catch {
85
+ return { kind: 'none' };
86
+ }
87
+
88
+ // A daemon that accepts the socket and never answers must not outlast the
89
+ // startup deadline: closing the client rejects the handshake.
90
+ const expiry = setTimeout(
91
+ () => {
92
+ client.stop();
93
+ },
94
+ Math.max(0, deadline - Date.now()),
95
+ );
96
+
97
+ try {
98
+ const hello = await client.sendHello(getBuild());
99
+
100
+ const build = hello['daemon'];
101
+
102
+ return { kind: 'ok', client, build: typeof build === 'string' ? build : 'unknown' };
103
+ } catch (error) {
104
+ client.stop();
105
+
106
+ return error instanceof DaemonError && error.code === 'protocol_mismatch'
107
+ ? { kind: 'refused', message: error.message }
108
+ : { kind: 'none' };
109
+ } finally {
110
+ clearTimeout(expiry);
111
+ }
112
+ }
@@ -0,0 +1,17 @@
1
+ import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import type { RestartResult } from './parse-restart-result';
4
+ import { restartsDir } from './shared/config';
5
+
6
+ /**
7
+ * Writes the result of a finished restart to `last.json` in the restarts
8
+ * directory, through a rename so a reader never sees half a record.
9
+ */
10
+ export function writeRestartResult(result: RestartResult): void {
11
+ mkdirSync(restartsDir, { recursive: true });
12
+
13
+ const staging = join(restartsDir, `last.${process.pid}.tmp`);
14
+
15
+ writeFileSync(staging, `${JSON.stringify(result)}\n`);
16
+ renameSync(staging, join(restartsDir, 'last.json'));
17
+ }
File without changes
File without changes