fix(daemon): reseed id counter on restore + heal duplicate leaves

Root cause of the multi-focus/multi-search/linked-terminal bug: the in-memory
id counter resets to 0 each daemon start, but restore() never advanced it past
restored ids. After a restart new_surface_id() re-minted existing ids → the same
surface_id appeared twice in a layout tree (rendered as two panels sharing focus,
search bar, and output channel — one ends up blank). Session-persistence made
restarts routine, surfacing the latent bug.

- restore() now reseeds the counter to max(restored id)+1
- ops::dedupe_leaves heals an already-corrupted persisted tree on load

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 16:46:04 +07:00
parent 3317b24d18
commit d62628be8d
2 changed files with 120 additions and 0 deletions
+73
View File
@@ -23,6 +23,12 @@ pub struct Registry {
states: HashMap<SurfaceId, SurfaceState>,
}
/// Parse the hex numeric suffix of an id (`"s_1f"` → `0x1f`). None if malformed.
/// All ids are minted as `format!("{prefix}_{n:x}")`, so the suffix is hex.
fn id_num(id: &str) -> Option<u64> {
id.rsplit_once('_').and_then(|(_, hex)| u64::from_str_radix(hex, 16).ok())
}
impl Registry {
pub fn new() -> Self {
Self::default()
@@ -193,8 +199,29 @@ impl Registry {
self.by_path.clear();
self.live.clear();
self.states.clear();
// Advance the id counter past every restored id. The in-memory counter
// resets to 0 on each daemon start; without this reseed, after a restart
// `new_surface_id()` re-mints ids that already exist — producing duplicate
// leaves in a workspace tree (same panel rendered twice, focus/search/
// output routing keyed by surface id all break) and cross-workspace id
// collisions.
let mut max_id = 0u64;
for gid in self.groups.keys() {
if let Some(n) = id_num(&gid.0) { max_id = max_id.max(n + 1); }
}
for w in &state.workspaces {
if let Some(n) = id_num(&w.id.0) { max_id = max_id.max(n + 1); }
for sid in w.surfaces.keys() {
if let Some(n) = id_num(&sid.0) { max_id = max_id.max(n + 1); }
}
}
self.counter.store(max_id, Ordering::Relaxed);
for w in state.workspaces {
let mut w = w;
// Heal a tree already corrupted by a duplicate leaf (pre-fix state).
w.layout = w.layout.take().and_then(spacesh_core::ops::dedupe_leaves);
if let Some(z) = &w.zoomed {
if !w.surfaces.contains_key(z) { w.zoomed = None; }
}
@@ -252,6 +279,52 @@ mod tests {
assert_eq!(w.layout, Some(LN::leaf(s1))); // split collapsed
}
#[test]
fn restore_advances_counter_past_existing_ids() {
// After a daemon restart the counter must not re-mint a restored id.
let mut r = Registry::new();
let mut surfaces = HashMap::new();
surfaces.insert(SurfaceId("s_5".into()), spec());
let st = PersistState {
version: 1, groups: vec![],
workspaces: vec![Workspace {
id: WorkspaceId("w_2".into()), path: "/p".into(), name: "p".into(),
group_id: None, order: 0, unread: false, pinned: false,
layout: Some(LN::leaf(SurfaceId("s_5".into()))), zoomed: None, surfaces,
}],
};
r.restore(st);
// max restored id is s_5 (hex 5) → next minted must be s_6, no collision.
assert_eq!(r.new_surface_id(), SurfaceId("s_6".into()));
}
#[test]
fn restore_heals_duplicate_leaf() {
// A persisted tree with s_1 twice (the production corruption) heals to one.
let mut r = Registry::new();
let mut surfaces = HashMap::new();
surfaces.insert(SurfaceId("s_0".into()), spec());
surfaces.insert(SurfaceId("s_1".into()), spec());
let tree = LN::Split {
orient: Orient::H, ratios: vec![1.0 / 3.0; 3],
children: vec![LN::leaf(SurfaceId("s_0".into())), LN::leaf(SurfaceId("s_1".into())), LN::leaf(SurfaceId("s_1".into()))],
};
let st = PersistState {
version: 1, groups: vec![],
workspaces: vec![Workspace {
id: WorkspaceId("w_0".into()), path: "/p".into(), name: "p".into(),
group_id: None, order: 0, unread: false, pinned: false,
layout: Some(tree), zoomed: None, surfaces,
}],
};
r.restore(st);
let w = r.workspace(&WorkspaceId("w_0".into())).unwrap();
assert_eq!(
spacesh_core::ops::leaves(w.layout.as_ref().unwrap()),
vec![SurfaceId("s_0".into()), SurfaceId("s_1".into())]
);
}
#[test]
fn restore_round_trips_through_persist_state() {
let mut r = Registry::new();