From 44dbbb7802801d11b22ce6eabe22f56550dc26c6 Mon Sep 17 00:00:00 2001 From: Amp Date: Sun, 6 Sep 2026 11:41:38 +0000 Subject: [PATCH] c api: expose POSIX TTY resize subscriptions C consumers cannot subscribe to the existing SIGWINCH dispatcher, leaving terminals without in-band resize notifications dependent on separate signal handling or polling. Expose callback registration and removal through the C API, with owned callback storage and documented threading and lifetime requirements. Reinstall SIGWINCH handling when a subscriber registers after the last handler was removed. Add a headless pseudo-terminal integration test covering delivery, removal, duplicate registrations, capacity, re-registration, and cleanup. Fixes: #365 Co-authored-by: Tim Culverhouse --- build.zig | 18 +++++++ examples/c/tty.c | 131 +++++++++++++++++++++++++++++++++++++++++++++++ include/vaxis.h | 22 ++++++++ src/c_api.zig | 48 ++++++++++++++++- src/tty.zig | 27 ++++++---- 5 files changed, 235 insertions(+), 11 deletions(-) create mode 100644 examples/c/tty.c diff --git a/build.zig b/build.zig index 034b2be..e706efd 100644 --- a/build.zig +++ b/build.zig @@ -267,6 +267,24 @@ pub fn build(b: *std.Build) void { }); tests_step.dependOn(&b.addRunArtifact(c_runtime_test).step); + const c_tty_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + .link_libc = true, + }); + c_tty_mod.addCSourceFile(.{ + .file = b.path("examples/c/tty.c"), + .flags = &.{ "-std=c99", "-pedantic-errors" }, + }); + c_tty_mod.addIncludePath(b.path("include")); + c_tty_mod.linkLibrary(static_lib); + const c_tty_test = b.addExecutable(.{ + .name = "example-c-tty", + .root_module = c_tty_mod, + .use_llvm = use_llvm, + }); + tests_step.dependOn(&b.addRunArtifact(c_tty_test).step); + // Docs const docs_step = b.step("docs", "Build the vaxis library docs"); const docs_obj = b.addObject(.{ diff --git a/examples/c/tty.c b/examples/c/tty.c new file mode 100644 index 0000000..b4e117b --- /dev/null +++ b/examples/c/tty.c @@ -0,0 +1,131 @@ +#define _XOPEN_SOURCE 600 +#define _DARWIN_C_SOURCE +#include +#include +#include + +#ifdef _WIN32 +int main(void) { + assert(vaxis_tty_notify_winsize(NULL, NULL, NULL) == VAXIS_ERR_UNSUPPORTED); + assert(vaxis_tty_remove_winsize_notify(NULL, NULL, NULL) == VAXIS_ERR_UNSUPPORTED); + return 0; +} +#else +#include +#include +#include +#include +#include +#include +#include +#include + +static int notifications[2]; + +/* A pipe wakes the event loop without sharing mutable state with dispatch. */ +static void resized(void *context) { + const char value = context ? *(const char *)context : '0'; + assert(write(notifications[1], &value, 1) == 1); +} + +static void other_resized(void *context) { + (void)context; + const char value = 'x'; + assert(write(notifications[1], &value, 1) == 1); +} + +static int ready(int timeout) { + struct pollfd fd = {notifications[0], POLLIN, 0}; + int rc; + do { rc = poll(&fd, 1, timeout); } while (rc < 0 && errno == EINTR); + assert(rc >= 0); + return rc; +} + +static void expect_resize(const char *expected) { + assert(kill(getpid(), SIGWINCH) == 0); + for (; *expected; ++expected) { + char value; + assert(ready(2000) == 1); + assert(read(notifications[0], &value, 1) == 1); + assert(value == *expected); + } + assert(ready(30) == 0); +} + +static void test_tty(void) { + vaxis_tty *tty = NULL; + char a = 'a', b = 'b'; + assert(pipe(notifications) == 0); + assert(vaxis_tty_new(&tty) == VAXIS_OK); + assert(vaxis_tty_notify_winsize(NULL, resized, &a) == VAXIS_ERR_INVALID); + assert(vaxis_tty_notify_winsize(tty, NULL, &a) == VAXIS_ERR_INVALID); + assert(vaxis_tty_remove_winsize_notify(NULL, resized, &a) == VAXIS_ERR_INVALID); + assert(vaxis_tty_remove_winsize_notify(tty, NULL, &a) == VAXIS_ERR_INVALID); + + assert(vaxis_tty_notify_winsize(tty, resized, &a) == VAXIS_OK); + assert(vaxis_tty_notify_winsize(tty, resized, &b) == VAXIS_OK); + assert(vaxis_tty_notify_winsize(tty, other_resized, &a) == VAXIS_OK); + assert(vaxis_tty_notify_winsize(tty, resized, NULL) == VAXIS_OK); + expect_resize("abx0"); + + /* Removal matches both callback and context, and absent removal is a no-op. */ + assert(vaxis_tty_remove_winsize_notify(tty, resized, &a) == VAXIS_OK); + assert(vaxis_tty_remove_winsize_notify(tty, resized, &a) == VAXIS_OK); + expect_resize("bx0"); + assert(vaxis_tty_remove_winsize_notify(tty, resized, &b) == VAXIS_OK); + assert(vaxis_tty_remove_winsize_notify(tty, other_resized, &a) == VAXIS_OK); + assert(vaxis_tty_remove_winsize_notify(tty, resized, NULL) == VAXIS_OK); + expect_resize(""); + + /* Re-register after removing the last callback, then exercise the limit, + * duplicates, removal of one duplicate, and reuse of the released slot. */ + for (int i = 0; i < 8; ++i) + assert(vaxis_tty_notify_winsize(tty, resized, &a) == VAXIS_OK); + assert(vaxis_tty_notify_winsize(tty, resized, &b) == VAXIS_ERR_OOM); + expect_resize("aaaaaaaa"); + assert(vaxis_tty_remove_winsize_notify(tty, resized, &a) == VAXIS_OK); + assert(vaxis_tty_notify_winsize(tty, resized, &b) == VAXIS_OK); + expect_resize("aaaaaaab"); + + /* Free with live subscriptions, then open again: no stale registrations. */ + vaxis_tty_free(tty); + expect_resize(""); + assert(vaxis_tty_new(&tty) == VAXIS_OK); + assert(vaxis_tty_notify_winsize(tty, resized, &b) == VAXIS_OK); + expect_resize("b"); + vaxis_tty_free(tty); + close(notifications[0]); + close(notifications[1]); +} + +int main(void) { + /* Give the child its own controlling terminal, even in headless CI. Keep + * the master open in the parent until the child has finished. */ + int master = posix_openpt(O_RDWR | O_NOCTTY); + assert(master >= 0); + assert(grantpt(master) == 0); + assert(unlockpt(master) == 0); + const char *slave_name = ptsname(master); + assert(slave_name); + pid_t child = fork(); + assert(child >= 0); + if (child == 0) { + alarm(15); + assert(setsid() >= 0); + int slave = open(slave_name, O_RDWR); + assert(slave >= 0); + assert(ioctl(slave, TIOCSCTTY, 0) == 0); + close(master); + test_tty(); + close(slave); + _exit(0); + } + int status; + assert(waitpid(child, &status, 0) == child); + close(master); + assert(WIFEXITED(status) && WEXITSTATUS(status) == 0); + puts("C TTY SIGWINCH checks passed"); + return 0; +} +#endif diff --git a/include/vaxis.h b/include/vaxis.h index 3315045..713689c 100644 --- a/include/vaxis.h +++ b/include/vaxis.h @@ -376,6 +376,28 @@ vaxis_result vaxis_tty_new_with_allocator(const vaxis_allocator *allocator, vaxis_tty **tty); void vaxis_tty_free(vaxis_tty *tty); vaxis_result vaxis_tty_winsize(vaxis_tty *tty, vaxis_winsize *size); +/* POSIX SIGWINCH fallback for terminals without in-band resize events. + * The callback runs on the TTY's dispatch thread, not in a signal handler. + * Use it to wake your event loop; query tty_winsize and resize/render there. + * Callbacks must not register/remove handlers or free the TTY (these wait for + * callback dispatch). Synchronize any data shared with your event loop. + * + * The callback must be non-NULL; context may be NULL. Keep context alive until + * all its registrations are removed or tty_free returns. Serialize + * registration/removal/free calls for a TTY. A NULL TTY or callback returns + * VAXIS_ERR_INVALID. At most 8 registrations share the POSIX dispatcher; + * exhaustion returns VAXIS_ERR_OOM. Duplicate registrations are allowed; + * removal removes the first matching (callback, context) pair, or does nothing + * if absent. tty_free clears all registrations and waits for dispatch to stop. + * Both functions return VAXIS_ERR_UNSUPPORTED on Windows, where resize events + * are delivered by tty_next_event instead. */ +typedef void (*vaxis_winsize_callback)(void *context); +vaxis_result vaxis_tty_notify_winsize(vaxis_tty *tty, + vaxis_winsize_callback callback, + void *context); +vaxis_result vaxis_tty_remove_winsize_notify(vaxis_tty *tty, + vaxis_winsize_callback callback, + void *context); vaxis_result vaxis_tty_read(vaxis_tty *tty, uint8_t *buffer, size_t capacity, size_t *length); /* Windows console input is record-based rather than a byte stream. On diff --git a/src/c_api.zig b/src/c_api.zig index 8b778de..e09b66b 100644 --- a/src/c_api.zig +++ b/src/c_api.zig @@ -260,7 +260,23 @@ const CTerminal = if (builtin.os.tag == .linux) struct { write_buf: [4096]u8, terminal: ?Terminal, } else opaque {}; -const CTty = struct { allocator: AllocatorState, threaded: std.Io.Threaded, buffer: [4096]u8, tty: ?Tty }; +const CWinsizeCallback = *const fn (?*anyopaque) callconv(.c) void; +const CWinsizeHandler = struct { + callback: CWinsizeCallback, + context: ?*anyopaque, + + fn notify(context: *anyopaque) void { + const self: *const CWinsizeHandler = @ptrCast(@alignCast(context)); + self.callback(self.context); + } +}; +const CTty = struct { + allocator: AllocatorState, + threaded: std.Io.Threaded, + buffer: [4096]u8, + tty: ?Tty, + winsize_handlers: [8]?CWinsizeHandler, +}; const CRuntime = struct { allocator: AllocatorState, tty: *CTty, env: std.process.Environ.Map, strings: GraphemeStore, vx: ?Vaxis }; fn sliceFrom(ptr: ?[*]const u8, len: usize) ?[]const u8 { @@ -755,6 +771,7 @@ pub fn tty_new_with_allocator(custom: ?*const CAllocator, out: ?*?*CTty) callcon const allocator = t.allocator.get(); t.threaded = std.Io.Threaded.init(allocator, .{}); t.tty = null; + t.winsize_handlers = @splat(null); t.tty = Tty.init(t.threaded.io(), &t.buffer) catch { t.threaded.deinit(); allocator.destroy(t); @@ -777,6 +794,35 @@ pub fn tty_winsize(tty: ?*CTty, out: ?*CWinsize) callconv(.c) Result { o.* = .{ .rows = ws.rows, .cols = ws.cols, .x_pixel = ws.x_pixel, .y_pixel = ws.y_pixel }; return .ok; } +pub fn tty_notify_winsize(tty: ?*CTty, callback: ?CWinsizeCallback, context: ?*anyopaque) callconv(.c) Result { + if (comptime builtin.os.tag == .windows) return .err_unsupported; + const t = tty orelse return .err_invalid; + const cb = callback orelse return .err_invalid; + for (&t.winsize_handlers) |*slot| { + if (slot.* != null) continue; + slot.* = .{ .callback = cb, .context = context }; + vaxis.tty.PosixTty.notifyWinsize(.{ .context = &slot.*.?, .callback = CWinsizeHandler.notify }) catch |err| { + slot.* = null; + return if (err == error.OutOfMemory) .err_oom else .err_io; + }; + return .ok; + } + return .err_oom; +} +pub fn tty_remove_winsize_notify(tty: ?*CTty, callback: ?CWinsizeCallback, context: ?*anyopaque) callconv(.c) Result { + if (comptime builtin.os.tag == .windows) return .err_unsupported; + const t = tty orelse return .err_invalid; + const cb = callback orelse return .err_invalid; + for (&t.winsize_handlers) |*slot| { + if (slot.*) |*handler| { + if (handler.callback != cb or handler.context != context) continue; + vaxis.tty.PosixTty.removeWinsize(.{ .context = handler, .callback = CWinsizeHandler.notify }); + slot.* = null; + return .ok; + } + } + return .ok; +} pub fn tty_read(tty: ?*CTty, buf: ?[*]u8, capacity: usize, length: ?*usize) callconv(.c) Result { if (comptime builtin.os.tag == .windows) return .err_unsupported; const t = tty orelse return .err_invalid; diff --git a/src/tty.zig b/src/tty.zig index 1f816f6..e485336 100644 --- a/src/tty.zig +++ b/src/tty.zig @@ -72,17 +72,8 @@ pub const PosixTty = struct { if (!handler_installed) { try startSignalThread(io); - var act = posix.Sigaction{ - .handler = .{ .handler = PosixTty.handleWinch }, - .mask = switch (builtin.os.tag) { - .macos => 0, - else => posix.sigemptyset(), - }, - .flags = 0, - }; - posix.sigaction(posix.SIG.WINCH, &act, null); handler_io = io; - handler_installed = true; + installSignalHandler(); } const self: PosixTty = .{ @@ -121,6 +112,19 @@ pub const PosixTty = struct { resetSignalHandlerLocked(); } + fn installSignalHandler() void { + var act = posix.Sigaction{ + .handler = .{ .handler = PosixTty.handleWinch }, + .mask = switch (builtin.os.tag) { + .macos => 0, + else => posix.sigemptyset(), + }, + .flags = 0, + }; + posix.sigaction(posix.SIG.WINCH, &act, null); + handler_installed = true; + } + fn resetSignalHandlerLocked() void { if (!handler_installed) return; handler_installed = false; @@ -149,6 +153,9 @@ pub const PosixTty = struct { try handler_mutex.lock(handler_io); defer handler_mutex.unlock(handler_io); if (handler_idx == handlers.len) return error.OutOfMemory; + // Removing the last subscriber restores SIG_DFL, but leaves the + // dispatch thread alive until deinit. Re-arm it for a new subscriber. + if (!handler_installed) installSignalHandler(); handlers[handler_idx] = handler; handler_idx += 1; } -- 2.51.2