@zgeoff/atc 3.3.1 → 3.4.1

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 (206) hide show
  1. package/package.json +5 -3
  2. package/src/agents/agent-adapter.ts +10 -0
  3. package/src/agents/build-agent-adapters.ts +21 -3
  4. package/src/agents/build-cli-argv.ts +2 -4
  5. package/src/agents/claude-adapter.ts +13 -3
  6. package/src/agents/codex-adapter.ts +31 -2
  7. package/src/agents/gateway-adapter.ts +20 -8
  8. package/src/agents/grok-adapter.ts +16 -13
  9. package/src/agents/load-claude-config-bundle.ts +6 -6
  10. package/src/agents/print-codex-hook-file.ts +7 -3
  11. package/src/agents/print-grok-hook-file.ts +8 -3
  12. package/src/agents/render-sdk-message.ts +57 -0
  13. package/src/agents/resolve-agent-home.ts +7 -3
  14. package/src/agents/resolve-claude-global-config-path.ts +4 -4
  15. package/src/agents/start-claude-headless-run.ts +2 -57
  16. package/src/agents/update-claude-project-trust.ts +41 -30
  17. package/src/agents/with-claude-config-lock.ts +37 -12
  18. package/src/agents/write-atc-bridge.ts +10 -7
  19. package/src/agents/write-hook-settings.ts +13 -7
  20. package/src/client/boot-daemon.ts +122 -39
  21. package/src/client/dirs.ts +4 -2
  22. package/src/client/index.ts +17 -1
  23. package/src/client/log-client-event.ts +20 -0
  24. package/src/client/resolve-workspace-root.ts +10 -5
  25. package/src/client/spawn-picker.ts +200 -121
  26. package/src/client/ui.ts +7 -7
  27. package/src/clients.ts +41 -11
  28. package/src/daemon/daemon.ts +143 -33
  29. package/src/daemon/event-signal.ts +19 -3
  30. package/src/daemon/make-hook-runner.ts +82 -22
  31. package/src/daemon/permission-registry.ts +11 -6
  32. package/src/daemon/restore-fleet.ts +62 -17
  33. package/src/daemon/run-eject-handoff.ts +20 -2
  34. package/src/daemon/session-runtime.ts +0 -4
  35. package/src/daemon/sessions.ts +6 -0
  36. package/src/daemon/start-tcp-listener.ts +12 -10
  37. package/src/federation/daemon-caller.ts +25 -4
  38. package/src/federation/daemon-pool.ts +3 -1
  39. package/src/federation/open-gateway-caller.ts +6 -1
  40. package/src/gateway.ts +2 -179
  41. package/src/grants.ts +36 -11
  42. package/src/hook-report.ts +17 -3
  43. package/src/mcp/read-report-texts.ts +11 -7
  44. package/src/mcp/start-mcp-http-server.ts +5 -1
  45. package/src/mcp-http-server.ts +88 -32
  46. package/src/report.ts +21 -3
  47. package/src/run-bridge-tap.ts +36 -7
  48. package/src/run-config-migrate.ts +35 -11
  49. package/src/run-gateway-cli.ts +327 -0
  50. package/src/run-gateway.ts +51 -18
  51. package/src/shared/build-atc-command.ts +7 -3
  52. package/src/shared/bun-sqlite-driver.ts +10 -0
  53. package/src/shared/claim-daemon-lock.ts +10 -4
  54. package/src/shared/collect-dir-roots.ts +6 -6
  55. package/src/shared/collect-hooks.ts +7 -6
  56. package/src/shared/config.ts +19 -9
  57. package/src/shared/system-clock.ts +24 -0
  58. package/src/sources/github/collect-github-repos.ts +1 -1
  59. package/src/sources/github/read-git-protocol.ts +1 -1
  60. package/src/sources/github/run-gh.ts +24 -9
  61. package/src/store/state-store.ts +57 -7
  62. package/src/tap.ts +44 -13
  63. package/src/test-utils/build-mock-agent-adapter.ts +25 -0
  64. package/src/test-utils/build-mock-agent-entry.ts +32 -0
  65. package/src/test-utils/build-mock-auth-binding.ts +28 -0
  66. package/src/test-utils/build-mock-auth-profile.ts +62 -0
  67. package/src/test-utils/build-mock-daemon-record.ts +26 -0
  68. package/src/test-utils/build-mock-execution-target.ts +25 -0
  69. package/src/test-utils/build-mock-fleet-entry.ts +33 -0
  70. package/src/test-utils/build-mock-gateway-config.ts +32 -0
  71. package/src/test-utils/build-mock-imp-identity.ts +18 -0
  72. package/src/test-utils/build-mock-imp-session-request.ts +53 -0
  73. package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
  74. package/src/test-utils/build-mock-message-record.ts +26 -0
  75. package/src/test-utils/build-mock-mirror-session.ts +42 -0
  76. package/src/test-utils/build-mock-registry-daemon.ts +28 -0
  77. package/src/test-utils/build-mock-session-descriptor.ts +66 -0
  78. package/src/test-utils/build-mock-session.ts +43 -0
  79. package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
  80. package/src/test-utils/build-mock-stored-event.ts +20 -0
  81. package/src/test-utils/build-mock-stored-row.ts +23 -0
  82. package/src/test-utils/build-stub-attention-adapter.ts +22 -0
  83. package/src/test-utils/build-stub-bridge-context.ts +20 -0
  84. package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
  85. package/src/test-utils/build-stub-brokered-gateway-adapter.ts +50 -0
  86. package/src/test-utils/build-stub-channel-opener.ts +45 -0
  87. package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
  88. package/src/test-utils/build-stub-client-screens.ts +33 -0
  89. package/src/test-utils/build-stub-clock.ts +67 -0
  90. package/src/test-utils/build-stub-daemon-context.ts +81 -0
  91. package/src/test-utils/build-stub-daemon-requests.ts +103 -0
  92. package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
  93. package/src/test-utils/build-stub-execution-provider.ts +112 -0
  94. package/src/test-utils/build-stub-fleet-caller.ts +60 -0
  95. package/src/test-utils/build-stub-forking-gh.ts +10 -0
  96. package/src/test-utils/build-stub-gh.ts +50 -0
  97. package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
  98. package/src/test-utils/build-stub-harness-relay.ts +50 -0
  99. package/src/test-utils/build-stub-headless-runner.ts +60 -0
  100. package/src/test-utils/build-stub-held-gh.ts +19 -0
  101. package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
  102. package/src/test-utils/build-stub-held-provider.ts +58 -0
  103. package/src/test-utils/build-stub-held-zoxide.ts +13 -0
  104. package/src/test-utils/build-stub-host-hold.ts +31 -0
  105. package/src/test-utils/build-stub-host-provider.ts +26 -0
  106. package/src/test-utils/build-stub-log.ts +15 -0
  107. package/src/test-utils/build-stub-mcp-claude.ts +26 -0
  108. package/src/test-utils/build-stub-mcp-grok.ts +19 -0
  109. package/src/test-utils/build-stub-mcp-stdio-server.ts +20 -0
  110. package/src/test-utils/build-stub-peer-socket.ts +81 -0
  111. package/src/test-utils/build-stub-picker-host.ts +36 -0
  112. package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
  113. package/src/test-utils/build-stub-pty-provider.ts +53 -0
  114. package/src/test-utils/build-stub-recording-atc.ts +10 -0
  115. package/src/test-utils/build-stub-recording-filter.ts +9 -0
  116. package/src/test-utils/build-stub-recording-git.ts +8 -0
  117. package/src/test-utils/build-stub-signed-in-gh.ts +18 -0
  118. package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
  119. package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
  120. package/src/test-utils/build-stub-targets.ts +58 -0
  121. package/src/test-utils/build-stub-terminal.ts +19 -0
  122. package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
  123. package/src/test-utils/build-stub-tui-claude.ts +47 -0
  124. package/src/test-utils/build-stub-tui-grok.ts +53 -0
  125. package/src/test-utils/build-stub-wait-clock.ts +23 -0
  126. package/src/test-utils/build-target-options-from-config.ts +37 -0
  127. package/src/test-utils/collect-process-tree.ts +75 -0
  128. package/src/test-utils/create-git-fixture.ts +117 -0
  129. package/src/test-utils/create-migrated-state-db.ts +12 -10
  130. package/src/test-utils/create-stub-bin.ts +19 -0
  131. package/src/test-utils/create-stub-claude.ts +75 -0
  132. package/src/test-utils/create-stub-codex.ts +31 -0
  133. package/src/test-utils/create-stub-composer.ts +65 -0
  134. package/src/test-utils/create-stub-echo-claude.ts +21 -0
  135. package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
  136. package/src/test-utils/create-stub-grok.ts +44 -0
  137. package/src/test-utils/create-stub-guest-clis.ts +55 -0
  138. package/src/test-utils/create-stub-harness-guest.ts +35 -0
  139. package/src/test-utils/{fixture-imp-port.ts → create-stub-imp-port.ts} +144 -63
  140. package/src/test-utils/create-stub-recording-claude.ts +20 -0
  141. package/src/test-utils/create-stub-recording-codex.ts +20 -0
  142. package/src/test-utils/create-stub-shared-server-codex.ts +56 -0
  143. package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +15 -9
  144. package/src/test-utils/get-gateway-config.ts +3 -3
  145. package/src/test-utils/get-only-effect-ref.ts +21 -0
  146. package/src/test-utils/get-only-imp-name.ts +19 -0
  147. package/src/test-utils/get-records.ts +20 -0
  148. package/src/test-utils/get-string.ts +14 -0
  149. package/src/test-utils/keys.ts +26 -0
  150. package/src/test-utils/merge-deep.ts +36 -0
  151. package/src/test-utils/mock-overrides.ts +13 -0
  152. package/src/test-utils/open-repo-step.ts +24 -0
  153. package/src/test-utils/read-query-plan.ts +22 -0
  154. package/src/test-utils/register-test-cleanup.ts +63 -0
  155. package/src/test-utils/resolve-atc-command.ts +13 -0
  156. package/src/test-utils/resolve-gateway-command.ts +13 -0
  157. package/src/test-utils/run-atc.ts +56 -0
  158. package/src/test-utils/run-legacy-daemon.ts +27 -5
  159. package/src/test-utils/run-listener-daemon.ts +5 -10
  160. package/src/test-utils/run-mcp-authorization.ts +7 -4
  161. package/src/test-utils/run-source-daemon.ts +2 -2
  162. package/src/test-utils/seed-faker.ts +7 -0
  163. package/src/test-utils/send-lines-before-handshake.ts +35 -0
  164. package/src/test-utils/send-mcp-request.ts +25 -0
  165. package/src/test-utils/setup-mcp-home.ts +110 -0
  166. package/src/test-utils/setup-mcp-http.ts +26 -30
  167. package/src/test-utils/setup-temp-dir.ts +7 -6
  168. package/src/test-utils/spawn-claude-session.ts +34 -0
  169. package/src/test-utils/spawn-grok-session.ts +39 -0
  170. package/src/test-utils/start-cut-proxy.ts +29 -8
  171. package/src/test-utils/start-daemon-process.ts +193 -0
  172. package/src/test-utils/start-git-http-server.ts +28 -4
  173. package/src/test-utils/start-mcp-stdio.ts +190 -0
  174. package/src/test-utils/start-output-capture.ts +20 -0
  175. package/src/test-utils/start-stub-answering-listener.ts +65 -0
  176. package/src/test-utils/start-stub-closing-listener.ts +31 -0
  177. package/src/test-utils/start-stub-dropping-daemon.ts +105 -0
  178. package/src/test-utils/start-stub-impd-info.ts +38 -0
  179. package/src/test-utils/start-stub-impd.ts +143 -0
  180. package/src/test-utils/start-stub-legacy-daemon.ts +171 -0
  181. package/src/test-utils/start-stub-mcp-server.ts +55 -0
  182. package/src/test-utils/start-stub-recording-listener.ts +47 -0
  183. package/src/test-utils/start-stub-refusing-listener.ts +43 -0
  184. package/src/test-utils/start-stub-reporter-socket.ts +79 -0
  185. package/src/test-utils/start-stub-restore-daemon.ts +59 -0
  186. package/src/test-utils/start-stub-session-bridge.ts +100 -0
  187. package/src/test-utils/start-stub-silent-server.ts +38 -0
  188. package/src/test-utils/start-stub-source-daemon.ts +41 -0
  189. package/src/test-utils/start-stub-stalled-client.ts +61 -0
  190. package/src/test-utils/start-stub-stalled-listener.ts +44 -0
  191. package/src/test-utils/start-stub-tap.ts +38 -0
  192. package/src/test-utils/start-stub-tcp-daemon.ts +75 -0
  193. package/src/test-utils/start-stub-telemetry-collector.ts +32 -0
  194. package/src/test-utils/start-stub-unanswering-listener.ts +41 -0
  195. package/src/test-utils/start-stub-unanswering-unix-listener.ts +38 -0
  196. package/src/test-utils/start-test-daemon.ts +229 -0
  197. package/src/test-utils/start-tui-harness.ts +291 -0
  198. package/src/test-utils/subscribe-to-socket-lines.ts +81 -14
  199. package/src/test-utils/try-send-request.ts +29 -0
  200. package/src/test-utils/wait-for-event.ts +41 -0
  201. package/src/test-utils/wait-for.ts +15 -3
  202. package/src/verify-restored-fleet.ts +30 -17
  203. package/src/workspace/check-repository-access.ts +4 -0
  204. package/src/workspace/collect-remote-refs.ts +4 -2
  205. package/src/workspace/run-git.ts +6 -0
  206. package/src/test-utils/start-legacy-daemon.ts +0 -111
@@ -1,30 +1,16 @@
1
1
  import { mkdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import type { AgentAdapter } from '../agents/agent-adapter';
4
3
  import { DaemonClient } from '../client/daemon-client';
5
4
  import { startDaemon } from '../daemon/daemon';
6
5
  import type { DaemonHandle } from '../daemon/daemon';
7
6
  import { openMCPAuth } from '../mcp/open-mcp-auth';
8
7
  import { ReconnectingCaller } from '../mcp/reconnecting-caller';
9
8
  import { startMCPHTTPServer } from '../mcp/start-mcp-http-server';
9
+ import { buildMockAgentAdapter } from './build-mock-agent-adapter';
10
+ import { registerTestCleanup } from './register-test-cleanup';
10
11
  import { setupTempDir } from './setup-temp-dir';
11
12
 
12
- const SLEEP_ADAPTER: AgentAdapter = {
13
- id: 'claude',
14
- headlessRunner: null,
15
- screenDetector: null,
16
- takesMessages: false,
17
- planSpawn: () => ({ bin: 'sleep', args: ['30'] }),
18
- normalizeHook: () => ({ kind: 'heartbeat' }),
19
- loadName: () => Promise.resolve(null),
20
- canResume: () => true,
21
- buildResumeCommand: () => null,
22
- };
23
-
24
13
  interface MCPHTTPSetupOptions {
25
- // How long a rotated refresh token still answers with its successor.
26
- readonly refreshReuseSeconds?: number;
27
-
28
14
  // Further Host header values the server accepts.
29
15
  readonly allowedHosts?: readonly string[];
30
16
  }
@@ -38,11 +24,23 @@ interface MCPHTTPSetupOptions {
38
24
  * `atc clients` and `atc grants` open it, and `addClient` adds a client
39
25
  * through it and returns the client id. `restartDaemon` stops the daemon and
40
26
  * starts a fresh one on the same socket and database, the way an operator
41
- * restarts it, with the principals it is given; `countDaemonClients` reads how many connections the current
42
- * daemon holds open. Hold the result with `await using`.
27
+ * restarts it, with the principals it is given; `countDaemonClients` reads
28
+ * how many connections the current daemon holds open. Everything it starts
29
+ * stops, and the directory is removed, once the current test finishes, so it
30
+ * must run inside a test; disposal does so sooner, and a second disposal
31
+ * does nothing.
43
32
  */
44
33
  export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
45
34
  const tmp = setupTempDir('atc-mcp-http-');
35
+
36
+ // Registered after the directory, so it releases first: each part stops
37
+ // before the one it depends on, and the directory goes last.
38
+ const stack = new AsyncDisposableStack();
39
+
40
+ const dispose = registerTestCleanup(() => stack.disposeAsync());
41
+
42
+ stack.use(tmp);
43
+
46
44
  const socketPath = join(tmp.dir, 'daemon.sock');
47
45
  const stateDir = join(tmp.dir, '.local', 'state', 'atc');
48
46
 
@@ -57,7 +55,7 @@ export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
57
55
  socketPath,
58
56
  reporterSocketPath: join(tmp.dir, 'reporter.sock'),
59
57
  build: 'atc/test-build',
60
- adapter: SLEEP_ADAPTER,
58
+ adapter: buildMockAgentAdapter(),
61
59
  dbPath: join(tmp.dir, 'state.db'),
62
60
  statusPath: join(tmp.dir, 'status.json'),
63
61
  principals,
@@ -65,10 +63,14 @@ export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
65
63
 
66
64
  let daemon: DaemonHandle = await startTestDaemon();
67
65
 
66
+ stack.defer(() => daemon.stop());
67
+
68
68
  const caller = new ReconnectingCaller(socketPath, 'atc/test-build', (path) =>
69
69
  DaemonClient.open(path),
70
70
  );
71
71
 
72
+ stack.defer(() => caller.stop());
73
+
72
74
  const server = await startMCPHTTPServer({
73
75
  caller,
74
76
  build: 'atc/test-build',
@@ -83,13 +85,14 @@ export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
83
85
  printRequest: (line) => {
84
86
  requests.push(line);
85
87
  },
86
- ...(options.refreshReuseSeconds === undefined
87
- ? {}
88
- : { refreshReuseSeconds: options.refreshReuseSeconds }),
89
88
  });
90
89
 
90
+ stack.defer(() => server.stop());
91
+
91
92
  const store = await openMCPAuth({ dbPath, origin: null });
92
93
 
94
+ stack.defer(() => store.close());
95
+
93
96
  return {
94
97
  home: tmp.dir,
95
98
  url: server.url,
@@ -114,13 +117,6 @@ export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
114
117
 
115
118
  daemon = await startTestDaemon(principals);
116
119
  },
117
- async [Symbol.asyncDispose]() {
118
- await server.stop();
119
- await store.close();
120
- await caller.stop();
121
- await daemon.stop();
122
-
123
- tmp[Symbol.dispose]();
124
- },
120
+ [Symbol.asyncDispose]: dispose,
125
121
  };
126
122
  }
@@ -1,6 +1,7 @@
1
1
  import { mkdtempSync, rmSync } from 'node:fs';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
+ import { registerTestCleanup } from './register-test-cleanup';
4
5
 
5
6
  interface TempDir {
6
7
  readonly dir: string;
@@ -9,17 +10,17 @@ interface TempDir {
9
10
  }
10
11
 
11
12
  /**
12
- * Creates a disposable directory under the system temp root, named by the
13
- * prefix. Disposal removes the tree; hold the result with `using` in a
14
- * synchronous test and `await using` in an asynchronous one, so the
15
- * directory outlives exactly the test that made it.
13
+ * Creates a directory under the system temp root, named by the prefix, and
14
+ * registers its removal to run once the current test finishes, so it must
15
+ * run inside a test. Disposal removes the tree sooner; whichever comes
16
+ * second does nothing.
16
17
  */
17
18
  export function setupTempDir(prefix: string): TempDir {
18
19
  const dir = mkdtempSync(join(tmpdir(), prefix));
19
20
 
20
- const remove = () => {
21
+ const remove = registerTestCleanup(() => {
21
22
  rmSync(dir, { recursive: true, force: true });
22
- };
23
+ });
23
24
 
24
25
  return {
25
26
  dir,
@@ -0,0 +1,34 @@
1
+ import { KEYS } from './keys';
2
+ import type { TUIHarness } from './start-tui-harness';
3
+
4
+ /**
5
+ * Spawns a Claude session named `name` through the client's spawn flow,
6
+ * from the home screen or the overlay: the first agent, the first
7
+ * directory, the name, and no first prompt. Resolves once the fake
8
+ * `claude` paints, with the capture holding what the client drew since the
9
+ * prompt step.
10
+ */
11
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- a harness is a live handle
12
+ export async function spawnClaudeSession(tui: TUIHarness, name: string): Promise<void> {
13
+ tui.write('n');
14
+
15
+ await tui.waitFor('spawn: agent');
16
+
17
+ tui.write(KEYS.enter);
18
+
19
+ await tui.waitFor('spawn: directory');
20
+
21
+ tui.write(KEYS.enter);
22
+
23
+ await tui.waitFor('spawn: name');
24
+
25
+ tui.reset();
26
+ tui.write(`${name}${KEYS.enter}`);
27
+
28
+ await tui.waitFor('spawn: initial prompt');
29
+
30
+ tui.reset();
31
+ tui.write(KEYS.enter);
32
+
33
+ await tui.waitFor('FAKE_CLAUDE_UP');
34
+ }
@@ -0,0 +1,39 @@
1
+ import { KEYS } from './keys';
2
+ import type { TUIHarness } from './start-tui-harness';
3
+
4
+ /**
5
+ * Spawns a Grok session named `name` through the client's spawn flow, from
6
+ * the home screen or the overlay: Grok, the second agent, then the first
7
+ * directory, the name, and no first prompt. Resolves once the fake `grok`
8
+ * paints, with the capture holding what the client drew since the prompt
9
+ * step.
10
+ */
11
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- a harness is a live handle
12
+ export async function spawnGrokSession(tui: TUIHarness, name: string): Promise<void> {
13
+ tui.write('n');
14
+
15
+ await tui.waitFor('spawn: agent');
16
+
17
+ tui.reset();
18
+ tui.write(KEYS.down);
19
+
20
+ await tui.waitFor('\u001B[7mGrok');
21
+
22
+ tui.write(KEYS.enter);
23
+
24
+ await tui.waitFor('spawn: directory');
25
+
26
+ tui.write(KEYS.enter);
27
+
28
+ await tui.waitFor('spawn: name');
29
+
30
+ tui.reset();
31
+ tui.write(`${name}${KEYS.enter}`);
32
+
33
+ await tui.waitFor('spawn: initial prompt');
34
+
35
+ tui.reset();
36
+ tui.write(KEYS.enter);
37
+
38
+ await tui.waitFor('FAKE_GROK_UP');
39
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Socket } from 'bun';
2
+ import { registerTestCleanup } from './register-test-cleanup';
2
3
 
3
4
  interface CutProxyOptions {
4
5
  // The address the proxy forwards to.
@@ -20,7 +21,11 @@ export interface CutProxy {
20
21
 
21
22
  // How many requests of the method the proxy has seen.
22
23
  readonly countRequests: () => number;
24
+
25
+ // How many answers the proxy has swallowed in hold mode.
26
+ readonly countHeld: () => number;
23
27
  readonly stop: () => void;
28
+ readonly [Symbol.dispose]: () => void;
24
29
  }
25
30
 
26
31
  // One proxied connection: the target side once it is connected, what the
@@ -33,6 +38,9 @@ interface ProxyLink {
33
38
  lineBuffer: string;
34
39
  cutting: boolean;
35
40
  held: boolean;
41
+
42
+ // Counts an answer the link swallows in hold mode.
43
+ readonly onHold: () => void;
36
44
  }
37
45
 
38
46
  /**
@@ -40,10 +48,14 @@ interface ProxyLink {
40
48
  * connection byte for byte, except that for the first `cuts` requests of
41
49
  * the method it loses the target's next answer after the request reached
42
50
  * the target: the request runs, and the client never sees its response.
51
+ * It stops, ending every link, once the current test finishes, so it must
52
+ * run inside a test; `stop` or disposal stops it sooner, and a second stop
53
+ * does nothing.
43
54
  */
44
55
  export function startCutProxy(options: CutProxyOptions): CutProxy {
45
56
  let cutsLeft = options.cuts;
46
57
  let requests = 0;
58
+ let held = 0;
47
59
 
48
60
  const links = new Set<Socket<ProxyLink>>();
49
61
 
@@ -59,6 +71,9 @@ export function startCutProxy(options: CutProxyOptions): CutProxy {
59
71
  lineBuffer: '',
60
72
  cutting: false,
61
73
  held: false,
74
+ onHold: () => {
75
+ held++;
76
+ },
62
77
  };
63
78
 
64
79
  links.add(client);
@@ -110,17 +125,21 @@ export function startCutProxy(options: CutProxyOptions): CutProxy {
110
125
  },
111
126
  });
112
127
 
128
+ const stop = registerTestCleanup(() => {
129
+ for (const client of links) {
130
+ client.end();
131
+ client.data.upstream?.end();
132
+ }
133
+
134
+ server.stop(true);
135
+ });
136
+
113
137
  return {
114
138
  port: server.port,
115
139
  countRequests: () => requests,
116
- stop() {
117
- for (const client of links) {
118
- client.end();
119
- client.data.upstream?.end();
120
- }
121
-
122
- server.stop(true);
123
- },
140
+ countHeld: () => held,
141
+ stop,
142
+ [Symbol.dispose]: stop,
124
143
  };
125
144
  }
126
145
 
@@ -149,6 +168,8 @@ async function openUpstream(client: Socket<ProxyLink>, options: CutProxyOptions)
149
168
  if (options.mode === 'hold') {
150
169
  client.data.held = true;
151
170
 
171
+ client.data.onHold();
172
+
152
173
  return;
153
174
  }
154
175
 
@@ -0,0 +1,193 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import type { Subprocess } from 'bun';
4
+ import { DaemonClient } from '../client/daemon-client';
5
+ import { findDaemonRecord } from '../shared/find-daemon-record';
6
+ import { registerTestCleanup } from './register-test-cleanup';
7
+ import { waitFor } from './wait-for';
8
+
9
+ interface DaemonProcessConfig {
10
+ // The command atc runs as, such as the source entry under bun or a
11
+ // compiled binary.
12
+ readonly command: readonly string[];
13
+
14
+ // HOME and XDG_RUNTIME_DIR of the daemon, so its config, state, and
15
+ // sockets all sit under this directory.
16
+ readonly home: string;
17
+
18
+ // Variables laid over the inherited environment and the default PATH;
19
+ // `undefined` removes one.
20
+ readonly env?: Readonly<Record<string, string | undefined>>;
21
+
22
+ // Arguments after `atc daemon`.
23
+ readonly args?: readonly string[];
24
+ }
25
+
26
+ /**
27
+ * One boot of the daemon: its process and the file its stderr goes to.
28
+ */
29
+ interface DaemonBoot {
30
+ readonly proc: Subprocess;
31
+ readonly stderrPath: string;
32
+ }
33
+
34
+ /**
35
+ * Starts `atc daemon` as its own process on a home, with the test's
36
+ * environment, HOME and XDG_RUNTIME_DIR at the home, and a PATH of the system
37
+ * directories alone unless the config's variables set another. It returns at
38
+ * once; `openClient` waits up to 15 seconds for the socket and connects,
39
+ * sending no handshake, and fails at once with the daemon's stderr when the
40
+ * daemon exits before it listens. `restart` stops the daemon with the given
41
+ * signal, waits for it to exit, and starts another on the same home with the
42
+ * same config. `readStderr` reads what the current boot printed. Disposal
43
+ * closes every client it opened, kills the daemon and waits for it to exit,
44
+ * then kills the daemon the home's state directory records, which a restart
45
+ * the test asked atc for may have started. That disposal runs once the
46
+ * current test finishes, so it must run inside a test; disposing sooner
47
+ * runs it then, and a second disposal does nothing.
48
+ */
49
+ export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
50
+ const socketPath = join(config.home, 'atc-daemon.sock');
51
+ const stateDir = join(config.home, '.local', 'state', 'atc');
52
+
53
+ const clients = new Set<DaemonClient>();
54
+
55
+ let boots = 0;
56
+ let disposed = false;
57
+
58
+ const boot = (): DaemonBoot => {
59
+ boots++;
60
+
61
+ const stderrPath = join(config.home, `daemon-${boots}.stderr`);
62
+
63
+ const proc = Bun.spawn([...config.command, 'daemon', ...(config.args ?? [])], {
64
+ env: Object.fromEntries(
65
+ Object.entries({
66
+ ...process.env,
67
+ HOME: config.home,
68
+ XDG_RUNTIME_DIR: config.home,
69
+ PATH: '/usr/sbin:/usr/bin:/bin',
70
+ ...config.env,
71
+ }).filter((entry): entry is [string, string] => entry[1] !== undefined),
72
+ ),
73
+ stdout: 'ignore',
74
+ stderr: Bun.file(stderrPath),
75
+ });
76
+
77
+ return { proc, stderrPath };
78
+ };
79
+
80
+ let current = boot();
81
+
82
+ const dispose = registerTestCleanup(async () => {
83
+ disposed = true;
84
+
85
+ for (const client of clients) {
86
+ client.stop();
87
+ }
88
+
89
+ current.proc.kill('SIGKILL');
90
+
91
+ await current.proc.exited;
92
+
93
+ const recorded = findDaemonRecord(join(stateDir, 'daemon.json'));
94
+
95
+ if (recorded !== null && recorded.pid !== current.proc.pid) {
96
+ try {
97
+ process.kill(recorded.pid, 'SIGKILL');
98
+ } catch {}
99
+ }
100
+ });
101
+
102
+ const readStderr = () => {
103
+ try {
104
+ return readFileSync(current.stderrPath, 'utf8');
105
+ } catch {
106
+ return '';
107
+ }
108
+ };
109
+
110
+ // A client that opens after the wait for it ended, or after disposal,
111
+ // closes at once, so nothing outlives the daemon it reached.
112
+ const openTrackedClient = async (isAbandoned: () => boolean) => {
113
+ const client = await DaemonClient.open(socketPath);
114
+
115
+ if (disposed || isAbandoned()) {
116
+ client.stop();
117
+ throw new Error('the wait for the daemon ended before this client opened');
118
+ }
119
+
120
+ clients.add(client);
121
+
122
+ return client;
123
+ };
124
+
125
+ const openClient = async (): Promise<DaemonClient> => {
126
+ const watched = current.proc;
127
+ let settled = false;
128
+ const isSettled = () => settled;
129
+
130
+ // A daemon that has exited either refused to start or handed its socket
131
+ // to a replacement, so one more connect tells the two apart.
132
+ const openAfterExit = async () => {
133
+ await watched.exited;
134
+
135
+ if (settled || disposed) {
136
+ return null;
137
+ }
138
+
139
+ try {
140
+ return await openTrackedClient(isSettled);
141
+ } catch {
142
+ throw new Error(
143
+ `the daemon exited (${String(watched.exitCode ?? watched.signalCode)}) before it listened:\n${readStderr()}`,
144
+ );
145
+ }
146
+ };
147
+
148
+ // Polls the socket while the daemon runs. Once the other wait settles or
149
+ // the daemon is disposed, each attempt resolves empty, which ends the
150
+ // polling.
151
+ const openWhenListening = () =>
152
+ waitFor(() => (settled || disposed ? null : openTrackedClient(isSettled)), {
153
+ timeoutMs: 15_000,
154
+ intervalMs: 50,
155
+ });
156
+
157
+ const hasExited = watched.exitCode !== null || watched.signalCode !== null;
158
+
159
+ try {
160
+ const client = await (hasExited
161
+ ? openAfterExit()
162
+ : Promise.race([openWhenListening(), openAfterExit()]));
163
+
164
+ if (client === null) {
165
+ throw new Error('the wait for the daemon settled without a client');
166
+ }
167
+
168
+ return client;
169
+ } finally {
170
+ settled = true;
171
+ }
172
+ };
173
+
174
+ return {
175
+ home: config.home,
176
+ socketPath,
177
+ reporterSocketPath: join(config.home, 'atc.sock'),
178
+ stateDir,
179
+ get proc(): Subprocess {
180
+ return current.proc;
181
+ },
182
+ readStderr,
183
+ openClient,
184
+ async restart(signal: NodeJS.Signals): Promise<void> {
185
+ current.proc.kill(signal);
186
+
187
+ await current.proc.exited;
188
+
189
+ current = boot();
190
+ },
191
+ [Symbol.asyncDispose]: dispose,
192
+ };
193
+ }
@@ -1,3 +1,5 @@
1
+ import { registerTestCleanup } from './register-test-cleanup';
2
+
1
3
  interface GitHTTPServer {
2
4
  // The base URL a repository under the root is served at, ending in `/`.
3
5
  readonly url: string;
@@ -5,6 +7,18 @@ interface GitHTTPServer {
5
7
  // Every Authorization header a request carried, in arrival order.
6
8
  readonly authorizations: string[];
7
9
  readonly stop: () => Promise<void>;
10
+ readonly [Symbol.asyncDispose]: () => Promise<void>;
11
+ }
12
+
13
+ interface GitHTTPServerOptions {
14
+ // How long each authenticated request waits before it is served.
15
+ readonly delayMs?: number;
16
+
17
+ // Waits out the delay; a timer unless given.
18
+ readonly wait?: (ms: number) => Promise<void>;
19
+
20
+ // Runs, and is awaited, while each authenticated request is held.
21
+ readonly onRequest?: () => Promise<void> | void;
8
22
  }
9
23
 
10
24
  /**
@@ -13,13 +27,18 @@ interface GitHTTPServer {
13
27
  * carries no basic auth, so a client only clones by authenticating. The
14
28
  * backend runs with the environment it is given. With a delay, each
15
29
  * authenticated request waits that long before it is served, which makes a
16
- * clone through it slow.
30
+ * clone through it slow. An `onRequest` callback runs, and is awaited, while
31
+ * each authenticated request is held, so it sees the client that sent the
32
+ * request still running. It stops once the current test finishes, so it
33
+ * must run inside a test; `stop` or disposal stops it sooner, and a second
34
+ * stop does nothing.
17
35
  */
18
36
  export function startGitHTTPServer(
19
37
  root: string,
20
38
  env: Readonly<Record<string, string | undefined>>,
21
- options: { readonly delayMs?: number } = {},
39
+ options: GitHTTPServerOptions = {},
22
40
  ): GitHTTPServer {
41
+ const wait = options.wait ?? Bun.sleep;
23
42
  const authorizations: string[] = [];
24
43
 
25
44
  const server = Bun.serve({
@@ -38,9 +57,11 @@ export function startGitHTTPServer(
38
57
  authorizations.push(authorization);
39
58
 
40
59
  if (options.delayMs !== undefined) {
41
- await Bun.sleep(options.delayMs);
60
+ await wait(options.delayMs);
42
61
  }
43
62
 
63
+ await options.onRequest?.();
64
+
44
65
  const url = new URL(request.url);
45
66
 
46
67
  const body = await request.arrayBuffer();
@@ -70,10 +91,13 @@ export function startGitHTTPServer(
70
91
  },
71
92
  });
72
93
 
94
+ const stop = registerTestCleanup(() => server.stop(true));
95
+
73
96
  return {
74
97
  url: `http://127.0.0.1:${server.port}/`,
75
98
  authorizations,
76
- stop: () => server.stop(true),
99
+ stop,
100
+ [Symbol.asyncDispose]: stop,
77
101
  };
78
102
  }
79
103