Skip to main content

superfluid_daemon/
runtime.rs

1use std::os::unix::net::UnixStream;
2use std::path::PathBuf;
3use std::thread::JoinHandle;
4
5use superfluid_agent::WorkerClient;
6use superfluid_engine::Engine;
7use superfluid_proto::linkw;
8use superfluid_worker::{WorkerConfig, WorkerServer};
9
10use crate::DaemonError;
11
12#[derive(Debug, Clone)]
13pub struct WorkerSpec {
14    pub bin: PathBuf,
15    pub args: Vec<String>,
16    pub stderr: Option<std::sync::Arc<std::os::fd::OwnedFd>>,
17}
18
19pub use superfluid_worker::ring_specs_for;
20
21enum Backend {
22    Thread(Option<JoinHandle<()>>),
23    Process {
24        child: std::process::Child,
25        spec: WorkerSpec,
26    },
27}
28
29pub struct EngineHost {
30    client: Option<WorkerClient>,
31    backend: Backend,
32    generation: u64,
33    caps: SharedCapabilities,
34    record_change: Option<String>,
35    yield_slot: YieldSlot,
36}
37
38/// The running worker's yield flag (replaced when the worker respawns):
39/// raised, the engine ends its tick at the next step boundary.
40pub type YieldSlot = std::sync::Arc<std::sync::RwLock<Option<superfluid_shm::YieldSignal>>>;
41
42fn yield_slot_of(client: &WorkerClient) -> YieldSlot {
43    std::sync::Arc::new(std::sync::RwLock::new(client.yield_signal()))
44}
45
46/// Tells the worker whether latency-sensitive requests are around.
47pub fn set_latency(slot: &YieldSlot, on: bool) {
48    if let Some(s) = slot.read().expect("yield slot").as_ref() {
49        s.set_latency(on);
50    }
51}
52
53/// Asks the running tick to end at its next step boundary.
54pub fn raise_yield(slot: &YieldSlot) {
55    if let Some(s) = slot.read().expect("yield slot").as_ref() {
56        s.raise();
57    }
58}
59
60pub type SharedCapabilities = std::sync::Arc<std::sync::RwLock<crate::capabilities::Capabilities>>;
61
62fn caps_of(client: &WorkerClient) -> SharedCapabilities {
63    std::sync::Arc::new(std::sync::RwLock::new(crate::capabilities::Capabilities::parse(&client.hello.capabilities)))
64}
65
66impl EngineHost {
67    pub fn spawn<E, F>(make_engine: F) -> Result<EngineHost, DaemonError>
68    where
69        E: Engine + 'static,
70        F: FnOnce() -> (E, Option<Vec<linkw::RingSpec>>) + Send + 'static,
71    {
72        let (frames_a, frames_w) = UnixStream::pair()?;
73        let (fds_a, fds_w) = UnixStream::pair()?;
74        let handle = std::thread::Builder::new()
75            .name("engine-worker".into())
76            .spawn(move || {
77                let (engine, ring_specs) = make_engine();
78                let mut server = WorkerServer::new(
79                    engine,
80                    WorkerConfig {
81                        engine_bundle_hash: [0; 32],
82                    },
83                    frames_w,
84                    fds_w,
85                );
86                if let Some(specs) = ring_specs {
87                    server = server.with_ring_specs(specs);
88                }
89                if let Err(e) = server.serve() {
90                    tracing::error!("engine-worker exited with error: {e}");
91                }
92            })?;
93        let client = WorkerClient::connect(frames_a, fds_a)?;
94        Ok(EngineHost {
95            caps: caps_of(&client),
96            yield_slot: yield_slot_of(&client),
97            client: Some(client),
98            backend: Backend::Thread(Some(handle)),
99            generation: 0,
100            record_change: None,
101        })
102    }
103
104    pub fn try_spawn<E, F>(make_engine: F) -> Result<EngineHost, DaemonError>
105    where
106        E: Engine + 'static,
107        F: FnOnce() -> Result<(E, Option<Vec<linkw::RingSpec>>), String> + Send + 'static,
108    {
109        EngineHost::try_spawn_loaded(move || make_engine().map(|(engine, rings)| superfluid_adapter_kit::Loaded::engine(engine, rings)))
110    }
111
112    pub fn try_spawn_loaded<F>(open: F) -> Result<EngineHost, DaemonError>
113    where
114        F: FnOnce() -> Result<superfluid_adapter_kit::Loaded, String> + Send + 'static,
115    {
116        let (frames_a, frames_w) = UnixStream::pair()?;
117        let (fds_a, fds_w) = UnixStream::pair()?;
118        let (tx, rx) = std::sync::mpsc::channel::<Result<(), String>>();
119        let handle = std::thread::Builder::new()
120            .name("engine-worker".into())
121            .spawn(move || {
122                let loaded = match open() {
123                    Ok(v) => {
124                        let _ = tx.send(Ok(()));
125                        v
126                    }
127                    Err(e) => {
128                        let _ = tx.send(Err(e));
129                        return;
130                    }
131                };
132                if let Err(e) = loaded.serve(frames_w, fds_w) {
133                    tracing::error!("engine-worker exited with error: {e}");
134                }
135            })?;
136        match rx.recv() {
137            Ok(Ok(())) => {}
138            Ok(Err(msg)) => {
139                let _ = handle.join();
140                return Err(DaemonError::EngineLoad(msg));
141            }
142            Err(_) => {
143                let _ = handle.join();
144                return Err(DaemonError::EngineLoad(
145                    "engine worker exited before reporting a load result".into(),
146                ));
147            }
148        }
149        let client = WorkerClient::connect(frames_a, fds_a)?;
150        Ok(EngineHost {
151            caps: caps_of(&client),
152            yield_slot: yield_slot_of(&client),
153            client: Some(client),
154            backend: Backend::Thread(Some(handle)),
155            generation: 0,
156            record_change: None,
157        })
158    }
159
160    pub fn spawn_process(spec: WorkerSpec) -> Result<EngineHost, DaemonError> {
161        let (child, client) = launch(&spec)?;
162        Ok(EngineHost {
163            caps: caps_of(&client),
164            yield_slot: yield_slot_of(&client),
165            client: Some(client),
166            backend: Backend::Process { child, spec },
167            generation: 0,
168            record_change: None,
169        })
170    }
171
172    pub fn yield_slot(&self) -> YieldSlot {
173        std::sync::Arc::clone(&self.yield_slot)
174    }
175
176    /// Lowers the yield flag before a tick is sent.
177    pub fn clear_yield(&self) {
178        if let Some(s) = self.yield_slot.read().expect("yield slot").as_ref() {
179            s.clear();
180        }
181    }
182
183    pub fn is_process(&self) -> bool {
184        matches!(self.backend, Backend::Process { .. })
185    }
186
187    pub fn worker_pid(&self) -> Option<u32> {
188        match &self.backend {
189            Backend::Process { child, .. } => Some(child.id()),
190            Backend::Thread(_) => None,
191        }
192    }
193
194    pub fn respawn(&mut self) -> Result<(), DaemonError> {
195        let Backend::Process { child, spec } = &mut self.backend else {
196            return Err(DaemonError::Protocol(
197                "in-process worker shares the daemon's fate; nothing to respawn",
198            ));
199        };
200        drop(self.client.take());
201        let _ = child.kill();
202        let _ = child.wait();
203        let (new_child, client) = launch(spec)?;
204        *child = new_child;
205        let fresh = crate::capabilities::Capabilities::parse(&client.hello.capabilities);
206        let mut held = self.caps.write().expect("capability record");
207        let changed = held.changes(&fresh);
208        if !changed.is_empty() {
209            tracing::warn!(changed = %changed.join(", "), "the respawned worker describes a different runtime or model");
210            self.record_change = Some(format!("the respawned worker's capability record differs ({})", changed.join(", ")));
211        }
212        *held = fresh;
213        drop(held);
214        *self.yield_slot.write().expect("yield slot") = client.yield_signal();
215        self.client = Some(client);
216        self.generation += 1;
217        Ok(())
218    }
219
220    pub fn take_record_change(&mut self) -> Option<String> {
221        self.record_change.take()
222    }
223
224    pub fn max_seqs(&self) -> Option<u64> {
225        self.caps.read().expect("capability record").limit("max_seqs")
226    }
227
228    pub fn exact_seeds_only(&self) -> bool {
229        matches!(self.caps.read().expect("capability record").get("state", "rollback"), crate::capabilities::Cap::No(_))
230    }
231
232    /// The runtime seeds only from a state covering a whole cached sequence, so a shared
233    /// prefix needs a copy kept where it ends.
234    pub fn needs_prefix_checkpoints(&self) -> bool {
235        matches!(self.caps.read().expect("capability record").get("state", "shared_prefix_seed"), crate::capabilities::Cap::No(_))
236    }
237
238    pub fn park_refused(&self) -> Option<String> {
239        match self.caps.read().expect("capability record").get("serving", "park_lossless") {
240            crate::capabilities::Cap::No(why) => Some(why.to_string()),
241            _ => None,
242        }
243    }
244
245    pub fn round_granular(&self) -> bool {
246        matches!(self.caps.read().expect("capability record").get("serving", "round_granular"), crate::capabilities::Cap::Yes)
247    }
248
249    pub fn lossy_park_allowed(&self) -> bool {
250        !matches!(self.caps.read().expect("capability record").get("serving", "park_lossy"), crate::capabilities::Cap::No(_))
251    }
252
253    pub fn capabilities(&self) -> SharedCapabilities {
254        std::sync::Arc::clone(&self.caps)
255    }
256
257    pub fn generation(&self) -> u64 {
258        self.generation
259    }
260
261    pub fn is_alive(&self) -> bool {
262        self.client.is_some()
263    }
264
265    pub fn client(&mut self) -> &mut WorkerClient {
266        self.client
267            .as_mut()
268            .expect("caller must gate on is_alive() after a failed respawn")
269    }
270
271    fn client_ref(&self) -> Option<&WorkerClient> {
272        self.client.as_ref()
273    }
274
275    pub fn kv_bits(&self) -> u32 {
276        self.client_ref().map(|c| c.hello.kv_bits).unwrap_or(0)
277    }
278
279    pub fn page_size(&self) -> Option<u64> {
280        self.client_ref()?
281            .hello
282            .state_space_descriptors
283            .iter()
284            .find(|d| d.page_size_tokens > 0)
285            .map(|d| d.page_size_tokens as u64)
286    }
287
288    pub fn has_unservable_space(&self) -> bool {
289        self.client_ref()
290            .map(|c| {
291                c.hello
292                    .state_space_descriptors
293                    .iter()
294                    .any(|d| d.flags & superfluid_abi::space_flag::OPS_UNAVAILABLE != 0)
295            })
296            .unwrap_or(false)
297    }
298
299    pub fn has_recurrent_space(&self) -> bool {
300        self.client_ref()
301            .map(|c| {
302                c.hello
303                    .state_space_descriptors
304                    .iter()
305                    .any(|d| d.kind == superfluid_abi::space_kind::RECURRENT_BLOB)
306            })
307            .unwrap_or(false)
308    }
309
310    pub fn cache_serves_all_spaces(&self) -> bool {
311        self.client_ref()
312            .map(|c| {
313                c.hello
314                    .state_space_descriptors
315                    .iter()
316                    .all(|d| d.flags & superfluid_abi::space_flag::NO_PREFIX_CACHE == 0)
317            })
318            .unwrap_or(false)
319    }
320
321    pub fn parkable_spaces(&self) -> Vec<(u32, u32, u32, u32)> {
322        let mut v: Vec<(u32, u32, u32, u32)> = self
323            .client_ref()
324            .map(|c| {
325                c.hello
326                    .state_space_descriptors
327                    .iter()
328                    .filter(|d| {
329                        d.flags & superfluid_abi::space_flag::OPS_UNAVAILABLE == 0
330                            && d.kind != superfluid_abi::space_kind::ENCODER_CACHE
331                    })
332                    .map(|d| {
333                        (
334                            d.space_id,
335                            d.kind,
336                            d.snapshot_cadence,
337                            d.snapshot_interval_tokens,
338                        )
339                    })
340                    .collect()
341            })
342            .unwrap_or_default();
343        v.sort_by_key(|&(id, kind, _, _)| (kind != superfluid_abi::space_kind::PAGED_TOKEN_KV, id));
344        v
345    }
346
347    pub fn park_boundary(&self, len: u64) -> u64 {
348        let mut b = len;
349        for &(_, kind, cadence, interval) in &self.parkable_spaces() {
350            if kind == superfluid_abi::space_kind::PAGED_TOKEN_KV {
351                continue;
352            }
353            if cadence == 2 && interval > 0 {
354                b = b.min(len / interval as u64 * interval as u64);
355            }
356        }
357        b
358    }
359
360    pub fn multi_space(&self) -> bool {
361        self.parkable_spaces()
362            .iter()
363            .any(|&(_, kind, _, _)| kind != superfluid_abi::space_kind::PAGED_TOKEN_KV)
364    }
365
366    pub fn kv_block_bytes(&self) -> Option<u64> {
367        self.client_ref()?
368            .hello
369            .state_space_descriptors
370            .iter()
371            .find(|d| d.page_size_tokens > 0)
372            .map(|d| d.bytes_per_token * d.page_size_tokens as u64)
373    }
374
375    pub fn paged_spaces(&self) -> Vec<(u32, u64, u64)> {
376        let Some(c) = self.client_ref() else {
377            return Vec::new();
378        };
379        c.hello
380            .state_space_descriptors
381            .iter()
382            .filter(|d| d.page_size_tokens > 0)
383            .filter(|d| d.kind != superfluid_abi::space_kind::ENCODER_CACHE)
384            .map(|d| {
385                let page = d.page_size_tokens as u64;
386                (d.space_id, page, d.bytes_per_token * page)
387            })
388            .collect()
389    }
390
391    pub fn blob_spaces(&self) -> Vec<(u32, u64)> {
392        let Some(c) = self.client_ref() else {
393            return Vec::new();
394        };
395        c.hello
396            .state_space_descriptors
397            .iter()
398            .filter(|d| d.page_size_tokens == 0 && d.blob_bytes > 0)
399            .filter(|d| d.kind != superfluid_abi::space_kind::ENCODER_CACHE)
400            .map(|d| (d.space_id, d.blob_bytes))
401            .collect()
402    }
403
404    pub fn space_shape(&self, space_id: u32) -> Option<(u32, u32)> {
405        self.client_ref()?
406            .hello
407            .state_space_descriptors
408            .iter()
409            .find(|d| d.space_id == space_id)
410            .map(|d| (d.kind, d.page_size_tokens))
411    }
412
413    pub fn max_stream_tokens(&self) -> u64 {
414        stream_ceiling(self.staged_tokens(), &self.caps.read().expect("capability record"))
415    }
416
417    pub fn staged_tokens(&self) -> u64 {
418        self.client_ref()
419            .map(|c| {
420                c.hello
421                    .limits
422                    .ring_specs
423                    .iter()
424                    .find(|s| s.ring_id == superfluid_shm::TOKEN_RING_IN)
425                    .map(|s| ((s.slot_bytes.saturating_sub(16)) / 4) as u64)
426                    .unwrap_or(0)
427            })
428            .unwrap_or(0)
429    }
430}
431
432pub(crate) fn stream_ceiling(staged: u64, caps: &crate::capabilities::Capabilities) -> u64 {
433    match caps.limit("max_seq_len") {
434        Some(held) if staged > 0 => staged.min(held),
435        _ => staged,
436    }
437}
438
439fn launch(spec: &WorkerSpec) -> Result<(std::process::Child, WorkerClient), DaemonError> {
440    use std::os::unix::io::AsRawFd;
441    use std::os::unix::process::CommandExt;
442
443    let (frames_a, frames_w) = UnixStream::pair()?;
444    let (fds_a, fds_w) = UnixStream::pair()?;
445    let frames_fd = frames_w.as_raw_fd();
446    let fdchan_fd = fds_w.as_raw_fd();
447    let mut cmd = std::process::Command::new(&spec.bin);
448    crate::exec::without_secrets(&mut cmd)
449        .args(&spec.args)
450        .arg("--frames-fd")
451        .arg("3")
452        .arg("--fd-channel-fd")
453        .arg("4");
454    if let Some(fd) = &spec.stderr {
455        cmd.stderr(std::process::Stdio::from(fd.try_clone()?));
456        cmd.stdout(std::process::Stdio::from(fd.try_clone()?));
457    }
458    // SAFETY: pre_exec runs in the forked child before exec. The two ends are first
459    // moved above 10, since one of them may already be 3 or 4: dup2 onto itself keeps
460    // CLOEXEC and the worker would inherit a closed descriptor, and dup2 onto the
461    // other's number would clobber it. dup2 onto 3 and 4 then clears CLOEXEC on
462    // exactly those two, so they alone survive the exec.
463    unsafe {
464        cmd.pre_exec(move || {
465            let frames = libc::fcntl(frames_fd, libc::F_DUPFD_CLOEXEC, 10);
466            let fdchan = libc::fcntl(fdchan_fd, libc::F_DUPFD_CLOEXEC, 10);
467            if frames < 0 || fdchan < 0 || libc::dup2(frames, 3) < 0 || libc::dup2(fdchan, 4) < 0 {
468                return Err(std::io::Error::last_os_error());
469            }
470            Ok(())
471        });
472    }
473    let child = crate::exec::when_not_busy(|| cmd.spawn())?;
474    drop(frames_w);
475    drop(fds_w);
476    let client = WorkerClient::connect(frames_a, fds_a)?;
477    Ok((child, client))
478}
479
480impl Drop for EngineHost {
481    fn drop(&mut self) {
482        drop(self.client.take());
483        match &mut self.backend {
484            Backend::Thread(handle) => {
485                if let Some(h) = handle.take() {
486                    let _ = h.join();
487                }
488            }
489            Backend::Process { child, .. } => {
490                let _ = child.wait();
491            }
492        }
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use std::sync::atomic::{AtomicBool, Ordering};
499    use std::sync::Arc;
500
501    use superfluid_engine::{EngineConfig, MockEngine};
502
503    use super::EngineHost;
504    use crate::DaemonError;
505
506    struct Unwound(Arc<AtomicBool>);
507    impl Drop for Unwound {
508        fn drop(&mut self) {
509            self.0.store(true, Ordering::SeqCst);
510        }
511    }
512
513    #[test]
514    fn try_spawn_refusal_is_engine_load_and_joins_the_worker() {
515        let unwound = Arc::new(AtomicBool::new(false));
516        let guard = Unwound(unwound.clone());
517        let err = EngineHost::try_spawn(move || {
518            let _guard = guard;
519            Err::<(MockEngine, _), _>("no paged KV on this backend".to_string())
520        })
521        .err()
522        .expect("refusal must surface as an error");
523        match err {
524            DaemonError::EngineLoad(msg) => assert_eq!(msg, "no paged KV on this backend"),
525            other => panic!("expected EngineLoad, got {other:?}"),
526        }
527        assert!(unwound.load(Ordering::SeqCst), "worker thread not joined");
528    }
529
530    #[test]
531    fn try_spawn_panicking_closure_reports_early_exit_without_panicking() {
532        let unwound = Arc::new(AtomicBool::new(false));
533        let guard = Unwound(unwound.clone());
534        let err = EngineHost::try_spawn(move || -> Result<(MockEngine, _), String> {
535            let _guard = guard;
536            panic!("engine build blew up");
537        })
538        .err()
539        .expect("a dead worker must surface as an error");
540        match err {
541            DaemonError::EngineLoad(msg) => {
542                assert_eq!(msg, "engine worker exited before reporting a load result")
543            }
544            other => panic!("expected EngineLoad, got {other:?}"),
545        }
546        assert!(unwound.load(Ordering::SeqCst), "worker thread not joined");
547    }
548
549    #[test]
550    fn try_spawn_success_builds_a_live_host() {
551        let host = EngineHost::try_spawn(|| Ok((MockEngine::new(EngineConfig::default()), None)))
552            .expect("mock engine loads");
553        assert!(!host.is_process());
554        drop(host);
555    }
556}