@parcha/pi-durable-disk 0.1.0-beta.14 → 0.1.0-beta.16

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/claim.d.ts CHANGED
@@ -143,7 +143,7 @@ export declare function parseTokenNickname(nickname: string, prefix?: string): {
143
143
  * whose TTL passes fences its mount the same way at the next refresh, so an instance never outlives its token by more
144
144
  * than one refresh. The nickname is given, or built from `run` so the user can be traced back to its run.
145
145
  */
146
- export declare function mintMountToken(control: ControlApi, opts: {
146
+ export declare function mintMountToken(control: Pick<ControlApi, "addUser">, opts: {
147
147
  nickname?: string;
148
148
  run?: {
149
149
  id: string;
@@ -158,7 +158,7 @@ export declare function mintMountToken(control: ControlApi, opts: {
158
158
  identifier: string;
159
159
  nickname: string;
160
160
  }>;
161
- export declare function removeMountToken(control: ControlApi, identifier: string): Promise<void>;
161
+ export declare function removeMountToken(control: Pick<ControlApi, "removeUser">, identifier: string): Promise<void>;
162
162
  /**
163
163
  * Delegations on `runs/<id>` and anything under it; never a sibling or a parent. The control API resolves a delegation's
164
164
  * path best-effort: it can list one with no path, live or orphaned, on a never-reused run. So when nothing matches by
@@ -205,6 +205,12 @@ export declare function revoke(control: ControlApi, id: string): Promise<Delegat
205
205
  export declare function revokeBestEffort(control: Pick<ControlApi, "listDelegations" | "revokeDelegation" | "exec">, id: string): Promise<Delegation[]>;
206
206
  /** Revoke through the control API, then mount plainly; if the API path fails, mount with `--force`. */
207
207
  export declare function takeOver(control: ControlApi, opts: AcquireOptions): Promise<Claim>;
208
+ /** A path of a disk, as one exclusive mount names it: `runs/<id>` for a run, any safe path for `withClaim`. */
209
+ export type MountTarget = {
210
+ readonly disk: string;
211
+ readonly region: string;
212
+ readonly path: string;
213
+ };
208
214
  /**
209
215
  * The errno codes of a stat on a FUSE mount whose daemon is gone: ENOTCONN once its connection is torn down, ECONNABORTED
210
216
  * for a stat already in flight while it is torn down. Codes, never messages: a message is translated by the locale.
@@ -216,6 +222,18 @@ export declare const DEAD_CONNECTION: ReadonlySet<string>;
216
222
  * in and moved aside. Any other live mount that refuses is never forced.
217
223
  */
218
224
  export declare function unmountClaim(mountpoint: string, host?: ArchilHost): Promise<Unmounted>;
225
+ /** The most entries of a bare mountpoint the stray check looks at; past it, nothing is moved aside. */
226
+ export declare const STRAY_CHECK_LIMIT = 100000;
227
+ /**
228
+ * The entries under `dir` written since `since` (ms since the epoch), as paths relative to it, walked without following
229
+ * links. `unchecked` says why the walk could not look at every entry: more than `limit` of them, or one it could not
230
+ * read (an entry gone meanwhile is not one). A directory keeps its time when a file in it is rewritten in place, so only
231
+ * a walk that looked at every entry shows there is none.
232
+ */
233
+ export declare function writtenSince(dir: string, since: number, limit?: number): Promise<{
234
+ fresh: string[];
235
+ unchecked: string | null;
236
+ }>;
219
237
  /**
220
238
  * Take the run's claim: mount `<disk>:/runs/<id>` exclusively at `<mountRoot>/runs/<id>` and verify it. A live mount of
221
239
  * the same run already there is kept (in-place restart); a dead one is cleaned first. With no mount there, a stale archil
@@ -224,4 +242,29 @@ export declare function unmountClaim(mountpoint: string, host?: ArchilHost): Pro
224
242
  * verifying write is refused, MOUNTPOINT_NOT_EMPTY when the mountpoint cannot be emptied or archil still finds it not
225
243
  * empty, MOUNT_FAILED (exit 1) for a daemon of this host that is still running there.
226
244
  */
245
+ /**
246
+ * The token an instance holds when it was given none (`--token-stdin` read nothing): enough to reuse its live mount, never
247
+ * to mount. A restart in place gets it from a driver that hands the token to the first incarnation only (localHost).
248
+ */
249
+ export declare const NO_TOKEN = "none";
227
250
  export declare function acquire(opts: AcquireOptions): Promise<Claim>;
251
+ /** One exclusive mount of a path, as `withClaim` holds it: its own mountpoint, never reused. */
252
+ export interface ExclusiveMount {
253
+ readonly root: string;
254
+ /** `archil sync`; a refusal (the mount was revoked) is a FencedError. */
255
+ barrier(): Promise<{
256
+ ms: number;
257
+ }>;
258
+ /** `archil unmount` (a dead mount is cleaned), and the mountpoint removed. */
259
+ unmount(): Promise<Unmounted>;
260
+ }
261
+ /**
262
+ * Mount `<disk>:/<path>` exclusively at `mountpoint`, a fresh directory of the caller's own, and verify it, with `probe` as
263
+ * the claim probe's content. Another client's delegation is HeldError CLAIM_HELD (`held` is added to its message); the
264
+ * rest fails as `acquire` does. Unlike `acquire`, a mount already there is never reused, and nothing is forced.
265
+ */
266
+ export declare function exclusiveMount(target: MountTarget, mountpoint: string, token: string, options: {
267
+ readonly host?: ArchilHost;
268
+ readonly probe: string;
269
+ readonly held?: string;
270
+ }): Promise<ExclusiveMount>;
package/dist/claim.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // a mount token and uses the archil client: mount, verify, barrier, release, dead-mount cleanup.
4
4
  import { spawn } from "node:child_process";
5
5
  import { readdirSync, readFileSync } from "node:fs";
6
- import { mkdir, open, readdir, readFile, rmdir, stat } from "node:fs/promises";
6
+ import { lstat, mkdir, open, readdir, readFile, rmdir, stat } from "node:fs/promises";
7
7
  import { basename, isAbsolute, join } from "node:path";
8
8
  import { setTimeout as sleep } from "node:timers/promises";
9
9
  import { fileURLToPath } from "node:url";
@@ -364,9 +364,10 @@ async function mountEntry(host, mountpoint) {
364
364
  }
365
365
  return found;
366
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}]`);
367
+ const targetOf = (ref) => ({ disk: ref.disk, region: ref.region, path: runPath(ref.id) });
368
+ function isOurs(entry, target) {
369
+ const base = `${target.disk}:/${target.path}`;
370
+ return entry.fstype === "fuse.archil" && (entry.source === base || entry.source === `${base}[${target.region}]`);
370
371
  }
371
372
  /**
372
373
  * The errno codes of a stat on a FUSE mount whose daemon is gone: ENOTCONN once its connection is torn down, ECONNABORTED
@@ -412,10 +413,13 @@ async function moveAside(host, mountpoint, code) {
412
413
  if (!ok(r))
413
414
  throw new ClaimError(code, `${mountpoint} was moved aside but its daemon was not stopped (${detail(r)})`);
414
415
  }
416
+ /** The archil client's own directory on the disk, where it keeps its bookkeeping (`client-<n>`, `unlinked`). */
417
+ const ARCHIL_BOOKKEEPING = ".archil";
415
418
  /**
416
419
  * Release a live mount the kernel will not unmount (Sysbox): `archil checkin` gives the delegation back, the client must
417
- * then list none on the run, and only then is the mount moved aside and its daemon killed. Any failed step leaves the
418
- * mount where it is, as UNMOUNT_FAILED: a mount that may still hold the delegation is never abandoned.
420
+ * then list none on the run (its own bookkeeping under `.archil/` is not the run's), and only then is the mount moved
421
+ * aside and its daemon killed. Any failed step leaves the mount where it is, as UNMOUNT_FAILED: a mount that may still
422
+ * hold the delegation is never abandoned.
419
423
  */
420
424
  async function checkinAndMoveAside(host, mountpoint) {
421
425
  const c = await archil(host, ["checkin", mountpoint], host.timeoutMs.unmount);
@@ -429,7 +433,11 @@ async function checkinAndMoveAside(host, mountpoint) {
429
433
  catch {
430
434
  list = null;
431
435
  }
432
- const mine = (p) => p === mountpoint || (typeof p === "string" && p.startsWith(`${mountpoint}/`));
436
+ // The client also lists its own bookkeeping on the disk, `<mp>/.archil/client-<n>` and its `unlinked` (made when a
437
+ // file under the mount is deleted), which a checkin of the run leaves and none can clear once the mount is read-only.
438
+ // It is outside the run's tree, so it never holds the run: every other delegation under the mount does.
439
+ const bookkeeping = (p) => p === `${mountpoint}/${ARCHIL_BOOKKEEPING}` || p.startsWith(`${mountpoint}/${ARCHIL_BOOKKEEPING}/`);
440
+ const mine = (p) => p === mountpoint || (typeof p === "string" && p.startsWith(`${mountpoint}/`) && !bookkeeping(p));
433
441
  const held = !Array.isArray(list) || list.some((x) => x?.state === "Active" && mine(x.path));
434
442
  if (held)
435
443
  throw new ClaimError("UNMOUNT_FAILED", `${mountpoint} still lists a delegation after archil checkin: ${(ok(d) ? d.stdout : detail(d)).trim().slice(0, 300)}`);
@@ -480,20 +488,23 @@ async function ensureMountpoint(host, dir) {
480
488
  * goes to the wrapper as one line on stdin (`bin/archil-scoped`), with or without sudo.
481
489
  */
482
490
  async function mountRun(host, ref, mountpoint, token, force) {
483
- const path = runPath(ref.id);
484
- const args = [host.archil, "mount", ...(force ? ["--force"] : []), `${ref.disk}:/${path}`, mountpoint, "--region", ref.region];
491
+ await mountTarget(host, targetOf(ref), mountpoint, token, force);
492
+ }
493
+ async function mountTarget(host, target, mountpoint, token, force, held = "") {
494
+ const { path } = target;
495
+ const args = [host.archil, "mount", ...(force ? ["--force"] : []), `${target.disk}:/${path}`, mountpoint, "--region", target.region];
485
496
  const r = await sh(asRoot(host, args), host.timeoutMs.mount, `${token}\n`);
486
497
  const entry = await mountEntry(host, mountpoint);
487
- if (ok(r) && entry && isOurs(entry, ref))
498
+ if (ok(r) && entry && isOurs(entry, target))
488
499
  return;
489
500
  if (entry)
490
501
  await unmountWith(host, mountpoint).catch(() => { });
491
502
  if (ok(r))
492
- throw new ClaimError("MOUNT_FAILED", `archil mount ${path} exited 0 but ${mountpoint} is not an archil mount of the run`);
503
+ throw new ClaimError("MOUNT_FAILED", `archil mount ${path} exited 0 but ${mountpoint} is not an archil mount of ${path}`);
493
504
  if (STALE_MARKER.test(r.stderr))
494
505
  throw new ClaimError("MOUNT_FAILED", `an archil daemon of this host still runs for ${mountpoint}: ${detail(r, token)}`);
495
506
  if (!force && r.stderr.includes(HELD_MARKER))
496
- throw new HeldError("claim", `${path} is held by another client (${detail(r, token)})`);
507
+ throw new HeldError("claim", `${path} is held by another client${held} (${detail(r, token)})`);
497
508
  if (r.stderr.includes(NOT_EMPTY_MARKER) || (MISLEADING_REFUSAL.test(r.stderr) && (await entries(mountpoint)) > 0)) {
498
509
  throw new ClaimError("MOUNTPOINT_NOT_EMPTY", `archil mount ${path} refused ${mountpoint}, which is not empty: ${detail(r, token)}`);
499
510
  }
@@ -558,6 +569,58 @@ async function clearStaleDaemon(host, ref, mountpoint) {
558
569
  process.stderr.write(`${JSON.stringify({ at: new Date().toISOString(), event: "stale archil daemon killed", run: ref.id, pids })}\n`);
559
570
  return pids;
560
571
  }
572
+ /** The most entries of a bare mountpoint the stray check looks at; past it, nothing is moved aside. */
573
+ export const STRAY_CHECK_LIMIT = 100_000;
574
+ /**
575
+ * The entries under `dir` written since `since` (ms since the epoch), as paths relative to it, walked without following
576
+ * links. `unchecked` says why the walk could not look at every entry: more than `limit` of them, or one it could not
577
+ * read (an entry gone meanwhile is not one). A directory keeps its time when a file in it is rewritten in place, so only
578
+ * a walk that looked at every entry shows there is none.
579
+ */
580
+ export async function writtenSince(dir, since, limit = STRAY_CHECK_LIMIT) {
581
+ const fresh = [];
582
+ const dirs = [""];
583
+ let seen = 0;
584
+ const failed = (error) => {
585
+ const code = error.code;
586
+ return code === "ENOENT" ? null : `an entry it cannot read (${code ?? "an unknown error"})`;
587
+ };
588
+ for (let i = 0; i < dirs.length; i++) {
589
+ const rel = dirs[i];
590
+ let names;
591
+ try {
592
+ names = await readdir(join(dir, rel));
593
+ }
594
+ catch (error) {
595
+ const unchecked = failed(error);
596
+ if (unchecked)
597
+ return { fresh, unchecked };
598
+ continue;
599
+ }
600
+ for (const name of names.sort()) {
601
+ if (++seen > limit)
602
+ return { fresh, unchecked: `more than ${limit} entries` };
603
+ const path = rel ? `${rel}/${name}` : name;
604
+ let st;
605
+ try {
606
+ st = await lstat(join(dir, path));
607
+ }
608
+ catch (error) {
609
+ const unchecked = failed(error);
610
+ if (unchecked)
611
+ return { fresh, unchecked };
612
+ continue;
613
+ }
614
+ if (st.mtimeMs >= since)
615
+ fresh.push(path);
616
+ if (st.isDirectory())
617
+ dirs.push(path);
618
+ }
619
+ }
620
+ return { fresh, unchecked: null };
621
+ }
622
+ /** The exit of a start refused for the app's own writes before its claim (EX_DATAERR): a restart would write them again. */
623
+ const EXIT_WRITTEN_BEFORE_MOUNT = 65;
561
624
  /** How many entries a directory holds; 0 when it cannot be read. */
562
625
  const entries = (dir) => readdir(dir).then((names) => names.length, () => 0);
563
626
  /**
@@ -566,11 +629,28 @@ const entries = (dir) => readdir(dir).then((names) => names.length, () => 0);
566
629
  * directory with its owner and mode: archil refuses to mount over entries, and nothing in them may enter the run. One
567
630
  * line on stderr names the run and the count of entries, never a name or a byte of them. An unreadable mountpoint goes
568
631
  * to the wrapper too, which looks as root.
632
+ *
633
+ * Entries written since this process started are not an earlier incarnation's leftovers but this one's own writes
634
+ * before its claim mounted (an app's factory writing under ctx.root, which runs before the mount): moving them aside
635
+ * would lose them silently and the run would later find them gone (ENOENT). The start is refused instead,
636
+ * MOUNTPOINT_WRITTEN (exit 65, terminal: a restart writes them again), naming the first of them. A mountpoint whose
637
+ * entries the check cannot all look at (more than STRAY_CHECK_LIMIT, or one this process cannot read) may hide such a
638
+ * write, so it is refused too, MOUNTPOINT_UNCHECKED (exit 65: someone looks at it), naming none of them.
569
639
  */
570
640
  async function moveStrayAside(host, ref, mountRoot, mountpoint) {
571
641
  const names = await readdir(mountpoint).catch(() => null);
572
642
  if (names && names.length === 0)
573
643
  return null;
644
+ if (names) {
645
+ const { fresh, unchecked } = await writtenSince(mountpoint, performance.timeOrigin);
646
+ if (fresh.length > 0) {
647
+ const named = fresh.slice(0, 4).map((p) => `<mp>/${p}`).join(", ");
648
+ throw new ClaimError("MOUNTPOINT_WRITTEN", `the bare mountpoint <mp> of ${runPath(ref.id)} holds ${fresh.length} entr${fresh.length === 1 ? "y" : "ies"} written since this incarnation started, before its claim mounted: ${named}`.slice(0, 240), { exitCode: EXIT_WRITTEN_BEFORE_MOUNT });
649
+ }
650
+ if (unchecked) {
651
+ throw new ClaimError("MOUNTPOINT_UNCHECKED", `the bare mountpoint <mp> of ${runPath(ref.id)} holds ${unchecked}, so this incarnation cannot rule out its own writes before its claim mounted: nothing is moved aside. Look at it, then move it aside by hand`, { exitCode: EXIT_WRITTEN_BEFORE_MOUNT });
652
+ }
653
+ }
574
654
  const tag = Date.now().toString(36);
575
655
  const r = await archil(host, ["stray", mountpoint, tag]);
576
656
  if (!ok(r))
@@ -596,7 +676,10 @@ async function moveStrayAside(host, ref, mountRoot, mountpoint) {
596
676
  * the run but not that this mount does.
597
677
  */
598
678
  async function verify(host, ref, mountpoint) {
599
- const path = runPath(ref.id);
679
+ await verifyTarget(host, runPath(ref.id), mountpoint, `${JSON.stringify({ pid: process.pid, at: new Date().toISOString() })}\n`);
680
+ }
681
+ /** `verify` for any path; `probe` is what the claim probe holds. */
682
+ async function verifyTarget(host, path, mountpoint, probe) {
600
683
  const r = await archil(host, ["delegations", "--json", mountpoint]);
601
684
  if (!ok(r))
602
685
  throw new ClaimError("CLAIM_NOT_VERIFIED", `archil delegations ${mountpoint}: ${detail(r)}`);
@@ -612,7 +695,7 @@ async function verify(host, ref, mountpoint) {
612
695
  if (!active)
613
696
  throw new ClaimError("CLAIM_NOT_VERIFIED", `${mountpoint} holds no Active delegation on ${path}: ${r.stdout.trim().slice(0, 300)}`);
614
697
  try {
615
- await host.persist(join(mountpoint, CLAIM_PROBE), `${JSON.stringify({ pid: process.pid, at: new Date().toISOString() })}\n`);
698
+ await host.persist(join(mountpoint, CLAIM_PROBE), probe);
616
699
  }
617
700
  catch (err) {
618
701
  throw ownWriteFenced(join(mountpoint, CLAIM_PROBE), err);
@@ -626,6 +709,11 @@ async function verify(host, ref, mountpoint) {
626
709
  * verifying write is refused, MOUNTPOINT_NOT_EMPTY when the mountpoint cannot be emptied or archil still finds it not
627
710
  * empty, MOUNT_FAILED (exit 1) for a daemon of this host that is still running there.
628
711
  */
712
+ /**
713
+ * The token an instance holds when it was given none (`--token-stdin` read nothing): enough to reuse its live mount, never
714
+ * to mount. A restart in place gets it from a driver that hands the token to the first incarnation only (localHost).
715
+ */
716
+ export const NO_TOKEN = "none";
629
717
  export async function acquire(opts) {
630
718
  const { ref, token } = opts;
631
719
  const path = runPath(ref.id);
@@ -641,7 +729,7 @@ export async function acquire(opts) {
641
729
  let reused = false;
642
730
  const existing = await mountEntry(host, root);
643
731
  if (existing) {
644
- if (!isOurs(existing, ref))
732
+ if (!isOurs(existing, targetOf(ref)))
645
733
  throw new ClaimError("MOUNTPOINT_BUSY", `${root} already holds ${existing.fstype} ${existing.source}`);
646
734
  if (await isDead(host, root))
647
735
  await cleanDead(host, root);
@@ -649,6 +737,11 @@ export async function acquire(opts) {
649
737
  reused = true;
650
738
  }
651
739
  let stray = null;
740
+ // No live mount to reuse and no token to mount with: a restart whose mount went with the failed incarnation. It cannot
741
+ // hold the run again, so it is fenced (75, terminal) at once, and the next start of the run brings a new token.
742
+ if (!reused && token === NO_TOKEN) {
743
+ throw new FencedError(`no live mount of ${path} to reuse at ${root}, and this incarnation was given no mount token: it cannot mount the run again; a new start brings a new token`, { code: "CLAIM_GONE_NO_TOKEN" });
744
+ }
652
745
  if (!reused) {
653
746
  await ensureMountpoint(host, root);
654
747
  await clearStaleDaemon(host, ref, root);
@@ -730,3 +823,40 @@ class ArchilClaim {
730
823
  return { via };
731
824
  }
732
825
  }
826
+ /**
827
+ * Mount `<disk>:/<path>` exclusively at `mountpoint`, a fresh directory of the caller's own, and verify it, with `probe` as
828
+ * the claim probe's content. Another client's delegation is HeldError CLAIM_HELD (`held` is added to its message); the
829
+ * rest fails as `acquire` does. Unlike `acquire`, a mount already there is never reused, and nothing is forced.
830
+ */
831
+ export async function exclusiveMount(target, mountpoint, token, options) {
832
+ if (!token || /[\r\n]/.test(token))
833
+ throw new ClaimError("INVALID_ARGUMENT", "no mount token, or one with a line break");
834
+ const host = resolveHost(options.host);
835
+ if (await mountEntry(host, mountpoint))
836
+ throw new ClaimError("MOUNTPOINT_BUSY", `${mountpoint} is already a mount`);
837
+ await ensureMountpoint(host, mountpoint);
838
+ try {
839
+ await mountTarget(host, target, mountpoint, token, false, options.held);
840
+ }
841
+ catch (err) {
842
+ await rmdir(mountpoint).catch(() => { });
843
+ throw err;
844
+ }
845
+ try {
846
+ await verifyTarget(host, target.path, mountpoint, options.probe);
847
+ }
848
+ catch (err) {
849
+ await unmountWith(host, mountpoint).catch(() => { });
850
+ throw err;
851
+ }
852
+ return {
853
+ root: mountpoint,
854
+ async barrier() {
855
+ const r = await archil(host, ["sync", mountpoint], host.timeoutMs.sync);
856
+ if (ok(r))
857
+ return { ms: r.ms };
858
+ throw new FencedError(`archil sync ${mountpoint}: ${detail(r)}`);
859
+ },
860
+ unmount: () => unmountWith(host, mountpoint),
861
+ };
862
+ }
package/dist/cli.js CHANGED
@@ -12,7 +12,7 @@ import { closeSync, openSync, readFileSync, realpathSync, statSync } from "node:
12
12
  import { basename, join, resolve } from "node:path";
13
13
  import { pathToFileURL } from "node:url";
14
14
  import { parseArgs } from "node:util";
15
- import { ARCHIL_SCOPED, DEFAULT_MOUNT_ROOT, runPath, TOKEN_PREFIX, unmountClaim } from "./claim.js";
15
+ import { ARCHIL_SCOPED, DEFAULT_MOUNT_ROOT, NO_TOKEN, runPath, TOKEN_PREFIX, unmountClaim } from "./claim.js";
16
16
  import { archilEnv } from "./env.js";
17
17
  import { exitCodeFor, FencedError, PdaError } from "./errors.js";
18
18
  import { fork } from "./fork.js";
@@ -165,7 +165,7 @@ export async function cliControl(values, client) {
165
165
  */
166
166
  async function readToken(values) {
167
167
  if (!values["token-stdin"])
168
- return "none";
168
+ return NO_TOKEN;
169
169
  let text = "";
170
170
  for await (const chunk of process.stdin)
171
171
  text += chunk;
@@ -176,12 +176,13 @@ async function readToken(values) {
176
176
  catch {
177
177
  // already closed
178
178
  }
179
- return text.trim() || "none";
179
+ return text.trim() || NO_TOKEN;
180
180
  }
181
181
  /**
182
182
  * The instance a host driver starts: openDurableRun with the app module's Harness options (src/app.ts), the
183
183
  * driver's handle as run.json's holder, the mount token from stdin (read once, then stdin is closed), and the lease
184
- * periods. The app loads before anything mounts; a missing or throwing module, or a failed `onOpen`, is AppError (exit 1,
184
+ * periods. The app's factory runs once the claim is held (`afterClaim`: the run's directory is mounted, so `ctx.root` is
185
+ * the live directory) and the Harness opens with what it returns; a missing or throwing module, or a failed `onOpen`, is AppError (exit 1,
185
186
  * so the unit retries within systemd's start limit; a restart in place reuses the claim). A fence of any kind kills the
186
187
  * run's commands and exits 75 inside openDurableRun.
187
188
  *
@@ -220,15 +221,10 @@ async function runInstance(values) {
220
221
  const mountRoot = str(values["mount-root"], DEFAULT_MOUNT_ROOT);
221
222
  const root = dir ?? join(mountRoot, runPath(ref.id));
222
223
  const log = (event, extra = {}) => process.stderr.write(`${JSON.stringify({ at: new Date().toISOString(), event, run: ref.id, ...extra })}\n`);
224
+ // The app's factory runs once the claim is held (`afterClaim`), so `ctx.root` is the live directory whenever app code
225
+ // runs; what it returns opens the Harness. Serve starts before the open, with the root options the factory fills in.
223
226
  let app;
224
- try {
225
- app = await loadApp(str(values.app), { ref, root, work: join(root, "work"), store: join(root, "store") });
226
- }
227
- catch (err) {
228
- log("app failed", { code: err.code, message: err.message });
229
- return exitCodeFor(err);
230
- }
231
- const { onOpen, beforeResume, root: rootOptions, wake: appWake, ...harness } = app;
227
+ const rootOptions = {};
232
228
  const holder = JSON.parse(process.env.PDA_HOLDER ?? "{}");
233
229
  // On a local directory nothing else knows this instance: its start ticks go into the holder, so `status --dir` tells it
234
230
  // from a later process that reuses its pid (a driver's handle already carries them).
@@ -252,7 +248,12 @@ async function runInstance(values) {
252
248
  run = await openDurableRun(ref, {
253
249
  // A local directory is its own claim (no mount, no token); a disk's run is mounted with a token.
254
250
  ...(dir === undefined ? { mountToken: await readToken(values), mountRoot, host: { archil: str(values.archil) } } : localRun(dir)),
255
- harness,
251
+ afterClaim: async (claim) => {
252
+ app = await loadApp(str(values.app), { ref, root: claim.root, work: claim.work, store: claim.store });
253
+ const { onOpen: _onOpen, beforeResume, root: appRoot, wake: _wake, ...harness } = app;
254
+ Object.assign(rootOptions, appRoot ?? {});
255
+ return { harness, ...(beforeResume ? { beforeResume } : {}) };
256
+ },
256
257
  ...(runAs
257
258
  ? { env: (c) => archilEnv(c, { runAs: { uid: runAs.uid, gid: runAs.gid }, shellEnv: { HOME: runAs.home, USER: runAs.name, LOGNAME: runAs.name } }) }
258
259
  : {}),
@@ -260,16 +261,16 @@ async function runInstance(values) {
260
261
  ownCgroup: cgroup.owns,
261
262
  lease: Object.fromEntries(Object.entries(lease).filter(([, v]) => v !== undefined)),
262
263
  onStep: (step, t) => void (steps[step] = Math.round(t)),
263
- ...(beforeResume ? { beforeResume } : {}),
264
264
  });
265
265
  }
266
266
  catch (err) {
267
- log("open failed", { code: err.code, exitCode: exitCodeFor(err), message: err.message, steps });
267
+ log(err instanceof AppError ? "app failed" : "open failed", { code: err.code, exitCode: exitCodeFor(err), message: err.message, steps });
268
268
  await server?.close();
269
269
  return exitCodeFor(err);
270
270
  }
271
271
  server?.attach(run);
272
272
  log("running", { generation: run.generation, steps, ...(server ? { serve: server.url } : {}) });
273
+ const { onOpen, wake: appWake } = app;
273
274
  return serveUntilDone(run, onOpen, onSigterm, log, process, {
274
275
  drainMs,
275
276
  ...(server ? { server } : {}),
package/dist/errors.d.ts CHANGED
@@ -30,11 +30,12 @@ export declare class HeldError extends PdaError {
30
30
  code?: string;
31
31
  });
32
32
  }
33
- export type ClaimErrorCode = "INVALID_ARGUMENT" | "MOUNTPOINT_BUSY" | "MOUNTPOINT_NOT_EMPTY" | "MOUNT_FAILED" | "CLAIM_NOT_VERIFIED" | "ARCHIL_CLI_FAILED" | "UNMOUNT_FAILED" | "DEAD_MOUNT_CLEANUP_FAILED" | "CLAIM_RELEASED" | "CONTROL_API_FAILED";
33
+ export type ClaimErrorCode = "INVALID_ARGUMENT" | "MOUNTPOINT_BUSY" | "MOUNTPOINT_NOT_EMPTY" | "MOUNTPOINT_WRITTEN" | "MOUNTPOINT_UNCHECKED" | "MOUNT_FAILED" | "CLAIM_NOT_VERIFIED" | "ARCHIL_CLI_FAILED" | "UNMOUNT_FAILED" | "DEAD_MOUNT_CLEANUP_FAILED" | "CLAIM_RELEASED" | "CONTROL_API_FAILED" | "NOT_RELEASED";
34
34
  /** Any other claim failure (bad input, a refused or unverifiable mount, a CLI or control API failure). Exit 1. */
35
35
  export declare class ClaimError extends PdaError {
36
36
  constructor(code: ClaimErrorCode, message: string, options?: {
37
37
  cause?: unknown;
38
+ exitCode?: number;
38
39
  });
39
40
  }
40
41
  export declare function exitCodeFor(err: unknown): number;
@@ -0,0 +1,3 @@
1
+ import type { StartAttempt } from "../supervise.js";
2
+ /** The start's extra environment, checked: PATH, PDA_HOLDER, a name that is not a variable name, or a value with a NUL, is `refuse`'s error. */
3
+ export declare function attemptEnv(attempt: StartAttempt | undefined, refuse: (message: string) => Error): Record<string, string>;
@@ -0,0 +1,15 @@
1
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
2
+ const DRIVER_KEYS = new Set(["PATH", "PDA_HOLDER"]);
3
+ /** The start's extra environment, checked: PATH, PDA_HOLDER, a name that is not a variable name, or a value with a NUL, is `refuse`'s error. */
4
+ export function attemptEnv(attempt, refuse) {
5
+ const env = attempt?.env ?? {};
6
+ for (const [name, value] of Object.entries(env)) {
7
+ if (!ENV_NAME.test(name))
8
+ throw refuse(`${JSON.stringify(name)} is not an environment variable name`);
9
+ if (DRIVER_KEYS.has(name))
10
+ throw refuse(`${name} is the driver's: a start cannot set it`);
11
+ if (typeof value !== "string" || value.includes("\0"))
12
+ throw refuse(`the value of ${name} is not a string without NUL`);
13
+ }
14
+ return env;
15
+ }
@@ -9,8 +9,10 @@
9
9
  // status <name> print running, stopped or failed from the state file and whether the processes still live
10
10
  // stop <name> SIGTERM the launcher (it drains the instance), SIGKILL both after the timeout, print the state
11
11
  // Files live in a root-only directory (default /run/pda, 0700), which the run user can never traverse: `<name>.json`
12
- // (the spec), `<name>.token` (gone once read), `<name>.state`, `<name>.log` (the instance's output). Only the first
13
- // incarnation gets the token; a restart in place reads an empty stdin and reuses its live mount, as under localHost.
12
+ // (the spec), `<name>.token` (gone once read), `<name>.state`, `<name>.log` (the instance's output). Every incarnation of
13
+ // the attempt gets its token on stdin: a restart whose mount is still live reuses it and never needs the token, and one
14
+ // whose mount went with the failed incarnation mounts again (a takeover in between makes that mount HeldError, 76,
15
+ // terminal). The token lives in this launcher's memory for the attempt.
14
16
  // No cgroup per incarnation: commands a crashed instance detached can outlive it until the box is deleted.
15
17
  import { spawn } from "node:child_process";
16
18
  import { closeSync, existsSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
@@ -128,9 +130,9 @@ export async function serve(dir, name) {
128
130
  try {
129
131
  for (;;) {
130
132
  const env = { PATH: SAFE_PATH, LANG: "C.UTF-8", HOME: ids.home, ...spec.env };
131
- const first = token !== null;
133
+ const piped = Boolean(token);
132
134
  try {
133
- child = spawn(spec.argv[0], spec.argv.slice(1), { uid: ids.uid, gid: ids.gid, env, cwd: "/", stdio: [first ? "pipe" : "ignore", log, log], detached: true });
135
+ child = spawn(spec.argv[0], spec.argv.slice(1), { uid: ids.uid, gid: ids.gid, env, cwd: "/", stdio: [piped ? "pipe" : "ignore", log, log], detached: true });
134
136
  await new Promise((resolve, reject) => {
135
137
  child.once("spawn", resolve);
136
138
  child.once("error", reject);
@@ -140,10 +142,9 @@ export async function serve(dir, name) {
140
142
  save({ phase: "exited", instance: null, instanceTicks: null, exit: 1, reason: `spawn failed: ${err.message}` });
141
143
  return state;
142
144
  }
143
- if (first) {
145
+ if (piped) {
144
146
  child.stdin.on("error", () => { });
145
- child.stdin.end(token ? `${token}\n` : "");
146
- token = null;
147
+ child.stdin.end(`${token}\n`);
147
148
  }
148
149
  save({ phase: stopping ? "stopping" : "running", instance: child.pid, instanceTicks: procStartTicks(child.pid), spawned: state.spawned + 1, exit: null, signal: null });
149
150
  if (stopping)
@@ -7,12 +7,37 @@ export type SandboxInfo = {
7
7
  id: string;
8
8
  name: string;
9
9
  state?: string;
10
+ /** The box's size as Daytona allocates it: vCPUs, GPUs, memory and disk (the API's GB). */
11
+ cpu?: number;
12
+ gpu?: number;
13
+ memory?: number;
14
+ disk?: number;
15
+ createdAt?: string;
10
16
  snapshot?: string;
11
17
  labels?: Record<string, string>;
12
18
  target?: string;
13
19
  toolboxProxyUrl?: string;
14
20
  errorReason?: string;
15
21
  };
22
+ /**
23
+ * Daytona's published list prices, as its pricing page gave them on `readOn`: billed per second, quoted per hour, for
24
+ * each vCPU, each GiB of memory and each GiB of disk. The page's footnote "Price per GiB after first 5 free" is applied
25
+ * to storage (disk) only: its placement is ambiguous and could be read as covering memory too, so this is a choice.
26
+ * The page lists GPU rates per GPU model; this driver does not map a box's GPU to a model, so a GPU box is unpriced.
27
+ */
28
+ export declare const DAYTONA_LIST_PRICES: {
29
+ readonly page: "https://www.daytona.io/pricing";
30
+ readonly readOn: "2026-10-16";
31
+ readonly vcpuPerHour: 0.0504;
32
+ readonly memoryGiBPerHour: 0.0162;
33
+ readonly diskGiBPerHour: 0.000108;
34
+ readonly freeDiskGiB: 5;
35
+ };
36
+ /**
37
+ * A box's list price per second at DAYTONA_LIST_PRICES, from its size; null when its size is unknown or it has a GPU
38
+ * (priced per GPU model on the page, which this driver does not map).
39
+ */
40
+ export declare function daytonaUsdPerSecond(size: Pick<SandboxInfo, "cpu" | "gpu" | "memory" | "disk">): number | null;
16
41
  export type CreateSandboxBody = {
17
42
  name: string;
18
43
  snapshot: string;
@@ -60,7 +85,7 @@ export interface DaytonaRestOptions {
60
85
  }
61
86
  /** The REST API with an API key (Bearer). Error messages carry the method, path and status, never a header or a body sent. */
62
87
  export declare function daytonaRest(options: DaytonaRestOptions): DaytonaClient;
63
- export type DaytonaHostErrorCode = "INVALID_ARGUMENT" | "START_FAILED" | "STOP_FAILED" | "DAYTONA_API_FAILED";
88
+ export type DaytonaHostErrorCode = "INVALID_ARGUMENT" | "START_FAILED" | "STOP_FAILED" | "KILL_FAILED" | "DAYTONA_API_FAILED";
64
89
  export declare class DaytonaHostError extends PdaError {
65
90
  constructor(code: DaytonaHostErrorCode, message: string, options?: {
66
91
  cause?: unknown;
@@ -133,7 +158,7 @@ export interface DaytonaHostOptions {
133
158
  export declare function sandboxStatus(state: string | undefined): HostStatus | "check";
134
159
  /** A Daytona-safe box name for a run: lowercase, at most 63 characters, unique per call. */
135
160
  export declare function sandboxName(prefix: string, runId: string, now: number): string;
136
- export declare function daytonaHost(opts: DaytonaHostOptions): HostDriver & {
161
+ export declare function daytonaHost(opts: DaytonaHostOptions): HostDriver & Required<Pick<HostDriver, "kill" | "kills">> & {
137
162
  readonly fleet: string;
138
163
  readonly mountRoot: string;
139
164
  };