kittynode_core/application/
web_service.rs

1use crate::domain::web_service::{DEFAULT_WEB_PORT, WebServiceStatus};
2use crate::infra::web_service::{self, WebProcessState};
3use eyre::{Result, WrapErr, eyre};
4use rand::RngCore;
5use std::ffi::{OsStr, OsString};
6use std::fs::{self, OpenOptions};
7use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream};
8use std::path::{Path, PathBuf};
9use std::process::{Child, Command, Stdio};
10use std::thread;
11use std::time::Duration;
12use sysinfo::{Pid, Process, System};
13use tracing::info;
14
15pub fn start_web_service(
16    port: Option<u16>,
17    binary_path: &Path,
18    args: &[&str],
19) -> Result<WebServiceStatus> {
20    let port = validate_web_port(port.unwrap_or(DEFAULT_WEB_PORT))?;
21    if let Some(mut state) = web_service::load_state()? {
22        if process_matches(&state) {
23            if state.log_path.is_none() {
24                state.log_path = Some(web_service::log_file_path()?);
25                let _ = web_service::save_state(&state);
26            }
27            return Ok(WebServiceStatus::AlreadyRunning {
28                pid: state.pid,
29                port: state.port,
30            });
31        }
32        web_service::clear_state()?;
33    }
34
35    let binary_path = binary_path
36        .canonicalize()
37        .unwrap_or_else(|_| binary_path.to_path_buf());
38    let token = generate_service_token();
39
40    let mut command = Command::new(&binary_path);
41    let log_path = web_service::log_file_path()?;
42    if let Some(parent) = log_path.parent() {
43        fs::create_dir_all(parent)
44            .wrap_err("Failed to create directories for kittynode-web log file")?;
45    }
46    let log_file = OpenOptions::new()
47        .create(true)
48        .write(true)
49        .truncate(true)
50        .open(&log_path)
51        .wrap_err("Failed to open kittynode-web log file")?;
52    let stdout = log_file
53        .try_clone()
54        .wrap_err("Failed to duplicate log handle for stdout")?;
55
56    command
57        .stdin(Stdio::null())
58        .stdout(Stdio::from(stdout))
59        .stderr(Stdio::from(log_file))
60        .args(args)
61        .arg("--port")
62        .arg(port.to_string())
63        .arg("--service-token")
64        .arg(&token);
65
66    let mut child = command
67        .spawn()
68        .wrap_err("Failed to spawn kittynode-web process")?;
69
70    wait_for_service_ready(&mut child, port)?;
71
72    let pid = child.id();
73
74    let state = WebProcessState {
75        pid,
76        port,
77        binary: binary_path.clone(),
78        token: Some(token),
79        log_path: Some(log_path.clone()),
80    };
81    if let Err(err) = web_service::save_state(&state) {
82        let kill_result = child.kill();
83        if kill_result.is_ok() {
84            let _ = child.wait();
85        }
86        let err = match kill_result {
87            Ok(_) => err.wrap_err(format!(
88                "Failed to persist kittynode-web state for pid {} on port {} and terminated spawned process",
89                pid, port
90            )),
91            Err(kill_err) => err.wrap_err(format!(
92                "Failed to persist kittynode-web state for pid {} on port {} and could not terminate spawned process: {kill_err}",
93                pid, port
94            )),
95        };
96        return Err(err);
97    }
98
99    drop(child);
100
101    info!(pid, port = state.port, binary = %binary_path.display(), "Started kittynode-web service");
102    Ok(WebServiceStatus::Started {
103        pid: state.pid,
104        port: state.port,
105    })
106}
107
108pub fn get_web_service_log_path() -> Result<PathBuf> {
109    let path = web_service::log_file_path()?;
110    if path.exists() {
111        return Ok(path);
112    }
113
114    if let Some(state) = web_service::load_state()?
115        && process_matches(&state)
116    {
117        return Err(eyre!(
118            "Kittynode web service logs are not available yet; restart the service to enable logging"
119        ));
120    }
121
122    Err(eyre!(
123        "Kittynode web service is not running; start it with `kittynode web start`"
124    ))
125}
126
127pub fn stop_web_service() -> Result<WebServiceStatus> {
128    let Some(state) = web_service::load_state()? else {
129        return Ok(WebServiceStatus::NotRunning);
130    };
131
132    if !process_matches(&state) {
133        web_service::clear_state()?;
134        return Ok(WebServiceStatus::NotRunning);
135    }
136
137    let system = System::new_all();
138    let pid = Pid::from_u32(state.pid);
139
140    let Some(process) = system.process(pid) else {
141        web_service::clear_state()?;
142        return Ok(WebServiceStatus::NotRunning);
143    };
144
145    if !process.kill() {
146        return Err(eyre!(
147            "Failed to stop kittynode-web process with pid {}",
148            state.pid
149        ));
150    }
151
152    web_service::clear_state()?;
153    info!(
154        pid = state.pid,
155        port = state.port,
156        "Stopped kittynode-web service"
157    );
158    Ok(WebServiceStatus::Stopped {
159        pid: state.pid,
160        port: state.port,
161    })
162}
163
164pub fn get_web_service_status() -> Result<WebServiceStatus> {
165    let Some(mut state) = web_service::load_state()? else {
166        return Ok(WebServiceStatus::NotRunning);
167    };
168
169    if process_matches(&state) {
170        if state.log_path.is_none() {
171            state.log_path = Some(web_service::log_file_path()?);
172            let _ = web_service::save_state(&state);
173        }
174        return Ok(WebServiceStatus::AlreadyRunning {
175            pid: state.pid,
176            port: state.port,
177        });
178    }
179
180    web_service::clear_state()?;
181    Ok(WebServiceStatus::NotRunning)
182}
183
184fn process_matches(state: &WebProcessState) -> bool {
185    let system = System::new_all();
186    let pid = Pid::from_u32(state.pid);
187    if let Some(process) = system.process(pid)
188        && let Some(exe_path) = process.exe()
189    {
190        return paths_match(exe_path, &state.binary)
191            && cmd_contains_token(process, state.token.as_deref());
192    }
193    false
194}
195
196fn paths_match(left: &Path, right: &Path) -> bool {
197    if left == right {
198        return true;
199    }
200
201    match (left.canonicalize(), right.canonicalize()) {
202        (Ok(left_canonical), Ok(right_canonical)) => left_canonical == right_canonical,
203        _ => false,
204    }
205}
206
207fn cmd_contains_token(process: &Process, token: Option<&str>) -> bool {
208    args_contain_token(process.cmd(), token)
209}
210
211fn args_contain_token(cmd: &[OsString], token: Option<&str>) -> bool {
212    let Some(token) = token else {
213        return false;
214    };
215    let service_flag = OsStr::new("--service-token");
216    let token_os = OsStr::new(token);
217    let token_flag = OsString::from(format!("--service-token={token}"));
218    for window in cmd.windows(2) {
219        if window[0] == service_flag && window[1] == token_os {
220            return true;
221        }
222    }
223    cmd.iter().any(|arg| arg == &token_flag)
224}
225
226fn generate_service_token() -> String {
227    let mut buf = [0u8; 16];
228    rand::rng().fill_bytes(&mut buf);
229    hex::encode(buf)
230}
231
232pub fn validate_web_port(port: u16) -> Result<u16> {
233    if port == 0 {
234        return Err(eyre!("Port must be greater than zero"));
235    }
236    Ok(port)
237}
238
239fn wait_for_service_ready(child: &mut Child, port: u16) -> Result<()> {
240    const MAX_ATTEMPTS: u32 = 50;
241    const RETRY_DELAY: Duration = Duration::from_millis(100);
242    let targets = [
243        SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port),
244        SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), port),
245    ];
246
247    for _ in 0..MAX_ATTEMPTS {
248        if let Some(status) = child
249            .try_wait()
250            .wrap_err("Failed to poll kittynode-web process state")?
251        {
252            let detail = status
253                .code()
254                .map(|code| format!("exit code {code}"))
255                .unwrap_or_else(|| "terminated by signal".to_string());
256            return Err(eyre!(
257                "kittynode-web process exited immediately ({detail}); check logs for details"
258            ));
259        }
260
261        if targets
262            .iter()
263            .any(|addr| TcpStream::connect_timeout(addr, Duration::from_millis(50)).is_ok())
264        {
265            return Ok(());
266        }
267
268        thread::sleep(RETRY_DELAY);
269    }
270
271    let _ = child.kill();
272    let _ = child.wait();
273    Err(eyre!(
274        "Timed out waiting for kittynode-web to bind on port {}",
275        port
276    ))
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use std::ffi::OsString;
283    use std::fs::{self, File};
284    use tempfile::tempdir;
285
286    #[test]
287    fn validate_web_port_rejects_zero() {
288        assert!(validate_web_port(0).is_err());
289        assert_eq!(validate_web_port(8080).unwrap(), 8080);
290    }
291
292    #[test]
293    fn generate_service_token_emits_hex_string() {
294        let token = generate_service_token();
295        assert_eq!(token.len(), 32);
296        assert!(token.chars().all(|c| c.is_ascii_hexdigit()));
297    }
298
299    #[test]
300    fn generate_service_token_produces_unique_tokens() {
301        let token1 = generate_service_token();
302        let token2 = generate_service_token();
303        assert_ne!(token1, token2);
304    }
305
306    #[test]
307    fn args_contain_token_detects_split_arguments() {
308        let args = vec![
309            OsString::from("--flag"),
310            OsString::from("--service-token"),
311            OsString::from("abc123"),
312        ];
313        assert!(args_contain_token(&args, Some("abc123")));
314    }
315
316    #[test]
317    fn args_contain_token_detects_inline_argument() {
318        let args = vec![OsString::from("--service-token=abc123")];
319        assert!(args_contain_token(&args, Some("abc123")));
320    }
321
322    #[test]
323    fn args_contain_token_is_false_when_missing() {
324        let args = vec![OsString::from("--service-token=abc123")];
325        assert!(!args_contain_token(&args, Some("zzz")));
326        assert!(!args_contain_token(&args, None));
327    }
328
329    #[test]
330    fn paths_match_handles_equivalent_paths() {
331        let temp = tempdir().expect("failed to create temp dir");
332        let bin_dir = temp.path().join("bin");
333        fs::create_dir_all(&bin_dir).expect("failed to create bin dir");
334        let target = bin_dir.join("kittynode-web");
335        File::create(&target).expect("failed to create dummy binary");
336
337        let alternate = bin_dir.join(".").join("kittynode-web");
338        assert!(paths_match(&target, &alternate));
339    }
340
341    #[test]
342    fn paths_match_rejects_different_targets() {
343        let temp = tempdir().expect("failed to create temp dir");
344        let bin_dir = temp.path().join("bin");
345        fs::create_dir_all(&bin_dir).expect("failed to create bin dir");
346        let a = bin_dir.join("kittynode-web");
347        let b = bin_dir.join("other-binary");
348        File::create(&a).expect("failed to create binary a");
349        File::create(&b).expect("failed to create binary b");
350
351        assert!(!paths_match(&a, &b));
352    }
353}