fix: use select on macOS

the man page for macOS `poll(2)` says, under "bugs", that polling
devices does not work, and indeed, although the current implementation
based on poll(2) does produce output, it does not appear to actually
be polling for this reason; instead the wait is blocking on the `read`
call, causing the macOS user to need to input another character to
exit.

this change introduces a wrapper over macOS's implementation of
`select`, modeled after `std.io.poll` as `select.zig`. it is a compile
error to use `select.zig` when `builtin.os.tag.isDarwin()` is false. a
lightly altered version of `Tty.zig` accompanies this change. it's
certainly possible to incorporate the two into one file; i didn't just
to leave the other one in its original state.

with this change, writing to the pipe correctly exits.

additionally, on macOS, `Thread.setName` must be called from the
thread whose name you wish to set, so I have just commented out that
line.
This commit is contained in:
Rylee Lyman 2024-03-09 00:23:25 -05:00 committed by Tim Culverhouse
parent 0192860c85
commit c65dab952c
5 changed files with 526 additions and 19 deletions

View file

@ -16,7 +16,11 @@ pub fn build(b: *std.Build) void {
});
// Module
const vaxis_mod = b.addModule("vaxis", .{ .root_source_file = root_source_file });
const vaxis_mod = b.addModule("vaxis", .{
.root_source_file = root_source_file,
.target = target,
.optimize = optimize,
});
vaxis_mod.addImport("ziglyph", ziglyph_dep.module("ziglyph"));
vaxis_mod.addImport("zigimg", zigimg_dep.module("zigimg"));
@ -34,13 +38,12 @@ pub fn build(b: *std.Build) void {
const example_run = b.addRunArtifact(example);
example_step.dependOn(&example_run.step);
b.default_step.dependOn(example_step);
// Tests
const tests_step = b.step("test", "Run tests");
const tests = b.addTest(.{
.root_source_file = root_source_file,
.root_source_file = .{ .path = "src/Tty-macos.zig" },
.target = target,
.optimize = optimize,
});
@ -48,8 +51,8 @@ pub fn build(b: *std.Build) void {
tests.root_module.addImport("zigimg", zigimg_dep.module("zigimg"));
const tests_run = b.addRunArtifact(tests);
b.installArtifact(tests);
tests_step.dependOn(&tests_run.step);
b.default_step.dependOn(tests_step);
// Lints
const lints_step = b.step("lint", "Run lints");

313
src/Tty-macos.zig Normal file
View file

@ -0,0 +1,313 @@
const std = @import("std");
const builtin = @import("builtin");
const os = std.os;
const Vaxis = @import("vaxis.zig").Vaxis;
const Parser = @import("Parser.zig");
const GraphemeCache = @import("GraphemeCache.zig");
const select = @import("select.zig").select;
const log = std.log.scoped(.tty);
const Tty = @This();
const Writer = std.io.Writer(os.fd_t, os.WriteError, os.write);
const BufferedWriter = std.io.BufferedWriter(4096, Writer);
/// the original state of the terminal, prior to calling makeRaw
termios: os.termios,
/// the file descriptor we are using for I/O
fd: std.fs.File,
/// the write end of a pipe to signal the tty should exit its run loop
quit_fd: ?os.fd_t = null,
buffered_writer: BufferedWriter,
/// initializes a Tty instance by opening /dev/tty and "making it raw"
pub fn init() !Tty {
// Open our tty
const fd = try std.fs.cwd().openFile("/dev/tty", .{
.mode = .read_write,
.allow_ctty = true,
});
// Set the termios of the tty
const termios = try makeRaw(fd.handle);
return Tty{
.fd = fd,
.termios = termios,
.buffered_writer = std.io.bufferedWriter(Writer{ .context = fd.handle }),
};
}
/// release resources associated with the Tty and return it to its original state
pub fn deinit(self: *Tty) void {
os.tcsetattr(self.fd.handle, .FLUSH, self.termios) catch |err| {
log.err("couldn't restore terminal: {}", .{err});
};
self.fd.close();
}
/// stops the run loop
pub fn stop(self: *Tty) void {
if (self.quit_fd) |fd| {
_ = std.os.write(fd, "q") catch {};
}
}
/// read input from the tty
pub fn run(
self: *Tty,
comptime Event: type,
vx: *Vaxis(Event),
) !void {
// create a pipe so we can signal to exit the run loop
const read_end, const write_end = try os.pipe();
defer os.close(read_end);
defer os.close(write_end);
// get our initial winsize
const winsize = try getWinsize(self.fd.handle);
if (@hasField(Event, "winsize")) {
vx.postEvent(.{ .winsize = winsize });
}
self.quit_fd = write_end;
// Build a winch handler. We need build this struct to get an anonymous
// function which can post the winsize event
// TODO: more signals, move this outside of this function?
const WinchHandler = struct {
const Self = @This();
var vx_winch: *Vaxis(Event) = undefined;
var fd: os.fd_t = undefined;
fn init(vx_arg: *Vaxis(Event), fd_arg: os.fd_t) !void {
vx_winch = vx_arg;
fd = fd_arg;
var act = os.Sigaction{
.handler = .{ .handler = Self.handleWinch },
.mask = switch (builtin.os.tag) {
.macos => 0,
.linux => std.os.empty_sigset,
else => @compileError("os not supported"),
},
.flags = 0,
};
try os.sigaction(os.SIG.WINCH, &act, null);
}
fn handleWinch(_: c_int) callconv(.C) void {
const ws = getWinsize(fd) catch {
return;
};
if (@hasField(Event, "winsize")) {
vx_winch.postEvent(.{ .winsize = ws });
}
}
};
try WinchHandler.init(vx, self.fd.handle);
// initialize a grapheme cache
var cache: GraphemeCache = .{};
var parser: Parser = .{};
// 2kb ought to be more than enough? given that we reset after each call?
var io_buf: [2 * 1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&io_buf);
// set up fds for selecting
var selector = try select(fba.allocator(), enum { tty, quit }, .{
.tty = self.fd,
.quit = .{ .handle = read_end },
});
// read loop
while (true) {
fba.reset();
try selector.select();
if (selector.fifo(.quit).readableLength() > 0) {
log.debug("quitting read thread", .{});
return;
}
const tty = selector.fifo(.tty);
const n = tty.readableLength();
var start: usize = 0;
defer tty.discard(n);
while (start < n) {
const result = try parser.parse(tty.readableSlice(start));
start += result.n;
// TODO: if we get 0 byte read, copy the remaining bytes to the
// beginning of the buffer and read mmore? this should only happen
// if we are in the middle of a grapheme at and filled our
// buffer. Probably can happen on large pastes so needs to be
// implemented but low priority
const event = result.event orelse continue;
switch (event) {
.key_press => |key| {
if (@hasField(Event, "key_press")) {
// HACK: yuck. there has to be a better way
var mut_key = key;
if (key.text) |text| {
mut_key.text = cache.put(text);
}
vx.postEvent(.{ .key_press = mut_key });
}
},
.mouse => |mouse| {
if (@hasField(Event, "mouse")) {
vx.postEvent(.{ .mouse = mouse });
}
},
.focus_in => {
if (@hasField(Event, "focus_in")) {
vx.postEvent(.focus_in);
}
},
.focus_out => {
if (@hasField(Event, "focus_out")) {
vx.postEvent(.focus_out);
}
},
.paste_start => {
if (@hasField(Event, "paste_start")) {
vx.postEvent(.paste_start);
}
},
.paste_end => {
if (@hasField(Event, "paste_end")) {
vx.postEvent(.paste_end);
}
},
.cap_kitty_keyboard => {
log.info("kitty keyboard capability detected", .{});
vx.caps.kitty_keyboard = true;
},
.cap_kitty_graphics => {
if (!vx.caps.kitty_graphics) {
log.info("kitty graphics capability detected", .{});
vx.caps.kitty_graphics = true;
}
},
.cap_rgb => {
log.info("rgb capability detected", .{});
vx.caps.rgb = true;
},
.cap_unicode => {
log.info("unicode capability detected", .{});
vx.caps.unicode = true;
vx.screen.unicode = true;
},
.cap_da1 => {
std.Thread.Futex.wake(&vx.query_futex, 10);
},
}
}
}
}
/// write to the tty. These writes are buffered and require calling flush to
/// flush writes to the tty
pub fn write(self: *Tty, bytes: []const u8) !usize {
return self.buffered_writer.write(bytes);
}
/// flushes the write buffer to the tty
pub fn flush(self: *Tty) !void {
try self.buffered_writer.flush();
}
/// makeRaw enters the raw state for the terminal.
pub fn makeRaw(fd: os.fd_t) !os.termios {
const state = try os.tcgetattr(fd);
var raw = state;
// see termios(3)
raw.iflag.IGNBRK = false;
raw.iflag.BRKINT = false;
raw.iflag.PARMRK = false;
raw.iflag.ISTRIP = false;
raw.iflag.INLCR = false;
raw.iflag.IGNCR = false;
raw.iflag.ICRNL = false;
raw.iflag.IXON = false;
raw.iflag.IUTF8 = true;
raw.oflag.OPOST = false;
raw.lflag.ECHO = false;
raw.lflag.ECHONL = false;
raw.lflag.ICANON = false;
raw.lflag.ISIG = false;
raw.lflag.IEXTEN = false;
raw.cflag.CSIZE = .CS8;
raw.cflag.PARENB = false;
raw.cc[@intFromEnum(std.posix.V.MIN)] = 1;
raw.cc[@intFromEnum(std.posix.V.TIME)] = 0;
try os.tcsetattr(fd, .FLUSH, raw);
return state;
}
/// The size of the terminal screen
pub const Winsize = @import("Tty.zig").Winsize;
fn getWinsize(fd: os.fd_t) !Winsize {
var winsize = os.winsize{
.ws_row = 0,
.ws_col = 0,
.ws_xpixel = 0,
.ws_ypixel = 0,
};
const TIOCGWINSZ = 1074295912;
const err = os.system.ioctl(fd, @as(c_int, TIOCGWINSZ), @intFromPtr(&winsize));
const e = os.errno(err);
if (e == .SUCCESS)
return Winsize{
.rows = winsize.ws_row,
.cols = winsize.ws_col,
.x_pixel = winsize.ws_xpixel,
.y_pixel = winsize.ws_ypixel,
};
return error.IoctlError;
}
test "run" {
if (true) return error.SkipZigTest;
const TestEvent = union(enum) {
winsize: Winsize,
key_press: @import("Key.zig"),
};
var vx = try Vaxis(TestEvent).init(.{});
defer vx.deinit(null);
var tty = try init();
defer tty.deinit();
const inner = struct {
fn f(t: *Tty) void {
std.time.sleep(std.time.ns_per_s);
t.stop();
}
};
const pid = try std.Thread.spawn(.{}, inner.f, .{&tty});
defer pid.join();
try tty.run(TestEvent, &vx);
}
test "get winsize" {
const tty = try init();
_ = try getWinsize(tty.fd);
}

View file

@ -19,7 +19,7 @@ termios: os.termios,
/// The file descriptor we are using for I/O
fd: os.fd_t,
/// the write end of a pipe to signal the tty should exit it's run loop
/// the write end of a pipe to signal the tty should exit its run loop
quit_fd: ?os.fd_t = null,
buffered_writer: BufferedWriter,
@ -39,7 +39,7 @@ pub fn init() !Tty {
};
}
/// release resources associated with the Tty return it to it's original state
/// release resources associated with the Tty return it to its original state
pub fn deinit(self: *Tty) void {
os.tcsetattr(self.fd, .FLUSH, self.termios) catch |err| {
log.err("couldn't restore terminal: {}", .{err});

190
src/select.zig Normal file
View file

@ -0,0 +1,190 @@
const std = @import("std");
const builtin = @import("builtin");
const c = struct {
comptime {
if (!builtin.os.tag.isDarwin())
@compileError("this file requires linking against darwin libc!");
}
extern "c" fn select(c_int, ?*fd_set, ?*fd_set, ?*fd_set, ?*timeval) c_int;
/// FIXME: pretty sure this will break if you define _DARWIN_UNLIMITED_SELECT lol
const fd_set = extern struct {
fds_bits: [32]i32 = .{0} ** 32,
};
const timeval = extern struct {
tv_sec: i64,
tv_usec: i32,
};
const DARWIN_NFDBITS = @sizeOf(i32) * 8;
/// hand translated mostly bc I didn't understand the zig translate-c output
/// notably, i'm skipping the check_fd_set call ... don't make me mad
inline fn FD_SET(fd: i32, fds: ?*fd_set) void {
const idx: usize = @intCast(@as(u64, @bitCast(@as(i64, fd))) / DARWIN_NFDBITS);
const val: u64 = @as(u64, 1) << @intCast(@as(u64, @bitCast(@as(i64, fd))) % DARWIN_NFDBITS);
fds.?.fds_bits[idx] |= @as(i32, @bitCast(@as(u32, @truncate(val))));
}
inline fn FD_CLR(fd: i32, fds: ?*fd_set) void {
const idx: usize = @intCast(@as(u64, @bitCast(@as(i64, fd))) / DARWIN_NFDBITS);
const val: u64 = @as(u64, 1) << @intCast(@as(u64, @bitCast(@as(i64, fd))) % DARWIN_NFDBITS);
fds.?.fds_bits[idx] &= ~@as(i32, @bitCast(@as(u32, @truncate(val))));
}
inline fn FD_ISSET(fd: i32, fds: ?*const fd_set) bool {
const idx: usize = @intCast(@as(u64, @bitCast(@as(i64, fd))) / DARWIN_NFDBITS);
const val: u64 = @as(u64, 1) << @intCast(@as(u64, @bitCast(@as(i64, fd))) % DARWIN_NFDBITS);
return fds.?.fds_bits[idx] & @as(i32, @bitCast(@as(u32, @truncate(val)))) > 0;
}
};
/// minimal wrapper over select(2); watches the specified files for input
/// API chosen to (mostly) match std.io.poll
pub fn select(
allocator: std.mem.Allocator,
comptime StreamEnum: type,
files: SelectFiles(StreamEnum),
) error{FdMaxExceeded}!Selector(StreamEnum) {
const enum_fields = @typeInfo(StreamEnum).Enum.fields;
var result: Selector(StreamEnum) = undefined;
var fd_max: std.os.system.fd_t = 0;
inline for (0..enum_fields.len) |i| {
result.fifos[i] = .{
.allocator = allocator,
.buf = &.{},
.head = 0,
.count = 0,
};
result.select_fds[i] = @field(files, enum_fields[i].name).handle;
fd_max = @max(fd_max, @field(files, enum_fields[i].name).handle);
}
result.fd_max = if (fd_max + 1 > 1024) return error.FdMaxExceeded else fd_max + 1;
return result;
}
pub const SelectFifo = std.fifo.LinearFifo(u8, .Dynamic);
pub fn Selector(comptime StreamEnum: type) type {
return struct {
const enum_fields = @typeInfo(StreamEnum).Enum.fields;
fifos: [enum_fields.len]SelectFifo,
select_fds: [enum_fields.len]std.os.system.fd_t,
fd_max: std.os.system.fd_t,
const Self = @This();
pub fn deinit(self: *Self) void {
inline for (&self.fifos) |*q| q.deinit();
self.* = undefined;
}
pub fn select(self: *Self) !void {
return selectInner(self, null);
}
pub fn selectTimeout(self: *Self, nanoseconds: u64) !void {
return selectInner(self, nanoseconds);
}
fn selectInner(self: *Self, nanoseconds: ?u64) !void {
// We ask for ensureUnusedCapacity with this much extra space. This
// has more of an effect on small reads because once the reads
// start to get larger the amount of space an ArrayList will
// allocate grows exponentially.
const bump_amt = 512;
const fds = fds: {
while (true) {
var timeval: ?c.timeval =
if (nanoseconds) |ns|
.{
.tv_sec = std.math.cast(i64, ns / std.time.ns_per_s) orelse std.math.maxInt(i64),
.tv_usec = std.math.cast(i32, (ns % std.time.ns_per_s) / std.time.ns_per_us) orelse 0,
}
else
null;
const ptr: ?*c.timeval = if (timeval) |*tv| tv else null;
var fds: c.fd_set = .{};
@memset(&fds.fds_bits, 0);
inline for (self.select_fds) |fd| {
c.FD_SET(fd, &fds);
}
const err = c.select(self.fd_max, &fds, null, null, ptr);
switch (std.os.errno(err)) {
.SUCCESS => break :fds fds,
// TODO: these are clearly not unreachable ...
.BADF => break :fds fds,
.INVAL => unreachable,
.INTR => continue,
.NOMEM => return error.SystemResources,
else => |e| return std.os.unexpectedErrno(e),
}
}
};
inline for (&self.select_fds, &self.fifos) |fd, *q| {
if (c.FD_ISSET(fd, &fds)) {
const buf = try q.writableWithSize(bump_amt);
const amt = try std.os.read(fd, buf);
q.update(amt);
}
}
}
pub inline fn fifo(self: *Self, comptime which: StreamEnum) *SelectFifo {
return &self.fifos[@intFromEnum(which)];
}
};
}
/// Given an enum, returns a struct with fields of that enum,
/// each field representing an I/O stream for selecting
pub fn SelectFiles(comptime StreamEnum: type) type {
const enum_fields = @typeInfo(StreamEnum).Enum.fields;
var struct_fields: [enum_fields.len]std.builtin.Type.StructField = undefined;
for (&struct_fields, enum_fields) |*struct_field, enum_field| {
struct_field.* = .{
.name = enum_field.name ++ "",
.type = std.fs.File,
.default_value = null,
.is_comptime = false,
.alignment = @alignOf(std.fs.File),
};
}
return @Type(.{ .Struct = .{
.layout = .Auto,
.fields = &struct_fields,
.decls = &.{},
.is_tuple = false,
} });
}
test "select" {
const read_end, const write_end = try std.os.pipe();
defer std.os.close(read_end);
defer std.os.close(write_end);
const read_fd: std.fs.File = .{ .handle = read_end };
const tty = try std.fs.cwd().openFile("/dev/tty", .{
.mode = .read_write,
.allow_ctty = true,
});
var selector = try select(std.testing.allocator, enum { tty, quit }, .{
.tty = tty,
.quit = read_fd,
});
defer selector.deinit();
const inner = struct {
fn f(fd: i32) !void {
std.time.sleep(std.time.ns_per_s);
_ = try std.os.write(fd, "q");
}
};
const pid = try std.Thread.spawn(.{}, inner.f, .{write_end});
defer pid.join();
try selector.selectTimeout(std.time.ns_per_us * 2);
try selector.select();
}

View file

@ -1,10 +1,11 @@
const std = @import("std");
const builtin = @import("builtin");
const atomic = std.atomic;
const base64 = std.base64.standard.Encoder;
const Queue = @import("queue.zig").Queue;
const ctlseqs = @import("ctlseqs.zig");
const Tty = @import("Tty.zig");
const Tty = if (builtin.os.tag.isDarwin()) @import("Tty-macos.zig") else @import("Tty.zig");
const Winsize = Tty.Winsize;
const Key = @import("Key.zig");
const Screen = @import("Screen.zig");
@ -128,8 +129,8 @@ pub fn Vaxis(comptime T: type) type {
pub fn startReadThread(self: *Self) !void {
self.tty = try Tty.init();
// run our tty read loop in it's own thread
const read_thread = try std.Thread.spawn(.{}, Tty.run, .{ &self.tty.?, T, self });
try read_thread.setName("tty");
_ = try std.Thread.spawn(.{}, Tty.run, .{ &self.tty.?, T, self });
// try read_thread.setName("tty");
}
/// stops reading from the tty
@ -668,13 +669,13 @@ pub fn Vaxis(comptime T: type) type {
};
}
test "Vaxis: event queueing" {
const Event = union(enum) {
key,
};
var vx: Vaxis(Event) = try Vaxis(Event).init(.{});
defer vx.deinit(null);
vx.postEvent(.key);
const event = vx.nextEvent();
try std.testing.expect(event == .key);
}
// test "Vaxis: event queueing" {
// const Event = union(enum) {
// key: void,
// };
// var vx: Vaxis(Event) = try Vaxis(Event).init(.{});
// defer vx.deinit(null);
// vx.postEvent(.{ .key = {} });
// const event = vx.nextEvent();
// try std.testing.expect(event == .key);
// }