@qlzt/dsh-subprocess-local 0.0.0-stage → 0.2.1-alpha.2

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/lib/output.js ADDED
@@ -0,0 +1,239 @@
1
+ import { closeSync, mkdtempSync, openSync, rmdirSync, unlinkSync, writeSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { randomBytes } from "node:crypto";
5
+ //#region lib/types/output.js
6
+ /** Bounded output tails and private spill files shared by process providers. */
7
+ let spillCounter = 0;
8
+ let defaultSpillDir;
9
+ /**
10
+ * The default spill location: a private (0700) per-process directory under
11
+ * the OS tmpdir, created lazily. Predictable world-readable paths would let
12
+ * other local users read command output or pre-create symlinks. The directory
13
+ * is created once per process and never recreated: when an external
14
+ * temporary-file cleaner removes it while empty, the next spill open fails and
15
+ * the collector degrades to its in-memory tail. At a JavaScript-observable
16
+ * process exit the directory is removed only when it holds no completed spill
17
+ * file (spill files are retained as full-output recovery artifacts until an
18
+ * external cleanup).
19
+ */
20
+ function privateSpillDir() {
21
+ defaultSpillDir ??= mkdtempSync(join(tmpdir(), "dsh-subprocess-"));
22
+ return defaultSpillDir;
23
+ }
24
+ /* v8 ignore next 4 -- exit listeners run after the coverage dump; removal is verified by the CI /tmp residue measurement. */
25
+ process.once("exit", () => {
26
+ if (defaultSpillDir === void 0) return;
27
+ try {
28
+ rmdirSync(defaultSpillDir);
29
+ } catch {}
30
+ });
31
+ /**
32
+ * The stderr reporter used when no owner supplies one: a bare
33
+ * {@link prepareManagedProcessBinding} caller has no plugin logger, and the
34
+ * failure must still reach the process diagnostics.
35
+ * @param error - the spill failure.
36
+ * @param label - the failed stream label.
37
+ */
38
+ function reportSpillFailureToStderr(error, label) {
39
+ process.stderr.write(`dsh-subprocess-local: ${label} spill failed; only the in-memory tail is retained: ${String(error)}\n`);
40
+ }
41
+ /**
42
+ * Build the reporter an owner passes as {@link SpillOptions.onFailure}: one
43
+ * error-level log line naming the owner and stream, with the failure appended
44
+ * so its `code`, `syscall`, and `path` reach the log.
45
+ * @param logger - the owner's plugin logger.
46
+ * @param owner - the component named in the line.
47
+ * @returns the reporter.
48
+ */
49
+ function logSpillFailure(logger, owner) {
50
+ return (error, label) => {
51
+ const removedDirectory = error.code === "ENOENT";
52
+ logger.error(`${owner} could not write the complete ${label} stream to its spill file; the result keeps only the in-memory tail and reports no full-output path.` + (removedDirectory ? " The spill directory no longer exists; a temporary-file cleaner removing it while empty is the usual cause." : ""), error);
53
+ };
54
+ }
55
+ /**
56
+ * Prepare fallible output storage before starting a managed native process.
57
+ * This is the explicit resolve step for spill inputs: the spill directory
58
+ * defaults to the private per-process directory, and the failure reporter
59
+ * defaults to a stderr line when the caller has no logger.
60
+ * @param internals - optional caller-owned spill directory and failure reporter.
61
+ * @returns binding inputs whose spill directory is ready for use.
62
+ */
63
+ function prepareManagedProcessBinding(internals = {}) {
64
+ return {
65
+ spillDir: internals.spillDir ?? privateSpillDir(),
66
+ onSpillFailure: internals.onSpillFailure ?? reportSpillFailureToStderr
67
+ };
68
+ }
69
+ /**
70
+ * Collects one stream with a bounded in-memory tail. With spill options, on
71
+ * first overflow a spill file is created and every chunk (including those
72
+ * already collected) is appended there while the full stream remains within
73
+ * the cap; without them, only the in-memory tail is ever retained (the
74
+ * diagnostic-tail shape — a language server's stderr).
75
+ *
76
+ * Spilling is best-effort: a spill open or write failure discards the spill,
77
+ * reports once through {@link SpillOptions.onFailure}, and never interrupts
78
+ * in-memory collection, because `push()` runs inside the stream's `'data'`
79
+ * listener where a thrown error would become an uncaught exception.
80
+ *
81
+ * Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
82
+ * end of command output; the spill file covers the head.
83
+ */
84
+ var OutputCollector = class {
85
+ maxBytes;
86
+ label;
87
+ spill;
88
+ chunks = [];
89
+ bytes = 0;
90
+ dropped = false;
91
+ spillFd;
92
+ spillFile;
93
+ spillDisabled;
94
+ /** Total bytes ever pushed (not just retained). */
95
+ total = 0;
96
+ /**
97
+ * @param maxBytes - in-memory tail cap in bytes.
98
+ * @param label - stream label used in spill file names and failure reports.
99
+ * @param spill - spill storage; omit for tail-only collection.
100
+ */
101
+ constructor(maxBytes, label, spill) {
102
+ this.maxBytes = maxBytes;
103
+ this.label = label;
104
+ this.spill = spill;
105
+ this.spillDisabled = spill === void 0;
106
+ }
107
+ /**
108
+ * Ingest one stream chunk, counting it toward the whole-stream total. On
109
+ * first overflow of the in-memory cap a spill file is opened (when spilling
110
+ * is enabled) and every chunk (already-collected ones included) is appended
111
+ * there from then on; the in-memory tail then drops whole chunks from its
112
+ * head (or the head of a single over-cap chunk) until it fits the cap again.
113
+ * @param chunk - the raw bytes from one stream 'data' event.
114
+ */
115
+ push(chunk) {
116
+ this.total += chunk.length;
117
+ const overflows = this.bytes + chunk.length > this.maxBytes;
118
+ const spill = this.spill;
119
+ if (spill !== void 0 && !this.spillDisabled && (overflows || this.spillFd !== void 0)) this.spillAll(spill, chunk);
120
+ this.chunks.push(chunk);
121
+ this.bytes += chunk.length;
122
+ while (this.bytes > this.maxBytes) {
123
+ const head = this.chunks[0];
124
+ const excess = this.bytes - this.maxBytes;
125
+ if (head.length <= excess) {
126
+ this.chunks.shift();
127
+ this.bytes -= head.length;
128
+ } else {
129
+ this.chunks[0] = head.subarray(excess);
130
+ this.bytes -= excess;
131
+ }
132
+ this.dropped = true;
133
+ }
134
+ }
135
+ /**
136
+ * Open the spill file lazily and append `chunk` (and any prior chunks once).
137
+ * Runs inside the stream's `'data'` listener, so every filesystem failure is
138
+ * contained here: the spill is discarded, reported once, and collection
139
+ * continues with the in-memory tail alone.
140
+ */
141
+ spillAll(spill, chunk) {
142
+ if (this.total > spill.maxBytes) {
143
+ this.discardSpill();
144
+ return;
145
+ }
146
+ try {
147
+ if (this.spillFd === void 0) {
148
+ const file = join(spill.dir, `dsh-subprocess-${process.pid}-${++spillCounter}-${randomBytes(6).toString("hex")}-${this.label}.log`);
149
+ const fd = openSync(file, "wx", 384);
150
+ this.spillFile = file;
151
+ this.spillFd = fd;
152
+ for (const prior of this.chunks) writeSync(fd, prior);
153
+ }
154
+ writeSync(this.spillFd, chunk);
155
+ } catch (error) {
156
+ this.discardSpill();
157
+ try {
158
+ spill.onFailure(error, this.label);
159
+ } catch (reporterFailure) {
160
+ process.stderr.write(`dsh-subprocess-local: spill failure reporter threw: ${String(reporterFailure)}\n`);
161
+ }
162
+ }
163
+ }
164
+ /** Stop spilling and remove the file once it can no longer hold the complete stream. */
165
+ discardSpill() {
166
+ const fd = this.spillFd;
167
+ const file = this.spillFile;
168
+ this.spillFd = void 0;
169
+ this.spillFile = void 0;
170
+ this.spillDisabled = true;
171
+ if (fd !== void 0) try {
172
+ closeSync(fd);
173
+ } catch {
174
+ this.spillFd = fd;
175
+ }
176
+ if (file !== void 0) try {
177
+ unlinkSync(file);
178
+ } catch {}
179
+ }
180
+ /**
181
+ * Incremental read in whole-stream byte coordinates: returns everything
182
+ * pushed since `fromByte`. When `fromByte` has already slid out of the
183
+ * in-memory tail window, the read is `lossy` — it returns the whole
184
+ * retained tail and the gap is only recoverable from the spill file.
185
+ * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
186
+ * @returns the delta text, the offset for the next read, the `lossy` flag, and the spill path when one was created.
187
+ */
188
+ readFrom(fromByte) {
189
+ const windowStart = this.total - this.bytes;
190
+ const buffer = Buffer.concat(this.chunks);
191
+ const lossy = fromByte < windowStart;
192
+ return {
193
+ text: (lossy ? buffer : buffer.subarray(fromByte - windowStart)).toString("utf8"),
194
+ nextOffset: this.total,
195
+ lossy,
196
+ ...this.spillFile !== void 0 ? { spillPath: this.spillFile } : {}
197
+ };
198
+ }
199
+ /**
200
+ * Copy the retained raw tail with its position in the complete observed stream.
201
+ * @returns independent tail bytes and the total byte count before truncation.
202
+ */
203
+ snapshot() {
204
+ return {
205
+ bytes: Buffer.concat(this.chunks),
206
+ totalBytes: this.total
207
+ };
208
+ }
209
+ /**
210
+ * Close the spill file once the stream has ended. A failed close (delayed
211
+ * writeback fault) stops advertising the spill path — the file may be
212
+ * missing its tail — while every in-memory read keeps working. Idempotent;
213
+ * the spawn path seals both collectors at settlement so reads after exit
214
+ * never point at a still-open file.
215
+ */
216
+ seal() {
217
+ if (this.spillFd === void 0) return;
218
+ try {
219
+ closeSync(this.spillFd);
220
+ } catch {
221
+ this.spillFile = void 0;
222
+ }
223
+ this.spillFd = void 0;
224
+ }
225
+ /**
226
+ * Seal the spill file and return the final output.
227
+ * @returns the final collected output: tail text, truncation flag, and the spill path when intact.
228
+ */
229
+ finalize() {
230
+ this.seal();
231
+ return {
232
+ text: Buffer.concat(this.chunks).toString("utf8"),
233
+ truncated: this.dropped,
234
+ ...this.spillFile !== void 0 ? { spillPath: this.spillFile } : {}
235
+ };
236
+ }
237
+ };
238
+ //#endregion
239
+ export { OutputCollector, logSpillFailure, prepareManagedProcessBinding };