pi-supernova 0.9.0 → 0.10.0
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/README.md +191 -14
- package/index.js +30 -9
- package/package.json +1 -1
- package/src/adapters/bash.js +40 -0
- package/src/adapters/read.js +24 -3
- package/src/bridge/host-bridge.js +50 -7
- package/src/bridge/invoke.js +6 -1
- package/src/bridge/trace.js +4 -1
- package/src/context/evidence.js +10 -1
- package/src/context/fuzzy.js +116 -43
- package/src/context/query.js +14 -5
- package/src/context/repo-index.js +15 -4
- package/src/context/snap-search.js +14 -13
- package/src/context/snap.js +11 -2
- package/src/contract/bash.js +43 -11
- package/src/contract/read.js +19 -5
- package/src/fs/background.js +304 -0
- package/src/fs/commit.js +6 -0
- package/src/fs/file-io.js +17 -2
- package/src/fs/process-tree.js +92 -0
- package/src/fs/read-window.js +18 -1
- package/src/fs/vfs.js +17 -4
- package/src/fs/workspace.js +51 -46
- package/src/output/bottleneck.js +14 -1
- package/src/runtime/guest-api.js +36 -20
- package/src/runtime/reference.js +11 -14
- package/src/shared/syntax-context.js +28 -7
package/src/fs/commit.js
CHANGED
|
@@ -60,7 +60,9 @@ async function writeTemporary(entry, content, stat) {
|
|
|
60
60
|
} catch (error) {
|
|
61
61
|
// Never leak the temporary name: name the destination and the real cause.
|
|
62
62
|
if (error?.code === "EACCES" || error?.code === "EPERM") throw new Error("permission denied writing " + entry.target + ": the directory or file is not writable");
|
|
63
|
+
|
|
63
64
|
if (error?.code === "EROFS") throw new Error("cannot write " + entry.target + ": the file system is read-only");
|
|
65
|
+
|
|
64
66
|
if (error?.code === "ENOSPC") throw new Error("cannot write " + entry.target + ": no space left on device");
|
|
65
67
|
|
|
66
68
|
throw error;
|
|
@@ -130,6 +132,9 @@ async function assertExpectedSignature(vfs, logicalPath, target, stat) {
|
|
|
130
132
|
|
|
131
133
|
async function installStaged(vfs, staged) {
|
|
132
134
|
for (const entry of staged) {
|
|
135
|
+
// Staging and earlier renames await I/O. Recheck before each disk effect;
|
|
136
|
+
// failCommit restores earlier replacements if ownership changed mid-commit.
|
|
137
|
+
vfs.assertCurrent?.();
|
|
133
138
|
vfs.signal?.throwIfAborted();
|
|
134
139
|
await fs.rename(entry.temporary, entry.target);
|
|
135
140
|
entry.replaced = true;
|
|
@@ -158,4 +163,5 @@ async function failCommit(vfs, staged, error) {
|
|
|
158
163
|
if (recoveryErrors.length) throw new AggregateError([error, ...recoveryErrors.map(message => new Error(message))], "commit failed: " + error.message + "; recovery failed: " + recoveryErrors.join("; "));
|
|
159
164
|
throw error;
|
|
160
165
|
}
|
|
166
|
+
|
|
161
167
|
export {assertExpectedSignature,collectMissingAncestors,makeStageEntry,stageReplacement,installStaged,failCommit,cleanupStaged};
|
package/src/fs/file-io.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import {createHash} from 'node:crypto';
|
|
3
4
|
|
|
4
5
|
function textSignature(text) {
|
|
@@ -19,6 +20,7 @@ async function* fileChunks(file, signal, maxBytes = Infinity) {
|
|
|
19
20
|
signal?.throwIfAborted();
|
|
20
21
|
const { bytesRead } = await file.read(buffer, 0, Math.min(buffer.length, remaining), null);
|
|
21
22
|
signal?.throwIfAborted();
|
|
23
|
+
|
|
22
24
|
if (!bytesRead) break;
|
|
23
25
|
remaining -= bytesRead;
|
|
24
26
|
// Consumers retaining a chunk must copy it before the next read.
|
|
@@ -33,6 +35,7 @@ async function fileSignature(target, signal, observed) {
|
|
|
33
35
|
const actual = await file.stat();
|
|
34
36
|
|
|
35
37
|
if (!actual.isFile()) throw new Error("read requires a regular file: " + target);
|
|
38
|
+
|
|
36
39
|
if (observed && !sameFileVersion(observed, actual)) throw new Error("file changed while reading: " + target);
|
|
37
40
|
const hash = createHash("sha256");
|
|
38
41
|
|
|
@@ -83,10 +86,21 @@ async function readLimitedBytes(file, stat, maxBytes, label, signal, overflow =
|
|
|
83
86
|
return Buffer.concat(chunks);
|
|
84
87
|
}
|
|
85
88
|
|
|
86
|
-
function
|
|
89
|
+
async function hasFileParent(target) {
|
|
90
|
+
for (let parent = path.dirname(target); ; parent = path.dirname(parent)) {
|
|
91
|
+
try { return !(await fs.stat(parent)).isDirectory(); }
|
|
92
|
+
catch (error) { if (error.code !== "ENOENT" && error.code !== "ENOTDIR") return false; }
|
|
93
|
+
|
|
94
|
+
if (parent === path.dirname(parent)) return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function remapReadError(err, target) {
|
|
87
99
|
if (err.code === "EISDIR") throw new Error("path is a directory, not a file: " + target);
|
|
88
100
|
|
|
89
|
-
|
|
101
|
+
// Windows returns ENOENT, not ENOTDIR, for a file used as a parent.
|
|
102
|
+
if (err.code === "ENOTDIR" || err.code === "ENOENT" && await hasFileParent(target)) throw new Error("cannot use path: a parent component of " + target + " is a file, not a directory");
|
|
103
|
+
|
|
90
104
|
if (err.code === "EACCES" || err.code === "EPERM") throw new Error("permission denied reading " + target + ": check the file mode (for example bash chmod)");
|
|
91
105
|
|
|
92
106
|
if (err.code === "ENOENT") {
|
|
@@ -97,4 +111,5 @@ function remapReadError(err, target) {
|
|
|
97
111
|
|
|
98
112
|
throw err;
|
|
99
113
|
}
|
|
114
|
+
|
|
100
115
|
export { textSignature, sameFileVersion, fileChunks, fileSignature, sameSignature, tooLargeRead, overlayOrThrow, assertReadableFile, readLimitedBytes, remapReadError };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
|
|
5
|
+
const exec = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
// PTYs and shell job control can create groups distinct from the original
|
|
8
|
+
// leader. Record those groups while ancestry exists, before signalling parents.
|
|
9
|
+
async function descendantGroups(pid) {
|
|
10
|
+
const { stdout } = await exec("ps", ["-ax", "-o", "pid=,ppid=,pgid="], {timeout:1000, maxBuffer:4*1024*1024});
|
|
11
|
+
const children = new Map();
|
|
12
|
+
|
|
13
|
+
for (const line of stdout.trim().split("\n")) {
|
|
14
|
+
const [child, parent, group] = line.trim().split(/\s+/).map(Number);
|
|
15
|
+
|
|
16
|
+
if (!children.has(parent)) children.set(parent, []);
|
|
17
|
+
children.get(parent).push({pid:child,group});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const found = [];
|
|
21
|
+
const pending = [...(children.get(pid) ?? [])];
|
|
22
|
+
|
|
23
|
+
while (pending.length) {
|
|
24
|
+
const child = pending.pop();
|
|
25
|
+
|
|
26
|
+
if (child.group > 0) found.push(child.group);
|
|
27
|
+
pending.push(...(children.get(child.pid) ?? []));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return found;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function signalGroups(job, signal) {
|
|
34
|
+
for (const group of job.groups) {
|
|
35
|
+
// Once SIGKILL was delivered, wait for reaping. Darwin can return EPERM
|
|
36
|
+
// when signalling a zombie again; newly discovered groups still need a kill.
|
|
37
|
+
if (signal === "SIGKILL" && job.killedGroups.has(group)) continue;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
process.kill(-group, signal);
|
|
41
|
+
|
|
42
|
+
if (signal === "SIGKILL") job.killedGroups.add(group);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error.code === "EPERM") {
|
|
45
|
+
// Even signal 0 can encounter that race. Keep ownership until ESRCH;
|
|
46
|
+
// an inaccessible live group must fail at the deadline, not look gone.
|
|
47
|
+
job.signalError = `cannot signal process group ${group} with ${signal}: ${error.message}`;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (error.code !== "ESRCH") throw error;
|
|
52
|
+
job.groups.delete(group);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Both foreground and background commands own their POSIX groups until they
|
|
58
|
+
// disappear, not merely until the leader exits or inherited output pipes close.
|
|
59
|
+
// Callers update processExited/processClosed from the real child events and may
|
|
60
|
+
// add PTY group identities while cleanup awaits. Windows remains best-effort.
|
|
61
|
+
export async function retireProcessTree(job) {
|
|
62
|
+
let discoveryError;
|
|
63
|
+
|
|
64
|
+
if (process.platform === "win32") {
|
|
65
|
+
if (!job.processExited) await exec("taskkill", ["/pid", String(job.child.pid), "/t", "/f"], {timeout:2000,windowsHide:true});
|
|
66
|
+
} else {
|
|
67
|
+
if (!job.processExited) {
|
|
68
|
+
try { for (const group of await descendantGroups(job.child.pid)) job.groups.add(group); }
|
|
69
|
+
catch (error) { discoveryError = error; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
signalGroups(job, "SIGTERM");
|
|
73
|
+
|
|
74
|
+
if (job.groups.size) await delay(150);
|
|
75
|
+
signalGroups(job, "SIGKILL");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const deadline = Date.now() + 1000;
|
|
79
|
+
|
|
80
|
+
while (true) {
|
|
81
|
+
if (process.platform !== "win32") signalGroups(job, 0);
|
|
82
|
+
|
|
83
|
+
if (job.processClosed && (process.platform === "win32" || job.groups.size === 0)) break;
|
|
84
|
+
|
|
85
|
+
if (Date.now() >= deadline) throw new Error("owned process group or output pipes did not close" + (job.signalError ? ": " + job.signalError : ""));
|
|
86
|
+
|
|
87
|
+
if (process.platform !== "win32") signalGroups(job, "SIGKILL");
|
|
88
|
+
await delay(10);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (discoveryError) throw new Error("descendant discovery failed: " + discoveryError.message);
|
|
92
|
+
}
|
package/src/fs/read-window.js
CHANGED
|
@@ -7,67 +7,84 @@ import { fileChunks, remapReadError } from "./file-io.js";
|
|
|
7
7
|
function advanceLines(bytes, start, remaining) {
|
|
8
8
|
while (remaining > 0) {
|
|
9
9
|
const newline = bytes.indexOf(10, start);
|
|
10
|
+
|
|
10
11
|
if (newline < 0) return { offset: bytes.length, remaining };
|
|
11
12
|
start = newline + 1;
|
|
12
13
|
remaining--;
|
|
13
14
|
}
|
|
15
|
+
|
|
14
16
|
return { offset: start, remaining };
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
async function scanWindow(file, stat, startLine, lineCount, maxBytes, signal) {
|
|
18
20
|
const scan = { parts: [], collected: 0, startByte: undefined, endByte: undefined };
|
|
19
21
|
let skip = startLine - 1, take = lineCount ?? Infinity, position = 0;
|
|
22
|
+
|
|
20
23
|
for await (const chunk of fileChunks(file, signal, stat.size)) {
|
|
21
24
|
const head = advanceLines(chunk, 0, skip);
|
|
22
25
|
skip = head.remaining;
|
|
23
26
|
const start = position;
|
|
24
27
|
position += chunk.length;
|
|
28
|
+
|
|
25
29
|
if (skip) continue;
|
|
26
30
|
scan.startByte ??= start + head.offset;
|
|
27
31
|
const tail = advanceLines(chunk, head.offset, take);
|
|
28
32
|
take = tail.remaining;
|
|
33
|
+
|
|
29
34
|
if (take === 0) scan.endByte = start + tail.offset;
|
|
30
35
|
const end = Math.min(tail.offset, head.offset + maxBytes + 1 - scan.collected);
|
|
36
|
+
|
|
31
37
|
if (end > head.offset) {
|
|
32
38
|
scan.parts.push(Buffer.from(chunk.subarray(head.offset, end)));
|
|
33
39
|
scan.collected += end - head.offset;
|
|
34
40
|
}
|
|
41
|
+
|
|
35
42
|
if (take === 0 || scan.collected > maxBytes) break;
|
|
36
43
|
}
|
|
44
|
+
|
|
37
45
|
return scan;
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
function overlayWindow(overlay, startLine, lineCount) {
|
|
41
49
|
const window = sliceLinesRawInfo(overlay, startLine, lineCount);
|
|
42
50
|
const satisfied = lineCount === undefined || lineCount === 0 || window.text === "" || window.count >= lineCount || window.eof;
|
|
51
|
+
|
|
43
52
|
return { text: window.text, satisfied, whole: window.whole };
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
async function openReadFile(target) {
|
|
47
56
|
try { return await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0)); }
|
|
48
|
-
catch (error) { remapReadError(error, target); }
|
|
57
|
+
catch (error) { await remapReadError(error, target); }
|
|
49
58
|
}
|
|
50
59
|
|
|
51
60
|
export function createWindowReader(vfs) {
|
|
52
61
|
return async function readWindow(target, startLine, lineCount, maxBytes, signal) {
|
|
53
62
|
const overlay = vfs.getOverlay(target);
|
|
63
|
+
|
|
54
64
|
if (overlay !== undefined) {
|
|
55
65
|
const window = overlayWindow(overlay,startLine,lineCount);
|
|
56
66
|
window.satisfied &&= Buffer.byteLength(window.text,"utf8") <= maxBytes;
|
|
67
|
+
|
|
57
68
|
return window;
|
|
58
69
|
}
|
|
70
|
+
|
|
59
71
|
const file = await openReadFile(target);
|
|
72
|
+
|
|
60
73
|
try {
|
|
61
74
|
const stat = await file.stat();
|
|
75
|
+
|
|
62
76
|
if (!stat.isFile()) throw new Error("read requires a regular file: " + target);
|
|
77
|
+
|
|
63
78
|
if (lineCount === 0) return { text: "", satisfied: true, whole: stat.size === 0 };
|
|
64
79
|
const scan = await scanWindow(file, stat, startLine, lineCount, maxBytes, signal);
|
|
80
|
+
|
|
65
81
|
if (scan.startByte === undefined) return { text: "", satisfied: true, whole: stat.size === 0 };
|
|
66
82
|
const bytes = Buffer.concat(scan.parts, scan.collected);
|
|
67
83
|
const end = scan.startByte + scan.collected;
|
|
68
84
|
const eof = end >= stat.size;
|
|
69
85
|
const text = eof ? decodeUtf8Strict(bytes, target) : decodeUtf8Window(bytes);
|
|
70
86
|
await vfs.recordExpected(target, stat);
|
|
87
|
+
|
|
71
88
|
return { text, satisfied: end >= scan.endByte || eof, whole: startLine === 1 && scan.startByte === 0 && eof };
|
|
72
89
|
} finally { await file.close(); }
|
|
73
90
|
};
|
package/src/fs/vfs.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {textSignature,sameFileVersion,fileSignature,tooLargeRead,overlayOrThrow,assertReadableFile,readLimitedBytes,remapReadError} from './file-io.js';
|
|
2
2
|
import {resolveCommitTarget,assertExpectedSignature,collectMissingAncestors,makeStageEntry,stageReplacement,installStaged,failCommit,cleanupStaged} from './commit.js';
|
|
3
|
+
|
|
3
4
|
export {resolveCommitTarget} from './commit.js';
|
|
5
|
+
|
|
4
6
|
import * as fs from "node:fs/promises";
|
|
5
7
|
import * as path from "node:path";
|
|
6
8
|
import { isString } from "../shared/decode.js";
|
|
@@ -10,8 +12,9 @@ import { decodeUtf8Strict } from "../shared/utf8.js";
|
|
|
10
12
|
let commitTail = Promise.resolve();
|
|
11
13
|
|
|
12
14
|
export class CausalVfs {
|
|
13
|
-
constructor(onNewFile, validateWrite) {
|
|
15
|
+
constructor(onNewFile, validateWrite, assertCurrent) {
|
|
14
16
|
this.validateWrite = validateWrite;
|
|
17
|
+
this.assertCurrent = assertCurrent;
|
|
15
18
|
// No body cache: every read hits disk (or its overlay) so observed bytes
|
|
16
19
|
// are never stale. CAS baselines in `expected` are the only retained
|
|
17
20
|
// per-file state, cleared only at external-mutation boundaries.
|
|
@@ -24,6 +27,8 @@ export class CausalVfs {
|
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
assertWritable() {
|
|
30
|
+
this.assertCurrent?.();
|
|
31
|
+
|
|
27
32
|
if (this.closed) throw new Error("program is already complete");
|
|
28
33
|
this.signal?.throwIfAborted();
|
|
29
34
|
}
|
|
@@ -55,6 +60,7 @@ export class CausalVfs {
|
|
|
55
60
|
bytes = maxBytes === undefined
|
|
56
61
|
? await file.readFile({ signal: this.signal })
|
|
57
62
|
: await readLimitedBytes(file, stat, maxBytes, label, this.signal, () => tooLargeRead(label,maxBytes,target));
|
|
63
|
+
|
|
58
64
|
if (!sameFileVersion(stat, await file.stat())) throw new Error("file changed while reading: " + target);
|
|
59
65
|
} finally { await file.close(); }
|
|
60
66
|
|
|
@@ -63,7 +69,7 @@ export class CausalVfs {
|
|
|
63
69
|
|
|
64
70
|
return strict ? decodeUtf8Strict(bytes, target) : bytes.toString("utf8");
|
|
65
71
|
} catch (err) {
|
|
66
|
-
remapReadError(err, target);
|
|
72
|
+
await remapReadError(err, target);
|
|
67
73
|
}
|
|
68
74
|
}
|
|
69
75
|
|
|
@@ -139,6 +145,9 @@ export class CausalVfs {
|
|
|
139
145
|
let failed = false;
|
|
140
146
|
|
|
141
147
|
try {
|
|
148
|
+
// Admission may have preceded another transaction's asynchronous commit.
|
|
149
|
+
this.assertCurrent?.();
|
|
150
|
+
|
|
142
151
|
for (const [logicalPath, content] of writes) {
|
|
143
152
|
this.signal?.throwIfAborted();
|
|
144
153
|
const { target, stat } = await resolveCommitTarget(logicalPath);
|
|
@@ -193,10 +202,14 @@ export class CausalVfs {
|
|
|
193
202
|
return { rolledBack: top?.size ?? 0, depth: this.overlays.length };
|
|
194
203
|
}
|
|
195
204
|
|
|
196
|
-
|
|
205
|
+
assertExternalAllowed(name) {
|
|
197
206
|
this.assertWritable();
|
|
198
207
|
|
|
199
|
-
if (this.overlays.length > 1) throw new Error(name + " cannot run inside an edit checkpoint because external mutations cannot be rolled back");
|
|
208
|
+
if (this.overlays.length > 1) throw new Error(name + " cannot run inside an edit checkpoint because external mutations cannot be rolled back; run bash after the checkpoint, or use explicit restoration outside checkpoints for mutation tests");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async prepareExternalMutation(name) {
|
|
212
|
+
this.assertExternalAllowed(name);
|
|
200
213
|
|
|
201
214
|
if (!this.overlays.length) { this.mutations.external++;
|
|
202
215
|
|
package/src/fs/workspace.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {remapReadError} from "./file-io.js";
|
|
2
|
+
import { retireProcessTree } from "./process-tree.js";
|
|
2
3
|
import * as fs from "node:fs/promises";
|
|
3
4
|
import * as path from "node:path";
|
|
4
5
|
import { spawn } from "node:child_process";
|
|
@@ -45,7 +46,7 @@ async function realpathNearest(target) {
|
|
|
45
46
|
try {
|
|
46
47
|
return await fs.realpath(probe);
|
|
47
48
|
} catch (err) {
|
|
48
|
-
if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") remapReadError(err, target);
|
|
49
|
+
if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") await remapReadError(err, target);
|
|
49
50
|
const parent = path.dirname(probe);
|
|
50
51
|
|
|
51
52
|
if (parent === probe) throw err;
|
|
@@ -137,21 +138,22 @@ function commandTimeoutMs(options) {
|
|
|
137
138
|
return Math.max(1, Math.min(2_147_483_647, Math.floor(requestedTimeout)));
|
|
138
139
|
}
|
|
139
140
|
|
|
140
|
-
function
|
|
141
|
-
return
|
|
142
|
-
cwd, env: options.env ?? process.env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32",
|
|
143
|
-
});
|
|
144
|
-
}
|
|
141
|
+
export function commandSpawnError(error, command) {
|
|
142
|
+
if (error?.code === "EACCES" || error?.code === "EPERM") return new Error("cannot execute " + command + ": permission denied (is it executable?)");
|
|
145
143
|
|
|
146
|
-
|
|
147
|
-
if (!child.pid) return;
|
|
144
|
+
if (error?.code === "ENOENT") return new Error("command not found: " + command);
|
|
148
145
|
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
146
|
+
if (error?.code === "ENOTDIR") return new Error("cannot run " + command + ": the working directory is not a directory");
|
|
147
|
+
|
|
148
|
+
return error;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function spawnCommand(argv, options, cwd = options.cwd) {
|
|
152
|
+
try {
|
|
153
|
+
return spawn(argv[0], argv.slice(1), {
|
|
154
|
+
cwd, env: options.env ?? process.env, stdio: options.stdio ?? ["ignore", "pipe", "pipe"], detached: process.platform !== "win32", windowsHide:true,
|
|
155
|
+
});
|
|
156
|
+
} catch (error) { throw commandSpawnError(error,argv[0]); }
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
function failCommand(state, error) {
|
|
@@ -171,11 +173,29 @@ function failCommand(state, error) {
|
|
|
171
173
|
}
|
|
172
174
|
|
|
173
175
|
function terminateCommand(state, error) {
|
|
174
|
-
if (state.settled
|
|
175
|
-
state.terminationError
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
if (state.settled) return;
|
|
177
|
+
state.terminationError ??= error;
|
|
178
|
+
|
|
179
|
+
if (state.stopping) return;
|
|
180
|
+
clearTimeout(state.timer);
|
|
181
|
+
state.stopping = retireProcessTree(state).then(()=>{
|
|
182
|
+
if (state.terminationError) {
|
|
183
|
+
failCommand(state,state.terminationError);
|
|
184
|
+
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
state.settled = true;
|
|
189
|
+
state.cleanup();
|
|
190
|
+
state.resolve({ stdout:state.stdout, stderr:state.stderr,
|
|
191
|
+
exitCode:state.exitCode ?? (128 + (constants.signals[state.signal] ?? 1)),
|
|
192
|
+
signal:state.signal, outputTruncated:state.outputTruncated });
|
|
193
|
+
}).catch(cause=>{
|
|
194
|
+
state.child.stdout.destroy();
|
|
195
|
+
state.child.stderr.destroy();
|
|
196
|
+
const prefix = state.terminationError ? state.terminationError.message + "; " : "";
|
|
197
|
+
failCommand(state,new Error(prefix + "command cleanup failed: " + cause.message));
|
|
198
|
+
});
|
|
179
199
|
}
|
|
180
200
|
|
|
181
201
|
function appendCommandOutput(state, current, chunk) {
|
|
@@ -186,33 +206,14 @@ function appendCommandOutput(state, current, chunk) {
|
|
|
186
206
|
return remaining ? current + chunk.slice(0, remaining) : current;
|
|
187
207
|
}
|
|
188
208
|
|
|
189
|
-
function onCommandClose(state, code, signal) {
|
|
190
|
-
if (state.settled) return;
|
|
191
|
-
|
|
192
|
-
if (state.terminationError) {
|
|
193
|
-
// A closed pipe alone says nothing about descendants. Only ESRCH proves
|
|
194
|
-
// the owned POSIX group is gone; otherwise retain the escalation timer.
|
|
195
|
-
if (process.platform !== "win32" && state.child.pid) {
|
|
196
|
-
try { process.kill(-state.child.pid, 0); }
|
|
197
|
-
catch (error) { if (error.code === "ESRCH") failCommand(state, state.terminationError); }
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
state.settled = true;
|
|
204
|
-
state.cleanup();
|
|
205
|
-
state.resolve({ stdout: state.stdout, stderr: state.stderr, exitCode: code ?? (128 + (constants.signals[signal] ?? 1)), signal, outputTruncated: state.outputTruncated });
|
|
206
|
-
}
|
|
207
|
-
|
|
208
209
|
function attachCommandIO(state, options, argv, timeoutMs) {
|
|
209
210
|
const { child } = state;
|
|
210
211
|
const onAbort = () => terminateCommand(state, new Error("aborted"));
|
|
211
212
|
state.cleanup = () => {
|
|
212
213
|
clearTimeout(state.timer);
|
|
213
|
-
clearTimeout(state.escalation);
|
|
214
214
|
options.signal?.removeEventListener("abort", onAbort);
|
|
215
215
|
};
|
|
216
|
+
|
|
216
217
|
state.timer = setTimeout(() => terminateCommand(state, new Error(
|
|
217
218
|
"command timed out after " + timeoutMs + "ms: " + truncateChars(options.commandLabel ?? argv.join(" "), 240, "command").text
|
|
218
219
|
+ "\nhint: Increase this bash timeoutMs and the outer supernova timeoutMs, or split the work. Sleeps and every command in a shell chain share the same limit."
|
|
@@ -221,13 +222,16 @@ function attachCommandIO(state, options, argv, timeoutMs) {
|
|
|
221
222
|
child.stderr.setEncoding("utf8");
|
|
222
223
|
child.stdout.on("data", chunk => { state.stdout = appendCommandOutput(state, state.stdout, chunk); });
|
|
223
224
|
child.stderr.on("data", chunk => { state.stderr = appendCommandOutput(state, state.stderr, chunk); });
|
|
224
|
-
child.on("error", error =>
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
225
|
+
child.on("error", error => failCommand(state,commandSpawnError(error,argv[0])));
|
|
226
|
+
child.once("exit", (code, signal) => {
|
|
227
|
+
state.processExited = true;
|
|
228
|
+
state.exitCode = code;
|
|
229
|
+
state.signal = signal;
|
|
230
|
+
// An orphan can retain the pipes forever. Begin retirement at leader exit,
|
|
231
|
+
// then wait for close and group disappearance before reporting completion.
|
|
232
|
+
terminateCommand(state);
|
|
229
233
|
});
|
|
230
|
-
child.
|
|
234
|
+
child.once("close", () => { state.processClosed = true; });
|
|
231
235
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
232
236
|
|
|
233
237
|
if (options.signal?.aborted) onAbort();
|
|
@@ -243,7 +247,8 @@ export async function runCommand(argv, options = {}) {
|
|
|
243
247
|
const child = spawnCommand(argv, options, cwd);
|
|
244
248
|
attachCommandIO({
|
|
245
249
|
child, resolve, reject, stdout: "", stderr: "", settled: false,
|
|
246
|
-
outputTruncated: false, terminationError: undefined,
|
|
250
|
+
outputTruncated: false, terminationError: undefined, stopping: undefined,
|
|
251
|
+
groups:new Set(child.pid ? [child.pid] : []), killedGroups:new Set(),
|
|
247
252
|
maxOutputChars, timer: undefined, cleanup() {},
|
|
248
253
|
}, options, argv, timeoutMs);
|
|
249
254
|
});
|
package/src/output/bottleneck.js
CHANGED
|
@@ -167,6 +167,7 @@ function packageBatchItems(batch, details, maxChars) {
|
|
|
167
167
|
const itemErrors = (details.itemErrors ?? []).map(error => boundItemError(error, maxChars, batch.length));
|
|
168
168
|
let remaining = maxChars;
|
|
169
169
|
let truncated = false;
|
|
170
|
+
|
|
170
171
|
const items = batch.map((item, index) => {
|
|
171
172
|
const share = details.independent === true ? maxChars : Math.floor(remaining / (batch.length - index));
|
|
172
173
|
const bounded = boundBatchItem(item, share);
|
|
@@ -223,14 +224,26 @@ function attachTruncation(result, truncated, batch, text, config, maxChars, capp
|
|
|
223
224
|
|
|
224
225
|
export function packageHostResult(raw, config) {
|
|
225
226
|
const details = detailsOf(raw);
|
|
227
|
+
|
|
226
228
|
if (raw && Object.hasOwn(raw, READ_VALUE)) {
|
|
227
229
|
const batch = batchFromDetails(details);
|
|
228
230
|
const result = {ok: !hostResultFailed(raw), value: raw[READ_VALUE], typed: true, cloneItems: details?.jsonMany === true, truncated: details?.outputTruncated === true};
|
|
229
231
|
attachDetails(result, details, batch);
|
|
230
|
-
|
|
232
|
+
|
|
233
|
+
if (isString(details?.sourcePath)) result.sourcePath = details.sourcePath;
|
|
234
|
+
|
|
235
|
+
if (batch) {
|
|
236
|
+
result.items = batch;
|
|
237
|
+
result.itemErrors = details.itemErrors ?? [];
|
|
238
|
+
|
|
239
|
+
if (details.sourcePaths) result.sourcePaths = details.sourcePaths;
|
|
240
|
+
}
|
|
241
|
+
|
|
231
242
|
if (details?.streamed) result.streamed = true;
|
|
243
|
+
|
|
232
244
|
return result;
|
|
233
245
|
}
|
|
246
|
+
|
|
234
247
|
const maxChars = config.maxCallResultChars ?? 65536;
|
|
235
248
|
const batch = batchFromDetails(details);
|
|
236
249
|
const text = batch ? "" : extractRawString(raw);
|
package/src/runtime/guest-api.js
CHANGED
|
@@ -15,16 +15,20 @@ function unwrapValue(res) {
|
|
|
15
15
|
return res;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
function unwrapRead(res, args) {
|
|
18
|
+
function unwrapRead(res, args, observe) {
|
|
19
19
|
const value = unwrapValue(res);
|
|
20
20
|
|
|
21
21
|
if ((args.complete === true || args.json !== undefined) && res?.truncated) throw new Error("incomplete read: complete:true or json refuses truncated host output");
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
const decoded = decodeReadItem(args, value, res);
|
|
24
|
+
observe?.(args,res);
|
|
25
|
+
|
|
26
|
+
return decoded;
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
function decodeReadItem(args, value, res) {
|
|
27
30
|
if (!res?.typed) return decodeReadValue(args, value);
|
|
31
|
+
|
|
28
32
|
// JSON selectors used to cross RPC as separately encoded JSON values. Keep
|
|
29
33
|
// their mutable results independent, but put the copies in the guest heap.
|
|
30
34
|
return res.cloneItems ? value.map(item => structuredClone(item)) : value;
|
|
@@ -59,18 +63,22 @@ function missingResolvedIndex(values, paths) {
|
|
|
59
63
|
|
|
60
64
|
function settleReadItem(wave, index, res) {
|
|
61
65
|
const job = wave[index];
|
|
66
|
+
|
|
62
67
|
if (!job) throw new Error("invalid streamed read index: " + index);
|
|
63
68
|
wave[index] = null; // Release resolver closures and their potentially large values.
|
|
64
|
-
|
|
69
|
+
|
|
70
|
+
try { job.resolve(unwrapRead(res,job.args,job.observe)); }
|
|
65
71
|
catch (error) { job.reject(error); }
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
function settleInlineWave(wave,res,received) {
|
|
69
75
|
unwrapValue(res);
|
|
76
|
+
|
|
70
77
|
if (received || !Array.isArray(res.items) || res.items.length !== wave.length) throw new Error("invalid batch read response");
|
|
78
|
+
|
|
71
79
|
for (let i=0;i<wave.length;i++) {
|
|
72
80
|
const error = res.itemErrors?.[i];
|
|
73
|
-
settleReadItem(wave,i,{...res,ok:!error,value:error ?? res.items[i],items:undefined});
|
|
81
|
+
settleReadItem(wave,i,{...res,ok:!error,value:error ?? res.items[i],sourcePath:res.sourcePaths?.[i],items:undefined});
|
|
74
82
|
}
|
|
75
83
|
}
|
|
76
84
|
|
|
@@ -78,24 +86,32 @@ async function rpcReadWave(rpc, wave) {
|
|
|
78
86
|
if (wave.length === 1) {
|
|
79
87
|
const res = leanEnvelope(await rpc("call",["read",wave[0].args]));
|
|
80
88
|
settleReadItem(wave,0,res);
|
|
89
|
+
|
|
81
90
|
return;
|
|
82
91
|
}
|
|
92
|
+
|
|
83
93
|
const args = {...wave[0].args,path:wave.map(job=>job.args.path),_independent:true};
|
|
84
94
|
let received = 0, last;
|
|
95
|
+
|
|
85
96
|
const onItem = (index,res) => {
|
|
86
97
|
if (!Number.isInteger(index) || !wave[index] || last?.index === index) throw new Error("invalid streamed read response");
|
|
87
98
|
received++;
|
|
99
|
+
|
|
88
100
|
// Keep the final read pending until the host RPC/barrier itself has settled.
|
|
89
101
|
// An awaited batch must never look complete while its host call is running.
|
|
90
102
|
if (received === wave.length) last = {index,res};
|
|
91
103
|
else settleReadItem(wave,index,res);
|
|
92
104
|
};
|
|
105
|
+
|
|
93
106
|
const res = leanEnvelope(await rpc("call",["read",args],onItem));
|
|
107
|
+
|
|
94
108
|
if (res?.streamed) {
|
|
95
109
|
if (received !== wave.length || !last) throw new Error("incomplete streamed read response");
|
|
96
110
|
settleReadItem(wave,last.index,last.res);
|
|
111
|
+
|
|
97
112
|
return;
|
|
98
113
|
}
|
|
114
|
+
|
|
99
115
|
settleInlineWave(wave,res,received);
|
|
100
116
|
}
|
|
101
117
|
|
|
@@ -118,7 +134,7 @@ function enqueueCompatibleRead(readState, flushReads, args) {
|
|
|
118
134
|
if (readState.queued.length && readState.queued[0].key !== key) flushReads();
|
|
119
135
|
|
|
120
136
|
const promise = new Promise((resolve, reject) => {
|
|
121
|
-
readState.queued.push({ args, key, resolve, reject });
|
|
137
|
+
readState.queued.push({ args, key, resolve, reject, observe:readState.observe });
|
|
122
138
|
|
|
123
139
|
if (readState.queued.length === 1) queueMicrotask(flushReads);
|
|
124
140
|
});
|
|
@@ -128,12 +144,16 @@ function enqueueCompatibleRead(readState, flushReads, args) {
|
|
|
128
144
|
|
|
129
145
|
async function readManyPaths(readOne, args, paths) {
|
|
130
146
|
assertReadPaths(paths);
|
|
147
|
+
|
|
131
148
|
const values = await Promise.all(paths.map(async item => {
|
|
132
149
|
try { return await readOne({...args,path:item}); }
|
|
133
150
|
catch (error) { throwReadPathError(item,error.message); }
|
|
134
151
|
}));
|
|
152
|
+
|
|
135
153
|
const missing = args.resolve ? missingResolvedIndex(values,paths) : -1;
|
|
154
|
+
|
|
136
155
|
if (missing >= 0) throwReadPathError(paths[missing],"not_found");
|
|
156
|
+
|
|
137
157
|
return values;
|
|
138
158
|
}
|
|
139
159
|
|
|
@@ -193,12 +213,13 @@ export function buildGuestApi(rpc, batchRead) {
|
|
|
193
213
|
if (checkpoint) throw new Error("edit checkpoints cannot overlap or nest; await the current checkpoint");
|
|
194
214
|
const token = {};
|
|
195
215
|
checkpoint = token;
|
|
216
|
+
|
|
196
217
|
try { return await runSpeculation(fn, token, checkpointScope, drainReads, rpc); }
|
|
197
218
|
finally { checkpoint = null; }
|
|
198
219
|
}
|
|
199
220
|
|
|
200
221
|
// Coalesce already-started compatible reads without rewriting JS control flow.
|
|
201
|
-
const readState = { queued: [], waves: new Set() };
|
|
222
|
+
const readState = { queued: [], waves: new Set(), observe:noteReadPath };
|
|
202
223
|
|
|
203
224
|
function flushReads() {
|
|
204
225
|
const pending = readState.queued;
|
|
@@ -227,26 +248,21 @@ export function buildGuestApi(rpc, batchRead) {
|
|
|
227
248
|
const args = normalizeRead(gatherReadArgs(p, a, b));
|
|
228
249
|
p = args.path;
|
|
229
250
|
|
|
230
|
-
if (Array.isArray(p))
|
|
231
|
-
const values = await readManyPaths(read, args, p);
|
|
232
|
-
for (const item of p) if (isString(item)) noteReadPath({ path: item }, values);
|
|
233
|
-
|
|
234
|
-
return values;
|
|
235
|
-
}
|
|
251
|
+
if (Array.isArray(p)) return await readManyPaths(read, args, p);
|
|
236
252
|
|
|
237
|
-
|
|
238
|
-
? unwrapRead(await invoke("read", args), args)
|
|
253
|
+
return !batchRead
|
|
254
|
+
? unwrapRead(await invoke("read", args), args, noteReadPath)
|
|
239
255
|
: await enqueueCompatibleRead(readState, flushReads, args);
|
|
240
|
-
noteReadPath(args, readValue);
|
|
241
|
-
|
|
242
|
-
return readValue;
|
|
243
256
|
};
|
|
244
257
|
|
|
245
258
|
const readFiles = new Set();
|
|
246
259
|
|
|
247
|
-
function noteReadPath(args,
|
|
248
|
-
if (isString(args.path) &&
|
|
249
|
-
|
|
260
|
+
function noteReadPath(args, envelope) {
|
|
261
|
+
if (isString(args.path) && args.resolve !== true && args.query === undefined && args.evidence !== true) readFiles.add(args.path);
|
|
262
|
+
|
|
263
|
+
// A document may itself contain path/status fields. Only adapter provenance,
|
|
264
|
+
// carried separately through scalar, inline and streamed replies, names a read.
|
|
265
|
+
if (isString(envelope.sourcePath)) readFiles.add(envelope.sourcePath);
|
|
250
266
|
}
|
|
251
267
|
|
|
252
268
|
const write = async (p, content) => {
|