Skip to content

Commit 31d9c60

Browse files
16bit-ykikoclaude
andauthored
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/kotatsu#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/kotatsu#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/kotatsu#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>
1 parent a1b6c06 commit 31d9c60

File tree

6 files changed

+369
-51
lines changed

6 files changed

+369
-51
lines changed

src/server/master_server.cpp

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1373,7 +1373,7 @@ void MasterServer::register_handlers() {
13731373
auto& doc = it->second;
13741374
doc.version = params.text_document.version;
13751375

1376-
// Apply incremental changes
1376+
// Apply content changes.
13771377
for(auto& change: params.content_changes) {
13781378
std::visit(
13791379
[&](auto& c) {
@@ -1384,7 +1384,6 @@ void MasterServer::register_handlers() {
13841384
} else {
13851385
// Incremental change: replace range
13861386
auto& range = c.range;
1387-
13881387
lsp::PositionMapper mapper(doc.text, lsp::PositionEncoding::UTF16);
13891388
auto start = mapper.to_offset(range.start);
13901389
auto end = mapper.to_offset(range.end);
@@ -1403,7 +1402,6 @@ void MasterServer::register_handlers() {
14031402
worker::DocumentUpdateParams update;
14041403
update.path = path;
14051404
update.version = doc.version;
1406-
update.text = doc.text;
14071405
pool.notify_stateful(path_id, update);
14081406
});
14091407

src/server/protocol.h

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,6 @@ struct IndexResult {
141141
struct DocumentUpdateParams {
142142
std::string path;
143143
int version;
144-
std::string text;
145144
};
146145

147146
struct EvictParams {

src/server/stateful_worker.cpp

Lines changed: 49 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ class StatefulWorker {
7878
et::ipc::BincodePeer& peer;
7979
std::uint64_t memory_limit;
8080

81-
llvm::StringMap<std::unique_ptr<DocumentEntry>> documents;
81+
llvm::StringMap<std::shared_ptr<DocumentEntry>> documents;
8282

8383
// LRU tracking — owns keys so they don't dangle after request handler returns
8484
std::list<std::string> lru;
@@ -106,13 +106,13 @@ class StatefulWorker {
106106
}
107107
}
108108

109-
DocumentEntry& get_or_create(llvm::StringRef path) {
109+
std::shared_ptr<DocumentEntry> get_or_create(llvm::StringRef path) {
110110
auto [it, inserted] = documents.try_emplace(path, nullptr);
111111
if(inserted) {
112-
it->second = std::make_unique<DocumentEntry>();
112+
it->second = std::make_shared<DocumentEntry>();
113113
LOG_DEBUG("Created new document entry: {}", path.str());
114114
}
115-
return *it->second;
115+
return it->second;
116116
}
117117

118118
/// Look up document, wait for AST, lock strand, run fn(doc) on thread pool, unlock.
@@ -123,19 +123,20 @@ class StatefulWorker {
123123
if(it == documents.end())
124124
co_return et::serde::RawValue{"null"};
125125

126-
auto& doc = *it->second;
126+
// Hold shared_ptr so Evict can't destroy the entry mid-request.
127+
auto doc = it->second;
127128
touch_lru(path);
128129

129-
co_await doc.ast_ready.wait();
130-
co_await doc.strand.lock();
130+
co_await doc->ast_ready.wait();
131+
co_await doc->strand.lock();
131132

132133
auto result = co_await et::queue([&]() -> et::serde::RawValue {
133-
if(!doc.has_ast || (!doc.unit.completed() && !doc.unit.fatal_error()))
134+
if(!doc->has_ast || (!doc->unit.completed() && !doc->unit.fatal_error()))
134135
return et::serde::RawValue{"null"};
135-
return fn(doc);
136+
return fn(*doc);
136137
});
137138

138-
doc.strand.unlock();
139+
doc->strand.unlock();
139140
co_return result.value();
140141
}
141142

@@ -153,71 +154,78 @@ void StatefulWorker::register_handlers() {
153154
const worker::CompileParams& params) -> RequestResult<worker::CompileParams> {
154155
LOG_INFO("Compile request: path={}, version={}", params.path, params.version);
155156

156-
auto& doc = get_or_create(params.path);
157-
doc.version = params.version;
158-
doc.text = params.text;
159-
doc.directory = params.directory;
160-
doc.arguments = params.arguments;
161-
doc.pch = params.pch;
162-
doc.pcms.clear();
163-
for(auto& [name, pcm_path]: params.pcms) {
164-
doc.pcms.try_emplace(name, pcm_path);
165-
}
166-
157+
// Hold shared_ptr so Evict can't destroy the entry mid-compile.
158+
auto doc = get_or_create(params.path);
167159
touch_lru(params.path);
168160

169-
co_await doc.strand.lock();
161+
co_await doc->strand.lock();
162+
163+
// Copy params to doc AFTER acquiring the strand lock, so that
164+
// concurrent Compile requests waiting on the strand don't
165+
// overwrite our fields before we use them.
166+
doc->version = params.version;
167+
doc->text = params.text;
168+
doc->directory = params.directory;
169+
doc->arguments = params.arguments;
170+
doc->pch = params.pch;
171+
doc->pcms.clear();
172+
for(auto& [name, pcm_path]: params.pcms) {
173+
doc->pcms.try_emplace(name, pcm_path);
174+
}
170175

171176
auto compile_result = co_await et::queue([&]() -> worker::CompileResult {
172-
LOG_DEBUG("Compiling: path={}, {} args", params.path, doc.arguments.size());
173-
174177
ScopedTimer timer;
175178

176179
CompilationParams cp;
177180
cp.kind = CompilationKind::Content;
178-
fill_args(cp, doc.directory, doc.arguments);
179-
if(!doc.pch.first.empty()) {
180-
cp.pch = doc.pch;
181+
fill_args(cp, doc->directory, doc->arguments);
182+
if(!doc->pch.first.empty()) {
183+
cp.pch = doc->pch;
181184
}
182-
cp.add_remapped_file(params.path, doc.text);
183-
for(auto& entry: doc.pcms) {
185+
cp.add_remapped_file(params.path, doc->text);
186+
for(auto& entry: doc->pcms) {
184187
cp.pcms.try_emplace(entry.getKey(), entry.getValue());
185188
}
186189

187-
doc.unit = compile(cp);
188-
doc.has_ast = true;
189-
doc.dirty.store(false, std::memory_order_release);
190+
doc->unit = compile(cp);
191+
doc->has_ast = true;
192+
doc->dirty.store(false, std::memory_order_release);
190193

191194
worker::CompileResult result;
192-
result.version = doc.version;
193-
if(doc.unit.completed() || doc.unit.fatal_error()) {
194-
auto diags = feature::diagnostics(doc.unit);
195+
result.version = doc->version;
196+
if(doc->unit.completed() || doc->unit.fatal_error()) {
197+
auto diags = feature::diagnostics(doc->unit);
195198
auto json = et::serde::json::to_json<et::ipc::lsp_config>(diags);
196199
result.diagnostics = et::serde::RawValue{json ? std::move(*json) : "[]"};
197200
LOG_INFO("Compile done: path={}, {}ms, {} diags, fatal={}",
198201
params.path,
199202
timer.ms(),
200203
diags.size(),
201-
doc.unit.fatal_error());
204+
doc->unit.fatal_error());
202205
} else {
203206
result.diagnostics = et::serde::RawValue{"[]"};
204207
LOG_WARN("Compile incomplete: path={}, {}ms", params.path, timer.ms());
205208
}
206209
result.memory_usage = 0; // TODO: query actual memory
207-
if(doc.unit.completed()) {
208-
result.deps = doc.unit.deps();
210+
if(doc->unit.completed()) {
211+
result.deps = doc->unit.deps();
209212
}
210213
return result;
211214
});
212215

213-
doc.strand.unlock();
214-
doc.ast_ready.set();
216+
doc->strand.unlock();
217+
doc->ast_ready.set();
215218
shrink_if_over_limit();
216219

217220
co_return compile_result.value();
218221
});
219222

220223
// === DocumentUpdate ===
224+
// Only mark the document dirty — do NOT update doc.text or doc.version
225+
// here. The et::queue compilation work may be reading doc.text on the
226+
// thread pool concurrently, so writing it from the event loop would be
227+
// a data race. The next Compile request will bring the correct text
228+
// and update it inside the strand lock.
221229
peer.on_notification([this](const worker::DocumentUpdateParams& params) {
222230
LOG_TRACE("DocumentUpdate: path={}, version={}", params.path, params.version);
223231

@@ -227,10 +235,7 @@ void StatefulWorker::register_handlers() {
227235
return;
228236
}
229237

230-
auto& doc = *it->second;
231-
doc.version = params.version;
232-
doc.text = params.text;
233-
doc.dirty.store(true, std::memory_order_release);
238+
it->second->dirty.store(true, std::memory_order_release);
234239
});
235240

236241
// === Evict ===

0 commit comments

Comments
 (0)