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

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 +329 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +2 -0
  4. package/README.md +695 -2
  5. package/bin/archil-scoped +165 -0
  6. package/dist/app.d.ts +40 -0
  7. package/dist/app.js +47 -0
  8. package/dist/claim.d.ts +222 -0
  9. package/dist/claim.js +722 -0
  10. package/dist/cli.d.ts +64 -0
  11. package/dist/cli.js +664 -0
  12. package/dist/commands.d.ts +67 -0
  13. package/dist/commands.js +299 -0
  14. package/dist/confine.d.ts +91 -0
  15. package/dist/confine.js +341 -0
  16. package/dist/env.d.ts +129 -0
  17. package/dist/env.js +608 -0
  18. package/dist/errors.d.ts +47 -0
  19. package/dist/errors.js +55 -0
  20. package/dist/fork.d.ts +41 -0
  21. package/dist/fork.js +181 -0
  22. package/dist/hosts/daytona-launch.d.ts +52 -0
  23. package/dist/hosts/daytona-launch.js +270 -0
  24. package/dist/hosts/daytona.d.ts +147 -0
  25. package/dist/hosts/daytona.js +368 -0
  26. package/dist/hosts/docker.d.ts +103 -0
  27. package/dist/hosts/docker.js +362 -0
  28. package/dist/hosts/local-host.d.ts +71 -0
  29. package/dist/hosts/local-host.js +356 -0
  30. package/dist/index.d.ts +16 -0
  31. package/dist/index.js +17 -0
  32. package/dist/lease.d.ts +7 -0
  33. package/dist/lease.js +6 -0
  34. package/dist/local-run.d.ts +10 -0
  35. package/dist/local-run.js +63 -0
  36. package/dist/park.d.ts +92 -0
  37. package/dist/park.js +229 -0
  38. package/dist/run.d.ts +290 -0
  39. package/dist/run.js +785 -0
  40. package/dist/serve.d.ts +101 -0
  41. package/dist/serve.js +393 -0
  42. package/dist/status.d.ts +123 -0
  43. package/dist/status.js +264 -0
  44. package/dist/store.d.ts +103 -0
  45. package/dist/store.js +217 -0
  46. package/dist/supervise.d.ts +340 -0
  47. package/dist/supervise.js +528 -0
  48. package/dist/watchdog.d.ts +39 -0
  49. package/dist/watchdog.js +137 -0
  50. package/package.json +87 -4
package/dist/claim.js ADDED
@@ -0,0 +1,722 @@
1
+ // The claim: one exclusive Archil mount of `runs/<id>/` per run. The supervisor side holds the API key and uses
2
+ // the control API: create the run directory, mint a mount token, revoke a delegation. The host side holds only
3
+ // a mount token and uses the archil client: mount, verify, barrier, release, dead-mount cleanup.
4
+ import { spawn } from "node:child_process";
5
+ import { readdirSync, readFileSync } from "node:fs";
6
+ import { mkdir, open, readdir, readFile, rmdir, stat } from "node:fs/promises";
7
+ import { basename, isAbsolute, join } from "node:path";
8
+ import { setTimeout as sleep } from "node:timers/promises";
9
+ import { fileURLToPath } from "node:url";
10
+ import { ClaimError, FencedError, HeldError, ownWriteFenced } from "./errors.js";
11
+ export const DEFAULT_MOUNT_ROOT = "/mnt/archil";
12
+ /** This package's `bin/archil-scoped`. A deployment installs a root-owned copy and passes its path as `archil`. */
13
+ export const ARCHIL_SCOPED = fileURLToPath(new URL("../bin/archil-scoped", import.meta.url));
14
+ /** The file the claim writes and fsyncs to prove it holds the delegation; written on every acquire. */
15
+ export const CLAIM_PROBE = ".claim";
16
+ // The client exits 1 for every refused mount; this phrase is its only signal that another client holds the delegation
17
+ // ("another client has an outstanding delegation to the root of the disk" or "... which conflicts with the checkout").
18
+ const HELD_MARKER = "has an outstanding delegation";
19
+ // A daemon of this host still runs for the mountpoint (with no mount, or mid-unmount) and holds its control socket
20
+ // ("Failed to bind control socket ... in use"): a local refusal, never another client's delegation, though archil's
21
+ // advice text mentions orphaning "outstanding delegations".
22
+ const STALE_MARKER = /an older Archil process is still running|Failed to bind control socket/;
23
+ // archil refuses to mount over a directory that has entries: in plain words ("'<mountpoint>' is not empty"), or with a
24
+ // misleading error (`os error 115` or `Unspecified Error`, its daemon logging `try_delete_delegation ... in Removing
25
+ // state`). The misleading one also comes from an Archil lockout on an empty mountpoint, so it means "not empty" only
26
+ // when the mountpoint has entries.
27
+ const NOT_EMPTY_MARKER = "is not empty";
28
+ const MISLEADING_REFUSAL = /os error 115|Unspecified Error|in Removing state/;
29
+ // Sysbox (nestybox/sysbox#854, the runtime of Daytona's sandboxes) refuses every umount2 of a FUSE mount with ENOENT,
30
+ // live or dead, while the mount stays listed; `archil unmount` and `fusermount -u` both report that errno.
31
+ const UNMOUNT_ENOENT = /No such file or directory/;
32
+ const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
33
+ const SAFE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
34
+ export function runPath(id) {
35
+ if (!RUN_ID.test(id))
36
+ throw new ClaimError("INVALID_ARGUMENT", `run id ${JSON.stringify(id)} is not one safe path segment`);
37
+ return `runs/${id}`;
38
+ }
39
+ // ---- supervisor side: the control API -------------------------------------------------------------------------------
40
+ /** PutObject of the `/`-terminated key; uid/gid are required or the directory is root:root. 409: held. */
41
+ export async function createRunDir(control, id, owner) {
42
+ const key = `${runPath(id)}/`;
43
+ try {
44
+ await control.putObject(key, "", { uid: owner.uid, gid: owner.gid, mode: owner.mode ?? 0o755 });
45
+ }
46
+ catch (err) {
47
+ if (err.status === 409)
48
+ throw new HeldError("claim", `${key} is held by another client`, { cause: err });
49
+ throw new ClaimError("CONTROL_API_FAILED", `PutObject ${key} failed`, { cause: err });
50
+ }
51
+ }
52
+ /** A mount token's lifetime: one day, a Go duration (the control plane rejects "1d"). */
53
+ export const MOUNT_TOKEN_TTL = "24h";
54
+ export const TOKEN_PREFIX = "pda-";
55
+ /** `<prefix><run id>-g<attempt>-<base36 ms>`: the token user names the run and start attempt it was minted for. */
56
+ export function tokenNickname(runId, attempt, at = Date.now(), prefix = TOKEN_PREFIX) {
57
+ runPath(runId);
58
+ if (!Number.isSafeInteger(attempt) || attempt < 0)
59
+ throw new ClaimError("INVALID_ARGUMENT", `attempt ${attempt} is not a count`);
60
+ const nickname = `${prefix}${runId}-g${attempt}-${at.toString(36)}`;
61
+ if (nickname.length > 255)
62
+ throw new ClaimError("INVALID_ARGUMENT", `token nickname for ${runId} is longer than the 255 characters the control plane takes`);
63
+ return nickname;
64
+ }
65
+ /** The run, attempt and minting time a nickname from `tokenNickname` names; null for anything else. */
66
+ export function parseTokenNickname(nickname, prefix = TOKEN_PREFIX) {
67
+ if (!nickname.startsWith(prefix))
68
+ return null;
69
+ const m = /^(.+)-g(\d+)-([0-9a-z]+)$/.exec(nickname.slice(prefix.length));
70
+ if (!m || !RUN_ID.test(m[1]))
71
+ return null;
72
+ return { runId: m[1], attempt: Number(m[2]), at: parseInt(m[3], 36) };
73
+ }
74
+ /**
75
+ * A reusable mount token with a one-day TTL by default, minted per start attempt. Not single-use: the client
76
+ * re-authenticates at its background endpoint refresh, 5 min after the mount and every 5 min after, and the control plane
77
+ * rejects a spent single-use token for good, after which every write on the mount fails (client 0.8.42). A token
78
+ * whose TTL passes fences its mount the same way at the next refresh, so an instance never outlives its token by more
79
+ * than one refresh. The nickname is given, or built from `run` so the user can be traced back to its run.
80
+ */
81
+ export async function mintMountToken(control, opts) {
82
+ const nickname = opts.nickname ?? (opts.run ? tokenNickname(opts.run.id, opts.run.attempt, (opts.now ?? Date.now)(), opts.prefix) : undefined);
83
+ if (!nickname)
84
+ throw new ClaimError("INVALID_ARGUMENT", "a mount token needs a nickname or a run");
85
+ let user;
86
+ try {
87
+ user = await control.addUser({ type: "token", nickname, ttl: opts.ttl ?? MOUNT_TOKEN_TTL, oneUse: opts.oneUse ?? false });
88
+ }
89
+ catch (err) {
90
+ throw new ClaimError("CONTROL_API_FAILED", "addUser failed", { cause: err });
91
+ }
92
+ if (!user.token || !user.identifier)
93
+ throw new ClaimError("CONTROL_API_FAILED", "addUser returned no token or identifier");
94
+ return { token: user.token, identifier: user.identifier, nickname };
95
+ }
96
+ export async function removeMountToken(control, identifier) {
97
+ try {
98
+ await control.removeUser("token", identifier);
99
+ }
100
+ catch (err) {
101
+ throw new ClaimError("CONTROL_API_FAILED", `removeUser ${identifier} failed`, { cause: err });
102
+ }
103
+ }
104
+ /**
105
+ * Delegations on `runs/<id>` and anything under it; never a sibling or a parent. The control API resolves a delegation's
106
+ * path best-effort: it can list one with no path, live or orphaned, on a never-reused run. So when nothing matches by
107
+ * path and some entry has none, the pathless entries are attributed by inode (`pathlessResolver`): those on the inode
108
+ * `runs/<id>` names now are the run's. A pathless entry that cannot be attributed fails CONTROL_API_FAILED: an unseen
109
+ * holder must never read as none, or a caller starts over it.
110
+ */
111
+ export async function findDelegations(control, id, resolve) {
112
+ runPath(id);
113
+ return matchDelegations(await control.listDelegations(), id, resolve ?? pathlessResolver(control));
114
+ }
115
+ /** `findDelegations` over a listing the caller already holds; share `resolve` across the runs of one listing or pass. */
116
+ export async function matchDelegations(all, id, resolve) {
117
+ const path = runPath(id);
118
+ const byPath = all.filter((d) => {
119
+ const p = d.path?.replace(/^\/+/, "");
120
+ return p === path || p?.startsWith(`${path}/`) === true;
121
+ });
122
+ const pathless = all.filter((d) => !d.path);
123
+ if (byPath.length > 0 || pathless.length === 0)
124
+ return byPath;
125
+ const names = await resolve(pathless);
126
+ return pathless.filter((d) => names.get(d.inodeId) === id);
127
+ }
128
+ /**
129
+ * Attributes pathless delegations by inode: one `exec` maps every inode not seen before to the run directory that has
130
+ * it (`find runs -inum`, whose inode numbers are the control API's inode ids). Share one across a listing or a supervisor
131
+ * pass: it costs one exec per batch of new inodes, never one per run. The output names only the inodes asked for, so it
132
+ * stays small however many runs the disk holds. A failed exec fails this call and every later one, with no new exec.
133
+ * Inodes on no run directory are reported once on stderr: they block nothing, and only a revoke by client and inode
134
+ * removes them.
135
+ */
136
+ export function pathlessResolver(control) {
137
+ const known = new Map();
138
+ let failed = null;
139
+ let queue = Promise.resolve();
140
+ return (pathless) => {
141
+ const call = queue.then(async () => {
142
+ if (failed)
143
+ throw failed;
144
+ const fresh = [...new Set(pathless.map((d) => d.inodeId))].filter((inode) => !known.has(inode));
145
+ if (fresh.length === 0)
146
+ return known;
147
+ let names;
148
+ try {
149
+ names = await runDirsByInode(control, fresh);
150
+ }
151
+ catch (err) {
152
+ failed = err instanceof ClaimError ? err : new ClaimError("CONTROL_API_FAILED", "attributing pathless delegations failed", { cause: err });
153
+ throw failed;
154
+ }
155
+ for (const inode of fresh)
156
+ known.set(inode, names.get(inode) ?? null);
157
+ const unlinked = pathless.filter((d) => fresh.includes(d.inodeId) && !names.has(d.inodeId));
158
+ if (unlinked.length) {
159
+ const sample = unlinked.slice(0, 10).map((d) => ({ clientId: d.clientId, inodeId: d.inodeId, orphaned: d.isOrphaned }));
160
+ process.stderr.write(`${JSON.stringify({ at: new Date().toISOString(), event: "pathless delegations on no run directory", count: unlinked.length, delegations: sample })}\n`);
161
+ }
162
+ return known;
163
+ });
164
+ queue = call.catch(() => { });
165
+ return call;
166
+ };
167
+ }
168
+ /**
169
+ * One exec: the run directories among `inodes`, by inode. `exec` runs at the disk root in shared mode. The disk root is
170
+ * listed first, so a missing `runs/` and an unreadable one are told apart: an unreadable root or `runs/` fails the exec,
171
+ * never reads as no run directory.
172
+ */
173
+ async function runDirsByInode(control, inodes) {
174
+ if (!control.exec)
175
+ throw new ClaimError("CONTROL_API_FAILED", "a delegation with no path may be a run's, and the control API has no exec to attribute it");
176
+ if (!inodes.every((i) => Number.isSafeInteger(i) && i >= 0))
177
+ throw new ClaimError("CONTROL_API_FAILED", "a pathless delegation has no valid inode id");
178
+ const test = inodes.map((i) => `-inum ${i}`).join(" -o ");
179
+ let r;
180
+ try {
181
+ r = await control.exec(`top=$(find . -mindepth 1 -maxdepth 1 -name runs -print) || exit 3; [ -n "$top" ] || exit 0; ` +
182
+ `find runs -mindepth 1 -maxdepth 1 -type d \\( ${test} \\) -printf '%i %f\\n'`);
183
+ }
184
+ catch (err) {
185
+ throw new ClaimError("CONTROL_API_FAILED", "resolving pathless delegations to run directories failed", { cause: err });
186
+ }
187
+ if (r.exitCode !== 0)
188
+ throw new ClaimError("CONTROL_API_FAILED", `resolving pathless delegations to run directories: exit ${r.exitCode}`);
189
+ const names = new Map();
190
+ for (const line of String(r.stdout ?? "").split("\n").filter(Boolean)) {
191
+ const m = /^(\d+) (.+)$/.exec(line);
192
+ if (!m)
193
+ throw new ClaimError("CONTROL_API_FAILED", `resolving pathless delegations: unexpected line ${JSON.stringify(line.slice(0, 80))}`);
194
+ names.set(Number(m[1]), m[2]);
195
+ }
196
+ return names;
197
+ }
198
+ /** A client's private directories on the disk: `.archil/client-<clientId>` and its `unlinked/`. */
199
+ const PRIVATE_DIR = /^\/*\.archil\/client-([A-Za-z0-9_-]+)(\/unlinked)?$/;
200
+ /**
201
+ * The delegations `held`'s clients also hold on their own private directories (`privateDirs`: inode to the client whose
202
+ * private directory it is). A killed client leaves them orphaned, and nothing ties them to a run: they block no run, but
203
+ * they pile up and resurface in listings. A client that holds anything else beyond `held` may hold another run, so none
204
+ * of its delegations is a companion.
205
+ */
206
+ export function companionsOf(all, held, privateDirs) {
207
+ const clients = new Set(held.map((d) => d.clientId));
208
+ const listed = new Set(held.map((d) => `${d.clientId}/${d.inodeId}`));
209
+ const others = all.filter((d) => clients.has(d.clientId) && !listed.has(`${d.clientId}/${d.inodeId}`));
210
+ const own = (d) => (d.path ? PRIVATE_DIR.exec(d.path)?.[1] : privateDirs.get(d.inodeId)) === d.clientId;
211
+ const holdsMore = new Set(others.filter((d) => !own(d)).map((d) => d.clientId));
212
+ return others.filter((d) => !holdsMore.has(d.clientId));
213
+ }
214
+ /** The inodes of `clients`' private directories, by one `exec` (`stat`); none without `exec`. */
215
+ async function privateDirInodes(control, clients) {
216
+ const asked = clients.filter((c) => /^[A-Za-z0-9_-]+$/.test(c));
217
+ const dirs = new Map();
218
+ if (!control.exec || asked.length === 0)
219
+ return dirs;
220
+ const paths = asked.flatMap((c) => [`.archil/client-${c}`, `.archil/client-${c}/unlinked`]);
221
+ const r = await control.exec(`stat -c '%i %n' -- ${paths.join(" ")} 2>/dev/null; true`);
222
+ for (const line of String(r.stdout ?? "").split("\n")) {
223
+ const m = /^(\d+) (\S+)$/.exec(line);
224
+ const client = m && PRIVATE_DIR.exec(m[2])?.[1];
225
+ if (m && client && asked.includes(client))
226
+ dirs.set(Number(m[1]), client);
227
+ }
228
+ return dirs;
229
+ }
230
+ /**
231
+ * Revoke the companions of `held` (`companionsOf`): only for clients the caller is already revoking because it fences or
232
+ * deletes a run, never as a sweep. From `all` or a fresh listing. Best effort, and hygiene only: a companion blocks no
233
+ * run, so one left behind (or a failed listing or `exec`) costs a listing entry. Returns the ones revoked.
234
+ */
235
+ export async function revokeCompanions(control, held, all) {
236
+ if (held.length === 0)
237
+ return [];
238
+ const listing = all ?? (await control.listDelegations().catch(() => []));
239
+ const clients = [...new Set(held.map((d) => d.clientId))];
240
+ const listed = new Set(held.map((d) => `${d.clientId}/${d.inodeId}`));
241
+ if (!listing.some((d) => clients.includes(d.clientId) && !listed.has(`${d.clientId}/${d.inodeId}`)))
242
+ return [];
243
+ const dirs = await privateDirInodes(control, clients).catch(() => new Map());
244
+ const revoked = [];
245
+ for (const d of companionsOf(listing, held, dirs)) {
246
+ await control.revokeDelegation({ clientId: d.clientId, inodeId: d.inodeId }).then(() => revoked.push(d), () => { });
247
+ }
248
+ return revoked;
249
+ }
250
+ /**
251
+ * Revoke every delegation on the run through the control API, then its holders' private directories
252
+ * (`revokeCompanions`). The old holder's next fsync returns EIO. Returns the run's own delegations.
253
+ */
254
+ export async function revoke(control, id) {
255
+ const path = runPath(id);
256
+ let all;
257
+ let held;
258
+ try {
259
+ all = await control.listDelegations();
260
+ held = await matchDelegations(all, id, pathlessResolver(control));
261
+ for (const d of held)
262
+ await control.revokeDelegation(d);
263
+ }
264
+ catch (err) {
265
+ throw new ClaimError("CONTROL_API_FAILED", `revoking ${path} failed`, { cause: err });
266
+ }
267
+ await revokeCompanions(control, held, all);
268
+ return held;
269
+ }
270
+ /**
271
+ * Revoke what `revoke` revokes, skipping every revoke that fails: for cleanup, where deleting the tree next checks what
272
+ * is left. Only the listing (and a pathless resolution) can fail it. Returns the run's own delegations.
273
+ */
274
+ export async function revokeBestEffort(control, id) {
275
+ const all = await control.listDelegations();
276
+ const held = await matchDelegations(all, id, pathlessResolver(control));
277
+ for (const d of held)
278
+ await control.revokeDelegation({ clientId: d.clientId, inodeId: d.inodeId }).catch(() => { });
279
+ await revokeCompanions(control, held, all);
280
+ return held;
281
+ }
282
+ /** Revoke through the control API, then mount plainly; if the API path fails, mount with `--force`. */
283
+ export async function takeOver(control, opts) {
284
+ let force = false;
285
+ try {
286
+ await revoke(control, opts.ref.id);
287
+ }
288
+ catch (err) {
289
+ if (err.code !== "CONTROL_API_FAILED")
290
+ throw err;
291
+ force = true;
292
+ }
293
+ return acquire({ ...opts, force });
294
+ }
295
+ function resolveHost(h = {}) {
296
+ return {
297
+ archil: h.archil ?? ARCHIL_SCOPED,
298
+ fusermount: h.fusermount ?? "/usr/bin/fusermount",
299
+ sudo: h.sudo ?? (process.getuid?.() === 0 ? false : "/usr/bin/sudo"),
300
+ procMounts: h.procMounts ?? "/proc/self/mounts",
301
+ proc: h.proc ?? "/proc",
302
+ timeoutMs: { mount: 120_000, sync: 300_000, unmount: 180_000, cli: 30_000, staleGrace: 5_000, ...h.timeoutMs },
303
+ stat: h.fs?.stat ?? stat,
304
+ persist: h.fs?.persist ?? persistFile,
305
+ };
306
+ }
307
+ async function persistFile(path, data) {
308
+ const fh = await open(path, "w", 0o644);
309
+ try {
310
+ await fh.writeFile(data);
311
+ await fh.sync();
312
+ }
313
+ finally {
314
+ await fh.close();
315
+ }
316
+ }
317
+ /**
318
+ * Spawn with a minimal environment: the parent's (which may hold the API key) never reaches the child. `input` goes to
319
+ * the child's stdin, the only way a secret may travel: never in argv, never in an environment sudo would log.
320
+ */
321
+ function sh(argv, timeoutMs, input) {
322
+ const env = { PATH: SAFE_PATH, LANG: "C.UTF-8", HOME: process.env.HOME ?? "/" };
323
+ return new Promise((resolve, reject) => {
324
+ const t0 = performance.now();
325
+ let stdout = "", stderr = "", timedOut = false, settled = false;
326
+ const child = spawn(argv[0], argv.slice(1), { env, stdio: ["pipe", "pipe", "pipe"] });
327
+ // A child that exits before reading (sudo refusing, a missing wrapper) closes the pipe; its exit code tells why.
328
+ child.stdin.on("error", () => { });
329
+ child.stdin.end(input ?? "");
330
+ child.stdout.setEncoding("utf8").on("data", (c) => (stdout += c));
331
+ child.stderr.setEncoding("utf8").on("data", (c) => (stderr += c));
332
+ const done = (fn) => void (settled || ((settled = true), clearTimeout(timer), fn()));
333
+ // On timeout: SIGTERM (sudo relays it; it cannot relay SIGKILL), drop the pipes a grandchild may hold, settle now.
334
+ const timer = setTimeout(() => {
335
+ timedOut = true;
336
+ child.kill("SIGTERM");
337
+ child.stdout.destroy();
338
+ child.stderr.destroy();
339
+ done(() => resolve({ code: null, timedOut, stdout, stderr, ms: performance.now() - t0 }));
340
+ }, timeoutMs);
341
+ child.on("error", (err) => done(() => reject(new ClaimError("ARCHIL_CLI_FAILED", `cannot run ${argv[0]}`, { cause: err }))));
342
+ child.on("close", (code) => done(() => resolve({ code, timedOut, stdout, stderr, ms: performance.now() - t0 })));
343
+ });
344
+ }
345
+ function asRoot(host, argv) {
346
+ return host.sudo ? [host.sudo, "-n", ...argv] : argv;
347
+ }
348
+ const archil = (host, args, timeoutMs = host.timeoutMs.cli) => sh(asRoot(host, [host.archil, ...args]), timeoutMs);
349
+ const ok = (r) => r.code === 0 && !r.timedOut;
350
+ /** One line of the child's stderr for an error message, with the token scrubbed. */
351
+ function detail(r, secret = "") {
352
+ const text = secret ? r.stderr.split(secret).join("<token>") : r.stderr;
353
+ const lines = text.split(/[\r\n]+/).map((l) => l.trim()).filter(Boolean);
354
+ const line = lines.find((l) => l.startsWith("✗")) ?? lines.at(-1) ?? "";
355
+ return `${r.timedOut ? "timed out" : `exit ${r.code}`}${line ? `: ${line.slice(0, 400)}` : ""}`;
356
+ }
357
+ const unescapeMount = (s) => s.replace(/\\([0-7]{3})/g, (_, o) => String.fromCharCode(parseInt(o, 8)));
358
+ async function mountEntry(host, mountpoint) {
359
+ let found = null;
360
+ for (const line of (await readFile(host.procMounts, "utf8")).split("\n")) {
361
+ const f = line.split(" ");
362
+ if (f.length >= 3 && unescapeMount(f[1]) === mountpoint)
363
+ found = { source: unescapeMount(f[0]), fstype: f[2] };
364
+ }
365
+ return found;
366
+ }
367
+ function isOurs(entry, ref) {
368
+ const base = `${ref.disk}:/${runPath(ref.id)}`;
369
+ return entry.fstype === "fuse.archil" && (entry.source === base || entry.source === `${base}[${ref.region}]`);
370
+ }
371
+ /** A FUSE mount whose daemon is gone answers stat with ENOTCONN; `archil` refuses it as "not running". */
372
+ async function isDead(host, mountpoint) {
373
+ try {
374
+ await host.stat(mountpoint);
375
+ return false;
376
+ }
377
+ catch (err) {
378
+ return err.code === "ENOTCONN";
379
+ }
380
+ }
381
+ // Exit codes of the mount tools are not evidence (a busy mount can report success and stay); the mount table decides.
382
+ const refusedWithEnoent = (r) => !ok(r) && UNMOUNT_ENOENT.test(`${r.stderr}\n${r.stdout}`);
383
+ /** Remove a dead mount with `fusermount -u`; where the kernel refuses with ENOENT (Sysbox), move it aside instead. */
384
+ async function cleanDead(host, mountpoint) {
385
+ const r = await sh(asRoot(host, [host.fusermount, "-u", mountpoint]), host.timeoutMs.cli);
386
+ if (!(await mountEntry(host, mountpoint)))
387
+ return "fusermount";
388
+ if (refusedWithEnoent(r)) {
389
+ await moveAside(host, mountpoint, "DEAD_MOUNT_CLEANUP_FAILED");
390
+ return "moved";
391
+ }
392
+ throw new ClaimError("DEAD_MOUNT_CLEANUP_FAILED", `${mountpoint} is still mounted after fusermount -u (${detail(r)})`);
393
+ }
394
+ /**
395
+ * The wrapper's `retire`: move the mount to `<mountRoot>/.released/<run>-<tag>` and SIGKILL its daemon. Done only when
396
+ * the mount table, read here, no longer lists the path and the wrapper reports its daemon gone.
397
+ */
398
+ async function moveAside(host, mountpoint, code) {
399
+ const r = await archil(host, ["retire", mountpoint, Date.now().toString(36)]);
400
+ if (await mountEntry(host, mountpoint))
401
+ throw new ClaimError(code, `${mountpoint} is still mounted after moving it aside (${detail(r)})`);
402
+ if (!ok(r))
403
+ throw new ClaimError(code, `${mountpoint} was moved aside but its daemon was not stopped (${detail(r)})`);
404
+ }
405
+ /**
406
+ * Release a live mount the kernel will not unmount (Sysbox): `archil checkin` gives the delegation back, the client must
407
+ * then list none on the run, and only then is the mount moved aside and its daemon killed. Any failed step leaves the
408
+ * mount where it is, as UNMOUNT_FAILED: a mount that may still hold the delegation is never abandoned.
409
+ */
410
+ async function checkinAndMoveAside(host, mountpoint) {
411
+ const c = await archil(host, ["checkin", mountpoint], host.timeoutMs.unmount);
412
+ if (!ok(c))
413
+ throw new ClaimError("UNMOUNT_FAILED", `${mountpoint} cannot be unmounted on this host (ENOENT) and archil checkin failed: ${detail(c)}`);
414
+ const d = await archil(host, ["delegations", "--json", mountpoint]);
415
+ let list = null;
416
+ try {
417
+ list = ok(d) ? JSON.parse(d.stdout) : null;
418
+ }
419
+ catch {
420
+ list = null;
421
+ }
422
+ const mine = (p) => p === mountpoint || (typeof p === "string" && p.startsWith(`${mountpoint}/`));
423
+ const held = !Array.isArray(list) || list.some((x) => x?.state === "Active" && mine(x.path));
424
+ if (held)
425
+ throw new ClaimError("UNMOUNT_FAILED", `${mountpoint} still lists a delegation after archil checkin: ${(ok(d) ? d.stdout : detail(d)).trim().slice(0, 300)}`);
426
+ await moveAside(host, mountpoint, "UNMOUNT_FAILED");
427
+ }
428
+ /**
429
+ * Unmount whatever the run has at `mountpoint`: `archil unmount` flushes and checks the delegation in; a dead mount
430
+ * (which `archil unmount` refuses) is cleaned with `fusermount -u`; a live mount refused with ENOENT (Sysbox) is checked
431
+ * in and moved aside. Any other live mount that refuses is never forced.
432
+ */
433
+ export function unmountClaim(mountpoint, host) {
434
+ return unmountWith(resolveHost(host), mountpoint);
435
+ }
436
+ async function unmountWith(host, mountpoint) {
437
+ if (!(await mountEntry(host, mountpoint)))
438
+ return "none";
439
+ const r = await archil(host, ["unmount", mountpoint], host.timeoutMs.unmount);
440
+ let via = "archil";
441
+ if (await mountEntry(host, mountpoint)) {
442
+ if (await isDead(host, mountpoint))
443
+ via = await cleanDead(host, mountpoint);
444
+ else if (refusedWithEnoent(r)) {
445
+ await checkinAndMoveAside(host, mountpoint);
446
+ via = "moved";
447
+ }
448
+ else
449
+ throw new ClaimError("UNMOUNT_FAILED", `${mountpoint} is still mounted after archil unmount (${detail(r)})`);
450
+ }
451
+ await rmdir(mountpoint).catch(() => (host.sudo ? sh([host.sudo, "-n", "rmdir", mountpoint], host.timeoutMs.cli).catch(() => { }) : undefined));
452
+ return via;
453
+ }
454
+ async function ensureMountpoint(host, dir) {
455
+ try {
456
+ await mkdir(dir, { recursive: true });
457
+ return;
458
+ }
459
+ catch (err) {
460
+ const code = err.code;
461
+ if (!host.sudo || (code !== "EACCES" && code !== "EPERM"))
462
+ throw new ClaimError("MOUNT_FAILED", `cannot create ${dir}`, { cause: err });
463
+ }
464
+ const r = await sh([host.sudo, "-n", "mkdir", "-p", dir], host.timeoutMs.cli);
465
+ if (!ok(r))
466
+ throw new ClaimError("MOUNT_FAILED", `cannot create ${dir}: ${detail(r)}`);
467
+ }
468
+ /**
469
+ * Exclusive mode is the absence of `--shared`, `--read-only` and `--conditional`; `verify` proves the mode took. The token
470
+ * goes to the wrapper as one line on stdin (`bin/archil-scoped`), with or without sudo.
471
+ */
472
+ async function mountRun(host, ref, mountpoint, token, force) {
473
+ const path = runPath(ref.id);
474
+ const args = [host.archil, "mount", ...(force ? ["--force"] : []), `${ref.disk}:/${path}`, mountpoint, "--region", ref.region];
475
+ const r = await sh(asRoot(host, args), host.timeoutMs.mount, `${token}\n`);
476
+ const entry = await mountEntry(host, mountpoint);
477
+ if (ok(r) && entry && isOurs(entry, ref))
478
+ return;
479
+ if (entry)
480
+ await unmountWith(host, mountpoint).catch(() => { });
481
+ if (ok(r))
482
+ throw new ClaimError("MOUNT_FAILED", `archil mount ${path} exited 0 but ${mountpoint} is not an archil mount of the run`);
483
+ if (STALE_MARKER.test(r.stderr))
484
+ throw new ClaimError("MOUNT_FAILED", `an archil daemon of this host still runs for ${mountpoint}: ${detail(r, token)}`);
485
+ if (!force && r.stderr.includes(HELD_MARKER))
486
+ throw new HeldError("claim", `${path} is held by another client (${detail(r, token)})`);
487
+ if (r.stderr.includes(NOT_EMPTY_MARKER) || (MISLEADING_REFUSAL.test(r.stderr) && (await entries(mountpoint)) > 0)) {
488
+ throw new ClaimError("MOUNTPOINT_NOT_EMPTY", `archil mount ${path} refused ${mountpoint}, which is not empty: ${detail(r, token)}`);
489
+ }
490
+ throw new ClaimError("MOUNT_FAILED", `archil mount ${path} failed: ${detail(r, token)}`);
491
+ }
492
+ /**
493
+ * Pids whose argument vector is an archil `mount` naming exactly `mountpoint`. The wrapper's `stale` matches the archil
494
+ * binary's full path; this match is what decides whether to call it. Read synchronously: one pass over a few thousand
495
+ * processes takes tens of milliseconds this way and several times that through the promise API.
496
+ */
497
+ function archilDaemons(proc, mountpoint) {
498
+ const needle = Buffer.from(`\0${mountpoint}\0`);
499
+ const pids = [];
500
+ let names;
501
+ try {
502
+ names = readdirSync(proc);
503
+ }
504
+ catch {
505
+ return pids;
506
+ }
507
+ for (const name of names) {
508
+ if (!/^\d+$/.test(name))
509
+ continue;
510
+ let raw;
511
+ try {
512
+ raw = readFileSync(join(proc, name, "cmdline"));
513
+ }
514
+ catch {
515
+ continue;
516
+ }
517
+ if (!raw.includes(needle))
518
+ continue;
519
+ const argv = raw.toString("utf8").split("\0");
520
+ if (basename(argv[0]) === "archil" && argv[1] === "mount" && argv.slice(2).includes(mountpoint))
521
+ pids.push(Number(name));
522
+ }
523
+ return pids;
524
+ }
525
+ /**
526
+ * An archil daemon naming the run's mountpoint while no mount is listed there is stale: a client can drop its FUSE mount
527
+ * (after an authentication failure) and keep its process and the mountpoint's control socket, and archil then refuses
528
+ * every mount there ("an older Archil process is still running"). It gets `staleGrace` to exit by itself (a client
529
+ * unmounted moments ago may still be checking its delegation in), then the wrapper's `stale` SIGKILLs it as root. The
530
+ * wrapper refuses while a mount is listed there, so a live mount's daemon is never killed. A daemon that survives is
531
+ * MOUNT_FAILED (exit 1, retried), never held. One line on stderr names the run and the pids.
532
+ */
533
+ async function clearStaleDaemon(host, ref, mountpoint) {
534
+ const deadline = performance.now() + host.timeoutMs.staleGrace;
535
+ let pids = archilDaemons(host.proc, mountpoint);
536
+ while (pids.length && performance.now() < deadline) {
537
+ await sleep(Math.min(250, Math.max(0, deadline - performance.now())));
538
+ pids = archilDaemons(host.proc, mountpoint);
539
+ }
540
+ if (!pids.length)
541
+ return [];
542
+ const r = await archil(host, ["stale", mountpoint]);
543
+ const left = archilDaemons(host.proc, mountpoint);
544
+ if (!ok(r) || left.length) {
545
+ const why = ok(r) ? `pid ${left.join(", ")} survived archil-scoped stale` : detail(r);
546
+ throw new ClaimError("MOUNT_FAILED", `a stale archil daemon (pid ${pids.join(", ")}) runs for ${mountpoint} with no mount and was not cleared: ${why}`);
547
+ }
548
+ process.stderr.write(`${JSON.stringify({ at: new Date().toISOString(), event: "stale archil daemon killed", run: ref.id, pids })}\n`);
549
+ return pids;
550
+ }
551
+ /** How many entries a directory holds; 0 when it cannot be read. */
552
+ const entries = (dir) => readdir(dir).then((names) => names.length, () => 0);
553
+ /**
554
+ * A run's mountpoint that is no mount but has entries (files a process wrote by path after the run's mount vanished)
555
+ * is moved aside whole, as root, by the wrapper's `stray` to `<mountRoot>/.stray/<run>-<tag>` and replaced by an empty
556
+ * directory with its owner and mode: archil refuses to mount over entries, and nothing in them may enter the run. One
557
+ * line on stderr names the run and the count of entries, never a name or a byte of them. An unreadable mountpoint goes
558
+ * to the wrapper too, which looks as root.
559
+ */
560
+ async function moveStrayAside(host, ref, mountRoot, mountpoint) {
561
+ const names = await readdir(mountpoint).catch(() => null);
562
+ if (names && names.length === 0)
563
+ return null;
564
+ const tag = Date.now().toString(36);
565
+ const r = await archil(host, ["stray", mountpoint, tag]);
566
+ if (!ok(r))
567
+ throw new ClaimError("MOUNTPOINT_NOT_EMPTY", `${mountpoint} is not empty and could not be moved aside: ${detail(r)}`);
568
+ const counted = /entries=(\d+)/.exec(r.stdout);
569
+ if (!counted)
570
+ return null;
571
+ const left = await readdir(mountpoint).catch(() => null);
572
+ if (!left || left.length)
573
+ throw new ClaimError("MOUNTPOINT_NOT_EMPTY", `${mountpoint} is not empty after its entries were moved aside`);
574
+ const moved = { to: join(mountRoot, ".stray", `${ref.id}-${tag}`), entries: Number(counted[1]) };
575
+ process.stderr.write(`${JSON.stringify({ at: new Date().toISOString(), event: "stray entries moved aside", run: ref.id, entries: moved.entries, to: moved.to })}\n`);
576
+ return moved;
577
+ }
578
+ /**
579
+ * Ownership is never inferred from a call the client can answer from its cache: on a revoked mount, opening the store,
580
+ * mkdir and writing a new file all succeed locally, and the client keeps listing its delegation Active. So:
581
+ * 1. the client must list an Active delegation on the run, which proves the mount is exclusive (a shared or conditional
582
+ * mount holds none); then
583
+ * 2. a write and fsync of the probe must succeed, which the server answers and refuses on a revoked mount. Any error
584
+ * there is a fence (`ownWriteFenced`).
585
+ * The control API cannot replace step 2: a host cannot learn its own client id, so the API can show that someone holds
586
+ * the run but not that this mount does.
587
+ */
588
+ async function verify(host, ref, mountpoint) {
589
+ const path = runPath(ref.id);
590
+ const r = await archil(host, ["delegations", "--json", mountpoint]);
591
+ if (!ok(r))
592
+ throw new ClaimError("CLAIM_NOT_VERIFIED", `archil delegations ${mountpoint}: ${detail(r)}`);
593
+ let list;
594
+ try {
595
+ list = JSON.parse(r.stdout);
596
+ }
597
+ catch {
598
+ list = null;
599
+ }
600
+ const accepted = [mountpoint, join(mountpoint, path)];
601
+ const active = Array.isArray(list) && list.some((d) => d?.state === "Active" && accepted.includes(d.path));
602
+ if (!active)
603
+ throw new ClaimError("CLAIM_NOT_VERIFIED", `${mountpoint} holds no Active delegation on ${path}: ${r.stdout.trim().slice(0, 300)}`);
604
+ try {
605
+ await host.persist(join(mountpoint, CLAIM_PROBE), `${JSON.stringify({ pid: process.pid, at: new Date().toISOString() })}\n`);
606
+ }
607
+ catch (err) {
608
+ throw ownWriteFenced(join(mountpoint, CLAIM_PROBE), err);
609
+ }
610
+ }
611
+ /**
612
+ * Take the run's claim: mount `<disk>:/runs/<id>` exclusively at `<mountRoot>/runs/<id>` and verify it. A live mount of
613
+ * the same run already there is kept (in-place restart); a dead one is cleaned first. With no mount there, a stale archil
614
+ * daemon on the mountpoint is killed (`stale`) and entries in it are moved aside to `<mountRoot>/.stray` (`stray`) before
615
+ * the mount. Throws HeldError (exit 76) only when another client holds the delegation, FencedError (exit 75) when the
616
+ * verifying write is refused, MOUNTPOINT_NOT_EMPTY when the mountpoint cannot be emptied or archil still finds it not
617
+ * empty, MOUNT_FAILED (exit 1) for a daemon of this host that is still running there.
618
+ */
619
+ export async function acquire(opts) {
620
+ const { ref, token } = opts;
621
+ const path = runPath(ref.id);
622
+ const mountRoot = opts.mountRoot ?? DEFAULT_MOUNT_ROOT;
623
+ if (!isAbsolute(mountRoot))
624
+ throw new ClaimError("INVALID_ARGUMENT", `mountRoot ${mountRoot} is not absolute`);
625
+ // The wrapper reads exactly one line, so a token with a line break would reach archil cut short.
626
+ if (!token || /[\r\n]/.test(token))
627
+ throw new ClaimError("INVALID_ARGUMENT", "no mount token, or one with a line break");
628
+ const host = resolveHost(opts.host);
629
+ const root = join(mountRoot, path);
630
+ const t0 = performance.now();
631
+ let reused = false;
632
+ const existing = await mountEntry(host, root);
633
+ if (existing) {
634
+ if (!isOurs(existing, ref))
635
+ throw new ClaimError("MOUNTPOINT_BUSY", `${root} already holds ${existing.fstype} ${existing.source}`);
636
+ if (await isDead(host, root))
637
+ await cleanDead(host, root);
638
+ else
639
+ reused = true;
640
+ }
641
+ let stray = null;
642
+ if (!reused) {
643
+ await ensureMountpoint(host, root);
644
+ await clearStaleDaemon(host, ref, root);
645
+ stray = await moveStrayAside(host, ref, mountRoot, root);
646
+ await mountRun(host, ref, root, token, opts.force === true);
647
+ }
648
+ const t1 = performance.now();
649
+ try {
650
+ await verify(host, ref, root);
651
+ }
652
+ catch (err) {
653
+ await unmountWith(host, root).catch(() => { });
654
+ throw err;
655
+ }
656
+ return new ArchilClaim(host, ref, root, reused, !reused && opts.force === true, { mountMs: t1 - t0, verifyMs: performance.now() - t1 }, stray);
657
+ }
658
+ class ArchilClaim {
659
+ ref;
660
+ disk;
661
+ root;
662
+ work;
663
+ store;
664
+ reused;
665
+ forced;
666
+ timings;
667
+ stray;
668
+ #host;
669
+ #fence = null;
670
+ #released = false;
671
+ constructor(host, ref, root, reused, forced, timings, stray = null) {
672
+ this.#host = host;
673
+ this.ref = ref;
674
+ this.disk = ref.disk;
675
+ this.root = root;
676
+ this.work = join(root, "work");
677
+ this.store = join(root, "store");
678
+ this.reused = reused;
679
+ this.forced = forced;
680
+ this.timings = timings;
681
+ this.stray = stray;
682
+ }
683
+ get fenced() {
684
+ return this.#fence !== null;
685
+ }
686
+ markFenced(cause) {
687
+ this.#fence ??= { cause };
688
+ }
689
+ async barrier() {
690
+ if (this.#released)
691
+ throw new ClaimError("CLAIM_RELEASED", `${this.root} was released`);
692
+ if (this.#fence)
693
+ throw new FencedError(`${this.root} is fenced; nothing is retried on it`, { cause: this.#fence.cause });
694
+ const r = await archil(this.#host, ["sync", this.root], this.#host.timeoutMs.sync);
695
+ if (ok(r))
696
+ return { ms: r.ms };
697
+ // `archil sync` fails only when the mount has failed or is read-only: its pending writes did not become durable.
698
+ const err = new FencedError(`archil sync ${this.root}: ${detail(r)}`);
699
+ this.markFenced(err);
700
+ throw err;
701
+ }
702
+ async release() {
703
+ if (this.#released)
704
+ return { via: "none" };
705
+ let lost = null;
706
+ if (!this.#fence) {
707
+ try {
708
+ await this.barrier();
709
+ }
710
+ catch (err) {
711
+ if (!(err instanceof FencedError))
712
+ throw err;
713
+ lost = err;
714
+ }
715
+ }
716
+ const via = await unmountWith(this.#host, this.root);
717
+ this.#released = true;
718
+ if (lost)
719
+ throw lost;
720
+ return { via };
721
+ }
722
+ }