From e0ce61c9411a06c106a15677501716ed4d3206aa Mon Sep 17 00:00:00 2001 From: Jonathan Liebig Date: Sat, 12 Sep 2026 17:16:22 +0200 Subject: [PATCH] fix: finish windows ssh setup after bridge exit refs #3651 --- src/remote/attach.rs | 80 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 5 deletions(-) diff --git a/src/remote/attach.rs b/src/remote/attach.rs index 4f8d2b7c74..e6abc8f4a9 100644 --- a/src/remote/attach.rs +++ b/src/remote/attach.rs @@ -350,8 +350,10 @@ fn windows_powershell_streaming_application_command(path: &str, args: &[&str]) - .map(|arg| crate::platform::quote_windows_command_line_arg(arg)) .collect::>() .join(" "); + // Start-Process -Wait waits for descendants, including a cold-started server. + // Retain the handle so Windows PowerShell 5.1 keeps the application's exit code. windows_powershell_script_command(&format!( - "$process = Start-Process -FilePath {} -ArgumentList {} -NoNewWindow -Wait -PassThru -ErrorAction Stop; exit $process.ExitCode", + "$process = Start-Process -FilePath {} -ArgumentList {} -NoNewWindow -PassThru -ErrorAction Stop; $null = $process.Handle; $process.WaitForExit(); exit $process.ExitCode", crate::platform::quote_powershell_arg(path), crate::platform::quote_powershell_arg(&command_line), )) @@ -3615,6 +3617,74 @@ mod tests { } } + #[cfg(windows)] + #[test] + fn windows_bridge_returns_application_exit_while_descendant_is_running() { + let pid_file = std::env::temp_dir().join(format!( + "herdr bridge descendant {}-{}.pid", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("current time") + .as_nanos() + )); + let script = format!( + "$child = Start-Process powershell.exe -ArgumentList '-NoProfile -NonInteractive -Command Start-Sleep -Seconds 30' -NoNewWindow -PassThru; Set-Content -LiteralPath {} -Value $child.Id; exit 23", + crate::platform::quote_powershell_arg(&pid_file.to_string_lossy()) + ); + let encoded = base64::engine::general_purpose::STANDARD.encode( + script + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(), + ); + let command = windows_powershell_streaming_application_command( + "powershell.exe", + &["-NoProfile", "-NonInteractive", "-EncodedCommand", &encoded], + ); + let mut launcher = Command::new("powershell.exe"); + launcher + .args(["-NoProfile", "-NonInteractive", "-EncodedCommand"]) + .arg(command.split_whitespace().last().unwrap()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + crate::platform::configure_background_command(&mut launcher); + let mut launcher = launcher.spawn().expect("launch Windows bridge command"); + let deadline = Instant::now() + Duration::from_secs(10); + let status = loop { + let status = launcher.try_wait().expect("poll bridge launcher"); + if status.is_some() || Instant::now() >= deadline { + break status; + } + thread::sleep(Duration::from_millis(20)); + }; + if status.is_none() { + let mut cleanup = Command::new("taskkill.exe"); + cleanup.args(["/PID", &launcher.id().to_string(), "/T", "/F"]); + crate::platform::configure_background_command(&mut cleanup); + let _ = cleanup.output(); + let _ = launcher.kill(); + } + let _ = launcher.wait(); + // Clean up the launcher before a missing PID can fail the test. + let descendant = fs::read_to_string(&pid_file); + let _ = fs::remove_file(pid_file); + let descendant = descendant.expect("descendant PID"); + let descendant = descendant.trim().parse::().expect("numeric PID"); + let mut cleanup = Command::new("powershell.exe"); + cleanup + .args(["-NoProfile", "-NonInteractive", "-Command"]) + .arg(format!("Stop-Process -Id {descendant} -ErrorAction Stop")); + crate::platform::configure_background_command(&mut cleanup); + let descendant_was_running = cleanup.status().expect("stop test descendant").success(); + assert!( + descendant_was_running, + "descendant must outlive the application" + ); + assert_eq!(status.and_then(|status| status.code()), Some(23)); + } + #[test] fn windows_remote_commands_use_one_encoded_powershell_grammar() { fn decode(command: &str) -> String { @@ -3661,22 +3731,22 @@ mod tests { ( "direct bridge", executable.bridge_command("agents"), - "$process = Start-Process -FilePath herdr.exe -ArgumentList '--session agents remote-client-bridge' -NoNewWindow -Wait -PassThru -ErrorAction Stop; exit $process.ExitCode", + "$process = Start-Process -FilePath herdr.exe -ArgumentList '--session agents remote-client-bridge' -NoNewWindow -PassThru -ErrorAction Stop; $null = $process.Handle; $process.WaitForExit(); exit $process.ExitCode", ), ( "API bridge with explicit default session", remote_api_bridge_command(&RemoteHerdr::for_platform(RemotePlatform { os: "windows", arch: "x86_64" }), "default", false), - "$process = Start-Process -FilePath herdr.exe -ArgumentList '--session default remote-api-bridge' -NoNewWindow -Wait -PassThru -ErrorAction Stop; exit $process.ExitCode", + "$process = Start-Process -FilePath herdr.exe -ArgumentList '--session default remote-api-bridge' -NoNewWindow -PassThru -ErrorAction Stop; $null = $process.Handle; $process.WaitForExit(); exit $process.ExitCode", ), ( "API bridge capability probe", remote_api_bridge_command(&RemoteHerdr::for_platform(RemotePlatform { os: "windows", arch: "x86_64" }), "agents", true), - "$process = Start-Process -FilePath herdr.exe -ArgumentList '--session agents remote-api-bridge --check' -NoNewWindow -Wait -PassThru -ErrorAction Stop; exit $process.ExitCode", + "$process = Start-Process -FilePath herdr.exe -ArgumentList '--session agents remote-api-bridge --check' -NoNewWindow -PassThru -ErrorAction Stop; $null = $process.Handle; $process.WaitForExit(); exit $process.ExitCode", ), ( "saved bridge with closed stdin", executable.saved_bridge_command("agents"), - "$process = Start-Process -FilePath herdr.exe -ArgumentList '--session agents remote-client-bridge' -NoNewWindow -Wait -PassThru -ErrorAction Stop; exit $process.ExitCode", + "$process = Start-Process -FilePath herdr.exe -ArgumentList '--session agents remote-client-bridge' -NoNewWindow -PassThru -ErrorAction Stop; $null = $process.Handle; $process.WaitForExit(); exit $process.ExitCode", ), ];