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