//! Supervisor-gated restart requests; the executable still owns normal shutdown. use crate::{Host, RpcResult, error}; use serde_json::json; use std::{ffi::OsStr, sync::atomic::Ordering, time::Duration}; pub(crate) fn enabled(value: Option<&OsStr>) -> bool { value == Some(OsStr::new("1")) } impl Host { pub(crate) fn request_restart(&self) -> RpcResult { if !self.0.loop_supervised { return Err(error( -32600, "restart needs the loop supervisor (SAUSAGE_LOOP not set); start with SAUSAGE_LOOP=1", )); } if !self.0.restart_pending.swap(true, Ordering::SeqCst) { let host = self.clone(); tokio::spawn(async move { // Let the RPC response reach the caller before closing any stdin, // including that of a detached gateway carrying this response. tokio::time::sleep(Duration::from_millis(250)).await; host.0.exit.send_replace(true); }); } Ok(json!({})) } } #[cfg(test)] mod tests { use super::*; use crate::{runtime::Runtime, world::World}; use std::sync::Arc; use tokio::time::timeout; #[test] fn only_explicit_loop_marker_enables_restart() { assert!(!enabled(None)); for value in ["", "0", "true", "yes", "2"] { assert!(!enabled(Some(OsStr::new(value)))); } assert!(enabled(Some(OsStr::new("1")))); } async fn host(supervised: bool) -> (tempfile::TempDir, Host) { let dir = tempfile::tempdir().unwrap(); World::create(&dir.path().join("world")).unwrap(); let runtime = Runtime::new(&dir.path().join("cache")).unwrap(); let mut host = Host::test_world(&dir.path().join("world"), runtime) .await .unwrap(); // No global environment mutation: parallel tests have independent gates. Arc::get_mut(&mut host.0).unwrap().loop_supervised = supervised; (dir, host) } #[tokio::test] async fn restart_without_supervisor_never_triggers_exit() { timeout(Duration::from_secs(10), async { let (_dir, host) = host(false).await; for method in ["core.restart", "core.exit"] { let error = host.call(method, json!({})).await.unwrap_err(); assert_eq!(error["code"], -32600); assert!( error["message"] .as_str() .unwrap() .contains("SAUSAGE_LOOP not set") ); let indirect = host .call("core.call", json!({"method":method})) .await .unwrap_err(); assert_eq!(indirect, error); } assert!(!host.0.restart_pending.load(Ordering::SeqCst)); assert!( timeout(Duration::from_millis(300), host.exit_requested()) .await .is_err() ); host.stop().await; }) .await .unwrap(); } #[tokio::test] async fn restart_and_exit_share_response_grace_and_coalescing() { timeout(Duration::from_secs(10), async { for first in ["core.restart", "core.exit"] { let (_dir, host) = host(true).await; // Same dispatch as a plugin's core.call, without killing this runner. assert_eq!( host.call("core.call", json!({"method":first})) .await .unwrap(), json!({}) ); assert!(!host.restart_requested()); assert!(host.0.restart_pending.load(Ordering::SeqCst)); assert_eq!( host.call("core.restart", json!({})).await.unwrap(), json!({}) ); timeout(Duration::from_secs(1), host.exit_requested()) .await .unwrap(); // A duplicate after the trigger must not schedule a second task. host.0.exit.send_replace(false); assert_eq!( host.call("core.restart", json!({})).await.unwrap(), json!({}) ); assert_eq!(host.call("core.exit", json!({})).await.unwrap(), json!({})); assert!( timeout(Duration::from_millis(300), host.exit_requested()) .await .is_err() ); host.stop().await; } }) .await .unwrap(); } }