@parcha/pi-durable-disk 0.1.0-beta.13 → 0.1.0-beta.15
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 +262 -0
- package/README.md +281 -47
- package/dist/app.d.ts +5 -1
- package/dist/claim.d.ts +51 -3
- package/dist/claim.js +157 -17
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +112 -47
- package/dist/commands.d.ts +4 -1
- package/dist/commands.js +48 -90
- package/dist/confine.d.ts +36 -7
- package/dist/confine.js +125 -18
- package/dist/env.d.ts +7 -22
- package/dist/env.js +25 -60
- package/dist/errors.d.ts +2 -1
- package/dist/fork.d.ts +23 -0
- package/dist/fork.js +103 -3
- package/dist/hosts/attempt-env.d.ts +3 -0
- package/dist/hosts/attempt-env.js +15 -0
- package/dist/hosts/daytona-launch.d.ts +3 -1
- package/dist/hosts/daytona-launch.js +35 -12
- package/dist/hosts/daytona.d.ts +2 -2
- package/dist/hosts/daytona.js +48 -5
- package/dist/hosts/docker.d.ts +2 -2
- package/dist/hosts/docker.js +22 -5
- package/dist/hosts/local-host.d.ts +9 -3
- package/dist/hosts/local-host.js +59 -22
- package/dist/index.d.ts +35 -16
- package/dist/index.js +27 -16
- package/dist/internals.d.ts +3 -0
- package/dist/internals.js +6 -0
- package/dist/lease.d.ts +4 -1
- package/dist/lease.js +2 -0
- package/dist/local-run.js +3 -2
- package/dist/place.d.ts +190 -0
- package/dist/place.js +526 -0
- package/dist/process-table.d.ts +38 -0
- package/dist/process-table.js +158 -0
- package/dist/run.d.ts +61 -10
- package/dist/run.js +69 -33
- package/dist/status.d.ts +19 -3
- package/dist/status.js +25 -13
- package/dist/store.d.ts +14 -2
- package/dist/store.js +16 -4
- package/dist/supervise.d.ts +43 -1
- package/dist/supervise.js +26 -1
- package/dist/watchdog.d.ts +3 -1
- package/dist/watchdog.js +43 -12
- package/dist/with-claim.d.ts +63 -0
- package/dist/with-claim.js +325 -0
- package/package.json +9 -3
package/dist/cli.js
CHANGED
|
@@ -9,18 +9,21 @@
|
|
|
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
|
-
import { ARCHIL_SCOPED, DEFAULT_MOUNT_ROOT, runPath, TOKEN_PREFIX, unmountClaim } from "./claim.js";
|
|
15
|
+
import { ARCHIL_SCOPED, DEFAULT_MOUNT_ROOT, NO_TOKEN, runPath, TOKEN_PREFIX, unmountClaim } from "./claim.js";
|
|
16
16
|
import { archilEnv } from "./env.js";
|
|
17
17
|
import { exitCodeFor, FencedError, PdaError } from "./errors.js";
|
|
18
18
|
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
|
/**
|
|
@@ -173,7 +165,7 @@ async function control(values) {
|
|
|
173
165
|
*/
|
|
174
166
|
async function readToken(values) {
|
|
175
167
|
if (!values["token-stdin"])
|
|
176
|
-
return
|
|
168
|
+
return NO_TOKEN;
|
|
177
169
|
let text = "";
|
|
178
170
|
for await (const chunk of process.stdin)
|
|
179
171
|
text += chunk;
|
|
@@ -184,12 +176,13 @@ async function readToken(values) {
|
|
|
184
176
|
catch {
|
|
185
177
|
// already closed
|
|
186
178
|
}
|
|
187
|
-
return text.trim() ||
|
|
179
|
+
return text.trim() || NO_TOKEN;
|
|
188
180
|
}
|
|
189
181
|
/**
|
|
190
182
|
* The instance a host driver starts: openDurableRun with the app module's Harness options (src/app.ts), the
|
|
191
183
|
* driver's handle as run.json's holder, the mount token from stdin (read once, then stdin is closed), and the lease
|
|
192
|
-
* periods. The app
|
|
184
|
+
* periods. The app's factory runs once the claim is held (`afterClaim`: the run's directory is mounted, so `ctx.root` is
|
|
185
|
+
* the live directory) and the Harness opens with what it returns; a missing or throwing module, or a failed `onOpen`, is AppError (exit 1,
|
|
193
186
|
* so the unit retries within systemd's start limit; a restart in place reuses the claim). A fence of any kind kills the
|
|
194
187
|
* run's commands and exits 75 inside openDurableRun.
|
|
195
188
|
*
|
|
@@ -201,8 +194,12 @@ async function readToken(values) {
|
|
|
201
194
|
* between the open and the exit is `serveUntilDone`.
|
|
202
195
|
*/
|
|
203
196
|
async function runInstance(values) {
|
|
204
|
-
|
|
205
|
-
|
|
197
|
+
const dir = localDir(values, ["disk", "region", "mount-root", "archil", "token-stdin"]);
|
|
198
|
+
if (dir === undefined)
|
|
199
|
+
need(values, "disk", "region", "id", "app");
|
|
200
|
+
else
|
|
201
|
+
need(values, "app");
|
|
202
|
+
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
203
|
const runAs = typeof values["run-as"] === "string" ? runUser(values["run-as"]) : undefined;
|
|
207
204
|
if (runAs && process.getuid?.() !== 0)
|
|
208
205
|
throw new UsageError("--run-as needs an instance that runs as root (inside a container); elsewhere run the instance as the run user");
|
|
@@ -222,18 +219,17 @@ async function runInstance(values) {
|
|
|
222
219
|
throw new UsageError(`--serve-host ${serveHost} is not loopback: name a --serve-token-file`);
|
|
223
220
|
const serveToken = tokenFile === undefined ? undefined : readServeToken(tokenFile);
|
|
224
221
|
const mountRoot = str(values["mount-root"], DEFAULT_MOUNT_ROOT);
|
|
225
|
-
const root = join(mountRoot, runPath(ref.id));
|
|
222
|
+
const root = dir ?? join(mountRoot, runPath(ref.id));
|
|
226
223
|
const log = (event, extra = {}) => process.stderr.write(`${JSON.stringify({ at: new Date().toISOString(), event, run: ref.id, ...extra })}\n`);
|
|
224
|
+
// The app's factory runs once the claim is held (`afterClaim`), so `ctx.root` is the live directory whenever app code
|
|
225
|
+
// runs; what it returns opens the Harness. Serve starts before the open, with the root options the factory fills in.
|
|
227
226
|
let app;
|
|
228
|
-
|
|
229
|
-
app = await loadApp(str(values.app), { ref, root, work: join(root, "work"), store: join(root, "store") });
|
|
230
|
-
}
|
|
231
|
-
catch (err) {
|
|
232
|
-
log("app failed", { code: err.code, message: err.message });
|
|
233
|
-
return exitCodeFor(err);
|
|
234
|
-
}
|
|
235
|
-
const { onOpen, beforeResume, root: rootOptions, wake: appWake, ...harness } = app;
|
|
227
|
+
const rootOptions = {};
|
|
236
228
|
const holder = JSON.parse(process.env.PDA_HOLDER ?? "{}");
|
|
229
|
+
// On a local directory nothing else knows this instance: its start ticks go into the holder, so `status --dir` tells it
|
|
230
|
+
// from a later process that reuses its pid (a driver's handle already carries them).
|
|
231
|
+
if (dir !== undefined && typeof holder.startTicks !== "number")
|
|
232
|
+
holder.startTicks = procStartTicks(process.pid);
|
|
237
233
|
const cgroup = watchdogOwnsCgroup(holder);
|
|
238
234
|
if (cgroup.warning)
|
|
239
235
|
log("cgroup not owned", { warning: cgroup.warning });
|
|
@@ -250,10 +246,14 @@ async function runInstance(values) {
|
|
|
250
246
|
let run;
|
|
251
247
|
try {
|
|
252
248
|
run = await openDurableRun(ref, {
|
|
253
|
-
|
|
254
|
-
mountRoot,
|
|
255
|
-
|
|
256
|
-
|
|
249
|
+
// A local directory is its own claim (no mount, no token); a disk's run is mounted with a token.
|
|
250
|
+
...(dir === undefined ? { mountToken: await readToken(values), mountRoot, host: { archil: str(values.archil) } } : localRun(dir)),
|
|
251
|
+
afterClaim: async (claim) => {
|
|
252
|
+
app = await loadApp(str(values.app), { ref, root: claim.root, work: claim.work, store: claim.store });
|
|
253
|
+
const { onOpen: _onOpen, beforeResume, root: appRoot, wake: _wake, ...harness } = app;
|
|
254
|
+
Object.assign(rootOptions, appRoot ?? {});
|
|
255
|
+
return { harness, ...(beforeResume ? { beforeResume } : {}) };
|
|
256
|
+
},
|
|
257
257
|
...(runAs
|
|
258
258
|
? { env: (c) => archilEnv(c, { runAs: { uid: runAs.uid, gid: runAs.gid }, shellEnv: { HOME: runAs.home, USER: runAs.name, LOGNAME: runAs.name } }) }
|
|
259
259
|
: {}),
|
|
@@ -261,16 +261,16 @@ async function runInstance(values) {
|
|
|
261
261
|
ownCgroup: cgroup.owns,
|
|
262
262
|
lease: Object.fromEntries(Object.entries(lease).filter(([, v]) => v !== undefined)),
|
|
263
263
|
onStep: (step, t) => void (steps[step] = Math.round(t)),
|
|
264
|
-
...(beforeResume ? { beforeResume } : {}),
|
|
265
264
|
});
|
|
266
265
|
}
|
|
267
266
|
catch (err) {
|
|
268
|
-
log("open failed", { code: err.code, exitCode: exitCodeFor(err), message: err.message, steps });
|
|
267
|
+
log(err instanceof AppError ? "app failed" : "open failed", { code: err.code, exitCode: exitCodeFor(err), message: err.message, steps });
|
|
269
268
|
await server?.close();
|
|
270
269
|
return exitCodeFor(err);
|
|
271
270
|
}
|
|
272
271
|
server?.attach(run);
|
|
273
272
|
log("running", { generation: run.generation, steps, ...(server ? { serve: server.url } : {}) });
|
|
273
|
+
const { onOpen, wake: appWake } = app;
|
|
274
274
|
return serveUntilDone(run, onOpen, onSigterm, log, process, {
|
|
275
275
|
drainMs,
|
|
276
276
|
...(server ? { server } : {}),
|
|
@@ -451,7 +451,7 @@ async function supervise(values) {
|
|
|
451
451
|
throw new UsageError("--check proves this host's own mounts; with --host docker the containers mount, so it does not apply");
|
|
452
452
|
if (docker && (typeof values.image !== "string" || !values.image))
|
|
453
453
|
throw new UsageError("--host docker needs --image");
|
|
454
|
-
const disk = await
|
|
454
|
+
const disk = await cliControl(values);
|
|
455
455
|
const controlTimeoutMs = values["control-timeout"] ? parseDuration(str(values["control-timeout"])) : CONTROL_TIMEOUT_MS;
|
|
456
456
|
if (values.check) {
|
|
457
457
|
const report = await checkHost({
|
|
@@ -537,8 +537,11 @@ async function supervise(values) {
|
|
|
537
537
|
return 0;
|
|
538
538
|
}
|
|
539
539
|
async function status(values) {
|
|
540
|
+
const dir = localDir(values, ["disk", "region", "mount-root", "api-key-env", "host-name", "docker"]);
|
|
541
|
+
if (dir !== undefined)
|
|
542
|
+
return localStatus(dir);
|
|
540
543
|
need(values, "disk", "region", "id");
|
|
541
|
-
const disk = await
|
|
544
|
+
const disk = await cliControl(values);
|
|
542
545
|
const id = str(values.id);
|
|
543
546
|
const run = await readRunStatus(disk, id);
|
|
544
547
|
const { findDelegations } = await import("./claim.js");
|
|
@@ -556,6 +559,67 @@ async function status(values) {
|
|
|
556
559
|
emit({ run: id, runJson: run, delegations: delegations.map(({ clientId, inodeId, path, isOrphaned, isPending }) => ({ clientId, inodeId, path, isOrphaned, isPending })), holderStatus, ...(container ? { container } : {}) });
|
|
557
560
|
return 0;
|
|
558
561
|
}
|
|
562
|
+
/**
|
|
563
|
+
* `--dir PATH`: the run is the local directory PATH (`localRun`), so the Archil flags mean nothing and are refused. Returns
|
|
564
|
+
* the directory, absolute; undefined without `--dir`.
|
|
565
|
+
*/
|
|
566
|
+
function localDir(values, archilFlags) {
|
|
567
|
+
if (values.dir === undefined)
|
|
568
|
+
return undefined;
|
|
569
|
+
const dir = str(values.dir);
|
|
570
|
+
if (!dir)
|
|
571
|
+
throw new UsageError("--dir needs a path");
|
|
572
|
+
const mixed = archilFlags.filter((flag) => values[flag] !== undefined);
|
|
573
|
+
if (mixed.length > 0)
|
|
574
|
+
throw new UsageError(`--dir is a run on a local directory: it takes no ${mixed.map((flag) => `--${flag}`).join(", ")}`);
|
|
575
|
+
return resolve(dir);
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* `status --dir PATH`: `run.json` read from the directory, and whether its holder is still the process that claimed it:
|
|
579
|
+
* `running` when it is this boot's and its pid is alive with the start ticks the holder recorded (a later process that
|
|
580
|
+
* reuses the pid has others), `stopped` when it is not, `gone` from another boot, and `unknown` when the holder recorded no
|
|
581
|
+
* start ticks (a run opened by a library caller) and its pid is alive.
|
|
582
|
+
*/
|
|
583
|
+
function localStatus(dir) {
|
|
584
|
+
let text;
|
|
585
|
+
try {
|
|
586
|
+
text = readFileSync(join(dir, RUN_JSON), "utf8");
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
throw new UsageError(`no run in ${dir}: ${err.message}`);
|
|
590
|
+
}
|
|
591
|
+
const run = parseRunRecord(text);
|
|
592
|
+
let holderStatus = null;
|
|
593
|
+
const holder = run.holder;
|
|
594
|
+
if (holder) {
|
|
595
|
+
// A read that failed (sysctl or ps on macOS) is no exit: it says `unknown`, never `gone` or `stopped`.
|
|
596
|
+
const boot = thisBoot();
|
|
597
|
+
const ticks = startOf(Number(holder.pid));
|
|
598
|
+
if (boot === null)
|
|
599
|
+
holderStatus = "unknown";
|
|
600
|
+
else if (holder.bootId !== boot)
|
|
601
|
+
holderStatus = "gone";
|
|
602
|
+
else if (ticks === undefined)
|
|
603
|
+
holderStatus = "unknown";
|
|
604
|
+
else if (typeof holder.startTicks === "number")
|
|
605
|
+
holderStatus = ticks === holder.startTicks ? "running" : "stopped";
|
|
606
|
+
else
|
|
607
|
+
holderStatus = ticks === null ? "stopped" : "unknown";
|
|
608
|
+
}
|
|
609
|
+
emit({ run: run.run, dir, runJson: run, holderStatus });
|
|
610
|
+
return 0;
|
|
611
|
+
}
|
|
612
|
+
/** A live process's start (null: no such process, or a zombie), or undefined when the process table cannot be read. */
|
|
613
|
+
function startOf(pid) {
|
|
614
|
+
if (!Number.isInteger(pid) || pid <= 1)
|
|
615
|
+
return null;
|
|
616
|
+
try {
|
|
617
|
+
return processTable().snapshot().info(pid)?.start ?? null;
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
return undefined;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
559
623
|
async function release(values) {
|
|
560
624
|
need(values, "id");
|
|
561
625
|
const via = await unmountClaim(join(str(values["mount-root"], DEFAULT_MOUNT_ROOT), runPath(str(values.id))));
|
|
@@ -564,7 +628,7 @@ async function release(values) {
|
|
|
564
628
|
}
|
|
565
629
|
async function forkRun(values) {
|
|
566
630
|
need(values, "disk", "region", "id", "new-id");
|
|
567
|
-
const disk = await
|
|
631
|
+
const disk = await cliControl(values);
|
|
568
632
|
const result = await fork({ disk: str(values.disk), region: str(values.region), id: str(values.id) }, str(values["new-id"]), {
|
|
569
633
|
control: disk,
|
|
570
634
|
mountRoot: str(values["mount-root"], DEFAULT_MOUNT_ROOT),
|
|
@@ -583,6 +647,7 @@ export async function main(argv) {
|
|
|
583
647
|
case "run":
|
|
584
648
|
return await runInstance(parse(rest, {
|
|
585
649
|
...COMMON,
|
|
650
|
+
dir: { type: "string" },
|
|
586
651
|
archil: { type: "string" },
|
|
587
652
|
app: { type: "string" },
|
|
588
653
|
"heartbeat-ms": { type: "string" },
|
|
@@ -641,7 +706,7 @@ export async function main(argv) {
|
|
|
641
706
|
apparmor: { type: "string" },
|
|
642
707
|
}));
|
|
643
708
|
case "status":
|
|
644
|
-
return await status(parse(rest, { ...COMMON, docker: { type: "string" } }));
|
|
709
|
+
return await status(parse(rest, { ...COMMON, dir: { type: "string" }, docker: { type: "string" } }));
|
|
645
710
|
case "release":
|
|
646
711
|
return await release(parse(rest, COMMON));
|
|
647
712
|
case "fork":
|
package/dist/commands.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { type ProcessTable } from "./process-table.js";
|
|
1
2
|
/** The environment variable that marks a command as one environment's: its value is that recorder's token. */
|
|
2
3
|
export declare const COMMANDS_MARK = "PI_DURABLE_DISK_COMMANDS";
|
|
3
|
-
/** One process, as recorded: a pid alone could be reused, a pid with its start
|
|
4
|
+
/** One process, as recorded: a pid alone could be reused, a pid with its start on one boot cannot (process-table.ts). */
|
|
4
5
|
export interface ProcessMark {
|
|
5
6
|
readonly pid: number;
|
|
6
7
|
readonly start: number;
|
|
@@ -19,6 +20,8 @@ export interface Reaped {
|
|
|
19
20
|
export interface CommandsOptions {
|
|
20
21
|
/** The `/proc` to read; for tests. */
|
|
21
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;
|
|
22
25
|
/** How a reap signals; default `process.kill`. For tests. */
|
|
23
26
|
readonly kill?: (target: number, signal: NodeJS.Signals) => void;
|
|
24
27
|
}
|
package/dist/commands.js
CHANGED
|
@@ -1,81 +1,26 @@
|
|
|
1
1
|
// The commands a run's environment starts, recorded where the next open of the same claim finds them. pi makes each
|
|
2
2
|
// command the leader of a process group of its own, so killing an instance does not reach its commands: a host's unit
|
|
3
3
|
// cgroup or a container ends them with it, and a run on a local directory has neither. While commands run, the instance
|
|
4
|
-
// walks its own process tree (`/proc/<pid>/task/<tid>/children`
|
|
5
|
-
// descendant it has seen, each by pid, start
|
|
6
|
-
// carry its mark (COMMANDS_MARK=<its token>) in their environment:
|
|
7
|
-
// or the app's own helpers, are not its to record. The next open,
|
|
8
|
-
// previous incarnation recorded that is still the same process (pid, start
|
|
9
|
-
// gone, and clears the records, before the Harness opens. Only the instance writes
|
|
10
|
-
// Nothing stops a command from writing one too, and none needs stopping: a run's commands
|
|
11
|
-
// with `runAs`, as another one that cannot write the instance's 0700 directory), so a
|
|
12
|
-
// what that command could already kill itself. A record grants nothing.
|
|
4
|
+
// walks its own process tree (`/proc/<pid>/task/<tid>/children` on Linux, one ps listing on macOS: process-table.ts) and
|
|
5
|
+
// records each of its commands' group leader and every descendant it has seen, each by pid, start and boot. Its commands
|
|
6
|
+
// are the children that lead their own group and carry its mark (COMMANDS_MARK=<its token>) in their environment:
|
|
7
|
+
// another environment's commands in the same process, or the app's own helpers, are not its to record. The next open,
|
|
8
|
+
// which holds the owner lock, kills whatever a previous incarnation recorded that is still the same process (pid, start
|
|
9
|
+
// and boot all match), waits until it is gone, and clears the records, before the Harness opens. Only the instance writes
|
|
10
|
+
// records: a command never does. Nothing stops a command from writing one too, and none needs stopping: a run's commands
|
|
11
|
+
// run as the instance's user (or, with `runAs`, as another one that cannot write the instance's 0700 directory), so a
|
|
12
|
+
// record a command forged names only what that command could already kill itself. A record grants nothing.
|
|
13
13
|
import { randomUUID } from "node:crypto";
|
|
14
14
|
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import { HeldError } from "./errors.js";
|
|
17
|
+
import { processTable, procTable } from "./process-table.js";
|
|
17
18
|
/** The environment variable that marks a command as one environment's: its value is that recorder's token. */
|
|
18
19
|
export const COMMANDS_MARK = "PI_DURABLE_DISK_COMMANDS";
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const fields = text.slice(text.lastIndexOf(")") + 2).split(" ");
|
|
24
|
-
if (fields[0] === "Z" || fields[0] === "X")
|
|
25
|
-
return undefined;
|
|
26
|
-
return { ppid: Number(fields[1]), pgrp: Number(fields[2]), start: Number(fields[19]) };
|
|
27
|
-
}
|
|
28
|
-
catch {
|
|
29
|
-
return undefined;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
function bootId(proc) {
|
|
33
|
-
try {
|
|
34
|
-
return readFileSync(join(proc, "sys/kernel/random/boot_id"), "utf8").trim() || undefined;
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
return undefined;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
/** The direct children of `pid`, from each of its threads' `children` list. */
|
|
41
|
-
function children(proc, pid) {
|
|
42
|
-
let tasks;
|
|
43
|
-
try {
|
|
44
|
-
tasks = readdirSync(join(proc, String(pid), "task"));
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
return [];
|
|
48
|
-
}
|
|
49
|
-
return tasks.flatMap((tid) => {
|
|
50
|
-
try {
|
|
51
|
-
return readFileSync(join(proc, String(pid), "task", tid, "children"), "utf8").split(" ").filter(Boolean).map(Number);
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
return [];
|
|
55
|
-
}
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
/** Whether the process `pid` was started with `name=value` in its environment (false when it cannot be read). */
|
|
59
|
-
function marked(proc, pid, name, value) {
|
|
60
|
-
try {
|
|
61
|
-
return readFileSync(join(proc, String(pid), "environ"), "utf8").split("\0").includes(`${name}=${value}`);
|
|
62
|
-
}
|
|
63
|
-
catch {
|
|
64
|
-
return false;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
/** Whether `mark` is still the process it was: same pid, same start ticks, not a zombie. Never pid 1 or below. */
|
|
68
|
-
function same(proc, mark) {
|
|
69
|
-
return mark.pid > 1 && stat(proc, mark.pid)?.start === mark.start;
|
|
70
|
-
}
|
|
71
|
-
/** A process's command line for a message, best effort. */
|
|
72
|
-
function commandLine(proc, pid) {
|
|
73
|
-
try {
|
|
74
|
-
return readFileSync(join(proc, String(pid), "cmdline"), "utf8").split("\0").filter(Boolean).join(" ") || "?";
|
|
75
|
-
}
|
|
76
|
-
catch {
|
|
77
|
-
return "?";
|
|
78
|
-
}
|
|
20
|
+
const tableOf = (options) => options.table ?? (options.proc === undefined ? processTable() : procTable(options.proc));
|
|
21
|
+
/** Whether `mark` is still the process it was: same pid, same start, not a zombie. Never pid 1 or below. */
|
|
22
|
+
function same(look, mark) {
|
|
23
|
+
return mark.pid > 1 && look.info(mark.pid)?.start === mark.start;
|
|
79
24
|
}
|
|
80
25
|
const RECORD = /^(\d+)-(\d+)\.json$/;
|
|
81
26
|
/**
|
|
@@ -89,7 +34,7 @@ export class CommandRecorder {
|
|
|
89
34
|
/** The value of `COMMANDS_MARK` in this environment's commands. */
|
|
90
35
|
token = randomUUID();
|
|
91
36
|
#dir;
|
|
92
|
-
#
|
|
37
|
+
#table;
|
|
93
38
|
#instance;
|
|
94
39
|
#everyMs;
|
|
95
40
|
#boot;
|
|
@@ -102,10 +47,10 @@ export class CommandRecorder {
|
|
|
102
47
|
#timer;
|
|
103
48
|
constructor(dir, options = {}) {
|
|
104
49
|
this.#dir = dir;
|
|
105
|
-
this.#
|
|
50
|
+
this.#table = tableOf(options);
|
|
106
51
|
this.#instance = options.instance ?? process.pid;
|
|
107
|
-
this.#everyMs = options.everyMs ??
|
|
108
|
-
this.#boot = bootId(
|
|
52
|
+
this.#everyMs = options.everyMs ?? this.#table.walkMs;
|
|
53
|
+
this.#boot = this.#table.bootId();
|
|
109
54
|
this.#onError = options.onError;
|
|
110
55
|
}
|
|
111
56
|
/** A command is about to start. */
|
|
@@ -151,24 +96,25 @@ export class CommandRecorder {
|
|
|
151
96
|
const boot = this.#boot;
|
|
152
97
|
if (boot === undefined)
|
|
153
98
|
return;
|
|
154
|
-
const
|
|
99
|
+
const look = this.#table.snapshot();
|
|
100
|
+
const own = look.info(this.#instance);
|
|
155
101
|
if (own === undefined)
|
|
156
102
|
return;
|
|
157
103
|
// New commands: children of this process that lead their own group (pi spawns each one so) and carry this
|
|
158
104
|
// environment's mark. Once recorded, a leader stays its environment's whatever it execs next (same pid and start).
|
|
159
|
-
for (const pid of children(this.#
|
|
160
|
-
const leader =
|
|
105
|
+
for (const pid of look.children(this.#instance)) {
|
|
106
|
+
const leader = look.info(pid);
|
|
161
107
|
if (leader === undefined || leader.pgrp !== pid || leader.pgrp === own.pgrp)
|
|
162
108
|
continue;
|
|
163
109
|
const key = `${pid}-${leader.start}`;
|
|
164
|
-
if (this.#records.has(key) || !marked(
|
|
110
|
+
if (this.#records.has(key) || !look.marked(pid, COMMANDS_MARK, this.token))
|
|
165
111
|
continue;
|
|
166
112
|
this.#records.set(key, { leader: { pid, start: leader.start }, members: new Map() });
|
|
167
113
|
}
|
|
168
114
|
// Every record: walk from each of its processes still alive, the leader and every member, so a background worker whose
|
|
169
115
|
// leader (or parent) exited and was reparented away is still followed, and so are the children it starts later.
|
|
170
116
|
for (const [key, record] of this.#records) {
|
|
171
|
-
const live = [record.leader, ...record.members.values()].filter((mark) => same(
|
|
117
|
+
const live = [record.leader, ...record.members.values()].filter((mark) => same(look, mark));
|
|
172
118
|
if (live.length === 0) {
|
|
173
119
|
this.#records.delete(key);
|
|
174
120
|
this.#written.delete(key);
|
|
@@ -177,8 +123,8 @@ export class CommandRecorder {
|
|
|
177
123
|
}
|
|
178
124
|
let grew = !this.#written.has(key);
|
|
179
125
|
const walk = (parent) => {
|
|
180
|
-
for (const child of children(
|
|
181
|
-
const s =
|
|
126
|
+
for (const child of look.children(parent)) {
|
|
127
|
+
const s = look.info(child);
|
|
182
128
|
if (s === undefined || child === record.leader.pid)
|
|
183
129
|
continue;
|
|
184
130
|
const known = record.members.get(child)?.start === s.start;
|
|
@@ -212,7 +158,7 @@ export class CommandRecorder {
|
|
|
212
158
|
* again, and they are returned. Run only while holding the run (after its owner lock), before the Harness opens.
|
|
213
159
|
*/
|
|
214
160
|
export async function reapCommands(dir, options = {}) {
|
|
215
|
-
const
|
|
161
|
+
const table = tableOf(options);
|
|
216
162
|
const signal = options.kill ?? ((target, name) => process.kill(target, name));
|
|
217
163
|
let files;
|
|
218
164
|
try {
|
|
@@ -221,8 +167,13 @@ export async function reapCommands(dir, options = {}) {
|
|
|
221
167
|
catch {
|
|
222
168
|
return { signalled: 0, survivors: [] };
|
|
223
169
|
}
|
|
224
|
-
|
|
225
|
-
|
|
170
|
+
if (files.length === 0) {
|
|
171
|
+
rmSync(dir, { recursive: true, force: true });
|
|
172
|
+
return { signalled: 0, survivors: [] };
|
|
173
|
+
}
|
|
174
|
+
const boot = table.bootId();
|
|
175
|
+
const before = table.snapshot();
|
|
176
|
+
const own = before.info(process.pid);
|
|
226
177
|
let signalled = 0;
|
|
227
178
|
/**
|
|
228
179
|
* False for what is never a target: this process, its group, and a pid of 1 or below, since `kill(0)` is the caller's
|
|
@@ -255,19 +206,26 @@ export async function reapCommands(dir, options = {}) {
|
|
|
255
206
|
rmSync(file, { force: true }); // another boot's pids mean nothing on this one
|
|
256
207
|
continue;
|
|
257
208
|
}
|
|
258
|
-
const leader = record.leader !== null && same(
|
|
209
|
+
const leader = record.leader !== null && same(before, record.leader) && kill(-record.leader.pid, record.leader) ? record.leader : null;
|
|
259
210
|
// A member that is the leader went with its group.
|
|
260
|
-
const members = (record.members ?? []).filter((member) => member.pid !== record.leader?.pid && same(
|
|
211
|
+
const members = (record.members ?? []).filter((member) => member.pid !== record.leader?.pid && same(before, member) && kill(member.pid, member));
|
|
261
212
|
pending.push({ file, boot, leader, members });
|
|
262
213
|
}
|
|
263
|
-
const alive = () =>
|
|
214
|
+
const alive = () => {
|
|
215
|
+
const marks = pending.flatMap((p) => [...(p.leader ? [p.leader] : []), ...p.members]);
|
|
216
|
+
if (marks.length === 0)
|
|
217
|
+
return marks;
|
|
218
|
+
const look = table.snapshot();
|
|
219
|
+
return marks.filter((mark) => same(look, mark));
|
|
220
|
+
};
|
|
264
221
|
const deadline = Date.now() + (options.timeoutMs ?? 5_000);
|
|
265
222
|
while (alive().length > 0 && Date.now() < deadline)
|
|
266
223
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
267
224
|
const survivors = [];
|
|
225
|
+
const after = pending.length === 0 ? before : table.snapshot();
|
|
268
226
|
for (const record of pending) {
|
|
269
|
-
const leader = record.leader !== null && same(
|
|
270
|
-
const members = record.members.filter((member) => same(
|
|
227
|
+
const leader = record.leader !== null && same(after, record.leader) ? record.leader : null;
|
|
228
|
+
const members = record.members.filter((member) => same(after, member));
|
|
271
229
|
if (leader === null && members.length === 0) {
|
|
272
230
|
rmSync(record.file, { force: true });
|
|
273
231
|
continue;
|
|
@@ -288,10 +246,10 @@ export async function reapCommands(dir, options = {}) {
|
|
|
288
246
|
export async function reapOrRefuse(dir, options = {}) {
|
|
289
247
|
const reaped = await reapCommands(dir, options);
|
|
290
248
|
if (reaped.survivors.length > 0) {
|
|
291
|
-
const
|
|
249
|
+
const look = tableOf(options).snapshot();
|
|
292
250
|
const pids = reaped.survivors.map((s) => s.pid);
|
|
293
251
|
throw new HeldError("commands", `${pids.length} process(es) a killed incarnation's commands started are still alive after SIGKILL: ` +
|
|
294
|
-
`${reaped.survivors.map((s) => `pid ${s.pid} (${commandLine(
|
|
252
|
+
`${reaped.survivors.map((s) => `pid ${s.pid} (${look.commandLine(s.pid)})`).join(", ")}. ` +
|
|
295
253
|
`The run does not resume beside them. End them (kill -9 ${pids.join(" ")}, as their owner, or wait for them) and open the run again; ` +
|
|
296
254
|
`they stay recorded in ${dir} until then.`);
|
|
297
255
|
}
|
package/dist/confine.d.ts
CHANGED
|
@@ -37,8 +37,9 @@ export interface PinSpec {
|
|
|
37
37
|
readonly strict?: boolean;
|
|
38
38
|
}
|
|
39
39
|
/**
|
|
40
|
-
* A resolved target held open. `dir` and `entry` are `/proc/self/fd/...` paths to hand to pi's
|
|
41
|
-
* path and message it returns is mapped back to the real one with `restore`. Close it when
|
|
40
|
+
* A resolved target held open. `dir` and `entry` are `/proc/self/fd/...` paths (by path, the real ones) to hand to pi's
|
|
41
|
+
* own implementation; every path and message it returns is mapped back to the real one with `restore`. Close it when
|
|
42
|
+
* the operation is done.
|
|
42
43
|
*/
|
|
43
44
|
export declare class Pin {
|
|
44
45
|
#private;
|
|
@@ -49,25 +50,50 @@ export declare class Pin {
|
|
|
49
50
|
readonly dir: string;
|
|
50
51
|
/** The target below `dir`, for operations that do not follow a last symlink. */
|
|
51
52
|
readonly entry: string;
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
/**
|
|
54
|
+
* `held` is the walk's: whether it went by path, and then the held directory's real path; and the names of the
|
|
55
|
+
* confinement's roots (`Confinement`'s `names`).
|
|
56
|
+
*/
|
|
57
|
+
constructor(handle: FileHandle, canonical: string, name: string, dirMode: boolean, held?: {
|
|
58
|
+
byPath: boolean;
|
|
59
|
+
path: string;
|
|
60
|
+
names?: readonly RootName[];
|
|
61
|
+
});
|
|
62
|
+
/** `text` with the held paths replaced by the real ones, and a root named as the caller names it. */
|
|
54
63
|
translate(text: string): string;
|
|
55
64
|
/** An error pi or Node raised on a held path, as a FileError that names the real one. */
|
|
56
65
|
error(error: unknown, path: string): FileError;
|
|
57
66
|
restore<T>(result: Result<T, FileError>): Result<T, FileError>;
|
|
58
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Opens the target's last component, which is never followed, and returns its handle; the caller closes it. By path,
|
|
69
|
+
* the directory's path must still name the directory held once the file is open.
|
|
70
|
+
*/
|
|
59
71
|
openFile(flags: number, mode?: number): Promise<FileHandle>;
|
|
60
72
|
binaryReader(reader: BinaryReader): BinaryReader;
|
|
61
73
|
/** The reader keeps the pin open: pi's reader names each entry through `dir` as it pages. */
|
|
62
74
|
dirReader(reader: DirReader): DirReader;
|
|
63
75
|
close(): Promise<void>;
|
|
64
76
|
}
|
|
77
|
+
/** A root's canonical path, as a pattern that matches it whole in a path or a message, and the name the caller gave it. */
|
|
78
|
+
interface RootName {
|
|
79
|
+
readonly canonical: RegExp;
|
|
80
|
+
readonly named: string;
|
|
81
|
+
}
|
|
65
82
|
export declare class Confinement {
|
|
66
83
|
#private;
|
|
67
84
|
readonly work: string;
|
|
68
85
|
readonly readOnly: readonly string[];
|
|
69
|
-
/**
|
|
70
|
-
|
|
86
|
+
/** Whether the walk goes by path (macOS, which has no `/proc/self/fd`; see the top of this file). */
|
|
87
|
+
readonly byPath: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Canonical directories: `work` may be read and written, `readOnly` only read. `byPath` defaults to true on macOS only.
|
|
90
|
+
* `names` maps a canonical root to the path the caller names it by, where they differ (a root under a symlinked
|
|
91
|
+
* directory, as `/tmp` and `/var` are on macOS): results name paths under it as the caller does, as pi's own do.
|
|
92
|
+
*/
|
|
93
|
+
constructor(work: string, readOnly: readonly string[], options?: {
|
|
94
|
+
readonly byPath?: boolean;
|
|
95
|
+
readonly names?: ReadonlyMap<string, string>;
|
|
96
|
+
});
|
|
71
97
|
/**
|
|
72
98
|
* Lets file operations write inside `dir` too: a directory this process just created (`createTempDir`) directly in a
|
|
73
99
|
* read-only root, for the tool it is handed to. `dir` is that root joined with the new name, never resolved: if the
|
|
@@ -89,3 +115,6 @@ export declare class Confinement {
|
|
|
89
115
|
create?: boolean;
|
|
90
116
|
}): Promise<Pin>;
|
|
91
117
|
}
|
|
118
|
+
/** Whether `path` still names the directory `held` holds (same device and inode); false when it cannot be read. */
|
|
119
|
+
export declare function namesHeld(path: string, held: FileHandle): Promise<boolean>;
|
|
120
|
+
export {};
|