@zgeoff/atc 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +6 -0
  2. package/package.json +4 -3
  3. package/src/build-own-daemon-process.ts +16 -0
  4. package/src/build-restart-failure.ts +24 -0
  5. package/src/cli.ts +96 -0
  6. package/src/client/boot-daemon.ts +5 -32
  7. package/src/collect-restart-env.ts +18 -0
  8. package/src/collect-restart-plan.ts +157 -0
  9. package/src/daemon/daemon.ts +7 -3
  10. package/src/daemon-process.ts +12 -0
  11. package/src/find-restart-unit.ts +45 -0
  12. package/src/format-restart-preflight.ts +74 -0
  13. package/src/format-restart-report.ts +45 -0
  14. package/src/is-daemon-command-line.ts +33 -0
  15. package/src/is-daemon-process.ts +33 -0
  16. package/src/parse-daemon-flags.ts +33 -0
  17. package/src/parse-mismatch-build.ts +19 -0
  18. package/src/parse-restart-result.ts +65 -0
  19. package/src/parse-systemd-unit.ts +25 -0
  20. package/src/read-daemon-process.ts +50 -0
  21. package/src/read-fleet-snapshot.ts +37 -0
  22. package/src/read-restart-result.ts +17 -0
  23. package/src/read-stored-fleet-file.ts +74 -0
  24. package/src/read-stored-rows.ts +36 -0
  25. package/src/remove-restart-result.ts +11 -0
  26. package/src/restart-daemon.ts +149 -0
  27. package/src/restart-plan.ts +33 -0
  28. package/src/run-daemon-restart-worker.ts +77 -0
  29. package/src/run-daemon-restart.ts +228 -0
  30. package/src/run-systemctl.ts +33 -0
  31. package/src/shared/build-atc-command.ts +13 -0
  32. package/src/shared/config.ts +5 -0
  33. package/src/shared/find-pid-file-pid.ts +15 -0
  34. package/src/shared/get-build.ts +4 -3
  35. package/src/shared/is-process-alive.ts +21 -0
  36. package/src/shared/spawn-atc-detached.ts +34 -0
  37. package/src/start-replacement-daemon.ts +31 -0
  38. package/src/stop-daemon-process.ts +46 -0
  39. package/src/store/state-store.ts +2 -2
  40. package/src/test-utils/can-bind-addresses.ts +19 -0
  41. package/src/test-utils/create-migrated-state-db.ts +38 -0
  42. package/src/test-utils/env-originals.ts +6 -0
  43. package/src/test-utils/fixture-dir-provider.ts +131 -0
  44. package/src/test-utils/fixture-imp-port.ts +1692 -0
  45. package/src/test-utils/get-agent-entry.ts +17 -0
  46. package/src/test-utils/get-gateway-config.ts +18 -0
  47. package/src/test-utils/isolate-home.ts +60 -0
  48. package/src/test-utils/read-json-record.ts +20 -0
  49. package/src/test-utils/remove-env-overrides.ts +18 -0
  50. package/src/test-utils/run-legacy-daemon.ts +38 -0
  51. package/src/test-utils/run-listener-daemon.ts +51 -0
  52. package/src/test-utils/run-mcp-authorization.ts +118 -0
  53. package/src/test-utils/run-source-daemon.ts +110 -0
  54. package/src/test-utils/setup-fake-systemd.ts +125 -0
  55. package/src/test-utils/setup-mcp-http.ts +126 -0
  56. package/src/test-utils/setup-temp-dir.ts +33 -0
  57. package/src/test-utils/spawn-named-session.ts +27 -0
  58. package/src/test-utils/start-cut-proxy.ts +175 -0
  59. package/src/test-utils/start-git-http-server.ts +102 -0
  60. package/src/test-utils/start-legacy-daemon.ts +111 -0
  61. package/src/test-utils/subscribe-to-socket-lines.ts +60 -0
  62. package/src/test-utils/update-env.ts +19 -0
  63. package/src/test-utils/wait-for.ts +38 -0
  64. package/src/verify-restored-fleet.ts +155 -0
  65. package/src/wait-for-replacement.ts +112 -0
  66. package/src/write-restart-result.ts +17 -0
  67. /package/src/{daemon → shared}/claim-daemon-lock.ts +0 -0
  68. /package/src/{daemon → shared}/load-listener-tokens.ts +0 -0
@@ -0,0 +1,126 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import type { AgentAdapter } from '../agents/agent-adapter';
4
+ import { DaemonClient } from '../client/daemon-client';
5
+ import { startDaemon } from '../daemon/daemon';
6
+ import type { DaemonHandle } from '../daemon/daemon';
7
+ import { openMCPAuth } from '../mcp/open-mcp-auth';
8
+ import { ReconnectingCaller } from '../mcp/reconnecting-caller';
9
+ import { startMCPHTTPServer } from '../mcp/start-mcp-http-server';
10
+ import { setupTempDir } from './setup-temp-dir';
11
+
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
+ interface MCPHTTPSetupOptions {
25
+ // How long a rotated refresh token still answers with its successor.
26
+ readonly refreshReuseSeconds?: number;
27
+
28
+ // Further Host header values the server accepts.
29
+ readonly allowedHosts?: readonly string[];
30
+ }
31
+
32
+ /**
33
+ * A real daemon in a temp directory and `atc mcp --http` in front of it on a
34
+ * free port, with every approval line and request line the server prints
35
+ * collected. The authorization server's database sits where atc keeps it
36
+ * under `home`, so a CLI run with that home opens the same file. `store` is
37
+ * the authorization server's database opened a second time the way
38
+ * `atc clients` and `atc grants` open it, and `addClient` adds a client
39
+ * through it and returns the client id. `restartDaemon` stops the daemon and
40
+ * 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`.
43
+ */
44
+ export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
45
+ const tmp = setupTempDir('atc-mcp-http-');
46
+ const socketPath = join(tmp.dir, 'daemon.sock');
47
+ const stateDir = join(tmp.dir, '.local', 'state', 'atc');
48
+
49
+ mkdirSync(stateDir, { recursive: true });
50
+
51
+ const dbPath = join(stateDir, 'mcp-auth.db');
52
+ const approvals: string[] = [];
53
+ const requests: string[] = [];
54
+
55
+ const startTestDaemon = (principals: ReadonlyMap<string, readonly string[]> | null = null) =>
56
+ startDaemon({
57
+ socketPath,
58
+ reporterSocketPath: join(tmp.dir, 'reporter.sock'),
59
+ build: 'atc/test-build',
60
+ adapter: SLEEP_ADAPTER,
61
+ dbPath: join(tmp.dir, 'state.db'),
62
+ statusPath: join(tmp.dir, 'status.json'),
63
+ principals,
64
+ });
65
+
66
+ let daemon: DaemonHandle = await startTestDaemon();
67
+
68
+ const caller = new ReconnectingCaller(socketPath, 'atc/test-build', (path) =>
69
+ DaemonClient.open(path),
70
+ );
71
+
72
+ const server = await startMCPHTTPServer({
73
+ caller,
74
+ build: 'atc/test-build',
75
+ host: '127.0.0.1',
76
+ port: 0,
77
+ publicURL: null,
78
+ allowedHosts: options.allowedHosts ?? [],
79
+ dbPath,
80
+ printApproval: (line) => {
81
+ approvals.push(line);
82
+ },
83
+ printRequest: (line) => {
84
+ requests.push(line);
85
+ },
86
+ ...(options.refreshReuseSeconds === undefined
87
+ ? {}
88
+ : { refreshReuseSeconds: options.refreshReuseSeconds }),
89
+ });
90
+
91
+ const store = await openMCPAuth({ dbPath, origin: null });
92
+
93
+ return {
94
+ home: tmp.dir,
95
+ url: server.url,
96
+ origin: server.origin,
97
+ dbPath,
98
+ approvals,
99
+ requests,
100
+ caller,
101
+ store,
102
+ async addClient(name: string, redirectURIs: readonly string[]): Promise<string> {
103
+ const created = await store.auth.api.createFixedClient({
104
+ body: { name, redirectURIs: [...redirectURIs] },
105
+ });
106
+
107
+ return created.clientID;
108
+ },
109
+ countDaemonClients() {
110
+ return daemon.countClients();
111
+ },
112
+ async restartDaemon(principals: ReadonlyMap<string, readonly string[]> | null = null) {
113
+ await daemon.stop();
114
+
115
+ daemon = await startTestDaemon(principals);
116
+ },
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
+ },
125
+ };
126
+ }
@@ -0,0 +1,33 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ interface TempDir {
6
+ readonly dir: string;
7
+ readonly [Symbol.dispose]: () => void;
8
+ readonly [Symbol.asyncDispose]: () => Promise<void>;
9
+ }
10
+
11
+ /**
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.
16
+ */
17
+ export function setupTempDir(prefix: string): TempDir {
18
+ const dir = mkdtempSync(join(tmpdir(), prefix));
19
+
20
+ const remove = () => {
21
+ rmSync(dir, { recursive: true, force: true });
22
+ };
23
+
24
+ return {
25
+ dir,
26
+ [Symbol.dispose]: remove,
27
+ [Symbol.asyncDispose]: () => {
28
+ remove();
29
+
30
+ return Promise.resolve();
31
+ },
32
+ };
33
+ }
@@ -0,0 +1,27 @@
1
+ import { isRecord } from '../shared/report';
2
+
3
+ type SendRequest = (
4
+ m: string,
5
+ p?: Readonly<Record<string, unknown>>,
6
+ ) => Promise<Readonly<Record<string, unknown>>>;
7
+
8
+ /**
9
+ * Sends one session.spawn request at 80x24 through the given request sender
10
+ * and returns the new session's id. Throws when the answer carries no
11
+ * session descriptor with a string id.
12
+ */
13
+ export async function spawnNamedSession(
14
+ send: SendRequest,
15
+ name: string,
16
+ cwd: string,
17
+ ): Promise<string> {
18
+ const ok = await send('session.spawn', { cwd, name, cols: 80, rows: 24 });
19
+
20
+ const spawned = ok['session'];
21
+
22
+ if (!isRecord(spawned) || typeof spawned['id'] !== 'string') {
23
+ throw new Error('no session in spawn answer');
24
+ }
25
+
26
+ return spawned['id'];
27
+ }
@@ -0,0 +1,175 @@
1
+ import type { Socket } from 'bun';
2
+
3
+ interface CutProxyOptions {
4
+ // The address the proxy forwards to.
5
+ readonly target: { readonly hostname: string; readonly port: number };
6
+
7
+ // The request method whose response the proxy loses, and how many times.
8
+ readonly method: string;
9
+ readonly cuts: number;
10
+
11
+ // `close` ends both sides where the response would pass; `hold`
12
+ // swallows it and every later byte from the target, leaving the
13
+ // connection open; `drop` ends both sides in place of forwarding the
14
+ // request, so the target never sees it.
15
+ readonly mode: 'close' | 'hold' | 'drop';
16
+ }
17
+
18
+ export interface CutProxy {
19
+ readonly port: number;
20
+
21
+ // How many requests of the method the proxy has seen.
22
+ readonly countRequests: () => number;
23
+ readonly stop: () => void;
24
+ }
25
+
26
+ // One proxied connection: the target side once it is connected, what the
27
+ // client sent before it was, the client's unfinished line, and whether
28
+ // the target's next answer is lost or every answer from now on is.
29
+ interface ProxyLink {
30
+ upstream: Socket | null;
31
+ readonly pending: Uint8Array[];
32
+ readonly decoder: TextDecoder;
33
+ lineBuffer: string;
34
+ cutting: boolean;
35
+ held: boolean;
36
+ }
37
+
38
+ /**
39
+ * A TCP proxy on a loopback port that forwards an NDJSON protocol
40
+ * connection byte for byte, except that for the first `cuts` requests of
41
+ * the method it loses the target's next answer after the request reached
42
+ * the target: the request runs, and the client never sees its response.
43
+ */
44
+ export function startCutProxy(options: CutProxyOptions): CutProxy {
45
+ let cutsLeft = options.cuts;
46
+ let requests = 0;
47
+
48
+ const links = new Set<Socket<ProxyLink>>();
49
+
50
+ const server = Bun.listen<ProxyLink>({
51
+ hostname: '127.0.0.1',
52
+ port: 0,
53
+ socket: {
54
+ open(client) {
55
+ client.data = {
56
+ upstream: null,
57
+ pending: [],
58
+ decoder: new TextDecoder(),
59
+ lineBuffer: '',
60
+ cutting: false,
61
+ held: false,
62
+ };
63
+
64
+ links.add(client);
65
+ void openUpstream(client, options);
66
+ },
67
+ data(client, buf) {
68
+ client.data.lineBuffer += client.data.decoder.decode(buf, { stream: true });
69
+
70
+ const lines = client.data.lineBuffer.split('\n');
71
+
72
+ client.data.lineBuffer = lines.pop() ?? '';
73
+
74
+ let dropped = false;
75
+
76
+ for (const line of lines) {
77
+ if (!line.includes(`"m":"${options.method}"`)) {
78
+ continue;
79
+ }
80
+
81
+ requests++;
82
+
83
+ if (cutsLeft > 0) {
84
+ cutsLeft--;
85
+ client.data.cutting = options.mode !== 'drop';
86
+ dropped = options.mode === 'drop';
87
+ }
88
+ }
89
+
90
+ // A dropped request ends the link with nothing of its chunk
91
+ // forwarded.
92
+ if (dropped) {
93
+ client.end();
94
+ client.data.upstream?.end();
95
+
96
+ return;
97
+ }
98
+
99
+ if (client.data.upstream === null) {
100
+ client.data.pending.push(new Uint8Array(buf));
101
+ } else {
102
+ client.data.upstream.write(buf);
103
+ }
104
+ },
105
+ close(client) {
106
+ links.delete(client);
107
+ client.data.upstream?.end();
108
+ },
109
+ error() {},
110
+ },
111
+ });
112
+
113
+ return {
114
+ port: server.port,
115
+ 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
+ },
124
+ };
125
+ }
126
+
127
+ // Connects the client's link to the target, forwarding what the client
128
+ // sent meanwhile, and ends the client when the target cannot be reached.
129
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- a socket is a live handle the proxy writes to
130
+ async function openUpstream(client: Socket<ProxyLink>, options: CutProxyOptions): Promise<void> {
131
+ try {
132
+ const upstream = await Bun.connect({
133
+ hostname: options.target.hostname,
134
+ port: options.target.port,
135
+ socket: {
136
+ data(_upstream, buf) {
137
+ if (client.data.held) {
138
+ return;
139
+ }
140
+
141
+ if (!client.data.cutting) {
142
+ client.write(buf);
143
+
144
+ return;
145
+ }
146
+
147
+ client.data.cutting = false;
148
+
149
+ if (options.mode === 'hold') {
150
+ client.data.held = true;
151
+
152
+ return;
153
+ }
154
+
155
+ client.end();
156
+ client.data.upstream?.end();
157
+ },
158
+ close() {
159
+ if (!client.data.held) {
160
+ client.end();
161
+ }
162
+ },
163
+ error() {},
164
+ },
165
+ });
166
+
167
+ client.data.upstream = upstream;
168
+
169
+ for (const chunk of client.data.pending.splice(0)) {
170
+ upstream.write(chunk);
171
+ }
172
+ } catch {
173
+ client.end();
174
+ }
175
+ }
@@ -0,0 +1,102 @@
1
+ interface GitHTTPServer {
2
+ // The base URL a repository under the root is served at, ending in `/`.
3
+ readonly url: string;
4
+
5
+ // Every Authorization header a request carried, in arrival order.
6
+ readonly authorizations: string[];
7
+ readonly stop: () => Promise<void>;
8
+ }
9
+
10
+ /**
11
+ * Serves the bare repositories under a directory over smart HTTP through
12
+ * `git http-backend` on a loopback port, and refuses every request that
13
+ * carries no basic auth, so a client only clones by authenticating. The
14
+ * backend runs with the environment it is given. With a delay, each
15
+ * authenticated request waits that long before it is served, which makes a
16
+ * clone through it slow.
17
+ */
18
+ export function startGitHTTPServer(
19
+ root: string,
20
+ env: Readonly<Record<string, string | undefined>>,
21
+ options: { readonly delayMs?: number } = {},
22
+ ): GitHTTPServer {
23
+ const authorizations: string[] = [];
24
+
25
+ const server = Bun.serve({
26
+ port: 0,
27
+ hostname: '127.0.0.1',
28
+ async fetch(request) {
29
+ const authorization = request.headers.get('authorization');
30
+
31
+ if (authorization === null) {
32
+ return new Response('auth required', {
33
+ status: 401,
34
+ headers: { 'www-authenticate': 'Basic realm="atc"' },
35
+ });
36
+ }
37
+
38
+ authorizations.push(authorization);
39
+
40
+ if (options.delayMs !== undefined) {
41
+ await Bun.sleep(options.delayMs);
42
+ }
43
+
44
+ const url = new URL(request.url);
45
+
46
+ const body = await request.arrayBuffer();
47
+
48
+ const backend = Bun.spawn(['git', 'http-backend'], {
49
+ env: {
50
+ ...env,
51
+ GIT_PROJECT_ROOT: root,
52
+ GIT_HTTP_EXPORT_ALL: '1',
53
+ REQUEST_METHOD: request.method,
54
+ PATH_INFO: url.pathname,
55
+ QUERY_STRING: url.search.slice(1),
56
+ CONTENT_TYPE: request.headers.get('content-type') ?? '',
57
+ HTTP_CONTENT_ENCODING: request.headers.get('content-encoding') ?? '',
58
+ HTTP_GIT_PROTOCOL: request.headers.get('git-protocol') ?? '',
59
+ REMOTE_USER: 'atc',
60
+ REMOTE_ADDR: '127.0.0.1',
61
+ },
62
+ stdin: new Uint8Array(body),
63
+ stdout: 'pipe',
64
+ stderr: 'ignore',
65
+ });
66
+
67
+ const raw = await new Response(backend.stdout).arrayBuffer();
68
+
69
+ return toCGIResponse(Buffer.from(raw));
70
+ },
71
+ });
72
+
73
+ return {
74
+ url: `http://127.0.0.1:${server.port}/`,
75
+ authorizations,
76
+ stop: () => server.stop(true),
77
+ };
78
+ }
79
+
80
+ // A CGI answer is a header block, a blank line, and the body; a `Status`
81
+ // header sets the response status.
82
+ function toCGIResponse(output: Buffer): Response {
83
+ const split = output.indexOf('\r\n\r\n');
84
+
85
+ const headers = new Headers();
86
+
87
+ let status = 200;
88
+
89
+ for (const line of output.subarray(0, split).toString('latin1').split('\r\n')) {
90
+ const colon = line.indexOf(':');
91
+ const name = line.slice(0, colon).trim();
92
+ const value = line.slice(colon + 1).trim();
93
+
94
+ if (name.toLowerCase() === 'status') {
95
+ status = Number(value.split(' ')[0]);
96
+ } else {
97
+ headers.append(name, value);
98
+ }
99
+ }
100
+
101
+ return new Response(output.subarray(split + 4), { status, headers });
102
+ }
@@ -0,0 +1,111 @@
1
+ import type { DaemonFeature } from '../protocol/daemon-features';
2
+ import {
3
+ MAX_CHUNK,
4
+ MAX_LINE,
5
+ PROTOCOL_V,
6
+ decodeMessage,
7
+ encodeMessage,
8
+ } from '../protocol/protocol';
9
+
10
+ interface ReceivedRequest {
11
+ readonly m: string;
12
+ readonly p: Readonly<Record<string, unknown>> | undefined;
13
+ }
14
+
15
+ interface LegacyDaemonOptions {
16
+ readonly features?: readonly DaemonFeature[];
17
+
18
+ // The protocol version the daemon speaks; a hello on any other version is
19
+ // refused with `protocol_mismatch`, as a real daemon refuses it.
20
+ readonly protocol?: number;
21
+
22
+ readonly replies?: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
23
+ }
24
+
25
+ /**
26
+ * An older atc daemon listening on a unix socket and speaking the current
27
+ * protocol version, or the one `protocol` holds. Its `daemon.hello` announces `features` when the options
28
+ * hold that list, and announces none without it, as a daemon from before
29
+ * features did. It answers `daemon.hello`, `daemon.ping`, and each method in
30
+ * `replies` with that method's reply, and refuses every other method with
31
+ * `unknown_method`. Every request it receives is recorded in `requests`. Stop
32
+ * it with `stop`.
33
+ */
34
+ export function startLegacyDaemon(socketPath: string, options: LegacyDaemonOptions = {}) {
35
+ const answers: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
36
+ 'daemon.hello': {
37
+ daemon: 'atc/legacy-build',
38
+ limits: { maxLine: MAX_LINE, maxChunk: MAX_CHUNK },
39
+ lastUsedAgent: 'claude',
40
+ ...(options.features === undefined ? {} : { features: options.features }),
41
+ },
42
+ 'daemon.ping': {},
43
+ ...options.replies,
44
+ };
45
+
46
+ const protocol = options.protocol ?? PROTOCOL_V;
47
+ const requests: ReceivedRequest[] = [];
48
+
49
+ const server = Bun.listen<{ buffer: string }>({
50
+ unix: socketPath,
51
+ socket: {
52
+ open(socket) {
53
+ socket.data = { buffer: '' };
54
+ },
55
+ data(socket, chunk) {
56
+ const lines = `${socket.data.buffer}${chunk.toString()}`.split('\n');
57
+
58
+ socket.data.buffer = lines.pop() ?? '';
59
+
60
+ for (const line of lines) {
61
+ const decoded = decodeMessage(line);
62
+
63
+ if (decoded.kind !== 'request') {
64
+ continue;
65
+ }
66
+
67
+ const req = decoded.msg;
68
+
69
+ requests.push({ m: req.m, p: req.p });
70
+
71
+ if (req.m === 'daemon.hello' && req.v !== protocol) {
72
+ const client = req.p?.['client'];
73
+
74
+ socket.write(
75
+ encodeMessage({
76
+ v: protocol,
77
+ id: req.id,
78
+ err: {
79
+ code: 'protocol_mismatch',
80
+ msg: `${String(client)} speaks protocol v${req.v}, daemon atc/legacy-build speaks v${protocol}; restart the daemon so both run the same build`,
81
+ },
82
+ }),
83
+ );
84
+
85
+ continue;
86
+ }
87
+
88
+ const answer = answers[req.m];
89
+
90
+ const reply =
91
+ answer === undefined
92
+ ? {
93
+ v: protocol,
94
+ id: req.id,
95
+ err: { code: 'unknown_method' as const, msg: `unknown method '${req.m}'` },
96
+ }
97
+ : { v: protocol, id: req.id, ok: answer };
98
+
99
+ socket.write(encodeMessage(reply));
100
+ }
101
+ },
102
+ },
103
+ });
104
+
105
+ return {
106
+ requests,
107
+ stop() {
108
+ server.stop(true);
109
+ },
110
+ };
111
+ }
@@ -0,0 +1,60 @@
1
+ import { waitFor } from './wait-for';
2
+
3
+ interface SocketLines {
4
+ readonly lines: string[];
5
+ readonly write: (data: string) => void;
6
+ readonly waitForLine: (count?: number, timeoutMs?: number) => Promise<string[]>;
7
+ readonly [Symbol.asyncDispose]: () => Promise<void>;
8
+ }
9
+
10
+ /**
11
+ * 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.
15
+ */
16
+ export async function subscribeToSocketLines(path: string): Promise<SocketLines> {
17
+ const lines: string[] = [];
18
+ let buffer = '';
19
+
20
+ const socket = await Bun.connect({
21
+ unix: path,
22
+ socket: {
23
+ data(_s, buf) {
24
+ buffer += buf.toString();
25
+
26
+ const parts = buffer.split('\n');
27
+
28
+ buffer = parts.pop() ?? '';
29
+
30
+ lines.push(...parts.filter((part) => part.trim() !== ''));
31
+ },
32
+ close() {},
33
+ error() {},
34
+ },
35
+ });
36
+
37
+ return {
38
+ lines,
39
+ write(data: string) {
40
+ socket.write(data);
41
+ },
42
+ waitForLine(count = 1, timeoutMs = 5000) {
43
+ return waitFor(
44
+ () => {
45
+ if (lines.length < count) {
46
+ throw new Error(`timed out waiting for ${count} lines; got ${JSON.stringify(lines)}`);
47
+ }
48
+
49
+ return lines;
50
+ },
51
+ { timeoutMs },
52
+ );
53
+ },
54
+ [Symbol.asyncDispose]: () => {
55
+ socket.end();
56
+
57
+ return Promise.resolve();
58
+ },
59
+ };
60
+ }
@@ -0,0 +1,19 @@
1
+ import { envOriginals } from './env-originals';
2
+
3
+ /**
4
+ * Overrides an environment variable for the rest of the running test, or
5
+ * unsets it when the value is `undefined`. The value it held before the
6
+ * test's first override of it is recorded once, and the preload puts it back
7
+ * after the test.
8
+ */
9
+ export function updateEnv(key: string, value: string | undefined): void {
10
+ if (!envOriginals.has(key)) {
11
+ envOriginals.set(key, process.env[key]);
12
+ }
13
+
14
+ if (value === undefined) {
15
+ delete process.env[key];
16
+ } else {
17
+ process.env[key] = value;
18
+ }
19
+ }
@@ -0,0 +1,38 @@
1
+ interface WaitForOptions {
2
+ /**
3
+ * Milliseconds between retries.
4
+ */
5
+ readonly intervalMs?: number;
6
+
7
+ /**
8
+ * Milliseconds before the wait gives up.
9
+ */
10
+ readonly timeoutMs?: number;
11
+ }
12
+
13
+ /**
14
+ * Retries the attempt until it stops throwing or rejecting, resolving with
15
+ * its value — so assertion-style checks read exactly as they would in a
16
+ * straight-line test body. Once the deadline passes, the attempt's final
17
+ * failure is rethrown as the wait's rejection.
18
+ */
19
+ export async function waitFor<T>(
20
+ attempt: () => Promise<T> | T,
21
+ options: Readonly<WaitForOptions> = {},
22
+ ): Promise<T> {
23
+ const intervalMs = options.intervalMs ?? 20;
24
+ const timeoutMs = options.timeoutMs ?? 5000;
25
+ const deadline = Date.now() + timeoutMs;
26
+
27
+ for (;;) {
28
+ try {
29
+ return await attempt();
30
+ } catch (error) {
31
+ if (Date.now() >= deadline) {
32
+ throw error;
33
+ }
34
+ }
35
+
36
+ await Bun.sleep(intervalMs);
37
+ }
38
+ }