magi_code/service/persistent/
entrypoint.rs1use 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
13pub 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 pub fn start() -> Result<Self> {
47 Self::start_with_loader(ServiceRuntime::load_persistent)
48 }
49
50 #[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 #[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 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 return;
120 }
121 }
122 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
123 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 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 pub fn connect(&self) -> Result<String> {
184 self.call(Action::Connect)?
185 .ok_or_else(|| anyhow!("connection unavailable"))
186 }
187
188 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 pub fn disconnect(&self, connection: &str) -> Result<()> {
202 self.call(Action::Disconnect(checked_id(connection)?))
203 .map(|_| ())
204 }
205
206 pub fn next_record(&self, connection: &str) -> Result<Option<String>> {
209 self.call(Action::Next(checked_id(connection)?))
210 }
211
212 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 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 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 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 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 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}