Skip to content

Commit 5a03ca7

Browse files
committed
Spawn server processes from argv directly, no shell in between
Every FineCode-owned server (LSP servers, the ER) was started via create_subprocess_shell with a single joined command string. A shell in the middle re-parses that string, so a path containing a space or shell metacharacter breaks, and on Windows cmd.exe becomes the actual parent -- which is exactly what left an ER alive with nothing on its pipes: the recorded pid was the shell wrapper's, not the server's, so the process-group kill and the pid FineCode tracks disagreed. - New finecode_jsonrpc._spawn.spawn_process() is the one place that calls create_subprocess_exec, shared by JsonRpcClient.start_server and StdioTransport.start. It rejects a str (which satisfies Sequence[str] so the type checker can't catch it, but would exec character-by-character) and an empty argv. Windows now sets only CREATE_NO_WINDOW: Microsoft documents it as ignored when combined with DETACHED_PROCESS, which the old code also passed. - cmd/server_cmd/full_cmd move from str to Sequence[str] across IJsonRpcClient, ILspClient, LspService, JsonRpcClientImpl, and the runner_manager ER launch, so no caller can rejoin argv into a string first. LspService and spawn_process both raise TypeError at construction/spawn time rather than miscounting argv later. - Add finecode_jsonrpc._spawn_selfcheck, run from setup-dev-workspace.sh right before prepare-envs: it spawns a fake TCP and a fake STDIO server through the real client/transport code paths and checks the recorded pid matches the server's own, so a spawn regression shows up as a two-line diagnostic instead of a prepare-envs timeout.
1 parent dd4186c commit 5a03ca7

20 files changed

Lines changed: 460 additions & 86 deletions

File tree

‎extensions/fine_python_pyrefly/fine_python_pyrefly/pyrefly_lsp_service.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def __init__(
8484
lsp_client=lsp_client,
8585
file_editor=file_editor,
8686
logger=logger,
87-
cmd=f"{pyrefly_bin} lsp",
87+
cmd=[str(pyrefly_bin), "lsp"],
8888
language_id="python",
8989
readable_id="pyrefly-lsp",
9090
client_capabilities=_PYREFLY_CLIENT_CAPABILITIES,

‎extensions/fine_python_ruff/fine_python_ruff/ruff_lsp_service.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ def __init__(
165165
lsp_client=lsp_client,
166166
file_editor=file_editor,
167167
logger=logger,
168-
cmd=f"{ruff_bin} server",
168+
cmd=[str(ruff_bin), "server"],
169169
language_id="python",
170170
readable_id="ruff-lsp",
171171
client_capabilities=_RUFF_CLIENT_CAPABILITIES,

‎extensions/fine_python_ruff/tests/test_ruff_lsp_integration.py‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,10 @@
2020
import asyncio
2121
import contextlib
2222
import json
23-
import shlex
2423
import sys
2524
import time
2625
import typing
27-
from collections.abc import AsyncIterator
26+
from collections.abc import AsyncIterator, Sequence
2827
from pathlib import Path
2928
from typing import Any, Self
3029

@@ -64,7 +63,7 @@
6463
class _StdioLspSession:
6564
"""Speaks LSP to a subprocess: enough of ILspSession for LspService."""
6665

67-
def __init__(self, cmd: str, root_uri: str, **kwargs: Any) -> None:
66+
def __init__(self, cmd: Sequence[str], root_uri: str, **kwargs: Any) -> None:
6867
self._cmd = cmd
6968
self._root_uri = root_uri
7069
self._client_capabilities = kwargs.get("client_capabilities") or {}
@@ -80,7 +79,7 @@ def __init__(self, cmd: str, root_uri: str, **kwargs: Any) -> None:
8079

8180
async def __aenter__(self) -> Self:
8281
self._process = await asyncio.create_subprocess_exec(
83-
*shlex.split(self._cmd),
82+
*self._cmd,
8483
stdin=asyncio.subprocess.PIPE,
8584
stdout=asyncio.subprocess.PIPE,
8685
stderr=asyncio.subprocess.DEVNULL,

‎extensions/fine_toml_tombi/fine_toml_tombi/tombi_lsp_service.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def __init__(
6565
# against PyPI) while analyzing a pyproject.toml, on top of its local
6666
# schema cache. No action here consumes live dependency data, so the
6767
# lookups are latency for results nothing reads.
68-
cmd=f"{tombi_bin} lsp --offline",
68+
cmd=[str(tombi_bin), "lsp", "--offline"],
6969
language_id="toml",
7070
readable_id="tombi-lsp",
7171
client_capabilities=_TOMBI_CLIENT_CAPABILITIES,

‎finecode_extension_api/src/finecode_extension_api/contrib/lsp_service.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,17 +86,24 @@ def __init__(
8686
file_editor: ifileeditor.IFileEditor,
8787
logger: ilogger.ILogger,
8888
*,
89-
cmd: str,
89+
cmd: collections.abc.Sequence[str],
9090
language_id: str,
9191
readable_id: str = "",
9292
client_capabilities: dict[str, Any] | None = None,
9393
max_concurrent_requests: int | None = None,
9494
empty_diagnostics_settle_sec: float = 1.0,
9595
) -> None:
96+
# A str satisfies Sequence[str], so the type checker cannot reject it —
97+
# and tuple(cmd) would silently explode "ruff server" into characters.
98+
# Fail here, at service construction, rather than at the first spawn.
99+
if isinstance(cmd, str):
100+
raise TypeError("cmd must be an argv sequence, not a str")
96101
self._lsp_client = lsp_client
97102
self._file_editor = file_editor
98103
self._logger = logger
99-
self._cmd = cmd
104+
# tuple() so a caller mutating its list afterwards cannot change a later
105+
# restart's command.
106+
self._cmd = tuple(cmd)
100107
self._language_id = language_id
101108
self._readable_id = readable_id
102109
self._client_capabilities = client_capabilities

‎finecode_extension_api/src/finecode_extension_api/interfaces/ijsonrpcclient.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ class IJsonRpcClient(Protocol):
108108

109109
def session(
110110
self,
111-
cmd: str,
111+
cmd: collections.abc.Sequence[str],
112112
cwd: Path | None = None,
113113
env: dict[str, str] | None = None,
114114
readable_id: str = "",
@@ -117,11 +117,12 @@ def session(
117117
118118
Usage::
119119
120-
async with json_rpc_client.session("some-server --stdio") as session:
120+
async with json_rpc_client.session(["some-server", "--stdio"]) as session:
121121
result = await session.send_request("method", {"key": "value"})
122122
123123
Args:
124-
cmd: Shell command to start the JSON-RPC server process.
124+
cmd: Program and arguments of the server process, executed directly
125+
(no shell); a ``str`` is rejected.
125126
cwd: Working directory for the subprocess.
126127
env: Environment variables for the subprocess.
127128
readable_id: Human-readable identifier for logging.

‎finecode_extension_api/src/finecode_extension_api/interfaces/ilspclient.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ class ILspClient(Protocol):
110110

111111
def session(
112112
self,
113-
cmd: str,
113+
cmd: collections.abc.Sequence[str],
114114
root_uri: str,
115115
workspace_folders: list[dict[str, str]] | None = None,
116116
initialization_options: dict[str, Any] | None = None,
@@ -126,7 +126,7 @@ def session(
126126
Usage::
127127
128128
async with lsp_client.session(
129-
cmd="pyright-langserver --stdio",
129+
cmd=["pyright-langserver", "--stdio"],
130130
root_uri="file:///path/to/project",
131131
) as session:
132132
result = await session.send_request(
@@ -135,7 +135,8 @@ def session(
135135
)
136136
137137
Args:
138-
cmd: Shell command to start the language server.
138+
cmd: Program and arguments of the server process, executed directly
139+
(no shell); a ``str`` is rejected.
139140
root_uri: The root URI of the workspace.
140141
workspace_folders: Optional workspace folders (each with 'uri' and 'name' keys).
141142
initialization_options: Optional server-specific initialization options.

‎finecode_extension_api/tests/test_lsp_service.py‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ async def _running_service(
235235
lsp_client=_FakeLspClient(session),
236236
file_editor=file_editor, # type: ignore[arg-type]
237237
logger=_NullLogger(), # type: ignore[arg-type]
238-
cmd="fake-lsp-server",
238+
cmd=["fake-lsp-server"],
239239
language_id="python",
240240
max_concurrent_requests=max_concurrent_requests,
241241
)
@@ -767,12 +767,26 @@ def test_concurrency_limit_below_one_is_rejected() -> None:
767767
lsp_client=_FakeLspClient(_FakeLspSession()),
768768
file_editor=file_editor, # type: ignore[arg-type]
769769
logger=_NullLogger(), # type: ignore[arg-type]
770-
cmd="fake-lsp-server",
770+
cmd=["fake-lsp-server"],
771771
language_id="python",
772772
max_concurrent_requests=0,
773773
)
774774

775775

776+
def test_cmd_str_is_rejected() -> None:
777+
"""A str command would be exec'd character-by-character at spawn, long after
778+
construction; reject it when the service is built."""
779+
file_editor = _FakeFileEditor(Path("/nonexistent"), "")
780+
with pytest.raises(TypeError, match="argv sequence"):
781+
LspService(
782+
lsp_client=_FakeLspClient(_FakeLspSession()),
783+
file_editor=file_editor, # type: ignore[arg-type]
784+
logger=_NullLogger(), # type: ignore[arg-type]
785+
cmd="fake-lsp-server",
786+
language_id="python",
787+
)
788+
789+
776790
async def test_two_waiters_for_one_file_are_both_woken_by_its_diagnostics(
777791
tmp_path: Path,
778792
) -> None:

‎finecode_extension_runner/src/finecode_extension_runner/impls/lsp_client.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ def __init__(self, json_rpc_client: ijsonrpcclient.IJsonRpcClient) -> None:
161161

162162
def session(
163163
self,
164-
cmd: str,
164+
cmd: collections.abc.Sequence[str],
165165
root_uri: str,
166166
workspace_folders: list[dict[str, str]] | None = None,
167167
initialization_options: dict[str, Any] | None = None,
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Spawn FineCode-owned server processes from an argv sequence.
2+
3+
A server is exec'd directly from argv with no shell between the caller and the
4+
process, so a path containing spaces or shell metacharacters cannot be re-parsed
5+
and no ``cmd.exe`` sits in the middle on Windows. On Windows the only creation
6+
flag is ``CREATE_NO_WINDOW``: Microsoft documents it as ignored when combined
7+
with ``DETACHED_PROCESS``, so adding the latter silently disables the hidden
8+
console this intends.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import asyncio
14+
import collections.abc
15+
import sys
16+
import typing
17+
from pathlib import Path
18+
19+
__all__ = ["SpawnCommand", "spawn_process"]
20+
21+
SpawnCommand = collections.abc.Sequence[str]
22+
23+
# Win32 CREATE_NO_WINDOW (processthreadsapi). Spelled as a literal because
24+
# `subprocess.CREATE_NO_WINDOW` only exists on Windows, and the flag choice is
25+
# unit-tested on every platform.
26+
_CREATE_NO_WINDOW = 0x08000000
27+
28+
29+
def _platform_spawn_kwargs(platform: str) -> dict[str, typing.Any]:
30+
if platform == "win32":
31+
return {"creationflags": _CREATE_NO_WINDOW}
32+
return {"start_new_session": True}
33+
34+
35+
async def spawn_process(
36+
cmd: SpawnCommand,
37+
*,
38+
stdin_pipe: bool,
39+
cwd: Path | None,
40+
env: dict[str, str] | None,
41+
limit: int | None = None,
42+
) -> asyncio.subprocess.Process:
43+
"""Spawn *cmd* as a child process, executing it directly.
44+
45+
Raises:
46+
TypeError: *cmd* is a ``str``. A ``str`` satisfies ``Sequence[str]``, so
47+
the type checker cannot reject one, and exec'ing ``"ruff server"``
48+
would run argv ``['r', 'u', 'f', ...]`` instead of the server.
49+
ValueError: *cmd* is empty.
50+
"""
51+
if isinstance(cmd, str):
52+
raise TypeError("cmd must be an argv sequence, not a str")
53+
argv = list(cmd)
54+
if not argv:
55+
raise ValueError("empty command")
56+
57+
kwargs = _platform_spawn_kwargs(sys.platform)
58+
if limit is not None:
59+
kwargs["limit"] = limit
60+
61+
return await asyncio.create_subprocess_exec(
62+
*argv,
63+
stdin=asyncio.subprocess.PIPE if stdin_pipe else None,
64+
stdout=asyncio.subprocess.PIPE,
65+
stderr=asyncio.subprocess.PIPE,
66+
cwd=cwd,
67+
env=env,
68+
**kwargs,
69+
)

0 commit comments

Comments
 (0)