@parcha/pi-durable-disk 0.0.0-bootstrap.1 → 0.1.0-beta.12

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/run.js ADDED
@@ -0,0 +1,753 @@
1
+ // The run lifecycle in two layers. `openRunLease` is the claim's lifecycle without a store or a Harness: the
2
+ // claim, the owner lock, `run.json` with its heartbeat, the self-fence (a timer and a watchdog thread), the seal, the
3
+ // fence and the release; an app that owns its own Harness and store connections uses it directly. `openDurableRun`
4
+ // builds the whole run on it: the store, the seal check, pi's Harness, resume. A fence reported by the store, the claim,
5
+ // a write of the run's own files or the lapsed lease kills the run's commands and ends the instance (exit 75). Nothing
6
+ // is retried. The lease writes its own files through the claim directory (`ClaimDir`), never by path.
7
+ import { mkdir } from "node:fs/promises";
8
+ import { isAbsolute, join, relative } from "node:path";
9
+ import { DatabaseSync } from "node:sqlite";
10
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
11
+ import { Harness } from "@earendil-works/pi-durable";
12
+ import { acquire } from "./claim.js";
13
+ import { archilEnv } from "./env.js";
14
+ import { EXIT_FENCED, FencedError, HeldError, ownWriteFenced, PdaError } from "./errors.js";
15
+ import { openArchilStore } from "./store.js";
16
+ import { LeaseWatchdog } from "./watchdog.js";
17
+ import { currentHolder, DEFAULT_HEARTBEAT_MS, DEFAULT_LEASE_EXPIRY_MS, DEFAULT_LEASE_MARGIN_MS, openClaimDir, persistRecord, readRunRecord, RUN_JSON, RunRecordError, writeRunRecord, } from "./status.js";
18
+ /** The same-host owner lock under the run's root. */
19
+ export const OWNER_LOCK = "owner.lock";
20
+ /** pi's store under the claim's `store/` directory. */
21
+ export const STORE_FILE = "run.sqlite";
22
+ /** EX_DATAERR: the run's data refuses to open (a store behind its seal, an unreadable `run.json`); a restart cannot help. */
23
+ export const EXIT_DATAERR = 65;
24
+ /** EX_SOFTWARE: this package cannot read the head of pi's store (pi's schema moved); a restart cannot help. */
25
+ export const EXIT_SOFTWARE = 70;
26
+ /**
27
+ * Bad options, a call on a released run, or a store whose head cannot be read (STORE_HEAD_UNREADABLE: the run is marked
28
+ * `failed` and released, exit 70).
29
+ */
30
+ export class RunError extends PdaError {
31
+ constructor(code, message, options = {}) {
32
+ super(code, message, { cause: options.cause, exitCode: code === "STORE_HEAD_UNREADABLE" ? EXIT_SOFTWARE : 1 });
33
+ }
34
+ }
35
+ /**
36
+ * The store's last committed sequence is below the `sealedSeq` the last clean release wrote: an acknowledged commit is
37
+ * missing. The run is refused before pi commits anything, and marked `failed` in `run.json`. Exit 65.
38
+ */
39
+ export class StoreBehindSealError extends PdaError {
40
+ sealedSeq;
41
+ /** The store's last committed sequence. */
42
+ head;
43
+ constructor(file, sealedSeq, head) {
44
+ super("STORE_BEHIND_SEAL", `store ${file} is at sequence ${head}, behind its seal at ${sealedSeq}`, { exitCode: EXIT_DATAERR });
45
+ this.sealedSeq = sealedSeq;
46
+ this.head = head;
47
+ }
48
+ }
49
+ /**
50
+ * The self-fence rule: the last heartbeat that succeeded started longer ago than the lease expiry minus its margin. `by`
51
+ * names who saw it: the main thread's timer, or the watchdog thread (which had already killed the commands).
52
+ */
53
+ export class LeaseLapsedError extends FencedError {
54
+ sinceMs;
55
+ by;
56
+ constructor(sinceMs, limitMs, by = "timer") {
57
+ super(`the last successful heartbeat started ${Math.round(sinceMs)} ms ago, past the ${limitMs} ms self-fence (seen by the ${by})`, { code: "LEASE_LAPSED" });
58
+ this.sinceMs = sinceMs;
59
+ this.by = by;
60
+ }
61
+ }
62
+ /** Locks this process holds. A collected DatabaseSync closes its file, which would drop the lock without a word. */
63
+ const heldLocks = new Set();
64
+ const SQLITE_BUSY = 5;
65
+ const SQLITE_LOCKED = 6;
66
+ const isBusy = (error) => {
67
+ const errcode = error.errcode;
68
+ return typeof errcode === "number" && [SQLITE_BUSY, SQLITE_LOCKED].includes(errcode & 0xff);
69
+ };
70
+ /**
71
+ * Take the run's same-host owner lock for the life of this process. Node has no flock(2), so the lock is
72
+ * SQLite's exclusive POSIX lock on `<root>/owner.lock` (`locking_mode = EXCLUSIVE`, then `BEGIN EXCLUSIVE`), which the
73
+ * kernel drops when the process dies, however it dies. The journal stays in memory, so `owner.lock` is the only file
74
+ * the lock makes. Another process holding it is HeldError("owner-lock") (76); any other error is a fence, since the
75
+ * file is the claim's.
76
+ *
77
+ * Only this function may open `owner.lock` in the process: a POSIX lock is dropped when any descriptor the process holds
78
+ * on the file is closed, so a stray open and close elsewhere would silently release it.
79
+ */
80
+ export function takeOwnerLock(root) {
81
+ const file = join(root, OWNER_LOCK);
82
+ let db;
83
+ try {
84
+ db = new DatabaseSync(file, { timeout: 0 });
85
+ }
86
+ catch (error) {
87
+ throw ownWriteFenced(file, error);
88
+ }
89
+ try {
90
+ db.exec("PRAGMA locking_mode = EXCLUSIVE");
91
+ db.exec("PRAGMA journal_mode = MEMORY");
92
+ db.exec("BEGIN EXCLUSIVE");
93
+ db.exec("COMMIT");
94
+ }
95
+ catch (error) {
96
+ try {
97
+ db.close();
98
+ }
99
+ catch {
100
+ // The lock was never ours; the refusal is the error to report.
101
+ }
102
+ if (isBusy(error))
103
+ throw new HeldError("owner-lock", `${file} is held by another process on this host`, { cause: error });
104
+ throw ownWriteFenced(file, error);
105
+ }
106
+ heldLocks.add(db);
107
+ return {
108
+ file,
109
+ release() {
110
+ if (!heldLocks.delete(db))
111
+ return;
112
+ try {
113
+ db.close();
114
+ }
115
+ catch (error) {
116
+ throw ownWriteFenced(file, error);
117
+ }
118
+ },
119
+ };
120
+ }
121
+ /**
122
+ * The last sequence the store committed: the one read of pi's internal schema in this package. pi-durable 1.0.4's
123
+ * public `Storage` has no head query, and the seal check must run before `Harness.open` can commit (its recovery commit
124
+ * would reuse a lost sequence number). pi's SqliteStorage gives each commit `durable_metadata.next_seq` and stores the
125
+ * successor in the same transaction, so the head is `next_seq - 1`. The query only reads. A store without that table,
126
+ * row or column fails closed (STORE_HEAD_UNREADABLE), so a pi whose schema moved never skips the check silently;
127
+ * a unit test pins the read to the sequence pi's commits return.
128
+ *
129
+ * `database` is the store's fence-aware facade (`FencedDatabase`), which turns an I/O-class error into a fence; through
130
+ * a raw connection such an error would read as STORE_HEAD_UNREADABLE.
131
+ */
132
+ export async function storeHead(store) {
133
+ let row;
134
+ try {
135
+ row = await store.database.get("SELECT next_seq FROM durable_metadata WHERE singleton = 1");
136
+ }
137
+ catch (error) {
138
+ if (error instanceof FencedError)
139
+ throw error;
140
+ throw new RunError("STORE_HEAD_UNREADABLE", `cannot read the head of ${store.file}`, { cause: error });
141
+ }
142
+ const next = typeof row?.next_seq === "bigint" ? Number(row.next_seq) : row?.next_seq;
143
+ if (typeof next !== "number" || !Number.isSafeInteger(next) || next < 1) {
144
+ throw new RunError("STORE_HEAD_UNREADABLE", `${store.file} has no usable durable_metadata.next_seq (${JSON.stringify(row ?? null)})`);
145
+ }
146
+ return next - 1;
147
+ }
148
+ /**
149
+ * `storage` with `before` run ahead of every commit (it may throw to refuse it), every resolved commit's sequence
150
+ * reported to `after` and every rejected commit's error to `failed`; everything else delegates unchanged.
151
+ */
152
+ function observeCommits(storage, hooks) {
153
+ return new Proxy(storage, {
154
+ get(target, key) {
155
+ if (key === "commit") {
156
+ return async (writes, context) => {
157
+ hooks.before();
158
+ let seq;
159
+ try {
160
+ seq = await target.commit(writes, context);
161
+ }
162
+ catch (error) {
163
+ hooks.failed(error);
164
+ throw error;
165
+ }
166
+ hooks.after(seq);
167
+ return seq;
168
+ };
169
+ }
170
+ const value = Reflect.get(target, key, target);
171
+ return typeof value === "function" ? value.bind(target) : value;
172
+ },
173
+ });
174
+ }
175
+ // ---- the lease --------------------------------------------------------------------------------------------------------
176
+ const asFenced = (error, what) => error instanceof FencedError ? error : new FencedError(`${what} failed, so the claim cannot be trusted`, { cause: error });
177
+ function exitFenced(error) {
178
+ process.stderr.write(`pi-durable-disk: fenced (${error.code}): ${error.message}\n`);
179
+ process.exit(EXIT_FENCED);
180
+ }
181
+ function stepper(onStep) {
182
+ return async (name, run) => {
183
+ const t0 = performance.now();
184
+ const value = await run();
185
+ onStep?.(name, performance.now() - t0);
186
+ return value;
187
+ };
188
+ }
189
+ function leaseOf(options = {}) {
190
+ const lease = {
191
+ heartbeatMs: options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS,
192
+ expiryMs: options.expiryMs ?? DEFAULT_LEASE_EXPIRY_MS,
193
+ marginMs: options.marginMs ?? DEFAULT_LEASE_MARGIN_MS,
194
+ checkMs: options.checkMs ?? 1000,
195
+ };
196
+ const limit = lease.expiryMs - lease.marginMs;
197
+ if (!(lease.heartbeatMs > 0 && lease.checkMs > 0 && lease.marginMs >= 0 && limit > lease.heartbeatMs)) {
198
+ throw new RunError("INVALID_ARGUMENT", `lease ${JSON.stringify(lease)}: the self-fence (expiry minus margin) must exceed the heartbeat period`);
199
+ }
200
+ return lease;
201
+ }
202
+ const noCleanup = async () => undefined;
203
+ class Lease {
204
+ ref;
205
+ claim;
206
+ /** The claim itself, for a layer that builds on the lease (`openDurableRun` builds the env on it). */
207
+ rawClaim;
208
+ #options;
209
+ #context;
210
+ #lease;
211
+ #clock;
212
+ #persist;
213
+ #cleanup;
214
+ #onFenced;
215
+ #openDir;
216
+ /** The claim's root held open: every write of the lease's own files goes through it. */
217
+ #dir;
218
+ #dirClosed;
219
+ #lock;
220
+ #previous;
221
+ #record;
222
+ #lastSeq = 0;
223
+ #fence;
224
+ /** Aborted by the fence, synchronously: a `run.json` write still in flight then refuses to rename. */
225
+ #fencing = new AbortController();
226
+ #releasing;
227
+ #released = false;
228
+ #writes = Promise.resolve();
229
+ #beating = false;
230
+ #lastBeatStart = 0;
231
+ #lastBeatNs = 0n;
232
+ #heartbeat;
233
+ #leaseCheck;
234
+ #watchdog;
235
+ constructor(ref, claim, options, context) {
236
+ this.ref = ref;
237
+ this.rawClaim = claim;
238
+ this.#options = options;
239
+ this.#context = context;
240
+ this.#lease = leaseOf(options.lease);
241
+ this.#clock = options.clock ?? (() => performance.now());
242
+ this.#persist = options.persist ?? persistRecord;
243
+ this.#cleanup = options.cleanup ?? noCleanup;
244
+ this.#onFenced = options.onFenced ?? exitFenced;
245
+ this.#openDir = options.claimDir ?? ((root) => openClaimDir(root));
246
+ const lease = this;
247
+ this.claim = {
248
+ ref: claim.ref,
249
+ disk: claim.disk,
250
+ root: claim.root,
251
+ work: claim.work,
252
+ store: claim.store,
253
+ get fenced() {
254
+ return lease.fenced;
255
+ },
256
+ barrier: () => lease.barrier(),
257
+ };
258
+ }
259
+ get record() {
260
+ return this.#record;
261
+ }
262
+ get generation() {
263
+ return this.#record.generation;
264
+ }
265
+ get sealedSeq() {
266
+ return this.#previous?.sealedSeq ?? null;
267
+ }
268
+ get fenced() {
269
+ return this.#fence !== undefined;
270
+ }
271
+ /** The first fence, once fenced. */
272
+ get fencedBy() {
273
+ return this.#fence?.error;
274
+ }
275
+ /** True once `release()` began. */
276
+ get releasing() {
277
+ return this.#releasing !== undefined || this.#released;
278
+ }
279
+ fence(error) {
280
+ if (this.#fence)
281
+ return this.#fence.error;
282
+ const fence = { error, done: Promise.resolve() };
283
+ this.#fence = fence;
284
+ this.#fencing.abort(error);
285
+ this.#stopTimers();
286
+ this.rawClaim.markFenced(error);
287
+ fence.done = this.#cleanup(this.#context)
288
+ .catch(() => undefined)
289
+ .then(() => this.#onFenced(error))
290
+ .catch(() => exitFenced(error));
291
+ void fence.done.then(() => this.#closeDir());
292
+ return error;
293
+ }
294
+ fenceSettled() {
295
+ return this.#fence?.done ?? Promise.resolve();
296
+ }
297
+ /**
298
+ * The order after the claim. `run.json` is written (`running`, generation + 1, this holder, a heartbeat) and the
299
+ * heartbeat starts right after the owner lock, before the store and the Harness open, so a supervisor never sees the
300
+ * previous incarnation's stale heartbeat on a live claim. That first write keeps the previous `sealedSeq`, which the
301
+ * seal check still needs; `live()` clears it once the store is open.
302
+ */
303
+ async open(step, holder) {
304
+ const claim = this.rawClaim;
305
+ // SQLite resolves symbolic links in a file name, `/proc/self/fd/<n>` included, so `owner.lock` is the one file opened
306
+ // by path: right after the mount check, and the file it names must then be the one in the claim directory.
307
+ this.#lock = await step("owner-lock", async () => {
308
+ const dir = (this.#dir = await this.#openDir(claim.root));
309
+ await dir.assertMounted();
310
+ const lock = takeOwnerLock(claim.root);
311
+ try {
312
+ await dir.assertSame(OWNER_LOCK);
313
+ }
314
+ catch (error) {
315
+ try {
316
+ lock.release();
317
+ }
318
+ catch {
319
+ // The lock is not the claim's; the failed check is the fence to report.
320
+ }
321
+ throw error;
322
+ }
323
+ return lock;
324
+ });
325
+ const previous = await this.#readPrevious();
326
+ this.#previous = previous;
327
+ this.#lastSeq = previous?.sealedSeq ?? 0;
328
+ await step("run.json", async () => {
329
+ const now = new Date().toISOString();
330
+ this.#noteBeat(await this.#write(() => ({
331
+ run: this.ref.id,
332
+ status: "running",
333
+ generation: (previous?.generation ?? 0) + 1,
334
+ sealedSeq: previous?.sealedSeq ?? null,
335
+ wakeAt: null,
336
+ holder,
337
+ heartbeatAt: now,
338
+ updatedAt: now,
339
+ detail: null,
340
+ })));
341
+ });
342
+ // Two self-fences: a timer on this thread, and a watchdog thread for when this thread is stuck in a FUSE request.
343
+ await step("heartbeat", () => {
344
+ this.#heartbeat = setInterval(() => this.#beat(), this.#lease.heartbeatMs).unref();
345
+ this.#leaseCheck = setInterval(() => this.#checkLease(), this.#lease.checkMs).unref();
346
+ this.#watchdog = new LeaseWatchdog({
347
+ limitMs: this.#lease.expiryMs - this.#lease.marginMs,
348
+ checkMs: this.#lease.checkMs,
349
+ beatNs: this.#lastBeatNs,
350
+ ownCgroup: this.#options.ownCgroup === true,
351
+ });
352
+ });
353
+ await step("layout", async () => {
354
+ for (const dir of [claim.store, claim.work, join(claim.root, "tmp")]) {
355
+ const under = relative(claim.root, dir);
356
+ if (under === "" || under.startsWith("..") || isAbsolute(under))
357
+ throw new RunError("INVALID_ARGUMENT", `${dir} is not under the claim root ${claim.root}`);
358
+ try {
359
+ await mkdir(join(this.#dir.at, under), { recursive: true });
360
+ }
361
+ catch (error) {
362
+ throw ownWriteFenced(dir, error);
363
+ }
364
+ }
365
+ });
366
+ }
367
+ async checkSeal(store) {
368
+ const sealed = this.sealedSeq;
369
+ let head;
370
+ try {
371
+ head = await storeHead(store);
372
+ }
373
+ catch (error) {
374
+ if (error instanceof RunError)
375
+ await this.#markFailed({ code: error.code, sealedSeq: sealed, reason: error.message });
376
+ throw error;
377
+ }
378
+ this.#lastSeq = Math.max(this.#lastSeq, head);
379
+ if (sealed === null || head >= sealed)
380
+ return;
381
+ const refusal = new StoreBehindSealError(store.file, sealed, head);
382
+ await this.#markFailed({ code: refusal.code, sealedSeq: sealed, head });
383
+ throw refusal;
384
+ }
385
+ observe(storage) {
386
+ return observeCommits(storage, {
387
+ before: () => {
388
+ const lapse = this.#watchdogLapse();
389
+ if (lapse)
390
+ throw this.fence(lapse);
391
+ },
392
+ after: (seq) => {
393
+ this.#lastSeq = Math.max(this.#lastSeq, seq);
394
+ const lapse = this.#watchdogLapse();
395
+ if (lapse)
396
+ this.fence(lapse);
397
+ },
398
+ failed: (error) => {
399
+ if (error instanceof FencedError)
400
+ this.fence(error);
401
+ },
402
+ });
403
+ }
404
+ async live() {
405
+ this.#assertUsable();
406
+ const now = new Date().toISOString();
407
+ this.#noteBeat(await this.#write((current) => ({ ...current, sealedSeq: null, heartbeatAt: now })));
408
+ }
409
+ async abandon(error, close) {
410
+ this.#stopTimers();
411
+ if (this.#fence || error instanceof FencedError) {
412
+ this.fence(this.#fence?.error ?? error);
413
+ await this.fenceSettled();
414
+ return;
415
+ }
416
+ try {
417
+ await this.#writes;
418
+ await close?.();
419
+ this.#lock?.release();
420
+ await this.#closeDir();
421
+ if (!(error instanceof HeldError))
422
+ await this.rawClaim.release();
423
+ }
424
+ catch (cleanupError) {
425
+ if (cleanupError instanceof FencedError) {
426
+ this.fence(cleanupError);
427
+ await this.fenceSettled();
428
+ }
429
+ }
430
+ }
431
+ async setStatus(status, detail = null, options = {}) {
432
+ this.#assertUsable();
433
+ if (status === "done" || status === "failed")
434
+ await this.barrier();
435
+ const now = new Date().toISOString();
436
+ await this.#write((current) => ({ ...current, status, detail, wakeAt: options.wakeAt ?? null, updatedAt: now }));
437
+ }
438
+ async barrier() {
439
+ if (this.#fence)
440
+ throw this.#fence.error;
441
+ try {
442
+ await this.rawClaim.barrier();
443
+ }
444
+ catch (error) {
445
+ if (error instanceof FencedError)
446
+ throw this.fence(error);
447
+ throw error;
448
+ }
449
+ }
450
+ release() {
451
+ if (this.#released)
452
+ return Promise.resolve();
453
+ if (this.#fence)
454
+ return Promise.reject(this.#fence.error);
455
+ this.#releasing ??= this.#release();
456
+ return this.#releasing;
457
+ }
458
+ async #release() {
459
+ const step = stepper(this.#options.onStep);
460
+ await step("cleanup", () => this.#cleanup(this.#context));
461
+ await step("barrier", () => this.barrier());
462
+ clearInterval(this.#heartbeat);
463
+ await step("seal", () => {
464
+ const now = new Date().toISOString();
465
+ return this.#write((current) => ({
466
+ ...current,
467
+ status: current.status === "running" ? "paused" : current.status,
468
+ sealedSeq: this.#lastSeq,
469
+ updatedAt: now,
470
+ }));
471
+ });
472
+ this.#stopTimers();
473
+ await step("unlock", () => {
474
+ try {
475
+ this.#lock.release();
476
+ }
477
+ catch (error) {
478
+ throw this.fence(asFenced(error, `releasing ${OWNER_LOCK}`));
479
+ }
480
+ });
481
+ await step("unmount", async () => {
482
+ await this.#closeDir();
483
+ try {
484
+ await this.rawClaim.release();
485
+ }
486
+ catch (error) {
487
+ if (error instanceof FencedError)
488
+ throw this.fence(error);
489
+ throw error;
490
+ }
491
+ });
492
+ this.#released = true;
493
+ }
494
+ async #readPrevious() {
495
+ let previous;
496
+ try {
497
+ previous = await readRunRecord(this.#dir);
498
+ }
499
+ catch (error) {
500
+ if (error instanceof RunRecordError)
501
+ throw error;
502
+ throw asFenced(error, `reading ${join(this.rawClaim.root, RUN_JSON)}`);
503
+ }
504
+ if (previous !== undefined && previous.run !== this.ref.id) {
505
+ throw new RunRecordError(`${join(this.rawClaim.root, RUN_JSON)} names run ${JSON.stringify(previous.run)}, not ${JSON.stringify(this.ref.id)}`);
506
+ }
507
+ return previous;
508
+ }
509
+ /** `run.json` becomes `failed` with `detail`; the seal the record carries is kept as evidence. */
510
+ async #markFailed(detail) {
511
+ const now = new Date().toISOString();
512
+ await this.#write((current) => ({ ...current, status: "failed", updatedAt: now, detail }));
513
+ }
514
+ /** Serialize writes of `run.json`. Resolves with the clock readings when this write started; a failure fences. */
515
+ #write(change) {
516
+ const next = this.#writes.then(async () => {
517
+ if (this.#fence)
518
+ throw this.#fence.error;
519
+ const record = change(this.#record);
520
+ const started = this.#clock();
521
+ const startedNs = process.hrtime.bigint();
522
+ try {
523
+ const dir = this.#dir;
524
+ if (dir === undefined)
525
+ throw new FencedError(`the claim directory of ${this.rawClaim.root} is closed`);
526
+ await writeRunRecord(dir, record, this.#persist, this.#fencing.signal);
527
+ }
528
+ catch (error) {
529
+ throw this.fence(asFenced(error, `writing ${RUN_JSON}`));
530
+ }
531
+ this.#record = record;
532
+ return { started, startedNs };
533
+ });
534
+ this.#writes = next.catch(() => undefined);
535
+ return next;
536
+ }
537
+ /**
538
+ * Close the claim directory once no write can still address it: a closed descriptor's number may be reused, and
539
+ * `/proc/self/fd/<n>` would then name another file. An open directory also keeps the mount busy, so this runs before
540
+ * the unmount.
541
+ */
542
+ #closeDir() {
543
+ this.#dirClosed ??= (async () => {
544
+ await this.#writes;
545
+ const dir = this.#dir;
546
+ this.#dir = undefined;
547
+ await dir?.close().catch(() => undefined);
548
+ })();
549
+ return this.#dirClosed;
550
+ }
551
+ #beat() {
552
+ if (this.#beating || this.#fence || this.#released)
553
+ return;
554
+ this.#beating = true;
555
+ this.#write((current) => ({ ...current, heartbeatAt: new Date().toISOString() }))
556
+ .then((started) => this.#noteBeat(started))
557
+ .catch(() => undefined)
558
+ .finally(() => (this.#beating = false));
559
+ }
560
+ /** A heartbeat-class write (the first, the one in `live()`, each heartbeat) succeeded. */
561
+ #noteBeat(write) {
562
+ this.#lastBeatStart = Math.max(this.#lastBeatStart, write.started);
563
+ if (write.startedNs > this.#lastBeatNs)
564
+ this.#lastBeatNs = write.startedNs;
565
+ this.#watchdog?.beat(write.startedNs);
566
+ }
567
+ #watchdogLapse() {
568
+ const since = this.#watchdog?.lapsedAfterMs;
569
+ return since === undefined ? undefined : new LeaseLapsedError(since, this.#lease.expiryMs - this.#lease.marginMs, "watchdog");
570
+ }
571
+ #checkLease() {
572
+ if (this.#fence)
573
+ return;
574
+ const lapse = this.#watchdogLapse();
575
+ if (lapse) {
576
+ this.fence(lapse);
577
+ return;
578
+ }
579
+ const since = this.#clock() - this.#lastBeatStart;
580
+ const limit = this.#lease.expiryMs - this.#lease.marginMs;
581
+ if (since > limit)
582
+ this.fence(new LeaseLapsedError(since, limit));
583
+ }
584
+ #stopTimers() {
585
+ clearInterval(this.#heartbeat);
586
+ clearInterval(this.#leaseCheck);
587
+ this.#heartbeat = this.#leaseCheck = undefined;
588
+ this.#watchdog?.stop();
589
+ this.#watchdog = undefined;
590
+ }
591
+ #assertUsable() {
592
+ if (this.#fence)
593
+ throw this.#fence.error;
594
+ if (this.#released || this.#releasing)
595
+ throw new RunError("RUN_RELEASED", `run ${this.ref.id} is released`);
596
+ }
597
+ }
598
+ async function openLease(ref, options, step) {
599
+ const context = options.context ?? BACKGROUND_CONTEXT;
600
+ leaseOf(options.lease);
601
+ let holder;
602
+ try {
603
+ holder = currentHolder(options.holder);
604
+ }
605
+ catch (error) {
606
+ throw new RunError("INVALID_ARGUMENT", `holder ${JSON.stringify(options.holder)}: ${error.message}`, { cause: error });
607
+ }
608
+ const claim = await step("acquire", () => (options.acquire ?? acquire)({
609
+ ref,
610
+ token: options.mountToken,
611
+ ...(options.mountRoot === undefined ? {} : { mountRoot: options.mountRoot }),
612
+ ...(options.force === undefined ? {} : { force: options.force }),
613
+ ...(options.host === undefined ? {} : { host: options.host }),
614
+ }));
615
+ const lease = new Lease(ref, claim, options, context);
616
+ try {
617
+ await lease.open(step, holder);
618
+ }
619
+ catch (error) {
620
+ await lease.abandon(error);
621
+ throw error;
622
+ }
623
+ return lease;
624
+ }
625
+ /**
626
+ * Open the claim's lifecycle for run `ref` (see `RunLease`): acquire and verify the claim, take the owner lock, write
627
+ * `run.json` and start the heartbeat and the self-fence, create `store/`, `work/` and `tmp/`. Errors are typed as for
628
+ * `openDurableRun`: HeldError (76), FencedError (75, also passed to `onFenced`), RunRecordError (65), RunError
629
+ * INVALID_ARGUMENT before any claim.
630
+ */
631
+ export async function openRunLease(ref, options) {
632
+ return openLease(ref, options, stepper(options.onStep));
633
+ }
634
+ // ---- the run ----------------------------------------------------------------------------------------------------------
635
+ class Run {
636
+ ref;
637
+ env;
638
+ #lease;
639
+ #options;
640
+ #context;
641
+ #store;
642
+ #harness;
643
+ #releasing;
644
+ #released = false;
645
+ constructor(lease, env, options, context) {
646
+ this.ref = lease.ref;
647
+ this.#lease = lease;
648
+ this.env = env;
649
+ this.#options = options;
650
+ this.#context = context;
651
+ }
652
+ get harness() {
653
+ return this.#harness;
654
+ }
655
+ get store() {
656
+ return this.#store;
657
+ }
658
+ get claim() {
659
+ return this.#lease.claim;
660
+ }
661
+ get record() {
662
+ return this.#lease.record;
663
+ }
664
+ get generation() {
665
+ return this.#lease.generation;
666
+ }
667
+ get fenced() {
668
+ return this.#lease.fenced;
669
+ }
670
+ /** The store, the seal check, pi's Harness over the observed storage, the seal cleared, resume. */
671
+ async open(step) {
672
+ const lease = this.#lease;
673
+ const file = join(lease.rawClaim.store, STORE_FILE);
674
+ this.#store = await step("store", () => openArchilStore(file, this.#options.profile ?? "exclusive", {
675
+ onFenced: (error) => void lease.fence(error),
676
+ ...(this.#options.decorateStore === undefined ? {} : { decorate: this.#options.decorateStore }),
677
+ }));
678
+ // Both refusals here are terminal: the run is marked `failed`, keeping its seal, so the supervisor stops on it.
679
+ await step("seal", () => lease.checkSeal(this.#store));
680
+ const storage = lease.observe(this.#store.storage);
681
+ this.#harness = await step("harness", () => Harness.open(storage, { ...this.#options.harness, env: this.env }, this.#context));
682
+ await step("live", () => lease.live());
683
+ await step("resume", () => this.#harness.resume());
684
+ }
685
+ /** Undo a failed open: close what this layer opened, then the lease's own undo. */
686
+ abandon(error) {
687
+ return this.#lease.abandon(error, async () => {
688
+ if (this.#harness)
689
+ await this.#harness.close(this.#context);
690
+ else if (this.#store)
691
+ await this.#store.storage.close(this.#context);
692
+ });
693
+ }
694
+ async setStatus(status, detail = null, options = {}) {
695
+ if (!this.#lease.fenced && (this.#released || this.#releasing))
696
+ throw new RunError("RUN_RELEASED", `run ${this.ref.id} is released`);
697
+ await this.#lease.setStatus(status, detail, options);
698
+ }
699
+ release() {
700
+ if (this.#released)
701
+ return Promise.resolve();
702
+ if (this.#lease.fenced)
703
+ return Promise.reject(this.#lease.fencedBy);
704
+ this.#releasing ??= this.#release();
705
+ return this.#releasing;
706
+ }
707
+ async #release() {
708
+ const step = stepper(this.#options.onStep);
709
+ // Closing first aborts running work without recording an outcome, so the next open reports it interrupted; the
710
+ // lease then kills the commands, barriers, seals and unmounts.
711
+ await step("close", async () => {
712
+ try {
713
+ await this.#harness.close(this.#context);
714
+ }
715
+ catch (error) {
716
+ throw this.#lease.fence(asFenced(error, "closing the Harness"));
717
+ }
718
+ });
719
+ await this.#lease.release();
720
+ this.#released = true;
721
+ }
722
+ }
723
+ /**
724
+ * Open run `ref` on this host: the lease (`openRunLease`: claim, owner lock, `run.json`, heartbeat and
725
+ * self-fence, layout), then the store, the seal check before anything commits, pi's Harness with the app's registry,
726
+ * the seal cleared in `run.json`, resume.
727
+ *
728
+ * Errors are typed: HeldError (76) when another client, another process on this host or another connection holds the
729
+ * run; FencedError (75) when the claim cannot be trusted (also passed to `onFenced`); StoreBehindSealError (65) and
730
+ * RunError STORE_HEAD_UNREADABLE (70), each after marking the run `failed` and releasing the claim.
731
+ */
732
+ export async function openDurableRun(ref, options) {
733
+ const context = options.context ?? BACKGROUND_CONTEXT;
734
+ const step = stepper(options.onStep);
735
+ let env;
736
+ const lease = await openLease(ref, { ...options, context, cleanup: (c) => env?.cleanup(c) ?? Promise.resolve() }, step);
737
+ try {
738
+ env = (options.env ?? ((c) => archilEnv(c)))(lease.rawClaim);
739
+ }
740
+ catch (error) {
741
+ await lease.abandon(error);
742
+ throw error;
743
+ }
744
+ const run = new Run(lease, env, options, context);
745
+ try {
746
+ await run.open(step);
747
+ }
748
+ catch (error) {
749
+ await run.abandon(error);
750
+ throw error;
751
+ }
752
+ return run;
753
+ }