git.lucas.co / cce-map
map viewer
git clone https://git.lucas.co/cce-map.git

commit964da435ef124b8edf9acaa7b82e53d4940d6658
parent5e794f5b56
authorLucas Galante <[email protected]>
date2026-09-19 00:53
Resident tiles belong to a renderer, not to the process

An image id names an entry in one renderer's image table, and a
renderer does not outlive its session: cce-ui's window_runner repairs a
lost Wayland transport by opening a new session around the same
Application, which rebuilds the VkRenderer and with it the image table.
A draw for an id that table does not hold is skipped rather than
reported.

TileManager caches up to 180 of those ids and never re-fetches a
resident tile, so a reconnected map came back as bare background with
its markers, scale bar and chrome floating on nothing, and stayed that
way until panning far enough to evict the whole cache.

renderer_init on any renderer after the first calls a new reset():
every resident tile is freed and the next paint asks for what it needs
again. Re-fetching is cheap — anything that was resident is already in
~/.cache/cce/map/tiles, so it is a decode and an upload, not a network
round trip. Failed entries go too, which is the one behavior change: a
tile that failed before the reconnect gets one more try.

A fetch now carries the generation it was queued under, the way
cce-preview's PageStore does, because a worker that uploaded just
before the drop would otherwise deliver an id naming nothing and cache
it as a tile that draws blank for as long as it stays resident. A
mismatched result is freed on arrival instead.

Not on the first renderer: the tiles queued from new() are waiting for
exactly that one. window_runner documents the contract above `run`.

 src/main.rs  | 28 +++++++++++++++++++++++++---
 src/tiles.rs | 54 ++++++++++++++++++++++++++++++++++++++++++++++--------
 2 files changed, 71 insertions(+), 11 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 8daaebb..6ba1c3f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -22,11 +22,15 @@ const KEY_PAN_PX: f64 = 120.0;
 
 #[derive(Debug, Clone)]
 enum Message {
-    Tile { key: TileKey, image: Option<u32> },
+    Tile { generation: u64, key: TileKey, image: Option<u32> },
 }
 
 struct MapApp {
     tiles: TileManager,
+    /// Whether a renderer has been handed over yet — the first one is the
+    /// process's own, any later one is a replacement after a reconnect. See
+    /// `renderer_init`.
+    seen_renderer: bool,
     /// World coords of the window center, u east [0,1), v south [0,1].
     center: (f64, f64),
     zoom: f64,
@@ -115,6 +119,7 @@ impl Application for MapApp {
     fn new(_qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self {
         Self {
             tiles: TileManager::new(sender),
+            seen_renderer: false,
             center: (0.5, 0.5),
             zoom: 2.0,
             zoom_target: 2.0,
@@ -138,13 +143,30 @@ impl Application for MapApp {
 
     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, _exit: &mut bool) {
         match msg {
-            Message::Tile { key, image } => {
-                self.tiles.complete(key, image);
+            Message::Tile { generation, key, image } => {
+                self.tiles.complete(generation, key, image);
                 *needs_rebuild = true;
             }
         }
     }
 
+    /// Re-fetch the visible tiles when the renderer is replaced.
+    ///
+    /// The tile store caches **renderer** image ids, which do not survive the
+    /// reconnect `window_runner` performs around a live `Application` — see
+    /// [`TileManager::reset`] for the whole story. Every resident tile is
+    /// dropped here and the next paint asks for what it needs again, off the
+    /// disk cache.
+    ///
+    /// Not on the first renderer: the tiles queued from `new()` are waiting
+    /// for exactly that one.
+    fn renderer_init(&mut self, _renderer: &mut cce_ui::vk::VkRenderer) {
+        if std::mem::replace(&mut self.seen_renderer, true) {
+            log::info!("[map] renderer replaced; re-fetching the resident tiles");
+            self.tiles.reset();
+        }
+    }
+
     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
         if self.tick_zoom(dt) {
             *needs_rebuild = true;
diff --git a/src/tiles.rs b/src/tiles.rs
index dfb08ed..52ecfa8 100644
--- a/src/tiles.rs
+++ b/src/tiles.rs
@@ -39,14 +39,18 @@ enum TileState {
 
 pub struct TileManager {
     states: HashMap<TileKey, TileState>,
-    queue: mpsc::Sender<TileKey>,
+    queue: mpsc::Sender<(u64, TileKey)>,
     /// Frame counter used as the LRU clock; bumped by the app each rebuild.
     frame: u64,
+    /// Bumped by [`TileManager::reset`]. A fetch carries the generation it
+    /// was queued under, so a tile uploaded to a renderer that has since been
+    /// replaced is freed on arrival instead of drawn as a dead id.
+    generation: u64,
 }
 
 impl TileManager {
     pub fn new(notify: calloop::channel::Sender<Message>) -> Self {
-        let (queue, rx) = mpsc::channel::<TileKey>();
+        let (queue, rx) = mpsc::channel::<(u64, TileKey)>();
         let rx = Arc::new(Mutex::new(rx));
         let url_template = std::env::var("CCE_MAP_TILE_URL").unwrap_or_else(|_| DEFAULT_TILE_URL.to_string());
         let cache_root = cache_root();
@@ -57,7 +61,7 @@ impl TileManager {
             let cache_root = cache_root.clone();
             std::thread::spawn(move || worker(rx, notify, url_template, cache_root));
         }
-        Self { states: HashMap::new(), queue, frame: 0 }
+        Self { states: HashMap::new(), queue, frame: 0, generation: 0 }
     }
 
     pub fn begin_frame(&mut self) {
@@ -75,7 +79,7 @@ impl TileManager {
             Some(_) => None,
             None => {
                 self.states.insert(key, TileState::Pending);
-                let _ = self.queue.send(key);
+                let _ = self.queue.send((self.generation, key));
                 None
             }
         }
@@ -92,7 +96,16 @@ impl TileManager {
         }
     }
 
-    pub fn complete(&mut self, key: TileKey, image: Option<u32>) {
+    pub fn complete(&mut self, generation: u64, key: TileKey, image: Option<u32>) {
+        if generation != self.generation {
+            // Uploaded to a renderer that is gone (see `reset`): the id names
+            // nothing, so free it rather than cache it as a tile that would
+            // draw blank for as long as it stayed resident.
+            if let Some(image) = image {
+                cce_ui::vk::free_image(image);
+            }
+            return;
+        }
         let state = match image {
             Some(image) => TileState::Ready { image, last_used: self.frame },
             None => TileState::Failed,
@@ -101,6 +114,31 @@ impl TileManager {
         self.evict();
     }
 
+    /// Throw every resident tile away and re-fetch on demand.
+    ///
+    /// For one caller: the renderer has been replaced. GPU tile ids belong to
+    /// a **renderer**, and a renderer does not outlive its session —
+    /// `cce-ui`'s `window_runner` repairs a lost Wayland transport by opening
+    /// a new session around the same `Application`, which rebuilds the
+    /// renderer and with it the image table. A draw for an unknown id is
+    /// skipped rather than reported, so a reconnected map came back as bare
+    /// background with its markers and scale bar floating on it, and stayed
+    /// that way: a resident tile is never re-fetched.
+    ///
+    /// Re-fetching is cheap — every tile that was resident is already on disk
+    /// under `~/.cache/cce/map/tiles`, so this is a decode and an upload, not
+    /// a network round trip. `Failed` entries go too, which is the one
+    /// behavior change: a tile that failed before the reconnect gets one more
+    /// try.
+    pub fn reset(&mut self) {
+        for (_, state) in self.states.drain() {
+            if let TileState::Ready { image, .. } = state {
+                cce_ui::vk::free_image(image);
+            }
+        }
+        self.generation += 1;
+    }
+
     /// Free the least-recently-used GPU tiles once over budget. Tiles
     /// touched this frame are never evicted.
     fn evict(&mut self) {
@@ -135,7 +173,7 @@ fn cache_root() -> PathBuf {
 }
 
 fn worker(
-    rx: Arc<Mutex<mpsc::Receiver<TileKey>>>,
+    rx: Arc<Mutex<mpsc::Receiver<(u64, TileKey)>>>,
     notify: calloop::channel::Sender<Message>,
     url_template: String,
     cache_root: PathBuf,
@@ -146,14 +184,14 @@ fn worker(
         .build()
         .expect("http client");
     loop {
-        let key = match rx.lock().unwrap().recv() {
+        let (generation, key) = match rx.lock().unwrap().recv() {
             Ok(k) => k,
             Err(_) => return,
         };
         let image = fetch_tile(&client, &url_template, &cache_root, key)
             .map_err(|e| log::warn!("tile {}/{}/{}: {e}", key.z, key.x, key.y))
             .ok();
-        if notify.send(Message::Tile { key, image }).is_err() {
+        if notify.send(Message::Tile { generation, key, image }).is_err() {
             return;
         }
     }