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
@@ -14,7 +14,7 @@
14
14
  * @module dsh-plugin-wsl/agent-protocol
15
15
  */
16
16
  /** The protocol version this host side speaks; the agent must match. */
17
- export declare const PROTOCOL_VERSION = 4;
17
+ export declare const PROTOCOL_VERSION = 5;
18
18
  /**
19
19
  * Upper bound on one `read` op's payload, set by the HOST looping over windowed
20
20
  * requests. Keeping each frame's decoded payload here means the agent's RES
@@ -127,15 +127,21 @@ export interface FsFrameRequest {
127
127
  */
128
128
  export declare function encodeFsFrame({ id, op, args, timeoutMs }: FsFrameRequest): string[];
129
129
  /**
130
- * `HELLO|name|version|digest` — `digest` is the sha256 of the script file the
131
- * agent is executing (empty when it could not hash it); the host compares it
132
- * against its own shipped copy.
130
+ * `HELLO|name|version|digest|pid` — `digest` is the sha256 of the script file
131
+ * the agent is executing (empty when it could not hash it); the host compares
132
+ * it against its own shipped copy. `pid` is the agent shell's PID inside the
133
+ * distro, present from protocol 5 on: the host uses it to deliver an
134
+ * out-of-band `USR1` cancellation while the agent is blocked in `wait`, which
135
+ * the serial stdin loop cannot do by frame. It is absent when the agent
136
+ * itself runs inside a PID namespace (the confined resident), where that PID
137
+ * is not addressable from a second `wsl.exe`.
133
138
  */
134
139
  export interface HelloMessage {
135
140
  type: "hello";
136
141
  name: string;
137
142
  version: number;
138
143
  digest: string;
144
+ pid?: number;
139
145
  }
140
146
  /** `PONG` — the liveness answer to `PING`. */
141
147
  export interface PongMessage {
@@ -14,7 +14,7 @@
14
14
  * @module dsh-plugin-wsl/agent-protocol
15
15
  */
16
16
  /** The protocol version this host side speaks; the agent must match. */
17
- export const PROTOCOL_VERSION = 4;
17
+ export const PROTOCOL_VERSION = 5;
18
18
  /**
19
19
  * Upper bound on one `read` op's payload, set by the HOST looping over windowed
20
20
  * requests. Keeping each frame's decoded payload here means the agent's RES
@@ -124,8 +124,15 @@ export function encodeFsFrame({ id, op, args, timeoutMs = 0 }) {
124
124
  */
125
125
  export function parseAgentLine(line) {
126
126
  if (line.startsWith("HELLO|")) {
127
- const [, name, version, digest] = line.split("|");
128
- return { type: "hello", name: name ?? "", version: Number(version), digest: digest ?? "" };
127
+ const [, name, version, digest, pid] = line.split("|");
128
+ const agentPid = Number(pid);
129
+ return {
130
+ type: "hello",
131
+ name: name ?? "",
132
+ version: Number(version),
133
+ digest: digest ?? "",
134
+ ...(Number.isSafeInteger(agentPid) && agentPid > 0 ? { pid: agentPid } : {}),
135
+ };
129
136
  }
130
137
  if (line === "PONG")
131
138
  return { type: "pong" };
@@ -14,10 +14,12 @@
14
14
  * @module dsh-plugin-wsl/agent-shared
15
15
  */
16
16
  import { createHash } from "node:crypto";
17
+ import { spawn } from "node:child_process";
17
18
  import { readFileSync } from "node:fs";
18
19
  import { fileURLToPath } from "node:url";
19
20
  import { toLinuxPath } from "./paths.js";
20
- import { WslAgent } from "./agent.js";
21
+ import { pinnedWindowsEnv, WslAgent } from "./agent.js";
22
+ import { configuredAgentBackend, ensureNativeBackend, NATIVE_AGENT_ENV } from "./native-agent.js";
21
23
  const AGENT_SCRIPT_PATH = fileURLToPath(new URL("../agent/wsl-agent.sh", import.meta.url));
22
24
  // The script the distro reads must be byte-identical to the copy this
23
25
  // package shipped: the deployed layout reads it from the /mnt/c mirror, and
@@ -25,6 +27,9 @@ const AGENT_SCRIPT_PATH = fileURLToPath(new URL("../agent/wsl-agent.sh", import.
25
27
  // Both resident factories (plain and confined) pin it.
26
28
  const AGENT_SCRIPT_DIGEST = createHash("sha256").update(readFileSync(AGENT_SCRIPT_PATH)).digest("hex");
27
29
  const sharedAgents = new Map();
30
+ // Distros where the native shared agent failed at runtime; the reads fail open
31
+ // to the shell instead of retrying the same broken binary on every call.
32
+ const nativeFailed = new Set();
28
33
  /** The agent script as a host-side path, for factories that need both spellings. */
29
34
  export const agentScriptPath = AGENT_SCRIPT_PATH;
30
35
  /** The sha256 of the shipped agent script; every resident verifies its HELLO against it. */
@@ -37,9 +42,32 @@ export const agentScriptDigest = AGENT_SCRIPT_DIGEST;
37
42
  */
38
43
  export function sharedAgent(distro) {
39
44
  let agent = sharedAgents.get(distro);
40
- if (agent === undefined) {
41
- agent = new WslAgent({ distro, scriptPath: toLinuxPath(AGENT_SCRIPT_PATH), expectedDigest: AGENT_SCRIPT_DIGEST });
42
- sharedAgents.set(distro, agent);
43
- }
45
+ if (agent !== undefined && !agent.unavailable)
46
+ return agent;
47
+ if (agent !== undefined)
48
+ nativeFailed.add(distro);
49
+ agent = new WslAgent({
50
+ distro,
51
+ scriptPath: toLinuxPath(AGENT_SCRIPT_PATH),
52
+ expectedDigest: AGENT_SCRIPT_DIGEST,
53
+ prepare: async () => {
54
+ if (configuredAgentBackend() === "shell" || nativeFailed.has(distro))
55
+ return undefined;
56
+ const prepared = await ensureNativeBackend({ wslPath: "wsl.exe", distro, source: process.env[NATIVE_AGENT_ENV] ?? "" });
57
+ if (prepared === undefined)
58
+ return undefined;
59
+ return {
60
+ scriptPath: prepared.path,
61
+ expectedDigest: prepared.digest,
62
+ signalable: false,
63
+ spawnTransport: ({ wslPath, distro: target, argvPrefix }) => spawn(wslPath, ["-d", target, "--exec", ...argvPrefix, prepared.path], {
64
+ stdio: ["pipe", "pipe", "pipe"],
65
+ windowsHide: true,
66
+ env: pinnedWindowsEnv(process.env),
67
+ }),
68
+ };
69
+ },
70
+ });
71
+ sharedAgents.set(distro, agent);
44
72
  return agent;
45
73
  }
package/lib/agent.d.ts CHANGED
@@ -59,6 +59,14 @@ export interface ExecResult {
59
59
  export interface PendingRequest {
60
60
  /** The request id, as the agent echoes it. */
61
61
  id: string;
62
+ /** Which metric bucket this request belongs to. */
63
+ kind: "fs" | "exec";
64
+ /** When the frame was written, for the queue measurement. */
65
+ sentAt: number;
66
+ /** When the agent ACKed it, for the execution measurement. */
67
+ ackedAt?: number;
68
+ /** Whether the metrics recorded this request's settle. */
69
+ measured: boolean;
62
70
  /** The frame lines (kept so a rebuild can re-send). */
63
71
  lines: string[];
64
72
  /** Settles a RES. */
@@ -73,6 +81,57 @@ export interface PendingRequest {
73
81
  onAbort: (() => void) | null;
74
82
  /** Arms `watchdog`; null when the request carries no timeout. */
75
83
  armWatchdog: (() => void) | null;
84
+ /** Whether the agent has ACKed this request (it is the running one). */
85
+ started: boolean;
86
+ /** The caller cancelled it; the entry stays until the agent answers. */
87
+ cancelled: boolean;
88
+ /** The caller was already settled; a late answer only cleans up. */
89
+ settled: boolean;
90
+ }
91
+ /** Per-kind request metrics. */
92
+ export interface RequestMetrics {
93
+ /** Requests that settled (successful, failed or cancelled). */
94
+ requests: number;
95
+ /** Total queue wait (sent -> ACK), ms. */
96
+ queueMs: number;
97
+ /** Largest single queue wait, ms. */
98
+ queueMaxMs: number;
99
+ /** Total execution time (ACK -> settle), ms. */
100
+ execMs: number;
101
+ /** Largest single execution time, ms. */
102
+ execMaxMs: number;
103
+ /** Settled with an error (not cancellation). */
104
+ failed: number;
105
+ /** Cancelled by the caller. */
106
+ cancelled: number;
107
+ /** Rejected before tracking: queue full, already aborted, or send failed. */
108
+ rejected: number;
109
+ }
110
+ /** The agent's cumulative request metrics. */
111
+ export interface AgentMetrics {
112
+ /** FS frames. */
113
+ fs: RequestMetrics;
114
+ /** EXEC frames (commands, ping). */
115
+ exec: RequestMetrics;
116
+ /** Peak number of pending requests. */
117
+ inFlightMax: number;
118
+ }
119
+ /** The entry an async preparation may select before the first spawn. */
120
+ export interface WslAgentPrepared {
121
+ /** The agent entrypoint (the shell script or the native cache path). */
122
+ scriptPath: string;
123
+ /** The HELLO digest to verify. */
124
+ expectedDigest?: string;
125
+ /** Whether USR1 can reach the agent (false for the native KILL-frame path). */
126
+ signalable?: boolean;
127
+ /** The process factory for this entry. */
128
+ spawnTransport?: (options: {
129
+ wslPath: string;
130
+ distro: string;
131
+ scriptPath: string;
132
+ argvPrefix: string[];
133
+ leaseMs: number;
134
+ }) => ChildProcess;
76
135
  }
77
136
  /**
78
137
  * The Windows environment the transport starts from, pinned.
@@ -134,13 +193,36 @@ export declare class WslAgent {
134
193
  distro: string;
135
194
  scriptPath: string;
136
195
  argvPrefix: string[];
196
+ leaseMs: number;
137
197
  }) => ChildProcess) | undefined;
198
+ /** The explicit lease, when the caller supplied one (independent of idleMs). */
199
+ configuredLease: number | undefined;
200
+ /** The injectable signal sender, when the caller supplied one. */
201
+ configuredSignal: ((options: {
202
+ wslPath: string;
203
+ distro: string;
204
+ pid: number;
205
+ signal: NodeJS.Signals;
206
+ }) => void) | undefined;
207
+ /** The agent shell's PID from its HELLO, when a second wsl.exe can address it. */
208
+ agentPid: number | undefined;
209
+ /**
210
+ * Whether a second `wsl.exe` can deliver `USR1` to the agent's HELLO pid.
211
+ * True for the plain agent; false for a resident inside a PID namespace. A
212
+ * session runtime has a prefix (scope + mounts) but no PID namespace, so it
213
+ * opts in explicitly.
214
+ */
215
+ signalable: boolean;
216
+ /** The maximum requests allowed in flight on this resident. */
217
+ maxPending: number;
138
218
  /** The live resident process, when any. */
139
219
  process: ChildProcess | null;
140
220
  /** The lifecycle state. */
141
221
  state: "idle" | "starting" | "ready" | "dead";
142
222
  /** The in-flight start, shared by concurrent callers. */
143
223
  startPromise: Promise<void> | null;
224
+ /** The in-flight start's settler, so close() can fail a start it interrupts. */
225
+ startSettler: ((error?: unknown) => void) | null;
144
226
  /** The next request id. */
145
227
  nextRequestId: number;
146
228
  /** The requests in flight, keyed by their current id. */
@@ -151,6 +233,10 @@ export declare class WslAgent {
151
233
  idleTimer: NodeJS.Timeout | null;
152
234
  /** The last failure, for `unavailableReason` and log messages. */
153
235
  lastError: string;
236
+ /** Cumulative request metrics; see {@link metrics}. */
237
+ requestMetrics: AgentMetrics;
238
+ /** The optional async preparation hook. */
239
+ prepare: (() => Promise<WslAgentPrepared | undefined>) | undefined;
154
240
  /**
155
241
  * @param config - the agent configuration.
156
242
  */
@@ -166,6 +252,13 @@ export declare class WslAgent {
166
252
  wslPath?: string;
167
253
  /** Idle shutdown delay; 0 disables. */
168
254
  idleMs?: number;
255
+ /**
256
+ * The client lease in ms, independent of `idleMs`. A session runtime holds
257
+ * the agent for the session's lifetime (no idle close) but still wants the
258
+ * lease's last-resort self-termination; a positive value here overrides the
259
+ * idle-derived lease.
260
+ */
261
+ leaseMs?: number;
169
262
  /**
170
263
  * The sha256 of the agent script as this package shipped it; a HELLO whose
171
264
  * digest differs is refused the same way a version mismatch is (the script
@@ -180,6 +273,13 @@ export declare class WslAgent {
180
273
  * Empty for the plain agent.
181
274
  */
182
275
  argvPrefix?: string[];
276
+ /**
277
+ * Whether a second `wsl.exe` can signal the agent's pid. Defaults to
278
+ * "true when the argv prefix is empty": the plain agent shares the distro's
279
+ * PID namespace, a confined resident does not, and a session runtime has a
280
+ * prefix but no PID namespace and must opt in.
281
+ */
282
+ signalable?: boolean;
183
283
  /** Hard ceiling in bytes on one stdout protocol line; defaults to {@link MAX_FRAME_BYTES}. */
184
284
  frameCapBytes?: number;
185
285
  /** Injectable process factory for tests. */
@@ -188,7 +288,25 @@ export declare class WslAgent {
188
288
  distro: string;
189
289
  scriptPath: string;
190
290
  argvPrefix: string[];
291
+ leaseMs: number;
191
292
  }) => ChildProcess;
293
+ /**
294
+ * Injectable out-of-band signal delivery for tests. The real path spawns a
295
+ * second `wsl.exe` to send USR1 to the agent's PID.
296
+ */
297
+ signalTransport?: (options: {
298
+ wslPath: string;
299
+ distro: string;
300
+ pid: number;
301
+ signal: NodeJS.Signals;
302
+ }) => void;
303
+ /**
304
+ * Maximum requests in flight on one resident; a submission past it is
305
+ * refused instead of growing the request map without bound.
306
+ */
307
+ maxPending?: number;
308
+ /** Optional async preparation selecting the entry before the first spawn. */
309
+ prepare?: () => Promise<WslAgentPrepared | undefined>;
192
310
  });
193
311
  /**
194
312
  * Whether the agent is permanently out — the executor's fallback cue.
@@ -205,6 +323,18 @@ export declare class WslAgent {
205
323
  * @returns resolved once the state is `ready`.
206
324
  */
207
325
  start(): Promise<void>;
326
+ /**
327
+ * Apply an async preparation entry to this agent before the first spawn.
328
+ *
329
+ * @param entry - the selected entry.
330
+ */
331
+ private applyPrepared;
332
+ /**
333
+ * Spawn the process and run the handshake.
334
+ *
335
+ * @returns resolved once the state is `ready`.
336
+ */
337
+ private beginStart;
208
338
  /**
209
339
  * One stdout line from the agent. The first must be a matching HELLO.
210
340
  *
@@ -217,6 +347,7 @@ export declare class WslAgent {
217
347
  * The process died. Requests in flight ride exactly one rebuild; a second
218
348
  * death is permanent.
219
349
  *
350
+ * @param child - the process that exited.
220
351
  * @param code - the exit code, null when killed by a signal.
221
352
  * @param signalName - the killing signal, null on a normal exit.
222
353
  */
@@ -310,6 +441,53 @@ export declare class WslAgent {
310
441
  * @returns resolved on PONG.
311
442
  */
312
443
  ping(signal?: AbortSignal): Promise<void>;
444
+ /**
445
+ * Deliver an out-of-band signal to the resident shell. The serial stdin loop
446
+ * cannot consume a frame while a command runs, so cancellation is a signal;
447
+ * a second `wsl.exe` is the only channel that reaches the agent while it is
448
+ * blocked in `wait`.
449
+ *
450
+ * Only an agent that shares the distro's PID namespace is addressable: the
451
+ * plain agent, or a session runtime (its prefix is a scope plus mounts, but
452
+ * no PID namespace). A confined resident runs inside a PID namespace whose
453
+ * PIDs do not exist in the distro, so signalling its HELLO PID could hit an
454
+ * unrelated process; there the KILL frame remains the (delayed) fallback.
455
+ *
456
+ * @param signal - the signal to deliver (`SIGUSR1` is the cancel trap).
457
+ */
458
+ /**
459
+ * The cumulative request metrics: queue wait (sent -> ACK) and execution
460
+ * time (ACK -> settle) per request kind, plus the peak in-flight count.
461
+ *
462
+ * @returns a snapshot the caller can keep.
463
+ */
464
+ metrics(): AgentMetrics;
465
+ /** Reset the cumulative metrics (diagnostics/probes). */
466
+ resetMetrics(): void;
467
+ /**
468
+ * The metric bucket for a request kind.
469
+ * @param kind - fs or exec.
470
+ * @returns the bucket.
471
+ */
472
+ private kindMetrics;
473
+ /**
474
+ * Record a request that never entered the pending map.
475
+ * @param kind - the bucket.
476
+ */
477
+ private noteRejected;
478
+ /**
479
+ * Record the dequeue ACK: the request's queue wait.
480
+ * @param pending - the request.
481
+ */
482
+ private recordAck;
483
+ /**
484
+ * Record a settle: the requests count, the execution time (or the queue wait
485
+ * for a request that never ACKed), and the outcome.
486
+ * @param pending - the request.
487
+ * @param error - the failure, when any.
488
+ */
489
+ private recordSettle;
490
+ private signalAgent;
313
491
  /**
314
492
  * Shut the agent down: SHUTDOWN first, then kill after a short grace. The
315
493
  * agent becomes reusable — a later `exec` starts a fresh process.
@@ -317,6 +495,15 @@ export declare class WslAgent {
317
495
  * @returns resolved once the process is gone.
318
496
  */
319
497
  close(): Promise<void>;
498
+ /**
499
+ * Release the host's ends of a dead child's stdio. A descendant that
500
+ * inherited a pipe's write end would otherwise keep the read side — and the
501
+ * fd — open until that descendant exits; the agent's own helpers are
502
+ * redirected at their spawn sites, but the host must not depend on it.
503
+ *
504
+ * @param child - the process whose streams are being released.
505
+ */
506
+ private teardownStreams;
320
507
  /**
321
508
  * Write one frame to the live process.
322
509
  *