@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.
@@ -13,7 +13,7 @@
13
13
  // incarnation gets the token; a restart in place reads an empty stdin and reuses its live mount, as under localHost.
14
14
  // No cgroup per incarnation: commands a crashed instance detached can outlive it until the box is deleted.
15
15
  import { spawn } from "node:child_process";
16
- import { closeSync, existsSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
16
+ import { closeSync, existsSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
17
17
  import { join, resolve } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { procStartTicks, TERMINAL_EXITS } from "./local-host.js";
@@ -43,14 +43,17 @@ function writeState(path, state) {
43
43
  // Only a real process with its start ticks: `stop` signals these pids, the instance's as a group, and a pid of 1 or below
44
44
  // names a group (`kill(-1)` is every process this user may signal). The state file holds whatever was written there.
45
45
  const alive = (pid, ticks) => Number.isInteger(pid) && pid > 1 && typeof ticks === "number" && procStartTicks(pid) === ticks;
46
- /** running while either process lives; otherwise stopped after a clean exit, failed after any other end. */
46
+ /** running while either process lives and the launcher has not recorded its exit; otherwise stopped after a clean exit,
47
+ * failed after any other end. Every "exited" the launcher saves is final and comes after its instance ended, so once it is
48
+ * saved (and no instance lives) the record is the answer, even while the launcher's own process is still exiting. */
47
49
  export function launchStatus(dir, name) {
48
50
  const state = readState(dir, name);
49
51
  if (!state)
50
52
  return { status: "failed", state: null, launcherAlive: false, instanceAlive: false };
51
53
  const launcherAlive = alive(state.launcher, state.launcherTicks);
52
54
  const instanceAlive = alive(state.instance, state.instanceTicks);
53
- if (launcherAlive || instanceAlive)
55
+ const ended = state.phase === "exited" && !instanceAlive;
56
+ if (!ended && (launcherAlive || instanceAlive))
54
57
  return { status: "running", state, launcherAlive, instanceAlive };
55
58
  const clean = state.phase === "exited" && state.exit === 0;
56
59
  return { status: clean ? "stopped" : "failed", state, launcherAlive, instanceAlive };
@@ -216,8 +219,12 @@ export async function stop(dir, name, timeoutMs) {
216
219
  }
217
220
  };
218
221
  let s = launchStatus(dir, name);
219
- if (s.status !== "running")
222
+ if (s.status !== "running") {
223
+ // The launcher recorded its exit and is ending on its own; a launcher process that outstays its record is still sent away.
224
+ if (s.launcherAlive)
225
+ kill(s.state.launcher, s.state.launcherTicks, "SIGTERM");
220
226
  return s;
227
+ }
221
228
  if (s.launcherAlive)
222
229
  kill(s.state.launcher, s.state.launcherTicks, "SIGTERM");
223
230
  else
@@ -262,7 +269,22 @@ async function main(argv) {
262
269
  return 2;
263
270
  }
264
271
  }
265
- if (process.argv[1] && resolve(process.argv[1]) === LAUNCH_SCRIPT) {
272
+ /**
273
+ * Whether Node was started with this module. LAUNCH_SCRIPT comes from import.meta.url, which is the real path; argv[1] is
274
+ * the path as given, which may go through a link (a symlinked package directory, pnpm's linked node_modules).
275
+ */
276
+ function invokedAsMain() {
277
+ const arg = process.argv[1];
278
+ if (!arg)
279
+ return false;
280
+ try {
281
+ return realpathSync(arg) === LAUNCH_SCRIPT;
282
+ }
283
+ catch {
284
+ return resolve(arg) === LAUNCH_SCRIPT;
285
+ }
286
+ }
287
+ if (invokedAsMain()) {
266
288
  main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
267
289
  process.stderr.write(`${err.message}\n`);
268
290
  process.exit(1);
@@ -121,7 +121,11 @@ export class DaytonaHostError extends PdaError {
121
121
  super(code, message, options);
122
122
  }
123
123
  }
124
- const BOX_PATH = /^\/[A-Za-z0-9._/-]+$/;
124
+ // Shell-inert characters only: the paths go into launcher command lines. `@` admits npm's scoped package directories
125
+ // (`node_modules/@scope/pkg`), where the package is installed with npm and not copied to a fixed directory.
126
+ const BOX_PATH = /^\/[A-Za-z0-9._@/-]+$/;
127
+ /** A path that climbs out of where it names: never a box path, whatever else is in it. */
128
+ const climbs = (path) => path.split("/").includes("..");
125
129
  const ACCOUNT = /^(?:[a-z_][a-z0-9_-]{0,31}|\d{1,10})$/;
126
130
  const FLEET = /^[a-z0-9][a-z0-9-]{0,62}$/;
127
131
  const UP = new Set(["creating", "pulling_snapshot", "pending_build", "building_snapshot", "starting", "restoring", "resuming", "resizing", "snapshotting", "forking", "stopping", "pausing", "paused"]);
@@ -152,6 +156,14 @@ export function sandboxName(prefix, runId, now) {
152
156
  return `${prefix}${slug}-${stamp}`.slice(0, 63);
153
157
  }
154
158
  /** The last line of a command's output that parses as JSON, or null. */
159
+ /**
160
+ * What the launcher printed, for a start it gave no reason for: the last 4 KB, or that it printed nothing. It prints only
161
+ * its state; the mount token is in a root-only file it reads, never in its output.
162
+ */
163
+ function launcherOutput(text) {
164
+ const out = text.trim();
165
+ return out ? `the launcher printed: ${out.slice(-4096)}` : "the launcher printed nothing and exited 0";
166
+ }
155
167
  function lastJson(text) {
156
168
  for (const line of text.trim().split("\n").reverse()) {
157
169
  try {
@@ -185,7 +197,7 @@ export function daytonaHost(opts) {
185
197
  const now = opts.now ?? Date.now;
186
198
  const launch = opts.launcher ?? `${packageDir}/dist/hosts/daytona-launch.js`;
187
199
  for (const [what, value] of Object.entries({ mountRoot, node, packageDir, archil, boxDir: dir, stageDir: stage, launcher: launch })) {
188
- if (!BOX_PATH.test(value))
200
+ if (!BOX_PATH.test(value) || climbs(value))
189
201
  throw new DaytonaHostError("INVALID_ARGUMENT", `${what} ${JSON.stringify(value)} is not a plain absolute path`);
190
202
  }
191
203
  if (!ACCOUNT.test(user) || !ACCOUNT.test(group))
@@ -288,7 +300,7 @@ export function daytonaHost(opts) {
288
300
  const r = await run(box, launcher("start", name, left), Math.ceil(left / 1000) + 10, "starting the instance");
289
301
  const state = lastJson(r.result);
290
302
  if (!state || !(state.spawned > 0)) {
291
- throw new DaytonaHostError("START_FAILED", `the instance in ${name} did not start: ${state?.reason ?? r.result.trim().slice(0, 300)}`);
303
+ throw new DaytonaHostError("START_FAILED", `the instance in ${name} did not start: ${state?.reason || launcherOutput(r.result)}`);
292
304
  }
293
305
  return handle;
294
306
  }
@@ -54,10 +54,13 @@ export declare class LocalHostError extends PdaError {
54
54
  cause?: unknown;
55
55
  });
56
56
  }
57
+ /** The argv of that child for `path`. */
58
+ export declare const statCodeArgv: (path: string) => string[];
57
59
  /** Exits a restart cannot fix: 65 data error, 70 store head unreadable, 75 fenced, 76 held. */
58
60
  export declare const TERMINAL_EXITS: readonly [65, 70, 75, 76];
59
61
  /** Minimal environment, a timeout that settles even if a grandchild keeps the pipes, and the input on stdin. */
60
62
  export declare const runCommand: Runner;
63
+ /** This boot's id: `/proc/sys/kernel/random/boot_id`, or `kern.bootsessionuuid` on macOS. Throws when it cannot be read. */
61
64
  export declare function currentBootId(): string;
62
65
  /** `systemctl show` output as a map. */
63
66
  export declare function parseShow(text: string): Record<string, string>;
@@ -67,5 +70,8 @@ export declare function localHost(opts?: LocalHostOptions): HostDriver & {
67
70
  readonly hostName: string;
68
71
  readonly mountRoot: string;
69
72
  };
70
- /** Field 22 of /proc/<pid>/stat (start time in clock ticks), so a reused pid is never mistaken for the instance. */
73
+ /**
74
+ * A live process's start (field 22 of /proc/<pid>/stat, in clock ticks; on macOS the second it started), so a reused pid is
75
+ * never mistaken for the instance. Null for a zombie, a process that is gone, or one the table cannot be read for.
76
+ */
71
77
  export declare function procStartTicks(pid: number): number | null;
@@ -12,14 +12,14 @@
12
12
  // place reads nothing and reuses its live mount), the instance closes its stdin after reading, and stop removes it.
13
13
  // The token is never in argv, the environment, a systemd credential directory or anything the run's user can open.
14
14
  import { spawn } from "node:child_process";
15
- import { readFileSync } from "node:fs";
16
15
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
17
16
  import { hostname } from "node:os";
18
17
  import { extname, join } from "node:path";
19
18
  import { fileURLToPath } from "node:url";
20
- import { ARCHIL_SCOPED, DEFAULT_MOUNT_ROOT, runPath } from "../claim.js";
19
+ import { ARCHIL_SCOPED, DEAD_CONNECTION, DEFAULT_MOUNT_ROOT, runPath } from "../claim.js";
21
20
  import { EXIT_FENCED, EXIT_HELD, PdaError } from "../errors.js";
22
21
  import { LOCAL_PARK_THRESHOLD_MS } from "../park.js";
22
+ import { processTable, thisBoot } from "../process-table.js";
23
23
  export class LocalHostError extends PdaError {
24
24
  constructor(code, message, options = {}) {
25
25
  super(code, message, options);
@@ -32,7 +32,13 @@ const SYSTEMD_RUN = "/usr/bin/systemd-run";
32
32
  const ARCHIL = "/usr/bin/archil";
33
33
  const UNIT_SAFE = /^[A-Za-z0-9:_.-]{1,200}$/;
34
34
  const POLITE_UNMOUNT_MS = 10_000;
35
- const STAT = "/usr/bin/stat";
35
+ /**
36
+ * A stat of one path in a child process, so a stuck daemon never blocks this one: prints the errno code (or "ok"), never a
37
+ * message, which coreutils' `stat` prints in the locale's language.
38
+ */
39
+ const STAT_CODE = `try { require("node:fs").statSync(process.argv[1]); process.stdout.write("ok"); } catch (e) { process.stdout.write(String(e.code)); process.exitCode = 1; }`;
40
+ /** The argv of that child for `path`. */
41
+ export const statCodeArgv = (path) => [process.execPath, "-e", STAT_CODE, path];
36
42
  const FUSERMOUNT = "/usr/bin/fusermount";
37
43
  const UMOUNT = "/usr/bin/umount";
38
44
  /** How long a stat of the mountpoint may take before its daemon counts as alive but stuck (never removed). */
@@ -63,8 +69,12 @@ export const runCommand = (argv, opts = {}) => new Promise((resolve, reject) =>
63
69
  child.stdin.on("error", () => { });
64
70
  child.stdin.end(opts.input ?? "");
65
71
  });
72
+ /** This boot's id: `/proc/sys/kernel/random/boot_id`, or `kern.bootsessionuuid` on macOS. Throws when it cannot be read. */
66
73
  export function currentBootId() {
67
- return readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
74
+ const boot = thisBoot();
75
+ if (boot === null)
76
+ throw new Error("this machine's boot id cannot be read");
77
+ return boot;
68
78
  }
69
79
  const firstLine = (r) => (r.stderr.trim().split("\n").at(-1) ?? "").slice(0, 300);
70
80
  const okRan = (r) => r.code === 0 && !r.timedOut;
@@ -234,13 +244,14 @@ export function localHost(opts = {}) {
234
244
  await new Promise((r) => setTimeout(r, 100));
235
245
  }
236
246
  /**
237
- * Whether the mount at `mp` is dead: its FUSE daemon is gone, so the kernel answers with ENOTCONN ("Transport endpoint
238
- * is not connected"). The stat runs in a child with a timeout, so a live but stuck daemon never blocks this process; a
239
- * stat that answers or does not finish is a live mount.
247
+ * Whether the mount at `mp` is dead: its FUSE daemon is gone, so the stat fails with one of `DEAD_CONNECTION`'s codes
248
+ * (ENOTCONN, or ECONNABORTED for a stat in flight while the dying daemon's connection is torn down). The stat runs in a
249
+ * child with a timeout, so a live but stuck daemon never blocks this process, and the child prints the code, not a
250
+ * message; a stat that answers, fails otherwise or does not finish is a live mount.
240
251
  */
241
252
  async function deadMount(mp) {
242
- const r = await exec([STAT, "-c", "%i", mp], { timeoutMs: STAT_TIMEOUT_MS });
243
- return !r.timedOut && r.code !== 0 && /Transport endpoint is not connected/.test(r.stderr);
253
+ const r = await exec(statCodeArgv(mp), { timeoutMs: STAT_TIMEOUT_MS });
254
+ return !r.timedOut && r.code !== 0 && DEAD_CONNECTION.has(r.stdout.trim());
244
255
  }
245
256
  /** Remove a dead mount: `fusermount -u`, then a lazy `umount -l`. The mount table, not an exit code, decides. */
246
257
  async function removeDeadMount(mp) {
@@ -338,19 +349,17 @@ export function localHost(opts = {}) {
338
349
  },
339
350
  };
340
351
  }
341
- /** Field 22 of /proc/<pid>/stat (start time in clock ticks), so a reused pid is never mistaken for the instance. */
352
+ /**
353
+ * A live process's start (field 22 of /proc/<pid>/stat, in clock ticks; on macOS the second it started), so a reused pid is
354
+ * never mistaken for the instance. Null for a zombie, a process that is gone, or one the table cannot be read for.
355
+ */
342
356
  export function procStartTicks(pid) {
343
357
  if (!Number.isInteger(pid) || pid <= 0)
344
358
  return null;
345
- let stat;
346
359
  try {
347
- stat = readFileSync(`/proc/${pid}/stat`, "utf8");
360
+ return processTable().snapshot().info(pid)?.start ?? null;
348
361
  }
349
362
  catch {
350
363
  return null;
351
364
  }
352
- const rest = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
353
- if (rest[0] === "Z" || rest[0] === "X")
354
- return null;
355
- return Number(rest[19]);
356
365
  }
package/dist/index.d.ts CHANGED
@@ -1,16 +1,31 @@
1
- export * from "./app.js";
2
- export * from "./claim.js";
3
- export { PathOutsideWorkError, type RefusalReason } from "./confine.js";
4
- export * from "./env.js";
5
- export * from "./errors.js";
6
- export * from "./fork.js";
7
- export * from "./hosts/daytona.js";
8
- export * from "./hosts/docker.js";
9
- export * from "./hosts/local-host.js";
10
- export * from "./local-run.js";
11
- export * from "./park.js";
12
- export * from "./run.js";
13
- export * from "./serve.js";
14
- export * from "./status.js";
15
- export * from "./store.js";
16
- export * from "./supervise.js";
1
+ export { openDurableRun, RunError, StoreBehindSealError } from "./run.js";
2
+ export type { BeforeResume, DurableRun, LeaseOptions, OpenDurableRunOptions, OpenRunLeaseOptions, OpenStep, ReleaseStep, RunClaim, RunEnv, RunErrorCode, RunLease, StoreHeadSource, StoreReads } from "./run.js";
3
+ export { localRun } from "./local-run.js";
4
+ export type { LocalRun } from "./local-run.js";
5
+ export { CLAIM_UNMOUNTED, openClaimDir, RunRecordError } from "./status.js";
6
+ export type { ClaimDir, OpenClaimDirOptions, PersistRecord, RunHolder, RunRecord, RunStatus } from "./status.js";
7
+ export { ArchilCodingTools, archilEnv, EnvClosedError, EnvError, markReadOnlySafe } from "./env.js";
8
+ export type { ArchilEnvClaim, ArchilEnvFactory, ArchilEnvOptions, EnvErrorCode } from "./env.js";
9
+ export { acquire, ARCHIL_SCOPED, revoke, takeOver } from "./claim.js";
10
+ export type { AcquireOptions, ArchilHost, Claim, ControlApi, PathlessResolver, RunRef, Unmounted } from "./claim.js";
11
+ export { PathOutsideWorkError } from "./confine.js";
12
+ export type { RefusalReason } from "./confine.js";
13
+ export { ClaimError, FencedError, HeldError, PdaError } from "./errors.js";
14
+ export type { ClaimErrorCode, Holder } from "./errors.js";
15
+ export type { ArchilStore, Fence, FencedDatabase, Profile, StoreFencedError } from "./store.js";
16
+ export { AppError } from "./app.js";
17
+ export type { AppContext, AppModule, AppOptions } from "./app.js";
18
+ export { archilControl, deleteRunTree, ensureRunning, readRunStatus, SuperviseError } from "./supervise.js";
19
+ export type { ArchilControl, CheckControl, CheckOptions, DiskClient, EnsureOptions, EnsureResult, HostDriver, HostHandle, HostStatus, Json, StartAttempt, StartReason, StonithOutcome, SuperviseErrorCode, SupervisorControl, TokenUser } from "./supervise.js";
20
+ export { localHost, LocalHostError } from "./hosts/local-host.js";
21
+ export type { LocalHostOptions, Ran, Runner } from "./hosts/local-host.js";
22
+ export { dockerHost, DockerHostError } from "./hosts/docker.js";
23
+ export type { ContainerInfo, DockerHostOptions, DockerRan, DockerRunner } from "./hosts/docker.js";
24
+ export { DaytonaApiError, daytonaHost, DaytonaHostError, daytonaRest } from "./hosts/daytona.js";
25
+ export type { CreateSandboxBody, DaytonaClient, DaytonaHostErrorCode, DaytonaHostOptions, DaytonaRestOptions, ExecResult, SandboxInfo } from "./hosts/daytona.js";
26
+ export { requestRun, ServeConfigError, ServeError, serveRun } from "./serve.js";
27
+ export type { RequestOptions, RunResponse, RunServer, ServeConfigErrorCode, ServeErrorCode, ServeOptions } from "./serve.js";
28
+ export { busyState, drain, leaseParkTarget, watchParking } from "./park.js";
29
+ export type { BusyState, DrainOptions, Parking, ParkOptions, ParkResult, ParkTarget } from "./park.js";
30
+ export { fork, ForkError, forkLocal } from "./fork.js";
31
+ export type { ForkErrorCode, ForkLocalResult, ForkOptions, ForkResult } from "./fork.js";
package/dist/index.js CHANGED
@@ -1,17 +1,24 @@
1
- // Public API.
2
- export * from "./app.js";
3
- export * from "./claim.js";
1
+ // The package's public API. Every name is listed: a new export is a reviewed line here, and test/entries.test.ts pins the
2
+ // list of each entry. `./lease` is the narrow entry for a host that owns its Harness; `./internals` holds plumbing that
3
+ // examples and demos use, with no compatibility promise.
4
+ // The run: open it, on a disk or a local directory, with its environment, its claim and the errors it fails with.
5
+ export { openDurableRun, RunError, StoreBehindSealError } from "./run.js";
6
+ export { localRun } from "./local-run.js";
7
+ export { CLAIM_UNMOUNTED, openClaimDir, RunRecordError } from "./status.js";
8
+ export { ArchilCodingTools, archilEnv, EnvClosedError, EnvError, markReadOnlySafe } from "./env.js";
9
+ export { acquire, ARCHIL_SCOPED, revoke, takeOver } from "./claim.js";
4
10
  export { PathOutsideWorkError } from "./confine.js";
5
- export * from "./env.js";
6
- export * from "./errors.js";
7
- export * from "./fork.js";
8
- export * from "./hosts/daytona.js";
9
- export * from "./hosts/docker.js";
10
- export * from "./hosts/local-host.js";
11
- export * from "./local-run.js";
12
- export * from "./park.js";
13
- export * from "./run.js";
14
- export * from "./serve.js";
15
- export * from "./status.js";
16
- export * from "./store.js";
17
- export * from "./supervise.js";
11
+ export { ClaimError, FencedError, HeldError, PdaError } from "./errors.js";
12
+ // The app contract: what `--app` names.
13
+ export { AppError } from "./app.js";
14
+ // The supervisor and the host drivers.
15
+ export { archilControl, deleteRunTree, ensureRunning, readRunStatus, SuperviseError } from "./supervise.js";
16
+ export { localHost, LocalHostError } from "./hosts/local-host.js";
17
+ export { dockerHost, DockerHostError } from "./hosts/docker.js";
18
+ export { DaytonaApiError, daytonaHost, DaytonaHostError, daytonaRest } from "./hosts/daytona.js";
19
+ // Serve: one HTTP request runs one turn.
20
+ export { requestRun, ServeConfigError, ServeError, serveRun } from "./serve.js";
21
+ // Parking: an idle run gives its host back.
22
+ export { busyState, drain, leaseParkTarget, watchParking } from "./park.js";
23
+ // Fork: a new run from a run's committed state.
24
+ export { fork, ForkError, forkLocal } from "./fork.js";
@@ -0,0 +1,3 @@
1
+ export { createRunDir, findDelegations, mintMountToken, pathlessResolver, removeMountToken, runPath, unmountClaim } from "./claim.js";
2
+ export { LABEL_FLEET, LABEL_RUN, sandboxName, sandboxStatus, sweepSandboxes } from "./hosts/daytona.js";
3
+ export { isLeaseFresh, parseRunRecord, RUN_JSON } from "./status.js";
@@ -0,0 +1,6 @@
1
+ // `@parcha/pi-durable-disk/internals`: the claim, status and Daytona plumbing that this repository's examples and demos
2
+ // use. No compatibility promise: a name here may change or go in any release, and the CHANGELOG says when. A name moves
3
+ // to the root entry when something outside this repository needs it.
4
+ export { createRunDir, findDelegations, mintMountToken, pathlessResolver, removeMountToken, runPath, unmountClaim } from "./claim.js";
5
+ export { LABEL_FLEET, LABEL_RUN, sandboxName, sandboxStatus, sweepSandboxes } from "./hosts/daytona.js";
6
+ export { isLeaseFresh, parseRunRecord, RUN_JSON } from "./status.js";
package/dist/lease.d.ts CHANGED
@@ -5,3 +5,6 @@ export { assertPragmas, Fence, FencedDatabase, openArchilStore, PROFILE_PRAGMAS,
5
5
  export type { ArchilStore, OpenOptions, PragmaMismatch, Profile } from "./store.js";
6
6
  export type { AcquireOptions, ArchilHost, Claim, RunRef, Unmounted } from "./claim.js";
7
7
  export type { ClaimDir, PersistRecord, RunHolder, RunRecord, RunStatus } from "./status.js";
8
+ export { busyState, drain, leaseParkTarget, watchParking } from "./park.js";
9
+ export type { BusyState, DrainOptions, Parking, ParkOptions, ParkResult, ParkTarget } from "./park.js";
10
+ export type { DurableRun, RunEnv } from "./run.js";
package/dist/lease.js CHANGED
@@ -4,3 +4,5 @@
4
4
  export { EXIT_DATAERR, EXIT_SOFTWARE, LeaseLapsedError, openRunLease, OWNER_LOCK, RunError, StoreBehindSealError, storeHead, STORE_FILE } from "./run.js";
5
5
  export * from "./errors.js";
6
6
  export { assertPragmas, Fence, FencedDatabase, openArchilStore, PROFILE_PRAGMAS, StoreBusyError, StoreFencedError, StorePragmaError } from "./store.js";
7
+ // Parking, for a Harness the host opened over `openRunLease` (`leaseParkTarget`).
8
+ export { busyState, drain, leaseParkTarget, watchParking } from "./park.js";
package/dist/local-run.js CHANGED
@@ -8,8 +8,9 @@
8
8
  // store's own commits are made durable by SQLite, and work/ is the operating system's to flush; a power loss can lose what
9
9
  // it had not written yet. It refuses once the claim is fenced, as a mount's does.
10
10
  //
11
- // Linux only, for now: the run's root is held open through /proc/self/fd (openClaimDir), and the default environment
12
- // (`archilEnv`) confines commands with `setpriv` (util-linux) and the file tools through /proc/self/fd. macOS has neither.
11
+ // On macOS, for development: it has no /proc/self/fd and no setpriv, so there the run's root is addressed by its path
12
+ // (openClaimDir's `byPath`), commands keep the user's rights, the file tools are confined by path (confine.ts), and the
13
+ // commands are recorded from ps (process-table.ts). The README's "On macOS" says what that costs.
13
14
  import { mkdir } from "node:fs/promises";
14
15
  import { join, resolve } from "node:path";
15
16
  import { FencedError } from "./errors.js";
@@ -0,0 +1,38 @@
1
+ /** A live process (a zombie is not one): its parent, its group, and its start. */
2
+ export interface ProcessInfo {
3
+ readonly ppid: number;
4
+ readonly pgrp: number;
5
+ readonly start: number;
6
+ }
7
+ /** One look at the processes. Through ps, every `info` and `children` of one snapshot sees the same listing; /proc is read live. */
8
+ export interface ProcessSnapshot {
9
+ info(pid: number): ProcessInfo | undefined;
10
+ /** The direct children of `pid`. */
11
+ children(pid: number): number[];
12
+ /** Whether `pid` was started with `name=value` in its environment (false when it cannot be read). */
13
+ marked(pid: number, name: string, value: string): boolean;
14
+ /** Its command line, for a message; `?` when it cannot be read. */
15
+ commandLine(pid: number): string;
16
+ }
17
+ export interface ProcessTable {
18
+ /** A new look. Through ps it throws when the listing cannot be had or read: no process is ever taken for gone on a failed read. */
19
+ snapshot(): ProcessSnapshot;
20
+ /** This boot's id; undefined when it cannot be read. */
21
+ bootId(): string | undefined;
22
+ /** How often a recorder's walk is worth repeating: a /proc read is cheap, a ps listing is not. */
23
+ readonly walkMs: number;
24
+ }
25
+ /** The table on Linux: `/proc` (or another directory laid out like it, for tests). */
26
+ export declare function procTable(proc?: string): ProcessTable;
27
+ /** Runs a program with arguments and returns what it printed; throws when it fails. */
28
+ export type CommandRunner = (file: string, args: readonly string[]) => string;
29
+ /** The table on macOS: `ps` and `sysctl`, through `run` (for tests). */
30
+ export declare function psTable(run?: CommandRunner): ProcessTable;
31
+ /** Test seam: a table that stands in for this machine's everywhere `processTable()` is read. */
32
+ export declare const processTableHooks: {
33
+ table?: ProcessTable;
34
+ };
35
+ /** This machine's table: ps on macOS, /proc elsewhere. */
36
+ export declare function processTable(): ProcessTable;
37
+ /** This boot's id on this machine, null when it cannot be read. */
38
+ export declare function thisBoot(): string | null;
@@ -0,0 +1,158 @@
1
+ // The process table a run reads to know its commands and its holder. On Linux it is /proc, read live. macOS has no /proc:
2
+ // there it is one ps(1) listing per look, ps for a process's environment, and sysctl(8) for the boot. A process's start
3
+ // tells it from a later process with the same pid: on Linux its start time in clock ticks after boot (field 22 of
4
+ // /proc/<pid>/stat), on macOS the second it started (ps's `lstart`, read in UTC). A start is only ever compared with
5
+ // another read on the same machine and boot. On macOS a pid freed and handed out again within the same second would pass
6
+ // for the old process; pids there are handed out in order up to 99999 before one is reused.
7
+ import { execFileSync } from "node:child_process";
8
+ import { readdirSync, readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ /** The table on Linux: `/proc` (or another directory laid out like it, for tests). */
11
+ export function procTable(proc = "/proc") {
12
+ const read = (...parts) => readFileSync(join(proc, ...parts), "utf8");
13
+ const snapshot = {
14
+ // `stat`'s fields after the last `)`, since the command name may hold spaces and parentheses.
15
+ info(pid) {
16
+ try {
17
+ const text = read(String(pid), "stat");
18
+ const fields = text.slice(text.lastIndexOf(")") + 2).split(" ");
19
+ if (fields[0] === "Z" || fields[0] === "X")
20
+ return undefined;
21
+ return { ppid: Number(fields[1]), pgrp: Number(fields[2]), start: Number(fields[19]) };
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ },
27
+ // From each of its threads' `children` list.
28
+ children(pid) {
29
+ let tasks;
30
+ try {
31
+ tasks = readdirSync(join(proc, String(pid), "task"));
32
+ }
33
+ catch {
34
+ return [];
35
+ }
36
+ return tasks.flatMap((tid) => {
37
+ try {
38
+ return read(String(pid), "task", tid, "children").split(" ").filter(Boolean).map(Number);
39
+ }
40
+ catch {
41
+ return [];
42
+ }
43
+ });
44
+ },
45
+ marked(pid, name, value) {
46
+ try {
47
+ return read(String(pid), "environ").split("\0").includes(`${name}=${value}`);
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ },
53
+ commandLine(pid) {
54
+ try {
55
+ return read(String(pid), "cmdline").split("\0").filter(Boolean).join(" ") || "?";
56
+ }
57
+ catch {
58
+ return "?";
59
+ }
60
+ },
61
+ };
62
+ return {
63
+ snapshot: () => snapshot,
64
+ bootId() {
65
+ try {
66
+ return read("sys/kernel/random/boot_id").trim() || undefined;
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ },
72
+ walkMs: 100,
73
+ };
74
+ }
75
+ const PS = "/bin/ps";
76
+ const SYSCTL = "/usr/sbin/sysctl";
77
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
78
+ /** In the C locale and UTC, so `lstart` reads the same whatever the user's settings. */
79
+ const runCommand = (file, args) => execFileSync(file, args, { encoding: "utf8", env: { LC_ALL: "C", TZ: "UTC" }, stdio: ["ignore", "pipe", "ignore"], timeout: 10_000, maxBuffer: 64 << 20 });
80
+ /**
81
+ * One line of `ps -A -o pid=,ppid=,pgid=,stat=,lstart=` in UTC: `pid ppid pgid stat Www Mmm dd hh:mm:ss yyyy`. Null
82
+ * for a zombie; throws on a line it does not read.
83
+ */
84
+ function psLine(line) {
85
+ const f = line.trim().split(/\s+/);
86
+ const [pid, ppid, pgrp] = f.slice(0, 3).map(Number);
87
+ const month = MONTHS.indexOf(f[5] ?? "");
88
+ const [h, m, s] = (f[7] ?? "").split(":").map(Number);
89
+ const start = Date.UTC(Number(f[8]), month, Number(f[6]), h, m, s) / 1000;
90
+ if (f.length !== 9 || ![pid, ppid, pgrp, start].every(Number.isSafeInteger) || month < 0)
91
+ throw new Error(`ps printed a line this does not read: ${JSON.stringify(line)}`);
92
+ return f[3].startsWith("Z") ? null : [pid, { ppid: ppid, pgrp: pgrp, start }];
93
+ }
94
+ /** The table on macOS: `ps` and `sysctl`, through `run` (for tests). */
95
+ export function psTable(run = runCommand) {
96
+ let boot;
97
+ return {
98
+ snapshot() {
99
+ const rows = new Map();
100
+ for (const line of run(PS, ["-A", "-o", "pid=,ppid=,pgid=,stat=,lstart="]).split("\n")) {
101
+ if (line.trim() === "")
102
+ continue;
103
+ const row = psLine(line);
104
+ if (row !== null)
105
+ rows.set(...row);
106
+ }
107
+ return {
108
+ info: (pid) => rows.get(pid),
109
+ children: (pid) => [...rows].filter(([, info]) => info.ppid === pid).map(([child]) => child),
110
+ // `-E` prints the environment the process was started with after its arguments, separated by spaces; a mark is
111
+ // a name and a UUID, which hold none.
112
+ marked(pid, name, value) {
113
+ try {
114
+ return run(PS, ["-ww", "-E", "-p", String(pid), "-o", "command="]).split(/\s+/).includes(`${name}=${value}`);
115
+ }
116
+ catch {
117
+ return false;
118
+ }
119
+ },
120
+ commandLine(pid) {
121
+ try {
122
+ return run(PS, ["-ww", "-p", String(pid), "-o", "command="]).trim() || "?";
123
+ }
124
+ catch {
125
+ return "?";
126
+ }
127
+ },
128
+ };
129
+ },
130
+ // Read once it can be: a boot's id does not change.
131
+ bootId() {
132
+ try {
133
+ boot ??= run(SYSCTL, ["-n", "kern.bootsessionuuid"]).trim() || undefined;
134
+ }
135
+ catch {
136
+ // not now; the next call asks again
137
+ }
138
+ return boot;
139
+ },
140
+ walkMs: 500,
141
+ };
142
+ }
143
+ /** Test seam: a table that stands in for this machine's everywhere `processTable()` is read. */
144
+ export const processTableHooks = {};
145
+ let platformTable;
146
+ /** This machine's table: ps on macOS, /proc elsewhere. */
147
+ export function processTable() {
148
+ if (processTableHooks.table !== undefined)
149
+ return processTableHooks.table;
150
+ if (platformTable?.platform !== process.platform) {
151
+ platformTable = { platform: process.platform, table: process.platform === "darwin" ? psTable() : procTable() };
152
+ }
153
+ return platformTable.table;
154
+ }
155
+ /** This boot's id on this machine, null when it cannot be read. */
156
+ export function thisBoot() {
157
+ return processTable().bootId() ?? null;
158
+ }
package/dist/run.d.ts CHANGED
@@ -130,7 +130,15 @@ export interface BeforeResume {
130
130
  readonly harness: Harness;
131
131
  readonly context: Context;
132
132
  readonly generation: number;
133
+ /** This incarnation's `run.json`, which the open has already written (`running`, this holder, `detail` null). */
133
134
  readonly record: RunRecord;
135
+ /**
136
+ * `run.json` as it was before this open wrote over it: how the run was left. After a park it is `sleeping` with its
137
+ * `wakeAt` and `detail` and the seal its release wrote; after a planned stop, `paused` or `done` with the `detail` it
138
+ * set; after a crash, `running` with the dead holder and no new seal. On a fork's first open it is the record `fork`
139
+ * wrote: `paused`, generation 0, `detail.forkedFrom`. Null only when there was no `run.json` (a new run's first open).
140
+ */
141
+ readonly previous: RunRecord | null;
134
142
  readonly claim: RunClaim;
135
143
  /** The store the Harness was opened over, read-only: four reads on an object of their own, and no way to a write. */
136
144
  readonly storage: StoreReads;
@@ -171,6 +179,8 @@ export interface RunLease {
171
179
  readonly record: RunRecord;
172
180
  /** The seal the previous incarnation's clean release wrote; null on a first run and after a crash. */
173
181
  readonly sealedSeq: number | null;
182
+ /** `run.json` as the open found it, before it wrote this incarnation's: null when there was none (a new run's first open). */
183
+ readonly previous: RunRecord | null;
174
184
  readonly fenced: boolean;
175
185
  /**
176
186
  * The seal check on the app's store, before anything commits: the store's head (`storeHead`) below
package/dist/run.js CHANGED
@@ -278,6 +278,9 @@ class Lease {
278
278
  get sealedSeq() {
279
279
  return this.#previous?.sealedSeq ?? null;
280
280
  }
281
+ get previous() {
282
+ return this.#previous ?? null;
283
+ }
281
284
  get fenced() {
282
285
  return this.#fence !== undefined;
283
286
  }
@@ -702,7 +705,7 @@ class Run {
702
705
  // between the open and the hook, since the Harness has not resumed.
703
706
  await step("before-resume", async () => {
704
707
  const head = (await storeHead(this.#store));
705
- await before({ harness: this.#harness, context: this.#context, generation: lease.generation, record: lease.record, claim: lease.claim, storage: storeReads(storage), head });
708
+ await before({ harness: this.#harness, context: this.#context, generation: lease.generation, record: lease.record, previous: lease.previous, claim: lease.claim, storage: storeReads(storage), head });
706
709
  });
707
710
  }
708
711
  await step("resume", () => this.#harness.resume());