Skip to content

Fix Windows live TUI freeze when console window loses/regains focus #151

Description

@kevinbds

Bug

On Windows, codex-auth switch --live (and other live TUI screens: list --live, remove --live) can freeze/hang when the user switches away from the terminal window and then clicks back into it. After that, the picker stops updating and does not respond to input until a real key press or a window resize event happens to arrive.

Root Cause

In src/cli/tui.zig, the live loop uses pollTuiInput, which on Windows calls:

pub const pollTuiInput = if (builtin.os.tag == .windows)
    struct {
        fn call(file: std.Io.File, timeout_ms: i32, _: i16) !TuiPollResult {
            const wait_ms: win.DWORD = if (timeout_ms < 0) win.INFINITE else @intCast(timeout_ms);
            return switch (win.WaitForSingleObject(file.handle, wait_ms)) {
                win.WAIT_OBJECT_0 => .ready,
                win.WAIT_TIMEOUT => .timeout,
                else => .closed,
            };
        }
    }.call
...

This correctly waits with a timeout so the live refresh timer can keep firing. However, once WaitForSingleObject reports the input handle is signaled (.ready), control passes to readWindowsKey():

pub fn readWindowsKey(self: *@This()) !TuiInputKey {
    if (comptime builtin.os.tag != .windows) unreachable;

    if (self.pending_windows_key) |pending| {
        ...
        return pending;
    }

    while (true) {
        var record: win.INPUT_RECORD = undefined;
        var events_read: win.DWORD = 0;
        if (win.ReadConsoleInputW(self.input.handle, &record, 1, &events_read) == .FALSE) {
            return error.EndOfStream;
        }
        if (events_read == 0) continue;
        if (record.EventType == win.WINDOW_BUFFER_SIZE_EVENT) {
            self.pending_windows_key = null;
            self.pending_windows_repeat_count = 0;
            return .redraw;
        }
        if (record.EventType != win.KEY_EVENT) continue;
        ...
    }
}

This inner loop calls ReadConsoleInputW with no timeout. Since the console input mode has ENABLE_WINDOW_INPUT set (see windowsTuiInputMode), the console queues non-key input records — most notably FOCUS_EVENT_RECORD (event type 0x0010) — whenever the console window loses or gains focus (e.g., the user clicks away from the terminal, or clicks back into it).

When such a record is read, the current code just does continue and blocks again on ReadConsoleInputW with no timeout. This means:

  1. The user clicks away from the terminal window → the console queues a focus-lost event → WaitForSingleObject (which does have a timeout) wakes up and returns .ready.
  2. Execution moves into readWindowsKey's inner loop, reads the non-key focus record, discards it via continue, and re-blocks indefinitely on ReadConsoleInputW.
  3. Because this call has no timeout, the periodic live-refresh logic that depends on returning control to the outer live loop (via .timeout) never runs again.
  4. When the user clicks back into the terminal, another FOCUS_EVENT_RECORD (focus gained) is queued, which is likewise silently discarded, and the code blocks again — so the live view appears completely frozen until a real key press or a window resize (WINDOW_BUFFER_SIZE_EVENT) happens to arrive.

Expected Behavior

The Windows console-input reading loop should not block indefinitely once it starts discarding non-key records (such as focus events). It should respect the same effective timeout budget that the outer live-refresh loop relies on, so that:

  • Focus-lost / focus-gained events do not cause switch --live, list --live, or remove --live to become unresponsive.
  • The live refresh timer keeps firing on schedule even if unrelated console input records (like focus change events) are queued.
  • Real key presses and window resize events continue to work exactly as before.

Suggested Fix

In src/cli/tui.zig:

  • Make the Windows key-reading path (readWindowsKey, and/or the TuiSession.pollKeys/live-loop caller in the same file) timeout-aware, so that when ReadConsoleInputW returns a non-key, non-resize record (e.g., FOCUS_EVENT_RECORD = 0x0010, MENU_EVENT_RECORD = 0x0008), the function does not simply loop forever on a blocking read.
  • One approach: track a remaining timeout budget across the inner loop iterations (e.g., using WaitForSingleObject with a decreasing timeout before each ReadConsoleInputW call, similar to what pollTuiInput already does), and return a .timeout (or equivalent no-op/redraw) result up to the caller so the live loop can proceed to its next scheduled refresh instead of being stuck.
  • Ensure any relevant timeout value used for the live refresh is threaded through consistently.
  • Do not change the non-Windows (posix) code path, which already uses std.posix.poll with a timeout correctly.
  • Filter out/skip non-key, non-resize console input records (FOCUS_EVENT_RECORD, MENU_EVENT_RECORD, MOUSE_EVENT_RECORD if not already handled) without blocking indefinitely.

Tests

  • Add/extend tests in tests/tui_session_test.zig that simulate a sequence of non-key console input records (e.g., a focus event) followed by a delay, and verify that the key-reading logic returns control within the expected time budget rather than blocking indefinitely.
  • Follow existing project conventions: Zig, zig build test, BDD-style test names ("Scenario: Given ... when ... then ...").

Additional Notes

  • This affects codex-auth switch --live, codex-auth list --live, and codex-auth remove --live on Windows only. POSIX/macOS/Linux paths are unaffected.
  • Keep code style consistent with src/cli/tui.zig.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions