src/frame_loop.zig
Ref: Size: 13.5 KiB
const std = @import("std");
const scale_tracker = @import("scale_tracker");
// Mirror of wayland.SurfaceState. Using a structural duplicate here instead of
// importing wayland.zig avoids a circular module dependency (wayland depends on
// frame_loop). The real wayland.SurfaceState embeds one of these by reference.
pub const SurfaceStateView = struct {
configured_ptr: *const bool,
suspended_ptr: *const bool,
tracker: *const scale_tracker.ScaleTracker,
pub fn visible(self: SurfaceStateView) bool {
return self.configured_ptr.*
and !self.suspended_ptr.*
and self.tracker.enteredCount() > 0;
}
};
// Opaque handle to a frame callback. Real path holds a *wl.Callback cast here;
// mock holds a usize cast here. Identity is pointer equality.
pub const CallbackToken = *const anyopaque;
pub const DisplayOps = struct {
ctx: *anyopaque,
// All fn pointers receive the same ctx so the caller can carry whatever
// concrete objects it needs (wl.Display, wl.Surface, test mock, etc).
flushFn: *const fn (*anyopaque) void,
prepareReadFn: *const fn (*anyopaque) bool,
readEventsFn: *const fn (*anyopaque) void,
dispatchPendingFn: *const fn (*anyopaque) void,
getFdFn: *const fn (*anyopaque) std.posix.fd_t,
// Requests a wl_surface.frame() and sets its done listener. Returns the
// token identifying the new callback. FrameLoop compares the token
// delivered by onFrameCallbackDone against pending_token.
requestFrameFn: *const fn (*anyopaque, *FrameLoop) anyerror!CallbackToken,
// Destroys a previously issued frame callback (for hide-path cleanup).
// Must tolerate being called on a token the compositor has already
// consumed — real path: wl.Callback.destroy is idempotent on the client.
destroyCallbackFn: *const fn (*anyopaque, CallbackToken) void,
};
pub const FrameLoop = struct {
ops: DisplayOps,
state: SurfaceStateView,
pending_token: ?CallbackToken = null,
armed: bool = true,
pub fn init(ops: DisplayOps, state: SurfaceStateView) FrameLoop {
return .{ .ops = ops, .state = state };
}
pub fn deinit(self: *FrameLoop) void {
if (self.pending_token) |t| self.ops.destroyCallbackFn(self.ops.ctx, t);
self.pending_token = null;
}
pub fn canRender(self: *const FrameLoop) bool {
return self.armed and self.state.visible();
}
pub fn commitRender(self: *FrameLoop) !void {
if (!self.canRender()) return error.CannotRender;
if (self.pending_token) |t| self.ops.destroyCallbackFn(self.ops.ctx, t);
self.pending_token = try self.ops.requestFrameFn(self.ops.ctx, self);
self.armed = false;
}
pub fn onFrameCallbackDone(self: *FrameLoop, token: CallbackToken) void {
if (self.pending_token == null or self.pending_token.? != token) return;
self.ops.destroyCallbackFn(self.ops.ctx, token);
self.pending_token = null;
self.armed = true;
}
pub fn onSurfaceHidden(self: *FrameLoop) void {
if (self.pending_token) |t| self.ops.destroyCallbackFn(self.ops.ctx, t);
self.pending_token = null;
// armed unchanged — canRender() is false while hidden regardless.
}
pub fn onSurfaceShown(self: *FrameLoop) void {
self.armed = true;
}
pub fn forceArm(self: *FrameLoop) void {
if (self.pending_token) |t| self.ops.destroyCallbackFn(self.ops.ctx, t);
self.pending_token = null;
self.armed = true;
}
/// Blocks on wl_fd + extra pollfds with `timeout_ms`, then reads + dispatches
/// any pending Wayland events. Safe to call in any state.
///
/// `extra.len` must not exceed 8. waystty's production callers never poll
/// more than wl_fd + one extra fd (pty); the cap of 8 is a 4x margin and
/// keeps the internal pollfd array stack-allocated. Increase requires a
/// review of the stack frame size in this function.
pub fn waitForWork(
self: *FrameLoop,
extra: []std.posix.pollfd,
timeout_ms: i32,
) !void {
self.ops.flushFn(self.ops.ctx);
const wl_fd = self.ops.getFdFn(self.ops.ctx);
// Build a small on-stack pollfd array: wl_fd + extras.
// Cap extras at 8 — waystty never polls more than pty+wl.
var all: [9]std.posix.pollfd = undefined;
all[0] = .{ .fd = wl_fd, .events = std.posix.POLL.IN, .revents = 0 };
std.debug.assert(extra.len <= all.len - 1);
for (extra, 0..) |fd, i| all[i + 1] = fd;
const total = 1 + extra.len;
_ = std.posix.poll(all[0..total], timeout_ms) catch {};
// Propagate only revents back into caller's extra slice; events/fd are
// caller-owned and must be preserved across calls.
for (extra, 0..) |*fd, i| fd.revents = all[i + 1].revents;
if (all[0].revents & std.posix.POLL.IN != 0) {
if (self.ops.prepareReadFn(self.ops.ctx)) {
self.ops.readEventsFn(self.ops.ctx);
}
}
self.ops.dispatchPendingFn(self.ops.ctx);
}
};
// ---------------------------------------------------------------------------
// Test-only mock below this line.
// ---------------------------------------------------------------------------
const Mock = struct {
next_token: usize = 1,
frame_requests: usize = 0,
callbacks_destroyed: usize = 0,
flushed: usize = 0,
dispatched: usize = 0,
// A fake fd that never becomes ready — tests call waitForWork with a
// 0ms timeout so poll returns immediately.
pipe_fds: [2]std.posix.fd_t = .{ -1, -1 },
fn init() !Mock {
const pipes = try std.posix.pipe();
return .{ .pipe_fds = pipes };
}
fn deinit(self: *Mock) void {
if (self.pipe_fds[0] >= 0) std.posix.close(self.pipe_fds[0]);
if (self.pipe_fds[1] >= 0) std.posix.close(self.pipe_fds[1]);
}
fn flushThunk(ctx: *anyopaque) void {
const self: *Mock = @ptrCast(@alignCast(ctx));
self.flushed += 1;
}
fn prepareReadThunk(_: *anyopaque) bool { return false; }
fn readEventsThunk(_: *anyopaque) void {}
fn dispatchPendingThunk(ctx: *anyopaque) void {
const self: *Mock = @ptrCast(@alignCast(ctx));
self.dispatched += 1;
}
fn getFdThunk(ctx: *anyopaque) std.posix.fd_t {
const self: *Mock = @ptrCast(@alignCast(ctx));
return self.pipe_fds[0];
}
fn requestFrameThunk(ctx: *anyopaque, _: *FrameLoop) anyerror!CallbackToken {
const self: *Mock = @ptrCast(@alignCast(ctx));
const tok: CallbackToken = @ptrFromInt(self.next_token);
self.next_token += 1;
self.frame_requests += 1;
return tok;
}
fn destroyCallbackThunk(ctx: *anyopaque, _: CallbackToken) void {
const self: *Mock = @ptrCast(@alignCast(ctx));
self.callbacks_destroyed += 1;
}
fn ops(self: *Mock) DisplayOps {
return .{
.ctx = self,
.flushFn = flushThunk,
.prepareReadFn = prepareReadThunk,
.readEventsFn = readEventsThunk,
.dispatchPendingFn = dispatchPendingThunk,
.getFdFn = getFdThunk,
.requestFrameFn = requestFrameThunk,
.destroyCallbackFn = destroyCallbackThunk,
};
}
};
const TestState = struct {
configured: bool = true,
suspended: bool = false,
tracker: scale_tracker.ScaleTracker,
fn init(alloc: std.mem.Allocator) !TestState {
var t = scale_tracker.ScaleTracker.init(alloc);
try t.addOutput(1);
try t.enterOutput(1);
return .{ .tracker = t };
}
fn deinit(self: *TestState) void {
self.tracker.deinit();
}
fn view(self: *const TestState) SurfaceStateView {
return .{
.configured_ptr = &self.configured,
.suspended_ptr = &self.suspended,
.tracker = &self.tracker,
};
}
};
test "initial state: armed and can render when visible" {
var mock = try Mock.init();
defer mock.deinit();
var ts = try TestState.init(std.testing.allocator);
defer ts.deinit();
var loop = FrameLoop.init(mock.ops(), ts.view());
defer loop.deinit();
try std.testing.expect(loop.armed);
try std.testing.expect(loop.canRender());
try std.testing.expectEqual(@as(?CallbackToken, null), loop.pending_token);
}
test "commitRender requests a frame and disarms" {
var mock = try Mock.init();
defer mock.deinit();
var ts = try TestState.init(std.testing.allocator);
defer ts.deinit();
var loop = FrameLoop.init(mock.ops(), ts.view());
defer loop.deinit();
try loop.commitRender();
try std.testing.expect(!loop.armed);
try std.testing.expect(!loop.canRender());
try std.testing.expect(loop.pending_token != null);
try std.testing.expectEqual(@as(usize, 1), mock.frame_requests);
}
test "onFrameCallbackDone re-arms when token matches" {
var mock = try Mock.init();
defer mock.deinit();
var ts = try TestState.init(std.testing.allocator);
defer ts.deinit();
var loop = FrameLoop.init(mock.ops(), ts.view());
defer loop.deinit();
try loop.commitRender();
const tok = loop.pending_token.?;
loop.onFrameCallbackDone(tok);
try std.testing.expect(loop.armed);
try std.testing.expectEqual(@as(?CallbackToken, null), loop.pending_token);
try std.testing.expectEqual(@as(usize, 1), mock.callbacks_destroyed);
}
test "onFrameCallbackDone ignores stale token" {
var mock = try Mock.init();
defer mock.deinit();
var ts = try TestState.init(std.testing.allocator);
defer ts.deinit();
var loop = FrameLoop.init(mock.ops(), ts.view());
defer loop.deinit();
try loop.commitRender();
const stale_tok: CallbackToken = @ptrFromInt(0xDEAD);
loop.onFrameCallbackDone(stale_tok);
try std.testing.expect(!loop.armed);
try std.testing.expect(loop.pending_token != null);
}
test "onSurfaceHidden destroys pending callback and leaves armed unchanged" {
var mock = try Mock.init();
defer mock.deinit();
var ts = try TestState.init(std.testing.allocator);
defer ts.deinit();
var loop = FrameLoop.init(mock.ops(), ts.view());
defer loop.deinit();
try loop.commitRender();
const armed_before = loop.armed;
loop.onSurfaceHidden();
try std.testing.expectEqual(armed_before, loop.armed); // false, unchanged
try std.testing.expectEqual(@as(?CallbackToken, null), loop.pending_token);
try std.testing.expectEqual(@as(usize, 1), mock.callbacks_destroyed);
}
test "onSurfaceShown force-arms" {
var mock = try Mock.init();
defer mock.deinit();
var ts = try TestState.init(std.testing.allocator);
defer ts.deinit();
var loop = FrameLoop.init(mock.ops(), ts.view());
defer loop.deinit();
try loop.commitRender();
try std.testing.expect(!loop.armed);
loop.onSurfaceShown();
try std.testing.expect(loop.armed);
}
test "canRender requires both armed and visible" {
var mock = try Mock.init();
defer mock.deinit();
var ts = try TestState.init(std.testing.allocator);
defer ts.deinit();
var loop = FrameLoop.init(mock.ops(), ts.view());
defer loop.deinit();
try std.testing.expect(loop.canRender()); // armed + visible
ts.suspended = true;
try std.testing.expect(!loop.canRender()); // visibility gate
ts.suspended = false;
try loop.commitRender();
try std.testing.expect(!loop.canRender()); // armed gate
}
test "forceArm recovers without a callback (OUT_OF_DATE path)" {
var mock = try Mock.init();
defer mock.deinit();
var ts = try TestState.init(std.testing.allocator);
defer ts.deinit();
var loop = FrameLoop.init(mock.ops(), ts.view());
defer loop.deinit();
try loop.commitRender();
try std.testing.expect(!loop.armed);
loop.forceArm();
try std.testing.expect(loop.armed);
try std.testing.expectEqual(@as(?CallbackToken, null), loop.pending_token);
}
test "deinit destroys any pending callback" {
var mock = try Mock.init();
defer mock.deinit();
var ts = try TestState.init(std.testing.allocator);
defer ts.deinit();
{
var loop = FrameLoop.init(mock.ops(), ts.view());
defer loop.deinit();
try loop.commitRender();
}
try std.testing.expectEqual(@as(usize, 1), mock.callbacks_destroyed);
}
test "hidden-freeze regression: pty flood while hidden never blocks" {
var mock = try Mock.init();
defer mock.deinit();
var ts = try TestState.init(std.testing.allocator);
defer ts.deinit();
var loop = FrameLoop.init(mock.ops(), ts.view());
defer loop.deinit();
// Initial render.
try loop.commitRender();
const first_tok = loop.pending_token.?;
loop.onFrameCallbackDone(first_tok);
// Hide the surface: tracker leaves all outputs.
ts.tracker.leaveOutput(1);
loop.onSurfaceHidden();
try std.testing.expect(!loop.canRender());
// Simulate 100 iterations of "pty wrote more data, we want to render".
// Under the gate, canRender is false every iteration and we never call
// commitRender — so no Vulkan work, no blocking.
var i: usize = 0;
while (i < 100) : (i += 1) {
try std.testing.expect(!loop.canRender());
}
// Show again: tracker re-enters an output.
try ts.tracker.enterOutput(1);
loop.onSurfaceShown();
try std.testing.expect(loop.canRender());
// Normal render resumes.
try loop.commitRender();
try std.testing.expect(loop.pending_token != null);
const tok = loop.pending_token.?;
loop.onFrameCallbackDone(tok);
try std.testing.expect(loop.armed);
}