Skip to content

Commit e0adfe2

Browse files
authored
fix(tui): resolve login screen freeze during browser authentication (#2)
Replace blocking synchronous event polling with async tokio::select! pattern that properly handles concurrent terminal events and async token polling. - Use crossterm's EventStream for non-blocking keyboard input - Integrate async message handling via tokio::select! for responsive UI - Add 'q' key exit option from waiting screen for better UX - Add proper cancellation when user presses Esc during auth flow - Add debug/trace logging for authentication flow diagnostics
1 parent 9efbc3e commit e0adfe2

File tree

3 files changed

+157
-95
lines changed

3 files changed

+157
-95
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cortex-tui/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ tokio = { workspace = true, features = ["full", "sync", "time", "macros"] }
3333
tokio-stream = { workspace = true }
3434
async-channel = { workspace = true }
3535
async-trait = { workspace = true }
36+
futures = { workspace = true }
3637

3738
# Serialization
3839
serde = { workspace = true }

src/cortex-tui/src/runner/login_screen.rs

Lines changed: 155 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ use std::path::PathBuf;
88
use std::time::{Duration, Instant};
99

1010
use anyhow::Result;
11-
use crossterm::event::{self, Event, KeyCode, KeyEvent};
11+
use crossterm::event::{Event, EventStream, KeyCode, KeyEvent};
12+
use futures::StreamExt;
1213
use ratatui::Terminal;
1314
use ratatui::backend::CrosstermBackend;
1415
use ratatui::layout::{Constraint, Direction, Layout, Rect};
@@ -169,18 +170,15 @@ impl LoginScreen {
169170
&mut self,
170171
terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
171172
) -> Result<LoginResult> {
172-
// Flush any pending input events to prevent stale keypresses
173-
while event::poll(Duration::from_millis(0))? {
174-
let _ = event::read()?;
175-
}
173+
// Create an async event stream - this is crucial for non-blocking event handling
174+
// that allows the tokio runtime to process async messages concurrently
175+
let mut event_stream = EventStream::new();
176176

177177
// Small delay to let terminal settle
178-
std::thread::sleep(Duration::from_millis(50));
178+
tokio::time::sleep(Duration::from_millis(50)).await;
179179

180-
// Flush again after delay
181-
while event::poll(Duration::from_millis(0))? {
182-
let _ = event::read()?;
183-
}
180+
// Timer for UI updates (60fps refresh rate)
181+
let mut render_interval = tokio::time::interval(Duration::from_millis(16));
184182

185183
loop {
186184
self.frame_count = self.frame_count.wrapping_add(1);
@@ -195,21 +193,7 @@ impl LoginScreen {
195193
// Render
196194
terminal.draw(|f| self.render(f))?;
197195

198-
// Check async messages
199-
self.process_async_messages();
200-
201-
// Handle events - only process KeyPress, not KeyRelease or KeyRepeat
202-
if event::poll(Duration::from_millis(80))?
203-
&& let Event::Key(key) = event::read()?
204-
{
205-
// Filter to only handle key press events (not release)
206-
if key.kind == crossterm::event::KeyEventKind::Press
207-
&& let Some(result) = self.handle_key(key)
208-
{
209-
return Ok(result);
210-
}
211-
}
212-
196+
// Check state before waiting for events
213197
match self.state {
214198
LoginState::Success => {
215199
return Ok(LoginResult::LoggedIn);
@@ -223,6 +207,42 @@ impl LoginScreen {
223207
}
224208
_ => {}
225209
}
210+
211+
// Use tokio::select! to concurrently wait for:
212+
// 1. Terminal events (keyboard input)
213+
// 2. Async messages from background tasks (token polling)
214+
// 3. Render timer tick
215+
// This prevents blocking the async runtime and ensures responsive UI
216+
tokio::select! {
217+
// Handle keyboard/terminal events
218+
maybe_event = event_stream.next() => {
219+
if let Some(Ok(Event::Key(key))) = maybe_event
220+
&& key.kind == crossterm::event::KeyEventKind::Press
221+
&& let Some(result) = self.handle_key(key)
222+
{
223+
return Ok(result);
224+
}
225+
}
226+
227+
// Handle async messages from token polling
228+
msg = async {
229+
if let Some(ref mut rx) = self.async_rx {
230+
rx.recv().await
231+
} else {
232+
// No receiver, wait forever (will be cancelled by other branches)
233+
std::future::pending::<Option<AsyncMessage>>().await
234+
}
235+
} => {
236+
if let Some(msg) = msg {
237+
self.handle_async_message(msg);
238+
}
239+
}
240+
241+
// Periodic render tick to keep UI responsive
242+
_ = render_interval.tick() => {
243+
// Just continue to re-render
244+
}
245+
}
226246
}
227247
}
228248

@@ -499,18 +519,28 @@ impl LoginScreen {
499519
fn handle_waiting_key(&mut self, key: KeyEvent) -> Option<LoginResult> {
500520
match key.code {
501521
KeyCode::Esc => {
522+
// Cancel the current auth flow and go back to method selection
502523
self.state = LoginState::SelectMethod;
503524
self.error_message = None;
525+
self.user_code = None;
526+
self.verification_uri = None;
527+
// Drop the receiver to signal the async task it can stop
528+
// (the task will get a send error and terminate)
504529
self.async_rx = None;
505530
}
506531
KeyCode::Char('c') | KeyCode::Char('C') => {
532+
// Only handle 'c' for copy if NOT Ctrl+C (Ctrl+C is handled in handle_key)
507533
// Copy URL to clipboard using the safe clipboard function
508534
// This properly handles Linux (with wait()) and Windows clipboard behavior
509535
let url = self.get_direct_url();
510536
if super::terminal::safe_clipboard_copy(&url) {
511537
self.copied_notification = Some(Instant::now());
512538
}
513539
}
540+
KeyCode::Char('q') | KeyCode::Char('Q') => {
541+
// Also allow 'q' to exit from waiting screen for better UX
542+
return Some(LoginResult::Exit);
543+
}
514544
_ => {}
515545
}
516546
None
@@ -534,73 +564,67 @@ impl LoginScreen {
534564
tx
535565
}
536566

537-
fn process_async_messages(&mut self) {
538-
let messages: Vec<AsyncMessage> = if let Some(ref mut rx) = self.async_rx {
539-
let mut msgs = Vec::new();
540-
while let Ok(msg) = rx.try_recv() {
541-
msgs.push(msg);
542-
}
543-
msgs
544-
} else {
545-
Vec::new()
546-
};
547-
548-
for msg in messages {
549-
match msg {
550-
AsyncMessage::DeviceCodeReceived {
551-
user_code,
552-
device_code,
553-
verification_uri: _,
554-
} => {
555-
let auth_url = format!("{}/device", AUTH_BASE_URL);
556-
self.user_code = Some(user_code.clone());
557-
self.verification_uri = Some(auth_url.clone());
558-
559-
// Open browser
560-
let link_url = format!("{}?code={}", auth_url, user_code);
561-
#[cfg(target_os = "macos")]
562-
{
563-
let _ = std::process::Command::new("open")
564-
.arg(&link_url)
565-
.stdout(std::process::Stdio::null())
566-
.stderr(std::process::Stdio::null())
567-
.spawn();
568-
}
569-
#[cfg(target_os = "linux")]
570-
{
571-
let _ = std::process::Command::new("xdg-open")
572-
.arg(&link_url)
573-
.stdout(std::process::Stdio::null())
574-
.stderr(std::process::Stdio::null())
575-
.spawn();
576-
}
577-
#[cfg(target_os = "windows")]
578-
{
579-
let _ = std::process::Command::new("cmd")
580-
.args(["/C", "start", "", &link_url])
581-
.stdout(std::process::Stdio::null())
582-
.stderr(std::process::Stdio::null())
583-
.spawn();
584-
}
585-
586-
// Start token polling
587-
let cortex_home = self.cortex_home.clone();
588-
let tx = self.create_async_channel();
589-
tokio::spawn(async move {
590-
poll_for_token_async(cortex_home, device_code, tx).await;
591-
});
592-
}
593-
AsyncMessage::DeviceCodeError(e) => {
594-
self.state = LoginState::SelectMethod;
595-
self.error_message = Some(e);
567+
fn handle_async_message(&mut self, msg: AsyncMessage) {
568+
match msg {
569+
AsyncMessage::DeviceCodeReceived {
570+
user_code,
571+
device_code,
572+
verification_uri: _,
573+
} => {
574+
tracing::info!("Device code received: {}", user_code);
575+
let auth_url = format!("{}/device", AUTH_BASE_URL);
576+
self.user_code = Some(user_code.clone());
577+
self.verification_uri = Some(auth_url.clone());
578+
579+
// Open browser
580+
let link_url = format!("{}?code={}", auth_url, user_code);
581+
tracing::debug!("Opening browser to: {}", link_url);
582+
#[cfg(target_os = "macos")]
583+
{
584+
let _ = std::process::Command::new("open")
585+
.arg(&link_url)
586+
.stdout(std::process::Stdio::null())
587+
.stderr(std::process::Stdio::null())
588+
.spawn();
596589
}
597-
AsyncMessage::TokenReceived => {
598-
self.state = LoginState::Success;
590+
#[cfg(target_os = "linux")]
591+
{
592+
let _ = std::process::Command::new("xdg-open")
593+
.arg(&link_url)
594+
.stdout(std::process::Stdio::null())
595+
.stderr(std::process::Stdio::null())
596+
.spawn();
599597
}
600-
AsyncMessage::TokenError(e) => {
601-
self.state = LoginState::SelectMethod;
602-
self.error_message = Some(e);
598+
#[cfg(target_os = "windows")]
599+
{
600+
let _ = std::process::Command::new("cmd")
601+
.args(["/C", "start", "", &link_url])
602+
.stdout(std::process::Stdio::null())
603+
.stderr(std::process::Stdio::null())
604+
.spawn();
603605
}
606+
607+
// Start token polling - create new channel for this phase
608+
tracing::debug!("Starting token polling for device code");
609+
let cortex_home = self.cortex_home.clone();
610+
let tx = self.create_async_channel();
611+
tokio::spawn(async move {
612+
poll_for_token_async(cortex_home, device_code, tx).await;
613+
});
614+
}
615+
AsyncMessage::DeviceCodeError(e) => {
616+
tracing::error!("Device code error: {}", e);
617+
self.state = LoginState::SelectMethod;
618+
self.error_message = Some(e);
619+
}
620+
AsyncMessage::TokenReceived => {
621+
tracing::info!("Authentication token received - login successful");
622+
self.state = LoginState::Success;
623+
}
624+
AsyncMessage::TokenError(e) => {
625+
tracing::error!("Token error: {}", e);
626+
self.state = LoginState::SelectMethod;
627+
self.error_message = Some(e);
604628
}
605629
}
606630
}
@@ -688,34 +712,51 @@ async fn poll_for_token_async(
688712
device_code: String,
689713
tx: mpsc::Sender<AsyncMessage>,
690714
) {
715+
tracing::debug!("Token polling started");
716+
691717
let client = match cortex_engine::create_default_client() {
692718
Ok(c) => c,
693719
Err(e) => {
720+
tracing::error!("Failed to create HTTP client: {}", e);
694721
let _ = tx.send(AsyncMessage::TokenError(e.to_string())).await;
695722
return;
696723
}
697724
};
698725

699726
let interval = Duration::from_secs(5);
700-
let max_attempts = 180;
727+
let max_attempts = 180; // 15 minutes total
701728

702-
for _ in 0..max_attempts {
729+
for attempt in 0..max_attempts {
703730
tokio::time::sleep(interval).await;
704731

732+
// Check if the receiver was dropped (user cancelled)
733+
// This is a cheap check that allows us to exit early
734+
if tx.is_closed() {
735+
tracing::debug!("Token polling cancelled (receiver dropped)");
736+
return;
737+
}
738+
739+
tracing::trace!("Polling for token (attempt {}/{})", attempt + 1, max_attempts);
740+
705741
let response = match client
706742
.post(format!("{}/auth/device/token", API_BASE_URL))
707743
.json(&serde_json::json!({ "device_code": device_code }))
708744
.send()
709745
.await
710746
{
711747
Ok(r) => r,
712-
Err(_) => continue,
748+
Err(e) => {
749+
tracing::debug!("Token poll request failed: {}", e);
750+
continue;
751+
}
713752
};
714753

715754
let status = response.status();
716755
let body = response.text().await.unwrap_or_default();
717756

718757
if status.is_success() {
758+
tracing::debug!("Token response received (success)");
759+
719760
#[derive(serde::Deserialize)]
720761
struct TokenResponse {
721762
access_token: String,
@@ -733,7 +774,12 @@ async fn poll_for_token_async(
733774
match save_auth_with_fallback(&cortex_home, &auth_data) {
734775
Ok(mode) => {
735776
tracing::info!("Auth credentials saved using {:?} storage", mode);
736-
let _ = tx.send(AsyncMessage::TokenReceived).await;
777+
// Send the success message - this is the critical moment
778+
if let Err(e) = tx.send(AsyncMessage::TokenReceived).await {
779+
tracing::error!("Failed to send TokenReceived message: {}", e);
780+
} else {
781+
tracing::debug!("TokenReceived message sent successfully");
782+
}
737783
return;
738784
}
739785
Err(e) => {
@@ -747,6 +793,8 @@ async fn poll_for_token_async(
747793
return;
748794
}
749795
}
796+
} else {
797+
tracing::warn!("Failed to parse token response");
750798
}
751799
continue;
752800
}
@@ -755,24 +803,36 @@ async fn poll_for_token_async(
755803
&& let Some(err) = error.get("error").and_then(|e| e.as_str())
756804
{
757805
match err {
758-
"authorization_pending" | "slow_down" => continue,
806+
"authorization_pending" => {
807+
tracing::trace!("Authorization pending...");
808+
continue;
809+
}
810+
"slow_down" => {
811+
tracing::debug!("Server requested slow down");
812+
continue;
813+
}
759814
"expired_token" => {
815+
tracing::warn!("Device code expired");
760816
let _ = tx
761817
.send(AsyncMessage::TokenError("Device code expired".to_string()))
762818
.await;
763819
return;
764820
}
765821
"access_denied" => {
822+
tracing::warn!("Access denied by user");
766823
let _ = tx
767824
.send(AsyncMessage::TokenError("Access denied".to_string()))
768825
.await;
769826
return;
770827
}
771-
_ => {}
828+
_ => {
829+
tracing::debug!("Unknown error response: {}", err);
830+
}
772831
}
773832
}
774833
}
775834

835+
tracing::warn!("Token polling timed out after {} attempts", max_attempts);
776836
let _ = tx
777837
.send(AsyncMessage::TokenError(
778838
"Authentication timed out".to_string(),

0 commit comments

Comments
 (0)