@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
@@ -9,11 +9,13 @@
9
9
  // status <name> print running, stopped or failed from the state file and whether the processes still live
10
10
  // stop <name> SIGTERM the launcher (it drains the instance), SIGKILL both after the timeout, print the state
11
11
  // Files live in a root-only directory (default /run/pda, 0700), which the run user can never traverse: `<name>.json`
12
- // (the spec), `<name>.token` (gone once read), `<name>.state`, `<name>.log` (the instance's output). Only the first
13
- // incarnation gets the token; a restart in place reads an empty stdin and reuses its live mount, as under localHost.
12
+ // (the spec), `<name>.token` (gone once read), `<name>.state`, `<name>.log` (the instance's output). Every incarnation of
13
+ // the attempt gets its token on stdin: a restart whose mount is still live reuses it and never needs the token, and one
14
+ // whose mount went with the failed incarnation mounts again (a takeover in between makes that mount HeldError, 76,
15
+ // terminal). The token lives in this launcher's memory for the attempt.
14
16
  // No cgroup per incarnation: commands a crashed instance detached can outlive it until the box is deleted.
15
17
  import { spawn } from "node:child_process";
16
- import { closeSync, existsSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
18
+ import { closeSync, existsSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
17
19
  import { join, resolve } from "node:path";
18
20
  import { fileURLToPath } from "node:url";
19
21
  import { procStartTicks, TERMINAL_EXITS } from "./local-host.js";
@@ -43,14 +45,17 @@ function writeState(path, state) {
43
45
  // 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
46
  // names a group (`kill(-1)` is every process this user may signal). The state file holds whatever was written there.
45
47
  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. */
48
+ /** running while either process lives and the launcher has not recorded its exit; otherwise stopped after a clean exit,
49
+ * failed after any other end. Every "exited" the launcher saves is final and comes after its instance ended, so once it is
50
+ * saved (and no instance lives) the record is the answer, even while the launcher's own process is still exiting. */
47
51
  export function launchStatus(dir, name) {
48
52
  const state = readState(dir, name);
49
53
  if (!state)
50
54
  return { status: "failed", state: null, launcherAlive: false, instanceAlive: false };
51
55
  const launcherAlive = alive(state.launcher, state.launcherTicks);
52
56
  const instanceAlive = alive(state.instance, state.instanceTicks);
53
- if (launcherAlive || instanceAlive)
57
+ const ended = state.phase === "exited" && !instanceAlive;
58
+ if (!ended && (launcherAlive || instanceAlive))
54
59
  return { status: "running", state, launcherAlive, instanceAlive };
55
60
  const clean = state.phase === "exited" && state.exit === 0;
56
61
  return { status: clean ? "stopped" : "failed", state, launcherAlive, instanceAlive };
@@ -125,9 +130,9 @@ export async function serve(dir, name) {
125
130
  try {
126
131
  for (;;) {
127
132
  const env = { PATH: SAFE_PATH, LANG: "C.UTF-8", HOME: ids.home, ...spec.env };
128
- const first = token !== null;
133
+ const piped = Boolean(token);
129
134
  try {
130
- child = spawn(spec.argv[0], spec.argv.slice(1), { uid: ids.uid, gid: ids.gid, env, cwd: "/", stdio: [first ? "pipe" : "ignore", log, log], detached: true });
135
+ child = spawn(spec.argv[0], spec.argv.slice(1), { uid: ids.uid, gid: ids.gid, env, cwd: "/", stdio: [piped ? "pipe" : "ignore", log, log], detached: true });
131
136
  await new Promise((resolve, reject) => {
132
137
  child.once("spawn", resolve);
133
138
  child.once("error", reject);
@@ -137,10 +142,9 @@ export async function serve(dir, name) {
137
142
  save({ phase: "exited", instance: null, instanceTicks: null, exit: 1, reason: `spawn failed: ${err.message}` });
138
143
  return state;
139
144
  }
140
- if (first) {
145
+ if (piped) {
141
146
  child.stdin.on("error", () => { });
142
- child.stdin.end(token ? `${token}\n` : "");
143
- token = null;
147
+ child.stdin.end(`${token}\n`);
144
148
  }
145
149
  save({ phase: stopping ? "stopping" : "running", instance: child.pid, instanceTicks: procStartTicks(child.pid), spawned: state.spawned + 1, exit: null, signal: null });
146
150
  if (stopping)
@@ -216,8 +220,12 @@ export async function stop(dir, name, timeoutMs) {
216
220
  }
217
221
  };
218
222
  let s = launchStatus(dir, name);
219
- if (s.status !== "running")
223
+ if (s.status !== "running") {
224
+ // The launcher recorded its exit and is ending on its own; a launcher process that outstays its record is still sent away.
225
+ if (s.launcherAlive)
226
+ kill(s.state.launcher, s.state.launcherTicks, "SIGTERM");
220
227
  return s;
228
+ }
221
229
  if (s.launcherAlive)
222
230
  kill(s.state.launcher, s.state.launcherTicks, "SIGTERM");
223
231
  else
@@ -262,7 +270,22 @@ async function main(argv) {
262
270
  return 2;
263
271
  }
264
272
  }
265
- if (process.argv[1] && resolve(process.argv[1]) === LAUNCH_SCRIPT) {
273
+ /**
274
+ * Whether Node was started with this module. LAUNCH_SCRIPT comes from import.meta.url, which is the real path; argv[1] is
275
+ * the path as given, which may go through a link (a symlinked package directory, pnpm's linked node_modules).
276
+ */
277
+ function invokedAsMain() {
278
+ const arg = process.argv[1];
279
+ if (!arg)
280
+ return false;
281
+ try {
282
+ return realpathSync(arg) === LAUNCH_SCRIPT;
283
+ }
284
+ catch {
285
+ return resolve(arg) === LAUNCH_SCRIPT;
286
+ }
287
+ }
288
+ if (invokedAsMain()) {
266
289
  main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
267
290
  process.stderr.write(`${err.message}\n`);
268
291
  process.exit(1);
@@ -60,7 +60,7 @@ export interface DaytonaRestOptions {
60
60
  }
61
61
  /** The REST API with an API key (Bearer). Error messages carry the method, path and status, never a header or a body sent. */
62
62
  export declare function daytonaRest(options: DaytonaRestOptions): DaytonaClient;
63
- export type DaytonaHostErrorCode = "INVALID_ARGUMENT" | "START_FAILED" | "STOP_FAILED" | "DAYTONA_API_FAILED";
63
+ export type DaytonaHostErrorCode = "INVALID_ARGUMENT" | "START_FAILED" | "STOP_FAILED" | "KILL_FAILED" | "DAYTONA_API_FAILED";
64
64
  export declare class DaytonaHostError extends PdaError {
65
65
  constructor(code: DaytonaHostErrorCode, message: string, options?: {
66
66
  cause?: unknown;
@@ -133,7 +133,7 @@ export interface DaytonaHostOptions {
133
133
  export declare function sandboxStatus(state: string | undefined): HostStatus | "check";
134
134
  /** A Daytona-safe box name for a run: lowercase, at most 63 characters, unique per call. */
135
135
  export declare function sandboxName(prefix: string, runId: string, now: number): string;
136
- export declare function daytonaHost(opts: DaytonaHostOptions): HostDriver & {
136
+ export declare function daytonaHost(opts: DaytonaHostOptions): HostDriver & Required<Pick<HostDriver, "kill" | "kills">> & {
137
137
  readonly fleet: string;
138
138
  readonly mountRoot: string;
139
139
  };
@@ -15,6 +15,7 @@
15
15
  // in it, the commands a crashed incarnation detached included. The supervisor holds the API key; boxes never do.
16
16
  import { DEFAULT_MOUNT_ROOT, runPath } from "../claim.js";
17
17
  import { PdaError } from "../errors.js";
18
+ import { attemptEnv } from "./attempt-env.js";
18
19
  import { LAUNCH_DIR } from "./daytona-launch.js";
19
20
  import { TERMINAL_EXITS } from "./local-host.js";
20
21
  export const DAYTONA_API_URL = "https://app.daytona.io/api";
@@ -121,7 +122,11 @@ export class DaytonaHostError extends PdaError {
121
122
  super(code, message, options);
122
123
  }
123
124
  }
124
- const BOX_PATH = /^\/[A-Za-z0-9._/-]+$/;
125
+ // Shell-inert characters only: the paths go into launcher command lines. `@` admits npm's scoped package directories
126
+ // (`node_modules/@scope/pkg`), where the package is installed with npm and not copied to a fixed directory.
127
+ const BOX_PATH = /^\/[A-Za-z0-9._@/-]+$/;
128
+ /** A path that climbs out of where it names: never a box path, whatever else is in it. */
129
+ const climbs = (path) => path.split("/").includes("..");
125
130
  const ACCOUNT = /^(?:[a-z_][a-z0-9_-]{0,31}|\d{1,10})$/;
126
131
  const FLEET = /^[a-z0-9][a-z0-9-]{0,62}$/;
127
132
  const UP = new Set(["creating", "pulling_snapshot", "pending_build", "building_snapshot", "starting", "restoring", "resuming", "resizing", "snapshotting", "forking", "stopping", "pausing", "paused"]);
@@ -152,6 +157,14 @@ export function sandboxName(prefix, runId, now) {
152
157
  return `${prefix}${slug}-${stamp}`.slice(0, 63);
153
158
  }
154
159
  /** The last line of a command's output that parses as JSON, or null. */
160
+ /**
161
+ * What the launcher printed, for a start it gave no reason for: the last 4 KB, or that it printed nothing. It prints only
162
+ * its state; the mount token is in a root-only file it reads, never in its output.
163
+ */
164
+ function launcherOutput(text) {
165
+ const out = text.trim();
166
+ return out ? `the launcher printed: ${out.slice(-4096)}` : "the launcher printed nothing and exited 0";
167
+ }
155
168
  function lastJson(text) {
156
169
  for (const line of text.trim().split("\n").reverse()) {
157
170
  try {
@@ -185,7 +198,7 @@ export function daytonaHost(opts) {
185
198
  const now = opts.now ?? Date.now;
186
199
  const launch = opts.launcher ?? `${packageDir}/dist/hosts/daytona-launch.js`;
187
200
  for (const [what, value] of Object.entries({ mountRoot, node, packageDir, archil, boxDir: dir, stageDir: stage, launcher: launch })) {
188
- if (!BOX_PATH.test(value))
201
+ if (!BOX_PATH.test(value) || climbs(value))
189
202
  throw new DaytonaHostError("INVALID_ARGUMENT", `${what} ${JSON.stringify(value)} is not a plain absolute path`);
190
203
  }
191
204
  if (!ACCOUNT.test(user) || !ACCOUNT.test(group))
@@ -246,10 +259,12 @@ export function daytonaHost(opts) {
246
259
  return {
247
260
  fleet,
248
261
  mountRoot,
249
- async start(ref, mountToken) {
262
+ kills: "box",
263
+ async start(ref, mountToken, attempt) {
250
264
  runPath(ref.id);
251
265
  if (!mountToken)
252
266
  throw new DaytonaHostError("INVALID_ARGUMENT", "no mount token");
267
+ const extra = attemptEnv(attempt, (m) => new DaytonaHostError("INVALID_ARGUMENT", m));
253
268
  const t0 = now();
254
269
  const name = sandboxName(prefix, ref.id, t0);
255
270
  const body = {
@@ -273,7 +288,7 @@ export function daytonaHost(opts) {
273
288
  const handle = { driver: "daytona", fleet, target: box.target ?? opts.target ?? null, sandboxId: box.id, name, mountpoint: `${mountRoot}/${runPath(ref.id)}` };
274
289
  const spec = {
275
290
  argv: [...command, "run", "--disk", ref.disk, "--region", ref.region, "--id", ref.id, "--mount-root", mountRoot, "--archil", archil, ...(opts.runArgs ?? []), "--token-stdin"],
276
- env: { ...opts.env, PDA_HOLDER: JSON.stringify(handle) },
291
+ env: { ...opts.env, ...extra, PDA_HOLDER: JSON.stringify(handle) },
277
292
  user,
278
293
  group,
279
294
  restart: opts.restart !== false,
@@ -288,7 +303,7 @@ export function daytonaHost(opts) {
288
303
  const r = await run(box, launcher("start", name, left), Math.ceil(left / 1000) + 10, "starting the instance");
289
304
  const state = lastJson(r.result);
290
305
  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)}`);
306
+ throw new DaytonaHostError("START_FAILED", `the instance in ${name} did not start: ${state?.reason || launcherOutput(r.result)}`);
292
307
  }
293
308
  return handle;
294
309
  }
@@ -352,6 +367,34 @@ export function daytonaHost(opts) {
352
367
  }
353
368
  }
354
369
  },
370
+ async kill(h) {
371
+ if (!mine(h))
372
+ return;
373
+ const id = h.sandboxId;
374
+ let box;
375
+ try {
376
+ box = await client.get(id);
377
+ }
378
+ catch (err) {
379
+ throw new DaytonaHostError("KILL_FAILED", `reading sandbox ${h.name} to confirm it is this fleet's: ${err.message}`, { cause: err });
380
+ }
381
+ if (box === null || box.labels?.[LABEL_FLEET] !== fleet)
382
+ return;
383
+ // The box goes with everything in it, the instance and its mount: no launcher stop, no drain.
384
+ try {
385
+ await client.remove(id);
386
+ }
387
+ catch (first) {
388
+ // A delete refused mid-transition: power the box off, then delete again.
389
+ await client.stop(id, true).catch(() => { });
390
+ try {
391
+ await client.remove(id);
392
+ }
393
+ catch (err) {
394
+ throw new DaytonaHostError("KILL_FAILED", `deleting sandbox ${h.name} failed: ${err.message}`, { cause: first });
395
+ }
396
+ }
397
+ },
355
398
  };
356
399
  }
357
400
  /** Delete every box of a fleet (the janitor after a crash of whoever started them); returns what it asked to delete. */
@@ -58,7 +58,7 @@ export interface DockerHostOptions {
58
58
  exec?: DockerRunner;
59
59
  }
60
60
  export declare class DockerHostError extends PdaError {
61
- constructor(code: "INVALID_ARGUMENT" | "START_FAILED" | "STOP_FAILED" | "DOCKER_FAILED", message: string, options?: {
61
+ constructor(code: "INVALID_ARGUMENT" | "START_FAILED" | "STOP_FAILED" | "KILL_FAILED" | "DOCKER_FAILED", message: string, options?: {
62
62
  cause?: unknown;
63
63
  });
64
64
  }
@@ -95,7 +95,7 @@ export type ContainerInfo = {
95
95
  startedAt: string | null;
96
96
  finishedAt: string | null;
97
97
  };
98
- export declare function dockerHost(opts?: DockerHostOptions): HostDriver & {
98
+ export declare function dockerHost(opts?: DockerHostOptions): HostDriver & Required<Pick<HostDriver, "kill" | "kills">> & {
99
99
  readonly fleet: string;
100
100
  readonly mountRoot: string;
101
101
  describe(handle: HostHandle): Promise<ContainerInfo | null>;
@@ -23,6 +23,7 @@ import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:p
23
23
  import { DEFAULT_MOUNT_ROOT, runPath } from "../claim.js";
24
24
  import { PdaError } from "../errors.js";
25
25
  import { LOCAL_PARK_THRESHOLD_MS } from "../park.js";
26
+ import { attemptEnv } from "./attempt-env.js";
26
27
  export class DockerHostError extends PdaError {
27
28
  constructor(code, message, options = {}) {
28
29
  super(code, message, options);
@@ -191,8 +192,8 @@ export function dockerHost(opts = {}) {
191
192
  if (!ok(r) && !NO_SUCH.test(r.stderr))
192
193
  throw new DockerHostError("DOCKER_FAILED", `docker rm -f ${ref}: ${firstLine(r)}`);
193
194
  }
194
- function createArgs(ref, name, attempt, holder, profile) {
195
- const env = { ...opts.env, PDA_HOLDER: JSON.stringify(holder) };
195
+ function createArgs(ref, name, attempt, holder, profile, extra) {
196
+ const env = { ...opts.env, ...extra, PDA_HOLDER: JSON.stringify(holder) };
196
197
  const runFlags = [
197
198
  "run",
198
199
  "--disk", ref.disk,
@@ -228,10 +229,10 @@ export function dockerHost(opts = {}) {
228
229
  ...runFlags,
229
230
  ];
230
231
  }
231
- async function create(ref, name, attempt, holder) {
232
+ async function create(ref, name, attempt, holder, extra) {
232
233
  const profile = await apparmor();
233
234
  for (let tries = 0;; tries++) {
234
- const r = await run(createArgs(ref, name, attempt, holder, profile), { timeoutMs: 120_000 });
235
+ const r = await run(createArgs(ref, name, attempt, holder, profile, extra), { timeoutMs: 120_000 });
235
236
  if (ok(r))
236
237
  return { id: r.stdout.trim().split("\n").at(-1).trim() };
237
238
  if (!IN_USE.test(r.stderr) || tries > 0)
@@ -270,6 +271,7 @@ export function dockerHost(opts = {}) {
270
271
  return {
271
272
  fleet,
272
273
  mountRoot,
274
+ kills: "box",
273
275
  async start(ref, token, attemptInfo) {
274
276
  runPath(ref.id);
275
277
  if (!opts.image)
@@ -277,11 +279,12 @@ export function dockerHost(opts = {}) {
277
279
  if (!token || /[\r\n]/.test(token))
278
280
  throw new DockerHostError("INVALID_ARGUMENT", "no mount token, or one with a line break");
279
281
  const attempt = attemptInfo?.attempt ?? null;
282
+ const extra = attemptEnv(attemptInfo, (m) => new DockerHostError("INVALID_ARGUMENT", m));
280
283
  const name = `${prefix}${ref.id}-${diskKey(ref)}-${attempt === null ? `t${Date.now().toString(36)}` : `g${attempt}`}`;
281
284
  if (!NAME_SAFE.test(name))
282
285
  throw new DockerHostError("INVALID_ARGUMENT", `container name ${name} is not safe`);
283
286
  const handle = { driver: "docker", fleet, daemon: await daemon(), name, mountpoint: posix.join(mountRoot, runPath(ref.id)), image: opts.image };
284
- const made = await create(ref, name, attempt, handle);
287
+ const made = await create(ref, name, attempt, handle, extra);
285
288
  // An earlier start of this attempt made the container: the token given to this start was not used.
286
289
  if ("adopted" in made)
287
290
  return { ...handle, adopted: true };
@@ -336,6 +339,20 @@ export function dockerHost(opts = {}) {
336
339
  if (await inspect(ref))
337
340
  throw new DockerHostError("STOP_FAILED", `container ${ref} is still there after docker rm -f`);
338
341
  },
342
+ async kill(h) {
343
+ if (!(await mine(h)))
344
+ return;
345
+ const ref = target(h);
346
+ const found = await inspect(ref);
347
+ if (!found || found.labels["pda.fleet"] !== fleet)
348
+ return;
349
+ // SIGKILL, no stop timeout: the container dies with its mount (its own namespace), as on a power loss. It is left
350
+ // exited, not removed; the next start of the run removes it.
351
+ const r = await run(["kill", ref], { timeoutMs: 30_000 });
352
+ if (!ok(r) && !NO_SUCH.test(r.stderr) && (await inspect(ref).then((f) => f && containerStatus(f.state) === "running"))) {
353
+ throw new DockerHostError("KILL_FAILED", `docker kill ${ref}: ${firstLine(r)}`);
354
+ }
355
+ },
339
356
  };
340
357
  }
341
358
  /** The bind mount of the app's directory (read-only, its node_modules hidden) and the module's path inside the container. */
@@ -50,22 +50,28 @@ export interface LocalHostOptions {
50
50
  procMounts?: string;
51
51
  }
52
52
  export declare class LocalHostError extends PdaError {
53
- constructor(code: "INVALID_ARGUMENT" | "START_FAILED" | "STOP_FAILED", message: string, options?: {
53
+ constructor(code: "INVALID_ARGUMENT" | "START_FAILED" | "STOP_FAILED" | "KILL_FAILED", message: string, options?: {
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>;
64
67
  /** A unit's state as a host status: loaded and active (or restarting) is running; unloaded is gone. */
65
68
  export declare function unitStatus(show: Record<string, string>): HostStatus;
66
- export declare function localHost(opts?: LocalHostOptions): HostDriver & {
69
+ export declare function localHost(opts?: LocalHostOptions): HostDriver & Required<Pick<HostDriver, "kill" | "kills">> & {
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,15 @@
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
+ import { attemptEnv } from "./attempt-env.js";
23
24
  export class LocalHostError extends PdaError {
24
25
  constructor(code, message, options = {}) {
25
26
  super(code, message, options);
@@ -32,7 +33,13 @@ const SYSTEMD_RUN = "/usr/bin/systemd-run";
32
33
  const ARCHIL = "/usr/bin/archil";
33
34
  const UNIT_SAFE = /^[A-Za-z0-9:_.-]{1,200}$/;
34
35
  const POLITE_UNMOUNT_MS = 10_000;
35
- const STAT = "/usr/bin/stat";
36
+ /**
37
+ * 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
38
+ * message, which coreutils' `stat` prints in the locale's language.
39
+ */
40
+ 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; }`;
41
+ /** The argv of that child for `path`. */
42
+ export const statCodeArgv = (path) => [process.execPath, "-e", STAT_CODE, path];
36
43
  const FUSERMOUNT = "/usr/bin/fusermount";
37
44
  const UMOUNT = "/usr/bin/umount";
38
45
  /** How long a stat of the mountpoint may take before its daemon counts as alive but stuck (never removed). */
@@ -63,8 +70,12 @@ export const runCommand = (argv, opts = {}) => new Promise((resolve, reject) =>
63
70
  child.stdin.on("error", () => { });
64
71
  child.stdin.end(opts.input ?? "");
65
72
  });
73
+ /** This boot's id: `/proc/sys/kernel/random/boot_id`, or `kern.bootsessionuuid` on macOS. Throws when it cannot be read. */
66
74
  export function currentBootId() {
67
- return readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
75
+ const boot = thisBoot();
76
+ if (boot === null)
77
+ throw new Error("this machine's boot id cannot be read");
78
+ return boot;
68
79
  }
69
80
  const firstLine = (r) => (r.stderr.trim().split("\n").at(-1) ?? "").slice(0, 300);
70
81
  const okRan = (r) => r.code === 0 && !r.timedOut;
@@ -151,13 +162,13 @@ export function localHost(opts = {}) {
151
162
  return rm(path, { force: true });
152
163
  await root(["/bin/rm", "-f", path]);
153
164
  }
154
- async function startUnit(ref, token) {
165
+ async function startUnit(ref, token, extra) {
155
166
  const unit = `${prefix}${ref.id}-${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36).padStart(2, "0")}`;
156
167
  if (!UNIT_SAFE.test(unit))
157
168
  throw new LocalHostError("INVALID_ARGUMENT", `unit name ${unit} is not safe`);
158
169
  const mountpoint = mountpointOf(ref);
159
170
  const handle = { driver: "local", mode: "systemd", host: hostName, bootId, unit, mountpoint };
160
- const env = { ...opts.env, PATH: SAFE_PATH, PDA_HOLDER: holderEnv(handle) };
171
+ const env = { ...opts.env, ...extra, PATH: SAFE_PATH, PDA_HOLDER: holderEnv(handle) };
161
172
  const path = tokenFile(unit);
162
173
  await writeTokenFile(path, token);
163
174
  let started = false;
@@ -192,7 +203,7 @@ export function localHost(opts = {}) {
192
203
  await (started ? emptyTokenFile(path) : removeFile(path)).catch(() => { });
193
204
  }
194
205
  }
195
- async function startChild(ref, token) {
206
+ async function startChild(ref, token, extra) {
196
207
  const tag = `${prefix}${ref.id}-${Date.now().toString(36)}`;
197
208
  const mountpoint = mountpointOf(ref);
198
209
  const base = { driver: "local", mode: "child", host: hostName, bootId, mountpoint, tag };
@@ -201,7 +212,7 @@ export function localHost(opts = {}) {
201
212
  const { openSync } = await import("node:fs");
202
213
  out = openSync(join(opts.logDir, `${tag}.log`), "a", 0o600);
203
214
  }
204
- const env = { ...opts.env, PATH: SAFE_PATH, LANG: "C.UTF-8", HOME: process.env.HOME ?? "/", PDA_HOLDER: holderEnv(base) };
215
+ const env = { ...opts.env, ...extra, PATH: SAFE_PATH, LANG: "C.UTF-8", HOME: process.env.HOME ?? "/", PDA_HOLDER: holderEnv(base) };
205
216
  const child = spawn(command[0], [...command.slice(1), ...runFlags(ref), "--token-stdin"], { detached: true, stdio: ["pipe", out, out], env });
206
217
  await new Promise((resolve, reject) => {
207
218
  child.once("spawn", resolve);
@@ -234,13 +245,14 @@ export function localHost(opts = {}) {
234
245
  await new Promise((r) => setTimeout(r, 100));
235
246
  }
236
247
  /**
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.
248
+ * Whether the mount at `mp` is dead: its FUSE daemon is gone, so the stat fails with one of `DEAD_CONNECTION`'s codes
249
+ * (ENOTCONN, or ECONNABORTED for a stat in flight while the dying daemon's connection is torn down). The stat runs in a
250
+ * child with a timeout, so a live but stuck daemon never blocks this process, and the child prints the code, not a
251
+ * message; a stat that answers, fails otherwise or does not finish is a live mount.
240
252
  */
241
253
  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);
254
+ const r = await exec(statCodeArgv(mp), { timeoutMs: STAT_TIMEOUT_MS });
255
+ return !r.timedOut && r.code !== 0 && DEAD_CONNECTION.has(r.stdout.trim());
244
256
  }
245
257
  /** Remove a dead mount: `fusermount -u`, then a lazy `umount -l`. The mount table, not an exit code, decides. */
246
258
  async function removeDeadMount(mp) {
@@ -277,9 +289,11 @@ export function localHost(opts = {}) {
277
289
  return {
278
290
  hostName,
279
291
  mountRoot,
280
- start(ref, token) {
292
+ kills: "process",
293
+ async start(ref, token, attempt) {
281
294
  runPath(ref.id);
282
- return mode === "systemd" ? startUnit(ref, token) : startChild(ref, token);
295
+ const extra = attemptEnv(attempt, (m) => new LocalHostError("INVALID_ARGUMENT", m));
296
+ return mode === "systemd" ? startUnit(ref, token, extra) : startChild(ref, token, extra);
283
297
  },
284
298
  async status(h) {
285
299
  if (!mine(h))
@@ -336,21 +350,44 @@ export function localHost(opts = {}) {
336
350
  await root([SYSTEMCTL, "reset-failed", `${h.unit}.service`]);
337
351
  await removeFile(tokenFile(h.unit));
338
352
  },
353
+ async kill(h) {
354
+ if (!mine(h) || h.bootId !== bootId)
355
+ return;
356
+ if (h.mode === "child") {
357
+ const pid = Number(h.pid);
358
+ // As in stop: only a real process, and only the one the handle names (its start ticks).
359
+ if (!Number.isInteger(pid) || pid <= 1 || typeof h.startTicks !== "number" || procStartTicks(pid) !== h.startTicks)
360
+ return;
361
+ try {
362
+ process.kill(-pid, "SIGKILL");
363
+ }
364
+ catch {
365
+ // gone already
366
+ }
367
+ return;
368
+ }
369
+ if (typeof h.unit !== "string" || !UNIT_SAFE.test(h.unit))
370
+ return;
371
+ // Every process of the unit's control group at once, the instance's commands included. Its FUSE daemon runs in a
372
+ // scope of its own and keeps the mount, as after an OOM kill; the unit restarts in place unless restart is off.
373
+ const r = await root([SYSTEMCTL, "kill", "--signal=SIGKILL", "--kill-whom=all", `${h.unit}.service`]);
374
+ if (!okRan(r) && unitStatus(await show(h.unit)) === "running") {
375
+ throw new LocalHostError("KILL_FAILED", `systemctl kill ${h.unit}: ${r.timedOut ? "timed out" : firstLine(r)}`);
376
+ }
377
+ },
339
378
  };
340
379
  }
341
- /** Field 22 of /proc/<pid>/stat (start time in clock ticks), so a reused pid is never mistaken for the instance. */
380
+ /**
381
+ * 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
382
+ * never mistaken for the instance. Null for a zombie, a process that is gone, or one the table cannot be read for.
383
+ */
342
384
  export function procStartTicks(pid) {
343
385
  if (!Number.isInteger(pid) || pid <= 0)
344
386
  return null;
345
- let stat;
346
387
  try {
347
- stat = readFileSync(`/proc/${pid}/stat`, "utf8");
388
+ return processTable().snapshot().info(pid)?.start ?? null;
348
389
  }
349
390
  catch {
350
391
  return null;
351
392
  }
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
393
  }
package/dist/index.d.ts CHANGED
@@ -1,16 +1,35 @@
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 { AfterClaim, BeforeResume, DurableRun, LeaseOptions, OpenDurableRunOptions, OpenRunLeaseOptions, OpenStep, ReleaseStep, RunClaim, RunEnv, RunErrorCode, RunLease, RunSeed, RunSeedDir, 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, readLocalRunStatus, 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 { readReleased, withClaim } from "./with-claim.js";
12
+ export type { ClaimControl, HeldPath, WithClaimOptions } from "./with-claim.js";
13
+ export { PathOutsideWorkError } from "./confine.js";
14
+ export type { RefusalReason } from "./confine.js";
15
+ export { ClaimError, FencedError, HeldError, PdaError } from "./errors.js";
16
+ export type { ClaimErrorCode, Holder } from "./errors.js";
17
+ export type { ArchilStore, Fence, FencedDatabase, Profile, StoreFencedError } from "./store.js";
18
+ export { AppError } from "./app.js";
19
+ export type { AppContext, AppModule, AppOptions } from "./app.js";
20
+ export { archilControl, deleteRunTree, ensureRunning, readRunStatus, SuperviseError } from "./supervise.js";
21
+ export type { ArchilControl, CheckControl, CheckOptions, DiskClient, EnsureOptions, EnsureResult, HostDriver, HostHandle, HostStatus, Json, StartAttempt, StartReason, StonithOutcome, SuperviseErrorCode, SupervisorControl, TokenUser } from "./supervise.js";
22
+ export { localHost, LocalHostError } from "./hosts/local-host.js";
23
+ export type { LocalHostOptions, Ran, Runner } from "./hosts/local-host.js";
24
+ export { dockerHost, DockerHostError } from "./hosts/docker.js";
25
+ export type { ContainerInfo, DockerHostOptions, DockerRan, DockerRunner } from "./hosts/docker.js";
26
+ export { DaytonaApiError, daytonaHost, DaytonaHostError, daytonaRest } from "./hosts/daytona.js";
27
+ export type { CreateSandboxBody, DaytonaClient, DaytonaHostErrorCode, DaytonaHostOptions, DaytonaRestOptions, ExecResult, SandboxInfo } from "./hosts/daytona.js";
28
+ export { requestRun, ServeConfigError, ServeError, serveRun } from "./serve.js";
29
+ export type { RequestOptions, RunResponse, RunServer, ServeConfigErrorCode, ServeErrorCode, ServeOptions } from "./serve.js";
30
+ export { archilPlace, localPlace, PlaceError } from "./place.js";
31
+ export type { ArchilPlaceOptions, LocalPlaceOptions, Place, PlaceContext, PlaceErrorCode, PlaceExit, PlaceKill, PlaceOutcome, PlacePaths, PlaceRecovery, PlaceRunner, PlaceSignal, PlaceStatus, RunnerOrder } from "./place.js";
32
+ export { busyState, drain, leaseParkTarget, watchParking } from "./park.js";
33
+ export type { BusyState, DrainOptions, Parking, ParkOptions, ParkResult, ParkTarget } from "./park.js";
34
+ export { fork, ForkError, forkLocal } from "./fork.js";
35
+ export type { ForkErrorCode, ForkLocalResult, ForkOptions, ForkResult } from "./fork.js";
package/dist/index.js CHANGED
@@ -1,17 +1,28 @@
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, readLocalRunStatus, RunRecordError } from "./status.js";
8
+ export { ArchilCodingTools, archilEnv, EnvClosedError, EnvError, markReadOnlySafe } from "./env.js";
9
+ export { acquire, ARCHIL_SCOPED, revoke, takeOver } from "./claim.js";
10
+ // A short exclusive claim on any path of a disk, and a consistent S3 read of a released file (an agent home beside the runs).
11
+ export { readReleased, withClaim } from "./with-claim.js";
4
12
  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";
13
+ export { ClaimError, FencedError, HeldError, PdaError } from "./errors.js";
14
+ // The app contract: what `--app` names.
15
+ export { AppError } from "./app.js";
16
+ // The supervisor and the host drivers.
17
+ export { archilControl, deleteRunTree, ensureRunning, readRunStatus, SuperviseError } from "./supervise.js";
18
+ export { localHost, LocalHostError } from "./hosts/local-host.js";
19
+ export { dockerHost, DockerHostError } from "./hosts/docker.js";
20
+ export { DaytonaApiError, daytonaHost, DaytonaHostError, daytonaRest } from "./hosts/daytona.js";
21
+ // Serve: one HTTP request runs one turn.
22
+ export { requestRun, ServeConfigError, ServeError, serveRun } from "./serve.js";
23
+ // Places: where a run lives for a supervisor that kills it and brings it back (agentrun's reference place seam).
24
+ export { archilPlace, localPlace, PlaceError } from "./place.js";
25
+ // Parking: an idle run gives its host back.
26
+ export { busyState, drain, leaseParkTarget, watchParking } from "./park.js";
27
+ // Fork: a new run from a run's committed state.
28
+ 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";