a73x

185553de

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

a73x   2026-04-16 11:14

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

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>

src/main.zig
Old New
@@ -2,6 +2,7 @@ const std = @import("std");
2 const vt = @import("vt"); 2 const vt = @import("vt");
3 const pty = @import("pty"); 3 const pty = @import("pty");
4 const wayland_client = @import("wayland-client"); 4 const wayland_client = @import("wayland-client");
5 const frame_loop_mod = @import("frame_loop");
5 const renderer = @import("renderer"); 6 const renderer = @import("renderer");
6 const font = @import("font"); 7 const font = @import("font");
7 const config = @import("config"); 8 const config = @import("config");
@@ -229,10 +230,17 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
229 var frame_ring = FrameTimingRing{}; 230 var frame_ring = FrameTimingRing{};
230 installSigusr1Handler(); 231 installSigusr1Handler();
231 232
233 // === frame loop ===
234 var frame_loop = frame_loop_mod.FrameLoop.init(
235 window.displayOps(conn.display),
236 window.surfaceStateView(),
237 );
238 defer frame_loop.deinit();
239 window.frame_loop = &frame_loop;
240 defer window.frame_loop = null;
241
232 // === main loop === 242 // === main loop ===
233 const wl_fd = conn.display.getFd(); 243 var pollfds_extra = [_]std.posix.pollfd{
234 var pollfds = [_]std.posix.pollfd{
235 .{ .fd = wl_fd, .events = std.posix.POLL.IN, .revents = 0 },
236 .{ .fd = p.master_fd, .events = std.posix.POLL.IN, .revents = 0 }, 244 .{ .fd = p.master_fd, .events = std.posix.POLL.IN, .revents = 0 },
237 }; 245 };
238 246
@@ -242,24 +250,16 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
242 var last_window_h = window.height; 250 var last_window_h = window.height;
243 var last_scale: i32 = geom.buffer_scale; 251 var last_scale: i32 = geom.buffer_scale;
244 var render_pending = true; 252 var render_pending = true;
253 var resize_pending = false;
254 var scale_pending = false;
245 255
246 while (!window.should_close and p.isChildAlive()) { 256 while (!window.should_close and p.isChildAlive()) {
247 // Flush any pending wayland requests
248 _ = conn.display.flush();
249
250 const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs()); 257 const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs());
251 _ = std.posix.poll(&pollfds, computePollTimeoutMs(repeat_timeout_ms, render_pending)) catch {}; 258 const timeout = computePollTimeoutMs(repeat_timeout_ms, render_pending and frame_loop.canRender());
252 259 try frame_loop.waitForWork(&pollfds_extra, timeout);
253 // Wayland events: prepare_read / read_events / dispatch_pending
254 if (pollfds[0].revents & std.posix.POLL.IN != 0) {
255 if (conn.display.prepareRead()) {
256 _ = conn.display.readEvents();
257 }
258 }
259 _ = conn.display.dispatchPending();
260 260
261 // PTY output 261 // PTY output
262 if (pollfds[1].revents & std.posix.POLL.IN != 0) { 262 if (pollfds_extra[0].revents & std.posix.POLL.IN != 0) {
263 while (true) { 263 while (true) {
264 const n = p.read(&read_buf) catch |err| switch (err) { 264 const n = p.read(&read_buf) catch |err| switch (err) {
265 error.WouldBlock => break, 265 error.WouldBlock => break,
@@ -314,8 +314,24 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
314 } 314 }
315 keyboard.event_queue.clearRetainingCapacity(); 315 keyboard.event_queue.clearRetainingCapacity();
316 316
317 // observeResize — detect scale/size changes. No Vulkan here; this runs
318 // every loop iteration so transitions are noticed even while hidden,
319 // and applyPendingScale/Resize below catches up once visible again.
317 const current_scale = window.bufferScale(); 320 const current_scale = window.bufferScale();
318 if (current_scale != last_scale) { 321 if (current_scale != last_scale) {
322 scale_pending = true;
323 render_pending = true;
324 }
325 if (window.width != last_window_w or window.height != last_window_h) {
326 resize_pending = true;
327 render_pending = true;
328 }
329
330 if (!shouldRenderFrame(render_pending, false, false)) continue;
331 if (!frame_loop.canRender()) continue; // hidden — no Vulkan at all
332
333 // applyPendingScale — Vulkan work, gated on canRender().
334 if (scale_pending) {
319 _ = try ctx.vkd.deviceWaitIdle(ctx.device); 335 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
320 336
321 geom = try rebuildFaceForScale( 337 geom = try rebuildFaceForScale(
@@ -324,7 +340,7 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
324 font_lookup.path, 340 font_lookup.path,
325 font_lookup.index, 341 font_lookup.index,
326 font_size, 342 font_size,
327 current_scale, 343 window.bufferScale(),
328 ); 344 );
329 cell_w = geom.cell_w_px; 345 cell_w = geom.cell_w_px;
330 cell_h = geom.cell_h_px; 346 cell_h = geom.cell_h_px;
@@ -341,11 +357,12 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
341 const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale)); 357 const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
342 try ctx.recreateSwapchain(buf_w, buf_h); 358 try ctx.recreateSwapchain(buf_w, buf_h);
343 359
344 last_scale = current_scale; 360 last_scale = geom.buffer_scale;
345 render_pending = true; 361 scale_pending = false;
346 } 362 }
347 363
348 if (window.width != last_window_w or window.height != last_window_h) { 364 // applyPendingResize — Vulkan work, gated on canRender().
365 if (resize_pending) {
349 // Grid is sized in surface coordinates. cell_w/cell_h are in buffer 366 // Grid is sized in surface coordinates. cell_w/cell_h are in buffer
350 // pixels, so divide by buffer_scale to get surface-pixel cell dims. 367 // pixels, so divide by buffer_scale to get surface-pixel cell dims.
351 const surf_cell_w = cell_w / @as(u32, @intCast(geom.buffer_scale)); 368 const surf_cell_w = cell_w / @as(u32, @intCast(geom.buffer_scale));
@@ -376,11 +393,9 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
376 } 393 }
377 last_window_w = window.width; 394 last_window_w = window.width;
378 last_window_h = window.height; 395 last_window_h = window.height;
379 render_pending = true; 396 resize_pending = false;
380 } 397 }
381 398
382 if (!shouldRenderFrame(render_pending, false, false)) continue;
383
384 var frame_timing: FrameTiming = .{}; 399 var frame_timing: FrameTiming = .{};
385 400
386 // === render === 401 // === render ===
@@ -577,6 +592,7 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
577 const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale)); 592 const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
578 const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale)); 593 const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
579 try ctx.recreateSwapchain(buf_w, buf_h); 594 try ctx.recreateSwapchain(buf_w, buf_h);
595 frame_loop.forceArm();
580 render_pending = true; 596 render_pending = true;
581 continue; 597 continue;
582 }, 598 },
@@ -592,6 +608,7 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
592 } 608 }
593 609
594 clearConsumedDirtyFlags(&term.render_state.dirty, dirty_rows, refresh_plan); 610 clearConsumedDirtyFlags(&term.render_state.dirty, dirty_rows, refresh_plan);
611 try frame_loop.commitRender();
595 render_pending = false; 612 render_pending = false;
596 } 613 }
597 614
src/wayland.zig
Old New
@@ -625,6 +625,7 @@ pub const Window = struct {
625 width: u32 = 800, 625 width: u32 = 800,
626 height: u32 = 600, 626 height: u32 = 600,
627 display_adapter: DisplayAdapter = undefined, 627 display_adapter: DisplayAdapter = undefined,
628 frame_loop: ?*FrameLoop = null,
628 629
629 pub fn deinit(self: *Window) void { 630 pub fn deinit(self: *Window) void {
630 self.xdg_toplevel.destroy(); 631 self.xdg_toplevel.destroy();
@@ -1137,29 +1138,43 @@ fn wmBaseListener(wm_base: *xdg.WmBase, event: xdg.WmBase.Event, _: *xdg.WmBase)
1137 } 1138 }
1138 1139
1139 fn xdgSurfaceListener(surface: *xdg.Surface, event: xdg.Surface.Event, window: *Window) void { 1140 fn xdgSurfaceListener(surface: *xdg.Surface, event: xdg.Surface.Event, window: *Window) void {
1141 const was_visible = window.state.visible();
1140 switch (event) { 1142 switch (event) {
1141 .configure => |cfg| { 1143 .configure => |cfg| {
1142 surface.ackConfigure(cfg.serial); 1144 surface.ackConfigure(cfg.serial);
1143 window.state.configured = true; 1145 window.state.configured = true;
1144 }, 1146 },
1145 } 1147 }
1148 const now_visible = window.state.visible();
1149 if (window.frame_loop) |loop| {
1150 if (was_visible and !now_visible) loop.onSurfaceHidden();
1151 if (!was_visible and now_visible) loop.onSurfaceShown();
1152 }
1146 } 1153 }
1147 1154
1148 fn surfaceListener(_: *wl.Surface, event: wl.Surface.Event, window: *Window) void { 1155 fn surfaceListener(_: *wl.Surface, event: wl.Surface.Event, window: *Window) void {
1156 const was_visible = window.state.visible();
1149 switch (event) { 1157 switch (event) {
1150 .enter => |e| { 1158 .enter => |e| {
1151 const wl_out = e.output orelse return; 1159 if (e.output) |wl_out| {
1152 window.handleSurfaceEnter(wl_out); 1160 window.handleSurfaceEnter(wl_out);
1153 window.scale_generation += 1; 1161 window.scale_generation += 1;
1162 }
1154 }, 1163 },
1155 .leave => |e| { 1164 .leave => |e| {
1156 const wl_out = e.output orelse return; 1165 if (e.output) |wl_out| {
1157 window.handleSurfaceLeave(wl_out); 1166 window.handleSurfaceLeave(wl_out);
1158 window.scale_generation += 1; 1167 window.scale_generation += 1;
1168 }
1159 }, 1169 },
1160 .preferred_buffer_scale => {}, 1170 .preferred_buffer_scale => {},
1161 .preferred_buffer_transform => {}, 1171 .preferred_buffer_transform => {},
1162 } 1172 }
1173 const now_visible = window.state.visible();
1174 if (window.frame_loop) |loop| {
1175 if (was_visible and !now_visible) loop.onSurfaceHidden();
1176 if (!was_visible and now_visible) loop.onSurfaceShown();
1177 }
1163 } 1178 }
1164 1179
1165 fn applyToplevelStates(state: *SurfaceState, states: []const u32) void { 1180 fn applyToplevelStates(state: *SurfaceState, states: []const u32) void {
@@ -1174,6 +1189,7 @@ fn applyToplevelStates(state: *SurfaceState, states: []const u32) void {
1174 } 1189 }
1175 1190
1176 fn xdgToplevelListener(_: *xdg.Toplevel, event: xdg.Toplevel.Event, window: *Window) void { 1191 fn xdgToplevelListener(_: *xdg.Toplevel, event: xdg.Toplevel.Event, window: *Window) void {
1192 const was_visible = window.state.visible();
1177 switch (event) { 1193 switch (event) {
1178 .configure => |cfg| { 1194 .configure => |cfg| {
1179 if (cfg.width > 0) window.width = @intCast(cfg.width); 1195 if (cfg.width > 0) window.width = @intCast(cfg.width);
@@ -1184,6 +1200,11 @@ fn xdgToplevelListener(_: *xdg.Toplevel, event: xdg.Toplevel.Event, window: *Win
1184 .configure_bounds => {}, 1200 .configure_bounds => {},
1185 .wm_capabilities => {}, 1201 .wm_capabilities => {},
1186 } 1202 }
1203 const now_visible = window.state.visible();
1204 if (window.frame_loop) |loop| {
1205 if (was_visible and !now_visible) loop.onSurfaceHidden();
1206 if (!was_visible and now_visible) loop.onSurfaceShown();
1207 }
1187 } 1208 }
1188 1209
1189 fn registryListener( 1210 fn registryListener(