@parcha/pi-durable-disk 0.1.0-beta.12 → 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.
- package/CHANGELOG.md +145 -0
- package/README.md +184 -11
- package/dist/app.d.ts +5 -0
- package/dist/app.js +3 -2
- package/dist/claim.d.ts +28 -1
- package/dist/claim.js +74 -3
- package/dist/cli.js +2 -1
- package/dist/commands.d.ts +67 -0
- package/dist/commands.js +299 -0
- package/dist/confine.d.ts +11 -0
- package/dist/confine.js +24 -2
- package/dist/env.d.ts +10 -4
- package/dist/env.js +50 -10
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +1 -1
- package/dist/hosts/daytona-launch.js +3 -1
- package/dist/hosts/local-host.js +5 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lease.d.ts +2 -2
- package/dist/lease.js +1 -1
- package/dist/local-run.d.ts +10 -0
- package/dist/local-run.js +63 -0
- package/dist/park.d.ts +4 -3
- package/dist/park.js +4 -3
- package/dist/run.d.ts +42 -10
- package/dist/run.js +39 -7
- package/dist/supervise.d.ts +3 -3
- package/dist/supervise.js +15 -13
- package/dist/watchdog.js +4 -2
- package/package.json +7 -7
package/dist/hosts/local-host.js
CHANGED
|
@@ -299,6 +299,11 @@ export function localHost(opts = {}) {
|
|
|
299
299
|
return;
|
|
300
300
|
if (h.mode === "child") {
|
|
301
301
|
const pid = Number(h.pid);
|
|
302
|
+
// A handle comes back from run.json with whatever was written there, and stop signals its pid as a group: only a
|
|
303
|
+
// real process, then. A pid of 1 or below names a group (`kill(-0)` is this supervisor's own, `kill(-1)` every
|
|
304
|
+
// process it may signal), and without start ticks nothing tells a reused pid from the instance.
|
|
305
|
+
if (!Number.isInteger(pid) || pid <= 1 || typeof h.startTicks !== "number")
|
|
306
|
+
return;
|
|
302
307
|
const alive = () => procStartTicks(pid) === h.startTicks;
|
|
303
308
|
if (!alive())
|
|
304
309
|
return;
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export * from "./fork.js";
|
|
|
7
7
|
export * from "./hosts/daytona.js";
|
|
8
8
|
export * from "./hosts/docker.js";
|
|
9
9
|
export * from "./hosts/local-host.js";
|
|
10
|
+
export * from "./local-run.js";
|
|
10
11
|
export * from "./park.js";
|
|
11
12
|
export * from "./run.js";
|
|
12
13
|
export * from "./serve.js";
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export * from "./fork.js";
|
|
|
8
8
|
export * from "./hosts/daytona.js";
|
|
9
9
|
export * from "./hosts/docker.js";
|
|
10
10
|
export * from "./hosts/local-host.js";
|
|
11
|
+
export * from "./local-run.js";
|
|
11
12
|
export * from "./park.js";
|
|
12
13
|
export * from "./run.js";
|
|
13
14
|
export * from "./serve.js";
|
package/dist/lease.d.ts
CHANGED
|
@@ -3,5 +3,5 @@ export type { LeaseOptions, OpenRunLeaseOptions, OpenStep, ReleaseStep, RunClaim
|
|
|
3
3
|
export * from "./errors.js";
|
|
4
4
|
export { assertPragmas, Fence, FencedDatabase, openArchilStore, PROFILE_PRAGMAS, StoreBusyError, StoreFencedError, StorePragmaError } from "./store.js";
|
|
5
5
|
export type { ArchilStore, OpenOptions, PragmaMismatch, Profile } from "./store.js";
|
|
6
|
-
export type { ArchilHost, Claim, RunRef } from "./claim.js";
|
|
7
|
-
export type { RunHolder, RunRecord, RunStatus } from "./status.js";
|
|
6
|
+
export type { AcquireOptions, ArchilHost, Claim, RunRef, Unmounted } from "./claim.js";
|
|
7
|
+
export type { ClaimDir, PersistRecord, RunHolder, RunRecord, RunStatus } from "./status.js";
|
package/dist/lease.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The narrow entry (`@parcha/pi-durable-disk/lease`) for a host that owns its own pi-durable Harness and store connections: the
|
|
2
2
|
// claim's lifecycle without the supervisor, the host drivers, the CLI or the app contract. Importing it must not pull those
|
|
3
|
-
// in (test/lease.test.ts bundles it and checks); a name added here is part of that promise.
|
|
3
|
+
// in (test/lease-entry.test.ts bundles it and checks); a name added here is part of that promise.
|
|
4
4
|
export { EXIT_DATAERR, EXIT_SOFTWARE, LeaseLapsedError, openRunLease, OWNER_LOCK, RunError, StoreBehindSealError, storeHead, STORE_FILE } from "./run.js";
|
|
5
5
|
export * from "./errors.js";
|
|
6
6
|
export { assertPragmas, Fence, FencedDatabase, openArchilStore, PROFILE_PRAGMAS, StoreBusyError, StoreFencedError, StorePragmaError } from "./store.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AcquireOptions, Claim } from "./claim.js";
|
|
2
|
+
import { type ClaimDir } from "./status.js";
|
|
3
|
+
export interface LocalRun {
|
|
4
|
+
/** Takes the claim: the directory, made when it is missing. */
|
|
5
|
+
readonly acquire: (options: AcquireOptions) => Promise<Claim>;
|
|
6
|
+
/** Holds the directory open as the run's root; a local directory is not an Archil mount, so its type is not checked. */
|
|
7
|
+
readonly claimDir: (root: string) => Promise<ClaimDir>;
|
|
8
|
+
}
|
|
9
|
+
/** `openDurableRun`'s `acquire` and `claimDir` for a run in `root`, a directory on this machine. */
|
|
10
|
+
export declare function localRun(root: string): LocalRun;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// A run on a plain local directory, for one machine: the claim is the directory itself. Nothing mounts and nothing is
|
|
2
|
+
// delegated, so nothing but this machine's owner lock stands between two processes and the run: it is for a laptop, a CI
|
|
3
|
+
// job or a test, never for a directory another machine can reach.
|
|
4
|
+
//
|
|
5
|
+
// const run = await openDurableRun(ref, { ...localRun("/var/runs/run-1"), harness: { models, registry } });
|
|
6
|
+
//
|
|
7
|
+
// No mount token is needed (`acquire` is replaced). The claim's barrier does not sync the files the agent wrote: the
|
|
8
|
+
// store's own commits are made durable by SQLite, and work/ is the operating system's to flush; a power loss can lose what
|
|
9
|
+
// it had not written yet. It refuses once the claim is fenced, as a mount's does.
|
|
10
|
+
//
|
|
11
|
+
// Linux only, for now: the run's root is held open through /proc/self/fd (openClaimDir), and the default environment
|
|
12
|
+
// (`archilEnv`) confines commands with `setpriv` (util-linux) and the file tools through /proc/self/fd. macOS has neither.
|
|
13
|
+
import { mkdir } from "node:fs/promises";
|
|
14
|
+
import { join, resolve } from "node:path";
|
|
15
|
+
import { FencedError } from "./errors.js";
|
|
16
|
+
import { openClaimDir } from "./status.js";
|
|
17
|
+
/** `openDurableRun`'s `acquire` and `claimDir` for a run in `root`, a directory on this machine. */
|
|
18
|
+
export function localRun(root) {
|
|
19
|
+
const dir = resolve(root);
|
|
20
|
+
return {
|
|
21
|
+
acquire: async ({ ref }) => {
|
|
22
|
+
await mkdir(dir, { recursive: true });
|
|
23
|
+
return new LocalClaim(ref, dir);
|
|
24
|
+
},
|
|
25
|
+
claimDir: (at) => openClaimDir(at, { fstype: null }),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
class LocalClaim {
|
|
29
|
+
ref;
|
|
30
|
+
disk;
|
|
31
|
+
root;
|
|
32
|
+
work;
|
|
33
|
+
store;
|
|
34
|
+
/** `<root>/commands`: a local run has no unit cgroup, so the next open ends the commands a killed one left running. */
|
|
35
|
+
commands;
|
|
36
|
+
reused = false;
|
|
37
|
+
stray = null;
|
|
38
|
+
forced = false;
|
|
39
|
+
timings = { mountMs: 0, verifyMs: 0 };
|
|
40
|
+
#fence;
|
|
41
|
+
constructor(ref, root) {
|
|
42
|
+
this.ref = ref;
|
|
43
|
+
this.disk = ref.disk;
|
|
44
|
+
this.root = root;
|
|
45
|
+
this.work = join(root, "work");
|
|
46
|
+
this.store = join(root, "store");
|
|
47
|
+
this.commands = join(root, "commands");
|
|
48
|
+
}
|
|
49
|
+
get fenced() {
|
|
50
|
+
return this.#fence !== undefined;
|
|
51
|
+
}
|
|
52
|
+
markFenced(cause) {
|
|
53
|
+
this.#fence ??= { cause };
|
|
54
|
+
}
|
|
55
|
+
async barrier() {
|
|
56
|
+
if (this.#fence)
|
|
57
|
+
throw new FencedError(`${this.root} is fenced; nothing is retried on it`, { cause: this.#fence.cause });
|
|
58
|
+
return { ms: 0 };
|
|
59
|
+
}
|
|
60
|
+
async release() {
|
|
61
|
+
return { via: "none" };
|
|
62
|
+
}
|
|
63
|
+
}
|
package/dist/park.d.ts
CHANGED
|
@@ -26,9 +26,10 @@ export type BusyState = {
|
|
|
26
26
|
};
|
|
27
27
|
/**
|
|
28
28
|
* The deadline of a task that is only sleeping: `until` in pi's retry phase (generation and compaction), `pollAt` in
|
|
29
|
-
* its deferred poll phase. This is pi-durable
|
|
30
|
-
* read here and nowhere else. Any other shape is no deadline, which counts as
|
|
31
|
-
* instances up rather than parking them mid-work. pi#10325 asks for this as
|
|
29
|
+
* its deferred poll phase. This is pi-durable's internal checkpoint shape, the same in 1.0.4 and 1.1.0
|
|
30
|
+
* (harness/generation.js, compaction.js), read here and nowhere else. Any other shape is no deadline, which counts as
|
|
31
|
+
* busy, so a pi that changes the shape keeps instances up rather than parking them mid-work. pi#10325 asks for this as
|
|
32
|
+
* a contract.
|
|
32
33
|
*/
|
|
33
34
|
export declare function waitDeadline(checkpoint: JsonValue | undefined): number | undefined;
|
|
34
35
|
/**
|
package/dist/park.js
CHANGED
|
@@ -27,9 +27,10 @@ export function leaseParkTarget(lease, harness, context = BACKGROUND_CONTEXT) {
|
|
|
27
27
|
export const LOCAL_PARK_THRESHOLD_MS = 60_000;
|
|
28
28
|
/**
|
|
29
29
|
* The deadline of a task that is only sleeping: `until` in pi's retry phase (generation and compaction), `pollAt` in
|
|
30
|
-
* its deferred poll phase. This is pi-durable
|
|
31
|
-
* read here and nowhere else. Any other shape is no deadline, which counts as
|
|
32
|
-
* instances up rather than parking them mid-work. pi#10325 asks for this as
|
|
30
|
+
* its deferred poll phase. This is pi-durable's internal checkpoint shape, the same in 1.0.4 and 1.1.0
|
|
31
|
+
* (harness/generation.js, compaction.js), read here and nowhere else. Any other shape is no deadline, which counts as
|
|
32
|
+
* busy, so a pi that changes the shape keeps instances up rather than parking them mid-work. pi#10325 asks for this as
|
|
33
|
+
* a contract.
|
|
33
34
|
*/
|
|
34
35
|
export function waitDeadline(checkpoint) {
|
|
35
36
|
if (typeof checkpoint !== "object" || checkpoint === null || Array.isArray(checkpoint))
|
package/dist/run.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Context, JsonValue } from "@earendil-works/chord";
|
|
2
2
|
import { Harness } from "@earendil-works/pi-durable";
|
|
3
|
-
import type { HarnessOptions, Storage, ToolRegistration } from "@earendil-works/pi-durable";
|
|
3
|
+
import type { HarnessOptions, Seq, Storage, ToolRegistration } from "@earendil-works/pi-durable";
|
|
4
4
|
import type { SqliteDatabase, SqliteExecutor } from "@earendil-works/pi-durable/storage/sqlite";
|
|
5
5
|
import { type AcquireOptions, type ArchilHost, type Claim, type RunRef } from "./claim.js";
|
|
6
6
|
import { FencedError, PdaError } from "./errors.js";
|
|
@@ -57,11 +57,15 @@ export interface LeaseOptions {
|
|
|
57
57
|
/** How often the self-fence timer looks; default 1 s. */
|
|
58
58
|
readonly checkMs?: number;
|
|
59
59
|
}
|
|
60
|
-
export type OpenStep = "acquire" | "owner-lock" | "run.json" | "heartbeat" | "layout" | "store" | "seal" | "harness" | "live" | "resume";
|
|
60
|
+
export type OpenStep = "acquire" | "owner-lock" | "run.json" | "heartbeat" | "layout" | "commands" | "store" | "seal" | "harness" | "live" | "before-resume" | "resume";
|
|
61
61
|
export type ReleaseStep = "close" | "cleanup" | "barrier" | "seal" | "unlock" | "unmount";
|
|
62
62
|
export interface OpenRunLeaseOptions {
|
|
63
|
-
/**
|
|
64
|
-
|
|
63
|
+
/**
|
|
64
|
+
* The mount token from the host driver (reusable, 24 h); travels only on the archil wrapper's stdin, never in an
|
|
65
|
+
* environment. Required to mount an Archil disk (the default `acquire`); a run whose `acquire` is replaced (a local
|
|
66
|
+
* directory: `localRun`) needs none, and its `acquire` is handed an empty token.
|
|
67
|
+
*/
|
|
68
|
+
readonly mountToken?: string;
|
|
65
69
|
/** Default /mnt/archil. */
|
|
66
70
|
readonly mountRoot?: string;
|
|
67
71
|
/** Mount with `--force`: the takeover fallback when the supervisor could not revoke. */
|
|
@@ -110,7 +114,35 @@ export interface OpenDurableRunOptions<Tool extends ToolRegistration = ToolRegis
|
|
|
110
114
|
readonly env?: (claim: Claim) => RunEnv;
|
|
111
115
|
/** Wraps the raw database inside the store's fence (instrumentation, fault injection). */
|
|
112
116
|
readonly decorateStore?: (database: SqliteDatabase) => SqliteDatabase;
|
|
117
|
+
/**
|
|
118
|
+
* Awaited once the Harness is open (its registry installed) and the run is live, before the Harness resumes: no
|
|
119
|
+
* resumed task takes a step until it settles, and what it admits (a submission, a write) is committed before anything
|
|
120
|
+
* a resumed task's steps commit. A host reconciles here what a resumed tool depends on (a browser, a mount). A rejection
|
|
121
|
+
* fails the open as any step does (`before-resume`), and the claim is released.
|
|
122
|
+
*/
|
|
123
|
+
readonly beforeResume?: (run: BeforeResume) => void | Promise<void>;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* What `beforeResume` sees: the open Harness, not yet resumed, the open's context, the incarnation it belongs to, and the
|
|
127
|
+
* store the Harness was opened over with its head (a recorder snapshots every document at `head` here).
|
|
128
|
+
*/
|
|
129
|
+
export interface BeforeResume {
|
|
130
|
+
readonly harness: Harness;
|
|
131
|
+
readonly context: Context;
|
|
132
|
+
readonly generation: number;
|
|
133
|
+
readonly record: RunRecord;
|
|
134
|
+
readonly claim: RunClaim;
|
|
135
|
+
/** The store the Harness was opened over, read-only: four reads on an object of their own, and no way to a write. */
|
|
136
|
+
readonly storage: StoreReads;
|
|
137
|
+
/**
|
|
138
|
+
* The store's last committed sequence once the Harness is open, read just before the hook runs. Until the hook commits
|
|
139
|
+
* something, the store's current state is its state at `head`: a document that keeps no history (`latest`, and session
|
|
140
|
+
* and task documents) reads only at "current", a `rewindable` one at `head` too.
|
|
141
|
+
*/
|
|
142
|
+
readonly head: Seq;
|
|
113
143
|
}
|
|
144
|
+
/** The reads of a store that `beforeResume` gets. */
|
|
145
|
+
export type StoreReads = Pick<Storage, "scanConversations" | "scanTasks" | "scanDocuments" | "document">;
|
|
114
146
|
/** The claim as the app sees it: paths, the fenced flag, and a barrier whose failure fences the run. */
|
|
115
147
|
export interface RunClaim {
|
|
116
148
|
readonly ref: RunRef;
|
|
@@ -228,12 +260,12 @@ export interface StoreHeadSource {
|
|
|
228
260
|
readonly file: string;
|
|
229
261
|
}
|
|
230
262
|
/**
|
|
231
|
-
* The last sequence the store committed: the one read of pi's internal schema in this package. pi-durable
|
|
232
|
-
*
|
|
233
|
-
* would reuse a lost sequence number). pi's SqliteStorage gives each commit `durable_metadata.next_seq`
|
|
234
|
-
* successor in the same transaction, so the head is `next_seq - 1`. The query only reads. A store
|
|
235
|
-
* row or column fails closed (STORE_HEAD_UNREADABLE), so a pi whose schema moved never skips the
|
|
236
|
-
* a unit test pins the read to the sequence pi's commits return.
|
|
263
|
+
* The last sequence the store committed: the one read of pi's internal schema in this package. pi-durable's public
|
|
264
|
+
* `Storage` has no head query (1.0.4 and 1.1.0), and the seal check must run before `Harness.open` can commit (its
|
|
265
|
+
* recovery commit would reuse a lost sequence number). pi's SqliteStorage gives each commit `durable_metadata.next_seq`
|
|
266
|
+
* and stores the successor in the same transaction, so the head is `next_seq - 1`. The query only reads. A store
|
|
267
|
+
* without that table, row or column fails closed (STORE_HEAD_UNREADABLE), so a pi whose schema moved never skips the
|
|
268
|
+
* check silently; a unit test pins the read to the sequence pi's commits return.
|
|
237
269
|
*
|
|
238
270
|
* `database` is the store's fence-aware facade (`FencedDatabase`), which turns an I/O-class error into a fence; through
|
|
239
271
|
* a raw connection such an error would read as STORE_HEAD_UNREADABLE.
|
package/dist/run.js
CHANGED
|
@@ -10,6 +10,7 @@ import { DatabaseSync } from "node:sqlite";
|
|
|
10
10
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
11
11
|
import { Harness } from "@earendil-works/pi-durable";
|
|
12
12
|
import { acquire } from "./claim.js";
|
|
13
|
+
import { reapOrRefuse } from "./commands.js";
|
|
13
14
|
import { archilEnv } from "./env.js";
|
|
14
15
|
import { EXIT_FENCED, FencedError, HeldError, ownWriteFenced, PdaError } from "./errors.js";
|
|
15
16
|
import { openArchilStore } from "./store.js";
|
|
@@ -59,6 +60,18 @@ export class LeaseLapsedError extends FencedError {
|
|
|
59
60
|
this.by = by;
|
|
60
61
|
}
|
|
61
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* `storage`'s four reads on a frozen object that holds nothing else: no `commit` or `mintId` on it, and no reference to
|
|
65
|
+
* `storage` a cast could reach.
|
|
66
|
+
*/
|
|
67
|
+
function storeReads(storage) {
|
|
68
|
+
return Object.freeze({
|
|
69
|
+
scanConversations: (...args) => storage.scanConversations(...args),
|
|
70
|
+
scanTasks: (...args) => storage.scanTasks(...args),
|
|
71
|
+
scanDocuments: (...args) => storage.scanDocuments(...args),
|
|
72
|
+
document: (...args) => storage.document(...args),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
62
75
|
/** Locks this process holds. A collected DatabaseSync closes its file, which would drop the lock without a word. */
|
|
63
76
|
const heldLocks = new Set();
|
|
64
77
|
const SQLITE_BUSY = 5;
|
|
@@ -119,12 +132,12 @@ export function takeOwnerLock(root) {
|
|
|
119
132
|
};
|
|
120
133
|
}
|
|
121
134
|
/**
|
|
122
|
-
* The last sequence the store committed: the one read of pi's internal schema in this package. pi-durable
|
|
123
|
-
*
|
|
124
|
-
* would reuse a lost sequence number). pi's SqliteStorage gives each commit `durable_metadata.next_seq`
|
|
125
|
-
* successor in the same transaction, so the head is `next_seq - 1`. The query only reads. A store
|
|
126
|
-
* row or column fails closed (STORE_HEAD_UNREADABLE), so a pi whose schema moved never skips the
|
|
127
|
-
* a unit test pins the read to the sequence pi's commits return.
|
|
135
|
+
* The last sequence the store committed: the one read of pi's internal schema in this package. pi-durable's public
|
|
136
|
+
* `Storage` has no head query (1.0.4 and 1.1.0), and the seal check must run before `Harness.open` can commit (its
|
|
137
|
+
* recovery commit would reuse a lost sequence number). pi's SqliteStorage gives each commit `durable_metadata.next_seq`
|
|
138
|
+
* and stores the successor in the same transaction, so the head is `next_seq - 1`. The query only reads. A store
|
|
139
|
+
* without that table, row or column fails closed (STORE_HEAD_UNREADABLE), so a pi whose schema moved never skips the
|
|
140
|
+
* check silently; a unit test pins the read to the sequence pi's commits return.
|
|
128
141
|
*
|
|
129
142
|
* `database` is the store's fence-aware facade (`FencedDatabase`), which turns an I/O-class error into a fence; through
|
|
130
143
|
* a raw connection such an error would read as STORE_HEAD_UNREADABLE.
|
|
@@ -605,9 +618,12 @@ async function openLease(ref, options, step) {
|
|
|
605
618
|
catch (error) {
|
|
606
619
|
throw new RunError("INVALID_ARGUMENT", `holder ${JSON.stringify(options.holder)}: ${error.message}`, { cause: error });
|
|
607
620
|
}
|
|
621
|
+
if (options.acquire === undefined && !options.mountToken) {
|
|
622
|
+
throw new RunError("INVALID_ARGUMENT", "mountToken is required to mount an Archil disk; a run on a local directory passes localRun(root) instead");
|
|
623
|
+
}
|
|
608
624
|
const claim = await step("acquire", () => (options.acquire ?? acquire)({
|
|
609
625
|
ref,
|
|
610
|
-
token: options.mountToken,
|
|
626
|
+
token: options.mountToken ?? "",
|
|
611
627
|
...(options.mountRoot === undefined ? {} : { mountRoot: options.mountRoot }),
|
|
612
628
|
...(options.force === undefined ? {} : { force: options.force }),
|
|
613
629
|
...(options.host === undefined ? {} : { host: options.host }),
|
|
@@ -680,6 +696,15 @@ class Run {
|
|
|
680
696
|
const storage = lease.observe(this.#store.storage);
|
|
681
697
|
this.#harness = await step("harness", () => Harness.open(storage, { ...this.#options.harness, env: this.env }, this.#context));
|
|
682
698
|
await step("live", () => lease.live());
|
|
699
|
+
const before = this.#options.beforeResume;
|
|
700
|
+
if (before) {
|
|
701
|
+
// The storage the Harness was opened over (the lease observes its commits), and its head now: nothing commits
|
|
702
|
+
// between the open and the hook, since the Harness has not resumed.
|
|
703
|
+
await step("before-resume", async () => {
|
|
704
|
+
const head = (await storeHead(this.#store));
|
|
705
|
+
await before({ harness: this.#harness, context: this.#context, generation: lease.generation, record: lease.record, claim: lease.claim, storage: storeReads(storage), head });
|
|
706
|
+
});
|
|
707
|
+
}
|
|
683
708
|
await step("resume", () => this.#harness.resume());
|
|
684
709
|
}
|
|
685
710
|
/** Undo a failed open: close what this layer opened, then the lease's own undo. */
|
|
@@ -735,6 +760,13 @@ export async function openDurableRun(ref, options) {
|
|
|
735
760
|
let env;
|
|
736
761
|
const lease = await openLease(ref, { ...options, context, cleanup: (c) => env?.cleanup(c) ?? Promise.resolve() }, step);
|
|
737
762
|
try {
|
|
763
|
+
// A claim that records its commands (a local run's): end what a killed incarnation left running, now that the owner
|
|
764
|
+
// lock is held and before the Harness opens, so no command of it still writes in work/ once the run resumes.
|
|
765
|
+
const commands = lease.rawClaim.commands;
|
|
766
|
+
// A process of a killed incarnation that outlives its SIGKILL refuses the open (HeldError, 76): the run never resumes
|
|
767
|
+
// beside it, and its record stays for the next open.
|
|
768
|
+
if (commands !== undefined)
|
|
769
|
+
await step("commands", () => reapOrRefuse(commands));
|
|
738
770
|
env = (options.env ?? ((c) => archilEnv(c)))(lease.rawClaim);
|
|
739
771
|
}
|
|
740
772
|
catch (error) {
|
package/dist/supervise.d.ts
CHANGED
|
@@ -330,9 +330,9 @@ export declare function checkHost(options: CheckOptions): Promise<CheckReport>;
|
|
|
330
330
|
/**
|
|
331
331
|
* Delete a run's directory tree over S3. A tree with a delegation on it, even an orphaned one, is not deleted: S3
|
|
332
332
|
* DeleteObjects leaves its objects in place and reports no error, and the run id would later reopen with its old
|
|
333
|
-
* store. So: revoke every delegation on the subtree, delete the files, then the
|
|
334
|
-
* directory with children refuses), then list the prefix. Anything left is
|
|
335
|
-
* never reported as deleted.
|
|
333
|
+
* store. So: revoke every delegation on the subtree and its holders' private directories, delete the files, then the
|
|
334
|
+
* directory markers deepest first (a directory with children refuses), then list the prefix. Anything left is
|
|
335
|
+
* RUN_TREE_NOT_DELETED; a non-empty prefix is never reported as deleted.
|
|
336
336
|
*/
|
|
337
337
|
export declare function deleteRunTree(control: CheckControl, id: string): Promise<{
|
|
338
338
|
objects: number;
|
package/dist/supervise.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { open } from "node:fs/promises";
|
|
7
7
|
import { hostname } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
-
import { acquire, createRunDir, findDelegations, matchDelegations, pathlessResolver, mintMountToken, MOUNT_TOKEN_TTL, parseTokenNickname, removeMountToken, runPath, unmountClaim, } from "./claim.js";
|
|
9
|
+
import { acquire, createRunDir, findDelegations, matchDelegations, pathlessResolver, revokeBestEffort, revokeCompanions, mintMountToken, MOUNT_TOKEN_TTL, parseTokenNickname, removeMountToken, runPath, unmountClaim, } from "./claim.js";
|
|
10
10
|
import { FencedError, HeldError, PdaError } from "./errors.js";
|
|
11
11
|
import { parseRunRecord, RUN_JSON } from "./status.js";
|
|
12
12
|
export const CONTROL_TIMEOUT_MS = 10_000;
|
|
@@ -106,7 +106,7 @@ export async function ensureRunning(ref, host, options) {
|
|
|
106
106
|
}
|
|
107
107
|
// A due wake still goes through the delegation check: an instance that wrote `sleeping` and died before releasing
|
|
108
108
|
// left a held or orphaned delegation, which a plain start would hit (76) on every tick.
|
|
109
|
-
const held = await listHeld(opts.control, ref.id, opts.pathless);
|
|
109
|
+
const { held, all } = await listHeld(opts.control, ref.id, opts.pathless);
|
|
110
110
|
// A start in flight: the instance has not written run.json at its generation yet (it may not even have mounted).
|
|
111
111
|
// Revoking it or starting a second one would fence what was just started.
|
|
112
112
|
const baseGraceMs = opts.startGraceMs ?? leaseExpiryMs;
|
|
@@ -144,7 +144,7 @@ export async function ensureRunning(ref, host, options) {
|
|
|
144
144
|
const inFlight = await starting();
|
|
145
145
|
if (inFlight)
|
|
146
146
|
return inFlight;
|
|
147
|
-
await revokeListed(opts.control, ref.id, held);
|
|
147
|
+
await revokeListed(opts.control, ref.id, held, all);
|
|
148
148
|
return start("orphaned", held);
|
|
149
149
|
}
|
|
150
150
|
if (held.some((d) => d.isPending && !d.isOrphaned))
|
|
@@ -157,7 +157,7 @@ export async function ensureRunning(ref, host, options) {
|
|
|
157
157
|
if (inFlight)
|
|
158
158
|
return inFlight;
|
|
159
159
|
const stonith = status?.holder ? await stopHolder(host, status.holder, opts.stonithTimeoutMs ?? STONITH_TIMEOUT_MS) : { outcome: "no-holder" };
|
|
160
|
-
await revokeListed(opts.control, ref.id, held);
|
|
160
|
+
await revokeListed(opts.control, ref.id, held, all);
|
|
161
161
|
return start("lease-expired", held, stonith);
|
|
162
162
|
}
|
|
163
163
|
/**
|
|
@@ -219,9 +219,11 @@ async function ensureRunDir(control, id, owner) {
|
|
|
219
219
|
await createRunDir(control, id, owner);
|
|
220
220
|
return true;
|
|
221
221
|
}
|
|
222
|
+
/** The run's delegations (`matchDelegations`), and the listing they came from. */
|
|
222
223
|
async function listHeld(control, id, resolve) {
|
|
223
224
|
try {
|
|
224
|
-
|
|
225
|
+
const all = await control.listDelegations();
|
|
226
|
+
return { held: await matchDelegations(all, id, resolve ?? pathlessResolver(control)), all };
|
|
225
227
|
}
|
|
226
228
|
catch (err) {
|
|
227
229
|
throw new SuperviseError("CONTROL_API_FAILED", `listing delegations on ${runPath(id)} failed`, { cause: err });
|
|
@@ -231,18 +233,20 @@ const sameDelegation = (a, b) => a.clientId === b.clientId && a.inodeId === b.in
|
|
|
231
233
|
/**
|
|
232
234
|
* Revoke exactly the delegations the decision was made on, never a fresh listing: a racing supervisor may have started
|
|
233
235
|
* a new holder since, and that one is not ours to judge. A revoke that fails is fine only if the delegation is gone.
|
|
236
|
+
* Then the same clients' private directories (`revokeCompanions`): those clients are gone or fenced.
|
|
234
237
|
*/
|
|
235
|
-
async function revokeListed(control, id, held) {
|
|
238
|
+
async function revokeListed(control, id, held, all) {
|
|
236
239
|
for (const d of held) {
|
|
237
240
|
try {
|
|
238
241
|
await control.revokeDelegation({ clientId: d.clientId, inodeId: d.inodeId });
|
|
239
242
|
}
|
|
240
243
|
catch (err) {
|
|
241
|
-
const still = await listHeld(control, id).then((now) => now.some((n) => sameDelegation(n, d)));
|
|
244
|
+
const still = await listHeld(control, id).then((now) => now.held.some((n) => sameDelegation(n, d)));
|
|
242
245
|
if (still)
|
|
243
246
|
throw new SuperviseError("CONTROL_API_FAILED", `revoking ${runPath(id)} (client ${d.clientId}) failed`, { cause: err });
|
|
244
247
|
}
|
|
245
248
|
}
|
|
249
|
+
await revokeCompanions(control, held, all);
|
|
246
250
|
}
|
|
247
251
|
function settleWithin(promise, ms) {
|
|
248
252
|
return new Promise((resolve) => {
|
|
@@ -501,15 +505,13 @@ export async function checkHost(options) {
|
|
|
501
505
|
/**
|
|
502
506
|
* Delete a run's directory tree over S3. A tree with a delegation on it, even an orphaned one, is not deleted: S3
|
|
503
507
|
* DeleteObjects leaves its objects in place and reports no error, and the run id would later reopen with its old
|
|
504
|
-
* store. So: revoke every delegation on the subtree, delete the files, then the
|
|
505
|
-
* directory with children refuses), then list the prefix. Anything left is
|
|
506
|
-
* never reported as deleted.
|
|
508
|
+
* store. So: revoke every delegation on the subtree and its holders' private directories, delete the files, then the
|
|
509
|
+
* directory markers deepest first (a directory with children refuses), then list the prefix. Anything left is
|
|
510
|
+
* RUN_TREE_NOT_DELETED; a non-empty prefix is never reported as deleted.
|
|
507
511
|
*/
|
|
508
512
|
export async function deleteRunTree(control, id) {
|
|
509
513
|
const prefix = `${runPath(id)}/`;
|
|
510
|
-
const held = await
|
|
511
|
-
for (const d of held)
|
|
512
|
-
await control.revokeDelegation({ clientId: d.clientId, inodeId: d.inodeId }).catch(() => { });
|
|
514
|
+
const held = await revokeBestEffort(control, id);
|
|
513
515
|
const keys = (await control.listObjects(prefix, { recursive: true })).objects.map((o) => o.key);
|
|
514
516
|
const dirs = [...new Set([...keys.filter((k) => k.endsWith("/")), prefix])];
|
|
515
517
|
const depth = (k) => k.split("/").length;
|
package/dist/watchdog.js
CHANGED
|
@@ -60,7 +60,7 @@ function sweep(instance, options = {}) {
|
|
|
60
60
|
if (!Number.isInteger(pid) || pid <= 0 || pid === instance) continue;
|
|
61
61
|
const child = stat(proc, pid);
|
|
62
62
|
if (child === undefined || child.ppid !== instance) continue;
|
|
63
|
-
targets.add(child.pgrp === own.pgrp || child.pgrp <=
|
|
63
|
+
targets.add(child.pgrp === own.pgrp || child.pgrp <= 1 ? pid : -child.pgrp);
|
|
64
64
|
}
|
|
65
65
|
if (options.ownCgroup) {
|
|
66
66
|
for (const pid of cgroupPids(instance, proc, options.sys ?? "/sys/fs/cgroup")) if (pid !== instance && pid > 0) targets.add(pid);
|
|
@@ -110,7 +110,9 @@ export class LeaseWatchdog {
|
|
|
110
110
|
Atomics.store(this.#shared, SLOT_BEAT, options.beatNs);
|
|
111
111
|
Atomics.store(this.#shared, SLOT_LIMIT, BigInt(Math.round(options.limitMs * 1e6)));
|
|
112
112
|
const data = { buffer, checkMs: options.checkMs, instance: process.pid, ownCgroup: options.ownCgroup === true };
|
|
113
|
-
|
|
113
|
+
// No inherited Node flags: the source is self-contained CommonJS and needs none of the host's, and some would make Node
|
|
114
|
+
// read it as ESM (`--input-type=module`, a host running inline ESM through -e or stdin), where `require` is undefined.
|
|
115
|
+
this.#worker = new Worker(WORKER_SOURCE, { eval: true, workerData: data, execArgv: [] });
|
|
114
116
|
this.#worker.unref();
|
|
115
117
|
}
|
|
116
118
|
/** A heartbeat write that started at `startNs` succeeded. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parcha/pi-durable-disk",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.13",
|
|
4
4
|
"description": "A durable pi host on a network disk (Archil): one owner per run, a fence, durable commits, and a supervisor.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"build": "rm -rf dist && tsc -p tsconfig.build.json && node scripts/rewrite-dts.mjs dist",
|
|
50
50
|
"prepack": "npm run build",
|
|
51
51
|
"typecheck": "tsc --noEmit && tsc --noEmit -p examples",
|
|
52
|
-
"test": "node --test 'test/*.test.ts'",
|
|
52
|
+
"test": "node --test --test-timeout=240000 'test/*.test.ts' 'test/examples/*.test.ts'",
|
|
53
53
|
"test:acceptance": "node --test --test-concurrency=1 'test/acceptance/*.test.ts'",
|
|
54
54
|
"test:live": "node --test --test-concurrency=1 'test/live/*.test.ts'",
|
|
55
55
|
"test:live:claim": "node --test --test-concurrency=1 test/live/claim.live.test.ts",
|
|
@@ -62,16 +62,16 @@
|
|
|
62
62
|
"verify:tarball": "node scripts/verify-tarball.mjs"
|
|
63
63
|
},
|
|
64
64
|
"peerDependencies": {
|
|
65
|
-
"@earendil-works/chord": "
|
|
66
|
-
"@earendil-works/pi-durable": "
|
|
65
|
+
"@earendil-works/chord": ">=1.0.4 <1.3.0",
|
|
66
|
+
"@earendil-works/pi-durable": ">=1.0.4 <1.3.0"
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
69
|
"disk": "1.7.0"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
|
-
"@earendil-works/chord": "1.0
|
|
73
|
-
"@earendil-works/pi-ai": "1.0
|
|
74
|
-
"@earendil-works/pi-durable": "1.0
|
|
72
|
+
"@earendil-works/chord": "1.1.0",
|
|
73
|
+
"@earendil-works/pi-ai": "1.1.0",
|
|
74
|
+
"@earendil-works/pi-durable": "1.1.0"
|
|
75
75
|
},
|
|
76
76
|
"repository": {
|
|
77
77
|
"type": "git",
|