rawterm.zig

8.0 KB · raw

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.19const std = @import("std");2021pub const STDIN_FD = std.posix.STDIN_FILENO;2223var g_saved: ?std.posix.termios = null;2425// 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.28fn restoreSavedTermios(fd: std.posix.fd_t) void {29    if (g_saved) |t| std.posix.tcsetattr(fd, .FLUSH, t) catch {};30}3132fn defaultAction() std.posix.Sigaction {33    return .{34        .handler = .{ .handler = std.posix.SIG.DFL },35        .mask = std.posix.sigemptyset(),36        .flags = 0,37    };38}3940// 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.44fn 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}5051fn 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}6162fn 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}6869// 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.75pub 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}8990pub 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}9596// --- 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.107108const testing = std.testing;109110extern "c" fn posix_openpt(flags: c_int) c_int;111extern "c" fn grantpt(fd: c_int) c_int;112extern "c" fn unlockpt(fd: c_int) c_int;113extern "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.117extern "c" fn close(fd: c_int) c_int;118119const TestPty = struct {120    master: std.posix.fd_t,121    slave: std.posix.fd_t,122123    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    }134135    fn closePty(self: TestPty) void {136        _ = close(self.slave);137        _ = close(self.master);138    }139};140141test "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();149150    try testing.expect(g_saved == null);151152    var saved: std.posix.termios = undefined;153    try rawModeEnable(pty.slave, &saved);154    defer rawModeDisable(pty.slave, saved);155156    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}162163test "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();169170    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);174175    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}180181test "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();187188    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;196197    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}