@parcha/pi-durable-disk 0.1.0-beta.12 → 0.1.0-beta.14

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