diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index eac5753..6a837f6 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -1683,6 +1683,18 @@ local function purge_dead_pending() for rid, entry in pairs(pend) do -- Absent or terminal, or the same sid running a NEW generation: -- in every case the request this entry awaits is unanswerable. + -- + -- The generation half is **defensive and not covered by the + -- acceptance suite**, stated plainly rather than left to look + -- tested. Reaching it requires a crash and its restart to both + -- fall inside a gap with no `_async.tick` — the crash backoff is + -- 500ms (`src/lsp.rs:1007`), so any tick during that window sees + -- `crashed` and the absent-or-terminal test above fires first. A + -- stalled or idle editor can produce such a gap, and then this is + -- the only thing standing between a one-shot and waiting forever + -- on a reply the dead generation owed. Every attempt to stage it + -- deterministically ended up exercising the `crashed` path + -- instead, so it is kept as insurance and labelled as such. if attempt == nil or attempt ~= entry.attempt then dead[#dead + 1] = rid end diff --git a/tests/lsp_dispatch_seams_acceptance.rs b/tests/lsp_dispatch_seams_acceptance.rs index d240ba4..cb9acff 100644 --- a/tests/lsp_dispatch_seams_acceptance.rs +++ b/tests/lsp_dispatch_seams_acceptance.rs @@ -336,16 +336,22 @@ fn acc33_raising_response_handler_does_not_stop_the_drain() { } // --------------------------------------------------------------------------- -// Acceptance 32 — the one-shot is removed BEFORE invocation. +// Acceptance 32 — the one-shot is removed exactly once, whether or not +// the handler raises. // -// Observed rather than asserted structurally: the handler raises, and -// the server is then stopped. If removal happened only on a clean -// return — or not at all — the purge below would invoke the same handler -// a second time with an error. The count is what pins it. +// Named for what it pins rather than for the framing's wording. Q#LN9 +// specifies removal *before* invocation, and the implementation does +// that — but bite-testing showed the before/after ordering is not +// observable on its own: `pcall` catches the raise either way, so +// removal after the call is behaviorally identical unless a handler +// re-enters the drain, which nothing does. What IS observable, and what +// this pins, is that removal is **unconditional**: the bite that moves +// it inside `if ok then` fails here 2 != 1, because the surviving +// registration gets invoked a second time by the purge. // --------------------------------------------------------------------------- #[test] -fn acc32_response_one_shot_is_removed_before_invocation() { +fn acc32_response_one_shot_is_removed_even_when_the_handler_raises() { let fx = Fixture::new(); let mut state = editor(); attached_rust(&mut state, &fx); @@ -375,8 +381,8 @@ fn acc32_response_one_shot_is_removed_before_invocation() { assert_eq!( eval::(&state, "return _G.calls"), 1, - "a delivered one-shot must not be re-invoked by the purge — it \ - was removed before the raising handler ran, not after" + "a delivered one-shot must not be re-invoked by the purge — \ + removal is unconditional, not gated on a clean return" ); }