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))?;
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
273pub 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
298fn 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
313fn 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
336pub 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
343fn 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
352fn 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
377pub 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}