Skip to main content

running_process/broker/server/
hello_router.rs

1//! Service-definition-backed Hello routing.
2//!
3//! `HelloHandler` owns deterministic validation and in-memory negotiation.
4//! `HelloRouter` adds the broker-facing lookup layer: reload the service
5//! definition for each request, resolve the trust-domain instance, query the
6//! backend registry, and then delegate the final reply construction to
7//! `HelloHandler`.
8
9use std::collections::HashMap;
10use std::sync::Mutex;
11use std::time::{Duration, Instant};
12
13use crate::broker::protocol::{
14    hello_reply::Result as HelloReplyResult, ErrorCode, Frame, HelloReply, Refused,
15    ServiceDefinition, PROTOCOL_VERSION,
16};
17use crate::broker::server::hello_handler::validate_hello_shape;
18use crate::broker::server::session_token::SessionTokenAuthority;
19use crate::broker::server::{
20    check_version_allowed, BackendKey, BackendLaunchRequest, BackendLauncher, BackendRegistry,
21    BrokerInstanceKey, HelloHandler, HelloHandlerError, HelloRequest, PeerIdentity,
22    RegisteredBackend, ServiceDefinitionError, ServiceDefinitionSource, SpawnBeginError,
23    SpawnCoordinator, SpawnOutcome, TraceContext, VersionPolicyBlock,
24};
25
26/// Routes decoded Hello requests through service definitions and backend state.
27///
28/// The service-definition source is a `&dyn `[`ServiceDefinitionSource`] so the
29/// shared broker's v2-first [`super::CombinedServiceDefinitionLoader`] and the
30/// bare v1 [`super::ServiceDefinitionLoader`] (used by tests that install v1
31/// files) both drive the router unchanged (soldr#2364).
32#[derive(Clone, Copy)]
33pub struct HelloRouter<'a> {
34    service_definitions: &'a dyn ServiceDefinitionSource,
35    backends: BackendRegistryView<'a>,
36    spawn_coordinator: Option<&'a Mutex<SpawnCoordinator>>,
37    backend_launcher: Option<&'a dyn BackendLauncher>,
38    session_tokens: Option<&'a Mutex<SessionTokenAuthority>>,
39}
40
41#[derive(Clone, Copy)]
42enum BackendRegistryView<'a> {
43    Static(&'a BackendRegistry),
44    Live(&'a Mutex<BackendRegistry>),
45}
46
47impl<'a> HelloRouter<'a> {
48    /// Create a router over immutable broker state.
49    pub fn new(
50        service_definitions: &'a dyn ServiceDefinitionSource,
51        backends: &'a BackendRegistry,
52    ) -> Self {
53        Self {
54            service_definitions,
55            backends: BackendRegistryView::Static(backends),
56            spawn_coordinator: None,
57            backend_launcher: None,
58            session_tokens: None,
59        }
60    }
61
62    /// Create a router over live broker state that prunes stale backend handles
63    /// before each registry lookup.
64    pub fn with_lifecycle_monitor(
65        service_definitions: &'a dyn ServiceDefinitionSource,
66        backends: &'a Mutex<BackendRegistry>,
67    ) -> Self {
68        Self {
69            service_definitions,
70            backends: BackendRegistryView::Live(backends),
71            spawn_coordinator: None,
72            backend_launcher: None,
73            session_tokens: None,
74        }
75    }
76
77    /// Attach spawn-budget coordination for backend registry misses.
78    pub fn with_spawn_coordinator(
79        mut self,
80        spawn_coordinator: &'a Mutex<SpawnCoordinator>,
81    ) -> Self {
82        self.spawn_coordinator = Some(spawn_coordinator);
83        self
84    }
85
86    /// Attach a launcher used to satisfy verified backend registry misses.
87    pub fn with_backend_launcher(mut self, backend_launcher: &'a dyn BackendLauncher) -> Self {
88        self.backend_launcher = Some(backend_launcher);
89        self
90    }
91
92    /// Attach a session-token authority (zackees/soldr#2361 Phase 2,
93    /// #2363). When configured, every successful backend launch mints and
94    /// registers that daemon's token half (`daemon_id = key.service_name`,
95    /// matching `HelloHandler`'s convention) before returning. `None` --
96    /// the default -- leaves this router's launch behavior unchanged from
97    /// before this field existed.
98    pub fn with_session_token_authority(
99        mut self,
100        session_tokens: &'a Mutex<SessionTokenAuthority>,
101    ) -> Self {
102        self.session_tokens = Some(session_tokens);
103        self
104    }
105
106    /// Decode and route a framed Hello request.
107    pub fn handle_frame(&self, frame: Frame, peer: PeerIdentity) -> HelloReply {
108        match HelloRequest::decode(frame, peer) {
109            Ok(request) => self.handle_request(&request),
110            Err(refused) => refused_reply(refused),
111        }
112    }
113
114    /// Route a decoded Hello request.
115    ///
116    /// The wire-protocol floor is checked FIRST, before any service lookup
117    /// or backend spawn (soldr#2363: a below-floor Hello must be refused at
118    /// connect and spawn nothing) — `route_request` below can reload a
119    /// service definition and launch a backend process, so the floor check
120    /// must happen before it runs, not after.
121    pub fn handle_request(&self, request: &HelloRequest) -> HelloReply {
122        if let Some(refused) = validate_hello_shape(&request.hello, &request.peer) {
123            return refused_reply(refused);
124        }
125        match self.route_request(request) {
126            Ok(registered) => match HelloHandler::new().with_backend(registered) {
127                Ok(handler) => handler.handle_request(request),
128                Err(err) => refused_reply(refused_from_handler_error(err)),
129            },
130            Err(refused) => refused_reply(refused),
131        }
132    }
133
134    fn route_request(&self, request: &HelloRequest) -> Result<RegisteredBackend, Refused> {
135        let service_definition = self
136            .service_definitions
137            .lookup_or_reload(&request.hello.service_name)
138            .map_err(refused_from_service_definition_error)?;
139
140        if let Err(block) =
141            check_version_allowed(&request.hello.wanted_version, &service_definition)
142        {
143            return Err(refused_from_version_policy(block));
144        }
145
146        let instance =
147            BrokerInstanceKey::from_service_definition(&service_definition).map_err(|err| {
148                refused(
149                    ErrorCode::ErrorInternal,
150                    format!("service isolation could not be resolved: {err}"),
151                    0,
152                )
153            })?;
154
155        // Content hash of the on-disk daemon binary this request would launch.
156        // It becomes part of the routing key so a rebuilt daemon (same version,
157        // different bytes) is a registry miss and gets its own daemon instead
158        // of being handed the resident stale-code one (running-process#894).
159        let expected_exe = on_disk_exe_hash_hex(&service_definition.binary_path);
160
161        if let Some(registered) = self.registered_backend_for(
162            &instance,
163            &service_definition,
164            &request.hello.wanted_version,
165            &expected_exe,
166        ) {
167            return Ok(registered);
168        }
169
170        let key = BackendKey::new(
171            instance,
172            request.hello.service_name.clone(),
173            request.hello.wanted_version.clone(),
174            expected_exe,
175        );
176        let trace_context = request.trace_context();
177        self.launch_backend(&key, &service_definition, &trace_context)
178    }
179
180    fn registered_backend_for(
181        &self,
182        instance: &BrokerInstanceKey,
183        service_definition: &ServiceDefinition,
184        service_version: &str,
185        expected_exe_hash: &str,
186    ) -> Option<RegisteredBackend> {
187        match self.backends {
188            BackendRegistryView::Static(registry) => registry.registered_backend_for(
189                instance,
190                service_definition,
191                service_version,
192                expected_exe_hash,
193            ),
194            BackendRegistryView::Live(registry) => {
195                let mut registry = registry
196                    .lock()
197                    .unwrap_or_else(|poisoned| poisoned.into_inner());
198                let _removed = registry.prune_stale();
199                registry.registered_backend_for(
200                    instance,
201                    service_definition,
202                    service_version,
203                    expected_exe_hash,
204                )
205            }
206        }
207    }
208
209    fn launch_backend(
210        &self,
211        key: &BackendKey,
212        service_definition: &ServiceDefinition,
213        trace_context: &TraceContext,
214    ) -> Result<RegisteredBackend, Refused> {
215        self.begin_spawn(key.clone())?;
216
217        let Some(backend_launcher) = self.backend_launcher else {
218            self.finish_spawn(key, SpawnOutcome::Failed);
219            return Err(refused(
220                ErrorCode::ErrorBackendSpawnFailed,
221                "backend is not registered",
222                1_000,
223            ));
224        };
225
226        // Mint and register this daemon's token half BEFORE spawning
227        // (zackees/soldr#2361 Phase 2, #2363), so it can ride along in the
228        // spawned process's environment (`BACKEND_ENV_SESSION_TOKEN`) --
229        // the daemon has its own valid token from the moment it starts,
230        // never a window where it exists but doesn't know it yet.
231        // `daemon_id = key.service_name`, matching `HelloHandler`'s
232        // existing convention (see `session_token.rs`'s "Not done yet").
233        // Best-effort: a mint failure (OS randomness exhausted) does not
234        // block the launch -- the daemon simply starts without a token,
235        // identical to today's behavior with no authority configured.
236        let minted_session_token = self.session_tokens.and_then(|authority| {
237            let mut authority = authority
238                .lock()
239                .unwrap_or_else(|poisoned| poisoned.into_inner());
240            authority
241                .register_daemon(key.service_name.clone())
242                .ok()
243                .map(|daemon_half| {
244                    crate::broker::server::session_token::compose_presented_token(
245                        authority.broker_token(),
246                        &daemon_half,
247                    )
248                })
249        });
250
251        let request = BackendLaunchRequest {
252            key,
253            service_definition,
254            trace_context,
255            session_token: minted_session_token.as_deref(),
256        };
257        match backend_launcher.launch(&request) {
258            Ok(handle) => match self.register_launched_backend(key, service_definition, handle) {
259                Ok(registered) => {
260                    self.finish_spawn(key, SpawnOutcome::Success);
261                    Ok(registered)
262                }
263                Err(refused) => {
264                    self.finish_spawn(key, SpawnOutcome::Failed);
265                    Err(refused)
266                }
267            },
268            Err(err) => {
269                self.finish_spawn(key, SpawnOutcome::Failed);
270                Err(refused(
271                    ErrorCode::ErrorBackendSpawnFailed,
272                    format!("backend spawn failed: {err}"),
273                    1_000,
274                ))
275            }
276        }
277    }
278
279    fn begin_spawn(&self, key: BackendKey) -> Result<(), Refused> {
280        let Some(spawn_coordinator) = self.spawn_coordinator else {
281            return Ok(());
282        };
283
284        let now = Instant::now();
285        let mut coordinator = spawn_coordinator
286            .lock()
287            .unwrap_or_else(|poisoned| poisoned.into_inner());
288        match coordinator.try_begin(key.clone(), now) {
289            Ok(_) => Ok(()),
290            Err(SpawnBeginError::AlreadyInProgress) => Err(refused(
291                ErrorCode::ErrorRateLimited,
292                "backend spawn already in progress",
293                1_000,
294            )),
295            Err(SpawnBeginError::BudgetExhausted {
296                retry_after,
297                is_storm_trip,
298                ..
299            }) => {
300                // zackees/soldr#2364: "threshold trip -> broker rotates its
301                // own token -> all clients exit 1". Gated on is_storm_trip
302                // so a flapping key that stays exhausted for the rest of
303                // its window rotates once, not once per refused request --
304                // matches "trips the guard exactly once" in the phase's
305                // testing invariants. Rotation itself is best-effort: a
306                // mint failure (OS randomness exhausted) must not turn a
307                // rate-limit refusal into an unrelated panic or error.
308                if is_storm_trip {
309                    if let Some(session_tokens) = self.session_tokens {
310                        let mut authority = session_tokens
311                            .lock()
312                            .unwrap_or_else(|poisoned| poisoned.into_inner());
313                        let _ = authority.rotate_broker_token();
314                    }
315                }
316                Err(refused(
317                    ErrorCode::ErrorRateLimited,
318                    "backend spawn budget exhausted",
319                    duration_to_retry_ms(retry_after),
320                ))
321            }
322        }
323    }
324
325    fn finish_spawn(&self, key: &BackendKey, outcome: SpawnOutcome) {
326        let Some(spawn_coordinator) = self.spawn_coordinator else {
327            return;
328        };
329
330        let mut coordinator = spawn_coordinator
331            .lock()
332            .unwrap_or_else(|poisoned| poisoned.into_inner());
333        coordinator.finish(key, outcome, Instant::now());
334    }
335
336    fn register_launched_backend(
337        &self,
338        key: &BackendKey,
339        service_definition: &ServiceDefinition,
340        handle: crate::broker::backend_handle::BackendHandle,
341    ) -> Result<RegisteredBackend, Refused> {
342        if handle.service_name != key.service_name || handle.service_version != key.service_version
343        {
344            return Err(refused(
345                ErrorCode::ErrorInternal,
346                "launched backend identity did not match request",
347                0,
348            ));
349        }
350
351        let registered = RegisteredBackend {
352            service_definition: service_definition.clone(),
353            daemon_version: handle.service_version.clone(),
354            backend_pipe: handle.daemon_process.ipc_endpoint.path.clone(),
355            server_capabilities: 0,
356        };
357
358        if let BackendRegistryView::Live(registry) = self.backends {
359            let mut registry = registry
360                .lock()
361                .unwrap_or_else(|poisoned| poisoned.into_inner());
362            registry.insert(key.instance.clone(), handle);
363        }
364
365        Ok(registered)
366    }
367}
368
369fn refused_from_service_definition_error(error: ServiceDefinitionError) -> Refused {
370    match error {
371        ServiceDefinitionError::InvalidName(_) => {
372            refused(ErrorCode::ErrorPeerRejected, "invalid service_name", 0)
373        }
374        ServiceDefinitionError::Io(err) if err.kind() == std::io::ErrorKind::NotFound => refused(
375            ErrorCode::ErrorServiceUnknown,
376            "service definition was not found",
377            0,
378        ),
379        other => refused(
380            ErrorCode::ErrorServiceUnknown,
381            format!("service definition could not be loaded: {other}"),
382            0,
383        ),
384    }
385}
386
387fn refused_from_version_policy(block: VersionPolicyBlock) -> Refused {
388    match block {
389        VersionPolicyBlock::BelowMinVersion => refused(
390            ErrorCode::ErrorVersionBlocked,
391            "wanted_version is below min_version",
392            30_000,
393        ),
394        VersionPolicyBlock::OutsideAllowList => refused(
395            ErrorCode::ErrorVersionBlocked,
396            "wanted_version is not in version_allow_list",
397            30_000,
398        ),
399    }
400}
401
402fn refused_from_handler_error(error: HelloHandlerError) -> Refused {
403    refused(
404        ErrorCode::ErrorInternal,
405        format!("registered backend could not be installed: {error}"),
406        0,
407    )
408}
409
410fn refused(code: ErrorCode, reason: impl Into<String>, retry_after_ms: u64) -> Refused {
411    Refused {
412        reason: reason.into(),
413        daemon_min_protocol: PROTOCOL_VERSION,
414        daemon_max_protocol: PROTOCOL_VERSION,
415        code: code as i32,
416        details: HashMap::new(),
417        retry_after_ms,
418    }
419}
420
421fn duration_to_retry_ms(duration: Duration) -> u64 {
422    let millis = duration.as_millis().max(1);
423    u64::try_from(millis).unwrap_or(u64::MAX)
424}
425
426fn refused_reply(refused: Refused) -> HelloReply {
427    HelloReply {
428        result: Some(HelloReplyResult::Refused(refused)),
429    }
430}
431
432/// BLAKE3 content hash (lowercase hex) of the daemon binary at `binary_path`,
433/// memoized on `(path, mtime, size)` so a hot broker does not re-hash a large,
434/// unchanged executable on every Hello. A rebuild bumps mtime (and usually
435/// size), which invalidates the cache entry and yields the new build's hash —
436/// which is exactly what makes a rebuilt daemon a routing-key miss.
437///
438/// Returns an empty string when the path cannot be stat'd or read. An empty
439/// hash never matches a registered backend (every live entry carries a real
440/// 64-hex hash from its verified identity), so the caller treats it as a miss
441/// and proceeds to launch — where the real spawn error surfaces — rather than
442/// silently reusing a stale daemon.
443fn on_disk_exe_hash_hex(binary_path: &str) -> String {
444    use crate::broker::backend_lifecycle::identity::executable_hash_file;
445    use crate::broker::server::backend_registry::hex_lower;
446    use std::path::PathBuf;
447    use std::sync::OnceLock;
448    use std::time::UNIX_EPOCH;
449
450    // path -> (mtime_nanos, size_bytes, hex_hash)
451    type ExeHashCache = Mutex<HashMap<PathBuf, (u128, u64, String)>>;
452    static CACHE: OnceLock<ExeHashCache> = OnceLock::new();
453
454    let path = PathBuf::from(binary_path);
455    let Ok(meta) = std::fs::metadata(&path) else {
456        return String::new();
457    };
458    let size = meta.len();
459    let mtime = meta
460        .modified()
461        .ok()
462        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
463        .map(|d| d.as_nanos())
464        .unwrap_or(0);
465
466    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
467    {
468        let map = cache
469            .lock()
470            .unwrap_or_else(|poisoned| poisoned.into_inner());
471        if let Some((c_mtime, c_size, hash)) = map.get(&path) {
472            if *c_mtime == mtime && *c_size == size {
473                return hash.clone();
474            }
475        }
476    }
477
478    let hash = match executable_hash_file(&path) {
479        Ok(bytes) => hex_lower(&bytes),
480        Err(_) => return String::new(),
481    };
482    cache
483        .lock()
484        .unwrap_or_else(|poisoned| poisoned.into_inner())
485        .insert(path, (mtime, size, hash.clone()));
486    hash
487}
488
489#[cfg(test)]
490mod tests {
491    use std::fs;
492
493    use prost::Message;
494
495    use crate::broker::backend_handle::{BackendHandle, DaemonProcess};
496    use crate::broker::protocol::{
497        BrokerIsolation, Endpoint, FrameKind, Hello, PayloadEncoding, ServiceDefinition,
498    };
499    use crate::broker::server::{
500        ensure_service_definition_dir, service_definition_path, PeerIdentity,
501        ServiceDefinitionLoader,
502    };
503
504    use super::*;
505
506    fn service_definition() -> ServiceDefinition {
507        let exe = std::env::current_exe().unwrap();
508        let dir = exe.parent().unwrap().to_path_buf();
509        ServiceDefinition {
510            service_name: "zccache".into(),
511            binary_path: exe.to_string_lossy().into_owned(),
512            isolation: BrokerIsolation::SharedBroker as i32,
513            explicit_instance: String::new(),
514            per_version_binary_dir: dir.to_string_lossy().into_owned(),
515            min_version: "1.10.0".into(),
516            version_allow_list: vec!["1.11.20".into()],
517            labels: Default::default(),
518        }
519    }
520
521    fn service_dir_with_definition(definition: &ServiceDefinition) -> tempfile::TempDir {
522        let tmp = tempfile::tempdir().unwrap();
523        let root = tmp.path().join("services");
524        ensure_service_definition_dir(&root).unwrap();
525        fs::write(
526            service_definition_path(&root, "zccache").unwrap(),
527            definition.encode_to_vec(),
528        )
529        .unwrap();
530        tmp
531    }
532
533    fn request() -> HelloRequest {
534        let hello = Hello {
535            client_min_protocol: 1,
536            client_max_protocol: 1,
537            service_name: "zccache".into(),
538            wanted_version: "1.11.20".into(),
539            client_version: "zccache-cli/1.11.20".into(),
540            client_capabilities: 0,
541            auth_token: Vec::new(),
542            request_id: "req-live-prune".into(),
543            connection_id: 0,
544            peer_pid: 0,
545            client_lib_name: "running-process".into(),
546            client_lib_version: env!("CARGO_PKG_VERSION").into(),
547            peer_attestation_nonce: Vec::new(),
548            capability_token: Vec::new(),
549            client_keepalive_secs: 60,
550        };
551        HelloRequest {
552            frame: Frame {
553                envelope_version: 1,
554                kind: FrameKind::Request as i32,
555                payload_protocol: 0,
556                payload: hello.encode_to_vec(),
557                request_id: 1,
558                payload_encoding: PayloadEncoding::None as i32,
559                deadline_unix_ms: 0,
560                traceparent: String::new(),
561                tracestate: String::new(),
562            },
563            hello,
564            peer: PeerIdentity {
565                pid: 0,
566                uid_or_sid: "test-peer".into(),
567            },
568        }
569    }
570
571    fn stale_backend_handle() -> BackendHandle {
572        let endpoint = Endpoint {
573            namespace_id: "shared".into(),
574            path: "rpb-v1-test-stale-backend".into(),
575        };
576        let mut daemon = DaemonProcess::current_process(endpoint, Some(30)).unwrap();
577        daemon.pid = u32::MAX;
578        BackendHandle {
579            service_name: "zccache".into(),
580            service_version: "1.11.20".into(),
581            daemon_process: daemon,
582            process_handle: None,
583        }
584    }
585
586    #[test]
587    fn live_registry_prunes_stale_backend_before_routing() {
588        let definition = service_definition();
589        let tmp = service_dir_with_definition(&definition);
590        let loader = ServiceDefinitionLoader::new(tmp.path().join("services"));
591        let mut registry = BackendRegistry::new();
592        registry.insert(BrokerInstanceKey::Shared, stale_backend_handle());
593        let registry = Mutex::new(registry);
594        let router = HelloRouter::with_lifecycle_monitor(&loader, &registry);
595
596        let reply = router.handle_request(&request());
597
598        assert!(registry.lock().unwrap().is_empty());
599        match reply.result.unwrap() {
600            HelloReplyResult::Refused(refused) => {
601                assert_eq!(
602                    ErrorCode::try_from(refused.code).unwrap(),
603                    ErrorCode::ErrorBackendSpawnFailed
604                );
605            }
606            HelloReplyResult::Negotiated(negotiated) => {
607                panic!("stale backend must not negotiate: {negotiated:?}")
608            }
609        }
610    }
611}