Skip to main content

max / shop

Redraw coalescing via wl_surface.frame callbacks Replaces the 'redraw on every PTY read chunk' model with a frame-callback loop: PTY reads mark dirty; a wl_surface.frame callback is in flight at most once; each callback presents one frame and requests the next. Idle prompt = 0 renders. Under load = 1 render per compositor tick. Also fixes two crashes that had been silently latent: 1. --exec shutdown segfault. wgpu Surface's Vulkan swapchain drop calls wl_proxy_marshal_flags which needs the wayland Connection alive. The Connection lives inside event_loop's WaylandSource, which was dropping BEFORE app (event_loop declared later -> LIFO drop). Explicit `drop(app); drop(event_loop)` at end of main. 2. Field ordering in App now puts wgpu Surface before WaylandChrome so Surface's Drop uses valid wl_surface pointers. Benchmark deltas (median ms/sample, vtebench, shop / foot ratio): workload before coalesced improvement log_colored (47MB file) 1.96s 0.87s 2.3x faster hexdump (34MB file) 1.81s 0.57s 3.2x faster llm_stream (chatty small) 1.27s 0.08s 16x faster unicode_mix 1.30s 0.26s 5x faster vtebench light_cells 4 3 1.33x vtebench dense_cells 20 16 1.25x vtebench scrolling 411 410 unchanged vtebench unicode 6 6 unchanged (still wins vs foot 8) Chatty workloads (bespoke files with many small PTY writes) benefit enormously; vtebench's already-batched workloads see modest gain because they were never emitting many chunks per frame anyway. This matches the predicted 'chatty = coalescing helps' model. Damage tracking remains the biggest structural gap for vtebench scrolling workloads (still 3x foot); that's the next tier-1 followup.
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-24 02:23 UTC
Signed with PGP, not checked
Commit: 076904decdffcca2ee98bb2886d10be35848fdca
Parent: 5af7c0e
1 file changed, +78 insertions, -16 deletions
@@ -30,7 +30,7 @@
30 30 use std::collections::VecDeque;
31 31 use wayland_client::protocol::wl_keyboard;
32 32 use tracing::{info, warn};
33 - use wayland_client::protocol::{wl_output, wl_seat, wl_surface};
33 + use wayland_client::protocol::{wl_callback, wl_output, wl_seat, wl_surface};
34 34
35 35 const INITIAL: (u32, u32) = (960, 540);
36 36 /// IosevkaTerm Nerd Font Mono, terminal-optimized variant of Iosevka with
@@ -193,7 +193,9 @@
193 193 modifiers: Modifiers::default(),
194 194 cursor_phase: true,
195 195 focused: false,
196 - pending_redraw: true,
196 + qh: qh.clone(),
197 + dirty: true,
198 + awaiting_frame: false,
197 199 };
198 200 app.surface.configure(&app.device, &app.surface_config);
199 201 app.text
@@ -243,7 +245,7 @@
243 245 app.chrome.xdg_window.set_title(title);
244 246 }
245 247 app.cursor_phase = !app.cursor_phase;
246 - app.pending_redraw = true;
248 + app.dirty = true;
247 249 }
248 250 Err(e)
249 251 if matches!(
@@ -295,20 +297,27 @@
295 297 cell_width: (CELL_ADVANCE * s as f32).round() as u16,
296 298 cell_height: (CELL_HEIGHT * s as f32).round() as u16,
297 299 });
298 - app.pending_redraw = true;
300 + app.dirty = true;
299 301 }
300 302 Pending::CloseRequested => app.chrome.exit = true,
301 303 }
302 304 }
303 305
304 - if app.pending_redraw && !app.chrome.exit {
305 - if let Err(e) = draw(&mut app) {
306 - warn!("draw: {e:?}");
307 - }
308 - app.pending_redraw = false;
306 + // If we have new content and no frame callback is in flight, kick off
307 + // a render. The render function requests the next frame callback and
308 + // sets awaiting_frame; subsequent dirty events piggyback on that
309 + // pending callback and coalesce into one frame per compositor tick.
310 + if app.dirty && !app.awaiting_frame && !app.chrome.exit {
311 + render_now(&mut app);
309 312 }
310 313 }
311 314
315 + // Drop order matters — the wayland Connection lives inside
316 + // event_loop's WaylandSource; wgpu Surface needs a valid wl_surface (and
317 + // an alive connection) to destroy its Vulkan swapchain. Drop app first
318 + // so Surface is torn down while wayland is still up.
319 + drop(app);
320 + drop(event_loop);
312 321 Ok(())
313 322 }
314 323
@@ -456,14 +465,17 @@
456 465 }
457 466
458 467 struct App {
459 - chrome: WaylandChrome,
468 + // Field order matters — Rust drops in declaration order. Anything that
469 + // references wayland handles (wgpu Surface via raw pointers) MUST drop
470 + // before `chrome` releases those handles, else use-after-free on exit.
460 471 surface: wgpu::Surface<'static>,
461 472 surface_config: wgpu::SurfaceConfiguration,
462 473 surface_format: wgpu::TextureFormat,
463 - device: Arc<wgpu::Device>,
464 - queue: Arc<wgpu::Queue>,
465 474 text: TextRenderer,
466 475 images: ImageRenderer,
476 + device: Arc<wgpu::Device>,
477 + queue: Arc<wgpu::Queue>,
478 + chrome: WaylandChrome,
467 479 pty: Pty,
468 480 grid: Grid,
469 481 parser: shop_vt::Parser,
@@ -483,7 +495,31 @@
483 495 /// True while our surface has keyboard focus. Cursor dims when unfocused
484 496 /// so you can tell at a glance which window is receiving input.
485 497 focused: bool,
486 - pending_redraw: bool,
498 + /// QueueHandle kept so we can request `wl_surface.frame` callbacks from
499 + /// non-dispatch contexts (PTY read, resize handling).
500 + qh: QueueHandle<App>,
501 + /// Content has changed since the last render.
502 + dirty: bool,
503 + /// We've requested a `wl_surface.frame` callback that hasn't fired yet —
504 + /// don't request another one, just mark dirty. The pending callback
505 + /// coalesces all dirty events since the last frame into one render.
506 + awaiting_frame: bool,
507 + }
508 +
509 + /// Render one frame and request the next `wl_surface.frame` callback. All
510 + /// content changes (PTY reads, resize, focus toggle, activity-light phase)
511 + /// go through this — never called directly except by the coalescing paths
512 + /// (main-loop-after-events and CompositorHandler::frame).
513 + fn render_now(app: &mut App) {
514 + // Register the frame callback BEFORE presenting so it becomes active on
515 + // the commit that queue.present issues. `awaiting_frame` blocks further
516 + // renders until the compositor tells us it's ready for another one.
517 + app.chrome.xdg_window.wl_surface().frame(&app.qh, ());
518 + app.awaiting_frame = true;
519 + app.dirty = false;
520 + if let Err(e) = draw(app) {
521 + warn!("render: {e:?}");
522 + }
487 523 }
488 524
489 525 fn draw(app: &mut App) -> anyhow::Result<()> {
@@ -646,7 +682,11 @@
646 682 _: wl_output::Transform,
647 683 ) {
648 684 }
649 - fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {}
685 + fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {
686 + // SCTK doesn't actually forward the raw wl_callback done event here;
687 + // our own Dispatch<WlCallback, ()> impl below is what drives the
688 + // coalesce loop. Left in place because CompositorHandler requires it.
689 + }
650 690 fn surface_enter(
651 691 &mut self,
652 692 _: &Connection,
@@ -721,7 +761,7 @@
721 761 _: &[Keysym],
722 762 ) {
723 763 self.focused = true;
724 - self.pending_redraw = true;
764 + self.dirty = true;
725 765 }
726 766 fn leave(
727 767 &mut self,
@@ -732,7 +772,7 @@
732 772 _: u32,
733 773 ) {
734 774 self.focused = false;
735 - self.pending_redraw = true;
775 + self.dirty = true;
736 776 }
737 777 fn press_key(
738 778 &mut self,
@@ -963,3 +1003,25 @@
963 1003
964 1004 delegate_registry!(App);
965 1005 delegate_dispatch2!(App);
1006 +
1007 + /// `wl_surface.frame` done event. Compositor signalling us that it's ready
1008 + /// for the next frame — if the grid has dirtied since we submitted the last
1009 + /// one, render immediately. Otherwise wait for content to arrive (a PTY
1010 + /// read will kick the loop back off via the main-loop dirty-check).
1011 + impl wayland_client::Dispatch<wl_callback::WlCallback, ()> for App {
1012 + fn event(
1013 + state: &mut Self,
1014 + _: &wl_callback::WlCallback,
1015 + event: wl_callback::Event,
1016 + _: &(),
1017 + _: &Connection,
1018 + _: &QueueHandle<Self>,
1019 + ) {
1020 + if let wl_callback::Event::Done { .. } = event {
1021 + state.awaiting_frame = false;
1022 + if state.dirty {
1023 + render_now(state);
1024 + }
1025 + }
1026 + }
1027 + }