From f43c1e11b44e2464f0bb0b635e0abcaf3c717e23 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 28 Jul 2026 23:30:29 -0300 Subject: LSP seam: async execution model, helix keymap, evaluation harness The base every language backend plugs into. Three parts: ASYNC. The core had no request/response shape - every effect was fire-and-forget or instantaneous. A language query is the first thing that answers later, so: Effect .lsp -> shell worker -> Event .lsp_resp. tty.zig uses io.concurrent + the vaxis queue, gui.zig a detached thread + the mutex queue it already had for ptys; web no-ops it. The worker never touches the core (path/source/arg are snapshotted into an LspJob), one query in flight identified by a monotonic id so a second press makes the first answer stale, and no rows is a legal answer. KEYMAP. Helix's, verified against its default.rs rather than recalled. gd/gD/gy/gi/gr and ]d/[d had no conflicts. The SPC letters did, so pardes's own builtins moved instead of helix's: Kill k->q, Del d->wc (closing a pane is a window op, and c is helix's own close), Dump/Restore s?->f?, Tutor ht->T. A three-exception muscle-memory map is not a map. RESULTS ARE +SEARCH ROWS. path:LINE:COL text, absolute. That is what look.zig resolves and n/N step, so one row from a goto jumps and several open a buffer - helix's multi-result picker needed no picker code. Backends supply exactly one function (lsp.query) plus a supports set and a name; the base has none on purpose. zig build lspbench scores them on the same corpus: feature matrix (trusting results, not the supports flag - a claimed-but-empty kind is reported as a false claim), cold and warm latency, peak RSS. Two snapshot scripts moved. leader.snap encoded the old key paths. chordcut.snap's last two steps clicked column 5, which lands on a FILE pane, so 'key c-b' toggled nothing and the typed text was being read as normal-mode keys - the golden recorded no TTY pane and no cat -v output anywhere. Pointing them at an actual shell makes both steps assert what their comments claim, and the tty paste chord is now covered for the first time. --- src/tty.zig | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 116 insertions(+), 2 deletions(-) (limited to 'src/tty.zig') diff --git a/src/tty.zig b/src/tty.zig index 2326924c..818d8b48 100644 --- a/src/tty.zig +++ b/src/tty.zig @@ -34,10 +34,34 @@ pub const Command = struct { winsize: vaxis.Winsize, mouse: vaxis.Mouse, paste: []const u8, + /// a language query finished on a worker; rows are gpa-owned + lsp_done: struct { id: u32, rows: []u8 }, } = .nop; }; const Loop = vaxis.Loop(@TypeOf(Command.value)); +/// One language query, owned by the worker that runs it. Everything the +/// backend may read is copied in here before the worker starts: the core goes +/// on editing the moment the effect is drained, so a borrowed slice would be a +/// use-after-free the length of one keystroke. +const LspJob = struct { + id: u32, + kind: pardes.lsp.Kind, + offset: u32, + path: []u8, + source: [:0]u8, + arg: []u8, + root: []u8, + + fn free(j: *LspJob, gpa: std.mem.Allocator) void { + gpa.free(j.path); + gpa.free(j.source); + gpa.free(j.arg); + gpa.free(j.root); + gpa.destroy(j); + } +}; + const Pty = struct { file: std.Io.File, pid: posix.pid_t, @@ -101,6 +125,8 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // per-slot spawn generation: a reused pane id ignores the old shell's // late pty_eof (which would otherwise close the NEW pty on that slot) var gens: [pardes.MAX_PANES]u32 = @splat(0); + // the single in-flight language query (see the .lsp effect) + var lsp_task: ?std.Io.Future(anyerror!void) = null; var loop: Loop = .init(io, &tty, &vx); defer { // reap the reader tasks (cancel interrupts a blocked read) before @@ -112,16 +138,23 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { _ = libc.close(pt.file.handle); slot.* = null; }; + // join the query worker BEFORE the drain below, or its late post + // lands in a queue nobody empties again and the rows leak + if (lsp_task) |*t| { + t.cancel(io) catch {}; + lsp_task = null; + } while (loop.tryEvent() catch null) |ev| switch (ev) { .pty_read => |pr| gpa.free(pr.bytes), .paste => |b| gpa.free(@constCast(b)), + .lsp_done => |d| gpa.free(d.rows), else => {}, }; } // Perform the initial spawns BEFORE any worker thread exists: forkpty from // a multithreaded process can wedge the child before exec. - drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, false); + drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, false); try loop.start(); defer loop.stop(); @@ -205,6 +238,16 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { core.update(.{ .paste = bytes }); gpa.free(@constCast(bytes)); }, + .lsp_done => |d| { + core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } }); + gpa.free(d.rows); + // the worker is finished; join it so its future does not + // leak (same contract as pty_eof above) + if (lsp_task) |*t| { + t.await(io) catch {}; + lsp_task = null; + } + }, } batch += 1; if (stop or output or batch >= 64) break; @@ -213,7 +256,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { tz_event.end(); if (stop) break; - drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, true); + drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, true); if (core.quit) break; // Restore builtin: swap in a core rebuilt from the dump; the live @@ -302,6 +345,7 @@ fn drainEffects( loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty, + lsp_task: *?std.Io.Future(anyerror!void), threads_ok: bool, ) void { while (core.nextEffect()) |effect| switch (effect) { @@ -370,10 +414,80 @@ fn drainEffects( // mirror the core's yank register out via OSC 52 if (core.yank) |y| vx.copyToSystemClipboard(tty.writer(), y, gpa) catch {}; }, + .lsp => |q| { + if (!threads_ok) continue; // pre-loop drain: nothing to answer to yet + const pane = core.panes[q.pane] orelse continue; + const f = pane.file orelse continue; + // snapshot everything the worker may read (see LspJob) + const job = gpa.create(LspJob) catch continue; + job.* = .{ + .id = q.id, + .kind = q.kind, + .offset = q.offset, + .path = gpa.dupe(u8, f.path) catch { + gpa.destroy(job); + continue; + }, + .source = gpa.dupeZ(u8, f.content) catch { + gpa.free(job.path); + gpa.destroy(job); + continue; + }, + .arg = gpa.dupe(u8, q.arg.slice()) catch { + gpa.free(job.path); + gpa.free(job.source); + gpa.destroy(job); + continue; + }, + .root = gpa.dupe(u8, std.fs.path.dirname(f.path) orelse "/") catch { + gpa.free(job.path); + gpa.free(job.source); + gpa.free(job.arg); + gpa.destroy(job); + continue; + }, + }; + // ponytail: ONE query in flight, so one future slot. Replacing it + // cancels-then-joins the previous worker, which for a backend that + // ignores cancellation means waiting out a query the user already + // abandoned. Queries are milliseconds; make this a real pool the + // day a backend takes long enough to notice. + if (lsp_task.*) |*old| { + old.cancel(io) catch {}; + lsp_task.* = null; + } + lsp_task.* = io.concurrent(lspWorker, .{ gpa, job, loop }) catch { + job.free(gpa); + continue; + }; + }, .quit => {}, }; } +/// Answer a language query off the event loop and post the rows back. This is +/// the whole async execution model: the same shape as readPty — do the slow +/// thing on a worker, hand the result to the loop as an event, let the core +/// stay a state machine that never blocks. +fn lspWorker(gpa: std.mem.Allocator, job: *LspJob, loop: *Loop) anyerror!void { + defer job.free(gpa); + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + var out: std.ArrayList(u8) = .empty; + defer out.deinit(gpa); + pardes.lsp.query(gpa, arena.allocator(), .{ + .kind = job.kind, + .path = job.path, + .source = job.source, + .offset = job.offset, + .arg = job.arg, + .root = job.root, + }, &out); + const rows = gpa.dupe(u8, out.items) catch return; + loop.postEvent(.{ .lsp_done = .{ .id = job.id, .rows = rows } }) catch gpa.free(rows); + return; +} + /// Consume SIGWINCH synchronously (it is blocked in every thread) and post /// the new size as a winsize event from normal thread context — the one place /// vaxis's Io-backed queue is safe to touch on a resize. -- cgit v1.3