@parcha/pi-durable-disk 0.1.0-beta.13 → 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 +96 -0
- package/README.md +102 -35
- package/dist/claim.d.ts +6 -1
- package/dist/claim.js +12 -2
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +96 -32
- 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/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 +27 -5
- package/dist/hosts/daytona.js +15 -3
- package/dist/hosts/local-host.d.ts +7 -1
- package/dist/hosts/local-host.js +25 -16
- package/dist/index.d.ts +31 -16
- package/dist/index.js +23 -16
- package/dist/internals.d.ts +3 -0
- package/dist/internals.js +6 -0
- package/dist/lease.d.ts +3 -0
- package/dist/lease.js +2 -0
- package/dist/local-run.js +3 -2
- package/dist/process-table.d.ts +38 -0
- package/dist/process-table.js +158 -0
- package/dist/run.d.ts +10 -0
- package/dist/run.js +4 -1
- 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 +19 -0
- package/dist/supervise.js +25 -0
- package/dist/watchdog.d.ts +3 -1
- package/dist/watchdog.js +43 -12
- package/package.json +8 -3
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 {
|
|
@@ -234,6 +230,10 @@ async function runInstance(values) {
|
|
|
234
230
|
}
|
|
235
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 } }) }
|
|
@@ -451,7 +450,7 @@ async function supervise(values) {
|
|
|
451
450
|
throw new UsageError("--check proves this host's own mounts; with --host docker the containers mount, so it does not apply");
|
|
452
451
|
if (docker && (typeof values.image !== "string" || !values.image))
|
|
453
452
|
throw new UsageError("--host docker needs --image");
|
|
454
|
-
const disk = await
|
|
453
|
+
const disk = await cliControl(values);
|
|
455
454
|
const controlTimeoutMs = values["control-timeout"] ? parseDuration(str(values["control-timeout"])) : CONTROL_TIMEOUT_MS;
|
|
456
455
|
if (values.check) {
|
|
457
456
|
const report = await checkHost({
|
|
@@ -537,8 +536,11 @@ async function supervise(values) {
|
|
|
537
536
|
return 0;
|
|
538
537
|
}
|
|
539
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);
|
|
540
542
|
need(values, "disk", "region", "id");
|
|
541
|
-
const disk = await
|
|
543
|
+
const disk = await cliControl(values);
|
|
542
544
|
const id = str(values.id);
|
|
543
545
|
const run = await readRunStatus(disk, id);
|
|
544
546
|
const { findDelegations } = await import("./claim.js");
|
|
@@ -556,6 +558,67 @@ async function status(values) {
|
|
|
556
558
|
emit({ run: id, runJson: run, delegations: delegations.map(({ clientId, inodeId, path, isOrphaned, isPending }) => ({ clientId, inodeId, path, isOrphaned, isPending })), holderStatus, ...(container ? { container } : {}) });
|
|
557
559
|
return 0;
|
|
558
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
|
+
}
|
|
559
622
|
async function release(values) {
|
|
560
623
|
need(values, "id");
|
|
561
624
|
const via = await unmountClaim(join(str(values["mount-root"], DEFAULT_MOUNT_ROOT), runPath(str(values.id))));
|
|
@@ -564,7 +627,7 @@ async function release(values) {
|
|
|
564
627
|
}
|
|
565
628
|
async function forkRun(values) {
|
|
566
629
|
need(values, "disk", "region", "id", "new-id");
|
|
567
|
-
const disk = await
|
|
630
|
+
const disk = await cliControl(values);
|
|
568
631
|
const result = await fork({ disk: str(values.disk), region: str(values.region), id: str(values.id) }, str(values["new-id"]), {
|
|
569
632
|
control: disk,
|
|
570
633
|
mountRoot: str(values["mount-root"], DEFAULT_MOUNT_ROOT),
|
|
@@ -583,6 +646,7 @@ export async function main(argv) {
|
|
|
583
646
|
case "run":
|
|
584
647
|
return await runInstance(parse(rest, {
|
|
585
648
|
...COMMON,
|
|
649
|
+
dir: { type: "string" },
|
|
586
650
|
archil: { type: "string" },
|
|
587
651
|
app: { type: "string" },
|
|
588
652
|
"heartbeat-ms": { type: "string" },
|
|
@@ -641,7 +705,7 @@ export async function main(argv) {
|
|
|
641
705
|
apparmor: { type: "string" },
|
|
642
706
|
}));
|
|
643
707
|
case "status":
|
|
644
|
-
return await status(parse(rest, { ...COMMON, docker: { type: "string" } }));
|
|
708
|
+
return await status(parse(rest, { ...COMMON, dir: { type: "string" }, docker: { type: "string" } }));
|
|
645
709
|
case "release":
|
|
646
710
|
return await release(parse(rest, COMMON));
|
|
647
711
|
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 {};
|