pi-supernova 0.9.1 → 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.
@@ -0,0 +1,304 @@
1
+ import { spawnCommand, commandSpawnError } from "./workspace.js";
2
+ import { retireProcessTree } from "./process-tree.js";
3
+ import { randomUUID } from "node:crypto";
4
+ import { access } from "node:fs/promises";
5
+ import { constants } from "node:fs";
6
+
7
+ const MAX_RUNNING = 8;
8
+
9
+ const MAX_RETAINED = 32;
10
+
11
+ const OUTPUT_CHARS = 65536;
12
+
13
+ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
14
+
15
+ function terminalArgv(argv) {
16
+ // The PTY child is a process-group leader. Capture that identity before exec,
17
+ // then close the control FD so commands cannot hold it open or forge messages.
18
+ const command = ["/bin/sh", "-c", 'printf "group:%s\\n" "$$" >&3; /bin/stty cols 80 rows 24 || exit; exec "$@" 3>&-', "supernova-command", ...argv];
19
+
20
+ // Node uses socketpairs for stdio; macOS script needs a real input pipe.
21
+ // Report script's exit separately from the feeder, which may still await input.
22
+ if (process.platform === "darwin") return ["/bin/sh", "-c", '/bin/cat | { "$@"; code=$?; printf "exit:%s\\n" "$code" >&3; }', "supernova-pty", "/usr/bin/script", "-q", "-F", "/dev/null", ...command];
23
+ const quote = value => "'" + value.replaceAll("'", "'\\''") + "'";
24
+
25
+ return ["/usr/bin/script", "-q", "-e", "-f", "-c", "exec " + command.map(quote).join(" "), "/dev/null"];
26
+ }
27
+
28
+ function notifyChanged(job) {
29
+ // Advisory invalidation must not prevent resource cleanup or escape an event.
30
+ try { job.changed?.(); } catch {}
31
+
32
+ if (job.cleaned) job.changed = undefined;
33
+ }
34
+
35
+ function wake(job) {
36
+ for (const notify of job.waiters) notify();
37
+ }
38
+
39
+ function appendOutput(job, chunk) {
40
+ job.total += chunk.length;
41
+ job.output = (job.output + chunk).slice(-OUTPUT_CHARS);
42
+ wake(job);
43
+ }
44
+
45
+ function snapshot(job, cursor = 0) {
46
+ if (cursor > job.total) throw new Error("terminal cursor is beyond available output");
47
+ const start = job.total - job.output.length;
48
+ const outputStart = Math.max(start, cursor);
49
+
50
+ const result = {
51
+ sessionId:job.id, pid:job.child.pid, status:job.status, pty:job.pty,
52
+ exitCode:job.exitCode, signal:job.signal, outputStart, cursor:job.total,
53
+ truncated:cursor < start, output:job.output.slice(outputStart-start),
54
+ };
55
+
56
+ if (job.error) result.error = job.error;
57
+
58
+ return result;
59
+ }
60
+
61
+ function waitForChange(job, waitMs, signal) {
62
+ signal?.throwIfAborted();
63
+
64
+ return new Promise((resolve, reject) => {
65
+ const done = error => {
66
+ clearTimeout(timer);
67
+ job.waiters.delete(changed);
68
+ signal?.removeEventListener("abort", aborted);
69
+
70
+ if (error) reject(error); else resolve();
71
+ };
72
+
73
+ const changed = () => done();
74
+ const aborted = () => done(new Error("terminal poll aborted; job remains running"));
75
+ const timer = setTimeout(changed, waitMs);
76
+ job.waiters.add(changed);
77
+ signal?.addEventListener("abort", aborted, {once:true});
78
+ });
79
+ }
80
+
81
+ function sendInput(job, input, signal) {
82
+ signal?.throwIfAborted();
83
+
84
+ if (job.child.stdin.writableLength > OUTPUT_CHARS) throw new Error("terminal input queue is full; wait before writing more");
85
+
86
+ return new Promise((resolve, reject) => {
87
+ const done = error => {
88
+ signal?.removeEventListener("abort", aborted);
89
+
90
+ if (error) reject(error); else resolve();
91
+ };
92
+
93
+ const aborted = () => done(new Error("terminal input aborted; input may have been sent"));
94
+ signal?.addEventListener("abort", aborted, {once:true});
95
+ job.child.stdin.write(input, done);
96
+ });
97
+ }
98
+
99
+ export function createBackgroundTerminals() {
100
+ const jobs = new Map();
101
+ let closed = false;
102
+ let generation = 0;
103
+ let closing;
104
+
105
+ function lookup(id, owner) {
106
+ const job = jobs.get(id);
107
+
108
+ if (!job || job.owner !== owner) throw new Error("unknown background terminal session: " + id);
109
+
110
+ return job;
111
+ }
112
+
113
+ function assertCapacity(expectedGeneration) {
114
+ if (closed || expectedGeneration !== generation) throw new Error("background terminal session changed or closed; start a new call");
115
+
116
+ if ([...jobs.values()].filter(job=>!job.cleaned).length >= MAX_RUNNING) throw new Error("background terminal limit reached (8 running); stop a session first");
117
+ }
118
+
119
+ async function validateStart(params, expectedGeneration = generation) {
120
+ assertCapacity(expectedGeneration);
121
+
122
+ if (params.pty) {
123
+ if (!["darwin","linux"].includes(process.platform)) throw new Error("PTY background terminals require macOS or Linux; use pty:false for pipes");
124
+ await access("/usr/bin/script", constants.X_OK).catch(()=>{throw new Error("PTY background terminals require executable /usr/bin/script; use pty:false for pipes");});
125
+ }
126
+ }
127
+
128
+ async function terminate(job, status) {
129
+ if (job.stopping) return job.stopping;
130
+
131
+ if (job.cleaned) return;
132
+ job.stopping = (async () => {
133
+ clearTimeout(job.timer);
134
+ await retireProcessTree(job);
135
+ job.cleaned = true;
136
+ job.error = job.processError;
137
+ job.status = job.error ? "failed" : status;
138
+ notifyChanged(job);
139
+ wake(job);
140
+ })().catch(error => {
141
+ job.stopping = null; // A later stop/shutdown must be able to retry cleanup.
142
+ job.status = "failed";
143
+ job.error = "terminal cleanup failed: " + error.message;
144
+ notifyChanged(job);
145
+ wake(job);
146
+ throw error;
147
+ });
148
+
149
+ return job.stopping;
150
+ }
151
+
152
+ async function start(argv, options) {
153
+ const startedGeneration = options.generation ?? generation;
154
+ await validateStart(options, startedGeneration);
155
+ options.signal?.throwIfAborted();
156
+ // Capacity and lifecycle can change while the PTY capability check awaits.
157
+ assertCapacity(startedGeneration);
158
+
159
+ while (jobs.size >= MAX_RETAINED) {
160
+ const old = [...jobs.values()].find(job=>job.cleaned);
161
+
162
+ if (!old) break;
163
+ jobs.delete(old.id);
164
+ }
165
+
166
+ const command = options.pty ? terminalArgv(argv) : argv;
167
+
168
+ const child = spawnCommand(command, {
169
+ cwd:options.cwd, env:options.pty ? {...options.env,TERM:options.env.TERM || "xterm-256color"} : options.env,
170
+ stdio:options.pty ? ["pipe","pipe","pipe","pipe"] : ["pipe","pipe","pipe"],
171
+ });
172
+
173
+ const job = {
174
+ id:randomUUID(), owner:options.owner, child, pty:options.pty === true,
175
+ status:"running", output:"", total:0, exitCode:null, signal:null,
176
+ waiters:new Set(), changed:options.changed, groups:new Set(child.pid ? [child.pid] : []), killedGroups:new Set(), cleaned:false,
177
+ };
178
+
179
+ jobs.set(job.id,job);
180
+ child.stdin.on("error",()=>{}); // EPIPE is delivered to each write callback, never uncaught.
181
+
182
+ if (job.pty) {
183
+ let control = "";
184
+ child.stdio[3].setEncoding("utf8");
185
+ child.stdio[3].on("data",chunk=>{
186
+ control += chunk;
187
+
188
+ if (control.length > 128) {
189
+ job.processError = "invalid PTY control message";
190
+ void terminate(job,"failed").catch(()=>{});
191
+
192
+ return;
193
+ }
194
+
195
+ let newline;
196
+
197
+ while ((newline = control.indexOf("\n")) >= 0) {
198
+ const message = control.slice(0,newline);
199
+ control = control.slice(newline+1);
200
+
201
+ if (/^group:[1-9]\d*$/.test(message)) job.groups.add(Number(message.slice(6)));
202
+ else if (/^exit:\d+$/.test(message)) {
203
+ job.commandExitCode = Number(message.slice(5));
204
+ void terminate(job,"exited").catch(()=>{});
205
+ } else job.processError = "invalid PTY control message";
206
+ }
207
+ });
208
+ child.stdio[3].on("error",error=>{
209
+ job.processError = error.message;
210
+ void terminate(job,"failed").catch(()=>{});
211
+ });
212
+ }
213
+
214
+ for (const stream of [child.stdout,child.stderr]) {
215
+ stream.setEncoding("utf8");
216
+ stream.on("data",chunk=>appendOutput(job,chunk));
217
+ }
218
+
219
+ child.once("exit",(code,signal)=>{
220
+ job.processExited = true;
221
+ job.exitCode = job.commandExitCode ?? code;
222
+ job.signal = job.commandExitCode === undefined ? signal : null;
223
+ void terminate(job,"exited").catch(()=>{});
224
+ });
225
+ child.once("close",(code,signal)=>{
226
+ job.processClosed = true;
227
+ job.exitCode = job.commandExitCode ?? code;
228
+ job.signal = job.commandExitCode === undefined ? signal : null;
229
+ });
230
+
231
+ try {
232
+ await new Promise((resolve,reject)=>{
233
+ child.once("spawn",resolve);
234
+ child.once("error",error=>{const mapped=commandSpawnError(error,command[0]);job.error=mapped.message;reject(mapped);});
235
+ });
236
+
237
+ if (options.signal?.aborted || closed || startedGeneration !== generation) {
238
+ await terminate(job,"stopped");
239
+ throw new Error("background terminal start aborted");
240
+ }
241
+ } catch (error) {
242
+ if (!child.pid || job.cleaned) jobs.delete(job.id);
243
+ throw error;
244
+ }
245
+
246
+ job.timer = setTimeout(()=>{void terminate(job,"timed_out").catch(()=>{});},options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
247
+ job.timer.unref?.();
248
+
249
+ return snapshot(job);
250
+ }
251
+
252
+ function validateControl(params, owner, expectedGeneration = generation) {
253
+ if (expectedGeneration !== generation) throw new Error("background terminal session changed; start a new call");
254
+
255
+ if (params.action === "list") return;
256
+ const job = lookup(params.sessionId,owner);
257
+
258
+ if (params.action === "write" && (job.status !== "running" || job.stopping)) throw new Error("background terminal is not running");
259
+
260
+ if (params.cursor !== undefined && params.cursor > job.total) throw new Error("terminal cursor is beyond available output");
261
+ }
262
+
263
+ async function control(params, owner, signal, expectedGeneration = generation) {
264
+ validateControl(params,owner,expectedGeneration);
265
+ signal?.throwIfAborted();
266
+
267
+ if (params.action === "list") return [...jobs.values()].flatMap(job=>job.owner === owner ? [snapshot(job,job.total)] : []);
268
+ const job = lookup(params.sessionId,owner);
269
+
270
+ if (params.action === "write") await sendInput(job,params.input,signal);
271
+ else if (params.action === "stop") await terminate(job,"stopped");
272
+ else if (job.status === "running" && (params.cursor ?? 0) === job.total && params.waitMs) await waitForChange(job,params.waitMs,signal);
273
+
274
+ // Polls, writes and stops can cross a session shutdown while awaiting I/O.
275
+ // Do not return a result owned by the session that just closed.
276
+ // Polls, writes and stops can cross a session shutdown while awaiting I/O.
277
+ // Do not return a result owned by the session that just closed.
278
+ if (expectedGeneration !== generation) throw new Error("background terminal session changed; start a new call");
279
+
280
+ return snapshot(job,params.cursor);
281
+ }
282
+
283
+ function shutdown() {
284
+ if (closing) return closing;
285
+ closed = true;
286
+ generation++;
287
+ const retiring = [...jobs.values()];
288
+ closing = Promise.allSettled(retiring.map(job=>terminate(job,"stopped"))).then(results=>{
289
+ for (const job of retiring) if (job.cleaned) jobs.delete(job.id);
290
+ const failure = results.find(result=>result.status === "rejected");
291
+
292
+ if (failure) throw failure.reason;
293
+ }).finally(()=>{closing=undefined;});
294
+
295
+ return closing;
296
+ }
297
+
298
+ function reopen() {
299
+ if (closing || [...jobs.values()].some(job=>!job.cleaned)) throw new Error("background terminal session cleanup is incomplete");
300
+ closed = false;
301
+ }
302
+
303
+ return {start, control, validateStart, validateControl, shutdown, reopen, getGeneration:()=>generation};
304
+ }
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 remapReadError(err, target) {
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
- if (err.code === "ENOTDIR") throw new Error("cannot use path: a parent component of " + target + " is a file, not a directory");
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
+ }
@@ -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
- async prepareExternalMutation(name) {
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