fix(lsp): bound the client write path against a server that stops reading stdin#146
Conversation
…ding stdin A live LSP server that stops draining its stdin fills the ~64KB OS pipe buffer and wedges the blocking write_all forever. There were read deadlines but no write deadline, so a stuck didOpen (the largest write) hung `cartog index` uncancellably. Add a dedicated writer thread that owns ChildStdin and acks each framed write on a persistent per-client channel; write_message waits on the ack with a bounded write_timeout (10s default). A stuck stdin surfaces as an ack timeout the owner can bail on instead of a wedged write. On timeout the client is latched wedged (later writes fast-fail, never re-block) and dropped so a warm manager cannot reuse it; Drop kills the child, breaking the pipe so the parked writer exits. Both drain write-timeout arms set stopped_early (drained answers stay trusted) and drop the client. The cancel-path close_file now fast-fails on a wedged client, so Ctrl-C stays prompt. The ack round-trip costs ~6us/write vs a direct write_all (two thread wakeups, not allocation). Immaterial: writes pipeline 64-wide and hide behind 1-50ms server round-trips, so ~100k writes add ~0.6s to a ~3.5min resolution pass. The ack channel is reused per client to keep it off the per-edge allocation path. Tests: regression-first write-timeout + fast-fail-once-wedged + drop-and-reap + Drop-does-not-hang. Docs: concurrency.md (write side now bounded, measured cost) and troubleshooting.md.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (5)
📝 WalkthroughWalkthroughIntroduces a background writer thread in LspClient for stdin writes with per-write acknowledgement timeouts and a wedged-state latch, replacing direct synchronous writes. LspManager adds drop_client and test helpers. resolve.rs's drain_language treats write timeouts as a mute-server condition. Documentation updated accordingly. ChangesLSP write timeout and wedge handling
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Caller
participant LspClient
participant WriterThread
participant ChildStdin
participant LspManager
Caller->>LspClient: write_message(bytes)
LspClient->>WriterThread: send WriteJob
WriterThread->>ChildStdin: write_all + flush
alt ack within timeout
WriterThread-->>LspClient: ack Ok(())
LspClient-->>Caller: Ok(())
else ack timeout
LspClient->>LspClient: set write_wedged = true
LspClient-->>Caller: WRITE_TIMEOUT_PREFIX error
Caller->>LspManager: drop_client(language)
LspManager->>LspClient: drop (kill child process)
end
Estimated code review effort: 4 (Complex) | ~60 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #146 +/- ##
==========================================
+ Coverage 88.32% 88.35% +0.03%
==========================================
Files 96 96
Lines 32307 32474 +167
==========================================
+ Hits 28534 28692 +158
- Misses 3773 3782 +9 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Context
PR #145 shipped a fast-abort for one LSP failure mode: a server that reads its stdin but never replies (all-timeout windows → abandon the language). It did not cover the sibling mode: a server that stops reading its stdin.
LspClient::write_messagedid a blockingwrite_allonChildStdin. Once the ~64KB kernel pipe buffer fills, thatwrite_allblocks forever — there were read deadlines but no write deadline. A wedgeddidOpenwrite (the whole file text) hungcartog indexuncancellably; the process stays alive, so neither the death path nor the mute-abort fired. This was finding #5 of the #145 review, deferred as too structural to ride the bug-fix.Change
Dedicated writer thread (mirrors the existing reader thread) that owns
ChildStdinand performs every framed write, acking each on a persistent per-client channel.write_messagewaits on the ack with a boundedwrite_timeout(10s default) — a stuck stdin surfaces as an ack timeout the owner can bail on, not a wedgedwrite_all.On timeout the client is latched wedged (later writes fast-fail, never re-block behind the parked job) and dropped so a warm manager can't reuse it.
Dropkills the child, breaking the pipe so the parked writer exits on its own (no join, no leak).stopped_early(drained answers stay trusted) and drop the client viaLspManager::drop_client.close_filenow fast-fails on a wedged client, so Ctrl-C stays prompt.unsafe, identical on Windows, trivial framed-write atomicity, reuses a proven pattern.Review-driven follow-ups (same session, pre-commit)
An xhigh workflow review found gaps in the initial writer-thread version, all fixed here:
write_wedgedlatch makes any write after a wedge fast-fail (was:close_file/auto_respondre-blocking another fullwrite_timeout).start()'scontains_keyshort-circuit can't reuse it and re-stall every warmcartog_index.Performance
The per-write ack round-trip costs ~6.7µs vs ~0.44µs for a direct
write_all— two thread wakeups, not allocation (removing the per-call channel alloc left it unchanged). Immaterial in context: writes pipeline 64-wide and each hides behind a 1–50ms server round-trip, so ~100k writes add ~0.6s to a ~3.5-min resolution pass (~0.3%). No gated criterion bench touches this path. Recorded inconcurrency.md§7. The ack channel is reused per client to keep it off the per-edge allocation path.Tests
write_message_times_out_when_child_never_reads_stdin(regression-first; proven to hang onmain)second_write_fast_fails_after_a_wedge_instead_of_blocking_againwrite_timeout_does_not_hang_drop,is_write_timeout_matches_only_the_write_prefixdrop_client_removes_and_reaps_so_start_wont_reuse_a_wedged_serverdrain_language_stops_early_on_write_timeout(assertsstopped_early+ client dropped)Docs
concurrency.md§7 (write side now bounded, measured cost) andtroubleshooting.md. No CLI/flag/config/MCP/language/count change, so the site and reference docs are untouched per the doc-sync rule.Gates
cargo fmt --check, clippy (default +--no-default-features, workspace),cargo test --workspace+--no-default-features(both green),cargo test -p cartog-lsp(79 passed), doctests,cargo doc --no-depswarning-clean.🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Documentation