Skip to main content

winx_code_agent/runtime/
selection.rs

1use std::sync::Arc;
2
3#[cfg(unix)]
4use std::path::{Path, PathBuf};
5#[cfg(unix)]
6use std::process::{Command, Stdio};
7#[cfg(unix)]
8use std::time::{Duration, Instant};
9
10#[cfg(unix)]
11use crate::daemon::{default_socket_path, DaemonClient, DaemonShellRuntime, HelloResult};
12use crate::errors::{Result, WinxError};
13
14use super::{EmbeddedShellRuntime, ShellRuntime};
15
16#[cfg(unix)]
17const GUARDIAN_LIFECYCLE_CAPABILITY: &str = "guardian_activity_clock";
18#[cfg(unix)]
19const PLANNED_CONTROL_RESTART_CAPABILITY: &str = "planned_control_restart";
20#[cfg(unix)]
21const DAEMON_TRANSITION_TIMEOUT: Duration = Duration::from_secs(3);
22
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub enum RuntimeMode {
25    Daemon,
26    Embedded,
27}
28
29fn enabled(value: Option<&str>) -> bool {
30    value.and_then(crate::config::parse_bool).unwrap_or(false)
31}
32
33/// Pure runtime-mode selection, kept separate from process spawning so its
34/// default and kill switches are deterministic under test.
35pub fn select_runtime_mode(
36    embedded: Option<&str>,
37    runtime: Option<&str>,
38    sandbox: Option<&str>,
39) -> Result<RuntimeMode> {
40    select_runtime_mode_for_platform(cfg!(unix), embedded, runtime, sandbox)
41}
42
43fn select_runtime_mode_for_platform(
44    daemon_supported: bool,
45    embedded: Option<&str>,
46    runtime: Option<&str>,
47    sandbox: Option<&str>,
48) -> Result<RuntimeMode> {
49    if enabled(sandbox) || enabled(embedded) {
50        return Ok(RuntimeMode::Embedded);
51    }
52    match runtime {
53        None | Some("" | "daemon") if daemon_supported => Ok(RuntimeMode::Daemon),
54        None | Some("" | "embedded") => Ok(RuntimeMode::Embedded),
55        Some("daemon") => Err(WinxError::ConfigurationError(
56            "WINX_RUNTIME=\"daemon\" requires a Unix platform; use `embedded` on this OS"
57                .to_string(),
58        )),
59        Some(other) => Err(WinxError::ConfigurationError(format!(
60            "invalid WINX_RUNTIME={other:?}; expected `daemon` or `embedded`"
61        ))),
62    }
63}
64
65pub fn configured_runtime_mode() -> Result<RuntimeMode> {
66    let embedded = std::env::var("WINX_EMBEDDED").ok();
67    let runtime = std::env::var("WINX_RUNTIME").ok();
68    let sandbox = std::env::var("WINX_SANDBOX").ok();
69    select_runtime_mode(embedded.as_deref(), runtime.as_deref(), sandbox.as_deref())
70}
71
72pub async fn configured_shell_runtime() -> Result<Arc<dyn ShellRuntime>> {
73    match configured_runtime_mode()? {
74        RuntimeMode::Embedded => Ok(Arc::new(EmbeddedShellRuntime)),
75        #[cfg(unix)]
76        RuntimeMode::Daemon => {
77            let socket = default_socket_path();
78            let binary = configured_daemon_binary()?;
79            ensure_control_daemon_at(&socket, &binary).await?;
80            Ok(Arc::new(DaemonShellRuntime::new(socket)))
81        }
82        #[cfg(not(unix))]
83        RuntimeMode::Daemon => Err(WinxError::ConfigurationError(
84            "the daemon runtime requires a Unix platform".to_string(),
85        )),
86    }
87}
88
89#[cfg(unix)]
90pub fn configured_daemon_binary() -> Result<PathBuf> {
91    if let Some(path) = std::env::var_os("WINXD_BIN") {
92        return Ok(PathBuf::from(path));
93    }
94    let executable = std::env::current_exe().map_err(|error| {
95        WinxError::ConfigurationError(format!("cannot locate current Winx executable: {error}"))
96    })?;
97    let sibling = executable.with_file_name("winxd");
98    if sibling.is_file() {
99        Ok(sibling)
100    } else {
101        Err(WinxError::ConfigurationError(format!(
102            "winxd not found beside {} (set WINXD_BIN or WINX_EMBEDDED=1)",
103            executable.display()
104        )))
105    }
106}
107
108#[cfg(unix)]
109fn has_capability(hello: &HelloResult, name: &str) -> bool {
110    hello.capabilities.iter().any(|capability| capability == name)
111}
112
113/// Ensure the current control-plane feature set is reachable. An older `winxd`
114/// that advertises safe planned restarts is replaced in place; per-session
115/// guardians keep owning their PTYs throughout the control-plane transition.
116#[cfg(unix)]
117pub async fn ensure_control_daemon_at(socket: &Path, daemon_binary: &Path) -> Result<()> {
118    ensure_daemon_at(socket, daemon_binary).await?;
119    let client = DaemonClient::new(socket);
120    let hello = client.hello().await?;
121    if has_capability(&hello, GUARDIAN_LIFECYCLE_CAPABILITY) {
122        return Ok(());
123    }
124    if !has_capability(&hello, PLANNED_CONTROL_RESTART_CAPABILITY) {
125        return Err(WinxError::ConfigurationError(format!(
126            "the running winxd (pid {}) lacks the current guardian lifecycle controls and cannot be safely \
127             restarted automatically; stop it manually, then retry",
128            hello.daemon_pid
129        )));
130    }
131
132    tracing::info!(
133        pid = hello.daemon_pid,
134        "restarting older winxd control plane while preserving session guardians"
135    );
136    let restarted = restart_control_daemon_from_hello(socket, daemon_binary, hello).await?;
137    if !has_capability(&restarted, GUARDIAN_LIFECYCLE_CAPABILITY) {
138        return Err(WinxError::ConfigurationError(format!(
139            "restarted winxd at {} still lacks required capability {GUARDIAN_LIFECYCLE_CAPABILITY}",
140            socket.display()
141        )));
142    }
143    Ok(())
144}
145
146/// Safely restart only `winxd`; guardian processes and their PTYs remain alive.
147#[cfg(unix)]
148pub async fn restart_control_daemon_at(socket: &Path, daemon_binary: &Path) -> Result<HelloResult> {
149    let hello = DaemonClient::new(socket).hello().await?;
150    restart_control_daemon_from_hello(socket, daemon_binary, hello).await
151}
152
153#[cfg(unix)]
154async fn restart_control_daemon_from_hello(
155    socket: &Path,
156    daemon_binary: &Path,
157    previous: HelloResult,
158) -> Result<HelloResult> {
159    if !has_capability(&previous, PLANNED_CONTROL_RESTART_CAPABILITY) {
160        return Err(WinxError::ConfigurationError(format!(
161            "winxd pid {} does not advertise safe planned control restarts",
162            previous.daemon_pid
163        )));
164    }
165    signal_control_shutdown(previous.daemon_pid)?;
166
167    let client = DaemonClient::new(socket);
168    let deadline = Instant::now() + DAEMON_TRANSITION_TIMEOUT;
169    while Instant::now() < deadline {
170        match client.hello().await {
171            Ok(current) if current.daemon_epoch != previous.daemon_epoch => return Ok(current),
172            Ok(_) => tokio::time::sleep(Duration::from_millis(25)).await,
173            Err(_) => break,
174        }
175    }
176    if let Ok(current) = client.hello().await {
177        if current.daemon_epoch == previous.daemon_epoch {
178            return Err(WinxError::ShellInitializationError(format!(
179                "winxd pid {} did not stop within {} seconds",
180                previous.daemon_pid,
181                DAEMON_TRANSITION_TIMEOUT.as_secs()
182            )));
183        }
184        return Ok(current);
185    }
186
187    ensure_daemon_at(socket, daemon_binary).await?;
188    let restarted = client.hello().await?;
189    if restarted.daemon_epoch == previous.daemon_epoch {
190        return Err(WinxError::ShellInitializationError(
191            "winxd restart returned the previous daemon epoch".to_string(),
192        ));
193    }
194    Ok(restarted)
195}
196
197#[cfg(unix)]
198fn signal_control_shutdown(pid: u32) -> Result<()> {
199    crate::os::unix::signal_process(pid, libc::SIGTERM).map_err(Into::into)
200}
201
202/// Ensure a compatible daemon is reachable, starting `daemon_binary` only when
203/// the socket cannot be reached. A reachable incompatible daemon is never
204/// replaced or killed.
205#[cfg(unix)]
206pub async fn ensure_daemon_at(socket: &Path, daemon_binary: &Path) -> Result<()> {
207    let client = DaemonClient::new(socket);
208    match client.hello().await {
209        Ok(_) => return Ok(()),
210        Err(error @ WinxError::ConfigurationError(_)) => return Err(error),
211        Err(_) => {}
212    }
213
214    let mut command = Command::new(daemon_binary);
215    command
216        .arg("--socket")
217        .arg(socket)
218        .stdin(Stdio::null())
219        .stdout(Stdio::null())
220        .stderr(Stdio::null());
221    crate::os::unix::configure_detached(&mut command);
222    let mut child = command.spawn().map_err(|error| {
223        WinxError::ShellInitializationError(format!(
224            "failed to auto-start {}: {error}",
225            daemon_binary.display()
226        ))
227    })?;
228
229    let deadline = Instant::now() + DAEMON_TRANSITION_TIMEOUT;
230    while Instant::now() < deadline {
231        match client.hello().await {
232            Ok(_) => {
233                std::thread::spawn(move || {
234                    let _ = child.wait();
235                });
236                return Ok(());
237            }
238            Err(error @ WinxError::ConfigurationError(_)) => return Err(error),
239            Err(_) => {}
240        }
241        if let Some(status) = child.try_wait()? {
242            return Err(WinxError::ShellInitializationError(format!(
243                "winxd exited before accepting connections: {status}"
244            )));
245        }
246        tokio::time::sleep(Duration::from_millis(25)).await;
247    }
248    Err(WinxError::ShellInitializationError(format!(
249        "timed out waiting for winxd at {}",
250        socket.display()
251    )))
252}
253
254#[cfg(test)]
255mod tests {
256    use super::{select_runtime_mode_for_platform, RuntimeMode};
257
258    #[test]
259    fn platform_without_unix_sockets_defaults_to_embedded() {
260        assert!(matches!(
261            select_runtime_mode_for_platform(false, None, None, None),
262            Ok(RuntimeMode::Embedded)
263        ));
264        assert!(select_runtime_mode_for_platform(false, None, Some("daemon"), None).is_err());
265    }
266}