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
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
* This is a TypeScript source built to `lib/terminal-activity.js`; edit THIS
|
|
33
|
+
* file and run `pnpm run build` — the artifact under `lib/` is generated, and
|
|
34
|
+
* `pnpm test` fails when it drifts.
|
|
35
|
+
*
|
|
36
|
+
* @module dsh-plugin-wsl/terminal-activity
|
|
37
|
+
*/
|
|
38
|
+
import type { SubprocessTerminalHandle } from "@deepseek-ai/dsh-subprocess";
|
|
39
|
+
/** The env name that marks one terminal's process tree inside the distro. */
|
|
40
|
+
export declare const TERMINAL_ID_ENV = "DSH_TERMINAL_ID";
|
|
41
|
+
/**
|
|
42
|
+
* The seam's activity observation — structurally the upstream
|
|
43
|
+
* `SubprocessTerminalActivity` (`subprocess-local/src/terminal.ts`): `state`
|
|
44
|
+
* is `'idle' | 'busy' | 'unknown'` and `revision` is a counter that changes
|
|
45
|
+
* when the reported state changes, which is what the controller's retention
|
|
46
|
+
* loop keys its idle accumulation on.
|
|
47
|
+
*/
|
|
48
|
+
export interface SubprocessTerminalActivity {
|
|
49
|
+
state: "idle" | "busy" | "unknown";
|
|
50
|
+
revision: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The probe: count the `/proc` processes carrying the terminal marker.
|
|
54
|
+
*
|
|
55
|
+
* POSIX sh on purpose (the distro's `sh` is the only guaranteed interpreter).
|
|
56
|
+
* The count is the whole decision: the terminal's login shell is exec'd WITH
|
|
57
|
+
* the marker (wsl.exe imports it into the session before `--exec`), so an
|
|
58
|
+
* idle-at-prompt shell is exactly one marked process, and anything the shell
|
|
59
|
+
* runs — a command, a pipeline, a nested shell — is exec'd with the marker
|
|
60
|
+
* inherited, pushing the count past one. `/proc/<pid>/environ` shows the
|
|
61
|
+
* exec-time environment, which is precisely why the count works: runtime
|
|
62
|
+
* exports would be invisible, and exec-time inheritance is the signal.
|
|
63
|
+
*
|
|
64
|
+
* The marker check reads the NUL-separated environ through `tr`, which is why
|
|
65
|
+
* the id is passed as `$1` rather than interpolated into the script.
|
|
66
|
+
*
|
|
67
|
+
* @param terminalId - the `DSH_TERMINAL_ID` value one terminal was launched with.
|
|
68
|
+
* @returns the agent `exec` target: the probe script with the id as `$1`.
|
|
69
|
+
*/
|
|
70
|
+
export declare function terminalActivityProbe(terminalId: string): {
|
|
71
|
+
cwd: string;
|
|
72
|
+
argv: string[];
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Parse the probe's stdout into an activity state.
|
|
76
|
+
*
|
|
77
|
+
* @param stdout - the probe's raw stdout.
|
|
78
|
+
* @returns `"busy"` or `"idle"` as the probe reported, or undefined when the
|
|
79
|
+
* output is anything else (the caller answers `unknown`).
|
|
80
|
+
*/
|
|
81
|
+
export declare function parseTerminalActivity(stdout: string | Buffer): "busy" | "idle" | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* Wrap a terminal handle so its activity is observed inside the distro.
|
|
84
|
+
*
|
|
85
|
+
* Everything except `inspectActivity` forwards to the wrapped handle
|
|
86
|
+
* transparently — methods stay bound to the real handle, so the controller's
|
|
87
|
+
* `write`/`resize`/`signalForeground`/stream access behave exactly as on the
|
|
88
|
+
* shipped object. `inspectActivity` is replaced wholesale: the shipped
|
|
89
|
+
* implementation can only answer `unknown` for a distro terminal, and the
|
|
90
|
+
* replacement answers from the marker scan (or `unknown` itself, when the
|
|
91
|
+
* probe cannot answer).
|
|
92
|
+
*
|
|
93
|
+
* @param handle - the handle `spawnTerminal` returned.
|
|
94
|
+
* @param inspectActivity - the replacement observation, matching the seam's
|
|
95
|
+
* `() => Promise<SubprocessTerminalActivity>` shape.
|
|
96
|
+
* @returns the transparent proxy handle.
|
|
97
|
+
*/
|
|
98
|
+
export declare function wrapTerminalHandle(handle: SubprocessTerminalHandle, inspectActivity: () => Promise<SubprocessTerminalActivity>): SubprocessTerminalHandle;
|
package/lib/terminal-activity.js
CHANGED
|
@@ -29,24 +29,14 @@
|
|
|
29
29
|
* Pure builders and a transparent proxy here; the agent wiring lives in the
|
|
30
30
|
* subprocess provider.
|
|
31
31
|
*
|
|
32
|
+
* This is a TypeScript source built to `lib/terminal-activity.js`; edit THIS
|
|
33
|
+
* file and run `pnpm run build` — the artifact under `lib/` is generated, and
|
|
34
|
+
* `pnpm test` fails when it drifts.
|
|
35
|
+
*
|
|
32
36
|
* @module dsh-plugin-wsl/terminal-activity
|
|
33
37
|
*/
|
|
34
|
-
|
|
35
38
|
/** The env name that marks one terminal's process tree inside the distro. */
|
|
36
39
|
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
40
|
/**
|
|
51
41
|
* The probe: count the `/proc` processes carrying the terminal marker.
|
|
52
42
|
*
|
|
@@ -66,21 +56,20 @@ export const TERMINAL_ID_ENV = "DSH_TERMINAL_ID";
|
|
|
66
56
|
* @returns the agent `exec` target: the probe script with the id as `$1`.
|
|
67
57
|
*/
|
|
68
58
|
export function terminalActivityProbe(terminalId) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
59
|
+
const script = [
|
|
60
|
+
"id=$1",
|
|
61
|
+
"count=0",
|
|
62
|
+
'for d in /proc/[0-9]*; do',
|
|
63
|
+
' [ -r "$d/environ" ] || continue',
|
|
64
|
+
' if tr "\\000" "\\n" < "$d/environ" 2>/dev/null | grep -qx "DSH_TERMINAL_ID=$id"; then',
|
|
65
|
+
" count=$((count+1))",
|
|
66
|
+
' [ "$count" -gt 1 ] && { echo busy; exit 0; }',
|
|
67
|
+
" fi",
|
|
68
|
+
"done",
|
|
69
|
+
"echo idle",
|
|
70
|
+
].join("\n");
|
|
71
|
+
return { cwd: "/", argv: ["sh", "-c", script, "sh", terminalId] };
|
|
82
72
|
}
|
|
83
|
-
|
|
84
73
|
/**
|
|
85
74
|
* Parse the probe's stdout into an activity state.
|
|
86
75
|
*
|
|
@@ -89,11 +78,11 @@ export function terminalActivityProbe(terminalId) {
|
|
|
89
78
|
* output is anything else (the caller answers `unknown`).
|
|
90
79
|
*/
|
|
91
80
|
export function parseTerminalActivity(stdout) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
81
|
+
const text = String(stdout).trim();
|
|
82
|
+
if (text === "busy" || text === "idle")
|
|
83
|
+
return text;
|
|
84
|
+
return undefined;
|
|
95
85
|
}
|
|
96
|
-
|
|
97
86
|
/**
|
|
98
87
|
* Wrap a terminal handle so its activity is observed inside the distro.
|
|
99
88
|
*
|
|
@@ -111,11 +100,12 @@ export function parseTerminalActivity(stdout) {
|
|
|
111
100
|
* @returns the transparent proxy handle.
|
|
112
101
|
*/
|
|
113
102
|
export function wrapTerminalHandle(handle, inspectActivity) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
103
|
+
return new Proxy(handle, {
|
|
104
|
+
get(target, prop) {
|
|
105
|
+
if (prop === "inspectActivity")
|
|
106
|
+
return inspectActivity;
|
|
107
|
+
const value = Reflect.get(target, prop);
|
|
108
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
109
|
+
},
|
|
110
|
+
});
|
|
121
111
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which execution world a terminal launch belongs to.
|
|
3
|
+
*
|
|
4
|
+
* The terminal controller hands this provider the request's directory, and the
|
|
5
|
+
* directory's coordinate system is the evidence of intent: a UNC path under a
|
|
6
|
+
* distro's share names a WSL session outright, a POSIX absolute path is a
|
|
7
|
+
* distro path by construction, and a Windows drive path is a session opened on
|
|
8
|
+
* a Windows folder — which deserves a HOST shell, not the distro's shell
|
|
9
|
+
* started at `/mnt/<drive>/…`. An empty directory carries no evidence either
|
|
10
|
+
* way and defaults to the distro, which is what a profile pins.
|
|
11
|
+
*
|
|
12
|
+
* Pure by construction.
|
|
13
|
+
*
|
|
14
|
+
* This is a TypeScript source built to `lib/terminal-route.js`; edit THIS file
|
|
15
|
+
* and run `pnpm run build` — the artifact under `lib/` is generated, and
|
|
16
|
+
* `pnpm test` fails when it drifts.
|
|
17
|
+
*
|
|
18
|
+
* @module dsh-plugin-wsl/terminal-route
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* The route decision for one terminal launch.
|
|
22
|
+
*
|
|
23
|
+
* @param cwd - the terminal request's working directory, any coordinate system.
|
|
24
|
+
* @returns `"distro"` when the launch belongs inside the pinned distro,
|
|
25
|
+
* `"host"` when it belongs to a Windows-folder session.
|
|
26
|
+
*/
|
|
27
|
+
export declare function terminalRoute(cwd: string | undefined): "distro" | "host";
|
package/lib/terminal-route.js
CHANGED
|
@@ -11,21 +11,29 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Pure by construction.
|
|
13
13
|
*
|
|
14
|
+
* This is a TypeScript source built to `lib/terminal-route.js`; edit THIS file
|
|
15
|
+
* and run `pnpm run build` — the artifact under `lib/` is generated, and
|
|
16
|
+
* `pnpm test` fails when it drifts.
|
|
17
|
+
*
|
|
14
18
|
* @module dsh-plugin-wsl/terminal-route
|
|
15
19
|
*/
|
|
16
|
-
|
|
17
20
|
import { isPosixAbsolute, uncToPosix, windowsToLinuxMount } from "./paths.js";
|
|
18
|
-
|
|
19
21
|
/**
|
|
22
|
+
* The route decision for one terminal launch.
|
|
23
|
+
*
|
|
20
24
|
* @param cwd - the terminal request's working directory, any coordinate system.
|
|
21
25
|
* @returns `"distro"` when the launch belongs inside the pinned distro,
|
|
22
26
|
* `"host"` when it belongs to a Windows-folder session.
|
|
23
27
|
*/
|
|
24
28
|
export function terminalRoute(cwd) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
const text = String(cwd ?? "").trim();
|
|
30
|
+
if (text.length === 0)
|
|
31
|
+
return "distro";
|
|
32
|
+
if (uncToPosix(text) !== undefined)
|
|
33
|
+
return "distro";
|
|
34
|
+
if (isPosixAbsolute(text))
|
|
35
|
+
return "distro";
|
|
36
|
+
if (windowsToLinuxMount(text) !== undefined)
|
|
37
|
+
return "host";
|
|
38
|
+
return "distro";
|
|
31
39
|
}
|
package/lib/watcher.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-distro file watching for `WslFileSystem.watch`.
|
|
3
|
+
*
|
|
4
|
+
* A 9p share gives Node no usable change notification — chokidar over
|
|
5
|
+
* `\\wsl.localhost` is the unreliability that made this provider refuse to
|
|
6
|
+
* watch at all. The seam's contract, however, asks for far less than events:
|
|
7
|
+
* `watch(target, changed, signal)` wants ONE invalidation callback, invoked
|
|
8
|
+
* whenever something under `target` may have changed. That is cheap to honor
|
|
9
|
+
* from inside the distro, where the kernel does know.
|
|
10
|
+
*
|
|
11
|
+
* The watcher is a long-lived `wsl.exe` shell loop: periodically
|
|
12
|
+
* `find <dir> -newer <stamp>` — the directory itself is in the scan, so
|
|
13
|
+
* creations AND deletions (which bump the parent's mtime) are both seen — and
|
|
14
|
+
* one `C` line per hit. No `inotifywait` dependency; the mtime walk is exact
|
|
15
|
+
* for interactive-paced edits, which is the only pace a session watch serves.
|
|
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
|
+
*
|
|
27
|
+
* Known blind spot, accepted and documented: a file created with an mtime
|
|
28
|
+
* older than the stamp (`cp -p`, `tar -x`) is not seen until something else
|
|
29
|
+
* touches the tree.
|
|
30
|
+
*
|
|
31
|
+
* This is a TypeScript source built to `lib/watcher.js`; edit THIS file and
|
|
32
|
+
* run `pnpm run build` — the artifact under `lib/` is generated, and `pnpm test`
|
|
33
|
+
* fails when it drifts.
|
|
34
|
+
*
|
|
35
|
+
* @module dsh-plugin-wsl/watcher
|
|
36
|
+
*/
|
|
37
|
+
import type { ChildProcess } from "node:child_process";
|
|
38
|
+
/**
|
|
39
|
+
* The shell loop the watcher runs, parameterised by argv so no path can break
|
|
40
|
+
* quoting: `$1` is the Linux directory, `$2` the poll interval in seconds.
|
|
41
|
+
* Pure text, so it is unit-testable.
|
|
42
|
+
*
|
|
43
|
+
* @param intervalSeconds - poll cadence; must be >= 1.
|
|
44
|
+
* @param maxDepth - bound the scan's depth, 1 = the directory
|
|
45
|
+
* itself and its direct children; 0 (the default) scans the whole tree,
|
|
46
|
+
* which is the contract's semantics but a real cost over a tree with a
|
|
47
|
+
* `node_modules` in it.
|
|
48
|
+
* @returns the `sh -c` script body.
|
|
49
|
+
*/
|
|
50
|
+
export declare function watchLoopScript(intervalSeconds?: number, maxDepth?: number): string;
|
|
51
|
+
/**
|
|
52
|
+
* Arm one in-distro watcher over a Linux directory.
|
|
53
|
+
*
|
|
54
|
+
* @param options - the watch request.
|
|
55
|
+
* @returns resolves once observation is active, with the async close function
|
|
56
|
+
* the seam's contract asks for.
|
|
57
|
+
*/
|
|
58
|
+
export declare function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError, signal, intervalSeconds, maxDepth, spawn: spawnProcess }: {
|
|
59
|
+
/** The `wsl.exe` path. */
|
|
60
|
+
wslPath: string;
|
|
61
|
+
/** The distro to watch inside. */
|
|
62
|
+
distro: string;
|
|
63
|
+
/** The absolute Linux path to observe. */
|
|
64
|
+
linuxPath: string;
|
|
65
|
+
/** Watcher-level failures (an exited loop, a vanished target); the seam reports these through its own channel. */
|
|
66
|
+
onError?: (error: Error) => void;
|
|
67
|
+
/** The invalidation callback. */
|
|
68
|
+
onChange: () => void;
|
|
69
|
+
/** Cancels initialization; the CALLER invokes the returned close to stop an armed watcher. */
|
|
70
|
+
signal: AbortSignal;
|
|
71
|
+
/** Poll cadence, default 2. */
|
|
72
|
+
intervalSeconds?: number;
|
|
73
|
+
/** Scan depth bound, 0 = the whole tree. */
|
|
74
|
+
maxDepth?: number;
|
|
75
|
+
/** Injectable process factory for tests. */
|
|
76
|
+
spawn?: (wslPath: string, args: string[]) => ChildProcess;
|
|
77
|
+
}): Promise<() => Promise<void>>;
|
package/lib/watcher.js
CHANGED
|
@@ -28,151 +28,145 @@
|
|
|
28
28
|
* older than the stamp (`cp -p`, `tar -x`) is not seen until something else
|
|
29
29
|
* touches the tree.
|
|
30
30
|
*
|
|
31
|
+
* This is a TypeScript source built to `lib/watcher.js`; edit THIS file and
|
|
32
|
+
* run `pnpm run build` — the artifact under `lib/` is generated, and `pnpm test`
|
|
33
|
+
* fails when it drifts.
|
|
34
|
+
*
|
|
31
35
|
* @module dsh-plugin-wsl/watcher
|
|
32
36
|
*/
|
|
33
|
-
|
|
34
37
|
import { spawn } from "node:child_process";
|
|
35
38
|
import { createInterface } from "node:readline";
|
|
36
|
-
|
|
37
39
|
/**
|
|
38
40
|
* The shell loop the watcher runs, parameterised by argv so no path can break
|
|
39
41
|
* quoting: `$1` is the Linux directory, `$2` the poll interval in seconds.
|
|
40
42
|
* Pure text, so it is unit-testable.
|
|
41
|
-
*
|
|
42
|
-
* @param
|
|
43
|
+
*
|
|
44
|
+
* @param intervalSeconds - poll cadence; must be >= 1.
|
|
45
|
+
* @param maxDepth - bound the scan's depth, 1 = the directory
|
|
43
46
|
* itself and its direct children; 0 (the default) scans the whole tree,
|
|
44
47
|
* which is the contract's semantics but a real cost over a tree with a
|
|
45
48
|
* `node_modules` in it.
|
|
46
|
-
* @returns
|
|
49
|
+
* @returns the `sh -c` script body.
|
|
47
50
|
*/
|
|
48
51
|
export function watchLoopScript(intervalSeconds = 2, maxDepth = 0) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
52
|
+
const seconds = Math.max(1, Math.floor(intervalSeconds));
|
|
53
|
+
const depth = Number.isInteger(maxDepth) && maxDepth >= 1 ? maxDepth : 0;
|
|
54
|
+
// A bounded walk puts `-maxdepth` directly after the path, where every find
|
|
55
|
+
// that supports it (GNU, BusyBox, BSD) accepts it as a global option.
|
|
56
|
+
const depthArgs = depth > 0 ? ` -maxdepth ${depth}` : "";
|
|
57
|
+
// `find` lists the directory itself first: a child's creation or deletion
|
|
58
|
+
// bumps the parent's mtime, so both edges are seen, not just modifications.
|
|
59
|
+
return [
|
|
60
|
+
'D="$1"',
|
|
61
|
+
'S="${TMPDIR:-/tmp}/wsl-watch.$$.stamp"',
|
|
62
|
+
'touch "$S"',
|
|
63
|
+
// The stamp is the loop's private state, so every way out removes it. The
|
|
64
|
+
// EXIT trap covers plain exits; a signal alone would end the shell WITHOUT
|
|
65
|
+
// running it, so TERM/INT/HUP are trapped to a plain exit first.
|
|
66
|
+
"trap 'rm -f \"$S\"' EXIT",
|
|
67
|
+
"trap 'exit 0' TERM INT HUP",
|
|
68
|
+
// The READY line is the activation barrier: everything changed BEFORE it
|
|
69
|
+
// predates the stamp, so the host must not resolve the watch promise until
|
|
70
|
+
// it arrives — otherwise a write racing the first tick is invisible forever.
|
|
71
|
+
"printf 'R\\n'",
|
|
72
|
+
// Check first, sleep second: a change made right after activation is seen
|
|
73
|
+
// on the first cadence, not one cadence later.
|
|
74
|
+
"while :; do",
|
|
75
|
+
' if [ ! -d "$D" ]; then',
|
|
76
|
+
// Say so and die: silence here is the armed-forever, fires-never trap.
|
|
77
|
+
" printf 'E\\n'",
|
|
78
|
+
" exit 1",
|
|
79
|
+
" fi",
|
|
80
|
+
` if [ -n "$(find "$D"${depthArgs} -newer "$S" -print -quit 2>/dev/null)" ]; then`,
|
|
81
|
+
' touch "$S"',
|
|
82
|
+
" printf 'C\\n'",
|
|
83
|
+
" fi",
|
|
84
|
+
` sleep ${seconds}`,
|
|
85
|
+
"done",
|
|
86
|
+
].join("\n");
|
|
84
87
|
}
|
|
85
|
-
|
|
86
88
|
/**
|
|
87
89
|
* Arm one in-distro watcher over a Linux directory.
|
|
88
90
|
*
|
|
89
|
-
* @param
|
|
90
|
-
* @
|
|
91
|
-
*
|
|
92
|
-
* @param {string} options.linuxPath - the absolute Linux path to observe.
|
|
93
|
-
* @param {(error: Error) => void} [options.onError] - watcher-level failures
|
|
94
|
-
* (an exited loop, a vanished target); the seam reports these through its
|
|
95
|
-
* own channel.
|
|
96
|
-
* @param {() => void} options.onChange - the invalidation callback.
|
|
97
|
-
* @param {AbortSignal} options.signal - cancels initialization; the CALLER
|
|
98
|
-
* invokes the returned close to stop an armed watcher.
|
|
99
|
-
* @param {number} [options.intervalSeconds] - poll cadence, default 2.
|
|
100
|
-
* @param {number} [options.maxDepth] - scan depth bound, 0 = the whole tree.
|
|
101
|
-
* @param {(wslPath: string, args: string[]) => import("node:child_process").ChildProcess} [options.spawn]
|
|
102
|
-
* injectable process factory for tests.
|
|
103
|
-
* @returns {Promise<() => Promise<void>>} resolves once observation is active,
|
|
104
|
-
* with the async close function the seam's contract asks for.
|
|
91
|
+
* @param options - the watch request.
|
|
92
|
+
* @returns resolves once observation is active, with the async close function
|
|
93
|
+
* the seam's contract asks for.
|
|
105
94
|
*/
|
|
106
95
|
export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError, signal, intervalSeconds = 2, maxDepth = 0, spawn: spawnProcess }) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
const child = doSpawn(wslPath, ["-d", distro, "--exec", "sh", "-c", watchLoopScript(intervalSeconds, maxDepth), "wsl-watch", linuxPath]);
|
|
114
|
-
let settled = false;
|
|
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
|
-
};
|
|
130
|
-
const lines = createInterface({ input: child.stdout });
|
|
131
|
-
lines.on("line", (line) => {
|
|
132
|
-
if (line === "R") {
|
|
133
|
-
if (!settled && !signal?.aborted) {
|
|
134
|
-
settled = true;
|
|
135
|
-
resolve(close);
|
|
96
|
+
const doSpawn = spawnProcess ?? ((path, args) => spawn(path, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }));
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
if (signal?.aborted) {
|
|
99
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
|
|
100
|
+
return;
|
|
136
101
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
child.
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
102
|
+
const child = doSpawn(wslPath, ["-d", distro, "--exec", "sh", "-c", watchLoopScript(intervalSeconds, maxDepth), "wsl-watch", linuxPath]);
|
|
103
|
+
if (child.stdout === null || child.stderr === null)
|
|
104
|
+
throw new Error("the watcher spawn did not provide piped stdio");
|
|
105
|
+
let settled = false;
|
|
106
|
+
let closed = false;
|
|
107
|
+
let reported = false;
|
|
108
|
+
// The loop itself writes nothing to stderr, but `sh` can (a syntax slip in
|
|
109
|
+
// a future edit) — and an undrained pipe stalls the child at the 64KB
|
|
110
|
+
// buffer limit, which would look exactly like a silently-dead watch. So
|
|
111
|
+
// the pipe is drained continuously and the tail kept for error context.
|
|
112
|
+
let stderrTail = "";
|
|
113
|
+
child.stderr?.setEncoding("utf8");
|
|
114
|
+
child.stderr?.on("data", (chunk) => {
|
|
115
|
+
stderrTail = `${stderrTail}${chunk}`.slice(-4096);
|
|
116
|
+
});
|
|
117
|
+
const describe = (message) => {
|
|
118
|
+
const detail = stderrTail.trim();
|
|
119
|
+
return detail.length > 0 ? `${message}: ${detail}` : message;
|
|
120
|
+
};
|
|
121
|
+
const lines = createInterface({ input: child.stdout });
|
|
122
|
+
lines.on("line", (line) => {
|
|
123
|
+
if (line === "R") {
|
|
124
|
+
if (!settled && !signal?.aborted) {
|
|
125
|
+
settled = true;
|
|
126
|
+
resolve(close);
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (line === "E") {
|
|
131
|
+
fail(new Error(describe(`the watched directory "${linuxPath}" no longer exists inside "${distro}", so the watch has ended`)));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (line === "C") {
|
|
135
|
+
onChange();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
const close = async () => {
|
|
139
|
+
if (closed)
|
|
140
|
+
return;
|
|
141
|
+
closed = true;
|
|
142
|
+
child.removeAllListeners("exit");
|
|
143
|
+
lines.close();
|
|
144
|
+
child.stdout?.destroy();
|
|
145
|
+
child.kill();
|
|
146
|
+
await new Promise((resolveClose) => {
|
|
147
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
148
|
+
return resolveClose();
|
|
149
|
+
child.once("exit", resolveClose);
|
|
150
|
+
});
|
|
151
|
+
};
|
|
152
|
+
const fail = (error) => {
|
|
153
|
+
// One report per watcher lifetime: the loop's E line is followed by its
|
|
154
|
+
// own exit, and both must not reach the seam's error channel.
|
|
155
|
+
if (reported || closed)
|
|
156
|
+
return;
|
|
157
|
+
reported = true;
|
|
158
|
+
if (settled) {
|
|
159
|
+
onError?.(error);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
settled = true;
|
|
163
|
+
void close();
|
|
164
|
+
reject(error);
|
|
165
|
+
};
|
|
166
|
+
signal?.addEventListener("abort", () => fail(signal.reason instanceof Error ? signal.reason : new Error("aborted")), { once: true });
|
|
167
|
+
child.on("error", fail);
|
|
168
|
+
child.on("exit", (code, signalName) => {
|
|
169
|
+
fail(new Error(`distro watcher exited unexpectedly (code=${code} signal=${signalName ?? "none"})`));
|
|
170
|
+
});
|
|
176
171
|
});
|
|
177
|
-
});
|
|
178
172
|
}
|