Windows process spawn failures for npm/pip CLI tools
Cluster 34864. Data as of
Nothing open on GitHub. Fix merged in #35250 on 30 May 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #35250. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #35250. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and fixes address WinError 193 and related failures when spawning npm-installed language servers and CLI tools (like codex) on Windows, where CreateProcess cannot handle Unix shell scripts or unresolved PATHEXT extensions like .cmd. Fixes involve resolving executables via shutil.which or wrapping launchers appropriately.
Open issues0
Open PRs0
Fixes on record
-
#35250 fix(lsp): spawn + discover npm/pip language servers on native Windows
files:
agent/lsp/cli.py,agent/lsp/client.py,agent/lsp/install.py,scripts/release.py,tests/agent/lsp/test_install_and_lint_fixes.pyShow the diff
-
agent/lsp/cli.py@@ -247,18 +247,13 @@ def _cmd_restart() -> int: def _cmd_which(server_id: str) -> int: - from agent.lsp.install import INSTALL_RECIPES, hermes_lsp_bin_dir - import shutil as _shutil + from agent.lsp.install import INSTALL_RECIPES, _existing_binary recipe = INSTALL_RECIPES.get(server_id) bin_name = (recipe or {}).get("bin", server_id) - staged = hermes_lsp_bin_dir() / bin_name - if staged.exists(): - sys.stdout.write(str(staged) + "\n") - return 0 - on_path = _shutil.which(bin_name) - if on_path: - sys.stdout.write(on_path + "\n") + resolved = _existing_binary(bin_name) + if resolved: + sys.stdout.write(resolved + "\n") return 0 sys.stderr.write(f"{server_id}: not installed\n") return 1 @@ -292,11 +287,9 @@ def _backend_warnings() -> list: suggestion across common platforms. """ import shutil as _shutil - from agent.lsp.install import hermes_lsp_bin_dir + from agent.lsp.install import _existing_binary notes: list = [] - bash_installed = _shutil.which("bash-language-server") is not None or ( - (hermes_lsp_bin_dir() / "bash-language-server").exists() - ) + bash_installed = _existing_binary("bash-language-server") is not None if bash_installed and _shutil.which("shellcheck") is None: notes.append( "bash-language-server is installed but shellcheck is missing — " -
agent/lsp/client.py@@ -44,6 +44,7 @@ import asyncio import logging import os +import sys from pathlib import Path from typing import Any, Awaitable, Callable, Dict, List, Optional, Set from urllib.parse import quote, unquote @@ -244,15 +245,27 @@ async def start(self) -> None: await self._cleanup_process() raise + @staticmethod + def _win_wrap_cmd(cmd: List[str]) -> List[str]: + """On Windows, wrap .cmd/.bat shims so CreateProcess can run them.""" + exe = cmd[0] + if exe.lower().endswith((".cmd", ".bat")): + return ["cmd.exe", "/c", *cmd] + return cmd + async def _spawn(self) -> None: env = dict(os.environ) if self._env: env.update(self._env) + cmd = self._command + if sys.platform == "win32": + cmd = self._win_wrap_cmd(cmd) + try: self._proc = await asyncio.create_subprocess_exec( - self._command[0], - *self._command[1:], + cmd[0], + *cmd[1:], stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, @@ -261,7 +274,7 @@ async def _spawn(self) -> None: ) except FileNotFoundError as e: raise LSPProtocolError( - f"LSP server binary not found: {self._command[0]} ({e})" + f"LSP server binary not found: {cmd[0]} ({e})" ) from e # Drain stderr at debug level — if we don't, the pipe buffer -
agent/lsp/install.py@@ -108,6 +108,11 @@ _install_locks: Dict[str, threading.Lock] = {} _install_results: Dict[str, Optional[str]] = {} _install_lock_meta = threading.Lock() +_WINDOWS_WRAPPER_SUFFIXES = (".cmd", ".exe", ".bat") + + +def _is_windows() -> bool: + return os.name == "nt" def hermes_lsp_bin_dir() -> Path: @@ -120,14 +125,33 @@ def hermes_lsp_bin_dir() -> Path: return p +def _native_binary_candidates(base: Path) -> list[Path]: + """Return platform-native executable candidates for a staged binary.""" + candidates = [base] + if _is_windows(): + existing = {str(base).lower()} + for suffix in _WINDOWS_WRAPPER_SUFFIXES: + candidate = Path(str(base) + suffix) + key = str(candidate).lower() + if key not in existing: + candidates.append(candidate) + existing.add(key) + return candidates + + def _existing_binary(name: str) -> Optional[str]: """Probe the staging dir + PATH for a binary named ``name``.""" - staged = hermes_lsp_bin_dir() / name - if staged.exists() and os.access(staged, os.X_OK): - return str(staged) + for staged in _native_binary_candidates(hermes_lsp_bin_dir() / name): + if staged.exists() and os.access(staged, os.X_OK): + return str(staged) on_path = shutil.which(name) if on_path: return on_path + if _is_windows(): + for suffix in _WINDOWS_WRAPPER_SUFFIXES: + on_path = shutil.which(f"{name}{suffix}") + if on_path: + return on_path return None @@ -250,12 +274,7 @@ def _install_npm( # Find the bin nm_bin = staging / "node_modules" / ".bin" / bin_name - if os.name == "nt": - # On Windows npm sometimes drops `.cmd` shims - candidates = [nm_bin, nm_bin.with_suffix(".cmd")] - else: - candidates = [nm_bin] - for c in candidates: + for c in _native_binary_candidates(nm_bin): if c.exists(): # Symlink into our `lsp/bin/` for stable PATH access. link = hermes_lsp_bin_dir() / c.name @@ -301,7 +320,7 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]: logger.warning("[install] go install errored for %s: %s", pkg, e) return None bin_path = staging / bin_name - if os.name == "nt": + if _is_windows(): bin_path = bin_path.with_suffix(".exe") if bin_path.exists(): return str(bin_path) @@ -337,19 +356,24 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]: except (subprocess.TimeoutExpired, OSError) as e: logger.warning("[install] pip install errored for %s: %s", pkg, e) return None - # Look for the script - bin_path = pip_target / "bin" / bin_name - if bin_path.exists(): - link = hermes_lsp_bin_dir() / bin_name - if not link.exists(): - try: - link.symlink_to(bin_path) - except (OSError, NotImplementedError): - try: - shutil.copy2(bin_path, link) - except OSError: - return str(bin_path) - return str(link if link.exists() else bin_path) + # Look for the console script. POSIX wheels generally write to bin/, + # while native Windows installs use Scripts/. + script_dirs = [pip_target / "bin"] + if _is_windows(): + script_dirs.append(pip_target / "Scripts") + for script_dir in script_dirs: + for bin_path in _native_binary_candidates(script_dir / bin_name): + if bin_path.exists(): + link = hermes_lsp_bin_dir() / bin_path.name + if not link.exists(): + try: + link.symlink_to(bin_path) + except (OSError, NotImplementedError): + try: + shutil.copy2(bin_path, link) + except OSError: + return str(bin_path) + return str(link if link.exists() else bin_path) return None -
scripts/release.py@@ -1205,6 +1205,7 @@ "[email protected]": "1RB", # PR #25462 salvage (discord forwarded messages) "[email protected]": "ayushere", # PR #25342 salvage (memory teardown leak) "[email protected]": "domtriola", # PR #25424 salvage (docs tirith link) + "[redacted:email]": "tuancookiez-hub", # PR #34865 salvage (LSP Windows .cmd shim spawn, #34864) "[email protected]": "ephron-ren", # PR #25358 salvage (MiMo reasoning echo-back) "[email protected]": "freqyfreqy", # PR #25423 salvage (docs LSP worktree -> repo) "[email protected]": "fu576", # PR #25369 salvage (api_mode not inherited cross-provider) -
tests/agent/lsp/test_install_and_lint_fixes.py@@ -94,6 +94,47 @@ def fake_run(cmd, **kwargs): assert install_targets == ["pyright"] +def test_existing_binary_finds_windows_wrapper_in_staging(tmp_path, monkeypatch): + """Installed Windows shims should satisfy later status/probe calls.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + from agent.lsp import install as install_mod + + wrapper = install_mod.hermes_lsp_bin_dir() / "pyright-langserver.cmd" + wrapper.write_text("@echo off\n") + wrapper.chmod(0o755) + + monkeypatch.setattr(install_mod, "_is_windows", lambda: True) + monkeypatch.setattr(install_mod.shutil, "which", lambda _name: None) + + assert install_mod._existing_binary("pyright-langserver") == str(wrapper) + assert install_mod.detect_status("pyright") == "installed" + + +def test_install_pip_finds_windows_scripts_launcher(tmp_path, monkeypatch): + """pip console scripts can land in Scripts/ on native Windows.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + from agent.lsp import install as install_mod + + def fake_run(cmd, **kwargs): + scripts_dir = install_mod.hermes_lsp_bin_dir().parent / "python-packages" / "Scripts" + scripts_dir.mkdir(parents=True, exist_ok=True) + launcher = scripts_dir / "fake-language-server.exe" + launcher.write_text("launcher\n") + launcher.chmod(0o755) + return MagicMock(returncode=0, stderr="") + + monkeypatch.setattr(install_mod, "_is_windows", lambda: True) + monkeypatch.setattr(install_mod.subprocess, "run", fake_run) + + resolved = install_mod._install_pip("fake-lsp", "fake-language-server") + + assert resolved is not None + assert resolved.endswith("fake-language-server.exe") + assert (install_mod.hermes_lsp_bin_dir() / "fake-language-server.exe").exists() + + # --------------------------------------------------------------------------- # Fix 2: ``hermes lsp status`` surfaces shellcheck-missing for bash # ---------------------------------------------------------------------------
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#34865 fix(lsp): handle Windows .cmd shims in LSP process spawn · merged via another change →
296fcdf,#35250,#34864
Closed issues · 1
- Closed#34864LSP spawn fails on Windows with WinError 193 for npm-installed language servers
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Merged PRs
- #35250
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 30 May 2026