@zgeoff/atc 2.26.1 → 2.26.2
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 +1 -1
- package/src/client/pick-refusal-step.ts +1 -0
- package/src/daemon/daemon-connection.ts +19 -0
- package/src/daemon/daemon.ts +58 -18
- package/src/daemon/imp-provider.ts +28 -9
- package/src/daemon/materialize-workspace.ts +88 -26
- package/src/daemon/sessions.ts +515 -16
- package/src/protocol/protocol.ts +1 -0
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@ type RefusalStep = 'confirm' | 'destination' | 'ref' | 'source';
|
|
|
3
3
|
|
|
4
4
|
const REFUSAL_STEPS: ReadonlyMap<string, RefusalStep> = new Map<string, RefusalStep>([
|
|
5
5
|
['workspace_exists', 'destination'],
|
|
6
|
+
['workspace_overlap', 'destination'],
|
|
6
7
|
['ref_not_found', 'ref'],
|
|
7
8
|
['invalid_git_url', 'source'],
|
|
8
9
|
['credential_in_url', 'source'],
|
|
@@ -976,6 +976,16 @@ export class DaemonConnection {
|
|
|
976
976
|
throw new DaemonError('bad_args', 'a spawn with a workspace requires an absolute cwd');
|
|
977
977
|
}
|
|
978
978
|
|
|
979
|
+
// A workspace is created, filled, and removed at the one directory
|
|
980
|
+
// the host resolves cwd to, which a dot segment or a control
|
|
981
|
+
// character would make differ from the path as written.
|
|
982
|
+
if (data.workspace !== undefined && !isPlainWorkspaceDir(data.cwd)) {
|
|
983
|
+
throw new DaemonError(
|
|
984
|
+
'bad_args',
|
|
985
|
+
'a spawn with a workspace requires a cwd without . or .. segments or control characters',
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
|
|
979
989
|
let parent: SessionID | null = null;
|
|
980
990
|
|
|
981
991
|
if (data.parent !== undefined) {
|
|
@@ -1791,3 +1801,12 @@ function findEventSession(event: EventMsg): SessionID | null {
|
|
|
1791
1801
|
|
|
1792
1802
|
return null;
|
|
1793
1803
|
}
|
|
1804
|
+
|
|
1805
|
+
// oxlint-disable-next-line no-control-regex -- control characters are what a workspace cwd is refused for
|
|
1806
|
+
const CONTROL_CHARACTER = /[\u0000-\u001F\u007F]/u;
|
|
1807
|
+
|
|
1808
|
+
function isPlainWorkspaceDir(dir: string): boolean {
|
|
1809
|
+
return (
|
|
1810
|
+
!CONTROL_CHARACTER.test(dir) && !dir.split('/').some((part) => part === '.' || part === '..')
|
|
1811
|
+
);
|
|
1812
|
+
}
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -938,9 +938,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
938
938
|
});
|
|
939
939
|
};
|
|
940
940
|
|
|
941
|
-
// A spawn with a workspace source materializes it
|
|
942
|
-
//
|
|
943
|
-
// over a half-built checkout. A spawn that throws once its process has
|
|
941
|
+
// A spawn with a workspace source materializes it once every refusal has
|
|
942
|
+
// passed and its host is ready, and the session registers only once its
|
|
943
|
+
// workspace is ready, so no session ever lists over a half-built checkout. A spawn that throws once its process has
|
|
944
944
|
// started takes the session back before it throws, so a failed start
|
|
945
945
|
// leaves nothing running and a keyed retry spawns once. When taking it
|
|
946
946
|
// back fails too, the session may still stand, and the throw says so.
|
|
@@ -949,17 +949,42 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
949
949
|
id: SessionID,
|
|
950
950
|
requireInReach: () => void = () => {},
|
|
951
951
|
): Promise<Readonly<Record<string, unknown>>> => {
|
|
952
|
-
const
|
|
953
|
-
|
|
952
|
+
const source = p.workspace;
|
|
953
|
+
let warnings: readonly string[] = [];
|
|
954
|
+
|
|
955
|
+
const materialize =
|
|
956
|
+
source === null
|
|
957
|
+
? null
|
|
958
|
+
: async (
|
|
959
|
+
host: Readonly<{
|
|
960
|
+
readyHost: () => Promise<{ readonly host: SessionID; readonly dir: string }>;
|
|
961
|
+
removeClaim: (dir: string) => Promise<boolean>;
|
|
962
|
+
}>,
|
|
963
|
+
targetIdentity: string,
|
|
964
|
+
) => {
|
|
965
|
+
const prepared = await materializeSpawnWorkspace(p, id, source, host, targetIdentity);
|
|
966
|
+
|
|
967
|
+
if (prepared.kind !== 'ready') {
|
|
968
|
+
return null;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
warnings = prepared.warnings;
|
|
954
972
|
|
|
955
|
-
|
|
956
|
-
|
|
973
|
+
return prepared;
|
|
974
|
+
};
|
|
957
975
|
|
|
958
976
|
try {
|
|
959
|
-
const session = await startSpawnedSession(p, id,
|
|
977
|
+
const session = await startSpawnedSession(p, id, materialize, requireInReach);
|
|
960
978
|
|
|
961
979
|
return warnings.length === 0 ? { session } : { session, warnings };
|
|
962
980
|
} catch (error) {
|
|
981
|
+
// A failed spawn gives back the workspace directory it reserved,
|
|
982
|
+
// unless what it did may still stand: its key then stays held as
|
|
983
|
+
// outcome_unknown, and so does its directory.
|
|
984
|
+
if (!(error instanceof EffectRemainsError)) {
|
|
985
|
+
mgr.releaseWorkspace(id);
|
|
986
|
+
}
|
|
987
|
+
|
|
963
988
|
try {
|
|
964
989
|
await mgr.removeFailedSpawn(id);
|
|
965
990
|
} catch (cleanupError) {
|
|
@@ -975,16 +1000,21 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
975
1000
|
}
|
|
976
1001
|
};
|
|
977
1002
|
|
|
978
|
-
// Materializes a spawn's workspace on its
|
|
979
|
-
//
|
|
980
|
-
// passes the execution gate against that binding.
|
|
1003
|
+
// Materializes a spawn's workspace on the host its spawn readies once the
|
|
1004
|
+
// source resolves, under the target identity the spawn bound, and every
|
|
1005
|
+
// provider call passes the execution gate against that binding.
|
|
981
1006
|
const materializeSpawnWorkspace = (
|
|
982
1007
|
p: SpawnParams,
|
|
983
1008
|
id: SessionID,
|
|
984
1009
|
source: SpawnWorkspaceSource,
|
|
1010
|
+
host: Readonly<{
|
|
1011
|
+
readyHost: () => Promise<{ readonly host: SessionID; readonly dir: string }>;
|
|
1012
|
+
removeClaim: (dir: string) => Promise<boolean>;
|
|
1013
|
+
}>,
|
|
1014
|
+
targetIdentity: string,
|
|
985
1015
|
) => {
|
|
986
|
-
const
|
|
987
|
-
const
|
|
1016
|
+
const binding = { target: p.target, targetIdentity };
|
|
1017
|
+
const bound = mgr.requireExecution(binding, 'run');
|
|
988
1018
|
|
|
989
1019
|
return materializeWorkspace(
|
|
990
1020
|
{
|
|
@@ -1000,6 +1030,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1000
1030
|
log: (line) => {
|
|
1001
1031
|
mgr.log(line);
|
|
1002
1032
|
},
|
|
1033
|
+
readyHost: host.readyHost,
|
|
1034
|
+
removeClaim: host.removeClaim,
|
|
1003
1035
|
stagingRoot: tmpdir(),
|
|
1004
1036
|
gitTransports,
|
|
1005
1037
|
},
|
|
@@ -1009,10 +1041,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1009
1041
|
const startSpawnedSession = async (
|
|
1010
1042
|
p: SpawnParams,
|
|
1011
1043
|
id: SessionID,
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1044
|
+
materialize:
|
|
1045
|
+
| ((
|
|
1046
|
+
host: Readonly<{
|
|
1047
|
+
readyHost: () => Promise<{ readonly host: SessionID; readonly dir: string }>;
|
|
1048
|
+
removeClaim: (dir: string) => Promise<boolean>;
|
|
1049
|
+
}>,
|
|
1050
|
+
targetIdentity: string,
|
|
1051
|
+
) => Promise<Readonly<{
|
|
1052
|
+
workspace: SessionWorkspace;
|
|
1053
|
+
withheldEnv: readonly string[];
|
|
1054
|
+
}> | null>)
|
|
1055
|
+
| null,
|
|
1016
1056
|
requireInReach: () => void,
|
|
1017
1057
|
): Promise<SessionDescriptor> => {
|
|
1018
1058
|
const s = await mgr.spawn(
|
|
@@ -1028,7 +1068,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1028
1068
|
p.overrides,
|
|
1029
1069
|
id,
|
|
1030
1070
|
p.target,
|
|
1031
|
-
|
|
1071
|
+
materialize,
|
|
1032
1072
|
requireInReach,
|
|
1033
1073
|
);
|
|
1034
1074
|
|
|
@@ -4,6 +4,7 @@ import { isCompiledBinary } from '../shared/is-compiled-binary';
|
|
|
4
4
|
import type { BrokerAuthHost } from './broker-auth-host';
|
|
5
5
|
import { buildImpName } from './build-imp-name';
|
|
6
6
|
import { buildTarArchive } from './build-tar-archive';
|
|
7
|
+
import { EffectRemainsError } from './effect-remains-error';
|
|
7
8
|
import type {
|
|
8
9
|
CommandResult,
|
|
9
10
|
CommandSpec,
|
|
@@ -219,9 +220,18 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
219
220
|
|
|
220
221
|
await this.setupGuest(name, request.installATC === true);
|
|
221
222
|
} catch (error) {
|
|
222
|
-
await this.tryUndoPrepare(name, label, created, leased);
|
|
223
|
+
const undone = await this.tryUndoPrepare(name, label, created, leased);
|
|
223
224
|
|
|
224
|
-
|
|
225
|
+
const refusal = toHostRefusal(error, name);
|
|
226
|
+
|
|
227
|
+
if (!undone) {
|
|
228
|
+
throw new EffectRemainsError(
|
|
229
|
+
`imp ${name} failed to ready and destroying the imp it created failed too`,
|
|
230
|
+
{ cause: refusal },
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
throw refusal;
|
|
225
235
|
}
|
|
226
236
|
|
|
227
237
|
const host = this.hosts.get(request.host) ?? {
|
|
@@ -405,20 +415,29 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
405
415
|
// Takes back what a failed prepare left: an imp it created is destroyed,
|
|
406
416
|
// which ends the lease on it too, since no session was ever listed on it;
|
|
407
417
|
// on an imp that existed before, only the lease this prepare took is
|
|
408
|
-
// given back, and the imp itself stays.
|
|
418
|
+
// given back, and the imp itself stays. Resolves to false when an imp it
|
|
419
|
+
// created may still stand; a lease left behind runs out on its own.
|
|
409
420
|
private async tryUndoPrepare(
|
|
410
421
|
name: string,
|
|
411
422
|
label: string,
|
|
412
423
|
created: boolean,
|
|
413
424
|
leased: boolean,
|
|
414
|
-
): Promise<
|
|
415
|
-
|
|
416
|
-
|
|
425
|
+
): Promise<boolean> {
|
|
426
|
+
if (created) {
|
|
427
|
+
try {
|
|
417
428
|
await this.port.destroyImp(name);
|
|
418
|
-
|
|
419
|
-
|
|
429
|
+
|
|
430
|
+
return true;
|
|
431
|
+
} catch (error) {
|
|
432
|
+
return error instanceof ImpPortError && error.code === 'NOT_FOUND';
|
|
420
433
|
}
|
|
421
|
-
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (leased) {
|
|
437
|
+
await this.port.releaseLease(name, label).catch(() => false);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return true;
|
|
422
441
|
}
|
|
423
442
|
|
|
424
443
|
// Readies the folder the harnesses' report sockets live in, and copies
|
|
@@ -17,6 +17,7 @@ import { resolveGitURL } from '../workspace/resolve-git-url';
|
|
|
17
17
|
import { resolvePathSource } from '../workspace/resolve-path-source';
|
|
18
18
|
import { runGit } from '../workspace/run-git';
|
|
19
19
|
import { sanitizeWorkspaceClone } from '../workspace/sanitize-workspace-clone';
|
|
20
|
+
import { EffectRemainsError } from './effect-remains-error';
|
|
20
21
|
import type { ExecutionProvider } from './execution-provider';
|
|
21
22
|
import { requireGitTransports } from './require-git-transports';
|
|
22
23
|
|
|
@@ -33,6 +34,12 @@ interface MaterializeRequest {
|
|
|
33
34
|
readonly inPlace: boolean;
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
// The host a workspace lands on, and the directory there it is built in.
|
|
38
|
+
interface Landing {
|
|
39
|
+
readonly host: string;
|
|
40
|
+
readonly dir: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
interface MaterializeDeps {
|
|
37
44
|
// The target's provider for one operation, after the execution gate
|
|
38
45
|
// passes it; throws the gate's refusal otherwise.
|
|
@@ -40,6 +47,15 @@ interface MaterializeDeps {
|
|
|
40
47
|
readonly store: Pick<StateStore, 'createMaterialization' | 'updateMaterialization'>;
|
|
41
48
|
readonly log: (line: string) => void;
|
|
42
49
|
|
|
50
|
+
// Readies the host on the target the workspace lands on and resolves to
|
|
51
|
+
// it, once the source has resolved.
|
|
52
|
+
readonly readyHost: () => Promise<Landing>;
|
|
53
|
+
|
|
54
|
+
// Removes the directory this call claimed after a failure, and resolves
|
|
55
|
+
// to whether it did; one it leaves holds what another session needs, or
|
|
56
|
+
// no longer resolves to itself.
|
|
57
|
+
readonly removeClaim: (dir: string) => Promise<boolean>;
|
|
58
|
+
|
|
43
59
|
// The directory on the daemon's host that holds each clone's staging
|
|
44
60
|
// directory while the workspace is built.
|
|
45
61
|
readonly stagingRoot: string;
|
|
@@ -63,6 +79,10 @@ interface ReadyWorkspace {
|
|
|
63
79
|
interface MaterializationProgress {
|
|
64
80
|
phase: MaterializationPhase;
|
|
65
81
|
|
|
82
|
+
// The host the workspace lands on and its directory there, once the
|
|
83
|
+
// host is ready.
|
|
84
|
+
landing: Landing | null;
|
|
85
|
+
|
|
66
86
|
// Whether this call created the target directory, so a failure removes
|
|
67
87
|
// only a directory it made.
|
|
68
88
|
claimed: boolean;
|
|
@@ -113,7 +133,7 @@ export async function materializeWorkspace(
|
|
|
113
133
|
|
|
114
134
|
const secret = findCredentialSecret(source);
|
|
115
135
|
const withheldEnv = buildWithheldEnv(source);
|
|
116
|
-
const progress: MaterializationProgress = { phase: 'resolving', claimed: false };
|
|
136
|
+
const progress: MaterializationProgress = { phase: 'resolving', landing: null, claimed: false };
|
|
117
137
|
|
|
118
138
|
const updateProgress = (update: Readonly<Partial<MaterializationProgress>>) => {
|
|
119
139
|
Object.assign(progress, update);
|
|
@@ -145,14 +165,28 @@ export async function materializeWorkspace(
|
|
|
145
165
|
} catch (error) {
|
|
146
166
|
const refusal = toScrubbedRefusal(error, progress.phase, secret);
|
|
147
167
|
|
|
148
|
-
|
|
149
|
-
|
|
168
|
+
// A failure that may have left an effect standing, such as a host the
|
|
169
|
+
// spawn could not take back, reaches the caller as it is, so a keyed
|
|
170
|
+
// spawn keeps its key as outcome_unknown.
|
|
171
|
+
const remains = error instanceof EffectRemainsError;
|
|
172
|
+
const code = remains ? 'outcome_unknown' : refusal.code;
|
|
173
|
+
|
|
174
|
+
const left = await tryRemoveClaimedDir(request, deps, progress, secret);
|
|
175
|
+
|
|
176
|
+
await tryUpdateFailed(request, deps, code);
|
|
150
177
|
|
|
151
178
|
deps.log(
|
|
152
|
-
`atc: workspace for session ${request.sessionID} failed while ${progress.phase}: ${
|
|
179
|
+
`atc: workspace for session ${request.sessionID} failed while ${progress.phase}: ${code}: ${refusal.message}`,
|
|
153
180
|
);
|
|
154
181
|
|
|
155
|
-
|
|
182
|
+
if (remains) {
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// A directory the failure left is reported, so the caller can remove it.
|
|
187
|
+
throw left === null
|
|
188
|
+
? refusal
|
|
189
|
+
: new DaemonError(refusal.code, refusal.message, { ...refusal.data, leftDir: left });
|
|
156
190
|
}
|
|
157
191
|
}
|
|
158
192
|
|
|
@@ -220,7 +254,11 @@ async function runMaterialization(
|
|
|
220
254
|
const repoURL = secret === null ? pinned.repoURL : toRedacted(pinned.repoURL, secret);
|
|
221
255
|
const ref = secret === null || pinned.ref === null ? pinned.ref : toRedacted(pinned.ref, secret);
|
|
222
256
|
|
|
223
|
-
await
|
|
257
|
+
const landing = await deps.readyHost();
|
|
258
|
+
|
|
259
|
+
updateProgress({ landing });
|
|
260
|
+
|
|
261
|
+
await claimTargetDir(request, deps, landing, updateProgress);
|
|
224
262
|
await recordPhase(request, deps, updateProgress, 'cloning', { repoURL, ref });
|
|
225
263
|
|
|
226
264
|
const clone = await createCleanClone(pinned, join(staging, 'clone'), transports);
|
|
@@ -231,13 +269,15 @@ async function runMaterialization(
|
|
|
231
269
|
await recordPhase(request, deps, updateProgress, 'transferring', { sha: clone.sha });
|
|
232
270
|
|
|
233
271
|
try {
|
|
234
|
-
await deps
|
|
272
|
+
await deps
|
|
273
|
+
.requireProvider('transfer')
|
|
274
|
+
.transferArchive(clone.archive, landing.dir, landing.host);
|
|
235
275
|
} catch (error) {
|
|
236
276
|
throw toDaemonError(error, 'transfer_failed', 'transferring');
|
|
237
277
|
}
|
|
238
278
|
|
|
239
279
|
await recordPhase(request, deps, updateProgress, 'verifying', {});
|
|
240
|
-
await verifyTargetHead(request, deps, clone.sha);
|
|
280
|
+
await verifyTargetHead(request, deps, landing, clone.sha);
|
|
241
281
|
|
|
242
282
|
const materializedAt = Date.now();
|
|
243
283
|
|
|
@@ -306,6 +346,16 @@ async function resolveSource(
|
|
|
306
346
|
};
|
|
307
347
|
}
|
|
308
348
|
|
|
349
|
+
// A credential the clone would refuse is refused here, before any host
|
|
350
|
+
// is readied for it.
|
|
351
|
+
if (source.credentialRef !== undefined && findCredentialSecret(source) === null) {
|
|
352
|
+
throw new DaemonError(
|
|
353
|
+
'credential_missing',
|
|
354
|
+
'the credential environment variable is unset or empty',
|
|
355
|
+
{ phase: 'resolving' },
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
309
359
|
// The clone fetches the URL it records, so the spawn API's `owner/repo`
|
|
310
360
|
// shorthand reaches the repository it expands to.
|
|
311
361
|
const resolved = await resolveGitURL(expandGitShorthand(source.url), staging, transports);
|
|
@@ -359,11 +409,14 @@ async function requireNoURLCredentials(url: string, cwd: string): Promise<void>
|
|
|
359
409
|
async function claimTargetDir(
|
|
360
410
|
request: MaterializeRequest,
|
|
361
411
|
deps: MaterializeDeps,
|
|
412
|
+
landing: Landing,
|
|
362
413
|
updateProgress: ProgressTracker,
|
|
363
414
|
): Promise<void> {
|
|
364
|
-
const parent = await deps
|
|
365
|
-
|
|
366
|
-
|
|
415
|
+
const parent = await deps.requireProvider('run').runCommand({
|
|
416
|
+
argv: ['mkdir', '-p', '--', dirname(landing.dir)],
|
|
417
|
+
cwd: '/',
|
|
418
|
+
host: landing.host,
|
|
419
|
+
});
|
|
367
420
|
|
|
368
421
|
if (parent.exitCode !== 0) {
|
|
369
422
|
throw new DaemonError(
|
|
@@ -375,7 +428,7 @@ async function claimTargetDir(
|
|
|
375
428
|
|
|
376
429
|
const claim = await deps
|
|
377
430
|
.requireProvider('run')
|
|
378
|
-
.runCommand({ argv: ['mkdir', '--',
|
|
431
|
+
.runCommand({ argv: ['mkdir', '--', landing.dir], cwd: '/', host: landing.host });
|
|
379
432
|
|
|
380
433
|
if (claim.exitCode !== 0) {
|
|
381
434
|
throw new DaemonError(
|
|
@@ -487,11 +540,12 @@ const STATUS_ARGV = [
|
|
|
487
540
|
async function verifyTargetHead(
|
|
488
541
|
request: MaterializeRequest,
|
|
489
542
|
deps: MaterializeDeps,
|
|
543
|
+
landing: Landing,
|
|
490
544
|
sha: string,
|
|
491
545
|
): Promise<void> {
|
|
492
546
|
const head = await deps
|
|
493
547
|
.requireProvider('run')
|
|
494
|
-
.runCommand({ argv: [...VERIFY_ENV, ...VERIFY_ARGV], cwd:
|
|
548
|
+
.runCommand({ argv: [...VERIFY_ENV, ...VERIFY_ARGV], cwd: landing.dir, host: landing.host });
|
|
495
549
|
|
|
496
550
|
const actual = head.exitCode === 0 ? head.stdout.trim() : null;
|
|
497
551
|
|
|
@@ -505,7 +559,7 @@ async function verifyTargetHead(
|
|
|
505
559
|
|
|
506
560
|
const status = await deps
|
|
507
561
|
.requireProvider('run')
|
|
508
|
-
.runCommand({ argv: [...VERIFY_ENV, ...STATUS_ARGV], cwd:
|
|
562
|
+
.runCommand({ argv: [...VERIFY_ENV, ...STATUS_ARGV], cwd: landing.dir, host: landing.host });
|
|
509
563
|
|
|
510
564
|
const changed = status.stdout.split('\n').filter((line) => line !== '');
|
|
511
565
|
|
|
@@ -550,35 +604,43 @@ function toRedacted(text: string, secret: string): string {
|
|
|
550
604
|
|
|
551
605
|
/**
|
|
552
606
|
* Removes the target directory after a failure, when this call created it,
|
|
553
|
-
* so a failed materialization leaves no partial checkout behind.
|
|
554
|
-
*
|
|
555
|
-
*
|
|
607
|
+
* so a failed materialization leaves no partial checkout behind. Resolves
|
|
608
|
+
* to the directory when it stays, logged with the reason: one another
|
|
609
|
+
* session's directory lies inside, one that no longer resolves to itself,
|
|
610
|
+
* or one whose removal failed. It then blocks the next materialization
|
|
611
|
+
* into it with `workspace_exists` until an operator removes it.
|
|
556
612
|
*/
|
|
557
613
|
async function tryRemoveClaimedDir(
|
|
558
614
|
request: MaterializeRequest,
|
|
559
615
|
deps: MaterializeDeps,
|
|
560
616
|
progress: Readonly<MaterializationProgress>,
|
|
561
617
|
secret: string | null,
|
|
562
|
-
): Promise<
|
|
563
|
-
if (!progress.claimed) {
|
|
564
|
-
return;
|
|
618
|
+
): Promise<string | null> {
|
|
619
|
+
if (!progress.claimed || progress.landing === null) {
|
|
620
|
+
return null;
|
|
565
621
|
}
|
|
566
622
|
|
|
623
|
+
const dir = progress.landing.dir;
|
|
624
|
+
|
|
567
625
|
try {
|
|
568
|
-
const removed = await deps
|
|
569
|
-
.requireProvider('run')
|
|
570
|
-
.runCommand({ argv: ['rm', '-rf', '--', request.dir], cwd: '/' });
|
|
626
|
+
const removed = await deps.removeClaim(dir);
|
|
571
627
|
|
|
572
|
-
if (removed
|
|
573
|
-
|
|
628
|
+
if (removed) {
|
|
629
|
+
return null;
|
|
574
630
|
}
|
|
631
|
+
|
|
632
|
+
deps.log(
|
|
633
|
+
`atc: left ${dir} on target '${request.target}' after its workspace for session ${request.sessionID} failed: another session's directory lies inside it, or it no longer resolves to itself; remove it by hand`,
|
|
634
|
+
);
|
|
575
635
|
} catch (error) {
|
|
576
636
|
const reason = toScrubbedRefusal(error, progress.phase, secret).message;
|
|
577
637
|
|
|
578
638
|
deps.log(
|
|
579
|
-
`atc:
|
|
639
|
+
`atc: left ${dir} on target '${request.target}' after its workspace for session ${request.sessionID} failed, since removing it failed: ${reason}; remove it by hand`,
|
|
580
640
|
);
|
|
581
641
|
}
|
|
642
|
+
|
|
643
|
+
return dir;
|
|
582
644
|
}
|
|
583
645
|
|
|
584
646
|
async function tryUpdateFailed(
|
package/src/daemon/sessions.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { posix } from 'node:path';
|
|
2
3
|
import type {
|
|
3
4
|
AgentAdapter,
|
|
4
5
|
GuestPaths,
|
|
@@ -33,6 +34,7 @@ import { buildSessionLifecycle } from './build-session-lifecycle';
|
|
|
33
34
|
import type { SessionLifecycle } from './build-session-lifecycle';
|
|
34
35
|
import { buildTarArchive } from './build-tar-archive';
|
|
35
36
|
import { buildTargetIdentity } from './build-target-identity';
|
|
37
|
+
import { EffectRemainsError } from './effect-remains-error';
|
|
36
38
|
import type {
|
|
37
39
|
ExecutionCapability,
|
|
38
40
|
ExecutionProvider,
|
|
@@ -185,6 +187,54 @@ interface MaterializedSpawn {
|
|
|
185
187
|
readonly withheldEnv: readonly string[];
|
|
186
188
|
}
|
|
187
189
|
|
|
190
|
+
// Builds a spawn's workspace on a target bound to an identity, calling
|
|
191
|
+
// readyHost for the host it lands on once its source resolves and asking
|
|
192
|
+
// canRemoveClaim before a failure removes the directory it claimed, or
|
|
193
|
+
// resolves to null for a directory that runs as it stands.
|
|
194
|
+
type SpawnMaterializer = (
|
|
195
|
+
host: SpawnHostAccess,
|
|
196
|
+
targetIdentity: string,
|
|
197
|
+
) => Promise<MaterializedSpawn | null>;
|
|
198
|
+
|
|
199
|
+
interface SpawnHostAccess {
|
|
200
|
+
readonly readyHost: () => Promise<{ readonly host: SessionID; readonly dir: string }>;
|
|
201
|
+
readonly removeClaim: (dir: string) => Promise<boolean>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// A workspace directory a spawn on a shared host holds, as the spawn gave
|
|
205
|
+
// it and as the host resolves it once checked there.
|
|
206
|
+
interface WorkspaceReservation {
|
|
207
|
+
readonly hostKey: SessionID;
|
|
208
|
+
readonly target: string;
|
|
209
|
+
readonly dir: string;
|
|
210
|
+
resolved: string | null;
|
|
211
|
+
|
|
212
|
+
// A workspace spawn builds its directory and may remove it on a failure;
|
|
213
|
+
// a plain spawn only starts in its directory, which another plain spawn
|
|
214
|
+
// may share.
|
|
215
|
+
readonly kind: 'workspace' | 'plain';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Prints a directory with every symlink in it resolved: its nearest
|
|
219
|
+
// existing directory as the host resolves it and a newline, which keeps a
|
|
220
|
+
// name that ends in newlines whole, then a NUL and the rest of the path as
|
|
221
|
+
// given.
|
|
222
|
+
const RESOLVE_DIR_SCRIPT = `p=$1; s=; while [ ! -d "$p" ]; do s=/\${p##*/}$s; p=\${p%/*}; [ -n "$p" ] || p=/; done; cd -P -- "$p" && pwd -P && printf '\\0%s' "$s"`;
|
|
223
|
+
|
|
224
|
+
// Removes a directory only while it still resolves to itself: it enters
|
|
225
|
+
// the directory, compares where it landed with the path it was given, and
|
|
226
|
+
// removes the contents from inside it, so no symlink changed on the way is
|
|
227
|
+
// followed, then the directory itself, which is empty by then.
|
|
228
|
+
const REMOVE_DIR_SCRIPT =
|
|
229
|
+
'cd -P -- "$1" || exit 3; [ "$(pwd -P; printf x)" = "$2" ] || exit 4; find . -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + || exit 5; cd / && rmdir -- "$1"';
|
|
230
|
+
|
|
231
|
+
// A readied host's harness plan, and the auth attempt that provisioned the
|
|
232
|
+
// host, if one did.
|
|
233
|
+
interface HarnessSetup {
|
|
234
|
+
readonly plan: HarnessPlan;
|
|
235
|
+
readonly attemptID: string | null;
|
|
236
|
+
}
|
|
237
|
+
|
|
188
238
|
// The identity of the implicit `local` target, which a fleet row without a
|
|
189
239
|
// stored identity ran on.
|
|
190
240
|
const LOCAL_TARGET_IDENTITY = buildTargetIdentity('local-pty', {});
|
|
@@ -283,6 +333,14 @@ export class SessionManager {
|
|
|
283
333
|
// so a host with a launch in flight is never idle.
|
|
284
334
|
private readonly readying = new Map<SessionID, number>();
|
|
285
335
|
|
|
336
|
+
// The workspace directory each spawn on a shared host holds from its
|
|
337
|
+
// overlap check until its session lists or its spawn fails, by spawn id.
|
|
338
|
+
private readonly reservations = new Map<SessionID, WorkspaceReservation>();
|
|
339
|
+
|
|
340
|
+
// The shared hosts with a workspace rollback removing a directory now,
|
|
341
|
+
// each with how many removals run there.
|
|
342
|
+
private readonly removals = new Map<string, number>();
|
|
343
|
+
|
|
286
344
|
// Sessions dropped from the list on purpose whose rows the next fleet
|
|
287
345
|
// write deletes; each stays here until a write carrying it lands.
|
|
288
346
|
private readonly removedIDs = new Set<SessionID>();
|
|
@@ -805,9 +863,11 @@ export class SessionManager {
|
|
|
805
863
|
// the new process runs with, and the session keeps them for every revive.
|
|
806
864
|
// id is minted here unless the caller minted it ahead of the spawn. target
|
|
807
865
|
// is the execution target the harness runs on; one this daemon cannot use
|
|
808
|
-
// refuses the spawn before anything starts.
|
|
809
|
-
//
|
|
810
|
-
//
|
|
866
|
+
// refuses the spawn before anything starts. materialize builds cwd on the
|
|
867
|
+
// host once every refusal has passed and the host is ready, and returns
|
|
868
|
+
// what cwd was materialized from, when it was, and the variables the
|
|
869
|
+
// session's harnesses go without; a failure there takes back the host the
|
|
870
|
+
// spawn readied, unless it is a parent's.
|
|
811
871
|
async spawn(
|
|
812
872
|
cwd: string,
|
|
813
873
|
name: string,
|
|
@@ -821,7 +881,7 @@ export class SessionManager {
|
|
|
821
881
|
overrides: SpawnOverrides = {},
|
|
822
882
|
id: SessionID = mintSessionID(),
|
|
823
883
|
target = 'local',
|
|
824
|
-
|
|
884
|
+
materialize: SpawnMaterializer | null = null,
|
|
825
885
|
requireInReach: () => void = () => {},
|
|
826
886
|
): Promise<Session> {
|
|
827
887
|
const adapter = this.findAdapter(agent);
|
|
@@ -853,16 +913,52 @@ export class SessionManager {
|
|
|
853
913
|
targetIdentity: execution.identity,
|
|
854
914
|
};
|
|
855
915
|
|
|
856
|
-
const
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
916
|
+
const refusal = adapter.findSpawnRefusal?.() ?? null;
|
|
917
|
+
|
|
918
|
+
if (refusal !== null) {
|
|
919
|
+
throw refusal;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (hostKey !== id) {
|
|
923
|
+
if (materialize === null) {
|
|
924
|
+
this.claimPlainDir(id, hostKey, target, cwd);
|
|
925
|
+
} else {
|
|
926
|
+
this.claimWorkspace(id, hostKey, target, cwd);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const setupHost = () =>
|
|
931
|
+
this.setupHarnessOnHost(
|
|
932
|
+
adapter,
|
|
933
|
+
provider,
|
|
934
|
+
id,
|
|
935
|
+
hostKey,
|
|
936
|
+
target,
|
|
937
|
+
{ prompt, resume, ...overrides },
|
|
938
|
+
authSetup,
|
|
939
|
+
);
|
|
865
940
|
|
|
941
|
+
// The host stays readying until its workspace is in place, so nothing
|
|
942
|
+
// gives its lease back or puts it to sleep in between.
|
|
943
|
+
const prepared = await this.withHostReadying(hostKey, async () => {
|
|
944
|
+
if (materialize === null) {
|
|
945
|
+
return { setup: await setupHost(), materialized: null };
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
return this.materializeOnSpawnHost(
|
|
949
|
+
provider,
|
|
950
|
+
id,
|
|
951
|
+
hostKey,
|
|
952
|
+
target,
|
|
953
|
+
cwd,
|
|
954
|
+
execution.identity,
|
|
955
|
+
materialize,
|
|
956
|
+
setupHost,
|
|
957
|
+
);
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
const setup = prepared.setup;
|
|
961
|
+
const materialized = prepared.materialized;
|
|
866
962
|
const plan = setup.plan;
|
|
867
963
|
|
|
868
964
|
// The caller's check runs again once the host is ready, before the
|
|
@@ -933,6 +1029,7 @@ export class SessionManager {
|
|
|
933
1029
|
|
|
934
1030
|
this.attachHarness(session, pty, this.hasHostLifecycle(target));
|
|
935
1031
|
this.sessions.push(session);
|
|
1032
|
+
this.releaseWorkspace(id);
|
|
936
1033
|
void this.tryWriteFleet(session.id);
|
|
937
1034
|
this.writeStatus();
|
|
938
1035
|
this.onEvent('added', session);
|
|
@@ -990,7 +1087,8 @@ export class SessionManager {
|
|
|
990
1087
|
}
|
|
991
1088
|
|
|
992
1089
|
// A sub-session joins its parent's host only under the binding that host
|
|
993
|
-
// holds: both without runtime auth, or both bound to the same hash
|
|
1090
|
+
// holds: both without runtime auth, or both bound to the same hash, and
|
|
1091
|
+
// then only while that binding is ready.
|
|
994
1092
|
private async requireSharedBinding(
|
|
995
1093
|
hostKey: SessionID,
|
|
996
1094
|
binding: AuthBinding | null,
|
|
@@ -1004,6 +1102,14 @@ export class SessionManager {
|
|
|
1004
1102
|
}
|
|
1005
1103
|
|
|
1006
1104
|
if (held !== null && binding !== null && held.bindingHash === binding.hash) {
|
|
1105
|
+
if (held.state !== 'ready') {
|
|
1106
|
+
throw new DaemonError(
|
|
1107
|
+
'auth_blocked',
|
|
1108
|
+
`the runtime auth of host ${hostKey} is ${held.state}; rebind it to launch again`,
|
|
1109
|
+
{ host: hostKey, state: held.state },
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1007
1113
|
return;
|
|
1008
1114
|
}
|
|
1009
1115
|
|
|
@@ -1014,6 +1120,375 @@ export class SessionManager {
|
|
|
1014
1120
|
);
|
|
1015
1121
|
}
|
|
1016
1122
|
|
|
1123
|
+
/**
|
|
1124
|
+
* Gives back the workspace directory a spawn reserved on a shared host.
|
|
1125
|
+
* The spawn's session listing gives it back, and so does a failed spawn,
|
|
1126
|
+
* except one whose effects may still stand, whose directory stays
|
|
1127
|
+
* reserved so no other workspace lands in or around what it left.
|
|
1128
|
+
*/
|
|
1129
|
+
releaseWorkspace(id: SessionID): void {
|
|
1130
|
+
this.reservations.delete(id);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Claims a workspace's directory on a shared host, refusing one inside
|
|
1134
|
+
// or around the directory of a session listed there or of another spawn's
|
|
1135
|
+
// reservation. The check and the reservation run in one turn, so of two
|
|
1136
|
+
// concurrent spawns at most one passes.
|
|
1137
|
+
private claimWorkspace(id: SessionID, hostKey: SessionID, target: string, dir: string): void {
|
|
1138
|
+
const listed = this.sessions
|
|
1139
|
+
.filter((s) => s.hostKey === hostKey && s.target === target && posix.isAbsolute(s.cwd))
|
|
1140
|
+
.map((s) => [s.id, s.cwd] as const);
|
|
1141
|
+
|
|
1142
|
+
this.requireSeparateWorkspace(id, hostKey, target, dir, [dir], listed);
|
|
1143
|
+
this.reservations.set(id, { hostKey, target, dir, resolved: null, kind: 'workspace' });
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// Holds a plain spawn's directory on a shared host until the session
|
|
1147
|
+
// lists, refusing one inside or around a directory a workspace spawn is
|
|
1148
|
+
// still building there: that spawn's rollback could remove it. While a
|
|
1149
|
+
// rollback removes a directory on the host, every plain spawn there is
|
|
1150
|
+
// refused, since its directory may reach the one going through a
|
|
1151
|
+
// symlink no check has resolved yet. Plain spawns share directories
|
|
1152
|
+
// freely, and a rollback resolves each held plain directory on the host
|
|
1153
|
+
// and keeps a directory that holds one.
|
|
1154
|
+
private claimPlainDir(id: SessionID, hostKey: SessionID, target: string, dir: string): void {
|
|
1155
|
+
if ((this.removals.get(buildHostSlot(hostKey, target)) ?? 0) > 0) {
|
|
1156
|
+
throw new DaemonError(
|
|
1157
|
+
'workspace_overlap',
|
|
1158
|
+
`a failed workspace spawn is removing its directory on the host of session ${hostKey}; spawn again once it is done`,
|
|
1159
|
+
{ phase: 'resolving', dir, session: hostKey },
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
for (const [other, r] of this.reservations) {
|
|
1164
|
+
if (!posix.isAbsolute(dir)) {
|
|
1165
|
+
break;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
if (
|
|
1169
|
+
other !== id &&
|
|
1170
|
+
r.kind === 'workspace' &&
|
|
1171
|
+
r.hostKey === hostKey &&
|
|
1172
|
+
r.target === target &&
|
|
1173
|
+
[r.dir, r.resolved ?? r.dir].some((held) => isPathOverlapping(dir, held))
|
|
1174
|
+
) {
|
|
1175
|
+
throw new DaemonError(
|
|
1176
|
+
'workspace_overlap',
|
|
1177
|
+
`${dir} overlaps ${r.dir}, where session ${other} is still building its workspace on the same host`,
|
|
1178
|
+
{ phase: 'resolving', dir, session: other },
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
this.reservations.set(id, { hostKey, target, dir, resolved: null, kind: 'plain' });
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// The directory a spawn's workspace lands in, as the readied host
|
|
1187
|
+
// resolves it: every later step creates, fills, and removes this
|
|
1188
|
+
// physical path, never the requested one. On a shared host the spawn's
|
|
1189
|
+
// claim is checked again against every directory as the host resolves
|
|
1190
|
+
// it, and records the physical path, in the turn after the last wait,
|
|
1191
|
+
// with the sessions listed then.
|
|
1192
|
+
private async claimHostDir(
|
|
1193
|
+
provider: ExecutionProvider,
|
|
1194
|
+
id: SessionID,
|
|
1195
|
+
hostKey: SessionID,
|
|
1196
|
+
target: string,
|
|
1197
|
+
dir: string,
|
|
1198
|
+
): Promise<string> {
|
|
1199
|
+
const before = this.collectHostSessionIDs(hostKey, target);
|
|
1200
|
+
|
|
1201
|
+
const physical = await this.resolveHostDir(provider, hostKey, dir);
|
|
1202
|
+
const listed = await this.resolveListedDirs(provider, hostKey, target);
|
|
1203
|
+
|
|
1204
|
+
if (this.collectHostSessionIDs(hostKey, target) !== before) {
|
|
1205
|
+
return this.claimHostDir(provider, id, hostKey, target, dir);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
const reservation = this.reservations.get(id);
|
|
1209
|
+
|
|
1210
|
+
if (reservation !== undefined) {
|
|
1211
|
+
this.requireSeparateWorkspace(id, hostKey, target, dir, [dir, physical], listed.dirs);
|
|
1212
|
+
|
|
1213
|
+
reservation.resolved = physical;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
return physical;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// Removes the physical directory a failed spawn claimed, and resolves to
|
|
1220
|
+
// whether it did. It removes nothing while the directory, as the host
|
|
1221
|
+
// resolves it in the turn after the last wait, holds the directory of a
|
|
1222
|
+
// listed session or of another spawn's claim, or once the directory no
|
|
1223
|
+
// longer resolves to itself on the host; the directory then stays for an
|
|
1224
|
+
// operator to remove.
|
|
1225
|
+
private async removeClaimedDir(
|
|
1226
|
+
provider: ExecutionProvider,
|
|
1227
|
+
id: SessionID,
|
|
1228
|
+
hostKey: SessionID,
|
|
1229
|
+
target: string,
|
|
1230
|
+
dir: string,
|
|
1231
|
+
): Promise<boolean> {
|
|
1232
|
+
if (!this.hasHostLifecycle(target)) {
|
|
1233
|
+
const removed = await provider.runCommand({
|
|
1234
|
+
argv: ['sh', '-c', REMOVE_DIR_SCRIPT, 'sh', dir, `${dir}\nx`],
|
|
1235
|
+
cwd: '/',
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
return removed.exitCode === 0;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
const before = this.collectHostSessionIDs(hostKey, target);
|
|
1242
|
+
|
|
1243
|
+
const listed = await this.resolveListedDirs(provider, hostKey, target);
|
|
1244
|
+
|
|
1245
|
+
if (this.collectHostSessionIDs(hostKey, target) !== before) {
|
|
1246
|
+
return this.removeClaimedDir(provider, id, hostKey, target, dir);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// A directory the host could not resolve may lie inside through a
|
|
1250
|
+
// symlink, so the rollback keeps its own.
|
|
1251
|
+
if (listed.unresolved) {
|
|
1252
|
+
return false;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const holds = this.collectOtherWorkspaceDirs(id, hostKey, target, listed.dirs).some(
|
|
1256
|
+
([, other]) => isPathWithin(other, dir),
|
|
1257
|
+
);
|
|
1258
|
+
|
|
1259
|
+
if (holds) {
|
|
1260
|
+
return false;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
const slot = buildHostSlot(hostKey, target);
|
|
1264
|
+
|
|
1265
|
+
this.removals.set(slot, (this.removals.get(slot) ?? 0) + 1);
|
|
1266
|
+
|
|
1267
|
+
try {
|
|
1268
|
+
const removed = await provider.runCommand({
|
|
1269
|
+
argv: ['sh', '-c', REMOVE_DIR_SCRIPT, 'sh', dir, `${dir}\nx`],
|
|
1270
|
+
cwd: '/',
|
|
1271
|
+
host: hostKey,
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
return removed.exitCode === 0;
|
|
1275
|
+
} finally {
|
|
1276
|
+
const left = (this.removals.get(slot) ?? 1) - 1;
|
|
1277
|
+
|
|
1278
|
+
if (left === 0) {
|
|
1279
|
+
this.removals.delete(slot);
|
|
1280
|
+
} else {
|
|
1281
|
+
this.removals.set(slot, left);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// The ids of the sessions listed on a host and of the plain spawns still
|
|
1287
|
+
// starting there, as one string a later read compares against.
|
|
1288
|
+
private collectHostSessionIDs(hostKey: SessionID, target: string): string {
|
|
1289
|
+
return [
|
|
1290
|
+
...this.sessions.filter((s) => s.hostKey === hostKey && s.target === target).map((s) => s.id),
|
|
1291
|
+
...this.collectPlainDirs(hostKey, target).map(([id]) => id),
|
|
1292
|
+
].join(' ');
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// The directories plain spawns still starting on a host hold, as given.
|
|
1296
|
+
private collectPlainDirs(hostKey: SessionID, target: string): (readonly [SessionID, string])[] {
|
|
1297
|
+
return [...this.reservations]
|
|
1298
|
+
.filter(([, r]) => r.kind === 'plain' && r.hostKey === hostKey && r.target === target)
|
|
1299
|
+
.map(([id, r]) => [id, r.dir] as const);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
private requireSeparateWorkspace(
|
|
1303
|
+
id: SessionID,
|
|
1304
|
+
hostKey: SessionID,
|
|
1305
|
+
target: string,
|
|
1306
|
+
dir: string,
|
|
1307
|
+
forms: readonly string[],
|
|
1308
|
+
listed: readonly (readonly [SessionID, string])[],
|
|
1309
|
+
): void {
|
|
1310
|
+
const other = this.collectOtherWorkspaceDirs(id, hostKey, target, listed).find(([, path]) =>
|
|
1311
|
+
forms.some((form) => isPathOverlapping(form, path)),
|
|
1312
|
+
);
|
|
1313
|
+
|
|
1314
|
+
if (other !== undefined) {
|
|
1315
|
+
throw new DaemonError(
|
|
1316
|
+
'workspace_overlap',
|
|
1317
|
+
`${dir} overlaps ${other[1]}, the directory of session ${other[0]} on the same host; a workspace there lands beside it`,
|
|
1318
|
+
{ phase: 'resolving', dir, session: other[0] },
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// The directories other sessions on a host hold: the listed ones given,
|
|
1324
|
+
// and every other spawn's reservation in each form it has.
|
|
1325
|
+
private collectOtherWorkspaceDirs(
|
|
1326
|
+
id: SessionID,
|
|
1327
|
+
hostKey: SessionID,
|
|
1328
|
+
target: string,
|
|
1329
|
+
listed: readonly (readonly [SessionID, string])[],
|
|
1330
|
+
): (readonly [SessionID, string])[] {
|
|
1331
|
+
const dirs = [...listed];
|
|
1332
|
+
|
|
1333
|
+
for (const [other, r] of this.reservations) {
|
|
1334
|
+
if (other !== id && r.hostKey === hostKey && r.target === target) {
|
|
1335
|
+
// A relative plain directory counts only once the host resolves
|
|
1336
|
+
// it, since it resolves against the session's home there.
|
|
1337
|
+
dirs.push(
|
|
1338
|
+
...[r.dir, r.resolved ?? r.dir]
|
|
1339
|
+
.filter((held) => posix.isAbsolute(held))
|
|
1340
|
+
.map((held) => [other, held] as const),
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
return dirs;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// The directory of every session listed on a host, and of every plain
|
|
1349
|
+
// spawn still starting there, as the host resolves it, and its own
|
|
1350
|
+
// absolute form, and whether any of them failed to resolve, which a
|
|
1351
|
+
// rollback treats as a directory that may lie inside its own.
|
|
1352
|
+
private async resolveListedDirs(
|
|
1353
|
+
provider: ExecutionProvider,
|
|
1354
|
+
hostKey: SessionID,
|
|
1355
|
+
target: string,
|
|
1356
|
+
): Promise<{ readonly dirs: (readonly [SessionID, string])[]; readonly unresolved: boolean }> {
|
|
1357
|
+
const onHost = [
|
|
1358
|
+
...this.sessions.filter((s) => s.hostKey === hostKey && s.target === target),
|
|
1359
|
+
...this.collectPlainDirs(hostKey, target).map(([id, cwd]) => ({ id, cwd })),
|
|
1360
|
+
];
|
|
1361
|
+
|
|
1362
|
+
const resolved = await Promise.all(
|
|
1363
|
+
onHost.map(async (s) => {
|
|
1364
|
+
const dir = await this.resolveHostDir(provider, hostKey, s.cwd).catch(() => null);
|
|
1365
|
+
|
|
1366
|
+
const dirs: (readonly [SessionID, string])[] = [];
|
|
1367
|
+
|
|
1368
|
+
if (posix.isAbsolute(s.cwd)) {
|
|
1369
|
+
dirs.push([s.id, s.cwd]);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
if (dir !== null) {
|
|
1373
|
+
dirs.push([s.id, dir]);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
return { dirs, unresolved: dir === null };
|
|
1377
|
+
}),
|
|
1378
|
+
);
|
|
1379
|
+
|
|
1380
|
+
return {
|
|
1381
|
+
dirs: resolved.flatMap((r) => r.dirs),
|
|
1382
|
+
unresolved: resolved.some((r) => r.unresolved),
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// A directory on a host with every symlink in it resolved: an absolute
|
|
1387
|
+
// one through its nearest existing directory, and a relative one as a
|
|
1388
|
+
// harness started in it sees it, since impd resolves both alike. A null
|
|
1389
|
+
// host resolves it on the daemon's own machine.
|
|
1390
|
+
private async resolveHostDir(
|
|
1391
|
+
provider: ExecutionProvider,
|
|
1392
|
+
hostKey: SessionID | null,
|
|
1393
|
+
dir: string,
|
|
1394
|
+
): Promise<string> {
|
|
1395
|
+
const absolute = posix.isAbsolute(dir);
|
|
1396
|
+
|
|
1397
|
+
const result = await provider.runCommand({
|
|
1398
|
+
argv: ['sh', '-c', RESOLVE_DIR_SCRIPT, 'sh', absolute ? posix.normalize(dir) : '.'],
|
|
1399
|
+
cwd: absolute ? '/' : dir,
|
|
1400
|
+
...(hostKey === null ? {} : { host: hostKey }),
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
const split = result.stdout.lastIndexOf('\0');
|
|
1404
|
+
|
|
1405
|
+
if (result.exitCode !== 0 || split < 1 || result.stdout[split - 1] !== '\n') {
|
|
1406
|
+
throw new DaemonError(
|
|
1407
|
+
'host_unavailable',
|
|
1408
|
+
`${hostKey === null ? 'the daemon host' : `the host of session ${hostKey}`} cannot resolve ${dir}: ${result.stderr.trim()}`,
|
|
1409
|
+
{ phase: 'resolving', dir },
|
|
1410
|
+
);
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
const existing = result.stdout.slice(0, split - 1);
|
|
1414
|
+
const rest = result.stdout.slice(split + 1);
|
|
1415
|
+
|
|
1416
|
+
return existing === '/' ? rest || '/' : `${existing}${rest}`;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Materializes a spawn's workspace, readying its host once the source
|
|
1420
|
+
// resolves, or after the workspace for a directory that runs as it
|
|
1421
|
+
// stands. A failure once the host is ready takes it back.
|
|
1422
|
+
private async materializeOnSpawnHost(
|
|
1423
|
+
provider: ExecutionProvider,
|
|
1424
|
+
id: SessionID,
|
|
1425
|
+
hostKey: SessionID,
|
|
1426
|
+
target: string,
|
|
1427
|
+
dir: string,
|
|
1428
|
+
targetIdentity: string,
|
|
1429
|
+
materialize: SpawnMaterializer,
|
|
1430
|
+
setupHost: () => Promise<HarnessSetup>,
|
|
1431
|
+
): Promise<{ readonly setup: HarnessSetup; readonly materialized: MaterializedSpawn | null }> {
|
|
1432
|
+
const readied: { setup: HarnessSetup | null } = { setup: null };
|
|
1433
|
+
|
|
1434
|
+
try {
|
|
1435
|
+
const materialized = await materialize(
|
|
1436
|
+
{
|
|
1437
|
+
readyHost: async () => {
|
|
1438
|
+
readied.setup = await setupHost();
|
|
1439
|
+
|
|
1440
|
+
const landing = this.hasHostLifecycle(target)
|
|
1441
|
+
? await this.claimHostDir(provider, id, hostKey, target, dir)
|
|
1442
|
+
: await this.resolveHostDir(provider, null, dir);
|
|
1443
|
+
|
|
1444
|
+
return { host: hostKey, dir: landing };
|
|
1445
|
+
},
|
|
1446
|
+
removeClaim: (landing) => this.removeClaimedDir(provider, id, hostKey, target, landing),
|
|
1447
|
+
},
|
|
1448
|
+
targetIdentity,
|
|
1449
|
+
);
|
|
1450
|
+
|
|
1451
|
+
readied.setup ??= await setupHost();
|
|
1452
|
+
|
|
1453
|
+
return { setup: readied.setup, materialized };
|
|
1454
|
+
} catch (error) {
|
|
1455
|
+
if (readied.setup !== null) {
|
|
1456
|
+
await this.destroyFailedSpawnHost(provider, id, hostKey, readied.setup.attemptID);
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
throw error;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// Takes back the host a spawn readied when the spawn fails before its
|
|
1464
|
+
// session lists: an attempt that provisioned the host takes back its imp
|
|
1465
|
+
// and binding, and a host of the spawn's own without one is destroyed. A
|
|
1466
|
+
// parent's host stays as it is. A take-back that fails throws, and a
|
|
1467
|
+
// destroy that fails throws that the host may still stand.
|
|
1468
|
+
private async destroyFailedSpawnHost(
|
|
1469
|
+
provider: ExecutionProvider,
|
|
1470
|
+
id: SessionID,
|
|
1471
|
+
hostKey: SessionID,
|
|
1472
|
+
attemptID: string | null,
|
|
1473
|
+
): Promise<void> {
|
|
1474
|
+
if (attemptID !== null) {
|
|
1475
|
+
await this.tryRemoveAuthAttempt(provider, hostKey, attemptID);
|
|
1476
|
+
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
if (hostKey === id && provider.capabilities.destroy) {
|
|
1481
|
+
try {
|
|
1482
|
+
await provider.destroyHost(hostKey);
|
|
1483
|
+
} catch (error) {
|
|
1484
|
+
throw new EffectRemainsError(
|
|
1485
|
+
`spawn of session ${id} failed and destroying its host failed too`,
|
|
1486
|
+
{ cause: error },
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1017
1492
|
// Takes back what a spawn attempt bound when the spawn fails before its
|
|
1018
1493
|
// session lists; a take-back that cannot be confirmed throws.
|
|
1019
1494
|
private async tryRemoveAuthAttempt(
|
|
@@ -1105,7 +1580,7 @@ export class SessionManager {
|
|
|
1105
1580
|
// behind the broker has its binding created or verified before the host
|
|
1106
1581
|
// is readied, and a binding this call created is taken back when a later
|
|
1107
1582
|
// step fails; the attempt that created it comes back with the plan.
|
|
1108
|
-
private
|
|
1583
|
+
private setupHarness(
|
|
1109
1584
|
adapter: AgentAdapter,
|
|
1110
1585
|
provider: ExecutionProvider,
|
|
1111
1586
|
id: SessionID,
|
|
@@ -1114,10 +1589,17 @@ export class SessionManager {
|
|
|
1114
1589
|
options: SpawnOptions,
|
|
1115
1590
|
auth: HarnessAuthSetup | null,
|
|
1116
1591
|
): Promise<{ readonly plan: HarnessPlan; readonly attemptID: string | null }> {
|
|
1592
|
+
return this.withHostReadying(hostKey, () =>
|
|
1593
|
+
this.setupHarnessOnHost(adapter, provider, id, hostKey, target, options, auth),
|
|
1594
|
+
);
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// Counts a host as readying while run runs, so it is not idle then.
|
|
1598
|
+
private async withHostReadying<T>(hostKey: SessionID, run: () => Promise<T>): Promise<T> {
|
|
1117
1599
|
this.readying.set(hostKey, (this.readying.get(hostKey) ?? 0) + 1);
|
|
1118
1600
|
|
|
1119
1601
|
try {
|
|
1120
|
-
return await
|
|
1602
|
+
return await run();
|
|
1121
1603
|
} finally {
|
|
1122
1604
|
const left = (this.readying.get(hostKey) ?? 1) - 1;
|
|
1123
1605
|
|
|
@@ -2235,3 +2717,20 @@ function formatTargetRefusal(code: ErrorCode, target: string): string {
|
|
|
2235
2717
|
|
|
2236
2718
|
return `target '${target}' cannot start a terminal`;
|
|
2237
2719
|
}
|
|
2720
|
+
|
|
2721
|
+
// Whether either of two directories on a host holds the other, or they are
|
|
2722
|
+
// the same.
|
|
2723
|
+
// The key of a shared host on one target, for state kept per host.
|
|
2724
|
+
function buildHostSlot(hostKey: SessionID, target: string): string {
|
|
2725
|
+
return `${target}\0${hostKey}`;
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
function isPathOverlapping(a: string, b: string): boolean {
|
|
2729
|
+
return isPathWithin(a, b) || isPathWithin(b, a);
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
function isPathWithin(child: string, parent: string): boolean {
|
|
2733
|
+
const relative = posix.relative(parent, child);
|
|
2734
|
+
|
|
2735
|
+
return relative !== '..' && !relative.startsWith('../') && !posix.isAbsolute(relative);
|
|
2736
|
+
}
|