@parcha/pi-durable-disk 0.1.0-beta.12 → 0.1.0-beta.14
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/CHANGELOG.md +241 -0
- package/README.md +268 -28
- package/dist/app.d.ts +5 -0
- package/dist/app.js +3 -2
- package/dist/claim.d.ts +34 -2
- package/dist/claim.js +86 -5
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +98 -33
- package/dist/commands.d.ts +70 -0
- package/dist/commands.js +257 -0
- package/dist/confine.d.ts +47 -7
- package/dist/confine.js +148 -19
- package/dist/env.d.ts +17 -26
- package/dist/env.js +73 -68
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +1 -1
- package/dist/fork.d.ts +23 -0
- package/dist/fork.js +103 -3
- package/dist/hosts/daytona-launch.d.ts +3 -1
- package/dist/hosts/daytona-launch.js +30 -6
- package/dist/hosts/daytona.js +15 -3
- package/dist/hosts/local-host.d.ts +7 -1
- package/dist/hosts/local-host.js +30 -16
- package/dist/index.d.ts +31 -15
- package/dist/index.js +23 -15
- package/dist/internals.d.ts +3 -0
- package/dist/internals.js +6 -0
- package/dist/lease.d.ts +5 -2
- package/dist/lease.js +3 -1
- package/dist/local-run.d.ts +10 -0
- package/dist/local-run.js +64 -0
- package/dist/park.d.ts +4 -3
- package/dist/park.js +4 -3
- package/dist/process-table.d.ts +38 -0
- package/dist/process-table.js +158 -0
- package/dist/run.d.ts +52 -10
- package/dist/run.js +42 -7
- package/dist/status.d.ts +11 -3
- package/dist/status.js +15 -13
- package/dist/store.d.ts +14 -2
- package/dist/store.js +16 -4
- package/dist/supervise.d.ts +22 -3
- package/dist/supervise.js +40 -13
- package/dist/watchdog.d.ts +3 -1
- package/dist/watchdog.js +46 -13
- package/package.json +12 -7
package/dist/claim.js
CHANGED
|
@@ -195,18 +195,89 @@ async function runDirsByInode(control, inodes) {
|
|
|
195
195
|
}
|
|
196
196
|
return names;
|
|
197
197
|
}
|
|
198
|
-
/**
|
|
198
|
+
/** A client's private directories on the disk: `.archil/client-<clientId>` and its `unlinked/`. */
|
|
199
|
+
const PRIVATE_DIR = /^\/*\.archil\/client-([A-Za-z0-9_-]+)(\/unlinked)?$/;
|
|
200
|
+
/**
|
|
201
|
+
* The delegations `held`'s clients also hold on their own private directories (`privateDirs`: inode to the client whose
|
|
202
|
+
* private directory it is). A killed client leaves them orphaned, and nothing ties them to a run: they block no run, but
|
|
203
|
+
* they pile up and resurface in listings. A client that holds anything else beyond `held` may hold another run, so none
|
|
204
|
+
* of its delegations is a companion.
|
|
205
|
+
*/
|
|
206
|
+
export function companionsOf(all, held, privateDirs) {
|
|
207
|
+
const clients = new Set(held.map((d) => d.clientId));
|
|
208
|
+
const listed = new Set(held.map((d) => `${d.clientId}/${d.inodeId}`));
|
|
209
|
+
const others = all.filter((d) => clients.has(d.clientId) && !listed.has(`${d.clientId}/${d.inodeId}`));
|
|
210
|
+
const own = (d) => (d.path ? PRIVATE_DIR.exec(d.path)?.[1] : privateDirs.get(d.inodeId)) === d.clientId;
|
|
211
|
+
const holdsMore = new Set(others.filter((d) => !own(d)).map((d) => d.clientId));
|
|
212
|
+
return others.filter((d) => !holdsMore.has(d.clientId));
|
|
213
|
+
}
|
|
214
|
+
/** The inodes of `clients`' private directories, by one `exec` (`stat`); none without `exec`. */
|
|
215
|
+
async function privateDirInodes(control, clients) {
|
|
216
|
+
const asked = clients.filter((c) => /^[A-Za-z0-9_-]+$/.test(c));
|
|
217
|
+
const dirs = new Map();
|
|
218
|
+
if (!control.exec || asked.length === 0)
|
|
219
|
+
return dirs;
|
|
220
|
+
const paths = asked.flatMap((c) => [`.archil/client-${c}`, `.archil/client-${c}/unlinked`]);
|
|
221
|
+
const r = await control.exec(`stat -c '%i %n' -- ${paths.join(" ")} 2>/dev/null; true`);
|
|
222
|
+
for (const line of String(r.stdout ?? "").split("\n")) {
|
|
223
|
+
const m = /^(\d+) (\S+)$/.exec(line);
|
|
224
|
+
const client = m && PRIVATE_DIR.exec(m[2])?.[1];
|
|
225
|
+
if (m && client && asked.includes(client))
|
|
226
|
+
dirs.set(Number(m[1]), client);
|
|
227
|
+
}
|
|
228
|
+
return dirs;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Revoke the companions of `held` (`companionsOf`): only for clients the caller is already revoking because it fences or
|
|
232
|
+
* deletes a run, never as a sweep. From `all` or a fresh listing. Best effort, and hygiene only: a companion blocks no
|
|
233
|
+
* run, so one left behind (or a failed listing or `exec`) costs a listing entry. Returns the ones revoked.
|
|
234
|
+
*/
|
|
235
|
+
export async function revokeCompanions(control, held, all) {
|
|
236
|
+
if (held.length === 0)
|
|
237
|
+
return [];
|
|
238
|
+
const listing = all ?? (await control.listDelegations().catch(() => []));
|
|
239
|
+
const clients = [...new Set(held.map((d) => d.clientId))];
|
|
240
|
+
const listed = new Set(held.map((d) => `${d.clientId}/${d.inodeId}`));
|
|
241
|
+
if (!listing.some((d) => clients.includes(d.clientId) && !listed.has(`${d.clientId}/${d.inodeId}`)))
|
|
242
|
+
return [];
|
|
243
|
+
const dirs = await privateDirInodes(control, clients).catch(() => new Map());
|
|
244
|
+
const revoked = [];
|
|
245
|
+
for (const d of companionsOf(listing, held, dirs)) {
|
|
246
|
+
await control.revokeDelegation({ clientId: d.clientId, inodeId: d.inodeId }).then(() => revoked.push(d), () => { });
|
|
247
|
+
}
|
|
248
|
+
return revoked;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Revoke every delegation on the run through the control API, then its holders' private directories
|
|
252
|
+
* (`revokeCompanions`). The old holder's next fsync returns EIO. Returns the run's own delegations.
|
|
253
|
+
*/
|
|
199
254
|
export async function revoke(control, id) {
|
|
200
255
|
const path = runPath(id);
|
|
256
|
+
let all;
|
|
257
|
+
let held;
|
|
201
258
|
try {
|
|
202
|
-
|
|
259
|
+
all = await control.listDelegations();
|
|
260
|
+
held = await matchDelegations(all, id, pathlessResolver(control));
|
|
203
261
|
for (const d of held)
|
|
204
262
|
await control.revokeDelegation(d);
|
|
205
|
-
return held;
|
|
206
263
|
}
|
|
207
264
|
catch (err) {
|
|
208
265
|
throw new ClaimError("CONTROL_API_FAILED", `revoking ${path} failed`, { cause: err });
|
|
209
266
|
}
|
|
267
|
+
await revokeCompanions(control, held, all);
|
|
268
|
+
return held;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Revoke what `revoke` revokes, skipping every revoke that fails: for cleanup, where deleting the tree next checks what
|
|
272
|
+
* is left. Only the listing (and a pathless resolution) can fail it. Returns the run's own delegations.
|
|
273
|
+
*/
|
|
274
|
+
export async function revokeBestEffort(control, id) {
|
|
275
|
+
const all = await control.listDelegations();
|
|
276
|
+
const held = await matchDelegations(all, id, pathlessResolver(control));
|
|
277
|
+
for (const d of held)
|
|
278
|
+
await control.revokeDelegation({ clientId: d.clientId, inodeId: d.inodeId }).catch(() => { });
|
|
279
|
+
await revokeCompanions(control, held, all);
|
|
280
|
+
return held;
|
|
210
281
|
}
|
|
211
282
|
/** Revoke through the control API, then mount plainly; if the API path fails, mount with `--force`. */
|
|
212
283
|
export async function takeOver(control, opts) {
|
|
@@ -297,14 +368,24 @@ function isOurs(entry, ref) {
|
|
|
297
368
|
const base = `${ref.disk}:/${runPath(ref.id)}`;
|
|
298
369
|
return entry.fstype === "fuse.archil" && (entry.source === base || entry.source === `${base}[${ref.region}]`);
|
|
299
370
|
}
|
|
300
|
-
/**
|
|
371
|
+
/**
|
|
372
|
+
* The errno codes of a stat on a FUSE mount whose daemon is gone: ENOTCONN once its connection is torn down, ECONNABORTED
|
|
373
|
+
* for a stat already in flight while it is torn down. Codes, never messages: a message is translated by the locale.
|
|
374
|
+
*/
|
|
375
|
+
export const DEAD_CONNECTION = new Set(["ENOTCONN", "ECONNABORTED"]);
|
|
376
|
+
/**
|
|
377
|
+
* Whether the FUSE daemon behind a mount is gone, which `archil` reports as "not running". Its connection is torn down,
|
|
378
|
+
* so a stat fails with ENOTCONN; a stat already in flight while the dying daemon's connection is torn down (right after a
|
|
379
|
+
* kill -9, while its threads exit) fails with ECONNABORTED instead. Both are the connection's own end: a live daemon,
|
|
380
|
+
* however slow, answers, and a stopped one keeps the stat waiting.
|
|
381
|
+
*/
|
|
301
382
|
async function isDead(host, mountpoint) {
|
|
302
383
|
try {
|
|
303
384
|
await host.stat(mountpoint);
|
|
304
385
|
return false;
|
|
305
386
|
}
|
|
306
387
|
catch (err) {
|
|
307
|
-
return err.code
|
|
388
|
+
return DEAD_CONNECTION.has(String(err.code));
|
|
308
389
|
}
|
|
309
390
|
}
|
|
310
391
|
// Exit codes of the mount tools are not evidence (a busy mount can report success and stay); the mount table decides.
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { type Parking } from "./park.js";
|
|
3
3
|
import { type RunServer } from "./serve.js";
|
|
4
|
+
import { type ArchilControl, type DiskClient } from "./supervise.js";
|
|
4
5
|
import { type DurableRun } from "./run.js";
|
|
5
6
|
/** "30s", "500ms", "2m", "1h", or plain milliseconds. */
|
|
6
7
|
/**
|
|
@@ -28,6 +29,11 @@ export declare function runUser(spec: string, passwd?: string): {
|
|
|
28
29
|
home: string;
|
|
29
30
|
name: string;
|
|
30
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* The disk's control API for the supervisor (`archilControl`), from the API key in `--api-key-env` (default
|
|
34
|
+
* ARCHIL_API_KEY) and `--disk` and `--region`. `client` replaces the `disk` client (tests).
|
|
35
|
+
*/
|
|
36
|
+
export declare function cliControl(values: Record<string, unknown>, client?: DiskClient): Promise<ArchilControl>;
|
|
31
37
|
/** What the instance does besides the app between its open and its exit. */
|
|
32
38
|
export interface InstanceLife {
|
|
33
39
|
/** How long a drain waits for running work before the release; 0 (default) releases at once. */
|
package/dist/cli.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// The API key is read from the environment variable named by `--api-key-env` (default ARCHIL_API_KEY) and only by the
|
|
10
10
|
// supervisor commands; `run` never needs it and a host driver never passes it on.
|
|
11
11
|
import { closeSync, openSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
12
|
-
import { join, resolve } from "node:path";
|
|
12
|
+
import { basename, join, resolve } from "node:path";
|
|
13
13
|
import { pathToFileURL } from "node:url";
|
|
14
14
|
import { parseArgs } from "node:util";
|
|
15
15
|
import { ARCHIL_SCOPED, DEFAULT_MOUNT_ROOT, runPath, TOKEN_PREFIX, unmountClaim } from "./claim.js";
|
|
@@ -19,8 +19,11 @@ import { fork } from "./fork.js";
|
|
|
19
19
|
import { drain, recordWake, watchParking } from "./park.js";
|
|
20
20
|
import { isLoopback, readServeToken, serveRun } from "./serve.js";
|
|
21
21
|
import { dockerHost } from "./hosts/docker.js";
|
|
22
|
-
import { localHost } from "./hosts/local-host.js";
|
|
23
|
-
import {
|
|
22
|
+
import { localHost, procStartTicks } from "./hosts/local-host.js";
|
|
23
|
+
import { processTable, thisBoot } from "./process-table.js";
|
|
24
|
+
import { localRun } from "./local-run.js";
|
|
25
|
+
import { parseRunRecord, RUN_JSON } from "./status.js";
|
|
26
|
+
import { archilControl, checkHost, CONTROL_TIMEOUT_MS, readRunStatus, superviseRuns, sweepTokens, withTimeouts, } from "./supervise.js";
|
|
24
27
|
import { openDurableRun } from "./run.js";
|
|
25
28
|
import { AppError, loadApp } from "./app.js";
|
|
26
29
|
class UsageError extends PdaError {
|
|
@@ -29,6 +32,7 @@ class UsageError extends PdaError {
|
|
|
29
32
|
}
|
|
30
33
|
}
|
|
31
34
|
const USAGE = `usage:
|
|
35
|
+
pi-durable-disk run --dir PATH --app MODULE [--id ID] [any run flag below but the Archil ones]
|
|
32
36
|
pi-durable-disk run --disk D --region R --id ID --app MODULE [--mount-root DIR] [--archil BIN]
|
|
33
37
|
[--heartbeat-ms N] [--lease-expiry-ms N] [--lease-margin-ms N] [--on-sigterm resume|pause]
|
|
34
38
|
[--token-stdin] [--run-as USER] [--serve PORT] [--serve-host H] [--serve-url URL]
|
|
@@ -42,6 +46,7 @@ const USAGE = `usage:
|
|
|
42
46
|
with --host docker: --image IMAGE [--app-root DIR] [--fleet F] [--name-prefix P] [--run-as USER]
|
|
43
47
|
[--docker-arg ARG ...] [--apparmor auto|PROFILE|none] [--docker BIN]
|
|
44
48
|
pi-durable-disk supervise --check --disk D --region R [--mount-root DIR] [--user U] [--group G] [--check-id-prefix P]
|
|
49
|
+
pi-durable-disk status --dir PATH
|
|
45
50
|
pi-durable-disk status --disk D --region R --id ID [--host-name NAME] [--api-key-env NAME] [--docker BIN]
|
|
46
51
|
pi-durable-disk release --id ID [--mount-root DIR]
|
|
47
52
|
pi-durable-disk fork --disk D --region R --id ID --new-id NEW [--mount-root DIR] [--archil BIN] [--token-prefix P]
|
|
@@ -141,29 +146,16 @@ const need = (v, ...keys) => {
|
|
|
141
146
|
};
|
|
142
147
|
const str = (v, fallback) => (typeof v === "string" ? v : fallback);
|
|
143
148
|
const emit = (line) => process.stdout.write(`${JSON.stringify({ at: new Date().toISOString(), ...line })}\n`);
|
|
144
|
-
/**
|
|
145
|
-
|
|
149
|
+
/**
|
|
150
|
+
* The disk's control API for the supervisor (`archilControl`), from the API key in `--api-key-env` (default
|
|
151
|
+
* ARCHIL_API_KEY) and `--disk` and `--region`. `client` replaces the `disk` client (tests).
|
|
152
|
+
*/
|
|
153
|
+
export async function cliControl(values, client) {
|
|
146
154
|
const name = str(values["api-key-env"], "ARCHIL_API_KEY");
|
|
147
155
|
const apiKey = process.env[name];
|
|
148
156
|
if (!apiKey)
|
|
149
157
|
throw new UsageError(`no API key in $${name}`);
|
|
150
|
-
|
|
151
|
-
configure({ apiKey, region: str(values.region) });
|
|
152
|
-
const id = str(values.disk);
|
|
153
|
-
const disk = await getDisk(id);
|
|
154
|
-
return {
|
|
155
|
-
getObject: (key) => disk.getObject(key),
|
|
156
|
-
headObject: (key) => disk.headObject(key),
|
|
157
|
-
putObject: (key, body, options) => disk.putObject(key, body, options),
|
|
158
|
-
addUser: (user) => disk.addUser(user),
|
|
159
|
-
removeUser: (type, identifier) => disk.removeUser(type, identifier),
|
|
160
|
-
listDelegations: () => disk.listDelegations(),
|
|
161
|
-
revokeDelegation: (d) => disk.revokeDelegation(d),
|
|
162
|
-
exec: (command) => disk.exec(command),
|
|
163
|
-
listObjects: (prefix, options) => disk.listObjects(prefix, options),
|
|
164
|
-
deleteObjects: (keys, options) => disk.deleteObjects(keys, options),
|
|
165
|
-
listUsers: async () => (await getDisk(id)).authorizedUsers ?? [],
|
|
166
|
-
};
|
|
158
|
+
return archilControl({ apiKey, disk: str(values.disk), region: str(values.region), ...(client ? { client } : {}) });
|
|
167
159
|
}
|
|
168
160
|
// ---- run ---------------------------------------------------------------------------------------------------------------
|
|
169
161
|
/**
|
|
@@ -201,8 +193,12 @@ async function readToken(values) {
|
|
|
201
193
|
* between the open and the exit is `serveUntilDone`.
|
|
202
194
|
*/
|
|
203
195
|
async function runInstance(values) {
|
|
204
|
-
|
|
205
|
-
|
|
196
|
+
const dir = localDir(values, ["disk", "region", "mount-root", "archil", "token-stdin"]);
|
|
197
|
+
if (dir === undefined)
|
|
198
|
+
need(values, "disk", "region", "id", "app");
|
|
199
|
+
else
|
|
200
|
+
need(values, "app");
|
|
201
|
+
const ref = dir === undefined ? { disk: str(values.disk), region: str(values.region), id: str(values.id) } : { disk: "local", region: "local", id: str(values.id) ?? basename(dir) };
|
|
206
202
|
const runAs = typeof values["run-as"] === "string" ? runUser(values["run-as"]) : undefined;
|
|
207
203
|
if (runAs && process.getuid?.() !== 0)
|
|
208
204
|
throw new UsageError("--run-as needs an instance that runs as root (inside a container); elsewhere run the instance as the run user");
|
|
@@ -222,7 +218,7 @@ async function runInstance(values) {
|
|
|
222
218
|
throw new UsageError(`--serve-host ${serveHost} is not loopback: name a --serve-token-file`);
|
|
223
219
|
const serveToken = tokenFile === undefined ? undefined : readServeToken(tokenFile);
|
|
224
220
|
const mountRoot = str(values["mount-root"], DEFAULT_MOUNT_ROOT);
|
|
225
|
-
const root = join(mountRoot, runPath(ref.id));
|
|
221
|
+
const root = dir ?? join(mountRoot, runPath(ref.id));
|
|
226
222
|
const log = (event, extra = {}) => process.stderr.write(`${JSON.stringify({ at: new Date().toISOString(), event, run: ref.id, ...extra })}\n`);
|
|
227
223
|
let app;
|
|
228
224
|
try {
|
|
@@ -232,8 +228,12 @@ async function runInstance(values) {
|
|
|
232
228
|
log("app failed", { code: err.code, message: err.message });
|
|
233
229
|
return exitCodeFor(err);
|
|
234
230
|
}
|
|
235
|
-
const { onOpen, root: rootOptions, wake: appWake, ...harness } = app;
|
|
231
|
+
const { onOpen, beforeResume, root: rootOptions, wake: appWake, ...harness } = app;
|
|
236
232
|
const holder = JSON.parse(process.env.PDA_HOLDER ?? "{}");
|
|
233
|
+
// On a local directory nothing else knows this instance: its start ticks go into the holder, so `status --dir` tells it
|
|
234
|
+
// from a later process that reuses its pid (a driver's handle already carries them).
|
|
235
|
+
if (dir !== undefined && typeof holder.startTicks !== "number")
|
|
236
|
+
holder.startTicks = procStartTicks(process.pid);
|
|
237
237
|
const cgroup = watchdogOwnsCgroup(holder);
|
|
238
238
|
if (cgroup.warning)
|
|
239
239
|
log("cgroup not owned", { warning: cgroup.warning });
|
|
@@ -250,9 +250,8 @@ async function runInstance(values) {
|
|
|
250
250
|
let run;
|
|
251
251
|
try {
|
|
252
252
|
run = await openDurableRun(ref, {
|
|
253
|
-
|
|
254
|
-
mountRoot,
|
|
255
|
-
host: { archil: str(values.archil) },
|
|
253
|
+
// A local directory is its own claim (no mount, no token); a disk's run is mounted with a token.
|
|
254
|
+
...(dir === undefined ? { mountToken: await readToken(values), mountRoot, host: { archil: str(values.archil) } } : localRun(dir)),
|
|
256
255
|
harness,
|
|
257
256
|
...(runAs
|
|
258
257
|
? { env: (c) => archilEnv(c, { runAs: { uid: runAs.uid, gid: runAs.gid }, shellEnv: { HOME: runAs.home, USER: runAs.name, LOGNAME: runAs.name } }) }
|
|
@@ -261,6 +260,7 @@ async function runInstance(values) {
|
|
|
261
260
|
ownCgroup: cgroup.owns,
|
|
262
261
|
lease: Object.fromEntries(Object.entries(lease).filter(([, v]) => v !== undefined)),
|
|
263
262
|
onStep: (step, t) => void (steps[step] = Math.round(t)),
|
|
263
|
+
...(beforeResume ? { beforeResume } : {}),
|
|
264
264
|
});
|
|
265
265
|
}
|
|
266
266
|
catch (err) {
|
|
@@ -450,7 +450,7 @@ async function supervise(values) {
|
|
|
450
450
|
throw new UsageError("--check proves this host's own mounts; with --host docker the containers mount, so it does not apply");
|
|
451
451
|
if (docker && (typeof values.image !== "string" || !values.image))
|
|
452
452
|
throw new UsageError("--host docker needs --image");
|
|
453
|
-
const disk = await
|
|
453
|
+
const disk = await cliControl(values);
|
|
454
454
|
const controlTimeoutMs = values["control-timeout"] ? parseDuration(str(values["control-timeout"])) : CONTROL_TIMEOUT_MS;
|
|
455
455
|
if (values.check) {
|
|
456
456
|
const report = await checkHost({
|
|
@@ -536,8 +536,11 @@ async function supervise(values) {
|
|
|
536
536
|
return 0;
|
|
537
537
|
}
|
|
538
538
|
async function status(values) {
|
|
539
|
+
const dir = localDir(values, ["disk", "region", "mount-root", "api-key-env", "host-name", "docker"]);
|
|
540
|
+
if (dir !== undefined)
|
|
541
|
+
return localStatus(dir);
|
|
539
542
|
need(values, "disk", "region", "id");
|
|
540
|
-
const disk = await
|
|
543
|
+
const disk = await cliControl(values);
|
|
541
544
|
const id = str(values.id);
|
|
542
545
|
const run = await readRunStatus(disk, id);
|
|
543
546
|
const { findDelegations } = await import("./claim.js");
|
|
@@ -555,6 +558,67 @@ async function status(values) {
|
|
|
555
558
|
emit({ run: id, runJson: run, delegations: delegations.map(({ clientId, inodeId, path, isOrphaned, isPending }) => ({ clientId, inodeId, path, isOrphaned, isPending })), holderStatus, ...(container ? { container } : {}) });
|
|
556
559
|
return 0;
|
|
557
560
|
}
|
|
561
|
+
/**
|
|
562
|
+
* `--dir PATH`: the run is the local directory PATH (`localRun`), so the Archil flags mean nothing and are refused. Returns
|
|
563
|
+
* the directory, absolute; undefined without `--dir`.
|
|
564
|
+
*/
|
|
565
|
+
function localDir(values, archilFlags) {
|
|
566
|
+
if (values.dir === undefined)
|
|
567
|
+
return undefined;
|
|
568
|
+
const dir = str(values.dir);
|
|
569
|
+
if (!dir)
|
|
570
|
+
throw new UsageError("--dir needs a path");
|
|
571
|
+
const mixed = archilFlags.filter((flag) => values[flag] !== undefined);
|
|
572
|
+
if (mixed.length > 0)
|
|
573
|
+
throw new UsageError(`--dir is a run on a local directory: it takes no ${mixed.map((flag) => `--${flag}`).join(", ")}`);
|
|
574
|
+
return resolve(dir);
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* `status --dir PATH`: `run.json` read from the directory, and whether its holder is still the process that claimed it:
|
|
578
|
+
* `running` when it is this boot's and its pid is alive with the start ticks the holder recorded (a later process that
|
|
579
|
+
* reuses the pid has others), `stopped` when it is not, `gone` from another boot, and `unknown` when the holder recorded no
|
|
580
|
+
* start ticks (a run opened by a library caller) and its pid is alive.
|
|
581
|
+
*/
|
|
582
|
+
function localStatus(dir) {
|
|
583
|
+
let text;
|
|
584
|
+
try {
|
|
585
|
+
text = readFileSync(join(dir, RUN_JSON), "utf8");
|
|
586
|
+
}
|
|
587
|
+
catch (err) {
|
|
588
|
+
throw new UsageError(`no run in ${dir}: ${err.message}`);
|
|
589
|
+
}
|
|
590
|
+
const run = parseRunRecord(text);
|
|
591
|
+
let holderStatus = null;
|
|
592
|
+
const holder = run.holder;
|
|
593
|
+
if (holder) {
|
|
594
|
+
// A read that failed (sysctl or ps on macOS) is no exit: it says `unknown`, never `gone` or `stopped`.
|
|
595
|
+
const boot = thisBoot();
|
|
596
|
+
const ticks = startOf(Number(holder.pid));
|
|
597
|
+
if (boot === null)
|
|
598
|
+
holderStatus = "unknown";
|
|
599
|
+
else if (holder.bootId !== boot)
|
|
600
|
+
holderStatus = "gone";
|
|
601
|
+
else if (ticks === undefined)
|
|
602
|
+
holderStatus = "unknown";
|
|
603
|
+
else if (typeof holder.startTicks === "number")
|
|
604
|
+
holderStatus = ticks === holder.startTicks ? "running" : "stopped";
|
|
605
|
+
else
|
|
606
|
+
holderStatus = ticks === null ? "stopped" : "unknown";
|
|
607
|
+
}
|
|
608
|
+
emit({ run: run.run, dir, runJson: run, holderStatus });
|
|
609
|
+
return 0;
|
|
610
|
+
}
|
|
611
|
+
/** A live process's start (null: no such process, or a zombie), or undefined when the process table cannot be read. */
|
|
612
|
+
function startOf(pid) {
|
|
613
|
+
if (!Number.isInteger(pid) || pid <= 1)
|
|
614
|
+
return null;
|
|
615
|
+
try {
|
|
616
|
+
return processTable().snapshot().info(pid)?.start ?? null;
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
return undefined;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
558
622
|
async function release(values) {
|
|
559
623
|
need(values, "id");
|
|
560
624
|
const via = await unmountClaim(join(str(values["mount-root"], DEFAULT_MOUNT_ROOT), runPath(str(values.id))));
|
|
@@ -563,7 +627,7 @@ async function release(values) {
|
|
|
563
627
|
}
|
|
564
628
|
async function forkRun(values) {
|
|
565
629
|
need(values, "disk", "region", "id", "new-id");
|
|
566
|
-
const disk = await
|
|
630
|
+
const disk = await cliControl(values);
|
|
567
631
|
const result = await fork({ disk: str(values.disk), region: str(values.region), id: str(values.id) }, str(values["new-id"]), {
|
|
568
632
|
control: disk,
|
|
569
633
|
mountRoot: str(values["mount-root"], DEFAULT_MOUNT_ROOT),
|
|
@@ -582,6 +646,7 @@ export async function main(argv) {
|
|
|
582
646
|
case "run":
|
|
583
647
|
return await runInstance(parse(rest, {
|
|
584
648
|
...COMMON,
|
|
649
|
+
dir: { type: "string" },
|
|
585
650
|
archil: { type: "string" },
|
|
586
651
|
app: { type: "string" },
|
|
587
652
|
"heartbeat-ms": { type: "string" },
|
|
@@ -640,7 +705,7 @@ export async function main(argv) {
|
|
|
640
705
|
apparmor: { type: "string" },
|
|
641
706
|
}));
|
|
642
707
|
case "status":
|
|
643
|
-
return await status(parse(rest, { ...COMMON, docker: { type: "string" } }));
|
|
708
|
+
return await status(parse(rest, { ...COMMON, dir: { type: "string" }, docker: { type: "string" } }));
|
|
644
709
|
case "release":
|
|
645
710
|
return await release(parse(rest, COMMON));
|
|
646
711
|
case "fork":
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type ProcessTable } from "./process-table.js";
|
|
2
|
+
/** The environment variable that marks a command as one environment's: its value is that recorder's token. */
|
|
3
|
+
export declare const COMMANDS_MARK = "PI_DURABLE_DISK_COMMANDS";
|
|
4
|
+
/** One process, as recorded: a pid alone could be reused, a pid with its start on one boot cannot (process-table.ts). */
|
|
5
|
+
export interface ProcessMark {
|
|
6
|
+
readonly pid: number;
|
|
7
|
+
readonly start: number;
|
|
8
|
+
}
|
|
9
|
+
/** What a record holds: a command's group leader (null once only members of it outlived a reap) and the descendants seen. */
|
|
10
|
+
export interface CommandRecord {
|
|
11
|
+
readonly boot: string;
|
|
12
|
+
readonly leader: ProcessMark | null;
|
|
13
|
+
readonly members: readonly ProcessMark[];
|
|
14
|
+
}
|
|
15
|
+
/** What a reap did: how many processes it signalled, and those still alive at its deadline (kept for the next open). */
|
|
16
|
+
export interface Reaped {
|
|
17
|
+
readonly signalled: number;
|
|
18
|
+
readonly survivors: readonly ProcessMark[];
|
|
19
|
+
}
|
|
20
|
+
export interface CommandsOptions {
|
|
21
|
+
/** The `/proc` to read; for tests. */
|
|
22
|
+
readonly proc?: string;
|
|
23
|
+
/** The process table to read; default this machine's (`/proc`, or `proc` when given; ps on macOS). For tests. */
|
|
24
|
+
readonly table?: ProcessTable;
|
|
25
|
+
/** How a reap signals; default `process.kill`. For tests. */
|
|
26
|
+
readonly kill?: (target: number, signal: NodeJS.Signals) => void;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Records this environment's command groups under this process while commands run. `started()` and `finished()` bracket
|
|
30
|
+
* each command, which carries `COMMANDS_MARK=<token>` in its environment; while any runs, the tree is walked soon after a
|
|
31
|
+
* start and then every `everyMs`. A record is written when its group gains a process and removed once nothing in it is
|
|
32
|
+
* alive. A walk that fails (a record cannot be written: a full disk) goes to `onError` the first time, never out of a
|
|
33
|
+
* timer, and the next walk tries again.
|
|
34
|
+
*/
|
|
35
|
+
export declare class CommandRecorder {
|
|
36
|
+
#private;
|
|
37
|
+
/** The value of `COMMANDS_MARK` in this environment's commands. */
|
|
38
|
+
readonly token: `${string}-${string}-${string}-${string}-${string}`;
|
|
39
|
+
constructor(dir: string, options?: CommandsOptions & {
|
|
40
|
+
readonly instance?: number;
|
|
41
|
+
readonly everyMs?: number;
|
|
42
|
+
readonly onError?: (error: unknown) => void;
|
|
43
|
+
});
|
|
44
|
+
/** A command is about to start. */
|
|
45
|
+
started(): void;
|
|
46
|
+
/** A command ended (its result is in). The last one stops the walks after a final one. */
|
|
47
|
+
finished(): void;
|
|
48
|
+
/** Stops recording (the environment closed); the records stay for the next open. */
|
|
49
|
+
close(): void;
|
|
50
|
+
/** One walk. It is called from timers, so it never throws: a failure goes to `onError` once, and the next walk retries. */
|
|
51
|
+
scan(): void;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Ends what a previous incarnation's commands left running: for each record of this boot, the leader's whole group when
|
|
55
|
+
* the leader is still the recorded process, and each recorded descendant that still is. Waits up to `timeoutMs` until
|
|
56
|
+
* none of them is alive. A record whose processes are all gone is removed; one that still names a live process (it did
|
|
57
|
+
* not die at the deadline: a process in an uninterruptible wait) is rewritten with just those, so the next open tries
|
|
58
|
+
* again, and they are returned. Run only while holding the run (after its owner lock), before the Harness opens.
|
|
59
|
+
*/
|
|
60
|
+
export declare function reapCommands(dir: string, options?: CommandsOptions & {
|
|
61
|
+
readonly timeoutMs?: number;
|
|
62
|
+
}): Promise<Reaped>;
|
|
63
|
+
/**
|
|
64
|
+
* `reapCommands` at an open: refuses the run (`HeldError("commands")`, exit 76) when a process a killed incarnation's
|
|
65
|
+
* commands left is still alive after it, since the run must not resume beside it. Its record stays, and the next open
|
|
66
|
+
* tries again.
|
|
67
|
+
*/
|
|
68
|
+
export declare function reapOrRefuse(dir: string, options?: CommandsOptions & {
|
|
69
|
+
readonly timeoutMs?: number;
|
|
70
|
+
}): Promise<Reaped>;
|