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";
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";
40const 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 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
126struct 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 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
168fn 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
182fn 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 pub model_name: String,
219 pub summary: String,
221}
222
223pub 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
268pub 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
293fn 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
308fn 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
331pub 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
338fn 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
347fn 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
372pub 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}