Files
clice/tests/integration/test_staleness.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

481 lines
19 KiB
Python

"""Integration tests for mtime-based staleness tracking.
Verifies that ensure_compiled() and ensure_pch() detect dependency file
changes via mtime snapshots, triggering recompilation without relying
on didSave to mark everything dirty.
"""
import asyncio
import json
import os
import shutil
import pytest
from lsprotocol.types import (
DidChangeTextDocumentParams,
DidCloseTextDocumentParams,
DidSaveTextDocumentParams,
HoverParams,
Position,
TextDocumentContentChangeWholeDocument,
TextDocumentIdentifier,
VersionedTextDocumentIdentifier,
)
def _write_cdb(workspace, files, extra_args=None):
"""Write a compile_commands.json for the given source files."""
entries = []
for f in files:
args = ["clang++", "-std=c++17", "-fsyntax-only"]
if extra_args:
args.extend(extra_args)
args.append(str(workspace / f))
entries.append(
{
"directory": str(workspace),
"file": str(workspace / f),
"arguments": args,
}
)
(workspace / "compile_commands.json").write_text(json.dumps(entries, indent=2))
def _doc(uri: str) -> TextDocumentIdentifier:
return TextDocumentIdentifier(uri=uri)
# =========================================================================
# Staleness detection tests
# =========================================================================
async def test_header_change_invalidates_ast(client, tmp_path):
"""Modifying a header on disk should cause recompilation on next hover,
even though didSave was never called (mtime-based detection)."""
# Setup: main.cpp includes header.h
(tmp_path / "header.h").write_text("inline int value() { return 1; }\n")
(tmp_path / "main.cpp").write_text(
'#include "header.h"\nint main() { return value(); }\n'
)
_write_cdb(tmp_path, ["main.cpp"])
await client.initialize(tmp_path)
# First compile — should succeed with no diagnostics.
uri, _ = await client.open_and_wait(tmp_path / "main.cpp")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected clean compile, got: {diags}"
# Modify header on disk — introduce an error.
# Ensure mtime advances past filesystem granularity (1s on some FSes).
await asyncio.sleep(1.1)
(tmp_path / "header.h").write_text(
"inline int value() { return }\n"
) # syntax error
# Send another hover — ensure_compiled should detect mtime change
# in deps and trigger recompilation. The recompilation publishes
# fresh diagnostics as a side effect.
event = client.wait_for_diagnostics(uri)
await client.text_document_hover_async(
HoverParams(text_document=_doc(uri), position=Position(line=0, character=0))
)
await asyncio.wait_for(event.wait(), timeout=60.0)
# Should now have diagnostics from the broken header.
diags = client.diagnostics.get(uri, [])
assert len(diags) > 0, "Expected diagnostics after header change"
async def test_header_change_invalidates_pch(client, tmp_path):
"""Modifying a preamble header on disk should trigger PCH rebuild."""
(tmp_path / "header.h").write_text("#pragma once\nstruct Foo { int x; };\n")
(tmp_path / "main.cpp").write_text(
'#include "header.h"\nint main() { Foo f; return f.x; }\n'
)
_write_cdb(tmp_path, ["main.cpp"])
await client.initialize(tmp_path)
# First compile — success.
uri, _ = await client.open_and_wait(tmp_path / "main.cpp")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0
# Modify header — rename struct field.
# Ensure mtime advances past filesystem granularity (1s on some FSes).
await asyncio.sleep(1.1)
(tmp_path / "header.h").write_text(
"#pragma once\nstruct Foo { int y; };\n" # x -> y
)
# Hover again — PCH should rebuild, AST should recompile.
# main.cpp uses f.x which no longer exists → diagnostics expected.
event = client.wait_for_diagnostics(uri)
await client.text_document_hover_async(
HoverParams(text_document=_doc(uri), position=Position(line=0, character=0))
)
await asyncio.wait_for(event.wait(), timeout=30.0)
diags = client.diagnostics.get(uri, [])
assert len(diags) > 0, "Expected error after header field rename"
async def test_no_change_skips_recompile(client, tmp_path):
"""When no dependency has changed, ensure_compiled should fast-path."""
(tmp_path / "main.cpp").write_text("int main() { return 0; }\n")
_write_cdb(tmp_path, ["main.cpp"])
await client.initialize(tmp_path)
uri, _ = await client.open_and_wait(tmp_path / "main.cpp")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0
# Second hover — should use cached AST (no recompilation).
# Verify it returns quickly and doesn't crash.
hover = await client.text_document_hover_async(
HoverParams(text_document=_doc(uri), position=Position(line=0, character=4))
)
# "main" should be hoverable.
assert hover is not None
async def test_touch_without_content_change_skips_recompile(client, tmp_path):
"""Layer 2: touching a header (mtime changes) without modifying content
should NOT trigger recompilation — the hash check catches this."""
(tmp_path / "header.h").write_text("inline int value() { return 1; }\n")
(tmp_path / "main.cpp").write_text(
'#include "header.h"\nint main() { return value(); }\n'
)
_write_cdb(tmp_path, ["main.cpp"])
await client.initialize(tmp_path)
uri, _ = await client.open_and_wait(tmp_path / "main.cpp")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0
# Touch the header — mtime changes but content stays the same.
await asyncio.sleep(1.1)
original_content = (tmp_path / "header.h").read_text()
(tmp_path / "header.h").write_text(original_content)
# Hover triggers ensure_compiled which runs deps_changed.
# Layer 2 hash confirms nothing actually changed → cached AST reused.
# Hover on "main" (line 1, col 4) which should be hoverable.
hover = await client.text_document_hover_async(
HoverParams(text_document=_doc(uri), position=Position(line=1, character=4))
)
assert hover is not None
# No new diagnostics should appear — the file is still clean.
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0
async def test_header_replaced_with_different_content(client, tmp_path):
"""Replacing a header file with different content should be detected
and trigger recompilation reflecting the new content."""
(tmp_path / "header.h").write_text("inline int value() { return 1; }\n")
(tmp_path / "main.cpp").write_text(
'#include "header.h"\nint main() { return value(); }\n'
)
_write_cdb(tmp_path, ["main.cpp"])
await client.initialize(tmp_path)
uri, _ = await client.open_and_wait(tmp_path / "main.cpp")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0
# Replace header — delete and recreate with a breaking change.
await asyncio.sleep(1.1)
(tmp_path / "header.h").unlink()
(tmp_path / "header.h").write_text("inline int renamed_value() { return 1; }\n")
# main.cpp still calls value() which no longer exists → error.
event = client.wait_for_diagnostics(uri)
await client.text_document_hover_async(
HoverParams(text_document=_doc(uri), position=Position(line=0, character=0))
)
await asyncio.wait_for(event.wait(), timeout=60.0)
diags = client.diagnostics.get(uri, [])
assert len(diags) > 0, "Expected diagnostics after header replacement"
async def test_fix_error_clears_diagnostics(client, tmp_path):
"""After introducing and fixing an error in a header, diagnostics
should clear on the next recompilation cycle."""
(tmp_path / "header.h").write_text("inline int value() { return }\n") # broken
(tmp_path / "main.cpp").write_text(
'#include "header.h"\nint main() { return value(); }\n'
)
_write_cdb(tmp_path, ["main.cpp"])
await client.initialize(tmp_path)
# First compile — should produce diagnostics.
uri, _ = await client.open_and_wait(tmp_path / "main.cpp")
diags = client.diagnostics.get(uri, [])
assert len(diags) > 0, "Expected diagnostics from broken header"
# Fix the header.
await asyncio.sleep(1.1)
(tmp_path / "header.h").write_text("inline int value() { return 1; }\n")
# Hover triggers recompilation — diagnostics should clear.
event = client.wait_for_diagnostics(uri)
await client.text_document_hover_async(
HoverParams(text_document=_doc(uri), position=Position(line=0, character=0))
)
await asyncio.wait_for(event.wait(), timeout=60.0)
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected clean compile after fix, got: {diags}"
async def test_multiple_files_share_header(client, tmp_path):
"""When a shared header changes, all open files that depend on it
should detect the staleness independently."""
(tmp_path / "shared.h").write_text("inline int shared() { return 1; }\n")
(tmp_path / "a.cpp").write_text(
'#include "shared.h"\nint fa() { return shared(); }\n'
)
(tmp_path / "b.cpp").write_text(
'#include "shared.h"\nint fb() { return shared(); }\n'
)
_write_cdb(tmp_path, ["a.cpp", "b.cpp"])
await client.initialize(tmp_path)
uri_a, _ = await client.open_and_wait(tmp_path / "a.cpp")
uri_b, _ = await client.open_and_wait(tmp_path / "b.cpp")
assert len(client.diagnostics.get(uri_a, [])) == 0
assert len(client.diagnostics.get(uri_b, [])) == 0
# Break the shared header.
await asyncio.sleep(1.1)
(tmp_path / "shared.h").write_text("inline int shared() { return }\n")
# Both files should get diagnostics after hover.
event_a = client.wait_for_diagnostics(uri_a)
await client.text_document_hover_async(
HoverParams(text_document=_doc(uri_a), position=Position(line=0, character=0))
)
await asyncio.wait_for(event_a.wait(), timeout=60.0)
assert len(client.diagnostics.get(uri_a, [])) > 0, "File A should have diagnostics"
event_b = client.wait_for_diagnostics(uri_b)
await client.text_document_hover_async(
HoverParams(text_document=_doc(uri_b), position=Position(line=0, character=0))
)
await asyncio.wait_for(event_b.wait(), timeout=60.0)
assert len(client.diagnostics.get(uri_b, [])) > 0, "File B should have diagnostics"
async def test_transitive_header_change(client, tmp_path):
"""A change to a transitively included header should be detected."""
(tmp_path / "base.h").write_text("inline int base() { return 1; }\n")
(tmp_path / "mid.h").write_text('#include "base.h"\n')
(tmp_path / "main.cpp").write_text(
'#include "mid.h"\nint main() { return base(); }\n'
)
_write_cdb(tmp_path, ["main.cpp"])
await client.initialize(tmp_path)
uri, _ = await client.open_and_wait(tmp_path / "main.cpp")
assert len(client.diagnostics.get(uri, [])) == 0
# Modify the transitive dep (base.h).
await asyncio.sleep(1.1)
(tmp_path / "base.h").write_text("inline int base() { return }\n") # broken
event = client.wait_for_diagnostics(uri)
await client.text_document_hover_async(
HoverParams(text_document=_doc(uri), position=Position(line=0, character=0))
)
await asyncio.wait_for(event.wait(), timeout=60.0)
diags = client.diagnostics.get(uri, [])
assert len(diags) > 0, "Expected diagnostics from transitive header change"
# =========================================================================
# didChange / didOpen / didSave / didClose lifecycle tests
# =========================================================================
async def test_didchange_body_edit_recompiles(client, tmp_path):
"""Editing the body (not preamble) via didChange should trigger
recompilation and update diagnostics."""
(tmp_path / "main.cpp").write_text("int main() { return 0; }\n")
_write_cdb(tmp_path, ["main.cpp"])
await client.initialize(tmp_path)
uri, _ = await client.open_and_wait(tmp_path / "main.cpp")
assert len(client.diagnostics.get(uri, [])) == 0
# Introduce a body error via didChange.
event = client.wait_for_diagnostics(uri)
client.text_document_did_change(
DidChangeTextDocumentParams(
text_document=VersionedTextDocumentIdentifier(uri=uri, version=1),
content_changes=[
TextDocumentContentChangeWholeDocument(
text="int main() { return }\n" # missing expression
)
],
)
)
await client.text_document_hover_async(
HoverParams(text_document=_doc(uri), position=Position(line=0, character=4))
)
await asyncio.wait_for(event.wait(), timeout=30.0)
diags = client.diagnostics.get(uri, [])
assert len(diags) > 0, "Expected diagnostics after body error"
async def test_didchange_preamble_edit_recompiles(client, tmp_path):
"""Changing a preamble #include via didChange should trigger PCH rebuild
and recompilation reflecting the new header's declarations."""
(tmp_path / "a.h").write_text("#pragma once\ninline int from_a() { return 1; }\n")
(tmp_path / "b.h").write_text("#pragma once\ninline int from_b() { return 2; }\n")
(tmp_path / "main.cpp").write_text(
'#include "a.h"\nint main() { return from_a(); }\n'
)
_write_cdb(tmp_path, ["main.cpp"])
await client.initialize(tmp_path)
uri, _ = await client.open_and_wait(tmp_path / "main.cpp")
assert len(client.diagnostics.get(uri, [])) == 0
# Switch from a.h to b.h and call from_b() instead.
event = client.wait_for_diagnostics(uri)
client.text_document_did_change(
DidChangeTextDocumentParams(
text_document=VersionedTextDocumentIdentifier(uri=uri, version=1),
content_changes=[
TextDocumentContentChangeWholeDocument(
text='#include "b.h"\nint main() { return from_b(); }\n'
)
],
)
)
await client.text_document_hover_async(
HoverParams(text_document=_doc(uri), position=Position(line=1, character=4))
)
await asyncio.wait_for(event.wait(), timeout=30.0)
# Should compile cleanly — from_b() is available via b.h.
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, (
f"Expected clean compile after preamble switch, got: {diags}"
)
async def test_didclose_then_reopen(client, tmp_path):
"""Closing and reopening a file should work correctly — the server
should not retain stale state from the previous session."""
(tmp_path / "main.cpp").write_text("int main() { return 0; }\n")
_write_cdb(tmp_path, ["main.cpp"])
await client.initialize(tmp_path)
uri, _ = await client.open_and_wait(tmp_path / "main.cpp")
assert len(client.diagnostics.get(uri, [])) == 0
# Close the file.
client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri)))
# Modify on disk while closed.
await asyncio.sleep(1.1)
(tmp_path / "main.cpp").write_text("int main() { return }\n") # broken
# Reopen — should compile the new (broken) content from disk.
uri2, _ = await client.open_and_wait(tmp_path / "main.cpp")
diags = client.diagnostics.get(uri2, [])
assert len(diags) > 0, "Expected diagnostics after reopen with broken content"
async def test_didclose_clears_hover(client, tmp_path):
"""After didClose, hover on the closed file should return None."""
(tmp_path / "main.cpp").write_text("int main() { return 0; }\n")
_write_cdb(tmp_path, ["main.cpp"])
await client.initialize(tmp_path)
uri, _ = await client.open_and_wait(tmp_path / "main.cpp")
client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri)))
hover = await client.text_document_hover_async(
HoverParams(text_document=_doc(uri), position=Position(line=0, character=4))
)
assert hover is None, "Hover on closed file should return None"
async def test_didsave_triggers_recompile_for_dependents(client, tmp_path):
"""didSave on a header file should mark dependent documents dirty."""
(tmp_path / "header.h").write_text("inline int value() { return 1; }\n")
(tmp_path / "main.cpp").write_text(
'#include "header.h"\nint main() { return value(); }\n'
)
_write_cdb(tmp_path, ["main.cpp"])
await client.initialize(tmp_path)
uri, _ = await client.open_and_wait(tmp_path / "main.cpp")
assert len(client.diagnostics.get(uri, [])) == 0
# Modify header on disk and send didSave.
await asyncio.sleep(1.1)
(tmp_path / "header.h").write_text("inline int value() { return }\n") # broken
client.text_document_did_save(
DidSaveTextDocumentParams(
text_document=TextDocumentIdentifier(uri=(tmp_path / "header.h").as_uri())
)
)
# Hover should detect the change and recompile.
event = client.wait_for_diagnostics(uri)
await client.text_document_hover_async(
HoverParams(text_document=_doc(uri), position=Position(line=0, character=0))
)
await asyncio.wait_for(event.wait(), timeout=60.0)
diags = client.diagnostics.get(uri, [])
assert len(diags) > 0, "Expected diagnostics after didSave on broken header"
async def test_didsave_with_module_deps(client, test_data_dir, tmp_path):
"""didSave on a module file should invalidate CompileGraph dependents."""
src = test_data_dir / "modules" / "save_recompile"
for f in src.iterdir():
if f.is_file():
shutil.copy2(f, tmp_path / f.name)
from tests.conftest import generate_cdb
generate_cdb(tmp_path)
await client.initialize(tmp_path)
# Open and compile Mid (which imports Leaf).
mid_uri, _ = await client.open_and_wait(tmp_path / "mid.cppm")
diags = client.diagnostics.get(mid_uri, [])
assert len(diags) == 0
# Modify Leaf on disk and send didSave — should invalidate Mid's deps.
new_leaf = "export module Leaf;\nexport int leaf() { return 999; }\n"
(tmp_path / "leaf.cppm").write_text(new_leaf)
leaf_path = tmp_path / "leaf.cppm"
client.text_document_did_save(
DidSaveTextDocumentParams(
text_document=TextDocumentIdentifier(uri=leaf_path.as_uri())
)
)
# Hover on Mid should trigger recompilation (Leaf PCM was invalidated).
event = client.wait_for_diagnostics(mid_uri)
await client.text_document_hover_async(
HoverParams(text_document=_doc(mid_uri), position=Position(line=0, character=0))
)
await asyncio.wait_for(event.wait(), timeout=60.0)
diags = client.diagnostics.get(mid_uri, [])
assert len(diags) == 0, f"Expected clean compile after module update, got: {diags}"