dsh-wsl-desktop 0.3.2 → 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/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. */
@@ -520,7 +579,7 @@ const PROBE_TIMEOUT_MS = 60_000
520
579
  /**
521
580
  * Ceiling for one parsed-output probe, and the retry that goes with it.
522
581
  *
523
- * README.md states this policy for exactly these probes — 探针超时 60s + 超时后一次透明
582
+ * docs/CONFINEMENT.md states this policy for exactly these probes — 探针超时 60s + 超时后一次透明
524
583
  * 重试:桌面重启后的首个 wsl.exe 冷启动可以超过短上限 — and it names `listLinuxDir`,
525
584
  * `checkLinuxPath` and `resolveDistroHome` in the same sentence as the probes that
526
585
  * already had it (`resolveIdentity`, `detectRunner`, the NO_NEW_PRIVS probe, the PTY's
@@ -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
@@ -768,7 +834,7 @@ export class LoginShellUnresolvedError extends Error {
768
834
  *
769
835
  * A probe that did not complete is NOT the empty-field case either. It used to
770
836
  * return `/bin/bash` like a real answer, so a stalled relay — the machine
771
- * transient README.md's probe policy exists for — silently pinned the session's
837
+ * transient docs/CONFINEMENT.md's probe policy exists for — silently pinned the session's
772
838
  * shell with a value the caller could not tell from the distribution's own. The
773
839
  * ceiling and the repeat are `probeWithRetry`'s (探针超时 60s + 超时后一次透明重试),
774
840
  * the same ruling `listLinuxDir`/`checkLinuxPath`/`resolveDistroHome` follow, and a
@@ -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.2",
3
+ "version": "0.3.4",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "exports": {