@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.
package/dist/status.d.ts CHANGED
@@ -19,7 +19,7 @@ export type RunStatus = (typeof RUN_STATUSES)[number];
19
19
  export interface RunHolder {
20
20
  readonly driver: string;
21
21
  readonly host: string;
22
- /** `/proc/sys/kernel/random/boot_id`; null where the platform has none. */
22
+ /** `/proc/sys/kernel/random/boot_id` (`kern.bootsessionuuid` on macOS); null where it cannot be read. */
23
23
  readonly bootId: string | null;
24
24
  readonly pid: number;
25
25
  /** When this incarnation claimed the run (ISO 8601). */
@@ -81,12 +81,14 @@ export declare const ARCHIL_FSTYPE = "fuse.archil";
81
81
  * A run root held open as a directory for the life of the instance. `at` is `/proc/self/fd/<n>`, a path the kernel
82
82
  * resolves through the open directory and never through the root's path (Node has no openat; the magic link stands in
83
83
  * for it): a file made, written or renamed under `at` is in the directory opened at acquire, or the call fails (ENOTCONN
84
- * once the FUSE daemon is gone). It never lands on the local disk under a mountpoint whose mount went away.
84
+ * once the FUSE daemon is gone). It never lands on the local disk under a mountpoint whose mount went away. macOS has no
85
+ * such link: there `at` is the root's path (`byPath`), and only the checks below stand between a write and a root that was
86
+ * replaced.
85
87
  */
86
88
  export interface ClaimDir {
87
89
  /** The root's path, as the claim names it. */
88
90
  readonly root: string;
89
- /** `/proc/self/fd/<n>`. Throws once closed: the number may then name another file. */
91
+ /** `/proc/self/fd/<n>`, or the root's path `byPath`. Throws once closed: the number may then name another file. */
90
92
  readonly at: string;
91
93
  /**
92
94
  * Resolves while the root's path still resolves to the directory held open (same device, same inode). A mount that was
@@ -107,6 +109,12 @@ export interface OpenClaimDirOptions {
107
109
  readonly fstype?: string | null;
108
110
  /** Default `/proc/self/mountinfo`. */
109
111
  readonly mountinfo?: string;
112
+ /**
113
+ * `at` is the root's path, not `/proc/self/fd/<n>`: the default on macOS, which has none. A write is checked against the
114
+ * held directory first (`assertMounted`), but a root renamed and replaced between that check and the write gets the
115
+ * write. For a local directory only.
116
+ */
117
+ readonly byPath?: boolean;
110
118
  }
111
119
  /** `major:minor` of a device number, as `/proc/self/mountinfo` writes it (glibc's encoding). */
112
120
  export declare function deviceName(dev: bigint): string;
package/dist/status.js CHANGED
@@ -2,11 +2,12 @@
2
2
  // claim with write-to-temp, fsync, rename, and read by the supervisor (over the S3 API or a local mount). It is a hint
3
3
  // for the supervisor, never the source of truth: the store is. Any error writing it is a fence. The instance writes it
4
4
  // through its claim directory (`ClaimDir`), never by path, so a write cannot land under a mountpoint whose mount is gone.
5
- import { constants, readFileSync } from "node:fs";
5
+ import { constants } from "node:fs";
6
6
  import { open, readFile, rename, rm, stat } from "node:fs/promises";
7
7
  import { hostname } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import { FencedError, ownWriteFenced, PdaError } from "./errors.js";
10
+ import { thisBoot } from "./process-table.js";
10
11
  export const RUN_JSON = "run.json";
11
12
  /** The temporary file every write goes through; renamed over `run.json` once it is fsynced. */
12
13
  export const RUN_JSON_TEMP = "run.json.tmp";
@@ -204,7 +205,8 @@ export async function openClaimDir(root, options = {}) {
204
205
  await handle.close().catch(() => undefined);
205
206
  throw error instanceof FencedError ? error : unmounted(`checking the claim root ${root} failed: ${error.message}`, error);
206
207
  }
207
- const at = `/proc/self/fd/${handle.fd}`;
208
+ const byPath = options.byPath ?? process.platform === "darwin";
209
+ const at = byPath ? root : `/proc/self/fd/${handle.fd}`;
208
210
  let closed = false;
209
211
  const same = (path, now, claims) => {
210
212
  if (now.dev === claims.dev && now.ino === claims.ino)
@@ -234,10 +236,18 @@ export async function openClaimDir(root, options = {}) {
234
236
  async assertSame(name) {
235
237
  if (closed)
236
238
  throw unmounted(`the claim directory of ${root} is closed`);
237
- const [byPath, inDir] = await Promise.all([statOf(join(root, name)), statOf(join(at, name))]);
239
+ if (byPath) {
240
+ // No second way to name the entry: the root must still be the directory held, and the entry on its device.
241
+ const [dir, entry] = await Promise.all([statOf(root), statOf(join(root, name))]);
242
+ same(root, dir, held);
243
+ if (entry.dev !== held.dev)
244
+ throw unmounted(`${name} in the claim directory is on device ${deviceName(entry.dev)}, not the claim's ${deviceName(held.dev)}`);
245
+ return;
246
+ }
247
+ const [named, inDir] = await Promise.all([statOf(join(root, name)), statOf(join(at, name))]);
238
248
  if (inDir.dev !== held.dev)
239
249
  throw unmounted(`${name} in the claim directory is on device ${deviceName(inDir.dev)}, not the claim's ${deviceName(held.dev)}`);
240
- same(join(root, name), byPath, inDir);
250
+ same(join(root, name), named, inDir);
241
251
  },
242
252
  async close() {
243
253
  if (closed)
@@ -247,18 +257,10 @@ export async function openClaimDir(root, options = {}) {
247
257
  },
248
258
  };
249
259
  }
250
- function bootId() {
251
- try {
252
- return readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim() || null;
253
- }
254
- catch {
255
- return null;
256
- }
257
- }
258
260
  /**
259
261
  * This process as a holder: the host driver's `handle` (default driver `local`, this machine's host name and boot id)
260
262
  * with this process's pid and `since`. A handle whose named fields have the wrong type is refused (RunRecordError).
261
263
  */
262
264
  export function currentHolder(handle = {}, since = new Date()) {
263
- return holderOf({ driver: "local", host: hostname(), bootId: bootId(), ...handle, pid: process.pid, since: since.toISOString() });
265
+ return holderOf({ driver: "local", host: hostname(), bootId: thisBoot(), ...handle, pid: process.pid, since: since.toISOString() });
264
266
  }
package/dist/store.d.ts CHANGED
@@ -39,8 +39,18 @@ export declare const PROFILE_PRAGMAS: {
39
39
  readonly mmap_size: 0;
40
40
  };
41
41
  };
42
- /** Read every pragma of `profile` back and refuse (StorePragmaError) when any differs. */
43
- export declare function assertPragmas(database: SqliteExecutor, profile: Profile): Promise<void>;
42
+ /**
43
+ * 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
44
+ * SQLite sync with F_FULLFSYNC instead, at every commit and every checkpoint.
45
+ */
46
+ export declare const FULLFSYNC_PRAGMAS: {
47
+ readonly fullfsync: 1;
48
+ readonly checkpoint_fullfsync: 1;
49
+ };
50
+ /** What `profile` must read back on `platform`: its own pragmas, and on macOS `FULLFSYNC_PRAGMAS` too. */
51
+ export declare function profilePragmas(profile: Profile, platform?: NodeJS.Platform): Readonly<Record<string, string | number>>;
52
+ /** Read every pragma of `profile` on `platform` (default this one) back and refuse (StorePragmaError) when any differs. */
53
+ export declare function assertPragmas(database: SqliteExecutor, profile: Profile, platform?: NodeJS.Platform): Promise<void>;
44
54
  /**
45
55
  * The fenced state of one handle. The first error whose result code is in the I/O, full or not-a-database class marks it
46
56
  * fenced, runs `onFenced` once (before the error propagates), and from then on every operation fails with the same
@@ -84,6 +94,8 @@ export type OpenOptions = {
84
94
  readonly onFenced?: (error: StoreFencedError) => void;
85
95
  /** Wraps the raw database inside the fence, for instrumentation and fault injection. */
86
96
  readonly decorate?: (database: SqliteDatabase) => SqliteDatabase;
97
+ /** The platform whose pragmas the store takes (`profilePragmas`); default this one. For tests. */
98
+ readonly platform?: NodeJS.Platform;
87
99
  };
88
100
  export type ArchilStore = {
89
101
  readonly storage: SqliteStorage;
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
  }
@@ -241,6 +241,25 @@ export type TokenUser = {
241
241
  expiresAt?: string;
242
242
  createdAt?: string;
243
243
  };
244
+ /** What `archilControl` returns: everything `ensureRunning`, `superviseRuns`, `checkHost`, `fork` and the token janitor use. */
245
+ export type ArchilControl = SupervisorControl & CheckControl & {
246
+ listUsers(): Promise<TokenUser[]>;
247
+ };
248
+ /** The two calls of the `disk` client (this package's dependency) that `archilControl` makes. */
249
+ export type DiskClient = Pick<typeof import("disk"), "configure" | "getDisk">;
250
+ /**
251
+ * One Archil disk's control API from an API key, the object `pi-durable-disk supervise` builds for itself: a library
252
+ * caller that composes its own supervisor (with `daytonaHost`, or a driver of its own) passes it as `control`. The S3 and
253
+ * control calls go to the disk the key reaches; `listUsers` reads the disk record again on every call (the list lags by
254
+ * seconds). `configure` is the `disk` client's process-wide setting, so one process controls disks of one key and region.
255
+ * `client` replaces the `disk` client (tests).
256
+ */
257
+ export declare function archilControl(options: {
258
+ readonly apiKey: string;
259
+ readonly disk: string;
260
+ readonly region: string;
261
+ readonly client?: DiskClient;
262
+ }): Promise<ArchilControl>;
244
263
  export interface TokenSweepOptions {
245
264
  /** The disk's token users (`getDisk(id).authorizedUsers`; the list lags by seconds). */
246
265
  listUsers(): Promise<TokenUser[]>;
package/dist/supervise.js CHANGED
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parcha/pi-durable-disk",
3
- "version": "0.1.0-beta.13",
3
+ "version": "0.1.0-beta.14",
4
4
  "description": "A durable pi host on a network disk (Archil): one owner per run, a fence, durable commits, and a supervisor.",
5
5
  "keywords": [
6
6
  "pi",
@@ -30,6 +30,10 @@
30
30
  "types": "./dist/lease.d.ts",
31
31
  "default": "./dist/lease.js"
32
32
  },
33
+ "./internals": {
34
+ "types": "./dist/internals.d.ts",
35
+ "default": "./dist/internals.js"
36
+ },
33
37
  "./bin/archil-scoped": "./bin/archil-scoped",
34
38
  "./package.json": "./package.json"
35
39
  },
@@ -58,12 +62,13 @@
58
62
  "test:live:run": "node --test --test-concurrency=1 test/live/run.live.test.ts",
59
63
  "test:live:store": "node --test --test-concurrency=1 test/live/store.live.test.ts",
60
64
  "test:live:supervise": "node --test --test-concurrency=1 test/live/supervise.live.test.ts",
65
+ "test:live:supervise-slow": "node --test --test-concurrency=1 test/live/supervise-slow.live.test.ts",
61
66
  "test:live:docker": "PDA_LIVE_DOCKER=1 node --test --test-concurrency=1 test/live/docker.live.test.ts test/live/docker-park.live.test.ts",
62
67
  "verify:tarball": "node scripts/verify-tarball.mjs"
63
68
  },
64
69
  "peerDependencies": {
65
- "@earendil-works/chord": ">=1.0.4 <1.3.0",
66
- "@earendil-works/pi-durable": ">=1.0.4 <1.3.0"
70
+ "@earendil-works/chord": ">=1.0.4 <1.4.0",
71
+ "@earendil-works/pi-durable": ">=1.0.4 <1.4.0"
67
72
  },
68
73
  "dependencies": {
69
74
  "disk": "1.7.0"