feat: Auto-reconnect to Docker daemon when connection drops - #334
Conversation
The container manager exited as soon as the Docker event stream ended, which happens whenever the daemon drops its socket (e.g. `apt upgrade` restarting docker). Since the daemon emits `die` events for every container on the way down, the list emptied out and then stayed blank forever, so dtop had to be restarted by hand. The manager now loops: when the event stream breaks it aborts the per-container stats tasks, reports the host as disconnected, and pings it with a capped backoff (1s -> 5s) until it answers again. On success the container list is re-fetched, which re-synchronizes that host. `InitialContainerList` is therefore now the authoritative list for a host rather than a startup-only batch: stale containers for the host are dropped, an empty list is sent instead of skipped, and the user's selection is preserved instead of being reset to the first row. While a host is down the UI shows a persistent "connection lost, reconnecting..." banner, so the state is visible rather than silent. Fixes #333 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XhYmjDoKkrmMYePHfmuibs
Docker Image Built Successfully |
ReviewSolid fix for #333 — the loop/backoff/resync structure in 1. Any event-stream error now triggers a full disconnect/reconnect cycle ( 2. Selection "preservation" is index-based, not identity-based ( 3. Test coverage gap on the actual reconnect state machine Nothing security-sensitive stood out (no new untrusted input handling), and the CLAUDE.md doc updates accurately reflect the new architecture. |
… by identity Addresses review feedback on #334. The reconnect loop, the backoff, and `fetch_initial_containers`'s new failure signal had no automated coverage. They do now: a stand-in daemon serving the handful of endpoints the container manager touches over a unix socket, which can be killed and rebound at the same path to imitate a daemon restart. That covers a real Bollard client surviving the socket going away and coming back, re-syncing from the daemon afterwards, retrying rather than exiting when a host never answers, and stopping once the UI is gone. Both reconnect tests fail if the loop is reverted. Selection across a resync was index-based, which is unsafe here in a way it is not elsewhere: a resync zeroes the host's stats, so under a stats-based sort every row compares equal and the list can come back in a different order. The cursor would then sit on a container the user never picked, and the next Enter would open the action menu -- Remove included -- on it. It now follows the selected container's identity and only falls back to a row index when that container is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XhYmjDoKkrmMYePHfmuibs
|
Thanks — points 2 and 3 were worth acting on and are addressed in a375b5c. Point 1 I dug into and it turns out not to be a live concern; details below. 1. Event-stream errors — no recoverable variant existsI traced the stream, and there is no error the events stream can yield that leaves it usable, so the fatal branch can't be narrowed.
// Return `None` if we have encountered an error from the underlying decoder
// See: https://github.com/tokio-rs/tokio/issues/3976
if state.has_errored { ... return Poll::Ready(None); }So the stream yields That has a consequence worth noting: the old code was already dead in this path. I've kept the comment but made the reasoning checkable rather than asserted. 2. Index-based selection — fixed, and it was sharper than "not a regression"You were right to flag it, and the resync case is worse than the general sort case. A resync resets the host's stats to
I deliberately scoped this to the resync path rather than making selection identity-based everywhere — that's a broader behavioral change than this PR should carry, and elsewhere the list doesn't reshuffle underneath the user the way it does here. Happy to do it separately if you want it. 3. Test coverage — addedFair, and it was the gap that mattered most.
Both reconnect tests fail if the loop is reverted to the old straight-line form (verified). The backoff policy is extracted into One thing the fake daemon deliberately does not cover: stats streams restarting after a reconnect. That needs the
Generated by Claude Code |
Records the FramedRead/has_errored mechanism behind the fatal branch so the claim is checkable rather than asserted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XhYmjDoKkrmMYePHfmuibs
ReviewSolid fix for #333 — the reconnect state machine is well designed (bounded backoff, identity-based selection tracking across a resync, tests that drive a real socket-level daemon restart rather than mocking). A few notes: Correctness
Design
Tests
Minor
Overall this looks correct and well-tested; happy to see this merged once CI is green. |
ReviewSolid fix for a real problem (frozen UI after a Docker daemon restart), and the reconnect state machine is well-tested — the fake-daemon integration tests in A few things worth a look: 1. Selection fallback can still land on the wrong container ( 2. Reconnect banner text isn't truncated ( 3. Nothing here is a blocker — #1 is the one worth double-checking since it touches the exact hazard this PR set out to close; #2 and #3 are minor. Test coverage looks good otherwise (resync semantics, selection-by-identity, empty-list clearing, and a UI snapshot for the banner are all covered). |
Summary
Implements automatic reconnection to Docker daemons when the connection is lost (e.g., during daemon restart). Previously, a broken connection would freeze the UI on a stale container list. Now the tool detects connection loss, shows a "reconnecting" banner, and automatically re-synchronizes once the daemon is reachable again.
Key Changes
Connection recovery loop:
container_manager()now wraps the fetch/monitor cycle in a loop that detects connection failures and retries with exponential backoff (1s → 5s max)Return value from
fetch_initial_containers(): Now returnsboolto indicate success/failure, allowing the caller to detect when the daemon is unreachableNew
wait_until_reachable()method: Pings the daemon with capped exponential backoff until it responds, preventing reconnect loop spinEvent stream error handling: Changed from silently sleeping on error to returning immediately, since Bollard's event stream doesn't recover from broken connections
New app events:
HostDisconnected(HostId)- Emitted when connection is lost, triggers "reconnecting" bannerHostReconnected(HostId)- Emitted when daemon becomes reachable againUI reconnecting banner: New
render_status_notifications()function displays persistent "⟳ host: connection lost, reconnecting…" banners in the top right for disconnected hostsContainer re-synchronization:
InitialContainerListis now re-sent on reconnect as the authoritative list, so stale containers are dropped and new ones are picked up. Empty lists are sent to clear containers when a host has none.Selection preservation: When re-synchronizing, the UI preserves user selection if possible, or clamps it to valid range
Per-container stats cleanup: Stats streaming tasks are aborted on disconnect so reconnect starts from a clean slate
Implementation Details
BTreeSetfor stable rendering orderFixes #333