Files
clice/tests/replay.py
ykiko 31d9c609b6 fix: data race in stateful worker between Compile and DocumentUpdate (#389)
## 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>
2026-04-05 12:20:13 +08:00

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()