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, DaemonProcessRole, DaemonShellRuntime, HelloResult,
13    BUILD_IDENTITY_CAPABILITY, 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    hello.process_role == Some(DaemonProcessRole::Control)
121        && [
122            GUARDIAN_LIFECYCLE_CAPABILITY,
123            TYPED_ACTION_RESULT_CAPABILITY,
124            SESSION_NEGOTIATE_CAPABILITY,
125            BUILD_IDENTITY_CAPABILITY,
126        ]
127        .into_iter()
128        .all(|capability| has_capability(hello, capability))
129        && hello.build_matches(&crate::build_info::BuildIdentity::current())
130}
131
132/// Ensure the current control-plane feature set is reachable. An older `winxd`
133/// that advertises safe planned restarts is replaced in place; per-session
134/// guardians keep owning their PTYs throughout the control-plane transition.
135#[cfg(unix)]
136pub async fn ensure_control_daemon_at(socket: &Path, daemon_binary: &Path) -> Result<()> {
137    ensure_daemon_at(socket, daemon_binary).await?;
138    let client = DaemonClient::new(socket);
139    let hello = client.hello().await?;
140    if control_is_current(&hello) {
141        return Ok(());
142    }
143    if !has_capability(&hello, PLANNED_CONTROL_RESTART_CAPABILITY) {
144        return Err(WinxError::ConfigurationError(format!(
145            "the running winxd (pid {}) lacks the current guardian lifecycle controls and cannot be safely \
146             restarted automatically; stop it manually, then retry",
147            hello.daemon_pid
148        )));
149    }
150
151    tracing::info!(
152        pid = hello.daemon_pid,
153        "restarting older winxd control plane while preserving session guardians"
154    );
155    let restarted = restart_control_daemon_from_hello(socket, daemon_binary, hello).await?;
156    for capability in [
157        GUARDIAN_LIFECYCLE_CAPABILITY,
158        TYPED_ACTION_RESULT_CAPABILITY,
159        SESSION_NEGOTIATE_CAPABILITY,
160        BUILD_IDENTITY_CAPABILITY,
161    ] {
162        if !has_capability(&restarted, capability) {
163            return Err(WinxError::ConfigurationError(format!(
164                "restarted winxd at {} still lacks required capability {capability}",
165                socket.display()
166            )));
167        }
168    }
169    let expected = crate::build_info::BuildIdentity::current();
170    if restarted.process_role != Some(DaemonProcessRole::Control)
171        || !restarted.build_matches(&expected)
172    {
173        return Err(WinxError::ConfigurationError(format!(
174            "restarted daemon at {} is not the expected winxd build {}",
175            socket.display(),
176            expected.display
177        )));
178    }
179    Ok(())
180}
181
182/// Safely restart only `winxd`; guardian processes and their PTYs remain alive.
183#[cfg(unix)]
184pub async fn restart_control_daemon_at(socket: &Path, daemon_binary: &Path) -> Result<HelloResult> {
185    let hello = DaemonClient::new(socket).hello().await?;
186    restart_control_daemon_from_hello(socket, daemon_binary, hello).await
187}
188
189#[cfg(unix)]
190async fn restart_control_daemon_from_hello(
191    socket: &Path,
192    daemon_binary: &Path,
193    previous: HelloResult,
194) -> Result<HelloResult> {
195    if !has_capability(&previous, PLANNED_CONTROL_RESTART_CAPABILITY) {
196        return Err(WinxError::ConfigurationError(format!(
197            "winxd pid {} does not advertise safe planned control restarts",
198            previous.daemon_pid
199        )));
200    }
201    signal_control_shutdown(previous.daemon_pid)?;
202
203    let client = DaemonClient::new(socket);
204    let deadline = Instant::now() + DAEMON_TRANSITION_TIMEOUT;
205    while Instant::now() < deadline {
206        match client.hello().await {
207            Ok(current) if current.daemon_epoch != previous.daemon_epoch => return Ok(current),
208            Ok(_) => tokio::time::sleep(Duration::from_millis(25)).await,
209            Err(_) => break,
210        }
211    }
212    if let Ok(current) = client.hello().await {
213        if current.daemon_epoch == previous.daemon_epoch {
214            return Err(WinxError::ShellInitializationError(format!(
215                "winxd pid {} did not stop within {} seconds",
216                previous.daemon_pid,
217                DAEMON_TRANSITION_TIMEOUT.as_secs()
218            )));
219        }
220        return Ok(current);
221    }
222
223    ensure_daemon_at(socket, daemon_binary).await?;
224    let restarted = client.hello().await?;
225    if restarted.daemon_epoch == previous.daemon_epoch {
226        return Err(WinxError::ShellInitializationError(
227            "winxd restart returned the previous daemon epoch".to_string(),
228        ));
229    }
230    Ok(restarted)
231}
232
233#[cfg(unix)]
234fn signal_control_shutdown(pid: u32) -> Result<()> {
235    crate::os::unix::signal_process(pid, libc::SIGTERM).map_err(Into::into)
236}
237
238/// Ensure a compatible daemon is reachable, starting `daemon_binary` only when
239/// the socket cannot be reached. A reachable incompatible daemon is never
240/// replaced or killed.
241#[cfg(unix)]
242pub async fn ensure_daemon_at(socket: &Path, daemon_binary: &Path) -> Result<()> {
243    let client = DaemonClient::new(socket);
244    match client.hello().await {
245        Ok(_) => return Ok(()),
246        Err(error @ WinxError::ConfigurationError(_)) => return Err(error),
247        Err(_) => {}
248    }
249
250    let mut command = Command::new(daemon_binary);
251    command
252        .arg("--socket")
253        .arg(socket)
254        .stdin(Stdio::null())
255        .stdout(Stdio::null())
256        .stderr(Stdio::null());
257    crate::os::unix::configure_detached(&mut command);
258    let mut child = command.spawn().map_err(|error| {
259        WinxError::ShellInitializationError(format!(
260            "failed to auto-start {}: {error}",
261            daemon_binary.display()
262        ))
263    })?;
264
265    let deadline = Instant::now() + DAEMON_TRANSITION_TIMEOUT;
266    while Instant::now() < deadline {
267        match client.hello().await {
268            Ok(_) => {
269                std::thread::spawn(move || {
270                    let _ = child.wait();
271                });
272                return Ok(());
273            }
274            Err(error @ WinxError::ConfigurationError(_)) => return Err(error),
275            Err(_) => {}
276        }
277        if let Some(status) = child.try_wait()? {
278            return Err(WinxError::ShellInitializationError(format!(
279                "winxd exited before accepting connections: {status}"
280            )));
281        }
282        tokio::time::sleep(Duration::from_millis(25)).await;
283    }
284    Err(WinxError::ShellInitializationError(format!(
285        "timed out waiting for winxd at {}",
286        socket.display()
287    )))
288}
289
290#[cfg(test)]
291mod tests {
292    use super::{select_runtime_mode_for_platform, RuntimeMode};
293
294    #[test]
295    fn platform_without_unix_sockets_defaults_to_embedded() {
296        assert!(matches!(
297            select_runtime_mode_for_platform(false, None, None, None),
298            Ok(RuntimeMode::Embedded)
299        ));
300        assert!(select_runtime_mode_for_platform(false, None, Some("daemon"), None).is_err());
301    }
302
303    #[cfg(unix)]
304    #[test]
305    fn protocol_1_4_control_without_session_negotiate_requires_upgrade() {
306        let hello = crate::daemon::HelloResult {
307            protocol_major: 1,
308            protocol_minor: 4,
309            capabilities: vec![
310                super::GUARDIAN_LIFECYCLE_CAPABILITY.to_string(),
311                super::TYPED_ACTION_RESULT_CAPABILITY.to_string(),
312                super::PLANNED_CONTROL_RESTART_CAPABILITY.to_string(),
313            ],
314            max_frame_bytes: 1024,
315            daemon_epoch: "control-14".to_string(),
316            daemon_pid: 14,
317            process_role: None,
318            build: None,
319        };
320        assert!(!super::control_is_current(&hello));
321        assert!(super::has_capability(&hello, super::PLANNED_CONTROL_RESTART_CAPABILITY));
322    }
323
324    #[cfg(unix)]
325    #[test]
326    fn current_control_requires_the_exact_role_and_build() {
327        let capabilities = vec![
328            super::GUARDIAN_LIFECYCLE_CAPABILITY.to_string(),
329            super::TYPED_ACTION_RESULT_CAPABILITY.to_string(),
330            super::SESSION_NEGOTIATE_CAPABILITY.to_string(),
331            super::BUILD_IDENTITY_CAPABILITY.to_string(),
332        ];
333        let hello = crate::daemon::HelloResult::current(
334            crate::daemon::DaemonProcessRole::Control,
335            capabilities,
336            "current",
337        );
338        assert!(super::control_is_current(&hello));
339
340        let mut wrong_role = hello.clone();
341        wrong_role.process_role = Some(crate::daemon::DaemonProcessRole::Guardian);
342        assert!(!super::control_is_current(&wrong_role));
343
344        let mut wrong_build = hello;
345        if let Some(build) = wrong_build.build.as_mut() {
346            build.display.push_str("-different");
347        }
348        assert!(!super::control_is_current(&wrong_build));
349    }
350}