dsh-plugin-wsl-env 0.5.0 → 0.7.2
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 +256 -0
- package/CONTRIBUTING.md +4 -0
- package/README.md +9 -6
- package/agent/wsl-agent.sh +41 -8
- package/cordis.patch.yml +48 -0
- package/docs/ARCHITECTURE.md +36 -22
- package/docs/CONFIGURATION.md +3 -2
- package/docs/LIMITATIONS.md +52 -54
- package/docs/PARITY.md +18 -0
- package/docs/README.zh.md +10 -7
- package/lib/fs-decisions.js +22 -25
- package/lib/fs-routing.js +176 -0
- package/lib/git-route.js +142 -0
- package/lib/index.js +92 -306
- package/lib/listing.js +47 -0
- package/lib/picker.js +121 -17
- package/lib/sandbox-core.js +157 -0
- package/lib/sandbox.js +11 -131
- package/lib/search-exec.js +114 -0
- package/lib/search-route.js +95 -0
- package/lib/subprocess.js +153 -16
- package/lib/terminal-activity.js +121 -0
- package/lib/watcher.js +63 -12
- package/lib/workspace-files-route.js +205 -0
- package/lib/workspace-files-wsl.js +341 -0
- package/lib/wsl.js +24 -41
- package/package.json +23 -3
- package/lib/fs-publish.js +0 -42
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which execution world a file-search spawn belongs to, and the `wsl.exe`
|
|
3
|
+
* argv it becomes there.
|
|
4
|
+
*
|
|
5
|
+
* `dsh-tool-fs-search` spawns the packaged ripgrep binary through
|
|
6
|
+
* `ctx.subprocess` with the session workspace as the working directory. Left
|
|
7
|
+
* alone, that spawn runs the Windows binary over the distro's 9p share — the
|
|
8
|
+
* one model-facing I/O path that still crossed the share, and in the slow
|
|
9
|
+
* direction: measured at ~17 ms per file of metadata round trips where the
|
|
10
|
+
* distro's own ext4 answers in microseconds. The interception point is the
|
|
11
|
+
* subprocess provider's `spawn`, and the decision lives here — pure,
|
|
12
|
+
* peer-free, and unit-testable, mirroring `terminal-route.js`.
|
|
13
|
+
*
|
|
14
|
+
* The rewrite is deliberately narrow. A spawn is intercepted only when the
|
|
15
|
+
* working directory NAMES the distro outright — a `\\wsl.localhost\<distro>`
|
|
16
|
+
* UNC — because that is the form a distro session's search carries and the
|
|
17
|
+
* only one that resolves without an async distro query: `spawn` hands back
|
|
18
|
+
* its handle synchronously, so the override must stay synchronous. A Windows
|
|
19
|
+
* drive directory keeps the host binary, which is native there and touches
|
|
20
|
+
* no share; anything else passes through untouched, exactly as before.
|
|
21
|
+
*
|
|
22
|
+
* The rewritten argv forwards every rg argument verbatim, so the tool reads
|
|
23
|
+
* the output format and exit codes of the distro's own rg. One addition is
|
|
24
|
+
* made: when the tool named no search path — its pattern rides `--regexp=`,
|
|
25
|
+
* and a bare `--` separator is exactly how it spells an explicit path — the
|
|
26
|
+
* rewrite appends `-- .`. Reason: `wsl.exe --exec` hands the distro-side rg a
|
|
27
|
+
* relay FIFO for stdin, and rg's readable-stdin heuristic then searches stdin
|
|
28
|
+
* (instantly empty) instead of walking the working directory; the packaged
|
|
29
|
+
* binary under the old path saw a non-readable NUL stdin and walked it. An
|
|
30
|
+
* explicit path makes the search deterministic on either transport.
|
|
31
|
+
*
|
|
32
|
+
* The distro-side binary must therefore exist — `scripts/bootstrap.sh` reports
|
|
33
|
+
* it — and a distro without one surfaces rg's own "command not found" instead
|
|
34
|
+
* of a silent fall-back to the share.
|
|
35
|
+
*
|
|
36
|
+
* @module dsh-plugin-wsl/search-route
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { uncToPosix } from "./paths.js";
|
|
40
|
+
|
|
41
|
+
/** The one program this rewrite passes through to the distro. */
|
|
42
|
+
const SEARCH_PROGRAM = "rg";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whether a spawn's program is the ripgrep binary.
|
|
46
|
+
*
|
|
47
|
+
* A bare `argv[0]` is judged by its own name, so the packaged binary's
|
|
48
|
+
* absolute path and any other spelling of `rg` match alike.
|
|
49
|
+
*
|
|
50
|
+
* @param unknown - the spawn's `argv[0]`.
|
|
51
|
+
* @returns true when the spawn is a ripgrep launch.
|
|
52
|
+
*/
|
|
53
|
+
export function isSearchProgram(program) {
|
|
54
|
+
if (typeof program !== "string" || program.length === 0) return false;
|
|
55
|
+
const base = program.slice(Math.max(program.lastIndexOf("/"), program.lastIndexOf("\\")) + 1);
|
|
56
|
+
return base.toLowerCase().replace(/\.exe$/u, "") === SEARCH_PROGRAM;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The distro-side rewrite of one search spawn, when it belongs in the distro.
|
|
61
|
+
*
|
|
62
|
+
* Two argv forms come back for one decision. `rgArgv` is the distro command
|
|
63
|
+
* itself (`rg` and its arguments) — what the resident agent execs. `wslArgv`
|
|
64
|
+
* is the same command wrapped for a one-shot `wsl.exe --exec` launch — the
|
|
65
|
+
* fallback transport for when the agent is out. The working directory becomes
|
|
66
|
+
* the `--cd` the distro starts in; `wsl.exe` itself must start in a Windows
|
|
67
|
+
* directory, so the caller supplies its own host cwd when applying `wslArgv`.
|
|
68
|
+
*
|
|
69
|
+
* @param object - the spawn's inputs.
|
|
70
|
+
* @param {unknown[]} object.argv - the spawn's argv; `argv[0]` is the program.
|
|
71
|
+
* @param {unknown} object.cwd - the spawn's working directory, any coordinate system.
|
|
72
|
+
* @param {string} object.wslPath - path to `wsl.exe`.
|
|
73
|
+
* @returns {{distro: string, linuxCwd: string, rgArgv: string[], wslArgv: string[]}|undefined} the
|
|
74
|
+
* rewrite, or undefined when the spawn is not a distro search and must run
|
|
75
|
+
* exactly as the shipped implementation would run it.
|
|
76
|
+
*/
|
|
77
|
+
export function searchSpawnRewrite({ argv, cwd, wslPath }) {
|
|
78
|
+
if (!Array.isArray(argv) || !isSearchProgram(argv[0])) return undefined;
|
|
79
|
+
const named = uncToPosix(cwd);
|
|
80
|
+
if (named === undefined) return undefined;
|
|
81
|
+
const linuxCwd = named.linuxPath.length > 0 ? named.linuxPath : "/";
|
|
82
|
+
// No bare `--` means the tool named no search path; without one, distro-side
|
|
83
|
+
// rg reads the relay fifo on stdin (rg's readable-stdin heuristic) instead of
|
|
84
|
+
// walking the directory. `.` is the working directory — the same directory
|
|
85
|
+
// the packaged binary walked when the host spawned it directly.
|
|
86
|
+
const forwarded = argv.slice(1);
|
|
87
|
+
const tool = forwarded.includes("--") ? forwarded : [...forwarded, "--", "."];
|
|
88
|
+
const rgArgv = [SEARCH_PROGRAM, ...tool];
|
|
89
|
+
return {
|
|
90
|
+
distro: named.distro,
|
|
91
|
+
linuxCwd,
|
|
92
|
+
rgArgv,
|
|
93
|
+
wslArgv: [wslPath, "-d", named.distro, "--cd", linuxCwd, "--exec", ...rgArgv],
|
|
94
|
+
};
|
|
95
|
+
}
|
package/lib/subprocess.js
CHANGED
|
@@ -42,16 +42,21 @@
|
|
|
42
42
|
* ## What this provider therefore changes
|
|
43
43
|
*
|
|
44
44
|
* It replaces the shipped `subprocess` row at the composition level and
|
|
45
|
-
* overrides
|
|
45
|
+
* overrides two methods. `spawnTerminal` rewrites a `wsl.exe` launch into a
|
|
46
46
|
* launch that actually names the distro and the Session workspace's Linux
|
|
47
|
-
* directory
|
|
48
|
-
*
|
|
49
|
-
*
|
|
47
|
+
* directory. `spawn` rewrites one further launch — the file-search tool's
|
|
48
|
+
* packaged ripgrep binary, which a distro workspace's UNC working directory
|
|
49
|
+
* names outright — into the distro's own rg: agent-first, one round trip on
|
|
50
|
+
* the warm resident, with the one-shot `wsl.exe --exec` handle as the
|
|
51
|
+
* documented fallback when the agent is out (`lib/search-route.js` holds the
|
|
52
|
+
* decision, `lib/search-exec.js` the handle). Every other request —
|
|
53
|
+
* including every other `spawn()`, which the command path uses for
|
|
54
|
+
* `wsl.exe -lc <command>` — reaches the shipped implementation untouched.
|
|
50
55
|
*
|
|
51
56
|
* The interpreter, the PTY, output handling and process-tree teardown stay the
|
|
52
57
|
* shipped implementation's, so the blast radius of replacing a root service
|
|
53
|
-
* stays at the
|
|
54
|
-
* starts in.
|
|
58
|
+
* stays at the two things that were wrong: the directory and distro a terminal
|
|
59
|
+
* starts in, and the filesystem a search walks.
|
|
55
60
|
*
|
|
56
61
|
* ## What the operator must pair it with
|
|
57
62
|
*
|
|
@@ -70,10 +75,16 @@
|
|
|
70
75
|
* @module dsh-plugin-wsl/subprocess
|
|
71
76
|
*/
|
|
72
77
|
|
|
78
|
+
import { randomUUID } from "node:crypto";
|
|
73
79
|
import z from "@deepseek-ai/schemastery";
|
|
74
80
|
import { LocalSubprocessRuntime } from "@deepseek-ai/dsh-subprocess-local";
|
|
75
81
|
import { isPosixAbsolute, toLinuxPath, uncToPosix, windowsToLinuxMount } from "./paths.js";
|
|
76
82
|
import { DEFAULT_WSL_PATH, defaultDistro, wslEnvValue, wslTerminalArgv } from "./wsl.js";
|
|
83
|
+
import { searchSpawnRewrite } from "./search-route.js";
|
|
84
|
+
import { searchExecutionHandle } from "./search-exec.js";
|
|
85
|
+
import { sharedAgent } from "./agent-shared.js";
|
|
86
|
+
import { parseTerminalActivity, TERMINAL_ID_ENV, terminalActivityProbe, wrapTerminalHandle } from "./terminal-activity.js";
|
|
87
|
+
import { AgentUnavailableError } from "./agent-errors.js";
|
|
77
88
|
import { terminalRoute } from "./terminal-route.js";
|
|
78
89
|
|
|
79
90
|
/**
|
|
@@ -128,6 +139,19 @@ export class WslSubprocessRuntime extends LocalSubprocessRuntime {
|
|
|
128
139
|
* the composition-owned terminal for every session.
|
|
129
140
|
*/
|
|
130
141
|
hostSessions: z.boolean().default(true),
|
|
142
|
+
/**
|
|
143
|
+
* Let the controller reclaim an idle distro terminal. The terminal is
|
|
144
|
+
* marked at launch (`DSH_TERMINAL_ID` through the existing `WSLENV`
|
|
145
|
+
* forwarding) and its handle's `inspectActivity` is replaced with a
|
|
146
|
+
* distro-side observation: the resident agent scans `/proc` for the
|
|
147
|
+
* marker — a shell with live children is busy, a shell without children
|
|
148
|
+
* is idle. The shipped observation can only answer `unknown` for a distro
|
|
149
|
+
* terminal, and `unknown` never accumulates idle, which is why these
|
|
150
|
+
* terminals had to be closed by hand. When the agent is out the
|
|
151
|
+
* observation answers `unknown` (no reclaim) — a probe that cannot answer
|
|
152
|
+
* must not authorize a close. `false` restores that posture.
|
|
153
|
+
*/
|
|
154
|
+
terminalIdleReclaim: z.boolean().default(true),
|
|
131
155
|
});
|
|
132
156
|
|
|
133
157
|
constructor(ctx, config) {
|
|
@@ -208,21 +232,134 @@ export class WslSubprocessRuntime extends LocalSubprocessRuntime {
|
|
|
208
232
|
linuxCwd: target.linuxCwd,
|
|
209
233
|
login: this.config.loginShell,
|
|
210
234
|
});
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
);
|
|
215
|
-
|
|
216
|
-
|
|
235
|
+
// The idle-reclaim mark: one env name per terminal, riding the WSLENV
|
|
236
|
+
// forwarding this launch already does. The distro shell and everything it
|
|
237
|
+
// spawns carry it in /proc/<pid>/environ — what the activity probe scans.
|
|
238
|
+
const terminalId = this.config.terminalIdleReclaim ? randomUUID() : undefined;
|
|
239
|
+
const specWithMark = terminalId === undefined ? spec : { ...spec, env: { ...spec.env, [TERMINAL_ID_ENV]: terminalId } };
|
|
240
|
+
const handle = await super.spawnTerminal({
|
|
241
|
+
...specWithMark,
|
|
217
242
|
argv,
|
|
218
243
|
// A Windows CreateProcess cannot use a Linux path as its working
|
|
219
244
|
// directory; the Linux directory rides on `--cd` instead.
|
|
220
245
|
cwd: this.hostCwd(),
|
|
221
|
-
env:
|
|
222
|
-
...spec.env,
|
|
223
|
-
...(imported.length > 0 ? { WSLENV: wslEnvValue(imported) } : {}),
|
|
224
|
-
},
|
|
246
|
+
env: this.forwardedEnv(specWithMark),
|
|
225
247
|
});
|
|
248
|
+
if (terminalId === undefined) return handle;
|
|
249
|
+
return wrapTerminalHandle(handle, this.distroActivity(target.distro, terminalId));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The replacement `inspectActivity` for one distro terminal: ask the
|
|
254
|
+
* resident agent whether the marked shell is at its prompt.
|
|
255
|
+
*
|
|
256
|
+
* The revision increments only when the reported state changes, so the
|
|
257
|
+
* controller's retention accumulates idle across consecutive `idle`
|
|
258
|
+
* observations and restarts the clock on every transition. Any probe
|
|
259
|
+
* failure — the agent out, the distro stopped, a script error — answers
|
|
260
|
+
* `unknown`, which the retention reads as "not idle": the probe that cannot
|
|
261
|
+
* answer never authorizes a close. The first unexpected (non-availability)
|
|
262
|
+
* failure is logged once, so a broken probe is diagnosable without
|
|
263
|
+
* spamming the log every poll.
|
|
264
|
+
*
|
|
265
|
+
* @param distro - the distro the terminal lives in.
|
|
266
|
+
* @param terminalId - the launch's `DSH_TERMINAL_ID` mark.
|
|
267
|
+
* @returns the seam's activity observation.
|
|
268
|
+
*/
|
|
269
|
+
distroActivity(distro, terminalId) {
|
|
270
|
+
let revision = 0;
|
|
271
|
+
let lastState = "";
|
|
272
|
+
let warned = false;
|
|
273
|
+
return async () => {
|
|
274
|
+
try {
|
|
275
|
+
const probe = terminalActivityProbe(terminalId);
|
|
276
|
+
const result = await sharedAgent(distro).exec({
|
|
277
|
+
cwd: probe.cwd,
|
|
278
|
+
argv: probe.argv,
|
|
279
|
+
maxOutputBytes: 256,
|
|
280
|
+
timeoutMs: 10_000,
|
|
281
|
+
});
|
|
282
|
+
const state = parseTerminalActivity(result.stdout) ?? "unknown";
|
|
283
|
+
if (state !== lastState) {
|
|
284
|
+
revision += 1;
|
|
285
|
+
lastState = state;
|
|
286
|
+
}
|
|
287
|
+
return { state, revision };
|
|
288
|
+
} catch (error) {
|
|
289
|
+
if (!(error instanceof AgentUnavailableError) && !warned) {
|
|
290
|
+
warned = true;
|
|
291
|
+
this.ctx.logger?.warn(`wsl-subprocess: the terminal activity probe failed and idle reclamation is paused (${String(error?.message ?? error)})`);
|
|
292
|
+
}
|
|
293
|
+
return { state: "unknown", revision };
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Open one host-side process, rewriting two kinds of launches into the
|
|
300
|
+
* distro: the file-search tool's ripgrep (agent-first, one-shot fallback)
|
|
301
|
+
* and git snapshot commands (one-shot only — snapshot operations are not
|
|
302
|
+
* latency-sensitive, and their stdin relay and collected streams are the
|
|
303
|
+
* shipped handle's). Both decisions are pure coordinate functions
|
|
304
|
+
* (`search-route.js`, `git-route.js`); a spawn neither names (any other
|
|
305
|
+
* program, any drive or relative directory) reaches the shipped
|
|
306
|
+
* implementation verbatim.
|
|
307
|
+
*
|
|
308
|
+
* @param spec - the spawn request.
|
|
309
|
+
* @returns the live search handle.
|
|
310
|
+
*/
|
|
311
|
+
spawn(spec) {
|
|
312
|
+
const gitRewrite = gitSpawnRewrite({ argv: spec.argv, cwd: spec.cwd, wslPath: this.config.wslPath });
|
|
313
|
+
if (gitRewrite !== undefined) {
|
|
314
|
+
// The snapshot environment (GIT_INDEX_FILE / GIT_OBJECT_DIRECTORY /
|
|
315
|
+
// GIT_ALTERNATE_OBJECT_DIRECTORIES and the git hygiene flags) is
|
|
316
|
+
// path-shaped in Windows form; the values are translated into distro
|
|
317
|
+
// paths and every forwarded name rides WSLENV. The user's real index,
|
|
318
|
+
// object store, work tree, and refs stay untouched by design — the
|
|
319
|
+
// snapshot runs in the tool's private scratch.
|
|
320
|
+
const { env, wslenv } = translateGitEnv(spec.env);
|
|
321
|
+
return super.spawn({
|
|
322
|
+
...spec,
|
|
323
|
+
argv: gitRewrite.wslArgv,
|
|
324
|
+
cwd: this.hostCwd(),
|
|
325
|
+
env: wslenv.length > 0 ? { ...env, WSLENV: wslEnvValue(wslenv) } : env,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
const rewrite = searchSpawnRewrite({ argv: spec.argv, cwd: spec.cwd, wslPath: this.config.wslPath });
|
|
329
|
+
if (rewrite === undefined) return super.spawn(spec);
|
|
330
|
+
return searchExecutionHandle({
|
|
331
|
+
agent: sharedAgent(rewrite.distro),
|
|
332
|
+
cwd: rewrite.linuxCwd,
|
|
333
|
+
argv: rewrite.rgArgv,
|
|
334
|
+
// One budget serves both streams; the stdout budget is the binding one,
|
|
335
|
+
// and a pathological stderr is still bounded by it.
|
|
336
|
+
maxOutputBytes: spec.stdio?.stdout?.maxBytes ?? 0,
|
|
337
|
+
signal: spec.signal,
|
|
338
|
+
spawnFallback: () =>
|
|
339
|
+
super.spawn({
|
|
340
|
+
...spec,
|
|
341
|
+
argv: rewrite.wslArgv,
|
|
342
|
+
// `wsl.exe --cd` carries the Linux directory; the host process itself
|
|
343
|
+
// must start in a Windows directory, as with a terminal launch.
|
|
344
|
+
cwd: this.hostCwd(),
|
|
345
|
+
env: this.forwardedEnv(spec),
|
|
346
|
+
}),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The environment one `wsl.exe` launch forwards: the safe list plus every
|
|
352
|
+
* managed `DSH_*` name, admitted through `WSLENV` and never `PATH` — see the
|
|
353
|
+
* WSLENV note in the recipes.
|
|
354
|
+
* @param spec - the launch request whose env is forwarded.
|
|
355
|
+
* @returns the environment the host launch carries.
|
|
356
|
+
*/
|
|
357
|
+
forwardedEnv(spec) {
|
|
358
|
+
const forward = new Set([...SAFE_FORWARD, ...this.config.forwardEnv]);
|
|
359
|
+
const imported = Object.keys(spec.env ?? {}).filter(
|
|
360
|
+
(name) => forward.has(name) || name.startsWith(DSH_ENV_PREFIX),
|
|
361
|
+
);
|
|
362
|
+
return imported.length > 0 ? { ...spec.env, WSLENV: wslEnvValue(imported) } : spec.env;
|
|
226
363
|
}
|
|
227
364
|
|
|
228
365
|
/**
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The distro-side activity observation for GUI distro terminals, and the
|
|
3
|
+
* handle wrapper that installs it.
|
|
4
|
+
*
|
|
5
|
+
* ## The gap this closes
|
|
6
|
+
*
|
|
7
|
+
* The terminal controller reclaims unattended terminals after
|
|
8
|
+
* `unattendedTimeoutMs` of observed idleness, but its observation is
|
|
9
|
+
* host-side: `dsh-subprocess-local`'s shell-activity integration is gated to
|
|
10
|
+
* interactive `bash`/`zsh` launched directly on a POSIX host, and a distro
|
|
11
|
+
* terminal is `wsl.exe` on win32. Every branch of the shipped
|
|
12
|
+
* `inspectActivity` therefore settles on `unknown` for these terminals, and
|
|
13
|
+
* `unknown` never accumulates idle — a distro terminal had to be closed by
|
|
14
|
+
* hand.
|
|
15
|
+
*
|
|
16
|
+
* ## The observation
|
|
17
|
+
*
|
|
18
|
+
* The terminal is MARKED at launch: a per-terminal `DSH_TERMINAL_ID` rides
|
|
19
|
+
* the `WSLENV` forwarding this provider already does, so the distro shell —
|
|
20
|
+
* and every process it spawns — carries it in `/proc/<pid>/environ`. The
|
|
21
|
+
* controller's 30 s retention poll then asks the resident agent to count the
|
|
22
|
+
* marked processes: exactly one (the shell alone, at its prompt) is IDLE;
|
|
23
|
+
* more than one (a command, a pipeline, a nested shell is running) is BUSY;
|
|
24
|
+
* none at all defers to the shipped handle (the shell has exited; its own
|
|
25
|
+
* exit bookkeeping is more precise than this scan). The agent being out
|
|
26
|
+
* yields `unknown` — the same never-reclaim posture as before, which is the
|
|
27
|
+
* safe direction: a probe that cannot answer must not authorize a close.
|
|
28
|
+
*
|
|
29
|
+
* Pure builders and a transparent proxy here; the agent wiring lives in the
|
|
30
|
+
* subprocess provider.
|
|
31
|
+
*
|
|
32
|
+
* @module dsh-plugin-wsl/terminal-activity
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** The env name that marks one terminal's process tree inside the distro. */
|
|
36
|
+
export const TERMINAL_ID_ENV = "DSH_TERMINAL_ID";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The seam's activity observation — structurally the upstream
|
|
40
|
+
* `SubprocessTerminalActivity` (`subprocess-local/src/terminal.ts`): `state`
|
|
41
|
+
* is `'idle' | 'busy' | 'unknown'` and `revision` is a counter that changes
|
|
42
|
+
* when the reported state changes, which is what the controller's retention
|
|
43
|
+
* loop keys its idle accumulation on.
|
|
44
|
+
*
|
|
45
|
+
* @typedef {Object} SubprocessTerminalActivity
|
|
46
|
+
* @property {'idle' | 'busy' | 'unknown'} state
|
|
47
|
+
* @property {number} revision
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The probe: count the `/proc` processes carrying the terminal marker.
|
|
52
|
+
*
|
|
53
|
+
* POSIX sh on purpose (the distro's `sh` is the only guaranteed interpreter).
|
|
54
|
+
* The count is the whole decision: the terminal's login shell is exec'd WITH
|
|
55
|
+
* the marker (wsl.exe imports it into the session before `--exec`), so an
|
|
56
|
+
* idle-at-prompt shell is exactly one marked process, and anything the shell
|
|
57
|
+
* runs — a command, a pipeline, a nested shell — is exec'd with the marker
|
|
58
|
+
* inherited, pushing the count past one. `/proc/<pid>/environ` shows the
|
|
59
|
+
* exec-time environment, which is precisely why the count works: runtime
|
|
60
|
+
* exports would be invisible, and exec-time inheritance is the signal.
|
|
61
|
+
*
|
|
62
|
+
* The marker check reads the NUL-separated environ through `tr`, which is why
|
|
63
|
+
* the id is passed as `$1` rather than interpolated into the script.
|
|
64
|
+
*
|
|
65
|
+
* @param terminalId - the `DSH_TERMINAL_ID` value one terminal was launched with.
|
|
66
|
+
* @returns the agent `exec` target: the probe script with the id as `$1`.
|
|
67
|
+
*/
|
|
68
|
+
export function terminalActivityProbe(terminalId) {
|
|
69
|
+
const script = [
|
|
70
|
+
"id=$1",
|
|
71
|
+
"count=0",
|
|
72
|
+
'for d in /proc/[0-9]*; do',
|
|
73
|
+
' [ -r "$d/environ" ] || continue',
|
|
74
|
+
' if tr "\\000" "\\n" < "$d/environ" 2>/dev/null | grep -qx "DSH_TERMINAL_ID=$id"; then',
|
|
75
|
+
" count=$((count+1))",
|
|
76
|
+
' [ "$count" -gt 1 ] && { echo busy; exit 0; }',
|
|
77
|
+
" fi",
|
|
78
|
+
"done",
|
|
79
|
+
"echo idle",
|
|
80
|
+
].join("\n");
|
|
81
|
+
return { cwd: "/", argv: ["sh", "-c", script, "sh", terminalId] };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Parse the probe's stdout into an activity state.
|
|
86
|
+
*
|
|
87
|
+
* @param stdout - the probe's raw stdout.
|
|
88
|
+
* @returns `"busy"` or `"idle"` as the probe reported, or undefined when the
|
|
89
|
+
* output is anything else (the caller answers `unknown`).
|
|
90
|
+
*/
|
|
91
|
+
export function parseTerminalActivity(stdout) {
|
|
92
|
+
const text = String(stdout).trim();
|
|
93
|
+
if (text === "busy" || text === "idle") return text;
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Wrap a terminal handle so its activity is observed inside the distro.
|
|
99
|
+
*
|
|
100
|
+
* Everything except `inspectActivity` forwards to the wrapped handle
|
|
101
|
+
* transparently — methods stay bound to the real handle, so the controller's
|
|
102
|
+
* `write`/`resize`/`signalForeground`/stream access behave exactly as on the
|
|
103
|
+
* shipped object. `inspectActivity` is replaced wholesale: the shipped
|
|
104
|
+
* implementation can only answer `unknown` for a distro terminal, and the
|
|
105
|
+
* replacement answers from the marker scan (or `unknown` itself, when the
|
|
106
|
+
* probe cannot answer).
|
|
107
|
+
*
|
|
108
|
+
* @param handle - the handle `spawnTerminal` returned.
|
|
109
|
+
* @param inspectActivity - the replacement observation, matching the seam's
|
|
110
|
+
* `() => Promise<SubprocessTerminalActivity>` shape.
|
|
111
|
+
* @returns the transparent proxy handle.
|
|
112
|
+
*/
|
|
113
|
+
export function wrapTerminalHandle(handle, inspectActivity) {
|
|
114
|
+
return new Proxy(handle, {
|
|
115
|
+
get(target, prop) {
|
|
116
|
+
if (prop === "inspectActivity") return inspectActivity;
|
|
117
|
+
const value = Reflect.get(target, prop);
|
|
118
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
package/lib/watcher.js
CHANGED
|
@@ -14,6 +14,16 @@
|
|
|
14
14
|
* one `C` line per hit. No `inotifywait` dependency; the mtime walk is exact
|
|
15
15
|
* for interactive-paced edits, which is the only pace a session watch serves.
|
|
16
16
|
*
|
|
17
|
+
* The loop speaks three one-letter lines, the watcher protocol:
|
|
18
|
+
*
|
|
19
|
+
* - `R` — READY: the activation barrier; the host resolves the watch promise
|
|
20
|
+
* on it, never before.
|
|
21
|
+
* - `C` — CHANGED: something under the target may have changed.
|
|
22
|
+
* - `E` — ERROR: the watched directory no longer exists, and the loop has
|
|
23
|
+
* exited. Without it a vanished target would sit armed forever — `find`
|
|
24
|
+
* over a missing directory prints nothing, indistinguishable from "no
|
|
25
|
+
* changes" — so the loop says so and the host reports it.
|
|
26
|
+
*
|
|
17
27
|
* Known blind spot, accepted and documented: a file created with an mtime
|
|
18
28
|
* older than the stamp (`cp -p`, `tar -x`) is not seen until something else
|
|
19
29
|
* touches the tree.
|
|
@@ -29,29 +39,47 @@ import { createInterface } from "node:readline";
|
|
|
29
39
|
* quoting: `$1` is the Linux directory, `$2` the poll interval in seconds.
|
|
30
40
|
* Pure text, so it is unit-testable.
|
|
31
41
|
* @param {number} [intervalSeconds] - poll cadence; must be >= 1.
|
|
42
|
+
* @param {number} [maxDepth] - bound the scan's depth, 1 = the directory
|
|
43
|
+
* itself and its direct children; 0 (the default) scans the whole tree,
|
|
44
|
+
* which is the contract's semantics but a real cost over a tree with a
|
|
45
|
+
* `node_modules` in it.
|
|
32
46
|
* @returns {string} the `sh -c` script body.
|
|
33
47
|
*/
|
|
34
|
-
export function watchLoopScript(intervalSeconds = 2) {
|
|
48
|
+
export function watchLoopScript(intervalSeconds = 2, maxDepth = 0) {
|
|
35
49
|
const seconds = Math.max(1, Math.floor(intervalSeconds));
|
|
50
|
+
const depth = Number.isInteger(maxDepth) && maxDepth >= 1 ? maxDepth : 0;
|
|
51
|
+
// A bounded walk puts `-maxdepth` directly after the path, where every find
|
|
52
|
+
// that supports it (GNU, BusyBox, BSD) accepts it as a global option.
|
|
53
|
+
const depthArgs = depth > 0 ? ` -maxdepth ${depth}` : "";
|
|
36
54
|
// `find` lists the directory itself first: a child's creation or deletion
|
|
37
55
|
// bumps the parent's mtime, so both edges are seen, not just modifications.
|
|
38
56
|
return [
|
|
39
57
|
'D="$1"',
|
|
40
58
|
'S="${TMPDIR:-/tmp}/wsl-watch.$$.stamp"',
|
|
41
59
|
'touch "$S"',
|
|
60
|
+
// The stamp is the loop's private state, so every way out removes it. The
|
|
61
|
+
// EXIT trap covers plain exits; a signal alone would end the shell WITHOUT
|
|
62
|
+
// running it, so TERM/INT/HUP are trapped to a plain exit first.
|
|
63
|
+
"trap 'rm -f \"$S\"' EXIT",
|
|
64
|
+
"trap 'exit 0' TERM INT HUP",
|
|
42
65
|
// The READY line is the activation barrier: everything changed BEFORE it
|
|
43
66
|
// predates the stamp, so the host must not resolve the watch promise until
|
|
44
67
|
// it arrives — otherwise a write racing the first tick is invisible forever.
|
|
45
|
-
|
|
68
|
+
"printf 'R\\n'",
|
|
46
69
|
// Check first, sleep second: a change made right after activation is seen
|
|
47
70
|
// on the first cadence, not one cadence later.
|
|
48
|
-
|
|
49
|
-
' if [ -
|
|
71
|
+
"while :; do",
|
|
72
|
+
' if [ ! -d "$D" ]; then',
|
|
73
|
+
// Say so and die: silence here is the armed-forever, fires-never trap.
|
|
74
|
+
" printf 'E\\n'",
|
|
75
|
+
" exit 1",
|
|
76
|
+
" fi",
|
|
77
|
+
` if [ -n "$(find "$D"${depthArgs} -newer "$S" -print -quit 2>/dev/null)" ]; then`,
|
|
50
78
|
' touch "$S"',
|
|
51
|
-
|
|
52
|
-
|
|
79
|
+
" printf 'C\\n'",
|
|
80
|
+
" fi",
|
|
53
81
|
` sleep ${seconds}`,
|
|
54
|
-
|
|
82
|
+
"done",
|
|
55
83
|
].join("\n");
|
|
56
84
|
}
|
|
57
85
|
|
|
@@ -63,26 +91,42 @@ export function watchLoopScript(intervalSeconds = 2) {
|
|
|
63
91
|
* @param {string} options.distro - the distro to watch inside.
|
|
64
92
|
* @param {string} options.linuxPath - the absolute Linux path to observe.
|
|
65
93
|
* @param {(error: Error) => void} [options.onError] - watcher-level failures
|
|
66
|
-
* (an exited loop); the seam reports these through its
|
|
94
|
+
* (an exited loop, a vanished target); the seam reports these through its
|
|
95
|
+
* own channel.
|
|
67
96
|
* @param {() => void} options.onChange - the invalidation callback.
|
|
68
97
|
* @param {AbortSignal} options.signal - cancels initialization; the CALLER
|
|
69
98
|
* invokes the returned close to stop an armed watcher.
|
|
70
99
|
* @param {number} [options.intervalSeconds] - poll cadence, default 2.
|
|
100
|
+
* @param {number} [options.maxDepth] - scan depth bound, 0 = the whole tree.
|
|
71
101
|
* @param {(wslPath: string, args: string[]) => import("node:child_process").ChildProcess} [options.spawn]
|
|
72
102
|
* injectable process factory for tests.
|
|
73
103
|
* @returns {Promise<() => Promise<void>>} resolves once observation is active,
|
|
74
104
|
* with the async close function the seam's contract asks for.
|
|
75
105
|
*/
|
|
76
|
-
export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError, signal, intervalSeconds = 2, spawn: spawnProcess }) {
|
|
106
|
+
export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError, signal, intervalSeconds = 2, maxDepth = 0, spawn: spawnProcess }) {
|
|
77
107
|
const doSpawn = spawnProcess ?? ((path, args) => spawn(path, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }));
|
|
78
108
|
return new Promise((resolve, reject) => {
|
|
79
109
|
if (signal?.aborted) {
|
|
80
110
|
reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
|
|
81
111
|
return;
|
|
82
112
|
}
|
|
83
|
-
const child = doSpawn(wslPath, ["-d", distro, "--exec", "sh", "-c", watchLoopScript(intervalSeconds), "wsl-watch", linuxPath]);
|
|
113
|
+
const child = doSpawn(wslPath, ["-d", distro, "--exec", "sh", "-c", watchLoopScript(intervalSeconds, maxDepth), "wsl-watch", linuxPath]);
|
|
84
114
|
let settled = false;
|
|
85
115
|
let closed = false;
|
|
116
|
+
let reported = false;
|
|
117
|
+
// The loop itself writes nothing to stderr, but `sh` can (a syntax slip in
|
|
118
|
+
// a future edit) — and an undrained pipe stalls the child at the 64KB
|
|
119
|
+
// buffer limit, which would look exactly like a silently-dead watch. So
|
|
120
|
+
// the pipe is drained continuously and the tail kept for error context.
|
|
121
|
+
let stderrTail = "";
|
|
122
|
+
child.stderr?.setEncoding("utf8");
|
|
123
|
+
child.stderr?.on("data", (chunk) => {
|
|
124
|
+
stderrTail = `${stderrTail}${chunk}`.slice(-4096);
|
|
125
|
+
});
|
|
126
|
+
const describe = (message) => {
|
|
127
|
+
const detail = stderrTail.trim();
|
|
128
|
+
return detail.length > 0 ? `${message}: ${detail}` : message;
|
|
129
|
+
};
|
|
86
130
|
const lines = createInterface({ input: child.stdout });
|
|
87
131
|
lines.on("line", (line) => {
|
|
88
132
|
if (line === "R") {
|
|
@@ -92,6 +136,10 @@ export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError
|
|
|
92
136
|
}
|
|
93
137
|
return;
|
|
94
138
|
}
|
|
139
|
+
if (line === "E") {
|
|
140
|
+
fail(new Error(describe(`the watched directory "${linuxPath}" no longer exists inside "${distro}", so the watch has ended`)));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
95
143
|
if (line === "C") {
|
|
96
144
|
onChange();
|
|
97
145
|
}
|
|
@@ -109,6 +157,10 @@ export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError
|
|
|
109
157
|
});
|
|
110
158
|
};
|
|
111
159
|
const fail = (error) => {
|
|
160
|
+
// One report per watcher lifetime: the loop's E line is followed by its
|
|
161
|
+
// own exit, and both must not reach the seam's error channel.
|
|
162
|
+
if (reported || closed) return;
|
|
163
|
+
reported = true;
|
|
112
164
|
if (settled) {
|
|
113
165
|
onError?.(error);
|
|
114
166
|
return;
|
|
@@ -120,8 +172,7 @@ export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError
|
|
|
120
172
|
signal?.addEventListener("abort", () => fail(signal.reason instanceof Error ? signal.reason : new Error("aborted")), { once: true });
|
|
121
173
|
child.on("error", fail);
|
|
122
174
|
child.on("exit", (code, signalName) => {
|
|
123
|
-
|
|
175
|
+
fail(new Error(`distro watcher exited unexpectedly (code=${code} signal=${signalName ?? "none"})`));
|
|
124
176
|
});
|
|
125
|
-
|
|
126
177
|
});
|
|
127
178
|
}
|