Skip to main content

max / shop

Never count a frame we did not present A failed frame left shop blank and deaf for the rest of the run. render_now armed the wl_surface.frame callback and set awaiting_frame before drawing, so when the swapchain acquire bailed there was no commit to carry the request, no Done event to answer it, and awaiting_frame stayed true forever. Every later render was gated behind it: the window mapped, nothing drew, and keystrokes reached the shell whose echo never appeared. Request the callback on the commit that carries it, set awaiting_frame only when that commit happened, and leave dirty set otherwise so the next event retries. Outdated and Lost now reconfigure and retry once, which is what a compositor resizing us mid-frame produces.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-12 15:24 UTC
Signed with PGP, not checked
Commit: f3fe6d55eefb6836ade5d82c019dc9c7a28048b2
Parent: 69fa5a4
1 file changed, +45 insertions, -16 deletions
@@ -819,19 +819,47 @@
819 819 awaiting_frame: bool,
820 820 }
821 821
822 - /// Render one frame and request the next `wl_surface.frame` callback. All
823 - /// content changes (PTY reads, resize, focus toggle, activity-light phase)
824 - /// go through this — never called directly except by the coalescing paths
825 - /// (main-loop-after-events and CompositorHandler::frame).
822 + /// Render one frame. All content changes (PTY reads, resize, focus toggle,
823 + /// activity-light phase) go through this — never called directly except by
824 + /// the coalescing paths (main-loop-after-events and the frame callback).
825 + ///
826 + /// The frame callback that gates the next render is requested inside `draw`,
827 + /// on the commit that carries it, and `awaiting_frame` is set here only when
828 + /// that commit actually happened. The two have to agree: a callback counted as
829 + /// in flight but never sent is never answered, and since `awaiting_frame`
830 + /// blocks every later render, one failed frame would leave the window blank
831 + /// and unresponsive for the life of the process. A frame we could not present
832 + /// leaves `dirty` set instead, so the next event retries it.
826 833 fn render_now(app: &mut App) {
827 - // Register the frame callback BEFORE presenting so it becomes active on
828 - // the commit that queue.present issues. `awaiting_frame` blocks further
829 - // renders until the compositor tells us it's ready for another one.
830 - app.chrome.xdg_window.wl_surface().frame(&app.qh, ());
831 - app.awaiting_frame = true;
832 - app.dirty = false;
833 - if let Err(e) = draw(app) {
834 - warn!("render: {e:?}");
834 + match draw(app) {
835 + Ok(()) => {
836 + app.awaiting_frame = true;
837 + app.dirty = false;
838 + }
839 + Err(e) => warn!("render: {e:?}"),
840 + }
841 + }
842 +
843 + /// The swapchain image to draw into, reconfiguring once if the surface went
844 + /// stale under us.
845 + ///
846 + /// `Outdated` and `Lost` mean the swapchain no longer matches the surface,
847 + /// which a reconfigure fixes; a compositor that resizes us between our own
848 + /// configure and this acquire produces one on an ordinary frame. `Timeout`
849 + /// means the compositor is holding every buffer and has none for us yet, so
850 + /// there is nothing to fix and the frame is skipped.
851 + fn acquire(app: &mut App) -> anyhow::Result<wgpu::SurfaceTexture> {
852 + use wgpu::CurrentSurfaceTexture::{Lost, Outdated, Suboptimal, Success};
853 + match app.surface.get_current_texture() {
854 + Success(t) | Suboptimal(t) => Ok(t),
855 + Outdated | Lost => {
856 + app.surface.configure(&app.device, &app.surface_config);
857 + match app.surface.get_current_texture() {
858 + Success(t) | Suboptimal(t) => Ok(t),
859 + other => anyhow::bail!("surface acquire after reconfigure: {other:?}"),
860 + }
861 + }
862 + other => anyhow::bail!("surface acquire: {other:?}"),
835 863 }
836 864 }
837 865
@@ -839,10 +867,7 @@
839 867 // path; longer ones bury the arithmetic.
840 868 #[allow(clippy::many_single_char_names)]
841 869 fn draw(app: &mut App) -> anyhow::Result<()> {
842 - let frame = match app.surface.get_current_texture() {
843 - wgpu::CurrentSurfaceTexture::Success(t) | wgpu::CurrentSurfaceTexture::Suboptimal(t) => t,
844 - other => anyhow::bail!("surface acquire: {other:?}"),
845 - };
870 + let frame = acquire(app)?;
846 871 let view = frame
847 872 .texture
848 873 .create_view(&wgpu::TextureViewDescriptor::default());
@@ -1035,6 +1060,10 @@
1035 1060 );
1036 1061 }
1037 1062 app.queue.submit(std::iter::once(encoder.finish()));
1063 + // Requested before presenting so it rides the commit `present` issues.
1064 + // Both go down the same connection in the order they were made, so this
1065 + // is the last point at which the request still lands on this frame.
1066 + app.chrome.xdg_window.wl_surface().frame(&app.qh, ());
1038 1067 app.queue.present(frame);
1039 1068 Ok(())
1040 1069 }