From 69df5236b4d37af32b7cde091d610fe980c394c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fekete=20G=C3=A1bor?= Date: Thu, 29 Jan 2026 02:57:00 +0100 Subject: [PATCH] fix(windows): robust daemon startup and port discovery - Start daemon via PowerShell Start-Process to avoid Windows path/escaping issues - Strip \\\?\ prefix to prevent Windows dialog errors - Allow daemon to fall back to an OS-assigned port and persist it to a .port file - Read the .port file on the CLI to connect to the correct session port - Increase TCP readiness timeouts on Windows --- cli/src/connection.rs | 86 ++++++++++++++++++++++++++++++++----------- src/daemon.ts | 43 ++++++++++++++++------ 2 files changed, 97 insertions(+), 32 deletions(-) diff --git a/cli/src/connection.rs b/cli/src/connection.rs index f6a7a39a..0a4756e6 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -132,6 +132,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); @@ -154,10 +184,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() } @@ -170,10 +200,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() } @@ -302,14 +332,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"); @@ -351,16 +387,24 @@ pub fn ensure_daemon( cmd.env("AGENT_BROWSER_STATE", st); } - // 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 { @@ -385,7 +429,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 7c32eca1..686be1f2 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -315,28 +315,49 @@ export async function startDaemon(options?: { streamPort?: number }): Promise { + 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;