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 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 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
131struct 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 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
173fn 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
187fn 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 pub model_name: String,
230 pub summary: String,
232}
233
234pub 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
279pub 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
304fn 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
319fn 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
342pub 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
349fn 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
358fn 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
383pub 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}