Skip to content

fleet TUI: blocking I/O on the Textual event loop (2s poll, History mount, WSL dashboard open) #437

Description

Three places in the Fleet Manager TUI do blocking work directly on the Textual event loop, so the whole UI stops responding while they run. The pattern to follow already exists in the same package — screens/new_run.py does its resolution and launch via await asyncio.to_thread(...) inside an @work worker, with a comment explaining why.

The three sites

1. screens/runs.py::refresh_runs — every ~2 seconds, on the event loop

Called on mount and on every poll tick. Per tick it does a directory scan, a JSON parse per record, a per-record liveness probe, a destructive prune (read_run_records() deletes records it judges stale or corrupt), and a derive_run_summary() per row — each of which reads and parses a slice of that run's event log.

This scales with the size of the fleet, and it is the screen a user leaves open.

2. screens/history.py — on mount, before the screen paints

build_history_entries() reads up to keep_last event logs (default 200) in full before History renders. See also #436, which is about how much of each log is held at once; this issue is about where the reading happens.

3. tui/actions.py::_wsl_open — up to 15 seconds

result = subprocess.run(argv, capture_output=True, timeout=15, check=False)

Opening the dashboard on WSL shells out to powershell.exe. On a host where that is slow or hangs, the TUI is frozen for the full 15s with no indication it is still alive.

Why file it now rather than fix it in place

Nothing here is incorrect, and #431's lazy head-read removed the dominant per-tick cost (read_event_log_head used to run unconditionally for every row, on every tick, even when the tail already had what it needed). So this is less acute than it was.

It is still worth tracking, because the poll loop is exactly the kind of code that accretes work: each new column or badge adds a little more to a synchronous 2s tick, and the point at which it becomes visible is gradual rather than obvious.

Suggested direction

Move all three off the event loop with @work(thread=True) / asyncio.to_thread, posting results back for render — matching new_run.py. For the poll loop specifically, that also means deciding whether a tick that overruns the interval should be skipped rather than queued.

Provenance

Raised in the review of #431 (R4) and deferred as non-blocking.

Activity

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

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions