Skip to main content

superfluid_daemon/
fleet_manager.rs

1//! Head-side multi-node orchestration.
2
3use std::collections::BTreeMap;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{Arc, Mutex};
6
7use superfluid_proto::linkf::Lease;
8
9use crate::fleet::{FleetGeneration, FleetHead};
10use crate::wal::GenParams;
11use crate::DaemonError;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum PlacementPolicy {
15    LoadAware,
16    LeastLoaded,
17    RoundRobin,
18}
19
20#[derive(Clone, Copy, Default)]
21struct NodeLoad {
22    kv_used: u64,
23    kv_total: u64,
24    lanes_active: u32,
25    queue_depth: u32,
26    decode_tokens_per_s: u32,
27    prefill_tokens_per_s: u32,
28    at: Option<std::time::Instant>,
29}
30
31impl NodeLoad {
32    const TTL: std::time::Duration = std::time::Duration::from_secs(10);
33
34    fn fresh(&self) -> bool {
35        self.at.is_some_and(|t| t.elapsed() < Self::TTL)
36    }
37
38    fn pool_pct(&self) -> Option<u64> {
39        (self.fresh() && self.kv_total > 0).then(|| self.kv_used * 100 / self.kv_total)
40    }
41
42    /// `typical` stands in for a rate this node has not reported yet: the mean of the nodes
43    /// that have, so a node that has served nothing is neither shunned nor flooded.
44    fn eta_ms(&self, inflight: u32, prompt_tokens: u64, expected_out: u64, typical: (u64, u64)) -> u64 {
45        let ahead = if self.fresh() {
46            self.lanes_active as u64 + self.queue_depth as u64 + inflight as u64
47        } else {
48            inflight as u64
49        };
50        const NOMINAL_DECODE: u64 = 20;
51        const NOMINAL_PREFILL: u64 = 200;
52        // A rate is what the node does when busy, so it stays true while the node idles; only the
53        // load that piles up ahead of a request goes stale.
54        let or = |reported: u32, typical: u64, nominal: u64| match (reported, typical) {
55            (0, 0) => nominal,
56            (0, t) => t,
57            (r, _) => r as u64,
58        };
59        let decode = or(self.decode_tokens_per_s, typical.0, NOMINAL_DECODE);
60        let prefill = or(self.prefill_tokens_per_s, typical.1, NOMINAL_PREFILL);
61        let queued = ahead.saturating_mul(expected_out).saturating_mul(1_000) / decode;
62        let mine = prompt_tokens.saturating_mul(1_000) / prefill
63            + expected_out.saturating_mul(1_000) / decode;
64        queued.saturating_add(mine)
65    }
66}
67
68/// Keeps `node`'s live load from each status `head` reads; weak, as the node owns the head.
69fn watch(node: &Arc<NodeConn>, head: &mut FleetHead) {
70    let node = Arc::downgrade(node);
71    head.on_status(Arc::new(move |cap| {
72        if let Some(node) = node.upgrade() {
73            *node.live.lock().expect("live") = FleetManager::load_from(cap);
74        }
75    }));
76}
77
78#[derive(Debug, Clone)]
79pub struct NodeSpec {
80    pub addr: String,
81    pub auth: Vec<u8>,
82}
83
84struct NodeConn {
85    spec: NodeSpec,
86    conns: Vec<Mutex<Option<FleetHead>>>,
87    /// How many of `conns` are in use: the count asked for, or the node's lanes once learned.
88    open: std::sync::atomic::AtomicUsize,
89    cursor: std::sync::atomic::AtomicUsize,
90    /// Tokens this node has generated for the head, for `/metrics`.
91    decoded: std::sync::atomic::AtomicU64,
92    /// The last status any of its connections read, mid-generation included, for `/metrics`;
93    /// the load placement reads is synced from a connection between calls.
94    live: Mutex<NodeLoad>,
95}
96
97impl NodeMeta {
98    fn new() -> NodeMeta {
99        NodeMeta {
100            models: Vec::new(),
101            identity: String::new(),
102            peer: None,
103            down_since: None,
104            last_retry: None,
105            learned: false,
106            max_context_tokens: 0,
107            load: NodeLoad::default(),
108        }
109    }
110
111    fn up(&self) -> bool {
112        self.down_since.is_none()
113    }
114}
115
116impl NodeConn {
117    fn checkout_next(&self) -> (usize, std::sync::MutexGuard<'_, Option<FleetHead>>) {
118        let open = self.open.load(std::sync::atomic::Ordering::Relaxed).clamp(1, self.conns.len());
119        let i = self.cursor.fetch_add(1, std::sync::atomic::Ordering::Relaxed) % open;
120        (i, self.conns[i].lock().expect("node conn"))
121    }
122
123    fn checkout_at(&self, i: usize) -> std::sync::MutexGuard<'_, Option<FleetHead>> {
124        self.conns[i.min(self.conns.len() - 1)].lock().expect("node conn")
125    }
126
127    fn reset(&self) {
128        for slot in &self.conns {
129            if let Ok(mut g) = slot.try_lock() {
130                *g = None;
131            }
132        }
133    }
134}
135
136#[derive(Clone)]
137struct NodeMeta {
138    models: Vec<String>,
139    identity: String,
140    /// Where a joined node dialed from, so a second machine with its name is told apart.
141    peer: Option<std::net::IpAddr>,
142    /// Set when the node failed; requests pass it over until the background retry connects.
143    down_since: Option<std::time::Instant>,
144    last_retry: Option<std::time::Instant>,
145    learned: bool,
146    max_context_tokens: u64,
147    load: NodeLoad,
148}
149
150#[derive(Debug, Clone)]
151struct Placed {
152    node: usize,
153    params: GenParams,
154    qos_class: u8,
155    codec: String,
156    model: String,
157    tokens: Vec<u32>,
158    slot: usize,
159    epoch: u64,
160    parked: bool,
161}
162
163struct Shared {
164    meta: Vec<NodeMeta>,
165    /// Finished sessions to revoke on a node: (node, slot, session, epoch).
166    forgets: Vec<(usize, usize, u64, u64)>,
167    sessions: BTreeMap<u64, Placed>,
168    rr_cursor: usize,
169    cancels: BTreeMap<u64, Arc<AtomicBool>>,
170    inflight: Vec<u32>,
171    claims: BTreeMap<u64, usize>,
172}
173
174pub struct FleetManager {
175    nodes: std::sync::RwLock<Vec<Arc<NodeConn>>>,
176    conns_by_lanes: bool,
177    retry_every: std::time::Duration,
178    last_keepalive: Mutex<std::time::Instant>,
179    shared: Mutex<Shared>,
180    policy: PlacementPolicy,
181    lease: Lease,
182    pool_high_pct: u32,
183}
184
185struct InFlight<'a> {
186    shared: &'a Mutex<Shared>,
187    node: usize,
188    session: u64,
189}
190
191impl<'a> InFlight<'a> {
192    fn claim(shared: &'a Mutex<Shared>, node: usize, session: u64) {
193        let mut sh = shared.lock().expect("shared");
194        if let Some(prev) = sh.claims.insert(session, node) {
195            if prev != node {
196                if let Some(n) = sh.inflight.get_mut(prev) {
197                    *n = n.saturating_sub(1);
198                }
199            } else {
200                return;
201            }
202        }
203        if let Some(n) = sh.inflight.get_mut(node) {
204            *n = n.saturating_add(1);
205        }
206    }
207
208    fn release(shared: &Mutex<Shared>, session: u64) {
209        let mut sh = shared.lock().expect("shared");
210        if let Some(node) = sh.claims.remove(&session) {
211            if let Some(n) = sh.inflight.get_mut(node) {
212                *n = n.saturating_sub(1);
213            }
214        }
215    }
216
217    fn guard(shared: &'a Mutex<Shared>, node: usize, session: u64) -> InFlight<'a> {
218        Self::claim(shared, node, session);
219        InFlight { shared, node, session }
220    }
221}
222
223impl Drop for InFlight<'_> {
224    fn drop(&mut self) {
225        let _ = self.node;
226        Self::release(self.shared, self.session);
227    }
228}
229
230#[derive(Clone, Copy, Default)]
231pub struct RequestShape {
232    pub prompt_tokens: u64,
233    pub expected_out: u64,
234}
235
236pub const DEFAULT_POOL_HIGH_PCT: u32 = 90;
237
238/// 0: as many connections to a node as it has lanes, so each lane can be busy at once.
239pub const DEFAULT_CONNS_PER_NODE: usize = 0;
240
241const MAX_CONNS_PER_NODE: usize = 64;
242
243/// The address a joined node is listed under: it dialed in, so the head cannot dial it.
244const JOINED: &str = "joined:";
245
246/// How often the background retry tries a node that failed.
247pub const RETRY_DOWN_AFTER: std::time::Duration = std::time::Duration::from_secs(10);
248
249/// Under the node's idle timeout (120 s by default), so an idle head keeps its connections.
250pub const KEEPALIVE_EVERY: std::time::Duration = std::time::Duration::from_secs(15);
251
252/// One node as the head sees it now, for `/metrics`.
253#[derive(Debug, Clone)]
254pub struct NodeStatus {
255    /// The node's identity, or the address the head dials while it has not answered.
256    pub name: String,
257    /// The address it joined from or is dialed at.
258    pub addr: String,
259    pub up: bool,
260    /// Connections open to it: its lanes, once it answered.
261    pub lanes: usize,
262    /// Generations the head has in flight there.
263    pub lanes_active: u32,
264    pub queue_depth: u32,
265    pub kv_blocks_used: u64,
266    pub kv_blocks_total: u64,
267    pub decode_tokens: u64,
268}
269
270#[derive(Debug, Clone)]
271pub struct NodeReport {
272    pub addr: String,
273    pub identity: String,
274    pub models: Vec<String>,
275    pub max_context_tokens: u64,
276    pub max_lanes: u32,
277}
278
279impl FleetManager {
280    pub fn new(nodes: Vec<NodeSpec>, policy: PlacementPolicy, lease: Lease) -> FleetManager {
281        Self::with_pool_high(nodes, policy, lease, DEFAULT_POOL_HIGH_PCT)
282    }
283
284    pub fn with_pool_high(
285        nodes: Vec<NodeSpec>,
286        policy: PlacementPolicy,
287        lease: Lease,
288        pool_high_pct: u32,
289    ) -> FleetManager {
290        Self::with_options(nodes, policy, lease, pool_high_pct, DEFAULT_CONNS_PER_NODE)
291    }
292
293    pub fn with_options(
294        nodes: Vec<NodeSpec>,
295        policy: PlacementPolicy,
296        lease: Lease,
297        pool_high_pct: u32,
298        conns_per_node: usize,
299    ) -> FleetManager {
300        let meta = vec![NodeMeta::new(); nodes.len()];
301        let node_count = nodes.len();
302        let nodes = nodes
303            .into_iter()
304            .map(|spec| {
305                Arc::new(NodeConn {
306                    spec,
307                    conns: (0..if conns_per_node == 0 { MAX_CONNS_PER_NODE } else { conns_per_node })
308                        .map(|_| Mutex::new(None))
309                        .collect(),
310                    open: std::sync::atomic::AtomicUsize::new(conns_per_node.max(1)),
311                    cursor: std::sync::atomic::AtomicUsize::new(0),
312                    decoded: std::sync::atomic::AtomicU64::new(0),
313                    live: Mutex::new(NodeLoad::default()),
314                })
315            })
316            .collect();
317        FleetManager {
318            nodes: std::sync::RwLock::new(nodes),
319            conns_by_lanes: conns_per_node == 0,
320            retry_every: RETRY_DOWN_AFTER,
321            last_keepalive: Mutex::new(std::time::Instant::now()),
322            shared: Mutex::new(Shared {
323                meta,
324                sessions: BTreeMap::new(),
325                forgets: Vec::new(),
326                rr_cursor: 0,
327            inflight: vec![0; node_count],
328                claims: BTreeMap::new(),
329                cancels: BTreeMap::new(),
330            }),
331            policy,
332            lease,
333            pool_high_pct: pool_high_pct.clamp(1, 100),
334        }
335    }
336
337    fn node(&self, i: usize) -> Arc<NodeConn> {
338        Arc::clone(&self.nodes.read().expect("nodes")[i])
339    }
340
341    fn node_count(&self) -> usize {
342        self.nodes.read().expect("nodes").len()
343    }
344
345    /// Takes in a node that dialed the head: one seen before, by identity, gets its slot
346    /// back; a new one gets a slot of its own. `true` for a new one.
347    /// Takes in a connection from a node that dialed the head. The node has a slot per lane it
348    /// advertised and fills one per connection it opens, so it runs as many generations at
349    /// once as it dialed; a node seen before, by identity, takes its slots back, unless it is
350    /// still connected from another address, which is a second machine with the same name.
351    /// Returns the node's index, whether it is new, and its open connections.
352    pub fn adopt(&self, head: FleetHead, peer: std::net::IpAddr) -> Result<(usize, bool, usize), String> {
353        let identity = head.node().node_identity.clone();
354        let lanes = (head.node().capacity.max_lanes as usize).max(1);
355        let (i, new) = {
356            let mut sh = self.shared.lock().expect("shared");
357            match sh.meta.iter().position(|m| m.identity == identity) {
358                Some(i) => {
359                    let node = self.node(i);
360                    let connected = node.conns.iter().any(|c| c.lock().expect("node conn").is_some());
361                    if connected && sh.meta[i].peer.is_some_and(|p| p != peer) {
362                        return Err(format!(
363                            "another node named '{identity}' is joined from {}; give this one --identity",
364                            sh.meta[i].peer.expect("peer")
365                        ));
366                    }
367                    sh.meta[i].peer = Some(peer);
368                    (i, false)
369                }
370                None => {
371                    let mut nodes = self.nodes.write().expect("nodes");
372                    nodes.push(Arc::new(NodeConn {
373                        spec: NodeSpec { addr: format!("{JOINED}{identity}"), auth: Vec::new() },
374                        conns: (0..lanes).map(|_| Mutex::new(None)).collect(),
375                        open: std::sync::atomic::AtomicUsize::new(1),
376                        cursor: std::sync::atomic::AtomicUsize::new(0),
377                        decoded: std::sync::atomic::AtomicU64::new(0),
378                        live: Mutex::new(NodeLoad::default()),
379                    }));
380                    // Listed as down until its connection is in place, so no request lands
381                    // on an empty slot meanwhile.
382                    let mut meta = NodeMeta::new();
383                    meta.identity = identity.clone();
384                    meta.peer = Some(peer);
385                    meta.down_since = Some(std::time::Instant::now());
386                    sh.meta.push(meta);
387                    sh.inflight.push(0);
388                    (nodes.len() - 1, true)
389                }
390            }
391        };
392        self.sync_meta(i, &head);
393        let node = self.node(i);
394        let mut head = head;
395        watch(&node, &mut head);
396        let mut slot = 0;
397        for (s, conn) in node.conns.iter().enumerate() {
398            if conn.lock().expect("node conn").is_none() {
399                slot = s;
400                break;
401            }
402        }
403        *node.checkout_at(slot) = Some(head);
404        let open = node.conns.iter().filter(|c| c.lock().expect("node conn").is_some()).count();
405        node.open.store(open.max(1), std::sync::atomic::Ordering::Relaxed);
406        if new {
407            // Listed as down only until this connection was in place: not a return.
408            self.shared.lock().expect("shared").meta[i].down_since = None;
409        } else {
410            self.mark_up(i);
411        }
412        Ok((i, new, open))
413    }
414
415    /// The connections a node has open.
416    pub fn connections(&self, i: usize) -> usize {
417        self.node(i).open.load(std::sync::atomic::Ordering::Relaxed)
418    }
419
420    pub fn load(&self) -> Vec<usize> {
421        let sh = self.shared.lock().expect("shared");
422        let mut counts = vec![0usize; self.node_count()];
423        for p in sh.sessions.values() {
424            if !p.parked {
425                counts[p.node] += 1;
426            }
427        }
428        counts
429    }
430
431    /// Every node the head knows, in the order it learned them.
432    pub fn status(&self) -> Vec<NodeStatus> {
433        let nodes: Vec<Arc<NodeConn>> = self.nodes.read().expect("nodes").clone();
434        let sh = self.shared.lock().expect("shared");
435        nodes
436            .iter()
437            .zip(&sh.meta)
438            .zip(&sh.inflight)
439            .map(|((node, meta), &inflight)| {
440                let live = *node.live.lock().expect("live");
441                let load = if live.at > meta.load.at { live } else { meta.load };
442                let addr = match (&meta.peer, node.spec.addr.strip_prefix(JOINED)) {
443                    (Some(peer), Some(_)) => peer.to_string(),
444                    _ => node.spec.addr.clone(),
445                };
446                NodeStatus {
447                    name: if meta.identity.is_empty() { addr.clone() } else { meta.identity.clone() },
448                    addr,
449                    up: meta.up() && meta.learned,
450                    lanes: node.open.load(Ordering::Relaxed),
451                    lanes_active: inflight,
452                    queue_depth: load.queue_depth,
453                    kv_blocks_used: load.kv_used,
454                    kv_blocks_total: load.kv_total,
455                    decode_tokens: node.decoded.load(Ordering::Relaxed),
456                }
457            })
458            .collect()
459    }
460
461    pub fn node_of(&self, session: u64) -> Option<String> {
462        let sh = self.shared.lock().expect("shared");
463        let node = sh.sessions.get(&session)?.node;
464        Some(sh.meta[node].identity.clone())
465    }
466
467    /// How often a node that failed is tried again in the background (default
468    /// [`RETRY_DOWN_AFTER`]).
469    pub fn with_retry_every(mut self, every: std::time::Duration) -> FleetManager {
470        self.retry_every = every;
471        self
472    }
473
474    fn mark_unhealthy(&self, i: usize, why: &dyn std::fmt::Display) {
475        let mut sh = self.shared.lock().expect("shared");
476        if sh.meta[i].down_since.is_none() {
477            let now = std::time::Instant::now();
478            sh.meta[i].down_since = Some(now);
479            sh.meta[i].last_retry = Some(now);
480            tracing::warn!("fleet: node {} is down ({why}); retrying it in the background", self.label(&sh, i));
481        }
482    }
483
484    fn mark_up(&self, i: usize) {
485        let mut sh = self.shared.lock().expect("shared");
486        if let Some(since) = sh.meta[i].down_since.take() {
487            tracing::info!("fleet: node {} is back after {} s", self.label(&sh, i), since.elapsed().as_secs());
488        }
489    }
490
491    fn label(&self, sh: &Shared, i: usize) -> String {
492        let node = self.node(i);
493        let addr = &node.spec.addr;
494        match sh.meta[i].identity.as_str() {
495            "" => addr.clone(),
496            id => format!("{id} ({addr})"),
497        }
498    }
499
500    fn identity(&self, i: usize) -> String {
501        let sh = self.shared.lock().expect("shared");
502        self.label(&sh, i)
503    }
504
505    fn active_node(&self, session: u64) -> Result<usize, DaemonError> {
506        let sh = self.shared.lock().expect("shared");
507        let p = sh
508            .sessions
509            .get(&session)
510            .ok_or(DaemonError::UnknownSession(session))?;
511        if p.parked {
512            return Err(DaemonError::Config("session is parked; resume it first"));
513        }
514        Ok(p.node)
515    }
516
517    fn ensure_connected(&self, i: usize, guard: &mut Option<FleetHead>) -> Result<(), DaemonError> {
518        if guard.is_some() {
519            return Ok(());
520        }
521        let node = self.node(i);
522        let spec = &node.spec;
523        if spec.addr.starts_with(JOINED) {
524            let e = DaemonError::FleetNode { addr: spec.addr.clone(), why: "left; waiting for it to join again".into() };
525            self.mark_unhealthy(i, &e);
526            return Err(e);
527        }
528        match FleetHead::connect(&spec.addr, spec.auth.clone()) {
529            Ok(mut head) => {
530                watch(&node, &mut head);
531                self.sync_meta(i, &head);
532                *guard = Some(head);
533                Ok(())
534            }
535            Err(e) => {
536                self.mark_unhealthy(i, &e);
537                Err(e)
538            }
539        }
540    }
541
542    /// A fresh connection knows none of the sessions placed over the one it replaced; this
543    /// places the session on it again from what the head kept.
544    fn rebind(&self, session: u64, head: &mut FleetHead) -> Result<(), DaemonError> {
545        if head.knows(session) {
546            return Ok(());
547        }
548        let p = self
549            .shared
550            .lock()
551            .expect("shared")
552            .sessions
553            .get(&session)
554            .cloned()
555            .ok_or(DaemonError::UnknownSession(session))?;
556        let epoch = head.assign(session, p.params, p.qos_class, &p.codec, self.lease)?;
557        if !p.tokens.is_empty() {
558            head.append(session, &p.tokens)?;
559        }
560        if let Some(p) = self.shared.lock().expect("shared").sessions.get_mut(&session) {
561            p.epoch = epoch;
562        }
563        Ok(())
564    }
565
566    /// Rebinds the session on the slot's connection, dropping the connection only when the link
567    /// itself failed.
568    fn rebind_on(&self, session: u64, guard: &mut Option<FleetHead>) -> Result<(), DaemonError> {
569        let r = self.rebind(session, guard.as_mut().expect("connected"));
570        if let Err(e) = &r {
571            if is_fatal_node(e) {
572                *guard = None;
573            }
574        }
575        r
576    }
577
578    pub fn advertised_context(&self, model: &str) -> Option<u64> {
579        let sh = self.shared.lock().expect("shared");
580        (0..self.node_count())
581            .filter(|&i| sh.meta[i].up() && Self::serves(&sh, i, model))
582            .map(|i| sh.meta[i].max_context_tokens)
583            .filter(|&c| c > 0)
584            .max()
585    }
586
587    fn serves(sh: &Shared, i: usize, model: &str) -> bool {
588        !sh.meta[i].learned || sh.meta[i].models.iter().any(|m| m == model)
589    }
590
591    fn holds(sh: &Shared, i: usize, len: u64) -> bool {
592        let max = sh.meta[i].max_context_tokens;
593        max == 0 || len <= max
594    }
595
596    fn length_refusal(&self, cand: &[usize], model: &str, len: u64) -> Option<DaemonError> {
597        let sh = self.shared.lock().expect("shared");
598        let eligible: Vec<usize> =
599            cand.iter().copied().filter(|&i| Self::serves(&sh, i, model)).collect();
600        if eligible.is_empty() || eligible.iter().any(|&i| Self::holds(&sh, i, len)) {
601            return None;
602        }
603        Some(DaemonError::StreamTooLong {
604            len,
605            max: eligible.iter().map(|&i| sh.meta[i].max_context_tokens).max().unwrap_or(0),
606        })
607    }
608
609    fn sync_meta(&self, i: usize, head: &FleetHead) {
610        let mut sh = self.shared.lock().expect("shared");
611        let cap = &head.node().capacity;
612        sh.meta[i].models = head.node().loadable_models.clone();
613        sh.meta[i].max_context_tokens = cap.max_context_tokens;
614        sh.meta[i].identity = head.node().node_identity.clone();
615        sh.meta[i].learned = true;
616        sh.meta[i].load = Self::load_from(cap);
617        let node = self.node(i);
618        if self.conns_by_lanes && !node.spec.addr.starts_with(JOINED) {
619            let (lanes, pool) = (cap.max_lanes.max(1) as usize, node.conns.len());
620            let was = node.open.swap(lanes.min(pool), std::sync::atomic::Ordering::Relaxed);
621            if lanes > pool && was != pool {
622                tracing::warn!(
623                    "fleet: node {} has {lanes} lanes; the head opens at most {pool} connections to it",
624                    self.label(&sh, i)
625                );
626            }
627        }
628    }
629
630    fn load_from(cap: &superfluid_proto::handshake::CapacitySummary) -> NodeLoad {
631        NodeLoad {
632            kv_used: cap.kv_blocks_used,
633            kv_total: cap.kv_blocks_total,
634            lanes_active: cap.lanes_active,
635            queue_depth: cap.queue_depth,
636            decode_tokens_per_s: cap.decode_tokens_per_s,
637            prefill_tokens_per_s: cap.prefill_tokens_per_s,
638            at: Some(std::time::Instant::now()),
639        }
640    }
641
642    fn replacement_shape(tokens: &[u32], expected_out: u64) -> RequestShape {
643        RequestShape {
644            prompt_tokens: tokens.len() as u64,
645            expected_out,
646        }
647    }
648
649    fn candidates_claiming(
650        &self,
651        model: &str,
652        claim_for: Option<u64>,
653        shape: RequestShape,
654    ) -> Vec<usize> {
655        let mut sh = self.shared.lock().expect("shared");
656        let mut load = vec![0usize; self.node_count()];
657        for p in sh.sessions.values() {
658            if !p.parked {
659                load[p.node] += 1;
660            }
661        }
662        let mut cand: Vec<usize> = (0..self.node_count())
663            .filter(|&i| sh.meta[i].up() && Self::serves(&sh, i, model))
664            .collect();
665        match self.policy {
666            PlacementPolicy::LoadAware => {
667                let fits: Vec<usize> = cand
668                    .iter()
669                    .copied()
670                    .filter(|&i| Self::holds(&sh, i, shape.prompt_tokens))
671                    .collect();
672                let judged: &[usize] = if fits.is_empty() { &cand } else { &fits };
673                let hot: Vec<usize> = judged
674                    .iter()
675                    .copied()
676                    .filter(|&i| {
677                        sh.meta[i].load.pool_pct().is_some_and(|p| p >= self.pool_high_pct as u64)
678                    })
679                    .collect();
680                if hot.len() < judged.len() {
681                    cand.retain(|i| !hot.contains(i));
682                }
683                let inflight = sh.inflight.clone();
684                let mean = |rate: fn(&NodeLoad) -> u32| {
685                    let known: Vec<u64> =
686                        cand.iter().map(|&i| rate(&sh.meta[i].load) as u64).filter(|&r| r > 0).collect();
687                    known.iter().sum::<u64>() / (known.len() as u64).max(1)
688                };
689                let typical = (mean(|l| l.decode_tokens_per_s), mean(|l| l.prefill_tokens_per_s));
690                cand.sort_by_key(|&i| {
691                    (
692                        sh.meta[i].load.eta_ms(inflight[i], shape.prompt_tokens, shape.expected_out, typical),
693                        load[i],
694                    )
695                });
696            }
697            PlacementPolicy::LeastLoaded => {
698                let inflight = sh.inflight.clone();
699                cand.sort_by_key(|&i| load[i] + inflight[i] as usize);
700            }
701            PlacementPolicy::RoundRobin => {
702                let n = self.node_count().max(1);
703                let c = sh.rr_cursor % n;
704                cand.sort_by_key(|&i| (i + n - c) % n);
705                sh.rr_cursor = sh.rr_cursor.wrapping_add(1);
706            }
707        }
708        if let (Some(session), Some(&winner)) = (claim_for, cand.first()) {
709            Self::claim_locked(&mut sh, winner, session);
710        }
711        cand
712    }
713
714    /// The slot's connection, dropped first if the node closed it while it sat idle: a write
715    /// into a closed socket can look like it succeeded.
716    fn checkout_live(node_conn: &NodeConn, slot: usize) -> std::sync::MutexGuard<'_, Option<FleetHead>> {
717        let mut guard = node_conn.checkout_at(slot);
718        if guard.as_mut().is_some_and(|h| h.drain_status().is_err()) {
719            *guard = None;
720        }
721        guard
722    }
723
724    fn slot_of(&self, session: u64) -> usize {
725        self.shared
726            .lock()
727            .expect("shared")
728            .sessions
729            .get(&session)
730            .map(|p| p.slot)
731            .unwrap_or(0)
732    }
733
734    fn claim_locked(sh: &mut Shared, node: usize, session: u64) {
735        if let Some(prev) = sh.claims.insert(session, node) {
736            if prev == node {
737                return;
738            }
739            if let Some(n) = sh.inflight.get_mut(prev) {
740                *n = n.saturating_sub(1);
741            }
742        }
743        if let Some(n) = sh.inflight.get_mut(node) {
744            *n = n.saturating_add(1);
745        }
746    }
747
748    #[allow(clippy::too_many_arguments)]
749    pub fn place(
750        &self,
751        session: u64,
752        params: GenParams,
753        qos_class: u8,
754        codec: &str,
755        model: &str,
756        tokens: &[u32],
757        expected_out: u64,
758    ) -> Result<usize, DaemonError> {
759        let cand = self.candidates_claiming(
760            model,
761            Some(session),
762            RequestShape {
763                prompt_tokens: tokens.len() as u64,
764                expected_out,
765            },
766        );
767        let release_on_error = |e: DaemonError| {
768            InFlight::release(&self.shared, session);
769            e
770        };
771        if cand.is_empty() {
772            return Err(release_on_error(DaemonError::Config(
773                "no healthy node can serve the model",
774            )));
775        }
776        let len = tokens.len() as u64;
777        if let Some(e) = self.length_refusal(&cand, model, len) {
778            return Err(release_on_error(e));
779        }
780        let mut last_err = None;
781        for &i in &cand {
782            match self.try_place_on(i, session, params, qos_class, codec, model, tokens) {
783                Ok(true) => {
784                    tracing::info!(
785                        "fleet: session {session} ({} prompt tokens) placed on {}",
786                        tokens.len(),
787                        self.identity(i)
788                    );
789                    return Ok(i);
790                }
791                Ok(false) => continue,
792                Err(e) => {
793                    self.mark_unhealthy(i, &e);
794                    self.node(i).reset();
795                    last_err = Some(e);
796                }
797            }
798        }
799        if let Some(e) = self.length_refusal(&cand, model, len) {
800            return Err(release_on_error(e));
801        }
802        Err(release_on_error(last_err.unwrap_or(DaemonError::Config(
803            "no reachable node serves the model",
804        ))))
805    }
806
807    #[allow(clippy::too_many_arguments)]
808    fn try_place_on(
809        &self,
810        i: usize,
811        session: u64,
812        params: GenParams,
813        qos_class: u8,
814        codec: &str,
815        model: &str,
816        tokens: &[u32],
817    ) -> Result<bool, DaemonError> {
818        let node = self.node(i);
819        let (slot, mut guard) = node.checkout_next();
820        if let Some(h) = guard.as_mut() {
821            if h.drain_status().is_err() {
822                *guard = None;
823            }
824        }
825        self.ensure_connected(i, &mut guard)?;
826        self.flush_forgets(i, slot, guard.as_mut().expect("connected"));
827        self.sync_meta(i, guard.as_ref().expect("connected"));
828        {
829            let sh = self.shared.lock().expect("shared");
830            if !Self::serves(&sh, i, model) {
831                return Ok(false);
832            }
833            if !Self::holds(&sh, i, tokens.len() as u64) {
834                return Ok(false);
835            }
836        }
837        let head = guard.as_mut().expect("connected");
838        let epoch = match head.assign(session, params, qos_class, codec, self.lease) {
839            Ok(epoch) => epoch,
840            Err(e) => {
841                *guard = None;
842                return Err(e);
843            }
844        };
845        if !tokens.is_empty() {
846            if let Err(e) = head.append(session, tokens) {
847                *guard = None;
848                return Err(e);
849            }
850        }
851        InFlight::claim(&self.shared, i, session);
852        self.shared.lock().expect("shared").sessions.insert(
853            session,
854            Placed {
855                node: i,
856                params,
857                qos_class,
858                codec: codec.to_string(),
859                model: model.to_string(),
860                tokens: tokens.to_vec(),
861                slot,
862                epoch,
863                parked: false,
864            },
865        );
866        Ok(true)
867    }
868
869    pub fn append(&self, session: u64, tokens: &[u32]) -> Result<(), DaemonError> {
870        let node = self.active_node(session)?;
871        let len = tokens.len() as u64;
872        let fits = {
873            let sh = self.shared.lock().expect("shared");
874            Self::holds(&sh, node, len)
875        };
876        if !fits {
877            return self.re_place_grown(session, node, tokens);
878        }
879        {
880            let node_conn = self.node(node);
881            let mut guard = Self::checkout_live(&node_conn, self.slot_of(session));
882            self.ensure_connected(node, &mut guard)?;
883            self.rebind_on(session, &mut guard)?;
884            let head = guard.as_mut().expect("connected");
885            if let Err(e) = head.append(session, tokens) {
886                *guard = None;
887                return Err(e);
888            }
889            self.sync_meta(node, head);
890        }
891        if let Some(p) = self.shared.lock().expect("shared").sessions.get_mut(&session) {
892            p.tokens = tokens.to_vec();
893        }
894        Ok(())
895    }
896
897    fn re_place_grown(
898        &self,
899        session: u64,
900        from: usize,
901        tokens: &[u32],
902    ) -> Result<(), DaemonError> {
903        let placed = {
904            let sh = self.shared.lock().expect("shared");
905            sh.sessions.get(&session).cloned().ok_or(DaemonError::UnknownSession(session))?
906        };
907        let cand: Vec<usize> = self
908            .candidates_claiming(
909                &placed.model,
910                Some(session),
911                Self::replacement_shape(tokens, 0),
912            )
913            .into_iter()
914            .filter(|&i| i != from)
915            .collect();
916        let len = tokens.len() as u64;
917        if let Some(e) = self.length_refusal(&cand, &placed.model, len) {
918            return Err(e);
919        }
920        for &i in &cand {
921            match self.try_place_on(
922                i,
923                session,
924                placed.params,
925                placed.qos_class,
926                &placed.codec,
927                &placed.model,
928                tokens,
929            ) {
930                Ok(true) => return Ok(()),
931                Ok(false) => continue,
932                Err(e) => {
933                    self.mark_unhealthy(i, &e);
934                    self.node(i).reset();
935                }
936            }
937        }
938        if let Some(e) = self.length_refusal(&cand, &placed.model, len) {
939            return Err(e);
940        }
941        Err(DaemonError::Config(
942            "no reachable node can hold the session's grown context",
943        ))
944    }
945
946    pub fn generate(
947        &self,
948        session: u64,
949        max_new_tokens: u64,
950        max_wall_ms: u64,
951    ) -> Result<FleetGeneration, DaemonError> {
952        let node = self.active_node(session)?;
953
954        match self.generate_on(node, session, max_new_tokens, max_wall_ms) {
955            Ok(g) => return Ok(g),
956            Err(e) if !is_fatal_node(&e) => return Err(e),
957            Err(e) => self.mark_unhealthy(node, &e),
958        }
959        self.node(node).reset();
960        let placed = self
961            .shared
962            .lock()
963            .expect("shared")
964            .sessions
965            .get(&session)
966            .cloned()
967            .expect("placed session");
968        let cand: Vec<usize> = self
969            .candidates_claiming(
970                &placed.model,
971                Some(session),
972                Self::replacement_shape(&placed.tokens, max_new_tokens),
973            )
974            .into_iter()
975            .filter(|&i| i != node)
976            .collect();
977        for i in cand {
978            match self.try_place_on(
979                i,
980                session,
981                placed.params,
982                placed.qos_class,
983                &placed.codec,
984                &placed.model,
985                &placed.tokens,
986            ) {
987                Ok(true) => {}
988                Ok(false) => continue,
989                Err(e) => {
990                    self.mark_unhealthy(i, &e);
991                    self.node(i).reset();
992                    continue;
993                }
994            }
995            tracing::warn!("fleet: session {session} moved to {}", self.identity(i));
996            match self.generate_on(i, session, max_new_tokens, max_wall_ms) {
997                Ok(g) => return Ok(g),
998                Err(e) => self.mark_unhealthy(i, &e),
999            }
1000            self.node(i).reset();
1001        }
1002        Err(DaemonError::Generation(
1003            "generation failed and no healthy node could take over the session".into(),
1004        ))
1005    }
1006
1007    fn map_refusal(
1008        &self,
1009        node: usize,
1010        session: u64,
1011        r: Result<FleetGeneration, DaemonError>,
1012    ) -> Result<FleetGeneration, DaemonError> {
1013        let g = r?;
1014        if g.finish == crate::nodeagent::FINISH_DECLINED {
1015            return Err(DaemonError::Generation(format!(
1016                "node {} declined session {session} (stale epoch or replayed command)",
1017                self.shared.lock().expect("shared").meta[node].identity
1018            )));
1019        }
1020        if g.finish != crate::nodeagent::FINISH_REFUSED {
1021            return Ok(g);
1022        }
1023        let sh = self.shared.lock().expect("shared");
1024        let len = sh.sessions.get(&session).map(|p| p.tokens.len() as u64).unwrap_or(0);
1025        Err(DaemonError::StreamTooLong {
1026            len,
1027            max: sh.meta[node].max_context_tokens,
1028        })
1029    }
1030
1031    fn generate_on(
1032        &self,
1033        node: usize,
1034        session: u64,
1035        max_new_tokens: u64,
1036        max_wall_ms: u64,
1037    ) -> Result<FleetGeneration, DaemonError> {
1038        let _inflight = InFlight::guard(&self.shared, node, session);
1039        let node_conn = self.node(node);
1040        let mut guard = Self::checkout_live(&node_conn, self.slot_of(session));
1041        self.ensure_connected(node, &mut guard)?;
1042        self.rebind_on(session, &mut guard)?;
1043        let head = guard.as_mut().expect("connected");
1044        let r = head.generate(session, max_new_tokens, max_wall_ms);
1045        if let Ok(g) = &r {
1046            node_conn.decoded.fetch_add(g.tokens.len() as u64, Ordering::Relaxed);
1047        }
1048        if let Err(e) = &r {
1049            if is_fatal_node(e) {
1050                *guard = None;
1051            }
1052        }
1053        if let Some(head) = guard.as_ref() {
1054            self.sync_meta(node, head);
1055        }
1056        drop(guard);
1057        self.map_refusal(node, session, r)
1058    }
1059
1060    pub fn generate_streaming(
1061        &self,
1062        session: u64,
1063        max_new_tokens: u64,
1064        max_wall_ms: u64,
1065        mut on_tokens: impl FnMut(&[u32]),
1066    ) -> Result<FleetGeneration, DaemonError> {
1067        let (node, flag) = {
1068            let mut sh = self.shared.lock().expect("shared");
1069            let p = sh
1070                .sessions
1071                .get(&session)
1072                .ok_or(DaemonError::UnknownSession(session))?;
1073            if p.parked {
1074                return Err(DaemonError::Config("session is parked; resume it first"));
1075            }
1076            let node = p.node;
1077            let flag = sh
1078                .cancels
1079                .entry(session)
1080                .or_insert_with(|| Arc::new(AtomicBool::new(false)))
1081                .clone();
1082            flag.store(false, Ordering::Relaxed);
1083            (node, flag)
1084        };
1085
1086        let mut all_tokens: Vec<u32> = Vec::new();
1087
1088        match self.generate_streaming_on(node, session, max_new_tokens, max_wall_ms, &flag, |b| {
1089            all_tokens.extend_from_slice(b);
1090            on_tokens(b);
1091        }) {
1092            Ok(g) => return Ok(g),
1093            Err(e) if !is_fatal_node(&e) => return Err(e),
1094            Err(e) => self.mark_unhealthy(node, &e),
1095        }
1096
1097        let placed = self
1098            .shared
1099            .lock()
1100            .expect("shared")
1101            .sessions
1102            .get(&session)
1103            .cloned()
1104            .expect("placed session");
1105        let input = placed.tokens.clone();
1106        let mut tried = vec![node];
1107        loop {
1108            let remaining = max_new_tokens.saturating_sub(all_tokens.len() as u64);
1109            if remaining == 0 {
1110                return Ok(FleetGeneration {
1111                    tokens: all_tokens,
1112                    finish: superfluid_abi::finish::LENGTH,
1113                    expired: false,
1114                });
1115            }
1116            let cand: Vec<usize> = self
1117                .candidates_claiming(
1118                    &placed.model,
1119                    Some(session),
1120                    Self::replacement_shape(&input, remaining),
1121                )
1122                .into_iter()
1123                .filter(|i| !tried.contains(i))
1124                .collect();
1125            if cand.is_empty() {
1126                return Err(DaemonError::Generation(
1127                    "streaming generation failed and no healthy node could take over".into(),
1128                ));
1129            }
1130            let i = cand[0];
1131            tried.push(i);
1132            let mut ctx = input.clone();
1133            ctx.extend_from_slice(&all_tokens);
1134            match self.try_place_on(
1135                i,
1136                session,
1137                placed.params,
1138                placed.qos_class,
1139                &placed.codec,
1140                &placed.model,
1141                &ctx,
1142            ) {
1143                Ok(true) => {}
1144                Ok(false) => continue,
1145                Err(e) => {
1146                    self.mark_unhealthy(i, &e);
1147                    self.node(i).reset();
1148                    continue;
1149                }
1150            }
1151            tracing::warn!(
1152                "fleet: session {session} moved to {} after {} streamed tokens",
1153                self.identity(i),
1154                all_tokens.len()
1155            );
1156            match self.generate_streaming_on(i, session, remaining, max_wall_ms, &flag, |b| {
1157                all_tokens.extend_from_slice(b);
1158                on_tokens(b);
1159            }) {
1160                Ok(g) => {
1161                    return Ok(FleetGeneration {
1162                        tokens: all_tokens,
1163                        finish: g.finish,
1164                        expired: g.expired,
1165                    })
1166                }
1167                Err(e) if !is_fatal_node(&e) => return Err(e),
1168                Err(e) => self.mark_unhealthy(i, &e),
1169            }
1170        }
1171    }
1172
1173    fn generate_streaming_on(
1174        &self,
1175        node: usize,
1176        session: u64,
1177        max_new_tokens: u64,
1178        max_wall_ms: u64,
1179        flag: &AtomicBool,
1180        mut on_tokens: impl FnMut(&[u32]),
1181    ) -> Result<FleetGeneration, DaemonError> {
1182        let _inflight = InFlight::guard(&self.shared, node, session);
1183        let node_conn = self.node(node);
1184        let mut guard = Self::checkout_live(&node_conn, self.slot_of(session));
1185        self.ensure_connected(node, &mut guard)?;
1186        self.rebind_on(session, &mut guard)?;
1187        let head = guard.as_mut().expect("connected");
1188        let decoded = &node_conn.decoded;
1189        let r = head.generate_streaming(session, max_new_tokens, max_wall_ms, flag, |b: &[u32]| {
1190            decoded.fetch_add(b.len() as u64, Ordering::Relaxed);
1191            on_tokens(b);
1192        });
1193        if let Err(e) = &r {
1194            if is_fatal_node(e) {
1195                *guard = None;
1196            }
1197        }
1198        if let Some(head) = guard.as_ref() {
1199            self.sync_meta(node, head);
1200        }
1201        drop(guard);
1202        self.map_refusal(node, session, r)
1203    }
1204
1205    pub fn cancel(&self, session: u64) -> Result<(), DaemonError> {
1206        let sh = self.shared.lock().expect("shared");
1207        if !sh.sessions.contains_key(&session) {
1208            return Err(DaemonError::UnknownSession(session));
1209        }
1210        if let Some(flag) = sh.cancels.get(&session) {
1211            flag.store(true, Ordering::Relaxed);
1212        }
1213        Ok(())
1214    }
1215
1216    pub fn park(&self, session: u64) -> Result<(), DaemonError> {
1217        let node = {
1218            let mut sh = self.shared.lock().expect("shared");
1219            let p = sh
1220                .sessions
1221                .get_mut(&session)
1222                .ok_or(DaemonError::UnknownSession(session))?;
1223            if p.parked {
1224                return Ok(());
1225            }
1226            p.parked = true;
1227            (p.node, p.slot, p.epoch)
1228        };
1229        let (node, slot, epoch) = node;
1230        let node_conn = self.node(node);
1231        let mut guard = Self::checkout_live(&node_conn, slot);
1232        if self.ensure_connected(node, &mut guard).is_ok() {
1233            if let Err(e) = guard.as_mut().expect("connected").revoke(session, epoch) {
1234                *guard = None;
1235                self.mark_unhealthy(node, &e);
1236            }
1237        }
1238        Ok(())
1239    }
1240
1241    pub fn resume(&self, session: u64) -> Result<usize, DaemonError> {
1242        let placed = {
1243            let sh = self.shared.lock().expect("shared");
1244            let p = sh
1245                .sessions
1246                .get(&session)
1247                .ok_or(DaemonError::UnknownSession(session))?;
1248            if !p.parked {
1249                return Err(DaemonError::Config("session is not parked"));
1250            }
1251            p.clone()
1252        };
1253        let cand = self.candidates_claiming(
1254            &placed.model,
1255            Some(session),
1256            Self::replacement_shape(&placed.tokens, 0),
1257        );
1258        if cand.is_empty() {
1259            InFlight::release(&self.shared, session);
1260            return Err(DaemonError::Config("no healthy node can serve the model"));
1261        }
1262        let mut last_err = None;
1263        for i in cand {
1264            match self.try_place_on(
1265                i,
1266                session,
1267                placed.params,
1268                placed.qos_class,
1269                &placed.codec,
1270                &placed.model,
1271                &placed.tokens,
1272            ) {
1273                Ok(true) => return Ok(i),
1274                Ok(false) => continue,
1275                Err(e) => {
1276                    self.mark_unhealthy(i, &e);
1277                    self.node(i).reset();
1278                    last_err = Some(e);
1279                }
1280            }
1281        }
1282        Err(last_err.unwrap_or(DaemonError::Config("no reachable node serves the model")))
1283    }
1284
1285    /// Ends a session the caller is done with: the head drops its record, and the node the
1286    /// context it kept (now if its connection is free, else the next time it is).
1287    pub fn finish(&self, session: u64) {
1288        let placed = {
1289            let mut sh = self.shared.lock().expect("shared");
1290            sh.cancels.remove(&session);
1291            sh.sessions.remove(&session)
1292        };
1293        InFlight::release(&self.shared, session);
1294        let Some(p) = placed else { return };
1295        self.shared.lock().expect("shared").forgets.push((p.node, p.slot, session, p.epoch));
1296        let node = self.node(p.node);
1297        let lock = node.conns[p.slot].try_lock();
1298        if let Ok(mut guard) = lock {
1299            if let Some(head) = guard.as_mut() {
1300                self.flush_forgets(p.node, p.slot, head);
1301            }
1302        }
1303    }
1304
1305    fn flush_forgets(&self, node: usize, slot: usize, head: &mut FleetHead) {
1306        let due: Vec<(u64, u64)> = {
1307            let mut sh = self.shared.lock().expect("shared");
1308            if sh.forgets.is_empty() {
1309                return;
1310            }
1311            let (due, keep) = sh.forgets.drain(..).partition(|&(n, s, _, _)| n == node && s == slot);
1312            sh.forgets = keep;
1313            due.into_iter().map(|(_, _, session, epoch)| (session, epoch)).collect()
1314        };
1315        for (session, epoch) in due {
1316            let _ = head.revoke(session, epoch);
1317        }
1318    }
1319
1320    /// Connects to every node once, so the head learns each one (or why it cannot) at start.
1321    pub fn probe(&self) -> Vec<Result<NodeReport, DaemonError>> {
1322        std::thread::scope(|scope| {
1323            let jobs: Vec<_> = (0..self.node_count())
1324                .map(|i| {
1325                    scope.spawn(move || {
1326                        let node = self.node(i);
1327                        let mut guard = node.checkout_at(0);
1328                        self.ensure_connected(i, &mut guard)?;
1329                        let node = guard.as_ref().expect("connected").node();
1330                        Ok(NodeReport {
1331                            addr: self.node(i).spec.addr.clone(),
1332                            identity: node.node_identity.clone(),
1333                            models: node.loadable_models.clone(),
1334                            max_context_tokens: node.capacity.max_context_tokens,
1335                            max_lanes: node.capacity.max_lanes,
1336                        })
1337                    })
1338                })
1339                .collect();
1340            jobs.into_iter().map(|j| j.join().expect("probe thread")).collect()
1341        })
1342    }
1343
1344    /// Keeps idle connections open, drops ones a node closed, and finishes deferred session ends.
1345    pub fn keepalive(&self) {
1346        let nodes: Vec<Arc<NodeConn>> = self.nodes.read().expect("nodes").clone();
1347        for (i, node) in nodes.iter().enumerate() {
1348            for (slot, conn) in node.conns.iter().enumerate() {
1349                let Ok(mut guard) = conn.try_lock() else { continue };
1350                let Some(head) = guard.as_mut() else { continue };
1351                self.flush_forgets(i, slot, head);
1352                match head.keepalive() {
1353                    Ok(()) => self.sync_meta(i, head),
1354                    Err(_) => *guard = None,
1355                }
1356            }
1357        }
1358    }
1359
1360    /// Tries every node that failed and is due a retry, in parallel and outside the connection
1361    /// slots; one that answers is placed on again. Requests never wait on a down node.
1362    pub fn retry_down(&self) {
1363        let due: Vec<usize> = {
1364            let mut sh = self.shared.lock().expect("shared");
1365            let now = std::time::Instant::now();
1366            let mut due = Vec::new();
1367            for (i, m) in sh.meta.iter_mut().enumerate() {
1368                if m.down_since.is_some() && m.last_retry.is_none_or(|t| now.duration_since(t) >= self.retry_every) {
1369                    m.last_retry = Some(now);
1370                    due.push(i);
1371                }
1372            }
1373            due
1374        };
1375        std::thread::scope(|scope| {
1376            for i in due {
1377                scope.spawn(move || {
1378                    let node = self.node(i);
1379                    let spec = &node.spec;
1380                    // A joined node comes back when it dials in again.
1381                    if spec.addr.starts_with(JOINED) {
1382                        return;
1383                    }
1384                    match FleetHead::connect(&spec.addr, spec.auth.clone()) {
1385                        Ok(mut head) => {
1386                            watch(&node, &mut head);
1387                            self.sync_meta(i, &head);
1388                            self.mark_up(i);
1389                            if let Ok(mut slot) = node.conns[0].try_lock() {
1390                                if slot.is_none() {
1391                                    *slot = Some(head);
1392                                }
1393                            }
1394                        }
1395                        Err(e) => tracing::debug!("fleet: {e}"),
1396                    }
1397                });
1398            }
1399        });
1400    }
1401
1402    pub fn tend(&self) {
1403        self.keepalive();
1404        self.retry_down();
1405    }
1406
1407    pub fn spawn_keepalive(self: &Arc<Self>) {
1408        let mgr = Arc::downgrade(self);
1409        let tick = self.retry_every.min(KEEPALIVE_EVERY);
1410        std::thread::Builder::new()
1411            .name("fleet-keepalive".into())
1412            .spawn(move || loop {
1413                std::thread::sleep(tick);
1414                let Some(mgr) = mgr.upgrade() else { return };
1415                mgr.retry_down();
1416                let due = mgr.last_keepalive.lock().expect("keepalive clock").elapsed() >= KEEPALIVE_EVERY;
1417                if due {
1418                    mgr.keepalive();
1419                    *mgr.last_keepalive.lock().expect("keepalive clock") = std::time::Instant::now();
1420                }
1421            })
1422            .expect("spawn the fleet keepalive");
1423    }
1424}
1425
1426fn is_fatal_node(e: &DaemonError) -> bool {
1427    matches!(
1428        e,
1429        DaemonError::FleetNode { .. }
1430            | DaemonError::Fleet(superfluid_linkf::LinkFError::Closed)
1431            | DaemonError::Fleet(superfluid_linkf::LinkFError::TimedOut)
1432            | DaemonError::Fleet(superfluid_linkf::LinkFError::Io(_))
1433    )
1434}