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 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 pub fn connect(&self) -> Result<String> {
180 self.call(Action::Connect)?
181 .ok_or_else(|| anyhow!("connection unavailable"))
182 }
183
184 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 pub fn disconnect(&self, connection: &str) -> Result<()> {
198 self.call(Action::Disconnect(checked_id(connection)?))
199 .map(|_| ())
200 }
201
202 pub fn next_record(&self, connection: &str) -> Result<Option<String>> {
205 self.call(Action::Next(checked_id(connection)?))
206 }
207
208 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 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 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 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 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 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}