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