1use 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";
43const 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 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 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 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
143struct 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 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
185fn 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
199fn 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
217fn 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 pub model_name: String,
260 pub summary: String,
262 pub tokenizer: Option<Arc<dyn superfluid_engine::Tokenizer>>,
264}
265
266pub 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
313pub 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
339fn 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
354fn 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
377pub 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 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
405fn 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
430pub 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}