diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-01 14:34:19 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-02 16:35:21 -0300 |
| commit | 3f2d6f43199d0e230490396deb50f8dc49c7b8b0 (patch) | |
| tree | 309d464fe96487545082920b4080639576644bdf /src/lsp_host.zig | |
| parent | cce6b18a49870086982f9a0e1fda90ed170b9fba (diff) | |
| download | pardes-3f2d6f43199d0e230490396deb50f8dc49c7b8b0.tar.gz pardes-3f2d6f43199d0e230490396deb50f8dc49c7b8b0.zip | |
hosts: the effects three shells kept a copy of become one, and the mac's own bugs go with them
Nine read-only scouts compared every host-side concern across `src/macos.zig`,
`src/tty/tty.zig`, `src/gui/gui.zig` and `src/detached/server.zig`. What they
found was not a style problem: each duplicated body had drifted, and in every
case the drift WAS a bug the users of that shell could see. So the fixes and
the deduplication are the same change.
**One PATH, adopted before the first fork.** LaunchServices hands a bundle
launchd's environment, whose `PATH` is `/usr/bin:/bin:/usr/sbin:/sbin`. Every
pty shell, `|` filter and language server the app forked inherited it, so
`yazi` in `/opt/homebrew/bin` was absent from a Dock launch and present in the
identical binary run from a terminal — the "it worked briefly" window was
simply the sessions started from a shell. `shell_bin.adoptSystemPath` composes
`/etc/paths` then `/etc/paths.d/*` in the order `path_helper` reads them,
deduplicating on first occurrence, and runs once at startup in all four native
hosts. It APPENDS: an entry already present keeps its position, so running it
over a real session cannot demote a mise shim behind `/usr/bin` and silently
change which `node` runs. A `PATH` that was configured is left byte-for-byte
alone; only one nobody configured is repaired. `prepareForFork` folds that
adoption together with the prompt-rc staging and the `BASH_SILENCE_DEPRECATION_WARNING`
setenv the five hand-copied prefork sites had between them — `server.zig` had
none of it, which is why every detached pane opened with Apple's zsh banner.
**The LSP protocol client never worked on macOS.** It opened its control
socket with `libc.SOCK.CLOEXEC`; Zig defines that constant for Linux and
Darwin answers `socketpair` with `EPROTONOSUPPORT`, so the call failed before
any fork, `ensure` returned `error.NoServer`, and every row in the spec table
— rust-analyzer, clangd, gopls — was unreachable in every macOS build. The
in-process ZLS backend kept answering, which is what made it read as "only Zig
is supported". It is a plain socket plus `fcntl(FD_CLOEXEC)` now, the route
`fuse.zig:943` and `nested.zig:95` already took for the same reason. The
snapshot suite that covered this path had never run natively on a Mac: the
harness targets defaulted to x86_64-linux.
**One LSP host worker.** `src/lsp_host.zig` is the snapshot, the worker body
and the job lifetime that `tty.zig` and `gui.zig` carried verbatim — `gui.zig`
said so in a comment — and that `macos.zig` did not carry at all: `lsp` and
`pipe` were absent from its `Host.VTable`, so the core answered its own empty
answer, `SPC l i` rendered a blank panel and a `|` filter silently did
nothing. All three shells share the module, and the AppKit host implements
both effects. Its status sink is now REGISTERED as well as defined, so
unsolicited server news reaches the message row instead of nowhere.
**The animation clock measures time.** `pardes_animation_tick` advanced one
scene frame per callback and published `frame_count / 60`, so scene time was a
count of callbacks rather than elapsed seconds — and `AppDelegate` re-armed
`asyncAfter(.now() + 0.016)` only after the previous frame's work had
finished, making the true period 16 ms plus all of it. Motion ran at about
three quarters of wall clock and unevenly. The tick now spends measured
monotonic time in whole `frame_ns` steps and banks the remainder, so a late
callback advances two frames instead of stretching one; `spendTickTime` is
that arithmetic as a pure function with its own tests and no display attached.
On macOS 14+ the animating run is one `CADisplayLink` phase-locked to vsync
rather than a chain rebuilt after every frame; macOS 13 keeps the old chain.
**Three more single definitions.** `panel_animation.paintOrder` is the
moving-then-opening-then-closing composite order as a rule the core applies
once in `Pardes.render` — `macos.zig` was re-sorting an already-sorted list.
`selection_pipe.Tasks` is the bounded in-flight pipe table `tty.zig` and
`gui.zig` each declared. `boxContains` was a fourth copy of the half-open cell
test and is now an alias of `Box.contains`.
**A filtered terminal stops asking libm per cell.** `Filter`'s legibility
stage called `RGB.contrast` for every painted cell, and that ends in
`std.math.pow` up to six times, re-deriving a ratio against a background that
had not moved; the existing memo cache covered the palette reduction beside it
and never this. The indexed path's input is a `u8`, so all 256 answers are
enumerated once per pass — after the default roles are fixed, before the first
cell is read — and what a cell names becomes an array index. Only truecolour
still reduces. ReleaseFast, 190x56, Tracy: recolour 3.09 ms -> 0.130 ms,
frame 3.37 ms -> 0.299 ms. The comptime luminance table is pinned to
`RGB.luminance` and `RGB.contrast` by exact-equality test over every channel
value and all 65 536 palette pairs, because the decision is a threshold
comparison where one ULP is a different colour. A `filterInit` Tracy zone
records the part that is still per-pass: 2.9 us warm against a 117 us pass,
which is the measurement that says not to cache it across frames.
Released as 0.0.2. `build.zig.zon` carries the version into `pardes --version`
and into the `Changelog` pane through `@embedFile`, so the entries above open a
`## 0.0.2` section and `## 0.0.1` closes with the tagline work of the parent
commit.
Two bugs here were mine, caught by review rather than by me: a double free in
the macOS pipe drain arm (`Msg.free` already owns the response) that segfaulted
the app on the first `|`, and a proposed `getRowAndCell` optimisation that
targeted 2 of 43 draw samples while the contrast math beside it took 12 — and
would not have compiled. The profile that justified it was a Debug build, which
`build.zig:1160` already documents as ~5x slower than release.
Native and -Dplatform=macos suites: 0 failures. All targets build with Tracy on
and off; the shipped release binary contains no `___tracy_emit_zone_begin`.
App reinstalled, signature verified, dmg regenerated, launched with 0 crash
reports; installed binaries verified byte-identical to a fresh build.
Diffstat (limited to 'src/lsp_host.zig')
| -rw-r--r-- | src/lsp_host.zig | 206 |
1 files changed, 206 insertions, 0 deletions
diff --git a/src/lsp_host.zig b/src/lsp_host.zig new file mode 100644 index 00000000..803beb31 --- /dev/null +++ b/src/lsp_host.zig @@ -0,0 +1,206 @@ +//! Turning the core's `lsp` effect into work on a thread, and its rows back +//! into something a loop can deliver. Native-shell side, like host_io.zig and +//! shell_bin.zig, and here for the reason those are: all three native shells +//! need it and none of them needs a different one. +//! +//! It was two copies before it was this. tty.zig had it inline and gui.zig had +//! it again with `tty.zig's LspJob, and copied for the same reason` written +//! over the top — identical down to the comment about a pane with no file. The +//! AppKit shell had NEITHER, so its vtable left `pull_lsp` null, the core +//! answered its own requests with no rows, and every language query did nothing +//! at all in the shell most people run. None of that looked like a missing +//! feature from the outside: `gd` just moved no cursor. A third copy is what +//! this module exists instead of. +//! +//! What is genuinely per-host stays per-host, and it is small: which allocator, +//! how a finished job reaches the loop (a mutex queue, a vaxis event, an inbox +//! plus a wakeup), and what bounds the in-flight set (a refcount to join at +//! teardown, or one future to cancel). What is NOT per-host is everything +//! below: the core goes on editing the moment the effect is drained, so every +//! byte the backend may read has to be COPIED first, and getting that ladder +//! subtly different in three places is how one shell reads freed text one +//! keystroke later. +const std = @import("std"); +const pardes = @import("pardes.zig"); +const host_api = @import("host.zig"); + +/// One language query, owned by the worker that runs it. +pub const Job = struct { + id: u32, + kind: pardes.lsp.Kind, + offset: u32, + path: []u8, + source: [:0]u8, + arg: []u8, + root: []u8, + + pub fn free(job: *Job, gpa: std.mem.Allocator) void { + gpa.free(job.path); + gpa.free(job.source); + gpa.free(job.arg); + gpa.free(job.root); + gpa.destroy(job); + } +}; + +/// Copy the query out of the core. Null when the pane is gone or an allocation +/// failed, and nothing leaks on either path — the ladder frees exactly what it +/// had managed to take. +/// +/// A pane with no file still asks `status`: that query is about the BACKEND, +/// not the buffer. Empty path and source then, and the root comes off the +/// pane's cwd so a bare terminal still reports which servers it would reach. +pub fn snapshot(gpa: std.mem.Allocator, core: *const pardes.Pardes, req: host_api.LspRequest) ?*Job { + const pane = core.panes[req.pane] orelse return null; + const file = pane.file; + const job = gpa.create(Job) catch return null; + job.* = .{ + .id = req.id, + .kind = req.kind, + .offset = req.offset, + .path = gpa.dupe(u8, if (file) |f| f.path else "") catch { + gpa.destroy(job); + return null; + }, + .source = gpa.dupeZ(u8, if (file) |f| f.content else "") catch { + gpa.free(job.path); + gpa.destroy(job); + return null; + }, + .arg = gpa.dupe(u8, req.arg) catch { + gpa.free(job.path); + gpa.free(job.source); + gpa.destroy(job); + return null; + }, + .root = gpa.dupe(u8, if (file) |f| + (std.fs.path.dirname(f.path) orelse "/") + else + pane.cwdSlice()) catch { + gpa.free(job.path); + gpa.free(job.source); + gpa.free(job.arg); + gpa.destroy(job); + return null; + }, + }; + return job; +} + +/// What a host does with finished rows. It TAKES OWNERSHIP of `rows`, which +/// were allocated with the same allocator the job was. +pub const Deliver = *const fn (ctx: ?*anyopaque, id: u32, rows: []u8) void; + +/// Run `job` to completion and hand its rows to `deliver`. Consumes the job +/// either way. +/// +/// This is the whole async execution model, and it is the one every shell +/// already uses for its pty reader: do the slow thing off the loop, hand the +/// result over as an event, let the core stay a state machine that never +/// blocks. The shell owns the result buffer; the backend only writes into it. +pub fn work(gpa: std.mem.Allocator, job: *Job, ctx: ?*anyopaque, deliver: Deliver) void { + defer job.free(gpa); + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + 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.writer); + // Duped out of the writer: `deliver` outlives this frame and the writer + // does not. A failed dupe drops the answer, which the core survives — the + // request times out into no rows, exactly as an empty answer would. + const rows = gpa.dupe(u8, out.written()) catch return; + deliver(ctx, job.id, rows); +} + +test "a snapshot owns every byte the backend will read" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + + var needle: [6]u8 = "needle".*; + const job = snapshot(gpa, core, .{ + .id = 7, + .kind = .status, + .pane = @intCast(core.active), + .offset = 0, + .arg = &needle, + }) orelse return error.SnapshotFailed; + defer job.free(gpa); + + try std.testing.expectEqual(@as(u32, 7), job.id); + try std.testing.expectEqual(pardes.lsp.Kind.status, job.kind); + // `arg` is the caller's buffer on the way in and the job's own bytes on the + // way out. THIS is the property the whole ladder exists for: the core reuses + // that buffer for the next builtin's argument the moment the effect drains. + try std.testing.expectEqualStrings("needle", job.arg); + try std.testing.expect(job.arg.ptr != &needle); + // A terminal pane has no file and the query still has to be answerable: + // empty path, a NUL-terminated empty source, and the pane's own cwd as the + // root so a bare terminal still reports which servers it would reach. The + // cwd may legitimately be empty in a core that has never spawned a shell; + // what matters is that the job OWNS it rather than borrowing it. + try std.testing.expectEqualStrings("", job.path); + try std.testing.expectEqual(@as(usize, 0), job.source.len); + try std.testing.expectEqual(@as(u8, 0), job.source[0]); + const pane = core.panes[core.active].?; + try std.testing.expectEqualStrings(pane.cwdSlice(), job.root); + if (job.root.len > 0) try std.testing.expect(job.root.ptr != pane.cwdSlice().ptr); +} + +test "a pane that is gone yields no job rather than a null deref" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + + // The effect is drained after the core has moved on, so the pane it names + // may already have been deleted. Every shell open-coded this check. + const empty = for (core.panes, 0..) |slot, id| { + if (slot == null) break @as(u8, @intCast(id)); + } else return error.NoEmptyPane; + try std.testing.expect(snapshot(gpa, core, .{ + .id = 1, + .kind = .definition, + .pane = empty, + .offset = 0, + .arg = "", + }) == null); +} + +test "work consumes the job and hands its rows to the sink" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + + const Sink = struct { + var seen_id: u32 = 0; + var seen_rows: ?[]u8 = null; + fn take(_: ?*anyopaque, id: u32, rows: []u8) void { + seen_id = id; + seen_rows = rows; + } + }; + Sink.seen_id = 0; + Sink.seen_rows = null; + + const job = snapshot(gpa, core, .{ + .id = 42, + .kind = .status, + .pane = @intCast(core.active), + .offset = 0, + .arg = "", + }) orelse return error.SnapshotFailed; + work(gpa, job, null, Sink.take); + + // `status` is the one kind that answers with no file and no cursor, which + // is what makes it assertable here without a language server on the box. + try std.testing.expectEqual(@as(u32, 42), Sink.seen_id); + const rows = Sink.seen_rows orelse return error.SinkNeverCalled; + defer gpa.free(rows); +} |
