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).map_err(String::from))?;
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 = crate::runtime_options::worker_args(runtime.id, &path, ctx, lanes, 0).map_err(|e| format!("model load failed: {e}"))?;
257        EngineHost::try_spawn_loaded(move || rt.open(&args))
258    }
259    .map_err(|e| format!("model load failed: {e}"))?;
260    let summary = format!(
261        "runtime {} serves {}: context window {ctx} tokens ({sized}), {lanes} lanes",
262        runtime.id.name(),
263        path.display()
264    );
265    loaded(host, Box::new(NoCodec), lanes, model_name, summary, wal)
266}
267
268/// A node on the test engine, which needs no model.
269pub fn load_mock_node(max_context: Option<i32>, lanes: u32, wal: &Path) -> Result<LoadedNode, String> {
270    let lanes = lanes.max(1);
271    let engine = crate::linked::engine("mock").ok_or("this build does not link the mock engine")?;
272    let args = superfluid_adapter_kit::WorkerArgs {
273        max_context: max_context.unwrap_or(4096).max(512),
274        max_batch: lanes,
275        ..Default::default()
276    };
277    let host = EngineHost::try_spawn_loaded(move || engine.open(&args)).map_err(|e| format!("model load failed: {e}"))?;
278    loaded(host, Box::new(crate::MockCodec), lanes, "mock".into(), "the mock engine".into(), wal)
279}
280
281fn loaded(
282    host: EngineHost,
283    codec: Box<dyn crate::TextCodec + Send + Sync>,
284    lanes: u32,
285    model_name: String,
286    summary: String,
287    wal: &Path,
288) -> Result<LoadedNode, String> {
289    let store = SessionStore::open(wal).map_err(|e| format!("node store: {e}"))?;
290    Ok(LoadedNode { daemon: Daemon::new(store, host, codec, lanes as usize), model_name, summary })
291}
292
293/// The window this device holds for `lanes` lanes of the model, as `superfluid serve` sizes it.
294fn device_window(runtime: &crate::runtimes::Runtime, model: &std::path::Path, lanes: u32) -> Result<i32, String> {
295    let id = runtime.id;
296    if crate::linked::sizes_context(id) {
297        return crate::linked::suggest_max_context(id, &[model.to_path_buf()], &[], lanes as i32, 0)?
298            .ok_or_else(|| format!("the {} runtime could not size a window for this device", id.base().name()));
299    }
300    let sizing = crate::runtimes::sizing_of(runtime, model)?;
301    let trained = sizing.trained_context;
302    let per_lane = superfluid_adapter_kit::sizing::window(&[sizing], lanes)
303        .ok_or_else(|| format!("the {} runtime reported no device budget to size a window against", id.base().name()))?;
304    let whole = per_lane.saturating_mul(u64::from(lanes)).min(trained.max(per_lane));
305    Ok(i32::try_from(whole).unwrap_or(i32::MAX))
306}
307
308/// The node's end of the link: it dials the first address that answers, and the head still
309/// opens the handshake.
310fn dial(addrs: &[SocketAddr], token: &[u8]) -> Result<Endpoint, LinkFError> {
311    let mut last = None;
312    for addr in addrs {
313        match TcpStream::connect_timeout(addr, CONNECT) {
314            Ok(sock) => {
315                return if token.is_empty() {
316                    Endpoint::from_tcp(sock)
317                } else {
318                    Endpoint::from_tcp_secure(sock, token, Role::Responder, HANDSHAKE)
319                };
320            }
321            Err(e) => last = Some(e),
322        }
323    }
324    Err(last.unwrap_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "address resolved to nothing")).into())
325}
326
327pub fn loopback(addr: &str) -> bool {
328    addr.starts_with("127.") || addr.starts_with("localhost:") || addr.starts_with("[::1]:")
329}
330
331/// A session store under the system temp dir, for `superfluid-noded`.
332pub fn scratch_wal() -> PathBuf {
333    let dir = std::env::temp_dir().join(format!("superfluid-node-{}", std::process::id()));
334    std::fs::create_dir_all(&dir).unwrap_or_else(|e| fail(format!("{}: {e}", dir.display())));
335    dir.join("wal.log")
336}
337
338/// The node's session store, under `$SUPERFLUID_HOME/fleet/nodes/<identity>`; a node has no
339/// state worth keeping across runs, so it starts empty.
340fn node_wal(home: &Path, identity: &str) -> PathBuf {
341    let dir = home.join("fleet").join("nodes").join(identity.replace(['/', '\\'], "_"));
342    let _ = std::fs::remove_dir_all(&dir);
343    std::fs::create_dir_all(&dir).unwrap_or_else(|e| fail(format!("{}: {e}", dir.display())));
344    dir.join("wal.log")
345}
346
347/// The model's path (pulled when given by id), the name the head sees it under, and its runtime.
348fn resolve_model(catalog: &Catalog, model: &str, runtime: Option<&str>, offline: bool) -> Result<(PathBuf, String, RuntimeId), String> {
349    let named = |name: &str| {
350        catalog.named(name).ok().or_else(|| RuntimeId::parse(name)).ok_or_else(|| format!("--runtime '{name}' is not a runtime id"))
351    };
352    if crate::pulls::looks_like_id(model) {
353        let id = match runtime {
354            Some(name) => named(name)?,
355            None => RuntimeId::new(crate::cli::runtime_for_id(model)),
356        };
357        let flags = crate::pulls::PullFlags { offline, options: Vec::new() };
358        let path = crate::pulls::pull(catalog, id, model, &flags)?;
359        return Ok((path, crate::pulls::served_name(model), id));
360    }
361    let path = PathBuf::from(model);
362    if !path.exists() {
363        return Err(format!("{}: no such file or directory", path.display()));
364    }
365    let id = match runtime {
366        Some(name) => named(name)?,
367        None => catalog.reader_of(&path)?,
368    };
369    Ok((path.clone(), crate::registry::model_id_for(&path), id))
370}
371
372/// The head's end: every node that dials in with the token is taken into the fleet. With no
373/// token, only nodes on this machine are; `model` is what the head serves, and a node that
374/// serves something else is taken but warned about, since no session will go to it.
375pub fn accept_nodes(listener: TcpListener, manager: Arc<FleetManager>, auth: Vec<u8>, model: String) {
376    let in_handshake = Arc::new(std::sync::atomic::AtomicUsize::new(0));
377    for sock in listener.incoming() {
378        let Ok(sock) = sock else { continue };
379        let Ok(peer) = sock.peer_addr() else { continue };
380        if auth.is_empty() && !peer.ip().is_loopback() {
381            eprintln!("superfluid: a node at {peer} was refused: this head has no fleet token (`superfluid fleet token` makes one)");
382            continue;
383        }
384        while in_handshake.load(std::sync::atomic::Ordering::Acquire) >= HANDSHAKES_AT_ONCE {
385            std::thread::sleep(Duration::from_millis(20));
386        }
387        in_handshake.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
388        let (manager, auth, model, in_handshake) = (Arc::clone(&manager), auth.clone(), model.clone(), Arc::clone(&in_handshake));
389        std::thread::spawn(move || {
390            let opened = FleetHead::accept(sock, auth);
391            in_handshake.fetch_sub(1, std::sync::atomic::Ordering::AcqRel);
392            match opened {
393                Ok(head) => {
394                    let node = head.node();
395                    let (identity, models, ctx) = (node.node_identity.clone(), node.loadable_models.join(", "), node.capacity.max_context_tokens);
396                    let serves_it = node.loadable_models.contains(&model);
397                    match manager.adopt(head, peer.ip()) {
398                        Ok((_, true, _)) => {
399                            eprintln!("superfluid: fleet node '{identity}' joined from {peer}: {models}, context window {ctx} tokens");
400                            if !serves_it {
401                                eprintln!("superfluid: fleet node '{identity}' serves {models}, not {model}; no session will go to it (set --model-name {model} there)");
402                            }
403                        }
404                        Ok((_, false, 1)) => eprintln!("superfluid: fleet node '{identity}' rejoined from {peer}"),
405                        Ok(_) => {}
406                        Err(why) => eprintln!("superfluid: a node at {peer} did not join: {why}"),
407                    }
408                }
409                Err(e) => eprintln!("superfluid: a node at {peer} did not join: {e}"),
410            }
411        });
412    }
413}