diff --git a/cli/src/connection.rs b/cli/src/connection.rs index b5cb26cd..35ec43b2 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -150,6 +150,36 @@ fn get_port_for_session(session: &str) -> u16 { 49152 + ((hash.unsigned_abs() as u32 % 16383) as u16) } +#[cfg(windows)] +fn read_port_file(session: &str) -> Option { + // On Windows the daemon writes the actual port it bound to. + // This lets the CLI connect even if the hashed port was unavailable. + let port_path = get_port_path(session); + let port_str = fs::read_to_string(port_path).ok()?; + port_str.trim().parse::().ok() +} + +#[cfg(windows)] +fn get_session_port(session: &str) -> u16 { + // Prefer the port file if present, otherwise fall back to the hash. + read_port_file(session).unwrap_or_else(|| get_port_for_session(session)) +} + +fn escape_powershell_single_quotes(value: &str) -> String { + value.replace('\'', "''") +} + +#[cfg(windows)] +fn normalize_windows_path(path: &PathBuf) -> String { + // Start-Process can fail with "\\?\"-prefixed paths ("Windows cannot access \\\?\"). + // Strip the prefix to keep PowerShell and Explorer happy. + let path_str = path.to_string_lossy().into_owned(); + path_str + .strip_prefix(r"\\?\") + .unwrap_or(&path_str) + .to_string() +} + #[cfg(unix)] fn is_daemon_running(session: &str) -> bool { let pid_path = get_pid_path(session); @@ -172,10 +202,10 @@ fn is_daemon_running(session: &str) -> bool { if !pid_path.exists() { return false; } - let port = get_port_for_session(session); + let port = get_session_port(session); TcpStream::connect_timeout( &format!("127.0.0.1:{}", port).parse().unwrap(), - Duration::from_millis(100), + Duration::from_millis(200), ) .is_ok() } @@ -188,10 +218,10 @@ fn daemon_ready(session: &str) -> bool { } #[cfg(windows)] { - let port = get_port_for_session(session); + let port = get_session_port(session); TcpStream::connect_timeout( &format!("127.0.0.1:{}", port).parse().unwrap(), - Duration::from_millis(50), + Duration::from_millis(200), ) .is_ok() } @@ -376,14 +406,20 @@ pub fn ensure_daemon( #[cfg(windows)] { - use std::os::windows::process::CommandExt; - - // On Windows, call node directly. Command::new handles PATH resolution (node.exe or node.cmd) - // and automatically quotes arguments containing spaces. - let mut cmd = Command::new("node"); - cmd.arg(daemon_path) - .env("AGENT_BROWSER_DAEMON", "1") - .env("AGENT_BROWSER_SESSION", session); + // On Windows, spawn via PowerShell Start-Process for reliability. + let node_exe = "node"; + let daemon_arg = escape_powershell_single_quotes(&normalize_windows_path(daemon_path)); + let ps_cmd = format!( + "Start-Process -FilePath '{}' -ArgumentList @('{}') -WindowStyle Hidden", + node_exe, daemon_arg + ); + let mut cmd = Command::new("powershell"); + cmd.arg("-NoProfile") + .arg("-NonInteractive") + .arg("-Command") + .arg(ps_cmd) + .env("AGENT_BROWSER_SESSION", session) + .env("AGENT_BROWSER_DAEMON", "1"); if headed { cmd.env("AGENT_BROWSER_HEADED", "1"); @@ -436,17 +472,24 @@ pub fn ensure_daemon( if let Some(d) = device { cmd.env("AGENT_BROWSER_IOS_DEVICE", d); } - - // CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS - const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; - const DETACHED_PROCESS: u32 = 0x00000008; - - cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS) + let output = cmd .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() .map_err(|e| format!("Failed to start daemon: {}", e))?; + + if !output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let msg = format!( + "Failed to start daemon (powershell). stdout: {} stderr: {}", + stdout.trim(), + stderr.trim() + ); + return Err(msg); + } + } for _ in 0..50 { @@ -474,7 +517,7 @@ fn connect(session: &str) -> Result { } #[cfg(windows)] { - let port = get_port_for_session(session); + let port = get_session_port(session); TcpStream::connect(format!("127.0.0.1:{}", port)) .map(Connection::Tcp) .map_err(|e| format!("Failed to connect: {}", e)) diff --git a/src/daemon.ts b/src/daemon.ts index 5bb2ec48..ee8ba208 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -371,28 +371,49 @@ export async function startDaemon(options?: { // Write PID file before listening fs.writeFileSync(pidFile, process.pid.toString()); + const handleServerError = (err: NodeJS.ErrnoException): void => { + console.error('Server error:', err); + cleanupSocket(); + process.exit(1); + }; + if (isWindows) { // Windows: use TCP socket on localhost - const port = getPortForSession(currentSession); const portFile = getPortFile(); - fs.writeFileSync(portFile, port.toString()); - server.listen(port, '127.0.0.1', () => { - // Daemon is ready on TCP port - }); + const preferredPort = getPortForSession(currentSession); + + const listenOnPort = (port: number, allowFallback: boolean): void => { + // Try the hashed port first; if unavailable, retry with an OS-assigned port. + server.once('error', (err: NodeJS.ErrnoException) => { + if (allowFallback && (err.code === 'EADDRINUSE' || err.code === 'EACCES')) { + listenOnPort(0, false); + return; + } + handleServerError(err); + }); + + server.listen(port, '127.0.0.1', () => { + const address = server.address(); + const actualPort = typeof address === 'string' ? parseInt(address, 10) : address?.port; + if (typeof actualPort === 'number') { + // Persist the actual port so the CLI can discover it reliably. + fs.writeFileSync(portFile, actualPort.toString()); + } + server.on('error', handleServerError); + // Daemon is ready on TCP port + }); + }; + + listenOnPort(preferredPort, true); } else { // Unix: use Unix domain socket const socketPath = getSocketPath(); server.listen(socketPath, () => { // Daemon is ready }); + server.on('error', handleServerError); } - server.on('error', (err) => { - console.error('Server error:', err); - cleanupSocket(); - process.exit(1); - }); - // Handle shutdown signals const shutdown = async () => { if (shuttingDown) return;