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