Skip to main content

omni_dev/
worktrees.rs

1//! The cross-window worktree registry engine.
2//!
3//! Maintains the live, authoritative set of repos/worktrees open across *every*
4//! VS Code window, fed by a first-party companion extension that reports from
5//! each window over the daemon's control socket. The resident daemon is the
6//! rendezvous point the per-window extension sandbox cannot replace: each window
7//! can see only its own `workspace.workspaceFolders`, so a single process
8//! aggregating those registrations is the only cross-window source of truth.
9//! See ADR-0040.
10//!
11//! This is the standalone engine, analogous to [`crate::browser`] and
12//! [`crate::snowflake`]; the daemon adapter lives in
13//! [`crate::daemon::services::worktrees`].
14//!
15//! Like the Snowflake engine this is cheap and in-memory — no async setup, no
16//! secret persisted. The registry lives behind a [`std::sync::Mutex`] that is
17//! **never held across an `.await`** (the Snowflake rule); every op is pure CPU
18//! under the lock, so liveness reaping happens inline on each read rather than
19//! from a background task.
20
21use std::collections::HashMap;
22use std::path::PathBuf;
23use std::sync::{Mutex, MutexGuard, PoisonError};
24use std::time::Duration;
25
26use chrono::{DateTime, Utc};
27use serde::{Deserialize, Serialize};
28
29/// How long a window may go silent before it ages out of the registry. Three
30/// missed ~10s heartbeats; a window that crashed without firing `unregister`
31/// disappears on the next read. The resident process is what makes this
32/// liveness correct — a flat shared file could not reap stale entries.
33const DEFAULT_TTL: Duration = Duration::from_secs(30);
34
35/// Ceiling on live registry entries, so a misbehaving companion flooding
36/// `register` with distinct keys cannot grow daemon memory faster than the TTL
37/// reaps it (#1140). Far above any real window count; when a new key would
38/// exceed it, the longest-silent entry is evicted instead of rejecting the
39/// request — an evicted live window self-heals via the `heartbeat` →
40/// `{known: false}` → re-register path, so `register` stays infallible for the
41/// companion.
42const MAX_WINDOWS: usize = 256;
43
44/// A `register` request from a companion extension.
45///
46/// The companion owns its `key` (a per-`activate()` UUID) so the registry never
47/// has to reason about whether `vscode.env.sessionId` is unique per window;
48/// everything else is best-effort metadata.
49#[derive(Debug, Clone, Deserialize)]
50pub struct RegisterRequest {
51    /// Stable per-window identity, generated by the companion on activation.
52    pub key: String,
53    /// Absolute paths of the window's workspace folders.
54    #[serde(default)]
55    pub folders: Vec<PathBuf>,
56    /// Repository root or name, when the window has one.
57    #[serde(default)]
58    pub repo: Option<String>,
59    /// The window title, for display.
60    #[serde(default)]
61    pub title: Option<String>,
62    /// The reporting extension-host process id.
63    #[serde(default)]
64    pub pid: Option<u32>,
65}
66
67/// One open window's live registration. Serialized verbatim into `list` /
68/// `status` payloads; consumers compute age from `last_seen` (RFC 3339).
69#[derive(Debug, Clone, Serialize)]
70pub struct WindowEntry {
71    /// The companion-owned per-window key.
72    pub key: String,
73    /// Absolute workspace-folder paths.
74    pub folders: Vec<PathBuf>,
75    /// Repository root or name, if reported.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub repo: Option<String>,
78    /// Window title, if reported.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub title: Option<String>,
81    /// Reporting extension-host pid, if reported.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub pid: Option<u32>,
84    /// When the registry last heard from this window (register or heartbeat).
85    pub last_seen: DateTime<Utc>,
86}
87
88/// The cross-window worktree registry: the in-memory, TTL-reaped set of open
89/// windows. Hosted by
90/// [`WorktreesService`](crate::daemon::services::worktrees::WorktreesService).
91pub struct WorktreesRegistry {
92    /// Open windows keyed by their companion-owned `key`.
93    windows: Mutex<HashMap<String, WindowEntry>>,
94    /// How long an entry survives without a heartbeat.
95    ttl: Duration,
96}
97
98impl WorktreesRegistry {
99    /// Creates the registry with the default liveness TTL. Cheap — no I/O.
100    #[must_use]
101    pub fn new() -> Self {
102        Self {
103            windows: Mutex::new(HashMap::new()),
104            ttl: DEFAULT_TTL,
105        }
106    }
107
108    /// Locks the registry, recovering from a poisoned mutex (a panic in a prior
109    /// critical section must not wedge the whole registry).
110    fn lock(&self) -> MutexGuard<'_, HashMap<String, WindowEntry>> {
111        self.windows.lock().unwrap_or_else(PoisonError::into_inner)
112    }
113
114    /// Records (upserts) a window registration. Reaps stale entries first, then
115    /// — only when a genuinely new key would grow the map past [`MAX_WINDOWS`] —
116    /// evicts the longest-silent entry. Infallible: an upsert never evicts, and
117    /// callers validate the `key` before reaching here.
118    pub fn register(&self, req: RegisterRequest) {
119        let now = Utc::now();
120        let mut windows = self.lock();
121        reap(&mut windows, self.ttl, now);
122        // Upserts never evict; only a genuinely new key can grow the map, and
123        // never past MAX_WINDOWS.
124        if !windows.contains_key(&req.key) && windows.len() >= MAX_WINDOWS {
125            evict_oldest(&mut windows);
126        }
127        windows.insert(
128            req.key.clone(),
129            WindowEntry {
130                key: req.key,
131                folders: req.folders,
132                repo: req.repo,
133                title: req.title,
134                pid: req.pid,
135                last_seen: now,
136            },
137        );
138    }
139
140    /// Refreshes a window's liveness. Returns whether the key was known: a
141    /// `false` tells a window that started before the daemon — or survived a
142    /// daemon restart — to re-`register`, since the registry is in-memory and
143    /// has no record of it.
144    pub fn heartbeat(&self, key: &str) -> bool {
145        let now = Utc::now();
146        let mut windows = self.lock();
147        reap(&mut windows, self.ttl, now);
148        match windows.get_mut(key) {
149            Some(entry) => {
150                entry.last_seen = now;
151                true
152            }
153            None => false,
154        }
155    }
156
157    /// Drops a window's registration. Returns whether an entry was present.
158    pub fn unregister(&self, key: &str) -> bool {
159        let now = Utc::now();
160        let mut windows = self.lock();
161        let removed = windows.remove(key).is_some();
162        reap(&mut windows, self.ttl, now);
163        removed
164    }
165
166    /// Reaps stale entries, then returns the live set sorted for deterministic
167    /// output. Holds the lock only for pure-CPU work.
168    pub fn list(&self) -> Vec<WindowEntry> {
169        let now = Utc::now();
170        let mut windows = self.lock();
171        reap(&mut windows, self.ttl, now);
172        sorted_entries(&windows)
173    }
174
175    /// The first workspace folder of a still-live window, if it has one. Used by
176    /// the tray "focus" action to resolve a key to a folder to open. Does not
177    /// reap — a menu action races the reaper either way, and the caller handles
178    /// a `None` (the window may have closed).
179    pub fn first_folder(&self, key: &str) -> Option<PathBuf> {
180        let windows = self.lock();
181        windows.get(key).and_then(|e| e.folders.first().cloned())
182    }
183}
184
185impl Default for WorktreesRegistry {
186    fn default() -> Self {
187        Self::new()
188    }
189}
190
191/// Removes entries last seen longer than `ttl` ago. Pure CPU; the caller holds
192/// the registry lock but never `.await`s while holding it.
193fn reap(windows: &mut HashMap<String, WindowEntry>, ttl: Duration, now: DateTime<Utc>) {
194    let max_age = ttl.as_secs() as i64;
195    windows.retain(|_, e| (now - e.last_seen).num_seconds() <= max_age);
196}
197
198/// Removes the entry with the oldest `last_seen` (ties broken by lowest key
199/// for determinism). Called when a `register` of a new key would grow the
200/// registry past [`MAX_WINDOWS`]. Pure CPU under the registry lock, like
201/// [`reap`].
202fn evict_oldest(windows: &mut HashMap<String, WindowEntry>) {
203    let oldest = windows
204        .values()
205        .min_by(|a, b| {
206            a.last_seen
207                .cmp(&b.last_seen)
208                .then_with(|| a.key.cmp(&b.key))
209        })
210        .map(|e| e.key.clone());
211    if let Some(key) = oldest {
212        windows.remove(&key);
213    }
214}
215
216/// Snapshots the registry into a stably-ordered vector (by repo, then key) so
217/// `list`/`status`/`menu` output is deterministic despite `HashMap` ordering.
218fn sorted_entries(windows: &HashMap<String, WindowEntry>) -> Vec<WindowEntry> {
219    let mut entries: Vec<WindowEntry> = windows.values().cloned().collect();
220    entries.sort_by(|a, b| a.repo.cmp(&b.repo).then_with(|| a.key.cmp(&b.key)));
221    entries
222}
223
224#[cfg(test)]
225#[allow(clippy::unwrap_used, clippy::expect_used)]
226mod tests {
227    use super::*;
228
229    fn register_request(key: &str, repo: Option<&str>, folder: &str) -> RegisterRequest {
230        RegisterRequest {
231            key: key.to_string(),
232            folders: vec![PathBuf::from(folder)],
233            repo: repo.map(str::to_string),
234            title: Some(format!("{key}-title")),
235            pid: Some(1234),
236        }
237    }
238
239    #[test]
240    fn list_is_empty_initially() {
241        let reg = WorktreesRegistry::new();
242        assert!(reg.list().is_empty());
243    }
244
245    #[test]
246    fn register_then_list_round_trips() {
247        let reg = WorktreesRegistry::new();
248        reg.register(register_request("w1", Some("repo-a"), "/tmp/a"));
249        let windows = reg.list();
250        assert_eq!(windows.len(), 1);
251        assert_eq!(windows[0].key, "w1");
252        assert_eq!(windows[0].repo.as_deref(), Some("repo-a"));
253    }
254
255    #[test]
256    fn register_is_idempotent_upsert() {
257        let reg = WorktreesRegistry::new();
258        reg.register(register_request("w1", Some("repo-a"), "/tmp/a"));
259        // Re-registering the same key updates rather than duplicates.
260        reg.register(register_request("w1", Some("repo-b"), "/tmp/b"));
261        let windows = reg.list();
262        assert_eq!(windows.len(), 1);
263        assert_eq!(windows[0].repo.as_deref(), Some("repo-b"));
264    }
265
266    #[test]
267    fn heartbeat_reports_known_and_unknown() {
268        let reg = WorktreesRegistry::new();
269        // Unknown before registration: the window must re-register.
270        assert!(!reg.heartbeat("w1"));
271        reg.register(register_request("w1", None, "/tmp/a"));
272        assert!(reg.heartbeat("w1"));
273    }
274
275    #[test]
276    fn unregister_removes() {
277        let reg = WorktreesRegistry::new();
278        reg.register(register_request("w1", None, "/tmp/a"));
279        assert!(reg.unregister("w1"));
280        // Removing again is a no-op.
281        assert!(!reg.unregister("w1"));
282    }
283
284    #[test]
285    fn first_folder_returns_first_folder_or_none() {
286        let reg = WorktreesRegistry::new();
287        // No such key.
288        assert!(reg.first_folder("missing").is_none());
289        reg.register(register_request("w1", None, "/tmp/a"));
290        assert_eq!(reg.first_folder("w1"), Some(PathBuf::from("/tmp/a")));
291        // A folderless window resolves to None rather than a folder.
292        reg.register(RegisterRequest {
293            key: "w2".to_string(),
294            folders: vec![],
295            repo: None,
296            title: None,
297            pid: None,
298        });
299        assert!(reg.first_folder("w2").is_none());
300    }
301
302    #[test]
303    fn reap_evicts_only_stale_entries() {
304        let now = Utc::now();
305        let mut windows = HashMap::new();
306        windows.insert(
307            "fresh".to_string(),
308            WindowEntry {
309                key: "fresh".to_string(),
310                folders: vec![],
311                repo: None,
312                title: None,
313                pid: None,
314                last_seen: now - chrono::Duration::seconds(5),
315            },
316        );
317        windows.insert(
318            "stale".to_string(),
319            WindowEntry {
320                key: "stale".to_string(),
321                folders: vec![],
322                repo: None,
323                title: None,
324                pid: None,
325                last_seen: now - chrono::Duration::seconds(120),
326            },
327        );
328        reap(&mut windows, DEFAULT_TTL, now);
329        assert!(windows.contains_key("fresh"));
330        assert!(!windows.contains_key("stale"));
331    }
332
333    /// A minimal entry for cap/eviction tests; only `key` and `last_seen`
334    /// participate in eviction order.
335    fn entry_at(key: &str, last_seen: DateTime<Utc>) -> WindowEntry {
336        WindowEntry {
337            key: key.to_string(),
338            folders: vec![],
339            repo: None,
340            title: None,
341            pid: None,
342            last_seen,
343        }
344    }
345
346    #[test]
347    fn evict_oldest_removes_oldest_with_key_tiebreak() {
348        let now = Utc::now();
349        let mut windows = HashMap::new();
350        windows.insert("young".to_string(), entry_at("young", now));
351        windows.insert(
352            "old-b".to_string(),
353            entry_at("old-b", now - chrono::Duration::seconds(10)),
354        );
355        windows.insert(
356            "old-a".to_string(),
357            entry_at("old-a", now - chrono::Duration::seconds(10)),
358        );
359        // Oldest `last_seen` is shared by two entries; the lowest key loses.
360        evict_oldest(&mut windows);
361        assert!(!windows.contains_key("old-a"));
362        assert!(windows.contains_key("old-b"));
363        assert!(windows.contains_key("young"));
364        // Empty map is a no-op rather than a panic.
365        let mut empty: HashMap<String, WindowEntry> = HashMap::new();
366        evict_oldest(&mut empty);
367        assert!(empty.is_empty());
368    }
369
370    #[test]
371    fn register_at_cap_evicts_only_the_oldest() {
372        let reg = WorktreesRegistry::new();
373        // Seed a full registry directly (registering 256 times would work too,
374        // but sub-second timestamps may tie; explicit timestamps make the
375        // highest-numbered key unambiguously the oldest).
376        {
377            let mut windows = reg.lock();
378            let base = Utc::now();
379            for i in 0..MAX_WINDOWS {
380                let key = format!("w{i:03}");
381                windows.insert(
382                    key.clone(),
383                    entry_at(&key, base - chrono::Duration::milliseconds(i as i64)),
384                );
385            }
386        }
387        // A new key at the cap displaces exactly the longest-silent entry.
388        reg.register(register_request("fresh", None, "/tmp/f"));
389        let windows = reg.lock();
390        assert_eq!(windows.len(), MAX_WINDOWS);
391        assert!(windows.contains_key("fresh"));
392        assert!(!windows.contains_key(&format!("w{:03}", MAX_WINDOWS - 1)));
393        assert!(windows.contains_key("w000"));
394    }
395
396    #[test]
397    fn register_upsert_at_cap_does_not_evict() {
398        let reg = WorktreesRegistry::new();
399        {
400            let mut windows = reg.lock();
401            let base = Utc::now();
402            for i in 0..MAX_WINDOWS {
403                let key = format!("w{i:03}");
404                windows.insert(
405                    key.clone(),
406                    entry_at(&key, base - chrono::Duration::milliseconds(i as i64)),
407                );
408            }
409        }
410        // Re-registering an existing key is an upsert: nothing is displaced,
411        // not even the oldest entry.
412        let oldest = format!("w{:03}", MAX_WINDOWS - 1);
413        reg.register(register_request(&oldest, Some("r"), "/tmp/a"));
414        let windows = reg.lock();
415        assert_eq!(windows.len(), MAX_WINDOWS);
416        assert!(windows.contains_key(&oldest));
417        assert!(windows.contains_key("w000"));
418    }
419
420    #[test]
421    fn sorted_entries_orders_by_repo_then_key() {
422        let now = Utc::now();
423        let mut windows = HashMap::new();
424        for (key, repo) in [("z", "repo-a"), ("a", "repo-b"), ("m", "repo-a")] {
425            windows.insert(
426                key.to_string(),
427                WindowEntry {
428                    key: key.to_string(),
429                    folders: vec![],
430                    repo: Some(repo.to_string()),
431                    title: None,
432                    pid: None,
433                    last_seen: now,
434                },
435            );
436        }
437        let entries = sorted_entries(&windows);
438        let ordered: Vec<(&str, &str)> = entries
439            .iter()
440            .map(|e| (e.key.as_str(), e.repo.as_deref().unwrap()))
441            .collect();
442        assert_eq!(
443            ordered,
444            vec![("m", "repo-a"), ("z", "repo-a"), ("a", "repo-b")]
445        );
446    }
447
448    #[test]
449    fn default_constructs_an_empty_registry() {
450        let reg = WorktreesRegistry::default();
451        assert!(reg.lock().is_empty());
452    }
453}