Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,7 @@

> one server. no resistance.

A persistent LSP process manager daemon for Neovim. Fixes memory bloat, stuck diagnostics, monorepo server duplication, and session degradation — the recurring pain points in Neovim's LSP lifecycle.

## The Problem

Neovim starts a new LSP server per session, leaks memory, leaves stuck diagnostics on detach, and spawns duplicate servers in monorepos. ohm solves it at the daemon layer.
A persistent LSP process manager daemon for Neovim. Neovim starts a fresh server per session — ohm replaces that with one shared server per `{root_dir, language}` pair, fixing memory bloat, stuck diagnostics, and monorepo duplication at the daemon layer.

## How It Works

Expand All @@ -28,7 +24,7 @@ Neovim instances (any number)
- **Grace period** — when refs hit 0, waits 10s before killing. Reopen a file within the window to cancel.
- **Diagnostic fence** — sends `textDocument/didClose` before detach to prevent stuck diagnostics.
- **Respawn** — crashed servers are automatically restarted without losing the proxy socket.
- **Watchdog** — kills servers exceeding 1500MB RSS or frozen for 5+ minutes.
- **Watchdog** — kills runaway or frozen servers automatically.
- **Shutdown interception** — intercepts client `shutdown`/`exit` so individual session closes don't kill the shared server.

## Requirements
Expand Down Expand Up @@ -146,6 +142,10 @@ mkdir -p tmp && go run . tmp/ohm.sock
mkdir -p tmp && go run . --debug tmp/ohm.sock
```

## Architecture

See [docs/architecture.md](docs/architecture.md) for a deep dive: two-socket design, request flow, ID rewriting, initialize caching, respawn, and the concurrency model.

## License

MIT
21 changes: 21 additions & 0 deletions daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,16 @@ func (d *Daemon) handleAttach(msg AttachMsg) (string, error) {
}

func (d *Daemon) respawnServer(key ServerKey) {
// Cancel any pending kill timer — it was set for the crashed process and
// would otherwise fire on the newly-spawned one.
d.mu.Lock()
if timer, ok := d.pendingKill[key]; ok {
timer.Stop()
delete(d.pendingKill, key)
slog.Info("respawn: cancelled pending kill", "lang", key.LanguageID)
}
d.mu.Unlock()

server, ok := d.registry.Get(key)
if !ok {
return
Expand Down Expand Up @@ -179,6 +189,8 @@ func captureStderr(proc *Process, lang string) {
}
}

// proxySocketPath returns a stable socket path for the per-server LSP proxy.
// The 4-byte hash prefix is for uniqueness across root+lang pairs, not security.
func (d *Daemon) proxySocketPath(key ServerKey) string {
h := sha256.Sum256([]byte(key.RootDir + "|" + key.LanguageID))
name := fmt.Sprintf("ohm-%s-%x.sock", key.LanguageID, h[:4])
Expand Down Expand Up @@ -276,6 +288,11 @@ func (d *Daemon) handleConn(conn net.Conn) {
}
switch msg.Method {
case "attach":
if len(msg.Params) == 0 {
slog.Error("attach: missing params")
h.WriteResponse(conn, msg.MsgID, nil)
continue
}
var a AttachMsg
if err := h.DecodeParam(&a, msg.Params[0]); err != nil {
slog.Error("decode attach", "err", err)
Expand All @@ -291,6 +308,10 @@ func (d *Daemon) handleConn(conn net.Conn) {
h.WriteResponse(conn, msg.MsgID, socketPath)

case "detach":
if len(msg.Params) == 0 {
slog.Error("detach: missing params")
continue
}
var a DetachMsg
if err := h.DecodeParam(&a, msg.Params[0]); err != nil {
slog.Error("decode detach", "err", err)
Expand Down
118 changes: 118 additions & 0 deletions daemon/frame_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package daemon

import (
"bufio"
"bytes"
"strings"
"testing"
)

func TestWriteReadFrame_roundtrip(t *testing.T) {
body := []byte(`{"jsonrpc":"2.0","method":"initialized","params":{}}`)

var buf bytes.Buffer
if err := WriteFrame(&buf, body); err != nil {
t.Fatalf("WriteFrame: %v", err)
}

got, err := ReadFrame(bufio.NewReader(&buf))
if err != nil {
t.Fatalf("ReadFrame: %v", err)
}
if !bytes.Equal(got, body) {
t.Errorf("roundtrip mismatch\ngot: %s\nwant: %s", got, body)
}
}

func TestWriteFrame_header(t *testing.T) {
body := []byte(`{}`)
var buf bytes.Buffer
if err := WriteFrame(&buf, body); err != nil {
t.Fatalf("WriteFrame: %v", err)
}
s := buf.String()
if !strings.HasPrefix(s, "Content-Length: 2\r\n\r\n") {
t.Errorf("unexpected header: %q", s)
}
}

func TestWriteReadFrame_empty(t *testing.T) {
body := []byte{}
var buf bytes.Buffer
if err := WriteFrame(&buf, body); err != nil {
t.Fatalf("WriteFrame: %v", err)
}
got, err := ReadFrame(bufio.NewReader(&buf))
if err != nil {
t.Fatalf("ReadFrame: %v", err)
}
if len(got) != 0 {
t.Errorf("expected empty body, got %q", got)
}
}

func TestReadFrame_missingContentLength(t *testing.T) {
// Headers with no Content-Length, then blank line
raw := "X-Custom: foo\r\n\r\n"
_, err := ReadFrame(bufio.NewReader(strings.NewReader(raw)))
if err == nil {
t.Fatal("expected error for missing Content-Length, got nil")
}
if !strings.Contains(err.Error(), "missing Content-Length") {
t.Errorf("unexpected error: %v", err)
}
}

func TestReadFrame_truncatedBody(t *testing.T) {
// Content-Length says 100 bytes but only 3 bytes follow
raw := "Content-Length: 100\r\n\r\nabc"
_, err := ReadFrame(bufio.NewReader(strings.NewReader(raw)))
if err == nil {
t.Fatal("expected error for truncated body, got nil")
}
}

func TestReadFrame_multipleHeaders(t *testing.T) {
// LSP spec allows extra headers before Content-Length
body := []byte(`{"id":1}`)
var buf bytes.Buffer
buf.WriteString("Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n")
if err := WriteFrame(&buf, body); err != nil {
t.Fatalf("WriteFrame: %v", err)
}
// Rewrite: put extra header before Content-Length in a fresh buffer
combined := "Content-Type: application/vscode-jsonrpc\r\nContent-Length: 8\r\n\r\n{\"id\":1}"
got, err := ReadFrame(bufio.NewReader(strings.NewReader(combined)))
if err != nil {
t.Fatalf("ReadFrame: %v", err)
}
if string(got) != `{"id":1}` {
t.Errorf("got %q", got)
}
}

func TestWriteReadFrame_multipleMessages(t *testing.T) {
msgs := [][]byte{
[]byte(`{"id":1,"method":"initialize"}`),
[]byte(`{"id":2,"method":"shutdown"}`),
[]byte(`{"method":"exit"}`),
}

var buf bytes.Buffer
for _, m := range msgs {
if err := WriteFrame(&buf, m); err != nil {
t.Fatalf("WriteFrame: %v", err)
}
}

r := bufio.NewReader(&buf)
for i, want := range msgs {
got, err := ReadFrame(r)
if err != nil {
t.Fatalf("msg %d: ReadFrame: %v", i, err)
}
if !bytes.Equal(got, want) {
t.Errorf("msg %d: got %s, want %s", i, got, want)
}
}
}
6 changes: 4 additions & 2 deletions daemon/lsp_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ func RunClient(args []string) error {
}

func parseClientArgs(args []string) (socket, root, lang string, cmd []string, err error) {
hasSep := false
for i := 0; i < len(args); i++ {
switch args[i] {
case "--socket":
Expand All @@ -110,8 +111,9 @@ func parseClientArgs(args []string) (socket, root, lang string, cmd []string, er
}
lang = args[i]
case "--":
hasSep = true
cmd = args[i+1:]
return
i = len(args) // consumed; exit loop to run validation below
}
}
if socket == "" {
Expand All @@ -120,7 +122,7 @@ func parseClientArgs(args []string) (socket, root, lang string, cmd []string, er
err = fmt.Errorf("missing --root")
} else if lang == "" {
err = fmt.Errorf("missing --lang")
} else {
} else if !hasSep {
err = fmt.Errorf("missing -- <cmd>")
}
return
Expand Down
97 changes: 97 additions & 0 deletions daemon/lsp_client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package daemon

import (
"testing"
)

func TestParseClientArgs_valid(t *testing.T) {
args := []string{"--socket", "/tmp/ohm.sock", "--root", "/srv/proj", "--lang", "go", "--", "gopls", "-v"}
socket, root, lang, cmd, err := parseClientArgs(args)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if socket != "/tmp/ohm.sock" {
t.Errorf("socket: got %q", socket)
}
if root != "/srv/proj" {
t.Errorf("root: got %q", root)
}
if lang != "go" {
t.Errorf("lang: got %q", lang)
}
if len(cmd) != 2 || cmd[0] != "gopls" || cmd[1] != "-v" {
t.Errorf("cmd: got %v", cmd)
}
}

func TestParseClientArgs_differentOrder(t *testing.T) {
args := []string{"--lang", "rust", "--root", "/ws", "--socket", "/var/ohm.sock", "--", "rust-analyzer"}
socket, root, lang, cmd, err := parseClientArgs(args)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if socket != "/var/ohm.sock" {
t.Errorf("socket: got %q", socket)
}
if root != "/ws" {
t.Errorf("root: got %q", root)
}
if lang != "rust" {
t.Errorf("lang: got %q", lang)
}
if len(cmd) != 1 || cmd[0] != "rust-analyzer" {
t.Errorf("cmd: got %v", cmd)
}
}

func TestParseClientArgs_noCmd(t *testing.T) {
// -- present but no command after it
args := []string{"--socket", "/s", "--root", "/r", "--lang", "go", "--"}
_, _, _, cmd, err := parseClientArgs(args)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(cmd) != 0 {
t.Errorf("expected empty cmd, got %v", cmd)
}
}

func TestParseClientArgs_missingSocket(t *testing.T) {
args := []string{"--root", "/r", "--lang", "go", "--", "gopls"}
_, _, _, _, err := parseClientArgs(args)
if err == nil {
t.Fatal("expected error for missing --socket")
}
}

func TestParseClientArgs_missingRoot(t *testing.T) {
args := []string{"--socket", "/s", "--lang", "go", "--", "gopls"}
_, _, _, _, err := parseClientArgs(args)
if err == nil {
t.Fatal("expected error for missing --root")
}
}

func TestParseClientArgs_missingLang(t *testing.T) {
args := []string{"--socket", "/s", "--root", "/r", "--", "gopls"}
_, _, _, _, err := parseClientArgs(args)
if err == nil {
t.Fatal("expected error for missing --lang")
}
}

func TestParseClientArgs_missingDoubleDash(t *testing.T) {
args := []string{"--socket", "/s", "--root", "/r", "--lang", "go"}
_, _, _, _, err := parseClientArgs(args)
if err == nil {
t.Fatal("expected error for missing -- separator")
}
}

func TestParseClientArgs_socketMissingValue(t *testing.T) {
args := []string{"--socket"}
_, _, _, _, err := parseClientArgs(args)
if err == nil {
t.Fatal("expected error when --socket has no value")
}
}
Loading