dsh-plugin-wsl-env 0.8.0 → 0.10.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 +488 -12
- package/CONTRIBUTING.md +125 -62
- package/README.md +99 -68
- package/SECURITY.md +18 -2
- package/SUPPORT.md +26 -10
- package/agent/native/linux-aarch64/dsh-wsl-agent +0 -0
- package/agent/native/linux-x86_64/dsh-wsl-agent +0 -0
- package/agent/wsl-agent.sh +83 -16
- package/cordis.patch.yml +50 -35
- package/docs/ARCHITECTURE.md +203 -55
- package/docs/CONFIGURATION.md +185 -18
- package/docs/LIMITATIONS.md +125 -45
- package/docs/PARITY.md +68 -38
- package/docs/PEER-PARITY.md +103 -33
- package/docs/README.zh.md +105 -64
- package/docs/UPSTREAM-FSIO-EXPORT.md +159 -50
- package/docs/UPSTREAM-TERMINAL-TITLE.md +56 -14
- package/examples/preset-wsl-session-sandbox.cordis.patch.yml +178 -0
- package/lib/agent-protocol.d.ts +10 -4
- package/lib/agent-protocol.js +10 -3
- package/lib/agent.d.ts +84 -0
- package/lib/agent.js +167 -15
- package/lib/bwrap.d.ts +8 -0
- package/lib/bwrap.js +4 -1
- package/lib/fs-routing.d.ts +40 -4
- package/lib/fs-routing.js +80 -12
- package/lib/fsio-agent.js +1 -1
- package/lib/index.d.ts +134 -0
- package/lib/index.js +202 -9
- package/lib/native-agent.d.ts +124 -0
- package/lib/native-agent.js +198 -0
- package/lib/ports.d.ts +13 -2
- package/lib/ports.js +18 -5
- package/lib/preflight.d.ts +91 -0
- package/lib/preflight.js +117 -0
- package/lib/probe-cache.d.ts +12 -1
- package/lib/probe-cache.js +11 -3
- package/lib/sandbox-core.d.ts +70 -0
- package/lib/sandbox-core.js +187 -18
- package/lib/session-runtime.d.ts +161 -0
- package/lib/session-runtime.js +220 -0
- package/lib/session-scope.d.ts +124 -0
- package/lib/session-scope.js +191 -0
- package/lib/shell-env.js +10 -3
- package/lib/watcher.d.ts +25 -12
- package/lib/watcher.js +120 -32
- package/lib/workspace-files-route.d.ts +8 -4
- package/lib/workspace-files-route.js +42 -14
- package/lib/workspace-files-wsl.js +25 -8
- package/lib/wsl.js +10 -4
- package/locale/preflight/en.json +6 -0
- package/locale/preflight/zh.json +6 -0
- package/package.json +44 -27
- package/scripts/diagnose.sh +92 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure builders for the session-scoped sandbox launcher (ADR 0001).
|
|
3
|
+
*
|
|
4
|
+
* The runtime is one transient systemd **user scope** per session, running the
|
|
5
|
+
* agent as its main process; commands are the agent's children, so a
|
|
6
|
+
* background dev server stays in the scope's cgroup, `/tmp` is the distro's
|
|
7
|
+
* real `/tmp`, and `ps` sees the session. Session end stops the scope and the
|
|
8
|
+
* cgroup reaps everything.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here spawns: the host inserts {@link sessionScopePrefix} between
|
|
11
|
+
* `wsl.exe --exec` and the agent's own argv, runs
|
|
12
|
+
* {@link sessionScopeProbeArgv} before enabling the mode, and runs
|
|
13
|
+
* {@link sessionScopeStopArgv} when the session ends. Keeping the shapes pure
|
|
14
|
+
* is what lets the launcher be tested without systemd.
|
|
15
|
+
*
|
|
16
|
+
* This is a TypeScript source built to `lib/session-scope.js`; edit THIS file
|
|
17
|
+
* and run `pnpm run build` — the artifact under `lib/` is generated, and
|
|
18
|
+
* `pnpm test` fails when it drifts.
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-plugin-wsl/session-scope
|
|
21
|
+
*/
|
|
22
|
+
/** The longest piece of a unit name this builder emits, per component. */
|
|
23
|
+
const MAX_COMPONENT = 64;
|
|
24
|
+
/**
|
|
25
|
+
* Characters systemd accepts in a unit name; everything else is folded to `_`.
|
|
26
|
+
* The allowed set is `[A-Za-z0-9:_.\-]`; `:` is legal in unit names but not
|
|
27
|
+
* useful here, so it is folded too, keeping the name easy to copy into a
|
|
28
|
+
* shell.
|
|
29
|
+
*/
|
|
30
|
+
const UNIT_CHARS = /[^A-Za-z0-9_.-]/gu;
|
|
31
|
+
/**
|
|
32
|
+
* Sanitize one component of a unit name.
|
|
33
|
+
*
|
|
34
|
+
* @param value - the raw component.
|
|
35
|
+
* @param fallback - the component to use when nothing survives.
|
|
36
|
+
* @returns the sanitized, length-capped component.
|
|
37
|
+
*/
|
|
38
|
+
function component(value, fallback) {
|
|
39
|
+
const clean = value.replace(UNIT_CHARS, "_").slice(0, MAX_COMPONENT);
|
|
40
|
+
return clean.length > 0 ? clean : fallback;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Build the systemd user unit name for one session runtime.
|
|
44
|
+
*
|
|
45
|
+
* The caller supplies a token that is unique for the session (a random hex
|
|
46
|
+
* string, a session id); the distro name is part of the name so two distros'
|
|
47
|
+
* runtimes are never confused in `systemctl --user list-units`. The result is
|
|
48
|
+
* deterministic for the pair, which is what lets the host stop the unit later
|
|
49
|
+
* without keeping a map.
|
|
50
|
+
*
|
|
51
|
+
* @param distro - the distro name.
|
|
52
|
+
* @param token - the per-session unique token.
|
|
53
|
+
* @returns a unit name safe for `systemd-run --unit`.
|
|
54
|
+
*/
|
|
55
|
+
export function sessionScopeUnit(distro, token) {
|
|
56
|
+
return `dsh-wsl-${component(distro, "distro")}-${component(token, "session")}`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The arguments inserted after `wsl.exe --exec` to run the agent in a
|
|
60
|
+
* transient user scope. The agent's own argv (its confinement prefix, the
|
|
61
|
+
* shell and the script) follows the `--` terminator.
|
|
62
|
+
*
|
|
63
|
+
* `--scope` is deliberate: a scope's stdio is inherited, so the protocol's
|
|
64
|
+
* pipes stay the transport's; a transient *service* would need `--pipe` and a
|
|
65
|
+
* fresh verification of its EOF/exit-code semantics (ADR open question).
|
|
66
|
+
*
|
|
67
|
+
* @param unit - the unit name from {@link sessionScopeUnit}.
|
|
68
|
+
* @returns the argv fragment, ending with `--`.
|
|
69
|
+
*/
|
|
70
|
+
export function sessionScopePrefix(unit) {
|
|
71
|
+
return ["systemd-run", "--user", "--scope", "--quiet", "--unit", unit, "--"];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The host argv that stops one session runtime. Stopping the scope is the
|
|
75
|
+
* cleanup step that replaces the per-command PID namespace: the cgroup kills
|
|
76
|
+
* every descendant, including a background dev server a command left behind.
|
|
77
|
+
*
|
|
78
|
+
* @param options - the transport path, distro and unit name.
|
|
79
|
+
* @returns the `wsl.exe --exec systemctl --user stop` argv.
|
|
80
|
+
*/
|
|
81
|
+
export function sessionScopeStopArgv({ wslPath, distro, unit }) {
|
|
82
|
+
return [
|
|
83
|
+
wslPath,
|
|
84
|
+
...(distro.length > 0 ? ["-d", distro] : []),
|
|
85
|
+
"--exec",
|
|
86
|
+
"systemctl",
|
|
87
|
+
"--user",
|
|
88
|
+
"stop",
|
|
89
|
+
unit,
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The script the preflight runs inside the distro before the session runtime
|
|
94
|
+
* is enabled. It answers one word so the host can map the answer to a remedy:
|
|
95
|
+
*
|
|
96
|
+
* - `ok` — systemd is PID 1, the user manager answers, `systemd-run` exists
|
|
97
|
+
* and the user lingers (a headless `wsl.exe --exec` has no login session).
|
|
98
|
+
* - `no-systemd` — `/run/systemd/system` is absent (systemd is not PID 1).
|
|
99
|
+
* - `no-user-manager` — `systemctl --user` does not report running/degraded.
|
|
100
|
+
* - `no-systemd-run` — the `systemd-run` binary is missing.
|
|
101
|
+
* - `no-linger` — the user manager would stop with the last session; the
|
|
102
|
+
* remedy is `loginctl enable-linger <user>`.
|
|
103
|
+
*
|
|
104
|
+
* `degraded` is accepted: a partially failed unit elsewhere does not make the
|
|
105
|
+
* user manager unable to create a scope.
|
|
106
|
+
*
|
|
107
|
+
* @returns the shell script.
|
|
108
|
+
*/
|
|
109
|
+
export function sessionScopeProbeScript() {
|
|
110
|
+
return [
|
|
111
|
+
'[ -d /run/systemd/system ] || { echo no-systemd; exit 0; }',
|
|
112
|
+
'state=$(systemctl --user is-system-running 2>/dev/null || true)',
|
|
113
|
+
'case "$state" in running|degraded) ;; *) echo no-user-manager; exit 0;; esac',
|
|
114
|
+
'command -v systemd-run >/dev/null 2>&1 || { echo no-systemd-run; exit 0; }',
|
|
115
|
+
'loginctl show-user "$(id -un)" -p Linger 2>/dev/null | grep -q "^Linger=yes$" || { echo no-linger; exit 0; }',
|
|
116
|
+
"echo ok",
|
|
117
|
+
].join("\n");
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The script the session preflight runs to stop scopes a crashed app left
|
|
121
|
+
* behind. A scope is stale when its cgroup has no agent process: the shell
|
|
122
|
+
* `wsl-agent.sh` or the native `dsh-wsl-agent` is the liveness marker. A live
|
|
123
|
+
* agent means another session still owns it, so it must not be stopped.
|
|
124
|
+
* The script only lists `dsh-wsl-*.scope` units and only stops the ones it
|
|
125
|
+
* can prove stale; if there is no user manager it exits 0 (the session
|
|
126
|
+
* preflight reports that condition separately).
|
|
127
|
+
*
|
|
128
|
+
* @returns the shell script.
|
|
129
|
+
*/
|
|
130
|
+
export function sessionScopeSweepScript() {
|
|
131
|
+
return [
|
|
132
|
+
// Without a user manager there is nothing to list.
|
|
133
|
+
'systemctl --user is-system-running >/dev/null 2>&1 || exit 0',
|
|
134
|
+
"systemctl --user list-units 'dsh-wsl-*.scope' --all --plain --no-legend --no-pager 2>/dev/null | while read -r unit _; do",
|
|
135
|
+
' [ -n "$unit" ] || continue',
|
|
136
|
+
' cg=$(systemctl --user show -p ControlGroup --value "$unit" 2>/dev/null)',
|
|
137
|
+
' [ -n "$cg" ] || continue',
|
|
138
|
+
' alive=0',
|
|
139
|
+
' if [ -r "/sys/fs/cgroup$cg/cgroup.procs" ]; then',
|
|
140
|
+
' while read -r pid; do',
|
|
141
|
+
' [ -n "$pid" ] || continue',
|
|
142
|
+
" if tr '\\0' ' ' < \"/proc/$pid/cmdline\" 2>/dev/null | grep -q -e 'wsl-agent.sh' -e 'dsh-wsl-agent'; then alive=1; break; fi",
|
|
143
|
+
' done < "/sys/fs/cgroup$cg/cgroup.procs"',
|
|
144
|
+
' fi',
|
|
145
|
+
' [ "$alive" -eq 1 ] || systemctl --user stop "$unit" >/dev/null 2>&1 || true',
|
|
146
|
+
'done',
|
|
147
|
+
'exit 0',
|
|
148
|
+
].join("\n");
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Map the preflight's answer to a user-facing refusal, or `null` when the
|
|
152
|
+
* platform can host a session runtime. The probe prints one word (it always
|
|
153
|
+
* exits 0 so the host reads a verdict, not a spawn error); an empty or unknown
|
|
154
|
+
* answer is reported with whatever diagnostic the host captured.
|
|
155
|
+
*
|
|
156
|
+
* @param options - the probe output and the distro it ran in.
|
|
157
|
+
* @returns the refusal with a remedy, or null.
|
|
158
|
+
*/
|
|
159
|
+
export function sessionScopeProblem({ output, distro }) {
|
|
160
|
+
const word = output.stdout.trim().split(/\r?\n/u)[0]?.trim() ?? "";
|
|
161
|
+
switch (word) {
|
|
162
|
+
case "ok":
|
|
163
|
+
return null;
|
|
164
|
+
case "no-systemd":
|
|
165
|
+
return `sessionSandbox needs systemd as PID 1 in "${distro}": set [boot] systemd=true in /etc/wsl.conf, run "wsl --shutdown", and reopen the session.`;
|
|
166
|
+
case "no-user-manager":
|
|
167
|
+
return `sessionSandbox needs a reachable systemd user manager in "${distro}": log in to the distro once, or enable lingering (see the next refusal).`;
|
|
168
|
+
case "no-systemd-run":
|
|
169
|
+
return `sessionSandbox needs "systemd-run" in "${distro}"; the distro's systemd package is incomplete or missing.`;
|
|
170
|
+
case "no-linger":
|
|
171
|
+
return `sessionSandbox needs the user manager to outlive a login session: run "loginctl enable-linger $USER" in "${distro}" and retry.`;
|
|
172
|
+
default:
|
|
173
|
+
return `the sessionSandbox preflight in "${distro}" answered ${word === "" ? "(nothing)" : `"${word}"`}${output.error === undefined ? "" : `: ${output.error}`}; see docs/adr/0001-session-scoped-sandbox.md.`;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The host argv for the preflight probe.
|
|
178
|
+
*
|
|
179
|
+
* @param options - the transport path and distro.
|
|
180
|
+
* @returns the `wsl.exe --exec sh -c <script>` argv.
|
|
181
|
+
*/
|
|
182
|
+
export function sessionScopeProbeArgv({ wslPath, distro }) {
|
|
183
|
+
return [
|
|
184
|
+
wslPath,
|
|
185
|
+
...(distro.length > 0 ? ["-d", distro] : []),
|
|
186
|
+
"--exec",
|
|
187
|
+
"sh",
|
|
188
|
+
"-c",
|
|
189
|
+
sessionScopeProbeScript(),
|
|
190
|
+
];
|
|
191
|
+
}
|
package/lib/shell-env.js
CHANGED
|
@@ -30,7 +30,7 @@ import z from "@deepseek-ai/schemastery";
|
|
|
30
30
|
import { isAnotherDistrosUnc, isWslUnc } from "./paths.js";
|
|
31
31
|
import { defaultDistro, defaultShell, linuxHomePath } from "./wsl.js";
|
|
32
32
|
import { sharedAgent } from "./agent-shared.js";
|
|
33
|
-
import {
|
|
33
|
+
import { scanListeningPorts } from "./ports.js";
|
|
34
34
|
export const name = "wsl-shell-env";
|
|
35
35
|
export const inject = ["shellEnv"];
|
|
36
36
|
export const Config = z.object({
|
|
@@ -68,7 +68,7 @@ export async function apply(ctx, config = {}) {
|
|
|
68
68
|
description: `Home directory of the user model shell calls run as inside ${distro}.`,
|
|
69
69
|
},
|
|
70
70
|
DSH_WSL_PORTS: {
|
|
71
|
-
description: `Ports with a listener inside ${distro} (comma-separated, ascending), as of the last snapshot — refreshed every ${Math.round(config.portsRefreshMs / 1000)}s, so a dev server started moments ago may not be listed yet. Empty means none were listening at the last scan.`,
|
|
71
|
+
description: `Ports with a listener inside ${distro} (comma-separated, ascending), as of the last snapshot — refreshed every ${Math.round(config.portsRefreshMs / 1000)}s, so a dev server started moments ago may not be listed yet. Empty means none were listening at the last successful scan (or no scan has succeeded yet); a failed scan keeps the last successful list.`,
|
|
72
72
|
},
|
|
73
73
|
};
|
|
74
74
|
// The port snapshot refreshes on a timer and is served synchronously: the
|
|
@@ -76,7 +76,14 @@ export async function apply(ctx, config = {}) {
|
|
|
76
76
|
// the timer bounds staleness instead (see lib/ports.js).
|
|
77
77
|
let ports = [];
|
|
78
78
|
const refreshPorts = async () => {
|
|
79
|
-
|
|
79
|
+
const next = await scanListeningPorts(sharedAgent(distro), 8000);
|
|
80
|
+
if (next === undefined) {
|
|
81
|
+
// A transient agent outage must not read as "no ports": keep the last
|
|
82
|
+
// successful snapshot and leave a debug breadcrumb.
|
|
83
|
+
ctx.logger?.debug?.("wsl-shell-env: port snapshot failed; keeping the last known list");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
ports = next;
|
|
80
87
|
};
|
|
81
88
|
void refreshPorts();
|
|
82
89
|
const portsTimer = setInterval(() => void refreshPorts(), config.portsRefreshMs);
|
package/lib/watcher.d.ts
CHANGED
|
@@ -8,21 +8,33 @@
|
|
|
8
8
|
* whenever something under `target` may have changed. That is cheap to honor
|
|
9
9
|
* from inside the distro, where the kernel does know.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* When `inotifywait` is installed and the target is an unbounded directory,
|
|
12
|
+
* the watcher uses inotify: recursive kernel watches, one `C` per 0.5s of
|
|
13
|
+
* events (a build's event storm is coalesced, the first event is immediate).
|
|
14
|
+
* Otherwise it falls back to the long-lived polling loop: every cadence,
|
|
12
15
|
* `find <dir> -newer <stamp>` — the directory itself is in the scan, so
|
|
13
16
|
* creations AND deletions (which bump the parent's mtime) are both seen — and
|
|
14
|
-
* one `C` line per hit.
|
|
15
|
-
*
|
|
17
|
+
* one `C` line per hit. A FILE target uses the `mtime:size` fingerprint (so a
|
|
18
|
+
* rename-over or an in-place write is seen), and a positive `maxDepth` keeps
|
|
19
|
+
* the polling scan because inotify cannot bound its own walk.
|
|
16
20
|
*
|
|
17
21
|
* The loop speaks three one-letter lines, the watcher protocol:
|
|
18
22
|
*
|
|
19
23
|
* - `R` — READY: the activation barrier; the host resolves the watch promise
|
|
20
24
|
* on it, never before.
|
|
21
|
-
* - `C` — CHANGED:
|
|
22
|
-
* - `E` — ERROR: the watched
|
|
25
|
+
* - `C` — CHANGED: the target may have changed.
|
|
26
|
+
* - `E` — ERROR: the watched path no longer exists, and the loop has
|
|
23
27
|
* exited. Without it a vanished target would sit armed forever — `find`
|
|
24
28
|
* over a missing directory prints nothing, indistinguishable from "no
|
|
25
29
|
* changes" — so the loop says so and the host reports it.
|
|
30
|
+
* - `P <pid>` — the loop shell's PID, sent before READY. The host uses it to
|
|
31
|
+
* stop the loop with a second `wsl.exe` on close: killing the transport
|
|
32
|
+
* alone does not reliably close the distro-side pipe.
|
|
33
|
+
* - `H` — HEARTBEAT: liveness only, ignored by the host. It exists so the
|
|
34
|
+
* loop notices a closed stdout pipe: the host's close kills `wsl.exe` but
|
|
35
|
+
* cannot signal the distro-side shell, and without a write that shell
|
|
36
|
+
* would sleep and poll forever. The `PIPE` trap turns that write's
|
|
37
|
+
* SIGPIPE into a clean exit through the stamp-removing EXIT trap.
|
|
26
38
|
*
|
|
27
39
|
* Known blind spot, accepted and documented: a file created with an mtime
|
|
28
40
|
* older than the stamp (`cp -p`, `tar -x`) is not seen until something else
|
|
@@ -37,19 +49,20 @@
|
|
|
37
49
|
import type { ChildProcess } from "node:child_process";
|
|
38
50
|
/**
|
|
39
51
|
* The shell loop the watcher runs, parameterised by argv so no path can break
|
|
40
|
-
* quoting: `$1` is the Linux directory, `$2` the poll
|
|
41
|
-
* Pure text, so it is unit-testable.
|
|
52
|
+
* quoting: `$1` is the Linux path (a directory or a file), `$2` the poll
|
|
53
|
+
* interval in seconds. Pure text, so it is unit-testable.
|
|
42
54
|
*
|
|
43
55
|
* @param intervalSeconds - poll cadence; must be >= 1.
|
|
44
|
-
* @param maxDepth - bound the scan's depth, 1 = the
|
|
45
|
-
* itself and its direct children; 0 (the default) scans the whole
|
|
46
|
-
* which is the contract's semantics
|
|
47
|
-
*
|
|
56
|
+
* @param maxDepth - bound the scan's depth for a DIRECTORY target, 1 = the
|
|
57
|
+
* directory itself and its direct children; 0 (the default) scans the whole
|
|
58
|
+
* tree, which is the contract's semantics. Positive values select the
|
|
59
|
+
* polling scan (inotify is used only for the unbounded walk). A file target
|
|
60
|
+
* ignores it.
|
|
48
61
|
* @returns the `sh -c` script body.
|
|
49
62
|
*/
|
|
50
63
|
export declare function watchLoopScript(intervalSeconds?: number, maxDepth?: number): string;
|
|
51
64
|
/**
|
|
52
|
-
* Arm one in-distro watcher over a Linux directory.
|
|
65
|
+
* Arm one in-distro watcher over a Linux directory or file.
|
|
53
66
|
*
|
|
54
67
|
* @param options - the watch request.
|
|
55
68
|
* @returns resolves once observation is active, with the async close function
|
package/lib/watcher.js
CHANGED
|
@@ -8,21 +8,33 @@
|
|
|
8
8
|
* whenever something under `target` may have changed. That is cheap to honor
|
|
9
9
|
* from inside the distro, where the kernel does know.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* When `inotifywait` is installed and the target is an unbounded directory,
|
|
12
|
+
* the watcher uses inotify: recursive kernel watches, one `C` per 0.5s of
|
|
13
|
+
* events (a build's event storm is coalesced, the first event is immediate).
|
|
14
|
+
* Otherwise it falls back to the long-lived polling loop: every cadence,
|
|
12
15
|
* `find <dir> -newer <stamp>` — the directory itself is in the scan, so
|
|
13
16
|
* creations AND deletions (which bump the parent's mtime) are both seen — and
|
|
14
|
-
* one `C` line per hit.
|
|
15
|
-
*
|
|
17
|
+
* one `C` line per hit. A FILE target uses the `mtime:size` fingerprint (so a
|
|
18
|
+
* rename-over or an in-place write is seen), and a positive `maxDepth` keeps
|
|
19
|
+
* the polling scan because inotify cannot bound its own walk.
|
|
16
20
|
*
|
|
17
21
|
* The loop speaks three one-letter lines, the watcher protocol:
|
|
18
22
|
*
|
|
19
23
|
* - `R` — READY: the activation barrier; the host resolves the watch promise
|
|
20
24
|
* on it, never before.
|
|
21
|
-
* - `C` — CHANGED:
|
|
22
|
-
* - `E` — ERROR: the watched
|
|
25
|
+
* - `C` — CHANGED: the target may have changed.
|
|
26
|
+
* - `E` — ERROR: the watched path no longer exists, and the loop has
|
|
23
27
|
* exited. Without it a vanished target would sit armed forever — `find`
|
|
24
28
|
* over a missing directory prints nothing, indistinguishable from "no
|
|
25
29
|
* changes" — so the loop says so and the host reports it.
|
|
30
|
+
* - `P <pid>` — the loop shell's PID, sent before READY. The host uses it to
|
|
31
|
+
* stop the loop with a second `wsl.exe` on close: killing the transport
|
|
32
|
+
* alone does not reliably close the distro-side pipe.
|
|
33
|
+
* - `H` — HEARTBEAT: liveness only, ignored by the host. It exists so the
|
|
34
|
+
* loop notices a closed stdout pipe: the host's close kills `wsl.exe` but
|
|
35
|
+
* cannot signal the distro-side shell, and without a write that shell
|
|
36
|
+
* would sleep and poll forever. The `PIPE` trap turns that write's
|
|
37
|
+
* SIGPIPE into a clean exit through the stamp-removing EXIT trap.
|
|
26
38
|
*
|
|
27
39
|
* Known blind spot, accepted and documented: a file created with an mtime
|
|
28
40
|
* older than the stamp (`cp -p`, `tar -x`) is not seen until something else
|
|
@@ -38,14 +50,15 @@ import { spawn } from "node:child_process";
|
|
|
38
50
|
import { createInterface } from "node:readline";
|
|
39
51
|
/**
|
|
40
52
|
* The shell loop the watcher runs, parameterised by argv so no path can break
|
|
41
|
-
* quoting: `$1` is the Linux directory, `$2` the poll
|
|
42
|
-
* Pure text, so it is unit-testable.
|
|
53
|
+
* quoting: `$1` is the Linux path (a directory or a file), `$2` the poll
|
|
54
|
+
* interval in seconds. Pure text, so it is unit-testable.
|
|
43
55
|
*
|
|
44
56
|
* @param intervalSeconds - poll cadence; must be >= 1.
|
|
45
|
-
* @param maxDepth - bound the scan's depth, 1 = the
|
|
46
|
-
* itself and its direct children; 0 (the default) scans the whole
|
|
47
|
-
* which is the contract's semantics
|
|
48
|
-
*
|
|
57
|
+
* @param maxDepth - bound the scan's depth for a DIRECTORY target, 1 = the
|
|
58
|
+
* directory itself and its direct children; 0 (the default) scans the whole
|
|
59
|
+
* tree, which is the contract's semantics. Positive values select the
|
|
60
|
+
* polling scan (inotify is used only for the unbounded walk). A file target
|
|
61
|
+
* ignores it.
|
|
49
62
|
* @returns the `sh -c` script body.
|
|
50
63
|
*/
|
|
51
64
|
export function watchLoopScript(intervalSeconds = 2, maxDepth = 0) {
|
|
@@ -56,37 +69,90 @@ export function watchLoopScript(intervalSeconds = 2, maxDepth = 0) {
|
|
|
56
69
|
const depthArgs = depth > 0 ? ` -maxdepth ${depth}` : "";
|
|
57
70
|
// `find` lists the directory itself first: a child's creation or deletion
|
|
58
71
|
// bumps the parent's mtime, so both edges are seen, not just modifications.
|
|
72
|
+
// A FILE target has no tree to walk; its `mtime:size` fingerprint answers the
|
|
73
|
+
// same one-way question ("may have changed") and covers a rename-over too,
|
|
74
|
+
// since the path is re-stat'ed each tick.
|
|
59
75
|
return [
|
|
60
76
|
'D="$1"',
|
|
61
77
|
'S="${TMPDIR:-/tmp}/wsl-watch.$$.stamp"',
|
|
78
|
+
'FIFO="${TMPDIR:-/tmp}/wsl-watch.$$.fifo"',
|
|
79
|
+
'IW=""',
|
|
62
80
|
'touch "$S"',
|
|
63
|
-
// The stamp
|
|
64
|
-
// EXIT trap covers plain exits; a signal alone would end the
|
|
65
|
-
// running it, so TERM/INT/HUP are trapped to a plain
|
|
66
|
-
|
|
67
|
-
"trap '
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
81
|
+
// The stamp/FIFO are the loop's private state, so every way out removes
|
|
82
|
+
// them. The EXIT trap covers plain exits; a signal alone would end the
|
|
83
|
+
// shell WITHOUT running it, so TERM/INT/HUP/PIPE are trapped to a plain
|
|
84
|
+
// exit first. `pkill -P $$` also reaps inotifywait/sleep/find.
|
|
85
|
+
"trap 'kill \"$IW\" 2>/dev/null; pkill -P $$ 2>/dev/null; exec 3>&- 2>/dev/null; rm -f \"$FIFO\" \"$S\" 2>/dev/null' EXIT",
|
|
86
|
+
"trap 'exit 0' TERM INT HUP PIPE",
|
|
87
|
+
'F=""',
|
|
88
|
+
'[ -f "$D" ] && F=$(stat -c "%Y:%s" -- "$D" 2>/dev/null || :)',
|
|
89
|
+
// Prefer inotify for an unbounded DIRECTORY watch: events are immediate and
|
|
90
|
+
// the kernel does the walking. A file target uses the fingerprint loop
|
|
91
|
+
// (it also reports a removal as E), and a bounded scan (maxDepth > 0)
|
|
92
|
+
// keeps the polling loop, which honors the bound.
|
|
93
|
+
`if [ -d "$D" ] && [ ${depth} -eq 0 ] && command -v inotifywait >/dev/null 2>&1; then`,
|
|
94
|
+
' if mkfifo "$FIFO" 2>/dev/null; then',
|
|
95
|
+
' exec 3<>"$FIFO"',
|
|
96
|
+
' inotifywait -m -q -r -e create,delete,modify,move,attrib --format "%e" "$D" >"$FIFO" 2>/dev/null &',
|
|
97
|
+
' IW=$!',
|
|
98
|
+
' fi',
|
|
99
|
+
'fi',
|
|
100
|
+
// The PID precedes READY so a close can stop the loop even if the pipe
|
|
101
|
+
// stays open; killing wsl.exe alone does not close it reliably.
|
|
102
|
+
"printf 'P %s\\n' \"$$\"",
|
|
71
103
|
"printf 'R\\n'",
|
|
72
|
-
//
|
|
73
|
-
//
|
|
104
|
+
// One C per half second: a build's event storm becomes a coarse
|
|
105
|
+
// invalidation, while the first event is immediate. A quiet tree blocks
|
|
106
|
+
// in read; the close stops the shell through the P line, not the pipe.
|
|
107
|
+
'if [ -n "$IW" ]; then',
|
|
108
|
+
// A one-second marker on the same FIFO wakes the blocking read so the
|
|
109
|
+
// TERM trap and the vanished-target check run even on a quiet tree. The
|
|
110
|
+
// marker is not an event: it is skipped, and only checked against the
|
|
111
|
+
// target's existence.
|
|
112
|
+
' ( while :; do sleep 1; printf \'H\\n\' >"$FIFO" 2>/dev/null; done ) &',
|
|
113
|
+
' while read -r ev <&3; do',
|
|
114
|
+
' if [ "$ev" = "H" ]; then',
|
|
115
|
+
' [ -d "$D" ] || { printf \'E\\n\'; exit 1; }',
|
|
116
|
+
' continue',
|
|
117
|
+
' fi',
|
|
118
|
+
' [ -d "$D" ] || { printf \'E\\n\'; exit 1; }',
|
|
119
|
+
" printf 'C\\n'",
|
|
120
|
+
' sleep 0.5',
|
|
121
|
+
' done',
|
|
122
|
+
' exec 3>&-',
|
|
123
|
+
'fi',
|
|
124
|
+
// Polling fallback: no inotifywait, a bounded scan, or inotifywait exited
|
|
125
|
+
// (a vanished target or an inotify limit). The stamp is the baseline.
|
|
74
126
|
"while :; do",
|
|
75
|
-
' if [
|
|
76
|
-
|
|
127
|
+
' if [ -d "$D" ]; then',
|
|
128
|
+
` if [ -n "$(find "$D"${depthArgs} -newer "$S" -print -quit 2>/dev/null)" ]; then`,
|
|
129
|
+
' touch "$S"',
|
|
130
|
+
" printf 'C\\n'",
|
|
131
|
+
' fi',
|
|
132
|
+
' elif [ -f "$D" ]; then',
|
|
133
|
+
' cur=$(stat -c "%Y:%s" -- "$D" 2>/dev/null || :)',
|
|
134
|
+
' if [ -z "$cur" ]; then',
|
|
135
|
+
// The file was removed (or became unreadable): the target is gone.
|
|
136
|
+
" printf 'E\\n'",
|
|
137
|
+
' exit 1',
|
|
138
|
+
' fi',
|
|
139
|
+
' if [ "$cur" != "$F" ]; then',
|
|
140
|
+
' F="$cur"',
|
|
141
|
+
" printf 'C\\n'",
|
|
142
|
+
' fi',
|
|
143
|
+
' else',
|
|
144
|
+
// Neither a directory nor a file: say so and die, rather than spin.
|
|
77
145
|
" printf 'E\\n'",
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
'
|
|
82
|
-
" printf 'C\\n'",
|
|
83
|
-
" fi",
|
|
146
|
+
' exit 1',
|
|
147
|
+
' fi',
|
|
148
|
+
// Liveness: the host ignores H; the write dies with the pipe.
|
|
149
|
+
" printf 'H\\n'",
|
|
84
150
|
` sleep ${seconds}`,
|
|
85
|
-
|
|
151
|
+
'done',
|
|
86
152
|
].join("\n");
|
|
87
153
|
}
|
|
88
154
|
/**
|
|
89
|
-
* Arm one in-distro watcher over a Linux directory.
|
|
155
|
+
* Arm one in-distro watcher over a Linux directory or file.
|
|
90
156
|
*
|
|
91
157
|
* @param options - the watch request.
|
|
92
158
|
* @returns resolves once observation is active, with the async close function
|
|
@@ -118,8 +184,15 @@ export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError
|
|
|
118
184
|
const detail = stderrTail.trim();
|
|
119
185
|
return detail.length > 0 ? `${message}: ${detail}` : message;
|
|
120
186
|
};
|
|
187
|
+
let loopPid;
|
|
121
188
|
const lines = createInterface({ input: child.stdout });
|
|
122
189
|
lines.on("line", (line) => {
|
|
190
|
+
if (line.startsWith("P ")) {
|
|
191
|
+
const pid = Number.parseInt(line.slice(2), 10);
|
|
192
|
+
if (Number.isInteger(pid) && pid > 0)
|
|
193
|
+
loopPid = pid;
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
123
196
|
if (line === "R") {
|
|
124
197
|
if (!settled && !signal?.aborted) {
|
|
125
198
|
settled = true;
|
|
@@ -128,7 +201,7 @@ export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError
|
|
|
128
201
|
return;
|
|
129
202
|
}
|
|
130
203
|
if (line === "E") {
|
|
131
|
-
fail(new Error(describe(`the watched
|
|
204
|
+
fail(new Error(describe(`the watched path "${linuxPath}" no longer exists inside "${distro}", so the watch has ended`)));
|
|
132
205
|
return;
|
|
133
206
|
}
|
|
134
207
|
if (line === "C") {
|
|
@@ -139,10 +212,25 @@ export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError
|
|
|
139
212
|
if (closed)
|
|
140
213
|
return;
|
|
141
214
|
closed = true;
|
|
215
|
+
const pid = loopPid;
|
|
142
216
|
child.removeAllListeners("exit");
|
|
143
217
|
lines.close();
|
|
144
218
|
child.stdout?.destroy();
|
|
145
219
|
child.kill();
|
|
220
|
+
if (pid !== undefined) {
|
|
221
|
+
// A second wsl.exe reaches the same distro namespace; the loop's TERM
|
|
222
|
+
// trap exits through the EXIT cleanup, which removes the stamp.
|
|
223
|
+
try {
|
|
224
|
+
const stopper = doSpawn(wslPath, ["-d", distro, "--exec", "sh", "-c", 'kill -TERM "$1" 2>/dev/null', "sh", String(pid)]);
|
|
225
|
+
stopper.stdout?.destroy();
|
|
226
|
+
stopper.stderr?.destroy();
|
|
227
|
+
stopper.on("error", () => { });
|
|
228
|
+
stopper.unref?.();
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// Best effort: the transport kill already happened.
|
|
232
|
+
}
|
|
233
|
+
}
|
|
146
234
|
await new Promise((resolveClose) => {
|
|
147
235
|
if (child.exitCode !== null || child.signalCode !== null)
|
|
148
236
|
return resolveClose();
|
|
@@ -42,12 +42,14 @@ export declare function linuxJoin(linuxRoot: string, path: string): string;
|
|
|
42
42
|
/**
|
|
43
43
|
* The record invocation for one path: line 1 is the no-follow kind (the
|
|
44
44
|
* upstream lstat gate), line 2 the follow-stat fields (the upstream final
|
|
45
|
-
* stat), tab-joined.
|
|
45
|
+
* stat), tab-joined. The path is resolved and contained first — the GUI's
|
|
46
|
+
* workspace is a boundary for every read, not just for listings.
|
|
46
47
|
*
|
|
47
48
|
* @param file - absolute Linux path.
|
|
49
|
+
* @param root - the workspace root in Linux form (containment boundary).
|
|
48
50
|
* @returns the agent argv.
|
|
49
51
|
*/
|
|
50
|
-
export declare function statRecordArgv(file: string): string[];
|
|
52
|
+
export declare function statRecordArgv(file: string, root: string): string[];
|
|
51
53
|
/**
|
|
52
54
|
* Parse one stat record (kind + follow fields, tab-joined).
|
|
53
55
|
*
|
|
@@ -77,9 +79,10 @@ export declare function wireKind(kind: string): string;
|
|
|
77
79
|
* @param file - absolute Linux path.
|
|
78
80
|
* @param offset - first line to return (1-based).
|
|
79
81
|
* @param limit - maximum lines to return.
|
|
82
|
+
* @param root - the workspace root in Linux form (containment boundary).
|
|
80
83
|
* @returns the agent argv.
|
|
81
84
|
*/
|
|
82
|
-
export declare function pageArgv(file: string, offset: number, limit: number): string[];
|
|
85
|
+
export declare function pageArgv(file: string, offset: number, limit: number, root: string): string[];
|
|
83
86
|
/**
|
|
84
87
|
* The byte-window invocation: the stat record rides stderr, the window rides
|
|
85
88
|
* stdout. `$1` is the file, `$2` the byte offset, `$3` the byte count.
|
|
@@ -87,9 +90,10 @@ export declare function pageArgv(file: string, offset: number, limit: number): s
|
|
|
87
90
|
* @param file - absolute Linux path.
|
|
88
91
|
* @param offset - byte offset (0-based).
|
|
89
92
|
* @param length - byte count.
|
|
93
|
+
* @param root - the workspace root in Linux form (containment boundary).
|
|
90
94
|
* @returns the agent argv.
|
|
91
95
|
*/
|
|
92
|
-
export declare function bytesArgv(file: string, offset: number, length: number): string[];
|
|
96
|
+
export declare function bytesArgv(file: string, offset: number, length: number, root: string): string[];
|
|
93
97
|
/**
|
|
94
98
|
* Parse the stat record a read or byte-window relayed on stderr.
|
|
95
99
|
*
|
|
@@ -64,21 +64,43 @@ export function linuxJoin(linuxRoot, path) {
|
|
|
64
64
|
const joined = text.length === 0 ? base : `${base}/${text}`.replace(/\/\.(?=\/|$)/gu, "");
|
|
65
65
|
return joined.length === 0 ? "/" : joined;
|
|
66
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* The containment guard every distro-side workspace read shares. `$1` is the
|
|
69
|
+
* requested file, `$2` the workspace root; both are canonicalized with
|
|
70
|
+
* `realpath -L`, and a target that is neither the root itself nor below it is
|
|
71
|
+
* refused with exit 3 (the `workspace-file/outside-workspace` refusal). The
|
|
72
|
+
* resolved file is left in `real` for the rest of the script.
|
|
73
|
+
*
|
|
74
|
+
* A root of `/` admits every absolute path: the workspace IS the distro. The
|
|
75
|
+
* `-L` spelling matches the listing guard's, so symlinked workspace roots and
|
|
76
|
+
* `..` segments resolve the same way on both sides.
|
|
77
|
+
*/
|
|
78
|
+
const CONTAIN_FN = [
|
|
79
|
+
'contain() {',
|
|
80
|
+
' real=$(realpath -L -- "$1") || exit 2',
|
|
81
|
+
' root=$(realpath -L -- "$2") || exit 2',
|
|
82
|
+
' case "$root" in /) ;; *) case "$real" in "$root"|"$root"/*) ;; *) echo "OUTSIDE:$real" >&2; exit 3;; esac;; esac',
|
|
83
|
+
'}',
|
|
84
|
+
].join("\n");
|
|
67
85
|
/**
|
|
68
86
|
* The record invocation for one path: line 1 is the no-follow kind (the
|
|
69
87
|
* upstream lstat gate), line 2 the follow-stat fields (the upstream final
|
|
70
|
-
* stat), tab-joined.
|
|
88
|
+
* stat), tab-joined. The path is resolved and contained first — the GUI's
|
|
89
|
+
* workspace is a boundary for every read, not just for listings.
|
|
71
90
|
*
|
|
72
91
|
* @param file - absolute Linux path.
|
|
92
|
+
* @param root - the workspace root in Linux form (containment boundary).
|
|
73
93
|
* @returns the agent argv.
|
|
74
94
|
*/
|
|
75
|
-
export function statRecordArgv(file) {
|
|
95
|
+
export function statRecordArgv(file, root) {
|
|
76
96
|
const script = [
|
|
77
|
-
|
|
78
|
-
'
|
|
97
|
+
CONTAIN_FN,
|
|
98
|
+
'contain "$1" "$2"',
|
|
99
|
+
'k=$(stat -c %F -- "$real") || exit 2',
|
|
100
|
+
's=$(stat -L -c %s\\t%D\\t%i\\t%Y\\t%Z -- "$real") || exit 2',
|
|
79
101
|
"printf '%s\\t%s\\n' \"$k\" \"$s\"",
|
|
80
102
|
].join("\n");
|
|
81
|
-
return ["sh", "-c", script, "sh", file];
|
|
103
|
+
return ["sh", "-c", script, "sh", file, root];
|
|
82
104
|
}
|
|
83
105
|
/**
|
|
84
106
|
* Parse one stat record (kind + follow fields, tab-joined).
|
|
@@ -115,16 +137,19 @@ export function wireKind(kind) {
|
|
|
115
137
|
* @param file - absolute Linux path.
|
|
116
138
|
* @param offset - first line to return (1-based).
|
|
117
139
|
* @param limit - maximum lines to return.
|
|
140
|
+
* @param root - the workspace root in Linux form (containment boundary).
|
|
118
141
|
* @returns the agent argv.
|
|
119
142
|
*/
|
|
120
|
-
export function pageArgv(file, offset, limit) {
|
|
143
|
+
export function pageArgv(file, offset, limit, root) {
|
|
121
144
|
const script = [
|
|
122
|
-
|
|
123
|
-
'
|
|
145
|
+
CONTAIN_FN,
|
|
146
|
+
'contain "$1" "$4"',
|
|
147
|
+
'k=$(stat -c %F -- "$real") || exit 2',
|
|
148
|
+
's=$(stat -L -c %s\\t%D\\t%i\\t%Y\\t%Z -- "$real") || exit 2',
|
|
124
149
|
"printf '%s\\t%s\\n' \"$k\" \"$s\" >&2",
|
|
125
|
-
'sed -n "${2},$(( $2 + $3 ))p" -- "$
|
|
150
|
+
'sed -n "${2},$(( $2 + $3 ))p" -- "$real"',
|
|
126
151
|
].join("\n");
|
|
127
|
-
return ["sh", "-c", script, "sh", file, String(offset), String(limit)];
|
|
152
|
+
return ["sh", "-c", script, "sh", file, String(offset), String(limit), root];
|
|
128
153
|
}
|
|
129
154
|
/**
|
|
130
155
|
* The byte-window invocation: the stat record rides stderr, the window rides
|
|
@@ -133,15 +158,18 @@ export function pageArgv(file, offset, limit) {
|
|
|
133
158
|
* @param file - absolute Linux path.
|
|
134
159
|
* @param offset - byte offset (0-based).
|
|
135
160
|
* @param length - byte count.
|
|
161
|
+
* @param root - the workspace root in Linux form (containment boundary).
|
|
136
162
|
* @returns the agent argv.
|
|
137
163
|
*/
|
|
138
|
-
export function bytesArgv(file, offset, length) {
|
|
164
|
+
export function bytesArgv(file, offset, length, root) {
|
|
139
165
|
const script = [
|
|
140
|
-
|
|
166
|
+
CONTAIN_FN,
|
|
167
|
+
'contain "$1" "$4"',
|
|
168
|
+
's=$(stat -L -c %s\\t%D\\t%i\\t%Y\\t%Z -- "$real") || exit 2',
|
|
141
169
|
"printf '%s\\n' \"$s\" >&2",
|
|
142
|
-
'tail -c "+$(( $2 + 1 ))" -- "$
|
|
170
|
+
'tail -c "+$(( $2 + 1 ))" -- "$real" | head -c "$3"',
|
|
143
171
|
].join("\n");
|
|
144
|
-
return ["sh", "-c", script, "sh", file, String(offset), String(length)];
|
|
172
|
+
return ["sh", "-c", script, "sh", file, String(offset), String(length), root];
|
|
145
173
|
}
|
|
146
174
|
/**
|
|
147
175
|
* Parse the stat record a read or byte-window relayed on stderr.
|