Skip to main content

magi_code/service/persistent/
entrypoint.rs

1use super::{Coordinator, ServiceRuntime};
2use anyhow::{Result, anyhow};
3use crossbeam_channel::{Sender, bounded};
4use std::{
5    sync::Arc,
6    thread,
7    time::{Duration, Instant},
8};
9
10#[cfg(any(unix, test))]
11use std::path::PathBuf;
12
13/// Transport-independent wire-v2 service. This does not listen on a socket.
14/// A local adapter owns framing, write deadlines, and explicit disconnects.
15/// Dropping this owner shuts down the daemon; disconnecting a connection does not.
16/// A failed/timeout submission is uncertain: never automatically replay a mutation.
17pub struct PersistentService {
18    commands: Sender<Command>,
19    worker: Option<thread::JoinHandle<()>>,
20}
21
22enum Action {
23    #[cfg(any(unix, test))]
24    StopIfIdle,
25    Connect,
26    Submit {
27        connection: String,
28        record: Vec<u8>,
29    },
30    Disconnect(String),
31    Next(String),
32    Written {
33        connection: String,
34        request: String,
35    },
36}
37struct Command {
38    action: Action,
39    deadline: Instant,
40    response: Sender<Result<Option<String>>>,
41}
42
43impl PersistentService {
44    /// Load the normal runtime once and start its coordinator. No provider work runs until
45    /// an initialized connection explicitly claims a durable session and starts a turn.
46    pub fn start() -> Result<Self> {
47        Self::start_with_loader(ServiceRuntime::load_persistent)
48    }
49
50    /// Start the Unix adapter with explicit absolute workspace and state paths.
51    #[cfg(any(unix, test))]
52    pub(crate) fn start_unix(workspace: PathBuf, state_root: PathBuf) -> Result<Self> {
53        let start = || {
54            let runtime = ServiceRuntime::load_unix(workspace, state_root)?;
55            let mut coordinator = Coordinator::new(Arc::new(runtime))?;
56            coordinator.unix_transport = true;
57            Self::spawn(coordinator)
58        };
59        start().map_err(|_: anyhow::Error| anyhow!("application service startup failed"))
60    }
61
62    /// Stop only if all earlier admissions and worker cleanup have completed.
63    /// Commands behind a successful stop are rejected, never accepted then cancelled.
64    #[cfg(any(unix, test))]
65    pub(crate) fn stop_if_idle(&self) -> Result<bool> {
66        self.call(Action::StopIfIdle).map(|reply| reply.is_some())
67    }
68
69    #[cfg(any(unix, test))]
70    pub(crate) fn is_finished(&self) -> bool {
71        self.worker
72            .as_ref()
73            .is_none_or(|worker| worker.is_finished())
74    }
75
76    fn start_with_loader(load: impl FnOnce() -> Result<ServiceRuntime>) -> Result<Self> {
77        let start = || Self::spawn(Coordinator::new(Arc::new(load()?))?);
78        start().map_err(|_| anyhow!("application service startup failed"))
79    }
80
81    pub(super) fn spawn(coordinator: Coordinator) -> Result<Self> {
82        Self::spawn_with_idle_clock(coordinator, Instant::now)
83    }
84
85    fn spawn_with_idle_clock(
86        mut coordinator: Coordinator,
87        mut idle_clock: impl FnMut() -> Instant + Send + 'static,
88    ) -> Result<Self> {
89        let (commands, receiver) = bounded::<Command>(32);
90        let worker = thread::Builder::new()
91            .name("magi-persistent-service".into())
92            .spawn(move || {
93                let started = Instant::now();
94                let mut idle_since = started;
95                loop {
96                    // Only idle accounting uses this clock; request and worker deadlines
97                    // remain on the real monotonic clock.
98                    let now = idle_clock();
99                    coordinator.tick(Instant::now());
100                    let idle = coordinator.is_idle();
101                    if !idle {
102                        idle_since = now;
103                    }
104                    match receiver.recv_timeout(Duration::from_millis(5)) {
105                        Ok(command) => {
106                            #[cfg(any(unix, test))]
107                            let stopping = matches!(command.action, Action::StopIfIdle);
108                            #[cfg(not(any(unix, test)))]
109                            let stopping = false;
110                            let result = if Instant::now() >= command.deadline {
111                                Err(anyhow!(super::Code::RequestTimeout.message()))
112                            } else {
113                                execute(&mut coordinator, command.action, command.deadline)
114                            };
115                            let stopped = stopping && matches!(&result, Ok(Some(_)));
116                            let _ = command.response.try_send(result);
117                            if stopped {
118                                // No accepted work exists; do not enter owner-drop cancellation.
119                                return;
120                            }
121                        }
122                        Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
123                            // Check expiry only after giving queued admissions priority. A send
124                            // racing this exit gets a closed response channel, not acceptance.
125                            if idle
126                                && now.duration_since(started) >= Duration::from_secs(10)
127                                && now.duration_since(idle_since) >= Duration::from_secs(60)
128                            {
129                                return;
130                            }
131                        }
132                        Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
133                    }
134                }
135                // Explicit daemon owner shutdown cancels and drains shared workers.
136                // Ordinary connection loss never reaches this path.
137                // Preparation has no effects until commit. Discard it, but keep draining
138                // accepted turns before joining a possibly blocked preparation worker.
139                let preparation = coordinator.turn_preparation.take();
140                let source_capture = coordinator.source_capture.take();
141                let auth_work = coordinator.auth_work.take();
142                coordinator.execution.cancel_all();
143                while !coordinator.cleaning.is_empty() {
144                    coordinator.tick(Instant::now());
145                    thread::sleep(Duration::from_millis(1));
146                }
147                while let Ok(Some(output)) = coordinator.execution.shutdown_next() {
148                    if let Some(id) = output.terminal_turn_id {
149                        coordinator.execution.finish_worker_output(&id);
150                    }
151                }
152                if let Some(pending) = preparation {
153                    let _ = pending.worker.join();
154                }
155                if let Some(capture) = source_capture {
156                    capture.join();
157                }
158                if let Some(work) = auth_work {
159                    let _ = work.worker.join();
160                }
161            })?;
162        Ok(Self {
163            commands,
164            worker: Some(worker),
165        })
166    }
167
168    fn call(&self, action: Action) -> Result<Option<String>> {
169        let (response, receiver) = bounded(1);
170        self.commands
171            .try_send(Command {
172                action,
173                deadline: Instant::now() + Duration::from_secs(30),
174                response,
175            })
176            .map_err(|_| anyhow!("service admission queue unavailable"))?;
177        receiver
178            .recv_timeout(Duration::from_secs(30))
179            .map_err(|_| anyhow!("service response unavailable; admission may have occurred"))?
180    }
181
182    /// Allocate a pending connection. Initialize it within five seconds.
183    pub fn connect(&self) -> Result<String> {
184        self.call(Action::Connect)?
185            .ok_or_else(|| anyhow!("connection unavailable"))
186    }
187
188    /// Submit one complete, bounded JSON record. Responses are read with `next_record`.
189    pub fn submit(&self, connection: &str, record: &[u8]) -> Result<()> {
190        if record.len() > crate::service::protocol::MAX_RECORD_BYTES {
191            return Err(anyhow!("record too large"));
192        }
193        self.call(Action::Submit {
194            connection: checked_id(connection)?,
195            record: record.to_vec(),
196        })
197        .map(|_| ())
198    }
199
200    /// Revoke this connection's control and cancel only its login, not accepted turns/settings.
201    pub fn disconnect(&self, connection: &str) -> Result<()> {
202        self.call(Action::Disconnect(checked_id(connection)?))
203            .map(|_| ())
204    }
205
206    /// Remove one encoded record from the outbound queue. A response's request ID remains
207    /// reserved until `response_written` or disconnect, even after dequeue.
208    pub fn next_record(&self, connection: &str) -> Result<Option<String>> {
209        self.call(Action::Next(checked_id(connection)?))
210    }
211
212    /// Release response correlation only after the adapter's write attempt. On write failure,
213    /// disconnect instead. This acknowledgement never controls execution or lease cleanup.
214    pub fn response_written(&self, connection: &str, request: &str) -> Result<()> {
215        self.call(Action::Written {
216            connection: checked_id(connection)?,
217            request: checked_id(request)?,
218        })
219        .map(|_| ())
220    }
221}
222
223fn checked_id(id: &str) -> Result<String> {
224    anyhow::ensure!(super::wire::valid_id(id), "invalid service identity");
225    Ok(id.to_owned())
226}
227
228fn execute(
229    coordinator: &mut Coordinator,
230    action: Action,
231    deadline: Instant,
232) -> Result<Option<String>> {
233    let protocol_error = |code: super::Code| anyhow!(code.message());
234    match action {
235        #[cfg(any(unix, test))]
236        Action::StopIfIdle => Ok(coordinator.is_idle().then(|| "stopped".to_owned())),
237        Action::Connect => coordinator
238            .connect(Instant::now())
239            .map(Some)
240            .map_err(protocol_error),
241        Action::Submit { connection, record } => coordinator
242            .submit_until(&connection, &record, Instant::now(), deadline)
243            .map(|_| None)
244            .map_err(protocol_error),
245        Action::Disconnect(connection) => {
246            coordinator.disconnect(&connection);
247            Ok(None)
248        }
249        Action::Next(connection) => {
250            let client = coordinator
251                .connections
252                .get_mut(&connection)
253                .ok_or_else(|| protocol_error(super::Code::StaleConnection))?;
254            let record = client.queue.pop_front();
255            if let Some(record) = &record {
256                client.queued_bytes -= record.len();
257            }
258            Ok(record)
259        }
260        Action::Written {
261            connection,
262            request,
263        } => {
264            let client = coordinator
265                .connections
266                .get_mut(&connection)
267                .ok_or_else(|| protocol_error(super::Code::StaleConnection))?;
268            if let Some(count) = client.requests.get_mut(&request) {
269                *count -= 1;
270                if *count == 0 {
271                    client.requests.remove(&request);
272                }
273            }
274            Ok(None)
275        }
276    }
277}
278
279impl Drop for PersistentService {
280    fn drop(&mut self) {
281        let (replacement, _receiver) = bounded(1);
282        drop(std::mem::replace(&mut self.commands, replacement));
283        if let Some(worker) = self.worker.take() {
284            let _ = worker.join();
285        }
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    // The clock gates each coordinator iteration, so queue order and expiry do not
294    // depend on scheduling or on waiting sixty real seconds. Drop clock inputs first
295    // to unblock the coordinator before PersistentService joins it.
296    struct ControlledIdleService {
297        clock: Sender<Instant>,
298        ready: crossbeam_channel::Receiver<()>,
299        service: PersistentService,
300    }
301
302    impl ControlledIdleService {
303        fn new(coordinator: Coordinator) -> Self {
304            let (clock, times) = bounded(1);
305            let (ready_sender, ready) = bounded(1);
306            let service = PersistentService::spawn_with_idle_clock(coordinator, move || {
307                let _ = ready_sender.try_send(());
308                times.recv().unwrap_or_else(|_| Instant::now())
309            })
310            .unwrap();
311            ready.recv_timeout(Duration::from_secs(5)).unwrap();
312            Self {
313                clock,
314                ready,
315                service,
316            }
317        }
318
319        fn queue(&self, action: Action) -> crossbeam_channel::Receiver<Result<Option<String>>> {
320            let (response, reply) = bounded(1);
321            self.service
322                .commands
323                .try_send(Command {
324                    action,
325                    deadline: Instant::now() + Duration::from_secs(30),
326                    response,
327                })
328                .unwrap();
329            reply
330        }
331
332        fn advance(&self, now: Instant) {
333            self.clock.send(now).unwrap();
334            self.ready.recv_timeout(Duration::from_secs(5)).unwrap();
335            assert!(!self.service.is_finished());
336        }
337    }
338
339    fn idle_coordinator(temp: &tempfile::TempDir) -> Coordinator {
340        let runtime =
341            ServiceRuntime::load_unix(temp.path().to_owned(), temp.path().join("state")).unwrap();
342        Coordinator::new(Arc::new(runtime)).unwrap()
343    }
344
345    #[test]
346    fn automatic_idle_expiry_prioritizes_queued_admission() {
347        let temp = tempfile::tempdir().unwrap();
348        let controlled = ControlledIdleService::new(idle_coordinator(&temp));
349        let expired = Instant::now() + Duration::from_secs(61);
350        let admitted = controlled.queue(Action::Connect);
351        controlled.advance(expired);
352        let connection = admitted
353            .recv_timeout(Duration::from_secs(5))
354            .unwrap()
355            .unwrap()
356            .unwrap();
357        // A full timeout iteration after acceptance must not stop the service.
358        controlled.advance(expired + Duration::from_secs(61));
359        let disconnected = controlled.queue(Action::Disconnect(connection));
360        controlled.advance(expired + Duration::from_secs(61));
361        disconnected
362            .recv_timeout(Duration::from_secs(5))
363            .unwrap()
364            .unwrap();
365        controlled
366            .clock
367            .send(expired + Duration::from_secs(122))
368            .unwrap();
369        wait_for_completion(&controlled.service);
370        assert!(controlled.service.connect().is_err());
371    }
372
373    #[test]
374    fn automatic_idle_expiry_rejects_admission_after_exit() {
375        let temp = tempfile::tempdir().unwrap();
376        let controlled = ControlledIdleService::new(idle_coordinator(&temp));
377        controlled
378            .clock
379            .send(Instant::now() + Duration::from_secs(61))
380            .unwrap();
381        wait_for_completion(&controlled.service);
382        assert!(controlled.service.connect().is_err());
383    }
384
385    #[test]
386    fn automatic_idle_expiry_waits_for_disconnected_accepted_settings_write() {
387        let temp = tempfile::tempdir().unwrap();
388        let mut coordinator = idle_coordinator(&temp);
389        let connection = coordinator.connect(Instant::now()).unwrap();
390        coordinator.submit(&connection, br#"{"protocol_version":2,"kind":"request","request_id":"init","instance_id":null,"connection_id":null,"session_id":null,"operation_id":null,"control":null,"method":"initialize","payload":{"supported_protocol_versions":[2]}}"#, Instant::now()).unwrap();
391        let initialized: serde_json::Value =
392            serde_json::from_str(coordinator.connections[&connection].queue.front().unwrap())
393                .unwrap();
394        assert!(initialized["error"].is_null(), "{initialized}");
395        let paths = coordinator.runtime.config.paths.clone();
396        // Declare the service before the lock so unwinding releases the worker first.
397        let controlled;
398        let lock = crate::persistence::CrossProcessFileLock::acquire(&paths.settings_file).unwrap();
399        let request = serde_json::json!({
400            "protocol_version":2,"kind":"request","request_id":"write",
401            "instance_id":coordinator.instance,"connection_id":connection,
402            "session_id":null,"control":null,
403            "operation_id":"settings-write","method":"config.set",
404            "payload":{"scope":"global","fast":true}
405        });
406        coordinator
407            .submit(
408                &connection,
409                &serde_json::to_vec(&request).unwrap(),
410                Instant::now(),
411            )
412            .unwrap();
413        assert_eq!(
414            coordinator.operations.lookup(
415                &coordinator.instance,
416                &coordinator.instance,
417                "settings-write"
418            )["state"],
419            "accepted"
420        );
421        coordinator.disconnect(&connection);
422        controlled = ControlledIdleService::new(coordinator);
423        let mut now = Instant::now() + Duration::from_secs(61);
424        controlled.advance(now);
425        now += Duration::from_secs(61);
426        controlled.advance(now);
427        assert!(!crate::config::read_settings(&paths).unwrap().fast.enabled);
428        drop(lock);
429        // Let the real worker finish. Virtual idle periods alone cannot bypass it.
430        let deadline = Instant::now() + Duration::from_secs(5);
431        loop {
432            assert!(Instant::now() < deadline, "settings worker did not settle");
433            now += Duration::from_secs(61);
434            controlled.clock.send(now).unwrap();
435            match controlled.ready.recv_timeout(Duration::from_secs(5)) {
436                Ok(()) => {}
437                Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
438                Err(error) => panic!("coordinator did not progress: {error}"),
439            }
440        }
441        wait_for_completion(&controlled.service);
442        assert!(crate::config::read_settings(&paths).unwrap().fast.enabled);
443        assert!(controlled.service.connect().is_err());
444    }
445
446    fn wait_for_completion(service: &PersistentService) {
447        let deadline = Instant::now() + Duration::from_secs(5);
448        while !service.is_finished() {
449            assert!(Instant::now() < deadline, "coordinator did not exit");
450            thread::sleep(Duration::from_millis(1));
451        }
452    }
453
454    #[test]
455    fn unix_service_refuses_busy_stop_then_exits_without_accepting_more_connections() {
456        let temp = tempfile::tempdir().unwrap();
457        let service =
458            PersistentService::start_unix(temp.path().to_owned(), temp.path().join("state"))
459                .unwrap();
460        let connection = service.connect().unwrap();
461        assert!(!service.stop_if_idle().unwrap());
462        assert!(!service.is_finished());
463        service.submit(&connection, br#"{"protocol_version":2,"kind":"request","request_id":"init","instance_id":null,"connection_id":null,"session_id":null,"operation_id":null,"control":null,"method":"initialize","payload":{"supported_protocol_versions":[2]}}"#).unwrap();
464        let record: serde_json::Value =
465            serde_json::from_str(&service.next_record(&connection).unwrap().unwrap()).unwrap();
466        assert!(record["error"].is_null(), "{record}");
467        assert_eq!(
468            record["payload"]["capabilities"]["transports"],
469            serde_json::json!(["unix"])
470        );
471        service.disconnect(&connection).unwrap();
472        assert!(service.stop_if_idle().unwrap());
473        wait_for_completion(&service);
474        assert!(service.connect().is_err());
475    }
476
477    #[test]
478    fn preparation_blocks_stop_until_worker_completion() {
479        let temp = tempfile::tempdir().unwrap();
480        let runtime = Arc::new(
481            ServiceRuntime::load_unix(temp.path().to_owned(), temp.path().join("state")).unwrap(),
482        );
483        let mut coordinator = Coordinator::new(Arc::clone(&runtime)).unwrap();
484        let (release, released) = bounded(1);
485        coordinator.turn_preparation = Some(super::super::PendingTurnPreparation {
486            connection: "disconnected".into(),
487            request: serde_json::from_value(serde_json::json!({
488                "protocol_version":2,"kind":"request","request_id":"prepare",
489                "method":"turn.start","payload":{}
490            }))
491            .unwrap(),
492            deadline: Instant::now() + Duration::from_secs(30),
493            worker: thread::spawn(move || {
494                released.recv().unwrap();
495                Ok(runtime)
496            }),
497        });
498        let service = PersistentService::spawn(coordinator).unwrap();
499        assert!(!service.stop_if_idle().unwrap());
500        release.send(()).unwrap();
501        let deadline = Instant::now() + Duration::from_secs(5);
502        while !service.stop_if_idle().unwrap() {
503            assert!(Instant::now() < deadline, "preparation did not settle");
504            thread::sleep(Duration::from_millis(1));
505        }
506        wait_for_completion(&service);
507    }
508
509    #[test]
510    fn queued_stop_rejects_later_admissions() {
511        let temp = tempfile::tempdir().unwrap();
512        let runtime =
513            ServiceRuntime::load_unix(temp.path().to_owned(), temp.path().join("state")).unwrap();
514        let coordinator = Coordinator::new(Arc::new(runtime)).unwrap();
515        assert_eq!(
516            super::super::capabilities(coordinator.unix_transport)["transports"],
517            serde_json::json!([])
518        );
519        let service = PersistentService::spawn(coordinator).unwrap();
520        let (response, stopped) = bounded(1);
521        service
522            .commands
523            .try_send(Command {
524                action: Action::StopIfIdle,
525                deadline: Instant::now() + Duration::from_secs(30),
526                response,
527            })
528            .unwrap();
529        // This admission is behind the stop even if the worker has not processed it yet.
530        assert!(service.connect().is_err());
531        assert!(
532            stopped
533                .recv_timeout(Duration::from_secs(5))
534                .unwrap()
535                .unwrap()
536                .is_some()
537        );
538        wait_for_completion(&service);
539    }
540
541    #[test]
542    fn startup_errors_do_not_expose_settings_or_auth_secrets() {
543        for marker in ["settings-secret-marker", "auth-secret-marker"] {
544            let error = PersistentService::start_with_loader(|| Err(anyhow!(marker)))
545                .err()
546                .expect("startup must fail");
547            assert_eq!(error.to_string(), "application service startup failed");
548            assert!(!format!("{error:?}").contains(marker));
549        }
550    }
551}