@zgeoff/atc 3.3.1 → 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 +30 -17
  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.
@@ -30,14 +32,16 @@ const UNSIZED_READ_MS = 30_000;
30
32
  * at the deadline failed to revive. The deadline counts from the call, and
31
33
  * every request to the daemon is held to it: the daemon must answer the first
32
34
  * list before it, and when it overtakes a later list, the verdict comes from
33
- * the last list the daemon answered.
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.
34
37
  */
35
38
  export async function verifyRestoredFleet(
36
39
  client: Pick<DaemonClient, 'sendRequest'>,
37
40
  timeoutSeconds: number | null,
38
41
  snapshot: readonly StoredRow[] | null,
42
+ clock: Clock = systemClock,
39
43
  ): Promise<FleetVerdict> {
40
- const startedAt = Date.now();
44
+ const startedAt = clock.now();
41
45
 
42
46
  // Without a snapshot the rows come from the new daemon, and its count is
43
47
  // not known until they are read, so that read gets the fixed allowance.
@@ -46,19 +50,22 @@ export async function verifyRestoredFleet(
46
50
  ? await sendBounded(
47
51
  () => readStoredRows(client),
48
52
  startedAt + (timeoutSeconds === null ? UNSIZED_READ_MS : timeoutSeconds * 1000),
53
+ clock,
49
54
  )
50
55
  : null;
51
56
 
52
57
  const stored = snapshot ?? read ?? [];
53
58
  const deadline = startedAt + pickDeadlineMs(stored, timeoutSeconds);
54
59
 
55
- const restored = await tryRestore(client, deadline);
56
- 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);
57
62
 
58
- while (found.pending && Date.now() < deadline) {
59
- await Bun.sleep(Math.min(250, deadline - Date.now()));
63
+ while (found.pending && clock.now() < deadline) {
64
+ await new Promise<void>((resolve) => {
65
+ clock.schedule(resolve, Math.min(250, deadline - clock.now()));
66
+ });
60
67
 
61
- const polled = await tryCollectFailedRows(client, stored, restored, deadline);
68
+ const polled = await tryCollectFailedRows(client, stored, restored, deadline, clock);
62
69
 
63
70
  if (polled === null) {
64
71
  break;
@@ -74,22 +81,22 @@ export async function verifyRestoredFleet(
74
81
  * Runs a request and rejects once the deadline passes, so a daemon that
75
82
  * stops answering cannot hold the restart, and its lock, open.
76
83
  */
77
- async function sendBounded<T>(send: () => Promise<T>, deadline: number): Promise<T> {
78
- 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;
79
86
 
80
87
  const expired = new Promise<never>((_resolve, reject) => {
81
- timer = setTimeout(
88
+ stopTimer = clock.schedule(
82
89
  () => {
83
90
  reject(new Error('the new daemon stopped answering before the restore deadline'));
84
91
  },
85
- Math.max(0, deadline - Date.now()),
92
+ Math.max(0, deadline - clock.now()),
86
93
  );
87
94
  });
88
95
 
89
96
  try {
90
97
  return await Promise.race([send(), expired]);
91
98
  } finally {
92
- clearTimeout(timer);
99
+ stopTimer?.();
93
100
  }
94
101
  }
95
102
 
@@ -107,9 +114,14 @@ function pickDeadlineMs(stored: readonly StoredRow[], timeoutSeconds: number | n
107
114
  async function tryRestore(
108
115
  client: Pick<DaemonClient, 'sendRequest'>,
109
116
  deadline: number,
117
+ clock: Clock,
110
118
  ): Promise<boolean> {
111
119
  try {
112
- 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
+ );
113
125
 
114
126
  return true;
115
127
  } catch {
@@ -126,22 +138,23 @@ async function tryCollectFailedRows(
126
138
  stored: readonly StoredRow[],
127
139
  restored: boolean,
128
140
  deadline: number,
141
+ clock: Clock,
129
142
  ): Promise<FailedRows | null> {
130
- let timer: ReturnType<typeof setTimeout> | undefined;
143
+ let stopTimer: (() => void) | undefined;
131
144
 
132
145
  const expired = new Promise<null>((resolve) => {
133
- timer = setTimeout(
146
+ stopTimer = clock.schedule(
134
147
  () => {
135
148
  resolve(null);
136
149
  },
137
- Math.max(0, deadline - Date.now()),
150
+ Math.max(0, deadline - clock.now()),
138
151
  );
139
152
  });
140
153
 
141
154
  try {
142
155
  return await Promise.race([collectFailedRows(client, stored, restored), expired]);
143
156
  } finally {
144
- clearTimeout(timer);
157
+ stopTimer?.();
145
158
  }
146
159
  }
147
160
 
@@ -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]();