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