1use std::sync::Arc;
2
3use std::path::{Path, PathBuf};
4use std::process::{Command, Stdio};
5use std::time::{Duration, Instant};
6
7use crate::daemon::{
8 default_socket_path, DaemonClient, DaemonProcessRole, DaemonShellRuntime, HelloResult,
9 BUILD_IDENTITY_CAPABILITY, TYPED_ACTION_RESULT_CAPABILITY,
10};
11use crate::errors::{Result, WinxError};
12
13use super::{EmbeddedShellRuntime, ShellRuntime};
14
15const GUARDIAN_LIFECYCLE_CAPABILITY: &str = "guardian_activity_clock";
16const PLANNED_CONTROL_RESTART_CAPABILITY: &str = "planned_control_restart";
17const SESSION_NEGOTIATE_CAPABILITY: &str = "session.negotiate";
18const DAEMON_TRANSITION_TIMEOUT: Duration = Duration::from_secs(3);
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub enum RuntimeMode {
22 Daemon,
23 Embedded,
24}
25
26fn enabled(value: Option<&str>) -> bool {
27 value.and_then(crate::config::parse_bool).unwrap_or(false)
28}
29
30pub fn select_runtime_mode(
33 embedded: Option<&str>,
34 runtime: Option<&str>,
35 sandbox: Option<&str>,
36) -> Result<RuntimeMode> {
37 select_runtime_mode_for_platform(cfg!(any(unix, windows)), embedded, runtime, sandbox)
38}
39
40fn select_runtime_mode_for_platform(
41 daemon_supported: bool,
42 embedded: Option<&str>,
43 runtime: Option<&str>,
44 sandbox: Option<&str>,
45) -> Result<RuntimeMode> {
46 if enabled(sandbox) || enabled(embedded) {
47 return Ok(RuntimeMode::Embedded);
48 }
49 match runtime {
50 None | Some("" | "daemon") if daemon_supported => Ok(RuntimeMode::Daemon),
51 None | Some("" | "embedded") => Ok(RuntimeMode::Embedded),
52 Some("daemon") => Err(WinxError::ConfigurationError(
53 "WINX_RUNTIME=\"daemon\" requires Unix or Windows; use `embedded` on this OS"
54 .to_string(),
55 )),
56 Some(other) => Err(WinxError::ConfigurationError(format!(
57 "invalid WINX_RUNTIME={other:?}; expected `daemon` or `embedded`"
58 ))),
59 }
60}
61
62pub fn configured_runtime_mode() -> Result<RuntimeMode> {
63 let embedded = std::env::var("WINX_EMBEDDED").ok();
64 let runtime = std::env::var("WINX_RUNTIME").ok();
65 let sandbox = std::env::var("WINX_SANDBOX").ok();
66 select_runtime_mode(embedded.as_deref(), runtime.as_deref(), sandbox.as_deref())
67}
68
69pub async fn configured_shell_runtime() -> Result<Arc<dyn ShellRuntime>> {
70 match configured_runtime_mode()? {
71 RuntimeMode::Embedded => Ok(Arc::new(EmbeddedShellRuntime)),
72 RuntimeMode::Daemon => {
73 let socket = default_socket_path();
74 let binary = configured_daemon_binary()?;
75 ensure_control_daemon_at(&socket, &binary).await?;
76 Ok(Arc::new(DaemonShellRuntime::new(socket)))
77 }
78 }
79}
80
81pub fn configured_daemon_binary() -> Result<PathBuf> {
82 if let Some(path) = std::env::var_os("WINXD_BIN") {
83 return Ok(PathBuf::from(path));
84 }
85 let executable = std::env::current_exe().map_err(|error| {
86 WinxError::ConfigurationError(format!("cannot locate current Winx executable: {error}"))
87 })?;
88 let sibling = executable.with_file_name(if cfg!(windows) { "winxd.exe" } else { "winxd" });
89 if sibling.is_file() {
90 Ok(sibling)
91 } else {
92 Err(WinxError::ConfigurationError(format!(
93 "winxd not found beside {} (set WINXD_BIN or WINX_EMBEDDED=1)",
94 executable.display()
95 )))
96 }
97}
98
99fn has_capability(hello: &HelloResult, name: &str) -> bool {
100 hello.capabilities.iter().any(|capability| capability == name)
101}
102
103fn control_is_current(hello: &HelloResult) -> bool {
104 hello.process_role == Some(DaemonProcessRole::Control)
105 && [
106 GUARDIAN_LIFECYCLE_CAPABILITY,
107 TYPED_ACTION_RESULT_CAPABILITY,
108 SESSION_NEGOTIATE_CAPABILITY,
109 BUILD_IDENTITY_CAPABILITY,
110 ]
111 .into_iter()
112 .all(|capability| has_capability(hello, capability))
113 && hello.build_matches(&crate::build_info::BuildIdentity::current())
114}
115
116pub async fn ensure_control_daemon_at(socket: &Path, daemon_binary: &Path) -> Result<()> {
120 ensure_daemon_at(socket, daemon_binary).await?;
121 let client = DaemonClient::new(socket);
122 let hello = client.hello().await?;
123 if control_is_current(&hello) {
124 return Ok(());
125 }
126 if !has_capability(&hello, PLANNED_CONTROL_RESTART_CAPABILITY) {
127 return Err(WinxError::ConfigurationError(format!(
128 "the running winxd (pid {}) lacks the current guardian lifecycle controls and cannot be safely \
129 restarted automatically; stop it manually, then retry",
130 hello.daemon_pid
131 )));
132 }
133
134 tracing::info!(
135 pid = hello.daemon_pid,
136 "restarting older winxd control plane while preserving session guardians"
137 );
138 let restarted = restart_control_daemon_from_hello(socket, daemon_binary, hello).await?;
139 for capability in [
140 GUARDIAN_LIFECYCLE_CAPABILITY,
141 TYPED_ACTION_RESULT_CAPABILITY,
142 SESSION_NEGOTIATE_CAPABILITY,
143 BUILD_IDENTITY_CAPABILITY,
144 ] {
145 if !has_capability(&restarted, capability) {
146 return Err(WinxError::ConfigurationError(format!(
147 "restarted winxd at {} still lacks required capability {capability}",
148 socket.display()
149 )));
150 }
151 }
152 let expected = crate::build_info::BuildIdentity::current();
153 if restarted.process_role != Some(DaemonProcessRole::Control)
154 || !restarted.build_matches(&expected)
155 {
156 return Err(WinxError::ConfigurationError(format!(
157 "restarted daemon at {} is not the expected winxd build {}",
158 socket.display(),
159 expected.display
160 )));
161 }
162 Ok(())
163}
164
165pub 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
171async fn restart_control_daemon_from_hello(
172 socket: &Path,
173 daemon_binary: &Path,
174 previous: HelloResult,
175) -> Result<HelloResult> {
176 if !has_capability(&previous, PLANNED_CONTROL_RESTART_CAPABILITY) {
177 return Err(WinxError::ConfigurationError(format!(
178 "winxd pid {} does not advertise safe planned control restarts",
179 previous.daemon_pid
180 )));
181 }
182 signal_control_shutdown(socket, previous.daemon_pid).await?;
183
184 let client = DaemonClient::new(socket);
185 let deadline = Instant::now() + DAEMON_TRANSITION_TIMEOUT;
186 while Instant::now() < deadline {
187 match client.hello().await {
188 Ok(current) if current.daemon_epoch != previous.daemon_epoch => return Ok(current),
189 Ok(_) => tokio::time::sleep(Duration::from_millis(25)).await,
190 Err(_) => break,
191 }
192 }
193 if let Ok(current) = client.hello().await {
194 if current.daemon_epoch == previous.daemon_epoch {
195 return Err(WinxError::ShellInitializationError(format!(
196 "winxd pid {} did not stop within {} seconds",
197 previous.daemon_pid,
198 DAEMON_TRANSITION_TIMEOUT.as_secs()
199 )));
200 }
201 return Ok(current);
202 }
203
204 ensure_daemon_at(socket, daemon_binary).await?;
205 let restarted = client.hello().await?;
206 if restarted.daemon_epoch == previous.daemon_epoch {
207 return Err(WinxError::ShellInitializationError(
208 "winxd restart returned the previous daemon epoch".to_string(),
209 ));
210 }
211 Ok(restarted)
212}
213
214#[cfg_attr(unix, allow(clippy::unused_async))] async fn signal_control_shutdown(socket: &Path, pid: u32) -> Result<()> {
219 #[cfg(unix)]
220 {
221 let _ = socket;
222 crate::os::unix::signal_process(pid, libc::SIGTERM).map_err(Into::into)
223 }
224 #[cfg(not(unix))]
225 {
226 let _ = pid;
227 DaemonClient::new(socket).shutdown().await
228 }
229}
230
231fn spawn_detached(daemon_binary: &Path, socket: &Path) -> Result<std::process::Child> {
237 let build = |breakaway: bool| {
238 let mut command = Command::new(daemon_binary);
239 command
240 .arg("--socket")
241 .arg(socket)
242 .stdin(Stdio::null())
243 .stdout(Stdio::null())
244 .stderr(Stdio::null());
245 #[cfg(unix)]
246 {
247 let _ = breakaway;
248 crate::os::unix::configure_detached(&mut command);
249 }
250 #[cfg(windows)]
251 crate::os::windows::configure_detached(&mut command, breakaway);
252 command
253 };
254 let failure = |error: std::io::Error| {
255 WinxError::ShellInitializationError(format!(
256 "failed to auto-start {}: {error}",
257 daemon_binary.display()
258 ))
259 };
260 match build(true).spawn() {
261 Ok(child) => Ok(child),
262 Err(error) if cfg!(windows) => {
263 tracing::debug!(%error, "detached spawn with job breakaway refused; retrying inside the job");
264 build(false).spawn().map_err(failure)
265 }
266 Err(error) => Err(failure(error)),
267 }
268}
269
270pub async fn ensure_daemon_at(socket: &Path, daemon_binary: &Path) -> Result<()> {
274 let client = DaemonClient::new(socket);
275 match client.hello().await {
276 Ok(_) => return Ok(()),
277 Err(error @ WinxError::ConfigurationError(_)) => return Err(error),
278 Err(_) => {}
279 }
280
281 let mut child = spawn_detached(daemon_binary, socket)?;
282
283 let deadline = Instant::now() + DAEMON_TRANSITION_TIMEOUT;
288 let mut child_exit = None;
289 while Instant::now() < deadline {
290 match client.hello().await {
291 Ok(_) => {
292 if child_exit.is_none() {
293 std::thread::spawn(move || {
294 let _ = child.wait();
295 });
296 }
297 return Ok(());
298 }
299 Err(error @ WinxError::ConfigurationError(_)) => return Err(error),
300 Err(_) => {}
301 }
302 if child_exit.is_none() {
303 child_exit = child.try_wait()?;
304 }
305 tokio::time::sleep(Duration::from_millis(25)).await;
306 }
307 Err(WinxError::ShellInitializationError(match child_exit {
308 Some(status) => format!(
309 "winxd exited ({status}) and nothing answered at {} within {} seconds",
310 socket.display(),
311 DAEMON_TRANSITION_TIMEOUT.as_secs()
312 ),
313 None => format!("timed out waiting for winxd at {}", socket.display()),
314 }))
315}
316
317#[cfg(test)]
318mod tests {
319 use super::{select_runtime_mode_for_platform, RuntimeMode};
320
321 #[test]
322 fn platform_without_unix_sockets_defaults_to_embedded() {
323 assert!(matches!(
324 select_runtime_mode_for_platform(false, None, None, None),
325 Ok(RuntimeMode::Embedded)
326 ));
327 assert!(select_runtime_mode_for_platform(false, None, Some("daemon"), None).is_err());
328 }
329
330 #[test]
331 fn protocol_1_4_control_without_session_negotiate_requires_upgrade() {
332 let hello = crate::daemon::HelloResult {
333 protocol_major: 1,
334 protocol_minor: 4,
335 capabilities: vec![
336 super::GUARDIAN_LIFECYCLE_CAPABILITY.to_string(),
337 super::TYPED_ACTION_RESULT_CAPABILITY.to_string(),
338 super::PLANNED_CONTROL_RESTART_CAPABILITY.to_string(),
339 ],
340 max_frame_bytes: 1024,
341 daemon_epoch: "control-14".to_string(),
342 daemon_pid: 14,
343 process_role: None,
344 build: None,
345 };
346 assert!(!super::control_is_current(&hello));
347 assert!(super::has_capability(&hello, super::PLANNED_CONTROL_RESTART_CAPABILITY));
348 }
349
350 #[test]
351 fn current_control_requires_the_exact_role_and_build() {
352 let capabilities = vec![
353 super::GUARDIAN_LIFECYCLE_CAPABILITY.to_string(),
354 super::TYPED_ACTION_RESULT_CAPABILITY.to_string(),
355 super::SESSION_NEGOTIATE_CAPABILITY.to_string(),
356 super::BUILD_IDENTITY_CAPABILITY.to_string(),
357 ];
358 let hello = crate::daemon::HelloResult::current(
359 crate::daemon::DaemonProcessRole::Control,
360 capabilities,
361 "current",
362 );
363 assert!(super::control_is_current(&hello));
364
365 let mut wrong_role = hello.clone();
366 wrong_role.process_role = Some(crate::daemon::DaemonProcessRole::Guardian);
367 assert!(!super::control_is_current(&wrong_role));
368
369 let mut wrong_build = hello;
370 if let Some(build) = wrong_build.build.as_mut() {
371 build.display.push_str("-different");
372 }
373 assert!(!super::control_is_current(&wrong_build));
374 }
375}