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.

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