@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 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`. A seeder that dies stops
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 : `/proc/self/fd/${handle.fd}`;
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(`/proc/self/fd/${dir.fd}`);
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 = `/proc/self/fd/${dir.fd}/${name}`;
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 : `/proc/self/fd/${handle.fd}`, context));
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 : `/proc/self/fd/${held.fd}`}/${prefix ?? "tmp-"}`));
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;
@@ -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 (examples/reference/place.ts) structurally. The supervisor reads nothing under the run's directory: the
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. Best effort: a refused write leaves
102
- * the mark as it was. Returns the stop, which resolves once the thread has exited, so no write outlives it.
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
- // absent, unreadable or refused
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. Best effort: a refused write leaves
225
- * the mark as it was. Returns the stop, which resolves once the thread has exited, so no write outlives it.
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
- const at = byPath ? root : `/proc/self/fd/${handle.fd}`;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parcha/pi-durable-disk",
3
- "version": "0.1.0-beta.16",
3
+ "version": "0.1.0-beta.18",
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",