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.
- package/CHANGELOG.md +345 -8
- package/CONTRIBUTING.md +221 -68
- package/README.md +50 -35
- package/SECURITY.md +18 -2
- package/SUPPORT.md +23 -7
- package/cordis.patch.yml +9 -0
- package/docs/ARCHITECTURE.md +200 -57
- package/docs/CONFIGURATION.md +180 -16
- package/docs/LIMITATIONS.md +91 -33
- package/docs/PARITY.md +66 -38
- package/docs/PEER-PARITY.md +95 -32
- package/docs/README.zh.md +49 -31
- package/docs/UPSTREAM-FSIO-EXPORT.md +159 -50
- package/docs/UPSTREAM-TERMINAL-TITLE.md +49 -14
- package/lib/agent-confined.d.ts +42 -0
- package/lib/agent-confined.js +25 -41
- package/lib/agent-errors.d.ts +31 -0
- package/lib/agent-errors.js +19 -16
- package/lib/agent-exec.d.ts +159 -0
- package/lib/agent-exec.js +151 -165
- package/lib/agent-protocol.d.ts +192 -0
- package/lib/agent-protocol.js +76 -100
- package/lib/agent-shared.d.ts +27 -0
- package/lib/agent-shared.js +12 -12
- package/lib/agent.d.ts +353 -0
- package/lib/agent.js +536 -552
- package/lib/auto-preset.d.ts +110 -0
- package/lib/auto-preset.js +157 -141
- package/lib/bwrap.d.ts +90 -0
- package/lib/bwrap.js +18 -18
- package/lib/file-reference-wsl.d.ts +160 -0
- package/lib/file-reference-wsl.js +192 -173
- package/lib/fs-decisions.d.ts +107 -0
- package/lib/fs-decisions.js +64 -60
- package/lib/fs-routing.d.ts +182 -0
- package/lib/fs-routing.js +173 -116
- package/lib/fs-substrate.d.ts +258 -0
- package/lib/fs-substrate.js +248 -248
- package/lib/fs.d.ts +10 -0
- package/lib/fs.js +5 -0
- package/lib/fsio-agent.d.ts +307 -0
- package/lib/fsio-agent.js +414 -410
- package/lib/fsio-text.d.ts +115 -0
- package/lib/fsio-text.js +104 -84
- package/lib/git-route.d.ts +100 -0
- package/lib/git-route.js +46 -47
- package/lib/index.d.ts +1003 -0
- package/lib/index.js +1106 -1003
- package/lib/listing.d.ts +114 -0
- package/lib/listing.js +68 -55
- package/lib/paths.d.ts +182 -0
- package/lib/paths.js +134 -109
- package/lib/picker.d.ts +266 -0
- package/lib/picker.js +371 -351
- package/lib/ports.d.ts +44 -0
- package/lib/ports.js +39 -29
- package/lib/preflight.d.ts +91 -0
- package/lib/preflight.js +94 -0
- package/lib/preset-choice.d.ts +33 -0
- package/lib/preset-choice.js +15 -7
- package/lib/probe-cache.d.ts +51 -0
- package/lib/probe-cache.js +43 -29
- package/lib/sandbox-core.d.ts +149 -0
- package/lib/sandbox-core.js +239 -87
- package/lib/sandbox.d.ts +48 -0
- package/lib/sandbox.js +4 -4
- package/lib/search-exec.d.ts +96 -0
- package/lib/search-exec.js +52 -79
- package/lib/search-route.d.ts +75 -0
- package/lib/search-route.js +30 -31
- package/lib/shell-env.d.ts +88 -0
- package/lib/shell-env.js +78 -74
- package/lib/shell.d.ts +10 -0
- package/lib/shell.js +5 -0
- package/lib/subprocess.d.ts +313 -0
- package/lib/subprocess.js +267 -262
- package/lib/terminal-activity.d.ts +98 -0
- package/lib/terminal-activity.js +29 -39
- package/lib/terminal-route.d.ts +27 -0
- package/lib/terminal-route.js +16 -8
- package/lib/watcher.d.ts +77 -0
- package/lib/watcher.js +120 -126
- package/lib/workspace-files-route.d.ts +132 -0
- package/lib/workspace-files-route.js +73 -81
- package/lib/workspace-files-wsl.d.ts +257 -0
- package/lib/workspace-files-wsl.js +311 -251
- package/lib/wsl.d.ts +266 -0
- package/lib/wsl.js +150 -155
- package/locale/preflight/en.json +6 -0
- package/locale/preflight/zh.json +6 -0
- package/package.json +78 -22
- 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
|
|
77
|
-
* @returns
|
|
73
|
+
* @param parent - this process's environment.
|
|
74
|
+
* @returns the pinned environment for `wsl.exe`.
|
|
78
75
|
*/
|
|
79
76
|
export function pinnedWindowsEnv(parent) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
*
|
|
116
|
-
* @param
|
|
117
|
-
* @
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
this.
|
|
265
|
-
|
|
266
|
-
this.
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
*
|
|
647
|
-
* @
|
|
626
|
+
*
|
|
627
|
+
* @param lines - the stored frame.
|
|
628
|
+
* @returns true when the frame may be replayed.
|
|
648
629
|
*/
|
|
649
630
|
function isReplayableFrame(lines) {
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
*
|
|
660
|
-
* @param
|
|
661
|
-
* @
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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;
|