diff --git a/rust/src/managed_process.rs b/rust/src/managed_process.rs index d6a96279142..6f0904452b4 100644 --- a/rust/src/managed_process.rs +++ b/rust/src/managed_process.rs @@ -1056,41 +1056,51 @@ mod tests { drop(process); } + // Reuse the native test executable so the containment test does not time + // PowerShell/.NET startup while the rest of the library suite runs. + #[test] + fn managed_descendant_fixture() { + let Some(marker) = std::env::var_os("CODEXBAR_MANAGED_DESCENDANT_MARKER") else { + return; + }; + if std::env::var_os("CODEXBAR_MANAGED_DESCENDANT_LEAF").is_none() { + use std::os::windows::process::CommandExt; + let child = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "managed_process::tests::managed_descendant_fixture", + ]) + .env("CODEXBAR_MANAGED_DESCENDANT_LEAF", "1") + .creation_flags(0x0800_0000) + .spawn() + .expect("start the native descendant fixture"); + std::fs::write(marker, child.id().to_string()).expect("write descendant pid"); + } + std::thread::sleep(Duration::from_secs(120)); + } + #[tokio::test] async fn managed_process_contains_descendants_of_the_pty_child() { - let marker = std::env::temp_dir().join(format!( - "codexbar-managed-descendant-{}.txt", - std::process::id() - )); - drop(std::fs::remove_file(&marker)); - let script = format!( - "$psi = New-Object System.Diagnostics.ProcessStartInfo; \ - $psi.FileName = 'powershell.exe'; \ - $psi.Arguments = '-NoLogo -NoProfile -Command \"Start-Sleep -Seconds 120\"'; \ - $psi.UseShellExecute = $false; \ - $p = [System.Diagnostics.Process]::Start($psi); \ - Set-Content -LiteralPath '{}' -Value $p.Id; \ - Start-Sleep -Seconds 120", - marker.display() - ); + let directory = tempfile::tempdir().expect("isolated descendant fixture directory"); + let marker = directory.path().join("descendant.pid"); let config = ManagedProcessConfig { - program: PathBuf::from("powershell.exe"), + program: std::env::current_exe().expect("native test executable"), args: vec![ - OsString::from("-NoLogo"), - OsString::from("-NoProfile"), - OsString::from("-NonInteractive"), - OsString::from("-Command"), - OsString::from(script), + OsString::from("--exact"), + OsString::from("managed_process::tests::managed_descendant_fixture"), ], - env: Vec::new(), - cwd: None, + env: vec![( + OsString::from("CODEXBAR_MANAGED_DESCENDANT_MARKER"), + marker.as_os_str().to_owned(), + )], + cwd: Some(directory.path().to_owned()), pty_rows: 30, pty_cols: 120, label: "test-descendant".to_string(), }; - let process = ManagedProcess::spawn(&config).expect("start a managed test process"); + let mut process = ManagedProcess::spawn(&config).expect("start a managed test process"); - let descendant = wait_for_descendant_pid(&marker); + let descendant = wait_for_descendant_pid(&marker, &mut process); assert!( process_is_alive(descendant), "the descendant of the PTY child should be running before shutdown" @@ -1099,10 +1109,9 @@ mod tests { process.shutdown(Duration::from_secs(10)).await; wait_for_exit_within(descendant, Duration::from_secs(10)); - drop(std::fs::remove_file(&marker)); } - fn wait_for_descendant_pid(marker: &Path) -> u32 { + fn wait_for_descendant_pid(marker: &Path, process: &mut ManagedProcess) -> u32 { let deadline = Instant::now() + Duration::from_secs(15); loop { if let Ok(contents) = std::fs::read_to_string(marker) @@ -1110,9 +1119,19 @@ mod tests { { return pid; } + assert!( + process + .try_wait() + .expect("poll descendant fixture") + .is_none(), + "PTY fixture exited before reporting its descendant pid" + ); assert!( Instant::now() < deadline, - "the PTY child did not report a descendant pid" + "PTY fixture {} did not report a descendant pid at {}: {:?}", + process.pid(), + marker.display(), + std::fs::read_to_string(marker) ); std::thread::sleep(Duration::from_millis(50)); } diff --git a/rust/src/providers/claude/accounts/login.rs b/rust/src/providers/claude/accounts/login.rs index 8f0a6e002f3..71a0285a3af 100644 --- a/rust/src/providers/claude/accounts/login.rs +++ b/rust/src/providers/claude/accounts/login.rs @@ -582,23 +582,73 @@ mod tests { ) .unwrap(); std::fs::write(dir.path().join(".claude.json"), r#"{"oauthAccount":{"accountUuid":"test","organizationUuid":"org","emailAddress":"test@example.com"}}"#).unwrap(); + // This test covers exit handling and credential isolation, not shell + // startup speed. Reap each fixture before starting the login deadline; + // cancelled_and_timed_out_logins_reap_child covers a running process. + let mut successful_child = child(dir.path(), false, 0); + assert!( + wait_for_fixture(&mut successful_child, Duration::from_secs(60)) + .unwrap() + .success() + ); let login = wait_for_login( - &mut child(dir.path(), false, 0), + &mut successful_child, dir.path(), &AtomicBool::new(false), - Duration::from_secs(10), + Duration::ZERO, ) .unwrap(); assert_eq!(login.id().unwrap(), "test:org"); - assert!( - wait_for_login( - &mut child(dir.path(), false, 1), - dir.path(), - &AtomicBool::new(false), - Duration::from_secs(10) - ) - .is_err() + let mut failed_child = child(dir.path(), false, 1); + assert_eq!( + wait_for_fixture(&mut failed_child, Duration::from_secs(60)) + .unwrap() + .code(), + Some(1) ); + let error = wait_for_login( + &mut failed_child, + dir.path(), + &AtomicBool::new(false), + Duration::ZERO, + ) + .err() + .expect("a failed login process must be rejected"); + assert!(error.to_string().contains("exit code 1")); + } + + // This is a fixture-startup budget, not a change to the login timeout. + fn wait_for_fixture( + child: &mut LoginChild, + budget: Duration, + ) -> io::Result { + let deadline = Instant::now() + budget; + loop { + if let Some(status) = child.try_wait()? { + return Ok(status); + } + if Instant::now() >= deadline { + child.kill()?; + return Err(io::Error::new( + io::ErrorKind::TimedOut, + format!("login test fixture did not exit within {budget:?}; terminated"), + )); + } + std::thread::sleep(Duration::from_millis(25)); + } + } + + #[test] + fn stalled_login_fixture_is_terminated_at_its_deadline() { + let dir = tempfile::tempdir().unwrap(); + let mut child = child(dir.path(), true, 0); + let started = Instant::now(); + let error = wait_for_fixture(&mut child, Duration::ZERO).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + assert!(error.to_string().contains("login test fixture")); + assert!(started.elapsed() < Duration::from_secs(5)); + let status = wait_for_fixture(&mut child, Duration::from_secs(5)).unwrap(); + assert!(!status.success()); } #[test]