summaryrefslogtreecommitdiff
path: root/src/tty.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-28 23:30:29 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commitf43c1e11b44e2464f0bb0b635e0abcaf3c717e23 (patch)
treeddc5e5528ebb59f72d30bef49522f814578e4fce /src/tty.zig
parent65b207c3392c75eac3f2b18a266a6482d8345df1 (diff)
downloadpardes-f43c1e11b44e2464f0bb0b635e0abcaf3c717e23.tar.gz
pardes-f43c1e11b44e2464f0bb0b635e0abcaf3c717e23.zip
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.
Diffstat (limited to 'src/tty.zig')
-rw-r--r--src/tty.zig118
1 files changed, 116 insertions, 2 deletions
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.