@zgeoff/atc 3.3.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/agents/agent-adapter.ts +10 -0
- package/src/agents/build-cli-argv.ts +2 -4
- package/src/agents/claude-adapter.ts +13 -3
- package/src/agents/codex-adapter.ts +11 -2
- package/src/agents/gateway-adapter.ts +20 -8
- package/src/agents/grok-adapter.ts +16 -13
- package/src/agents/load-claude-config-bundle.ts +6 -6
- package/src/agents/print-codex-hook-file.ts +7 -3
- package/src/agents/print-grok-hook-file.ts +8 -3
- package/src/agents/resolve-agent-home.ts +7 -3
- package/src/agents/resolve-claude-global-config-path.ts +4 -4
- package/src/agents/update-claude-project-trust.ts +41 -30
- package/src/agents/with-claude-config-lock.ts +13 -1
- package/src/agents/write-atc-bridge.ts +10 -7
- package/src/agents/write-hook-settings.ts +13 -7
- package/src/client/boot-daemon.ts +48 -15
- package/src/client/dirs.ts +4 -2
- package/src/client/index.ts +17 -1
- package/src/client/log-client-event.ts +20 -0
- package/src/client/spawn-picker.ts +200 -121
- package/src/client/ui.ts +7 -7
- package/src/clients.ts +41 -11
- package/src/daemon/daemon.ts +143 -33
- package/src/daemon/event-signal.ts +19 -3
- package/src/daemon/make-hook-runner.ts +82 -22
- package/src/daemon/permission-registry.ts +11 -6
- package/src/daemon/restore-fleet.ts +62 -17
- package/src/daemon/run-eject-handoff.ts +20 -2
- package/src/daemon/sessions.ts +6 -0
- package/src/daemon/start-tcp-listener.ts +12 -10
- package/src/federation/daemon-caller.ts +25 -4
- package/src/federation/daemon-pool.ts +3 -1
- package/src/federation/open-gateway-caller.ts +6 -1
- package/src/grants.ts +36 -11
- package/src/hook-report.ts +17 -3
- package/src/mcp/read-report-texts.ts +11 -7
- package/src/mcp/start-mcp-http-server.ts +5 -1
- package/src/mcp-http-server.ts +85 -32
- package/src/report.ts +21 -3
- package/src/run-bridge-tap.ts +36 -7
- package/src/run-config-migrate.ts +35 -11
- package/src/run-gateway.ts +4 -2
- package/src/shared/build-atc-command.ts +7 -3
- package/src/shared/bun-sqlite-driver.ts +10 -0
- package/src/shared/claim-daemon-lock.ts +10 -4
- package/src/shared/collect-dir-roots.ts +6 -6
- package/src/shared/collect-hooks.ts +7 -6
- package/src/shared/config.ts +19 -9
- package/src/shared/system-clock.ts +24 -0
- package/src/sources/github/collect-github-repos.ts +1 -1
- package/src/sources/github/read-git-protocol.ts +1 -1
- package/src/sources/github/run-gh.ts +15 -9
- package/src/store/state-store.ts +57 -7
- package/src/tap.ts +44 -13
- package/src/test-utils/build-mock-agent-adapter.ts +25 -0
- package/src/test-utils/build-mock-agent-entry.ts +32 -0
- package/src/test-utils/build-mock-auth-binding.ts +28 -0
- package/src/test-utils/build-mock-auth-profile.ts +62 -0
- package/src/test-utils/build-mock-daemon-record.ts +26 -0
- package/src/test-utils/build-mock-fleet-entry.ts +33 -0
- package/src/test-utils/build-mock-gateway-config.ts +32 -0
- package/src/test-utils/build-mock-imp-identity.ts +18 -0
- package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
- package/src/test-utils/build-mock-message-record.ts +22 -0
- package/src/test-utils/build-mock-mirror-session.ts +42 -0
- package/src/test-utils/build-mock-registry-daemon.ts +28 -0
- package/src/test-utils/build-mock-session-descriptor.ts +66 -0
- package/src/test-utils/build-mock-session.ts +43 -0
- package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
- package/src/test-utils/build-stub-attention-adapter.ts +22 -0
- package/src/test-utils/build-stub-bridge-context.ts +20 -0
- package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
- package/src/test-utils/build-stub-channel-opener.ts +45 -0
- package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
- package/src/test-utils/build-stub-client-screens.ts +33 -0
- package/src/test-utils/build-stub-clock.ts +67 -0
- package/src/test-utils/build-stub-daemon-context.ts +81 -0
- package/src/test-utils/build-stub-daemon-requests.ts +103 -0
- package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
- package/src/test-utils/build-stub-execution-provider.ts +112 -0
- package/src/test-utils/build-stub-fleet-caller.ts +60 -0
- package/src/test-utils/build-stub-forking-gh.ts +10 -0
- package/src/test-utils/build-stub-gh.ts +50 -0
- package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
- package/src/test-utils/build-stub-harness-relay.ts +50 -0
- package/src/test-utils/build-stub-headless-runner.ts +60 -0
- package/src/test-utils/build-stub-held-gh.ts +19 -0
- package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
- package/src/test-utils/build-stub-held-provider.ts +58 -0
- package/src/test-utils/build-stub-held-zoxide.ts +13 -0
- package/src/test-utils/build-stub-host-hold.ts +31 -0
- package/src/test-utils/build-stub-host-provider.ts +26 -0
- package/src/test-utils/{fixture-imp-port.ts → build-stub-imp-port.ts} +119 -63
- package/src/test-utils/build-stub-log.ts +15 -0
- package/src/test-utils/build-stub-mcp-claude.ts +26 -0
- package/src/test-utils/build-stub-mcp-grok.ts +19 -0
- package/src/test-utils/build-stub-peer-socket.ts +81 -0
- package/src/test-utils/build-stub-picker-host.ts +36 -0
- package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
- package/src/test-utils/build-stub-pty-provider.ts +53 -0
- package/src/test-utils/build-stub-recording-atc.ts +10 -0
- package/src/test-utils/build-stub-signed-in-gh.ts +18 -0
- package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
- package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
- package/src/test-utils/build-stub-targets.ts +58 -0
- package/src/test-utils/build-stub-terminal.ts +19 -0
- package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
- package/src/test-utils/build-stub-tui-claude.ts +47 -0
- package/src/test-utils/build-stub-tui-grok.ts +53 -0
- package/src/test-utils/build-stub-wait-clock.ts +23 -0
- package/src/test-utils/build-target-options-from-config.ts +37 -0
- package/src/test-utils/collect-process-tree.ts +75 -0
- package/src/test-utils/create-git-fixture.ts +120 -0
- package/src/test-utils/create-migrated-state-db.ts +12 -10
- package/src/test-utils/create-stub-bin.ts +19 -0
- package/src/test-utils/create-stub-claude.ts +75 -0
- package/src/test-utils/create-stub-codex.ts +31 -0
- package/src/test-utils/create-stub-composer.ts +65 -0
- package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
- package/src/test-utils/create-stub-grok.ts +44 -0
- package/src/test-utils/create-stub-guest-clis.ts +55 -0
- package/src/test-utils/create-stub-harness-guest.ts +35 -0
- package/src/test-utils/create-stub-recording-claude.ts +20 -0
- package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +5 -5
- package/src/test-utils/get-gateway-config.ts +3 -3
- package/src/test-utils/get-only-effect-ref.ts +21 -0
- package/src/test-utils/get-only-imp-name.ts +19 -0
- package/src/test-utils/get-records.ts +20 -0
- package/src/test-utils/get-string.ts +14 -0
- package/src/test-utils/keys.ts +26 -0
- package/src/test-utils/merge-deep.ts +36 -0
- package/src/test-utils/mock-overrides.ts +13 -0
- package/src/test-utils/open-repo-step.ts +24 -0
- package/src/test-utils/read-query-plan.ts +22 -0
- package/src/test-utils/resolve-atc-command.ts +13 -0
- package/src/test-utils/resolve-gateway-command.ts +13 -0
- package/src/test-utils/run-atc.ts +56 -0
- package/src/test-utils/run-legacy-daemon.ts +27 -5
- package/src/test-utils/run-listener-daemon.ts +5 -10
- package/src/test-utils/run-source-daemon.ts +2 -2
- package/src/test-utils/seed-faker.ts +7 -0
- package/src/test-utils/send-lines-before-handshake.ts +35 -0
- package/src/test-utils/send-mcp-request.ts +25 -0
- package/src/test-utils/setup-mcp-home.ts +99 -0
- package/src/test-utils/setup-mcp-http.ts +19 -31
- package/src/test-utils/spawn-claude-session.ts +34 -0
- package/src/test-utils/spawn-grok-session.ts +39 -0
- package/src/test-utils/start-cut-proxy.ts +25 -8
- package/src/test-utils/start-daemon-process.ts +192 -0
- package/src/test-utils/start-git-http-server.ts +21 -3
- package/src/test-utils/start-mcp-stdio.ts +195 -0
- package/src/test-utils/start-output-capture.ts +20 -0
- package/src/test-utils/start-stub-dropping-daemon.ts +103 -0
- package/src/test-utils/start-stub-impd-info.ts +32 -0
- package/src/test-utils/start-stub-impd.ts +138 -0
- package/src/test-utils/start-stub-legacy-daemon.ts +166 -0
- package/src/test-utils/start-stub-mcp-server.ts +50 -0
- package/src/test-utils/start-stub-refusing-listener.ts +38 -0
- package/src/test-utils/start-stub-reporter-socket.ts +77 -0
- package/src/test-utils/start-stub-restore-daemon.ts +58 -0
- package/src/test-utils/start-stub-session-bridge.ts +98 -0
- package/src/test-utils/start-stub-silent-server.ts +33 -0
- package/src/test-utils/start-stub-source-daemon.ts +39 -0
- package/src/test-utils/start-stub-stalled-listener.ts +40 -0
- package/src/test-utils/start-stub-tap.ts +38 -0
- package/src/test-utils/start-stub-tcp-daemon.ts +70 -0
- package/src/test-utils/start-stub-telemetry-collector.ts +26 -0
- package/src/test-utils/start-test-daemon.ts +215 -0
- package/src/test-utils/start-tui-harness.ts +284 -0
- package/src/test-utils/subscribe-to-socket-lines.ts +59 -12
- package/src/test-utils/try-send-request.ts +29 -0
- package/src/test-utils/wait-for-event.ts +41 -0
- package/src/test-utils/wait-for.ts +15 -3
- package/src/verify-restored-fleet.ts +62 -13
- package/src/workspace/check-repository-access.ts +4 -0
- package/src/workspace/collect-remote-refs.ts +4 -2
- package/src/workspace/run-git.ts +6 -0
- 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. `
|
|
13
|
-
*
|
|
14
|
-
* when `
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
46
|
-
|
|
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
|
|
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 (
|
|
43
|
+
if (now() >= deadline) {
|
|
32
44
|
throw error;
|
|
33
45
|
}
|
|
34
46
|
}
|
|
35
47
|
|
|
36
|
-
await
|
|
48
|
+
await wait(intervalMs);
|
|
37
49
|
}
|
|
38
50
|
}
|
|
@@ -3,6 +3,8 @@ import type { RestartFailedRow } from './parse-restart-result';
|
|
|
3
3
|
import type { StoredRow } from './read-stored-rows';
|
|
4
4
|
import { readStoredRows } from './read-stored-rows';
|
|
5
5
|
import { isRecord } from './shared/report';
|
|
6
|
+
import { systemClock } from './shared/system-clock';
|
|
7
|
+
import type { Clock } from './shared/system-clock';
|
|
6
8
|
|
|
7
9
|
interface FleetVerdict {
|
|
8
10
|
// The rows the daemon stored, exited ones included.
|
|
@@ -28,14 +30,18 @@ const UNSIZED_READ_MS = 30_000;
|
|
|
28
30
|
* passes (`timeoutSeconds` when set, else the rows that are not exited times the restore boot cap, plus 30 s), and an exited row must be listed. A stored row that is not listed
|
|
29
31
|
* failed to restore, and a listed row that is still without a live terminal
|
|
30
32
|
* at the deadline failed to revive. The deadline counts from the call, and
|
|
31
|
-
* every request to the daemon is held to it
|
|
33
|
+
* every request to the daemon is held to it: the daemon must answer the first
|
|
34
|
+
* list before it, and when it overtakes a later list, the verdict comes from
|
|
35
|
+
* the last list the daemon answered. The deadline, the waits between lists,
|
|
36
|
+
* and every request's time limit run on `clock`, the wall clock unless given.
|
|
32
37
|
*/
|
|
33
38
|
export async function verifyRestoredFleet(
|
|
34
39
|
client: Pick<DaemonClient, 'sendRequest'>,
|
|
35
40
|
timeoutSeconds: number | null,
|
|
36
41
|
snapshot: readonly StoredRow[] | null,
|
|
42
|
+
clock: Clock = systemClock,
|
|
37
43
|
): Promise<FleetVerdict> {
|
|
38
|
-
const startedAt =
|
|
44
|
+
const startedAt = clock.now();
|
|
39
45
|
|
|
40
46
|
// Without a snapshot the rows come from the new daemon, and its count is
|
|
41
47
|
// not known until they are read, so that read gets the fixed allowance.
|
|
@@ -44,19 +50,28 @@ export async function verifyRestoredFleet(
|
|
|
44
50
|
? await sendBounded(
|
|
45
51
|
() => readStoredRows(client),
|
|
46
52
|
startedAt + (timeoutSeconds === null ? UNSIZED_READ_MS : timeoutSeconds * 1000),
|
|
53
|
+
clock,
|
|
47
54
|
)
|
|
48
55
|
: null;
|
|
49
56
|
|
|
50
57
|
const stored = snapshot ?? read ?? [];
|
|
51
58
|
const deadline = startedAt + pickDeadlineMs(stored, timeoutSeconds);
|
|
52
59
|
|
|
53
|
-
const restored = await tryRestore(client, deadline);
|
|
54
|
-
let found = await sendBounded(() => collectFailedRows(client, stored, restored), deadline);
|
|
60
|
+
const restored = await tryRestore(client, deadline, clock);
|
|
61
|
+
let found = await sendBounded(() => collectFailedRows(client, stored, restored), deadline, clock);
|
|
55
62
|
|
|
56
|
-
while (found.pending &&
|
|
57
|
-
await
|
|
63
|
+
while (found.pending && clock.now() < deadline) {
|
|
64
|
+
await new Promise<void>((resolve) => {
|
|
65
|
+
clock.schedule(resolve, Math.min(250, deadline - clock.now()));
|
|
66
|
+
});
|
|
58
67
|
|
|
59
|
-
|
|
68
|
+
const polled = await tryCollectFailedRows(client, stored, restored, deadline, clock);
|
|
69
|
+
|
|
70
|
+
if (polled === null) {
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
found = polled;
|
|
60
75
|
}
|
|
61
76
|
|
|
62
77
|
return { total: stored.length, failed: found.failed };
|
|
@@ -66,22 +81,22 @@ export async function verifyRestoredFleet(
|
|
|
66
81
|
* Runs a request and rejects once the deadline passes, so a daemon that
|
|
67
82
|
* stops answering cannot hold the restart, and its lock, open.
|
|
68
83
|
*/
|
|
69
|
-
async function sendBounded<T>(send: () => Promise<T>, deadline: number): Promise<T> {
|
|
70
|
-
let
|
|
84
|
+
async function sendBounded<T>(send: () => Promise<T>, deadline: number, clock: Clock): Promise<T> {
|
|
85
|
+
let stopTimer: (() => void) | undefined;
|
|
71
86
|
|
|
72
87
|
const expired = new Promise<never>((_resolve, reject) => {
|
|
73
|
-
|
|
88
|
+
stopTimer = clock.schedule(
|
|
74
89
|
() => {
|
|
75
90
|
reject(new Error('the new daemon stopped answering before the restore deadline'));
|
|
76
91
|
},
|
|
77
|
-
Math.max(0, deadline -
|
|
92
|
+
Math.max(0, deadline - clock.now()),
|
|
78
93
|
);
|
|
79
94
|
});
|
|
80
95
|
|
|
81
96
|
try {
|
|
82
97
|
return await Promise.race([send(), expired]);
|
|
83
98
|
} finally {
|
|
84
|
-
|
|
99
|
+
stopTimer?.();
|
|
85
100
|
}
|
|
86
101
|
}
|
|
87
102
|
|
|
@@ -99,9 +114,14 @@ function pickDeadlineMs(stored: readonly StoredRow[], timeoutSeconds: number | n
|
|
|
99
114
|
async function tryRestore(
|
|
100
115
|
client: Pick<DaemonClient, 'sendRequest'>,
|
|
101
116
|
deadline: number,
|
|
117
|
+
clock: Clock,
|
|
102
118
|
): Promise<boolean> {
|
|
103
119
|
try {
|
|
104
|
-
await sendBounded(
|
|
120
|
+
await sendBounded(
|
|
121
|
+
() => client.sendRequest('fleet.restore', { cols: 80, rows: 24 }),
|
|
122
|
+
deadline,
|
|
123
|
+
clock,
|
|
124
|
+
);
|
|
105
125
|
|
|
106
126
|
return true;
|
|
107
127
|
} catch {
|
|
@@ -109,6 +129,35 @@ async function tryRestore(
|
|
|
109
129
|
}
|
|
110
130
|
}
|
|
111
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Lists the rows again, or returns null when the deadline passes before the
|
|
134
|
+
* daemon answers, so the caller keeps the verdict of the last list it got.
|
|
135
|
+
*/
|
|
136
|
+
async function tryCollectFailedRows(
|
|
137
|
+
client: Pick<DaemonClient, 'sendRequest'>,
|
|
138
|
+
stored: readonly StoredRow[],
|
|
139
|
+
restored: boolean,
|
|
140
|
+
deadline: number,
|
|
141
|
+
clock: Clock,
|
|
142
|
+
): Promise<FailedRows | null> {
|
|
143
|
+
let stopTimer: (() => void) | undefined;
|
|
144
|
+
|
|
145
|
+
const expired = new Promise<null>((resolve) => {
|
|
146
|
+
stopTimer = clock.schedule(
|
|
147
|
+
() => {
|
|
148
|
+
resolve(null);
|
|
149
|
+
},
|
|
150
|
+
Math.max(0, deadline - clock.now()),
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
return await Promise.race([collectFailedRows(client, stored, restored), expired]);
|
|
156
|
+
} finally {
|
|
157
|
+
stopTimer?.();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
112
161
|
async function collectFailedRows(
|
|
113
162
|
client: Pick<DaemonClient, 'sendRequest'>,
|
|
114
163
|
stored: readonly StoredRow[],
|
|
@@ -21,6 +21,9 @@ interface AccessRequest {
|
|
|
21
21
|
|
|
22
22
|
// The transports the URL may use and git may fetch over.
|
|
23
23
|
readonly transports: readonly string[];
|
|
24
|
+
|
|
25
|
+
// Called with the pid of the listing's git as it starts.
|
|
26
|
+
readonly onSpawn?: ((pid: number) => void) | undefined;
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
interface RepositoryAccess {
|
|
@@ -77,6 +80,7 @@ export async function checkRepositoryAccess(
|
|
|
77
80
|
request.credential,
|
|
78
81
|
request.transports,
|
|
79
82
|
request.timeoutMs,
|
|
83
|
+
request.onSpawn,
|
|
80
84
|
);
|
|
81
85
|
|
|
82
86
|
if (!listing.ok) {
|
|
@@ -41,13 +41,15 @@ const REMOTE_TIMEOUT_MS = 20_000;
|
|
|
41
41
|
* askpass helper for an env credential. git never prompts. A listing git
|
|
42
42
|
* cannot read is refused as `clone_failed` with git's own message, and so
|
|
43
43
|
* is one that takes longer than the time limit, 20 s unless given. git
|
|
44
|
-
* fetches only over `transports`.
|
|
44
|
+
* fetches only over `transports`. `onSpawn` is called with the pid of the
|
|
45
|
+
* listing's git as it starts.
|
|
45
46
|
*/
|
|
46
47
|
export async function collectRemoteRefs(
|
|
47
48
|
url: string,
|
|
48
49
|
credential: GitCredential | undefined,
|
|
49
50
|
transports: readonly string[],
|
|
50
51
|
timeoutMs: number = REMOTE_TIMEOUT_MS,
|
|
52
|
+
onSpawn?: (pid: number) => void,
|
|
51
53
|
): Promise<RemoteRefListing | RemoteRefRefusal> {
|
|
52
54
|
const askpass = await createGitAskpass(credential);
|
|
53
55
|
|
|
@@ -72,7 +74,7 @@ export async function collectRemoteRefs(
|
|
|
72
74
|
'refs/heads/*',
|
|
73
75
|
'refs/tags/*',
|
|
74
76
|
],
|
|
75
|
-
{ env: { ...askpass.env, ...sshEnv }, timeoutMs, transports },
|
|
77
|
+
{ env: { ...askpass.env, ...sshEnv }, timeoutMs, transports, onSpawn },
|
|
76
78
|
);
|
|
77
79
|
} finally {
|
|
78
80
|
await askpass[Symbol.asyncDispose]();
|