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 persistent service. This does not listen on a socket or select v2
14/// for stdio. 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 retains stdio's proven cancellation/draining
136                // machinery. 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 auth_work = coordinator.auth_work.take();
141                coordinator.execution.cancel_all();
142                while !coordinator.cleaning.is_empty() {
143                    coordinator.tick(Instant::now());
144                    thread::sleep(Duration::from_millis(1));
145                }
146                while let Ok(Some(output)) = coordinator.execution.shutdown_next() {
147                    if let Some(id) = output.terminal_turn_id {
148                        coordinator.execution.finish_worker_output(&id);
149                    }
150                }
151                if let Some(pending) = preparation {
152                    let _ = pending.worker.join();
153                }
154                if let Some(work) = auth_work {
155                    let _ = work.worker.join();
156                }
157            })?;
158        Ok(Self {
159            commands,
160            worker: Some(worker),
161        })
162    }
163
164    fn call(&self, action: Action) -> Result<Option<String>> {
165        let (response, receiver) = bounded(1);
166        self.commands
167            .try_send(Command {
168                action,
169                deadline: Instant::now() + Duration::from_secs(30),
170                response,
171            })
172            .map_err(|_| anyhow!("service admission queue unavailable"))?;
173        receiver
174            .recv_timeout(Duration::from_secs(30))
175            .map_err(|_| anyhow!("service response unavailable; admission may have occurred"))?
176    }
177
178    /// Allocate a pending connection. Initialize it within five seconds.
179    pub fn connect(&self) -> Result<String> {
180        self.call(Action::Connect)?
181            .ok_or_else(|| anyhow!("connection unavailable"))
182    }
183
184    /// Submit one complete, bounded JSON record. Responses are read with `next_record`.
185    pub fn submit(&self, connection: &str, record: &[u8]) -> Result<()> {
186        if record.len() > crate::service::protocol::MAX_RECORD_BYTES {
187            return Err(anyhow!("record too large"));
188        }
189        self.call(Action::Submit {
190            connection: checked_id(connection)?,
191            record: record.to_vec(),
192        })
193        .map(|_| ())
194    }
195
196    /// Revoke this connection's control and cancel only its login, not accepted turns/settings.
197    pub fn disconnect(&self, connection: &str) -> Result<()> {
198        self.call(Action::Disconnect(checked_id(connection)?))
199            .map(|_| ())
200    }
201
202    /// Remove one encoded record from the outbound queue. A response's request ID remains
203    /// reserved until `response_written` or disconnect, even after dequeue.
204    pub fn next_record(&self, connection: &str) -> Result<Option<String>> {
205        self.call(Action::Next(checked_id(connection)?))
206    }
207
208    /// Release response correlation only after the adapter's write attempt. On write failure,
209    /// disconnect instead. This acknowledgement never controls execution or lease cleanup.
210    pub fn response_written(&self, connection: &str, request: &str) -> Result<()> {
211        self.call(Action::Written {
212            connection: checked_id(connection)?,
213            request: checked_id(request)?,
214        })
215        .map(|_| ())
216    }
217}
218
219fn checked_id(id: &str) -> Result<String> {
220    anyhow::ensure!(super::wire::valid_id(id), "invalid service identity");
221    Ok(id.to_owned())
222}
223
224fn execute(
225    coordinator: &mut Coordinator,
226    action: Action,
227    deadline: Instant,
228) -> Result<Option<String>> {
229    let protocol_error = |code: super::Code| anyhow!(code.message());
230    match action {
231        #[cfg(any(unix, test))]
232        Action::StopIfIdle => Ok(coordinator.is_idle().then(|| "stopped".to_owned())),
233        Action::Connect => coordinator
234            .connect(Instant::now())
235            .map(Some)
236            .map_err(protocol_error),
237        Action::Submit { connection, record } => coordinator
238            .submit_until(&connection, &record, Instant::now(), deadline)
239            .map(|_| None)
240            .map_err(protocol_error),
241        Action::Disconnect(connection) => {
242            coordinator.disconnect(&connection);
243            Ok(None)
244        }
245        Action::Next(connection) => {
246            let client = coordinator
247                .connections
248                .get_mut(&connection)
249                .ok_or_else(|| protocol_error(super::Code::StaleConnection))?;
250            let record = client.queue.pop_front();
251            if let Some(record) = &record {
252                client.queued_bytes -= record.len();
253            }
254            Ok(record)
255        }
256        Action::Written {
257            connection,
258            request,
259        } => {
260            let client = coordinator
261                .connections
262                .get_mut(&connection)
263                .ok_or_else(|| protocol_error(super::Code::StaleConnection))?;
264            if let Some(count) = client.requests.get_mut(&request) {
265                *count -= 1;
266                if *count == 0 {
267                    client.requests.remove(&request);
268                }
269            }
270            Ok(None)
271        }
272    }
273}
274
275impl Drop for PersistentService {
276    fn drop(&mut self) {
277        let (replacement, _receiver) = bounded(1);
278        drop(std::mem::replace(&mut self.commands, replacement));
279        if let Some(worker) = self.worker.take() {
280            let _ = worker.join();
281        }
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    // The clock gates each coordinator iteration, so queue order and expiry do not
290    // depend on scheduling or on waiting sixty real seconds. Drop clock inputs first
291    // to unblock the coordinator before PersistentService joins it.
292    struct ControlledIdleService {
293        clock: Sender<Instant>,
294        ready: crossbeam_channel::Receiver<()>,
295        service: PersistentService,
296    }
297
298    impl ControlledIdleService {
299        fn new(coordinator: Coordinator) -> Self {
300            let (clock, times) = bounded(1);
301            let (ready_sender, ready) = bounded(1);
302            let service = PersistentService::spawn_with_idle_clock(coordinator, move || {
303                let _ = ready_sender.try_send(());
304                times.recv().unwrap_or_else(|_| Instant::now())
305            })
306            .unwrap();
307            ready.recv_timeout(Duration::from_secs(5)).unwrap();
308            Self {
309                clock,
310                ready,
311                service,
312            }
313        }
314
315        fn queue(&self, action: Action) -> crossbeam_channel::Receiver<Result<Option<String>>> {
316            let (response, reply) = bounded(1);
317            self.service
318                .commands
319                .try_send(Command {
320                    action,
321                    deadline: Instant::now() + Duration::from_secs(30),
322                    response,
323                })
324                .unwrap();
325            reply
326        }
327
328        fn advance(&self, now: Instant) {
329            self.clock.send(now).unwrap();
330            self.ready.recv_timeout(Duration::from_secs(5)).unwrap();
331            assert!(!self.service.is_finished());
332        }
333    }
334
335    fn idle_coordinator(temp: &tempfile::TempDir) -> Coordinator {
336        let runtime =
337            ServiceRuntime::load_unix(temp.path().to_owned(), temp.path().join("state")).unwrap();
338        Coordinator::new(Arc::new(runtime)).unwrap()
339    }
340
341    #[test]
342    fn automatic_idle_expiry_prioritizes_queued_admission() {
343        let temp = tempfile::tempdir().unwrap();
344        let controlled = ControlledIdleService::new(idle_coordinator(&temp));
345        let expired = Instant::now() + Duration::from_secs(61);
346        let admitted = controlled.queue(Action::Connect);
347        controlled.advance(expired);
348        let connection = admitted
349            .recv_timeout(Duration::from_secs(5))
350            .unwrap()
351            .unwrap()
352            .unwrap();
353        // A full timeout iteration after acceptance must not stop the service.
354        controlled.advance(expired + Duration::from_secs(61));
355        let disconnected = controlled.queue(Action::Disconnect(connection));
356        controlled.advance(expired + Duration::from_secs(61));
357        disconnected
358            .recv_timeout(Duration::from_secs(5))
359            .unwrap()
360            .unwrap();
361        controlled
362            .clock
363            .send(expired + Duration::from_secs(122))
364            .unwrap();
365        wait_for_completion(&controlled.service);
366        assert!(controlled.service.connect().is_err());
367    }
368
369    #[test]
370    fn automatic_idle_expiry_rejects_admission_after_exit() {
371        let temp = tempfile::tempdir().unwrap();
372        let controlled = ControlledIdleService::new(idle_coordinator(&temp));
373        controlled
374            .clock
375            .send(Instant::now() + Duration::from_secs(61))
376            .unwrap();
377        wait_for_completion(&controlled.service);
378        assert!(controlled.service.connect().is_err());
379    }
380
381    #[test]
382    fn automatic_idle_expiry_waits_for_disconnected_accepted_settings_write() {
383        let temp = tempfile::tempdir().unwrap();
384        let mut coordinator = idle_coordinator(&temp);
385        let connection = coordinator.connect(Instant::now()).unwrap();
386        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();
387        let initialized: serde_json::Value =
388            serde_json::from_str(coordinator.connections[&connection].queue.front().unwrap())
389                .unwrap();
390        assert!(initialized["error"].is_null(), "{initialized}");
391        let paths = coordinator.runtime.config.paths.clone();
392        // Declare the service before the lock so unwinding releases the worker first.
393        let controlled;
394        let lock = crate::persistence::CrossProcessFileLock::acquire(&paths.settings_file).unwrap();
395        let request = serde_json::json!({
396            "protocol_version":2,"kind":"request","request_id":"write",
397            "instance_id":coordinator.instance,"connection_id":connection,
398            "session_id":null,"control":null,
399            "operation_id":"settings-write","method":"config.set",
400            "payload":{"scope":"global","fast":true}
401        });
402        coordinator
403            .submit(
404                &connection,
405                &serde_json::to_vec(&request).unwrap(),
406                Instant::now(),
407            )
408            .unwrap();
409        assert_eq!(
410            coordinator.operations.lookup(
411                &coordinator.instance,
412                &coordinator.instance,
413                "settings-write"
414            )["state"],
415            "accepted"
416        );
417        coordinator.disconnect(&connection);
418        controlled = ControlledIdleService::new(coordinator);
419        let mut now = Instant::now() + Duration::from_secs(61);
420        controlled.advance(now);
421        now += Duration::from_secs(61);
422        controlled.advance(now);
423        assert!(!crate::config::read_settings(&paths).unwrap().fast.enabled);
424        drop(lock);
425        // Let the real worker finish. Virtual idle periods alone cannot bypass it.
426        let deadline = Instant::now() + Duration::from_secs(5);
427        loop {
428            assert!(Instant::now() < deadline, "settings worker did not settle");
429            now += Duration::from_secs(61);
430            controlled.clock.send(now).unwrap();
431            match controlled.ready.recv_timeout(Duration::from_secs(5)) {
432                Ok(()) => {}
433                Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
434                Err(error) => panic!("coordinator did not progress: {error}"),
435            }
436        }
437        wait_for_completion(&controlled.service);
438        assert!(crate::config::read_settings(&paths).unwrap().fast.enabled);
439        assert!(controlled.service.connect().is_err());
440    }
441
442    fn wait_for_completion(service: &PersistentService) {
443        let deadline = Instant::now() + Duration::from_secs(5);
444        while !service.is_finished() {
445            assert!(Instant::now() < deadline, "coordinator did not exit");
446            thread::sleep(Duration::from_millis(1));
447        }
448    }
449
450    #[test]
451    fn unix_service_refuses_busy_stop_then_exits_without_accepting_more_connections() {
452        let temp = tempfile::tempdir().unwrap();
453        let service =
454            PersistentService::start_unix(temp.path().to_owned(), temp.path().join("state"))
455                .unwrap();
456        let connection = service.connect().unwrap();
457        assert!(!service.stop_if_idle().unwrap());
458        assert!(!service.is_finished());
459        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();
460        let record: serde_json::Value =
461            serde_json::from_str(&service.next_record(&connection).unwrap().unwrap()).unwrap();
462        assert!(record["error"].is_null(), "{record}");
463        assert_eq!(
464            record["payload"]["capabilities"]["transports"],
465            serde_json::json!(["unix"])
466        );
467        service.disconnect(&connection).unwrap();
468        assert!(service.stop_if_idle().unwrap());
469        wait_for_completion(&service);
470        assert!(service.connect().is_err());
471    }
472
473    #[test]
474    fn preparation_blocks_stop_until_worker_completion() {
475        let temp = tempfile::tempdir().unwrap();
476        let runtime = Arc::new(
477            ServiceRuntime::load_unix(temp.path().to_owned(), temp.path().join("state")).unwrap(),
478        );
479        let mut coordinator = Coordinator::new(Arc::clone(&runtime)).unwrap();
480        let (release, released) = bounded(1);
481        coordinator.turn_preparation = Some(super::super::PendingTurnPreparation {
482            connection: "disconnected".into(),
483            request: serde_json::from_value(serde_json::json!({
484                "protocol_version":2,"kind":"request","request_id":"prepare",
485                "method":"turn.start","payload":{}
486            }))
487            .unwrap(),
488            deadline: Instant::now() + Duration::from_secs(30),
489            worker: thread::spawn(move || {
490                released.recv().unwrap();
491                Ok(runtime)
492            }),
493        });
494        let service = PersistentService::spawn(coordinator).unwrap();
495        assert!(!service.stop_if_idle().unwrap());
496        release.send(()).unwrap();
497        let deadline = Instant::now() + Duration::from_secs(5);
498        while !service.stop_if_idle().unwrap() {
499            assert!(Instant::now() < deadline, "preparation did not settle");
500            thread::sleep(Duration::from_millis(1));
501        }
502        wait_for_completion(&service);
503    }
504
505    #[test]
506    fn queued_stop_rejects_later_admissions() {
507        let temp = tempfile::tempdir().unwrap();
508        let runtime =
509            ServiceRuntime::load_unix(temp.path().to_owned(), temp.path().join("state")).unwrap();
510        let coordinator = Coordinator::new(Arc::new(runtime)).unwrap();
511        assert_eq!(
512            super::super::capabilities(coordinator.unix_transport)["transports"],
513            serde_json::json!([])
514        );
515        let service = PersistentService::spawn(coordinator).unwrap();
516        let (response, stopped) = bounded(1);
517        service
518            .commands
519            .try_send(Command {
520                action: Action::StopIfIdle,
521                deadline: Instant::now() + Duration::from_secs(30),
522                response,
523            })
524            .unwrap();
525        // This admission is behind the stop even if the worker has not processed it yet.
526        assert!(service.connect().is_err());
527        assert!(
528            stopped
529                .recv_timeout(Duration::from_secs(5))
530                .unwrap()
531                .unwrap()
532                .is_some()
533        );
534        wait_for_completion(&service);
535    }
536
537    #[test]
538    fn startup_errors_do_not_expose_settings_or_auth_secrets() {
539        for marker in ["settings-secret-marker", "auth-secret-marker"] {
540            let error = PersistentService::start_with_loader(|| Err(anyhow!(marker)))
541                .err()
542                .expect("startup must fail");
543            assert_eq!(error.to_string(), "application service startup failed");
544            assert!(!format!("{error:?}").contains(marker));
545        }
546    }
547}