diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-11 08:37:31 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-11 08:37:31 +0300 |
| commit | dc8f0ab28276fac6aa16c0cf3591c322367036b7 (patch) | |
| tree | 46337a37ade2b02a6ecaac7811450df9f8f4540f /internal/lsp/chat_history_test.go | |
| parent | e95f3fdf0a66ba05ba2c8fb7e755e107f9cf7991 (diff) | |
Fix LSP panic: bounds-check stale line index in async chat apply
When handleChatPrompt detects an in-editor chat prompt it captures the
line index, then spawns a goroutine (requestChatResponse) that later
calls applyChatEdits/buildChatHistory. If a concurrent didChange shrinks
the document in the meantime, the captured lineIdx can exceed
len(d.lines), causing an index-out-of-range panic in the chat goroutine.
- applyChatEdits: skip the stale edit (and log) when lineIdx is < 0 or
>= len(d.lines), rather than indexing d.lines[lineIdx] and panicking
(or corrupting the already-changed document at the wrong position).
- buildChatHistory: clamp the starting index to len(d.lines)-1 so the
upward walk over d.lines[i] cannot read past the end.
Adds regression test TestChatEdits_StaleLineIndexAfterShrink covering
the shrink-then-apply race for both functions, including the
one-past-the-end boundary and a negative index. Confirmed the test
panics without the fix and passes with it; full `mage test` (-race) green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'internal/lsp/chat_history_test.go')
| -rw-r--r-- | internal/lsp/chat_history_test.go | 43 |
1 files changed, 42 insertions, 1 deletions
diff --git a/internal/lsp/chat_history_test.go b/internal/lsp/chat_history_test.go index a6d6266..00d00ec 100644 --- a/internal/lsp/chat_history_test.go +++ b/internal/lsp/chat_history_test.go @@ -1,6 +1,9 @@ package lsp -import "testing" +import ( + "io" + "testing" +) func TestStripTrailingTrigger(t *testing.T) { s := newTestServer() @@ -36,3 +39,41 @@ func TestBuildChatHistory_OrderAndLimit(t *testing.T) { t.Fatalf("unexpected contents: %+v", msgs) } } + +// TestChatEdits_StaleLineIndexAfterShrink is a regression test for the LSP +// server panic that occurred when an async chat response was applied after a +// concurrent didChange shrank the document. The chat prompt was detected at a +// line index that is now past the end of the (smaller) document, so the stale +// lineIdx exceeds len(d.lines). Before the fix, both applyChatEdits and +// buildChatHistory indexed d.lines without an upper-bound check and panicked +// with index-out-of-range. The fix makes applyChatEdits skip the stale edit and +// buildChatHistory clamp its starting index, so neither must panic. +func TestChatEdits_StaleLineIndexAfterShrink(t *testing.T) { + s := newTestServer() + // io.Discard avoids a nil-writer panic on the (valid-index) clientApplyEdit + // path; the stale-index path bails out before reaching it. + s.out = io.Discard + uri := "file:///shrink.txt" + // Originally the prompt sat on line 8, but the document has since shrunk to + // just three lines. lineIdx=8 is now well past len(d.lines). + s.setDocument(uri, "line0\nline1\nline2\n") + staleLineIdx := 8 + + // applyChatEdits must not panic on a stale (out-of-range) line index. + s.chatSvc().applyChatEdits(uri, staleLineIdx, 12, 1, "> reply") + + // buildChatHistory must not panic and must still return the current prompt. + msgs := s.chatSvc().buildChatHistory(uri, staleLineIdx, "current?") + if len(msgs) == 0 || msgs[len(msgs)-1].Content != "current?" { + t.Fatalf("expected history to end with current prompt, got: %+v", msgs) + } + + // Boundary case: lineIdx exactly equals len(d.lines) (one past the last line). + s.chatSvc().applyChatEdits(uri, 3, 4, 1, "> reply") + if msgs := s.chatSvc().buildChatHistory(uri, 3, "q"); len(msgs) == 0 { + t.Fatalf("expected non-empty history at boundary index") + } + + // Negative index must also be handled gracefully. + s.chatSvc().applyChatEdits(uri, -1, 0, 1, "> reply") +} |
