48628db291a4

search/man TUI raw mode restores the terminal on SIGINT/SIGTERM/SIGHUP via a shared rawterm.zig module, instead of relying solely on defer (which never runs on a signal or a ReleaseSafe panic-abort); ISIG stays enabled so ^C now delivers SIGINT to break out of a wedged search instead of landing as a dead 0x03 byte, and the handler restores the saved termios before re-raising with the default disposition so the process still dies the way it would have (NINA-014)

time
2026-07-02 14:30 UTC · 3 months ago
machine
koh2
agent
claude-sonnet-5
session
fable audit fix — TUI raw mode and pager
parent
ceda368a6dea
3 files src/man.zig+7 −25src/rawterm.zig+200 −0src/search.zig+7 −24
src/man.zig+7 −25
@@ -15,6 +15,7 @@1515 // lavender #7A8162 on bg-surface #EEE6DB / bg-deep #CDBFAE.1616 const std = @import("std");1717 const output = @import("output.zig");18+const rawterm = @import("rawterm.zig");1819 1920 const PINK = "\x1b[38;2;170;106;82m"; // --pink (#AA6A52)2021 const GOLD = "\x1b[38;2;173;138;80m"; // --gold (#AD8A50)@@ -527,8 +528,8 @@527528 528529     // Raw mode so we can read keys without waiting for Enter. Match search.zig.529530     var saved: std.posix.termios = undefined;530-    try rawModeEnable(STDIN_FD, &saved);531-    defer rawModeDisable(STDIN_FD, saved);531+    try rawterm.rawModeEnable(STDIN_FD, &saved);532+    defer rawterm.rawModeDisable(STDIN_FD, saved);532533 533534     PAGE_ROWS = detectPageRows(io);534535 @@ -686,30 +687,11 @@686687     output.flush();687688 }688689 689-const STDIN_FD = std.posix.STDIN_FILENO;690+const STDIN_FD = rawterm.STDIN_FD;690691 691-fn rawModeEnable(fd: std.posix.fd_t, saved: *std.posix.termios) !void {692-    var tio = try std.posix.tcgetattr(fd);693-    saved.* = tio;694-    // These flag names exist on every POSIX termios target Zig exposes695-    // (linux, macos, the *BSDs), so one body covers all of them — the old696-    // linux-only guard just left macos in cooked mode (echo on, line-buffered).697-    // OPOST is deliberately untouched: output keeps translating "\n" -> "\r\n",698-    // which the renderers rely on to return the cursor to column 0.699-    tio.lflag.ECHO = false;700-    tio.lflag.ICANON = false;701-    tio.lflag.ISIG = false;702-    tio.lflag.IEXTEN = false;703-    tio.iflag.ICRNL = false;704-    tio.iflag.IXON = false;705-    tio.cc[@intFromEnum(std.posix.V.MIN)] = 1;706-    tio.cc[@intFromEnum(std.posix.V.TIME)] = 0;707-    try std.posix.tcsetattr(fd, .FLUSH, tio);708-}709-710-fn rawModeDisable(fd: std.posix.fd_t, saved: std.posix.termios) void {711-    std.posix.tcsetattr(fd, .FLUSH, saved) catch {};712-}692+// Raw mode enable/disable, including signal-safe restoration on693+// SIGINT/SIGTERM/SIGHUP, now lives in rawterm.zig — shared with search.zig694+// (NINA-014).713695 714696 // --- Rendering ---715697 

view file

src/rawterm.zig+200 −0
@@ -0,0 +1,200 @@1+// rawterm.zig — shared raw-terminal-mode handling for the search and man TUI2+// widgets. Both widgets read single keystrokes from stdin without waiting3+// for Enter, which requires ECHO/ICANON off; both need the terminal restored4+// to its original state no matter how the process exits.5+//6+// `defer rawModeDisable(...)` alone only covers normal returns and Zig7+// errors — it does not run on SIGTERM/SIGHUP/SIGKILL (signals bypass defers8+// entirely) or on a ReleaseSafe panic (which aborts without unwinding). Left9+// unhandled, either one leaves the user's shell in raw, echo-off mode after10+// nina exits (NINA-014). SIGKILL can never be caught by any process, so it11+// stays out of scope; SIGTERM/SIGINT/SIGHUP are the ones a normal shell or12+// `kill` can actually deliver.13+//14+// A POSIX signal handler cannot close over local state — no captured15+// allocator, no pointer to the caller's stack `saved` termios — so the16+// termios snapshot the handler restores from has to live in a file-scope17+// var, updated by rawModeEnable/rawModeDisable alongside the caller's own18+// copy.19+const std = @import("std");20+21+pub const STDIN_FD = std.posix.STDIN_FILENO;22+23+var g_saved: ?std.posix.termios = null;24+25+// Restores the terminal from the global snapshot, if raw mode is currently26+// active. Split out from the signal handler so it can be exercised directly27+// in a test without actually delivering (and dying to) a signal.28+fn restoreSavedTermios(fd: std.posix.fd_t) void {29+    if (g_saved) |t| std.posix.tcsetattr(fd, .FLUSH, t) catch {};30+}31+32+fn defaultAction() std.posix.Sigaction {33+    return .{34+        .handler = .{ .handler = std.posix.SIG.DFL },35+        .mask = std.posix.sigemptyset(),36+        .flags = 0,37+    };38+}39+40+// Restores the terminal, then puts the signal's default disposition back41+// and re-raises it so the process actually terminates the way it would have42+// without this handler installed — a signal handler that swallows SIGTERM43+// and keeps running is worse than the ghosting bug it's fixing.44+fn restoreAndReraise(sig: std.posix.SIG) callconv(.c) void {45+    restoreSavedTermios(STDIN_FD);46+    var dfl = defaultAction();47+    std.posix.sigaction(sig, &dfl, null);48+    std.posix.raise(sig) catch {};49+}50+51+fn installSignalHandlers() void {52+    const act: std.posix.Sigaction = .{53+        .handler = .{ .handler = restoreAndReraise },54+        .mask = std.posix.sigemptyset(),55+        .flags = 0,56+    };57+    std.posix.sigaction(.INT, &act, null);58+    std.posix.sigaction(.TERM, &act, null);59+    std.posix.sigaction(.HUP, &act, null);60+}61+62+fn restoreDefaultSignalHandlers() void {63+    var dfl = defaultAction();64+    std.posix.sigaction(.INT, &dfl, null);65+    std.posix.sigaction(.TERM, &dfl, null);66+    std.posix.sigaction(.HUP, &dfl, null);67+}68+69+// Enables raw mode on `fd`: no echo, no line buffering, one byte at a time.70+// ISIG is deliberately left on (unlike a "classic" raw-mode setup) so ^C71+// still delivers SIGINT instead of arriving as a literal 0x03 byte in the72+// input stream — before this fix Esc was the only way out of a wedged73+// search, since ICANON+ISIG off meant ^C did nothing at all. OPOST stays on74+// so "\n" keeps translating to "\r\n", which the widgets rely on.75+pub fn rawModeEnable(fd: std.posix.fd_t, saved: *std.posix.termios) !void {76+    var tio = try std.posix.tcgetattr(fd);77+    saved.* = tio;78+    g_saved = tio;79+    installSignalHandlers();80+    tio.lflag.ECHO = false;81+    tio.lflag.ICANON = false;82+    tio.lflag.IEXTEN = false;83+    tio.iflag.ICRNL = false;84+    tio.iflag.IXON = false;85+    tio.cc[@intFromEnum(std.posix.V.MIN)] = 1;86+    tio.cc[@intFromEnum(std.posix.V.TIME)] = 0;87+    try std.posix.tcsetattr(fd, .FLUSH, tio);88+}89+90+pub fn rawModeDisable(fd: std.posix.fd_t, saved: std.posix.termios) void {91+    std.posix.tcsetattr(fd, .FLUSH, saved) catch {};92+    restoreDefaultSignalHandlers();93+    g_saved = null;94+}95+96+// --- Tests ---97+//98+// A real SIGTERM-to-a-spawned-nina-process test would be the strongest99+// evidence, but the repo has no subprocess+signal test harness to build on100+// (test/vm/ drives a full NixOS VM over serial, not a quick local pty), and101+// reliably synchronizing "the widget is blocked in poll() in raw mode" with102+// "now send the signal" from a test process is inherently racy. Instead103+// these tests open a real pty (posix_openpt/grantpt/unlockpt), so104+// tcgetattr/tcsetattr run against a genuine tty rather than being mocked,105+// and exercise the exact code paths the signal handler calls — everything106+// except the final `raise()`, which would kill the test binary.107+108+const testing = std.testing;109+110+extern "c" fn posix_openpt(flags: c_int) c_int;111+extern "c" fn grantpt(fd: c_int) c_int;112+extern "c" fn unlockpt(fd: c_int) c_int;113+extern "c" fn ptsname(fd: c_int) ?[*:0]u8;114+// std.posix has no close() wrapper for a bare fd in 0.16 (callers are115+// expected to go through std.Io.File) — these test-only pty fds come from116+// direct libc calls instead, so close them the same way.117+extern "c" fn close(fd: c_int) c_int;118+119+const TestPty = struct {120+    master: std.posix.fd_t,121+    slave: std.posix.fd_t,122+123+    fn open() !TestPty {124+        const O_RDWR_NOCTTY: std.posix.O = .{ .ACCMODE = .RDWR, .NOCTTY = true };125+        const master = posix_openpt(@bitCast(O_RDWR_NOCTTY));126+        if (master < 0) return error.OpenPtyFailed;127+        errdefer _ = close(master);128+        if (grantpt(master) != 0) return error.OpenPtyFailed;129+        if (unlockpt(master) != 0) return error.OpenPtyFailed;130+        const path = ptsname(master) orelse return error.OpenPtyFailed;131+        const slave = try std.posix.openatZ(std.posix.AT.FDCWD, path, O_RDWR_NOCTTY, 0);132+        return .{ .master = master, .slave = slave };133+    }134+135+    fn closePty(self: TestPty) void {136+        _ = close(self.slave);137+        _ = close(self.master);138+    }139+};140+141+test "rawModeEnable stashes the pre-raw termios in the signal-reachable global and disables echo/canon while leaving ISIG on" {142+    const pty = TestPty.open() catch |err| {143+        // No pty available in this sandbox (e.g. no /dev/ptmx) — nothing to144+        // assert against; skip rather than fail the whole suite.145+        std.log.warn("skipping rawterm pty test: {t}", .{err});146+        return error.SkipZigTest;147+    };148+    defer pty.closePty();149+150+    try testing.expect(g_saved == null);151+152+    var saved: std.posix.termios = undefined;153+    try rawModeEnable(pty.slave, &saved);154+    defer rawModeDisable(pty.slave, saved);155+156+    try testing.expect(g_saved != null);157+    const applied = try std.posix.tcgetattr(pty.slave);158+    try testing.expect(!applied.lflag.ECHO);159+    try testing.expect(!applied.lflag.ICANON);160+    try testing.expect(applied.lflag.ISIG); // NINA-014: ^C must still raise SIGINT161+}162+163+test "rawModeDisable restores the original termios and clears the signal-reachable global" {164+    const pty = TestPty.open() catch |err| {165+        std.log.warn("skipping rawterm pty test: {t}", .{err});166+        return error.SkipZigTest;167+    };168+    defer pty.closePty();169+170+    const original = try std.posix.tcgetattr(pty.slave);171+    var saved: std.posix.termios = undefined;172+    try rawModeEnable(pty.slave, &saved);173+    rawModeDisable(pty.slave, saved);174+175+    try testing.expect(g_saved == null);176+    const restored = try std.posix.tcgetattr(pty.slave);177+    try testing.expect(restored.lflag.ECHO == original.lflag.ECHO);178+    try testing.expect(restored.lflag.ICANON == original.lflag.ICANON);179+}180+181+test "restoreSavedTermios (the signal handler's own restore step) puts ECHO/ICANON back without a real signal" {182+    const pty = TestPty.open() catch |err| {183+        std.log.warn("skipping rawterm pty test: {t}", .{err});184+        return error.SkipZigTest;185+    };186+    defer pty.closePty();187+188+    var saved: std.posix.termios = undefined;189+    try rawModeEnable(pty.slave, &saved);190+    // Simulate "process dies mid-search, still in raw mode" by calling only191+    // the restore half of the handler — never the re-raise, which would192+    // terminate this test binary.193+    restoreSavedTermios(pty.slave);194+    restoreDefaultSignalHandlers();195+    g_saved = null;196+197+    const restored = try std.posix.tcgetattr(pty.slave);198+    try testing.expect(restored.lflag.ECHO == saved.lflag.ECHO);199+    try testing.expect(restored.lflag.ICANON == saved.lflag.ICANON);200+}

view file

src/search.zig+7 −24
@@ -4,10 +4,11 @@44 const types = @import("types.zig");55 const output = @import("output.zig");66 const api = @import("api.zig");7+const rawterm = @import("rawterm.zig");78 89 const WIDGET_LINES: usize = 18;910 const RESULT_ROWS: usize = 5;10-const STDIN_FD = std.posix.STDIN_FILENO;11+const STDIN_FD = rawterm.STDIN_FD;1112 1213 // Debounce window: a keystroke defers the nix search until typing pauses this1314 // long. nix search is synchronous and blocks the widget for ~1s, so the window@@ -45,8 +46,8 @@4546     if (!stdinIsTty(io)) return runPlain(gpa, io, initial_query, mode, environ);4647 4748     var saved: std.posix.termios = undefined;48-    try rawModeEnable(STDIN_FD, &saved);49-    defer rawModeDisable(STDIN_FD, saved);49+    try rawterm.rawModeEnable(STDIN_FD, &saved);50+    defer rawterm.rawModeDisable(STDIN_FD, saved);5051 5152     var arena = std.heap.ArenaAllocator.init(gpa);5253     defer arena.deinit();@@ -430,24 +431,6 @@430431     }431432 }432433 433-fn rawModeEnable(fd: std.posix.fd_t, saved: *std.posix.termios) !void {434-    var tio = try std.posix.tcgetattr(fd);435-    saved.* = tio;436-    // Same flag names exist on every POSIX termios target Zig exposes (linux,437-    // macos, the *BSDs), so one body covers all of them — the old linux-only438-    // guard left macos in cooked mode (echo on, line-buffered). OPOST is left439-    // on so output keeps translating "\n" -> "\r\n" (cursor to column 0).440-    tio.lflag.ECHO = false;441-    tio.lflag.ICANON = false;442-    tio.lflag.ISIG = false;443-    tio.lflag.IEXTEN = false;444-    tio.iflag.ICRNL = false;445-    tio.iflag.IXON = false;446-    tio.cc[@intFromEnum(std.posix.V.MIN)] = 1;447-    tio.cc[@intFromEnum(std.posix.V.TIME)] = 0;448-    try std.posix.tcsetattr(fd, .FLUSH, tio);449-}450-451-fn rawModeDisable(fd: std.posix.fd_t, saved: std.posix.termios) void {452-    std.posix.tcsetattr(fd, .FLUSH, saved) catch {};453-}434+// Raw mode enable/disable, including signal-safe restoration on435+// SIGINT/SIGTERM/SIGHUP, now lives in rawterm.zig — shared with man.zig436+// (NINA-014).

view file