@deepseek-ai/dsh-subprocess-local 0.1.5-rc.2 → 0.1.6-alpha.1
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.i18n.yaml +2 -2
- package/README.md +13 -2
- package/README.zh.md +20 -9
- package/lib/index.js +218 -71
- package/lib/output.js +181 -0
- package/lib/{runner-launch-COYGu0Dl.js → runner-launch-BnjugUhn.js} +63 -183
- package/lib/runner.js +21 -10
- package/lib/types/control-spawn.d.ts +19 -0
- package/lib/types/index.d.ts +5 -1
- package/lib/types/linux-execve.d.ts +1 -1
- package/lib/types/linux-scope.d.ts +13 -3
- package/lib/types/managed-owner.d.ts +2 -1
- package/lib/types/output.d.ts +85 -0
- package/lib/types/runner-launch.d.ts +4 -2
- package/lib/types/runner-protocol.d.ts +2 -0
- package/lib/types/spawn-runner.d.ts +1 -1
- package/lib/types/spawn.d.ts +1 -74
- package/lib/types/terminal.d.ts +2 -0
- package/package.json +13 -8
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 };
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { OutputCollector, prepareManagedProcessBinding } from "./output.js";
|
|
2
|
+
import { accessSync, chmodSync, closeSync, constants, existsSync, lstatSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
2
4
|
import { basename, dirname, extname, isAbsolute, join } from "node:path";
|
|
3
5
|
import { scrubbedParentEnv } from "@deepseek-ai/dsh-subprocess";
|
|
4
6
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
5
|
-
import { randomBytes } from "node:crypto";
|
|
6
|
-
import { tmpdir } from "node:os";
|
|
7
7
|
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
8
8
|
import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
|
|
9
|
+
import { SUBPROCESS_CONTROL_ENV, SUBPROCESS_CONTROL_FD } from "@deepseek-ai/dsh-subprocess/control";
|
|
9
10
|
import koffi from "koffi";
|
|
10
11
|
import { getSystemErrorMessage, getSystemErrorName, inspect } from "node:util";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
@@ -38,6 +39,30 @@ async function waitWithAbort(pending, signal) {
|
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
//#endregion
|
|
42
|
+
//#region lib/types/control-spawn.js
|
|
43
|
+
/** Parent-side setup for one explicitly requested inherited control pipe. */
|
|
44
|
+
/**
|
|
45
|
+
* Read the optional extra pipe from Node's stdio tuple.
|
|
46
|
+
* @param child - child whose requested extra pipe was allocated by Node.
|
|
47
|
+
* @param control - requested transport, or undefined when absent.
|
|
48
|
+
* @returns the parent duplex endpoint, absent when not requested or native startup failed.
|
|
49
|
+
*/
|
|
50
|
+
function controlPipe(child, control) {
|
|
51
|
+
const streams = child.stdio;
|
|
52
|
+
return control === "pipe" ? streams[SUBPROCESS_CONTROL_FD] : void 0;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Stamp the private marker on a fresh child environment after rejecting a caller override.
|
|
56
|
+
* @param env - newly materialized child environment, owned by the caller.
|
|
57
|
+
* @param control - requested control transport, or undefined when absent.
|
|
58
|
+
* @returns the same environment with the provider-owned launch marker when requested.
|
|
59
|
+
*/
|
|
60
|
+
function controlEnvironment(env, control) {
|
|
61
|
+
for (const [key, value] of Object.entries(env)) if (key.toUpperCase() === SUBPROCESS_CONTROL_ENV && value !== void 0) throw new Error(`${SUBPROCESS_CONTROL_ENV} is reserved for subprocess control-channel setup`);
|
|
62
|
+
if (control === "pipe") Object.assign(env, { [SUBPROCESS_CONTROL_ENV]: "pipe" });
|
|
63
|
+
return env;
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
41
66
|
//#region lib/types/windows-inspector.js
|
|
42
67
|
/**
|
|
43
68
|
* Windows process-table operations for terminal readiness, signalling, and
|
|
@@ -669,169 +694,6 @@ function childEnv(extra) {
|
|
|
669
694
|
function sleepTick() {
|
|
670
695
|
return setTimeout$1(15);
|
|
671
696
|
}
|
|
672
|
-
let spillCounter = 0;
|
|
673
|
-
let defaultSpillDir;
|
|
674
|
-
/**
|
|
675
|
-
* The default spill location: a private (0700) per-process directory under
|
|
676
|
-
* the OS tmpdir, created lazily. Predictable world-readable paths would let
|
|
677
|
-
* other local users read command output or pre-create symlinks. At a
|
|
678
|
-
* JavaScript-observable process exit the directory is removed only when it
|
|
679
|
-
* holds no completed spill file (spill files are retained as full-output
|
|
680
|
-
* recovery artifacts until an external cleanup).
|
|
681
|
-
*/
|
|
682
|
-
function privateSpillDir() {
|
|
683
|
-
defaultSpillDir ??= mkdtempSync(join(tmpdir(), "dsh-subprocess-"));
|
|
684
|
-
return defaultSpillDir;
|
|
685
|
-
}
|
|
686
|
-
/* v8 ignore next 4 -- exit listeners run after the coverage dump; removal is verified by the CI /tmp residue measurement. */
|
|
687
|
-
process.once("exit", () => {
|
|
688
|
-
if (defaultSpillDir === void 0) return;
|
|
689
|
-
try {
|
|
690
|
-
rmdirSync(defaultSpillDir);
|
|
691
|
-
} catch {}
|
|
692
|
-
});
|
|
693
|
-
/**
|
|
694
|
-
* Prepare fallible output storage before starting a managed native process.
|
|
695
|
-
* @param internals - optional caller-owned spill directory.
|
|
696
|
-
* @returns binding inputs whose spill directory is ready for use.
|
|
697
|
-
*/
|
|
698
|
-
function prepareManagedProcessBinding(internals = {}) {
|
|
699
|
-
return { spillDir: internals.spillDir ?? privateSpillDir() };
|
|
700
|
-
}
|
|
701
|
-
/**
|
|
702
|
-
* Collects one stream with a bounded in-memory tail. With a spill cap, on
|
|
703
|
-
* first overflow a spill file is created and every chunk (including those
|
|
704
|
-
* already collected) is appended there while the full stream remains within
|
|
705
|
-
* the cap; without one, only the in-memory tail is ever retained (the
|
|
706
|
-
* diagnostic-tail shape — a language server's stderr).
|
|
707
|
-
*
|
|
708
|
-
* Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
|
|
709
|
-
* end of command output; the spill file covers the head.
|
|
710
|
-
*/
|
|
711
|
-
var OutputCollector = class {
|
|
712
|
-
maxBytes;
|
|
713
|
-
maxSpillBytes;
|
|
714
|
-
label;
|
|
715
|
-
spillDir;
|
|
716
|
-
chunks = [];
|
|
717
|
-
bytes = 0;
|
|
718
|
-
dropped = false;
|
|
719
|
-
spillFd;
|
|
720
|
-
spillFile;
|
|
721
|
-
spillDisabled;
|
|
722
|
-
/** Total bytes ever pushed (not just retained). */
|
|
723
|
-
total = 0;
|
|
724
|
-
constructor(maxBytes, maxSpillBytes, label, spillDir) {
|
|
725
|
-
this.maxBytes = maxBytes;
|
|
726
|
-
this.maxSpillBytes = maxSpillBytes;
|
|
727
|
-
this.label = label;
|
|
728
|
-
this.spillDir = spillDir;
|
|
729
|
-
this.spillDisabled = maxSpillBytes === void 0;
|
|
730
|
-
}
|
|
731
|
-
/**
|
|
732
|
-
* Ingest one stream chunk, counting it toward the whole-stream total. On
|
|
733
|
-
* first overflow of the in-memory cap a spill file is opened (when spilling
|
|
734
|
-
* is enabled) and every chunk (already-collected ones included) is appended
|
|
735
|
-
* there from then on; the in-memory tail then drops whole chunks from its
|
|
736
|
-
* head (or the head of a single over-cap chunk) until it fits the cap again.
|
|
737
|
-
* @param chunk - the raw bytes from one stream 'data' event.
|
|
738
|
-
*/
|
|
739
|
-
push(chunk) {
|
|
740
|
-
this.total += chunk.length;
|
|
741
|
-
const overflows = this.bytes + chunk.length > this.maxBytes;
|
|
742
|
-
if (!this.spillDisabled && (overflows || this.spillFd !== void 0)) this.spillAll(chunk);
|
|
743
|
-
this.chunks.push(chunk);
|
|
744
|
-
this.bytes += chunk.length;
|
|
745
|
-
while (this.bytes > this.maxBytes) {
|
|
746
|
-
const head = this.chunks[0];
|
|
747
|
-
const excess = this.bytes - this.maxBytes;
|
|
748
|
-
if (head.length <= excess) {
|
|
749
|
-
this.chunks.shift();
|
|
750
|
-
this.bytes -= head.length;
|
|
751
|
-
} else {
|
|
752
|
-
this.chunks[0] = head.subarray(excess);
|
|
753
|
-
this.bytes -= excess;
|
|
754
|
-
}
|
|
755
|
-
this.dropped = true;
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
/** Open the spill file lazily and append `chunk` (and any prior chunks once). */
|
|
759
|
-
spillAll(chunk) {
|
|
760
|
-
if (this.maxSpillBytes !== void 0 && this.total > this.maxSpillBytes) {
|
|
761
|
-
this.discardSpill();
|
|
762
|
-
return;
|
|
763
|
-
}
|
|
764
|
-
if (this.spillFd === void 0) {
|
|
765
|
-
this.spillFile = join(this.spillDir, `dsh-subprocess-${process.pid}-${++spillCounter}-${randomBytes(6).toString("hex")}-${this.label}.log`);
|
|
766
|
-
this.spillFd = openSync(this.spillFile, "wx", 384);
|
|
767
|
-
for (const prior of this.chunks) writeSync(this.spillFd, prior);
|
|
768
|
-
}
|
|
769
|
-
writeSync(this.spillFd, chunk);
|
|
770
|
-
}
|
|
771
|
-
/** Stop spilling and remove the file once it can no longer hold the complete stream. */
|
|
772
|
-
discardSpill() {
|
|
773
|
-
const fd = this.spillFd;
|
|
774
|
-
const file = this.spillFile;
|
|
775
|
-
this.spillFd = void 0;
|
|
776
|
-
this.spillFile = void 0;
|
|
777
|
-
this.spillDisabled = true;
|
|
778
|
-
if (fd !== void 0) try {
|
|
779
|
-
closeSync(fd);
|
|
780
|
-
} catch {
|
|
781
|
-
this.spillFd = fd;
|
|
782
|
-
}
|
|
783
|
-
if (file !== void 0) try {
|
|
784
|
-
unlinkSync(file);
|
|
785
|
-
} catch {}
|
|
786
|
-
}
|
|
787
|
-
/**
|
|
788
|
-
* Incremental read in whole-stream byte coordinates: returns everything
|
|
789
|
-
* pushed since `fromByte`. When `fromByte` has already slid out of the
|
|
790
|
-
* in-memory tail window, the read is `lossy` — it returns the whole
|
|
791
|
-
* retained tail and the gap is only recoverable from the spill file.
|
|
792
|
-
* @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
|
|
793
|
-
* @returns the delta text, the offset for the next read, the `lossy` flag, and the spill path when one was created.
|
|
794
|
-
*/
|
|
795
|
-
readFrom(fromByte) {
|
|
796
|
-
const windowStart = this.total - this.bytes;
|
|
797
|
-
const buffer = Buffer.concat(this.chunks);
|
|
798
|
-
const lossy = fromByte < windowStart;
|
|
799
|
-
return {
|
|
800
|
-
text: (lossy ? buffer : buffer.subarray(fromByte - windowStart)).toString("utf8"),
|
|
801
|
-
nextOffset: this.total,
|
|
802
|
-
lossy,
|
|
803
|
-
...this.spillFile !== void 0 ? { spillPath: this.spillFile } : {}
|
|
804
|
-
};
|
|
805
|
-
}
|
|
806
|
-
/**
|
|
807
|
-
* Close the spill file once the stream has ended. A failed close (delayed
|
|
808
|
-
* writeback fault) stops advertising the spill path — the file may be
|
|
809
|
-
* missing its tail — while every in-memory read keeps working. Idempotent;
|
|
810
|
-
* the spawn path seals both collectors at settlement so reads after exit
|
|
811
|
-
* never point at a still-open file.
|
|
812
|
-
*/
|
|
813
|
-
seal() {
|
|
814
|
-
if (this.spillFd === void 0) return;
|
|
815
|
-
try {
|
|
816
|
-
closeSync(this.spillFd);
|
|
817
|
-
} catch {
|
|
818
|
-
this.spillFile = void 0;
|
|
819
|
-
}
|
|
820
|
-
this.spillFd = void 0;
|
|
821
|
-
}
|
|
822
|
-
/**
|
|
823
|
-
* Seal the spill file and return the final output.
|
|
824
|
-
* @returns the final collected output: tail text, truncation flag, and the spill path when intact.
|
|
825
|
-
*/
|
|
826
|
-
finalize() {
|
|
827
|
-
this.seal();
|
|
828
|
-
return {
|
|
829
|
-
text: Buffer.concat(this.chunks).toString("utf8"),
|
|
830
|
-
truncated: this.dropped,
|
|
831
|
-
...this.spillFile !== void 0 ? { spillPath: this.spillFile } : {}
|
|
832
|
-
};
|
|
833
|
-
}
|
|
834
|
-
};
|
|
835
697
|
/**
|
|
836
698
|
* Terminate one Windows process tree with `taskkill /T /F`. Contained like
|
|
837
699
|
* POSIX group signalling — delivery races tree exit, so an absent tree, a
|
|
@@ -1113,6 +975,7 @@ function bindManagedProcess(spec, launch, internals = {}) {
|
|
|
1113
975
|
stdin: stdinMode === "pipe" ? stdin ?? void 0 : void 0,
|
|
1114
976
|
stdout: outMode === "pipe" ? stdout ?? void 0 : void 0,
|
|
1115
977
|
stderr: errMode === "pipe" ? stderr ?? void 0 : void 0,
|
|
978
|
+
control: launch.control,
|
|
1116
979
|
/* v8 ignore stop */
|
|
1117
980
|
collected: {
|
|
1118
981
|
...stdoutCollector !== void 0 ? { stdout: stdoutCollector } : {},
|
|
@@ -1134,14 +997,19 @@ function spawnSubprocess(spec, internals = {}) {
|
|
|
1134
997
|
const binding = prepareManagedProcessBinding(internals);
|
|
1135
998
|
const platform = internals.platform ?? process.platform;
|
|
1136
999
|
const [program, ...args] = spec.argv;
|
|
1000
|
+
const stdio = [
|
|
1001
|
+
spec.stdio.stdin === "ignore" ? "ignore" : "pipe",
|
|
1002
|
+
spec.stdio.stdout === "inherit" ? "inherit" : "pipe",
|
|
1003
|
+
spec.stdio.stderr === "inherit" ? "inherit" : "pipe"
|
|
1004
|
+
];
|
|
1005
|
+
if (spec.stdio.control === "pipe") {
|
|
1006
|
+
while (stdio.length < SUBPROCESS_CONTROL_FD) stdio.push("ignore");
|
|
1007
|
+
stdio.push("overlapped");
|
|
1008
|
+
}
|
|
1137
1009
|
const child = (internals.spawn ?? spawn)(program, args, {
|
|
1138
1010
|
cwd: spec.cwd,
|
|
1139
|
-
env: childEnv(spec.env),
|
|
1140
|
-
stdio
|
|
1141
|
-
spec.stdio.stdin === "ignore" ? "ignore" : "pipe",
|
|
1142
|
-
spec.stdio.stdout === "inherit" ? "inherit" : "pipe",
|
|
1143
|
-
spec.stdio.stderr === "inherit" ? "inherit" : "pipe"
|
|
1144
|
-
],
|
|
1011
|
+
env: controlEnvironment(childEnv(spec.env), spec.stdio.control),
|
|
1012
|
+
stdio,
|
|
1145
1013
|
detached: platform !== "win32",
|
|
1146
1014
|
windowsHide: platform === "win32"
|
|
1147
1015
|
});
|
|
@@ -1152,6 +1020,7 @@ function spawnSubprocess(spec, internals = {}) {
|
|
|
1152
1020
|
stdin: child.stdin,
|
|
1153
1021
|
stdout: child.stdout,
|
|
1154
1022
|
stderr: child.stderr,
|
|
1023
|
+
control: controlPipe(child, spec.stdio.control),
|
|
1155
1024
|
direct,
|
|
1156
1025
|
owner
|
|
1157
1026
|
}, binding);
|
|
@@ -1189,8 +1058,9 @@ function loadLinuxExecve() {
|
|
|
1189
1058
|
const libc = koffi.load(null);
|
|
1190
1059
|
const nativeExecve = libc.func("int execve(const char *pathname, const char **argv, const char **envp)");
|
|
1191
1060
|
const nativeFcntl = libc.func("int fcntl(int fd, int cmd, int arg)");
|
|
1192
|
-
cachedExecve = (file, argv, env) => {
|
|
1193
|
-
|
|
1061
|
+
cachedExecve = (file, argv, env, control) => {
|
|
1062
|
+
const descriptors = control === "pipe" ? [...STANDARD_FILE_DESCRIPTORS, SUBPROCESS_CONTROL_FD] : STANDARD_FILE_DESCRIPTORS;
|
|
1063
|
+
for (const fd of descriptors) {
|
|
1194
1064
|
const flags = nativeFcntl(fd, F_GETFD, 0);
|
|
1195
1065
|
if (flags === -1) throw systemError(koffi.errno(), "fcntl");
|
|
1196
1066
|
if ((flags & FD_CLOEXEC) === 0) continue;
|
|
@@ -1277,10 +1147,11 @@ function consumeLinuxLaunchRequest(requestPath) {
|
|
|
1277
1147
|
const text = readFileSync(requestPath, "utf8");
|
|
1278
1148
|
unlinkSync(requestPath);
|
|
1279
1149
|
const value = JSON.parse(text);
|
|
1280
|
-
if (!isRecord(value) || !hasExactKeys(value, ["cwd", "env"]) || typeof value.cwd !== "string" || !isStringRecord(value.env)) throw new Error("subprocess runner received an invalid Linux launch request");
|
|
1150
|
+
if (!isRecord(value) || !hasExactKeys(value, ["cwd", "env"], ["control"]) || typeof value.cwd !== "string" || !isStringRecord(value.env) || value.control !== void 0 && value.control !== "pipe") throw new Error("subprocess runner received an invalid Linux launch request");
|
|
1281
1151
|
return {
|
|
1282
1152
|
cwd: value.cwd,
|
|
1283
|
-
env: value.env
|
|
1153
|
+
env: value.env,
|
|
1154
|
+
...value.control === "pipe" ? { control: "pipe" } : {}
|
|
1284
1155
|
};
|
|
1285
1156
|
}
|
|
1286
1157
|
/**
|
|
@@ -1315,11 +1186,12 @@ function parseWindowsStartRequest(value) {
|
|
|
1315
1186
|
"type",
|
|
1316
1187
|
"cwd",
|
|
1317
1188
|
"env"
|
|
1318
|
-
]) || value.type !== "start" || typeof value.cwd !== "string" || !isStringRecord(value.env)) throw new Error("subprocess runner received an invalid Windows start request");
|
|
1189
|
+
], ["control"]) || value.type !== "start" || typeof value.cwd !== "string" || !isStringRecord(value.env) || value.control !== void 0 && value.control !== "pipe") throw new Error("subprocess runner received an invalid Windows start request");
|
|
1319
1190
|
return {
|
|
1320
1191
|
type: "start",
|
|
1321
1192
|
cwd: value.cwd,
|
|
1322
|
-
env: value.env
|
|
1193
|
+
env: value.env,
|
|
1194
|
+
...value.control === "pipe" ? { control: "pipe" } : {}
|
|
1323
1195
|
};
|
|
1324
1196
|
}
|
|
1325
1197
|
/**
|
|
@@ -1477,7 +1349,7 @@ function parseRunnerTargetArgv(argv) {
|
|
|
1477
1349
|
}
|
|
1478
1350
|
/**
|
|
1479
1351
|
* Build direct Linux target stdio, or isolated Windows runner stdio with IPC
|
|
1480
|
-
* on fd 3 and target carriers on fd 4 through fd 6.
|
|
1352
|
+
* on fd 3 and target carriers on fd 4 through fd 6; optional control uses fd 7.
|
|
1481
1353
|
* @param spec - ordinary subprocess request whose stdio modes are preserved.
|
|
1482
1354
|
* @param ipc - whether to isolate the runner and add its private Node IPC descriptor.
|
|
1483
1355
|
* @param stdinCarrier - runner fd 4 carrier; Windows ignore passes an opened null-device fd.
|
|
@@ -1489,8 +1361,14 @@ function runnerStdio(spec, ipc, stdinCarrier = "pipe") {
|
|
|
1489
1361
|
spec.stdio.stdout === "inherit" ? "inherit" : "pipe",
|
|
1490
1362
|
spec.stdio.stderr === "inherit" ? "inherit" : "pipe"
|
|
1491
1363
|
];
|
|
1492
|
-
if (!ipc)
|
|
1493
|
-
|
|
1364
|
+
if (!ipc) {
|
|
1365
|
+
if (spec.stdio.control === "pipe") {
|
|
1366
|
+
while (targetStdio.length < SUBPROCESS_CONTROL_FD) targetStdio.push("ignore");
|
|
1367
|
+
targetStdio.push("overlapped");
|
|
1368
|
+
}
|
|
1369
|
+
return targetStdio;
|
|
1370
|
+
}
|
|
1371
|
+
const runner = [
|
|
1494
1372
|
"ignore",
|
|
1495
1373
|
"ignore",
|
|
1496
1374
|
"ignore",
|
|
@@ -1499,6 +1377,8 @@ function runnerStdio(spec, ipc, stdinCarrier = "pipe") {
|
|
|
1499
1377
|
spec.stdio.stdout === "inherit" ? 1 : "pipe",
|
|
1500
1378
|
spec.stdio.stderr === "inherit" ? 2 : "pipe"
|
|
1501
1379
|
];
|
|
1380
|
+
if (spec.stdio.control === "pipe") runner.push("overlapped");
|
|
1381
|
+
return runner;
|
|
1502
1382
|
}
|
|
1503
1383
|
function windowsEnvironmentValue(env, name) {
|
|
1504
1384
|
for (const key of Object.keys(env).sort()) if (key.toUpperCase() === name) return env[key];
|
|
@@ -1617,7 +1497,7 @@ function targetEnvironment(spec) {
|
|
|
1617
1497
|
validateNoNullByte(`options.env['${key}']`, key);
|
|
1618
1498
|
validateNoNullByte(`options.env['${key}']`, value);
|
|
1619
1499
|
}
|
|
1620
|
-
return env;
|
|
1500
|
+
return controlEnvironment(env, spec.stdio?.control);
|
|
1621
1501
|
}
|
|
1622
1502
|
//#endregion
|
|
1623
|
-
export { bindManagedProcess as C,
|
|
1503
|
+
export { bindManagedProcess as C, createProcessInspector as D, validateSubprocessSpec as E, controlPipe as O, loadLinuxExecve as S, spawnSubprocess as T, parseWindowsRunnerResult as _, resolveWindowsExecutable as a, serializeRunnerError as b, runnerStdio as c, cleanupLinuxLaunchFiles as d, consumeLinuxLaunchRequest as f, linuxLaunchFilesFromLocator as g, isWindowsTerminateRequest as h, parseRunnerTargetArgv as i, spawnRunnerInvocation as l, deserializeRunnerError as m, WINDOWS_RUNNER_SELECTION as n, runnerEnvironment as o, createLinuxLaunchFiles as p, consumeRunnerSelection as r, runnerInvocationAvailable as s, SUBPROCESS_RUNNER_ENV as t, targetEnvironment as u, parseWindowsStartRequest as v, childEnv as w, writeLinuxStartupError as x, readLinuxStartupError as y };
|
package/lib/runner.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { S as loadLinuxExecve, a as resolveWindowsExecutable, b as serializeRunnerError, f as consumeLinuxLaunchRequest, g as linuxLaunchFilesFromLocator, h as isWindowsTerminateRequest, i as parseRunnerTargetArgv, r as consumeRunnerSelection, t as SUBPROCESS_RUNNER_ENV, v as parseWindowsStartRequest, x as writeLinuxStartupError } from "./runner-launch-
|
|
1
|
+
import { S as loadLinuxExecve, a as resolveWindowsExecutable, b as serializeRunnerError, f as consumeLinuxLaunchRequest, g as linuxLaunchFilesFromLocator, h as isWindowsTerminateRequest, i as parseRunnerTargetArgv, r as consumeRunnerSelection, t as SUBPROCESS_RUNNER_ENV, v as parseWindowsStartRequest, x as writeLinuxStartupError } from "./runner-launch-BnjugUhn.js";
|
|
2
2
|
import { closeSync } from "node:fs";
|
|
3
|
+
import { SUBPROCESS_CONTROL_FD } from "@deepseek-ai/dsh-subprocess/control";
|
|
3
4
|
import { Win32Error, closeHandleChecked, isJobEmpty, loadWin32ProcessBindings, pollProcessExit, spawnCurrentTokenJobProcess, terminateJob } from "@deepseek-ai/dsh-win32-process";
|
|
4
5
|
//#region lib/types/spawn-runner.js
|
|
5
6
|
/** One-shot Linux exec bootstrap and Windows Job-owning subprocess runner. */
|
|
6
7
|
const defaultInternals = {
|
|
7
8
|
/* v8 ignore next -- source/built/packaged subprocess smoke executes this only in a replaceable child process. */
|
|
8
|
-
execve: (file, argv, env) => loadLinuxExecve()(file, argv, env),
|
|
9
|
+
execve: (file, argv, env, control) => loadLinuxExecve()(file, argv, env, control),
|
|
9
10
|
loadWin32ProcessBindings,
|
|
10
11
|
spawnCurrentTokenJobProcess,
|
|
11
12
|
closeFileDescriptor: closeSync,
|
|
@@ -58,28 +59,32 @@ function linuxPathNotFoundError(program) {
|
|
|
58
59
|
spawnargs: []
|
|
59
60
|
});
|
|
60
61
|
}
|
|
61
|
-
function execLinuxFile(file, argv, env, internals) {
|
|
62
|
+
function execLinuxFile(file, argv, env, internals, control) {
|
|
62
63
|
try {
|
|
63
|
-
return internals.execve(file, argv, env);
|
|
64
|
+
return control === void 0 ? internals.execve(file, argv, env) : internals.execve(file, argv, env, control);
|
|
64
65
|
} catch (error) {
|
|
65
66
|
if (error.code !== "ENOEXEC") throw error;
|
|
66
|
-
return internals.execve("/bin/sh", [
|
|
67
|
+
return control === void 0 ? internals.execve("/bin/sh", [
|
|
67
68
|
"/bin/sh",
|
|
68
69
|
file,
|
|
69
70
|
...argv.slice(1)
|
|
70
|
-
], env)
|
|
71
|
+
], env) : internals.execve("/bin/sh", [
|
|
72
|
+
"/bin/sh",
|
|
73
|
+
file,
|
|
74
|
+
...argv.slice(1)
|
|
75
|
+
], env, control);
|
|
71
76
|
}
|
|
72
77
|
}
|
|
73
78
|
function execLinuxTarget(request, argv, internals) {
|
|
74
79
|
const program = argv[0];
|
|
75
|
-
if (program.includes("/")) return execLinuxFile(program, argv, request.env, internals);
|
|
80
|
+
if (program.includes("/")) return execLinuxFile(program, argv, request.env, internals, request.control);
|
|
76
81
|
const path = request.env.PATH ?? "/usr/bin:/bin";
|
|
77
82
|
let permissionFailure;
|
|
78
83
|
for (const directory of path.split(":")) {
|
|
79
84
|
const root = directory.startsWith("/") ? directory : `${request.cwd}${request.cwd.endsWith("/") ? "" : "/"}${directory}`;
|
|
80
85
|
const candidate = `${root}${root.endsWith("/") ? "" : "/"}${program}`;
|
|
81
86
|
try {
|
|
82
|
-
return execLinuxFile(candidate, argv, request.env, internals);
|
|
87
|
+
return execLinuxFile(candidate, argv, request.env, internals, request.control);
|
|
83
88
|
} catch (error) {
|
|
84
89
|
const code = error.code;
|
|
85
90
|
if (code === "EACCES") {
|
|
@@ -227,12 +232,18 @@ var WindowsJobRunner = class {
|
|
|
227
232
|
stdio: {
|
|
228
233
|
stdin: 4,
|
|
229
234
|
stdout: 5,
|
|
230
|
-
stderr: 6
|
|
235
|
+
stderr: 6,
|
|
236
|
+
...request.control === "pipe" ? { control: SUBPROCESS_CONTROL_FD } : {}
|
|
231
237
|
}
|
|
232
238
|
});
|
|
233
239
|
this.processHandle = spawned.process;
|
|
234
240
|
this.jobHandle = spawned.job;
|
|
235
|
-
for (const fileDescriptor of [
|
|
241
|
+
for (const fileDescriptor of request.control === "pipe" ? [
|
|
242
|
+
4,
|
|
243
|
+
5,
|
|
244
|
+
6,
|
|
245
|
+
SUBPROCESS_CONTROL_FD
|
|
246
|
+
] : [
|
|
236
247
|
4,
|
|
237
248
|
5,
|
|
238
249
|
6
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Parent-side setup for one explicitly requested inherited control pipe. */
|
|
2
|
+
import type { Duplex, Readable, Writable } from 'node:stream';
|
|
3
|
+
/**
|
|
4
|
+
* Read the optional extra pipe from Node's stdio tuple.
|
|
5
|
+
* @param child - child whose requested extra pipe was allocated by Node.
|
|
6
|
+
* @param control - requested transport, or undefined when absent.
|
|
7
|
+
* @returns the parent duplex endpoint, absent when not requested or native startup failed.
|
|
8
|
+
*/
|
|
9
|
+
export declare function controlPipe(child: {
|
|
10
|
+
readonly stdio: ReadonlyArray<Readable | Writable | null | undefined>;
|
|
11
|
+
}, control?: 'pipe'): Duplex | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Stamp the private marker on a fresh child environment after rejecting a caller override.
|
|
14
|
+
* @param env - newly materialized child environment, owned by the caller.
|
|
15
|
+
* @param control - requested control transport, or undefined when absent.
|
|
16
|
+
* @returns the same environment with the provider-owned launch marker when requested.
|
|
17
|
+
*/
|
|
18
|
+
export declare function controlEnvironment<T extends NodeJS.ProcessEnv>(env: T, control?: 'pipe'): T;
|
|
19
|
+
//# sourceMappingURL=control-spawn.d.ts.map
|
package/lib/types/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { Context } from '@deepseek-ai/cordis';
|
|
11
11
|
import { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess';
|
|
12
|
-
import type { SubprocessHandle, SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess';
|
|
12
|
+
import type { SubprocessHandle, SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalEnvironment, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess';
|
|
13
13
|
import type { SpawnInternals } from './spawn.ts';
|
|
14
14
|
import type { ProcessInspector } from './process-inspector.ts';
|
|
15
15
|
/**
|
|
@@ -24,6 +24,8 @@ export declare class LocalSubprocessRuntime extends SubprocessRuntime {
|
|
|
24
24
|
private live;
|
|
25
25
|
/** Live terminals retained through normal quiescence or host-exit finalization. */
|
|
26
26
|
private terminals;
|
|
27
|
+
/** Caller endpoints retained until close, independently of managed process lifetime. */
|
|
28
|
+
private controlChannels;
|
|
27
29
|
/** Test hook: process, spill, and platform operations forwarded to spawnSubprocess. */
|
|
28
30
|
internals: SpawnInternals;
|
|
29
31
|
/** Provider-lifetime latch suppressing repeated weaker-containment warnings. */
|
|
@@ -40,6 +42,8 @@ export declare class LocalSubprocessRuntime extends SubprocessRuntime {
|
|
|
40
42
|
spawn(spec: SubprocessSpawnSpec): SubprocessHandle;
|
|
41
43
|
private selectContainmentMode;
|
|
42
44
|
private warnFallback;
|
|
45
|
+
/** @inheritdoc */
|
|
46
|
+
terminalEnvironment(signal?: AbortSignal): Promise<SubprocessTerminalEnvironment>;
|
|
43
47
|
spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle>;
|
|
44
48
|
}
|
|
45
49
|
export default LocalSubprocessRuntime;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Lazy libc execve and descriptor bindings used by the one-shot Linux bootstrap. */
|
|
2
2
|
/** Replace the current process image while preserving the supplied argv and environment. */
|
|
3
|
-
export type LinuxExecve = (file: string, argv: string[], env: Record<string, string
|
|
3
|
+
export type LinuxExecve = (file: string, argv: string[], env: Record<string, string>, control?: 'pipe') => never;
|
|
4
4
|
/**
|
|
5
5
|
* Load libc's execve and fcntl symbols on first use and retain the native bindings.
|
|
6
6
|
* @returns a process-replacing execve operation that throws Node-style errors on failure.
|