dsh-plugin-wsl-env 0.9.0 → 0.11.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 +456 -0
- package/CONTRIBUTING.md +2 -2
- package/README.md +85 -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 +59 -18
- package/docs/PARITY.md +3 -1
- package/docs/PEER-PARITY.md +18 -11
- package/docs/README.zh.md +98 -74
- package/docs/ROADMAP.md +79 -0
- 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-shared.js +33 -5
- package/lib/agent.d.ts +187 -0
- package/lib/agent.js +311 -23
- 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 +35 -25
package/lib/agent.js
CHANGED
|
@@ -40,10 +40,31 @@
|
|
|
40
40
|
import { spawn } from "node:child_process";
|
|
41
41
|
import { AgentUnavailableError, CwdError } from "./agent-errors.js";
|
|
42
42
|
import { AGENT_NAME, MAX_FRAME_BYTES, PROTOCOL_VERSION, encodeExecFrame, encodeFsFrame, encodeKill, encodeSetEnv, parseAgentLine } from "./agent-protocol.js";
|
|
43
|
+
/**
|
|
44
|
+
* A zeroed per-kind metrics record.
|
|
45
|
+
* @returns the record.
|
|
46
|
+
*/
|
|
47
|
+
function emptyRequestMetrics() {
|
|
48
|
+
return { requests: 0, queueMs: 0, queueMaxMs: 0, execMs: 0, execMaxMs: 0, failed: 0, cancelled: 0, rejected: 0 };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A zeroed metrics snapshot.
|
|
52
|
+
* @returns the snapshot.
|
|
53
|
+
*/
|
|
54
|
+
function emptyAgentMetrics() {
|
|
55
|
+
return { fs: emptyRequestMetrics(), exec: emptyRequestMetrics(), inFlightMax: 0 };
|
|
56
|
+
}
|
|
43
57
|
/** How long a rebuilt agent has to re-answer the handshake before giving up. */
|
|
44
58
|
const HANDSHAKE_TIMEOUT_MS = 15000;
|
|
45
59
|
/** Extra time the host watchdog waits past the request's own in-distro timeout. */
|
|
46
60
|
const WATCHDOG_GRACE_MS = 13000;
|
|
61
|
+
/**
|
|
62
|
+
* How many requests one resident may hold in flight before refusing more. The
|
|
63
|
+
* agent is serial, so a large queue only delays every entry; the cap keeps a
|
|
64
|
+
* burst from growing the pending map (and the retained frame lines) without
|
|
65
|
+
* bound.
|
|
66
|
+
*/
|
|
67
|
+
const MAX_PENDING_REQUESTS = 64;
|
|
47
68
|
/** How far past its host's idle shutdown the agent's own lease runs. The
|
|
48
69
|
* lease fires only when the host is gone in every way that matters (a wedged
|
|
49
70
|
* relay that never delivers EOF); the host's close() normally wins the race. */
|
|
@@ -135,18 +156,26 @@ export class WslAgent {
|
|
|
135
156
|
this.scriptPath = config.scriptPath;
|
|
136
157
|
this.wslPath = config.wslPath ?? "wsl.exe";
|
|
137
158
|
this.argvPrefix = config.argvPrefix ?? [];
|
|
159
|
+
this.signalable = config.signalable ?? this.argvPrefix.length === 0;
|
|
138
160
|
this.idleMs = config.idleMs ?? 600000;
|
|
139
161
|
this.expectedDigest = config.expectedDigest;
|
|
140
162
|
this.frameCapBytes = config.frameCapBytes ?? MAX_FRAME_BYTES;
|
|
141
163
|
this.configuredSpawn = config.spawnTransport;
|
|
164
|
+
this.configuredSignal = config.signalTransport;
|
|
165
|
+
this.configuredLease = config.leaseMs;
|
|
166
|
+
this.maxPending = config.maxPending ?? MAX_PENDING_REQUESTS;
|
|
167
|
+
this.agentPid = undefined;
|
|
142
168
|
this.process = null;
|
|
143
169
|
this.state = "idle";
|
|
144
170
|
this.startPromise = null;
|
|
171
|
+
this.startSettler = null;
|
|
145
172
|
this.nextRequestId = 0;
|
|
146
173
|
this.pending = new Map();
|
|
147
174
|
this.rebuildUsed = false;
|
|
148
175
|
this.idleTimer = null;
|
|
149
176
|
this.lastError = "";
|
|
177
|
+
this.requestMetrics = emptyAgentMetrics();
|
|
178
|
+
this.prepare = config.prepare;
|
|
150
179
|
}
|
|
151
180
|
/**
|
|
152
181
|
* Whether the agent is permanently out — the executor's fallback cue.
|
|
@@ -174,8 +203,42 @@ export class WslAgent {
|
|
|
174
203
|
return Promise.resolve();
|
|
175
204
|
if (this.startPromise)
|
|
176
205
|
return this.startPromise;
|
|
206
|
+
const prepared = this.prepare?.();
|
|
207
|
+
if (prepared === undefined) {
|
|
208
|
+
const started = this.beginStart();
|
|
209
|
+
this.startPromise = started;
|
|
210
|
+
return started;
|
|
211
|
+
}
|
|
212
|
+
const started = prepared.then((entry) => {
|
|
213
|
+
if (entry !== undefined)
|
|
214
|
+
this.applyPrepared(entry);
|
|
215
|
+
return this.beginStart();
|
|
216
|
+
});
|
|
217
|
+
this.startPromise = started;
|
|
218
|
+
return started;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Apply an async preparation entry to this agent before the first spawn.
|
|
222
|
+
*
|
|
223
|
+
* @param entry - the selected entry.
|
|
224
|
+
*/
|
|
225
|
+
applyPrepared(entry) {
|
|
226
|
+
this.scriptPath = entry.scriptPath;
|
|
227
|
+
this.expectedDigest = entry.expectedDigest;
|
|
228
|
+
if (entry.signalable !== undefined)
|
|
229
|
+
this.signalable = entry.signalable;
|
|
230
|
+
if (entry.spawnTransport !== undefined)
|
|
231
|
+
this.configuredSpawn = entry.spawnTransport;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Spawn the process and run the handshake.
|
|
235
|
+
*
|
|
236
|
+
* @returns resolved once the state is `ready`.
|
|
237
|
+
*/
|
|
238
|
+
beginStart() {
|
|
177
239
|
this.state = "starting";
|
|
178
|
-
this.
|
|
240
|
+
this.agentPid = undefined;
|
|
241
|
+
return new Promise((resolve, reject) => {
|
|
179
242
|
// stdio is all-pipe (the spawn options above), so every standard stream
|
|
180
243
|
// exists; the broad ChildProcess type models them as nullable.
|
|
181
244
|
const child = (this.process = (this.configuredSpawn ?? spawnDefault)({
|
|
@@ -183,14 +246,21 @@ export class WslAgent {
|
|
|
183
246
|
distro: this.distro,
|
|
184
247
|
scriptPath: this.scriptPath,
|
|
185
248
|
argvPrefix: this.argvPrefix,
|
|
186
|
-
leaseMs: this.idleMs > 0 ? this.idleMs + LEASE_GRACE_MS : 0,
|
|
249
|
+
leaseMs: this.configuredLease ?? (this.idleMs > 0 ? this.idleMs + LEASE_GRACE_MS : 0),
|
|
187
250
|
}));
|
|
251
|
+
// One generation, one owner: every closure below belongs to THIS child.
|
|
252
|
+
// A stale child's exit must not settle a newer start, null a newer
|
|
253
|
+
// transport, or answer a newer request.
|
|
254
|
+
const current = () => this.process === child;
|
|
188
255
|
const handshakeTimer = setTimeout(() => {
|
|
256
|
+
if (!current())
|
|
257
|
+
return;
|
|
189
258
|
this.lastError = `agent handshake timed out after ${HANDSHAKE_TIMEOUT_MS}ms`;
|
|
190
259
|
child.kill();
|
|
191
260
|
}, HANDSHAKE_TIMEOUT_MS);
|
|
192
261
|
const settleStart = (error) => {
|
|
193
262
|
clearTimeout(handshakeTimer);
|
|
263
|
+
this.startSettler = null;
|
|
194
264
|
this.startPromise = null;
|
|
195
265
|
if (error) {
|
|
196
266
|
this.state = this.rebuildUsed ? "dead" : "idle";
|
|
@@ -203,16 +273,30 @@ export class WslAgent {
|
|
|
203
273
|
resolve();
|
|
204
274
|
}
|
|
205
275
|
};
|
|
276
|
+
this.startSettler = settleStart;
|
|
206
277
|
child.on("error", (error) => {
|
|
278
|
+
if (!current())
|
|
279
|
+
return;
|
|
207
280
|
this.lastError = String(error);
|
|
208
281
|
settleStart(error);
|
|
209
282
|
});
|
|
283
|
+
// The write side can hit EPIPE after the child exits — a failed
|
|
284
|
+
// handshake followed by close(), or a scope stop. The failure is already
|
|
285
|
+
// reflected in the state/pending requests; an unhandled stream error
|
|
286
|
+
// would crash the host process instead.
|
|
287
|
+
child.stdin?.on("error", () => { });
|
|
210
288
|
child.on("exit", (code, signalName) => {
|
|
289
|
+
if (!current()) {
|
|
290
|
+
// A stale child's exit: release its pipes, but never touch the
|
|
291
|
+
// generation that replaced it.
|
|
292
|
+
this.teardownStreams(child);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
211
295
|
if (this.state === "starting") {
|
|
212
296
|
this.lastError = `agent exited during handshake (code=${code} signal=${signalName ?? "none"})`;
|
|
213
297
|
settleStart(new Error(this.lastError));
|
|
214
298
|
}
|
|
215
|
-
this.handleProcessExit(code, signalName);
|
|
299
|
+
this.handleProcessExit(child, code, signalName);
|
|
216
300
|
});
|
|
217
301
|
// A bounded line splitter instead of readline: readline accumulates an
|
|
218
302
|
// over-long line whole before anyone can object, which is exactly the
|
|
@@ -220,6 +304,8 @@ export class WslAgent {
|
|
|
220
304
|
// (ids, numbers, base64), so chunk-boundary decoding is not a concern.
|
|
221
305
|
let buffered = "";
|
|
222
306
|
child.stdout.on("data", (chunk) => {
|
|
307
|
+
if (!current())
|
|
308
|
+
return;
|
|
223
309
|
buffered += chunk.toString("utf8");
|
|
224
310
|
for (;;) {
|
|
225
311
|
const newline = buffered.indexOf("\n");
|
|
@@ -227,7 +313,7 @@ export class WslAgent {
|
|
|
227
313
|
if (buffered.length > this.frameCapBytes) {
|
|
228
314
|
this.lastError = `agent frame of ${buffered.length} bytes exceeded the ${this.frameCapBytes}-byte protocol cap`;
|
|
229
315
|
buffered = "";
|
|
230
|
-
|
|
316
|
+
child.kill();
|
|
231
317
|
}
|
|
232
318
|
return;
|
|
233
319
|
}
|
|
@@ -236,18 +322,19 @@ export class WslAgent {
|
|
|
236
322
|
if (line.length > this.frameCapBytes) {
|
|
237
323
|
this.lastError = `agent frame of ${line.length} bytes exceeded the ${this.frameCapBytes}-byte protocol cap`;
|
|
238
324
|
buffered = "";
|
|
239
|
-
|
|
325
|
+
child.kill();
|
|
240
326
|
return;
|
|
241
327
|
}
|
|
242
328
|
this.handleLine(line, settleStart, handshakeTimer);
|
|
243
329
|
}
|
|
244
330
|
});
|
|
245
331
|
child.stderr?.on("data", (chunk) => {
|
|
332
|
+
if (!current())
|
|
333
|
+
return;
|
|
246
334
|
// Diagnostics only; `wsl.exe` and the shell chatter here on failure.
|
|
247
335
|
this.lastError = String(chunk).trim().slice(0, 2000) || this.lastError;
|
|
248
336
|
});
|
|
249
337
|
});
|
|
250
|
-
return this.startPromise;
|
|
251
338
|
}
|
|
252
339
|
/**
|
|
253
340
|
* One stdout line from the agent. The first must be a matching HELLO.
|
|
@@ -274,12 +361,13 @@ export class WslAgent {
|
|
|
274
361
|
// deployed layout a /mnt/c mirror — so a digest that differs from
|
|
275
362
|
// the copy this package shipped means the runtime copy is stale,
|
|
276
363
|
// half-synced, or edited. Fail closed with the fix in the message.
|
|
277
|
-
this.lastError = `agent
|
|
364
|
+
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
365
|
settleStart(new Error(this.lastError));
|
|
279
366
|
this.process?.kill();
|
|
280
367
|
return;
|
|
281
368
|
}
|
|
282
369
|
clearTimeout(handshakeTimer);
|
|
370
|
+
this.agentPid = message.pid;
|
|
283
371
|
settleStart();
|
|
284
372
|
break;
|
|
285
373
|
}
|
|
@@ -287,9 +375,17 @@ export class WslAgent {
|
|
|
287
375
|
// Dequeue signal: start this request's own budget clock NOW. Armed
|
|
288
376
|
// here instead of at submission, a queued request can no longer time
|
|
289
377
|
// 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.
|
|
378
|
+
// kill the shared transport for a stall that was never its own. A
|
|
379
|
+
// request cancelled while it was queued is killed only now, when it is
|
|
380
|
+
// the one running.
|
|
291
381
|
const pending = this.pending.get(message.id);
|
|
292
|
-
pending
|
|
382
|
+
if (pending) {
|
|
383
|
+
pending.started = true;
|
|
384
|
+
this.recordAck(pending);
|
|
385
|
+
if (pending.cancelled)
|
|
386
|
+
this.signalAgent("SIGUSR1");
|
|
387
|
+
pending.armWatchdog?.();
|
|
388
|
+
}
|
|
293
389
|
break;
|
|
294
390
|
}
|
|
295
391
|
case "result": {
|
|
@@ -299,6 +395,10 @@ export class WslAgent {
|
|
|
299
395
|
this.clearPending(pending);
|
|
300
396
|
this.pending.delete(message.id);
|
|
301
397
|
this.rearmIdleTimer();
|
|
398
|
+
// A cancelled request keeps its entry only until this answer arrives.
|
|
399
|
+
if (pending.settled)
|
|
400
|
+
break;
|
|
401
|
+
pending.settled = true;
|
|
302
402
|
pending.resolve({ exitCode: message.exitCode, stdout: message.stdout, stderr: message.stderr, truncated: message.truncated });
|
|
303
403
|
break;
|
|
304
404
|
}
|
|
@@ -309,6 +409,9 @@ export class WslAgent {
|
|
|
309
409
|
this.clearPending(pending);
|
|
310
410
|
this.pending.delete(message.id);
|
|
311
411
|
this.rearmIdleTimer();
|
|
412
|
+
if (pending.settled)
|
|
413
|
+
break;
|
|
414
|
+
pending.settled = true;
|
|
312
415
|
// A cwd rejection is the command's own failure, not the agent's — it
|
|
313
416
|
// becomes a CwdError so the execution layer can report it exactly like
|
|
314
417
|
// the one-shot path's relay text.
|
|
@@ -330,11 +433,16 @@ export class WslAgent {
|
|
|
330
433
|
* The process died. Requests in flight ride exactly one rebuild; a second
|
|
331
434
|
* death is permanent.
|
|
332
435
|
*
|
|
436
|
+
* @param child - the process that exited.
|
|
333
437
|
* @param code - the exit code, null when killed by a signal.
|
|
334
438
|
* @param signalName - the killing signal, null on a normal exit.
|
|
335
439
|
*/
|
|
336
|
-
handleProcessExit(code, signalName) {
|
|
440
|
+
handleProcessExit(child, code, signalName) {
|
|
441
|
+
// The exit handler has already proved this child is the current generation;
|
|
442
|
+
// that check is the one place a stale exit can be rejected.
|
|
337
443
|
this.process = null;
|
|
444
|
+
this.agentPid = undefined;
|
|
445
|
+
this.teardownStreams(child);
|
|
338
446
|
if (this.pending.size === 0) {
|
|
339
447
|
if (this.state !== "dead")
|
|
340
448
|
this.state = "idle";
|
|
@@ -371,6 +479,9 @@ export class WslAgent {
|
|
|
371
479
|
}
|
|
372
480
|
else {
|
|
373
481
|
this.clearPending(pending);
|
|
482
|
+
if (pending.settled)
|
|
483
|
+
continue;
|
|
484
|
+
pending.settled = true;
|
|
374
485
|
pending.reject(new AgentUnavailableError(`agent exited while request ${pending.id} was in flight; its state is unknown — inspect the distro before retrying`));
|
|
375
486
|
}
|
|
376
487
|
}
|
|
@@ -385,6 +496,9 @@ export class WslAgent {
|
|
|
385
496
|
if (!this.writeLines(relabelFrame(pending.lines, id))) {
|
|
386
497
|
this.clearPending(pending);
|
|
387
498
|
this.pending.delete(id);
|
|
499
|
+
if (pending.settled)
|
|
500
|
+
continue;
|
|
501
|
+
pending.settled = true;
|
|
388
502
|
pending.reject(new AgentUnavailableError(`agent exited before request ${id} could be re-sent; its state is unknown`));
|
|
389
503
|
}
|
|
390
504
|
}
|
|
@@ -392,6 +506,9 @@ export class WslAgent {
|
|
|
392
506
|
.catch((error) => {
|
|
393
507
|
for (const pending of relayable) {
|
|
394
508
|
this.clearPending(pending);
|
|
509
|
+
if (pending.settled)
|
|
510
|
+
continue;
|
|
511
|
+
pending.settled = true;
|
|
395
512
|
pending.reject(error);
|
|
396
513
|
}
|
|
397
514
|
this.state = "dead";
|
|
@@ -402,6 +519,9 @@ export class WslAgent {
|
|
|
402
519
|
this.state = "dead";
|
|
403
520
|
for (const pending of this.pending.values()) {
|
|
404
521
|
this.clearPending(pending);
|
|
522
|
+
if (pending.settled)
|
|
523
|
+
continue;
|
|
524
|
+
pending.settled = true;
|
|
405
525
|
pending.reject(new AgentUnavailableError(this.lastError));
|
|
406
526
|
}
|
|
407
527
|
this.pending.clear();
|
|
@@ -416,45 +536,78 @@ export class WslAgent {
|
|
|
416
536
|
*
|
|
417
537
|
* @param request - the request to register.
|
|
418
538
|
*/
|
|
419
|
-
track({ id, lines, resolve, reject, timeoutMs, signal }) {
|
|
539
|
+
track({ id, kind = "exec", lines, resolve, reject, timeoutMs, signal }) {
|
|
540
|
+
if (this.pending.size >= this.maxPending) {
|
|
541
|
+
this.noteRejected(kind);
|
|
542
|
+
reject(new Error(`the resident agent has ${this.pending.size} requests in flight (max ${this.maxPending}); retry when the queue drains`));
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
420
545
|
const pending = {
|
|
421
546
|
id,
|
|
547
|
+
kind,
|
|
548
|
+
sentAt: performance.now(),
|
|
549
|
+
measured: false,
|
|
422
550
|
lines,
|
|
423
|
-
resolve
|
|
424
|
-
|
|
551
|
+
resolve: (result) => {
|
|
552
|
+
this.recordSettle(pending, undefined);
|
|
553
|
+
resolve(result);
|
|
554
|
+
},
|
|
555
|
+
reject: (error) => {
|
|
556
|
+
this.recordSettle(pending, error);
|
|
557
|
+
reject(error);
|
|
558
|
+
},
|
|
425
559
|
watchdog: null,
|
|
426
560
|
signal: signal ?? null,
|
|
427
561
|
onAbort: null,
|
|
562
|
+
started: false,
|
|
563
|
+
cancelled: false,
|
|
564
|
+
settled: false,
|
|
428
565
|
armWatchdog: timeoutMs > 0
|
|
429
566
|
? () => {
|
|
430
567
|
if (pending.watchdog)
|
|
431
568
|
return;
|
|
569
|
+
// Bind the watchdog to the generation it was armed on: a rebuild
|
|
570
|
+
// must not let a stale timer kill the new transport.
|
|
571
|
+
const child = this.process;
|
|
572
|
+
if (!child)
|
|
573
|
+
return;
|
|
432
574
|
// The in-distro timeout does the killing; this only catches an
|
|
433
575
|
// agent that accepted the request and then stopped answering
|
|
434
576
|
// even to its own timer.
|
|
435
577
|
pending.watchdog = setTimeout(() => {
|
|
578
|
+
if (this.process !== child)
|
|
579
|
+
return;
|
|
436
580
|
this.lastError = `agent did not answer request ${pending.id} within ${timeoutMs + WATCHDOG_GRACE_MS}ms of its dispatch`;
|
|
437
|
-
|
|
581
|
+
child.kill();
|
|
438
582
|
}, timeoutMs + WATCHDOG_GRACE_MS);
|
|
439
583
|
}
|
|
440
584
|
: null,
|
|
441
585
|
};
|
|
442
586
|
this.pending.set(id, pending);
|
|
587
|
+
this.requestMetrics.inFlightMax = Math.max(this.requestMetrics.inFlightMax, this.pending.size);
|
|
443
588
|
if (signal) {
|
|
444
589
|
if (signal.aborted) {
|
|
445
590
|
this.pending.delete(id);
|
|
591
|
+
this.noteRejected(kind);
|
|
446
592
|
reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
|
|
447
593
|
return;
|
|
448
594
|
}
|
|
449
595
|
pending.onAbort = () => {
|
|
450
|
-
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
596
|
+
pending.cancelled = true;
|
|
597
|
+
// The frame is the fallback for an agent that cannot be signalled (a
|
|
598
|
+
// confined resident, a build without the USR1 trap); the signal is what
|
|
599
|
+
// interrupts the command the agent is blocked on right now.
|
|
454
600
|
this.writeLines([encodeKill(pending.id)]);
|
|
601
|
+
if (pending.started)
|
|
602
|
+
this.signalAgent("SIGUSR1");
|
|
603
|
+
if (pending.settled)
|
|
604
|
+
return;
|
|
605
|
+
pending.settled = true;
|
|
606
|
+
// Keep the entry until the agent answers: a request cancelled while it
|
|
607
|
+
// was QUEUED is killed when its ACK arrives, and its late RES only
|
|
608
|
+
// cleans the map.
|
|
455
609
|
this.clearPending(pending);
|
|
456
|
-
|
|
457
|
-
reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
|
|
610
|
+
pending.reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
|
|
458
611
|
};
|
|
459
612
|
signal.addEventListener("abort", pending.onAbort, { once: true });
|
|
460
613
|
}
|
|
@@ -465,6 +618,7 @@ export class WslAgent {
|
|
|
465
618
|
if (!this.writeLines(lines)) {
|
|
466
619
|
this.clearPending(pending);
|
|
467
620
|
this.pending.delete(pending.id);
|
|
621
|
+
this.noteRejected(kind);
|
|
468
622
|
reject(new AgentUnavailableError("the agent exited before the request could be sent; it never ran"));
|
|
469
623
|
return;
|
|
470
624
|
}
|
|
@@ -488,7 +642,7 @@ export class WslAgent {
|
|
|
488
642
|
...encodeExecFrame({ id, cwd, argv, timeoutMs, maxOutputBytes }),
|
|
489
643
|
];
|
|
490
644
|
return new Promise((resolve, reject) => {
|
|
491
|
-
this.track({ id, lines, resolve, reject, timeoutMs, signal });
|
|
645
|
+
this.track({ id, kind: "exec", lines, resolve, reject, timeoutMs, signal });
|
|
492
646
|
});
|
|
493
647
|
}
|
|
494
648
|
/**
|
|
@@ -510,7 +664,7 @@ export class WslAgent {
|
|
|
510
664
|
const id = `r${this.nextRequestId++}`;
|
|
511
665
|
const lines = encodeFsFrame({ id, op, args, timeoutMs });
|
|
512
666
|
return new Promise((resolve, reject) => {
|
|
513
|
-
this.track({ id, lines, resolve, reject, timeoutMs, signal });
|
|
667
|
+
this.track({ id, kind: "fs", lines, resolve, reject, timeoutMs, signal });
|
|
514
668
|
});
|
|
515
669
|
}
|
|
516
670
|
/**
|
|
@@ -528,9 +682,110 @@ export class WslAgent {
|
|
|
528
682
|
return new Promise((resolve, reject) => {
|
|
529
683
|
const id = `r${this.nextRequestId++}`;
|
|
530
684
|
const lines = encodeExecFrame({ id, cwd: "/", argv: ["true"], timeoutMs: 0 });
|
|
531
|
-
this.track({ id, lines, resolve: () => resolve(), reject, timeoutMs: HANDSHAKE_TIMEOUT_MS, signal });
|
|
685
|
+
this.track({ id, kind: "exec", lines, resolve: () => resolve(), reject, timeoutMs: HANDSHAKE_TIMEOUT_MS, signal });
|
|
532
686
|
});
|
|
533
687
|
}
|
|
688
|
+
/**
|
|
689
|
+
* Deliver an out-of-band signal to the resident shell. The serial stdin loop
|
|
690
|
+
* cannot consume a frame while a command runs, so cancellation is a signal;
|
|
691
|
+
* a second `wsl.exe` is the only channel that reaches the agent while it is
|
|
692
|
+
* blocked in `wait`.
|
|
693
|
+
*
|
|
694
|
+
* Only an agent that shares the distro's PID namespace is addressable: the
|
|
695
|
+
* plain agent, or a session runtime (its prefix is a scope plus mounts, but
|
|
696
|
+
* no PID namespace). A confined resident runs inside a PID namespace whose
|
|
697
|
+
* PIDs do not exist in the distro, so signalling its HELLO PID could hit an
|
|
698
|
+
* unrelated process; there the KILL frame remains the (delayed) fallback.
|
|
699
|
+
*
|
|
700
|
+
* @param signal - the signal to deliver (`SIGUSR1` is the cancel trap).
|
|
701
|
+
*/
|
|
702
|
+
/**
|
|
703
|
+
* The cumulative request metrics: queue wait (sent -> ACK) and execution
|
|
704
|
+
* time (ACK -> settle) per request kind, plus the peak in-flight count.
|
|
705
|
+
*
|
|
706
|
+
* @returns a snapshot the caller can keep.
|
|
707
|
+
*/
|
|
708
|
+
metrics() {
|
|
709
|
+
return {
|
|
710
|
+
fs: { ...this.requestMetrics.fs },
|
|
711
|
+
exec: { ...this.requestMetrics.exec },
|
|
712
|
+
inFlightMax: this.requestMetrics.inFlightMax,
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
/** Reset the cumulative metrics (diagnostics/probes). */
|
|
716
|
+
resetMetrics() {
|
|
717
|
+
this.requestMetrics = emptyAgentMetrics();
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* The metric bucket for a request kind.
|
|
721
|
+
* @param kind - fs or exec.
|
|
722
|
+
* @returns the bucket.
|
|
723
|
+
*/
|
|
724
|
+
kindMetrics(kind) {
|
|
725
|
+
return kind === "fs" ? this.requestMetrics.fs : this.requestMetrics.exec;
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Record a request that never entered the pending map.
|
|
729
|
+
* @param kind - the bucket.
|
|
730
|
+
*/
|
|
731
|
+
noteRejected(kind) {
|
|
732
|
+
this.kindMetrics(kind).rejected += 1;
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Record the dequeue ACK: the request's queue wait.
|
|
736
|
+
* @param pending - the request.
|
|
737
|
+
*/
|
|
738
|
+
recordAck(pending) {
|
|
739
|
+
if (pending.ackedAt !== undefined)
|
|
740
|
+
return;
|
|
741
|
+
const now = performance.now();
|
|
742
|
+
pending.ackedAt = now;
|
|
743
|
+
const stats = this.kindMetrics(pending.kind);
|
|
744
|
+
const wait = now - pending.sentAt;
|
|
745
|
+
stats.queueMs += wait;
|
|
746
|
+
stats.queueMaxMs = Math.max(stats.queueMaxMs, wait);
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Record a settle: the requests count, the execution time (or the queue wait
|
|
750
|
+
* for a request that never ACKed), and the outcome.
|
|
751
|
+
* @param pending - the request.
|
|
752
|
+
* @param error - the failure, when any.
|
|
753
|
+
*/
|
|
754
|
+
recordSettle(pending, error) {
|
|
755
|
+
if (pending.measured)
|
|
756
|
+
return;
|
|
757
|
+
pending.measured = true;
|
|
758
|
+
const now = performance.now();
|
|
759
|
+
const stats = this.kindMetrics(pending.kind);
|
|
760
|
+
stats.requests += 1;
|
|
761
|
+
if (pending.ackedAt !== undefined) {
|
|
762
|
+
const exec = now - pending.ackedAt;
|
|
763
|
+
stats.execMs += exec;
|
|
764
|
+
stats.execMaxMs = Math.max(stats.execMaxMs, exec);
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
const wait = now - pending.sentAt;
|
|
768
|
+
stats.queueMs += wait;
|
|
769
|
+
stats.queueMaxMs = Math.max(stats.queueMaxMs, wait);
|
|
770
|
+
}
|
|
771
|
+
if (pending.cancelled)
|
|
772
|
+
stats.cancelled += 1;
|
|
773
|
+
else if (error !== undefined)
|
|
774
|
+
stats.failed += 1;
|
|
775
|
+
}
|
|
776
|
+
signalAgent(signal) {
|
|
777
|
+
const pid = this.agentPid;
|
|
778
|
+
if (pid === undefined || !this.signalable)
|
|
779
|
+
return;
|
|
780
|
+
if (this.configuredSignal) {
|
|
781
|
+
this.configuredSignal({ wslPath: this.wslPath, distro: this.distro, pid, signal });
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
const distroArgs = this.distro.length > 0 ? ["-d", this.distro] : [];
|
|
785
|
+
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) });
|
|
786
|
+
// The agent may already be gone; the KILL frame remains the fallback.
|
|
787
|
+
child.on("error", () => { });
|
|
788
|
+
}
|
|
534
789
|
/**
|
|
535
790
|
* Shut the agent down: SHUTDOWN first, then kill after a short grace. The
|
|
536
791
|
* agent becomes reusable — a later `exec` starts a fresh process.
|
|
@@ -544,8 +799,25 @@ export class WslAgent {
|
|
|
544
799
|
const child = this.process;
|
|
545
800
|
if (!child)
|
|
546
801
|
return;
|
|
802
|
+
// A close during a start must settle that start: the handshake will never
|
|
803
|
+
// complete once the process is being shut down, and the caller would wait
|
|
804
|
+
// for it forever.
|
|
805
|
+
if (this.state === "starting")
|
|
806
|
+
this.startSettler?.(new AgentUnavailableError("the agent was closed during start"));
|
|
547
807
|
this.state = "idle";
|
|
548
808
|
this.process = null;
|
|
809
|
+
this.agentPid = undefined;
|
|
810
|
+
if (this.pending.size > 0) {
|
|
811
|
+
const reason = new AgentUnavailableError("the agent was closed before this request answered");
|
|
812
|
+
for (const pending of this.pending.values()) {
|
|
813
|
+
this.clearPending(pending);
|
|
814
|
+
if (pending.settled)
|
|
815
|
+
continue;
|
|
816
|
+
pending.settled = true;
|
|
817
|
+
pending.reject(reason);
|
|
818
|
+
}
|
|
819
|
+
this.pending.clear();
|
|
820
|
+
}
|
|
549
821
|
this.writeLinesSafe(child, ["SHUTDOWN"]);
|
|
550
822
|
await new Promise((resolve) => {
|
|
551
823
|
const timer = setTimeout(() => {
|
|
@@ -557,6 +829,22 @@ export class WslAgent {
|
|
|
557
829
|
resolve();
|
|
558
830
|
});
|
|
559
831
|
});
|
|
832
|
+
// The transport is always spawned with pipes, so the streams exist; the
|
|
833
|
+
// field's broader type is Node's, not the spawn's.
|
|
834
|
+
this.teardownStreams(child);
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Release the host's ends of a dead child's stdio. A descendant that
|
|
838
|
+
* inherited a pipe's write end would otherwise keep the read side — and the
|
|
839
|
+
* fd — open until that descendant exits; the agent's own helpers are
|
|
840
|
+
* redirected at their spawn sites, but the host must not depend on it.
|
|
841
|
+
*
|
|
842
|
+
* @param child - the process whose streams are being released.
|
|
843
|
+
*/
|
|
844
|
+
teardownStreams(child) {
|
|
845
|
+
child.stdin.destroy();
|
|
846
|
+
child.stdout.destroy();
|
|
847
|
+
child.stderr?.destroy();
|
|
560
848
|
}
|
|
561
849
|
/**
|
|
562
850
|
* 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.
|