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 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 six documents this restructure created have an English edition (linked above; the file names carry an `.en` marker) and the pair is enforced 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.
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 + 18 offline suites; each item below notes its evidence strength):
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 (18)
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` 全绿 + 离线 18 套件;下面每条都注明证据强度):
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 # 全部离线套件(18 个)
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')
@@ -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
- // zcluo, with /opt/.../dir-helper (drwxr-xr-x root root). Only the version
181
- // half refused it, and only because sudo cannot exec a directory. The gate
182
- // claims to require an executable FILE; this is where it measures that.
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.
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wsl-desktop",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "icon": "./lib/icon.svg",