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.
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.pydoes its resolution and launch viaawait asyncio.to_thread(...)inside an@workworker, with a comment explaining why.The three sites
1.
screens/runs.py::refresh_runs— every ~2 seconds, on the event loopCalled 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 aderive_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 paintsbuild_history_entries()reads up tokeep_lastevent 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 secondsOpening 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_headused 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 — matchingnew_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.