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/shell_bin.zig | 221 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) (limited to 'src/shell_bin.zig') diff --git a/src/shell_bin.zig b/src/shell_bin.zig index 81ff90fa..1090bb2b 100644 --- a/src/shell_bin.zig +++ b/src/shell_bin.zig @@ -27,6 +27,227 @@ const libc = std.c; const X_OK: c_int = 1; extern "c" fn mkstemp(template: [*:0]u8) c_int; +extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; + +// ------------------------------------------------- the GUI launch's PATH + +/// Bounded storage for the composed PATH. /etc/paths and /etc/paths.d hold ten +/// directories on a stock machine and a handful more with third-party +/// packages; 4 KiB is not a limit anyone will meet, and a fixed buffer keeps +/// this callable from a host that has not built an allocator yet. +const path_capacity = 4096; +const max_path_files = 64; + +/// macOS: give the PROCESS the PATH a login session would have, but only when +/// it plainly has not got one. +/// +/// A GUI launch — Finder, the Dock, `open(1)` — inherits launchd's +/// environment, and launchd's PATH is `/usr/bin:/bin:/usr/sbin:/sbin`. Nothing +/// else: no /opt/homebrew/bin, no /usr/local/bin. A launch from a terminal +/// inherits the shell's PATH and is fine. That difference is the whole bug, +/// and it is why it reads as intermittent — the same build finds `yazi` when +/// you start it from a terminal and cannot find it when you start it from the +/// Dock. +/// +/// macOS's own answer is /usr/libexec/path_helper, which reads /etc/paths and +/// /etc/paths.d. LOGIN shells run it and non-login shells do not, and pardes +/// spawns non-login shells deliberately (see `resolve`) — so a pane cannot fix +/// this for itself. Nor should it: one environ is inherited by every pty shell +/// pardes forks, every `/bin/sh -c` filter, and every language server the LSP +/// client spawns, and `binOf` searching a launchd PATH is a rust-analyzer that +/// is never found. Fixing the process fixes all of them at once. +/// +/// ONLY when every entry already in PATH is a system directory. That is the +/// test for "nobody configured this". path_helper appends pre-existing entries +/// AFTER the system set, so running it over a real session's PATH would demote +/// a version manager's shims behind /usr/bin and quietly change which `node` +/// runs. A configured PATH is left exactly as it is; the launchd case is +/// unambiguous and is the only one touched. +pub fn adoptSystemPath() void { + if (comptime builtin.os.tag != .macos) return; + var buf: [path_capacity]u8 = undefined; + var len: usize = 0; + collectSystemPath(&buf, &len); + if (len == 0) return; + const system = buf[0..len]; + + const current: []const u8 = if (libc.getenv("PATH")) |p| std.mem.span(p) else ""; + if (!allEntriesWithin(current, system)) return; + if (std.mem.eql(u8, current, system)) return; + + var out: [path_capacity:0]u8 = undefined; + if (len >= out.len) return; + @memcpy(out[0..len], system); + out[len] = 0; + _ = setenv("PATH", out[0..len :0].ptr, 1); +} + +/// Everything a native shell must do TO THE PROCESS before it forks its first +/// pane, in the order it has to happen, handing back the prompt files those +/// forks will borrow. +/// +/// Four hosts performed this ritual by hand and the copies had already +/// diverged. detached/server.zig forks bash through `resolve` exactly like its +/// siblings and never set BASH_SILENCE_DEPRECATION_WARNING, so every pane in a +/// detached session on macOS opened with Apple's zsh-migration banner printed +/// across the top of it — and nobody noticed, because the three hosts anyone +/// looks at daily all had the line. That is the failure mode of a four-line +/// ritual written four times. +/// +/// The ORDER is the content here. `adoptSystemPath` has to precede the fork +/// because the child inherits the environ; the setenv has to precede bash +/// because bash reads it at startup and the rc file is already too late; and +/// the rc files have to be complete on disk before any child can be handed a +/// path to one. +pub fn prepareForFork() PromptRcs { + adoptSystemPath(); + if (comptime builtin.os.tag.isDarwin()) + _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); + return PromptRcs.init(); +} + +/// /etc/paths, then every file in /etc/paths.d in NAME ORDER, which is the +/// order path_helper reads them in and therefore the order the directories +/// take precedence in. +fn collectSystemPath(buf: []u8, len: *usize) void { + var file_buf: [path_capacity]u8 = undefined; + if (readSmall("/etc/paths", &file_buf)) |body| appendLines(buf, len, body); + + const io = std.Io.Threaded.global_single_threaded.io(); + var dir = std.Io.Dir.cwd().openDir(io, "/etc/paths.d", .{ .iterate = true }) catch return; + defer dir.close(io); + + // readdir order is undefined and path_helper's is not, so the names are + // collected and sorted before any of them is read. + var names: [max_path_files][256]u8 = undefined; + var name_lens: [max_path_files]usize = undefined; + var count: usize = 0; + var it = dir.iterate(); + while (count < names.len) { + const entry = (it.next(io) catch break) orelse break; + if (entry.kind == .directory) continue; + if (entry.name.len == 0 or entry.name.len > names[count].len) continue; + @memcpy(names[count][0..entry.name.len], entry.name); + name_lens[count] = entry.name.len; + count += 1; + } + var order: [max_path_files]usize = undefined; + for (0..count) |i| order[i] = i; + std.mem.sort(usize, order[0..count], Names{ .names = &names, .lens = &name_lens }, Names.lessThan); + + var path_buf: [512]u8 = undefined; + for (order[0..count]) |i| { + const name = names[i][0..name_lens[i]]; + const path = std.fmt.bufPrintSentinel(&path_buf, "/etc/paths.d/{s}", .{name}, 0) catch continue; + if (readSmall(path, &file_buf)) |body| appendLines(buf, len, body); + } +} + +const Names = struct { + names: *const [max_path_files][256]u8, + lens: *const [max_path_files]usize, + + fn lessThan(self: Names, a: usize, b: usize) bool { + return std.mem.order(u8, self.names[a][0..self.lens[a]], self.names[b][0..self.lens[b]]) == .lt; + } +}; + +/// One directory per line, blanks and whitespace ignored — the format both +/// files use and the only thing path_helper reads out of them. +fn appendLines(buf: []u8, len: *usize, body: []const u8) void { + var lines = std.mem.splitScalar(u8, body, '\n'); + while (lines.next()) |raw| appendEntry(buf, len, std.mem.trim(u8, raw, " \t\r")); +} + +/// Append `entry` unless it is already present. Dedup preserves the FIRST +/// occurrence, which is what makes the order above mean precedence. +fn appendEntry(buf: []u8, len: *usize, entry: []const u8) void { + if (entry.len == 0) return; + if (hasEntry(buf[0..len.*], entry)) return; + const separator: usize = if (len.* == 0) 0 else 1; + if (len.* + separator + entry.len > buf.len) return; + if (separator == 1) { + buf[len.*] = ':'; + len.* += 1; + } + @memcpy(buf[len.*..][0..entry.len], entry); + len.* += entry.len; +} + +fn hasEntry(list: []const u8, entry: []const u8) bool { + var it = std.mem.tokenizeScalar(u8, list, ':'); + while (it.next()) |have| if (std.mem.eql(u8, have, entry)) return true; + return false; +} + +/// Whether `candidate` holds nothing `list` does not. An empty candidate is +/// within any list: a process with no PATH at all is the launchd case too. +fn allEntriesWithin(candidate: []const u8, list: []const u8) bool { + var it = std.mem.tokenizeScalar(u8, candidate, ':'); + while (it.next()) |entry| if (!hasEntry(list, entry)) return false; + return true; +} + +fn readSmall(path: [:0]const u8, buf: []u8) ?[]const u8 { + const fd = libc.open(path, .{ .ACCMODE = .RDONLY }, @as(libc.mode_t, 0)); + if (fd < 0) return null; + defer _ = libc.close(fd); + var off: usize = 0; + while (off < buf.len) { + const n = libc.read(fd, buf[off..].ptr, buf.len - off); + if (n < 0) { + if (libc.errno(n) == .INTR) continue; + return null; + } + if (n == 0) break; + off += @intCast(n); + } + return buf[0..off]; +} + +test "the launchd PATH is replaced and a configured one is left alone" { + var buf: [256]u8 = undefined; + var len: usize = 0; + appendEntry(&buf, &len, "/usr/bin"); + appendEntry(&buf, &len, "/bin"); + appendEntry(&buf, &len, "/usr/bin"); // already there: dedup keeps the first + appendEntry(&buf, &len, ""); + try std.testing.expectEqualStrings("/usr/bin:/bin", buf[0..len]); + + // Exactly the launchd default, in any order: nothing here is a choice. + try std.testing.expect(allEntriesWithin("/usr/bin:/bin", "/usr/bin:/bin:/sbin")); + try std.testing.expect(allEntriesWithin("", "/usr/bin")); + // One entry nobody could have inherited by accident, and the whole PATH is + // off limits — reordering it behind /usr/bin is how a version manager stops + // deciding which `node` runs. + try std.testing.expect(!allEntriesWithin("/Users/x/.cargo/bin:/usr/bin", "/usr/bin:/bin")); + try std.testing.expect(!allEntriesWithin("/opt/homebrew/bin", "/usr/bin:/bin")); +} + +test "the composed system path is the real one, in path_helper's order" { + if (comptime builtin.os.tag != .macos) return; + var buf: [path_capacity]u8 = undefined; + var len: usize = 0; + collectSystemPath(&buf, &len); + const composed = buf[0..len]; + // /etc/paths exists on every mac and leads with these. + try std.testing.expect(hasEntry(composed, "/usr/bin")); + try std.testing.expect(hasEntry(composed, "/bin")); + // ...and its entries come before anything /etc/paths.d contributes, which + // is the precedence the order encodes. + try std.testing.expect(std.mem.startsWith(u8, composed, "/usr/local/bin:")); + // No duplicates: /etc/paths.d files routinely repeat a system directory. + var seen = std.mem.tokenizeScalar(u8, composed, ':'); + var index: usize = 0; + while (seen.next()) |entry| : (index += 1) { + var rest = std.mem.tokenizeScalar(u8, composed, ':'); + var matches: usize = 0; + while (rest.next()) |other| if (std.mem.eql(u8, other, entry)) { + matches += 1; + }; + try std.testing.expectEqual(@as(usize, 1), matches); + } +} /// Prompt integration, per shell FAMILY rather than per binary: pardes hides /// prompt rows, moves the cursor by clicking one, and tells a command's output -- cgit v1.3