Skip to main content

running_process/broker/server/
backend_registry.rs

1//! Verified backend registry keyed by broker instance, service, and version.
2
3use std::collections::HashMap;
4
5use crate::broker::backend_handle::BackendHandle;
6use crate::broker::protocol::ServiceDefinition;
7use crate::broker::server::hello_handler::RegisteredBackend;
8use crate::broker::server::instance::BrokerInstanceKey;
9
10/// Lookup key for one backend process.
11///
12/// The key includes the daemon executable's content hash (`exe_hash`, hex)
13/// so that two *different builds of the same version* — the ordinary
14/// edit-rebuild-the-daemon dev loop — are distinct registry entries rather
15/// than aliasing to one. Without it, a rebuilt daemon binary negotiates to the
16/// resident (stale-code) daemon on a `service_name` + `service_version` match,
17/// the un-isolated half of the daemon-collision class (running-process#894).
18#[derive(Clone, Debug, PartialEq, Eq, Hash)]
19pub struct BackendKey {
20    /// Broker trust-domain instance.
21    pub instance: BrokerInstanceKey,
22    /// Logical service name.
23    pub service_name: String,
24    /// Service version.
25    pub service_version: String,
26    /// BLAKE3 hash (lowercase hex) of the daemon executable this backend runs.
27    ///
28    /// Derived on `insert` from the launched daemon's verified identity, and
29    /// supplied on lookup as the hash of the on-disk `binary_path` the client
30    /// would launch. A rebuild changes the bytes → changes this segment → the
31    /// resident daemon is a lookup miss and the caller launches its own.
32    pub exe_hash: String,
33}
34
35impl BackendKey {
36    /// Build a key from an instance, service tuple, and daemon exe hash.
37    pub fn new(
38        instance: BrokerInstanceKey,
39        service_name: impl Into<String>,
40        service_version: impl Into<String>,
41        exe_hash: impl Into<String>,
42    ) -> Self {
43        Self {
44            instance,
45            service_name: service_name.into(),
46            service_version: service_version.into(),
47            exe_hash: exe_hash.into(),
48        }
49    }
50}
51
52/// In-memory table of verified backend handles.
53#[derive(Default)]
54pub struct BackendRegistry {
55    entries: HashMap<BackendKey, BackendHandle>,
56}
57
58impl BackendRegistry {
59    /// Create an empty registry.
60    pub fn new() -> Self {
61        Self {
62            entries: HashMap::new(),
63        }
64    }
65
66    /// Number of registered backend handles.
67    pub fn len(&self) -> usize {
68        self.entries.len()
69    }
70
71    /// Return true when the registry has no entries.
72    pub fn is_empty(&self) -> bool {
73        self.entries.is_empty()
74    }
75
76    /// Insert or replace one verified backend handle.
77    ///
78    /// The key's `exe_hash` segment is taken from the handle's verified
79    /// daemon identity, so a backend is always registered under the content
80    /// hash of the binary it actually launched.
81    pub fn insert(
82        &mut self,
83        instance: BrokerInstanceKey,
84        handle: BackendHandle,
85    ) -> Option<BackendHandle> {
86        let key = BackendKey::new(
87            instance,
88            handle.service_name.clone(),
89            handle.service_version.clone(),
90            hex_lower(&handle.daemon_process.exe_hash),
91        );
92        self.entries.insert(key, handle)
93    }
94
95    /// Return one handle by exact instance/service/version/exe-hash key.
96    ///
97    /// `exe_hash` is the lowercase-hex content hash of the daemon binary the
98    /// caller intends to reach. A handle registered under a different hash
99    /// (i.e. an earlier build of the same version) does not match.
100    pub fn get(
101        &self,
102        instance: &BrokerInstanceKey,
103        service_name: &str,
104        service_version: &str,
105        exe_hash: &str,
106    ) -> Option<&BackendHandle> {
107        self.entries.get(&BackendKey::new(
108            instance.clone(),
109            service_name,
110            service_version,
111            exe_hash,
112        ))
113    }
114
115    /// Return one handle by instance/service/version, ignoring the exe hash.
116    ///
117    /// For callers that are *re-locating a backend they already negotiated*
118    /// (the single-backend direct-serve path, or a Windows handoff for a
119    /// connection whose Hello already picked a backend) rather than making a
120    /// fresh routing decision. Routing decisions must use [`Self::get`], which
121    /// is hash-exact, so a rebuilt daemon does not alias the resident one.
122    /// If more than one build is registered, the first match is returned.
123    pub fn get_any_build(
124        &self,
125        instance: &BrokerInstanceKey,
126        service_name: &str,
127        service_version: &str,
128    ) -> Option<&BackendHandle> {
129        self.entries.iter().find_map(|(key, handle)| {
130            (key.instance == *instance
131                && key.service_name == service_name
132                && key.service_version == service_version)
133                .then_some(handle)
134        })
135    }
136
137    /// Iterate over all registered backend handles.
138    pub fn iter(&self) -> impl Iterator<Item = (&BackendKey, &BackendHandle)> {
139        self.entries.iter()
140    }
141
142    /// Remove backend handles whose verified process is no longer alive.
143    ///
144    /// Returns the removed keys so the lifecycle monitor can emit events,
145    /// metrics, or diagnostics after the registry mutation is complete.
146    pub fn prune_stale(&mut self) -> Vec<BackendKey> {
147        let mut removed = Vec::new();
148        self.entries.retain(|key, handle| {
149            let alive = handle.is_alive();
150            if !alive {
151                removed.push(key.clone());
152            }
153            alive
154        });
155        removed
156    }
157
158    /// Return Hello negotiation metadata for one registered backend.
159    ///
160    /// `expected_exe_hash` is the lowercase-hex content hash of the on-disk
161    /// daemon binary the client would launch. A resident daemon of the same
162    /// service+version but a *different* build hash is not returned, so the
163    /// caller falls through to launching its own (running-process#894).
164    pub fn registered_backend_for(
165        &self,
166        instance: &BrokerInstanceKey,
167        service_definition: &ServiceDefinition,
168        service_version: &str,
169        expected_exe_hash: &str,
170    ) -> Option<RegisteredBackend> {
171        let handle = self.get(
172            instance,
173            &service_definition.service_name,
174            service_version,
175            expected_exe_hash,
176        )?;
177        Some(RegisteredBackend {
178            service_definition: service_definition.clone(),
179            daemon_version: handle.service_version.clone(),
180            backend_pipe: handle.daemon_process.ipc_endpoint.path.clone(),
181            server_capabilities: 0,
182        })
183    }
184
185    /// Like [`Self::registered_backend_for`] but hash-agnostic — for the
186    /// single-backend direct-serve path that fronts exactly one build.
187    pub fn registered_backend_for_any_build(
188        &self,
189        instance: &BrokerInstanceKey,
190        service_definition: &ServiceDefinition,
191        service_version: &str,
192    ) -> Option<RegisteredBackend> {
193        let handle =
194            self.get_any_build(instance, &service_definition.service_name, service_version)?;
195        Some(RegisteredBackend {
196            service_definition: service_definition.clone(),
197            daemon_version: handle.service_version.clone(),
198            backend_pipe: handle.daemon_process.ipc_endpoint.path.clone(),
199            server_capabilities: 0,
200        })
201    }
202}
203
204/// Lowercase-hex encoding of a 32-byte digest, for use as a `BackendKey`
205/// segment. Kept local so the registry key has no external hex dependency.
206pub(crate) fn hex_lower(bytes: &[u8; 32]) -> String {
207    use std::fmt::Write as _;
208    let mut out = String::with_capacity(64);
209    for b in bytes {
210        let _ = write!(out, "{b:02x}");
211    }
212    out
213}
214
215#[cfg(test)]
216mod tests {
217    use crate::broker::backend_handle::{BackendHandle, DaemonProcess};
218    use crate::broker::protocol::Endpoint;
219
220    use super::*;
221
222    fn handle(service_name: &str, version: &str, pid: u32) -> BackendHandle {
223        let endpoint = Endpoint {
224            namespace_id: "shared".into(),
225            path: format!("rpb-v1-test-{service_name}-{version}"),
226        };
227        let mut daemon = DaemonProcess::current_process(endpoint, Some(30)).unwrap();
228        daemon.pid = pid;
229
230        BackendHandle {
231            service_name: service_name.into(),
232            service_version: version.into(),
233            daemon_process: daemon,
234            process_handle: None,
235        }
236    }
237
238    /// The exe hash every `handle()` in this module carries: the test binary's
239    /// own content hash (all handles are `DaemonProcess::current_process`).
240    fn test_exe_hash() -> String {
241        hex_lower(
242            &handle("probe", "0.0.0", std::process::id())
243                .daemon_process
244                .exe_hash,
245        )
246    }
247
248    #[test]
249    fn prune_stale_removes_dead_handles_and_keeps_live_ones() {
250        let mut registry = BackendRegistry::new();
251        let exe = test_exe_hash();
252        let live_key = BackendKey::new(BrokerInstanceKey::Shared, "zccache", "1.11.20", &exe);
253        let dead_key = BackendKey::new(BrokerInstanceKey::Shared, "zccache", "1.11.21", &exe);
254
255        registry.insert(
256            live_key.instance.clone(),
257            handle(
258                &live_key.service_name,
259                &live_key.service_version,
260                std::process::id(),
261            ),
262        );
263        registry.insert(
264            dead_key.instance.clone(),
265            handle(&dead_key.service_name, &dead_key.service_version, u32::MAX),
266        );
267
268        let removed = registry.prune_stale();
269
270        assert_eq!(removed, vec![dead_key.clone()]);
271        assert!(registry
272            .get(
273                &live_key.instance,
274                &live_key.service_name,
275                &live_key.service_version,
276                &exe,
277            )
278            .is_some());
279        assert!(registry
280            .get(
281                &dead_key.instance,
282                &dead_key.service_name,
283                &dead_key.service_version,
284                &exe,
285            )
286            .is_none());
287    }
288
289    #[test]
290    fn same_version_different_build_is_a_distinct_entry() {
291        // Two daemons, same service+version, different executable hash: the
292        // core running-process#894 case (a dev rebuild of the daemon binary).
293        let mut registry = BackendRegistry::new();
294
295        let mut a = handle("zccache", "1.11.20", std::process::id());
296        a.daemon_process.exe_hash = [0xAA; 32];
297        let mut b = handle("zccache", "1.11.20", std::process::id());
298        b.daemon_process.exe_hash = [0xBB; 32];
299        let a_pipe = a.daemon_process.ipc_endpoint.path.clone();
300
301        registry.insert(BrokerInstanceKey::Shared, a);
302        // A different build of the SAME version must NOT overwrite build A.
303        let replaced = registry.insert(BrokerInstanceKey::Shared, b);
304        assert!(
305            replaced.is_none(),
306            "a different exe hash must be a new registry entry, not a replacement"
307        );
308        assert_eq!(registry.len(), 2, "both builds coexist");
309
310        // A client that would launch build A reaches build A, never build B.
311        let got = registry
312            .get(
313                &BrokerInstanceKey::Shared,
314                "zccache",
315                "1.11.20",
316                &hex_lower(&[0xAA; 32]),
317            )
318            .expect("build A is reachable by its own hash");
319        assert_eq!(got.daemon_process.ipc_endpoint.path, a_pipe);
320
321        // A hash that matches neither build is a clean miss (→ caller launches).
322        assert!(registry
323            .get(
324                &BrokerInstanceKey::Shared,
325                "zccache",
326                "1.11.20",
327                &hex_lower(&[0xCC; 32]),
328            )
329            .is_none());
330    }
331}