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
↓ nina-48628db2.tar.gzatlas at this save
files changed3 files
src/man.zig+7 −25src/rawterm.zig+200 −0src/search.zig+7 −24src/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 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+}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).