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());
}