@parcha/pi-durable-disk 0.1.0-beta.13 → 0.1.0-beta.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +262 -0
  2. package/README.md +281 -47
  3. package/dist/app.d.ts +5 -1
  4. package/dist/claim.d.ts +51 -3
  5. package/dist/claim.js +157 -17
  6. package/dist/cli.d.ts +6 -0
  7. package/dist/cli.js +112 -47
  8. package/dist/commands.d.ts +4 -1
  9. package/dist/commands.js +48 -90
  10. package/dist/confine.d.ts +36 -7
  11. package/dist/confine.js +125 -18
  12. package/dist/env.d.ts +7 -22
  13. package/dist/env.js +25 -60
  14. package/dist/errors.d.ts +2 -1
  15. package/dist/fork.d.ts +23 -0
  16. package/dist/fork.js +103 -3
  17. package/dist/hosts/attempt-env.d.ts +3 -0
  18. package/dist/hosts/attempt-env.js +15 -0
  19. package/dist/hosts/daytona-launch.d.ts +3 -1
  20. package/dist/hosts/daytona-launch.js +35 -12
  21. package/dist/hosts/daytona.d.ts +2 -2
  22. package/dist/hosts/daytona.js +48 -5
  23. package/dist/hosts/docker.d.ts +2 -2
  24. package/dist/hosts/docker.js +22 -5
  25. package/dist/hosts/local-host.d.ts +9 -3
  26. package/dist/hosts/local-host.js +59 -22
  27. package/dist/index.d.ts +35 -16
  28. package/dist/index.js +27 -16
  29. package/dist/internals.d.ts +3 -0
  30. package/dist/internals.js +6 -0
  31. package/dist/lease.d.ts +4 -1
  32. package/dist/lease.js +2 -0
  33. package/dist/local-run.js +3 -2
  34. package/dist/place.d.ts +190 -0
  35. package/dist/place.js +526 -0
  36. package/dist/process-table.d.ts +38 -0
  37. package/dist/process-table.js +158 -0
  38. package/dist/run.d.ts +61 -10
  39. package/dist/run.js +69 -33
  40. package/dist/status.d.ts +19 -3
  41. package/dist/status.js +25 -13
  42. package/dist/store.d.ts +14 -2
  43. package/dist/store.js +16 -4
  44. package/dist/supervise.d.ts +43 -1
  45. package/dist/supervise.js +26 -1
  46. package/dist/watchdog.d.ts +3 -1
  47. package/dist/watchdog.js +43 -12
  48. package/dist/with-claim.d.ts +63 -0
  49. package/dist/with-claim.js +325 -0
  50. package/package.json +9 -3
package/dist/store.js CHANGED
@@ -64,10 +64,19 @@ export const PROFILE_PRAGMAS = {
64
64
  exclusive: { locking_mode: "exclusive", journal_mode: "wal", synchronous: 2, mmap_size: 0 },
65
65
  shared: { locking_mode: "normal", journal_mode: "wal", synchronous: 2, mmap_size: 0 },
66
66
  };
67
- /** Read every pragma of `profile` back and refuse (StorePragmaError) when any differs. */
68
- export async function assertPragmas(database, profile) {
67
+ /**
68
+ * On macOS, fsync does not flush the drive's cache, so a commit FULL syncs could still be lost to a power cut. These make
69
+ * SQLite sync with F_FULLFSYNC instead, at every commit and every checkpoint.
70
+ */
71
+ export const FULLFSYNC_PRAGMAS = { fullfsync: 1, checkpoint_fullfsync: 1 };
72
+ /** What `profile` must read back on `platform`: its own pragmas, and on macOS `FULLFSYNC_PRAGMAS` too. */
73
+ export function profilePragmas(profile, platform = process.platform) {
74
+ return platform === "darwin" ? { ...PROFILE_PRAGMAS[profile], ...FULLFSYNC_PRAGMAS } : PROFILE_PRAGMAS[profile];
75
+ }
76
+ /** Read every pragma of `profile` on `platform` (default this one) back and refuse (StorePragmaError) when any differs. */
77
+ export async function assertPragmas(database, profile, platform = process.platform) {
69
78
  const mismatches = [];
70
- for (const [pragma, expected] of Object.entries(PROFILE_PRAGMAS[profile])) {
79
+ for (const [pragma, expected] of Object.entries(profilePragmas(profile, platform))) {
71
80
  const row = await database.get(`PRAGMA ${pragma}`);
72
81
  const actual = row === undefined ? undefined : Object.values(row)[0];
73
82
  if (actual !== expected)
@@ -203,7 +212,10 @@ export async function openArchilStore(file, profile = "exclusive", options = {})
203
212
  }
204
213
  await database.exec("PRAGMA synchronous = FULL"); // fsync the WAL on every commit
205
214
  await database.exec("PRAGMA mmap_size = 0"); // no mmap I/O of the database file on a network filesystem
206
- await assertPragmas(database, profile);
215
+ const platform = options.platform ?? process.platform;
216
+ for (const pragma of platform === "darwin" ? Object.keys(FULLFSYNC_PRAGMAS) : [])
217
+ await database.exec(`PRAGMA ${pragma} = ON`);
218
+ await assertPragmas(database, profile, platform);
207
219
  const storage = await SqliteStorage.open(database);
208
220
  return { storage, database, profile, file };
209
221
  }
@@ -12,9 +12,14 @@ export type HostHandle = {
12
12
  driver: string;
13
13
  [key: string]: Json;
14
14
  };
15
- /** Which start this is: the generation the instance will open (run.json's generation + 1), also in the token's nickname. */
15
+ /**
16
+ * Which start this is: the generation the instance will open (run.json's generation + 1), also in the token's nickname.
17
+ * `env`: what this start adds to the instance's environment (a place's per-attempt order), merged over the driver's own
18
+ * `env`. PATH and PDA_HOLDER are the driver's: a start that sets either, or a name that is not a variable name, is refused.
19
+ */
16
20
  export type StartAttempt = {
17
21
  attempt: number;
22
+ env?: Record<string, string>;
18
23
  };
19
24
  /** Compute is disposable and pluggable: a driver is these three calls. */
20
25
  export interface HostDriver {
@@ -28,6 +33,14 @@ export interface HostDriver {
28
33
  status(handle: HostHandle): Promise<HostStatus>;
29
34
  /** Stop the instance and whatever it left on its host. A handle the driver cannot reach is a no-op. */
30
35
  stop(handle: HostHandle): Promise<void>;
36
+ /**
37
+ * Optional: take the instance away abruptly, as a crash would. No drain, no release, nothing cleaned: the run's claim is
38
+ * left as a crash leaves it (orphaned with its box, or held by a live mount until its lease lapses), and the
39
+ * supervisor's next pass decides what follows. A handle the driver cannot reach is a no-op.
40
+ */
41
+ kill?(handle: HostHandle): Promise<void>;
42
+ /** What `kill` takes: `process`, the instance's processes while its machine stays (a restart policy may bring them back), or `box`, its machine. */
43
+ readonly kills?: "process" | "box";
31
44
  }
32
45
  /** The control-plane calls the supervisor uses; an archil `Disk` satisfies it. */
33
46
  export interface SupervisorControl extends ControlApi {
@@ -73,6 +86,16 @@ export interface EnsureOptions {
73
86
  tokenTtl?: string;
74
87
  /** Prefix of each mount token's nickname. */
75
88
  tokenPrefix?: string;
89
+ /**
90
+ * A mount token the caller minted for this start (`mintMountToken`), for a caller that mints its own (a place's
91
+ * `token(attempt)`): a start uses it instead of minting one, and removes it as it would its own when the driver starts
92
+ * nothing or adopts. A decision that starts nothing leaves it to the caller.
93
+ */
94
+ mountToken?: {
95
+ token: string;
96
+ identifier: string;
97
+ nickname: string;
98
+ };
76
99
  /** The caller has work for the run now: start a paused run, or a sleeping one before its `wakeAt`. */
77
100
  demand?: boolean;
78
101
  /** Create `runs/<id>/` with this owner when it does not exist and nothing holds it. */
@@ -241,6 +264,25 @@ export type TokenUser = {
241
264
  expiresAt?: string;
242
265
  createdAt?: string;
243
266
  };
267
+ /** What `archilControl` returns: everything `ensureRunning`, `superviseRuns`, `checkHost`, `fork` and the token janitor use. */
268
+ export type ArchilControl = SupervisorControl & CheckControl & {
269
+ listUsers(): Promise<TokenUser[]>;
270
+ };
271
+ /** The two calls of the `disk` client (this package's dependency) that `archilControl` makes. */
272
+ export type DiskClient = Pick<typeof import("disk"), "configure" | "getDisk">;
273
+ /**
274
+ * One Archil disk's control API from an API key, the object `pi-durable-disk supervise` builds for itself: a library
275
+ * caller that composes its own supervisor (with `daytonaHost`, or a driver of its own) passes it as `control`. The S3 and
276
+ * control calls go to the disk the key reaches; `listUsers` reads the disk record again on every call (the list lags by
277
+ * seconds). `configure` is the `disk` client's process-wide setting, so one process controls disks of one key and region.
278
+ * `client` replaces the `disk` client (tests).
279
+ */
280
+ export declare function archilControl(options: {
281
+ readonly apiKey: string;
282
+ readonly disk: string;
283
+ readonly region: string;
284
+ readonly client?: DiskClient;
285
+ }): Promise<ArchilControl>;
244
286
  export interface TokenSweepOptions {
245
287
  /** The disk's token users (`getDisk(id).authorizedUsers`; the list lags by seconds). */
246
288
  listUsers(): Promise<TokenUser[]>;
package/dist/supervise.js CHANGED
@@ -282,7 +282,7 @@ async function startInstance(ref, host, opts, why) {
282
282
  let token;
283
283
  try {
284
284
  // The attempt is the generation the instance will write, so the token user names the incarnation it served.
285
- token = await mintMountToken(opts.control, { run: { id: ref.id, attempt: why.generation + 1 }, prefix: opts.tokenPrefix, ttl: opts.tokenTtl ?? TOKEN_TTL, now: opts.now });
285
+ token = opts.mountToken ?? await mintMountToken(opts.control, { run: { id: ref.id, attempt: why.generation + 1 }, prefix: opts.tokenPrefix, ttl: opts.tokenTtl ?? TOKEN_TTL, now: opts.now });
286
286
  }
287
287
  catch (err) {
288
288
  throw new SuperviseError("CONTROL_API_FAILED", `minting a mount token for ${runPath(ref.id)} failed`, { cause: err });
@@ -347,6 +347,31 @@ export async function superviseRuns(refs, host, opts) {
347
347
  }
348
348
  return out;
349
349
  }
350
+ /**
351
+ * One Archil disk's control API from an API key, the object `pi-durable-disk supervise` builds for itself: a library
352
+ * caller that composes its own supervisor (with `daytonaHost`, or a driver of its own) passes it as `control`. The S3 and
353
+ * control calls go to the disk the key reaches; `listUsers` reads the disk record again on every call (the list lags by
354
+ * seconds). `configure` is the `disk` client's process-wide setting, so one process controls disks of one key and region.
355
+ * `client` replaces the `disk` client (tests).
356
+ */
357
+ export async function archilControl(options) {
358
+ const { configure, getDisk } = options.client ?? (await import("disk"));
359
+ configure({ apiKey: options.apiKey, region: options.region });
360
+ const disk = await getDisk(options.disk);
361
+ return {
362
+ getObject: (key) => disk.getObject(key),
363
+ headObject: (key) => disk.headObject(key),
364
+ putObject: (key, body, putOptions) => disk.putObject(key, body, putOptions),
365
+ addUser: (user) => disk.addUser(user),
366
+ removeUser: (type, identifier) => disk.removeUser(type, identifier),
367
+ listDelegations: () => disk.listDelegations(),
368
+ revokeDelegation: (d) => disk.revokeDelegation(d),
369
+ exec: (command) => disk.exec(command),
370
+ listObjects: (prefix, listOptions) => disk.listObjects(prefix, listOptions),
371
+ deleteObjects: (keys, deleteOptions) => disk.deleteObjects(keys, deleteOptions),
372
+ listUsers: async () => (await getDisk(options.disk)).authorizedUsers ?? [],
373
+ };
374
+ }
350
375
  const RELEASED = ["paused", "sleeping", "done", "failed"];
351
376
  /**
352
377
  * Remove mount-token users that no live mount needs. Removing a token user under a live mount makes that mount lose its
@@ -9,6 +9,8 @@ export interface SweepOptions {
9
9
  readonly proc?: string;
10
10
  readonly sys?: string;
11
11
  readonly kill?: (pid: number, signal: NodeJS.Signals) => void;
12
+ /** Lists processes with this ps(1) instead of reading `proc`: the default on macOS, which has no /proc, unless `proc` is given. */
13
+ readonly ps?: (file: string, args: readonly string[]) => string;
12
14
  }
13
15
  /** One look at the shared state at `now`. True once the lease has lapsed, for good; the first look records the age. */
14
16
  export declare const lapsed: (shared: BigInt64Array, now: bigint) => boolean;
@@ -16,7 +18,7 @@ export declare const lapsed: (shared: BigInt64Array, now: bigint) => boolean;
16
18
  * SIGKILL the instance's commands: the process group of each direct child (pi starts each command as its own group's
17
19
  * leader), or the child alone when it shares the instance's group; with `ownCgroup`, every other process of the cgroup.
18
20
  * Never the instance or its group. A process this user may not signal (sudo's archil helpers) is skipped. Returns the
19
- * number of signals sent. `stat` lines are `pid (comm) state ppid pgrp ...`, read after the last `)` since comm may hold
21
+ * number of signals sent. Where there is no /proc (macOS) the processes come from one ps listing, and there is no cgroup. `stat` lines are `pid (comm) state ppid pgrp ...`, read after the last `)` since comm may hold
20
22
  * spaces and parentheses.
21
23
  */
22
24
  export declare const sweep: (instance: number, options?: SweepOptions) => number;
package/dist/watchdog.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // The lease watchdog on a thread of its own. A store commit is synchronous, so a FUSE daemon that stops
2
2
  // answering blocks the instance's main thread inside the kernel, and with it every timer the main thread owns. This
3
- // thread never touches the mount: it reads a clock reading the main thread shares, /proc and the cgroup, and sends
4
- // signals. Once the lease has lapsed it kills the instance's commands on every look, until the main thread runs again,
3
+ // thread never touches the mount: it reads a clock reading the main thread shares, /proc and the cgroup (on macOS, one
4
+ // ps listing), and sends signals. Once the lease has lapsed it kills the instance's commands on every look, until the main thread runs again,
5
5
  // sees the flag and takes the normal fence path (exit 75).
6
+ import * as child_process from "node:child_process";
6
7
  import * as fs from "node:fs";
7
8
  import * as path from "node:path";
8
9
  import { Worker } from "node:worker_threads";
@@ -49,20 +50,49 @@ function cgroupPids(instance, proc, sys) {
49
50
  }
50
51
  }
51
52
 
52
- function sweep(instance, options = {}) {
53
- const proc = options.proc ?? "/proc";
54
- const kill = options.kill ?? ((pid, signal) => process.kill(pid, signal));
53
+ /** The instance's group and its direct children, from /proc. */
54
+ function procTree(instance, proc) {
55
55
  const own = stat(proc, instance);
56
- if (own === undefined) return 0;
57
- const targets = new Set();
56
+ if (own === undefined) return undefined;
57
+ const children = [];
58
58
  for (const entry of fs.readdirSync(proc)) {
59
59
  const pid = Number(entry);
60
60
  if (!Number.isInteger(pid) || pid <= 0 || pid === instance) continue;
61
61
  const child = stat(proc, pid);
62
- if (child === undefined || child.ppid !== instance) continue;
63
- targets.add(child.pgrp === own.pgrp || child.pgrp <= 1 ? pid : -child.pgrp);
62
+ if (child !== undefined && child.ppid === instance) children.push({ pid, pgrp: child.pgrp });
63
+ }
64
+ return { pgrp: own.pgrp, children };
65
+ }
66
+
67
+ /** The same from one ps listing, where there is no /proc (macOS). */
68
+ function psTree(instance, ps) {
69
+ let text;
70
+ try {
71
+ text = ps("/bin/ps", ["-A", "-o", "pid=,ppid=,pgid="]);
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ const rows = [];
76
+ for (const line of text.split("\n")) {
77
+ const f = line.trim().split(/\s+/).map(Number);
78
+ if (f.length === 3 && f.every(Number.isInteger)) rows.push({ pid: f[0], ppid: f[1], pgrp: f[2] });
64
79
  }
65
- if (options.ownCgroup) {
80
+ const own = rows.find((row) => row.pid === instance);
81
+ if (own === undefined) return undefined;
82
+ return { pgrp: own.pgrp, children: rows.filter((row) => row.ppid === instance && row.pid > 0 && row.pid !== instance) };
83
+ }
84
+
85
+ const runPs = (file, args) => child_process.execFileSync(file, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10000 });
86
+
87
+ function sweep(instance, options = {}) {
88
+ const proc = options.proc ?? "/proc";
89
+ const kill = options.kill ?? ((pid, signal) => process.kill(pid, signal));
90
+ const ps = options.ps ?? (options.proc === undefined && process.platform === "darwin" ? runPs : undefined);
91
+ const tree = ps === undefined ? procTree(instance, proc) : psTree(instance, ps);
92
+ if (tree === undefined) return 0;
93
+ const targets = new Set();
94
+ for (const child of tree.children) targets.add(child.pgrp === tree.pgrp || child.pgrp <= 1 ? child.pid : -child.pgrp);
95
+ if (options.ownCgroup && ps === undefined) {
66
96
  for (const pid of cgroupPids(instance, proc, options.sys ?? "/sys/fs/cgroup")) if (pid !== instance && pid > 0) targets.add(pid);
67
97
  }
68
98
  let sent = 0;
@@ -78,6 +108,7 @@ function sweep(instance, options = {}) {
78
108
  }
79
109
  `;
80
110
  const WORKER_SOURCE = `"use strict";
111
+ const child_process = require("node:child_process");
81
112
  const fs = require("node:fs");
82
113
  const path = require("node:path");
83
114
  const { workerData } = require("node:worker_threads");
@@ -88,14 +119,14 @@ setInterval(() => {
88
119
  Atomics.add(shared, SLOT_KILLED, BigInt(sweep(workerData.instance, { ownCgroup: workerData.ownCgroup })));
89
120
  }, workerData.checkMs);
90
121
  `;
91
- const compiled = new Function("fs", "path", `"use strict";${LIB}\nreturn { lapsed, sweep };`)(fs, path);
122
+ const compiled = new Function("child_process", "fs", "path", `"use strict";${LIB}\nreturn { lapsed, sweep };`)(child_process, fs, path);
92
123
  /** One look at the shared state at `now`. True once the lease has lapsed, for good; the first look records the age. */
93
124
  export const lapsed = compiled.lapsed;
94
125
  /**
95
126
  * SIGKILL the instance's commands: the process group of each direct child (pi starts each command as its own group's
96
127
  * leader), or the child alone when it shares the instance's group; with `ownCgroup`, every other process of the cgroup.
97
128
  * Never the instance or its group. A process this user may not signal (sudo's archil helpers) is skipped. Returns the
98
- * number of signals sent. `stat` lines are `pid (comm) state ppid pgrp ...`, read after the last `)` since comm may hold
129
+ * number of signals sent. Where there is no /proc (macOS) the processes come from one ps listing, and there is no cgroup. `stat` lines are `pid (comm) state ppid pgrp ...`, read after the last `)` since comm may hold
99
130
  * spaces and parentheses.
100
131
  */
101
132
  export const sweep = compiled.sweep;
@@ -0,0 +1,63 @@
1
+ import { type ArchilHost } from "./claim.js";
2
+ import type { SupervisorControl } from "./supervise.js";
3
+ /** The control API both calls use; `archilControl`'s satisfies it. */
4
+ export type ClaimControl = Pick<SupervisorControl, "putObject" | "addUser" | "removeUser" | "listDelegations" | "revokeDelegation" | "exec" | "getObject" | "headObject">;
5
+ /** The path `fn` holds: `root` is where it is mounted on this host. */
6
+ export interface HeldPath {
7
+ readonly root: string;
8
+ readonly path: string;
9
+ /** Aborted once the claim is known lost (a barrier refused) or the caller's `signal` aborts. */
10
+ readonly signal: AbortSignal;
11
+ /** `archil sync` now: every write so far is durable when it resolves; a refusal is FENCED. */
12
+ barrier(): Promise<{
13
+ ms: number;
14
+ }>;
15
+ }
16
+ export interface WithClaimOptions {
17
+ /** How long the holder may hold the path; written in its `.claim` as its deadline. Default 60 s. */
18
+ readonly holdMs?: number;
19
+ /** How long to queue behind a live holder before CLAIM_HELD. Default 30 s. */
20
+ readonly waitMs?: number;
21
+ /** Where the mount goes: `<mountRoot>/runs/<pid>-<uuid>`, of this call's own. Default `/mnt/archil/.claims`. */
22
+ readonly mountRoot?: string;
23
+ readonly host?: ArchilHost;
24
+ readonly signal?: AbortSignal;
25
+ /** The owner and mode of the path's directories it makes when the disk lacks them. Default this process's, 0700. */
26
+ readonly owner?: {
27
+ readonly uid: number;
28
+ readonly gid: number;
29
+ readonly mode?: number;
30
+ };
31
+ }
32
+ export declare const DEFAULT_HOLD_MS = 60000;
33
+ export declare const DEFAULT_WAIT_MS = 30000;
34
+ /**
35
+ * Hold `path` exclusively while `fn` runs: wait for a live holder (`waitMs`), revoke a lost one, mount `<disk>:/<path>` at
36
+ * a fresh `<mountRoot>/runs/<pid>-<uuid>`, write `.claim` with this holder's deadline (`holdMs`), run `fn`, then barrier,
37
+ * unmount and give the mount token back, whether `fn` resolved or threw. Returns `fn`'s value; throws `fn`'s own error,
38
+ * else INVALID_ARGUMENT, CLAIM_HELD (76, retryable), CONTROL_API_FAILED, the mount's errors, or FENCED (75: the claim was
39
+ * revoked from under `fn`, whose writes are then not durable).
40
+ */
41
+ export declare function withClaim<T>(where: {
42
+ readonly control: ClaimControl;
43
+ readonly disk: string;
44
+ readonly region: string;
45
+ readonly path: string;
46
+ }, fn: (held: HeldPath) => Promise<T>, options?: WithClaimOptions): Promise<T>;
47
+ /**
48
+ * `<path>/<file>` over S3, once it is released: no delegation on the path (or an ancestor or a descendant) before or
49
+ * after the read, for SQLite (`sqlite`) no `-wal` with content (its writer checkpointed with TRUNCATE and closed), and the
50
+ * object unchanged across the read. A held path is CLAIM_HELD (76, retryable; never read an older release instead), a
51
+ * WAL with content NOT_RELEASED; a file that moved during every one of `attempts` reads (default 3) is CLAIM_HELD. Null
52
+ * when the file does not exist. It never mounts, so it never blocks a carry back.
53
+ */
54
+ export declare function readReleased(where: {
55
+ readonly control: ClaimControl;
56
+ readonly path: string;
57
+ }, file: string, options?: {
58
+ readonly sqlite?: boolean;
59
+ readonly attempts?: number;
60
+ }): Promise<{
61
+ bytes: Uint8Array;
62
+ etag: string | null;
63
+ } | null>;
@@ -0,0 +1,325 @@
1
+ // A short exclusive claim on any path of a disk (withClaim), and a consistent S3 read of a released file (readReleased),
2
+ // for a host that keeps state beside its runs: pi-durable-agent's agent home under `agent/`, carried back at a run's end.
3
+ //
4
+ // A claim is one exclusive mount. Archil refuses a second client's mount of the path, of an ancestor or of a descendant,
5
+ // and that refusal is the exclusivity; the delegation listing only decides whom to wait for and whom to revoke. A holder
6
+ // is lost when its client is gone (orphaned), or when it holds exactly the path and the `.claim` there names a deadline
7
+ // that has passed and stays the same across a few seconds (a holder that just mounted rewrites it at once); only a lost
8
+ // holder is revoked. Safety rests on the fence, never on the deadline: a holder revoked while still alive gets EIO at
9
+ // its next fsync, so its commit rolls back and its own call throws FENCED. Both calls are supervisor side: they need the
10
+ // API key's control API.
11
+ import { randomUUID } from "node:crypto";
12
+ import { hostname } from "node:os";
13
+ import { join } from "node:path";
14
+ import { setTimeout as sleep } from "node:timers/promises";
15
+ import { CLAIM_PROBE, DEFAULT_MOUNT_ROOT, exclusiveMount, mintMountToken, removeMountToken, revokeCompanions } from "./claim.js";
16
+ import { ClaimError, HeldError } from "./errors.js";
17
+ export const DEFAULT_HOLD_MS = 60_000;
18
+ export const DEFAULT_WAIT_MS = 30_000;
19
+ const POLL_MS = 250;
20
+ /**
21
+ * How long a past-deadline `.claim` must stay the same, with the same holder, before that holder counts as lost. A holder
22
+ * that mounted a moment ago writes its own `.claim` right after its mount; until then the one there is its predecessor's.
23
+ */
24
+ const CONFIRM_MS = 3_000;
25
+ const TOKEN_TTL = "10m";
26
+ const SEGMENT = /^[A-Za-z0-9._-]+$/;
27
+ /** CLAIM_HELD reads as retryable: the path is not released yet, and nothing older stands in for it. */
28
+ const notReleased = (path, why, cause) => new HeldError("claim", `${path} is held by another client and is not released yet (${why}). CLAIM_HELD is retryable: retry within waitMs, ` +
29
+ "and never fall back to an older release.", { cause });
30
+ /** `path` if it is one relative path of safe segments that no run owns; else INVALID_ARGUMENT. */
31
+ function claimPath(path) {
32
+ const segments = path.split("/");
33
+ if (path === "" || segments.some((s) => !SEGMENT.test(s) || s === "." || s === "..")) {
34
+ throw new ClaimError("INVALID_ARGUMENT", `${JSON.stringify(path)} is not a relative path of safe segments (letters, digits, ".", "_", "-")`);
35
+ }
36
+ if (segments[0] === "runs")
37
+ throw new ClaimError("INVALID_ARGUMENT", `${path} is under runs/, which openDurableRun and the supervisor claim`);
38
+ if (segments[0] === ".archil")
39
+ throw new ClaimError("INVALID_ARGUMENT", `${path} is under .archil/, Archil's own`);
40
+ return path;
41
+ }
42
+ /** Whether a delegation's path is `path`, under it, or above it (the disk root included): each conflicts with a mount of it. */
43
+ const conflicts = (delegated, path) => {
44
+ const p = delegated.replace(/^\/+/, "").replace(/\/+$/, "");
45
+ return p === "" || p === path || p.startsWith(`${path}/`) || path.startsWith(`${p}/`);
46
+ };
47
+ /**
48
+ * The delegations that conflict with a mount of `path`, from one listing. A pathless one is attributed by inode with one
49
+ * `exec` (the path's ancestors and everything under it); one that cannot be attributed fails CONTROL_API_FAILED, never
50
+ * reads as unheld.
51
+ */
52
+ async function holdersOf(control, path) {
53
+ let all;
54
+ try {
55
+ all = await control.listDelegations();
56
+ }
57
+ catch (err) {
58
+ throw new ClaimError("CONTROL_API_FAILED", "listing delegations failed", { cause: err });
59
+ }
60
+ const holders = all.filter((d) => d.path !== undefined && d.path !== null && conflicts(d.path, path));
61
+ const pathless = all.filter((d) => !d.path);
62
+ if (pathless.length > 0)
63
+ holders.push(...(await pathlessOn(control, path, pathless)));
64
+ return { holders, all };
65
+ }
66
+ async function pathlessOn(control, path, pathless) {
67
+ if (!control.exec)
68
+ throw new ClaimError("CONTROL_API_FAILED", `a delegation with no path may be on ${path}, and the control API has no exec to attribute it`);
69
+ const inodes = [...new Set(pathless.map((d) => d.inodeId))];
70
+ if (!inodes.every((i) => Number.isSafeInteger(i) && i >= 0))
71
+ throw new ClaimError("CONTROL_API_FAILED", "a pathless delegation has no valid inode id");
72
+ // Every path relative to the disk root as ./<path>, so a segment that begins with "-" is never read as an option.
73
+ const segments = path.split("/");
74
+ const above = [".", ...segments.slice(0, -1).map((_, i) => `./${segments.slice(0, i + 1).join("/")}`)];
75
+ const test = inodes.map((i) => `-inum ${i}`).join(" -o ");
76
+ let r;
77
+ try {
78
+ r = await control.exec(`for a in ${above.join(" ")}; do if [ -e "$a" ]; then stat -c '%i' -- "$a" || exit 3; fi; done; if [ -e ./${path} ]; then find ./${path} \\( ${test} \\) -printf '%i\\n' || exit 3; fi`);
79
+ }
80
+ catch (err) {
81
+ throw new ClaimError("CONTROL_API_FAILED", `attributing pathless delegations to ${path} failed`, { cause: err });
82
+ }
83
+ if (r.exitCode !== 0)
84
+ throw new ClaimError("CONTROL_API_FAILED", `attributing pathless delegations to ${path}: exit ${r.exitCode}`);
85
+ const on = new Set(String(r.stdout ?? "").split("\n").filter(Boolean).map(Number));
86
+ return pathless.filter((d) => on.has(d.inodeId));
87
+ }
88
+ /** `<path>/.claim` over S3, and whether the deadline it names has passed; null when it cannot be read or parsed. */
89
+ async function readClaim(control, path) {
90
+ try {
91
+ const text = new TextDecoder().decode(await control.getObject(`${path}/${CLAIM_PROBE}`));
92
+ const claim = JSON.parse(text);
93
+ const deadline = typeof claim.deadline === "string" ? Date.parse(claim.deadline) : Number.NaN;
94
+ return Number.isFinite(deadline) ? { text, expired: deadline < Date.now() } : null;
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
100
+ const exactly = (d, path) => typeof d.path === "string" && d.path.replace(/^\/+/, "").replace(/\/+$/, "") === path;
101
+ const clientsOf = (holders) => [...new Set(holders.map((d) => d.clientId))].sort().join(",");
102
+ /**
103
+ * Whether the live holders are lost past their deadline. Only a holder of exactly `path` has its deadline there, so any
104
+ * other (a descendant's, an ancestor's, one with no path) is never lost this way. And the `.claim` must be the same
105
+ * expired one, with the same holders, across CONFIRM_MS: a holder that just mounted rewrites it within that time.
106
+ */
107
+ async function lostPastDeadline(control, path, holders, signal) {
108
+ if (!holders.every((d) => exactly(d, path)))
109
+ return false;
110
+ const first = await readClaim(control, path);
111
+ if (!first?.expired)
112
+ return false;
113
+ await sleep(CONFIRM_MS, undefined, { signal }).catch(() => signal?.throwIfAborted());
114
+ const again = await holdersOf(control, path);
115
+ if (clientsOf(again.holders) !== clientsOf(holders))
116
+ return false;
117
+ const second = await readClaim(control, path);
118
+ return second !== null && second.expired && second.text === first.text;
119
+ }
120
+ /** Until nothing conflicts with a mount of `path`: lost holders are revoked, live ones waited for until `deadline`. */
121
+ async function settle(control, path, deadline, signal) {
122
+ for (;;) {
123
+ signal?.throwIfAborted();
124
+ const { holders, all } = await holdersOf(control, path);
125
+ if (holders.length === 0)
126
+ return;
127
+ if (holders.every((d) => d.isOrphaned) || (await lostPastDeadline(control, path, holders, signal))) {
128
+ try {
129
+ for (const d of holders)
130
+ await control.revokeDelegation({ clientId: d.clientId, inodeId: d.inodeId });
131
+ }
132
+ catch (err) {
133
+ throw new ClaimError("CONTROL_API_FAILED", `revoking the lost holder of ${path} failed`, { cause: err });
134
+ }
135
+ await revokeCompanions(control, holders, all);
136
+ continue;
137
+ }
138
+ signal?.throwIfAborted();
139
+ if (Date.now() >= deadline)
140
+ throw notReleased(path, `a live holder within its deadline: ${holders.map((d) => d.clientId).join(", ")}`);
141
+ await sleep(POLL_MS, undefined, { signal }).catch(() => signal?.throwIfAborted());
142
+ }
143
+ }
144
+ /**
145
+ * The directories of `path` the disk lacks, made over S3 with `owner`: archil mounts no path whose directory is missing
146
+ * ("the configured subdirectory does not exist"). A write refused with 409 met another caller that made the directory
147
+ * and holds it: HeldError, which the caller queues on as on a refused mount.
148
+ */
149
+ async function ensureDirectories(control, path, owner) {
150
+ const segments = path.split("/");
151
+ for (let i = 1; i <= segments.length; i++) {
152
+ const key = `${segments.slice(0, i).join("/")}/`;
153
+ try {
154
+ if ((await control.headObject(key)) === null)
155
+ await control.putObject(key, "", owner);
156
+ }
157
+ catch (err) {
158
+ if (err.status === 409)
159
+ throw new HeldError("claim", `${key} is held by the caller that made it (409)`, { cause: err });
160
+ throw new ClaimError("CONTROL_API_FAILED", `making ${key} on the disk failed`, { cause: err });
161
+ }
162
+ }
163
+ }
164
+ const positive = (name, value) => {
165
+ if (!Number.isSafeInteger(value) || value <= 0)
166
+ throw new ClaimError("INVALID_ARGUMENT", `${name} ${value} is not a positive whole number of milliseconds`);
167
+ return value;
168
+ };
169
+ /**
170
+ * Hold `path` exclusively while `fn` runs: wait for a live holder (`waitMs`), revoke a lost one, mount `<disk>:/<path>` at
171
+ * a fresh `<mountRoot>/runs/<pid>-<uuid>`, write `.claim` with this holder's deadline (`holdMs`), run `fn`, then barrier,
172
+ * unmount and give the mount token back, whether `fn` resolved or threw. Returns `fn`'s value; throws `fn`'s own error,
173
+ * else INVALID_ARGUMENT, CLAIM_HELD (76, retryable), CONTROL_API_FAILED, the mount's errors, or FENCED (75: the claim was
174
+ * revoked from under `fn`, whose writes are then not durable).
175
+ */
176
+ export async function withClaim(where, fn, options = {}) {
177
+ const path = claimPath(where.path);
178
+ const holdMs = positive("holdMs", options.holdMs ?? DEFAULT_HOLD_MS);
179
+ const deadline = Date.now() + positive("waitMs", options.waitMs ?? DEFAULT_WAIT_MS);
180
+ const { control } = where;
181
+ const mountRoot = options.mountRoot ?? join(DEFAULT_MOUNT_ROOT, ".claims");
182
+ const owner = { uid: options.owner?.uid ?? process.getuid(), gid: options.owner?.gid ?? process.getgid(), mode: options.owner?.mode ?? 0o700 };
183
+ for (;;) {
184
+ await settle(control, path, deadline, options.signal);
185
+ options.signal?.throwIfAborted();
186
+ try {
187
+ await ensureDirectories(control, path, owner);
188
+ }
189
+ catch (err) {
190
+ // Another caller made the directory and holds it: queue again, within the same wait.
191
+ if (err instanceof HeldError && Date.now() < deadline && !options.signal?.aborted) {
192
+ await sleep(POLL_MS);
193
+ continue;
194
+ }
195
+ options.signal?.throwIfAborted();
196
+ throw err instanceof HeldError ? notReleased(path, "its directory is being made by another holder", err) : err;
197
+ }
198
+ const minted = await mintMountToken(control, { nickname: `pdc-${path.replaceAll("/", ".")}-${Date.now().toString(36)}`, ttl: TOKEN_TTL });
199
+ const at = new Date();
200
+ let mount;
201
+ try {
202
+ // <mountRoot>/runs/<name>: the shape archil-scoped's cleanup verbs take, for a host that cannot unmount (Sysbox).
203
+ mount = await exclusiveMount({ disk: where.disk, region: where.region, path }, join(mountRoot, "runs", `${process.pid}-${randomUUID()}`), minted.token, {
204
+ host: options.host,
205
+ probe: `${JSON.stringify({ pid: process.pid, host: hostname(), at: at.toISOString(), deadline: new Date(at.getTime() + holdMs).toISOString() })}\n`,
206
+ });
207
+ }
208
+ catch (err) {
209
+ await removeMountToken(control, minted.identifier).catch(() => { });
210
+ // Another caller mounted between the listing and the mount: queue again, within the same wait.
211
+ if (err instanceof HeldError && Date.now() < deadline && !options.signal?.aborted) {
212
+ await sleep(POLL_MS);
213
+ continue;
214
+ }
215
+ options.signal?.throwIfAborted();
216
+ throw err instanceof HeldError ? notReleased(path, "its mount was refused", err) : err;
217
+ }
218
+ // Aborted while it mounted: fn never runs, and nothing it holds is kept.
219
+ if (options.signal?.aborted) {
220
+ await mount.unmount().catch(() => undefined);
221
+ await removeMountToken(control, minted.identifier).catch(() => { });
222
+ options.signal.throwIfAborted();
223
+ }
224
+ const lost = new AbortController();
225
+ const onAbort = () => lost.abort(options.signal?.reason);
226
+ options.signal?.addEventListener("abort", onAbort, { once: true });
227
+ const barrier = async () => {
228
+ try {
229
+ return await mount.barrier();
230
+ }
231
+ catch (err) {
232
+ lost.abort(err);
233
+ throw err;
234
+ }
235
+ };
236
+ let outcome;
237
+ try {
238
+ outcome = { ok: true, value: await fn({ root: mount.root, path, signal: lost.signal, barrier }) };
239
+ }
240
+ catch (error) {
241
+ outcome = { ok: false, error };
242
+ }
243
+ let release;
244
+ try {
245
+ await barrier();
246
+ }
247
+ catch (err) {
248
+ release = err;
249
+ }
250
+ try {
251
+ await mount.unmount();
252
+ }
253
+ catch (err) {
254
+ release ??= err;
255
+ }
256
+ await removeMountToken(control, minted.identifier).catch(() => { });
257
+ options.signal?.removeEventListener("abort", onAbort);
258
+ if (!outcome.ok)
259
+ throw outcome.error;
260
+ if (release !== undefined)
261
+ throw release;
262
+ return outcome.value;
263
+ }
264
+ }
265
+ /** An object's identity for "did it move": its etag, else its size and last-modified time. */
266
+ function identity(head) {
267
+ if (head === null || typeof head !== "object")
268
+ return null;
269
+ const { etag, size, lastModified } = head;
270
+ if (typeof etag === "string" && etag !== "")
271
+ return `etag:${etag}`;
272
+ return `size:${String(size)}@${lastModified instanceof Date ? lastModified.toISOString() : String(lastModified)}`;
273
+ }
274
+ const sizeOf = (head) => (head !== null && typeof head === "object" ? Number(head.size ?? 0) : 0);
275
+ /**
276
+ * `<path>/<file>` over S3, once it is released: no delegation on the path (or an ancestor or a descendant) before or
277
+ * after the read, for SQLite (`sqlite`) no `-wal` with content (its writer checkpointed with TRUNCATE and closed), and the
278
+ * object unchanged across the read. A held path is CLAIM_HELD (76, retryable; never read an older release instead), a
279
+ * WAL with content NOT_RELEASED; a file that moved during every one of `attempts` reads (default 3) is CLAIM_HELD. Null
280
+ * when the file does not exist. It never mounts, so it never blocks a carry back.
281
+ */
282
+ export async function readReleased(where, file, options = {}) {
283
+ const path = claimPath(where.path);
284
+ const key = `${path}/${claimPath(file)}`;
285
+ const attempts = positive("attempts", options.attempts ?? 3);
286
+ const { control } = where;
287
+ const head = async (name) => {
288
+ try {
289
+ return await control.headObject(name);
290
+ }
291
+ catch (err) {
292
+ throw new ClaimError("CONTROL_API_FAILED", `head ${name} failed`, { cause: err });
293
+ }
294
+ };
295
+ for (let attempt = 1;; attempt++) {
296
+ const { holders } = await holdersOf(control, path);
297
+ if (holders.length > 0)
298
+ throw notReleased(path, `held by ${holders.map((d) => d.clientId).join(", ")}`);
299
+ if (options.sqlite && sizeOf(await head(`${key}-wal`)) > 0) {
300
+ throw new ClaimError("NOT_RELEASED", `${key}-wal has content: its writer did not checkpoint (TRUNCATE) and close, so ${key} alone is not the database`);
301
+ }
302
+ const before = await head(key);
303
+ if (before === null)
304
+ return null;
305
+ let bytes;
306
+ try {
307
+ bytes = await control.getObject(key);
308
+ }
309
+ catch (err) {
310
+ throw new ClaimError("CONTROL_API_FAILED", `get ${key} failed`, { cause: err });
311
+ }
312
+ const after = await head(key);
313
+ // A writer that mounted, committed to the WAL and let go during the read leaves the database file as it was.
314
+ if (options.sqlite && sizeOf(await head(`${key}-wal`)) > 0) {
315
+ throw new ClaimError("NOT_RELEASED", `${key}-wal gained content during the read: a writer committed without a checkpoint (TRUNCATE)`);
316
+ }
317
+ const still = (await holdersOf(control, path)).holders.length === 0;
318
+ if (still && after !== null && identity(after) === identity(before) && sizeOf(after) === bytes.length) {
319
+ const etag = after.etag;
320
+ return { bytes, etag: typeof etag === "string" ? etag : null };
321
+ }
322
+ if (attempt >= attempts)
323
+ throw notReleased(path, `${key} changed while it was read, ${attempts} times`);
324
+ }
325
+ }