ohm

April 17, 2026 · View on GitHub

Why ohm exists

Neovim's built-in LSP client starts a fresh server process per session. This creates several recurring problems in real-world use:

  • Memory bloat — gopls for a large Go repo can use 500–1500MB. Three Neovim sessions = three copies.
  • Monorepo duplication — opening the same root directory in multiple windows spawns redundant servers that index the same files independently.
  • Stuck diagnostics — when a Neovim session closes, the LSP server receives no textDocument/didClose, leaving stale diagnostics on the next open.
  • Session degradation — long-running LSP servers accumulate state; a server spawned fresh each session never benefits from warmup.

ohm moves the LSP server lifecycle out of Neovim and into a persistent daemon. One server per {root_dir, language} pair, shared across every Neovim session, for the lifetime of the workstation session.


Two-socket design

ohm uses two distinct Unix sockets with different protocols:

┌─────────────────────────────────────────────────────┐
│  Neovim instance                                     │
│                                                      │
│  lspconfig (ohm --client per buffer)                 │
│       │ stdio  LSP JSON-RPC                          │
│       ▼                                              │
│  ohm --client bridge process                         │
│       │ unix socket  LSP JSON-RPC                   │
│       ▼                                              │
│  [proxy socket]  ◄──── per server, raw LSP           │
└─────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────┐
│  ohm daemon                                          │
│                                                      │
│  Mux (fan-out, ID rewriting)                         │
│       │ stdio  LSP JSON-RPC                          │
│       ▼                                              │
│  LSP server process (gopls, rust-analyzer, ...)      │
└─────────────────────────────────────────────────────┘

  [control socket]  ◄──── msgpack-rpc, persistent channel


  Neovim shim (client.lua rpcrequest/rpcnotify)

Control socket (ohm.sock) — speaks msgpack-rpc (Neovim's native RPC protocol). Used by the Lua plugin to send attach/detach/status commands. One persistent connection per Neovim instance.

Proxy socket (e.g. ohm-go-a3f1b2c4.sock) — speaks raw LSP JSON-RPC. One socket per registered {root_dir, language} server. The ohm --client bridge connects here and forwards bytes to/from Neovim's lspconfig.

Keeping the protocols separate avoids the corruption that plagued V1, where LSP frames and msgpack-rpc frames shared a single socket.


Request flow

Attach (new buffer opened)

  1. Neovim opens main.go in a Go project.
  2. lspconfig calls vim.lsp.rpc.start, which ohm's wire_lspconfig hook has overridden to launch ohm --client --socket <sock> --root <dir> --lang go -- gopls.
  3. ohm --client connects to the control socket and sends a msgpack-rpc attach request.
  4. The daemon looks up {root_dir="...", lang="go"} in the registry:
    • Hit — increments ref count, returns existing proxy socket path.
    • Miss — spawns a new gopls process, creates a Mux, binds a proxy socket, registers the server, returns the proxy socket path.
  5. ohm --client disconnects from the control socket, connects to the proxy socket, and begins bridging stdin ↔ proxy bidirectionally.
  6. Neovim's lspconfig now believes it is speaking directly to gopls.

LSP request (e.g. hover)

Neovim  →  ohm --client (stdin→proxy)
        →  proxy socket  →  Mux.serveClient
        →  ID rewritten (client id → global id)
        →  WriteFrame to gopls stdin
        →  gopls processes request
        →  gopls stdout  →  Mux.Broadcast
        →  pending map lookup (global id → original client id + conn)
        →  ID restored
        →  WriteFrame to proxy socket
        →  ohm --client (proxy→stdout)  →  Neovim

Server-pushed notification (e.g. publishDiagnostics)

gopls stdout  →  Mux.Broadcast
              →  no id, has method  →  broadcast path
              →  WriteFrame to every connected client
              →  all Neovim instances receive diagnostics

Request ID rewriting

LSP uses numeric request IDs chosen by the client. With multiple Neovim sessions sharing one gopls, their IDs collide (every session starts at 1).

The Mux maintains a global atomic counter (nextID). On each incoming request:

  1. The original client ID is saved in a pending map keyed by globalID.
  2. The message body is rewritten with globalID before forwarding to gopls.
  3. When gopls responds, Broadcast looks up globalID in the pending map, rewrites the ID back to the original client value, and routes the response to that client's connection only.

Notifications (no ID field) are broadcast to all clients since they are not responses to a specific request.


initialize caching

The LSP initialize handshake is expensive: it triggers full project indexing in gopls. ohm ensures it happens exactly once per server lifetime.

First client                   Concurrent client           Later client
──────────────                 ─────────────────           ────────────
send initialize
  initInFlight = true ─────►  sees initInFlight=true
  forward to gopls             block on <-initReady
gopls responds
  cache initResponse
  close(initReady)  ──────►  unblocked
  send to client A             rewrite ID, send cached    send cached immediately

Three states tracked under initMu:

  • initResponse == nil, initInFlight == false → first caller; forward to server
  • initResponse == nil, initInFlight == true → concurrent caller; wait on initReady channel
  • initResponse != nil → cached; return immediately with ID rewrite

The initialized notification (sent after initialize succeeds) is only forwarded for the first client. Subsequent clients skip it via the same caching path.


Shutdown interception

When a Neovim session closes, lspconfig sends shutdown then exit. Forwarding these to gopls would kill the shared server.

serveClient intercepts both:

  • shutdown — a fake {"result": null} response is sent back to the client immediately. The request is never forwarded.
  • exit (notification, no ID) — silently dropped.

gopls never sees either message and stays running.


Ref counting and grace period

Each LSPServer tracks a Refs count — the number of ohm --client bridge processes currently connected to its proxy socket.

  • attachIncrRef
  • detachDecrRef

When Refs reaches 0, a 10-second timer starts (pendingKill). If a new attach arrives within the window the timer is cancelled and the server is reused immediately. After 10 seconds the server is shut down gracefully.

This handles the common case of closing and immediately reopening a file, or switching between splits.


Respawn

When a server process exits unexpectedly (gopls crash, OOM kill), Mux.Broadcast reads EOF from the process stdout and calls mux.onExit, which triggers respawnServer.

respawnServer:

  1. Cancels any in-flight pendingKill timer for the key (a crash during the grace period must not let the timer kill the new process).
  2. Spawns a fresh LSP process.
  3. Creates a new Mux for the new process.
  4. Swaps server.Process and server.mux in place under server.mu.
  5. Starts Broadcast on the new mux.

The proxy socket listener (listenProxy) keeps running throughout — it holds no reference to the old mux. New connections arriving after the swap go to the new mux automatically.

Existing Neovim clients connected to the old mux will see their serveClient goroutines exit (write errors to a dead process), disconnect, and reconnect on the next LSP request via lspconfig.


Watchdog

A goroutine wakes every 30 seconds and checks every registered server:

CheckThresholdAction
RSS memory> 1500 MBgraceful shutdown + remove
Last response age> 5 minutesgraceful shutdown + remove

Memory is read from /proc/{pid}/status (VmRSS). Last response time is an atomic timestamp updated on every message received from the server's stdout.

Both checks call server.Close() which sends a graceful LSP shutdown+exit sequence before killing the process. mux.onExit then triggers a respawn.


Graceful shutdown sequence

Mux.GracefulShutdown:

  1. Register a synthetic internal pending entry (no client, just a done channel).
  2. Send {"method":"shutdown","id":<globalID>} to the server.
  3. Wait up to 5 seconds for the response on done.
  4. Send {"method":"exit"} notification.
  5. Wait up to 2 seconds for the process to exit.
  6. If still running after step 5, Kill().

Concurrency model

GoroutineLifetimeWhat it does
handleConnper control connectiondecodes msgpack-rpc, dispatches attach/detach/status
Broadcastper LSP serverreads server stdout, routes to clients
serveClientper proxy clientreads client frames, rewrites IDs, writes to server stdin
sendLoopper proxy clientdrains send channel, writes frames to client conn
listenProxyper LSP serveraccepts new proxy connections
captureStderrper LSP serverpipes server stderr to slog
watchdogsingletonperiodic memory + frozen checks
respawnServeron crashruns as go m.onExit() from Broadcast

Shared state and its lock:

StateLock
registry.serversregistry.mu
daemon.pendingKilldaemon.mu
mux.clientsmux.clientsMu (RWMutex)
mux.pendingmux.pendingMu
mux.initResponse / initInFlightmux.initMu
server.mux / server.Processserver.mu (RWMutex)
mux.lastNsatomic
mux.nextIDatomic