kittynode_core/application/
web_service.rs1use 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}