@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/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 { checkHost, CONTROL_TIMEOUT_MS, readRunStatus, superviseRuns, sweepTokens, withTimeouts, } from "./supervise.js";
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
- /** The disk's control API for the supervisor, plus the two lists only the disk record carries (users, clients). */
145
- async function control(values) {
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
- const { configure, getDisk } = await import("disk");
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
- need(values, "disk", "region", "id", "app");
205
- const ref = { disk: str(values.disk), region: str(values.region), id: str(values.id) };
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
- mountToken: await readToken(values),
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 control(values);
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 control(values);
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 control(values);
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":
@@ -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 ticks on one boot cannot. */
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`) and records each of its commands' group leader and every
5
- // descendant it has seen, each by pid, start ticks and boot. Its commands are the children that lead their own group and
6
- // carry its mark (COMMANDS_MARK=<its token>) in their environment: another environment's commands in the same process,
7
- // or the app's own helpers, are not its to record. The next open, which holds the owner lock, kills whatever a
8
- // previous incarnation recorded that is still the same process (pid, start ticks and boot all match), waits until it is
9
- // gone, and clears the records, before the Harness opens. Only the instance writes records: a command never does.
10
- // Nothing stops a command from writing one too, and none needs stopping: a run's commands run as the instance's user (or,
11
- // with `runAs`, as another one that cannot write the instance's 0700 directory), so a record a command forged names only
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
- /** `/proc/<pid>/stat`: fields after the last `)` (the command name may hold spaces and parentheses); a zombie is gone. */
20
- function stat(proc, pid) {
21
- try {
22
- const text = readFileSync(join(proc, String(pid), "stat"), "utf8");
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
- #proc;
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.#proc = options.proc ?? "/proc";
50
+ this.#table = tableOf(options);
106
51
  this.#instance = options.instance ?? process.pid;
107
- this.#everyMs = options.everyMs ?? 100;
108
- this.#boot = bootId(this.#proc);
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 own = stat(this.#proc, this.#instance);
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.#proc, this.#instance)) {
160
- const leader = stat(this.#proc, pid);
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(this.#proc, pid, COMMANDS_MARK, this.token))
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(this.#proc, mark));
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(this.#proc, parent)) {
181
- const s = stat(this.#proc, child);
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 proc = options.proc ?? "/proc";
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
- const boot = bootId(proc);
225
- const own = stat(proc, process.pid);
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(proc, record.leader) && kill(-record.leader.pid, record.leader) ? record.leader : null;
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(proc, member) && kill(member.pid, member));
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 = () => pending.flatMap((p) => [...(p.leader ? [p.leader] : []), ...p.members]).filter((mark) => same(proc, mark));
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(proc, record.leader) ? record.leader : null;
270
- const members = record.members.filter((member) => same(proc, member));
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 proc = options.proc ?? "/proc";
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(proc, s.pid)})`).join(", ")}. ` +
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 own implementation; every
41
- * path and message it returns is mapped back to the real one with `restore`. Close it when the operation is done.
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
- constructor(handle: FileHandle, canonical: string, name: string, dirMode: boolean);
53
- /** `text` with the held paths replaced by the real ones. */
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
- /** Opens the target's last component, which is never followed, and returns its handle; the caller closes it. */
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
- /** Canonical directories: `work` may be read and written, `readOnly` only read. */
70
- constructor(work: string, readOnly: readonly string[]);
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 {};