cloud storage client
git clone https://git.lucas.co/cce-cloud.git
Keep one renderer for every daemon popup
Each popup built a fresh VkRenderer, and its pipelines were 70-100 ms of a
~100 ms popup on an idle machine and 250-500 ms under load: nearly all of
the time between the desktop menu's right-click and the menu. The daemon
now keeps the renderer like it keeps the font system, building it at
startup on a never-mapped scratch surface and moving it from popup to
popup with cce-ui's attach_surface/detach_surface (detached before the
State drops its wl_surface). Row icons, still uploaded per popup, are
freed at close, since the renderer now outlives them.
Measured in a shadow under load average ~20 against a control built from
HEAD at the same cce-ui pin: menu popups 220-590 ms -> 14-43 ms to first
frame, the launcher 300-1000 ms -> 75-580 ms. Screenshots of the menu and
the launcher on a third reopen are pixel-identical to the control's, and
daemon RSS is flat over 36 menu and 60 launcher opens.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 26 ++++++++++++++----
src/main.rs | 91 +++++++++++++++++++++++++++++++++++++++++++++++++------------
2 files changed, 95 insertions(+), 22 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 340fbb8..eb35e92 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -128,9 +128,11 @@ bound with plain `get_keyboard`, and a held Backspace deleted one character.
an absolute path, missing, or a generic name several apps share — see
`cce-icons/hicolor/README.md`. Overrides named after the `Icon=` value need
nothing here: the user's data dir is the theme search's first base dir.
- Icons are uploaded per popup on purpose (`cce_ui::icon::upload_themed` caches
- the decode, not the image id) because `Drop` destroys this app's `VkRenderer`
- between popups and an id cached across them would name freed GPU resources.
+ Icons are uploaded per popup (`cce_ui::icon::upload_themed` caches the
+ decode, not the image id), and the daemon frees them with
+ `cce_ui::vk::free_image` when the popup closes: the renderer outlives the
+ popup (see "One renderer for every popup" below), so an upload nobody freed
+ would stay on the GPU for the daemon's life.
Apps is also the one **tabbed** mode: the list carries an *Apps* page and a
*System* page of DE verbs (`SYSTEM_COMMANDS` — window-manager actions through
`ccectl`, plus session/power commands), and **Tab / Shift+Tab step between
@@ -210,13 +212,27 @@ list across to `Frame2D::images`: images ride a separate pipeline from the verte
batches, and that return value was dropped (with `images: &[]` hardcoded) until
2026-08-16, which made `PaintCtx::image` a silent no-op *in this app only* while
it worked in every engine-runner client. `State::renderer` is an `Option`
-solely so `Drop` can tear the swapchain down before destroying the `wl_surface` (daemon
-mode churns one `State` per popup). It still reuses cce-ui pieces à la carte: the
+so the daemon can take it back when a popup closes, and so `Drop` can tear the
+swapchain down before destroying the `wl_surface` in standalone mode. It still reuses cce-ui pieces à la carte: the
narrow widget traits (`Layout`/`Paint`/`Input` via `Adapted<T>`), the scene paint walk
(`append_widget_text`) for text extraction, `color`/`layout`/`scale` getters, and the
`zcce_window_manager_v1` protocol. Follow existing cce-ui conventions when touching
widget code, but don't try to "port" this app onto the engine runner.
+### One renderer for every popup
+
+The daemon keeps one `VkRenderer` for its whole life, like the font system:
+`State::new` takes it and moves it onto the popup's new `wl_surface` with
+`VkRenderer::attach_surface`, and when the popup closes the daemon takes it back
+and calls `detach_surface` BEFORE the `State` drops (the drop destroys the
+`wl_surface`, and a swapchain must not outlive it). `run_daemon` builds it at
+startup on a scratch surface that is never mapped, so the first popup attaches
+too. Until 2026-10-05 every popup built a new renderer, and its pipelines were
+70-100 ms of a ~100 ms popup on an idle machine and several hundred under
+load; a reopen is now one swapchain, and a menu popup is ready in ~2 ms and on
+screen in 15-40 ms. Anything a popup uploads to it (icons) must be freed at
+close for the same reason. Standalone mode still builds its own.
+
Surface choice: an XDG toplevel flagged as popup via the cce window-management
protocol when the compositor global is present and no `-x/-y` was given; otherwise a
layer-shell **Overlay** surface with exclusive keyboard. The window continuously
diff --git a/src/main.rs b/src/main.rs
index fb30ea2..5abf2b5 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1747,6 +1747,7 @@ impl State {
json_layout_config: Option<JsonLayoutConfig>,
parent_app_id: Option<String>,
fonts: Option<(FontSystem, SwashCache)>,
+ renderer: Option<VkRenderer>,
) -> Result<
(Self, Option<cce_ui::protocol::cce_window_management_v1::zcce_toplevel_v1::ZcceToplevelV1>),
cce_ui::vk::SurfaceLost,
@@ -1890,18 +1891,27 @@ impl State {
// Raw-Vulkan renderer on the same display/surface pointers the wgpu
// stack used. Corner radius 0: the window background tessellates its own
// rounded corners (rounded_rect_vertices_corners).
+ //
+ // The daemon hands in the renderer it kept from the last popup, and
+ // moving it onto this surface costs one swapchain. Building a new one
+ // costs a device and every pipeline: 70-100 ms of a ~100 ms popup on
+ // an idle machine, several hundred under load. Both fail only when
+ // the connection is already dead under the surface.
let t = std::time::Instant::now();
- // Fails only when the connection is already dead under the surface.
- let renderer = unsafe {
- VkRenderer::try_new(
- conn.backend().display_id().as_ptr() as *mut std::ffi::c_void,
- wl_surface.id().as_ptr() as *mut std::ffi::c_void,
- pw,
- ph,
- 0.0,
- )
- }?;
- log::debug!("[timing] VkRenderer::try_new: {:?}", t.elapsed());
+ let display_ptr = conn.backend().display_id().as_ptr() as *mut std::ffi::c_void;
+ let surface_ptr = wl_surface.id().as_ptr() as *mut std::ffi::c_void;
+ let renderer = match renderer {
+ Some(mut kept) => {
+ unsafe { kept.attach_surface(display_ptr, surface_ptr, pw, ph) }?;
+ log::debug!("[timing] VkRenderer::attach_surface: {:?}", t.elapsed());
+ kept
+ }
+ None => {
+ let made = unsafe { VkRenderer::try_new(display_ptr, surface_ptr, pw, ph, 0.0) }?;
+ log::debug!("[timing] VkRenderer::try_new: {:?}", t.elapsed());
+ made
+ }
+ };
// Reuse the daemon's font system across popups (a rebuild re-scans the
// fonts dir and loses the shaping caches).
@@ -1955,10 +1965,10 @@ impl State {
let app_names: Vec<String> = apps.iter().map(|app| app.name.clone()).collect();
// Resolve every entry's Icon= against the icon theme. Uploads are
- // per-popup by design (see cce_ui::icon::upload_themed): the daemon
- // tears its VkRenderer down between popups, so an id cached across
- // them would name freed GPU resources. Only the decode is cached, so
- // the second open of the launcher skips the disk and the rasterizer.
+ // per-popup, and the daemon frees them when the popup closes (the
+ // renderer itself is kept). Only the decode is cached (see
+ // cce_ui::icon::upload_themed), so the second open of the launcher
+ // skips the disk and the rasterizer.
let t_icons = std::time::Instant::now();
let icons: std::collections::HashMap<String, (u32, u32, u32)> = apps
.iter()
@@ -2141,8 +2151,8 @@ impl State {
/// Give the switcher's window rows their app's icon. Rows stream in over
/// stdin, so this runs per ingest and only resolves the rows that don't
- /// have an icon yet. Uploads are per-popup for the same reason as Apps
- /// mode's (see `State::new`). `index` is `State::switcher_icon_index`.
+ /// have an icon yet. Uploads are per-popup, like Apps mode's (see
+ /// `State::new`). `index` is `State::switcher_icon_index`.
fn resolve_switcher_icons(
fuzzel: &mut FuzzelWidget,
index: &mut Option<std::collections::HashMap<String, String>>,
@@ -3618,6 +3628,7 @@ fn run_standalone() {
json_layout_config,
parent_app_id,
None,
+ None,
)
.unwrap_or_else(|lost| {
log::error!("cannot open the window: {lost}");
@@ -3904,6 +3915,39 @@ fn run_daemon(socket_path: &str) {
event_queue.roundtrip(&mut app).unwrap();
+ // The renderer every popup draws with, built now on a surface that is
+ // never mapped and then detached from it, so the first popup only
+ // attaches it as every later one does. Each popup hands it back when it
+ // closes (see the end of the loop). If this fails the first popup builds
+ // its own, as all of them used to.
+ let t_renderer = std::time::Instant::now();
+ let mut renderer_slot: Option<VkRenderer> = {
+ let scratch = app.compositor_state.create_surface(&qh);
+ let made = unsafe {
+ VkRenderer::try_new(
+ conn_clone.backend().display_id().as_ptr() as *mut std::ffi::c_void,
+ scratch.id().as_ptr() as *mut std::ffi::c_void,
+ 1,
+ 1,
+ 0.0,
+ )
+ };
+ let kept = match made {
+ Ok(mut r) => {
+ r.detach_surface();
+ Some(r)
+ }
+ Err(e) => {
+ log::warn!("could not prewarm the popup renderer: {e}");
+ None
+ }
+ };
+ scratch.destroy();
+ let _ = conn_clone.flush();
+ kept
+ };
+ log::info!("[timing] daemon renderer prewarm: {:?}", t_renderer.elapsed());
+
let scale = cce_ui::wayland::detect_scale_factor(&app.output_state);
let xdg_shell_state = smithay_client_toolkit::shell::xdg::XdgShell::bind(&globals, &qh).ok();
@@ -4194,6 +4238,7 @@ fn run_daemon(socket_path: &str) {
json_layout_config,
parent_app_id,
fonts_slot.take(),
+ renderer_slot.take(),
) {
Ok(made) => made,
Err(lost) => {
@@ -4314,6 +4359,18 @@ fn run_daemon(socket_path: &str) {
);
let sc = std::mem::replace(&mut st.swash_cache, SwashCache::new());
fonts_slot = Some((fs, sc));
+ // Keep the renderer for the next popup, detached now: the State's
+ // drop below destroys this wl_surface, and the swapchain must not
+ // outlive it. Its images outlive the popup too, so free the row
+ // icons this popup uploaded (Apps, the switcher) or every open of
+ // the launcher would leave another set on the GPU.
+ for (_, (id, _, _)) in st.fuzzel.icons.drain() {
+ cce_ui::vk::free_image(id);
+ }
+ if let Some(mut r) = st.renderer.take() {
+ r.detach_surface();
+ renderer_slot = Some(r);
+ }
}
app.window = None;
app.surface = None;