@zgeoff/atc 3.3.0 → 3.4.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 (179) hide show
  1. package/package.json +4 -2
  2. package/src/agents/agent-adapter.ts +10 -0
  3. package/src/agents/build-cli-argv.ts +2 -4
  4. package/src/agents/claude-adapter.ts +13 -3
  5. package/src/agents/codex-adapter.ts +11 -2
  6. package/src/agents/gateway-adapter.ts +20 -8
  7. package/src/agents/grok-adapter.ts +16 -13
  8. package/src/agents/load-claude-config-bundle.ts +6 -6
  9. package/src/agents/print-codex-hook-file.ts +7 -3
  10. package/src/agents/print-grok-hook-file.ts +8 -3
  11. package/src/agents/resolve-agent-home.ts +7 -3
  12. package/src/agents/resolve-claude-global-config-path.ts +4 -4
  13. package/src/agents/update-claude-project-trust.ts +41 -30
  14. package/src/agents/with-claude-config-lock.ts +13 -1
  15. package/src/agents/write-atc-bridge.ts +10 -7
  16. package/src/agents/write-hook-settings.ts +13 -7
  17. package/src/client/boot-daemon.ts +48 -15
  18. package/src/client/dirs.ts +4 -2
  19. package/src/client/index.ts +17 -1
  20. package/src/client/log-client-event.ts +20 -0
  21. package/src/client/spawn-picker.ts +200 -121
  22. package/src/client/ui.ts +7 -7
  23. package/src/clients.ts +41 -11
  24. package/src/daemon/daemon.ts +143 -33
  25. package/src/daemon/event-signal.ts +19 -3
  26. package/src/daemon/make-hook-runner.ts +82 -22
  27. package/src/daemon/permission-registry.ts +11 -6
  28. package/src/daemon/restore-fleet.ts +62 -17
  29. package/src/daemon/run-eject-handoff.ts +20 -2
  30. package/src/daemon/sessions.ts +6 -0
  31. package/src/daemon/start-tcp-listener.ts +12 -10
  32. package/src/federation/daemon-caller.ts +25 -4
  33. package/src/federation/daemon-pool.ts +3 -1
  34. package/src/federation/open-gateway-caller.ts +6 -1
  35. package/src/grants.ts +36 -11
  36. package/src/hook-report.ts +17 -3
  37. package/src/mcp/read-report-texts.ts +11 -7
  38. package/src/mcp/start-mcp-http-server.ts +5 -1
  39. package/src/mcp-http-server.ts +85 -32
  40. package/src/report.ts +21 -3
  41. package/src/run-bridge-tap.ts +36 -7
  42. package/src/run-config-migrate.ts +35 -11
  43. package/src/run-gateway.ts +4 -2
  44. package/src/shared/build-atc-command.ts +7 -3
  45. package/src/shared/bun-sqlite-driver.ts +10 -0
  46. package/src/shared/claim-daemon-lock.ts +10 -4
  47. package/src/shared/collect-dir-roots.ts +6 -6
  48. package/src/shared/collect-hooks.ts +7 -6
  49. package/src/shared/config.ts +19 -9
  50. package/src/shared/system-clock.ts +24 -0
  51. package/src/sources/github/collect-github-repos.ts +1 -1
  52. package/src/sources/github/read-git-protocol.ts +1 -1
  53. package/src/sources/github/run-gh.ts +15 -9
  54. package/src/store/state-store.ts +57 -7
  55. package/src/tap.ts +44 -13
  56. package/src/test-utils/build-mock-agent-adapter.ts +25 -0
  57. package/src/test-utils/build-mock-agent-entry.ts +32 -0
  58. package/src/test-utils/build-mock-auth-binding.ts +28 -0
  59. package/src/test-utils/build-mock-auth-profile.ts +62 -0
  60. package/src/test-utils/build-mock-daemon-record.ts +26 -0
  61. package/src/test-utils/build-mock-fleet-entry.ts +33 -0
  62. package/src/test-utils/build-mock-gateway-config.ts +32 -0
  63. package/src/test-utils/build-mock-imp-identity.ts +18 -0
  64. package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
  65. package/src/test-utils/build-mock-message-record.ts +22 -0
  66. package/src/test-utils/build-mock-mirror-session.ts +42 -0
  67. package/src/test-utils/build-mock-registry-daemon.ts +28 -0
  68. package/src/test-utils/build-mock-session-descriptor.ts +66 -0
  69. package/src/test-utils/build-mock-session.ts +43 -0
  70. package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
  71. package/src/test-utils/build-stub-attention-adapter.ts +22 -0
  72. package/src/test-utils/build-stub-bridge-context.ts +20 -0
  73. package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
  74. package/src/test-utils/build-stub-channel-opener.ts +45 -0
  75. package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
  76. package/src/test-utils/build-stub-client-screens.ts +33 -0
  77. package/src/test-utils/build-stub-clock.ts +67 -0
  78. package/src/test-utils/build-stub-daemon-context.ts +81 -0
  79. package/src/test-utils/build-stub-daemon-requests.ts +103 -0
  80. package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
  81. package/src/test-utils/build-stub-execution-provider.ts +112 -0
  82. package/src/test-utils/build-stub-fleet-caller.ts +60 -0
  83. package/src/test-utils/build-stub-forking-gh.ts +10 -0
  84. package/src/test-utils/build-stub-gh.ts +50 -0
  85. package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
  86. package/src/test-utils/build-stub-harness-relay.ts +50 -0
  87. package/src/test-utils/build-stub-headless-runner.ts +60 -0
  88. package/src/test-utils/build-stub-held-gh.ts +19 -0
  89. package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
  90. package/src/test-utils/build-stub-held-provider.ts +58 -0
  91. package/src/test-utils/build-stub-held-zoxide.ts +13 -0
  92. package/src/test-utils/build-stub-host-hold.ts +31 -0
  93. package/src/test-utils/build-stub-host-provider.ts +26 -0
  94. package/src/test-utils/{fixture-imp-port.ts → build-stub-imp-port.ts} +119 -63
  95. package/src/test-utils/build-stub-log.ts +15 -0
  96. package/src/test-utils/build-stub-mcp-claude.ts +26 -0
  97. package/src/test-utils/build-stub-mcp-grok.ts +19 -0
  98. package/src/test-utils/build-stub-peer-socket.ts +81 -0
  99. package/src/test-utils/build-stub-picker-host.ts +36 -0
  100. package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
  101. package/src/test-utils/build-stub-pty-provider.ts +53 -0
  102. package/src/test-utils/build-stub-recording-atc.ts +10 -0
  103. package/src/test-utils/build-stub-signed-in-gh.ts +18 -0
  104. package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
  105. package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
  106. package/src/test-utils/build-stub-targets.ts +58 -0
  107. package/src/test-utils/build-stub-terminal.ts +19 -0
  108. package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
  109. package/src/test-utils/build-stub-tui-claude.ts +47 -0
  110. package/src/test-utils/build-stub-tui-grok.ts +53 -0
  111. package/src/test-utils/build-stub-wait-clock.ts +23 -0
  112. package/src/test-utils/build-target-options-from-config.ts +37 -0
  113. package/src/test-utils/collect-process-tree.ts +75 -0
  114. package/src/test-utils/create-git-fixture.ts +120 -0
  115. package/src/test-utils/create-migrated-state-db.ts +12 -10
  116. package/src/test-utils/create-stub-bin.ts +19 -0
  117. package/src/test-utils/create-stub-claude.ts +75 -0
  118. package/src/test-utils/create-stub-codex.ts +31 -0
  119. package/src/test-utils/create-stub-composer.ts +65 -0
  120. package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
  121. package/src/test-utils/create-stub-grok.ts +44 -0
  122. package/src/test-utils/create-stub-guest-clis.ts +55 -0
  123. package/src/test-utils/create-stub-harness-guest.ts +35 -0
  124. package/src/test-utils/create-stub-recording-claude.ts +20 -0
  125. package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +5 -5
  126. package/src/test-utils/get-gateway-config.ts +3 -3
  127. package/src/test-utils/get-only-effect-ref.ts +21 -0
  128. package/src/test-utils/get-only-imp-name.ts +19 -0
  129. package/src/test-utils/get-records.ts +20 -0
  130. package/src/test-utils/get-string.ts +14 -0
  131. package/src/test-utils/keys.ts +26 -0
  132. package/src/test-utils/merge-deep.ts +36 -0
  133. package/src/test-utils/mock-overrides.ts +13 -0
  134. package/src/test-utils/open-repo-step.ts +24 -0
  135. package/src/test-utils/read-query-plan.ts +22 -0
  136. package/src/test-utils/resolve-atc-command.ts +13 -0
  137. package/src/test-utils/resolve-gateway-command.ts +13 -0
  138. package/src/test-utils/run-atc.ts +56 -0
  139. package/src/test-utils/run-legacy-daemon.ts +27 -5
  140. package/src/test-utils/run-listener-daemon.ts +5 -10
  141. package/src/test-utils/run-source-daemon.ts +2 -2
  142. package/src/test-utils/seed-faker.ts +7 -0
  143. package/src/test-utils/send-lines-before-handshake.ts +35 -0
  144. package/src/test-utils/send-mcp-request.ts +25 -0
  145. package/src/test-utils/setup-mcp-home.ts +99 -0
  146. package/src/test-utils/setup-mcp-http.ts +19 -31
  147. package/src/test-utils/spawn-claude-session.ts +34 -0
  148. package/src/test-utils/spawn-grok-session.ts +39 -0
  149. package/src/test-utils/start-cut-proxy.ts +25 -8
  150. package/src/test-utils/start-daemon-process.ts +192 -0
  151. package/src/test-utils/start-git-http-server.ts +21 -3
  152. package/src/test-utils/start-mcp-stdio.ts +195 -0
  153. package/src/test-utils/start-output-capture.ts +20 -0
  154. package/src/test-utils/start-stub-dropping-daemon.ts +103 -0
  155. package/src/test-utils/start-stub-impd-info.ts +32 -0
  156. package/src/test-utils/start-stub-impd.ts +138 -0
  157. package/src/test-utils/start-stub-legacy-daemon.ts +166 -0
  158. package/src/test-utils/start-stub-mcp-server.ts +50 -0
  159. package/src/test-utils/start-stub-refusing-listener.ts +38 -0
  160. package/src/test-utils/start-stub-reporter-socket.ts +77 -0
  161. package/src/test-utils/start-stub-restore-daemon.ts +58 -0
  162. package/src/test-utils/start-stub-session-bridge.ts +98 -0
  163. package/src/test-utils/start-stub-silent-server.ts +33 -0
  164. package/src/test-utils/start-stub-source-daemon.ts +39 -0
  165. package/src/test-utils/start-stub-stalled-listener.ts +40 -0
  166. package/src/test-utils/start-stub-tap.ts +38 -0
  167. package/src/test-utils/start-stub-tcp-daemon.ts +70 -0
  168. package/src/test-utils/start-stub-telemetry-collector.ts +26 -0
  169. package/src/test-utils/start-test-daemon.ts +215 -0
  170. package/src/test-utils/start-tui-harness.ts +284 -0
  171. package/src/test-utils/subscribe-to-socket-lines.ts +59 -12
  172. package/src/test-utils/try-send-request.ts +29 -0
  173. package/src/test-utils/wait-for-event.ts +41 -0
  174. package/src/test-utils/wait-for.ts +15 -3
  175. package/src/verify-restored-fleet.ts +62 -13
  176. package/src/workspace/check-repository-access.ts +4 -0
  177. package/src/workspace/collect-remote-refs.ts +4 -2
  178. package/src/workspace/run-git.ts +6 -0
  179. package/src/test-utils/start-legacy-daemon.ts +0 -111
@@ -0,0 +1,284 @@
1
+ import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { spawn } from 'bun-pty';
4
+ import type { IDisposable, IPty } from 'bun-pty';
5
+ import { buildStubTUIClaude } from './build-stub-tui-claude';
6
+ import { buildStubTUIGrok } from './build-stub-tui-grok';
7
+ import { createStubBin } from './create-stub-bin';
8
+ import { mergeDeep } from './merge-deep';
9
+ import { setupTempDir } from './setup-temp-dir';
10
+ import { waitFor } from './wait-for';
11
+
12
+ /**
13
+ * One home with the real client running in it under a pseudo-terminal,
14
+ * and the calls that drive and read it.
15
+ */
16
+ export type TUIHarness = ReturnType<typeof startTUIHarness>;
17
+
18
+ const cliPath = join(import.meta.dir, '..', 'cli.ts');
19
+
20
+ interface TUIHarnessOptions {
21
+ // How long a wait gives the client to draw its first byte; 9 seconds
22
+ // unless set.
23
+ readonly bootMs?: number;
24
+ }
25
+
26
+ /**
27
+ * A fresh home for one run of the real `atc` client in a pseudo-terminal,
28
+ * holding stand-in `claude` and `grok` binaries, which take scenario files
29
+ * dropped into the home, and a config that points at them. `boot` starts
30
+ * the client in the home, which starts its own daemon there; a later `boot`
31
+ * starts a new client on the same home and stops capturing the old one.
32
+ * `read` returns every byte the client drew since the last `reset`, and
33
+ * `waitFor` polls that capture for a needle. The client appends what it
34
+ * decides without drawing to a log: `markClientLog` returns a cursor into
35
+ * it, and `waitForClientLog` polls for a line written after that cursor.
36
+ * `writeConfig` writes the home's config: the stand-in binaries and the git
37
+ * transports the fixture repositories need, with the fields given laid over
38
+ * them. `env` is the environment the client runs with, so a daemon started
39
+ * with it serves the client. A wait made before the client draws anything
40
+ * gets `bootMs` for that first byte. Disposal stops the client and the daemon in
41
+ * the home and removes it; hold the result with `await using`.
42
+ */
43
+ export function startTUIHarness(options: TUIHarnessOptions = {}) {
44
+ using setup = new DisposableStack();
45
+
46
+ // The client boots with this home as its cwd and lists it first in the
47
+ // picker, so the path is resolved the way the client reports it.
48
+ const tmp = setup.use(setupTempDir('atc-tui-'));
49
+ const home = realpathSync(tmp.dir);
50
+ const configPath = join(home, '.config', 'atc', 'config.json');
51
+ const clientLogPath = join(home, 'client.log');
52
+ const fakeClaude = createStubBin(home, 'fake-claude', buildStubTUIClaude());
53
+ const fakeGrok = createStubBin(home, 'fake-grok', buildStubTUIGrok());
54
+
55
+ const bootConfig: Readonly<Record<string, unknown>> = {
56
+ claudeBin: fakeClaude,
57
+ claudeArgs: [],
58
+ grokBin: fakeGrok,
59
+ grokArgs: [],
60
+
61
+ // No codex binary is written to the home, so the agent picker sees
62
+ // Codex as uninstalled whatever the host machine has.
63
+ codexBin: join(home, 'fake-codex'),
64
+ codexArgs: [],
65
+ gateways: [],
66
+
67
+ // The fixture repositories are local paths and loopback HTTP, which the
68
+ // default transports refuse.
69
+ workspaces: { gitTransports: ['https', 'ssh', 'http', 'file'] },
70
+ };
71
+
72
+ const writeConfig = (fields: Readonly<Record<string, unknown>> = {}) => {
73
+ mkdirSync(join(home, '.config', 'atc'), { recursive: true });
74
+ writeFileSync(configPath, JSON.stringify(mergeDeep(bootConfig, fields)));
75
+ };
76
+
77
+ writeConfig();
78
+
79
+ // A test that drops an executable into the home's bin directory puts it
80
+ // on the PATH of the client and of the daemons it starts.
81
+ const env = {
82
+ ...process.env,
83
+ HOME: home,
84
+ XDG_RUNTIME_DIR: home,
85
+ PATH: `${join(home, 'bin')}:/usr/sbin:/usr/bin:/bin`,
86
+ ATC_CLIENT_LOG: clientLogPath,
87
+ };
88
+
89
+ let pty: IPty | null = null;
90
+ let capture: IDisposable | null = null;
91
+ let exited: Promise<number> | null = null;
92
+ let out = '';
93
+
94
+ // When the latest client boot wrote its first byte. The client draws
95
+ // nothing until its daemon handshake completes, so that byte is the
96
+ // readiness signal: waits before it get the boot budget, waits after it
97
+ // the interaction budget.
98
+ let firstOutputAt: number | null = null;
99
+
100
+ const owned = new AsyncDisposableStack();
101
+
102
+ owned.use(setup.move());
103
+ owned.defer(() => stopClientAndDaemon(home, pty, firstOutputAt === null));
104
+
105
+ return {
106
+ home,
107
+ configPath,
108
+ env,
109
+ writeConfig,
110
+
111
+ boot(): IPty {
112
+ capture?.dispose();
113
+
114
+ const booted = spawn(process.execPath, [cliPath], {
115
+ name: 'xterm-256color',
116
+ cols: 110,
117
+ rows: 30,
118
+
119
+ // The picker lists the client's own directory first, so a spawn
120
+ // that takes the first entry lands in the home.
121
+ cwd: home,
122
+ env,
123
+ });
124
+
125
+ const exit = Promise.withResolvers<number>();
126
+
127
+ booted.onExit((event) => {
128
+ exit.resolve(event.exitCode);
129
+ });
130
+
131
+ firstOutputAt = null;
132
+ pty = booted;
133
+ exited = exit.promise;
134
+
135
+ capture = booted.onData((data) => {
136
+ firstOutputAt ??= Date.now();
137
+ out += data;
138
+ });
139
+
140
+ return booted;
141
+ },
142
+
143
+ write(data: string) {
144
+ if (pty === null) {
145
+ throw new Error('write before boot');
146
+ }
147
+
148
+ pty.write(data);
149
+ },
150
+
151
+ read(): string {
152
+ return out;
153
+ },
154
+
155
+ reset() {
156
+ out = '';
157
+ },
158
+
159
+ async waitFor(needle: string, ms = 4000): Promise<void> {
160
+ const start = Date.now();
161
+
162
+ // The client gives its daemon 8 seconds to answer before it draws an
163
+ // error, so the boot phase waits that long plus 1 second for the
164
+ // client's own cold start, which measures under 1.1 seconds on a
165
+ // runner loaded at four times its cores. Every wait after the first
166
+ // byte keeps its own deadline.
167
+ const bootMs = options.bootMs ?? 8000 + 1000;
168
+
169
+ const firstAt = await waitFor(
170
+ () => {
171
+ if (firstOutputAt === null) {
172
+ throw new Error(
173
+ `timed out waiting for ${JSON.stringify(needle)}; the client wrote nothing in ${bootMs}ms of boot`,
174
+ );
175
+ }
176
+
177
+ return firstOutputAt;
178
+ },
179
+ { timeoutMs: bootMs, intervalMs: 50 },
180
+ );
181
+
182
+ await waitFor(
183
+ () => {
184
+ if (!out.includes(needle)) {
185
+ throw new Error(
186
+ `timed out waiting for ${JSON.stringify(needle)}; tail: ${JSON.stringify(out.slice(-400))}`,
187
+ );
188
+ }
189
+ },
190
+ { timeoutMs: Math.max(0, Math.max(start, firstAt) + ms - Date.now()), intervalMs: 50 },
191
+ );
192
+ },
193
+
194
+ waitForExit(): Promise<number> {
195
+ if (exited === null) {
196
+ throw new Error('wait for exit before boot');
197
+ }
198
+
199
+ return exited;
200
+ },
201
+
202
+ markClientLog(): number {
203
+ return readClientLog(clientLogPath).length;
204
+ },
205
+
206
+ async waitForClientLog(line: string, mark: number, ms = 5000): Promise<void> {
207
+ await waitFor(
208
+ () => {
209
+ const written = readClientLog(clientLogPath).slice(mark);
210
+
211
+ if (!written.includes(line)) {
212
+ throw new Error(
213
+ `the client log never held ${JSON.stringify(line)} after line ${mark}; it holds ${JSON.stringify(written)} there`,
214
+ );
215
+ }
216
+ },
217
+ { timeoutMs: ms },
218
+ );
219
+ },
220
+
221
+ [Symbol.asyncDispose]: () => owned.disposeAsync(),
222
+ };
223
+ }
224
+
225
+ // The client started a daemon inside the home; its pid file is how the
226
+ // harness finds and stops it. The daemon writes that file once its modules
227
+ // load, so a client killed before its first frame can leave a daemon that
228
+ // has not written it yet.
229
+ async function stopClientAndDaemon(home: string, pty: IPty | null, beforeFirstFrame: boolean) {
230
+ pty?.kill();
231
+
232
+ const pid = await waitFor(
233
+ () => {
234
+ const read = Number(readFileSync(join(home, 'atc-daemon.pid'), 'utf8'));
235
+
236
+ if (!Number.isInteger(read) || read <= 1) {
237
+ throw new Error(`the daemon pid file holds no pid: ${read}`);
238
+ }
239
+
240
+ return read;
241
+ },
242
+ { timeoutMs: pty !== null && beforeFirstFrame ? 5000 : 0, intervalMs: 50 },
243
+ ).catch(() => null);
244
+
245
+ if (pid === null) {
246
+ return;
247
+ }
248
+
249
+ try {
250
+ process.kill(pid, 'SIGTERM');
251
+ } catch {}
252
+
253
+ // The next test's cold boot must not share the CPU with this daemon's
254
+ // shutdown, and the daemon must not outlive its home.
255
+ await waitFor(
256
+ () => {
257
+ if (isRunning(pid)) {
258
+ throw new Error(`daemon ${pid} still runs`);
259
+ }
260
+ },
261
+ { timeoutMs: 3000 },
262
+ ).catch(() => {});
263
+ }
264
+
265
+ function isRunning(pid: number): boolean {
266
+ try {
267
+ process.kill(pid, 0);
268
+
269
+ return true;
270
+ } catch {
271
+ return false;
272
+ }
273
+ }
274
+
275
+ // The client log's lines, or none before the client writes the file.
276
+ function readClientLog(path: string): string[] {
277
+ try {
278
+ return readFileSync(path, 'utf8')
279
+ .split('\n')
280
+ .filter((line) => line !== '');
281
+ } catch {
282
+ return [];
283
+ }
284
+ }
@@ -1,21 +1,40 @@
1
+ import { OutboundQueue } from '../protocol/outbound-queue';
1
2
  import { waitFor } from './wait-for';
2
3
 
3
4
  interface SocketLines {
5
+ // Every complete newline-terminated line the peer sent, in order.
4
6
  readonly lines: string[];
7
+
8
+ // Resolves once the connection has closed, from either end.
9
+ readonly closed: Promise<void>;
10
+
5
11
  readonly write: (data: string) => void;
6
12
  readonly waitForLine: (count?: number, timeoutMs?: number) => Promise<string[]>;
7
13
  readonly [Symbol.asyncDispose]: () => Promise<void>;
8
14
  }
9
15
 
16
+ interface SocketLinesOptions {
17
+ // How many unsent bytes the connection holds before it refuses a write.
18
+ readonly queueBytes?: number;
19
+ }
20
+
10
21
  /**
11
22
  * Connects to a unix socket and collects every complete newline-terminated
12
- * line it sends, buffering a partial line across reads. `waitForLine` polls
13
- * until the collected count reaches `count` and returns the lines, throwing
14
- * when `timeoutMs` passes first; disposal ends the connection.
23
+ * line it sends, buffering a partial line across reads. `write` sends the
24
+ * whole of what it is given, the rest going out as the socket drains,
25
+ * and throws instead when the unsent bytes already fill the queue. `waitForLine` polls until the collected count reaches
26
+ * `count` and returns the lines, throwing when `timeoutMs` passes first or
27
+ * the connection closes short of the count. Disposal ends the connection.
15
28
  */
16
- export async function subscribeToSocketLines(path: string): Promise<SocketLines> {
29
+ export async function subscribeToSocketLines(
30
+ path: string,
31
+ options: SocketLinesOptions = {},
32
+ ): Promise<SocketLines> {
17
33
  const lines: string[] = [];
34
+ const closed = Promise.withResolvers<void>();
35
+ let isClosed = false;
18
36
  let buffer = '';
37
+ let queue: OutboundQueue | null = null;
19
38
 
20
39
  const socket = await Bun.connect({
21
40
  unix: path,
@@ -29,27 +48,55 @@ export async function subscribeToSocketLines(path: string): Promise<SocketLines>
29
48
 
30
49
  lines.push(...parts.filter((part) => part.trim() !== ''));
31
50
  },
32
- close() {},
51
+ drain() {
52
+ queue?.drain();
53
+ },
54
+ close() {
55
+ isClosed = true;
56
+
57
+ closed.resolve();
58
+ },
33
59
  error() {},
34
60
  },
35
61
  });
36
62
 
63
+ // The default leaves room for the largest payload a test sends in one
64
+ // piece.
65
+ queue = new OutboundQueue(socket, options.queueBytes ?? 8 * 1024 * 1024);
66
+
67
+ const sending = queue;
68
+
69
+ const requireLines = (count: number) => {
70
+ if (lines.length < count) {
71
+ throw new Error(`timed out waiting for ${count} lines; got ${JSON.stringify(lines)}`);
72
+ }
73
+
74
+ return lines;
75
+ };
76
+
37
77
  return {
38
78
  lines,
79
+ closed: closed.promise,
39
80
  write(data: string) {
40
- socket.write(data);
81
+ if (!sending.send(data)) {
82
+ throw new Error(
83
+ `the socket refused a ${data.length}-character write: ${sending.queuedBytes} bytes are still unsent`,
84
+ );
85
+ }
41
86
  },
42
- waitForLine(count = 1, timeoutMs = 5000) {
43
- return waitFor(
87
+ async waitForLine(count = 1, timeoutMs = 5000) {
88
+ // The poll ends early once the connection closes, since no more lines
89
+ // can arrive.
90
+ await waitFor(
44
91
  () => {
45
- if (lines.length < count) {
46
- throw new Error(`timed out waiting for ${count} lines; got ${JSON.stringify(lines)}`);
92
+ if (!isClosed) {
93
+ requireLines(count);
47
94
  }
48
-
49
- return lines;
50
95
  },
51
96
  { timeoutMs },
52
97
  );
98
+
99
+ return requireLines(count);
53
100
  },
54
101
  [Symbol.asyncDispose]: () => {
55
102
  socket.end();
@@ -0,0 +1,29 @@
1
+ import { DaemonError } from '../protocol/daemon-error';
2
+
3
+ /**
4
+ * Sends a request and returns its answer as plain data, `{ ok }` or, when
5
+ * the daemon refuses it, `{ error: { code, message, data } }`, with every
6
+ * occurrence of `id` replaced by `<id>`, so the answers to the same request
7
+ * for two ids compare whole: a refusal for a session a client may not see
8
+ * and one for a session that never existed must read alike. A failure that
9
+ * is not a daemon refusal rejects as it is.
10
+ */
11
+ export async function trySendRequest(
12
+ send: () => Promise<Readonly<Record<string, unknown>>>,
13
+ id: string,
14
+ ): Promise<unknown> {
15
+ const answer = await send().then(
16
+ (ok) => ({ ok }),
17
+ (error: unknown) => ({ error: toRefusal(error) }),
18
+ );
19
+
20
+ return JSON.parse(JSON.stringify(answer).replaceAll(id, '<id>'));
21
+ }
22
+
23
+ function toRefusal(error: unknown): unknown {
24
+ if (!(error instanceof DaemonError)) {
25
+ throw error;
26
+ }
27
+
28
+ return { code: error.code, message: error.message, data: error.data ?? null };
29
+ }
@@ -0,0 +1,41 @@
1
+ import { expect } from 'bun:test';
2
+ import type { EventMsg } from '../protocol/protocol';
3
+ import { waitFor } from './wait-for';
4
+
5
+ /**
6
+ * Waits until the collected events hold one that matches the shape, as
7
+ * `toMatchObject` matches, and resolves with the first such event. Every
8
+ * event collected so far counts, so a wait finds an event that arrived
9
+ * before it began, and every event stays as it arrived. Throws once the
10
+ * deadline passes, five seconds unless the options set another, listing
11
+ * the shape and the events it saw.
12
+ */
13
+ export function waitForEvent(
14
+ events: readonly EventMsg[],
15
+ shape: Readonly<Record<string, unknown>>,
16
+ options: Parameters<typeof waitFor>[1] = {},
17
+ ): Promise<EventMsg> {
18
+ return waitFor(() => {
19
+ const found = events.find((event) => isMatch(event, shape));
20
+
21
+ if (found === undefined) {
22
+ throw new Error(
23
+ `no event matches ${JSON.stringify(shape)}; got ${JSON.stringify(events.map((event) => event.ev))}`,
24
+ );
25
+ }
26
+
27
+ return found;
28
+ }, options);
29
+ }
30
+
31
+ // The match runs on a copy: Bun's toMatchObject writes each asymmetric
32
+ // matcher it compares over the field it matched in the received object.
33
+ function isMatch(event: EventMsg, shape: Readonly<Record<string, unknown>>): boolean {
34
+ try {
35
+ expect(structuredClone(event)).toMatchObject(shape);
36
+
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
@@ -8,6 +8,16 @@ interface WaitForOptions {
8
8
  * Milliseconds before the wait gives up.
9
9
  */
10
10
  readonly timeoutMs?: number;
11
+
12
+ /**
13
+ * The clock the deadline is read from.
14
+ */
15
+ readonly now?: () => number;
16
+
17
+ /**
18
+ * Waits out the interval between retries.
19
+ */
20
+ readonly wait?: (ms: number) => Promise<void>;
11
21
  }
12
22
 
13
23
  /**
@@ -22,17 +32,19 @@ export async function waitFor<T>(
22
32
  ): Promise<T> {
23
33
  const intervalMs = options.intervalMs ?? 20;
24
34
  const timeoutMs = options.timeoutMs ?? 5000;
25
- const deadline = Date.now() + timeoutMs;
35
+ const now = options.now ?? Date.now;
36
+ const wait = options.wait ?? Bun.sleep;
37
+ const deadline = now() + timeoutMs;
26
38
 
27
39
  for (;;) {
28
40
  try {
29
41
  return await attempt();
30
42
  } catch (error) {
31
- if (Date.now() >= deadline) {
43
+ if (now() >= deadline) {
32
44
  throw error;
33
45
  }
34
46
  }
35
47
 
36
- await Bun.sleep(intervalMs);
48
+ await wait(intervalMs);
37
49
  }
38
50
  }
@@ -3,6 +3,8 @@ import type { RestartFailedRow } from './parse-restart-result';
3
3
  import type { StoredRow } from './read-stored-rows';
4
4
  import { readStoredRows } from './read-stored-rows';
5
5
  import { isRecord } from './shared/report';
6
+ import { systemClock } from './shared/system-clock';
7
+ import type { Clock } from './shared/system-clock';
6
8
 
7
9
  interface FleetVerdict {
8
10
  // The rows the daemon stored, exited ones included.
@@ -28,14 +30,18 @@ const UNSIZED_READ_MS = 30_000;
28
30
  * 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
31
  * failed to restore, and a listed row that is still without a live terminal
30
32
  * at the deadline failed to revive. The deadline counts from the call, and
31
- * every request to the daemon is held to it.
33
+ * every request to the daemon is held to it: the daemon must answer the first
34
+ * list before it, and when it overtakes a later list, the verdict comes from
35
+ * the last list the daemon answered. The deadline, the waits between lists,
36
+ * and every request's time limit run on `clock`, the wall clock unless given.
32
37
  */
33
38
  export async function verifyRestoredFleet(
34
39
  client: Pick<DaemonClient, 'sendRequest'>,
35
40
  timeoutSeconds: number | null,
36
41
  snapshot: readonly StoredRow[] | null,
42
+ clock: Clock = systemClock,
37
43
  ): Promise<FleetVerdict> {
38
- const startedAt = Date.now();
44
+ const startedAt = clock.now();
39
45
 
40
46
  // Without a snapshot the rows come from the new daemon, and its count is
41
47
  // not known until they are read, so that read gets the fixed allowance.
@@ -44,19 +50,28 @@ export async function verifyRestoredFleet(
44
50
  ? await sendBounded(
45
51
  () => readStoredRows(client),
46
52
  startedAt + (timeoutSeconds === null ? UNSIZED_READ_MS : timeoutSeconds * 1000),
53
+ clock,
47
54
  )
48
55
  : null;
49
56
 
50
57
  const stored = snapshot ?? read ?? [];
51
58
  const deadline = startedAt + pickDeadlineMs(stored, timeoutSeconds);
52
59
 
53
- const restored = await tryRestore(client, deadline);
54
- let found = await sendBounded(() => collectFailedRows(client, stored, restored), deadline);
60
+ const restored = await tryRestore(client, deadline, clock);
61
+ let found = await sendBounded(() => collectFailedRows(client, stored, restored), deadline, clock);
55
62
 
56
- while (found.pending && Date.now() < deadline) {
57
- await Bun.sleep(250);
63
+ while (found.pending && clock.now() < deadline) {
64
+ await new Promise<void>((resolve) => {
65
+ clock.schedule(resolve, Math.min(250, deadline - clock.now()));
66
+ });
58
67
 
59
- found = await sendBounded(() => collectFailedRows(client, stored, restored), deadline);
68
+ const polled = await tryCollectFailedRows(client, stored, restored, deadline, clock);
69
+
70
+ if (polled === null) {
71
+ break;
72
+ }
73
+
74
+ found = polled;
60
75
  }
61
76
 
62
77
  return { total: stored.length, failed: found.failed };
@@ -66,22 +81,22 @@ export async function verifyRestoredFleet(
66
81
  * Runs a request and rejects once the deadline passes, so a daemon that
67
82
  * stops answering cannot hold the restart, and its lock, open.
68
83
  */
69
- async function sendBounded<T>(send: () => Promise<T>, deadline: number): Promise<T> {
70
- let timer: ReturnType<typeof setTimeout> | undefined;
84
+ async function sendBounded<T>(send: () => Promise<T>, deadline: number, clock: Clock): Promise<T> {
85
+ let stopTimer: (() => void) | undefined;
71
86
 
72
87
  const expired = new Promise<never>((_resolve, reject) => {
73
- timer = setTimeout(
88
+ stopTimer = clock.schedule(
74
89
  () => {
75
90
  reject(new Error('the new daemon stopped answering before the restore deadline'));
76
91
  },
77
- Math.max(0, deadline - Date.now()),
92
+ Math.max(0, deadline - clock.now()),
78
93
  );
79
94
  });
80
95
 
81
96
  try {
82
97
  return await Promise.race([send(), expired]);
83
98
  } finally {
84
- clearTimeout(timer);
99
+ stopTimer?.();
85
100
  }
86
101
  }
87
102
 
@@ -99,9 +114,14 @@ function pickDeadlineMs(stored: readonly StoredRow[], timeoutSeconds: number | n
99
114
  async function tryRestore(
100
115
  client: Pick<DaemonClient, 'sendRequest'>,
101
116
  deadline: number,
117
+ clock: Clock,
102
118
  ): Promise<boolean> {
103
119
  try {
104
- await sendBounded(() => client.sendRequest('fleet.restore', { cols: 80, rows: 24 }), deadline);
120
+ await sendBounded(
121
+ () => client.sendRequest('fleet.restore', { cols: 80, rows: 24 }),
122
+ deadline,
123
+ clock,
124
+ );
105
125
 
106
126
  return true;
107
127
  } catch {
@@ -109,6 +129,35 @@ async function tryRestore(
109
129
  }
110
130
  }
111
131
 
132
+ /**
133
+ * Lists the rows again, or returns null when the deadline passes before the
134
+ * daemon answers, so the caller keeps the verdict of the last list it got.
135
+ */
136
+ async function tryCollectFailedRows(
137
+ client: Pick<DaemonClient, 'sendRequest'>,
138
+ stored: readonly StoredRow[],
139
+ restored: boolean,
140
+ deadline: number,
141
+ clock: Clock,
142
+ ): Promise<FailedRows | null> {
143
+ let stopTimer: (() => void) | undefined;
144
+
145
+ const expired = new Promise<null>((resolve) => {
146
+ stopTimer = clock.schedule(
147
+ () => {
148
+ resolve(null);
149
+ },
150
+ Math.max(0, deadline - clock.now()),
151
+ );
152
+ });
153
+
154
+ try {
155
+ return await Promise.race([collectFailedRows(client, stored, restored), expired]);
156
+ } finally {
157
+ stopTimer?.();
158
+ }
159
+ }
160
+
112
161
  async function collectFailedRows(
113
162
  client: Pick<DaemonClient, 'sendRequest'>,
114
163
  stored: readonly StoredRow[],
@@ -21,6 +21,9 @@ interface AccessRequest {
21
21
 
22
22
  // The transports the URL may use and git may fetch over.
23
23
  readonly transports: readonly string[];
24
+
25
+ // Called with the pid of the listing's git as it starts.
26
+ readonly onSpawn?: ((pid: number) => void) | undefined;
24
27
  }
25
28
 
26
29
  interface RepositoryAccess {
@@ -77,6 +80,7 @@ export async function checkRepositoryAccess(
77
80
  request.credential,
78
81
  request.transports,
79
82
  request.timeoutMs,
83
+ request.onSpawn,
80
84
  );
81
85
 
82
86
  if (!listing.ok) {
@@ -41,13 +41,15 @@ const REMOTE_TIMEOUT_MS = 20_000;
41
41
  * askpass helper for an env credential. git never prompts. A listing git
42
42
  * cannot read is refused as `clone_failed` with git's own message, and so
43
43
  * is one that takes longer than the time limit, 20 s unless given. git
44
- * fetches only over `transports`.
44
+ * fetches only over `transports`. `onSpawn` is called with the pid of the
45
+ * listing's git as it starts.
45
46
  */
46
47
  export async function collectRemoteRefs(
47
48
  url: string,
48
49
  credential: GitCredential | undefined,
49
50
  transports: readonly string[],
50
51
  timeoutMs: number = REMOTE_TIMEOUT_MS,
52
+ onSpawn?: (pid: number) => void,
51
53
  ): Promise<RemoteRefListing | RemoteRefRefusal> {
52
54
  const askpass = await createGitAskpass(credential);
53
55
 
@@ -72,7 +74,7 @@ export async function collectRemoteRefs(
72
74
  'refs/heads/*',
73
75
  'refs/tags/*',
74
76
  ],
75
- { env: { ...askpass.env, ...sshEnv }, timeoutMs, transports },
77
+ { env: { ...askpass.env, ...sshEnv }, timeoutMs, transports, onSpawn },
76
78
  );
77
79
  } finally {
78
80
  await askpass[Symbol.asyncDispose]();