dsh-plugin-wsl-env 0.7.5 → 0.9.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 (92) hide show
  1. package/CHANGELOG.md +345 -8
  2. package/CONTRIBUTING.md +221 -68
  3. package/README.md +50 -35
  4. package/SECURITY.md +18 -2
  5. package/SUPPORT.md +23 -7
  6. package/cordis.patch.yml +9 -0
  7. package/docs/ARCHITECTURE.md +200 -57
  8. package/docs/CONFIGURATION.md +180 -16
  9. package/docs/LIMITATIONS.md +91 -33
  10. package/docs/PARITY.md +66 -38
  11. package/docs/PEER-PARITY.md +95 -32
  12. package/docs/README.zh.md +49 -31
  13. package/docs/UPSTREAM-FSIO-EXPORT.md +159 -50
  14. package/docs/UPSTREAM-TERMINAL-TITLE.md +49 -14
  15. package/lib/agent-confined.d.ts +42 -0
  16. package/lib/agent-confined.js +25 -41
  17. package/lib/agent-errors.d.ts +31 -0
  18. package/lib/agent-errors.js +19 -16
  19. package/lib/agent-exec.d.ts +159 -0
  20. package/lib/agent-exec.js +151 -165
  21. package/lib/agent-protocol.d.ts +192 -0
  22. package/lib/agent-protocol.js +76 -100
  23. package/lib/agent-shared.d.ts +27 -0
  24. package/lib/agent-shared.js +12 -12
  25. package/lib/agent.d.ts +353 -0
  26. package/lib/agent.js +536 -552
  27. package/lib/auto-preset.d.ts +110 -0
  28. package/lib/auto-preset.js +157 -141
  29. package/lib/bwrap.d.ts +90 -0
  30. package/lib/bwrap.js +18 -18
  31. package/lib/file-reference-wsl.d.ts +160 -0
  32. package/lib/file-reference-wsl.js +192 -173
  33. package/lib/fs-decisions.d.ts +107 -0
  34. package/lib/fs-decisions.js +64 -60
  35. package/lib/fs-routing.d.ts +182 -0
  36. package/lib/fs-routing.js +173 -116
  37. package/lib/fs-substrate.d.ts +258 -0
  38. package/lib/fs-substrate.js +248 -248
  39. package/lib/fs.d.ts +10 -0
  40. package/lib/fs.js +5 -0
  41. package/lib/fsio-agent.d.ts +307 -0
  42. package/lib/fsio-agent.js +414 -410
  43. package/lib/fsio-text.d.ts +115 -0
  44. package/lib/fsio-text.js +104 -84
  45. package/lib/git-route.d.ts +100 -0
  46. package/lib/git-route.js +46 -47
  47. package/lib/index.d.ts +1003 -0
  48. package/lib/index.js +1106 -1003
  49. package/lib/listing.d.ts +114 -0
  50. package/lib/listing.js +68 -55
  51. package/lib/paths.d.ts +182 -0
  52. package/lib/paths.js +134 -109
  53. package/lib/picker.d.ts +266 -0
  54. package/lib/picker.js +371 -351
  55. package/lib/ports.d.ts +44 -0
  56. package/lib/ports.js +39 -29
  57. package/lib/preflight.d.ts +91 -0
  58. package/lib/preflight.js +94 -0
  59. package/lib/preset-choice.d.ts +33 -0
  60. package/lib/preset-choice.js +15 -7
  61. package/lib/probe-cache.d.ts +51 -0
  62. package/lib/probe-cache.js +43 -29
  63. package/lib/sandbox-core.d.ts +149 -0
  64. package/lib/sandbox-core.js +239 -87
  65. package/lib/sandbox.d.ts +48 -0
  66. package/lib/sandbox.js +4 -4
  67. package/lib/search-exec.d.ts +96 -0
  68. package/lib/search-exec.js +52 -79
  69. package/lib/search-route.d.ts +75 -0
  70. package/lib/search-route.js +30 -31
  71. package/lib/shell-env.d.ts +88 -0
  72. package/lib/shell-env.js +78 -74
  73. package/lib/shell.d.ts +10 -0
  74. package/lib/shell.js +5 -0
  75. package/lib/subprocess.d.ts +313 -0
  76. package/lib/subprocess.js +267 -262
  77. package/lib/terminal-activity.d.ts +98 -0
  78. package/lib/terminal-activity.js +29 -39
  79. package/lib/terminal-route.d.ts +27 -0
  80. package/lib/terminal-route.js +16 -8
  81. package/lib/watcher.d.ts +77 -0
  82. package/lib/watcher.js +120 -126
  83. package/lib/workspace-files-route.d.ts +132 -0
  84. package/lib/workspace-files-route.js +73 -81
  85. package/lib/workspace-files-wsl.d.ts +257 -0
  86. package/lib/workspace-files-wsl.js +311 -251
  87. package/lib/wsl.d.ts +266 -0
  88. package/lib/wsl.js +150 -155
  89. package/locale/preflight/en.json +6 -0
  90. package/locale/preflight/zh.json +6 -0
  91. package/package.json +78 -22
  92. package/scripts/diagnose.sh +92 -0
package/lib/agent.js CHANGED
@@ -31,30 +31,27 @@
31
31
  * The transport is injectable so the lifecycle and the fallback decision are
32
32
  * unit-testable without a distro.
33
33
  *
34
+ * This is a TypeScript source built to `lib/agent.js`; edit THIS file and run
35
+ * `pnpm run build` — the artifact under `lib/` is generated, and `pnpm test`
36
+ * fails when it drifts.
37
+ *
34
38
  * @module dsh-plugin-wsl/agent
35
39
  */
36
-
37
40
  import { spawn } from "node:child_process";
38
41
  import { AgentUnavailableError, CwdError } from "./agent-errors.js";
39
42
  import { AGENT_NAME, MAX_FRAME_BYTES, PROTOCOL_VERSION, encodeExecFrame, encodeFsFrame, encodeKill, encodeSetEnv, parseAgentLine } from "./agent-protocol.js";
40
-
41
43
  /** How long a rebuilt agent has to re-answer the handshake before giving up. */
42
44
  const HANDSHAKE_TIMEOUT_MS = 15000;
43
-
44
45
  /** Extra time the host watchdog waits past the request's own in-distro timeout. */
45
46
  const WATCHDOG_GRACE_MS = 13000;
46
-
47
47
  /** How far past its host's idle shutdown the agent's own lease runs. The
48
48
  * lease fires only when the host is gone in every way that matters (a wedged
49
49
  * relay that never delivers EOF); the host's close() normally wins the race. */
50
50
  const LEASE_GRACE_MS = 120000;
51
-
52
51
  /** The Windows essentials `wsl.exe` itself needs to start and report cleanly. */
53
52
  const WINDOWS_ESSENTIALS = /^(systemroot|systemdrive|windir|path|pathext|comspec|os|computername)$/i;
54
-
55
53
  /** The managed facts prefix whose names (and only theirs) may be forwarded. */
56
54
  const MANAGED_ENV_PREFIX = "DSH_";
57
-
58
55
  /**
59
56
  * The Windows environment the transport starts from, pinned.
60
57
  *
@@ -73,38 +70,27 @@ const MANAGED_ENV_PREFIX = "DSH_";
73
70
  * has never forwarded them, and this pin keeps that posture instead of
74
71
  * widening it silently.
75
72
  *
76
- * @param {Record<string, string|undefined>} parent - this process's environment.
77
- * @returns {Record<string, string>} the pinned environment for `wsl.exe`.
73
+ * @param parent - this process's environment.
74
+ * @returns the pinned environment for `wsl.exe`.
78
75
  */
79
76
  export function pinnedWindowsEnv(parent) {
80
- const env = {};
81
- for (const [name, value] of Object.entries(parent)) {
82
- if (value === undefined) continue;
83
- if (WINDOWS_ESSENTIALS.test(name) || name.startsWith(MANAGED_ENV_PREFIX)) env[name] = value;
84
- }
85
- env.WSLENV = String(parent.WSLENV ?? "")
86
- .split(":")
87
- .filter((entry) => entry.startsWith(MANAGED_ENV_PREFIX))
88
- .join(":");
89
- // wsl.exe writes its own diagnostics as UTF-16LE without this, and a UTF-16
90
- // message read as UTF-8 is mojibake — including the `WSL_E_*` codes. Same
91
- // pin the one-shot path applies to every spawn.
92
- env.WSL_UTF8 = "1";
93
- return env;
77
+ const env = {};
78
+ for (const [name, value] of Object.entries(parent)) {
79
+ if (value === undefined)
80
+ continue;
81
+ if (WINDOWS_ESSENTIALS.test(name) || name.startsWith(MANAGED_ENV_PREFIX))
82
+ env[name] = value;
83
+ }
84
+ env.WSLENV = String(parent.WSLENV ?? "")
85
+ .split(":")
86
+ .filter((entry) => entry.startsWith(MANAGED_ENV_PREFIX))
87
+ .join(":");
88
+ // wsl.exe writes its own diagnostics as UTF-16LE without this, and a UTF-16
89
+ // message read as UTF-8 is mojibake — including the `WSL_E_*` codes. Same
90
+ // pin the one-shot path applies to every spawn.
91
+ env.WSL_UTF8 = "1";
92
+ return env;
94
93
  }
95
-
96
- /**
97
- * @param {object} options - the spawn options.
98
- * @param {string} options.wslPath - the `wsl.exe` path.
99
- * @param {string} options.distro - the distro name.
100
- * @param {string} options.scriptPath - the agent script as a Linux path inside
101
- * the distro (a `/mnt/<drive>/...` translation of the package's
102
- * `agent/wsl-agent.sh`).
103
- * @param {number} [options.leaseMs] - the agent's own client lease, forwarded
104
- * through the managed `DSH_` namespace so the script can self-terminate
105
- * when a wedged relay stops carrying traffic without ever delivering EOF.
106
- * @returns {import("node:child_process").ChildProcess} the spawned process.
107
- */
108
94
  /**
109
95
  * The spawn environment for one resident, with the client lease folded in.
110
96
  * The pinned environment forwards only the managed `DSH_` namespace, and it
@@ -112,565 +98,563 @@ export function pinnedWindowsEnv(parent) {
112
98
  * both: the value on the variable, the name on `WSLENV`. A non-positive
113
99
  * lease leaves the environment as the pin built it (the script treats an
114
100
  * unset variable as no lease).
115
- * @param {Record<string, string>} env - the pinned environment.
116
- * @param {number} leaseMs - the agent's client lease in ms; 0 disables.
117
- * @returns {Record<string, string>} the environment to spawn with.
101
+ *
102
+ * @param env - the pinned environment.
103
+ * @param leaseMs - the agent's client lease in ms; 0 disables.
104
+ * @returns the environment to spawn with.
118
105
  */
119
106
  export function agentLeaseEnv(env, leaseMs) {
120
- if (!(leaseMs > 0)) return env;
121
- return {
122
- ...env,
123
- DSH_AGENT_LEASE_MS: String(leaseMs),
124
- WSLENV: [env.WSLENV, "DSH_AGENT_LEASE_MS"].filter(Boolean).join(":"),
125
- };
126
- }
127
-
128
- function spawnDefault({ wslPath, distro, scriptPath, argvPrefix = [], leaseMs = 0 }) {
129
- return spawn(wslPath, ["-d", distro, "--exec", ...argvPrefix, "sh", scriptPath], {
130
- env: agentLeaseEnv(pinnedWindowsEnv(process.env), leaseMs),
131
- stdio: ["pipe", "pipe", "pipe"],
132
- windowsHide: true,
133
- });
107
+ if (!(leaseMs > 0))
108
+ return env;
109
+ return {
110
+ ...env,
111
+ DSH_AGENT_LEASE_MS: String(leaseMs),
112
+ WSLENV: [env.WSLENV, "DSH_AGENT_LEASE_MS"].filter(Boolean).join(":"),
113
+ };
134
114
  }
135
-
136
115
  /**
137
- * The resident agent for one distro.
116
+ * The default transport: `wsl.exe -d <distro> --exec [prefix] sh <script>`.
117
+ *
118
+ * @param options - the spawn spec.
119
+ * @returns the spawned process.
138
120
  */
121
+ function spawnDefault({ wslPath, distro, scriptPath, argvPrefix = [], leaseMs = 0 }) {
122
+ return spawn(wslPath, ["-d", distro, "--exec", ...argvPrefix, "sh", scriptPath], {
123
+ env: agentLeaseEnv(pinnedWindowsEnv(process.env), leaseMs),
124
+ stdio: ["pipe", "pipe", "pipe"],
125
+ windowsHide: true,
126
+ });
127
+ }
128
+ /** The resident agent for one distro. */
139
129
  export class WslAgent {
140
- /**
141
- * @param {object} config - the agent configuration.
142
- * @param {string} config.distro - the distro this agent serves.
143
- * @param {string} config.scriptPath - the agent script as a Linux path
144
- * inside the distro (see {@link spawnDefault}).
145
- * @param {string} [config.wslPath] - the `wsl.exe` path.
146
- * @param {number} [config.idleMs] - idle shutdown delay; 0 disables.
147
- * @param {string} [config.expectedDigest] - the sha256 of the agent script
148
- * as this package shipped it; a HELLO whose digest differs is refused the
149
- * same way a version mismatch is (the script is read in place, so a stale
150
- * or half-synced runtime copy is the failure this catches).
151
- * @param {string[]} [config.argvPrefix] - arguments inserted between
152
- * `wsl.exe --exec` and the `sh script` pair — the confinement wrapper
153
- * (`bwrap … --`) a confined agent runs its whole lifetime under, so every
154
- * filesystem op it performs is kernel-enforced. Empty for the plain agent.
155
- * @param {(options: {wslPath: string, distro: string, scriptPath: string, argvPrefix: string[]}) => import("node:child_process").ChildProcess} [config.spawnTransport]
156
- * injectable process factory for tests.
157
- */
158
- constructor(config) {
159
- this.distro = config.distro;
160
- this.scriptPath = config.scriptPath;
161
- this.wslPath = config.wslPath ?? "wsl.exe";
162
- this.argvPrefix = config.argvPrefix ?? [];
163
- this.idleMs = config.idleMs ?? 600000;
164
- this.expectedDigest = config.expectedDigest;
165
- /** Hard ceiling on one stdout protocol line; a longer line is a broken or
166
- * hostile agent, not data to hold — readline used to accumulate it all. */
167
- this.frameCapBytes = config.frameCapBytes ?? MAX_FRAME_BYTES;
168
- this.configuredSpawn = config.spawnTransport;
169
- /** @type {import("node:child_process").ChildProcess|null} */
170
- this.process = null;
171
- /** @type {"idle"|"starting"|"ready"|"dead"} */
172
- this.state = "idle";
173
- /** @type {Promise<void>|null} */
174
- this.startPromise = null;
175
- this.nextRequestId = 0;
176
- /** @type {Map<string, {lines: string[], resolve: Function, reject: Function, watchdog: NodeJS.Timeout|null, signal: AbortSignal|null, onAbort: Function|null}>} */
177
- this.pending = new Map();
178
- this.rebuildUsed = false;
179
- /** @type {NodeJS.Timeout|null} */
180
- this.idleTimer = null;
181
- this.lastError = "";
182
- }
183
-
184
- /**
185
- * Whether the agent is permanently out — the executor's fallback cue.
186
- * @returns {boolean}
187
- */
188
- get unavailable() {
189
- return this.state === "dead";
190
- }
191
-
192
- /** Human-readable reason for the last transition to `dead`, for logging. */
193
- get unavailableReason() {
194
- return this.lastError;
195
- }
196
-
197
- /**
198
- * Start the process and wait for the handshake. Re-entrant: concurrent
199
- * callers share the one start.
200
- * @returns {Promise<void>} resolved once the state is `ready`.
201
- */
202
- start() {
203
- if (this.state === "dead") {
204
- return Promise.reject(new AgentUnavailableError(this.lastError));
205
- }
206
- if (this.state === "ready") return Promise.resolve();
207
- if (this.startPromise) return this.startPromise;
208
- this.state = "starting";
209
- this.startPromise = new Promise((resolve, reject) => {
210
- const child = (this.process = (this.configuredSpawn ?? spawnDefault)({
211
- wslPath: this.wslPath,
212
- distro: this.distro,
213
- scriptPath: this.scriptPath,
214
- argvPrefix: this.argvPrefix,
215
- leaseMs: this.idleMs > 0 ? this.idleMs + LEASE_GRACE_MS : 0,
216
- }));
217
- const handshakeTimer = setTimeout(() => {
218
- this.lastError = `agent handshake timed out after ${HANDSHAKE_TIMEOUT_MS}ms`;
219
- child.kill();
220
- }, HANDSHAKE_TIMEOUT_MS);
221
- const settleStart = (error) => {
222
- clearTimeout(handshakeTimer);
130
+ /**
131
+ * @param config - the agent configuration.
132
+ */
133
+ constructor(config) {
134
+ this.distro = config.distro;
135
+ this.scriptPath = config.scriptPath;
136
+ this.wslPath = config.wslPath ?? "wsl.exe";
137
+ this.argvPrefix = config.argvPrefix ?? [];
138
+ this.idleMs = config.idleMs ?? 600000;
139
+ this.expectedDigest = config.expectedDigest;
140
+ this.frameCapBytes = config.frameCapBytes ?? MAX_FRAME_BYTES;
141
+ this.configuredSpawn = config.spawnTransport;
142
+ this.process = null;
143
+ this.state = "idle";
223
144
  this.startPromise = null;
224
- if (error) {
225
- this.state = this.rebuildUsed ? "dead" : "idle";
226
- if (this.rebuildUsed) this.lastError = this.lastError || String(error);
227
- reject(error instanceof AgentUnavailableError ? error : new AgentUnavailableError(String(error)));
228
- } else {
229
- this.state = "ready";
230
- resolve();
231
- }
232
- };
233
- child.on("error", (error) => {
234
- this.lastError = String(error);
235
- settleStart(error);
236
- });
237
- child.on("exit", (code, signalName) => {
238
- if (this.state === "starting") {
239
- this.lastError = `agent exited during handshake (code=${code} signal=${signalName ?? "none"})`;
240
- settleStart(new Error(this.lastError));
145
+ this.nextRequestId = 0;
146
+ this.pending = new Map();
147
+ this.rebuildUsed = false;
148
+ this.idleTimer = null;
149
+ this.lastError = "";
150
+ }
151
+ /**
152
+ * Whether the agent is permanently out — the executor's fallback cue.
153
+ *
154
+ * @returns true once the rebuild budget is spent.
155
+ */
156
+ get unavailable() {
157
+ return this.state === "dead";
158
+ }
159
+ /** Human-readable reason for the last transition to `dead`, for logging. */
160
+ get unavailableReason() {
161
+ return this.lastError;
162
+ }
163
+ /**
164
+ * Start the process and wait for the handshake. Re-entrant: concurrent
165
+ * callers share the one start.
166
+ *
167
+ * @returns resolved once the state is `ready`.
168
+ */
169
+ start() {
170
+ if (this.state === "dead") {
171
+ return Promise.reject(new AgentUnavailableError(this.lastError));
241
172
  }
242
- this.handleProcessExit(code, signalName);
243
- });
244
- // A bounded line splitter instead of readline: readline accumulates an
245
- // over-long line whole before anyone can object, which is exactly the
246
- // unbounded-capture hole. Protocol lines are ASCII by construction
247
- // (ids, numbers, base64), so chunk-boundary decoding is not a concern.
248
- let buffered = "";
249
- child.stdout.on("data", (chunk) => {
250
- buffered += chunk.toString("utf8");
251
- for (;;) {
252
- const newline = buffered.indexOf("\n");
253
- if (newline === -1) {
254
- if (buffered.length > this.frameCapBytes) {
255
- this.lastError = `agent frame of ${buffered.length} bytes exceeded the ${this.frameCapBytes}-byte protocol cap`;
256
- buffered = "";
257
- this.process?.kill();
173
+ if (this.state === "ready")
174
+ return Promise.resolve();
175
+ if (this.startPromise)
176
+ return this.startPromise;
177
+ this.state = "starting";
178
+ this.startPromise = new Promise((resolve, reject) => {
179
+ // stdio is all-pipe (the spawn options above), so every standard stream
180
+ // exists; the broad ChildProcess type models them as nullable.
181
+ const child = (this.process = (this.configuredSpawn ?? spawnDefault)({
182
+ wslPath: this.wslPath,
183
+ distro: this.distro,
184
+ scriptPath: this.scriptPath,
185
+ argvPrefix: this.argvPrefix,
186
+ leaseMs: this.idleMs > 0 ? this.idleMs + LEASE_GRACE_MS : 0,
187
+ }));
188
+ const handshakeTimer = setTimeout(() => {
189
+ this.lastError = `agent handshake timed out after ${HANDSHAKE_TIMEOUT_MS}ms`;
190
+ child.kill();
191
+ }, HANDSHAKE_TIMEOUT_MS);
192
+ const settleStart = (error) => {
193
+ clearTimeout(handshakeTimer);
194
+ this.startPromise = null;
195
+ if (error) {
196
+ this.state = this.rebuildUsed ? "dead" : "idle";
197
+ if (this.rebuildUsed)
198
+ this.lastError = this.lastError || String(error);
199
+ reject(error instanceof AgentUnavailableError ? error : new AgentUnavailableError(String(error)));
200
+ }
201
+ else {
202
+ this.state = "ready";
203
+ resolve();
204
+ }
205
+ };
206
+ child.on("error", (error) => {
207
+ this.lastError = String(error);
208
+ settleStart(error);
209
+ });
210
+ child.on("exit", (code, signalName) => {
211
+ if (this.state === "starting") {
212
+ this.lastError = `agent exited during handshake (code=${code} signal=${signalName ?? "none"})`;
213
+ settleStart(new Error(this.lastError));
214
+ }
215
+ this.handleProcessExit(code, signalName);
216
+ });
217
+ // A bounded line splitter instead of readline: readline accumulates an
218
+ // over-long line whole before anyone can object, which is exactly the
219
+ // unbounded-capture hole. Protocol lines are ASCII by construction
220
+ // (ids, numbers, base64), so chunk-boundary decoding is not a concern.
221
+ let buffered = "";
222
+ child.stdout.on("data", (chunk) => {
223
+ buffered += chunk.toString("utf8");
224
+ for (;;) {
225
+ const newline = buffered.indexOf("\n");
226
+ if (newline === -1) {
227
+ if (buffered.length > this.frameCapBytes) {
228
+ this.lastError = `agent frame of ${buffered.length} bytes exceeded the ${this.frameCapBytes}-byte protocol cap`;
229
+ buffered = "";
230
+ this.process?.kill();
231
+ }
232
+ return;
233
+ }
234
+ const line = buffered.slice(0, newline);
235
+ buffered = buffered.slice(newline + 1);
236
+ if (line.length > this.frameCapBytes) {
237
+ this.lastError = `agent frame of ${line.length} bytes exceeded the ${this.frameCapBytes}-byte protocol cap`;
238
+ buffered = "";
239
+ this.process?.kill();
240
+ return;
241
+ }
242
+ this.handleLine(line, settleStart, handshakeTimer);
243
+ }
244
+ });
245
+ child.stderr?.on("data", (chunk) => {
246
+ // Diagnostics only; `wsl.exe` and the shell chatter here on failure.
247
+ this.lastError = String(chunk).trim().slice(0, 2000) || this.lastError;
248
+ });
249
+ });
250
+ return this.startPromise;
251
+ }
252
+ /**
253
+ * One stdout line from the agent. The first must be a matching HELLO.
254
+ *
255
+ * @param line - one protocol line, terminator stripped.
256
+ * @param settleStart - the handshake settler.
257
+ * @param handshakeTimer - the handshake watchdog.
258
+ */
259
+ handleLine(line, settleStart, handshakeTimer) {
260
+ const message = parseAgentLine(line);
261
+ switch (message.type) {
262
+ case "hello": {
263
+ if (message.name !== AGENT_NAME || message.version !== PROTOCOL_VERSION) {
264
+ // Settle BEFORE killing: the exit event must not overwrite the real
265
+ // reason with the generic handshake message (the fake transports
266
+ // emit exit synchronously inside kill(); a real wsl.exe races it).
267
+ this.lastError = `agent handshake mismatch: got ${message.name}@${message.version}, want ${AGENT_NAME}@${PROTOCOL_VERSION}`;
268
+ settleStart(new Error(this.lastError));
269
+ this.process?.kill();
270
+ return;
271
+ }
272
+ if (this.expectedDigest !== undefined && message.digest !== this.expectedDigest) {
273
+ // The script is read in place from the distro-visible path — in the
274
+ // deployed layout a /mnt/c mirror — so a digest that differs from
275
+ // the copy this package shipped means the runtime copy is stale,
276
+ // 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`;
278
+ settleStart(new Error(this.lastError));
279
+ this.process?.kill();
280
+ return;
281
+ }
282
+ clearTimeout(handshakeTimer);
283
+ settleStart();
284
+ break;
285
+ }
286
+ case "ack": {
287
+ // Dequeue signal: start this request's own budget clock NOW. Armed
288
+ // here instead of at submission, a queued request can no longer time
289
+ // 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.
291
+ const pending = this.pending.get(message.id);
292
+ pending?.armWatchdog?.();
293
+ break;
258
294
  }
295
+ case "result": {
296
+ const pending = this.pending.get(message.id);
297
+ if (!pending)
298
+ break; // answered after its caller gave up — noise
299
+ this.clearPending(pending);
300
+ this.pending.delete(message.id);
301
+ this.rearmIdleTimer();
302
+ pending.resolve({ exitCode: message.exitCode, stdout: message.stdout, stderr: message.stderr, truncated: message.truncated });
303
+ break;
304
+ }
305
+ case "agentError": {
306
+ const pending = this.pending.get(message.id);
307
+ if (!pending)
308
+ break;
309
+ this.clearPending(pending);
310
+ this.pending.delete(message.id);
311
+ this.rearmIdleTimer();
312
+ // A cwd rejection is the command's own failure, not the agent's — it
313
+ // becomes a CwdError so the execution layer can report it exactly like
314
+ // the one-shot path's relay text.
315
+ if (message.reason === "cwd") {
316
+ pending.reject(new CwdError(message.message));
317
+ }
318
+ else {
319
+ pending.reject(new AgentUnavailableError(`agent rejected request ${message.id} (${message.reason}): ${message.message}`));
320
+ }
321
+ break;
322
+ }
323
+ default:
324
+ // Garbage after the handshake is logged and dropped: a late RES for an
325
+ // abandoned request is expected noise, not a broken pipe.
326
+ break;
327
+ }
328
+ }
329
+ /**
330
+ * The process died. Requests in flight ride exactly one rebuild; a second
331
+ * death is permanent.
332
+ *
333
+ * @param code - the exit code, null when killed by a signal.
334
+ * @param signalName - the killing signal, null on a normal exit.
335
+ */
336
+ handleProcessExit(code, signalName) {
337
+ this.process = null;
338
+ if (this.pending.size === 0) {
339
+ if (this.state !== "dead")
340
+ this.state = "idle";
259
341
  return;
260
- }
261
- const line = buffered.slice(0, newline);
262
- buffered = buffered.slice(newline + 1);
263
- if (line.length > this.frameCapBytes) {
264
- this.lastError = `agent frame of ${line.length} bytes exceeded the ${this.frameCapBytes}-byte protocol cap`;
265
- buffered = "";
266
- this.process?.kill();
342
+ }
343
+ if (!this.rebuildUsed) {
344
+ this.rebuildUsed = true;
345
+ this.lastError = `agent exited unexpectedly (code=${code} signal=${signalName ?? "none"}); rebuilding once`;
346
+ this.state = "idle";
347
+ this.startPromise = null;
348
+ const carrying = [...this.pending.values()];
349
+ this.pending.clear();
350
+ // Only provably read-only FS requests ride the rebuild: whatever their
351
+ // first execution did, it changed nothing. A command or a write that was
352
+ // in flight has UNKNOWN state — replaying it would run non-idempotent
353
+ // work a second time, or report a guarded create that actually
354
+ // succeeded as a refusal — so it fails with that fact instead. A stale
355
+ // watchdog must not survive either: its closure kills `this.process`,
356
+ // which after the rebuild is the NEW transport.
357
+ const relayable = [];
358
+ for (const pending of carrying) {
359
+ if (pending.signal?.aborted) {
360
+ // Already rejected to its caller by the abort listener; do not
361
+ // resurrect it on the rebuilt agent.
362
+ this.clearPending(pending);
363
+ continue;
364
+ }
365
+ if (isReplayableFrame(pending.lines)) {
366
+ if (pending.watchdog) {
367
+ clearTimeout(pending.watchdog);
368
+ pending.watchdog = null;
369
+ }
370
+ relayable.push(pending);
371
+ }
372
+ else {
373
+ this.clearPending(pending);
374
+ pending.reject(new AgentUnavailableError(`agent exited while request ${pending.id} was in flight; its state is unknown — inspect the distro before retrying`));
375
+ }
376
+ }
377
+ this.start()
378
+ .then(() => {
379
+ for (const pending of relayable) {
380
+ const id = `r${this.nextRequestId++}`;
381
+ pending.id = id;
382
+ this.pending.set(id, pending);
383
+ // The rebuilt transport can itself be gone by now; a frame that
384
+ // cannot be written must fail its caller, not strand the entry.
385
+ if (!this.writeLines(relabelFrame(pending.lines, id))) {
386
+ this.clearPending(pending);
387
+ this.pending.delete(id);
388
+ pending.reject(new AgentUnavailableError(`agent exited before request ${id} could be re-sent; its state is unknown`));
389
+ }
390
+ }
391
+ })
392
+ .catch((error) => {
393
+ for (const pending of relayable) {
394
+ this.clearPending(pending);
395
+ pending.reject(error);
396
+ }
397
+ this.state = "dead";
398
+ });
267
399
  return;
268
- }
269
- this.handleLine(line, settleStart, handshakeTimer);
270
400
  }
271
- });
272
- child.stderr?.on("data", (chunk) => {
273
- // Diagnostics only; `wsl.exe` and the shell chatter here on failure.
274
- this.lastError = String(chunk).trim().slice(0, 2000) || this.lastError;
275
- });
276
- });
277
- return this.startPromise;
278
- }
279
-
280
- /**
281
- * One stdout line from the agent. The first must be a matching HELLO.
282
- * @private
283
- */
284
- handleLine(line, settleStart, handshakeTimer) {
285
- const message = parseAgentLine(line);
286
- switch (message.type) {
287
- case "hello": {
288
- if (message.name !== AGENT_NAME || message.version !== PROTOCOL_VERSION) {
289
- // Settle BEFORE killing: the exit event must not overwrite the real
290
- // reason with the generic handshake message (the fake transports
291
- // emit exit synchronously inside kill(); a real wsl.exe races it).
292
- this.lastError = `agent handshake mismatch: got ${message.name}@${message.version}, want ${AGENT_NAME}@${PROTOCOL_VERSION}`;
293
- settleStart(new Error(this.lastError));
294
- this.process?.kill();
295
- return;
401
+ this.lastError = `agent exited again (code=${code} signal=${signalName ?? "none"}); falling back permanently`;
402
+ this.state = "dead";
403
+ for (const pending of this.pending.values()) {
404
+ this.clearPending(pending);
405
+ pending.reject(new AgentUnavailableError(this.lastError));
296
406
  }
297
- if (this.expectedDigest !== undefined && message.digest !== this.expectedDigest) {
298
- // The script is read in place from the distro-visible path — in the
299
- // deployed layout a /mnt/c mirror — so a digest that differs from
300
- // the copy this package shipped means the runtime copy is stale,
301
- // half-synced, or edited. Fail closed with the fix in the message.
302
- 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`;
303
- settleStart(new Error(this.lastError));
304
- this.process?.kill();
305
- return;
407
+ this.pending.clear();
408
+ }
409
+ /**
410
+ * Register one request's pending entry and write its frame: the shared
411
+ * request machinery (abort, submission, the ACK-armed watchdog). The
412
+ * watchdog is deliberately NOT armed here — it starts at the agent's
413
+ * dequeue `ACK`, so time spent queued behind an earlier request never
414
+ * spends this request's budget and never kills the shared transport for a
415
+ * stall that was not its own.
416
+ *
417
+ * @param request - the request to register.
418
+ */
419
+ track({ id, lines, resolve, reject, timeoutMs, signal }) {
420
+ const pending = {
421
+ id,
422
+ lines,
423
+ resolve,
424
+ reject,
425
+ watchdog: null,
426
+ signal: signal ?? null,
427
+ onAbort: null,
428
+ armWatchdog: timeoutMs > 0
429
+ ? () => {
430
+ if (pending.watchdog)
431
+ return;
432
+ // The in-distro timeout does the killing; this only catches an
433
+ // agent that accepted the request and then stopped answering
434
+ // even to its own timer.
435
+ pending.watchdog = setTimeout(() => {
436
+ this.lastError = `agent did not answer request ${pending.id} within ${timeoutMs + WATCHDOG_GRACE_MS}ms of its dispatch`;
437
+ this.process?.kill();
438
+ }, timeoutMs + WATCHDOG_GRACE_MS);
439
+ }
440
+ : null,
441
+ };
442
+ this.pending.set(id, pending);
443
+ if (signal) {
444
+ if (signal.aborted) {
445
+ this.pending.delete(id);
446
+ reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
447
+ return;
448
+ }
449
+ 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.
454
+ this.writeLines([encodeKill(pending.id)]);
455
+ this.clearPending(pending);
456
+ this.pending.delete(pending.id);
457
+ reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
458
+ };
459
+ signal.addEventListener("abort", pending.onAbort, { once: true });
306
460
  }
307
- clearTimeout(handshakeTimer);
308
- settleStart();
309
- break;
310
- }
311
- case "ack": {
312
- // Dequeue signal: start this request's own budget clock NOW. Armed
313
- // here instead of at submission, a queued request can no longer time
314
- // out on a queue it never chose to join — and its watchdog can never
315
- // kill the shared transport for a stall that was never its own.
316
- const pending = this.pending.get(message.id);
317
- pending?.armWatchdog?.();
318
- break;
319
- }
320
- case "result": {
321
- const pending = this.pending.get(message.id);
322
- if (!pending) break; // answered after its caller gave up — noise
323
- this.clearPending(pending);
324
- this.pending.delete(message.id);
325
- this.rearmIdleTimer();
326
- pending.resolve({ exitCode: message.exitCode, stdout: message.stdout, stderr: message.stderr, truncated: message.truncated });
327
- break;
328
- }
329
- case "agentError": {
330
- const pending = this.pending.get(message.id);
331
- if (!pending) break;
332
- this.clearPending(pending);
333
- this.pending.delete(message.id);
334
- this.rearmIdleTimer();
335
- // A cwd rejection is the command's own failure, not the agent's — it
336
- // becomes a CwdError so the execution layer can report it exactly like
337
- // the one-shot path's relay text.
338
- if (message.reason === "cwd") {
339
- pending.reject(new CwdError(message.message));
340
- } else {
341
- pending.reject(new AgentUnavailableError(`agent rejected request ${message.id} (${message.reason}): ${message.message}`));
461
+ // A frame that cannot be WRITTEN must fail its caller now. Silently
462
+ // dropping it used to strand the request: nothing would ever answer, the
463
+ // watchdog (armed on an ACK that will not come) had no process to kill,
464
+ // and with no timeout the caller waited forever.
465
+ if (!this.writeLines(lines)) {
466
+ this.clearPending(pending);
467
+ this.pending.delete(pending.id);
468
+ reject(new AgentUnavailableError("the agent exited before the request could be sent; it never ran"));
469
+ return;
342
470
  }
343
- break;
344
- }
345
- default:
346
- // Garbage after the handshake is logged and dropped: a late RES for an
347
- // abandoned request is expected noise, not a broken pipe.
348
- break;
349
- }
350
- }
351
-
352
- /**
353
- * The process died. Requests in flight ride exactly one rebuild; a second
354
- * death is permanent.
355
- * @private
356
- */
357
- handleProcessExit(code, signalName) {
358
- this.process = null;
359
- if (this.pending.size === 0) {
360
- if (this.state !== "dead") this.state = "idle";
361
- return;
471
+ this.rearmIdleTimer();
362
472
  }
363
- if (!this.rebuildUsed) {
364
- this.rebuildUsed = true;
365
- this.lastError = `agent exited unexpectedly (code=${code} signal=${signalName ?? "none"}); rebuilding once`;
366
- this.state = "idle";
367
- this.startPromise = null;
368
- const carrying = [...this.pending.values()];
369
- this.pending.clear();
370
- // Only provably read-only FS requests ride the rebuild: whatever their
371
- // first execution did, it changed nothing. A command or a write that was
372
- // in flight has UNKNOWN state — replaying it would run non-idempotent
373
- // work a second time, or report a guarded create that actually
374
- // succeeded as a refusal — so it fails with that fact instead. A stale
375
- // watchdog must not survive either: its closure kills `this.process`,
376
- // which after the rebuild is the NEW transport.
377
- const relayable = [];
378
- for (const pending of carrying) {
379
- if (pending.signal?.aborted) {
380
- // Already rejected to its caller by the abort listener; do not
381
- // resurrect it on the rebuilt agent.
382
- this.clearPending(pending);
383
- continue;
473
+ /**
474
+ * Run one command inside the distro through the agent.
475
+ *
476
+ * @param request - the execution request.
477
+ * @returns the settled result.
478
+ * @throws {AgentUnavailableError} when the agent is out for this process.
479
+ */
480
+ async exec({ cwd, argv, timeoutMs = 0, maxOutputBytes = 0, env, signal }) {
481
+ if (this.state === "dead") {
482
+ throw new AgentUnavailableError(this.lastError);
384
483
  }
385
- if (isReplayableFrame(pending.lines)) {
386
- if (pending.watchdog) {
387
- clearTimeout(pending.watchdog);
388
- pending.watchdog = null;
389
- }
390
- relayable.push(pending);
391
- } else {
392
- this.clearPending(pending);
393
- pending.reject(new AgentUnavailableError(
394
- `agent exited while request ${pending.id} was in flight; its state is unknown — inspect the distro before retrying`,
395
- ));
484
+ await this.start();
485
+ const id = `r${this.nextRequestId++}`;
486
+ const lines = [
487
+ ...(env ? Object.entries(env).map(([key, value]) => encodeSetEnv(key, value)) : []),
488
+ ...encodeExecFrame({ id, cwd, argv, timeoutMs, maxOutputBytes }),
489
+ ];
490
+ return new Promise((resolve, reject) => {
491
+ this.track({ id, lines, resolve, reject, timeoutMs, signal });
492
+ });
493
+ }
494
+ /**
495
+ * Run one filesystem-substrate op inside the distro through the agent.
496
+ * Responses reuse the EXEC `ACK`/`RES` lines, so cancellation and the
497
+ * watchdog are exactly the command path's. The rebuild relay differs by op:
498
+ * a read-only op rides a rebuild, a `write` in flight fails with an
499
+ * unknown-state error instead of being replayed.
500
+ *
501
+ * @param request - the filesystem request.
502
+ * @returns the settled result.
503
+ * @throws {AgentUnavailableError} when the agent is out for this process.
504
+ */
505
+ async fs({ op, args, timeoutMs = 0, signal }) {
506
+ if (this.state === "dead") {
507
+ throw new AgentUnavailableError(this.lastError);
396
508
  }
397
- }
398
- this.start()
399
- .then(() => {
400
- for (const pending of relayable) {
401
- const id = `r${this.nextRequestId++}`;
402
- pending.id = id;
403
- this.pending.set(id, pending);
404
- // The rebuilt transport can itself be gone by now; a frame that
405
- // cannot be written must fail its caller, not strand the entry.
406
- if (!this.writeLines(relabelFrame(pending.lines, id))) {
407
- this.clearPending(pending);
408
- this.pending.delete(id);
409
- pending.reject(new AgentUnavailableError(`agent exited before request ${id} could be re-sent; its state is unknown`));
410
- }
411
- }
412
- })
413
- .catch((error) => {
414
- for (const pending of relayable) {
415
- this.clearPending(pending);
416
- pending.reject(error);
417
- }
418
- this.state = "dead";
509
+ await this.start();
510
+ const id = `r${this.nextRequestId++}`;
511
+ const lines = encodeFsFrame({ id, op, args, timeoutMs });
512
+ return new Promise((resolve, reject) => {
513
+ this.track({ id, lines, resolve, reject, timeoutMs, signal });
419
514
  });
420
- return;
421
515
  }
422
- this.lastError = `agent exited again (code=${code} signal=${signalName ?? "none"}); falling back permanently`;
423
- this.state = "dead";
424
- for (const pending of this.pending.values()) {
425
- this.clearPending(pending);
426
- pending.reject(new AgentUnavailableError(this.lastError));
516
+ /**
517
+ * Liveness probe, also used to warm the agent up.
518
+ *
519
+ * @param signal - cancels the wait.
520
+ * @returns resolved on PONG.
521
+ */
522
+ async ping(signal) {
523
+ if (this.state === "dead")
524
+ throw new AgentUnavailableError(this.lastError);
525
+ await this.start();
526
+ if (signal?.aborted)
527
+ throw signal.reason instanceof Error ? signal.reason : new Error("aborted");
528
+ return new Promise((resolve, reject) => {
529
+ const id = `r${this.nextRequestId++}`;
530
+ const lines = encodeExecFrame({ id, cwd: "/", argv: ["true"], timeoutMs: 0 });
531
+ this.track({ id, lines, resolve: () => resolve(), reject, timeoutMs: HANDSHAKE_TIMEOUT_MS, signal });
532
+ });
427
533
  }
428
- this.pending.clear();
429
- }
430
-
431
- /**
432
- * Register one request's pending entry and write its frame: the shared
433
- * request machinery (abort, submission, the ACK-armed watchdog). The
434
- * watchdog is deliberately NOT armed here — it starts at the agent's
435
- * dequeue `ACK`, so time spent queued behind an earlier request never
436
- * spends this request's budget and never kills the shared transport for a
437
- * stall that was not its own.
438
- * @private
439
- */
440
- track({ id, lines, resolve, reject, timeoutMs, signal }) {
441
- const pending = {
442
- id,
443
- lines,
444
- resolve,
445
- reject,
446
- watchdog: null,
447
- signal: signal ?? null,
448
- onAbort: null,
449
- armWatchdog:
450
- timeoutMs > 0
451
- ? () => {
452
- if (pending.watchdog) return;
453
- // The in-distro timeout does the killing; this only catches an
454
- // agent that accepted the request and then stopped answering
455
- // even to its own timer.
456
- pending.watchdog = setTimeout(() => {
457
- this.lastError = `agent did not answer request ${pending.id} within ${timeoutMs + WATCHDOG_GRACE_MS}ms of its dispatch`;
458
- this.process?.kill();
459
- }, timeoutMs + WATCHDOG_GRACE_MS);
460
- }
461
- : null,
462
- };
463
- this.pending.set(id, pending);
464
- if (signal) {
465
- if (signal.aborted) {
466
- this.pending.delete(id);
467
- reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
468
- return;
469
- }
470
- pending.onAbort = () => {
471
- // Address by the entry's CURRENT id: a request relayed across a
472
- // rebuild was re-registered under a fresh one, and a stale KILL (or a
473
- // stale map delete) would strand the entry and leave the command
474
- // running to its own timeout.
475
- this.writeLines([encodeKill(pending.id)]);
476
- this.clearPending(pending);
477
- this.pending.delete(pending.id);
478
- reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
479
- };
480
- signal.addEventListener("abort", pending.onAbort, { once: true });
534
+ /**
535
+ * Shut the agent down: SHUTDOWN first, then kill after a short grace. The
536
+ * agent becomes reusable — a later `exec` starts a fresh process.
537
+ *
538
+ * @returns resolved once the process is gone.
539
+ */
540
+ async close() {
541
+ if (this.idleTimer)
542
+ clearTimeout(this.idleTimer);
543
+ this.idleTimer = null;
544
+ const child = this.process;
545
+ if (!child)
546
+ return;
547
+ this.state = "idle";
548
+ this.process = null;
549
+ this.writeLinesSafe(child, ["SHUTDOWN"]);
550
+ await new Promise((resolve) => {
551
+ const timer = setTimeout(() => {
552
+ child.kill("SIGKILL");
553
+ resolve();
554
+ }, 1000);
555
+ child.once("exit", () => {
556
+ clearTimeout(timer);
557
+ resolve();
558
+ });
559
+ });
481
560
  }
482
- // A frame that cannot be WRITTEN must fail its caller now. Silently
483
- // dropping it used to strand the request: nothing would ever answer, the
484
- // watchdog (armed on an ACK that will not come) had no process to kill,
485
- // and with no timeout the caller waited forever.
486
- if (!this.writeLines(lines)) {
487
- this.clearPending(pending);
488
- this.pending.delete(pending.id);
489
- reject(new AgentUnavailableError("the agent exited before the request could be sent; it never ran"));
490
- return;
561
+ /**
562
+ * Write one frame to the live process.
563
+ *
564
+ * @param lines - the frame lines to write.
565
+ * @returns whether the frame was written.
566
+ */
567
+ writeLines(lines) {
568
+ const child = this.process;
569
+ if (!child || !child.stdin || !child.stdin.writable)
570
+ return false;
571
+ child.stdin.write(`${lines.join("\n")}\n`);
572
+ this.rearmIdleTimer();
573
+ return true;
491
574
  }
492
- this.rearmIdleTimer();
493
- }
494
-
495
- /**
496
- * Run one command inside the distro through the agent.
497
- * @param {object} request - the execution request.
498
- * @param {string} request.cwd - Linux path to run in.
499
- * @param {string[]} request.argv - the argv, already bwrap-wrapped when the
500
- * caller confines.
501
- * @param {number} [request.timeoutMs] - in-distro timeout, whole seconds
502
- * upward, measured from the agent's dequeue ACK; `<= 0` or omitted means
503
- * none. Queue time ahead of the dispatch is never counted.
504
- * @param {Record<string, string>} [request.env] - extra environment for this
505
- * request only; values lose trailing newlines (protocol limitation).
506
- * @param {number} [request.maxOutputBytes] - per-stream capture ceiling the
507
- * agent enforces (carried on the EXEC frame); the RES line reports which
508
- * streams were cut. 0 (the default) keeps the unbounded capture.
509
- * @param {AbortSignal} [request.signal] - cancels the request (KILL + reject).
510
- * @returns {Promise<{exitCode: number, stdout: Buffer, stderr: Buffer, truncated: {stdout: boolean, stderr: boolean}}>}
511
- * @throws {AgentUnavailableError} when the agent is out for this process.
512
- */
513
- async exec({ cwd, argv, timeoutMs = 0, maxOutputBytes = 0, env, signal }) {
514
- if (this.state === "dead") {
515
- throw new AgentUnavailableError(this.lastError);
575
+ /**
576
+ * Write one frame to a specific process, tolerating a dead pipe.
577
+ *
578
+ * @param child - the process being shut down.
579
+ * @param lines - the lines to write.
580
+ */
581
+ writeLinesSafe(child, lines) {
582
+ // The writable check matters beyond politeness: a write to an ENDED stdin
583
+ // does not throw here — it emits an async 'error' event the caller never
584
+ // listens for, which crashes the process during its own shutdown.
585
+ if (!child.stdin || !child.stdin.writable)
586
+ return;
587
+ try {
588
+ child.stdin.write(`${lines.join("\n")}\n`);
589
+ }
590
+ catch {
591
+ // A dead pipe is what close() is cleaning up after.
592
+ }
516
593
  }
517
- await this.start();
518
- const id = `r${this.nextRequestId++}`;
519
- const lines = [
520
- ...(env ? Object.entries(env).map(([key, value]) => encodeSetEnv(key, value)) : []),
521
- ...encodeExecFrame({ id, cwd, argv, timeoutMs, maxOutputBytes }),
522
- ];
523
- return new Promise((resolve, reject) => {
524
- this.track({ id, lines, resolve, reject, timeoutMs, signal });
525
- });
526
- }
527
-
528
- /**
529
- * Run one filesystem-substrate op inside the distro through the agent.
530
- * Responses reuse the EXEC `ACK`/`RES` lines, so cancellation and the
531
- * watchdog are exactly the command path's. The rebuild relay differs by op:
532
- * a read-only op rides a rebuild, a `write` in flight fails with an
533
- * unknown-state error instead of being replayed.
534
- * @param {object} request - the filesystem request.
535
- * @param {string} request.op - op name (`stat`, `lstat`, `list`, `realpath`,
536
- * `read`, `write`), dispatched by `agent/wsl-agent.sh`.
537
- * @param {string[]} request.args - op arguments; each rides one base64 line.
538
- * @param {number} [request.timeoutMs] - in-distro timeout, whole seconds
539
- * upward, measured from the agent's dequeue ACK; `<= 0` or omitted means
540
- * none.
541
- * @param {AbortSignal} [request.signal] - cancels the request (KILL + reject).
542
- * @returns {Promise<{exitCode: number, stdout: Buffer, stderr: Buffer}>}
543
- * @throws {AgentUnavailableError} when the agent is out for this process.
544
- */
545
- async fs({ op, args, timeoutMs = 0, signal }) {
546
- if (this.state === "dead") {
547
- throw new AgentUnavailableError(this.lastError);
594
+ /** Restart the idle-shutdown countdown. */
595
+ rearmIdleTimer() {
596
+ if (!this.idleMs)
597
+ return;
598
+ if (this.idleTimer)
599
+ clearTimeout(this.idleTimer);
600
+ this.idleTimer = setTimeout(() => {
601
+ this.idleTimer = null;
602
+ if (this.pending.size === 0)
603
+ void this.close();
604
+ }, this.idleMs);
548
605
  }
549
- await this.start();
550
- const id = `r${this.nextRequestId++}`;
551
- const lines = encodeFsFrame({ id, op, args, timeoutMs });
552
- return new Promise((resolve, reject) => {
553
- this.track({ id, lines, resolve, reject, timeoutMs, signal });
554
- });
555
- }
556
-
557
- /**
558
- * Liveness probe, also used to warm the agent up.
559
- * @param {AbortSignal} [signal] - cancels the wait.
560
- * @returns {Promise<void>} resolved on PONG.
561
- */
562
- async ping(signal) {
563
- if (this.state === "dead") throw new AgentUnavailableError(this.lastError);
564
- await this.start();
565
- if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error("aborted");
566
- return new Promise((resolve, reject) => {
567
- const id = `r${this.nextRequestId++}`;
568
- const lines = encodeExecFrame({ id, cwd: "/", argv: ["true"], timeoutMs: 0 });
569
- this.track({ id, lines, resolve: () => resolve(), reject, timeoutMs: HANDSHAKE_TIMEOUT_MS, signal });
570
- });
571
- }
572
-
573
- /**
574
- * Shut the agent down: SHUTDOWN first, then kill after a short grace. The
575
- * agent becomes reusable — a later `exec` starts a fresh process.
576
- * @returns {Promise<void>}
577
- */
578
- async close() {
579
- if (this.idleTimer) clearTimeout(this.idleTimer);
580
- this.idleTimer = null;
581
- const child = this.process;
582
- if (!child) return;
583
- this.state = "idle";
584
- this.process = null;
585
- this.writeLinesSafe(child, ["SHUTDOWN"]);
586
- await new Promise((resolve) => {
587
- const timer = setTimeout(() => {
588
- child.kill("SIGKILL");
589
- resolve();
590
- }, 1000);
591
- child.once("exit", () => {
592
- clearTimeout(timer);
593
- resolve();
594
- });
595
- });
596
- }
597
-
598
- /** @private */
599
- writeLines(lines) {
600
- const child = this.process;
601
- if (!child || !child.stdin.writable) return false;
602
- child.stdin.write(`${lines.join("\n")}\n`);
603
- this.rearmIdleTimer();
604
- return true;
605
- }
606
-
607
- /** @private */
608
- writeLinesSafe(child, lines) {
609
- // The writable check matters beyond politeness: a write to an ENDED stdin
610
- // does not throw here — it emits an async 'error' event the caller never
611
- // listens for, which crashes the process during its own shutdown.
612
- if (!child.stdin || !child.stdin.writable) return;
613
- try {
614
- child.stdin.write(`${lines.join("\n")}\n`);
615
- } catch {
616
- // A dead pipe is what close() is cleaning up after.
606
+ /**
607
+ * Detach an entry's timer and abort listener.
608
+ *
609
+ * @param pending - the entry to clear.
610
+ */
611
+ clearPending(pending) {
612
+ if (pending.watchdog)
613
+ clearTimeout(pending.watchdog);
614
+ if (pending.signal && pending.onAbort)
615
+ pending.signal.removeEventListener("abort", pending.onAbort);
617
616
  }
618
- }
619
-
620
- /** @private */
621
- rearmIdleTimer() {
622
- if (!this.idleMs) return;
623
- if (this.idleTimer) clearTimeout(this.idleTimer);
624
- this.idleTimer = setTimeout(() => {
625
- this.idleTimer = null;
626
- if (this.pending.size === 0) void this.close();
627
- }, this.idleMs);
628
- }
629
-
630
- /** @private */
631
- clearPending(pending) {
632
- if (pending.watchdog) clearTimeout(pending.watchdog);
633
- if (pending.signal && pending.onAbort) pending.signal.removeEventListener("abort", pending.onAbort);
634
- }
635
617
  }
636
-
637
618
  /** The read-only FS ops: replaying them across a rebuild changes nothing. */
638
619
  const REPLAYABLE_FS_OPS = new Set(["stat", "lstat", "list", "realpath", "read"]);
639
-
640
620
  /**
641
621
  * Whether a stored request frame's first execution provably had no side
642
622
  * effects, so the frame may ride the one permitted rebuild. An EXEC never
643
623
  * qualifies — a command's effects are unknowable after the agent died — and
644
624
  * neither does an FS `write`. (A SETENV prefix line never decides: the frame
645
625
  * header sits at `lines[0]` only for FS frames, which carry no SETENV.)
646
- * @param {string[]} lines - the stored frame.
647
- * @returns {boolean}
626
+ *
627
+ * @param lines - the stored frame.
628
+ * @returns true when the frame may be replayed.
648
629
  */
649
630
  function isReplayableFrame(lines) {
650
- const header = lines[0] ?? "";
651
- if (!header.startsWith("FS|")) return false;
652
- return REPLAYABLE_FS_OPS.has(header.split("|")[2]);
631
+ const header = lines[0] ?? "";
632
+ if (!header.startsWith("FS|"))
633
+ return false;
634
+ const op = header.split("|")[2];
635
+ return op !== undefined && REPLAYABLE_FS_OPS.has(op);
653
636
  }
654
-
655
637
  /**
656
638
  * Rewrite the request id inside a stored frame so a resent request does not
657
639
  * collide with ids the new process has already seen. SETENV lines pass
658
640
  * through unchanged (they are id-less and idempotent).
659
- * @param {string[]} lines - the stored frame.
660
- * @param {string} newId - the new request id.
661
- * @returns {string[]} the relabelled frame.
641
+ *
642
+ * @param lines - the stored frame.
643
+ * @param newId - the new request id.
644
+ * @returns the relabelled frame.
662
645
  */
663
646
  export function relabelFrame(lines, newId) {
664
- return lines.map((line) => {
665
- const separator = line.indexOf("|");
666
- if (separator === -1) return line;
667
- const kind = line.slice(0, separator);
668
- if (kind !== "EXEC" && kind !== "FS" && kind !== "KILL") return line;
669
- const rest = line.slice(separator + 1);
670
- const pipe = rest.indexOf("|");
671
- const tail = pipe === -1 ? "" : rest.slice(pipe);
672
- return `${kind}|${newId}${tail}`;
673
- });
647
+ return lines.map((line) => {
648
+ const separator = line.indexOf("|");
649
+ if (separator === -1)
650
+ return line;
651
+ const kind = line.slice(0, separator);
652
+ if (kind !== "EXEC" && kind !== "FS" && kind !== "KILL")
653
+ return line;
654
+ const rest = line.slice(separator + 1);
655
+ const pipe = rest.indexOf("|");
656
+ const tail = pipe === -1 ? "" : rest.slice(pipe);
657
+ return `${kind}|${newId}${tail}`;
658
+ });
674
659
  }
675
-
676
660
  export default WslAgent;