a73x

185553de

Migrate main terminal loop to FrameLoop; fix hidden-workspace freeze

a73x   2026-04-16 11:14

Splits the scale/resize handler into observeResize (non-Vulkan, always
runs) and applyPendingResize/Scale (Vulkan, gated on canRender). All
Vulkan calls — deviceWaitIdle, recreateSwapchain, rebuildFaceForScale,
drawCells — now happen only when the surface is visible. OUT_OF_DATE
path calls forceArm() to retry without a callback.

Wires FrameLoop hide/show hooks into surfaceListener, xdgToplevelListener,
and xdgSurfaceListener: each now samples visible() before and after
event handling and invokes onSurfaceHidden/onSurfaceShown on transitions.

Fixes: waystty hanging when its window is moved to a hidden sway
workspace.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

diff --git a/src/main.zig b/src/main.zig
index 6ed0a50..057f19f 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -2,6 +2,7 @@ const std = @import("std");
const vt = @import("vt");
const pty = @import("pty");
const wayland_client = @import("wayland-client");
const frame_loop_mod = @import("frame_loop");
const renderer = @import("renderer");
const font = @import("font");
const config = @import("config");
@@ -229,10 +230,17 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
    var frame_ring = FrameTimingRing{};
    installSigusr1Handler();

    // === frame loop ===
    var frame_loop = frame_loop_mod.FrameLoop.init(
        window.displayOps(conn.display),
        window.surfaceStateView(),
    );
    defer frame_loop.deinit();
    window.frame_loop = &frame_loop;
    defer window.frame_loop = null;

    // === main loop ===
    const wl_fd = conn.display.getFd();
    var pollfds = [_]std.posix.pollfd{
        .{ .fd = wl_fd,       .events = std.posix.POLL.IN, .revents = 0 },
    var pollfds_extra = [_]std.posix.pollfd{
        .{ .fd = p.master_fd, .events = std.posix.POLL.IN, .revents = 0 },
    };

@@ -242,24 +250,16 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
    var last_window_h = window.height;
    var last_scale: i32 = geom.buffer_scale;
    var render_pending = true;
    var resize_pending = false;
    var scale_pending = false;

    while (!window.should_close and p.isChildAlive()) {
        // Flush any pending wayland requests
        _ = conn.display.flush();

        const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs());
        _ = std.posix.poll(&pollfds, computePollTimeoutMs(repeat_timeout_ms, render_pending)) catch {};

        // Wayland events: prepare_read / read_events / dispatch_pending
        if (pollfds[0].revents & std.posix.POLL.IN != 0) {
            if (conn.display.prepareRead()) {
                _ = conn.display.readEvents();
            }
        }
        _ = conn.display.dispatchPending();
        const timeout = computePollTimeoutMs(repeat_timeout_ms, render_pending and frame_loop.canRender());
        try frame_loop.waitForWork(&pollfds_extra, timeout);

        // PTY output
        if (pollfds[1].revents & std.posix.POLL.IN != 0) {
        if (pollfds_extra[0].revents & std.posix.POLL.IN != 0) {
            while (true) {
                const n = p.read(&read_buf) catch |err| switch (err) {
                    error.WouldBlock => break,
@@ -314,8 +314,24 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
        }
        keyboard.event_queue.clearRetainingCapacity();

        // observeResize — detect scale/size changes. No Vulkan here; this runs
        // every loop iteration so transitions are noticed even while hidden,
        // and applyPendingScale/Resize below catches up once visible again.
        const current_scale = window.bufferScale();
        if (current_scale != last_scale) {
            scale_pending = true;
            render_pending = true;
        }
        if (window.width != last_window_w or window.height != last_window_h) {
            resize_pending = true;
            render_pending = true;
        }

        if (!shouldRenderFrame(render_pending, false, false)) continue;
        if (!frame_loop.canRender()) continue; // hidden — no Vulkan at all

        // applyPendingScale — Vulkan work, gated on canRender().
        if (scale_pending) {
            _ = try ctx.vkd.deviceWaitIdle(ctx.device);

            geom = try rebuildFaceForScale(
@@ -324,7 +340,7 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
                font_lookup.path,
                font_lookup.index,
                font_size,
                current_scale,
                window.bufferScale(),
            );
            cell_w = geom.cell_w_px;
            cell_h = geom.cell_h_px;
@@ -341,11 +357,12 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
            const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
            try ctx.recreateSwapchain(buf_w, buf_h);

            last_scale = current_scale;
            render_pending = true;
            last_scale = geom.buffer_scale;
            scale_pending = false;
        }

        if (window.width != last_window_w or window.height != last_window_h) {
        // applyPendingResize — Vulkan work, gated on canRender().
        if (resize_pending) {
            // Grid is sized in surface coordinates. cell_w/cell_h are in buffer
            // pixels, so divide by buffer_scale to get surface-pixel cell dims.
            const surf_cell_w = cell_w / @as(u32, @intCast(geom.buffer_scale));
@@ -376,11 +393,9 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
            }
            last_window_w = window.width;
            last_window_h = window.height;
            render_pending = true;
            resize_pending = false;
        }

        if (!shouldRenderFrame(render_pending, false, false)) continue;

        var frame_timing: FrameTiming = .{};

        // === render ===
@@ -577,6 +592,7 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
                const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
                const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
                try ctx.recreateSwapchain(buf_w, buf_h);
                frame_loop.forceArm();
                render_pending = true;
                continue;
            },
@@ -592,6 +608,7 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
        }

        clearConsumedDirtyFlags(&term.render_state.dirty, dirty_rows, refresh_plan);
        try frame_loop.commitRender();
        render_pending = false;
    }

diff --git a/src/wayland.zig b/src/wayland.zig
index e31224c..7afebf7 100644
--- a/src/wayland.zig
+++ b/src/wayland.zig
@@ -625,6 +625,7 @@ pub const Window = struct {
    width: u32 = 800,
    height: u32 = 600,
    display_adapter: DisplayAdapter = undefined,
    frame_loop: ?*FrameLoop = null,

    pub fn deinit(self: *Window) void {
        self.xdg_toplevel.destroy();
@@ -1137,29 +1138,43 @@ fn wmBaseListener(wm_base: *xdg.WmBase, event: xdg.WmBase.Event, _: *xdg.WmBase)
}

fn xdgSurfaceListener(surface: *xdg.Surface, event: xdg.Surface.Event, window: *Window) void {
    const was_visible = window.state.visible();
    switch (event) {
        .configure => |cfg| {
            surface.ackConfigure(cfg.serial);
            window.state.configured = true;
        },
    }
    const now_visible = window.state.visible();
    if (window.frame_loop) |loop| {
        if (was_visible and !now_visible) loop.onSurfaceHidden();
        if (!was_visible and now_visible) loop.onSurfaceShown();
    }
}

fn surfaceListener(_: *wl.Surface, event: wl.Surface.Event, window: *Window) void {
    const was_visible = window.state.visible();
    switch (event) {
        .enter => |e| {
            const wl_out = e.output orelse return;
            window.handleSurfaceEnter(wl_out);
            window.scale_generation += 1;
            if (e.output) |wl_out| {
                window.handleSurfaceEnter(wl_out);
                window.scale_generation += 1;
            }
        },
        .leave => |e| {
            const wl_out = e.output orelse return;
            window.handleSurfaceLeave(wl_out);
            window.scale_generation += 1;
            if (e.output) |wl_out| {
                window.handleSurfaceLeave(wl_out);
                window.scale_generation += 1;
            }
        },
        .preferred_buffer_scale => {},
        .preferred_buffer_transform => {},
    }
    const now_visible = window.state.visible();
    if (window.frame_loop) |loop| {
        if (was_visible and !now_visible) loop.onSurfaceHidden();
        if (!was_visible and now_visible) loop.onSurfaceShown();
    }
}

fn applyToplevelStates(state: *SurfaceState, states: []const u32) void {
@@ -1174,6 +1189,7 @@ fn applyToplevelStates(state: *SurfaceState, states: []const u32) void {
}

fn xdgToplevelListener(_: *xdg.Toplevel, event: xdg.Toplevel.Event, window: *Window) void {
    const was_visible = window.state.visible();
    switch (event) {
        .configure => |cfg| {
            if (cfg.width > 0) window.width = @intCast(cfg.width);
@@ -1184,6 +1200,11 @@ fn xdgToplevelListener(_: *xdg.Toplevel, event: xdg.Toplevel.Event, window: *Win
        .configure_bounds => {},
        .wm_capabilities => {},
    }
    const now_visible = window.state.visible();
    if (window.frame_loop) |loop| {
        if (was_visible and !now_visible) loop.onSurfaceHidden();
        if (!was_visible and now_visible) loop.onSurfaceShown();
    }
}

fn registryListener(