@zgeoff/atc 2.23.0 → 2.25.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 +2 -1
- package/src/agents/agent-adapter.ts +27 -2
- package/src/agents/gateway-adapter.ts +21 -3
- package/src/cli.ts +20 -8
- package/src/client/collect-target-picks.ts +5 -0
- package/src/client/spawn-picker.ts +15 -1
- package/src/clients.ts +19 -12
- package/src/daemon/broker-auth-host.ts +25 -0
- package/src/daemon/broker-authority-error.ts +2 -1
- package/src/daemon/build-agent-list.ts +16 -3
- package/src/daemon/build-scoped-context.ts +2 -0
- package/src/daemon/build-target-list.ts +5 -0
- package/src/daemon/daemon-connection.ts +52 -2
- package/src/daemon/daemon-context.ts +12 -0
- package/src/daemon/daemon.ts +58 -1
- package/src/daemon/execution-provider.ts +50 -2
- package/src/daemon/imp-client-port.ts +71 -23
- package/src/daemon/imp-harness.ts +299 -18
- package/src/daemon/imp-port.ts +23 -1
- package/src/daemon/imp-provider.ts +161 -40
- package/src/daemon/local-pty-provider.ts +11 -0
- package/src/daemon/restore-fleet.ts +15 -1
- package/src/daemon/runtime-auth-binder.ts +1086 -0
- package/src/daemon/sessions.ts +565 -25
- package/src/daemon/verify-broker-authority.ts +10 -5
- package/src/gateway.ts +183 -0
- package/src/mcp/mcp-tools.ts +2 -0
- package/src/mcp/require-daemon-features.ts +1 -0
- package/src/parse-gateway-state-dir.ts +94 -0
- package/src/protocol/daemon-features.ts +4 -0
- package/src/protocol/protocol.ts +16 -0
- package/src/protocol/request-param-schemas.ts +5 -0
- package/src/run-gateway.ts +93 -0
- package/src/shared/collect-gateways.ts +1 -22
- package/src/shared/is-broker-variable.ts +24 -0
- package/src/store/state-store.ts +11 -0
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { DaemonError } from '../protocol/daemon-error';
|
|
2
|
+
import type { BrokerAuthHost } from './broker-auth-host';
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* The host a session's harness runs on: it starts a process in a
|
|
3
6
|
* pseudo-terminal and, as its capabilities declare, unpacks files into its
|
|
@@ -22,6 +25,11 @@ export interface ExecutionProvider {
|
|
|
22
25
|
// host has none. Absent on the daemon's own machine.
|
|
23
26
|
readonly guest?: GuestLayout;
|
|
24
27
|
|
|
28
|
+
// How a harness here can take its credential from impd's broker instead
|
|
29
|
+
// of holding it. Absent on a host with no broker, which never starts a
|
|
30
|
+
// session that needs one.
|
|
31
|
+
readonly brokerAuth?: BrokerAuthHost;
|
|
32
|
+
|
|
25
33
|
// Readies the host a harness is about to start on: a remote host is
|
|
26
34
|
// created when missing, woken when asleep, and held awake while its
|
|
27
35
|
// harnesses run. Rejects with the refusal before any harness starts.
|
|
@@ -41,8 +49,12 @@ export interface ExecutionProvider {
|
|
|
41
49
|
|
|
42
50
|
// Puts a host to sleep with every harness on it kept inside, so a revive
|
|
43
51
|
// finds each one as it was. Rejects with `host_leased` when another owner
|
|
44
|
-
// keeps the host awake, and leaves the host as it was then.
|
|
45
|
-
|
|
52
|
+
// keeps the host awake, and leaves the host as it was then. A sleep runs
|
|
53
|
+
// after any readying of the host before it, and a readying waits for it.
|
|
54
|
+
// isIdle makes it a sleep of an idle host only: it is checked when the
|
|
55
|
+
// sleep starts and again just before the host sleeps, and a host that a
|
|
56
|
+
// harness or a readying keeps busy stays awake with `host_unavailable`.
|
|
57
|
+
readonly suspendHost: (host: string, isIdle?: () => boolean) => Promise<void>;
|
|
46
58
|
|
|
47
59
|
// Deletes a host and everything on it, harnesses included. Nothing brings
|
|
48
60
|
// a destroyed host back.
|
|
@@ -65,6 +77,10 @@ export interface HostRequest {
|
|
|
65
77
|
// Whether the harness about to start needs atc inside the host, which a
|
|
66
78
|
// provider that ships its own binary installs when missing.
|
|
67
79
|
readonly installATC?: boolean;
|
|
80
|
+
|
|
81
|
+
// Whether the daemon has nothing running or starting on the host, which
|
|
82
|
+
// the provider checks before it gives the host's lease back on its own.
|
|
83
|
+
readonly isIdle?: () => boolean;
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
export interface GuestLayout {
|
|
@@ -132,6 +148,20 @@ export interface HarnessSpec {
|
|
|
132
148
|
readonly cols: number;
|
|
133
149
|
readonly rows: number;
|
|
134
150
|
|
|
151
|
+
// Whether the harness must not start unless the host's credential broker
|
|
152
|
+
// is ready: the host refuses the start and runs nothing otherwise, on
|
|
153
|
+
// every start, a revive's included.
|
|
154
|
+
readonly requireBroker?: boolean;
|
|
155
|
+
|
|
156
|
+
// Admits each start or attach of a harness that requires the broker by
|
|
157
|
+
// calling send, which hands the request to the host, or rejects with the
|
|
158
|
+
// refusal that ends the harness instead, sending nothing. send gets the
|
|
159
|
+
// admission's ticket.
|
|
160
|
+
readonly admit?: (
|
|
161
|
+
kind: 'start' | 'attach',
|
|
162
|
+
send: (ticket: LaunchTicket) => void,
|
|
163
|
+
) => Promise<void>;
|
|
164
|
+
|
|
135
165
|
// Takes each connection a process of the harness opens to the daemon. A
|
|
136
166
|
// remote provider relays them from a socket inside the host that serves
|
|
137
167
|
// this harness alone, and points the harness's ATC_SOCKET at it.
|
|
@@ -143,6 +173,18 @@ export interface HarnessSpec {
|
|
|
143
173
|
* lines: each line the process writes arrives whole, and each line the
|
|
144
174
|
* daemon writes reaches the process in order.
|
|
145
175
|
*/
|
|
176
|
+
/**
|
|
177
|
+
* One admitted request of a harness behind the broker. check runs just
|
|
178
|
+
* before the request goes out and returns the refusal that stops it
|
|
179
|
+
* unsent, or null to let it go; release gives the admission up once its
|
|
180
|
+
* connection ends without the request going out. Each runs at most once
|
|
181
|
+
* to any effect.
|
|
182
|
+
*/
|
|
183
|
+
export interface LaunchTicket {
|
|
184
|
+
readonly check: () => DaemonError | null;
|
|
185
|
+
readonly release: () => void;
|
|
186
|
+
}
|
|
187
|
+
|
|
146
188
|
export interface HarnessRelay {
|
|
147
189
|
readonly onLine: (listener: (line: string) => void) => void;
|
|
148
190
|
readonly onClose: (listener: () => void) => void;
|
|
@@ -176,6 +218,12 @@ export interface HarnessHandle {
|
|
|
176
218
|
// a provider that can send one; absent on a provider that cannot.
|
|
177
219
|
readonly killForced?: () => void;
|
|
178
220
|
|
|
221
|
+
// Resolves once the harness's process has started, or a running one was
|
|
222
|
+
// attached, and rejects with the refusal when the host ends the harness
|
|
223
|
+
// or the daemon lets go of it first. Absent on a provider whose harness
|
|
224
|
+
// starts at once.
|
|
225
|
+
readonly waitForStart?: () => Promise<void>;
|
|
226
|
+
|
|
179
227
|
// Resolves true once the harness's process has exited, and false when the
|
|
180
228
|
// wait runs out first or the harness stops being followed without an
|
|
181
229
|
// exit, such as a host that went to sleep with the process inside.
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createImpClient, openExecSession, openReverseForward } from '@zgeoff/imp-client';
|
|
2
|
-
import type { ExecOutcome, ImpClient } from '@zgeoff/imp-client';
|
|
2
|
+
import type { ExecOutcome, ExecSessionOptions, ImpClient } from '@zgeoff/imp-client';
|
|
3
3
|
import { isRecord } from '../shared/report';
|
|
4
4
|
import type {
|
|
5
5
|
ImpCommand,
|
|
6
6
|
ImpCommandResult,
|
|
7
7
|
ImpCreateSpec,
|
|
8
|
+
ImpExecRequirement,
|
|
8
9
|
ImpFeatures,
|
|
9
10
|
ImpIdentity,
|
|
10
11
|
ImpLease,
|
|
@@ -63,6 +64,7 @@ export class ImpClientPort implements ImpPort {
|
|
|
63
64
|
leases: features?.['leases'] === true,
|
|
64
65
|
grantableTokens: features?.['grantableTokens'] === true,
|
|
65
66
|
secretRebind: features?.['secretRebind'] === true,
|
|
67
|
+
execRequire: features?.['execRequire'] === true,
|
|
66
68
|
};
|
|
67
69
|
};
|
|
68
70
|
|
|
@@ -199,6 +201,7 @@ export class ImpClientPort implements ImpPort {
|
|
|
199
201
|
readonly openSession = (
|
|
200
202
|
request: ImpSessionRequest,
|
|
201
203
|
handlers: ImpSessionHandlers,
|
|
204
|
+
gate?: () => boolean,
|
|
202
205
|
): ImpSessionConnection => {
|
|
203
206
|
const token = this.tryReadToken();
|
|
204
207
|
|
|
@@ -217,34 +220,14 @@ export class ImpClientPort implements ImpPort {
|
|
|
217
220
|
const session = openExecSession({
|
|
218
221
|
baseUrl: this.url,
|
|
219
222
|
token,
|
|
220
|
-
start:
|
|
221
|
-
request.kind === 'start'
|
|
222
|
-
? {
|
|
223
|
-
name: request.name,
|
|
224
|
-
session: request.session,
|
|
225
|
-
argv: request.argv,
|
|
226
|
-
tty: true,
|
|
227
|
-
env: request.env,
|
|
228
|
-
cwd: request.cwd,
|
|
229
|
-
cols: request.cols,
|
|
230
|
-
rows: request.rows,
|
|
231
|
-
...(request.resumeFrom === undefined ? {} : { resumeFrom: request.resumeFrom }),
|
|
232
|
-
}
|
|
233
|
-
: {
|
|
234
|
-
name: request.name,
|
|
235
|
-
session: request.session,
|
|
236
|
-
cols: request.cols,
|
|
237
|
-
rows: request.rows,
|
|
238
|
-
wake: request.wake,
|
|
239
|
-
...(request.resumeFrom === undefined ? {} : { resumeFrom: request.resumeFrom }),
|
|
240
|
-
},
|
|
223
|
+
start: buildExecStart(request),
|
|
241
224
|
onStarted: (started) => {
|
|
242
225
|
handlers.onStarted({ created: started.created, output: started.output });
|
|
243
226
|
},
|
|
244
227
|
onOutput: (_channel, data) => {
|
|
245
228
|
handlers.onOutput(data);
|
|
246
229
|
},
|
|
247
|
-
connect: (url, headers) =>
|
|
230
|
+
connect: (url, headers) => openGatedSocket(url, headers, gate),
|
|
248
231
|
});
|
|
249
232
|
|
|
250
233
|
return {
|
|
@@ -389,6 +372,41 @@ function toPortError(error: unknown): ImpPortError {
|
|
|
389
372
|
return new ImpPortError('UNREACHABLE', message);
|
|
390
373
|
}
|
|
391
374
|
|
|
375
|
+
// A start or an attach as the client takes it, with the requirements impd
|
|
376
|
+
// checks before the command runs or the attach joins it.
|
|
377
|
+
type ExecStartWithRequire = ExecSessionOptions['start'] & {
|
|
378
|
+
readonly require?: readonly ImpExecRequirement[];
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// The client sends every field of a start or an attach as it is, so a list
|
|
382
|
+
// of requirements reaches impd though the client's types lack it.
|
|
383
|
+
function buildExecStart(request: ImpSessionRequest): ExecStartWithRequire {
|
|
384
|
+
if (request.kind === 'attach') {
|
|
385
|
+
return {
|
|
386
|
+
name: request.name,
|
|
387
|
+
session: request.session,
|
|
388
|
+
cols: request.cols,
|
|
389
|
+
rows: request.rows,
|
|
390
|
+
wake: request.wake,
|
|
391
|
+
...(request.resumeFrom === undefined ? {} : { resumeFrom: request.resumeFrom }),
|
|
392
|
+
...(request.require === undefined ? {} : { require: request.require }),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
name: request.name,
|
|
398
|
+
session: request.session,
|
|
399
|
+
argv: request.argv,
|
|
400
|
+
tty: true,
|
|
401
|
+
env: request.env,
|
|
402
|
+
cwd: request.cwd,
|
|
403
|
+
cols: request.cols,
|
|
404
|
+
rows: request.rows,
|
|
405
|
+
...(request.resumeFrom === undefined ? {} : { resumeFrom: request.resumeFrom }),
|
|
406
|
+
...(request.require === undefined ? {} : { require: request.require }),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
392
410
|
// oxlint-disable-next-line prefer-readonly-parameter-types -- a promise is a live handle
|
|
393
411
|
async function waitForSessionOutcome(outcome: Promise<ExecOutcome>): Promise<ImpSessionOutcome> {
|
|
394
412
|
const ended = await outcome;
|
|
@@ -469,3 +487,33 @@ function openRelay(accept: RelayAccept): ImpRelayConnection {
|
|
|
469
487
|
},
|
|
470
488
|
};
|
|
471
489
|
}
|
|
490
|
+
|
|
491
|
+
// The client sends its request from the socket's open listener. The gate
|
|
492
|
+
// listens first, so a closed gate, or one that throws, stops that
|
|
493
|
+
// listener and closes the socket before anything goes out.
|
|
494
|
+
function openGatedSocket(
|
|
495
|
+
url: string,
|
|
496
|
+
headers: Readonly<Record<string, string>>,
|
|
497
|
+
gate: (() => boolean) | undefined,
|
|
498
|
+
): WebSocket {
|
|
499
|
+
const socket = new WebSocket(url, { headers });
|
|
500
|
+
|
|
501
|
+
if (gate !== undefined) {
|
|
502
|
+
socket.addEventListener('open', (event) => {
|
|
503
|
+
if (!tryPassGate(gate)) {
|
|
504
|
+
event.stopImmediatePropagation();
|
|
505
|
+
socket.close(1000, 'closed before sending');
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return socket;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function tryPassGate(gate: () => boolean): boolean {
|
|
514
|
+
try {
|
|
515
|
+
return gate();
|
|
516
|
+
} catch {
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
1
2
|
import { isRecord } from '../shared/report';
|
|
2
|
-
import type { HarnessAttachment, HarnessExit, HarnessHandle } from './execution-provider';
|
|
3
3
|
import type {
|
|
4
|
+
HarnessAttachment,
|
|
5
|
+
HarnessExit,
|
|
6
|
+
HarnessHandle,
|
|
7
|
+
LaunchTicket,
|
|
8
|
+
} from './execution-provider';
|
|
9
|
+
import type {
|
|
10
|
+
ImpExecRequirement,
|
|
4
11
|
ImpPort,
|
|
5
12
|
ImpSessionConnection,
|
|
6
13
|
ImpSessionOutcome,
|
|
@@ -28,6 +35,15 @@ interface ImpHarnessHost {
|
|
|
28
35
|
// Settles once the harness may start, such as when its report socket
|
|
29
36
|
// listens; a rejection ends the harness before it starts.
|
|
30
37
|
readonly ready?: Promise<void>;
|
|
38
|
+
|
|
39
|
+
// Admits each request that has requirements by calling send, or rejects
|
|
40
|
+
// with the refusal that ends the harness, sending nothing. The ticket
|
|
41
|
+
// send gets is checked just before the request goes out, and a refusal
|
|
42
|
+
// from it ends the harness with nothing sent.
|
|
43
|
+
readonly admit?: (
|
|
44
|
+
kind: 'start' | 'attach',
|
|
45
|
+
send: (ticket: LaunchTicket) => void,
|
|
46
|
+
) => Promise<void>;
|
|
31
47
|
}
|
|
32
48
|
|
|
33
49
|
// The bytes a fresh attach sends listeners ahead of its replay: reset the
|
|
@@ -55,6 +71,20 @@ export class ImpHarness implements HarnessHandle {
|
|
|
55
71
|
|
|
56
72
|
private readonly session: string;
|
|
57
73
|
|
|
74
|
+
// What impd must have ready before the start and every reattach, so an
|
|
75
|
+
// attach never joins the process past a requirement the start had.
|
|
76
|
+
private readonly require: readonly ImpExecRequirement[] | undefined;
|
|
77
|
+
|
|
78
|
+
// The request that starts the process, and whether it went out to impd.
|
|
79
|
+
// Until it has, a retry sends it again, since there is no process to
|
|
80
|
+
// attach to.
|
|
81
|
+
private readonly start: ImpSessionRequest;
|
|
82
|
+
|
|
83
|
+
private startSent = false;
|
|
84
|
+
|
|
85
|
+
// The admission of the open connection's request, while it has one.
|
|
86
|
+
private connectionTicket: LaunchTicket | null = null;
|
|
87
|
+
|
|
58
88
|
private readonly dataListeners = new Set<(data: string) => void>();
|
|
59
89
|
|
|
60
90
|
private readonly exitListeners = new Set<(exit: HarnessExit) => void>();
|
|
@@ -110,6 +140,16 @@ export class ImpHarness implements HarnessHandle {
|
|
|
110
140
|
|
|
111
141
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
112
142
|
|
|
143
|
+
// How the harness's start settled: started once a connection first
|
|
144
|
+
// starts the process or attaches to it, or the refusal it ended with
|
|
145
|
+
// before that; null until then. The waits on that answer meanwhile.
|
|
146
|
+
private startOutcome: 'started' | Readonly<DaemonError> | null = null;
|
|
147
|
+
|
|
148
|
+
private readonly startWaiters = new Set<PromiseWithResolvers<void>>();
|
|
149
|
+
|
|
150
|
+
// Why impd refused to start the harness, once it has given a reason.
|
|
151
|
+
private startRefusal: DaemonError | null = null;
|
|
152
|
+
|
|
113
153
|
// The terminal size the open connection's request asked for.
|
|
114
154
|
private requestedSize: { cols: number; rows: number } = { cols: 0, rows: 0 };
|
|
115
155
|
|
|
@@ -119,11 +159,13 @@ export class ImpHarness implements HarnessHandle {
|
|
|
119
159
|
this.host = host;
|
|
120
160
|
this.name = start.name;
|
|
121
161
|
this.session = start.session;
|
|
162
|
+
this.require = start.require;
|
|
163
|
+
this.start = start;
|
|
122
164
|
this.cols = start.cols;
|
|
123
165
|
this.rows = start.rows;
|
|
124
166
|
|
|
125
167
|
if (host.ready === undefined) {
|
|
126
|
-
this.
|
|
168
|
+
this.openCheckedConnection(start);
|
|
127
169
|
} else {
|
|
128
170
|
void this.startWhenReady(host.ready, start);
|
|
129
171
|
}
|
|
@@ -206,6 +248,22 @@ export class ImpHarness implements HarnessHandle {
|
|
|
206
248
|
return waited.promise;
|
|
207
249
|
};
|
|
208
250
|
|
|
251
|
+
readonly waitForStart = (): Promise<void> => {
|
|
252
|
+
if (this.startOutcome === 'started') {
|
|
253
|
+
return Promise.resolve();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (this.startOutcome !== null) {
|
|
257
|
+
return Promise.reject(this.startOutcome);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const waited = Promise.withResolvers<void>();
|
|
261
|
+
|
|
262
|
+
this.startWaiters.add(waited);
|
|
263
|
+
|
|
264
|
+
return waited.promise;
|
|
265
|
+
};
|
|
266
|
+
|
|
209
267
|
readonly detach = (): void => {
|
|
210
268
|
if (this.done) {
|
|
211
269
|
return;
|
|
@@ -235,28 +293,159 @@ export class ImpHarness implements HarnessHandle {
|
|
|
235
293
|
|
|
236
294
|
// A resize while the host readied changed the size the start asks for.
|
|
237
295
|
if (!this.done) {
|
|
238
|
-
this.
|
|
296
|
+
this.openCheckedConnection({ ...start, cols: this.cols, rows: this.rows });
|
|
239
297
|
}
|
|
240
298
|
}
|
|
241
299
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
300
|
+
// An impd from before exec requirements ignores them and runs the
|
|
301
|
+
// command anyway, so a request that has any goes out only after impd
|
|
302
|
+
// shows it honours them; otherwise the harness ends as impd's own
|
|
303
|
+
// client refuses an outdated impd, and nothing runs.
|
|
304
|
+
private openCheckedConnection(request: ImpSessionRequest): void {
|
|
305
|
+
if (request.require === undefined || request.require.length === 0) {
|
|
306
|
+
this.openConnection(request);
|
|
307
|
+
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
void this.openRequiredConnection(request);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private async openRequiredConnection(request: ImpSessionRequest): Promise<void> {
|
|
315
|
+
let execRequire: boolean;
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const features = await this.port.readFeatures();
|
|
319
|
+
|
|
320
|
+
execRequire = features.execRequire;
|
|
321
|
+
} catch (error) {
|
|
322
|
+
if (!this.done && !this.host.isSuspending()) {
|
|
323
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
324
|
+
|
|
325
|
+
this.applyOutcome({ kind: 'unreachable', detail }, false);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (this.done || this.host.isSuspending()) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (!execRequire) {
|
|
336
|
+
this.applyOutcome(
|
|
337
|
+
{
|
|
338
|
+
kind: 'failed',
|
|
339
|
+
code: 'PRECONDITION_FAILED',
|
|
340
|
+
message: `impd for imp ${this.name} does not honour exec requirements`,
|
|
341
|
+
data: { reason: 'impd_outdated' },
|
|
342
|
+
},
|
|
343
|
+
false,
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const admit = this.host.admit;
|
|
350
|
+
|
|
351
|
+
if (admit === undefined) {
|
|
352
|
+
this.openConnection(request);
|
|
353
|
+
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
await admit(request.kind, (ticket) => {
|
|
359
|
+
if (this.done || this.host.isSuspending()) {
|
|
360
|
+
ticket.release();
|
|
361
|
+
} else {
|
|
362
|
+
this.openConnection(request, ticket);
|
|
252
363
|
}
|
|
253
|
-
}
|
|
364
|
+
});
|
|
365
|
+
} catch (error) {
|
|
366
|
+
if (!this.done) {
|
|
367
|
+
this.applyAdmissionRefusal(error);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Whether an admitted request may still go out as its connection opens.
|
|
373
|
+
// A refusal there, or a check that throws, closes the connection unsent
|
|
374
|
+
// and ends the harness with it, once the port has returned to the
|
|
375
|
+
// caller. Only a start that passes counts as sent.
|
|
376
|
+
private checkTicket(ticket: LaunchTicket, kind: ImpSessionRequest['kind']): boolean {
|
|
377
|
+
let refusal: DaemonError | null;
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
refusal = ticket.check();
|
|
381
|
+
} catch (error) {
|
|
382
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
383
|
+
|
|
384
|
+
refusal = new DaemonError(
|
|
385
|
+
'internal',
|
|
386
|
+
`imp ${this.name}'s harness could not check its admission: ${detail}`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (refusal === null) {
|
|
391
|
+
this.startSent ||= kind === 'start';
|
|
392
|
+
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
queueMicrotask(() => {
|
|
397
|
+
if (!this.done) {
|
|
398
|
+
this.connection = null;
|
|
399
|
+
|
|
400
|
+
this.applyAdmissionRefusal(refusal);
|
|
401
|
+
}
|
|
254
402
|
});
|
|
255
403
|
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// A launch the host's binding no longer admits ends the harness with
|
|
408
|
+
// that refusal; nothing went out to impd.
|
|
409
|
+
private applyAdmissionRefusal(error: unknown): void {
|
|
410
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
411
|
+
|
|
412
|
+
this.startRefusal =
|
|
413
|
+
error instanceof DaemonError
|
|
414
|
+
? error
|
|
415
|
+
: new DaemonError('internal', `imp ${this.name} could not admit the harness: ${detail}`);
|
|
416
|
+
|
|
417
|
+
this.emitExit({ exitCode: 1, reason: 'ended', detail: `launch refused (${detail})` });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// A request without a ticket counts as sent once the port has it; one
|
|
421
|
+
// with a ticket only once its check passes, and the ticket goes back
|
|
422
|
+
// whenever the connection ends or the harness lets go of it.
|
|
423
|
+
private openConnection(request: ImpSessionRequest, ticket?: LaunchTicket): void {
|
|
424
|
+
const gate = ticket === undefined ? undefined : () => this.checkTicket(ticket, request.kind);
|
|
425
|
+
|
|
426
|
+
const connection = this.port.openSession(
|
|
427
|
+
request,
|
|
428
|
+
{
|
|
429
|
+
onStarted: (started) => {
|
|
430
|
+
if (this.connection === connection) {
|
|
431
|
+
this.applyStarted(connection, started);
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
onOutput: (data) => {
|
|
435
|
+
if (this.connection === connection) {
|
|
436
|
+
this.applyOutput(data);
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
gate,
|
|
441
|
+
);
|
|
442
|
+
|
|
256
443
|
this.connection = connection;
|
|
444
|
+
this.connectionTicket = ticket ?? null;
|
|
257
445
|
this.started = false;
|
|
446
|
+
this.startSent ||= ticket === undefined && request.kind === 'start';
|
|
258
447
|
this.requestedSize = { cols: request.cols, rows: request.rows };
|
|
259
|
-
void this.waitForOutcome(connection);
|
|
448
|
+
void this.waitForOutcome(connection, ticket ?? null);
|
|
260
449
|
}
|
|
261
450
|
|
|
262
451
|
private applyStarted(connection: ImpSessionConnection, started: ImpSessionStarted): void {
|
|
@@ -294,6 +483,7 @@ export class ImpHarness implements HarnessHandle {
|
|
|
294
483
|
this.failures = 0;
|
|
295
484
|
this.started = true;
|
|
296
485
|
|
|
486
|
+
this.updateStartOutcome('started');
|
|
297
487
|
this.emitAttachment('attached');
|
|
298
488
|
|
|
299
489
|
// impd drops a resize that reaches it before the session starts, so the
|
|
@@ -345,9 +535,14 @@ export class ImpHarness implements HarnessHandle {
|
|
|
345
535
|
}
|
|
346
536
|
}
|
|
347
537
|
|
|
348
|
-
private async waitForOutcome(
|
|
538
|
+
private async waitForOutcome(
|
|
539
|
+
connection: ImpSessionConnection,
|
|
540
|
+
ticket: LaunchTicket | null,
|
|
541
|
+
): Promise<void> {
|
|
349
542
|
const outcome = await connection.outcome;
|
|
350
543
|
|
|
544
|
+
ticket?.release();
|
|
545
|
+
|
|
351
546
|
if (this.connection !== connection || this.done || this.host.isSuspending()) {
|
|
352
547
|
return;
|
|
353
548
|
}
|
|
@@ -450,6 +645,35 @@ export class ImpHarness implements HarnessHandle {
|
|
|
450
645
|
return;
|
|
451
646
|
}
|
|
452
647
|
|
|
648
|
+
if (outcome.code === 'PRECONDITION_FAILED' && isImpdOutdated(outcome.data)) {
|
|
649
|
+
this.startRefusal = new DaemonError(
|
|
650
|
+
'auth_impd_too_old',
|
|
651
|
+
`impd for imp ${this.name} does not honour exec requirements; upgrade it to 0.30.0 or later`,
|
|
652
|
+
{ imp: this.name, execRequire: false },
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
this.emitExit({ exitCode: 1, reason: 'ended', detail: 'impd too old to require the broker' });
|
|
656
|
+
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (outcome.code === 'PRECONDITION_FAILED' && isBrokerNotReady(outcome.data)) {
|
|
661
|
+
const detail =
|
|
662
|
+
isRecord(outcome.data) && typeof outcome.data['detail'] === 'string'
|
|
663
|
+
? outcome.data['detail']
|
|
664
|
+
: 'no detail';
|
|
665
|
+
|
|
666
|
+
this.startRefusal = new DaemonError(
|
|
667
|
+
'broker_not_ready',
|
|
668
|
+
`imp ${this.name} refused to start the harness: its credential broker is not ready (${detail})`,
|
|
669
|
+
{ imp: this.name, detail },
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
this.emitExit({ exitCode: 1, reason: 'ended', detail: `imp broker not ready (${detail})` });
|
|
673
|
+
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
453
677
|
this.emitExit({ exitCode: 1, reason: 'ended', detail: formatOutcome(outcome) });
|
|
454
678
|
}
|
|
455
679
|
|
|
@@ -477,7 +701,11 @@ export class ImpHarness implements HarnessHandle {
|
|
|
477
701
|
this.reconnectTimer = null;
|
|
478
702
|
|
|
479
703
|
if (!this.done) {
|
|
480
|
-
this.
|
|
704
|
+
const next = this.startSent
|
|
705
|
+
? this.buildAttach()
|
|
706
|
+
: { ...this.start, cols: this.cols, rows: this.rows };
|
|
707
|
+
|
|
708
|
+
this.openCheckedConnection(next);
|
|
481
709
|
}
|
|
482
710
|
}, wait);
|
|
483
711
|
|
|
@@ -500,6 +728,7 @@ export class ImpHarness implements HarnessHandle {
|
|
|
500
728
|
cols: this.cols,
|
|
501
729
|
rows: this.rows,
|
|
502
730
|
wake: false,
|
|
731
|
+
...(this.require === undefined ? {} : { require: this.require }),
|
|
503
732
|
...(cursor === null
|
|
504
733
|
? {}
|
|
505
734
|
: { resumeFrom: { executionGeneration: cursor.generation, offset: this.highWater } }),
|
|
@@ -516,7 +745,7 @@ export class ImpHarness implements HarnessHandle {
|
|
|
516
745
|
|
|
517
746
|
this.fresh = true;
|
|
518
747
|
|
|
519
|
-
this.
|
|
748
|
+
this.openCheckedConnection(this.buildAttach());
|
|
520
749
|
}
|
|
521
750
|
|
|
522
751
|
// A generation under the session's name other than its own is not the
|
|
@@ -552,6 +781,15 @@ export class ImpHarness implements HarnessHandle {
|
|
|
552
781
|
private emitExit(exit: HarnessExit): void {
|
|
553
782
|
const listeners = [...this.exitListeners];
|
|
554
783
|
|
|
784
|
+
this.updateStartOutcome(
|
|
785
|
+
this.startRefusal ??
|
|
786
|
+
new DaemonError(
|
|
787
|
+
'host_unavailable',
|
|
788
|
+
`imp ${this.name} ended the harness before it started (${exit.detail ?? 'process exited'})`,
|
|
789
|
+
{ provider: 'imp', problem: 'not_started' },
|
|
790
|
+
),
|
|
791
|
+
);
|
|
792
|
+
|
|
555
793
|
this.exitConfirmed = exit.reason === undefined || exit.reason === 'exited';
|
|
556
794
|
|
|
557
795
|
this.stopFollowing();
|
|
@@ -561,6 +799,25 @@ export class ImpHarness implements HarnessHandle {
|
|
|
561
799
|
}
|
|
562
800
|
}
|
|
563
801
|
|
|
802
|
+
// The first outcome counts; each wait settles with it.
|
|
803
|
+
private updateStartOutcome(outcome: 'started' | Readonly<DaemonError>): void {
|
|
804
|
+
if (this.startOutcome !== null) {
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
this.startOutcome = outcome;
|
|
809
|
+
|
|
810
|
+
for (const waited of this.startWaiters) {
|
|
811
|
+
if (outcome === 'started') {
|
|
812
|
+
waited.resolve();
|
|
813
|
+
} else {
|
|
814
|
+
waited.reject(outcome);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
this.startWaiters.clear();
|
|
819
|
+
}
|
|
820
|
+
|
|
564
821
|
private stopFollowing(): void {
|
|
565
822
|
if (this.reconnectTimer !== null) {
|
|
566
823
|
clearTimeout(this.reconnectTimer);
|
|
@@ -570,8 +827,21 @@ export class ImpHarness implements HarnessHandle {
|
|
|
570
827
|
|
|
571
828
|
this.done = true;
|
|
572
829
|
this.connection = null;
|
|
830
|
+
this.connectionTicket?.release();
|
|
831
|
+
this.connectionTicket = null;
|
|
573
832
|
this.exitConfirmed ??= false;
|
|
574
833
|
|
|
834
|
+
this.updateStartOutcome(
|
|
835
|
+
new DaemonError(
|
|
836
|
+
'host_unavailable',
|
|
837
|
+
`the daemon let go of imp ${this.name}'s harness before it started`,
|
|
838
|
+
{
|
|
839
|
+
provider: 'imp',
|
|
840
|
+
problem: 'not_started',
|
|
841
|
+
},
|
|
842
|
+
),
|
|
843
|
+
);
|
|
844
|
+
|
|
575
845
|
for (const waiter of this.exitWaiters) {
|
|
576
846
|
clearTimeout(waiter.timer);
|
|
577
847
|
|
|
@@ -586,6 +856,17 @@ export class ImpHarness implements HarnessHandle {
|
|
|
586
856
|
}
|
|
587
857
|
}
|
|
588
858
|
|
|
859
|
+
// impd's refusal of a start whose broker is not ready.
|
|
860
|
+
function isBrokerNotReady(data: unknown): boolean {
|
|
861
|
+
return isRecord(data) && data['reason'] === 'broker_not_ready';
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// The refusal of an exec with requirements against an impd that would
|
|
865
|
+
// ignore them, which impd's client and the harness both give.
|
|
866
|
+
function isImpdOutdated(data: unknown): boolean {
|
|
867
|
+
return isRecord(data) && data['reason'] === 'impd_outdated';
|
|
868
|
+
}
|
|
869
|
+
|
|
589
870
|
// What ended a process impd no longer holds. In the same boot, the kept
|
|
590
871
|
// previous generation gives its exit. Across a cold boot, the first boot
|
|
591
872
|
// after the daemon's own gives the cause; without the daemon's boot among
|