@parcha/pi-durable-disk 0.1.0-beta.13 → 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 +96 -0
- package/README.md +102 -35
- package/dist/claim.d.ts +6 -1
- package/dist/claim.js +12 -2
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +96 -32
- package/dist/commands.d.ts +4 -1
- package/dist/commands.js +48 -90
- package/dist/confine.d.ts +36 -7
- package/dist/confine.js +125 -18
- package/dist/env.d.ts +7 -22
- package/dist/env.js +25 -60
- 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 +27 -5
- package/dist/hosts/daytona.js +15 -3
- package/dist/hosts/local-host.d.ts +7 -1
- package/dist/hosts/local-host.js +25 -16
- package/dist/index.d.ts +31 -16
- package/dist/index.js +23 -16
- package/dist/internals.d.ts +3 -0
- package/dist/internals.js +6 -0
- package/dist/lease.d.ts +3 -0
- package/dist/lease.js +2 -0
- package/dist/local-run.js +3 -2
- package/dist/process-table.d.ts +38 -0
- package/dist/process-table.js +158 -0
- package/dist/run.d.ts +10 -0
- package/dist/run.js +4 -1
- 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 +19 -0
- package/dist/supervise.js +25 -0
- package/dist/watchdog.d.ts +3 -1
- package/dist/watchdog.js +43 -12
- package/package.json +8 -3
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// incarnation gets the token; a restart in place reads an empty stdin and reuses its live mount, as under localHost.
|
|
14
14
|
// No cgroup per incarnation: commands a crashed instance detached can outlive it until the box is deleted.
|
|
15
15
|
import { spawn } from "node:child_process";
|
|
16
|
-
import { closeSync, existsSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { closeSync, existsSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { join, resolve } from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { procStartTicks, TERMINAL_EXITS } from "./local-host.js";
|
|
@@ -43,14 +43,17 @@ function writeState(path, state) {
|
|
|
43
43
|
// Only a real process with its start ticks: `stop` signals these pids, the instance's as a group, and a pid of 1 or below
|
|
44
44
|
// names a group (`kill(-1)` is every process this user may signal). The state file holds whatever was written there.
|
|
45
45
|
const alive = (pid, ticks) => Number.isInteger(pid) && pid > 1 && typeof ticks === "number" && procStartTicks(pid) === ticks;
|
|
46
|
-
/** running while either process lives; otherwise stopped after a clean exit,
|
|
46
|
+
/** running while either process lives and the launcher has not recorded its exit; otherwise stopped after a clean exit,
|
|
47
|
+
* failed after any other end. Every "exited" the launcher saves is final and comes after its instance ended, so once it is
|
|
48
|
+
* saved (and no instance lives) the record is the answer, even while the launcher's own process is still exiting. */
|
|
47
49
|
export function launchStatus(dir, name) {
|
|
48
50
|
const state = readState(dir, name);
|
|
49
51
|
if (!state)
|
|
50
52
|
return { status: "failed", state: null, launcherAlive: false, instanceAlive: false };
|
|
51
53
|
const launcherAlive = alive(state.launcher, state.launcherTicks);
|
|
52
54
|
const instanceAlive = alive(state.instance, state.instanceTicks);
|
|
53
|
-
|
|
55
|
+
const ended = state.phase === "exited" && !instanceAlive;
|
|
56
|
+
if (!ended && (launcherAlive || instanceAlive))
|
|
54
57
|
return { status: "running", state, launcherAlive, instanceAlive };
|
|
55
58
|
const clean = state.phase === "exited" && state.exit === 0;
|
|
56
59
|
return { status: clean ? "stopped" : "failed", state, launcherAlive, instanceAlive };
|
|
@@ -216,8 +219,12 @@ export async function stop(dir, name, timeoutMs) {
|
|
|
216
219
|
}
|
|
217
220
|
};
|
|
218
221
|
let s = launchStatus(dir, name);
|
|
219
|
-
if (s.status !== "running")
|
|
222
|
+
if (s.status !== "running") {
|
|
223
|
+
// The launcher recorded its exit and is ending on its own; a launcher process that outstays its record is still sent away.
|
|
224
|
+
if (s.launcherAlive)
|
|
225
|
+
kill(s.state.launcher, s.state.launcherTicks, "SIGTERM");
|
|
220
226
|
return s;
|
|
227
|
+
}
|
|
221
228
|
if (s.launcherAlive)
|
|
222
229
|
kill(s.state.launcher, s.state.launcherTicks, "SIGTERM");
|
|
223
230
|
else
|
|
@@ -262,7 +269,22 @@ async function main(argv) {
|
|
|
262
269
|
return 2;
|
|
263
270
|
}
|
|
264
271
|
}
|
|
265
|
-
|
|
272
|
+
/**
|
|
273
|
+
* Whether Node was started with this module. LAUNCH_SCRIPT comes from import.meta.url, which is the real path; argv[1] is
|
|
274
|
+
* the path as given, which may go through a link (a symlinked package directory, pnpm's linked node_modules).
|
|
275
|
+
*/
|
|
276
|
+
function invokedAsMain() {
|
|
277
|
+
const arg = process.argv[1];
|
|
278
|
+
if (!arg)
|
|
279
|
+
return false;
|
|
280
|
+
try {
|
|
281
|
+
return realpathSync(arg) === LAUNCH_SCRIPT;
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return resolve(arg) === LAUNCH_SCRIPT;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (invokedAsMain()) {
|
|
266
288
|
main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
|
|
267
289
|
process.stderr.write(`${err.message}\n`);
|
|
268
290
|
process.exit(1);
|
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) {
|
|
@@ -338,19 +349,17 @@ export function localHost(opts = {}) {
|
|
|
338
349
|
},
|
|
339
350
|
};
|
|
340
351
|
}
|
|
341
|
-
/**
|
|
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
|
+
*/
|
|
342
356
|
export function procStartTicks(pid) {
|
|
343
357
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
344
358
|
return null;
|
|
345
|
-
let stat;
|
|
346
359
|
try {
|
|
347
|
-
|
|
360
|
+
return processTable().snapshot().info(pid)?.start ?? null;
|
|
348
361
|
}
|
|
349
362
|
catch {
|
|
350
363
|
return null;
|
|
351
364
|
}
|
|
352
|
-
const rest = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
353
|
-
if (rest[0] === "Z" || rest[0] === "X")
|
|
354
|
-
return null;
|
|
355
|
-
return Number(rest[19]);
|
|
356
365
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,16 +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
|
|
16
|
-
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,17 +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
|
|
17
|
-
|
|
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
|
@@ -5,3 +5,6 @@ export { assertPragmas, Fence, FencedDatabase, openArchilStore, PROFILE_PRAGMAS,
|
|
|
5
5
|
export type { ArchilStore, OpenOptions, PragmaMismatch, Profile } from "./store.js";
|
|
6
6
|
export type { AcquireOptions, ArchilHost, Claim, RunRef, Unmounted } from "./claim.js";
|
|
7
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
|
@@ -4,3 +4,5 @@
|
|
|
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";
|
package/dist/local-run.js
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
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
9
|
// it had not written yet. It refuses once the claim is fenced, as a mount's does.
|
|
10
10
|
//
|
|
11
|
-
//
|
|
12
|
-
// (`
|
|
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.
|
|
13
14
|
import { mkdir } from "node:fs/promises";
|
|
14
15
|
import { join, resolve } from "node:path";
|
|
15
16
|
import { FencedError } from "./errors.js";
|
|
@@ -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
|
+
}
|
package/dist/run.d.ts
CHANGED
|
@@ -130,7 +130,15 @@ export interface BeforeResume {
|
|
|
130
130
|
readonly harness: Harness;
|
|
131
131
|
readonly context: Context;
|
|
132
132
|
readonly generation: number;
|
|
133
|
+
/** This incarnation's `run.json`, which the open has already written (`running`, this holder, `detail` null). */
|
|
133
134
|
readonly record: RunRecord;
|
|
135
|
+
/**
|
|
136
|
+
* `run.json` as it was before this open wrote over it: how the run was left. After a park it is `sleeping` with its
|
|
137
|
+
* `wakeAt` and `detail` and the seal its release wrote; after a planned stop, `paused` or `done` with the `detail` it
|
|
138
|
+
* set; after a crash, `running` with the dead holder and no new seal. On a fork's first open it is the record `fork`
|
|
139
|
+
* wrote: `paused`, generation 0, `detail.forkedFrom`. Null only when there was no `run.json` (a new run's first open).
|
|
140
|
+
*/
|
|
141
|
+
readonly previous: RunRecord | null;
|
|
134
142
|
readonly claim: RunClaim;
|
|
135
143
|
/** The store the Harness was opened over, read-only: four reads on an object of their own, and no way to a write. */
|
|
136
144
|
readonly storage: StoreReads;
|
|
@@ -171,6 +179,8 @@ export interface RunLease {
|
|
|
171
179
|
readonly record: RunRecord;
|
|
172
180
|
/** The seal the previous incarnation's clean release wrote; null on a first run and after a crash. */
|
|
173
181
|
readonly sealedSeq: number | null;
|
|
182
|
+
/** `run.json` as the open found it, before it wrote this incarnation's: null when there was none (a new run's first open). */
|
|
183
|
+
readonly previous: RunRecord | null;
|
|
174
184
|
readonly fenced: boolean;
|
|
175
185
|
/**
|
|
176
186
|
* The seal check on the app's store, before anything commits: the store's head (`storeHead`) below
|
package/dist/run.js
CHANGED
|
@@ -278,6 +278,9 @@ class Lease {
|
|
|
278
278
|
get sealedSeq() {
|
|
279
279
|
return this.#previous?.sealedSeq ?? null;
|
|
280
280
|
}
|
|
281
|
+
get previous() {
|
|
282
|
+
return this.#previous ?? null;
|
|
283
|
+
}
|
|
281
284
|
get fenced() {
|
|
282
285
|
return this.#fence !== undefined;
|
|
283
286
|
}
|
|
@@ -702,7 +705,7 @@ class Run {
|
|
|
702
705
|
// between the open and the hook, since the Harness has not resumed.
|
|
703
706
|
await step("before-resume", async () => {
|
|
704
707
|
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 });
|
|
708
|
+
await before({ harness: this.#harness, context: this.#context, generation: lease.generation, record: lease.record, previous: lease.previous, claim: lease.claim, storage: storeReads(storage), head });
|
|
706
709
|
});
|
|
707
710
|
}
|
|
708
711
|
await step("resume", () => this.#harness.resume());
|