## Summary Fix two data races in the stateful worker that caused spurious "redefinition" errors during rapid edits, and remove a didChange workaround that is no longer needed after clice-io/eventide#95. ### stateful_worker.cpp **Compile handler**: move `params` → `doc` field copy **after** `strand.lock()`. Previously the copy happened before the lock, so a concurrent Compile request waiting on the strand could overwrite `doc.text` while `et::queue` was reading it on the thread pool: ``` T1: Compile A → doc.text = text_A → lock → et::queue reads doc.text T2: Compile B → doc.text = text_B → waits for strand (overwrites!) T3: et::queue sees text_B instead of text_A → PCH/text mismatch ``` **DocumentUpdate handler**: only mark `dirty`, stop modifying `doc.text`/`doc.version`. The event loop notification can fire while `et::queue` work is running on the thread pool — writing `doc.text` from one thread while reading it from another is a data race. ### master_server.cpp Remove the `{0,0}-{0,0}` range workaround for whole-document `didChange`. eventide's variant deserialization now correctly rejects `TextDocumentContentChangePartial` when the `range` field is absent (clice-io/eventide#95), so `TextDocumentContentChangeWholeDocument` is matched as intended. ### protocol.h Remove `text` field from `DocumentUpdateParams` — the worker no longer needs it since DocumentUpdate only sets the dirty flag. ### Integration tests (+312 lines) Extend test_staleness.py from 5 to 14 tests covering document lifecycle: - `didChange` body edit → recompilation with updated diagnostics - `didChange` preamble edit → PCH rebuild + clean recompilation - `didClose` + reopen → compiles fresh from disk - `didClose` → hover returns None - `didSave` header → dependent file recompiles - `didSave` module → CompileGraph dependents invalidated ## Test plan - [x] 422 unit tests pass (426 on CI with extra test suites) - [x] 14 integration tests pass locally - [x] Depends on clice-io/eventide#95 (merged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Smaller document-update notifications sent to background workers (only path and version). * **Bug Fixes** * Reduced races and unnecessary work between update and compile flows. * Prevented notifications from overwriting in-memory document text, improving state consistency. * Safer concurrent handling to avoid mid-request eviction of active documents. * **Tests** * Added integration tests for staleness, dependency propagation, and LSP lifecycle. * Updated unit tests to match revised update behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
328 lines
10 KiB
Python
328 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Replay recorded LSP traces against clice to detect hangs and crashes.
|
|
|
|
Usage:
|
|
python replay.py tests/smoke/session.jsonl --clice build/clice
|
|
"""
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import re
|
|
import signal
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from urllib.parse import quote, unquote
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
SERVER_REQUEST_DEFAULTS: dict[str, object] = {
|
|
"window/workDoneProgress/create": None,
|
|
"client/registerCapability": None,
|
|
"workspace/configuration": [{}],
|
|
}
|
|
|
|
|
|
def load_trace(path: Path) -> list[dict]:
|
|
"""Load a JSONL trace file. Each line: {"ts": <ms>, "msg": "<json>"}"""
|
|
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
|
|
|
|
|
|
def extract_original_workspace(records: list[dict]) -> str | None:
|
|
for rec in records:
|
|
parsed = json.loads(rec["msg"])
|
|
if parsed.get("method") != "initialize":
|
|
continue
|
|
root_uri = parsed.get("params", {}).get("rootUri")
|
|
if root_uri and root_uri.startswith("file://"):
|
|
path = unquote(root_uri[len("file://") :])
|
|
if len(path) > 2 and path[0] == "/" and path[2] == ":":
|
|
path = path[1:]
|
|
return path
|
|
return None
|
|
|
|
|
|
def rewrite_workspace(original_ws: str) -> str | None:
|
|
"""Map recorded workspace path to the current repo location."""
|
|
for marker in ("tests/data/", "tests/smoke/"):
|
|
idx = original_ws.find(marker)
|
|
if idx != -1:
|
|
return str(REPO_ROOT / original_ws[idx:])
|
|
return None
|
|
|
|
|
|
def rewrite_records(records: list[dict], original_ws: str, new_ws: str) -> list[dict]:
|
|
"""Text-level workspace path replacement in all messages.
|
|
|
|
Backslashes in Windows paths must be doubled inside JSON strings.
|
|
"""
|
|
new_ws_json = new_ws.replace("\\", "\\\\")
|
|
original_encoded = quote(original_ws, safe="/:")
|
|
new_encoded = quote(new_ws, safe="/:")
|
|
new_encoded_json = new_encoded.replace("\\", "\\\\")
|
|
rewritten = []
|
|
for rec in records:
|
|
msg = rec["msg"]
|
|
msg = msg.replace(original_ws, new_ws_json)
|
|
if original_encoded != original_ws:
|
|
msg = msg.replace(original_encoded, new_encoded_json)
|
|
rewritten.append({"ts": rec["ts"], "msg": msg})
|
|
return rewritten
|
|
|
|
|
|
def print_trace_info(name: str, records: list[dict], workspace: str | None):
|
|
methods: dict[str, int] = {}
|
|
for rec in records:
|
|
m = json.loads(rec["msg"]).get("method")
|
|
if m:
|
|
methods[m] = methods.get(m, 0) + 1
|
|
print(f"--- {name} ---")
|
|
print(f" messages: {len(records)}")
|
|
if workspace:
|
|
print(f" workspace: {workspace}")
|
|
if records:
|
|
print(f" duration: {(records[-1]['ts'] - records[0]['ts']) / 1000.0:.1f}s")
|
|
top = sorted(methods.items(), key=lambda x: -x[1])[:8]
|
|
print(f" methods: {', '.join(f'{m}({n})' for m, n in top)}")
|
|
|
|
|
|
async def read_lsp_message(reader: asyncio.StreamReader) -> dict | None:
|
|
header = b""
|
|
while True:
|
|
line = await reader.readline()
|
|
if not line:
|
|
return None
|
|
header += line
|
|
if header.endswith(b"\r\n\r\n"):
|
|
break
|
|
match = re.search(rb"Content-Length:\s*(\d+)", header)
|
|
if not match:
|
|
return None
|
|
return json.loads(await reader.readexactly(int(match.group(1))))
|
|
|
|
|
|
async def write_lsp_message(writer: asyncio.StreamWriter, payload: str):
|
|
encoded = payload.encode("utf-8")
|
|
writer.write(f"Content-Length: {len(encoded)}\r\n\r\n".encode("ascii") + encoded)
|
|
await writer.drain()
|
|
|
|
|
|
async def replay_one(trace_path: Path, clice_bin: Path, timeout: int) -> bool | None:
|
|
"""Replay a single trace. Returns True=PASS, False=FAIL, None=SKIP."""
|
|
records = load_trace(trace_path)
|
|
if not records:
|
|
print(f"SKIP: {trace_path.name} (empty)")
|
|
return None
|
|
|
|
original_ws = extract_original_workspace(records)
|
|
display_ws = original_ws
|
|
if original_ws is not None:
|
|
new_ws = rewrite_workspace(original_ws)
|
|
if new_ws and original_ws != new_ws:
|
|
records = rewrite_records(records, original_ws, new_ws)
|
|
display_ws = new_ws
|
|
|
|
print_trace_info(trace_path.name, records, display_ws)
|
|
|
|
env = dict(os.environ)
|
|
if sys.platform == "darwin":
|
|
prev = env.get("ASAN_OPTIONS", "")
|
|
env["ASAN_OPTIONS"] = f"{prev}:detect_leaks=0" if prev else "detect_leaks=0"
|
|
|
|
proc = await asyncio.create_subprocess_exec(
|
|
str(clice_bin),
|
|
"--mode",
|
|
"pipe",
|
|
env=env,
|
|
stdin=asyncio.subprocess.PIPE,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
|
|
pending: dict[int | str, asyncio.Future] = {}
|
|
|
|
async def reader_loop():
|
|
try:
|
|
while True:
|
|
msg = await read_lsp_message(proc.stdout)
|
|
if msg is None:
|
|
break
|
|
msg_id, method = msg.get("id"), msg.get("method")
|
|
if msg_id is not None and method is not None:
|
|
resp = SERVER_REQUEST_DEFAULTS.get(method)
|
|
await write_lsp_message(
|
|
proc.stdin,
|
|
json.dumps({"jsonrpc": "2.0", "id": msg_id, "result": resp}),
|
|
)
|
|
elif msg_id is not None:
|
|
fut = pending.pop(msg_id, None)
|
|
if fut and not fut.done():
|
|
fut.set_result(msg)
|
|
if "error" in msg:
|
|
err = msg["error"]
|
|
print(
|
|
f" ERROR response id={msg_id}: "
|
|
f"code={err.get('code')}, message={err.get('message')}"
|
|
)
|
|
except (asyncio.IncompleteReadError, ConnectionError, BrokenPipeError):
|
|
pass
|
|
finally:
|
|
for fut in pending.values():
|
|
if not fut.done():
|
|
fut.cancel()
|
|
|
|
wall_start = time.monotonic()
|
|
reader_task = asyncio.create_task(reader_loop())
|
|
success = True
|
|
last_method = None
|
|
sent_count = 0
|
|
|
|
try:
|
|
for i, rec in enumerate(records):
|
|
if i > 0:
|
|
delay = rec["ts"] - records[i - 1]["ts"]
|
|
if delay > 0:
|
|
await asyncio.sleep(delay / 1000.0)
|
|
|
|
parsed = json.loads(rec["msg"])
|
|
method = parsed.get("method")
|
|
msg_id = parsed.get("id")
|
|
last_method = method or last_method
|
|
|
|
# Before shutdown/exit, wait for all pending responses
|
|
if method in ("shutdown", "exit") and pending:
|
|
try:
|
|
await asyncio.wait_for(
|
|
asyncio.gather(*pending.values(), return_exceptions=True),
|
|
timeout=timeout,
|
|
)
|
|
except asyncio.TimeoutError:
|
|
elapsed = time.monotonic() - wall_start
|
|
print(
|
|
f" result: HANG ({len(pending)} pending before {method}, {elapsed:.1f}s)"
|
|
)
|
|
success = False
|
|
break
|
|
pending.clear()
|
|
|
|
if msg_id is not None and method is not None:
|
|
pending[msg_id] = asyncio.get_event_loop().create_future()
|
|
|
|
await write_lsp_message(proc.stdin, rec["msg"])
|
|
sent_count = i + 1
|
|
|
|
except (ConnectionError, BrokenPipeError):
|
|
elapsed = time.monotonic() - wall_start
|
|
try:
|
|
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
|
except asyncio.TimeoutError:
|
|
proc.kill()
|
|
await proc.wait()
|
|
print(
|
|
f" result: CRASH (broken pipe at {last_method}, exit={proc.returncode},"
|
|
f" sent={sent_count}/{len(records)}, {elapsed:.1f}s)"
|
|
)
|
|
success = False
|
|
|
|
# Wait for remaining responses after all messages sent
|
|
if success and pending:
|
|
try:
|
|
await asyncio.wait_for(
|
|
asyncio.gather(*pending.values(), return_exceptions=True),
|
|
timeout=timeout,
|
|
)
|
|
except asyncio.TimeoutError:
|
|
elapsed = time.monotonic() - wall_start
|
|
print(f" result: HANG ({len(pending)} pending after {elapsed:.1f}s)")
|
|
success = False
|
|
|
|
if success:
|
|
try:
|
|
proc.stdin.close()
|
|
await proc.stdin.wait_closed()
|
|
except (ConnectionError, BrokenPipeError):
|
|
pass
|
|
|
|
try:
|
|
await asyncio.wait_for(proc.wait(), timeout=10.0)
|
|
except asyncio.TimeoutError:
|
|
proc.kill()
|
|
await proc.wait()
|
|
|
|
reader_task.cancel()
|
|
try:
|
|
await reader_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
stderr_data = await proc.stderr.read()
|
|
elapsed = time.monotonic() - wall_start
|
|
returncode = proc.returncode
|
|
|
|
def print_stderr():
|
|
if stderr_data:
|
|
for line in stderr_data.decode("utf-8", errors="replace").splitlines()[
|
|
-20:
|
|
]:
|
|
print(f" | {line}")
|
|
|
|
if not success:
|
|
print_stderr()
|
|
return False
|
|
|
|
if returncode and returncode != 0:
|
|
sig = ""
|
|
if returncode < 0:
|
|
try:
|
|
sig = f" ({signal.Signals(-returncode).name})"
|
|
except (ValueError, AttributeError):
|
|
pass
|
|
print(f" result: CRASH (exit={returncode}{sig}, {elapsed:.1f}s)")
|
|
print_stderr()
|
|
return False
|
|
|
|
print(f" result: PASS ({elapsed:.1f}s)")
|
|
return True
|
|
|
|
|
|
async def async_main(args):
|
|
passed = failed = skipped = 0
|
|
for trace in args.traces:
|
|
if not trace.exists():
|
|
print(f"SKIP: {trace} (not found)")
|
|
skipped += 1
|
|
continue
|
|
result = await replay_one(trace, args.clice, args.timeout)
|
|
if result is None:
|
|
skipped += 1
|
|
elif result:
|
|
passed += 1
|
|
else:
|
|
failed += 1
|
|
|
|
total = passed + failed + skipped
|
|
print(f"\n{passed}/{total} passed", end="")
|
|
if skipped:
|
|
print(f", {skipped} skipped", end="")
|
|
if failed:
|
|
print(f", {failed} FAILED", end="")
|
|
print()
|
|
return 1 if failed else 0
|
|
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser(description="Replay LSP traces against clice")
|
|
p.add_argument("traces", nargs="+", type=Path, help="JSONL trace files")
|
|
p.add_argument("--clice", required=True, type=Path, help="Path to clice binary")
|
|
p.add_argument(
|
|
"--timeout", type=int, default=120, help="Timeout in seconds (default: 120)"
|
|
)
|
|
args = p.parse_args()
|
|
sys.exit(asyncio.run(async_main(args)))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|