# Defer syntax check off the message loop

**Effort:** M (minimal defer) → L (full cancel) · **Impact:** L · **Immediate roc UX:** yes

## Repro (broken today)

1. Point Helix (or any client with a shutdown timeout) at `roc experimental-lsp`.
2. Open any non-trivial `.roc` file (workspace with platform / imports is enough to make `buildResolvingMain` take noticeable time).
3. Immediately close the file / quit the editor (or otherwise trigger LSP `shutdown` + `exit`) **while** the server is still checking the just-opened document.
4. Observe: client **times out** waiting for `shutdown` response; process may still be deep in a compile.

**Why:** Single-threaded loop. `didOpen` / `didChange` call `onDocumentChanged` → `syntax_checker.check` → `BuildSession` → `env.buildResolvingMain(...)` **before** the next `readMessage`. `shutdown` only does `state = .shutdown` + `sendNullResponse` — but that code never runs until the current notification finishes.

```zig
// did_open.zig
try self.doc_store.upsert(uri, version, text);
self.onDocumentChanged(uri);  // sync full build

// server.zig run loop
while (try self.processNextMessage()) {}  // one message at a time
```

There is **no** scheduling, **no** `$/cancelRequest`, **no** coop cancel token.

## Evidence

- Loop: `src/lsp/server.zig` → `run` / `processNextMessage`
- Sync trigger: `src/lsp/handlers/did_open.zig`, `did_change.zig` → `onDocumentChanged`
- Heavy work: `src/lsp/server.zig` → `runSyntaxCheck` → `src/lsp/syntax.zig` → `check` → `src/lsp/build_session.zig` → `buildResolvingMain`
- Shutdown itself is cheap: `src/lsp/handlers/shutdown.zig`

## Goal (this hand-off: minimal)

**Message loop always stays responsive.** Document text is stored immediately; analysis runs in a way that cannot block `shutdown` / `exit` for more than a trivial amount of time.

Minimum bar:

- After `didOpen` upsert, return to the read loop without completing a full build.
- Pending / in-flight check is abandoned or skipped once `state == .shutdown` (or on `exit`).
- Diagnostics may arrive slightly later; shutdown must not time out.

## Fix shape (choose the smallest that meets the bar)

### Preferred minimal

1. `didOpen` / `didChange`: upsert only; record `uri` (and version) as “dirty”.
2. After handling a message (or via a non-blocking idle step), run **at most one** dirty check if `state == .running`.
3. Before/after check slices, if state is shutdown/exit, skip publish and return.
4. Do **not** invent heuristics about “which file to prefer” beyond explicit dirty queue order (FIFO or latest-version-wins per URI — pick one, document it, no guessing).

### Not required yet (follow-up)

- Background thread + coop cancel inside `BuildEnv` (<1ms checkpoints).
- `$/cancelRequest` for hover/etc.
- Full request concurrency.

AGENTS note: deferral is control-flow, not a typing heuristic — still must not “best-effort reconstruct” missing analysis; skip publish if abandoned.

## Acceptance

- [ ] Repro: open → immediate shutdown gets a null `shutdown` result and clean `exit` under the client timeout (Helix default).
- [ ] Upsert still visible to subsequent requests (store has text even if check not done).
- [ ] When left idle after open, diagnostics still eventually publish for the dirty URI.
- [ ] No check starts after `shutdown` accepted.

## Out of scope for the minimal hand-off

- Wiring `getStaleModules` (separate improvement).
- Splitting `SyntaxChecker` god object.
- Guaranteeing mid-build cancel <1ms (needs BuildEnv coop points — later).
