@deepseek-ai/dsh-subprocess-local 0.1.5-rc.2 → 0.1.6-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,181 @@
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. At a
13
+ * JavaScript-observable process exit the directory is removed only when it
14
+ * holds no completed spill file (spill files are retained as full-output
15
+ * recovery artifacts until an external cleanup).
16
+ */
17
+ function privateSpillDir() {
18
+ defaultSpillDir ??= mkdtempSync(join(tmpdir(), "dsh-subprocess-"));
19
+ return defaultSpillDir;
20
+ }
21
+ /* v8 ignore next 4 -- exit listeners run after the coverage dump; removal is verified by the CI /tmp residue measurement. */
22
+ process.once("exit", () => {
23
+ if (defaultSpillDir === void 0) return;
24
+ try {
25
+ rmdirSync(defaultSpillDir);
26
+ } catch {}
27
+ });
28
+ /**
29
+ * Prepare fallible output storage before starting a managed native process.
30
+ * @param internals - optional caller-owned spill directory.
31
+ * @returns binding inputs whose spill directory is ready for use.
32
+ */
33
+ function prepareManagedProcessBinding(internals = {}) {
34
+ return { spillDir: internals.spillDir ?? privateSpillDir() };
35
+ }
36
+ /**
37
+ * Collects one stream with a bounded in-memory tail. With a spill cap, on
38
+ * first overflow a spill file is created and every chunk (including those
39
+ * already collected) is appended there while the full stream remains within
40
+ * the cap; without one, only the in-memory tail is ever retained (the
41
+ * diagnostic-tail shape — a language server's stderr).
42
+ *
43
+ * Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
44
+ * end of command output; the spill file covers the head.
45
+ */
46
+ var OutputCollector = class {
47
+ maxBytes;
48
+ maxSpillBytes;
49
+ label;
50
+ spillDir;
51
+ chunks = [];
52
+ bytes = 0;
53
+ dropped = false;
54
+ spillFd;
55
+ spillFile;
56
+ spillDisabled;
57
+ /** Total bytes ever pushed (not just retained). */
58
+ total = 0;
59
+ constructor(maxBytes, maxSpillBytes, label, spillDir) {
60
+ this.maxBytes = maxBytes;
61
+ this.maxSpillBytes = maxSpillBytes;
62
+ this.label = label;
63
+ this.spillDir = spillDir;
64
+ this.spillDisabled = maxSpillBytes === void 0;
65
+ }
66
+ /**
67
+ * Ingest one stream chunk, counting it toward the whole-stream total. On
68
+ * first overflow of the in-memory cap a spill file is opened (when spilling
69
+ * is enabled) and every chunk (already-collected ones included) is appended
70
+ * there from then on; the in-memory tail then drops whole chunks from its
71
+ * head (or the head of a single over-cap chunk) until it fits the cap again.
72
+ * @param chunk - the raw bytes from one stream 'data' event.
73
+ */
74
+ push(chunk) {
75
+ this.total += chunk.length;
76
+ const overflows = this.bytes + chunk.length > this.maxBytes;
77
+ if (!this.spillDisabled && (overflows || this.spillFd !== void 0)) this.spillAll(chunk);
78
+ this.chunks.push(chunk);
79
+ this.bytes += chunk.length;
80
+ while (this.bytes > this.maxBytes) {
81
+ const head = this.chunks[0];
82
+ const excess = this.bytes - this.maxBytes;
83
+ if (head.length <= excess) {
84
+ this.chunks.shift();
85
+ this.bytes -= head.length;
86
+ } else {
87
+ this.chunks[0] = head.subarray(excess);
88
+ this.bytes -= excess;
89
+ }
90
+ this.dropped = true;
91
+ }
92
+ }
93
+ /** Open the spill file lazily and append `chunk` (and any prior chunks once). */
94
+ spillAll(chunk) {
95
+ if (this.maxSpillBytes !== void 0 && this.total > this.maxSpillBytes) {
96
+ this.discardSpill();
97
+ return;
98
+ }
99
+ if (this.spillFd === void 0) {
100
+ this.spillFile = join(this.spillDir, `dsh-subprocess-${process.pid}-${++spillCounter}-${randomBytes(6).toString("hex")}-${this.label}.log`);
101
+ this.spillFd = openSync(this.spillFile, "wx", 384);
102
+ for (const prior of this.chunks) writeSync(this.spillFd, prior);
103
+ }
104
+ writeSync(this.spillFd, chunk);
105
+ }
106
+ /** Stop spilling and remove the file once it can no longer hold the complete stream. */
107
+ discardSpill() {
108
+ const fd = this.spillFd;
109
+ const file = this.spillFile;
110
+ this.spillFd = void 0;
111
+ this.spillFile = void 0;
112
+ this.spillDisabled = true;
113
+ if (fd !== void 0) try {
114
+ closeSync(fd);
115
+ } catch {
116
+ this.spillFd = fd;
117
+ }
118
+ if (file !== void 0) try {
119
+ unlinkSync(file);
120
+ } catch {}
121
+ }
122
+ /**
123
+ * Incremental read in whole-stream byte coordinates: returns everything
124
+ * pushed since `fromByte`. When `fromByte` has already slid out of the
125
+ * in-memory tail window, the read is `lossy` — it returns the whole
126
+ * retained tail and the gap is only recoverable from the spill file.
127
+ * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
128
+ * @returns the delta text, the offset for the next read, the `lossy` flag, and the spill path when one was created.
129
+ */
130
+ readFrom(fromByte) {
131
+ const windowStart = this.total - this.bytes;
132
+ const buffer = Buffer.concat(this.chunks);
133
+ const lossy = fromByte < windowStart;
134
+ return {
135
+ text: (lossy ? buffer : buffer.subarray(fromByte - windowStart)).toString("utf8"),
136
+ nextOffset: this.total,
137
+ lossy,
138
+ ...this.spillFile !== void 0 ? { spillPath: this.spillFile } : {}
139
+ };
140
+ }
141
+ /**
142
+ * Copy the retained raw tail with its position in the complete observed stream.
143
+ * @returns independent tail bytes and the total byte count before truncation.
144
+ */
145
+ snapshot() {
146
+ return {
147
+ bytes: Buffer.concat(this.chunks),
148
+ totalBytes: this.total
149
+ };
150
+ }
151
+ /**
152
+ * Close the spill file once the stream has ended. A failed close (delayed
153
+ * writeback fault) stops advertising the spill path — the file may be
154
+ * missing its tail — while every in-memory read keeps working. Idempotent;
155
+ * the spawn path seals both collectors at settlement so reads after exit
156
+ * never point at a still-open file.
157
+ */
158
+ seal() {
159
+ if (this.spillFd === void 0) return;
160
+ try {
161
+ closeSync(this.spillFd);
162
+ } catch {
163
+ this.spillFile = void 0;
164
+ }
165
+ this.spillFd = void 0;
166
+ }
167
+ /**
168
+ * Seal the spill file and return the final output.
169
+ * @returns the final collected output: tail text, truncation flag, and the spill path when intact.
170
+ */
171
+ finalize() {
172
+ this.seal();
173
+ return {
174
+ text: Buffer.concat(this.chunks).toString("utf8"),
175
+ truncated: this.dropped,
176
+ ...this.spillFile !== void 0 ? { spillPath: this.spillFile } : {}
177
+ };
178
+ }
179
+ };
180
+ //#endregion
181
+ export { OutputCollector, prepareManagedProcessBinding };