git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commitf9ccb5f149f727458e91c3f7b626a147d874aa62
parentab18ef02c6
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-25 15:48
fix(vk): a lost window surface ends the session instead of panicking

VkRenderer::new expect()ed every surface query, so a window requested over
a dead display connection took the whole process down -- cce-cloud's
daemon at logout, `No surface formats: ERROR_SURFACE_LOST_KHR`. Mesa's
Wayland WSI answers the surface queries with a roundtrip, so they are
the first thing to find out the compositor is gone.

- vk::SurfaceLost: the error for a failed surface call.
- VkRenderer::try_new returns it (surface creation, present support,
  formats, swapchain build); `new` stays as the panicking wrapper for
  tools that own their window outright.
- The runner ends the session as ConnectionLost on it: reconnect if the
  compositor is still there, clean exit if not.
- Mid-session, a swapchain rebuild / acquire / present reporting the
  surface lost latches surface_lost() and skips draws, logging once.
- attach_surface returns it; the menu popup closes on it.

Verified in a shadow: wl_surface opened, compositor killed, then
constructing -- `new` panics as before, `try_new` returns the error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                    |  16 ++++++
 src/backend/menu_popup.rs    |  19 ++++++--
 src/backend/window_runner.rs |  20 ++++++--
 src/vk/core.rs               |  90 ++++++++++++++++++++++++++--------
 src/vk/mod.rs                |   2 +-
 src/vk/renderer.rs           | 114 +++++++++++++++++++++++++++++++++++--------
 6 files changed, 213 insertions(+), 48 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index ecc14f5..b2ea686 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -100,6 +100,22 @@ Key methods (see the trait def around `window_runner.rs:1450`):
 The frame loop is demand-driven (single `redraw` dirty bool, gated by a Wayland frame-callback
 vsync) — it idles correctly when nothing changes. Don't add per-frame I/O to the render hot path.
 
+### A lost surface ends the session; it does not panic (since 2026-09-25)
+
+`VkRenderer::try_new` returns `vk::SurfaceLost` when the display connection under the
+surface is already dead — Mesa's Wayland WSI answers the surface queries with a roundtrip,
+so `vkGetPhysicalDeviceSurfaceFormatsKHR` is the first call to find out, with
+`ERROR_SURFACE_LOST_KHR`. The runner ends that session as `ConnectionLost`: a reconnect if
+the compositor is still there, a clean exit if it is not. Mid-session, a swapchain
+rebuild, acquire or present that reports the surface lost latches `surface_lost()` and
+skips draws (one WARN) until the event loop sees the dead connection itself; the menu
+popup just closes. `VkRenderer::new` is the panicking wrapper, kept for tools that own
+their window outright (designer's `vk-smoke`) — **a client that can outlive its
+compositor calls `try_new`**. Found as cce-cloud's daemon panicking at logout on
+`No surface formats`: it had outlived a compositor and asked for a window over its
+connection. Reproduced by opening a `wl_surface`, killing the shadow compositor, then
+constructing: `new` panics, `try_new` returns the error.
+
 ### `renderer_init` — GPU handles do not survive a reconnect
 
 A connection is one **session**. A Wayland transport cannot be repaired once it breaks,
diff --git a/src/backend/menu_popup.rs b/src/backend/menu_popup.rs
index 21ca19c..b995dfb 100644
--- a/src/backend/menu_popup.rs
+++ b/src/backend/menu_popup.rs
@@ -242,14 +242,27 @@ impl<A: Application> PopupHandler for EngineState<A> {
         let (_, pw, ph) = Self::buffer_geometry(self.scale_factor, w, h);
         let surface_ptr = mp.popup.wl_surface().id().as_ptr() as *mut std::ffi::c_void;
         let display_ptr = self.display_ptr as *mut std::ffi::c_void;
-        match self.menu_renderer.as_mut() {
-            Some(r) if r.has_surface() => r.resize(pw, ph),
+        let attached = match self.menu_renderer.as_mut() {
+            Some(r) if r.has_surface() => {
+                r.resize(pw, ph);
+                Ok(())
+            }
             Some(r) => unsafe { r.attach_surface(display_ptr, surface_ptr, pw, ph) },
             None => {
                 let t = std::time::Instant::now();
-                self.menu_renderer = Some(unsafe { VkRenderer::new(display_ptr, surface_ptr, pw, ph, 0.0) });
+                let made = unsafe { VkRenderer::try_new(display_ptr, surface_ptr, pw, ph, 0.0) };
                 log::debug!("[menu_popup] renderer created in {:?}", t.elapsed());
+                made.map(|r| self.menu_renderer = Some(r))
             }
+        };
+        // A lost surface is the connection dying under the menu; the window's
+        // own event loop ends the session on it. Just drop the menu.
+        if let Err(lost) = attached {
+            log::warn!("[menu_popup] {lost}; closing the menu");
+            context_menu::hide();
+            self.close_menu_popup();
+            self.redraw = true;
+            return;
         }
         self.redraw = true;
         self.render_menu_popup();
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 69f5586..797d3ab 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -3974,7 +3974,16 @@ pub struct EngineState<A: Application> {
 }
 
 impl<A: Application> EngineState<A> {
-    pub fn init_gpu(&mut self, conn: &Connection, width_logical: f32, height_logical: f32) {
+    /// Build the window's renderer. A [`SurfaceLost`](crate::vk::SurfaceLost)
+    /// means the connection under the surface is already dead; the session
+    /// ends as a lost connection, which reconnects if the compositor is still
+    /// there and exits if it is not.
+    pub fn init_gpu(
+        &mut self,
+        conn: &Connection,
+        width_logical: f32,
+        height_logical: f32,
+    ) -> Result<(), crate::vk::SurfaceLost> {
         let s = self.scale_factor as f32;
         let pw = (width_logical * s) as u32;
         let ph = (height_logical * s) as u32;
@@ -3987,8 +3996,7 @@ impl<A: Application> EngineState<A> {
 
         let load_system_fonts = self.inner.as_ref().map_or(false, |a| a.load_system_fonts());
         // Corner radius 0: runner apps tessellate their own rounded corners.
-        let renderer =
-            unsafe { VkRenderer::new(display_ptr, surface_ptr, pw, ph, 0.0) };
+        let renderer = unsafe { VkRenderer::try_new(display_ptr, surface_ptr, pw, ph, 0.0) }?;
         self.font_system = Some(if load_system_fonts {
             crate::create_font_system_with_system_fonts()
         } else {
@@ -3997,6 +4005,7 @@ impl<A: Application> EngineState<A> {
         self.renderer = Some(renderer);
         self.logical_width = width_logical;
         self.logical_height = height_logical;
+        Ok(())
     }
 
     /// Buffer scale and physical extent for a logical size under the current
@@ -6015,7 +6024,10 @@ fn run_session<'l, A: Application>(
     // larger than the window frame on every side; geometry/input-region are
     // published per-resize.
     let rim = 2.0 * engine_state.inner.as_ref().unwrap().overflow_margin() as f32;
-    engine_state.init_gpu(&conn, settings.width as f32 + rim, settings.height as f32 + rim);
+    if let Err(lost) = engine_state.init_gpu(&conn, settings.width as f32 + rim, settings.height as f32 + rim) {
+        log::error!("[window_runner] cannot create the renderer, ending session: {lost}");
+        return (engine_state.inner.take(), SessionEnd::ConnectionLost);
+    }
     engine_state
         .inner
         .as_mut()
diff --git a/src/vk/core.rs b/src/vk/core.rs
index 0640939..17901e0 100644
--- a/src/vk/core.rs
+++ b/src/vk/core.rs
@@ -190,6 +190,33 @@ fn shared_instance() -> &'static SharedInstance {
     })
 }
 
+/// A Vulkan call on a window surface failed — in practice
+/// `ERROR_SURFACE_LOST_KHR`: the display connection under the surface is dead,
+/// because the compositor exited (a logout) or the transport broke. Mesa's
+/// Wayland WSI answers the surface queries with a roundtrip, so they are the
+/// first thing to find out.
+///
+/// That is the client's SESSION ending, not a renderer bug, so the window
+/// constructors and the swapchain path report it instead of panicking, and the
+/// caller ends the session the way it would for any other lost connection.
+/// Until 2026-09-25 each of these calls `expect`ed, and a daemon asked for a
+/// window over a dead connection took the whole process down at logout
+/// (cce-cloud, `No surface formats: ERROR_SURFACE_LOST_KHR`).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct SurfaceLost {
+    /// The Vulkan entry point that failed.
+    pub call: &'static str,
+    pub result: vk::Result,
+}
+
+impl std::fmt::Display for SurfaceLost {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "window surface lost ({}: {})", self.call, self.result)
+    }
+}
+
+impl std::error::Error for SurfaceLost {}
+
 impl VkCore {
     /// A core bound to a Wayland surface: the returned `vk::SurfaceKHR` is
     /// created from the raw pointers and the chosen device supports presenting
@@ -198,12 +225,16 @@ impl VkCore {
     /// # Safety
     /// `display_ptr` and `surface_ptr` must be live `wl_display` / `wl_surface`
     /// pointers that outlive the core and everything created from it.
+    ///
+    /// Fails with [`SurfaceLost`] when the surface cannot be created or no
+    /// device can be asked whether it presents to it — a dead display
+    /// connection, not a driver fault.
     pub unsafe fn new_for_wayland_surface(
         display_ptr: *mut c_void,
         surface_ptr: *mut c_void,
-    ) -> (Self, vk::SurfaceKHR) {
-        let (core, surface) = Self::new_inner(Some((display_ptr, surface_ptr)));
-        (core, surface.expect("surface requested but not created"))
+    ) -> Result<(Self, vk::SurfaceKHR), SurfaceLost> {
+        let (core, surface) = Self::new_inner(Some((display_ptr, surface_ptr)))?;
+        Ok((core, surface.expect("surface requested but not created")))
     }
 
     /// A new `VkSurfaceKHR` on another Wayland surface, from this core's
@@ -217,7 +248,7 @@ impl VkCore {
         &self,
         display_ptr: *mut c_void,
         surface_ptr: *mut c_void,
-    ) -> vk::SurfaceKHR {
+    ) -> Result<vk::SurfaceKHR, SurfaceLost> {
         let shared = shared_instance();
         let wayland_loader = ash::khr::wayland_surface::Instance::new(&shared.entry, &self.instance);
         let surface = wayland_loader
@@ -227,7 +258,7 @@ impl VkCore {
                     .surface(surface_ptr),
                 None,
             )
-            .expect("Failed to create Wayland surface");
+            .map_err(|result| SurfaceLost { call: "vkCreateWaylandSurfaceKHR", result })?;
         // The device was chosen for the FIRST surface's present support; a
         // later surface on the same display is presentable from the same
         // family on every driver this runs on, but say so if not.
@@ -238,18 +269,19 @@ impl VkCore {
         {
             log::warn!("[vk] queue family {} cannot present to the re-attached surface", self.queue_family);
         }
-        surface
+        Ok(surface)
     }
 
     /// A windowless core: no surface extensions, any graphics-capable device.
     /// For offscreen rendering (thumbnails, previews) and compute.
     pub fn new_headless() -> Self {
-        unsafe { Self::new_inner(None).0 }
+        // Only a surface can be lost, and there is none here.
+        unsafe { Self::new_inner(None).expect("headless core cannot lose a surface").0 }
     }
 
     unsafe fn new_inner(
         wayland: Option<(*mut c_void, *mut c_void)>,
-    ) -> (Self, Option<vk::SurfaceKHR>) {
+    ) -> Result<(Self, Option<vk::SurfaceKHR>), SurfaceLost> {
         // CCE_VK_DEVICE: "integrated" (the default), "discrete", or a device
         // name substring. An explicit request also lifts a session-wide ICD
         // pin (VK_DRIVER_FILES / VK_ICD_FILENAMES) for THIS process — the
@@ -275,23 +307,32 @@ impl VkCore {
         // Instance-level loader; only usable when VK_KHR_surface was enabled.
         let surface_loader = ash::khr::surface::Instance::new(entry, &instance);
 
-        let surface = wayland.map(|(display_ptr, surface_ptr)| {
-            let wayland_loader = ash::khr::wayland_surface::Instance::new(entry, &instance);
-            wayland_loader
-                .create_wayland_surface(
-                    &vk::WaylandSurfaceCreateInfoKHR::default()
-                        .display(display_ptr)
-                        .surface(surface_ptr),
-                    None,
+        let surface = match wayland {
+            Some((display_ptr, surface_ptr)) => {
+                let wayland_loader = ash::khr::wayland_surface::Instance::new(entry, &instance);
+                Some(
+                    wayland_loader
+                        .create_wayland_surface(
+                            &vk::WaylandSurfaceCreateInfoKHR::default()
+                                .display(display_ptr)
+                                .surface(surface_ptr),
+                            None,
+                        )
+                        .map_err(|result| SurfaceLost { call: "vkCreateWaylandSurfaceKHR", result })?,
                 )
-                .expect("Failed to create Wayland surface")
-        });
+            }
+            None => None,
+        };
 
         // Physical device + queue family: graphics, plus present support when
         // a surface exists. Prefer integrated (the toolkit's LowPower default)
         // unless CCE_VK_DEVICE says otherwise; an unsatisfiable preference
         // falls back to the default order rather than failing.
         let mut candidates: Vec<(vk::PhysicalDevice, u32, i32)> = Vec::new();
+        // A present-support query that FAILED, as opposed to answering no:
+        // on a dead display connection every device fails it, and "no
+        // suitable device" would then misreport a lost surface.
+        let mut support_error: Option<vk::Result> = None;
         for pd in instance
             .enumerate_physical_devices()
             .expect("No Vulkan physical devices")
@@ -302,7 +343,10 @@ impl VkCore {
                 let present = match surface {
                     Some(surface) => surface_loader
                         .get_physical_device_surface_support(pd, i as u32, surface)
-                        .unwrap_or(false),
+                        .unwrap_or_else(|e| {
+                            support_error = Some(e);
+                            false
+                        }),
                     None => true,
                 };
                 (graphics && present).then_some(i as u32)
@@ -342,6 +386,10 @@ impl VkCore {
             }
         }
         candidates.sort_by_key(|&(_, _, rank)| rank);
+        if let (true, Some(surface), Some(result)) = (candidates.is_empty(), surface, support_error) {
+            surface_loader.destroy_surface(surface, None);
+            return Err(SurfaceLost { call: "vkGetPhysicalDeviceSurfaceSupportKHR", result });
+        }
         let (physical_device, queue_family, _) = *candidates
             .first()
             .expect("No suitable Vulkan device found");
@@ -473,7 +521,7 @@ impl VkCore {
             )
             .expect("Failed to create command pool");
 
-        (
+        Ok((
             VkCore {
                 allocator: Some(allocator),
                 command_pool,
@@ -489,7 +537,7 @@ impl VkCore {
                 max_line_width,
             },
             surface,
-        )
+        ))
     }
 }
 
diff --git a/src/vk/mod.rs b/src/vk/mod.rs
index e63ec0b..93154f2 100644
--- a/src/vk/mod.rs
+++ b/src/vk/mod.rs
@@ -47,7 +47,7 @@ mod scene;
 mod text;
 
 pub use compute::{workgroups, BindKind, Binding, ComputeDevice, Kernel, MAX_BINDINGS};
-pub use core::VkCore;
+pub use core::{SurfaceLost, VkCore};
 pub use image::{
     free_image, recycle_buffer, renderer_epoch, update_pixels, upload_pixels, upload_rgba, ImageQuad,
     PixelFormat,
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index c4a28c6..219375a 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -17,6 +17,7 @@ use gpu_allocator::MemoryLocation;
 
 use crate::engine::Vertex;
 
+use super::core::SurfaceLost;
 use super::image::{ImageQuad, ImageStage};
 use super::rt::{RtCamera, RtMaterial, RtStage, RtTriangle};
 use super::scene::{MeshId, SceneDraw, SceneStage, Vertex3D};
@@ -279,6 +280,11 @@ pub struct VkRenderer {
     swapchain_dirty: bool,
     present_mode: vk::PresentModeKHR,
     present_debug_count: u64,
+    /// Set when a surface call reports the surface lost (see [`SurfaceLost`]):
+    /// the display connection is dead, so every later draw is skipped until
+    /// a new surface is attached, rather than re-failing (and re-logging)
+    /// each frame while the caller's event loop finds out for itself.
+    surface_lost: bool,
 
     // Declared last: everything above must be destroyed before the device/
     // instance the core tears down in its own Drop.
@@ -440,9 +446,12 @@ pub(crate) fn flipped_viewport(extent: vk::Extent2D) -> vk::Viewport {
 
 
 impl VkRenderer {
+    /// [`try_new`](Self::try_new) for a caller that owns its window outright
+    /// and has no session to end — a smoke test. Panics on a lost surface;
+    /// a client that can outlive its compositor wants `try_new`.
+    ///
     /// # Safety
-    /// `display_ptr` and `surface_ptr` must be live `wl_display` / `wl_surface`
-    /// pointers that outlive the renderer (same contract as `WgpuAdapter::new`).
+    /// Same contract as [`try_new`](Self::try_new).
     pub unsafe fn new(
         display_ptr: *mut c_void,
         surface_ptr: *mut c_void,
@@ -450,9 +459,28 @@ impl VkRenderer {
         height: u32,
         corner_radius_px: f32,
     ) -> Self {
+        Self::try_new(display_ptr, surface_ptr, width, height, corner_radius_px)
+            .unwrap_or_else(|e| panic!("{e}"))
+    }
+
+    /// A renderer presenting to `surface_ptr`, or [`SurfaceLost`] when the
+    /// display connection under it is already dead — which is what a window
+    /// requested as the compositor goes away gets. The caller should treat
+    /// that as its connection ending (the runner does), not retry here.
+    ///
+    /// # Safety
+    /// `display_ptr` and `surface_ptr` must be live `wl_display` / `wl_surface`
+    /// pointers that outlive the renderer.
+    pub unsafe fn try_new(
+        display_ptr: *mut c_void,
+        surface_ptr: *mut c_void,
+        width: u32,
+        height: u32,
+        corner_radius_px: f32,
+    ) -> Result<Self, SurfaceLost> {
         let t_new = std::time::Instant::now();
         let (mut core, surface) =
-            super::core::VkCore::new_for_wayland_surface(display_ptr, surface_ptr);
+            super::core::VkCore::new_for_wayland_surface(display_ptr, surface_ptr)?;
         log::debug!("[timing] VkCore::new_for_wayland_surface: {:?}", t_new.elapsed());
         let t_rest = std::time::Instant::now();
         // Locals over the core for the setup below (methods use self.core.*).
@@ -462,13 +490,21 @@ impl VkRenderer {
         let physical_device = core.physical_device;
         let min_uniform_align = core.min_uniform_align;
         let surface_loader = core.surface_loader.clone();
-        let allocator = core.allocator.as_mut().unwrap();
 
         // Surface format: prefer sRGB (wgpu's get_default_config sorts sRGB first,
-        // so this matches the colors the app renders today).
-        let formats = surface_loader
+        // so this matches the colors the app renders today). The first query
+        // that talks to the compositor, so the one a dead connection fails.
+        let formats = match surface_loader
             .get_physical_device_surface_formats(physical_device, surface)
-            .expect("No surface formats");
+        {
+            Ok(formats) if !formats.is_empty() => formats,
+            Ok(_) => panic!("surface offers no formats"),
+            Err(result) => {
+                surface_loader.destroy_surface(surface, None);
+                return Err(SurfaceLost { call: "vkGetPhysicalDeviceSurfaceFormatsKHR", result });
+            }
+        };
+        let allocator = core.allocator.as_mut().unwrap();
         let surface_format = formats
             .iter()
             .copied()
@@ -890,17 +926,20 @@ impl VkRenderer {
             swapchain_dirty: false,
             present_mode: vk::PresentModeKHR::FIFO,
             present_debug_count: 0,
+            surface_lost: false,
             core,
         };
         log::debug!("[timing] VkRenderer pipelines/stages: {:?}", t_rest.elapsed());
         let t_swap = std::time::Instant::now();
-        renderer.create_swapchain();
+        // On failure `renderer` drops here, and its Drop tears down everything
+        // built so far, surface included.
+        renderer.create_swapchain()?;
         renderer.write_window_info();
         // The swapchain may have settled on a different extent than requested;
         // keep the backdrop targets in lockstep.
         renderer.sync_backdrop_targets();
         log::debug!("[timing] swapchain setup: {:?}", t_swap.elapsed());
-        renderer
+        Ok(renderer)
     }
 
     /// The window-clip corner radius as the shaders consume it: the nominal
@@ -969,12 +1008,19 @@ impl VkRenderer {
         }
     }
 
-    fn create_swapchain(&mut self) {
+    /// Build the swapchain for the current surface. Only the calls that ask
+    /// the surface can fail with [`SurfaceLost`]; everything after them is
+    /// device work and still panics as the bug it would be. A failure leaves
+    /// the previous swapchain (if any) in `self.swapchain` for Drop.
+    fn create_swapchain(&mut self) -> Result<(), SurfaceLost> {
         unsafe {
             let caps = self.core
                 .surface_loader
                 .get_physical_device_surface_capabilities(self.core.physical_device, self.surface)
-                .expect("Failed to query surface capabilities");
+                .map_err(|result| SurfaceLost {
+                    call: "vkGetPhysicalDeviceSurfaceCapabilitiesKHR",
+                    result,
+                })?;
 
             // Wayland reports "extent defined by the swapchain" (u32::MAX); use the
             // size the configure events gave us.
@@ -1055,7 +1101,7 @@ impl VkRenderer {
                         .old_swapchain(old_swapchain),
                     None,
                 )
-                .expect("Failed to create swapchain");
+                .map_err(|result| SurfaceLost { call: "vkCreateSwapchainKHR", result })?;
             if old_swapchain != vk::SwapchainKHR::null() {
                 self.swapchain_loader.destroy_swapchain(old_swapchain, None);
             }
@@ -1084,7 +1130,7 @@ impl VkRenderer {
             let images = self
                 .swapchain_loader
                 .get_swapchain_images(self.swapchain)
-                .expect("Failed to get swapchain images");
+                .map_err(|result| SurfaceLost { call: "vkGetSwapchainImagesKHR", result })?;
             self.swapchain_images = images.clone();
             let subresource_range = vk::ImageSubresourceRange::default()
                 .aspect_mask(vk::ImageAspectFlags::COLOR)
@@ -1126,16 +1172,34 @@ impl VkRenderer {
                 );
             }
         }
+        Ok(())
     }
 
-    fn recreate_swapchain(&mut self) {
+    fn recreate_swapchain(&mut self) -> Result<(), SurfaceLost> {
         unsafe {
             let _ = self.core.device.device_wait_idle();
         }
         self.destroy_swapchain_resources();
-        self.create_swapchain();
+        self.create_swapchain()?;
         self.write_window_info();
         self.sync_backdrop_targets();
+        Ok(())
+    }
+
+    /// Latch a lost surface: say so once, then skip draws until a new
+    /// surface is attached.
+    fn mark_surface_lost(&mut self, lost: SurfaceLost) {
+        if !self.surface_lost {
+            log::warn!("[vk] {lost}; skipping draws until the connection is replaced");
+        }
+        self.surface_lost = true;
+    }
+
+    /// Whether the surface has been reported lost (see [`SurfaceLost`]). A
+    /// caller with its own event loop can end its session on this rather
+    /// than wait for the connection error.
+    pub fn surface_lost(&self) -> bool {
+        self.surface_lost
     }
 
     /// Suspend the UI pass, copy the swapchain's frame-so-far into the blur
@@ -1483,13 +1547,15 @@ impl VkRenderer {
         surface_ptr: *mut c_void,
         width: u32,
         height: u32,
-    ) {
+    ) -> Result<(), SurfaceLost> {
         if self.surface != vk::SurfaceKHR::null() {
             self.detach_surface();
         }
-        self.surface = self.core.create_wayland_surface(display_ptr, surface_ptr);
+        self.surface = self.core.create_wayland_surface(display_ptr, surface_ptr)?;
+        self.surface_lost = false;
         self.resize(width, height);
         self.swapchain_dirty = true;
+        Ok(())
     }
 
     /// Whether a surface is attached — false between
@@ -1567,12 +1633,15 @@ impl VkRenderer {
     /// top. Returns false if the frame was skipped (swapchain rebuild); the
     /// caller just draws again next tick.
     pub fn draw_frame_2d(&mut self, frame2d: Frame2D<'_>) -> bool {
-        if self.surface == vk::SurfaceKHR::null() {
+        if self.surface == vk::SurfaceKHR::null() || self.surface_lost {
             return false;
         }
         if self.swapchain_dirty {
             self.swapchain_dirty = false;
-            self.recreate_swapchain();
+            if let Err(lost) = self.recreate_swapchain() {
+                self.mark_surface_lost(lost);
+                return false;
+            }
             if self.swapchain_dirty {
                 // The rebuild couldn't honor the requested extent (surface
                 // caps disagree, e.g. mid suspend/resume) — presenting it
@@ -1610,6 +1679,10 @@ impl VkRenderer {
                     self.swapchain_dirty = true;
                     return false;
                 }
+                Err(result @ vk::Result::ERROR_SURFACE_LOST_KHR) => {
+                    self.mark_surface_lost(SurfaceLost { call: "vkAcquireNextImageKHR", result });
+                    return false;
+                }
                 Err(e) => {
                     log::error!("acquire_next_image failed: {e:?}");
                     return false;
@@ -2101,6 +2174,9 @@ impl VkRenderer {
                 Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => {
                     self.swapchain_dirty = true;
                 }
+                Err(result @ vk::Result::ERROR_SURFACE_LOST_KHR) => {
+                    self.mark_surface_lost(SurfaceLost { call: "vkQueuePresentKHR", result });
+                }
                 Err(e) => log::error!("queue_present failed: {e:?}"),
             }