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.
Files changed (44) hide show
  1. package/CHANGELOG.md +417 -0
  2. package/CONTRIBUTING.md +2 -2
  3. package/README.md +84 -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 +53 -18
  12. package/docs/PARITY.md +3 -1
  13. package/docs/PEER-PARITY.md +18 -11
  14. package/docs/README.zh.md +97 -74
  15. package/docs/UPSTREAM-FSIO-EXPORT.md +3 -3
  16. package/docs/UPSTREAM-TERMINAL-TITLE.md +9 -2
  17. package/examples/preset-wsl-session-sandbox.cordis.patch.yml +178 -0
  18. package/lib/agent-protocol.d.ts +10 -4
  19. package/lib/agent-protocol.js +10 -3
  20. package/lib/agent.d.ts +84 -0
  21. package/lib/agent.js +167 -15
  22. package/lib/bwrap.d.ts +8 -0
  23. package/lib/bwrap.js +4 -1
  24. package/lib/fs-routing.d.ts +40 -4
  25. package/lib/fs-routing.js +80 -12
  26. package/lib/fsio-agent.js +1 -1
  27. package/lib/index.d.ts +134 -0
  28. package/lib/index.js +191 -3
  29. package/lib/native-agent.d.ts +124 -0
  30. package/lib/native-agent.js +198 -0
  31. package/lib/ports.d.ts +13 -2
  32. package/lib/ports.js +18 -5
  33. package/lib/preflight.js +24 -1
  34. package/lib/session-runtime.d.ts +161 -0
  35. package/lib/session-runtime.js +220 -0
  36. package/lib/session-scope.d.ts +124 -0
  37. package/lib/session-scope.js +191 -0
  38. package/lib/shell-env.js +10 -3
  39. package/lib/watcher.d.ts +25 -12
  40. package/lib/watcher.js +120 -32
  41. package/lib/workspace-files-route.d.ts +8 -4
  42. package/lib/workspace-files-route.js +42 -14
  43. package/lib/workspace-files-wsl.js +25 -8
  44. 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
- this.process?.kill();
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
- this.process?.kill();
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 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`;
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?.armWatchdog?.();
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
- this.process?.kill();
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
- // 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.
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
- this.pending.delete(pending.id);
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", "--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.