@parcha/pi-durable-disk 0.1.0-beta.16 → 0.1.0-beta.18
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 +13 -0
- package/README.md +2 -1
- package/dist/confine.js +4 -3
- package/dist/env.js +3 -2
- package/dist/held-fd.d.ts +5 -0
- package/dist/held-fd.js +14 -0
- package/dist/place.js +1 -1
- package/dist/run.js +1 -1
- package/dist/status.d.ts +5 -3
- package/dist/status.js +53 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.1.0-beta.17 (2026-10-18)
|
|
6
|
+
|
|
7
|
+
- **A claim directory's descriptor is closed by `close()` only, never by garbage collection (GRE-3853)**
|
|
8
|
+
- While a new run's seed waited on a promise nothing else held, the open's pending await chain was all that reached the
|
|
9
|
+
claim directory, so a collection could close its FileHandle. The start mark's keeper, which writes through
|
|
10
|
+
`/proc/self/fd/<n>` on a thread of its own, then failed silently, and the supervisor's grace ran out under a live seed.
|
|
11
|
+
A later open in the process could also be handed that number, and the keeper would have written the mark there.
|
|
12
|
+
- Every handle the package hands out as `/proc/self/fd/<n>` (the claim directory's `at`, a confined pin's paths, the walk
|
|
13
|
+
that makes them, the text line reader and the temp directory) is now held until it is closed.
|
|
14
|
+
- The keeper writes only while its directory is still the one it started on (same device and inode). If the directory
|
|
15
|
+
can't be reached or is another one, it stops. Its first failure is a process warning, code `PDA_START_MARK`, not
|
|
16
|
+
silence.
|
|
17
|
+
|
|
5
18
|
## 0.1.0-beta.16 (2026-10-18)
|
|
6
19
|
|
|
7
20
|
- **A place's outcome carries the models the run's live parts chose (`PlaceOutcome.models`)**
|
package/README.md
CHANGED
|
@@ -767,7 +767,8 @@ await openDurableRun(ref, {
|
|
|
767
767
|
(`--start-grace`, `startGraceMs`; default the lease expiry, 90 s) only while its start mark (`start.json`) is younger
|
|
768
768
|
than the grace. So while the seed runs, the instance rewrites the mark's time every heartbeat, through its claim, when a
|
|
769
769
|
supervisor's mark for generation 1 is there. It does so on a thread of its own, so a seed that holds its thread (a
|
|
770
|
-
synchronous import through `DatabaseSync`) is covered too. A live seed of any length stays `starting`.
|
|
770
|
+
synchronous import through `DatabaseSync`) is covered too. A live seed of any length stays `starting`. If the mark
|
|
771
|
+
can't be rewritten, the instance says so once, as a process warning with code `PDA_START_MARK`. A seeder that dies stops
|
|
771
772
|
rewriting, the grace runs out as for any start, and the claim is revoked. If the claim is revoked while the seed still
|
|
772
773
|
runs, its next write or its barrier is refused, the instance is fenced (exit 75) with nothing published, and the next
|
|
773
774
|
start seeds again.
|
package/dist/confine.js
CHANGED
|
@@ -20,6 +20,7 @@ import { constants, existsSync, realpathSync } from "node:fs";
|
|
|
20
20
|
import { lstat, mkdir, open, readlink, realpath, stat } from "node:fs/promises";
|
|
21
21
|
import { basename, dirname, join } from "node:path";
|
|
22
22
|
import { err, FileError, ok } from "@earendil-works/pi-durable/env";
|
|
23
|
+
import { fdPath, hold } from "./held-fd.js";
|
|
23
24
|
/**
|
|
24
25
|
* A file operation named a path that is not under the workspace (`outside_work`), or one whose components changed
|
|
25
26
|
* while it was being resolved (`changed`). pi turns it into a failed tool result that names the path. Its `code` is pi's
|
|
@@ -153,7 +154,7 @@ export class Pin {
|
|
|
153
154
|
this.name = name;
|
|
154
155
|
this.#byPath = held.byPath;
|
|
155
156
|
this.#names = held.names ?? [];
|
|
156
|
-
this.dir = held.byPath ? held.path :
|
|
157
|
+
this.dir = held.byPath ? (hold(handle), held.path) : fdPath(handle);
|
|
157
158
|
this.entry = dirMode ? this.dir : name === "" ? `${this.dir}/.` : `${this.dir}/${name}`;
|
|
158
159
|
this.#real = dirMode || name === "" ? canonical : dirname(canonical);
|
|
159
160
|
}
|
|
@@ -354,14 +355,14 @@ async function walk(root, names, create, canonical, byPath = false) {
|
|
|
354
355
|
}
|
|
355
356
|
try {
|
|
356
357
|
// The root was opened by name: it must be the directory that name denotes now, not one swapped in on the way.
|
|
357
|
-
const actual = await readlink(
|
|
358
|
+
const actual = await readlink(fdPath(dir));
|
|
358
359
|
if (actual !== root)
|
|
359
360
|
throw changed(canonical);
|
|
360
361
|
let real = root;
|
|
361
362
|
for (const name of names) {
|
|
362
363
|
if (name === "" || name === "." || name === ".." || name.includes("\0"))
|
|
363
364
|
throw changed(canonical);
|
|
364
|
-
const step =
|
|
365
|
+
const step = `${fdPath(dir)}/${name}`;
|
|
365
366
|
real = join(real, name);
|
|
366
367
|
let next;
|
|
367
368
|
try {
|
package/dist/env.js
CHANGED
|
@@ -14,6 +14,7 @@ import { CodingTools } from "@earendil-works/pi-durable/tools";
|
|
|
14
14
|
import { COMMANDS_MARK, CommandRecorder } from "./commands.js";
|
|
15
15
|
import { canonicalizeSync, confineHooks, Confinement, fileError, inside, namesHeld, PathOutsideWorkError } from "./confine.js";
|
|
16
16
|
import { PdaError } from "./errors.js";
|
|
17
|
+
import { fdPath } from "./held-fd.js";
|
|
17
18
|
/**
|
|
18
19
|
* What `exec` returns once the factory's `cleanup()` ran: no command was started. Its `code` is pi's `spawn_error`
|
|
19
20
|
* (the command could not start), `reason` tells it apart from a program that failed to spawn.
|
|
@@ -153,7 +154,7 @@ class ArchilExecutionEnv extends NodeExecutionEnv {
|
|
|
153
154
|
// pi opens the file again through the handle's own `/proc/self/fd` entry, which names that inode and no path; by path
|
|
154
155
|
// (macOS), through the path just checked.
|
|
155
156
|
const byPath = this.#scope.confinement.byPath;
|
|
156
|
-
return this.#file(path, context, { access: "read", flags: constants.O_RDONLY }, (handle, pin) => super.openTextLineReader(byPath ? pin.entry :
|
|
157
|
+
return this.#file(path, context, { access: "read", flags: constants.O_RDONLY }, (handle, pin) => super.openTextLineReader(byPath ? pin.entry : fdPath(handle), context));
|
|
157
158
|
}
|
|
158
159
|
async openBinaryReader(path, options, context) {
|
|
159
160
|
if (this.#scope.confinement === undefined)
|
|
@@ -314,7 +315,7 @@ class ArchilExecutionEnv extends NodeExecutionEnv {
|
|
|
314
315
|
// By path (macOS): made under tmp/'s path once that path is checked to still name the directory held.
|
|
315
316
|
if (confinement.byPath && !(await namesHeld(tempRoot, held)))
|
|
316
317
|
throw new PathOutsideWorkError(tempRoot, "changed", `${tempRoot}: changed while it was being resolved`);
|
|
317
|
-
name = basename(await mkdtemp(`${confinement.byPath ? tempRoot :
|
|
318
|
+
name = basename(await mkdtemp(`${confinement.byPath ? tempRoot : fdPath(held)}/${prefix ?? "tmp-"}`));
|
|
318
319
|
}
|
|
319
320
|
finally {
|
|
320
321
|
await held.close();
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { FileHandle } from "node:fs/promises";
|
|
2
|
+
/** Keeps `handle` reachable until it is closed. A handle held and never closed stays open until the process exits. */
|
|
3
|
+
export declare function hold(handle: FileHandle): FileHandle;
|
|
4
|
+
/** `/proc/self/fd/<n>` for `handle`, held until it is closed (`hold`). */
|
|
5
|
+
export declare function fdPath(handle: FileHandle): string;
|
package/dist/held-fd.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const HELD = new Set();
|
|
2
|
+
/** Keeps `handle` reachable until it is closed. A handle held and never closed stays open until the process exits. */
|
|
3
|
+
export function hold(handle) {
|
|
4
|
+
if (!HELD.has(handle)) {
|
|
5
|
+
HELD.add(handle);
|
|
6
|
+
// A FileHandle is an EventEmitter that emits `close` once closed (Node's typings leave that out).
|
|
7
|
+
handle.once("close", () => HELD.delete(handle));
|
|
8
|
+
}
|
|
9
|
+
return handle;
|
|
10
|
+
}
|
|
11
|
+
/** `/proc/self/fd/<n>` for `handle`, held until it is closed (`hold`). */
|
|
12
|
+
export function fdPath(handle) {
|
|
13
|
+
return `/proc/self/fd/${hold(handle).fd}`;
|
|
14
|
+
}
|
package/dist/place.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// kill (agentrun's reference demo) is the same for every place; the place decides how. `localPlace` runs the instance on
|
|
3
3
|
// a directory of this machine (`run --dir`, no disk and no key); `archilPlace` runs it on an Archil disk through a host
|
|
4
4
|
// driver, mints each attempt's mount token itself and takes the run over after a kill. The types match the reference's
|
|
5
|
-
// place seam (
|
|
5
|
+
// place seam (@parcha/agentrun-harness/place) structurally. The supervisor reads nothing under the run's directory: the
|
|
6
6
|
// outcome, the run's record and its delegations come through the place.
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
8
|
import { createHash, randomUUID } from "node:crypto";
|
package/dist/run.js
CHANGED
|
@@ -361,7 +361,7 @@ class Lease {
|
|
|
361
361
|
// While a new run seeds it has no `run.json` heartbeat, and a supervisor keeps a start in its grace only while its
|
|
362
362
|
// start mark is younger than the grace: the mark is kept fresh until the seed ends, through the held claim directory.
|
|
363
363
|
// A seeder that dies stops, and the grace runs out as for any start.
|
|
364
|
-
const stop = keepStartMark(this.#dir.at, this.#lease.heartbeatMs);
|
|
364
|
+
const stop = keepStartMark(this.#dir.at, this.#lease.heartbeatMs, claim.root);
|
|
365
365
|
try {
|
|
366
366
|
await seed({ root: claim.root, storePath: join(claim.store, STORE_FILE), workDir: claim.work });
|
|
367
367
|
}
|
package/dist/status.d.ts
CHANGED
|
@@ -98,10 +98,12 @@ export declare function parseStartMark(text: string): StartMark | null;
|
|
|
98
98
|
/**
|
|
99
99
|
* Keeps a supervisor's start mark for generation 1 in `dir` fresh: its `at` rewritten every `everyMs` (temp file, fsync,
|
|
100
100
|
* rename, fsync the directory), the rest of it as the supervisor wrote it; nothing is made when there is none. On a thread of
|
|
101
|
-
* its own, so a caller whose thread is blocked (a synchronous import) is kept fresh too.
|
|
102
|
-
* the
|
|
101
|
+
* its own, so a caller whose thread is blocked (a synchronous import) is kept fresh too. Only while `dir` is the directory
|
|
102
|
+
* the keeper first saw there. Best effort: a failure leaves the mark as it was, and the first one is a process warning
|
|
103
|
+
* (`PDA_START_MARK`) naming `label`; the grace then runs out as for any start. Returns the stop, which resolves once the
|
|
104
|
+
* thread has exited, so no write outlives it.
|
|
103
105
|
*/
|
|
104
|
-
export declare function keepStartMark(dir: string, everyMs: number): () => Promise<void>;
|
|
106
|
+
export declare function keepStartMark(dir: string, everyMs: number, label?: string): () => Promise<void>;
|
|
105
107
|
/** The FencedError code of a claim root that is no longer the mount the instance opened (unmounted, lazily or not). */
|
|
106
108
|
export declare const CLAIM_UNMOUNTED = "CLAIM_UNMOUNTED";
|
|
107
109
|
/** The file system type of an Archil mount in the mount table. */
|
package/dist/status.js
CHANGED
|
@@ -8,6 +8,7 @@ import { hostname } from "node:os";
|
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { Worker } from "node:worker_threads";
|
|
10
10
|
import { FencedError, ownWriteFenced, PdaError } from "./errors.js";
|
|
11
|
+
import { fdPath, hold } from "./held-fd.js";
|
|
11
12
|
import { thisBoot } from "./process-table.js";
|
|
12
13
|
export const RUN_JSON = "run.json";
|
|
13
14
|
/** The temporary file every write goes through; renamed over `run.json` once it is fsynced. */
|
|
@@ -181,17 +182,53 @@ export function parseStartMark(text) {
|
|
|
181
182
|
}
|
|
182
183
|
// The keeper's code is plain CommonJS in a string, started with `eval: true` and no inherited Node flags, as the lease
|
|
183
184
|
// watchdog's is (src/watchdog.ts says why). One refresh at a time, all synchronous: a stop (the message) is handled only
|
|
184
|
-
// between two refreshes, so none is still writing once the thread has exited.
|
|
185
|
+
// between two refreshes, so none is still writing once the thread has exited. It writes only while `dir` is still the
|
|
186
|
+
// directory it first saw (its device and inode, read on this thread, so a stalled mount never holds the caller's): a path
|
|
187
|
+
// that names another (a descriptor number reused) or none is never written, and the keeper stops. Its first failure goes
|
|
188
|
+
// to the main thread, once.
|
|
185
189
|
const KEEPER_SOURCE = `"use strict";
|
|
186
190
|
const fs = require("node:fs");
|
|
187
191
|
const path = require("node:path");
|
|
188
192
|
const { parentPort, workerData } = require("node:worker_threads");
|
|
189
193
|
const { dir, name, everyMs } = workerData;
|
|
190
194
|
const file = path.join(dir, name);
|
|
195
|
+
let first;
|
|
196
|
+
let stopped = false;
|
|
197
|
+
let reported = false;
|
|
198
|
+
const report = (message) => {
|
|
199
|
+
if (reported) return;
|
|
200
|
+
reported = true;
|
|
201
|
+
parentPort.postMessage(message);
|
|
202
|
+
};
|
|
191
203
|
function refresh() {
|
|
204
|
+
if (stopped) return;
|
|
205
|
+
let now;
|
|
206
|
+
try {
|
|
207
|
+
now = fs.statSync(dir, { bigint: true });
|
|
208
|
+
} catch (error) {
|
|
209
|
+
stopped = true;
|
|
210
|
+
return report("its directory cannot be reached (" + (error.code || error.message) + "), so it is no longer the directory the keeper started on");
|
|
211
|
+
}
|
|
212
|
+
first ??= { dev: now.dev, ino: now.ino };
|
|
213
|
+
if (now.dev !== first.dev || now.ino !== first.ino) {
|
|
214
|
+
stopped = true;
|
|
215
|
+
return report("its directory is no longer the directory the keeper started on");
|
|
216
|
+
}
|
|
217
|
+
let text;
|
|
218
|
+
try {
|
|
219
|
+
text = fs.readFileSync(file, "utf8");
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (error.code === "ENOENT") return; // no mark: nothing to keep
|
|
222
|
+
return report("reading it failed (" + (error.code || error.message) + ")");
|
|
223
|
+
}
|
|
224
|
+
let mark;
|
|
225
|
+
try {
|
|
226
|
+
mark = JSON.parse(text);
|
|
227
|
+
} catch {
|
|
228
|
+
return; // not a mark: nothing to keep
|
|
229
|
+
}
|
|
230
|
+
if (mark === null || typeof mark !== "object" || mark.generation !== 1 || typeof mark.at !== "string" || !Number.isFinite(Date.parse(mark.at))) return;
|
|
192
231
|
try {
|
|
193
|
-
const mark = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
194
|
-
if (mark === null || typeof mark !== "object" || mark.generation !== 1 || typeof mark.at !== "string" || !Number.isFinite(Date.parse(mark.at))) return;
|
|
195
232
|
const temp = file + ".tmp";
|
|
196
233
|
const fd = fs.openSync(temp, "w", 0o644);
|
|
197
234
|
try {
|
|
@@ -207,12 +244,12 @@ function refresh() {
|
|
|
207
244
|
} finally {
|
|
208
245
|
fs.closeSync(handle);
|
|
209
246
|
}
|
|
210
|
-
} catch {
|
|
211
|
-
|
|
247
|
+
} catch (error) {
|
|
248
|
+
report("rewriting it failed (" + (error.code || error.message) + ")");
|
|
212
249
|
}
|
|
213
250
|
}
|
|
214
|
-
refresh();
|
|
215
251
|
const timer = setInterval(refresh, everyMs);
|
|
252
|
+
refresh();
|
|
216
253
|
parentPort.once("message", () => {
|
|
217
254
|
clearInterval(timer);
|
|
218
255
|
parentPort.close();
|
|
@@ -221,15 +258,17 @@ parentPort.once("message", () => {
|
|
|
221
258
|
/**
|
|
222
259
|
* Keeps a supervisor's start mark for generation 1 in `dir` fresh: its `at` rewritten every `everyMs` (temp file, fsync,
|
|
223
260
|
* rename, fsync the directory), the rest of it as the supervisor wrote it; nothing is made when there is none. On a thread of
|
|
224
|
-
* its own, so a caller whose thread is blocked (a synchronous import) is kept fresh too.
|
|
225
|
-
* the
|
|
261
|
+
* its own, so a caller whose thread is blocked (a synchronous import) is kept fresh too. Only while `dir` is the directory
|
|
262
|
+
* the keeper first saw there. Best effort: a failure leaves the mark as it was, and the first one is a process warning
|
|
263
|
+
* (`PDA_START_MARK`) naming `label`; the grace then runs out as for any start. Returns the stop, which resolves once the
|
|
264
|
+
* thread has exited, so no write outlives it.
|
|
226
265
|
*/
|
|
227
|
-
export function keepStartMark(dir, everyMs) {
|
|
266
|
+
export function keepStartMark(dir, everyMs, label = dir) {
|
|
267
|
+
const warn = (why) => process.emitWarning(`the start mark of ${label} is no longer kept fresh: ${why}; a supervisor's start grace runs out as for any start`, { code: "PDA_START_MARK" });
|
|
228
268
|
const worker = new Worker(KEEPER_SOURCE, { eval: true, workerData: { dir, name: START_MARK, everyMs }, execArgv: [] });
|
|
269
|
+
worker.on("message", (why) => warn(why));
|
|
270
|
+
worker.on("error", (error) => warn(`its thread failed (${error.message})`));
|
|
229
271
|
worker.unref();
|
|
230
|
-
worker.on("error", () => {
|
|
231
|
-
// the thread failed to start or threw: nothing more is refreshed, and the grace runs out as for any start
|
|
232
|
-
});
|
|
233
272
|
const exited = new Promise((resolve) => worker.once("exit", () => resolve()));
|
|
234
273
|
return () => {
|
|
235
274
|
worker.ref();
|
|
@@ -295,7 +334,8 @@ export async function openClaimDir(root, options = {}) {
|
|
|
295
334
|
throw error instanceof FencedError ? error : unmounted(`checking the claim root ${root} failed: ${error.message}`, error);
|
|
296
335
|
}
|
|
297
336
|
const byPath = options.byPath ?? process.platform === "darwin";
|
|
298
|
-
|
|
337
|
+
// Held until close(): `at` names the directory by its number, and a collection must never free that number.
|
|
338
|
+
const at = byPath ? (hold(handle), root) : fdPath(handle);
|
|
299
339
|
let closed = false;
|
|
300
340
|
const same = (path, now, claims) => {
|
|
301
341
|
if (now.dev === claims.dev && now.ino === claims.ino)
|
package/package.json
CHANGED