@parcha/pi-durable-disk 0.1.0-beta.12 → 0.1.0-beta.14
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 +241 -0
- package/README.md +268 -28
- package/dist/app.d.ts +5 -0
- package/dist/app.js +3 -2
- package/dist/claim.d.ts +34 -2
- package/dist/claim.js +86 -5
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +98 -33
- package/dist/commands.d.ts +70 -0
- package/dist/commands.js +257 -0
- package/dist/confine.d.ts +47 -7
- package/dist/confine.js +148 -19
- package/dist/env.d.ts +17 -26
- package/dist/env.js +73 -68
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +1 -1
- package/dist/fork.d.ts +23 -0
- package/dist/fork.js +103 -3
- package/dist/hosts/daytona-launch.d.ts +3 -1
- package/dist/hosts/daytona-launch.js +30 -6
- package/dist/hosts/daytona.js +15 -3
- package/dist/hosts/local-host.d.ts +7 -1
- package/dist/hosts/local-host.js +30 -16
- package/dist/index.d.ts +31 -15
- package/dist/index.js +23 -15
- package/dist/internals.d.ts +3 -0
- package/dist/internals.js +6 -0
- package/dist/lease.d.ts +5 -2
- package/dist/lease.js +3 -1
- package/dist/local-run.d.ts +10 -0
- package/dist/local-run.js +64 -0
- package/dist/park.d.ts +4 -3
- package/dist/park.js +4 -3
- package/dist/process-table.d.ts +38 -0
- package/dist/process-table.js +158 -0
- package/dist/run.d.ts +52 -10
- package/dist/run.js +42 -7
- package/dist/status.d.ts +11 -3
- package/dist/status.js +15 -13
- package/dist/store.d.ts +14 -2
- package/dist/store.js +16 -4
- package/dist/supervise.d.ts +22 -3
- package/dist/supervise.js +40 -13
- package/dist/watchdog.d.ts +3 -1
- package/dist/watchdog.js +46 -13
- package/package.json +12 -7
package/dist/hosts/daytona.js
CHANGED
|
@@ -121,7 +121,11 @@ export class DaytonaHostError extends PdaError {
|
|
|
121
121
|
super(code, message, options);
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
|
-
|
|
124
|
+
// Shell-inert characters only: the paths go into launcher command lines. `@` admits npm's scoped package directories
|
|
125
|
+
// (`node_modules/@scope/pkg`), where the package is installed with npm and not copied to a fixed directory.
|
|
126
|
+
const BOX_PATH = /^\/[A-Za-z0-9._@/-]+$/;
|
|
127
|
+
/** A path that climbs out of where it names: never a box path, whatever else is in it. */
|
|
128
|
+
const climbs = (path) => path.split("/").includes("..");
|
|
125
129
|
const ACCOUNT = /^(?:[a-z_][a-z0-9_-]{0,31}|\d{1,10})$/;
|
|
126
130
|
const FLEET = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
127
131
|
const UP = new Set(["creating", "pulling_snapshot", "pending_build", "building_snapshot", "starting", "restoring", "resuming", "resizing", "snapshotting", "forking", "stopping", "pausing", "paused"]);
|
|
@@ -152,6 +156,14 @@ export function sandboxName(prefix, runId, now) {
|
|
|
152
156
|
return `${prefix}${slug}-${stamp}`.slice(0, 63);
|
|
153
157
|
}
|
|
154
158
|
/** The last line of a command's output that parses as JSON, or null. */
|
|
159
|
+
/**
|
|
160
|
+
* What the launcher printed, for a start it gave no reason for: the last 4 KB, or that it printed nothing. It prints only
|
|
161
|
+
* its state; the mount token is in a root-only file it reads, never in its output.
|
|
162
|
+
*/
|
|
163
|
+
function launcherOutput(text) {
|
|
164
|
+
const out = text.trim();
|
|
165
|
+
return out ? `the launcher printed: ${out.slice(-4096)}` : "the launcher printed nothing and exited 0";
|
|
166
|
+
}
|
|
155
167
|
function lastJson(text) {
|
|
156
168
|
for (const line of text.trim().split("\n").reverse()) {
|
|
157
169
|
try {
|
|
@@ -185,7 +197,7 @@ export function daytonaHost(opts) {
|
|
|
185
197
|
const now = opts.now ?? Date.now;
|
|
186
198
|
const launch = opts.launcher ?? `${packageDir}/dist/hosts/daytona-launch.js`;
|
|
187
199
|
for (const [what, value] of Object.entries({ mountRoot, node, packageDir, archil, boxDir: dir, stageDir: stage, launcher: launch })) {
|
|
188
|
-
if (!BOX_PATH.test(value))
|
|
200
|
+
if (!BOX_PATH.test(value) || climbs(value))
|
|
189
201
|
throw new DaytonaHostError("INVALID_ARGUMENT", `${what} ${JSON.stringify(value)} is not a plain absolute path`);
|
|
190
202
|
}
|
|
191
203
|
if (!ACCOUNT.test(user) || !ACCOUNT.test(group))
|
|
@@ -288,7 +300,7 @@ export function daytonaHost(opts) {
|
|
|
288
300
|
const r = await run(box, launcher("start", name, left), Math.ceil(left / 1000) + 10, "starting the instance");
|
|
289
301
|
const state = lastJson(r.result);
|
|
290
302
|
if (!state || !(state.spawned > 0)) {
|
|
291
|
-
throw new DaytonaHostError("START_FAILED", `the instance in ${name} did not start: ${state?.reason
|
|
303
|
+
throw new DaytonaHostError("START_FAILED", `the instance in ${name} did not start: ${state?.reason || launcherOutput(r.result)}`);
|
|
292
304
|
}
|
|
293
305
|
return handle;
|
|
294
306
|
}
|
|
@@ -54,10 +54,13 @@ export declare class LocalHostError extends PdaError {
|
|
|
54
54
|
cause?: unknown;
|
|
55
55
|
});
|
|
56
56
|
}
|
|
57
|
+
/** The argv of that child for `path`. */
|
|
58
|
+
export declare const statCodeArgv: (path: string) => string[];
|
|
57
59
|
/** Exits a restart cannot fix: 65 data error, 70 store head unreadable, 75 fenced, 76 held. */
|
|
58
60
|
export declare const TERMINAL_EXITS: readonly [65, 70, 75, 76];
|
|
59
61
|
/** Minimal environment, a timeout that settles even if a grandchild keeps the pipes, and the input on stdin. */
|
|
60
62
|
export declare const runCommand: Runner;
|
|
63
|
+
/** This boot's id: `/proc/sys/kernel/random/boot_id`, or `kern.bootsessionuuid` on macOS. Throws when it cannot be read. */
|
|
61
64
|
export declare function currentBootId(): string;
|
|
62
65
|
/** `systemctl show` output as a map. */
|
|
63
66
|
export declare function parseShow(text: string): Record<string, string>;
|
|
@@ -67,5 +70,8 @@ export declare function localHost(opts?: LocalHostOptions): HostDriver & {
|
|
|
67
70
|
readonly hostName: string;
|
|
68
71
|
readonly mountRoot: string;
|
|
69
72
|
};
|
|
70
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* A live process's start (field 22 of /proc/<pid>/stat, in clock ticks; on macOS the second it started), so a reused pid is
|
|
75
|
+
* never mistaken for the instance. Null for a zombie, a process that is gone, or one the table cannot be read for.
|
|
76
|
+
*/
|
|
71
77
|
export declare function procStartTicks(pid: number): number | null;
|
package/dist/hosts/local-host.js
CHANGED
|
@@ -12,14 +12,14 @@
|
|
|
12
12
|
// place reads nothing and reuses its live mount), the instance closes its stdin after reading, and stop removes it.
|
|
13
13
|
// The token is never in argv, the environment, a systemd credential directory or anything the run's user can open.
|
|
14
14
|
import { spawn } from "node:child_process";
|
|
15
|
-
import { readFileSync } from "node:fs";
|
|
16
15
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
17
16
|
import { hostname } from "node:os";
|
|
18
17
|
import { extname, join } from "node:path";
|
|
19
18
|
import { fileURLToPath } from "node:url";
|
|
20
|
-
import { ARCHIL_SCOPED, DEFAULT_MOUNT_ROOT, runPath } from "../claim.js";
|
|
19
|
+
import { ARCHIL_SCOPED, DEAD_CONNECTION, DEFAULT_MOUNT_ROOT, runPath } from "../claim.js";
|
|
21
20
|
import { EXIT_FENCED, EXIT_HELD, PdaError } from "../errors.js";
|
|
22
21
|
import { LOCAL_PARK_THRESHOLD_MS } from "../park.js";
|
|
22
|
+
import { processTable, thisBoot } from "../process-table.js";
|
|
23
23
|
export class LocalHostError extends PdaError {
|
|
24
24
|
constructor(code, message, options = {}) {
|
|
25
25
|
super(code, message, options);
|
|
@@ -32,7 +32,13 @@ const SYSTEMD_RUN = "/usr/bin/systemd-run";
|
|
|
32
32
|
const ARCHIL = "/usr/bin/archil";
|
|
33
33
|
const UNIT_SAFE = /^[A-Za-z0-9:_.-]{1,200}$/;
|
|
34
34
|
const POLITE_UNMOUNT_MS = 10_000;
|
|
35
|
-
|
|
35
|
+
/**
|
|
36
|
+
* A stat of one path in a child process, so a stuck daemon never blocks this one: prints the errno code (or "ok"), never a
|
|
37
|
+
* message, which coreutils' `stat` prints in the locale's language.
|
|
38
|
+
*/
|
|
39
|
+
const STAT_CODE = `try { require("node:fs").statSync(process.argv[1]); process.stdout.write("ok"); } catch (e) { process.stdout.write(String(e.code)); process.exitCode = 1; }`;
|
|
40
|
+
/** The argv of that child for `path`. */
|
|
41
|
+
export const statCodeArgv = (path) => [process.execPath, "-e", STAT_CODE, path];
|
|
36
42
|
const FUSERMOUNT = "/usr/bin/fusermount";
|
|
37
43
|
const UMOUNT = "/usr/bin/umount";
|
|
38
44
|
/** How long a stat of the mountpoint may take before its daemon counts as alive but stuck (never removed). */
|
|
@@ -63,8 +69,12 @@ export const runCommand = (argv, opts = {}) => new Promise((resolve, reject) =>
|
|
|
63
69
|
child.stdin.on("error", () => { });
|
|
64
70
|
child.stdin.end(opts.input ?? "");
|
|
65
71
|
});
|
|
72
|
+
/** This boot's id: `/proc/sys/kernel/random/boot_id`, or `kern.bootsessionuuid` on macOS. Throws when it cannot be read. */
|
|
66
73
|
export function currentBootId() {
|
|
67
|
-
|
|
74
|
+
const boot = thisBoot();
|
|
75
|
+
if (boot === null)
|
|
76
|
+
throw new Error("this machine's boot id cannot be read");
|
|
77
|
+
return boot;
|
|
68
78
|
}
|
|
69
79
|
const firstLine = (r) => (r.stderr.trim().split("\n").at(-1) ?? "").slice(0, 300);
|
|
70
80
|
const okRan = (r) => r.code === 0 && !r.timedOut;
|
|
@@ -234,13 +244,14 @@ export function localHost(opts = {}) {
|
|
|
234
244
|
await new Promise((r) => setTimeout(r, 100));
|
|
235
245
|
}
|
|
236
246
|
/**
|
|
237
|
-
* Whether the mount at `mp` is dead: its FUSE daemon is gone, so the
|
|
238
|
-
*
|
|
239
|
-
*
|
|
247
|
+
* Whether the mount at `mp` is dead: its FUSE daemon is gone, so the stat fails with one of `DEAD_CONNECTION`'s codes
|
|
248
|
+
* (ENOTCONN, or ECONNABORTED for a stat in flight while the dying daemon's connection is torn down). The stat runs in a
|
|
249
|
+
* child with a timeout, so a live but stuck daemon never blocks this process, and the child prints the code, not a
|
|
250
|
+
* message; a stat that answers, fails otherwise or does not finish is a live mount.
|
|
240
251
|
*/
|
|
241
252
|
async function deadMount(mp) {
|
|
242
|
-
const r = await exec(
|
|
243
|
-
return !r.timedOut && r.code !== 0 &&
|
|
253
|
+
const r = await exec(statCodeArgv(mp), { timeoutMs: STAT_TIMEOUT_MS });
|
|
254
|
+
return !r.timedOut && r.code !== 0 && DEAD_CONNECTION.has(r.stdout.trim());
|
|
244
255
|
}
|
|
245
256
|
/** Remove a dead mount: `fusermount -u`, then a lazy `umount -l`. The mount table, not an exit code, decides. */
|
|
246
257
|
async function removeDeadMount(mp) {
|
|
@@ -299,6 +310,11 @@ export function localHost(opts = {}) {
|
|
|
299
310
|
return;
|
|
300
311
|
if (h.mode === "child") {
|
|
301
312
|
const pid = Number(h.pid);
|
|
313
|
+
// A handle comes back from run.json with whatever was written there, and stop signals its pid as a group: only a
|
|
314
|
+
// real process, then. A pid of 1 or below names a group (`kill(-0)` is this supervisor's own, `kill(-1)` every
|
|
315
|
+
// process it may signal), and without start ticks nothing tells a reused pid from the instance.
|
|
316
|
+
if (!Number.isInteger(pid) || pid <= 1 || typeof h.startTicks !== "number")
|
|
317
|
+
return;
|
|
302
318
|
const alive = () => procStartTicks(pid) === h.startTicks;
|
|
303
319
|
if (!alive())
|
|
304
320
|
return;
|
|
@@ -333,19 +349,17 @@ export function localHost(opts = {}) {
|
|
|
333
349
|
},
|
|
334
350
|
};
|
|
335
351
|
}
|
|
336
|
-
/**
|
|
352
|
+
/**
|
|
353
|
+
* A live process's start (field 22 of /proc/<pid>/stat, in clock ticks; on macOS the second it started), so a reused pid is
|
|
354
|
+
* never mistaken for the instance. Null for a zombie, a process that is gone, or one the table cannot be read for.
|
|
355
|
+
*/
|
|
337
356
|
export function procStartTicks(pid) {
|
|
338
357
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
339
358
|
return null;
|
|
340
|
-
let stat;
|
|
341
359
|
try {
|
|
342
|
-
|
|
360
|
+
return processTable().snapshot().info(pid)?.start ?? null;
|
|
343
361
|
}
|
|
344
362
|
catch {
|
|
345
363
|
return null;
|
|
346
364
|
}
|
|
347
|
-
const rest = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
348
|
-
if (rest[0] === "Z" || rest[0] === "X")
|
|
349
|
-
return null;
|
|
350
|
-
return Number(rest[19]);
|
|
351
365
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,15 +1,31 @@
|
|
|
1
|
-
export
|
|
2
|
-
export
|
|
3
|
-
export {
|
|
4
|
-
export
|
|
5
|
-
export
|
|
6
|
-
export
|
|
7
|
-
export
|
|
8
|
-
export
|
|
9
|
-
export
|
|
10
|
-
export
|
|
11
|
-
export
|
|
12
|
-
export
|
|
13
|
-
export
|
|
14
|
-
export
|
|
15
|
-
export
|
|
1
|
+
export { openDurableRun, RunError, StoreBehindSealError } from "./run.js";
|
|
2
|
+
export type { BeforeResume, DurableRun, LeaseOptions, OpenDurableRunOptions, OpenRunLeaseOptions, OpenStep, ReleaseStep, RunClaim, RunEnv, RunErrorCode, RunLease, StoreHeadSource, StoreReads } from "./run.js";
|
|
3
|
+
export { localRun } from "./local-run.js";
|
|
4
|
+
export type { LocalRun } from "./local-run.js";
|
|
5
|
+
export { CLAIM_UNMOUNTED, openClaimDir, RunRecordError } from "./status.js";
|
|
6
|
+
export type { ClaimDir, OpenClaimDirOptions, PersistRecord, RunHolder, RunRecord, RunStatus } from "./status.js";
|
|
7
|
+
export { ArchilCodingTools, archilEnv, EnvClosedError, EnvError, markReadOnlySafe } from "./env.js";
|
|
8
|
+
export type { ArchilEnvClaim, ArchilEnvFactory, ArchilEnvOptions, EnvErrorCode } from "./env.js";
|
|
9
|
+
export { acquire, ARCHIL_SCOPED, revoke, takeOver } from "./claim.js";
|
|
10
|
+
export type { AcquireOptions, ArchilHost, Claim, ControlApi, PathlessResolver, RunRef, Unmounted } from "./claim.js";
|
|
11
|
+
export { PathOutsideWorkError } from "./confine.js";
|
|
12
|
+
export type { RefusalReason } from "./confine.js";
|
|
13
|
+
export { ClaimError, FencedError, HeldError, PdaError } from "./errors.js";
|
|
14
|
+
export type { ClaimErrorCode, Holder } from "./errors.js";
|
|
15
|
+
export type { ArchilStore, Fence, FencedDatabase, Profile, StoreFencedError } from "./store.js";
|
|
16
|
+
export { AppError } from "./app.js";
|
|
17
|
+
export type { AppContext, AppModule, AppOptions } from "./app.js";
|
|
18
|
+
export { archilControl, deleteRunTree, ensureRunning, readRunStatus, SuperviseError } from "./supervise.js";
|
|
19
|
+
export type { ArchilControl, CheckControl, CheckOptions, DiskClient, EnsureOptions, EnsureResult, HostDriver, HostHandle, HostStatus, Json, StartAttempt, StartReason, StonithOutcome, SuperviseErrorCode, SupervisorControl, TokenUser } from "./supervise.js";
|
|
20
|
+
export { localHost, LocalHostError } from "./hosts/local-host.js";
|
|
21
|
+
export type { LocalHostOptions, Ran, Runner } from "./hosts/local-host.js";
|
|
22
|
+
export { dockerHost, DockerHostError } from "./hosts/docker.js";
|
|
23
|
+
export type { ContainerInfo, DockerHostOptions, DockerRan, DockerRunner } from "./hosts/docker.js";
|
|
24
|
+
export { DaytonaApiError, daytonaHost, DaytonaHostError, daytonaRest } from "./hosts/daytona.js";
|
|
25
|
+
export type { CreateSandboxBody, DaytonaClient, DaytonaHostErrorCode, DaytonaHostOptions, DaytonaRestOptions, ExecResult, SandboxInfo } from "./hosts/daytona.js";
|
|
26
|
+
export { requestRun, ServeConfigError, ServeError, serveRun } from "./serve.js";
|
|
27
|
+
export type { RequestOptions, RunResponse, RunServer, ServeConfigErrorCode, ServeErrorCode, ServeOptions } from "./serve.js";
|
|
28
|
+
export { busyState, drain, leaseParkTarget, watchParking } from "./park.js";
|
|
29
|
+
export type { BusyState, DrainOptions, Parking, ParkOptions, ParkResult, ParkTarget } from "./park.js";
|
|
30
|
+
export { fork, ForkError, forkLocal } from "./fork.js";
|
|
31
|
+
export type { ForkErrorCode, ForkLocalResult, ForkOptions, ForkResult } from "./fork.js";
|
package/dist/index.js
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
// The package's public API. Every name is listed: a new export is a reviewed line here, and test/entries.test.ts pins the
|
|
2
|
+
// list of each entry. `./lease` is the narrow entry for a host that owns its Harness; `./internals` holds plumbing that
|
|
3
|
+
// examples and demos use, with no compatibility promise.
|
|
4
|
+
// The run: open it, on a disk or a local directory, with its environment, its claim and the errors it fails with.
|
|
5
|
+
export { openDurableRun, RunError, StoreBehindSealError } from "./run.js";
|
|
6
|
+
export { localRun } from "./local-run.js";
|
|
7
|
+
export { CLAIM_UNMOUNTED, openClaimDir, RunRecordError } from "./status.js";
|
|
8
|
+
export { ArchilCodingTools, archilEnv, EnvClosedError, EnvError, markReadOnlySafe } from "./env.js";
|
|
9
|
+
export { acquire, ARCHIL_SCOPED, revoke, takeOver } from "./claim.js";
|
|
4
10
|
export { PathOutsideWorkError } from "./confine.js";
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
export
|
|
10
|
-
export
|
|
11
|
-
export
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
export
|
|
15
|
-
|
|
16
|
-
export
|
|
11
|
+
export { ClaimError, FencedError, HeldError, PdaError } from "./errors.js";
|
|
12
|
+
// The app contract: what `--app` names.
|
|
13
|
+
export { AppError } from "./app.js";
|
|
14
|
+
// The supervisor and the host drivers.
|
|
15
|
+
export { archilControl, deleteRunTree, ensureRunning, readRunStatus, SuperviseError } from "./supervise.js";
|
|
16
|
+
export { localHost, LocalHostError } from "./hosts/local-host.js";
|
|
17
|
+
export { dockerHost, DockerHostError } from "./hosts/docker.js";
|
|
18
|
+
export { DaytonaApiError, daytonaHost, DaytonaHostError, daytonaRest } from "./hosts/daytona.js";
|
|
19
|
+
// Serve: one HTTP request runs one turn.
|
|
20
|
+
export { requestRun, ServeConfigError, ServeError, serveRun } from "./serve.js";
|
|
21
|
+
// Parking: an idle run gives its host back.
|
|
22
|
+
export { busyState, drain, leaseParkTarget, watchParking } from "./park.js";
|
|
23
|
+
// Fork: a new run from a run's committed state.
|
|
24
|
+
export { fork, ForkError, forkLocal } from "./fork.js";
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { createRunDir, findDelegations, mintMountToken, pathlessResolver, removeMountToken, runPath, unmountClaim } from "./claim.js";
|
|
2
|
+
export { LABEL_FLEET, LABEL_RUN, sandboxName, sandboxStatus, sweepSandboxes } from "./hosts/daytona.js";
|
|
3
|
+
export { isLeaseFresh, parseRunRecord, RUN_JSON } from "./status.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// `@parcha/pi-durable-disk/internals`: the claim, status and Daytona plumbing that this repository's examples and demos
|
|
2
|
+
// use. No compatibility promise: a name here may change or go in any release, and the CHANGELOG says when. A name moves
|
|
3
|
+
// to the root entry when something outside this repository needs it.
|
|
4
|
+
export { createRunDir, findDelegations, mintMountToken, pathlessResolver, removeMountToken, runPath, unmountClaim } from "./claim.js";
|
|
5
|
+
export { LABEL_FLEET, LABEL_RUN, sandboxName, sandboxStatus, sweepSandboxes } from "./hosts/daytona.js";
|
|
6
|
+
export { isLeaseFresh, parseRunRecord, RUN_JSON } from "./status.js";
|
package/dist/lease.d.ts
CHANGED
|
@@ -3,5 +3,8 @@ 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";
|
|
8
|
+
export { busyState, drain, leaseParkTarget, watchParking } from "./park.js";
|
|
9
|
+
export type { BusyState, DrainOptions, Parking, ParkOptions, ParkResult, ParkTarget } from "./park.js";
|
|
10
|
+
export type { DurableRun, RunEnv } from "./run.js";
|
package/dist/lease.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
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";
|
|
7
|
+
// Parking, for a Harness the host opened over `openRunLease` (`leaseParkTarget`).
|
|
8
|
+
export { busyState, drain, leaseParkTarget, watchParking } from "./park.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,64 @@
|
|
|
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
|
+
// On macOS, for development: it has no /proc/self/fd and no setpriv, so there the run's root is addressed by its path
|
|
12
|
+
// (openClaimDir's `byPath`), commands keep the user's rights, the file tools are confined by path (confine.ts), and the
|
|
13
|
+
// commands are recorded from ps (process-table.ts). The README's "On macOS" says what that costs.
|
|
14
|
+
import { mkdir } from "node:fs/promises";
|
|
15
|
+
import { join, resolve } from "node:path";
|
|
16
|
+
import { FencedError } from "./errors.js";
|
|
17
|
+
import { openClaimDir } from "./status.js";
|
|
18
|
+
/** `openDurableRun`'s `acquire` and `claimDir` for a run in `root`, a directory on this machine. */
|
|
19
|
+
export function localRun(root) {
|
|
20
|
+
const dir = resolve(root);
|
|
21
|
+
return {
|
|
22
|
+
acquire: async ({ ref }) => {
|
|
23
|
+
await mkdir(dir, { recursive: true });
|
|
24
|
+
return new LocalClaim(ref, dir);
|
|
25
|
+
},
|
|
26
|
+
claimDir: (at) => openClaimDir(at, { fstype: null }),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
class LocalClaim {
|
|
30
|
+
ref;
|
|
31
|
+
disk;
|
|
32
|
+
root;
|
|
33
|
+
work;
|
|
34
|
+
store;
|
|
35
|
+
/** `<root>/commands`: a local run has no unit cgroup, so the next open ends the commands a killed one left running. */
|
|
36
|
+
commands;
|
|
37
|
+
reused = false;
|
|
38
|
+
stray = null;
|
|
39
|
+
forced = false;
|
|
40
|
+
timings = { mountMs: 0, verifyMs: 0 };
|
|
41
|
+
#fence;
|
|
42
|
+
constructor(ref, root) {
|
|
43
|
+
this.ref = ref;
|
|
44
|
+
this.disk = ref.disk;
|
|
45
|
+
this.root = root;
|
|
46
|
+
this.work = join(root, "work");
|
|
47
|
+
this.store = join(root, "store");
|
|
48
|
+
this.commands = join(root, "commands");
|
|
49
|
+
}
|
|
50
|
+
get fenced() {
|
|
51
|
+
return this.#fence !== undefined;
|
|
52
|
+
}
|
|
53
|
+
markFenced(cause) {
|
|
54
|
+
this.#fence ??= { cause };
|
|
55
|
+
}
|
|
56
|
+
async barrier() {
|
|
57
|
+
if (this.#fence)
|
|
58
|
+
throw new FencedError(`${this.root} is fenced; nothing is retried on it`, { cause: this.#fence.cause });
|
|
59
|
+
return { ms: 0 };
|
|
60
|
+
}
|
|
61
|
+
async release() {
|
|
62
|
+
return { via: "none" };
|
|
63
|
+
}
|
|
64
|
+
}
|
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))
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** A live process (a zombie is not one): its parent, its group, and its start. */
|
|
2
|
+
export interface ProcessInfo {
|
|
3
|
+
readonly ppid: number;
|
|
4
|
+
readonly pgrp: number;
|
|
5
|
+
readonly start: number;
|
|
6
|
+
}
|
|
7
|
+
/** One look at the processes. Through ps, every `info` and `children` of one snapshot sees the same listing; /proc is read live. */
|
|
8
|
+
export interface ProcessSnapshot {
|
|
9
|
+
info(pid: number): ProcessInfo | undefined;
|
|
10
|
+
/** The direct children of `pid`. */
|
|
11
|
+
children(pid: number): number[];
|
|
12
|
+
/** Whether `pid` was started with `name=value` in its environment (false when it cannot be read). */
|
|
13
|
+
marked(pid: number, name: string, value: string): boolean;
|
|
14
|
+
/** Its command line, for a message; `?` when it cannot be read. */
|
|
15
|
+
commandLine(pid: number): string;
|
|
16
|
+
}
|
|
17
|
+
export interface ProcessTable {
|
|
18
|
+
/** A new look. Through ps it throws when the listing cannot be had or read: no process is ever taken for gone on a failed read. */
|
|
19
|
+
snapshot(): ProcessSnapshot;
|
|
20
|
+
/** This boot's id; undefined when it cannot be read. */
|
|
21
|
+
bootId(): string | undefined;
|
|
22
|
+
/** How often a recorder's walk is worth repeating: a /proc read is cheap, a ps listing is not. */
|
|
23
|
+
readonly walkMs: number;
|
|
24
|
+
}
|
|
25
|
+
/** The table on Linux: `/proc` (or another directory laid out like it, for tests). */
|
|
26
|
+
export declare function procTable(proc?: string): ProcessTable;
|
|
27
|
+
/** Runs a program with arguments and returns what it printed; throws when it fails. */
|
|
28
|
+
export type CommandRunner = (file: string, args: readonly string[]) => string;
|
|
29
|
+
/** The table on macOS: `ps` and `sysctl`, through `run` (for tests). */
|
|
30
|
+
export declare function psTable(run?: CommandRunner): ProcessTable;
|
|
31
|
+
/** Test seam: a table that stands in for this machine's everywhere `processTable()` is read. */
|
|
32
|
+
export declare const processTableHooks: {
|
|
33
|
+
table?: ProcessTable;
|
|
34
|
+
};
|
|
35
|
+
/** This machine's table: ps on macOS, /proc elsewhere. */
|
|
36
|
+
export declare function processTable(): ProcessTable;
|
|
37
|
+
/** This boot's id on this machine, null when it cannot be read. */
|
|
38
|
+
export declare function thisBoot(): string | null;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// The process table a run reads to know its commands and its holder. On Linux it is /proc, read live. macOS has no /proc:
|
|
2
|
+
// there it is one ps(1) listing per look, ps for a process's environment, and sysctl(8) for the boot. A process's start
|
|
3
|
+
// tells it from a later process with the same pid: on Linux its start time in clock ticks after boot (field 22 of
|
|
4
|
+
// /proc/<pid>/stat), on macOS the second it started (ps's `lstart`, read in UTC). A start is only ever compared with
|
|
5
|
+
// another read on the same machine and boot. On macOS a pid freed and handed out again within the same second would pass
|
|
6
|
+
// for the old process; pids there are handed out in order up to 99999 before one is reused.
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
/** The table on Linux: `/proc` (or another directory laid out like it, for tests). */
|
|
11
|
+
export function procTable(proc = "/proc") {
|
|
12
|
+
const read = (...parts) => readFileSync(join(proc, ...parts), "utf8");
|
|
13
|
+
const snapshot = {
|
|
14
|
+
// `stat`'s fields after the last `)`, since the command name may hold spaces and parentheses.
|
|
15
|
+
info(pid) {
|
|
16
|
+
try {
|
|
17
|
+
const text = read(String(pid), "stat");
|
|
18
|
+
const fields = text.slice(text.lastIndexOf(")") + 2).split(" ");
|
|
19
|
+
if (fields[0] === "Z" || fields[0] === "X")
|
|
20
|
+
return undefined;
|
|
21
|
+
return { ppid: Number(fields[1]), pgrp: Number(fields[2]), start: Number(fields[19]) };
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
// From each of its threads' `children` list.
|
|
28
|
+
children(pid) {
|
|
29
|
+
let tasks;
|
|
30
|
+
try {
|
|
31
|
+
tasks = readdirSync(join(proc, String(pid), "task"));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
return tasks.flatMap((tid) => {
|
|
37
|
+
try {
|
|
38
|
+
return read(String(pid), "task", tid, "children").split(" ").filter(Boolean).map(Number);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
marked(pid, name, value) {
|
|
46
|
+
try {
|
|
47
|
+
return read(String(pid), "environ").split("\0").includes(`${name}=${value}`);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
commandLine(pid) {
|
|
54
|
+
try {
|
|
55
|
+
return read(String(pid), "cmdline").split("\0").filter(Boolean).join(" ") || "?";
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return "?";
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
snapshot: () => snapshot,
|
|
64
|
+
bootId() {
|
|
65
|
+
try {
|
|
66
|
+
return read("sys/kernel/random/boot_id").trim() || undefined;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
walkMs: 100,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const PS = "/bin/ps";
|
|
76
|
+
const SYSCTL = "/usr/sbin/sysctl";
|
|
77
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
78
|
+
/** In the C locale and UTC, so `lstart` reads the same whatever the user's settings. */
|
|
79
|
+
const runCommand = (file, args) => execFileSync(file, args, { encoding: "utf8", env: { LC_ALL: "C", TZ: "UTC" }, stdio: ["ignore", "pipe", "ignore"], timeout: 10_000, maxBuffer: 64 << 20 });
|
|
80
|
+
/**
|
|
81
|
+
* One line of `ps -A -o pid=,ppid=,pgid=,stat=,lstart=` in UTC: `pid ppid pgid stat Www Mmm dd hh:mm:ss yyyy`. Null
|
|
82
|
+
* for a zombie; throws on a line it does not read.
|
|
83
|
+
*/
|
|
84
|
+
function psLine(line) {
|
|
85
|
+
const f = line.trim().split(/\s+/);
|
|
86
|
+
const [pid, ppid, pgrp] = f.slice(0, 3).map(Number);
|
|
87
|
+
const month = MONTHS.indexOf(f[5] ?? "");
|
|
88
|
+
const [h, m, s] = (f[7] ?? "").split(":").map(Number);
|
|
89
|
+
const start = Date.UTC(Number(f[8]), month, Number(f[6]), h, m, s) / 1000;
|
|
90
|
+
if (f.length !== 9 || ![pid, ppid, pgrp, start].every(Number.isSafeInteger) || month < 0)
|
|
91
|
+
throw new Error(`ps printed a line this does not read: ${JSON.stringify(line)}`);
|
|
92
|
+
return f[3].startsWith("Z") ? null : [pid, { ppid: ppid, pgrp: pgrp, start }];
|
|
93
|
+
}
|
|
94
|
+
/** The table on macOS: `ps` and `sysctl`, through `run` (for tests). */
|
|
95
|
+
export function psTable(run = runCommand) {
|
|
96
|
+
let boot;
|
|
97
|
+
return {
|
|
98
|
+
snapshot() {
|
|
99
|
+
const rows = new Map();
|
|
100
|
+
for (const line of run(PS, ["-A", "-o", "pid=,ppid=,pgid=,stat=,lstart="]).split("\n")) {
|
|
101
|
+
if (line.trim() === "")
|
|
102
|
+
continue;
|
|
103
|
+
const row = psLine(line);
|
|
104
|
+
if (row !== null)
|
|
105
|
+
rows.set(...row);
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
info: (pid) => rows.get(pid),
|
|
109
|
+
children: (pid) => [...rows].filter(([, info]) => info.ppid === pid).map(([child]) => child),
|
|
110
|
+
// `-E` prints the environment the process was started with after its arguments, separated by spaces; a mark is
|
|
111
|
+
// a name and a UUID, which hold none.
|
|
112
|
+
marked(pid, name, value) {
|
|
113
|
+
try {
|
|
114
|
+
return run(PS, ["-ww", "-E", "-p", String(pid), "-o", "command="]).split(/\s+/).includes(`${name}=${value}`);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
commandLine(pid) {
|
|
121
|
+
try {
|
|
122
|
+
return run(PS, ["-ww", "-p", String(pid), "-o", "command="]).trim() || "?";
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return "?";
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
// Read once it can be: a boot's id does not change.
|
|
131
|
+
bootId() {
|
|
132
|
+
try {
|
|
133
|
+
boot ??= run(SYSCTL, ["-n", "kern.bootsessionuuid"]).trim() || undefined;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// not now; the next call asks again
|
|
137
|
+
}
|
|
138
|
+
return boot;
|
|
139
|
+
},
|
|
140
|
+
walkMs: 500,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/** Test seam: a table that stands in for this machine's everywhere `processTable()` is read. */
|
|
144
|
+
export const processTableHooks = {};
|
|
145
|
+
let platformTable;
|
|
146
|
+
/** This machine's table: ps on macOS, /proc elsewhere. */
|
|
147
|
+
export function processTable() {
|
|
148
|
+
if (processTableHooks.table !== undefined)
|
|
149
|
+
return processTableHooks.table;
|
|
150
|
+
if (platformTable?.platform !== process.platform) {
|
|
151
|
+
platformTable = { platform: process.platform, table: process.platform === "darwin" ? psTable() : procTable() };
|
|
152
|
+
}
|
|
153
|
+
return platformTable.table;
|
|
154
|
+
}
|
|
155
|
+
/** This boot's id on this machine, null when it cannot be read. */
|
|
156
|
+
export function thisBoot() {
|
|
157
|
+
return processTable().bootId() ?? null;
|
|
158
|
+
}
|