@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +262 -0
  2. package/README.md +281 -47
  3. package/dist/app.d.ts +5 -1
  4. package/dist/claim.d.ts +51 -3
  5. package/dist/claim.js +157 -17
  6. package/dist/cli.d.ts +6 -0
  7. package/dist/cli.js +112 -47
  8. package/dist/commands.d.ts +4 -1
  9. package/dist/commands.js +48 -90
  10. package/dist/confine.d.ts +36 -7
  11. package/dist/confine.js +125 -18
  12. package/dist/env.d.ts +7 -22
  13. package/dist/env.js +25 -60
  14. package/dist/errors.d.ts +2 -1
  15. package/dist/fork.d.ts +23 -0
  16. package/dist/fork.js +103 -3
  17. package/dist/hosts/attempt-env.d.ts +3 -0
  18. package/dist/hosts/attempt-env.js +15 -0
  19. package/dist/hosts/daytona-launch.d.ts +3 -1
  20. package/dist/hosts/daytona-launch.js +35 -12
  21. package/dist/hosts/daytona.d.ts +2 -2
  22. package/dist/hosts/daytona.js +48 -5
  23. package/dist/hosts/docker.d.ts +2 -2
  24. package/dist/hosts/docker.js +22 -5
  25. package/dist/hosts/local-host.d.ts +9 -3
  26. package/dist/hosts/local-host.js +59 -22
  27. package/dist/index.d.ts +35 -16
  28. package/dist/index.js +27 -16
  29. package/dist/internals.d.ts +3 -0
  30. package/dist/internals.js +6 -0
  31. package/dist/lease.d.ts +4 -1
  32. package/dist/lease.js +2 -0
  33. package/dist/local-run.js +3 -2
  34. package/dist/place.d.ts +190 -0
  35. package/dist/place.js +526 -0
  36. package/dist/process-table.d.ts +38 -0
  37. package/dist/process-table.js +158 -0
  38. package/dist/run.d.ts +61 -10
  39. package/dist/run.js +69 -33
  40. package/dist/status.d.ts +19 -3
  41. package/dist/status.js +25 -13
  42. package/dist/store.d.ts +14 -2
  43. package/dist/store.js +16 -4
  44. package/dist/supervise.d.ts +43 -1
  45. package/dist/supervise.js +26 -1
  46. package/dist/watchdog.d.ts +3 -1
  47. package/dist/watchdog.js +43 -12
  48. package/dist/with-claim.d.ts +63 -0
  49. package/dist/with-claim.js +325 -0
  50. 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 { 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
  /**
@@ -173,7 +165,7 @@ async function control(values) {
173
165
  */
174
166
  async function readToken(values) {
175
167
  if (!values["token-stdin"])
176
- return "none";
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() || "none";
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 loads before anything mounts; a missing or throwing module, or a failed `onOpen`, is AppError (exit 1,
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
- need(values, "disk", "region", "id", "app");
205
- const ref = { disk: str(values.disk), region: str(values.region), id: str(values.id) };
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
- try {
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
- mountToken: await readToken(values),
254
- mountRoot,
255
- host: { archil: str(values.archil) },
256
- harness,
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 control(values);
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 control(values);
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 control(values);
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":
@@ -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 {};