@parcha/pi-durable-disk 0.1.0-beta.12 → 0.1.0-beta.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +145 -0
- package/README.md +184 -11
- package/dist/app.d.ts +5 -0
- package/dist/app.js +3 -2
- package/dist/claim.d.ts +28 -1
- package/dist/claim.js +74 -3
- package/dist/cli.js +2 -1
- package/dist/commands.d.ts +67 -0
- package/dist/commands.js +299 -0
- package/dist/confine.d.ts +11 -0
- package/dist/confine.js +24 -2
- package/dist/env.d.ts +10 -4
- package/dist/env.js +50 -10
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +1 -1
- package/dist/hosts/daytona-launch.js +3 -1
- package/dist/hosts/local-host.js +5 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lease.d.ts +2 -2
- package/dist/lease.js +1 -1
- package/dist/local-run.d.ts +10 -0
- package/dist/local-run.js +63 -0
- package/dist/park.d.ts +4 -3
- package/dist/park.js +4 -3
- package/dist/run.d.ts +42 -10
- package/dist/run.js +39 -7
- package/dist/supervise.d.ts +3 -3
- package/dist/supervise.js +15 -13
- package/dist/watchdog.js +4 -2
- package/package.json +7 -7
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** The environment variable that marks a command as one environment's: its value is that recorder's token. */
|
|
2
|
+
export declare const COMMANDS_MARK = "PI_DURABLE_DISK_COMMANDS";
|
|
3
|
+
/** One process, as recorded: a pid alone could be reused, a pid with its start ticks on one boot cannot. */
|
|
4
|
+
export interface ProcessMark {
|
|
5
|
+
readonly pid: number;
|
|
6
|
+
readonly start: number;
|
|
7
|
+
}
|
|
8
|
+
/** What a record holds: a command's group leader (null once only members of it outlived a reap) and the descendants seen. */
|
|
9
|
+
export interface CommandRecord {
|
|
10
|
+
readonly boot: string;
|
|
11
|
+
readonly leader: ProcessMark | null;
|
|
12
|
+
readonly members: readonly ProcessMark[];
|
|
13
|
+
}
|
|
14
|
+
/** What a reap did: how many processes it signalled, and those still alive at its deadline (kept for the next open). */
|
|
15
|
+
export interface Reaped {
|
|
16
|
+
readonly signalled: number;
|
|
17
|
+
readonly survivors: readonly ProcessMark[];
|
|
18
|
+
}
|
|
19
|
+
export interface CommandsOptions {
|
|
20
|
+
/** The `/proc` to read; for tests. */
|
|
21
|
+
readonly proc?: string;
|
|
22
|
+
/** How a reap signals; default `process.kill`. For tests. */
|
|
23
|
+
readonly kill?: (target: number, signal: NodeJS.Signals) => void;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Records this environment's command groups under this process while commands run. `started()` and `finished()` bracket
|
|
27
|
+
* each command, which carries `COMMANDS_MARK=<token>` in its environment; while any runs, the tree is walked soon after a
|
|
28
|
+
* start and then every `everyMs`. A record is written when its group gains a process and removed once nothing in it is
|
|
29
|
+
* alive. A walk that fails (a record cannot be written: a full disk) goes to `onError` the first time, never out of a
|
|
30
|
+
* timer, and the next walk tries again.
|
|
31
|
+
*/
|
|
32
|
+
export declare class CommandRecorder {
|
|
33
|
+
#private;
|
|
34
|
+
/** The value of `COMMANDS_MARK` in this environment's commands. */
|
|
35
|
+
readonly token: `${string}-${string}-${string}-${string}-${string}`;
|
|
36
|
+
constructor(dir: string, options?: CommandsOptions & {
|
|
37
|
+
readonly instance?: number;
|
|
38
|
+
readonly everyMs?: number;
|
|
39
|
+
readonly onError?: (error: unknown) => void;
|
|
40
|
+
});
|
|
41
|
+
/** A command is about to start. */
|
|
42
|
+
started(): void;
|
|
43
|
+
/** A command ended (its result is in). The last one stops the walks after a final one. */
|
|
44
|
+
finished(): void;
|
|
45
|
+
/** Stops recording (the environment closed); the records stay for the next open. */
|
|
46
|
+
close(): void;
|
|
47
|
+
/** One walk. It is called from timers, so it never throws: a failure goes to `onError` once, and the next walk retries. */
|
|
48
|
+
scan(): void;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Ends what a previous incarnation's commands left running: for each record of this boot, the leader's whole group when
|
|
52
|
+
* the leader is still the recorded process, and each recorded descendant that still is. Waits up to `timeoutMs` until
|
|
53
|
+
* none of them is alive. A record whose processes are all gone is removed; one that still names a live process (it did
|
|
54
|
+
* not die at the deadline: a process in an uninterruptible wait) is rewritten with just those, so the next open tries
|
|
55
|
+
* again, and they are returned. Run only while holding the run (after its owner lock), before the Harness opens.
|
|
56
|
+
*/
|
|
57
|
+
export declare function reapCommands(dir: string, options?: CommandsOptions & {
|
|
58
|
+
readonly timeoutMs?: number;
|
|
59
|
+
}): Promise<Reaped>;
|
|
60
|
+
/**
|
|
61
|
+
* `reapCommands` at an open: refuses the run (`HeldError("commands")`, exit 76) when a process a killed incarnation's
|
|
62
|
+
* commands left is still alive after it, since the run must not resume beside it. Its record stays, and the next open
|
|
63
|
+
* tries again.
|
|
64
|
+
*/
|
|
65
|
+
export declare function reapOrRefuse(dir: string, options?: CommandsOptions & {
|
|
66
|
+
readonly timeoutMs?: number;
|
|
67
|
+
}): Promise<Reaped>;
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// The commands a run's environment starts, recorded where the next open of the same claim finds them. pi makes each
|
|
2
|
+
// command the leader of a process group of its own, so killing an instance does not reach its commands: a host's unit
|
|
3
|
+
// cgroup or a container ends them with it, and a run on a local directory has neither. While commands run, the instance
|
|
4
|
+
// walks its own process tree (`/proc/<pid>/task/<tid>/children`) and records each of its commands' group leader and every
|
|
5
|
+
// descendant it has seen, each by pid, start ticks and boot. Its commands are the children that lead their own group and
|
|
6
|
+
// carry its mark (COMMANDS_MARK=<its token>) in their environment: another environment's commands in the same process,
|
|
7
|
+
// or the app's own helpers, are not its to record. The next open, which holds the owner lock, kills whatever a
|
|
8
|
+
// previous incarnation recorded that is still the same process (pid, start ticks and boot all match), waits until it is
|
|
9
|
+
// gone, and clears the records, before the Harness opens. Only the instance writes records: a command never does.
|
|
10
|
+
// Nothing stops a command from writing one too, and none needs stopping: a run's commands run as the instance's user (or,
|
|
11
|
+
// with `runAs`, as another one that cannot write the instance's 0700 directory), so a record a command forged names only
|
|
12
|
+
// what that command could already kill itself. A record grants nothing.
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { HeldError } from "./errors.js";
|
|
17
|
+
/** The environment variable that marks a command as one environment's: its value is that recorder's token. */
|
|
18
|
+
export const COMMANDS_MARK = "PI_DURABLE_DISK_COMMANDS";
|
|
19
|
+
/** `/proc/<pid>/stat`: fields after the last `)` (the command name may hold spaces and parentheses); a zombie is gone. */
|
|
20
|
+
function stat(proc, pid) {
|
|
21
|
+
try {
|
|
22
|
+
const text = readFileSync(join(proc, String(pid), "stat"), "utf8");
|
|
23
|
+
const fields = text.slice(text.lastIndexOf(")") + 2).split(" ");
|
|
24
|
+
if (fields[0] === "Z" || fields[0] === "X")
|
|
25
|
+
return undefined;
|
|
26
|
+
return { ppid: Number(fields[1]), pgrp: Number(fields[2]), start: Number(fields[19]) };
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function bootId(proc) {
|
|
33
|
+
try {
|
|
34
|
+
return readFileSync(join(proc, "sys/kernel/random/boot_id"), "utf8").trim() || undefined;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** The direct children of `pid`, from each of its threads' `children` list. */
|
|
41
|
+
function children(proc, pid) {
|
|
42
|
+
let tasks;
|
|
43
|
+
try {
|
|
44
|
+
tasks = readdirSync(join(proc, String(pid), "task"));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
return tasks.flatMap((tid) => {
|
|
50
|
+
try {
|
|
51
|
+
return readFileSync(join(proc, String(pid), "task", tid, "children"), "utf8").split(" ").filter(Boolean).map(Number);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** Whether the process `pid` was started with `name=value` in its environment (false when it cannot be read). */
|
|
59
|
+
function marked(proc, pid, name, value) {
|
|
60
|
+
try {
|
|
61
|
+
return readFileSync(join(proc, String(pid), "environ"), "utf8").split("\0").includes(`${name}=${value}`);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Whether `mark` is still the process it was: same pid, same start ticks, not a zombie. Never pid 1 or below. */
|
|
68
|
+
function same(proc, mark) {
|
|
69
|
+
return mark.pid > 1 && stat(proc, mark.pid)?.start === mark.start;
|
|
70
|
+
}
|
|
71
|
+
/** A process's command line for a message, best effort. */
|
|
72
|
+
function commandLine(proc, pid) {
|
|
73
|
+
try {
|
|
74
|
+
return readFileSync(join(proc, String(pid), "cmdline"), "utf8").split("\0").filter(Boolean).join(" ") || "?";
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return "?";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const RECORD = /^(\d+)-(\d+)\.json$/;
|
|
81
|
+
/**
|
|
82
|
+
* Records this environment's command groups under this process while commands run. `started()` and `finished()` bracket
|
|
83
|
+
* each command, which carries `COMMANDS_MARK=<token>` in its environment; while any runs, the tree is walked soon after a
|
|
84
|
+
* start and then every `everyMs`. A record is written when its group gains a process and removed once nothing in it is
|
|
85
|
+
* alive. A walk that fails (a record cannot be written: a full disk) goes to `onError` the first time, never out of a
|
|
86
|
+
* timer, and the next walk tries again.
|
|
87
|
+
*/
|
|
88
|
+
export class CommandRecorder {
|
|
89
|
+
/** The value of `COMMANDS_MARK` in this environment's commands. */
|
|
90
|
+
token = randomUUID();
|
|
91
|
+
#dir;
|
|
92
|
+
#proc;
|
|
93
|
+
#instance;
|
|
94
|
+
#everyMs;
|
|
95
|
+
#boot;
|
|
96
|
+
#onError;
|
|
97
|
+
#reported = false;
|
|
98
|
+
#records = new Map();
|
|
99
|
+
/** The records on disk, by key; a group's first walk writes its record even before it has members. */
|
|
100
|
+
#written = new Set();
|
|
101
|
+
#active = 0;
|
|
102
|
+
#timer;
|
|
103
|
+
constructor(dir, options = {}) {
|
|
104
|
+
this.#dir = dir;
|
|
105
|
+
this.#proc = options.proc ?? "/proc";
|
|
106
|
+
this.#instance = options.instance ?? process.pid;
|
|
107
|
+
this.#everyMs = options.everyMs ?? 100;
|
|
108
|
+
this.#boot = bootId(this.#proc);
|
|
109
|
+
this.#onError = options.onError;
|
|
110
|
+
}
|
|
111
|
+
/** A command is about to start. */
|
|
112
|
+
started() {
|
|
113
|
+
if (this.#boot === undefined)
|
|
114
|
+
return; // nothing could verify a record on the next open
|
|
115
|
+
if (this.#active++ === 0) {
|
|
116
|
+
this.#timer = setInterval(() => this.scan(), this.#everyMs);
|
|
117
|
+
this.#timer.unref();
|
|
118
|
+
}
|
|
119
|
+
setTimeout(() => this.scan(), 20).unref();
|
|
120
|
+
}
|
|
121
|
+
/** A command ended (its result is in). The last one stops the walks after a final one. */
|
|
122
|
+
finished() {
|
|
123
|
+
if (this.#boot === undefined || this.#active === 0)
|
|
124
|
+
return;
|
|
125
|
+
if (--this.#active === 0) {
|
|
126
|
+
clearInterval(this.#timer);
|
|
127
|
+
this.#timer = undefined;
|
|
128
|
+
this.scan();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Stops recording (the environment closed); the records stay for the next open. */
|
|
132
|
+
close() {
|
|
133
|
+
clearInterval(this.#timer);
|
|
134
|
+
this.#timer = undefined;
|
|
135
|
+
this.#active = 0;
|
|
136
|
+
}
|
|
137
|
+
/** One walk. It is called from timers, so it never throws: a failure goes to `onError` once, and the next walk retries. */
|
|
138
|
+
scan() {
|
|
139
|
+
try {
|
|
140
|
+
this.#scan();
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (this.#reported)
|
|
144
|
+
return;
|
|
145
|
+
this.#reported = true;
|
|
146
|
+
this.#onError?.(error);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** Record what is new under each of this environment's command groups, and drop the records of groups that are gone. */
|
|
150
|
+
#scan() {
|
|
151
|
+
const boot = this.#boot;
|
|
152
|
+
if (boot === undefined)
|
|
153
|
+
return;
|
|
154
|
+
const own = stat(this.#proc, this.#instance);
|
|
155
|
+
if (own === undefined)
|
|
156
|
+
return;
|
|
157
|
+
// New commands: children of this process that lead their own group (pi spawns each one so) and carry this
|
|
158
|
+
// environment's mark. Once recorded, a leader stays its environment's whatever it execs next (same pid and start).
|
|
159
|
+
for (const pid of children(this.#proc, this.#instance)) {
|
|
160
|
+
const leader = stat(this.#proc, pid);
|
|
161
|
+
if (leader === undefined || leader.pgrp !== pid || leader.pgrp === own.pgrp)
|
|
162
|
+
continue;
|
|
163
|
+
const key = `${pid}-${leader.start}`;
|
|
164
|
+
if (this.#records.has(key) || !marked(this.#proc, pid, COMMANDS_MARK, this.token))
|
|
165
|
+
continue;
|
|
166
|
+
this.#records.set(key, { leader: { pid, start: leader.start }, members: new Map() });
|
|
167
|
+
}
|
|
168
|
+
// Every record: walk from each of its processes still alive, the leader and every member, so a background worker whose
|
|
169
|
+
// leader (or parent) exited and was reparented away is still followed, and so are the children it starts later.
|
|
170
|
+
for (const [key, record] of this.#records) {
|
|
171
|
+
const live = [record.leader, ...record.members.values()].filter((mark) => same(this.#proc, mark));
|
|
172
|
+
if (live.length === 0) {
|
|
173
|
+
this.#records.delete(key);
|
|
174
|
+
this.#written.delete(key);
|
|
175
|
+
rmSync(join(this.#dir, `${key}.json`), { force: true });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
let grew = !this.#written.has(key);
|
|
179
|
+
const walk = (parent) => {
|
|
180
|
+
for (const child of children(this.#proc, parent)) {
|
|
181
|
+
const s = stat(this.#proc, child);
|
|
182
|
+
if (s === undefined || child === record.leader.pid)
|
|
183
|
+
continue;
|
|
184
|
+
const known = record.members.get(child)?.start === s.start;
|
|
185
|
+
if (!known) {
|
|
186
|
+
record.members.set(child, { pid: child, start: s.start });
|
|
187
|
+
grew = true;
|
|
188
|
+
}
|
|
189
|
+
if (!known || !live.some((mark) => mark.pid === child))
|
|
190
|
+
walk(child);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
for (const mark of live)
|
|
194
|
+
walk(mark.pid);
|
|
195
|
+
if (grew)
|
|
196
|
+
this.#write(key, { boot, leader: record.leader, members: [...record.members.values()] });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
#write(key, record) {
|
|
200
|
+
mkdirSync(this.#dir, { recursive: true, mode: 0o700 });
|
|
201
|
+
const file = join(this.#dir, `${key}.json`);
|
|
202
|
+
writeFileSync(`${file}.tmp`, JSON.stringify(record));
|
|
203
|
+
renameSync(`${file}.tmp`, file);
|
|
204
|
+
this.#written.add(key);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Ends what a previous incarnation's commands left running: for each record of this boot, the leader's whole group when
|
|
209
|
+
* the leader is still the recorded process, and each recorded descendant that still is. Waits up to `timeoutMs` until
|
|
210
|
+
* none of them is alive. A record whose processes are all gone is removed; one that still names a live process (it did
|
|
211
|
+
* not die at the deadline: a process in an uninterruptible wait) is rewritten with just those, so the next open tries
|
|
212
|
+
* again, and they are returned. Run only while holding the run (after its owner lock), before the Harness opens.
|
|
213
|
+
*/
|
|
214
|
+
export async function reapCommands(dir, options = {}) {
|
|
215
|
+
const proc = options.proc ?? "/proc";
|
|
216
|
+
const signal = options.kill ?? ((target, name) => process.kill(target, name));
|
|
217
|
+
let files;
|
|
218
|
+
try {
|
|
219
|
+
files = readdirSync(dir).filter((name) => RECORD.test(name));
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return { signalled: 0, survivors: [] };
|
|
223
|
+
}
|
|
224
|
+
const boot = bootId(proc);
|
|
225
|
+
const own = stat(proc, process.pid);
|
|
226
|
+
let signalled = 0;
|
|
227
|
+
/**
|
|
228
|
+
* False for what is never a target: this process, its group, and a pid of 1 or below, since `kill(0)` is the caller's
|
|
229
|
+
* group and `kill(-1)` every process the user may signal.
|
|
230
|
+
*/
|
|
231
|
+
const kill = (target, mark) => {
|
|
232
|
+
if (!(mark.pid > 1) || mark.pid === process.pid || (target < 0 && -target === own?.pgrp))
|
|
233
|
+
return false;
|
|
234
|
+
try {
|
|
235
|
+
signal(target, "SIGKILL");
|
|
236
|
+
signalled++;
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// gone between the check and the kill, or not ours to signal: still a target, a survivor if it is alive
|
|
240
|
+
}
|
|
241
|
+
return true;
|
|
242
|
+
};
|
|
243
|
+
const pending = [];
|
|
244
|
+
for (const name of files) {
|
|
245
|
+
const file = join(dir, name);
|
|
246
|
+
let record;
|
|
247
|
+
try {
|
|
248
|
+
record = JSON.parse(readFileSync(file, "utf8"));
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
rmSync(file, { force: true }); // a torn write of a record names nothing
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (boot === undefined || record.boot !== boot) {
|
|
255
|
+
rmSync(file, { force: true }); // another boot's pids mean nothing on this one
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const leader = record.leader !== null && same(proc, record.leader) && kill(-record.leader.pid, record.leader) ? record.leader : null;
|
|
259
|
+
// A member that is the leader went with its group.
|
|
260
|
+
const members = (record.members ?? []).filter((member) => member.pid !== record.leader?.pid && same(proc, member) && kill(member.pid, member));
|
|
261
|
+
pending.push({ file, boot, leader, members });
|
|
262
|
+
}
|
|
263
|
+
const alive = () => pending.flatMap((p) => [...(p.leader ? [p.leader] : []), ...p.members]).filter((mark) => same(proc, mark));
|
|
264
|
+
const deadline = Date.now() + (options.timeoutMs ?? 5_000);
|
|
265
|
+
while (alive().length > 0 && Date.now() < deadline)
|
|
266
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
267
|
+
const survivors = [];
|
|
268
|
+
for (const record of pending) {
|
|
269
|
+
const leader = record.leader !== null && same(proc, record.leader) ? record.leader : null;
|
|
270
|
+
const members = record.members.filter((member) => same(proc, member));
|
|
271
|
+
if (leader === null && members.length === 0) {
|
|
272
|
+
rmSync(record.file, { force: true });
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
survivors.push(...(leader ? [leader] : []), ...members);
|
|
276
|
+
writeFileSync(`${record.file}.tmp`, JSON.stringify({ boot: record.boot, leader, members }));
|
|
277
|
+
renameSync(`${record.file}.tmp`, record.file);
|
|
278
|
+
}
|
|
279
|
+
if (survivors.length === 0)
|
|
280
|
+
rmSync(dir, { recursive: true, force: true });
|
|
281
|
+
return { signalled, survivors };
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* `reapCommands` at an open: refuses the run (`HeldError("commands")`, exit 76) when a process a killed incarnation's
|
|
285
|
+
* commands left is still alive after it, since the run must not resume beside it. Its record stays, and the next open
|
|
286
|
+
* tries again.
|
|
287
|
+
*/
|
|
288
|
+
export async function reapOrRefuse(dir, options = {}) {
|
|
289
|
+
const reaped = await reapCommands(dir, options);
|
|
290
|
+
if (reaped.survivors.length > 0) {
|
|
291
|
+
const proc = options.proc ?? "/proc";
|
|
292
|
+
const pids = reaped.survivors.map((s) => s.pid);
|
|
293
|
+
throw new HeldError("commands", `${pids.length} process(es) a killed incarnation's commands started are still alive after SIGKILL: ` +
|
|
294
|
+
`${reaped.survivors.map((s) => `pid ${s.pid} (${commandLine(proc, s.pid)})`).join(", ")}. ` +
|
|
295
|
+
`The run does not resume beside them. End them (kill -9 ${pids.join(" ")}, as their owner, or wait for them) and open the run again; ` +
|
|
296
|
+
`they stay recorded in ${dir} until then.`);
|
|
297
|
+
}
|
|
298
|
+
return reaped;
|
|
299
|
+
}
|
package/dist/confine.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export declare class PathOutsideWorkError extends FileError {
|
|
|
14
14
|
/** Test seam: runs after a path was resolved and checked, before anything is opened. */
|
|
15
15
|
export declare const confineHooks: {
|
|
16
16
|
afterCheck?: (canonical: string) => Promise<void> | void;
|
|
17
|
+
/** After `createTempDir` made a directory and before it is granted (a command may act in between). */
|
|
18
|
+
afterTempDir?: (dir: string) => Promise<void> | void;
|
|
17
19
|
};
|
|
18
20
|
/** pi's mapping from a Node error to its `FileError` codes (pi does not export it). */
|
|
19
21
|
export declare function fileError(error: unknown, path: string): FileError;
|
|
@@ -61,10 +63,19 @@ export declare class Pin {
|
|
|
61
63
|
close(): Promise<void>;
|
|
62
64
|
}
|
|
63
65
|
export declare class Confinement {
|
|
66
|
+
#private;
|
|
64
67
|
readonly work: string;
|
|
65
68
|
readonly readOnly: readonly string[];
|
|
66
69
|
/** Canonical directories: `work` may be read and written, `readOnly` only read. */
|
|
67
70
|
constructor(work: string, readOnly: readonly string[]);
|
|
71
|
+
/**
|
|
72
|
+
* Lets file operations write inside `dir` too: a directory this process just created (`createTempDir`) directly in a
|
|
73
|
+
* read-only root, for the tool it is handed to. `dir` is that root joined with the new name, never resolved: if the
|
|
74
|
+
* directory is later swapped for a link, a path through it resolves elsewhere and is refused.
|
|
75
|
+
*/
|
|
76
|
+
allowWrites(dir: string): void;
|
|
77
|
+
/** `dir` was removed: writes inside it, or inside any handed directory below it, are no longer allowed. */
|
|
78
|
+
forgetWrites(dir: string): void;
|
|
68
79
|
/** Resolves `abs` and refuses it unless it lies under an allowed root; returns the root that holds it. */
|
|
69
80
|
locate(abs: string, original: string, access: "read" | "write", follow: boolean): Promise<{
|
|
70
81
|
canonical: string;
|
package/dist/confine.js
CHANGED
|
@@ -213,15 +213,32 @@ const changed = (path) => new PathOutsideWorkError(path, "changed", `${path}: a
|
|
|
213
213
|
export class Confinement {
|
|
214
214
|
work;
|
|
215
215
|
readOnly;
|
|
216
|
+
/** Directories handed out for writing after construction (the environment's temp dirs), canonical. */
|
|
217
|
+
#handed = new Set();
|
|
216
218
|
/** Canonical directories: `work` may be read and written, `readOnly` only read. */
|
|
217
219
|
constructor(work, readOnly) {
|
|
218
220
|
this.work = work;
|
|
219
221
|
this.readOnly = readOnly;
|
|
220
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Lets file operations write inside `dir` too: a directory this process just created (`createTempDir`) directly in a
|
|
225
|
+
* read-only root, for the tool it is handed to. `dir` is that root joined with the new name, never resolved: if the
|
|
226
|
+
* directory is later swapped for a link, a path through it resolves elsewhere and is refused.
|
|
227
|
+
*/
|
|
228
|
+
allowWrites(dir) {
|
|
229
|
+
if (this.readOnly.some((root) => dirname(dir) === root))
|
|
230
|
+
this.#handed.add(dir);
|
|
231
|
+
}
|
|
232
|
+
/** `dir` was removed: writes inside it, or inside any handed directory below it, are no longer allowed. */
|
|
233
|
+
forgetWrites(dir) {
|
|
234
|
+
for (const handed of this.#handed)
|
|
235
|
+
if (inside(handed, dir))
|
|
236
|
+
this.#handed.delete(handed);
|
|
237
|
+
}
|
|
221
238
|
/** Resolves `abs` and refuses it unless it lies under an allowed root; returns the root that holds it. */
|
|
222
239
|
async locate(abs, original, access, follow) {
|
|
223
240
|
const canonical = follow ? await canonicalize(abs) : join(await canonicalize(dirname(abs)), basename(abs));
|
|
224
|
-
const roots = access === "write" ? [this.work] : [this.work, ...this.readOnly];
|
|
241
|
+
const roots = access === "write" ? [this.work, ...this.#handed] : [this.work, ...this.#handed, ...this.readOnly];
|
|
225
242
|
const root = roots.find((candidate) => inside(canonical, candidate));
|
|
226
243
|
if (root === undefined) {
|
|
227
244
|
const where = canonical === abs ? "" : ` (it resolves to ${canonical})`;
|
|
@@ -235,7 +252,12 @@ export class Confinement {
|
|
|
235
252
|
await confineHooks.afterCheck?.(canonical);
|
|
236
253
|
const names = relative(root, canonical);
|
|
237
254
|
if (names.length === 0 && spec.strict === true) {
|
|
238
|
-
|
|
255
|
+
// a handed directory itself (a tool removing the temp dir it was given): pinned as an entry of the read-only root that
|
|
256
|
+
// holds it, so it can go; the workspace root never can
|
|
257
|
+
const holder = this.#handed.has(root) ? this.readOnly.find((candidate) => dirname(root) === candidate) : undefined;
|
|
258
|
+
if (holder === undefined)
|
|
259
|
+
throw new PathOutsideWorkError(original, "outside_work", `${original}: the workspace root itself cannot be removed or renamed`);
|
|
260
|
+
return new Pin(await walk(holder, [], "none", canonical), canonical, basename(root), false);
|
|
239
261
|
}
|
|
240
262
|
const name = names.pop() ?? "";
|
|
241
263
|
const handle = await walk(root, names, spec.parents === true ? "all" : "none", canonical);
|
package/dist/env.d.ts
CHANGED
|
@@ -12,6 +12,11 @@ export interface ArchilEnvClaim {
|
|
|
12
12
|
readonly work: string;
|
|
13
13
|
/** The disk's id; part of the environment id when present. */
|
|
14
14
|
readonly disk?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Where the commands this environment starts are recorded, so that the next open of the claim ends what a killed
|
|
17
|
+
* incarnation left running (`reapCommands`). Set by a claim that nothing else cleans up after: a local run's.
|
|
18
|
+
*/
|
|
19
|
+
readonly commands?: string;
|
|
15
20
|
}
|
|
16
21
|
export interface ArchilEnvOptions {
|
|
17
22
|
/** How `watch` runs. Polling is the default because a FUSE mount does not report changes made elsewhere. */
|
|
@@ -34,8 +39,8 @@ export interface ArchilEnvOptions {
|
|
|
34
39
|
readonly confineFiles?: boolean;
|
|
35
40
|
/**
|
|
36
41
|
* Directories file operations may read but never write, besides the workspace and the claim's `tmp/` (where a bash
|
|
37
|
-
* command's spilled output lives and which the bash tool names to the model
|
|
38
|
-
* material.
|
|
42
|
+
* command's spilled output lives and which the bash tool names to the model; a directory `createTempDir` returned is
|
|
43
|
+
* writable too, for the tool it was handed to). For an app's own skills or reference material.
|
|
39
44
|
*/
|
|
40
45
|
readonly readRoots?: readonly string[];
|
|
41
46
|
/**
|
|
@@ -72,7 +77,8 @@ export declare class EnvError extends PdaError {
|
|
|
72
77
|
* resumed run on another host has the same identity. Temporary files and directories (a bash command's spilled
|
|
73
78
|
* `pi-output-*.log` among them) live under `<root>/tmp/`, inside the claim and outside `work/`, so a path a result
|
|
74
79
|
* names still exists after a takeover on another host. Commands run under no_new_privs unless `noNewPrivs` is false, and
|
|
75
|
-
* every file operation is confined to the workspace (reads also to `tmp/` and `readRoots
|
|
80
|
+
* every file operation is confined to the workspace (reads also to `tmp/` and `readRoots`, writes also inside a directory
|
|
81
|
+
* `createTempDir` returned) unless `confineFiles` is false.
|
|
76
82
|
*/
|
|
77
83
|
export interface ArchilEnvFactory {
|
|
78
84
|
/** Rooted at the claim's `work/`; an agent `cwd` is resolved against it. */
|
|
@@ -101,7 +107,7 @@ export declare function replaySafe<T extends ToolRegistration>(tool: T): T;
|
|
|
101
107
|
export declare function markReadOnlySafe<T extends ToolRegistration>(tools: readonly T[], readOnly?: ReadonlySet<string>): T[];
|
|
102
108
|
/**
|
|
103
109
|
* pi's `CodingTools` with `read` declared replay-safe. It keeps pi's extension name, so installing it replaces
|
|
104
|
-
* `CodingTools` in place. `write`, `edit` and `bash` stay as pi ships them (unsafe). pi 1.0
|
|
110
|
+
* `CodingTools` in place. `write`, `edit` and `bash` stay as pi ships them (unsafe). pi 1.1.0 ships no ls, find or
|
|
105
111
|
* grep; an app that adds them declares them safe with `markReadOnlySafe`.
|
|
106
112
|
*/
|
|
107
113
|
export declare const ArchilCodingTools: Extension;
|
package/dist/env.js
CHANGED
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
// read-only built-in tools declared replay-safe, and the hook point for the workspace barrier. Tools run
|
|
5
5
|
// on the host that holds the claim, against the claimed mount.
|
|
6
6
|
import { accessSync, constants, existsSync, statSync } from "node:fs";
|
|
7
|
-
import { access, chmod, lchown, lstat, mkdir, mkdtemp, realpath, stat } from "node:fs/promises";
|
|
8
|
-
import { delimiter, dirname, join, resolve } from "node:path";
|
|
7
|
+
import { access, chmod, lchown, lstat, mkdir, mkdtemp, open, realpath, stat } from "node:fs/promises";
|
|
8
|
+
import { basename, delimiter, dirname, join, resolve } from "node:path";
|
|
9
9
|
import { withAbortSignal } from "@earendil-works/chord/context";
|
|
10
10
|
import { defineExtension, hook, ToolTask } from "@earendil-works/pi-durable";
|
|
11
11
|
import { err, ExecutionError, FileError, getOrThrow, ok } from "@earendil-works/pi-durable/env";
|
|
12
12
|
import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
|
|
13
13
|
import { CodingTools } from "@earendil-works/pi-durable/tools";
|
|
14
|
-
import {
|
|
14
|
+
import { COMMANDS_MARK, CommandRecorder } from "./commands.js";
|
|
15
|
+
import { canonicalizeSync, confineHooks, Confinement, fileError, inside } from "./confine.js";
|
|
15
16
|
import { PdaError } from "./errors.js";
|
|
16
17
|
/**
|
|
17
18
|
* What `exec` returns once the factory's `cleanup()` ran: no command was started. Its `code` is pi's `spawn_error`
|
|
@@ -50,17 +51,22 @@ class ArchilExecutionEnv extends NodeExecutionEnv {
|
|
|
50
51
|
return err(new EnvClosedError());
|
|
51
52
|
this.#active++;
|
|
52
53
|
this.#scope.running.add(this);
|
|
54
|
+
this.#scope.recorder?.started();
|
|
53
55
|
try {
|
|
54
|
-
|
|
56
|
+
// A recorded environment marks its commands, so its recorder knows them from any other child of this process.
|
|
57
|
+
const recorder = this.#scope.recorder;
|
|
58
|
+
const run = recorder === undefined ? options : { ...options, env: { ...options?.env, [COMMANDS_MARK]: recorder.token } };
|
|
59
|
+
const confined = await this.#confine(command, run, context);
|
|
55
60
|
if (!confined.ok)
|
|
56
61
|
return confined;
|
|
57
62
|
// Again after the confinement lookups, which await; the signal then covers pi's own pre-spawn work: pi refuses
|
|
58
63
|
// before spawning once it is aborted, and kills a command that spawns after.
|
|
59
64
|
if (closing.signal.aborted)
|
|
60
65
|
return err(new EnvClosedError());
|
|
61
|
-
return await super.exec(confined.value,
|
|
66
|
+
return await super.exec(confined.value, run, withAbortSignal(closing.signal, context));
|
|
62
67
|
}
|
|
63
68
|
finally {
|
|
69
|
+
this.#scope.recorder?.finished();
|
|
64
70
|
if (--this.#active === 0)
|
|
65
71
|
this.#scope.running.delete(this);
|
|
66
72
|
}
|
|
@@ -259,7 +265,15 @@ class ArchilExecutionEnv extends NodeExecutionEnv {
|
|
|
259
265
|
async remove(path, options, context) {
|
|
260
266
|
if (this.#scope.confinement === undefined)
|
|
261
267
|
return super.remove(path, options, context);
|
|
262
|
-
|
|
268
|
+
let gone;
|
|
269
|
+
const removed = await this.#entry(path, context, { access: "write", follow: false, strict: true }, async (pin) => {
|
|
270
|
+
const result = pin.restore(await super.remove(pin.entry, options, context));
|
|
271
|
+
if (result.ok)
|
|
272
|
+
gone = pin.canonical;
|
|
273
|
+
return result;
|
|
274
|
+
});
|
|
275
|
+
if (gone !== undefined)
|
|
276
|
+
this.#scope.confinement.forgetWrites(gone); // a temp dir it was given, removed: its grant goes with it
|
|
263
277
|
// `force` ignores a missing target, a missing directory above it included.
|
|
264
278
|
return !removed.ok && removed.error.code === "not_found" && options?.force === true ? ok(undefined) : removed;
|
|
265
279
|
}
|
|
@@ -283,11 +297,27 @@ class ArchilExecutionEnv extends NodeExecutionEnv {
|
|
|
283
297
|
async createTempDir(prefix, context) {
|
|
284
298
|
if (context.abortSignal?.aborted)
|
|
285
299
|
return err(new FileError("aborted", "aborted"));
|
|
286
|
-
const { tempRoot } = this.#scope;
|
|
300
|
+
const { tempRoot, confinement, tempCanonical } = this.#scope;
|
|
287
301
|
try {
|
|
288
302
|
if ((await mkdir(tempRoot, { recursive: true, mode: 0o755 })) !== undefined)
|
|
289
303
|
await chmod(tempRoot, 0o755);
|
|
290
|
-
|
|
304
|
+
if (confinement === undefined || tempCanonical === undefined)
|
|
305
|
+
return ok(await mkdtemp(join(tempRoot, prefix ?? "tmp-")));
|
|
306
|
+
// The tool it is handed to may write in it (vision writes its source there); the rest of tmp/ stays read-only. It is
|
|
307
|
+
// made through a held handle of tmp/ that does not follow a link (a tmp/ a command replaced with one is refused here),
|
|
308
|
+
// and granted by name under the canonical tmp/, never resolved (a directory swapped for a link later grants nothing).
|
|
309
|
+
const held = await open(tempRoot, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
310
|
+
let name;
|
|
311
|
+
try {
|
|
312
|
+
name = basename(await mkdtemp(`/proc/self/fd/${held.fd}/${prefix ?? "tmp-"}`));
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
await held.close();
|
|
316
|
+
}
|
|
317
|
+
const dir = join(tempRoot, name);
|
|
318
|
+
await confineHooks.afterTempDir?.(dir);
|
|
319
|
+
confinement.allowWrites(join(tempCanonical, name));
|
|
320
|
+
return ok(dir);
|
|
291
321
|
}
|
|
292
322
|
catch (error) {
|
|
293
323
|
return err(fileError(error, tempRoot));
|
|
@@ -450,7 +480,8 @@ function requireSetpriv(path) {
|
|
|
450
480
|
}
|
|
451
481
|
}
|
|
452
482
|
/**
|
|
453
|
-
* The roots, canonical once here: file operations may use the workspace, the claim's `tmp/` (read only
|
|
483
|
+
* The roots, canonical once here: file operations may use the workspace, the claim's `tmp/` (read only, but for the
|
|
484
|
+
* directories `createTempDir` hands out, which are writable) and `readRoots`.
|
|
454
485
|
* Taken at construction, before any agent runs, so a symlink an agent puts in place of `work` or `tmp` later cannot widen
|
|
455
486
|
* them: it resolves outside the roots and is refused.
|
|
456
487
|
*/
|
|
@@ -483,6 +514,14 @@ export function archilEnv(claim, options = {}) {
|
|
|
483
514
|
running,
|
|
484
515
|
closing: new AbortController(),
|
|
485
516
|
confinement,
|
|
517
|
+
tempCanonical: confinement === undefined ? undefined : join(canonicalizeSync(claim.root), TEMP_DIR),
|
|
518
|
+
// A record that cannot be written (a full disk) leaves a command the next open could miss: said once on stderr, and
|
|
519
|
+
// the recorder keeps trying; the run goes on.
|
|
520
|
+
recorder: claim.commands === undefined
|
|
521
|
+
? undefined
|
|
522
|
+
: new CommandRecorder(claim.commands, {
|
|
523
|
+
onError: (error) => process.stderr.write(`pi-durable-disk: cannot record the run's commands in ${claim.commands}: ${String(error)}\n`),
|
|
524
|
+
}),
|
|
486
525
|
};
|
|
487
526
|
const workOwned = runAs ? ownedOnce(claim.work, runAs) : undefined;
|
|
488
527
|
const build = (target) => {
|
|
@@ -497,6 +536,7 @@ export function archilEnv(claim, options = {}) {
|
|
|
497
536
|
const cleanup = async (context) => {
|
|
498
537
|
scope.closing.abort();
|
|
499
538
|
await Promise.all([...running].map((env) => env.cleanup(context)));
|
|
539
|
+
scope.recorder?.close();
|
|
500
540
|
};
|
|
501
541
|
return Object.assign(build, { id, cleanup });
|
|
502
542
|
}
|
|
@@ -514,7 +554,7 @@ export function markReadOnlySafe(tools, readOnly = READ_ONLY_TOOLS) {
|
|
|
514
554
|
}
|
|
515
555
|
/**
|
|
516
556
|
* pi's `CodingTools` with `read` declared replay-safe. It keeps pi's extension name, so installing it replaces
|
|
517
|
-
* `CodingTools` in place. `write`, `edit` and `bash` stay as pi ships them (unsafe). pi 1.0
|
|
557
|
+
* `CodingTools` in place. `write`, `edit` and `bash` stay as pi ships them (unsafe). pi 1.1.0 ships no ls, find or
|
|
518
558
|
* grep; an app that adds them declares them safe with `markReadOnlySafe`.
|
|
519
559
|
*/
|
|
520
560
|
export const ArchilCodingTools = defineExtension({
|
package/dist/errors.d.ts
CHANGED
|
@@ -20,7 +20,8 @@ export declare class FencedError extends PdaError {
|
|
|
20
20
|
code?: string;
|
|
21
21
|
});
|
|
22
22
|
}
|
|
23
|
-
|
|
23
|
+
/** `commands`: processes a killed incarnation's commands left that did not die when the next open killed them. */
|
|
24
|
+
export type Holder = "claim" | "owner-lock" | "store" | "commands";
|
|
24
25
|
/** Someone else holds the run (see `Holder`). The process exits 76. */
|
|
25
26
|
export declare class HeldError extends PdaError {
|
|
26
27
|
readonly holder: Holder;
|
package/dist/errors.js
CHANGED
|
@@ -25,7 +25,7 @@ export class FencedError extends PdaError {
|
|
|
25
25
|
super(options.code ?? "FENCED", message, { cause: options.cause, exitCode: EXIT_FENCED });
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
-
const HELD_CODES = { claim: "CLAIM_HELD", "owner-lock": "OWNER_LOCK_HELD", store: "STORE_BUSY" };
|
|
28
|
+
const HELD_CODES = { claim: "CLAIM_HELD", "owner-lock": "OWNER_LOCK_HELD", store: "STORE_BUSY", commands: "COMMANDS_ALIVE" };
|
|
29
29
|
/** Someone else holds the run (see `Holder`). The process exits 76. */
|
|
30
30
|
export class HeldError extends PdaError {
|
|
31
31
|
holder;
|
|
@@ -40,7 +40,9 @@ function writeState(path, state) {
|
|
|
40
40
|
writeFileSync(`${path}.tmp`, `${JSON.stringify(state)}\n`, { mode: 0o600 });
|
|
41
41
|
renameSync(`${path}.tmp`, path);
|
|
42
42
|
}
|
|
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
|
+
// names a group (`kill(-1)` is every process this user may signal). The state file holds whatever was written there.
|
|
45
|
+
const alive = (pid, ticks) => Number.isInteger(pid) && pid > 1 && typeof ticks === "number" && procStartTicks(pid) === ticks;
|
|
44
46
|
/** running while either process lives; otherwise stopped after a clean exit, failed after any other end. */
|
|
45
47
|
export function launchStatus(dir, name) {
|
|
46
48
|
const state = readState(dir, name);
|