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.
Files changed (46) hide show
  1. package/CHANGELOG.md +456 -0
  2. package/CONTRIBUTING.md +2 -2
  3. package/README.md +85 -68
  4. package/SUPPORT.md +3 -3
  5. package/agent/native/linux-aarch64/dsh-wsl-agent +0 -0
  6. package/agent/native/linux-x86_64/dsh-wsl-agent +0 -0
  7. package/agent/wsl-agent.sh +83 -16
  8. package/cordis.patch.yml +41 -35
  9. package/docs/ARCHITECTURE.md +9 -1
  10. package/docs/CONFIGURATION.md +7 -4
  11. package/docs/LIMITATIONS.md +59 -18
  12. package/docs/PARITY.md +3 -1
  13. package/docs/PEER-PARITY.md +18 -11
  14. package/docs/README.zh.md +98 -74
  15. package/docs/ROADMAP.md +79 -0
  16. package/docs/UPSTREAM-FSIO-EXPORT.md +3 -3
  17. package/docs/UPSTREAM-TERMINAL-TITLE.md +9 -2
  18. package/examples/preset-wsl-session-sandbox.cordis.patch.yml +178 -0
  19. package/lib/agent-protocol.d.ts +10 -4
  20. package/lib/agent-protocol.js +10 -3
  21. package/lib/agent-shared.js +33 -5
  22. package/lib/agent.d.ts +187 -0
  23. package/lib/agent.js +311 -23
  24. package/lib/bwrap.d.ts +8 -0
  25. package/lib/bwrap.js +4 -1
  26. package/lib/fs-routing.d.ts +40 -4
  27. package/lib/fs-routing.js +80 -12
  28. package/lib/fsio-agent.js +1 -1
  29. package/lib/index.d.ts +134 -0
  30. package/lib/index.js +191 -3
  31. package/lib/native-agent.d.ts +124 -0
  32. package/lib/native-agent.js +198 -0
  33. package/lib/ports.d.ts +13 -2
  34. package/lib/ports.js +18 -5
  35. package/lib/preflight.js +24 -1
  36. package/lib/session-runtime.d.ts +161 -0
  37. package/lib/session-runtime.js +220 -0
  38. package/lib/session-scope.d.ts +124 -0
  39. package/lib/session-scope.js +191 -0
  40. package/lib/shell-env.js +10 -3
  41. package/lib/watcher.d.ts +25 -12
  42. package/lib/watcher.js +120 -32
  43. package/lib/workspace-files-route.d.ts +8 -4
  44. package/lib/workspace-files-route.js +42 -14
  45. package/lib/workspace-files-wsl.js +25 -8
  46. 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.startPromise = new Promise((resolve, reject) => {
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
- this.process?.kill();
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
- this.process?.kill();
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 script 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`;
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?.armWatchdog?.();
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
- reject,
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
- this.process?.kill();
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
- // Address by the entry's CURRENT id: a request relayed across a
451
- // rebuild was re-registered under a fresh one, and a stale KILL (or a
452
- // stale map delete) would strand the entry and leave the command
453
- // running to its own timeout.
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
- this.pending.delete(pending.id);
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", "--unshare-pid", "--proc", "/proc", "--die-with-parent"];
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");
@@ -61,11 +61,16 @@ export interface RoutingConfig {
61
61
  watchMaxDepth?: number;
62
62
  }
63
63
  /**
64
- * Whether a resolved target identity belongs to the distro.
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 targetKey - the target's identity (a UNC for distro paths).
67
- * @returns true when the target lives inside a WSL distro.
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.