# Never drop JSON-RPC request replies

**Effort:** S · **Impact:** M · **Immediate roc UX:** yes

## Repro (broken today)

1. Run the LSP against a client that times out waiting for request replies (Helix is fine).
2. Force a request handler to fail **after** `id` is known but **before** it sends a response — easiest artificial case: temporarily make `textDocument/hover` (or any request) `return error.OutOfMemory` / a bubbled `HandlerError` after params parse, **or** inject a failure in a test that drives `Server.run` with a framed request whose handler returns an error.
3. Observe: server logs `failed to process message: …` and **never** writes a JSON-RPC result or error for that `id`.
4. Client waits until its request timeout (looks like a hang / “LSP not responding”), even though the process is still alive and reading stdin.

**Why:** `processNextMessage` catches `handlePayload` and only logs. For requests, the `id` was already parseable; `sendError` exists but is unused on this path.

```zig
// src/lsp/server.zig — processNextMessage
self.handlePayload(payload) catch |err| {
    log.err("failed to process message: {s}", .{@errorName(err)});
};
```

## Evidence

- Swallow: `src/lsp/server.zig` → `processNextMessage` catch on `handlePayload`
- Reply helper unused here: `src/lsp/server.zig` → `sendError`
- Contrast: individual handlers sometimes call `sendError` for invalid params, but **any** error that escapes the handler still dies silently at the loop.

## Goal

Once a message is identified as a **request** with a parsed `id`, every exit path must send either a result or a JSON-RPC error (best-effort). Notifications remain fire-and-forget.

## Fix shape

Preferred (keeps one place honest):

1. In `handlePayload` / `handleRequest`, on handler failure after `id` is owned: best-effort `sendError(id, .internal_error, @errorName(err))` (or a stable message).
2. Ensure `processNextMessage` does not leave requests unanswered if `handlePayload` still can fail before/after id — either:
   - parse `id` early and always reply on catch, or
   - make `handleRequest` responsible for never returning without a wire reply for that id.
3. Do **not** claim a reply was sent if transport write failed (`WriteFailed`); logging is then correct.
4. Test: request that forces handler error → stdout frame contains `"error"` with matching `id`; no silent log-only path.

Inherent exception (document in code comment): if JSON is not an object / has no usable `id`, there is nothing to reply to.

## Acceptance

- [ ] Integration or unit test: failing request yields one JSON-RPC error response with the same `id`.
- [ ] Happy-path requests unchanged.
- [ ] Notifications that fail still only log (no bogus response).

## Out of scope

- Distinguishing “miss” null results (see `03-null-means-miss-not-failure.md`).
- Cancellation / `$/cancelRequest`.
