tui_app_server: cancel active login before Ctrl+C exit (#15673)

## Summary

Fixes slow `Ctrl+C` exit from the ChatGPT browser-login screen in
`tui_app_server`.

## Root cause

Onboarding-level `Ctrl+C` quit bypassed the auth widget's cancel path.
That let the active ChatGPT login keep running, and in-process
app-server shutdown then waited on the stale login attempt before
finishing.

## Changes

- Extract a shared `cancel_active_attempt()` path in the auth widget
- Use that path from onboarding-level `Ctrl+C` before exiting the TUI
- Add focused tests for canceling browser-login and device-code attempts
- Add app-server shutdown cleanup that explicitly drops any active login
before draining background work
This commit is contained in:
Eric Traut
2026-03-24 15:11:43 -06:00
committed by GitHub
parent 1b86377635
commit c023e9d959
5 changed files with 96 additions and 31 deletions

View File

@@ -163,37 +163,7 @@ impl KeyboardHandler for AuthModeWidget {
}
KeyCode::Esc => {
tracing::info!("Esc pressed");
let mut sign_in_state = self.sign_in_state.write().unwrap();
match &*sign_in_state {
SignInState::ChatGptContinueInBrowser(state) => {
let request_handle = self.app_server_request_handle.clone();
let login_id = state.login_id.clone();
tokio::spawn(async move {
let _ = request_handle
.request_typed::<codex_app_server_protocol::CancelLoginAccountResponse>(
ClientRequest::CancelLoginAccount {
request_id: onboarding_request_id(),
params: CancelLoginAccountParams { login_id },
},
)
.await;
});
*sign_in_state = SignInState::PickMode;
drop(sign_in_state);
self.set_error(/*message*/ None);
self.request_frame.schedule_frame();
}
SignInState::ChatGptDeviceCode(state) => {
if let Some(cancel) = &state.cancel {
cancel.notify_one();
}
*sign_in_state = SignInState::PickMode;
drop(sign_in_state);
self.set_error(/*message*/ None);
self.request_frame.schedule_frame();
}
_ => {}
}
self.cancel_active_attempt();
}
_ => {}
}
@@ -221,6 +191,36 @@ pub(crate) struct AuthModeWidget {
}
impl AuthModeWidget {
pub(crate) fn cancel_active_attempt(&self) {
let mut sign_in_state = self.sign_in_state.write().unwrap();
match &*sign_in_state {
SignInState::ChatGptContinueInBrowser(state) => {
let request_handle = self.app_server_request_handle.clone();
let login_id = state.login_id.clone();
tokio::spawn(async move {
let _ = request_handle
.request_typed::<codex_app_server_protocol::CancelLoginAccountResponse>(
ClientRequest::CancelLoginAccount {
request_id: onboarding_request_id(),
params: CancelLoginAccountParams { login_id },
},
)
.await;
});
}
SignInState::ChatGptDeviceCode(state) => {
if let Some(cancel) = &state.cancel {
cancel.notify_one();
}
}
_ => return,
}
*sign_in_state = SignInState::PickMode;
drop(sign_in_state);
self.set_error(/*message*/ None);
self.request_frame.schedule_frame();
}
fn set_error(&self, message: Option<String>) {
*self.error.write().unwrap() = message;
}
@@ -1001,6 +1001,50 @@ mod tests {
));
}
#[tokio::test]
async fn cancel_active_attempt_resets_browser_login_state() {
let (widget, _tmp) = widget_forced_chatgpt().await;
*widget.error.write().unwrap() = Some("still logging in".to_string());
*widget.sign_in_state.write().unwrap() =
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
login_id: "login-1".to_string(),
auth_url: "https://auth.example.com".to_string(),
});
widget.cancel_active_attempt();
assert_eq!(widget.error_message(), None);
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::PickMode
));
}
#[tokio::test]
async fn cancel_active_attempt_notifies_device_code_login() {
let (widget, _tmp) = widget_forced_chatgpt().await;
let cancel = Arc::new(Notify::new());
*widget.error.write().unwrap() = Some("still logging in".to_string());
*widget.sign_in_state.write().unwrap() =
SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState {
device_code: None,
cancel: Some(cancel.clone()),
});
widget.cancel_active_attempt();
assert_eq!(widget.error_message(), None);
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::PickMode
));
assert!(
tokio::time::timeout(std::time::Duration::from_millis(50), cancel.notified())
.await
.is_ok()
);
}
/// Collects all buffer cell symbols that contain the OSC 8 open sequence
/// for the given URL. Returns the concatenated "inner" characters.
fn collect_osc8_chars(buf: &Buffer, area: Rect, url: &str) -> String {

View File

@@ -206,6 +206,14 @@ impl OnboardingScreen {
self.should_exit
}
fn cancel_auth_if_active(&self) {
for step in &self.steps {
if let Step::Auth(widget) = step {
widget.cancel_active_attempt();
}
}
}
fn auth_widget_mut(&mut self) -> Option<&mut AuthModeWidget> {
self.steps.iter_mut().find_map(|step| match step {
Step::Auth(widget) => Some(widget),
@@ -270,6 +278,7 @@ impl KeyboardHandler for OnboardingScreen {
};
if should_quit {
if self.is_auth_in_progress() {
self.cancel_auth_if_active();
// If the user cancels the auth menu, exit the app rather than
// leave the user at a prompt in an unauthed state.
self.should_exit = true;