@zgeoff/atc 3.4.0 → 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 (63) hide show
  1. package/package.json +2 -2
  2. package/src/agents/build-agent-adapters.ts +21 -3
  3. package/src/agents/codex-adapter.ts +20 -0
  4. package/src/agents/render-sdk-message.ts +57 -0
  5. package/src/agents/start-claude-headless-run.ts +2 -57
  6. package/src/agents/with-claude-config-lock.ts +28 -15
  7. package/src/client/boot-daemon.ts +75 -25
  8. package/src/client/resolve-workspace-root.ts +10 -5
  9. package/src/daemon/session-runtime.ts +0 -4
  10. package/src/gateway.ts +2 -179
  11. package/src/mcp-http-server.ts +13 -10
  12. package/src/run-gateway-cli.ts +327 -0
  13. package/src/run-gateway.ts +49 -18
  14. package/src/sources/github/run-gh.ts +9 -0
  15. package/src/test-utils/build-mock-execution-target.ts +25 -0
  16. package/src/test-utils/build-mock-imp-session-request.ts +53 -0
  17. package/src/test-utils/build-mock-message-record.ts +7 -3
  18. package/src/test-utils/build-mock-stored-event.ts +20 -0
  19. package/src/test-utils/build-mock-stored-row.ts +23 -0
  20. package/src/test-utils/build-stub-brokered-gateway-adapter.ts +50 -0
  21. package/src/test-utils/build-stub-mcp-stdio-server.ts +20 -0
  22. package/src/test-utils/build-stub-recording-filter.ts +9 -0
  23. package/src/test-utils/build-stub-recording-git.ts +8 -0
  24. package/src/test-utils/create-git-fixture.ts +5 -8
  25. package/src/test-utils/create-stub-echo-claude.ts +21 -0
  26. package/src/test-utils/{build-stub-imp-port.ts → create-stub-imp-port.ts} +30 -5
  27. package/src/test-utils/create-stub-recording-codex.ts +20 -0
  28. package/src/test-utils/create-stub-shared-server-codex.ts +56 -0
  29. package/src/test-utils/create-stub-systemd.ts +10 -4
  30. package/src/test-utils/get-only-imp-name.ts +2 -2
  31. package/src/test-utils/register-test-cleanup.ts +63 -0
  32. package/src/test-utils/run-legacy-daemon.ts +1 -1
  33. package/src/test-utils/run-mcp-authorization.ts +7 -4
  34. package/src/test-utils/setup-mcp-home.ts +24 -13
  35. package/src/test-utils/setup-mcp-http.ts +15 -7
  36. package/src/test-utils/setup-temp-dir.ts +7 -6
  37. package/src/test-utils/start-cut-proxy.ts +6 -2
  38. package/src/test-utils/start-daemon-process.ts +25 -24
  39. package/src/test-utils/start-git-http-server.ts +9 -3
  40. package/src/test-utils/start-mcp-stdio.ts +17 -22
  41. package/src/test-utils/start-stub-answering-listener.ts +65 -0
  42. package/src/test-utils/start-stub-closing-listener.ts +31 -0
  43. package/src/test-utils/start-stub-dropping-daemon.ts +10 -8
  44. package/src/test-utils/start-stub-impd-info.ts +10 -4
  45. package/src/test-utils/start-stub-impd.ts +8 -3
  46. package/src/test-utils/start-stub-legacy-daemon.ts +13 -8
  47. package/src/test-utils/start-stub-mcp-server.ts +7 -2
  48. package/src/test-utils/start-stub-recording-listener.ts +47 -0
  49. package/src/test-utils/start-stub-refusing-listener.ts +9 -4
  50. package/src/test-utils/start-stub-reporter-socket.ts +6 -4
  51. package/src/test-utils/start-stub-restore-daemon.ts +5 -4
  52. package/src/test-utils/start-stub-session-bridge.ts +6 -4
  53. package/src/test-utils/start-stub-silent-server.ts +10 -5
  54. package/src/test-utils/start-stub-source-daemon.ts +5 -3
  55. package/src/test-utils/start-stub-stalled-client.ts +61 -0
  56. package/src/test-utils/start-stub-stalled-listener.ts +13 -9
  57. package/src/test-utils/start-stub-tcp-daemon.ts +9 -4
  58. package/src/test-utils/start-stub-telemetry-collector.ts +9 -3
  59. package/src/test-utils/start-stub-unanswering-listener.ts +41 -0
  60. package/src/test-utils/start-stub-unanswering-unix-listener.ts +38 -0
  61. package/src/test-utils/start-test-daemon.ts +20 -6
  62. package/src/test-utils/start-tui-harness.ts +15 -8
  63. package/src/test-utils/subscribe-to-socket-lines.ts +25 -5
@@ -5,6 +5,7 @@ import { stopDaemonProcess } from '../stop-daemon-process';
5
5
  import { buildStubMCPClaude } from './build-stub-mcp-claude';
6
6
  import { buildStubMCPGrok } from './build-stub-mcp-grok';
7
7
  import { createStubBin } from './create-stub-bin';
8
+ import { registerTestCleanup } from './register-test-cleanup';
8
9
  import { setupTempDir } from './setup-temp-dir';
9
10
  import { waitFor } from './wait-for';
10
11
 
@@ -17,13 +18,33 @@ import { waitFor } from './wait-for';
17
18
  * home. Disposal stops the daemon, when one wrote its pid file here, and
18
19
  * waits for it to exit; then it kills the daemon's process group and every
19
20
  * group `stub-pids` records, such as a reporter a stopped session left
20
- * behind, waits until each group is empty, and removes the home. Hold the
21
- * result with `await using`.
21
+ * behind, waits until each group is empty, and removes the home. That
22
+ * disposal runs once the current test finishes, so it must run inside a
23
+ * test; disposing sooner runs it then, and a second disposal does nothing.
22
24
  */
23
25
  export function setupMCPHome() {
24
26
  const tmp = setupTempDir('atc-mcp-');
27
+
28
+ // Registered after the home, so it releases first: the daemon and the
29
+ // stand-ins stop before the home that records them is removed.
30
+ const stack = new AsyncDisposableStack();
31
+
32
+ const dispose = registerTestCleanup(() => stack.disposeAsync());
33
+
34
+ stack.use(tmp);
35
+
25
36
  const home = tmp.dir;
26
37
 
38
+ stack.defer(async () => {
39
+ const pid = findPidFilePID(join(home, 'atc-daemon.pid'));
40
+
41
+ if (pid !== null) {
42
+ await stopDaemonProcess(pid);
43
+ }
44
+
45
+ await killProcessGroups([...(pid === null ? [] : [pid]), ...readStubPIDs(home)]);
46
+ });
47
+
27
48
  mkdirSync(join(home, '.config', 'atc'), { recursive: true });
28
49
  mkdirSync(join(home, '.local', 'state', 'atc'), { recursive: true });
29
50
 
@@ -39,17 +60,7 @@ export function setupMCPHome() {
39
60
  home,
40
61
  claudeBin,
41
62
  grokBin,
42
- async [Symbol.asyncDispose]() {
43
- const pid = findPidFilePID(join(home, 'atc-daemon.pid'));
44
-
45
- if (pid !== null) {
46
- await stopDaemonProcess(pid);
47
- }
48
-
49
- await killProcessGroups([...(pid === null ? [] : [pid]), ...readStubPIDs(home)]);
50
-
51
- tmp[Symbol.dispose]();
52
- },
63
+ [Symbol.asyncDispose]: dispose,
53
64
  };
54
65
  }
55
66
 
@@ -7,6 +7,7 @@ import { openMCPAuth } from '../mcp/open-mcp-auth';
7
7
  import { ReconnectingCaller } from '../mcp/reconnecting-caller';
8
8
  import { startMCPHTTPServer } from '../mcp/start-mcp-http-server';
9
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
13
  interface MCPHTTPSetupOptions {
@@ -24,13 +25,22 @@ interface MCPHTTPSetupOptions {
24
25
  * through it and returns the client id. `restartDaemon` stops the daemon and
25
26
  * starts a fresh one on the same socket and database, the way an operator
26
27
  * restarts it, with the principals it is given; `countDaemonClients` reads
27
- * how many connections the current daemon holds open. Hold the result with
28
- * `await using`.
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.
29
32
  */
30
33
  export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
31
- await using stack = new AsyncDisposableStack();
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);
32
43
 
33
- const tmp = stack.use(setupTempDir('atc-mcp-http-'));
34
44
  const socketPath = join(tmp.dir, 'daemon.sock');
35
45
  const stateDir = join(tmp.dir, '.local', 'state', 'atc');
36
46
 
@@ -83,8 +93,6 @@ export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
83
93
 
84
94
  stack.defer(() => store.close());
85
95
 
86
- const owned = stack.move();
87
-
88
96
  return {
89
97
  home: tmp.dir,
90
98
  url: server.url,
@@ -109,6 +117,6 @@ export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
109
117
 
110
118
  daemon = await startTestDaemon(principals);
111
119
  },
112
- [Symbol.asyncDispose]: () => owned.disposeAsync(),
120
+ [Symbol.asyncDispose]: dispose,
113
121
  };
114
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,
@@ -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.
@@ -47,6 +48,9 @@ interface ProxyLink {
47
48
  * connection byte for byte, except that for the first `cuts` requests of
48
49
  * the method it loses the target's next answer after the request reached
49
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.
50
54
  */
51
55
  export function startCutProxy(options: CutProxyOptions): CutProxy {
52
56
  let cutsLeft = options.cuts;
@@ -121,14 +125,14 @@ export function startCutProxy(options: CutProxyOptions): CutProxy {
121
125
  },
122
126
  });
123
127
 
124
- const stop = () => {
128
+ const stop = registerTestCleanup(() => {
125
129
  for (const client of links) {
126
130
  client.end();
127
131
  client.data.upstream?.end();
128
132
  }
129
133
 
130
134
  server.stop(true);
131
- };
135
+ });
132
136
 
133
137
  return {
134
138
  port: server.port,
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import type { Subprocess } from 'bun';
4
4
  import { DaemonClient } from '../client/daemon-client';
5
5
  import { findDaemonRecord } from '../shared/find-daemon-record';
6
+ import { registerTestCleanup } from './register-test-cleanup';
6
7
  import { waitFor } from './wait-for';
7
8
 
8
9
  interface DaemonProcessConfig {
@@ -41,7 +42,9 @@ interface DaemonBoot {
41
42
  * same config. `readStderr` reads what the current boot printed. Disposal
42
43
  * closes every client it opened, kills the daemon and waits for it to exit,
43
44
  * then kills the daemon the home's state directory records, which a restart
44
- * the test asked atc for may have started; a second disposal does nothing.
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.
45
48
  */
46
49
  export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
47
50
  const socketPath = join(config.home, 'atc-daemon.sock');
@@ -76,6 +79,26 @@ export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
76
79
 
77
80
  let current = boot();
78
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
+
79
102
  const readStderr = () => {
80
103
  try {
81
104
  return readFileSync(current.stderrPath, 'utf8');
@@ -165,28 +188,6 @@ export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
165
188
 
166
189
  current = boot();
167
190
  },
168
- async [Symbol.asyncDispose](): Promise<void> {
169
- if (disposed) {
170
- return;
171
- }
172
-
173
- disposed = true;
174
-
175
- for (const client of clients) {
176
- client.stop();
177
- }
178
-
179
- current.proc.kill('SIGKILL');
180
-
181
- await current.proc.exited;
182
-
183
- const recorded = findDaemonRecord(join(stateDir, 'daemon.json'));
184
-
185
- if (recorded !== null && recorded.pid !== current.proc.pid) {
186
- try {
187
- process.kill(recorded.pid, 'SIGKILL');
188
- } catch {}
189
- }
190
- },
191
+ [Symbol.asyncDispose]: dispose,
191
192
  };
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;
@@ -27,7 +29,9 @@ interface GitHTTPServerOptions {
27
29
  * authenticated request waits that long before it is served, which makes a
28
30
  * clone through it slow. An `onRequest` callback runs, and is awaited, while
29
31
  * each authenticated request is held, so it sees the client that sent the
30
- * request still running. Stop it with `stop`, or hold it with `await using`.
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.
31
35
  */
32
36
  export function startGitHTTPServer(
33
37
  root: string,
@@ -87,11 +91,13 @@ export function startGitHTTPServer(
87
91
  },
88
92
  });
89
93
 
94
+ const stop = registerTestCleanup(() => server.stop(true));
95
+
90
96
  return {
91
97
  url: `http://127.0.0.1:${server.port}/`,
92
98
  authorizations,
93
- stop: () => server.stop(true),
94
- [Symbol.asyncDispose]: () => server.stop(true),
99
+ stop,
100
+ [Symbol.asyncDispose]: stop,
95
101
  };
96
102
  }
97
103
 
@@ -1,5 +1,6 @@
1
1
  import { join } from 'node:path';
2
2
  import { isRecord } from '../shared/report';
3
+ import { registerTestCleanup } from './register-test-cleanup';
3
4
 
4
5
  const CLI_PATH = join(import.meta.dir, '..', 'cli.ts');
5
6
 
@@ -37,9 +38,10 @@ interface MCPToolResult {
37
38
  * an object. `spawnSession` spawns through `atc_session_spawn` and resolves
38
39
  * with the new session's id. A request still unanswered when the server's
39
40
  * stdout ends rejects. When `initialize` fails, the server is stopped
40
- * before the start rejects. Disposal stops the server and waits for it to exit,
41
- * and a second disposal waits for the same exit; the daemon stays up for
42
- * the home to stop.
41
+ * before the start rejects. The server stops once the current test
42
+ * finishes, so it must run inside a test; disposal stops it sooner and waits
43
+ * for it to exit, and a second disposal waits for the same exit. The daemon
44
+ * stays up for the home to stop.
43
45
  */
44
46
  export async function startMCPStdio(options: MCPStdioOptions) {
45
47
  const proc = Bun.spawn([...(options.command ?? [process.execPath, CLI_PATH, 'mcp'])], {
@@ -56,6 +58,13 @@ export async function startMCPStdio(options: MCPStdioOptions) {
56
58
  stderr: 'ignore',
57
59
  });
58
60
 
61
+ const stopServer = registerTestCleanup(async (): Promise<void> => {
62
+ void proc.stdin.end();
63
+ proc.kill();
64
+
65
+ await proc.exited;
66
+ });
67
+
59
68
  const pending = new Map<number, PromiseWithResolvers<Readonly<Record<string, unknown>>>>();
60
69
 
61
70
  void readResponses(proc.stdout, pending);
@@ -90,23 +99,6 @@ export async function startMCPStdio(options: MCPStdioOptions) {
90
99
  return toToolResult(response);
91
100
  };
92
101
 
93
- let stopped = false;
94
-
95
- const stopServer = async (): Promise<void> => {
96
- if (!stopped) {
97
- stopped = true;
98
- void proc.stdin.end();
99
- proc.kill();
100
- }
101
-
102
- await proc.exited;
103
- };
104
-
105
- // A failed initialize stops the server here, before the caller holds it.
106
- await using stack = new AsyncDisposableStack();
107
-
108
- stack.defer(stopServer);
109
-
110
102
  const server = {
111
103
  sendRequest,
112
104
  sendToolCall,
@@ -124,13 +116,16 @@ export async function startMCPStdio(options: MCPStdioOptions) {
124
116
  [Symbol.asyncDispose]: stopServer,
125
117
  };
126
118
 
119
+ // A failed initialize stops the server here, before the start rejects.
127
120
  await sendRequest('initialize', {
128
121
  protocolVersion: '2025-06-18',
129
122
  capabilities: {},
130
123
  clientInfo: { name: 'atc-test' },
131
- });
124
+ }).catch(async (error: unknown) => {
125
+ await stopServer();
132
126
 
133
- stack.move();
127
+ throw error;
128
+ });
134
129
 
135
130
  return server;
136
131
  }
@@ -0,0 +1,65 @@
1
+ import { createServer } from 'node:net';
2
+ import type { Socket } from 'node:net';
3
+ import { registerTestCleanup } from './register-test-cleanup';
4
+
5
+ interface StubAnsweringListener {
6
+ // The server's side of each connection it accepted, in order.
7
+ readonly peers: readonly Socket[];
8
+
9
+ // The first read each connection sent, decoded, in arrival order.
10
+ readonly lines: readonly string[];
11
+ readonly [Symbol.asyncDispose]: () => Promise<void>;
12
+ }
13
+
14
+ /**
15
+ * A stand-in for a daemon socket that answers a handshake: it listens on
16
+ * the unix socket path, records each connection it accepts and the first
17
+ * read each one sends, answers that read with the line `answer`, and
18
+ * leaves every later byte unread on the connection. Resolves once it
19
+ * listens. Once the current test finishes, it destroys every connection it
20
+ * accepted and closes, so it must run inside a test; disposal does so
21
+ * sooner and resolves once the server has closed, and a second stop does
22
+ * nothing.
23
+ */
24
+ export async function startStubAnsweringListener(path: string): Promise<StubAnsweringListener> {
25
+ const peers: Socket[] = [];
26
+ const lines: string[] = [];
27
+
28
+ const server = createServer((peer) => {
29
+ peers.push(peer);
30
+
31
+ peer.once('data', (data) => {
32
+ peer.pause();
33
+ lines.push(data.toString());
34
+ peer.write('answer\n');
35
+ });
36
+ });
37
+
38
+ const listening = Promise.withResolvers<void>();
39
+
40
+ server.listen(path, () => {
41
+ listening.resolve();
42
+ });
43
+
44
+ await listening.promise;
45
+
46
+ const stop = registerTestCleanup(async () => {
47
+ for (const peer of peers) {
48
+ peer.destroy();
49
+ }
50
+
51
+ const closed = Promise.withResolvers<void>();
52
+
53
+ server.close(() => {
54
+ closed.resolve();
55
+ });
56
+
57
+ await closed.promise;
58
+ });
59
+
60
+ return {
61
+ peers,
62
+ lines,
63
+ [Symbol.asyncDispose]: stop,
64
+ };
65
+ }
@@ -0,0 +1,31 @@
1
+ import { registerTestCleanup } from './register-test-cleanup';
2
+
3
+ interface StubClosingListener {
4
+ readonly [Symbol.dispose]: () => void;
5
+ }
6
+
7
+ /**
8
+ * A stand-in for a daemon that hangs up: it listens on the unix socket path
9
+ * and ends every connection as soon as it opens, reading nothing. It stops
10
+ * once the current test finishes, so it must run inside a test; disposal
11
+ * stops it sooner, and a second stop does nothing.
12
+ */
13
+ export function startStubClosingListener(path: string): StubClosingListener {
14
+ const server = Bun.listen({
15
+ unix: path,
16
+ socket: {
17
+ open(socket) {
18
+ socket.end();
19
+ },
20
+ data() {},
21
+ },
22
+ });
23
+
24
+ const stop = registerTestCleanup(() => {
25
+ server.stop(true);
26
+ });
27
+
28
+ return {
29
+ [Symbol.dispose]: stop,
30
+ };
31
+ }
@@ -6,6 +6,7 @@ import {
6
6
  decodeMessage,
7
7
  encodeMessage,
8
8
  } from '../protocol/protocol';
9
+ import { registerTestCleanup } from './register-test-cleanup';
9
10
 
10
11
  interface DroppingDaemonOptions {
11
12
  // The features the first handshake announces.
@@ -23,8 +24,9 @@ interface DroppingDaemonOptions {
23
24
  * and answers every later one with a session `s-1`. `keys` records the
24
25
  * idempotency key each of those requests carried, `undefined` for one that
25
26
  * carried none. `reads` counts the reads it has taken from every connection,
26
- * so a test can wait until one piece of a split write has arrived. Stop it
27
- * with `stop`, or hold it with `using`.
27
+ * so a test can wait until one piece of a split write has arrived. It stops
28
+ * once the current test finishes, so it must run inside a test; `stop` or
29
+ * disposal stops it sooner, and a second stop does nothing.
28
30
  */
29
31
  export function startStubDroppingDaemon(socketPath: string, options: DroppingDaemonOptions) {
30
32
  const keys: unknown[] = [];
@@ -88,16 +90,16 @@ export function startStubDroppingDaemon(socketPath: string, options: DroppingDae
88
90
  },
89
91
  });
90
92
 
93
+ const stop = registerTestCleanup(() => {
94
+ server.stop(true);
95
+ });
96
+
91
97
  return {
92
98
  keys,
93
99
  get reads() {
94
100
  return reads;
95
101
  },
96
- stop() {
97
- server.stop(true);
98
- },
99
- [Symbol.dispose]() {
100
- server.stop(true);
101
- },
102
+ stop,
103
+ [Symbol.dispose]: stop,
102
104
  };
103
105
  }
@@ -1,10 +1,14 @@
1
+ import { registerTestCleanup } from './register-test-cleanup';
2
+
1
3
  /**
2
4
  * An impd stand-in on a real HTTP port of the loopback address. Every call
3
5
  * is answered with HTTP 200 and the body impd's RPC sends for system info,
4
6
  * `{ json: info }`, so a client reads `info.features` as impd's features;
5
7
  * the test sets `info.features` to what impd should report. `paths` holds
6
- * the request path of each call, in order. Disposal stops the server; the
7
- * asynchronous form resolves once it has stopped.
8
+ * the request path of each call, in order. The server stops once the
9
+ * current test finishes, so it must run inside a test; disposal stops it
10
+ * sooner, the asynchronous form resolving once it has stopped, and a second
11
+ * stop does nothing.
8
12
  */
9
13
  export function startStubImpdInfo() {
10
14
  const paths: string[] = [];
@@ -20,13 +24,15 @@ export function startStubImpdInfo() {
20
24
  },
21
25
  });
22
26
 
27
+ const stop = registerTestCleanup(() => server.stop(true));
28
+
23
29
  return {
24
30
  url: `http://127.0.0.1:${String(server.port)}`,
25
31
  paths,
26
32
  info,
27
33
  [Symbol.dispose]: () => {
28
- void server.stop(true);
34
+ void stop();
29
35
  },
30
- [Symbol.asyncDispose]: () => server.stop(true),
36
+ [Symbol.asyncDispose]: stop,
31
37
  };
32
38
  }
@@ -1,5 +1,6 @@
1
1
  import type { ServerWebSocket } from 'bun';
2
2
  import { isRecord } from '../shared/report';
3
+ import { registerTestCleanup } from './register-test-cleanup';
3
4
 
4
5
  // What the stand-in does with an exec open: refuse it as a start whose
5
6
  // broker is not ready, start it and count its stdin bytes as `wc -c` does,
@@ -15,8 +16,10 @@ type StubExecReply = 'refuse' | 'count' | 'exit-early';
15
16
  * announce a guest connection on it. It records each exec open message in
16
17
  * `execOpens` and answers it as `exec.reply` says, a refusal as a start
17
18
  * whose broker is not ready by default. It takes no WebSocket message over
18
- * 2 MiB, as impd refuses one over its own limit. Disposal stops the server
19
- * and drops every open connection at once.
19
+ * 2 MiB, as impd refuses one over its own limit. The server stops, dropping
20
+ * every open connection at once, when the current test finishes, so it must
21
+ * run inside a test; disposal stops it sooner, and a second stop does
22
+ * nothing.
20
23
  */
21
24
  export function startStubImpd() {
22
25
  const authorizations: (string | null)[] = [];
@@ -123,6 +126,8 @@ export function startStubImpd() {
123
126
  },
124
127
  });
125
128
 
129
+ const stop = registerTestCleanup(() => server.stop(true));
130
+
126
131
  return {
127
132
  url: `http://127.0.0.1:${String(server.port)}`,
128
133
  authorizations,
@@ -132,7 +137,7 @@ export function startStubImpd() {
132
137
  execOpens,
133
138
  exec,
134
139
  [Symbol.dispose]: () => {
135
- void server.stop(true);
140
+ void stop();
136
141
  },
137
142
  };
138
143
  }
@@ -7,6 +7,7 @@ import {
7
7
  decodeMessage,
8
8
  encodeMessage,
9
9
  } from '../protocol/protocol';
10
+ import { registerTestCleanup } from './register-test-cleanup';
10
11
 
11
12
  interface ReceivedRequest {
12
13
  readonly m: string;
@@ -28,6 +29,11 @@ interface StubLegacyDaemonOptions {
28
29
  readonly protocol?: number;
29
30
 
30
31
  readonly replies?: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
32
+
33
+ // Who stops the daemon: the current test, once it finishes, or the
34
+ // caller alone, for a process that runs it outside any test; the test
35
+ // unless set.
36
+ readonly owner?: 'test' | 'caller';
31
37
  }
32
38
 
33
39
  /**
@@ -39,8 +45,10 @@ interface StubLegacyDaemonOptions {
39
45
  * `replies` with that method's reply, and refuses every other method with
40
46
  * `unknown_method`. Every request it receives is recorded in `requests`, and
41
47
  * `connections` counts the connections it accepted and those still open.
42
- * `port` holds the TCP port it bound, `null` on a unix socket. Stop
43
- * it with `stop`, or hold it with `using`.
48
+ * `port` holds the TCP port it bound, `null` on a unix socket. It stops
49
+ * once the current test finishes, so it must run inside a test, unless the
50
+ * options make the caller its owner; `stop` or disposal stops it sooner, and
51
+ * a second stop does nothing.
44
52
  */
45
53
  export function startStubLegacyDaemon(
46
54
  address: string | StubLegacyDaemonTCPAddress,
@@ -120,17 +128,14 @@ export function startStubLegacyDaemon(
120
128
  };
121
129
 
122
130
  const server = startListener(address, handlers);
131
+ const stop = options.owner === 'caller' ? server.stop : registerTestCleanup(server.stop);
123
132
 
124
133
  return {
125
134
  requests,
126
135
  connections,
127
136
  port: server.port,
128
- stop() {
129
- server.stop();
130
- },
131
- [Symbol.dispose]() {
132
- server.stop();
133
- },
137
+ stop,
138
+ [Symbol.dispose]: stop,
134
139
  };
135
140
  }
136
141
 
@@ -1,3 +1,5 @@
1
+ import { registerTestCleanup } from './register-test-cleanup';
2
+
1
3
  interface StubMCPRequest {
2
4
  readonly method: string;
3
5
  readonly path: string;
@@ -19,7 +21,8 @@ interface StubMCPServer {
19
21
  * A stand-in for an MCP HTTP server on a loopback port: it answers every
20
22
  * request with the JSON body given, and records each request's method,
21
23
  * path, authorization and content-type headers, and JSON body. `url` holds
22
- * its origin. Disposal stops it; hold the result with `using`.
24
+ * its origin. It stops once the current test finishes, so it must run
25
+ * inside a test; disposal stops it sooner, and a second stop does nothing.
23
26
  */
24
27
  export function startStubMCPServer(answer: unknown): StubMCPServer {
25
28
  const requests: StubMCPRequest[] = [];
@@ -40,11 +43,13 @@ export function startStubMCPServer(answer: unknown): StubMCPServer {
40
43
  },
41
44
  });
42
45
 
46
+ const stop = registerTestCleanup(() => server.stop(true));
47
+
43
48
  return {
44
49
  url: `http://127.0.0.1:${String(server.port)}`,
45
50
  requests,
46
51
  [Symbol.dispose]: () => {
47
- void server.stop(true);
52
+ void stop();
48
53
  },
49
54
  };
50
55
  }