Skip to content

fix(lsp,dap): resolve program names via PATHEXT before spawning - #70

Merged
rwetz merged 2 commits into
mainfrom
fix/windows-program-resolution
Aug 22, 2026
Merged

fix(lsp,dap): resolve program names via PATHEXT before spawning#70
rwetz merged 2 commits into
mainfrom
fix/windows-program-resolution

Conversation

@rwetz

@rwetz rwetz commented Aug 20, 2026

Copy link
Copy Markdown
Owner

The bug

Installing vscode-langservers-extracted gave Nexis a working CSS/HTML/JSON language server that it still refused to start, and the missing-tools notice told the story backwards: pressing refresh cleared the entry, then opening a .css file put it straight back.

The two sides were asking different questions.

  • tool_probe (src-tauri/src/modules/tools.rs) walks PATHEXT on Windows on purpose — 1.25.0 added that precisely because these servers land as .cmd shims.
  • LspSession::start handed the bare name to proc::command, and Rust's Windows program resolution appends only .exe; it never consults PATHEXT.

So vscode-css-language-server resolved for the probe and did not exist for the spawn.

The fix

The PATH walk now returns the resolved path instead of a bool:

pub fn resolve_on_host(binary: &str) -> Option<PathBuf>
fn resolves_on_host(binary: &str) -> bool { resolve_on_host(binary).is_some() }

Both the LSP and DAP clients resolve first and spawn that path. One walk answers "is it installed?" and "what do I spawn?", so the two cannot drift apart again. When resolution finds nothing the bare name is still passed through, leaving the failure path and its error message unchanged.

The part that matters more than the original diagnosis

PATHEXT spellings are now tried before the extensionless one. npm's shim writer emits three files per bin — foo.cmd, foo.ps1, and an extensionless foo that is a bash script for MSYS/Git Bash. The old suffix list put the extensionless spelling first, which was harmless for a boolean probe but would have made the resolver return a path CreateProcessW cannot execute — converting a lookup miss into a spawn failure.

Trying PATHEXT first selects the .cmd, which Rust's std routes through cmd.exe with the hardened quoting added for CVE-2024-24576. No hand-rolled cmd /c.

Why DAP is in scope

Not pre-emptive: the adapter command is free text in the debugger panel, so anyone naming an npm-installed adapter (js-debug-adapter) hits the identical gap.

ml.rs and python.rs are deliberately untouched — they spawn interpreters and absolute paths, not npm shims.

Verification

Passing locally on Linux:

  • cargo test — 195 lib + 11 pitfall tripwire tests
  • cargo clippy --all-targets -- -D warnings — clean
  • cargo fmt --check — clean
  • src/lib/pitfall-guards.test.ts — 16 passed

Known gap: the Windows branch is #[cfg(windows)], so CI on Linux never compiles it. Its body was type-checked and run standalone (yields [".COM", ".EXE", ".BAT", ".CMD", ""], the ordering the fix depends on), and windows_tries_pathext_before_the_extensionless_name asserts the ordering invariant on every platform by checking the suffix list's shape. The actual spawn of a .cmd shim is unverified until this runs on Windows — rebuild, open a .css file, confirm the missing-tools pill stays clear instead of reappearing.

Also included

  • CHANGELOG entry under [Unreleased] / Fixed.
  • docs/vault/decisions/program-resolution-before-spawn.md recording the PATHEXT/Command asymmetry, the alternatives rejected, and why resolution lives in the callers rather than in proc::command.
  • Fixed a stale line in docs/vault/Home.md that claimed decisions/ was empty; two notes already existed.

🤖 Generated with Claude Code

ryanwetzstein and others added 2 commits August 19, 2026 18:18
`tool_probe` walks PATHEXT on Windows on purpose -- every server from
`vscode-langservers-extracted` installs as a `.cmd` shim, not an `.exe`.
The spawn sites did not: they handed the bare name to `proc::command`,
and Rust's Windows program resolution appends only `.exe`. So
`vscode-css-language-server` was simultaneously installed (probe) and
not found (spawn), and the missing-tools pill lied in both directions --
refresh cleared the entry, the next .css file put it straight back.

The PATH walk now returns the resolved path rather than a bool
(`tools::resolve_on_host`), and both the LSP and DAP clients spawn that
path. One walk answers "is it installed?" and "what do I spawn?", so the
two cannot drift apart again. When resolution finds nothing the bare
name is still passed through, leaving the failure path and its error
message unchanged.

PATHEXT spellings are now tried before the extensionless one, and that
ordering is the fix rather than a detail: npm writes three files per bin
-- `foo.cmd`, `foo.ps1`, and an extensionless `foo` that is a bash
script for MSYS/Git Bash. Preferring the bare spelling would resolve to
a path CreateProcessW cannot execute, turning a lookup miss into a spawn
failure. Trying PATHEXT first selects the `.cmd`, which std routes
through cmd.exe with the quoting added for CVE-2024-24576 -- so no
hand-rolled `cmd /c`.

DAP is covered for the same reason rather than pre-emptively: the
adapter command is free text in the debugger panel, so anyone naming an
npm-installed adapter (`js-debug-adapter`) hits the identical gap.

The Windows branch is `#[cfg(windows)]` and so is not compiled by CI on
Linux; `windows_tries_pathext_before_the_extensionless_name` asserts the
ordering invariant on every platform by checking the suffix list's
shape. The `.cmd` spawn itself still needs verification on Windows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to the PATHEXT resolution fix, closing four gaps found in review.

Tree kill. Rust's std spawns a batch program as `cmd.exe /d /c "<shim> ..."`,
so once a server resolves to a `.cmd` the handle held is the wrapper and the
server itself is a grandchild. `Child::kill` is a bare `TerminateProcess`: it
took out the wrapper and left the server alive holding both pipe ends, so the
reader thread never saw EOF and every restart leaked another orphan. LSP and
DAP sessions now hold a `KILL_ON_JOB_CLOSE` Job Object, the guard ConPTY
children already had — moved from `pty/job.rs` to `modules/job.rs` and shared
rather than duplicated. Both also `wait()` after killing, to reap.

Empty PATHEXT. A set-but-empty `PATHEXT` reads back as `Some("")`, not absent,
so the `.COM;.EXE;.BAT;.CMD` default never fired and the suffix list collapsed
to the extensionless spelling — silently disabling the `.cmd` lookup this all
exists for. Parsing is now a pure function, so its ordering and fallback are
tested on every platform instead of only on a Windows runner.

Names that are paths. The separator branch skipped the suffix walk, so a
program given as a path — which the debugger panel's free-text field invites —
matched npm's extensionless bash script sitting next to the shim, resolving to
something `CreateProcessW` cannot run.

Unix executability. `mode & 0o111 != 0` accepts an exec bit belonging to
somebody else. Harmless while the answer was a bool; wrong once the answer is
the path being spawned, since `execvp` skips an EACCES match and keeps walking
PATH. Now `access(X_OK)`.

Also removes a stray `test/test.c` scratch file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rwetz
rwetz merged commit be557df into main Aug 22, 2026
6 of 7 checks passed
@rwetz
rwetz deleted the fix/windows-program-resolution branch August 22, 2026 23:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants