a73x

test/rawmode.zig

Ref:   Size: 7.3 KiB   History

//! A deterministic stand-in for an editor, for tests about prediction.
//!
//! Prediction's hardest tier is raw mode, where the kernel echoes nothing
//! and the application decides what a keystroke looks like. nvim is the real
//! thing, and it is the wrong thing to put in an automated suite: its redraw
//! timing is its own business, its version changes what it paints, and it is
//! not installed everywhere this has to run. So the demo keeps nvim and the
//! suite gets this — a program with exactly the two behaviours that matter.
//!
//!   phase 1, `.echo`    — every byte is written straight back, which is
//!                         what an editor in insert mode looks like from
//!                         outside: predictions confirm.
//!   phase 2, `.swallow` — bytes are consumed and nothing is printed, which
//!                         is normal mode: predictions go unanswered, and
//!                         the expiry bound is what has to catch them.
//!
//! `0x00` moves it from the first to the second, once and not back — a test
//! wants to cross that boundary at a moment it chose. `0x03` ends it.
//!
//! Raw mode is set on the tty when there is one, so the LINE DISCIPLINE
//! echoes nothing and this program's own writes are the only echo. That is
//! what makes the daemon report icanon=0/echo=0 and the client's overlay
//! reach `.adaptive`, which is the whole point of the helper.
const std = @import("std");

pub const toggle_byte: u8 = 0x00;
pub const quit_byte: u8 = 0x03;

pub const Phase = enum { echo, swallow };

pub const Action = union(enum) {
    /// Write it back: insert mode.
    emit: u8,
    /// Consume it and print nothing: normal mode.
    swallow,
    /// Done.
    quit,
};

/// The whole behaviour, as a function of one byte. Pulled out of the loop so
/// the state machine can be tested without a process, a pipe or a tty.
pub fn step(phase: *Phase, byte: u8) Action {
    if (byte == quit_byte) return .quit;
    if (byte == toggle_byte) {
        // One-way, deliberately: a test crosses the boundary once, at a
        // moment it picked, and everything after that is normal mode.
        phase.* = .swallow;
        return .swallow;
    }
    return switch (phase.*) {
        .echo => .{ .emit = byte },
        .swallow => .swallow,
    };
}

/// Run the machine over a pair of descriptors until the quit byte or EOF.
/// Closes neither: whoever opened them owns them.
pub fn pump(in_fd: std.posix.fd_t, out_fd: std.posix.fd_t) !void {
    var phase: Phase = .echo;
    var buf: [4096]u8 = undefined;
    while (true) {
        const n = std.posix.read(in_fd, &buf) catch return;
        if (n == 0) return;
        for (buf[0..n]) |byte| {
            switch (step(&phase, byte)) {
                .quit => return,
                .swallow => {},
                .emit => |b| {
                    var one = [_]u8{b};
                    writeAll(out_fd, &one) catch return;
                },
            }
        }
    }
}

fn writeAll(fd: std.posix.fd_t, data: []const u8) !void {
    var i: usize = 0;
    while (i < data.len) i += try std.posix.write(fd, data[i..]);
}

pub fn main() !void {
    const in_fd = std.posix.STDIN_FILENO;
    const out_fd = std.posix.STDOUT_FILENO;

    // Raw mode when there is a terminal to put into it. Without this the
    // line discipline echoes for us and the pty reports icanon=1/echo=1,
    // which is the one tier this helper exists NOT to be.
    var restore: ?std.posix.termios = null;
    if (std.posix.isatty(in_fd)) {
        const orig = try std.posix.tcgetattr(in_fd);
        restore = orig;
        var raw = orig;
        raw.lflag.ICANON = false;
        raw.lflag.ECHO = false;
        raw.lflag.ISIG = false;
        try std.posix.tcsetattr(in_fd, .FLUSH, raw);
    }
    defer if (restore) |t| std.posix.tcsetattr(in_fd, .FLUSH, t) catch {};

    try pump(in_fd, out_fd);
}

test "step: insert mode echoes, normal mode does not, and the toggle is one-way" {
    var phase: Phase = .echo;

    // Insert-like: every printable comes straight back.
    try std.testing.expectEqual(Action{ .emit = 'a' }, step(&phase, 'a'));
    try std.testing.expectEqual(Action{ .emit = 'Z' }, step(&phase, 'Z'));
    try std.testing.expectEqual(Phase.echo, phase);

    // The boundary itself prints nothing.
    try std.testing.expectEqual(Action.swallow, step(&phase, toggle_byte));
    try std.testing.expectEqual(Phase.swallow, phase);

    // Normal-like: consumed, and the screen never hears about it. This is
    // the shape that leaves a prediction unanswered forever, which is what
    // the overlay's expiry bound exists for.
    try std.testing.expectEqual(Action.swallow, step(&phase, 'a'));
    try std.testing.expectEqual(Action.swallow, step(&phase, 'j'));

    // And it does not go back: a second toggle is still normal mode, so a
    // test that sends one cannot accidentally re-arm the echo it was
    // finished with.
    try std.testing.expectEqual(Action.swallow, step(&phase, toggle_byte));
    try std.testing.expectEqual(Phase.swallow, phase);

    try std.testing.expectEqual(Action.quit, step(&phase, quit_byte));
}

test "step: quit wins even in insert mode, where every other byte echoes" {
    var phase: Phase = .echo;
    try std.testing.expectEqual(Action.quit, step(&phase, quit_byte));
}

fn pumpThread(in_fd: std.posix.fd_t, out_fd: std.posix.fd_t) void {
    pump(in_fd, out_fd) catch {};
}

test "pump over a pipe pair: the echo stops exactly where the toggle is" {
    const alloc = std.testing.allocator;
    const to_child = try std.posix.pipe();
    const from_child = try std.posix.pipe();
    defer std.posix.close(to_child[0]);
    defer std.posix.close(from_child[0]);

    const th = try std.Thread.spawn(.{}, pumpThread, .{ to_child[0], from_child[1] });

    // "hi" in insert mode, then the boundary, then "xy" that must vanish,
    // then quit. One write, so the pump sees them as one chunk and has to
    // change behaviour mid-buffer rather than between reads.
    try writeAll(to_child[1], "hi\x00xy\x03");
    th.join();
    std.posix.close(to_child[1]);
    std.posix.close(from_child[1]);

    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(alloc);
    var buf: [256]u8 = undefined;
    while (true) {
        const n = try std.posix.read(from_child[0], &buf);
        if (n == 0) break;
        try out.appendSlice(alloc, buf[0..n]);
    }
    // Exactly the insert-mode bytes: "xy" was typed and answered with
    // silence, which is the behaviour prediction has to survive.
    try std.testing.expectEqualStrings("hi", out.items);
}

test "pump: EOF ends it as cleanly as the quit byte" {
    const to_child = try std.posix.pipe();
    const from_child = try std.posix.pipe();
    defer std.posix.close(to_child[0]);
    defer std.posix.close(from_child[0]);
    defer std.posix.close(from_child[1]);

    const th = try std.Thread.spawn(.{}, pumpThread, .{ to_child[0], from_child[1] });
    try writeAll(to_child[1], "ab");
    std.posix.close(to_child[1]); // EOF, with no quit byte at all
    th.join(); // returning at all is the assertion
}

// Forces semantic analysis of every pub decl under `zig build test`, so an
// unreferenced decl must at least compile (the silent-module-loss hazard,
// decisions.md). Pub decls only: std.meta.declarations sees nothing private.
test {
    std.testing.refAllDeclsRecursive(@This());
}