dsh-wsl-desktop 0.3.6 → 0.3.7
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/README.en.md +7 -4
- package/README.md +6 -3
- package/lib/index.js +104 -1
- package/lib/wsl/confinement.js +5 -4
- package/lib/wsl/fs.js +28 -0
- package/lib/wsl/watch.js +212 -0
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -14,13 +14,13 @@ Turns a WSL distribution into a **first-class execution world** inside DSH Deskt
|
|
|
14
14
|
## Contents
|
|
15
15
|
|
|
16
16
|
- **This page**: [Target capabilities](#target-capabilities) · [Install / Update / Uninstall](#install--update--uninstall) · [Quick start](#quick-start) · [Desktop update discipline](#desktop-update-discipline-mandatory) · [Security and trust boundaries](#security-and-trust-boundaries) · [Known limitations](#known-limitations) · [Verification](#verification) · [Development](#development) · [Release](#release)
|
|
17
|
-
- **In-depth material**: [Security and trust boundaries](SECURITY.md) · [Architecture and the execution world](docs/ARCHITECTURE.en.md) · [Confinement (Linux side)](docs/CONFINEMENT.en.md) · [The fs fence](docs/FS-FENCE.en.md) · [Terminal (PTY bridge)](docs/PTY-BRIDGE.en.md) · [Verification and the SKIP ruling](docs/VERIFICATION.en.md) · [Engineering notes](docs/ENGINEERING-NOTES.en.md) · [Distro support matrix](docs/DISTRO-SUPPORT.md) · [Upstream API proposals](docs/UPSTREAM-PROPOSALS.md) · [Architecture and flow diagrams](docs/diagrams.md)
|
|
17
|
+
- **In-depth material**: [Security and trust boundaries](SECURITY.md) · [Architecture and the execution world](docs/ARCHITECTURE.en.md) · [Confinement (Linux side)](docs/CONFINEMENT.en.md) · [The fs fence](docs/FS-FENCE.en.md) · [Terminal (PTY bridge)](docs/PTY-BRIDGE.en.md) · [WSL file watching](docs/WATCH.en.md) · [Verification and the SKIP ruling](docs/VERIFICATION.en.md) · [Engineering notes](docs/ENGINEERING-NOTES.en.md) · [Distro support matrix](docs/DISTRO-SUPPORT.md) · [Upstream API proposals](docs/UPSTREAM-PROPOSALS.md) · [Architecture and flow diagrams](docs/diagrams.md)
|
|
18
18
|
|
|
19
|
-
> The
|
|
19
|
+
> The bilingual documents linked above (their file names carry an `.en` marker) are enforced as pairs rather than trusted. The three older documents — the distro support matrix, the upstream proposals and the diagram index — are **Chinese-only**, because no English text for them exists: translating them is a separate piece of work, not a rename.
|
|
20
20
|
|
|
21
21
|
## Target capabilities
|
|
22
22
|
|
|
23
|
-
**Honest state after live acceptance** (`verify-post-restart.mjs` all green +
|
|
23
|
+
**Honest state after live acceptance** (`verify-post-restart.mjs` all green + 19 offline suites; each item below notes its evidence strength):
|
|
24
24
|
|
|
25
25
|
| # | Capability | Status |
|
|
26
26
|
|---|---|---|
|
|
@@ -75,10 +75,12 @@ After every DSH Desktop update, the plugin may break because of harness internal
|
|
|
75
75
|
|
|
76
76
|
**Confinement does not cover `/dev`, `/proc`, `/sys` or interop**, which is reported honestly in `enforcement: 'partial'`; and the session user's retained passwordless sudo grant can bypass the file fence — see [SECURITY.md](SECURITY.md).
|
|
77
77
|
|
|
78
|
+
**File-change tracking inside a WSL workspace polls; it is not event-driven.** `fs.watch` throws `EISDIR` on the distribution's 9P share (for a file target as well as a directory one), so the host backend's chokidar watcher cannot initialize for any WSL target. The plugin polls on the host side instead: a directory compares its entry set (one `readdir` per 300ms; measured 0ms CPU per 3s and 2-3ms latency in this process) and a file — or a path that does not exist yet — watches its stat (`fs.watchFile`, measured 0ms CPU per 3s and 52ms). **A directory target is invalidated by entry-set changes only**: adds, removes and renames are seen; the CONTENT change of a file inside it is not — watch that file's own path to follow it. Mechanisms, measurements and what was not measured are in [WSL file watching](docs/WATCH.en.md).
|
|
79
|
+
|
|
78
80
|
## Verification
|
|
79
81
|
|
|
80
82
|
```powershell
|
|
81
|
-
node scripts/verify-all.mjs # all offline suites (
|
|
83
|
+
node scripts/verify-all.mjs # all offline suites (19)
|
|
82
84
|
node scripts/verify-all.mjs --live # additionally the suites needing the installed plugin + a running host
|
|
83
85
|
```
|
|
84
86
|
|
|
@@ -106,6 +108,7 @@ lib/wsl/world.js Distro discovery, the wsl.exe execution core, directory
|
|
|
106
108
|
lib/wsl/shell.js ShellExecutor implementation (WSL bash + confinement hookup)
|
|
107
109
|
lib/wsl/fs.js LocalFileSystem subclass (9P backend + path dialects)
|
|
108
110
|
lib/wsl/publish.js The overwrite publication's wait bound (retry + measured tail; pure module, independently testable)
|
|
111
|
+
lib/wsl/watch.js Polling strategies for file watching (pure module, independently testable)
|
|
109
112
|
lib/wsl/subprocess.js SubprocessRuntime implementation (pipes + terminal; delegates to the host subprocess)
|
|
110
113
|
lib/wsl/pty.js Terminal handle: two processes driving the in-distro PTY bridge
|
|
111
114
|
lib/wsl/terminal-bridge.py The PTY bridge running inside the distro (python3 stdlib, zero install)
|
package/README.md
CHANGED
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
## 目录
|
|
15
15
|
|
|
16
16
|
- **本页**:[目标能力](#目标能力) · [安装 / 更新 / 卸载](#安装--更新--卸载) · [快速开始](#快速开始) · [桌面更新纪律](#桌面更新纪律强制) · [安全与信任边界](#安全与信任边界) · [已知限制](#已知限制) · [校验](#校验) · [开发](#开发) · [发版](#发版)
|
|
17
|
-
- **深入材料**:[安全与信任边界](SECURITY.md) · [架构与执行世界](docs/ARCHITECTURE.md) · [Linux 侧约束](docs/CONFINEMENT.md) · [fs 工具的围栏](docs/FS-FENCE.md) · [终端 PTY 桥](docs/PTY-BRIDGE.md) · [校验与 SKIP 裁定](docs/VERIFICATION.md) · [工程笔记](docs/ENGINEERING-NOTES.md) · [发行版支持矩阵](docs/DISTRO-SUPPORT.md) · [上游 API 提案](docs/UPSTREAM-PROPOSALS.md) · [架构与流程图](docs/diagrams.md)
|
|
17
|
+
- **深入材料**:[安全与信任边界](SECURITY.md) · [架构与执行世界](docs/ARCHITECTURE.md) · [Linux 侧约束](docs/CONFINEMENT.md) · [fs 工具的围栏](docs/FS-FENCE.md) · [终端 PTY 桥](docs/PTY-BRIDGE.md) · [WSL 文件变更监听](docs/WATCH.md) · [校验与 SKIP 裁定](docs/VERIFICATION.md) · [工程笔记](docs/ENGINEERING-NOTES.md) · [发行版支持矩阵](docs/DISTRO-SUPPORT.md) · [上游 API 提案](docs/UPSTREAM-PROPOSALS.md) · [架构与流程图](docs/diagrams.md)
|
|
18
18
|
|
|
19
19
|
## 目标能力
|
|
20
20
|
|
|
21
|
-
**活体验收通过后的诚实状态**(`verify-post-restart.mjs` 全绿 + 离线
|
|
21
|
+
**活体验收通过后的诚实状态**(`verify-post-restart.mjs` 全绿 + 离线 19 套件;下面每条都注明证据强度):
|
|
22
22
|
|
|
23
23
|
| # | 能力 | 状态 |
|
|
24
24
|
|---|---|---|
|
|
@@ -73,10 +73,12 @@
|
|
|
73
73
|
|
|
74
74
|
**约束覆盖不到 `/dev`、`/proc`、`/sys` 与 interop**,如实报在 `enforcement: 'partial'` 里;而会话用户保留的免密 sudo 授权可绕过文件围栏,闭合路径见 [SECURITY.md](SECURITY.md)。
|
|
75
75
|
|
|
76
|
+
**WSL 工作区里的文件变更跟踪是轮询的,不是事件驱动的。** 发行版的 9P 共享上 `fs.watch` 会抛 `EISDIR`(文件与目录目标都抛),宿主后端的 chokidar 监听因此在 WSL 目标上根本无法初始化;插件改为在宿主侧轮询——目录看条目集合(每 300ms 一次 `readdir`,实测本进程 0ms CPU/3s、延迟 2–3ms),文件与尚不存在的路径走 `fs.watchFile`(每 300ms 一次 `stat`,实测 0ms CPU/3s、延迟 52ms)。**目录目标只对条目集合变化失效**:新增、删除、改名可见,目录内某个文件的**内容**变化不可见——要盯着某个文件,就按文件路径监听它。机制、实测与未测项见 [WSL 文件变更监听](docs/WATCH.md)。
|
|
77
|
+
|
|
76
78
|
## 校验
|
|
77
79
|
|
|
78
80
|
```powershell
|
|
79
|
-
node scripts/verify-all.mjs # 全部离线套件(
|
|
81
|
+
node scripts/verify-all.mjs # 全部离线套件(19 个)
|
|
80
82
|
node scripts/verify-all.mjs --live # 追加需要已安装插件 + 运行中宿主的套件
|
|
81
83
|
```
|
|
82
84
|
|
|
@@ -104,6 +106,7 @@ lib/wsl/world.js 发行版发现、wsl.exe 执行核、目录事实
|
|
|
104
106
|
lib/wsl/shell.js ShellExecutor 实现(WSL bash + 约束接入)
|
|
105
107
|
lib/wsl/fs.js LocalFileSystem 子类(9P 后端 + 路径方言)
|
|
106
108
|
lib/wsl/publish.js 覆盖发布的等待边界(重试 + 实测尾部;纯模块,可独立测试)
|
|
109
|
+
lib/wsl/watch.js 文件变更监听的轮询策略(纯模块,可独立测试)
|
|
107
110
|
lib/wsl/subprocess.js SubprocessRuntime 实现(管道 + 终端;委托宿主 subprocess)
|
|
108
111
|
lib/wsl/pty.js 终端句柄:两个进程驱动发行版内的 PTY 桥
|
|
109
112
|
lib/wsl/terminal-bridge.py 发行版内运行的 PTY 桥(python3 标准库,零安装)
|
package/lib/index.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
17
|
-
import { existsSync } from 'node:fs'
|
|
17
|
+
import { existsSync, watch as nativeFsWatch } from 'node:fs'
|
|
18
18
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
19
19
|
import { homedir } from 'node:os'
|
|
20
20
|
import { dirname, join } from 'node:path'
|
|
@@ -27,6 +27,7 @@ import { buildVariantPlugins } from './wsl/preset.js'
|
|
|
27
27
|
import { setLocalSubprocess } from './wsl/host-refs.js'
|
|
28
28
|
import { checkLinuxPath, defaultDistro, defaultWorkspaceUnc, listDistros, listLinuxDir, resolveDistroHome, runWslShell } from './wsl/world.js'
|
|
29
29
|
import { joinWslUnc, parseWslUnc, windowsToMntPath } from './wsl/paths.js'
|
|
30
|
+
import { WATCH_INTERVAL_MS } from './wsl/watch.js'
|
|
30
31
|
|
|
31
32
|
/** Loader row identity, also used in diagnostics. */
|
|
32
33
|
export const name = 'wsl-desktop'
|
|
@@ -634,6 +635,108 @@ const METHODS = {
|
|
|
634
635
|
} catch (error) {
|
|
635
636
|
record('fs.roundtrip', { error: error instanceof Error ? error.message : String(error) })
|
|
636
637
|
}
|
|
638
|
+
|
|
639
|
+
// Watch rows: the strategy the provider installs, driven the way a consumer
|
|
640
|
+
// drives it, and mutated from INSIDE the distribution — the case the rows are
|
|
641
|
+
// about. No model-facing tool reaches `fs.watch`, so the provider's own entry
|
|
642
|
+
// is the only place this can be observed.
|
|
643
|
+
if (fs !== undefined && shell !== undefined && typeof workspaceCwd === 'string' && workspaceCwd.startsWith('\\\\wsl.localhost\\')) {
|
|
644
|
+
/**
|
|
645
|
+
* The policy every mutation below carries. A request that omits `sandboxPolicy`
|
|
646
|
+
* makes `bash-sandbox`'s `resolve()` substitute the session-less default, and the
|
|
647
|
+
* confinement then binds a root that does NOT contain this scratch workspace —
|
|
648
|
+
* measured: every mutation failed EROFS ("Read-only file system"). The fallback is
|
|
649
|
+
* not a MISSING root: `lib/wsl/confinement.js` binds the command's cwd when a policy
|
|
650
|
+
* carries none. It is a root from the deployment's own world, which
|
|
651
|
+
* `lib/wsl/paths.js` maps to `/mnt/<drive>/…`. The tool layer never meets this
|
|
652
|
+
* because `tool-bash` stamps the resolved policy into its request; a direct
|
|
653
|
+
* provider call has to stamp it itself.
|
|
654
|
+
*/
|
|
655
|
+
const workspaceWritePolicy = { mode: 'workspace-write', workspaceRoot: workspaceCwd }
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Arm one watch, mutate the target from the distribution, report what arrived.
|
|
659
|
+
* @param {{ targetKey: string }} target - the resolved watch target.
|
|
660
|
+
* @param {string} command - the shell command that changes it.
|
|
661
|
+
* @returns {Promise<object>} the observation row.
|
|
662
|
+
*/
|
|
663
|
+
const observeWatch = async (target, command) => {
|
|
664
|
+
const controller = new AbortController()
|
|
665
|
+
const linuxPath = fs.processPath(target)
|
|
666
|
+
let notified = 0
|
|
667
|
+
let watchError = null
|
|
668
|
+
let unwatch
|
|
669
|
+
try {
|
|
670
|
+
unwatch = await fs.watch(target, (error) => {
|
|
671
|
+
if (error === undefined) notified += 1
|
|
672
|
+
else watchError = String(error?.message ?? error)
|
|
673
|
+
}, controller.signal)
|
|
674
|
+
const execution = await runShellSpec(shell, shell.resolve({ command, workdir: workspaceCwd, sandboxPolicy: workspaceWritePolicy }))
|
|
675
|
+
// The mutation's outcome is REPORTED, not assumed. A refused write otherwise
|
|
676
|
+
// leaves `notified: 0` with a null `watchError`, which reads exactly like a
|
|
677
|
+
// watcher that armed and observed nothing — two different facts this row must
|
|
678
|
+
// not conflate.
|
|
679
|
+
const result = await execution.result()
|
|
680
|
+
if (result.exitCode !== 0) {
|
|
681
|
+
const failure = `the mutation failed: exit ${result.exitCode ?? 'null'} ${(result.stderr?.text ?? '').slice(0, 200)}`.trim()
|
|
682
|
+
// Both facts survive: a watcher error and a failed mutation can coexist, and
|
|
683
|
+
// reporting only one of them drops the other from the row.
|
|
684
|
+
watchError = watchError === null ? failure : `${watchError}; ${failure}`
|
|
685
|
+
}
|
|
686
|
+
// The window is 5 polling periods: measured latency is 2-52ms, so 1500ms is
|
|
687
|
+
// ~29x the slowest measured case, and a poll that never runs still reddens.
|
|
688
|
+
await new Promise((resolve) => setTimeout(resolve, 5 * WATCH_INTERVAL_MS))
|
|
689
|
+
} catch (error) {
|
|
690
|
+
watchError = error instanceof Error ? error.message : String(error)
|
|
691
|
+
} finally {
|
|
692
|
+
controller.abort()
|
|
693
|
+
if (unwatch !== undefined) await unwatch().catch(() => {})
|
|
694
|
+
}
|
|
695
|
+
return { notified, watchError, linuxPath }
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// The probe file EXISTS before the watch is armed — that is the point of the row.
|
|
699
|
+
// `fs.watchFile` on an ABSENT path notifies once immediately at arming (measured
|
|
700
|
+
// Node semantics, pinned offline in scripts/verify-watch.mjs, because an injected
|
|
701
|
+
// io can never fire by itself: only the default seam shows the baseline is real).
|
|
702
|
+
// With the file absent, `notified > 0` would be satisfied by that arming baseline
|
|
703
|
+
// alone — even if the in-distribution append below were never observed. An
|
|
704
|
+
// existing file is silent until it changes, so a notification here can only be
|
|
705
|
+
// the append.
|
|
706
|
+
const fileTarget = await fs.resolve('dsh-wsl-watch-probe.txt', { cwd: workspaceCwd })
|
|
707
|
+
// Created through the shell this block already runs its mutations with. A create
|
|
708
|
+
// that FAILED must not read as a false green, so its outcome is reported in the
|
|
709
|
+
// row: an absent path would notify from its arming baseline and pass on nothing.
|
|
710
|
+
const probeCreated = await runShellSpec(shell, shell.resolve({ command: `printf 'x\\n' > '${fs.processPath(fileTarget)}'`, workdir: workspaceCwd, sandboxPolicy: workspaceWritePolicy }))
|
|
711
|
+
.then((execution) => execution.result())
|
|
712
|
+
.catch((error) => ({ exitCode: null, stderr: { text: String(error?.message ?? error) } }))
|
|
713
|
+
record('fs.watch.file', probeCreated.exitCode === 0
|
|
714
|
+
? await observeWatch(fileTarget, `printf 'x\\n' >> '${fs.processPath(fileTarget)}'`)
|
|
715
|
+
: {
|
|
716
|
+
notified: 0,
|
|
717
|
+
watchError: `the probe file could not be created: exit ${probeCreated.exitCode ?? 'null'} ${probeCreated.stderr?.text?.slice(0, 200) ?? ''}`.trim(),
|
|
718
|
+
linuxPath: fs.processPath(fileTarget),
|
|
719
|
+
})
|
|
720
|
+
|
|
721
|
+
const dirTarget = await fs.resolve('.', { cwd: workspaceCwd })
|
|
722
|
+
const dirCommand = `printf 'x\\n' > '${fs.processPath(dirTarget)}/dsh-wsl-watch-entry.txt'`
|
|
723
|
+
record('fs.watch.dir', await observeWatch(dirTarget, dirCommand))
|
|
724
|
+
await runShellSpec(shell, shell.resolve({ command: `rm -f '${fs.processPath(dirTarget)}/dsh-wsl-watch-entry.txt'`, workdir: workspaceCwd, sandboxPolicy: workspaceWritePolicy }))
|
|
725
|
+
.then((execution) => execution.result()).catch(() => {})
|
|
726
|
+
|
|
727
|
+
// Why the override exists, recorded rather than assumed: if a future host or
|
|
728
|
+
// WSL release makes this work, this row says so and the override can go.
|
|
729
|
+
let nativeThrew = false
|
|
730
|
+
let nativeCode = null
|
|
731
|
+
try {
|
|
732
|
+
const probe = nativeFsWatch(fileTarget.targetKey, () => {})
|
|
733
|
+
probe.close()
|
|
734
|
+
} catch (error) {
|
|
735
|
+
nativeThrew = true
|
|
736
|
+
nativeCode = error?.code ?? null
|
|
737
|
+
}
|
|
738
|
+
record('fs.watch.native', { threw: nativeThrew, code: nativeCode, path: fileTarget.targetKey })
|
|
739
|
+
}
|
|
637
740
|
}
|
|
638
741
|
|
|
639
742
|
const subprocess = realmService('subprocess')
|
package/lib/wsl/confinement.js
CHANGED
|
@@ -176,10 +176,11 @@ function helperGateScript(helperPath) {
|
|
|
176
176
|
'ok=yes',
|
|
177
177
|
// TYPE FIRST, and by an explicit test rather than by 'test -x': a root:root
|
|
178
178
|
// 0755 DIRECTORY satisfies -x, uid 0, no write bits and every ancestor test,
|
|
179
|
-
// so the ownership predicate answered yes for a directory — measured, as
|
|
180
|
-
//
|
|
181
|
-
// half refused it, and only because sudo cannot exec a
|
|
182
|
-
// claims to require an executable FILE; this is where it
|
|
179
|
+
// so the ownership predicate answered yes for a directory — measured, as an
|
|
180
|
+
// ordinary (non-root) user, with /opt/.../dir-helper (drwxr-xr-x root root).
|
|
181
|
+
// Only the version half refused it, and only because sudo cannot exec a
|
|
182
|
+
// directory. The gate claims to require an executable FILE; this is where it
|
|
183
|
+
// measures that.
|
|
183
184
|
'[ -f "$f" ] || ok=no',
|
|
184
185
|
// A symlink is REFUSED, not resolved. The grant names a PATH, so the link
|
|
185
186
|
// and its target are both part of the trust decision: resolving would have
|
package/lib/wsl/fs.js
CHANGED
|
@@ -26,6 +26,7 @@ import z from '@deepseek-ai/schemastery'
|
|
|
26
26
|
import { crossDistroUnc, isUnderHost, writableHostRootsFor } from './fence.js'
|
|
27
27
|
import { isWindowsPathShaped, joinWslUnc, mntToWindowsPath, parseWslUnc, windowsToMntPath } from './paths.js'
|
|
28
28
|
import { publishReplace } from './publish.js'
|
|
29
|
+
import { startWatch, watchStrategyFor } from './watch.js'
|
|
29
30
|
|
|
30
31
|
/** Share-level errors that mean "this primitive is unavailable", not "the operation failed". */
|
|
31
32
|
const PRIMITIVE_UNAVAILABLE = new Set(['ENOTSUP', 'EOPNOTSUPP', 'EPERM', 'EINVAL'])
|
|
@@ -233,6 +234,33 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
233
234
|
throw new FsError(`cannot write "${target.displayPath}": file access denied under ${deniedUnder}`, 'FS_SANDBOX_DENIED')
|
|
234
235
|
}
|
|
235
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Observe one target through a strategy this share supports.
|
|
239
|
+
*
|
|
240
|
+
* Overridden because the inherited implementation cannot initialize here: it
|
|
241
|
+
* watches a DIRECTORY in both branches (the target, or a file target's `dirname`)
|
|
242
|
+
* and its primitive, `fs.watch`, throws `EISDIR` on `\\wsl.localhost\…` — for a
|
|
243
|
+
* file target as well as a directory one (measured; docs/WATCH.md). Unoverridden,
|
|
244
|
+
* `ctx.fs.watch` rejects for every WSL target and the workspace file tree keeps
|
|
245
|
+
* only manual refresh.
|
|
246
|
+
*
|
|
247
|
+
* The host path comes from the TARGET KEY, never from `processPath`: that returns
|
|
248
|
+
* the LINUX spelling for the model and for subprocesses, so resolving it on this
|
|
249
|
+
* side would put a watcher on `E:\home\…` for `/home/…` — observing something else
|
|
250
|
+
* entirely, and silently, since the wrong path usually does not exist.
|
|
251
|
+
* @param {{ targetKey: string }} target - the resolved target.
|
|
252
|
+
* @param {(error?: Error) => void} changed - invalidation callback.
|
|
253
|
+
* @param {AbortSignal} signal - cancels watcher initialization.
|
|
254
|
+
* @returns {Promise<() => Promise<void>>} the disposer once observation is active.
|
|
255
|
+
*/
|
|
256
|
+
async watch(target, changed, signal) {
|
|
257
|
+
signal.throwIfAborted()
|
|
258
|
+
const info = await this.stat(target, signal)
|
|
259
|
+
signal.throwIfAborted()
|
|
260
|
+
const kind = info?.type === 'directory' ? 'directory' : info === undefined ? 'absent' : 'file'
|
|
261
|
+
return startWatch({ hostPath: target.targetKey, strategy: watchStrategyFor(kind), changed, signal })
|
|
262
|
+
}
|
|
263
|
+
|
|
236
264
|
/**
|
|
237
265
|
* Fence the write by the per-call policy, then delegate to the inherited
|
|
238
266
|
* atomic write with the checked target.
|
package/lib/wsl/watch.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File watching for the distribution's 9P share.
|
|
3
|
+
*
|
|
4
|
+
* The share does not support the primitive the host backend is built on: `fs.watch`
|
|
5
|
+
* throws `EISDIR` on `\\wsl.localhost\…` for a DIRECTORY target and for a FILE target
|
|
6
|
+
* alike (measured; docs/WATCH.md). `LocalFileSystem.watch` watches a directory in
|
|
7
|
+
* both branches — the target, or a file target's `dirname` — so it cannot initialize
|
|
8
|
+
* here at all, and `ctx.fs.watch` rejects for every WSL target: the workspace's file
|
|
9
|
+
* tree loses its live refresh and keeps manual refresh only.
|
|
10
|
+
*
|
|
11
|
+
* This module is the strategy that does work over the share: user-space polling on
|
|
12
|
+
* `node:fs` alone. (chokidar is not importable: it is a transitive dependency of the
|
|
13
|
+
* host's fs-local, and this plugin declares no dependencies.)
|
|
14
|
+
*
|
|
15
|
+
* - `dir-entries` — one readdir per tick, comparing the entry-name set. Measured ~0ms
|
|
16
|
+
* CPU per 3s and 2-3ms latency at 200 entries. Blind to a child's CONTENT change,
|
|
17
|
+
* which is not what a consumer of a directory target asks for.
|
|
18
|
+
* - `path-stat` — `fs.watchFile` on one path. Measured ~0ms CPU per 3s and 52ms. Also
|
|
19
|
+
* covers a file target and an absent path observed for creation.
|
|
20
|
+
* @module dsh-wsl-desktop/wsl/watch
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// `readdir` comes from the PROMISE surface, because the io seam calls it promise-style
|
|
24
|
+
// (`await io.readdir(path)`) and the injected fake is async: the callback-style
|
|
25
|
+
// `fs.readdir` throws ERR_INVALID_ARG_TYPE the moment it is handed no callback
|
|
26
|
+
// (measured), so the same call that passes the suite would refuse every WSL target.
|
|
27
|
+
// The stat poller's two calls are the callback surface's own and stay on `node:fs`.
|
|
28
|
+
import { unwatchFile as nodeUnwatchFile, watchFile as nodeWatchFile } from 'node:fs'
|
|
29
|
+
import { readdir as nodeReaddir } from 'node:fs/promises'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The polling period of both strategies.
|
|
33
|
+
*
|
|
34
|
+
* A constant, not a config field: both strategies measured ~0ms CPU per 3s at 200
|
|
35
|
+
* entries, so there is no lever to hand an operator. If a deployment ever measures
|
|
36
|
+
* cost, this is the single place it would be turned.
|
|
37
|
+
*/
|
|
38
|
+
export const WATCH_INTERVAL_MS = 300
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The strategy for a target.
|
|
42
|
+
* @param {'directory' | 'file' | 'absent'} kind - what the target is right now.
|
|
43
|
+
* @returns {'dir-entries' | 'path-stat'} the strategy name.
|
|
44
|
+
*/
|
|
45
|
+
export function watchStrategyFor(kind) {
|
|
46
|
+
return kind === 'directory' ? 'dir-entries' : 'path-stat'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The comparison key of one directory reading.
|
|
51
|
+
* @param {readonly string[]} names - entry names, in any order.
|
|
52
|
+
* @returns {string} the key: sorted and NUL-joined.
|
|
53
|
+
*/
|
|
54
|
+
export function entrySetKey(names) {
|
|
55
|
+
return [...names].sort().join('\0')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether a directory reading differs from the previous one.
|
|
60
|
+
* @param {string | undefined} previousKey - the previous key; undefined before the first reading.
|
|
61
|
+
* @param {string} nextKey - this reading's key.
|
|
62
|
+
* @returns {boolean} true only when a baseline exists AND differs — the first reading
|
|
63
|
+
* establishes the baseline silently, mirroring fs-local's `ignoreInitial: true`.
|
|
64
|
+
* Without it every watch would report a change the moment it opens.
|
|
65
|
+
*/
|
|
66
|
+
export function entrySetChanged(previousKey, nextKey) {
|
|
67
|
+
return previousKey !== undefined && previousKey !== nextKey
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The io and timer surface this module uses.
|
|
72
|
+
*
|
|
73
|
+
* NOT all of it is `node:fs`: `readdir` is the `node:fs/promises` surface, for the reason
|
|
74
|
+
* the import note above records, so "the default is node:fs" would name the wrong module
|
|
75
|
+
* for one of the three calls.
|
|
76
|
+
*
|
|
77
|
+
* Injectable so the suite drives ticks and IO by hand: with the real timers a check
|
|
78
|
+
* would have to sleep and race the poll it is measuring.
|
|
79
|
+
*/
|
|
80
|
+
const NODE_IO = {
|
|
81
|
+
readdir: nodeReaddir,
|
|
82
|
+
watchFile: nodeWatchFile,
|
|
83
|
+
unwatchFile: nodeUnwatchFile,
|
|
84
|
+
setInterval: globalThis.setInterval,
|
|
85
|
+
clearInterval: globalThis.clearInterval,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read one directory reading as a key, answering an absent directory with the empty set.
|
|
90
|
+
*
|
|
91
|
+
* The contract observes a path for CREATION as well as for change, so a directory that
|
|
92
|
+
* is not there is an empty entry set rather than a failure — its deletion and its
|
|
93
|
+
* reappearance are both changes. One qualification: an EMPTY directory produces this same
|
|
94
|
+
* key, so an empty directory being created or removed notifies nobody (the limitation
|
|
95
|
+
* `docs/WATCH.md` states). Every other error belongs to the caller.
|
|
96
|
+
* @param {string} hostPath - the target's host path.
|
|
97
|
+
* @param {object} io - the io seam.
|
|
98
|
+
* @returns {Promise<string>} the entry-set key.
|
|
99
|
+
*/
|
|
100
|
+
async function readEntryKey(hostPath, io) {
|
|
101
|
+
try {
|
|
102
|
+
return entrySetKey(await io.readdir(hostPath))
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error?.code === 'ENOENT') return ''
|
|
105
|
+
throw error
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* One close path for both strategies: idempotent, stops the strategy, detaches the
|
|
111
|
+
* abort listener it added.
|
|
112
|
+
*
|
|
113
|
+
* The base contract uses `signal` to cancel INITIALIZATION only — the caller closes an
|
|
114
|
+
* initialized watcher. Stopping an active poll on a later abort as well is a deliberate
|
|
115
|
+
* superset: a leaked poller costs a syscall every interval forever, and the disposer is
|
|
116
|
+
* idempotent, so honouring a late abort cannot double-close.
|
|
117
|
+
* @param {() => void} stop - the strategy's own teardown.
|
|
118
|
+
* @param {AbortSignal} signal - the signal the caller passed.
|
|
119
|
+
* @returns {() => Promise<void>} the disposer.
|
|
120
|
+
*/
|
|
121
|
+
function makeDisposer(stop, signal) {
|
|
122
|
+
let stopped = false
|
|
123
|
+
const onAbort = () => { void dispose() }
|
|
124
|
+
/**
|
|
125
|
+
* Stop observing.
|
|
126
|
+
* @returns {Promise<void>} once stopped.
|
|
127
|
+
*/
|
|
128
|
+
function dispose() {
|
|
129
|
+
if (stopped) return Promise.resolve()
|
|
130
|
+
stopped = true
|
|
131
|
+
stop()
|
|
132
|
+
signal.removeEventListener('abort', onAbort)
|
|
133
|
+
return Promise.resolve()
|
|
134
|
+
}
|
|
135
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
136
|
+
return dispose
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Poll one directory's direct entries.
|
|
141
|
+
* @param {object} options - as {@link startWatch}.
|
|
142
|
+
* @returns {Promise<() => Promise<void>>} the disposer once the baseline is read.
|
|
143
|
+
*/
|
|
144
|
+
async function startDirEntries({ hostPath, changed, signal, intervalMs, io }) {
|
|
145
|
+
let previousKey
|
|
146
|
+
let ticking = false
|
|
147
|
+
let stopped = false
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* One poll: read the entries, compare, notify at most once.
|
|
151
|
+
* @returns {Promise<void>} once this tick has settled.
|
|
152
|
+
*/
|
|
153
|
+
const tick = async () => {
|
|
154
|
+
if (ticking || stopped) return
|
|
155
|
+
ticking = true
|
|
156
|
+
try {
|
|
157
|
+
const key = await readEntryKey(hostPath, io)
|
|
158
|
+
const previous = previousKey
|
|
159
|
+
previousKey = key
|
|
160
|
+
if (entrySetChanged(previous, key)) changed()
|
|
161
|
+
} catch (error) {
|
|
162
|
+
changed(error instanceof Error ? error : new Error(String(error)))
|
|
163
|
+
} finally {
|
|
164
|
+
ticking = false
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// The baseline is read BEFORE the watch reports itself active: a change landing
|
|
169
|
+
// between the call and the first tick must be a change, not the baseline. It also
|
|
170
|
+
// makes initialization fail loudly for a path that can never be read, which is what
|
|
171
|
+
// the base contract's `@throws cannot initialize` asks for.
|
|
172
|
+
previousKey = await readEntryKey(hostPath, io)
|
|
173
|
+
signal.throwIfAborted()
|
|
174
|
+
// The callback RETURNS the tick's promise rather than discarding it (`void tick()`):
|
|
175
|
+
// the real `setInterval` ignores a callback's return value, while the injected seam
|
|
176
|
+
// hands it back, and only a poll the seam hands back can be awaited. `tick` reports
|
|
177
|
+
// every failure through `changed`, so nothing is left unhandled either way.
|
|
178
|
+
const timer = io.setInterval(() => tick(), intervalMs)
|
|
179
|
+
return makeDisposer(() => { stopped = true; io.clearInterval(timer) }, signal)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Watch one path's stat.
|
|
184
|
+
* @param {object} options - as {@link startWatch}.
|
|
185
|
+
* @returns {() => Promise<void>} the disposer.
|
|
186
|
+
*/
|
|
187
|
+
function startPathStat({ hostPath, changed, signal, intervalMs, io }) {
|
|
188
|
+
const listener = () => { changed() }
|
|
189
|
+
io.watchFile(hostPath, { interval: intervalMs, persistent: true }, listener)
|
|
190
|
+
// The listener goes to `unwatchFile` so only THIS registration is removed: Node
|
|
191
|
+
// refcounts one StatWatcher per path, and `unwatchFile(path)` without a listener
|
|
192
|
+
// would drop every consumer watching that same path.
|
|
193
|
+
return makeDisposer(() => io.unwatchFile(hostPath, listener), signal)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Observe one target through a strategy the share supports.
|
|
198
|
+
* @param {object} options - the watch request.
|
|
199
|
+
* @param {string} options.hostPath - the target's HOST path (a UNC spelling for WSL).
|
|
200
|
+
* @param {'dir-entries' | 'path-stat'} options.strategy - from {@link watchStrategyFor}.
|
|
201
|
+
* @param {(error?: Error) => void} options.changed - the invalidation callback.
|
|
202
|
+
* @param {AbortSignal} options.signal - cancels initialization.
|
|
203
|
+
* @param {number} [options.intervalMs] - the polling period.
|
|
204
|
+
* @param {object} [options.io] - the io seam; defaults to `NODE_IO` — `readdir` from
|
|
205
|
+
* `node:fs/promises`, the stat poller from `node:fs`, and the real timers.
|
|
206
|
+
* @returns {Promise<() => Promise<void>>} the disposer once observation is active.
|
|
207
|
+
*/
|
|
208
|
+
export async function startWatch({ hostPath, strategy, changed, signal, intervalMs = WATCH_INTERVAL_MS, io = NODE_IO }) {
|
|
209
|
+
signal.throwIfAborted()
|
|
210
|
+
if (strategy === 'path-stat') return startPathStat({ hostPath, changed, signal, intervalMs, io })
|
|
211
|
+
return startDirEntries({ hostPath, changed, signal, intervalMs, io })
|
|
212
|
+
}
|