Skip to content

fix(lsp): crash-safety — panic recovery, layout canary, race+lint CI - #8

Merged
klaidliadon merged 4 commits into
masterfrom
ridl-lsp-hardening/crash-safety-ci
Jun 20, 2026
Merged

klaidliadon merged 4 commits into
masterfrom
ridl-lsp-hardening/crash-safety-ci

Conversation

@klaidliadon

@klaidliadon klaidliadon commented Jun 19, 2026 •

Copy link
Copy Markdown
Collaborator

A malformed document can currently take down the whole language server: go.lsp.dev runs every request in its own goroutine and recovers nothing, so a single panic in a handler — very reachable given the unsafe.Pointer parser mirrors — kills the process and every editor feature with it. This adds a panic-recovery middleware so a panic degrades to one failed request, plus a canary test that turns silent unsafe layout drift into a red build, and tightens CI to actually catch races and lint regressions.

First of three stacked PRs hardening ridl-lsp for production (from a Claude↔Codex audit). This one is the base — safe to merge first, no dependency on the others.


Changes (audit findings)

  • C1 — panic recovery (internal/lsp/recover.go): RecoverHandler wraps the protocol ServerHandler (inside ReplyHandler), recovers panics, logs method + stack, and replies a JSON-RPC error when no reply was sent yet. A recovered panic never propagates as a returned error (jsonrpc2 treats that as connection-fatal) and never double-replies. Wired in main.go by replicating protocol.NewServer's setup.
  • C2 — layout canary (internal/ridl/layout_canary_test.go, parser.go): the parser reads unexported webrpc/webrpc types through hand-mirrored structs via unsafe.Pointer, previously unguarded. Added a canary that parses a fixture with known positions and asserts every mirrored read (token line/col, error code/message/status, inline-struct arg, parser root); converted the never-read leading mirror fields to blank _ padding (same layout, documents intent, clears the unused linter).
  • I5 — CI gate (.github/workflows/ci.yml): run tests with -race and add a golangci-lint step (same go run + .golangci.yml the Makefile uses, minus --fix).
  • prep (internal/lsp/semantic_document.go): drop a redundant []rune conversion (staticcheck SA6003) so the lint gate goes green.

Test plan

$ go test -race ./...
Go test: 123 passed in 6 packages

$ go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint run ./... -c .golangci.yml
0 issues.

Canary teeth verified: inserting one bogus field ahead of line/col in the mirror made TestUpstreamLayoutCanary fail (TokenLine returned 18 instead of 6); reverted.

Review

Self-review (2 angles), security-review, and a Codex adversarial pass all returned zero Critical/Important findings. Codex's lone Suggestion — go run golangci-lint is toolchain-sensitive — is intentionally kept: it matches the repo's existing make lint, and CI pins Go 1.25.0 via go-version-file.


Stack: ridl-lsp-hardening

  1. fix(lsp): crash-safety — panic recovery, layout canary, race+lint CI #8 ◀ this PR
  2. perf(lsp): parse-pipeline hardening — immutable docs, partial-result cache, cancellation, workspace pruning #9
  3. feat(lsp): polish — graceful shutdown, log level, upstream error-format canaries #10

This stack is managed with sdf.

Range directly over the string in hasCompletedTypeExpr. The rune index was discarded, so the []rune materialization was unnecessary (staticcheck SA6003). Needed for the golangci-lint CI gate added later in this stack.
The parser reads unexported webrpc/webrpc schema/ridl types through unsafe.Pointer struct mirrors. Nothing verified that the mirrored field layouts still match upstream, so a field reorder/resize upstream would silently corrupt hover/goto/diagnostics with no compile error or test failure (audit C2).

- Convert the never-read leading mirror fields to blank padding, documenting that they exist only for offset fidelity (also clears the unused linter ahead of the CI lint gate).

- Add TestUpstreamLayoutCanary: parse a fixture with known positions and assert every mirrored read (token line/col, error code/message/status, inline-struct arg, parser root) returns the expected value. Verified it fails when a field is inserted ahead of line/col.
go.lsp.dev runs each request in its own goroutine via jsonrpc2.AsyncHandler, and neither jsonrpc2 nor go.lsp.dev/protocol recovers panics. An unrecovered panic in any handler therefore terminates the whole language server, taking down every editor feature until the client respawns it. The unsafe.Pointer parser mirrors make panics a realistic failure mode (audit C1).

- Add RecoverHandler middleware: recovers panics, logs method + stack, and replies with a JSON-RPC error when no reply was sent yet. A recovered panic never propagates as a returned error (jsonrpc2 treats that as connection-fatal) and never double-replies.

- Wire it in main by replicating protocol.NewServer's setup and wrapping ServerHandler, so it sits inside ReplyHandler and satisfies the reply-exactly-once contract.

- Tests cover panic-before-reply, panic-after-reply (no double reply), and pass-through.
CI ran only go build and go test, with no race detector and no lint gate, despite the repo carrying a .golangci.yml and the server being concurrency-bearing and unsafe-heavy. Race and lint regressions could land unblocked (audit I5).

- Run tests with -race.

- Add a lint step using the repo's pinned golangci-lint and .golangci.yml (no --fix, so CI reports rather than mutates). The codebase is lint-clean as of the earlier commits in this stack.
@klaidliadon
klaidliadon merged commit cb95e45 into master Jun 20, 2026
1 check passed
@klaidliadon
klaidliadon deleted the ridl-lsp-hardening/crash-safety-ci branch June 20, 2026 06:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant