dsh-plugin-wsl-env 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/CHANGELOG.md +417 -0
- package/CONTRIBUTING.md +2 -2
- package/README.md +84 -68
- package/SUPPORT.md +3 -3
- package/agent/native/linux-aarch64/dsh-wsl-agent +0 -0
- package/agent/native/linux-x86_64/dsh-wsl-agent +0 -0
- package/agent/wsl-agent.sh +83 -16
- package/cordis.patch.yml +41 -35
- package/docs/ARCHITECTURE.md +9 -1
- package/docs/CONFIGURATION.md +7 -4
- package/docs/LIMITATIONS.md +53 -18
- package/docs/PARITY.md +3 -1
- package/docs/PEER-PARITY.md +18 -11
- package/docs/README.zh.md +97 -74
- package/docs/UPSTREAM-FSIO-EXPORT.md +3 -3
- package/docs/UPSTREAM-TERMINAL-TITLE.md +9 -2
- package/examples/preset-wsl-session-sandbox.cordis.patch.yml +178 -0
- package/lib/agent-protocol.d.ts +10 -4
- package/lib/agent-protocol.js +10 -3
- package/lib/agent.d.ts +84 -0
- package/lib/agent.js +167 -15
- package/lib/bwrap.d.ts +8 -0
- package/lib/bwrap.js +4 -1
- package/lib/fs-routing.d.ts +40 -4
- package/lib/fs-routing.js +80 -12
- package/lib/fsio-agent.js +1 -1
- package/lib/index.d.ts +134 -0
- package/lib/index.js +191 -3
- package/lib/native-agent.d.ts +124 -0
- package/lib/native-agent.js +198 -0
- package/lib/ports.d.ts +13 -2
- package/lib/ports.js +18 -5
- package/lib/preflight.js +24 -1
- package/lib/session-runtime.d.ts +161 -0
- package/lib/session-runtime.js +220 -0
- package/lib/session-scope.d.ts +124 -0
- package/lib/session-scope.js +191 -0
- package/lib/shell-env.js +10 -3
- package/lib/watcher.d.ts +25 -12
- package/lib/watcher.js +120 -32
- package/lib/workspace-files-route.d.ts +8 -4
- package/lib/workspace-files-route.js +42 -14
- package/lib/workspace-files-wsl.js +25 -8
- package/package.json +34 -25
package/lib/agent.d.ts
CHANGED
|
@@ -73,6 +73,12 @@ export interface PendingRequest {
|
|
|
73
73
|
onAbort: (() => void) | null;
|
|
74
74
|
/** Arms `watchdog`; null when the request carries no timeout. */
|
|
75
75
|
armWatchdog: (() => void) | null;
|
|
76
|
+
/** Whether the agent has ACKed this request (it is the running one). */
|
|
77
|
+
started: boolean;
|
|
78
|
+
/** The caller cancelled it; the entry stays until the agent answers. */
|
|
79
|
+
cancelled: boolean;
|
|
80
|
+
/** The caller was already settled; a late answer only cleans up. */
|
|
81
|
+
settled: boolean;
|
|
76
82
|
}
|
|
77
83
|
/**
|
|
78
84
|
* The Windows environment the transport starts from, pinned.
|
|
@@ -134,13 +140,36 @@ export declare class WslAgent {
|
|
|
134
140
|
distro: string;
|
|
135
141
|
scriptPath: string;
|
|
136
142
|
argvPrefix: string[];
|
|
143
|
+
leaseMs: number;
|
|
137
144
|
}) => ChildProcess) | undefined;
|
|
145
|
+
/** The explicit lease, when the caller supplied one (independent of idleMs). */
|
|
146
|
+
configuredLease: number | undefined;
|
|
147
|
+
/** The injectable signal sender, when the caller supplied one. */
|
|
148
|
+
configuredSignal: ((options: {
|
|
149
|
+
wslPath: string;
|
|
150
|
+
distro: string;
|
|
151
|
+
pid: number;
|
|
152
|
+
signal: NodeJS.Signals;
|
|
153
|
+
}) => void) | undefined;
|
|
154
|
+
/** The agent shell's PID from its HELLO, when a second wsl.exe can address it. */
|
|
155
|
+
agentPid: number | undefined;
|
|
156
|
+
/**
|
|
157
|
+
* Whether a second `wsl.exe` can deliver `USR1` to the agent's HELLO pid.
|
|
158
|
+
* True for the plain agent; false for a resident inside a PID namespace. A
|
|
159
|
+
* session runtime has a prefix (scope + mounts) but no PID namespace, so it
|
|
160
|
+
* opts in explicitly.
|
|
161
|
+
*/
|
|
162
|
+
signalable: boolean;
|
|
163
|
+
/** The maximum requests allowed in flight on this resident. */
|
|
164
|
+
maxPending: number;
|
|
138
165
|
/** The live resident process, when any. */
|
|
139
166
|
process: ChildProcess | null;
|
|
140
167
|
/** The lifecycle state. */
|
|
141
168
|
state: "idle" | "starting" | "ready" | "dead";
|
|
142
169
|
/** The in-flight start, shared by concurrent callers. */
|
|
143
170
|
startPromise: Promise<void> | null;
|
|
171
|
+
/** The in-flight start's settler, so close() can fail a start it interrupts. */
|
|
172
|
+
startSettler: ((error?: unknown) => void) | null;
|
|
144
173
|
/** The next request id. */
|
|
145
174
|
nextRequestId: number;
|
|
146
175
|
/** The requests in flight, keyed by their current id. */
|
|
@@ -166,6 +195,13 @@ export declare class WslAgent {
|
|
|
166
195
|
wslPath?: string;
|
|
167
196
|
/** Idle shutdown delay; 0 disables. */
|
|
168
197
|
idleMs?: number;
|
|
198
|
+
/**
|
|
199
|
+
* The client lease in ms, independent of `idleMs`. A session runtime holds
|
|
200
|
+
* the agent for the session's lifetime (no idle close) but still wants the
|
|
201
|
+
* lease's last-resort self-termination; a positive value here overrides the
|
|
202
|
+
* idle-derived lease.
|
|
203
|
+
*/
|
|
204
|
+
leaseMs?: number;
|
|
169
205
|
/**
|
|
170
206
|
* The sha256 of the agent script as this package shipped it; a HELLO whose
|
|
171
207
|
* digest differs is refused the same way a version mismatch is (the script
|
|
@@ -180,6 +216,13 @@ export declare class WslAgent {
|
|
|
180
216
|
* Empty for the plain agent.
|
|
181
217
|
*/
|
|
182
218
|
argvPrefix?: string[];
|
|
219
|
+
/**
|
|
220
|
+
* Whether a second `wsl.exe` can signal the agent's pid. Defaults to
|
|
221
|
+
* "true when the argv prefix is empty": the plain agent shares the distro's
|
|
222
|
+
* PID namespace, a confined resident does not, and a session runtime has a
|
|
223
|
+
* prefix but no PID namespace and must opt in.
|
|
224
|
+
*/
|
|
225
|
+
signalable?: boolean;
|
|
183
226
|
/** Hard ceiling in bytes on one stdout protocol line; defaults to {@link MAX_FRAME_BYTES}. */
|
|
184
227
|
frameCapBytes?: number;
|
|
185
228
|
/** Injectable process factory for tests. */
|
|
@@ -188,7 +231,23 @@ export declare class WslAgent {
|
|
|
188
231
|
distro: string;
|
|
189
232
|
scriptPath: string;
|
|
190
233
|
argvPrefix: string[];
|
|
234
|
+
leaseMs: number;
|
|
191
235
|
}) => ChildProcess;
|
|
236
|
+
/**
|
|
237
|
+
* Injectable out-of-band signal delivery for tests. The real path spawns a
|
|
238
|
+
* second `wsl.exe` to send USR1 to the agent's PID.
|
|
239
|
+
*/
|
|
240
|
+
signalTransport?: (options: {
|
|
241
|
+
wslPath: string;
|
|
242
|
+
distro: string;
|
|
243
|
+
pid: number;
|
|
244
|
+
signal: NodeJS.Signals;
|
|
245
|
+
}) => void;
|
|
246
|
+
/**
|
|
247
|
+
* Maximum requests in flight on one resident; a submission past it is
|
|
248
|
+
* refused instead of growing the request map without bound.
|
|
249
|
+
*/
|
|
250
|
+
maxPending?: number;
|
|
192
251
|
});
|
|
193
252
|
/**
|
|
194
253
|
* Whether the agent is permanently out — the executor's fallback cue.
|
|
@@ -217,6 +276,7 @@ export declare class WslAgent {
|
|
|
217
276
|
* The process died. Requests in flight ride exactly one rebuild; a second
|
|
218
277
|
* death is permanent.
|
|
219
278
|
*
|
|
279
|
+
* @param child - the process that exited.
|
|
220
280
|
* @param code - the exit code, null when killed by a signal.
|
|
221
281
|
* @param signalName - the killing signal, null on a normal exit.
|
|
222
282
|
*/
|
|
@@ -310,6 +370,21 @@ export declare class WslAgent {
|
|
|
310
370
|
* @returns resolved on PONG.
|
|
311
371
|
*/
|
|
312
372
|
ping(signal?: AbortSignal): Promise<void>;
|
|
373
|
+
/**
|
|
374
|
+
* Deliver an out-of-band signal to the resident shell. The serial stdin loop
|
|
375
|
+
* cannot consume a frame while a command runs, so cancellation is a signal;
|
|
376
|
+
* a second `wsl.exe` is the only channel that reaches the agent while it is
|
|
377
|
+
* blocked in `wait`.
|
|
378
|
+
*
|
|
379
|
+
* Only an agent that shares the distro's PID namespace is addressable: the
|
|
380
|
+
* plain agent, or a session runtime (its prefix is a scope plus mounts, but
|
|
381
|
+
* no PID namespace). A confined resident runs inside a PID namespace whose
|
|
382
|
+
* PIDs do not exist in the distro, so signalling its HELLO PID could hit an
|
|
383
|
+
* unrelated process; there the KILL frame remains the (delayed) fallback.
|
|
384
|
+
*
|
|
385
|
+
* @param signal - the signal to deliver (`SIGUSR1` is the cancel trap).
|
|
386
|
+
*/
|
|
387
|
+
private signalAgent;
|
|
313
388
|
/**
|
|
314
389
|
* Shut the agent down: SHUTDOWN first, then kill after a short grace. The
|
|
315
390
|
* agent becomes reusable — a later `exec` starts a fresh process.
|
|
@@ -317,6 +392,15 @@ export declare class WslAgent {
|
|
|
317
392
|
* @returns resolved once the process is gone.
|
|
318
393
|
*/
|
|
319
394
|
close(): Promise<void>;
|
|
395
|
+
/**
|
|
396
|
+
* Release the host's ends of a dead child's stdio. A descendant that
|
|
397
|
+
* inherited a pipe's write end would otherwise keep the read side — and the
|
|
398
|
+
* fd — open until that descendant exits; the agent's own helpers are
|
|
399
|
+
* redirected at their spawn sites, but the host must not depend on it.
|
|
400
|
+
*
|
|
401
|
+
* @param child - the process whose streams are being released.
|
|
402
|
+
*/
|
|
403
|
+
private teardownStreams;
|
|
320
404
|
/**
|
|
321
405
|
* Write one frame to the live process.
|
|
322
406
|
*
|
package/lib/agent.js
CHANGED
|
@@ -44,6 +44,13 @@ import { AGENT_NAME, MAX_FRAME_BYTES, PROTOCOL_VERSION, encodeExecFrame, encodeF
|
|
|
44
44
|
const HANDSHAKE_TIMEOUT_MS = 15000;
|
|
45
45
|
/** Extra time the host watchdog waits past the request's own in-distro timeout. */
|
|
46
46
|
const WATCHDOG_GRACE_MS = 13000;
|
|
47
|
+
/**
|
|
48
|
+
* How many requests one resident may hold in flight before refusing more. The
|
|
49
|
+
* agent is serial, so a large queue only delays every entry; the cap keeps a
|
|
50
|
+
* burst from growing the pending map (and the retained frame lines) without
|
|
51
|
+
* bound.
|
|
52
|
+
*/
|
|
53
|
+
const MAX_PENDING_REQUESTS = 64;
|
|
47
54
|
/** How far past its host's idle shutdown the agent's own lease runs. The
|
|
48
55
|
* lease fires only when the host is gone in every way that matters (a wedged
|
|
49
56
|
* relay that never delivers EOF); the host's close() normally wins the race. */
|
|
@@ -135,13 +142,19 @@ export class WslAgent {
|
|
|
135
142
|
this.scriptPath = config.scriptPath;
|
|
136
143
|
this.wslPath = config.wslPath ?? "wsl.exe";
|
|
137
144
|
this.argvPrefix = config.argvPrefix ?? [];
|
|
145
|
+
this.signalable = config.signalable ?? this.argvPrefix.length === 0;
|
|
138
146
|
this.idleMs = config.idleMs ?? 600000;
|
|
139
147
|
this.expectedDigest = config.expectedDigest;
|
|
140
148
|
this.frameCapBytes = config.frameCapBytes ?? MAX_FRAME_BYTES;
|
|
141
149
|
this.configuredSpawn = config.spawnTransport;
|
|
150
|
+
this.configuredSignal = config.signalTransport;
|
|
151
|
+
this.configuredLease = config.leaseMs;
|
|
152
|
+
this.maxPending = config.maxPending ?? MAX_PENDING_REQUESTS;
|
|
153
|
+
this.agentPid = undefined;
|
|
142
154
|
this.process = null;
|
|
143
155
|
this.state = "idle";
|
|
144
156
|
this.startPromise = null;
|
|
157
|
+
this.startSettler = null;
|
|
145
158
|
this.nextRequestId = 0;
|
|
146
159
|
this.pending = new Map();
|
|
147
160
|
this.rebuildUsed = false;
|
|
@@ -175,6 +188,7 @@ export class WslAgent {
|
|
|
175
188
|
if (this.startPromise)
|
|
176
189
|
return this.startPromise;
|
|
177
190
|
this.state = "starting";
|
|
191
|
+
this.agentPid = undefined;
|
|
178
192
|
this.startPromise = new Promise((resolve, reject) => {
|
|
179
193
|
// stdio is all-pipe (the spawn options above), so every standard stream
|
|
180
194
|
// exists; the broad ChildProcess type models them as nullable.
|
|
@@ -183,14 +197,21 @@ export class WslAgent {
|
|
|
183
197
|
distro: this.distro,
|
|
184
198
|
scriptPath: this.scriptPath,
|
|
185
199
|
argvPrefix: this.argvPrefix,
|
|
186
|
-
leaseMs: this.idleMs > 0 ? this.idleMs + LEASE_GRACE_MS : 0,
|
|
200
|
+
leaseMs: this.configuredLease ?? (this.idleMs > 0 ? this.idleMs + LEASE_GRACE_MS : 0),
|
|
187
201
|
}));
|
|
202
|
+
// One generation, one owner: every closure below belongs to THIS child.
|
|
203
|
+
// A stale child's exit must not settle a newer start, null a newer
|
|
204
|
+
// transport, or answer a newer request.
|
|
205
|
+
const current = () => this.process === child;
|
|
188
206
|
const handshakeTimer = setTimeout(() => {
|
|
207
|
+
if (!current())
|
|
208
|
+
return;
|
|
189
209
|
this.lastError = `agent handshake timed out after ${HANDSHAKE_TIMEOUT_MS}ms`;
|
|
190
210
|
child.kill();
|
|
191
211
|
}, HANDSHAKE_TIMEOUT_MS);
|
|
192
212
|
const settleStart = (error) => {
|
|
193
213
|
clearTimeout(handshakeTimer);
|
|
214
|
+
this.startSettler = null;
|
|
194
215
|
this.startPromise = null;
|
|
195
216
|
if (error) {
|
|
196
217
|
this.state = this.rebuildUsed ? "dead" : "idle";
|
|
@@ -203,16 +224,30 @@ export class WslAgent {
|
|
|
203
224
|
resolve();
|
|
204
225
|
}
|
|
205
226
|
};
|
|
227
|
+
this.startSettler = settleStart;
|
|
206
228
|
child.on("error", (error) => {
|
|
229
|
+
if (!current())
|
|
230
|
+
return;
|
|
207
231
|
this.lastError = String(error);
|
|
208
232
|
settleStart(error);
|
|
209
233
|
});
|
|
234
|
+
// The write side can hit EPIPE after the child exits — a failed
|
|
235
|
+
// handshake followed by close(), or a scope stop. The failure is already
|
|
236
|
+
// reflected in the state/pending requests; an unhandled stream error
|
|
237
|
+
// would crash the host process instead.
|
|
238
|
+
child.stdin?.on("error", () => { });
|
|
210
239
|
child.on("exit", (code, signalName) => {
|
|
240
|
+
if (!current()) {
|
|
241
|
+
// A stale child's exit: release its pipes, but never touch the
|
|
242
|
+
// generation that replaced it.
|
|
243
|
+
this.teardownStreams(child);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
211
246
|
if (this.state === "starting") {
|
|
212
247
|
this.lastError = `agent exited during handshake (code=${code} signal=${signalName ?? "none"})`;
|
|
213
248
|
settleStart(new Error(this.lastError));
|
|
214
249
|
}
|
|
215
|
-
this.handleProcessExit(code, signalName);
|
|
250
|
+
this.handleProcessExit(child, code, signalName);
|
|
216
251
|
});
|
|
217
252
|
// A bounded line splitter instead of readline: readline accumulates an
|
|
218
253
|
// over-long line whole before anyone can object, which is exactly the
|
|
@@ -220,6 +255,8 @@ export class WslAgent {
|
|
|
220
255
|
// (ids, numbers, base64), so chunk-boundary decoding is not a concern.
|
|
221
256
|
let buffered = "";
|
|
222
257
|
child.stdout.on("data", (chunk) => {
|
|
258
|
+
if (!current())
|
|
259
|
+
return;
|
|
223
260
|
buffered += chunk.toString("utf8");
|
|
224
261
|
for (;;) {
|
|
225
262
|
const newline = buffered.indexOf("\n");
|
|
@@ -227,7 +264,7 @@ export class WslAgent {
|
|
|
227
264
|
if (buffered.length > this.frameCapBytes) {
|
|
228
265
|
this.lastError = `agent frame of ${buffered.length} bytes exceeded the ${this.frameCapBytes}-byte protocol cap`;
|
|
229
266
|
buffered = "";
|
|
230
|
-
|
|
267
|
+
child.kill();
|
|
231
268
|
}
|
|
232
269
|
return;
|
|
233
270
|
}
|
|
@@ -236,13 +273,15 @@ export class WslAgent {
|
|
|
236
273
|
if (line.length > this.frameCapBytes) {
|
|
237
274
|
this.lastError = `agent frame of ${line.length} bytes exceeded the ${this.frameCapBytes}-byte protocol cap`;
|
|
238
275
|
buffered = "";
|
|
239
|
-
|
|
276
|
+
child.kill();
|
|
240
277
|
return;
|
|
241
278
|
}
|
|
242
279
|
this.handleLine(line, settleStart, handshakeTimer);
|
|
243
280
|
}
|
|
244
281
|
});
|
|
245
282
|
child.stderr?.on("data", (chunk) => {
|
|
283
|
+
if (!current())
|
|
284
|
+
return;
|
|
246
285
|
// Diagnostics only; `wsl.exe` and the shell chatter here on failure.
|
|
247
286
|
this.lastError = String(chunk).trim().slice(0, 2000) || this.lastError;
|
|
248
287
|
});
|
|
@@ -274,12 +313,13 @@ export class WslAgent {
|
|
|
274
313
|
// deployed layout a /mnt/c mirror — so a digest that differs from
|
|
275
314
|
// the copy this package shipped means the runtime copy is stale,
|
|
276
315
|
// half-synced, or edited. Fail closed with the fix in the message.
|
|
277
|
-
this.lastError = `agent
|
|
316
|
+
this.lastError = `agent digest mismatch: the file the distro read at ${this.scriptPath} hashes to ${message.digest || "(nothing)"}, but this package shipped ${this.expectedDigest} — resync the runtime copy (npm run sync:windows) or reinstall the plugin`;
|
|
278
317
|
settleStart(new Error(this.lastError));
|
|
279
318
|
this.process?.kill();
|
|
280
319
|
return;
|
|
281
320
|
}
|
|
282
321
|
clearTimeout(handshakeTimer);
|
|
322
|
+
this.agentPid = message.pid;
|
|
283
323
|
settleStart();
|
|
284
324
|
break;
|
|
285
325
|
}
|
|
@@ -287,9 +327,16 @@ export class WslAgent {
|
|
|
287
327
|
// Dequeue signal: start this request's own budget clock NOW. Armed
|
|
288
328
|
// here instead of at submission, a queued request can no longer time
|
|
289
329
|
// out on a queue it never chose to join — and its watchdog can never
|
|
290
|
-
// kill the shared transport for a stall that was never its own.
|
|
330
|
+
// kill the shared transport for a stall that was never its own. A
|
|
331
|
+
// request cancelled while it was queued is killed only now, when it is
|
|
332
|
+
// the one running.
|
|
291
333
|
const pending = this.pending.get(message.id);
|
|
292
|
-
pending
|
|
334
|
+
if (pending) {
|
|
335
|
+
pending.started = true;
|
|
336
|
+
if (pending.cancelled)
|
|
337
|
+
this.signalAgent("SIGUSR1");
|
|
338
|
+
pending.armWatchdog?.();
|
|
339
|
+
}
|
|
293
340
|
break;
|
|
294
341
|
}
|
|
295
342
|
case "result": {
|
|
@@ -299,6 +346,10 @@ export class WslAgent {
|
|
|
299
346
|
this.clearPending(pending);
|
|
300
347
|
this.pending.delete(message.id);
|
|
301
348
|
this.rearmIdleTimer();
|
|
349
|
+
// A cancelled request keeps its entry only until this answer arrives.
|
|
350
|
+
if (pending.settled)
|
|
351
|
+
break;
|
|
352
|
+
pending.settled = true;
|
|
302
353
|
pending.resolve({ exitCode: message.exitCode, stdout: message.stdout, stderr: message.stderr, truncated: message.truncated });
|
|
303
354
|
break;
|
|
304
355
|
}
|
|
@@ -309,6 +360,9 @@ export class WslAgent {
|
|
|
309
360
|
this.clearPending(pending);
|
|
310
361
|
this.pending.delete(message.id);
|
|
311
362
|
this.rearmIdleTimer();
|
|
363
|
+
if (pending.settled)
|
|
364
|
+
break;
|
|
365
|
+
pending.settled = true;
|
|
312
366
|
// A cwd rejection is the command's own failure, not the agent's — it
|
|
313
367
|
// becomes a CwdError so the execution layer can report it exactly like
|
|
314
368
|
// the one-shot path's relay text.
|
|
@@ -330,11 +384,16 @@ export class WslAgent {
|
|
|
330
384
|
* The process died. Requests in flight ride exactly one rebuild; a second
|
|
331
385
|
* death is permanent.
|
|
332
386
|
*
|
|
387
|
+
* @param child - the process that exited.
|
|
333
388
|
* @param code - the exit code, null when killed by a signal.
|
|
334
389
|
* @param signalName - the killing signal, null on a normal exit.
|
|
335
390
|
*/
|
|
336
|
-
handleProcessExit(code, signalName) {
|
|
391
|
+
handleProcessExit(child, code, signalName) {
|
|
392
|
+
// The exit handler has already proved this child is the current generation;
|
|
393
|
+
// that check is the one place a stale exit can be rejected.
|
|
337
394
|
this.process = null;
|
|
395
|
+
this.agentPid = undefined;
|
|
396
|
+
this.teardownStreams(child);
|
|
338
397
|
if (this.pending.size === 0) {
|
|
339
398
|
if (this.state !== "dead")
|
|
340
399
|
this.state = "idle";
|
|
@@ -371,6 +430,9 @@ export class WslAgent {
|
|
|
371
430
|
}
|
|
372
431
|
else {
|
|
373
432
|
this.clearPending(pending);
|
|
433
|
+
if (pending.settled)
|
|
434
|
+
continue;
|
|
435
|
+
pending.settled = true;
|
|
374
436
|
pending.reject(new AgentUnavailableError(`agent exited while request ${pending.id} was in flight; its state is unknown — inspect the distro before retrying`));
|
|
375
437
|
}
|
|
376
438
|
}
|
|
@@ -385,6 +447,9 @@ export class WslAgent {
|
|
|
385
447
|
if (!this.writeLines(relabelFrame(pending.lines, id))) {
|
|
386
448
|
this.clearPending(pending);
|
|
387
449
|
this.pending.delete(id);
|
|
450
|
+
if (pending.settled)
|
|
451
|
+
continue;
|
|
452
|
+
pending.settled = true;
|
|
388
453
|
pending.reject(new AgentUnavailableError(`agent exited before request ${id} could be re-sent; its state is unknown`));
|
|
389
454
|
}
|
|
390
455
|
}
|
|
@@ -392,6 +457,9 @@ export class WslAgent {
|
|
|
392
457
|
.catch((error) => {
|
|
393
458
|
for (const pending of relayable) {
|
|
394
459
|
this.clearPending(pending);
|
|
460
|
+
if (pending.settled)
|
|
461
|
+
continue;
|
|
462
|
+
pending.settled = true;
|
|
395
463
|
pending.reject(error);
|
|
396
464
|
}
|
|
397
465
|
this.state = "dead";
|
|
@@ -402,6 +470,9 @@ export class WslAgent {
|
|
|
402
470
|
this.state = "dead";
|
|
403
471
|
for (const pending of this.pending.values()) {
|
|
404
472
|
this.clearPending(pending);
|
|
473
|
+
if (pending.settled)
|
|
474
|
+
continue;
|
|
475
|
+
pending.settled = true;
|
|
405
476
|
pending.reject(new AgentUnavailableError(this.lastError));
|
|
406
477
|
}
|
|
407
478
|
this.pending.clear();
|
|
@@ -417,6 +488,10 @@ export class WslAgent {
|
|
|
417
488
|
* @param request - the request to register.
|
|
418
489
|
*/
|
|
419
490
|
track({ id, lines, resolve, reject, timeoutMs, signal }) {
|
|
491
|
+
if (this.pending.size >= this.maxPending) {
|
|
492
|
+
reject(new Error(`the resident agent has ${this.pending.size} requests in flight (max ${this.maxPending}); retry when the queue drains`));
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
420
495
|
const pending = {
|
|
421
496
|
id,
|
|
422
497
|
lines,
|
|
@@ -425,16 +500,26 @@ export class WslAgent {
|
|
|
425
500
|
watchdog: null,
|
|
426
501
|
signal: signal ?? null,
|
|
427
502
|
onAbort: null,
|
|
503
|
+
started: false,
|
|
504
|
+
cancelled: false,
|
|
505
|
+
settled: false,
|
|
428
506
|
armWatchdog: timeoutMs > 0
|
|
429
507
|
? () => {
|
|
430
508
|
if (pending.watchdog)
|
|
431
509
|
return;
|
|
510
|
+
// Bind the watchdog to the generation it was armed on: a rebuild
|
|
511
|
+
// must not let a stale timer kill the new transport.
|
|
512
|
+
const child = this.process;
|
|
513
|
+
if (!child)
|
|
514
|
+
return;
|
|
432
515
|
// The in-distro timeout does the killing; this only catches an
|
|
433
516
|
// agent that accepted the request and then stopped answering
|
|
434
517
|
// even to its own timer.
|
|
435
518
|
pending.watchdog = setTimeout(() => {
|
|
519
|
+
if (this.process !== child)
|
|
520
|
+
return;
|
|
436
521
|
this.lastError = `agent did not answer request ${pending.id} within ${timeoutMs + WATCHDOG_GRACE_MS}ms of its dispatch`;
|
|
437
|
-
|
|
522
|
+
child.kill();
|
|
438
523
|
}, timeoutMs + WATCHDOG_GRACE_MS);
|
|
439
524
|
}
|
|
440
525
|
: null,
|
|
@@ -447,14 +532,21 @@ export class WslAgent {
|
|
|
447
532
|
return;
|
|
448
533
|
}
|
|
449
534
|
pending.onAbort = () => {
|
|
450
|
-
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
535
|
+
pending.cancelled = true;
|
|
536
|
+
// The frame is the fallback for an agent that cannot be signalled (a
|
|
537
|
+
// confined resident, a build without the USR1 trap); the signal is what
|
|
538
|
+
// interrupts the command the agent is blocked on right now.
|
|
454
539
|
this.writeLines([encodeKill(pending.id)]);
|
|
540
|
+
if (pending.started)
|
|
541
|
+
this.signalAgent("SIGUSR1");
|
|
542
|
+
if (pending.settled)
|
|
543
|
+
return;
|
|
544
|
+
pending.settled = true;
|
|
545
|
+
// Keep the entry until the agent answers: a request cancelled while it
|
|
546
|
+
// was QUEUED is killed when its ACK arrives, and its late RES only
|
|
547
|
+
// cleans the map.
|
|
455
548
|
this.clearPending(pending);
|
|
456
|
-
|
|
457
|
-
reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
|
|
549
|
+
pending.reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
|
|
458
550
|
};
|
|
459
551
|
signal.addEventListener("abort", pending.onAbort, { once: true });
|
|
460
552
|
}
|
|
@@ -531,6 +623,33 @@ export class WslAgent {
|
|
|
531
623
|
this.track({ id, lines, resolve: () => resolve(), reject, timeoutMs: HANDSHAKE_TIMEOUT_MS, signal });
|
|
532
624
|
});
|
|
533
625
|
}
|
|
626
|
+
/**
|
|
627
|
+
* Deliver an out-of-band signal to the resident shell. The serial stdin loop
|
|
628
|
+
* cannot consume a frame while a command runs, so cancellation is a signal;
|
|
629
|
+
* a second `wsl.exe` is the only channel that reaches the agent while it is
|
|
630
|
+
* blocked in `wait`.
|
|
631
|
+
*
|
|
632
|
+
* Only an agent that shares the distro's PID namespace is addressable: the
|
|
633
|
+
* plain agent, or a session runtime (its prefix is a scope plus mounts, but
|
|
634
|
+
* no PID namespace). A confined resident runs inside a PID namespace whose
|
|
635
|
+
* PIDs do not exist in the distro, so signalling its HELLO PID could hit an
|
|
636
|
+
* unrelated process; there the KILL frame remains the (delayed) fallback.
|
|
637
|
+
*
|
|
638
|
+
* @param signal - the signal to deliver (`SIGUSR1` is the cancel trap).
|
|
639
|
+
*/
|
|
640
|
+
signalAgent(signal) {
|
|
641
|
+
const pid = this.agentPid;
|
|
642
|
+
if (pid === undefined || !this.signalable)
|
|
643
|
+
return;
|
|
644
|
+
if (this.configuredSignal) {
|
|
645
|
+
this.configuredSignal({ wslPath: this.wslPath, distro: this.distro, pid, signal });
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
const distroArgs = this.distro.length > 0 ? ["-d", this.distro] : [];
|
|
649
|
+
const child = spawn(this.wslPath, [...distroArgs, "--exec", "sh", "-c", 'kill -s "$1" "$2" 2>/dev/null', "sh", signal.replace(/^SIG/, ""), String(pid)], { stdio: "ignore", windowsHide: true, env: pinnedWindowsEnv(process.env) });
|
|
650
|
+
// The agent may already be gone; the KILL frame remains the fallback.
|
|
651
|
+
child.on("error", () => { });
|
|
652
|
+
}
|
|
534
653
|
/**
|
|
535
654
|
* Shut the agent down: SHUTDOWN first, then kill after a short grace. The
|
|
536
655
|
* agent becomes reusable — a later `exec` starts a fresh process.
|
|
@@ -544,8 +663,25 @@ export class WslAgent {
|
|
|
544
663
|
const child = this.process;
|
|
545
664
|
if (!child)
|
|
546
665
|
return;
|
|
666
|
+
// A close during a start must settle that start: the handshake will never
|
|
667
|
+
// complete once the process is being shut down, and the caller would wait
|
|
668
|
+
// for it forever.
|
|
669
|
+
if (this.state === "starting")
|
|
670
|
+
this.startSettler?.(new AgentUnavailableError("the agent was closed during start"));
|
|
547
671
|
this.state = "idle";
|
|
548
672
|
this.process = null;
|
|
673
|
+
this.agentPid = undefined;
|
|
674
|
+
if (this.pending.size > 0) {
|
|
675
|
+
const reason = new AgentUnavailableError("the agent was closed before this request answered");
|
|
676
|
+
for (const pending of this.pending.values()) {
|
|
677
|
+
this.clearPending(pending);
|
|
678
|
+
if (pending.settled)
|
|
679
|
+
continue;
|
|
680
|
+
pending.settled = true;
|
|
681
|
+
pending.reject(reason);
|
|
682
|
+
}
|
|
683
|
+
this.pending.clear();
|
|
684
|
+
}
|
|
549
685
|
this.writeLinesSafe(child, ["SHUTDOWN"]);
|
|
550
686
|
await new Promise((resolve) => {
|
|
551
687
|
const timer = setTimeout(() => {
|
|
@@ -557,6 +693,22 @@ export class WslAgent {
|
|
|
557
693
|
resolve();
|
|
558
694
|
});
|
|
559
695
|
});
|
|
696
|
+
// The transport is always spawned with pipes, so the streams exist; the
|
|
697
|
+
// field's broader type is Node's, not the spawn's.
|
|
698
|
+
this.teardownStreams(child);
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Release the host's ends of a dead child's stdio. A descendant that
|
|
702
|
+
* inherited a pipe's write end would otherwise keep the read side — and the
|
|
703
|
+
* fd — open until that descendant exits; the agent's own helpers are
|
|
704
|
+
* redirected at their spawn sites, but the host must not depend on it.
|
|
705
|
+
*
|
|
706
|
+
* @param child - the process whose streams are being released.
|
|
707
|
+
*/
|
|
708
|
+
teardownStreams(child) {
|
|
709
|
+
child.stdin.destroy();
|
|
710
|
+
child.stdout.destroy();
|
|
711
|
+
child.stderr?.destroy();
|
|
560
712
|
}
|
|
561
713
|
/**
|
|
562
714
|
* Write one frame to the live process.
|
package/lib/bwrap.d.ts
CHANGED
|
@@ -32,6 +32,14 @@ export interface BwrapProfileOptions {
|
|
|
32
32
|
* ephemeral tmpfs over it.
|
|
33
33
|
*/
|
|
34
34
|
realTmp?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Create a PID namespace (`--unshare-pid`). Defaults to true: a COMMAND's
|
|
37
|
+
* background descendants die with it, which is the per-command shape. A
|
|
38
|
+
* session runtime sets this to false so a background dev server survives the
|
|
39
|
+
* command that started it — the systemd scope's cgroup, not the namespace,
|
|
40
|
+
* is then the cleanup boundary (ADR 0001).
|
|
41
|
+
*/
|
|
42
|
+
pidNamespace?: boolean;
|
|
35
43
|
}
|
|
36
44
|
/**
|
|
37
45
|
* Build the bwrap profile arguments for one file-effect policy, exactly as
|
package/lib/bwrap.js
CHANGED
|
@@ -59,7 +59,10 @@
|
|
|
59
59
|
* @returns profile arguments, before the `--` separator and the command argv.
|
|
60
60
|
*/
|
|
61
61
|
export function bwrapProfileArgs(policy, options = {}) {
|
|
62
|
-
const args = ["--ro-bind", "/", "/", "--dev", "/dev"
|
|
62
|
+
const args = ["--ro-bind", "/", "/", "--dev", "/dev"];
|
|
63
|
+
if (options.pidNamespace !== false)
|
|
64
|
+
args.push("--unshare-pid");
|
|
65
|
+
args.push("--proc", "/proc", "--die-with-parent");
|
|
63
66
|
if (policy.mode === "workspace-write") {
|
|
64
67
|
if (options.realTmp) {
|
|
65
68
|
args.push("--bind", "/tmp", "/tmp");
|
package/lib/fs-routing.d.ts
CHANGED
|
@@ -61,11 +61,16 @@ export interface RoutingConfig {
|
|
|
61
61
|
watchMaxDepth?: number;
|
|
62
62
|
}
|
|
63
63
|
/**
|
|
64
|
-
* Whether a
|
|
64
|
+
* Whether a distro target contains another, comparing BOTH the distro
|
|
65
|
+
* coordinate and the normalized Linux path. A child in another distro's
|
|
66
|
+
* share is never contained, even when its Linux spelling is a prefix match —
|
|
67
|
+
* the textual check this replaces said yes there.
|
|
65
68
|
*
|
|
66
|
-
* @param
|
|
67
|
-
* @
|
|
69
|
+
* @param parentKey - the parent's UNC identity.
|
|
70
|
+
* @param childKey - the child's UNC identity.
|
|
71
|
+
* @returns true when the child is the parent or lies under it in the same distro.
|
|
68
72
|
*/
|
|
73
|
+
export declare function distroContains(parentKey: string, childKey: string): boolean;
|
|
69
74
|
export declare function targetIsDistro(targetKey: string): boolean;
|
|
70
75
|
/**
|
|
71
76
|
* Whether a raw path-or-cwd input names the distro (used by the operations
|
|
@@ -83,8 +88,39 @@ export declare function inputIsDistro(value: string | undefined): boolean;
|
|
|
83
88
|
* mode the distro side reports through `sandboxMode`).
|
|
84
89
|
*/
|
|
85
90
|
export declare class WslRoutingFileSystem extends LocalFileSystem {
|
|
86
|
-
#private;
|
|
87
91
|
static Config: import("@deepseek-ai/schemastery").default<import("@deepseek-ai/dsh-fs-local").Config>;
|
|
92
|
+
/**
|
|
93
|
+
* The routed backend's constructor reads the deployment default mode from
|
|
94
|
+
* `ctx.sandboxPolicy`, exactly as the shipped `fs-sandbox` row this class
|
|
95
|
+
* replaces does. The service is provided by a sibling row, not an ancestor,
|
|
96
|
+
* so the fiber must declare the dependency; without it the lazy backend
|
|
97
|
+
* construction fails with "cannot get property sandboxPolicy without inject".
|
|
98
|
+
*/
|
|
99
|
+
static inject: string[];
|
|
100
|
+
/**
|
|
101
|
+
* The lazy distro backend, built on first routed use, typed as the concrete
|
|
102
|
+
* class: these routing calls are that class's own surface, and the
|
|
103
|
+
* `processPathFromHostPath` seam it once could not satisfy (async against the
|
|
104
|
+
* peer interface's synchronous contract) is closed — the backend maps
|
|
105
|
+
* synchronously now, from what it already knows.
|
|
106
|
+
*
|
|
107
|
+
* Plain fields on purpose: cordis invokes a service method with a shadow
|
|
108
|
+
* receiver (the traceable proxy `ReflectService` builds), and `#private`
|
|
109
|
+
* members do not cross it — every routed root call then fails with "Cannot
|
|
110
|
+
* read private member #distro from an object whose class did not declare it".
|
|
111
|
+
* The host backend this class extends keeps its own state plain (`config`,
|
|
112
|
+
* `locks`) for the same reason.
|
|
113
|
+
*/
|
|
114
|
+
private distroBackend;
|
|
115
|
+
/** The distro knobs, verbatim from the row config. */
|
|
116
|
+
private distroConfig;
|
|
117
|
+
/**
|
|
118
|
+
* The context this router was constructed with. `this.ctx` inside a method is
|
|
119
|
+
* the CALLER's context (cordis re-binds it for dependency tracing), so the
|
|
120
|
+
* backend's scope must not be derived from it: the first routed call may come
|
|
121
|
+
* from any consumer fiber.
|
|
122
|
+
*/
|
|
123
|
+
private routeContext;
|
|
88
124
|
/**
|
|
89
125
|
* @param ctx - the cordis context.
|
|
90
126
|
* @param config - the row config, host knobs plus distro knobs.
|