Skip to main content

superfluid_daemon/
fleet_join.rs

1//! `superfluid node join`: this machine serves a model to a fleet head it dials, and the head
2//! takes nodes that dial in.
3
4use std::net::{SocketAddr, TcpListener, TcpStream, ToSocketAddrs};
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex};
7use std::time::Duration;
8
9use superfluid_linkf::{Endpoint, LinkFError, Role};
10
11use crate::fleet::FleetHead;
12use crate::fleet_manager::FleetManager;
13use crate::nodeagent::NodeAgent;
14use crate::runtime_pick::RuntimeId;
15use crate::runtimes::Catalog;
16use crate::{Daemon, EngineHost, NoCodec, SessionStore};
17
18pub const HELP: &str = "Serve a model to a fleet head from this machine.
19Usage: superfluid node join [<head-host:port>] --model <path|org/model[:tag]> [options]
20  Dials the head (found on the local network when no address is given), serves the
21  model to it, and dials again whenever the link drops. The head's token, from
22  `superfluid fleet token` there, authenticates both ends and encrypts the link.
23Options:
24  --model <m>             The model; an id is pulled first, as `serve` pulls it
25  --token <value>         The head's fleet token, kept at $SUPERFLUID_HOME/fleet/token
26  --runtime <id>          The runtime, when the model's format does not decide it
27  --max-context <N>       This node's context window [default: sized for this device]
28  --max-batch <N>         This node's lanes [default: 8]
29  --identity <name>       The name the head shows for this node [default: the host name]
30  --model-name <id>       The name the head matches on [default: what the head announces,
31                          else the model's id]
32  --offline               Pull nothing; the model must be in the runtime's cache
33  --park                  Keep each prompt's prefix on disk, under
34                          $SUPERFLUID_HOME/fleet/nodes/<identity>/park, so a restarted node
35                          starts the head's sessions warm
36";
37
38const CONNECT: Duration = Duration::from_secs(5);
39const HANDSHAKE: Duration = Duration::from_secs(10);
40const DISCOVER: Duration = Duration::from_secs(30);
41const RETRY_MAX: Duration = Duration::from_secs(30);
42const REFUSED: &str = "the head refused this node's token; copy the head's `superfluid fleet token` here with --token";
43/// Peers in the handshake at once; more wait in the backlog, so a burst of connects cannot
44/// take a thread each.
45const HANDSHAKES_AT_ONCE: usize = 8;
46
47pub fn run(args: &[String]) {
48    match args.first().map(String::as_str) {
49        Some("join") => join(&args[1..]),
50        _ => {
51            eprint!("{HELP}");
52            std::process::exit(2);
53        }
54    }
55}
56
57fn fail(msg: impl std::fmt::Display) -> ! {
58    eprintln!("superfluid: {msg}");
59    std::process::exit(2);
60}
61
62fn join(args: &[String]) {
63    // The node-agent's warnings (a fenced session, a silent head) go to stderr, as noded's do.
64    let filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "warn".to_string());
65    if let Err(e) = crate::telemetry::init_stderr(&filter) {
66        eprintln!("superfluid: logging: {e}");
67    }
68    let (mut head, mut model, mut token, mut runtime, mut identity, mut model_name) = (None, None, None, None, None, None);
69    let (mut max_context, mut max_batch, mut offline, mut park) = (None, 8u32, false, false);
70    let mut it = args.iter();
71    while let Some(a) = it.next() {
72        let mut val = || it.next().cloned().unwrap_or_else(|| fail(format!("{a} takes a value")));
73        match a.as_str() {
74            "--model" => model = Some(val()),
75            "--token" => token = Some(val()),
76            "--runtime" => runtime = Some(val()),
77            "--identity" => identity = Some(val()),
78            "--model-name" => model_name = Some(val()),
79            "--max-context" => max_context = Some(val().parse().unwrap_or_else(|_| fail("--max-context takes a number"))),
80            "--max-batch" => max_batch = val().parse().unwrap_or_else(|_| fail("--max-batch takes a number")),
81            "--offline" => offline = true,
82            "--park" => park = true,
83            "-h" | "--help" => {
84                print!("{HELP}");
85                return;
86            }
87            s if s.starts_with('-') => fail(format!("unknown flag {s}")),
88            s if head.is_none() => head = Some(s.to_string()),
89            s => fail(format!("unexpected argument {s}")),
90        }
91    }
92    let Some(model) = model else { fail("--model is required") };
93    let home = crate::runtimes::default_home();
94    if let Some(t) = &token {
95        crate::fleet_token::write(&home, t.trim().as_bytes()).unwrap_or_else(|e| fail(e));
96    }
97    let token = crate::fleet_token::read(&home).unwrap_or_default();
98    let (target, announced) = match head {
99        Some(h) => (Target { given: Some(h), found: Mutex::new(Vec::new()) }, None),
100        None => {
101            let (addrs, served) = discover();
102            (Target { given: None, found: Mutex::new(addrs) }, Some(served))
103        }
104    };
105    let addrs = target.addrs();
106    if addrs.is_empty() {
107        fail(format!("cannot resolve {target}"));
108    }
109    if token.is_empty() && !addrs.iter().all(|a| a.ip().is_loopback()) {
110        fail("no fleet token: run `superfluid fleet token` on the head and pass --token <value> here");
111    }
112    let identity = identity.unwrap_or_else(crate::metrics::hostname);
113    let max_batch = max_batch.max(1);
114    let state = NodeState::open(&home, &identity, park).unwrap_or_else(|e| fail(e));
115    let loaded = load_node(&model, runtime.as_deref(), max_context, max_batch, offline, &state).unwrap_or_else(|e| fail(e));
116    eprintln!("superfluid: {}", loaded.summary);
117    let model_name = model_name.unwrap_or_else(|| name_among(announced.as_deref().unwrap_or_default(), loaded.model_name));
118    let mut node = NodeAgent::new(Arc::new(loaded.daemon), identity.clone(), model_name.clone()).with_max_lanes(max_batch);
119    if let Some(tok) = loaded.tokenizer {
120        node = node.with_tokenizer_of(tok);
121    }
122    // A head that keeps a connection for short calls asks for it; one connection more, once.
123    let (ask, asked) = std::sync::mpsc::sync_channel::<()>(1);
124    let node = node.with_auth(token.clone()).with_on_control_ask(move || {
125        let _ = ask.try_send(());
126    });
127    eprintln!("superfluid: node '{identity}' serves {model_name}; dialing the head at {target} once per lane ({max_batch})");
128    // One connection per lane, each its own generation at a time on the head.
129    let target = Arc::new(target);
130    let dial = |lane: u32| {
131        let (target, token, node) = (Arc::clone(&target), token.clone(), node.connection());
132        std::thread::spawn(move || keep_dialing(&target, &token, node, lane))
133    };
134    let mut links: Vec<_> = (0..max_batch).map(dial).collect();
135    if asked.recv().is_ok() {
136        links.push(dial(max_batch));
137    }
138    for link in links {
139        let _ = link.join();
140    }
141}
142
143/// Where the head is: an address given on the command line, resolved at each dial, or the
144/// addresses the local network announced, looked for again when none of them answers.
145struct Target {
146    given: Option<String>,
147    found: Mutex<Vec<SocketAddr>>,
148}
149
150impl Target {
151    fn addrs(&self) -> Vec<SocketAddr> {
152        match &self.given {
153            Some(h) => h.to_socket_addrs().map(|a| a.collect()).unwrap_or_default(),
154            None => self.found.lock().expect("head addrs").clone(),
155        }
156    }
157
158    /// After every address in `tried` failed: looks on the network again, unless the head was
159    /// given or another lane already found it anew. Lanes wait on the one that is looking.
160    fn refind(&self, tried: &[SocketAddr]) {
161        if self.given.is_some() {
162            return;
163        }
164        let mut found = self.found.lock().expect("head addrs");
165        if *found == tried {
166            if let Ok(head) = crate::fleet_mdns::find_head(DISCOVER) {
167                *found = head.nodes;
168            }
169        }
170    }
171}
172
173impl std::fmt::Display for Target {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        match &self.given {
176            Some(h) => f.write_str(h),
177            None => match self.found.lock().expect("head addrs").first() {
178                Some(a) => write!(f, "{a}"),
179                None => f.write_str("the fleet head"),
180            },
181        }
182    }
183}
184
185/// The head on the local network, however long it takes to appear.
186fn discover() -> (Vec<SocketAddr>, Vec<String>) {
187    eprintln!("superfluid: looking for a fleet head on the local network");
188    loop {
189        match crate::fleet_mdns::find_head(DISCOVER) {
190            Ok(head) => {
191                eprintln!("superfluid: found the head at {}, serving {}", head.nodes[0], head.models.join(", "));
192                return (head.nodes, head.models);
193            }
194            Err(e) => eprintln!("superfluid: {e}; looking again"),
195        }
196    }
197}
198
199/// The name a node serves its model under: its own when the head serves a model by that name,
200/// the head's one model when it serves one (the same model under another id or path), and its
201/// own, said, when the head serves several and none is it.
202fn name_among(served: &[String], own: String) -> String {
203    match served {
204        _ if served.contains(&own) => own,
205        [only] => only.clone(),
206        [] => own,
207        _ => {
208            eprintln!(
209                "superfluid: the head serves {}, and none is '{own}'; give this node --model-name <one of them>",
210                served.join(", ")
211            );
212            own
213        }
214    }
215}
216
217/// Dials the head and serves until the link ends, then dials again; the first lane speaks
218/// for the node in the log.
219fn keep_dialing(target: &Target, token: &[u8], mut conn: NodeAgent, lane: u32) {
220    let say = |msg: String| {
221        if lane == 0 {
222            eprintln!("superfluid: {msg}");
223        }
224    };
225    let mut backoff = Duration::from_secs(2);
226    loop {
227        let addrs = target.addrs();
228        match dial(&addrs, token) {
229            Ok(ep) => {
230                say(format!("joined the head at {target}"));
231                backoff = Duration::from_secs(2);
232                match conn.serve(ep) {
233                    Ok(()) => {
234                        tracing::debug!(lane, "the link to the head closed");
235                        say("the head closed the link".into())
236                    }
237                    Err(crate::DaemonError::Fleet(LinkFError::Unauthorized)) => fail(REFUSED),
238                    Err(e) => {
239                        tracing::debug!(lane, error = %e, "the link to the head ended");
240                        say(format!("the link to the head ended: {e}"))
241                    }
242                }
243            }
244            Err(LinkFError::Unauthorized) => fail(REFUSED),
245            Err(e) => {
246                say(format!("cannot join the head at {target}: {e}"));
247                target.refind(&addrs);
248            }
249        }
250        say(format!("dialing again in {} s", backoff.as_secs()));
251        std::thread::sleep(backoff);
252        backoff = (backoff * 2).min(RETRY_MAX);
253    }
254}
255
256pub struct LoadedNode {
257    pub daemon: Daemon,
258    /// The name the head sees the model under.
259    pub model_name: String,
260    /// The runtime, model, window and lanes, for the node's start line.
261    pub summary: String,
262    /// The model's tokenizer, for the head to check and, with no copy of the model, to use.
263    pub tokenizer: Option<Arc<dyn superfluid_engine::Tokenizer>>,
264}
265
266/// A fleet node's model by path or id, as `superfluid serve` loads one: pulled when given by
267/// id, on its runtime (installed first if it never was), in a window sized for this device
268/// unless `max_context` pins it.
269pub fn load_node(
270    model: &str,
271    runtime: Option<&str>,
272    max_context: Option<i32>,
273    lanes: u32,
274    offline: bool,
275    state: &NodeState,
276) -> Result<LoadedNode, String> {
277    let lanes = lanes.max(1);
278    let catalog = Catalog::from_env();
279    let runtime = runtime.map(|r| if r == "native" { "basert" } else { r });
280    let (path, model_name, id) = resolve_model(&catalog, model, runtime, offline)?;
281    let runtime = catalog.require(id).or_else(|_| crate::pulls::ensure_installed(&catalog, id, offline).map_err(String::from))?;
282    let (ctx, sized) = match max_context {
283        Some(n) => (n, format!("--max-context {n}")),
284        None => match device_window(&runtime, &path, lanes) {
285            Ok(n) => (n, "sized for this device".to_string()),
286            Err(why) => (8192, format!("{why}; --max-context N pins it")),
287        },
288    };
289    let ctx = ctx.max(512);
290    let host = if runtime.worker.bin.is_file() {
291        EngineHost::spawn_process(crate::WorkerSpec {
292            bin: runtime.worker.bin.clone(),
293            args: runtime.serve_args(&path, ctx, lanes, 0),
294            stderr: None,
295        })
296    } else {
297        let rt = crate::linked::engine(runtime.id.name())
298            .ok_or_else(|| format!("the {} runtime has no worker here", runtime.id.name()))?;
299        let args = crate::runtime_options::worker_args(runtime.id, &path, ctx, lanes, 0).map_err(|e| format!("model load failed: {e}"))?;
300        EngineHost::try_spawn_loaded(move || rt.open(&args))
301    }
302    .map_err(|e| format!("model load failed: {e}"))?;
303    let summary = format!(
304        "runtime {} serves {}: context window {ctx} tokens ({sized}), {lanes} lanes",
305        runtime.id.name(),
306        path.display()
307    );
308    let mut node = loaded(host, Box::new(NoCodec), lanes, model_name, summary, state)?;
309    node.tokenizer = crate::serve::open_tokenizer(&runtime, &path).ok();
310    Ok(node)
311}
312
313/// A node on the test engine, which needs no model.
314pub fn load_mock_node(max_context: Option<i32>, lanes: u32, state: &NodeState) -> Result<LoadedNode, String> {
315    let lanes = lanes.max(1);
316    let engine = crate::linked::engine("mock").ok_or("this build does not link the mock engine")?;
317    let args = superfluid_adapter_kit::WorkerArgs {
318        max_context: max_context.unwrap_or(4096).max(512),
319        max_batch: lanes,
320        ..Default::default()
321    };
322    let host = EngineHost::try_spawn_loaded(move || engine.open(&args)).map_err(|e| format!("model load failed: {e}"))?;
323    loaded(host, Box::new(crate::MockCodec), lanes, "mock".into(), "the mock engine".into(), state)
324}
325
326fn loaded(
327    host: EngineHost,
328    codec: Box<dyn crate::TextCodec + Send + Sync>,
329    lanes: u32,
330    model_name: String,
331    summary: String,
332    state: &NodeState,
333) -> Result<LoadedNode, String> {
334    let store = SessionStore::open(&state.wal).map_err(|e| format!("node store: {e}"))?;
335    let daemon = Daemon::with_park(store, host, codec, lanes as usize, state.park.clone());
336    Ok(LoadedNode { daemon, model_name, summary, tokenizer: None })
337}
338
339/// The window this device holds for `lanes` lanes of the model, as `superfluid serve` sizes it.
340fn device_window(runtime: &crate::runtimes::Runtime, model: &std::path::Path, lanes: u32) -> Result<i32, String> {
341    let id = runtime.id;
342    if crate::linked::sizes_context(id) {
343        return crate::linked::suggest_max_context(id, &[model.to_path_buf()], &[], lanes as i32, 0)?
344            .ok_or_else(|| format!("the {} runtime could not size a window for this device", id.base().name()));
345    }
346    let sizing = crate::runtimes::sizing_of(runtime, model)?;
347    let trained = sizing.trained_context;
348    let per_lane = superfluid_adapter_kit::sizing::window(&[sizing], lanes)
349        .ok_or_else(|| format!("the {} runtime reported no device budget to size a window against", id.base().name()))?;
350    let whole = per_lane.saturating_mul(u64::from(lanes)).min(trained.max(per_lane));
351    Ok(i32::try_from(whole).unwrap_or(i32::MAX))
352}
353
354/// The node's end of the link: it dials the first address that answers, and the head still
355/// opens the handshake.
356fn dial(addrs: &[SocketAddr], token: &[u8]) -> Result<Endpoint, LinkFError> {
357    let mut last = None;
358    for addr in addrs {
359        match TcpStream::connect_timeout(addr, CONNECT) {
360            Ok(sock) => {
361                return if token.is_empty() {
362                    Endpoint::from_tcp(sock)
363                } else {
364                    Endpoint::from_tcp_secure(sock, token, Role::Responder, HANDSHAKE)
365                };
366            }
367            Err(e) => last = Some(e),
368        }
369    }
370    Err(last.unwrap_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "address resolved to nothing")).into())
371}
372
373pub fn loopback(addr: &str) -> bool {
374    addr.starts_with("127.") || addr.starts_with("localhost:") || addr.starts_with("[::1]:")
375}
376
377/// A session store under the system temp dir, for `superfluid-noded`.
378/// Where a node keeps its state, under `$SUPERFLUID_HOME/fleet/nodes/<identity>`. Its session
379/// store starts empty each run: a node's sessions last one generation, and the head keeps the
380/// conversations. Its parked prefixes, with `--park`, are kept across runs.
381pub struct NodeState {
382    pub wal: PathBuf,
383    pub park: Option<PathBuf>,
384}
385
386impl NodeState {
387    pub fn open(home: &Path, identity: &str, park: bool) -> Result<NodeState, String> {
388        let dir = home.join("fleet").join("nodes").join(identity.replace(['/', '\\'], "_"));
389        std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
390        let wal = dir.join("wal.log");
391        for stale in [wal.clone(), dir.join("wal.log.prev")] {
392            let _ = std::fs::remove_file(stale);
393        }
394        Ok(NodeState { wal, park: park.then(|| dir.join("park")) })
395    }
396
397    /// A session store of this process's own, and no parking.
398    pub fn scratch() -> Result<NodeState, String> {
399        let dir = std::env::temp_dir().join(format!("superfluid-node-{}", std::process::id()));
400        std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
401        Ok(NodeState { wal: dir.join("wal.log"), park: None })
402    }
403}
404
405/// The model's path (pulled when given by id), the name the head sees it under, and its runtime.
406fn resolve_model(catalog: &Catalog, model: &str, runtime: Option<&str>, offline: bool) -> Result<(PathBuf, String, RuntimeId), String> {
407    let named = |name: &str| {
408        catalog.named(name).ok().or_else(|| RuntimeId::parse(name)).ok_or_else(|| format!("--runtime '{name}' is not a runtime id"))
409    };
410    if crate::pulls::looks_like_id(model) {
411        let id = match runtime {
412            Some(name) => named(name)?,
413            None => RuntimeId::new(crate::cli::runtime_for_id(model)),
414        };
415        let flags = crate::pulls::PullFlags { offline, options: Vec::new() };
416        let path = crate::pulls::pull(catalog, id, model, &flags)?;
417        return Ok((path, crate::pulls::served_name(model), id));
418    }
419    let path = PathBuf::from(model);
420    if !path.exists() {
421        return Err(format!("{}: no such file or directory", path.display()));
422    }
423    let id = match runtime {
424        Some(name) => named(name)?,
425        None => catalog.reader_of(&path)?,
426    };
427    Ok((path.clone(), crate::registry::model_id_for(&path), id))
428}
429
430/// The head's end: every node that dials in with the token is taken into the fleet. With no
431/// token, only nodes on this machine are; `model` is what the head serves, and a node that
432/// serves something else is taken but warned about, since no session will go to it.
433pub fn accept_nodes(listener: TcpListener, manager: Arc<FleetManager>, auth: Vec<u8>, models: Vec<String>) {
434    let in_handshake = Arc::new(std::sync::atomic::AtomicUsize::new(0));
435    for sock in listener.incoming() {
436        let Ok(sock) = sock else { continue };
437        let Ok(peer) = sock.peer_addr() else { continue };
438        if auth.is_empty() && !peer.ip().is_loopback() {
439            eprintln!("superfluid: a node at {peer} was refused: this head has no fleet token (`superfluid fleet token` makes one)");
440            continue;
441        }
442        while in_handshake.load(std::sync::atomic::Ordering::Acquire) >= HANDSHAKES_AT_ONCE {
443            std::thread::sleep(Duration::from_millis(20));
444        }
445        in_handshake.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
446        let (manager, auth, served, in_handshake) = (Arc::clone(&manager), auth.clone(), models.clone(), Arc::clone(&in_handshake));
447        std::thread::spawn(move || {
448            let opened = FleetHead::accept(sock, auth);
449            in_handshake.fetch_sub(1, std::sync::atomic::Ordering::AcqRel);
450            match opened {
451                Ok(head) => {
452                    let node = head.node();
453                    let (identity, models, ctx) = (node.node_identity.clone(), node.loadable_models.join(", "), node.capacity.max_context_tokens);
454                    let serves_one = node.loadable_models.iter().any(|m| served.contains(m));
455                    match manager.adopt(head, peer.ip()) {
456                        Ok((_, true, _)) => {
457                            eprintln!("superfluid: fleet node '{identity}' joined from {peer}: {models}, context window {ctx} tokens");
458                            if !serves_one {
459                                eprintln!(
460                                    "superfluid: fleet node '{identity}' serves {models}, none of {}; no session will go to it (set --model-name there)",
461                                    served.join(", ")
462                                );
463                            }
464                        }
465                        Ok((_, false, 1)) => eprintln!("superfluid: fleet node '{identity}' rejoined from {peer}"),
466                        Ok(_) => {}
467                        Err(why) => eprintln!("superfluid: a node at {peer} did not join: {why}"),
468                    }
469                }
470                Err(e) => eprintln!("superfluid: a node at {peer} did not join: {e}"),
471            }
472        });
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::name_among;
479
480    #[test]
481    fn a_node_takes_its_own_name_when_served_else_the_heads_only_one() {
482        let own = || "qwen".to_string();
483        assert_eq!(name_among(&["llama".into(), "qwen".into()], own()), "qwen");
484        assert_eq!(name_among(&["Qwen3-0.6B".into()], own()), "Qwen3-0.6B", "the same model under another id");
485        assert_eq!(name_among(&["llama".into(), "gemma".into()], own()), "qwen", "several, none it: its own");
486        assert_eq!(name_among(&[], own()), "qwen");
487    }
488}