dsh-wsl-desktop 0.3.3 → 0.3.4

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/lib/client.js CHANGED
@@ -446,7 +446,7 @@ window.__ModuleLoader__.load({
446
446
  function render() {
447
447
  const body = []
448
448
 
449
- body.push(el('div', { fontSize: '15px', fontWeight: '600' }, '添加 WSL 工作区'))
449
+ body.push(el('div', { fontSize: '15px', fontWeight: '600' }, BUTTON_LABEL))
450
450
  body.push(el('div', { fontSize: '12px', color: T.dim },
451
451
  '新会话的 bash 与文件工具都会在该发行版里工作;Windows 文件可在 /mnt/<盘符> 下访问。'))
452
452
 
@@ -610,17 +610,28 @@ window.__ModuleLoader__.load({
610
610
  hostElement().append(overlay)
611
611
  render()
612
612
 
613
+ // The rejection handler only ever sees a refusal of listDistros itself; a
614
+ // throw INSIDE the continuation (a body the route did not expect,
615
+ // chooseDistro failing) would surface as an unhandled rejection —
616
+ // state.distros assigned without a render, the dialog stuck on its reading
617
+ // line, and nothing anywhere to read. One handler owns the whole chain.
613
618
  call('listDistros').then(async (names) => {
614
- if (state.closed) return
615
- state.distros = names
616
- const fallback = await call('defaultDistro').catch(() => ({ distro: null }))
617
- if (state.closed) return
618
- const selected = names.includes(fallback.distro) ? fallback.distro : names[0] ?? null
619
- if (selected === null) {
619
+ try {
620
+ if (state.closed) return
621
+ state.distros = names
622
+ const fallback = await call('defaultDistro').catch(() => ({ distro: null }))
623
+ if (state.closed) return
624
+ const selected = names.includes(fallback.distro) ? fallback.distro : names[0] ?? null
625
+ if (selected === null) {
626
+ render()
627
+ return
628
+ }
629
+ chooseDistro(selected)
630
+ } catch (failure) {
631
+ if (state.closed) return
632
+ state.error = message(failure)
620
633
  render()
621
- return
622
634
  }
623
- chooseDistro(selected)
624
635
  }, (failure) => {
625
636
  if (state.closed) return
626
637
  state.error = message(failure)
@@ -806,6 +817,30 @@ window.__ModuleLoader__.load({
806
817
  }
807
818
  }
808
819
 
820
+ /**
821
+ * Whether one observed mutation could have changed a workspace row's markup.
822
+ *
823
+ * Chat streaming mutates the transcript many times a second, and the icon scan
824
+ * is a whole-document `querySelectorAll` — running it at that rate is exactly
825
+ * the per-frame scan the trigger cache exists to avoid, and the comment on
826
+ * `sync` claimed it did not happen when it did. A row only changes when React
827
+ * writes inside one (add, remove, relabel, expand) or replaces its container,
828
+ * so a record whose target and nodes all sit outside every workspace row
829
+ * cannot have moved what the scan reads.
830
+ * @param {MutationRecord} record - one observed mutation.
831
+ * @returns {boolean} true when the icon scan has to re-run.
832
+ */
833
+ function touchesWorkspaceRow(record) {
834
+ const target = record.target
835
+ const anchor = target instanceof Element ? target : target.parentElement
836
+ if (anchor === null) return false
837
+ // Inside a row, IS a row, or IS an ancestor of the rows (the list container
838
+ // React re-renders rows into). The transcript pane satisfies none of the
839
+ // three, which is the whole point.
840
+ if (anchor.closest('[data-row-key^="workspace:"]') !== null) return true
841
+ return anchor.querySelector('[data-row-key^="workspace:"]') !== null
842
+ }
843
+
809
844
  /**
810
845
  * Re-attach the companion button when React replaced or reordered its anchor.
811
846
  *
@@ -814,8 +849,12 @@ window.__ModuleLoader__.load({
814
849
  * of feeding the observer a fresh mutation; hiding while connected is
815
850
  * expressed as `display`, which is not a childList mutation at all.
816
851
  * @param {object} ctx - the client plugin context.
852
+ * @param {boolean} [iconsDirty] - false skips the whole-document workspace-row
853
+ * scan: the observer only asks for it when a mutation actually touched a
854
+ * workspace row, so transcript churn (chat streaming) costs the cheap
855
+ * placement checks below and nothing more.
817
856
  */
818
- function sync(ctx) {
857
+ function sync(ctx, iconsDirty = true) {
819
858
  companion.scheduled = false
820
859
  // A frame queued before disposal must not run: it would re-mark the rows
821
860
  // the disposer just restored, with no observer left to converge it (see
@@ -823,7 +862,7 @@ window.__ModuleLoader__.load({
823
862
  if (companion.disposed === true) return
824
863
  // Runs before the companion's own early returns: marking workspace rows
825
864
  // is independent of whether the add-workspace trigger was found.
826
- syncWorkspaceIcons(ctx)
865
+ if (iconsDirty) syncWorkspaceIcons(ctx)
827
866
  const { button } = companion
828
867
  if (button === null) return
829
868
  // Steady-state fast path: the cached trigger stays valid while it
@@ -902,12 +941,36 @@ window.__ModuleLoader__.load({
902
941
  // Cleared on every install: the flag is set by the disposer and this
903
942
  // companion object outlives a disable/enable cycle.
904
943
  companion.disposed = false
905
- const observer = new MutationObserver(() => {
906
- if (companion.scheduled) return
944
+ // Two independent things make the icon scan necessary, so they OR into one
945
+ // coalesced frame: the DOM actually changed inside the workspace rows, or the
946
+ // workspace LIST changed under them (a re-point edits the store, and the row
947
+ // markup may not move at all). Either trigger upgrades a scheduled
948
+ // transcript-only sync to a full pass; neither adds a timer.
949
+ let pendingIcons = false
950
+ const schedule = (iconsDirty) => {
951
+ if (companion.scheduled) {
952
+ if (iconsDirty) pendingIcons = true
953
+ return
954
+ }
907
955
  companion.scheduled = true
908
- requestAnimationFrame(() => sync(ctx))
956
+ pendingIcons = iconsDirty
957
+ requestAnimationFrame(() => {
958
+ const dirty = pendingIcons
959
+ pendingIcons = false
960
+ sync(ctx, dirty)
961
+ })
962
+ }
963
+ const observer = new MutationObserver((mutations) => {
964
+ // The record list is the cheap discriminator: a transcript-only burst
965
+ // schedules a sync that skips the icon scan, while anything that touched a
966
+ // workspace row — or the container React renders rows into — schedules the
967
+ // full pass. Either way the placement checks below run as before.
968
+ schedule(mutations.some((record) => touchesWorkspaceRow(record)))
909
969
  })
910
970
  companion.observer = observer
971
+ const workspaceSource = ctx.workspaces?.list
972
+ const unsubscribe =
973
+ typeof workspaceSource?.subscribe === 'function' ? workspaceSource.subscribe(() => schedule(true)) : null
911
974
  sync(ctx)
912
975
  observer.observe(document.documentElement, { childList: true, subtree: true })
913
976
  const warning = setTimeout(() => {
@@ -920,6 +983,7 @@ window.__ModuleLoader__.load({
920
983
  return () => {
921
984
  clearTimeout(warning)
922
985
  observer.disconnect()
986
+ unsubscribe?.()
923
987
  // disconnect() does not cancel a callback already handed to
924
988
  // requestAnimationFrame, and that queued sync would re-inject the cloud
925
989
  // icons (and re-hide the shipped folder icons) after the loop below put
package/lib/index.js CHANGED
@@ -513,30 +513,32 @@ const METHODS = {
513
513
  }
514
514
  }
515
515
 
516
- // 0.1.7 moved serviceFor off the registry (now a standalone
517
- // serviceForAgent in the package). The harness's own consumers use
518
- // `agent.ctx.get(name)` — which resolves through the agent's scope chain
519
- // into the realm — so that's the compatible form on both generations.
520
- // 0.1.7 mounts preset services under the standing mount's own fiber —
521
- // `agent.ctx.get` resolves the HOST services (the mount is not in the
522
- // agent's scope chain). The harness's accessor is the exported
523
- // serviceForAgent(ctx, agent, name); resolve it dynamically (the
524
- // registry package lives in the host graph, not in this plugin's
525
- // node_modules) and fall back to ctx.get on pre-0.1.7 hosts.
526
- let realmService = null
527
- try {
528
- const registryModule = await import('@deepseek-ai/dsh-agent-preset-registry')
529
- if (typeof registryModule.serviceForAgent === 'function') {
530
- realmService = (name) => registryModule.serviceForAgent(hostCtx, agent, name)
531
- }
532
- } catch {
533
- // Registry package not resolvable from this plugin — pre-0.1.7 host.
534
- }
535
- if (realmService === null) realmService = (name) => agent.ctx.get(name)
516
+ // A realm hides its services from the outside by design: the preset mount
517
+ // owns its own fiber, so `agent.ctx.get(name)` resolves the HOST plane.
518
+ // The registry is the only thing that can look inside, and it exposes
519
+ // that as the SERVICE METHOD `agentPresets.serviceFor(agent, name)`.
520
+ //
521
+ // This used to resolve the package-level `serviceForAgent(ctx, agent,
522
+ // name)` export dynamically. DSH 0.2.1-alpha.1 REMOVED that export
523
+ // (commit 2e3ab61940, "fix(presets): own composition mounts in registry
524
+ // service"; no upgrade-guide entry covers it), and the removal was
525
+ // silent here: the typeof probe simply missed, the ctx.get fallback then
526
+ // measured the HOST shell, and every step still reported success.
527
+ // `serviceFor` performs the same mount-fiber store scan the removed
528
+ // export did, reached through the service instead of the module.
529
+ //
530
+ // The ctx.get fallback stays for genuinely pre-0.1.7 hosts, but the
531
+ // accessor that answered travels WITH each observation, so a fallback
532
+ // can never again be read as a realm measurement.
533
+ const realmAccessor = typeof presets.serviceFor === 'function' ? 'agentPresets.serviceFor' : 'agent.ctx.get'
534
+ const realmService = realmAccessor === 'agentPresets.serviceFor'
535
+ ? (name) => presets.serviceFor(agent, name)
536
+ : (name) => agent.ctx.get(name)
536
537
 
537
538
  const shell = realmService('shell')
538
539
  record('shell.service', {
539
540
  found: shell !== undefined,
541
+ accessor: realmAccessor,
540
542
  sandboxMode: shell?.sandboxMode ?? null,
541
543
  proto: Object.getOwnPropertyNames(Object.getPrototypeOf(shell ?? {})).join(','),
542
544
  executeType: typeof shell?.execute,
@@ -561,7 +563,7 @@ const METHODS = {
561
563
  }
562
564
 
563
565
  const fs = realmService('fs')
564
- record('fs.service', { found: fs !== undefined })
566
+ record('fs.service', { found: fs !== undefined, accessor: realmAccessor })
565
567
  if (fs !== undefined) {
566
568
  // A mutation is called the way the tool layer calls it: `tool-fs`
567
569
  // resolves a per-call policy and stamps the calling session's cwd as the
@@ -592,7 +594,7 @@ const METHODS = {
592
594
  }
593
595
 
594
596
  const subprocess = realmService('subprocess')
595
- record('subprocess.service', { found: subprocess !== undefined })
597
+ record('subprocess.service', { found: subprocess !== undefined, accessor: realmAccessor })
596
598
  if (subprocess !== undefined) {
597
599
  try {
598
600
  const environment = await subprocess.terminalEnvironment()
@@ -722,10 +724,16 @@ const METHODS = {
722
724
  * @param {{ distro?: string, linuxPath?: string }} params - target directory.
723
725
  * @returns {Promise<object>} what each step observed.
724
726
  */
725
- workspaceFlow: async ({ distro, linuxPath = '/tmp' }) => {
727
+ workspaceFlow: async ({ distro, linuxPath }) => {
726
728
  const registry = hostCtx.get('workspaceRegistry')
727
729
  if (registry === undefined) throw new Error('workspaceRegistry 服务不可用')
728
- const target = await resolveLocation(linuxPath, distro)
730
+ // Default the same way the selftest does — the distribution home, never /tmp:
731
+ // the fence refuses a workspace root under /tmp (confinement mounts a private
732
+ // tmpfs over it after binding the workspace), so a workspace registered there
733
+ // could never be used as a writable root. This method's old default handed the
734
+ // registry exactly such a path.
735
+ const location = linuxPath ?? (await defaultWorkspaceUnc({ wslPath: routeWslPath })).uncPath
736
+ const target = await resolveLocation(location, distro)
729
737
  const listing = await listLinuxDir(target.distro, target.linuxPath, { wslPath: routeWslPath })
730
738
  const facts = await checkLinuxPath(target.distro, target.linuxPath, { wslPath: routeWslPath })
731
739
  const existing = await registry.resolveByPath(target.uncPath)
@@ -959,7 +967,11 @@ export function apply(ctx, config) {
959
967
  hostCtx = ctx
960
968
  routeWslPath = config.wslPath
961
969
  // Bound concurrent route methods: each discovery call spawns wsl.exe, and a
962
- // misbehaving page could fan out without limit.
970
+ // misbehaving page could fan out without limit. Named because a bare "8" here
971
+ // was once mis-read as a wsl.exe SPAWN cap — it is not: one route method may
972
+ // fan out several children (a command plus its probes). The spawn cap is
973
+ // MAX_CONCURRENT_SPAWNS in lib/wsl/world.js.
974
+ const MAX_ROUTE_IN_FLIGHT = 8
963
975
  let routeInFlight = 0
964
976
  // The WSL providers start `wsl.exe`, an ordinary Windows process, through the
965
977
  // host's own subprocess provider. A realm that isolates `subprocess` shadows
@@ -1030,7 +1042,7 @@ export function apply(ctx, config) {
1030
1042
  sendRejection(res, admitted)
1031
1043
  return
1032
1044
  }
1033
- if (routeInFlight >= 8) {
1045
+ if (routeInFlight >= MAX_ROUTE_IN_FLIGHT) {
1034
1046
  sendJson(res, 503, { ok: false, code: 'too-busy' })
1035
1047
  return
1036
1048
  }
@@ -576,6 +576,20 @@ function exemptPattern(keep) {
576
576
  return `^(${[...new Set([...keep, ...KERNEL_SURFACES])].map(escapeEre).join('|')})$`
577
577
  }
578
578
 
579
+ /**
580
+ * The shared head of the read-only sweep and the post-condition: list the
581
+ * namespace's mount targets, decode findmnt's hex escapes, drop the exempt ones
582
+ * (a failing grep is a setup failure, not a command failure), and hand the rest
583
+ * to a per-target loop. The two callers differ only in the loop BODY, so the head
584
+ * lives in one place — a decode or quoting fix cannot land in one and be missed
585
+ * in the other, which is the drift the helper exists to make impossible.
586
+ * @param {string} pattern - the exemption ERE as DATA (the callers pass
587
+ * exemptPattern(keep)'s output unquoted); this helper applies shellQuote.
588
+ * @returns {string} the pipeline up to the per-target loop.
589
+ */
590
+ function sweepHead(pattern) {
591
+ return `findmnt -rno TARGET | while IFS= read -r raw; do printf '%b\\n' "$raw"; done | { grep -Ev ${shellQuote(pattern)} || fail ${shellQuote('exemption grep failed')}; } | while IFS= read -r target; do`
592
+ }
579
593
  /**
580
594
  * The shell fragment that makes every remaining writable mount read-only.
581
595
  *
@@ -601,7 +615,7 @@ function readOnlySweep(keep) {
601
615
  // stay WRITABLE while the postcondition tested the bogus name. Decode
602
616
  // before matching: findmnt only ever emits \xNN for literal characters
603
617
  // (literal backslash arrives as \x5c), so bash's %b is lossless here.
604
- `findmnt -rno TARGET | while IFS= read -r raw; do printf '%b\\n' "$raw"; done | { grep -Ev ${shellQuote(pattern)} || fail ${shellQuote('exemption grep failed')}; } | while IFS= read -r target; do`,
618
+ sweepHead(pattern),
605
619
  ' mount -o remount,ro,bind "$target" >/dev/null 2>&1 || true;',
606
620
  'done',
607
621
  ].join('\n')
@@ -631,7 +645,7 @@ function postConditions(keep) {
631
645
  // `fail` still exits 97 from inside the loop, which pipefail turns into
632
646
  // the script's own setup failure — the marker and exit code still
633
647
  // surface.
634
- `findmnt -rno TARGET | while IFS= read -r raw; do printf '%b\\n' "$raw"; done | { grep -Ev ${shellQuote(pattern)} || fail ${shellQuote('exemption grep failed')}; } | while IFS= read -r target; do`,
648
+ sweepHead(pattern),
635
649
  ' [ -w "$target" ] && fail "$target is still writable";',
636
650
  ' true;',
637
651
  'done',
package/lib/wsl/fence.js CHANGED
@@ -36,6 +36,31 @@ export function canonicalHostPath(path) {
36
36
  }
37
37
  }
38
38
 
39
+ /**
40
+ * Whether two spellings name DIFFERENT distributions, both of them WSL UNCs.
41
+ *
42
+ * The rule behind the boolean: the Linux portion of a WSL UNC is ambiguous across
43
+ * distributions — 9P shares are not per-filesystem, so same-spelled paths on two
44
+ * distributions can carry the same content — and the distribution segment is a
45
+ * WINDOWS spelling, which folds case, so the comparison is case-insensitive while
46
+ * the Linux portion above it stays case-sensitive. One-sided answers (a drive
47
+ * path against a UNC root, either side not a WSL UNC at all) are NOT cross-distro:
48
+ * those compare by their own namespace rules.
49
+ *
50
+ * ONE owner, because this exact test stood in `isUnderHost` and in `contains()`
51
+ * (the fence's two containment consumers) and had to be kept in sync by hand.
52
+ * @param {string} a - one spelling.
53
+ * @param {string} b - the other spelling.
54
+ * @returns {boolean} true when both parse as WSL UNCs and the distribution
55
+ * segments differ.
56
+ */
57
+ export function crossDistroUnc(a, b) {
58
+ const aUnc = parseWslUnc(a)
59
+ const bUnc = parseWslUnc(b)
60
+ return aUnc !== null && bUnc !== null
61
+ && aUnc.distro.toLowerCase() !== bUnc.distro.toLowerCase()
62
+ }
63
+
39
64
  /**
40
65
  * Lexical containment with a separator boundary. The boundary is what stops
41
66
  * `/proj` matching `/proj-secret`.
@@ -222,10 +247,7 @@ export async function isUnderHost(targetKey, root) {
222
247
  // `wsl.exe -d DEBIAN` names the same distribution — so it is compared
223
248
  // case-insensitively, the way `isLexicallyUnderHost` folds the prefix and
224
249
  // `contains()` compares.
225
- const targetUnc = parseWslUnc(targetKey)
226
- const rootUnc = parseWslUnc(root)
227
- if (targetUnc !== null && rootUnc !== null
228
- && targetUnc.distro.toLowerCase() !== rootUnc.distro.toLowerCase()) return false
250
+ if (crossDistroUnc(targetKey, root)) return false
229
251
  const rootInfo = await statIfPresent(root)
230
252
  if (rootInfo === undefined) return false
231
253
  let ancestor = targetKey
package/lib/wsl/fs.js CHANGED
@@ -23,7 +23,7 @@ import { pathToFileURL } from 'node:url'
23
23
  import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
24
24
  import { FsError } from '@deepseek-ai/dsh-fs'
25
25
  import z from '@deepseek-ai/schemastery'
26
- import { isUnderHost, writableHostRootsFor } from './fence.js'
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
29
 
@@ -210,7 +210,14 @@ export class WslFileSystem extends LocalFileSystem {
210
210
  for (const root of this.writableHostRoots(policy)) {
211
211
  if (await isUnderHost(fresh.targetKey, root)) return fresh
212
212
  }
213
- throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, 'FS_SANDBOX_DENIED')
213
+ // Any mode that is neither granted nor refused above still fails closed —
214
+ // writableHostRootsFor grants nothing it does not recognise — but the denial
215
+ // must name the mode it actually saw: a mode arriving here that this backend
216
+ // does not implement would otherwise be triaged as a workspace-write
217
+ // containment question, with the real question (who passed this mode in)
218
+ // left unasked.
219
+ const deniedUnder = mode === 'workspace-write' ? 'workspace-write mode' : `unsupported mode '${String(mode)}'`
220
+ throw new FsError(`cannot write "${target.displayPath}": file access denied under ${deniedUnder}`, 'FS_SANDBOX_DENIED')
214
221
  }
215
222
 
216
223
  /**
@@ -316,10 +323,9 @@ export class WslFileSystem extends LocalFileSystem {
316
323
  * @returns {boolean} true when child is parent or below it.
317
324
  */
318
325
  contains(parent, child) {
319
- const parentDistro = parseWslUnc(parent.targetKey)?.distro
320
- const childDistro = parseWslUnc(child.targetKey)?.distro
321
- if (parentDistro !== undefined && childDistro !== undefined
322
- && parentDistro.toLowerCase() !== childDistro.toLowerCase()) return false
326
+ // The SAME cross-distribution rule fence.js's identity walk applies, now one
327
+ // shared helper — the two fail-closed distro checks cannot drift apart.
328
+ if (crossDistroUnc(parent.targetKey, child.targetKey)) return false
323
329
  const relative = posix.relative(this.processPath(parent), this.processPath(child))
324
330
  return relative === '' || (relative !== '..' && !relative.startsWith('../'))
325
331
  }
package/lib/wsl/paths.js CHANGED
@@ -12,8 +12,16 @@
12
12
  /** UNC hosts Windows uses for the WSL 9P share. */
13
13
  const UNC_HOSTS = new Set(['wsl.localhost', 'wsl$'])
14
14
 
15
- /** A distribution name that is safe to concatenate into a UNC path. */
16
- export const DISTRO_NAME = /^[A-Za-z0-9._-]+$/
15
+ /**
16
+ * A distribution name that is safe to concatenate into a UNC path.
17
+ *
18
+ * A name made ENTIRELY of dots is refused even though every one of its characters
19
+ * is in the class: as a path segment `.` and `..` are the current and the parent
20
+ * directory, so `\\wsl.localhost\..\<path>` would hand the share a traversal
21
+ * spelling. No real distribution name is all dots, and `wsl.exe -d ..` cannot be
22
+ * a legal invocation, so the lookahead costs nothing legitimate.
23
+ */
24
+ export const DISTRO_NAME = /^(?!\.+$)[A-Za-z0-9._-]+$/
17
25
 
18
26
  /** A Linux user name that is safe to pass as a single wsl.exe -u option value. */
19
27
  export const LINUX_USER = /^[A-Za-z0-9._][A-Za-z0-9._-]*\$?$/
package/lib/wsl/pty.js CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { appendFileSync } from 'node:fs'
19
19
  import { randomUUID } from 'node:crypto'
20
- import { homedir, tmpdir } from 'node:os'
20
+ import { homedir } from 'node:os'
21
21
  import { join } from 'node:path'
22
22
  import { shellQuote } from './paths.js'
23
23
  import { buildWslExecArgv, runWslShell } from './world.js'
package/lib/wsl/shell.js CHANGED
@@ -41,10 +41,7 @@ import {
41
41
  workspaceRootInLinux,
42
42
  } from './confinement.js'
43
43
  import { parseWslUnc, shellQuote, windowsToMntPath } from './paths.js'
44
- import { WSL_CHILD_ENV, planWsl, runWslShell, withWslEnvFlags } from './world.js'
45
-
46
- /** Model-readable environment overrides applied to every command. */
47
- const ENV_OVERRIDES = { ...WSL_CHILD_ENV, NO_COLOR: '1', TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat' }
44
+ import { ENV_OVERRIDES, planWsl, runWslShell, withWslEnvFlags } from './world.js'
48
45
 
49
46
  /** Default foreground timeout in milliseconds. */
50
47
  const DEFAULT_TIMEOUT_MS = 120_000
@@ -253,7 +250,9 @@ export class WslShellExecutor extends ShellExecutor {
253
250
  linuxCwd: plan.linuxCwd,
254
251
  mode,
255
252
  runner,
256
- workspaceLinuxRoot: workspaceLinuxRoot ?? undefined,
253
+ // Not `?? undefined`: the null case already threw above, so the fallback
254
+ // could never fire and the expression was a no-op read of the same value.
255
+ workspaceLinuxRoot,
257
256
  identity,
258
257
  noNewPrivs,
259
258
  isolateProcesses: this.config.isolateProcesses,
@@ -21,6 +21,7 @@ Exits with the child's own exit status (128 + signal when it was killed).
21
21
  """
22
22
 
23
23
  import errno
24
+ import atexit
24
25
  import fcntl
25
26
  import json
26
27
  import os
@@ -128,10 +129,22 @@ class Bridge:
128
129
  self.control_open = True
129
130
  self.revision = 0
130
131
  self.last_state = None
132
+ self.fifo = fifo
131
133
  self.control_fd = self._open_control(fifo)
132
134
  self._spawn()
133
135
  self.resize(cols, rows)
134
136
 
137
+ def remove_fifo(self):
138
+ """Unlink this bridge's control FIFO. Idempotent, and safe at any stage:
139
+ before the FIFO exists it raises FileNotFoundError, which is swallowed, and
140
+ after removal a second call does the same. The host runs a compensating
141
+ rm -f on every session-ending path, but that is a backstop for the paths
142
+ this process cannot catch (SIGKILL) — it is not the owner."""
143
+ try:
144
+ os.unlink(self.fifo)
145
+ except OSError:
146
+ pass
147
+
135
148
  def _open_control(self, fifo):
136
149
  """Create the control FIFO and hold its read end open."""
137
150
  try:
@@ -413,13 +426,37 @@ def main():
413
426
  cols, rows = sys.argv[2], sys.argv[3]
414
427
  argv = sys.argv[4:]
415
428
  bridge = Bridge(fifo, cols, rows, argv)
429
+
430
+ # The finally below covers every orderly path, but it does not run when the
431
+ # BRIDGE ITSELF is signalled — and the FIFO is the one thing this process owns
432
+ # exclusively, so the host's compensating rm -f must not be the only remover.
433
+ # SIGTERM and SIGINT therefore remove it first, then restore the default
434
+ # disposition and re-raise: a signalled bridge still reports 128+n, exactly the
435
+ # status it already had with no handler installed (an uncaught SIGTERM kills by
436
+ # default), so this changes what gets cleaned up and nothing else. SIGKILL is
437
+ # uncatchable by construction and stays the host's backstop.
438
+ atexit.register(bridge.remove_fifo)
439
+ for name in ("SIGTERM", "SIGINT"):
440
+ number = getattr(signal, name, None)
441
+ if number is None:
442
+ continue
443
+
444
+ def handler(signum, _frame, _default=number):
445
+ bridge.remove_fifo()
446
+ signal.signal(signum, signal.SIG_DFL)
447
+ os.kill(os.getpid(), signum)
448
+
449
+ try:
450
+ signal.signal(number, handler)
451
+ except (ValueError, OSError):
452
+ # Registered off the main thread, or the platform refuses it: the
453
+ # finally below still covers every orderly path.
454
+ pass
455
+
416
456
  try:
417
457
  code = bridge.run()
418
458
  finally:
419
- try:
420
- os.unlink(fifo)
421
- except OSError:
422
- pass
459
+ bridge.remove_fifo()
423
460
  return code
424
461
 
425
462
 
package/lib/wsl/world.js CHANGED
@@ -62,8 +62,16 @@ const LXSS_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss'
62
62
  */
63
63
  export const WSL_CHILD_ENV = { WSL_UTF8: '1' }
64
64
 
65
- /** Environment overrides that keep command output model-readable. */
66
- const ENV_OVERRIDES = { ...WSL_CHILD_ENV, NO_COLOR: '1', TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat' }
65
+ /**
66
+ * Environment overrides that keep command output model-readable.
67
+ *
68
+ * ONE owner, shared with the shell executor (which spreads it into every
69
+ * command it runs) — an override added to one copy and not the other used to be
70
+ * a silent divergence two modules could not see. Frozen so a shared object stays
71
+ * shared on purpose: an accidental write now throws instead of leaking into the
72
+ * other consumer's environment.
73
+ */
74
+ export const ENV_OVERRIDES = Object.freeze({ ...WSL_CHILD_ENV, NO_COLOR: '1', TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat' })
67
75
 
68
76
  /**
69
77
  * Decode `wsl.exe` output.
@@ -398,6 +406,52 @@ export function buildWslExecArgv(plan, argv, options = {}) {
398
406
  ]
399
407
  }
400
408
 
409
+ /** The most wsl.exe processes this module runs at once.
410
+ *
411
+ * Every spawn here lands on ONE shared WSL2 VM, and this file's own notes record it
412
+ * stalling under load (a cold start clears a 30 s ceiling, and a busy sibling
413
+ * distribution stalled probes past theirs). The HTTP route bounds its own methods
414
+ * (lib/index.js `routeInFlight`), but one route call can fan out several wsl.exe
415
+ * children — a command plus its confinement probes, executable lookup and
416
+ * login-shell probe — so the route counter is not a spawn cap. This is.
417
+ *
418
+ * Deliberately a plain FIFO queue rather than a pool: no caller holds a slot while
419
+ * awaiting another wsl.exe call, so a hand-off can never deadlock, and FIFO order
420
+ * keeps an early caller from starving behind later ones.
421
+ */
422
+ const MAX_CONCURRENT_SPAWNS = 8
423
+
424
+ /** Spawns currently in flight. */
425
+ let activeSpawns = 0
426
+
427
+ /** Callers waiting for a slot; each holds a resolve that hands the slot over. */
428
+ const spawnQueue = []
429
+
430
+ /**
431
+ * Take one spawn slot, waiting for a release when the module is at its cap.
432
+ * @returns {Promise<void>} settled once the caller may spawn.
433
+ */
434
+ function acquireSpawnSlot() {
435
+ if (activeSpawns < MAX_CONCURRENT_SPAWNS) {
436
+ activeSpawns += 1
437
+ return Promise.resolve()
438
+ }
439
+ return new Promise((resolve) => { spawnQueue.push(resolve) })
440
+ }
441
+
442
+ /**
443
+ * Give a spawn slot back — to the head of the queue when one waits (the count
444
+ * stays at the cap), otherwise back to the pool.
445
+ */
446
+ function releaseSpawnSlot() {
447
+ const next = spawnQueue.shift()
448
+ if (next !== undefined) {
449
+ next()
450
+ return
451
+ }
452
+ activeSpawns -= 1
453
+ }
454
+
401
455
  /**
402
456
  * Run one command inside a distribution.
403
457
  *
@@ -436,7 +490,11 @@ export function runWslShell({ distro, linuxCwd, command, username, loginShell =
436
490
  '--cd', linuxCwd,
437
491
  '-e', 'bash', loginShell ? '-lc' : '-c', script,
438
492
  ]
439
- return new Promise((resolve, reject) => {
493
+ // Validation stays SYNCHRONOUS: callers rely on a bad distro/username throwing
494
+ // before anything is awaited. Only the spawn itself waits for a slot, so the cap
495
+ // never turns a grammar rejection into a rejected promise.
496
+ return acquireSpawnSlot()
497
+ .then(() => new Promise((resolve, reject) => {
440
498
  let child
441
499
  try {
442
500
  child = spawn(wslPath, argv, {
@@ -511,7 +569,8 @@ export function runWslShell({ distro, linuxCwd, command, username, loginShell =
511
569
  child.on('close', (code) => {
512
570
  settle(code, undefined)
513
571
  })
514
- })
572
+ }))
573
+ .finally(releaseSpawnSlot)
515
574
  }
516
575
 
517
576
  /** The documented probe ceiling: 60s, because a cold VM's first spawn can exceed 30s. */
@@ -569,7 +628,11 @@ export async function listLinuxDir(distro, linuxPath, { wslPath, run = runWslShe
569
628
  // directory that is not empty.
570
629
  const result = await probeWithRetry(run, { distro, linuxCwd: '/', command: script, loginShell: false, raw: true, wslPath })
571
630
  if (result.exitCode !== 0) {
572
- throw new Error(`无法列出 ${path}:${result.stderr.toString('utf8').trim() || `退出码 ${String(result.exitCode)}`}`)
631
+ // The probe's own evidence goes into the message: a twice-retried cold-VM stall
632
+ // (exit code null) must read as a timeout, not as "退出码 null" — the transient
633
+ // failure a reader can act on by waking the distribution.
634
+ const stderr = result.stderr.toString('utf8').trim()
635
+ throw new Error(`无法列出 ${path}:${stderr || probeEvidence(result)}`)
573
636
  }
574
637
  const entries = result.stdout.toString('utf8')
575
638
  .split('\0')
@@ -656,7 +719,10 @@ export async function resolveDistroHome(distro, username, { wslPath, run = runWs
656
719
  const who = await probeWithRetry(run, { distro, linuxCwd: '/', command: 'id -un', loginShell: false, wslPath })
657
720
  user = who.stdout.trim()
658
721
  if (user === '' || user.includes('\n')) {
659
- throw new Error(`无法确定发行版 ${distro} 的默认用户:${who.stderr.trim() || `退出码 ${String(who.exitCode)}`}`)
722
+ // probeEvidence, not a bare exit code: a twice-retried stall reports
723
+ // exitCode null, and "null" is not something a reader can act on. The
724
+ // sibling home probe below already does this for the same reason.
725
+ throw new Error(`无法确定发行版 ${distro} 的默认用户:${who.stderr.trim() || probeEvidence(who)}`)
660
726
  }
661
727
  }
662
728
  // `getent` reads the distro's own NSS user database. The pipeline's exit
@@ -814,13 +880,29 @@ export async function resolveLoginShell(distro, username, { wslPath, run = runWs
814
880
  const stderr = typeof shell.stderr === 'string' ? shell.stderr.trim() : ''
815
881
  throw new LoginShellUnresolvedError(`无法确定发行版 ${distro} 的登录 shell:${stderr || probeEvidence(shell)}`)
816
882
  }
817
- const resolved = shell.stdout.trim().split('\n')[0]?.trim() ?? ''
818
- // EMPTY is the fallback; an ANSWERED value is returned as measured, so the test is
819
- // deliberately `=== ''` and not `startsWith('/')`. A non-path answer such as
820
- // `nologin` is the distribution's own ruling that this account has no interactive
821
- // shell, and both callers SPAWN the value (subprocess.js hands it back as the
822
- // resolved executable, and uses it as spawnTerminal's whole argv), so swapping in an
823
- // unmeasured /bin/bash would open exactly the shell the account's passwd entry
824
- // refuses — a value the caller cannot tell from one the probe measured.
883
+ // A MULTI-LINE answer is refused, not truncated to line 1: resolveDistroHome runs
884
+ // the same getent-passwd shape and refuses it because a name getent misparses can
885
+ // dump the whole database — taking line 1 would silently elect the FIRST entry's
886
+ // shell for whichever user the caller asked about. Exactly one line is the only
887
+ // unambiguous answer, and an unresolvable login shell is a SKIP for the terminal
888
+ // controller, while a wrong shell is a session that does not work. The refusal is
889
+ // on the LINE COUNT: a single line that trims to empty is NOT this case — that is
890
+ // the documented /bin/bash fallback below.
891
+ const lines = shell.stdout.trim().split('\n')
892
+ if (lines.length > 1) {
893
+ throw new LoginShellUnresolvedError(
894
+ `无法确定发行版 ${distro} 的登录 shell:getent 返回了 ${lines.length} 行,唯一一行才是无歧义答案`,
895
+ )
896
+ }
897
+ const resolved = lines[0].trim()
898
+ // EMPTY is the fallback (the only way `resolved` is empty past the refusal above
899
+ // is a single line that trims to nothing); an ANSWERED value is returned as
900
+ // measured, so the test is deliberately `=== ''` and not `startsWith('/')`. A
901
+ // non-path answer such as `nologin` is the distribution's own ruling that this
902
+ // account has no interactive shell, and both callers SPAWN the value
903
+ // (subprocess.js hands it back as the resolved executable, and uses it as
904
+ // spawnTerminal's whole argv), so swapping in an unmeasured /bin/bash would open
905
+ // exactly the shell the account's passwd entry refuses — a value the caller
906
+ // cannot tell from one the probe measured.
825
907
  return resolved === '' ? '/bin/bash' : resolved
826
908
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wsl-desktop",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "exports": {