From 3f2d6f43199d0e230490396deb50f8dc49c7b8b0 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 1 Sep 2026 14:34:19 -0300 Subject: hosts: the effects three shells kept a copy of become one, and the mac's own bugs go with them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/gui/gui.zig | 221 ++++++++++++++------------------------------------------ 1 file changed, 54 insertions(+), 167 deletions(-) (limited to 'src/gui/gui.zig') diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 8442c197..44e28125 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -930,25 +930,10 @@ const Msg = union(enum) { } }; -/// One language query, owned by the thread running it — the gui twin of -/// tty.zig's LspJob, and copied for the same reason: the core edits on. -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); - } -}; +/// The shared snapshot/worker pair. This file carried its own `LspJob` with +/// "tty.zig's LspJob, and copied for the same reason" over the top; both copies +/// are now one module, and the AppKit shell — which had neither — uses it too. +const lsp_host = @import("../lsp_host.zig"); const LspWorkers = struct { active: std.atomic.Value(usize) = .init(0), @@ -972,36 +957,10 @@ const LspWorkers = struct { const max_pipe_tasks = 16; -const PipeTask = struct { - id: u32, - future: std.Io.Future(anyerror!void), -}; - -const PipeTasks = struct { - items: [max_pipe_tasks]PipeTask = undefined, - len: usize = 0, - - fn add(tasks: *PipeTasks, task: PipeTask) bool { - if (tasks.len == tasks.items.len) return false; - tasks.items[tasks.len] = task; - tasks.len += 1; - return true; - } - - fn finish(tasks: *PipeTasks, io: std.Io, id: u32) void { - for (tasks.items[0..tasks.len], 0..) |*task, i| if (task.id == id) { - task.future.await(io) catch {}; - tasks.len -= 1; - std.mem.copyForwards(PipeTask, tasks.items[i..tasks.len], tasks.items[i + 1 .. tasks.len + 1]); - return; - }; - } - - fn cancelAll(tasks: *PipeTasks, io: std.Io) void { - for (tasks.items[0..tasks.len]) |*task| task.future.cancel(io) catch {}; - tasks.len = 0; - } -}; +/// Moved to `selection_pipe.Tasks`, beside the Job it tracks — tty.zig carried +/// this same table verbatim. +const PipeTask = selection_pipe.Tasks.Task; +const PipeTasks = selection_pipe.Tasks; const queue_capacity = 512; @@ -1192,27 +1151,18 @@ fn lookThread(gpa: std.mem.Allocator, fd: c_int, q: *Queue) void { } } -/// Answer a language query off the render loop and push the rows to the queue -/// — the async execution model, spelled in the plumbing this shell already has -/// (a detached thread and the mutex queue the pty readers use). -fn lspThread(lsp_allocator: std.mem.Allocator, workers: *LspWorkers, job: *LspJob, q: *Queue) void { +/// Answer a language query off the render loop and push the rows to the queue. +/// The snapshot and the query body are `lsp_host`'s; what stays here is this +/// shell's own plumbing — a detached thread, the refcount that teardown joins +/// on, and the mutex queue the pty readers already use. +fn lspThread(lsp_allocator: std.mem.Allocator, workers: *LspWorkers, job: *lsp_host.Job, q: *Queue) void { defer workers.finish(); - defer job.free(lsp_allocator); - var arena: std.heap.ArenaAllocator = .init(lsp_allocator); - defer arena.deinit(); - // the shell owns the result buffer; the backend only ever writes to it - var out: std.Io.Writer.Allocating = .init(lsp_allocator); - defer out.deinit(); - pardes.lsp.query(lsp_allocator, arena.allocator(), .{ - .kind = job.kind, - .path = job.path, - .source = job.source, - .offset = job.offset, - .arg = job.arg, - .root = job.root, - }, &out.writer); - const rows = lsp_allocator.dupe(u8, out.written()) catch return; - q.push(.{ .lsp = .{ .id = job.id, .rows = rows } }); + lsp_host.work(lsp_allocator, job, q, pushLspRows); +} + +fn pushLspRows(ctx: ?*anyopaque, id: u32, rows: []u8) void { + const q: *Queue = @ptrCast(@alignCast(ctx orelse return)); + q.push(.{ .lsp = .{ .id = id, .rows = rows } }); } /// Copy the query out of the core and hand it to a thread. A detached thread @@ -1220,38 +1170,7 @@ fn lspThread(lsp_allocator: std.mem.Allocator, workers: *LspWorkers, job: *LspJo /// already tolerates a late push after close. fn spawnLsp(core: *pardes.Pardes, q: *Queue, e: host_api.LspRequest) void { const lsp_allocator = q.lsp_allocator; - const pane = core.panes[e.pane] orelse return; - // a pane with no file still asks `status` (it is about the backend, not - // the buffer): empty path and source, root from the pane's cwd - const f = pane.file; - const job = lsp_allocator.create(LspJob) catch return; - job.* = .{ - .id = e.id, - .kind = e.kind, - .offset = e.offset, - .path = lsp_allocator.dupe(u8, if (f) |ff| ff.path else "") catch { - lsp_allocator.destroy(job); - return; - }, - .source = lsp_allocator.dupeZ(u8, if (f) |ff| ff.content else "") catch { - lsp_allocator.free(job.path); - lsp_allocator.destroy(job); - return; - }, - .arg = lsp_allocator.dupe(u8, e.arg) catch { - lsp_allocator.free(job.path); - lsp_allocator.free(job.source); - lsp_allocator.destroy(job); - return; - }, - .root = lsp_allocator.dupe(u8, if (f) |ff| (std.fs.path.dirname(ff.path) orelse "/") else pane.cwdSlice()) catch { - lsp_allocator.free(job.path); - lsp_allocator.free(job.source); - lsp_allocator.free(job.arg); - lsp_allocator.destroy(job); - return; - }, - }; + const job = lsp_host.snapshot(lsp_allocator, core, e) orelse return; q.lsp_workers.start(); const th = std.Thread.spawn(.{}, lspThread, .{ lsp_allocator, q.lsp_workers, job, q }) catch { q.lsp_workers.finish(); @@ -1582,13 +1501,19 @@ fn compactTaglineLayout(g: *const Gui, origin_col: f32) CellLayout { }; } -/// Where a tagline cell's compact band begins. `core` is null in an attached -/// window, and then EVERY tagline cell takes the last line's fallback: the -/// wire carries cells, not the pane rects that placed them, so there is no -/// band origin to compact against. That is the same answer `gridCellAtDimensions` -/// reaches for the same reason, which is what keeps the two honest — a click -/// lands on the glyph it was aimed at, because both sides map through the body -/// grid. The visible cost is one tagline row's worth of loose tracking. +/// Where a tagline cell's compact band begins. The origin rule itself is +/// `pardes.taglineOriginCol` — moved to the core so the AppKit shell can call +/// the SAME rule over the C ABI instead of advancing its tag rows on body +/// pitch, which is the second copy of this that already went wrong once (see +/// `taglineBandOffset`). +/// +/// `core` is null in an attached window, and then EVERY tagline cell takes the +/// last line's fallback: the wire carries cells, not the pane rects that placed +/// them, so there is no band origin to compact against. That is the same answer +/// `gridCellAtDimensions` reaches for the same reason, which is what keeps the +/// two honest — a click lands on the glyph it was aimed at, because both sides +/// map through the body grid. The visible cost is one tagline row's worth of +/// loose tracking. fn taglineLayoutForCell( g: *const Gui, core: ?*const pardes.Pardes, @@ -1597,33 +1522,15 @@ fn taglineLayoutForCell( track: ?pardes.panel_animation.Track, ) CellLayout { if (row < pardes.TOPBAR_H) return compactTaglineLayout(g, 0); - if (core) |p| { - if (track) |active| { - const box = active.contentBox(); - const tag_y = if (p.settings.tag_bottom) box.y + box.h - @as(f32, @floatFromInt(pardes.BOX_H)) else box.y; - if (@as(f32, @floatFromInt(row)) >= tag_y and - @as(f32, @floatFromInt(row)) < tag_y + @as(f32, @floatFromInt(pardes.BOX_H))) - return compactTaglineLayout(g, box.x); - } - for (p.panes, 0..) |slot, id| { - if (slot == null) continue; - const r = p.rects[id]; - const tag_y = if (p.settings.tag_bottom) r.y + r.h -| pardes.BOX_H else r.y; - if (row == tag_y and col >= r.x and col < r.x + r.w) - return compactTaglineLayout(g, @floatFromInt(r.x)); - } - } - // A stale/closing cell without a live pane should still remain legible, - // and so should every cell of an attached window. Its body-grid origin is - // the only safe fallback available. - return compactTaglineLayout(g, @floatFromInt(col)); + const p = core orelse return compactTaglineLayout(g, @floatFromInt(col)); + return compactTaglineLayout(g, pardes.taglineOriginCol(p, col, row, track)); } -fn boxContains(box: pardes.panel_animation.Box, col: u16, row: u16) bool { - const x: f32 = @floatFromInt(col); - const y: f32 = @floatFromInt(row); - return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h; -} +/// `panel_animation.Box.contains` under this file's older name. Kept as an +/// alias rather than renamed at three call sites so the predicate has exactly +/// one definition — it was a fourth copy of the same half-open cell test the +/// core, `taglineOriginCol` and ScenePostprocessor.swift all make. +const boxContains = pardes.panel_animation.Box.contains; // EFFECT_CODE_PANEL_HOST_BEGIN const PaintBatch = struct { @@ -2077,13 +1984,10 @@ fn localSession( observeGuiFont(g, core); syncTaglineFont(g, core); - // Private, complete before any fork and retained until the last possible - // spawn; children borrow only these stable in-struct path buffers. - var prompt_rcs = shell_bin.PromptRcs.init(); + // PATH, the bash banner and the prompt rc files, in the one order that + // works. Children borrow only these stable in-struct path buffers. + var prompt_rcs = shell_bin.prepareForFork(); defer prompt_rcs.deinit(); - // macos: apple's bash 3.2 prints the zsh-deprecation banner into every - // pane unless this is in the env BEFORE bash starts (the rc is too late) - if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); var ptys: [pardes.MAX_PANES]?Pty = @splat(null); // per-slot spawn generation: drops a dead shell's late output/eof when its @@ -2549,11 +2453,8 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { if (opts.load_path == null) core.update(.{ .resize = .{ .cols = grid_cols, .rows = grid_rows } }); - var prompt_rcs = shell_bin.PromptRcs.init(); + var prompt_rcs = shell_bin.prepareForFork(); defer prompt_rcs.deinit(); - // macos: apple's bash 3.2 prints the zsh-deprecation banner into every - // pane unless this is in the env BEFORE bash starts (the rc is too late) - if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); var ptys: [pardes.MAX_PANES]?Pty = @splat(null); // per-slot spawn generation: drops a dead shell's late output/eof when its @@ -3420,11 +3321,13 @@ fn pixelCell(px: f32, cell: u32) u16 { return @intFromFloat(@min(idx, 10_000)); } -/// Which grid cell a physical point is in. `core` is null in an attached -/// window, and then the pane loop is skipped and the body grid answers — the -/// same fallback `taglineLayoutForCell` takes for the same missing fact, which -/// is what makes a click on an attached tagline land on the glyph it was aimed -/// at. +/// Which grid cell a physical point is in. The COLUMN rule is +/// `pardes.gridColAt` — the inverse of the compact tagline layout, and in the +/// core beside it so the two cannot be compacted independently. `core` is null +/// in an attached window, and then the pane loop inside it is skipped and the +/// body grid answers: the same fallback `taglineLayoutForCell` takes for the +/// same missing fact, which is what makes a click on an attached tagline land +/// on the glyph it was aimed at. fn gridCellAtDimensions( core: ?*const pardes.Pardes, x: f32, @@ -3433,25 +3336,8 @@ fn gridCellAtDimensions( body_h: f32, tagline_w: f32, ) MouseCell { - const safe_body_w = @max(body_w, 1); - const safe_body_h = @max(body_h, 1); - const tag_w = @max(tagline_w, 1); - const row: u16 = @intFromFloat(@min(@floor(@max(y, 0) / safe_body_h), 10_000)); - if (row < pardes.TOPBAR_H) - return .{ .col = @intFromFloat(@min(@floor(@max(x, 0) / tag_w), 10_000)), .row = row }; - - if (core) |p| for (p.panes, 0..) |slot, id| { - if (slot == null) continue; - const r = p.rects[id]; - const tag_y = if (p.settings.tag_bottom) r.y + r.h -| pardes.BOX_H else r.y; - if (row != tag_y or r.w == 0) continue; - const left = @as(f32, @floatFromInt(r.x)) * safe_body_w; - const right = @as(f32, @floatFromInt(r.x + r.w)) * safe_body_w; - if (x < left or x >= right) continue; - const within: u16 = @intFromFloat(@min(@floor(@max(0, x - left) / tag_w), @as(f32, @floatFromInt(r.w - 1)))); - return .{ .col = r.x + within, .row = row }; - }; - return .{ .col = @intFromFloat(@min(@floor(@max(x, 0) / safe_body_w), 10_000)), .row = row }; + const row: u16 = @intFromFloat(@min(@floor(@max(y, 0) / @max(body_h, 1)), 10_000)); + return .{ .col = pardes.gridColAt(core, x, row, body_w, tagline_w), .row = row }; } fn gridCellAtPixels(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32) MouseCell { @@ -4159,6 +4045,7 @@ test "the headless grid host round-trips a yank back as a paste" { }; var pipe_tasks: PipeTasks = .{}; var watches: file_watch.Table = @splat(null); + shell_bin.adoptSystemPath(); var prompt_rcs = shell_bin.PromptRcs.init(); defer prompt_rcs.deinit(); var shell: Shell = .{ -- cgit v1.3