Skip to content

Commit 01efa3c

Browse files
committed
Lifecycle e2e tests
1 parent 8268469 commit 01efa3c

10 files changed

Lines changed: 624 additions & 0 deletions

File tree

tests/e2e/__init__.py

Whitespace-only changes.

tests/e2e/conftest.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Shared helpers and fixtures for e2e server lifecycle tests."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import signal
7+
import socket
8+
import subprocess
9+
import sys
10+
import threading
11+
import time
12+
from pathlib import Path
13+
14+
import pytest
15+
16+
17+
# ---------------------------------------------------------------------------
18+
# Process helpers
19+
# ---------------------------------------------------------------------------
20+
21+
22+
def find_free_port() -> int:
23+
"""Bind to port 0 and return the assigned port number."""
24+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
25+
s.bind(("127.0.0.1", 0))
26+
return s.getsockname()[1]
27+
28+
29+
def wait_for_file(path: Path, timeout: float = 15.0) -> bool:
30+
"""Poll until *path* exists and is non-empty."""
31+
deadline = time.monotonic() + timeout
32+
while time.monotonic() < deadline:
33+
if path.exists() and path.stat().st_size > 0:
34+
return True
35+
time.sleep(0.2)
36+
return False
37+
38+
39+
def wait_for_port(host: str, port: int, timeout: float = 15.0) -> bool:
40+
"""Poll until a TCP connection to *host:port* succeeds."""
41+
deadline = time.monotonic() + timeout
42+
while time.monotonic() < deadline:
43+
try:
44+
with socket.create_connection((host, port), timeout=0.5):
45+
return True
46+
except (ConnectionRefusedError, OSError):
47+
time.sleep(0.2)
48+
return False
49+
50+
51+
def start_server(args: list[str], cwd: Path) -> subprocess.Popen:
52+
"""Launch ``python -m finecode <args>`` in an isolated process group.
53+
54+
Using ``start_new_session=True`` ensures the server and any subprocesses
55+
it spawns (e.g. WM server started by the MCP server) share a dedicated
56+
process group, so ``sigint_group`` / ``kill_group`` can reach all of them.
57+
58+
Stderr is forwarded to the parent process (captured by pytest and shown on
59+
failure) via a background thread so the pipe buffer never blocks the child.
60+
"""
61+
proc = subprocess.Popen(
62+
[sys.executable, "-m", "finecode"] + args,
63+
cwd=cwd,
64+
stdout=subprocess.DEVNULL,
65+
stderr=subprocess.PIPE,
66+
start_new_session=True,
67+
)
68+
69+
def _forward_stderr():
70+
assert proc.stderr is not None
71+
for line in proc.stderr:
72+
sys.stderr.buffer.write(line)
73+
sys.stderr.buffer.flush()
74+
75+
threading.Thread(target=_forward_stderr, daemon=True).start()
76+
return proc
77+
78+
79+
def sigint_group(proc: subprocess.Popen) -> None:
80+
"""Send SIGINT to the entire process group of *proc*."""
81+
try:
82+
os.killpg(proc.pid, signal.SIGINT)
83+
except ProcessLookupError:
84+
pass
85+
86+
87+
def kill_group(proc: subprocess.Popen) -> None:
88+
"""Forcefully kill the entire process group of *proc* (test teardown)."""
89+
try:
90+
os.killpg(proc.pid, signal.SIGKILL)
91+
except ProcessLookupError:
92+
pass
93+
94+
95+
def wm_shared_port_file() -> Path:
96+
"""Return the shared WM discovery file path for the active Python venv.
97+
98+
Mirrors the formula in ``wm_server._cache_dir()``:
99+
``Path(sys.executable).parent.parent / "cache" / "finecode" / "wm_port"``
100+
"""
101+
return Path(sys.executable).parent.parent / "cache" / "finecode" / "wm_port"
102+
103+
104+
# ---------------------------------------------------------------------------
105+
# Fixtures
106+
# ---------------------------------------------------------------------------
107+
108+
109+
@pytest.fixture
110+
def workspace_dir(tmp_path: Path) -> Path:
111+
"""Minimal FineCode workspace with an empty pyproject.toml."""
112+
(tmp_path / "pyproject.toml").write_text("[tool.finecode]\n")
113+
return tmp_path

tests/e2e/er/__init__.py

Whitespace-only changes.

tests/e2e/er/test_lifecycle.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""E2E tests for Extension Runner lifecycle."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import socket
7+
import subprocess
8+
import sys
9+
import time
10+
from pathlib import Path
11+
12+
import pytest
13+
14+
psutil = pytest.importorskip("psutil")
15+
16+
from tests.e2e.conftest import kill_group, sigint_group, start_server, wait_for_file, wait_for_port
17+
18+
19+
# ---------------------------------------------------------------------------
20+
# Minimal WM JSON-RPC client helpers
21+
# ---------------------------------------------------------------------------
22+
23+
24+
def _send_request(sock: socket.socket, method: str, params: dict, req_id: int) -> None:
25+
"""Send a Content-Length-framed JSON-RPC request to the WM server."""
26+
body = json.dumps(
27+
{"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}
28+
).encode()
29+
header = f"Content-Length: {len(body)}\r\n\r\n".encode()
30+
sock.sendall(header + body)
31+
32+
33+
def _read_response(sock: socket.socket, timeout: float = 30.0) -> dict:
34+
"""Read a single Content-Length-framed JSON-RPC response from the WM server."""
35+
sock.settimeout(timeout)
36+
raw = b""
37+
while b"\r\n\r\n" not in raw:
38+
chunk = sock.recv(1)
39+
if not chunk:
40+
raise EOFError("Connection closed while reading response header")
41+
raw += chunk
42+
header_part, body_start = raw.split(b"\r\n\r\n", 1)
43+
length = int(header_part.split(b"Content-Length: ")[1])
44+
body = body_start
45+
while len(body) < length:
46+
chunk = sock.recv(length - len(body))
47+
if not chunk:
48+
raise EOFError("Connection closed while reading response body")
49+
body += chunk
50+
return json.loads(body)
51+
52+
53+
# ---------------------------------------------------------------------------
54+
# Fixtures
55+
# ---------------------------------------------------------------------------
56+
57+
58+
@pytest.fixture
59+
def workspace_dir_with_er(tmp_path: Path) -> Path:
60+
"""Workspace with a dev_workspace env symlinked to the current Python venv.
61+
62+
Symlinking the active venv avoids creating a separate virtual environment:
63+
``finecode_extension_runner`` is already installed here (it is a dev
64+
dependency of finecode itself), so the WM can start a real ER immediately.
65+
66+
The workspace declares one action backed by a built-in handler so that WM
67+
can validate the config and start the dev_workspace ER on ``workspace/addDir``.
68+
"""
69+
(tmp_path / "pyproject.toml").write_text(
70+
"[tool.finecode]\n\n"
71+
"[[tool.finecode.actions]]\n"
72+
'name = "test_action"\n\n'
73+
"[[tool.finecode.actions.handlers]]\n"
74+
'handler = "finecode_builtin_handlers.DumpConfigHandler"\n'
75+
'env = "dev_workspace"\n'
76+
)
77+
# Symlink current venv as the dev_workspace env.
78+
venvs_dir = tmp_path / ".venvs"
79+
venvs_dir.mkdir()
80+
current_venv = Path(sys.executable).parent.parent
81+
(venvs_dir / "dev_workspace").symlink_to(current_venv)
82+
return tmp_path
83+
84+
85+
# ---------------------------------------------------------------------------
86+
# Tests
87+
# ---------------------------------------------------------------------------
88+
89+
90+
def test_extension_runners_cleaned_up_on_wm_shutdown(workspace_dir_with_er, tmp_path):
91+
"""Extension Runner subprocesses are terminated when the WM shuts down cleanly.
92+
93+
The WM's ``on_shutdown()`` hook sends ``shutdown`` + ``exit`` JSON-RPC
94+
messages to every running ER so they stop gracefully. Without this, ERs
95+
would be orphaned (re-parented to PID 1) when the WM exits — a ghost-process
96+
scenario that silently consumes resources.
97+
98+
Sequence:
99+
1. Start WM in a workspace that has a dev_workspace ER configured.
100+
2. Connect to WM and call ``workspace/addDir``, which discovers the project
101+
and starts the dev_workspace Extension Runner subprocess.
102+
3. Poll via psutil until the ER child process appears.
103+
4. Close the client connection, then send SIGINT to the WM process group.
104+
5. Assert every ER PID recorded in step 3 is no longer alive.
105+
"""
106+
port_file = tmp_path / "wm_port"
107+
108+
proc = start_server(
109+
[
110+
"start-wm-server",
111+
"--port-file", str(port_file),
112+
"--disconnect-timeout", "10",
113+
],
114+
cwd=workspace_dir_with_er,
115+
)
116+
er_pids: set[int] = set()
117+
try:
118+
assert wait_for_file(port_file), (
119+
"WM server did not write port file within 15 s — server failed to start"
120+
)
121+
122+
port = int(port_file.read_text().strip())
123+
assert wait_for_port("127.0.0.1", port), (
124+
f"WM server not accepting connections on port {port}"
125+
)
126+
127+
with socket.create_connection(("127.0.0.1", port)) as sock:
128+
# Tell WM to load the workspace directory. This discovers the
129+
# project, reads its config, and starts the dev_workspace ER.
130+
_send_request(
131+
sock,
132+
"workspace/addDir",
133+
{"dir_path": str(workspace_dir_with_er)},
134+
req_id=1,
135+
)
136+
_read_response(sock, timeout=30.0)
137+
138+
# Poll until the ER child process appears in WM's process tree.
139+
wm_process = psutil.Process(proc.pid)
140+
deadline = time.monotonic() + 30.0
141+
while time.monotonic() < deadline:
142+
try:
143+
children = wm_process.children(recursive=True)
144+
er_procs = [
145+
c for c in children
146+
if "finecode_extension_runner" in " ".join(c.cmdline())
147+
]
148+
if er_procs:
149+
er_pids = {p.pid for p in er_procs}
150+
break
151+
except psutil.NoSuchProcess:
152+
break
153+
time.sleep(0.5)
154+
155+
assert er_pids, (
156+
"Extension Runner process did not start within 30 s after "
157+
"workspace/addDir — check that finecode_extension_runner is "
158+
"installed in the active venv"
159+
)
160+
161+
# Connection closed; SIGINT WM before the disconnect timer fires.
162+
sigint_group(proc)
163+
164+
try:
165+
proc.wait(timeout=15)
166+
except subprocess.TimeoutExpired:
167+
pytest.fail("WM did not exit within 15 s after SIGINT")
168+
finally:
169+
kill_group(proc)
170+
171+
# Every ER that was alive before shutdown must be gone now.
172+
for pid in er_pids:
173+
assert not psutil.pid_exists(pid), (
174+
f"Extension Runner PID {pid} is still alive after WM shutdown — "
175+
"on_shutdown() may not have sent exit to all runners"
176+
)

tests/e2e/lsp/__init__.py

Whitespace-only changes.

tests/e2e/lsp/test_lifecycle.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""E2E tests for the LSP server lifecycle."""
2+
3+
import socket
4+
import subprocess
5+
6+
import pytest
7+
8+
from tests.e2e.conftest import (
9+
find_free_port,
10+
kill_group,
11+
sigint_group,
12+
start_server,
13+
wait_for_port,
14+
)
15+
16+
17+
def test_starts_and_exits_on_sigint(workspace_dir):
18+
"""LSP server opens a TCP port on startup and exits cleanly on SIGINT.
19+
20+
The LSP server only connects to the WM server after receiving the LSP
21+
``initialized`` notification from a client, which we never send here.
22+
This test therefore exercises the LSP server process in isolation.
23+
24+
Sequence:
25+
1. Start ``start-lsp --socket <port>`` in TCP mode.
26+
2. Poll until the port accepts connections — proves the server is up.
27+
3. Send SIGINT to the process group.
28+
4. Assert the process exits within 10 s.
29+
"""
30+
port = find_free_port()
31+
32+
proc = start_server(
33+
["start-lsp", "--socket", str(port)],
34+
cwd=workspace_dir,
35+
)
36+
try:
37+
assert wait_for_port("127.0.0.1", port), (
38+
f"LSP server did not open TCP port {port} within 15 s — server failed to start"
39+
)
40+
41+
sigint_group(proc)
42+
43+
try:
44+
proc.wait(timeout=10)
45+
except subprocess.TimeoutExpired:
46+
pytest.fail("LSP server did not exit within 10 s after SIGINT")
47+
finally:
48+
kill_group(proc)
49+
50+
# exit code 0 or 1: LSP server handles KeyboardInterrupt and shuts down cleanly
51+
assert proc.returncode in (0, 1), (
52+
f"Expected clean exit (0 or 1), got {proc.returncode}"
53+
)
54+
55+
56+
def test_exits_on_client_disconnect(workspace_dir):
57+
"""LSP server exits when the TCP client drops the connection without SIGINT.
58+
59+
This test covers the ghost-process scenario: the IDE closes (or the
60+
extension crashes) without sending a clean LSP shutdown/exit sequence.
61+
The server detects the EOF on the TCP reader, shuts down, and exits on its own.
62+
63+
Sequence:
64+
1. Start ``start-lsp --socket <port>`` in TCP mode.
65+
2. Poll until the port accepts connections.
66+
3. Open a TCP connection (simulates IDE connecting).
67+
4. Close the socket without sending any LSP messages (simulates IDE crash).
68+
5. Assert the LSP server process exits within 10 s — no ghost process.
69+
"""
70+
port = find_free_port()
71+
72+
proc = start_server(
73+
["start-lsp", "--socket", str(port)],
74+
cwd=workspace_dir,
75+
)
76+
try:
77+
assert wait_for_port("127.0.0.1", port), (
78+
f"LSP server did not open TCP port {port} within 15 s — server failed to start"
79+
)
80+
81+
# Connect and immediately drop — simulates IDE closing without shutdown
82+
with socket.create_connection(("127.0.0.1", port)):
83+
pass # socket closed on __exit__
84+
85+
try:
86+
proc.wait(timeout=10)
87+
except subprocess.TimeoutExpired:
88+
pytest.fail(
89+
"LSP server did not exit within 10 s after client disconnect — "
90+
"ghost process risk"
91+
)
92+
finally:
93+
kill_group(proc)
94+
95+
assert proc.returncode in (0, 1), (
96+
f"Expected clean exit (0 or 1), got {proc.returncode}"
97+
)

tests/e2e/mcp/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)