From ae9325a5cb128d0d952afb8f9feaaca68e5e37a2 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 1 Sep 2026 09:23:53 -0300 Subject: lsp: a protocol client for every other language, narrated on the message row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seam grows a second backend: src/lsp/lsp_client.zig speaks JSON-RPC to child language servers — rust-analyzer, clangd, gopls, tsserver, pyright are rows in a spec table — while the in-process ZLS analyser keeps .zig. One reader thread per server owns the socket, routes responses to a mailbox under the conn mutex (monotonic condvar), answers server-to-client requests, feeds the diagnostics store, and narrates $/progress and state changes through a status sink both native shells post to the transient message row: "rust-analyzer: cargo check 88% 955/1083" lands where a save narrates, with the same clock. Chatty progress is throttled and deduplicated; settled states always land, which is also what makes the goldens deterministic. Nothing wedges and nothing healthy dies: waits are deadline-bounded, a timeout cancels and returns no rows, three consecutive timeouts restart the server ONLY while it is idle (an indexing server is narrating its own excuse), spawn and handshake failures back off 10s to 2min, a crash shortly after ready counts as a failure, and only a missing binary disables a spec. PARDES_LSP_{RS,C,GO,TS,PY} override binaries; empty disables; the snapshot harness pins RS to test/lspmock.zig and empties the rest. Mutating answers really mutate now: the @put record beside rename @edit carries per-range text, so = applies the formatter (both backends) and a same-file WorkspaceEdit rename applies atomically, one undo step, narrated ("renamed 2 range(s)"); a multi-file rename previews as rows instead of half-applying. Malformed responses fail closed: coordinates validated not clamped, one bad TextEdit poisons the whole edit set, poison frames kill the connection instead of buffering forever, decoded control bytes reject a uri, hierarchy items too deep to reserialize are skipped. Four kinds helix does not have, on SPC l: c/C incoming/outgoing calls (rows are call sites), t/T super/subtypes. Pull diagnostics (3.17) preferred when advertised. Help gains a language-keys footer for the motions no builtin row could carry; lsp.rel and look.grep now share one path-shortening rule. zig build lspprobe drives the seam from the CLI (comma-separated kinds share one server); measured against a 1083-crate workspace warm: gd 26ms, gr 213 rows 165ms, incoming calls 212 sites 197ms, document symbols 670 rows 347ms. docs/lsp.md tells the whole story; lsp-evaluation.md gets an addendum. --- src/tty/tty.zig | 50 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 6 deletions(-) (limited to 'src/tty/tty.zig') diff --git a/src/tty/tty.zig b/src/tty/tty.zig index c85908d6..f8f7150b 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -64,6 +64,10 @@ pub const Command = struct { paste_end, /// a language query finished on a worker; rows are lsp-domain-owned lsp_done: struct { id: u32, rows: []u8 }, + /// a language SERVER changed state (spawned, indexing, exited) — the + /// client's reader thread narrates and this lands it on the message + /// row; text is lsp-domain-owned + lsp_status: []u8, /// a selection-filter worker finished; every stdout is gpa-owned pipe_done: selection_pipe.Response, /// something happened in a watched directory (see watchFiles) @@ -702,7 +706,14 @@ fn localSession( .inotify_fd = if (builtin.os.tag == .linux) libc.inotify_init1(linux.IN.CLOEXEC) else -1, .fs = fs, }; + // The protocol client's reader threads narrate server state through this + // sink from the moment it is set; posting is safe because the loop queue + // outlives them all — and it is UNSET first thing in the defer below, + // under the sink's own lock, so no reader can be mid-post when the queue + // starts draining for teardown. + pardes.lsp.setStatusSink(&sh, lspStatusSink); defer { + pardes.lsp.setStatusSink(null, null); // reap the reader tasks (cancel interrupts a blocked read) before // closing the masters — the runtime joins those threads on exit and a // reader stuck in read(2) would hang the process — then drain the @@ -750,6 +761,7 @@ fn localSession( .command => |line| gpa.free(line), .paste => |b| gpa.free(@constCast(b)), .lsp_done => |d| allocs.lsp.free(d.rows), + .lsp_status => |text| allocs.lsp.free(text), .pipe_done => |response_value| { var response = response_value; response.deinit(gpa); @@ -1185,6 +1197,15 @@ const Shell = struct { s.lsp_task = null; } }, + // Server state on the transient message row — the same row, the + // same `message.stamp` clock, and the same shell-side ownership a + // completed save uses. The ACTIVE pane, because the state of a + // server is session news, not a fact about the pane that asked. + .lsp_status => |text| { + var mbuf: [256]u8 = undefined; + core.setMessage(core.active, message.stamp(&mbuf, "lsp", text)); + s.lsp_gpa.free(text); + }, .pipe_done => |response_value| { var response = response_value; core.update(.{ .pipe_resp = .{ @@ -1620,6 +1641,20 @@ fn lspWorker(allocator: std.mem.Allocator, job: *LspJob, loop: *Loop) anyerror!v return; } +/// The registered `lsp.setStatusSink` target, called from the protocol +/// client's READER threads. Only thread-safe, NON-BLOCKING things happen +/// here: a dupe with the concurrent lsp allocator and a TRY-post onto the +/// loop's queue. Never the blocking post — the sink lock is held around this +/// call, and a full queue plus a teardown spinning on that lock would be a +/// deadlock; server state is periodic news, so a dropped line is repriced +/// by the next one. +fn lspStatusSink(ctx: ?*anyopaque, text: []const u8) void { + const s: *Shell = @ptrCast(@alignCast(ctx orelse return)); + const copy = s.lsp_gpa.dupe(u8, text) catch return; + const posted = s.loop.tryPostEvent(.{ .lsp_status = copy }) catch false; + if (!posted) s.lsp_gpa.free(copy); +} + fn pipeWorker( io: std.Io, gpa: std.mem.Allocator, @@ -2110,14 +2145,17 @@ const Attach = struct { // (server.zig `nap`) and the frames simply arrive. `fs_ready` // belongs to `--fs`, which lives with the core. `lsp_done` and // `pipe_done` answer work the core dispatches, and it dispatches it - // there. `pty_read`, `pty_eof` and `files_changed` are the ones - // that MOVED: the daemon forks the pane shells and holds the - // inotify instance now, so the only descriptors this process reads - // are its terminal and one socket. An in-place switch (`Attach` in - // a local session) cancels its readers and its watcher and drains + // there; `lsp_status` narrates servers whose sink the local loop + // UNSET in its teardown before this loop started, and the queue + // was drained after that, so none is in flight. `pty_read`, + // `pty_eof` and `files_changed` are the ones that MOVED: the + // daemon forks the pane shells and holds the inotify instance + // now, so the only descriptors this process reads are its + // terminal and one socket. An in-place switch (`Attach` in a + // local session) cancels its readers and its watcher and drains // this queue before the attached loop starts, so not even a late // post from the session it just left arrives here. - .nop, .tick, .fs_ready, .lsp_done, .pipe_done, .pty_read, .pty_eof, .files_changed => {}, + .nop, .tick, .fs_ready, .lsp_done, .lsp_status, .pipe_done, .pty_read, .pty_eof, .files_changed => {}, .quit => return .none, .focus_in => {}, .focus_out => return a.send(.pointer_leave), -- cgit v1.3