Skip to content

fix: stale update badge, chart crosshair alignment, scheduled image prune, and unbounded Docker calls - #32

Merged
swimmesberger merged 17 commits into
mainfrom
wt/watchtower-siedler-issues-831e73
Aug 12, 2026
Merged

fix: stale update badge, chart crosshair alignment, scheduled image prune, and unbounded Docker calls#32
swimmesberger merged 17 commits into
mainfrom
wt/watchtower-siedler-issues-831e73

Conversation

@swimmesberger

@swimmesberger swimmesberger commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Fixes the three issues Siedler6 reported.

Closes #25
Closes #26
Closes #30

#30 — Metrics history crosshair does not follow the cursor

Two stacked bugs; the visible one was the second.

  1. The hover handler snapped to a sample by array-index fraction (Math.round(frac * (ts.length - 1))) while points and the crosshair are positioned by timestamp. ts is the deduped union of timestamps across series, so it is only evenly spaced when every series covers the same range — otherwise the two disagree. Now snaps to the nearest timestamp via binary search.
  2. The dominant error: the SVG uses a fixed viewBox="0 0 720 h" with width="100%" and no preserveAspectRatio, so the default xMidYMid meet letterboxes the content on containers wider than 720px. The pointer conversion assumed the viewBox stretched to full width, so on the 1112px-wide chart in the issue screenshot the crosshair was off by up to ~120px near the edges and correct only at the centre. Conversion now goes through svg.getScreenCTM().inverse(), which is correct under any preserveAspectRatio, container size, page zoom, or ancestor transform.

The conversion is now the exact algebraic inverse of the render-side x(t), so an un-snapped crosshair lands precisely under the cursor. Verified in a browser against a 1112px container: probes at the chart edges rendered exactly at the computed positions, diverging 104–165px from the old behaviour.

Also guards the degenerate zero-size case, which would otherwise pin a crosshair to the first sample.

#25 — Stale "update available" badge

The compose-file half of the issue was already fixed by 77c1ac6f (self-update now recreates the container through the Docker Engine API — no compose file read, no mount, no host-path configuration). The reporter's instance predates that commit; one manual update is enough and UI self-updates work from then on. Only a stale doc comment on ApplySelfUpdate still described the old compose-validating behaviour; corrected here.

The badge itself was a real bug. HasUpdates is cached in stack_update_checks and cleared only by a deploy Watchtower performs itself, or by a fresh check. An out-of-band docker compose pull && up -d never touches it, the periodic checker is off by default, and the UI does not refetch outside active deploys — so the badge was stale by construction.

Fix: record the remote digest behind each outdated image at check time, then revalidate against local Docker state only — no registry traffic on this path, so no Docker Hub rate-limit exposure. Triggered fire-and-forget from stacks.list/stacks.get, debounced per stack (30s) and serialized through a single queue, so the read path never blocks and a large stack list cannot fan out into hundreds of concurrent Docker calls.

Beyond the issue: an image is cleared only when a running container actually uses it, so a bare pull without up -d no longer clears the badge while the old container is still serving.

Deliberately not done: no Docker /events subscription (new long-lived-connection infrastructure this bug does not justify) and no change to default polling intervals.

#26 — Scheduled docker image prune

New "Prune dangling images" row on /settings, alongside the existing automation toggles: enable flag plus an interval, persisted live through the settings store and picked up without a restart.

Two departures from the request:

  • Engine API, not the CLI. POST /images/prune with a dangling=true filter, matching how the rest of the codebase talks to Docker. Explicitly never the -a/all-unused variant, which also deletes tagged images that a compose up without a pull would reuse.
  • Minutes only, clamped 1–1440, rather than the requested "minutes or hours" — both existing automation rows use exactly this control, and a second unit selector adds a concept without adding capability.

Defaults to off, once per day. Pruning cannot interfere with a self-update rollback: the coordinator keeps the previous container, and Docker will not prune an image any container still references.

Unrelated bug found along the way

Sweeping for the swallowed-cancellation pattern turned up a pre-existing bug in AutoDeployBackgroundService: a timed-out registry check (HttpClient's 100s default surfaces as TaskCanceledException, which derives from OperationCanceledException) was rethrown as if it were shutdown, unwound to the outer handler, and permanently ended the auto-deploy loop for every stack — with no log output, until a restart. Guarded on ct.IsCancellationRequested here and in four other services so only genuine shutdown is treated as shutdown.

Long-running Docker calls are no longer capped at 100 seconds

DockerEngineClient used one HttpClient with no Timeout set, so the .NET 100s default applied. Streamed calls (PullImageAsync, StreamLogsAsync) are unaffected once headers arrive — measured on .NET 10, not assumed, because HttpClient's per-request timeout CTS is disposed when SendAsync returns and so does not cover a ResponseHeadersRead body. Buffered calls are capped, and two of them legitimately exceed it: WaitContainerAsync (blocks until a container exits; used by the self-update coordinator watch) and PruneImagesAsync.

Now: a second HttpClient with Timeout.InfiniteTimeSpan, sharing the one SocketsHttpHandler (so the connection pool is shared and exactly one owner disposes it). Only those two calls are routed through it; UI-facing calls (list/inspect/stats) keep the 100s ceiling so a wedged socket still fails fast rather than hanging request handlers. Raising the timeout on the single shared client was rejected for that reason.

Removing a timeout removes a bound, so every affected wait got an explicit one instead:

  • Prune: a 30-minute cap via a dedicated cancellation source. The cap is read off its own source rather than inferred from the caller's token, so a shutdown landing just behind an expired cap is still reported as a TimeoutException and logged — it cannot be silently swallowed by the background service's shutdown guard.
  • Coordinator waits: bounded at both call sites — 60s for the startup reconcile, 10 minutes for the live apply watch (that coordinator is doing the recreate, not picking up leftovers). Each ceiling bounds only the wait, never the bookkeeping after it; bounding the whole reconcile would let a cap firing mid-bookkeeping leave ApplyStage cleared with a stale CoordinatorId and a leaked coordinator container that no later reconcile would clean up.

The startup ceiling matters more than it looks: IHost.StartAsync receives CancellationToken.None (app.RunAsync() with no token) and HostOptions.StartupTimeout is left infinite, so nothing else would ever cancel that wait. Untimed, a coordinator that reports running but never exits would hold startup open forever — SIGTERM would not help, since the shutdown signal never reaches the startup path. The ceiling surfaces as cancellation (not TimeoutException) on purpose: the catch (Exception) arm there clears the stage to Idle, which is wrong for a coordinator that is still alive.

Verification

  • dotnet build Watchtower.slnx — succeeded, 0 warnings, 0 errors.
  • dotnet test Watchtower.slnx650 passed, 0 failed, 0 skipped (478 Application + 172 Api), run on the merged tree rather than per-branch.
  • npm run typecheck and npm run build in watchtower-web — clean.
  • rpc-schema.json re-exported after the merge: no diff (90 methods), so the generated frontend client is current.

Reviewer notes

  • One EF migration: 20260811205038_RecordOutdatedImageDigests adds a non-null outdated_image_digests column defaulting to "". Rows written before it are skipped by revalidation and self-correct on the next full check.
  • StackDto is unchanged, so no frontend changes were needed for Updatefehler #25.
  • A stack the operator stopped never clears its badge via revalidation (there is no running container to confirm against). With the background check disabled too, it stays until someone presses Check. That is the honest reading — nothing is serving the new image — and it is no worse than before this path existed. Documented in code.
  • RemoveAppliedImages subtracts only positively-confirmed images from the freshly-read row, and only while that row still names the digest this pass verified, so a concurrent full check cannot lose a newly-found update. A microseconds-wide read-then-write window remains (no concurrency token); worst case is one stale flag until the next check. Stated in the code comments rather than glossed as "race-free".
  • GetSystemDfAsync is the one remaining buffered Docker call whose cost scales with host contents (the daemon walks images/containers/volumes). It is deliberately left on the 100s client because it is UI-facing — the fail-fast ceiling is the right trade there. Noted as the plausible future candidate if it ever bites.
  • SelfUpdateReconcileCeilingTests temporarily sets HOSTNAME (self-detection reads it) and restores it in a finally. SelfProjectNameProvider also reads that variable in production, so there is a theoretical coupling with test classes running in parallel; the failure mode is benign (an inspect that cannot succeed under test is treated as inconclusive, the same as unset) and the suite was run repeatedly, including under CPU saturation, without flakes.

The hover crosshair on the time-series charts snapped to a sample by
array-index fraction, but points are positioned on the x-axis by
timestamp. Since the deduped union of timestamps across series is
generally not evenly spaced, the crosshair drifted away from the
mouse cursor.

Convert the cursor fraction back to a target time and binary-search
the sorted timestamp array for the nearest sample instead.
A stack updated on the host by hand (docker compose pull && up -d) kept
advertising "update available" until an operator pressed Check: nothing
observed Docker changes Watchtower did not make itself.

The check now records the remote digest behind each outdated image, which
turns clearing the flag into a local question — once that digest is among
the image's local repo digests, the update has landed. Read handlers
announce stacks with a pending image update to a new singleton that runs
that comparison in the background, debounced to once per stack per 30s and
never awaited, so the UI corrects itself on its next refetch without a
single registry request. Commit-based state is left strictly alone; rows
written before the digests existed are skipped and self-correct on the
next full check.

Also aligns the check with revalidation by comparing the remote digest
against all local repo digests rather than only the first.
Adds a third automation row to Settings: an enable toggle plus an interval
in minutes that periodically removes dangling (untagged) images, reclaiming
the disk each pull-and-redeploy cycle leaves behind (GitHub issue #26).

The prune goes through the Docker Engine API
(POST /images/prune?filters={"dangling":["true"]}) rather than the CLI, and
never the -a / "all unused" variant: that would delete tagged images a stack
manager may still want, including the previous version of every stack.

The toggle and interval are settings-backed Watchtower:* keys, so they are
runtime-editable through system.updateAutomation without a restart, exactly
like the existing self-update and stack-check toggles.
The hover crosshair's client-to-viewBox conversion assumed the SVG
stretches to its container's full width. In fact the viewBox has no
preserveAspectRatio override, so the default xMidYMid meet applies:
on any container wider than the 720-unit viewBox (e.g. the
max-w-[1200px] /metrics/history layout) the content is letterboxed
and centered, not stretched. The naive `clientX / rect.width` ratio
ignored that offset, so the crosshair was off by tens to over a
hundred pixels near the plot edges and only correct near the center.

Convert client coordinates to viewBox coordinates through the SVG's
screen CTM instead, which accounts for any letterboxing/scale
correctly. Also bail out of the hover handler for degenerate layouts
(zero width/height, or no invertible CTM) so the crosshair stays
hidden rather than pinning to the first sample.
Review follow-up on 62b7ca5.

The write-back was a lost update: it re-read the row in a fresh scope and
then assigned a list derived from the pre-inspect snapshot, so a full check
that landed while the Docker inspects ran had its findings erased until the
next check. Revalidation now reports only what it positively confirmed as
applied, and the write subtracts those from the stored row — and only while
that row still names the digest this pass verified, so a newer update
published in the meantime survives too.

Clearing an entry now also requires a running container to be using the new
image, not merely for it to be on disk: a bare `docker compose pull` left
the old container serving while the badge went away, which is precisely the
state the badge exists to report. Container image ids come from the
containers list Docker already returns.

Scheduling gets the two limits the read path needs. Work is serialized onto
a single chain, so a cold start listing twenty stacks queues their inspects
instead of firing them at a small host at once; and a stack that is still
in flight is not queued again when the debounce window lapses under it. The
test-only completion seam is internal rather than public API.

Also folds the three Docker calls into named virtual seams, which is what
lets the tests assert that the local path never asks a registry anything.
…down

DockerEngineClient (and the registry clients) leave HttpClient.Timeout at its
100-second default, and a timeout surfaces as a TaskCanceledException — an
OperationCanceledException. Every background loop caught that unconditionally
as "normal shutdown" and logged nothing, so a pass that ran into the ceiling
left a toggle that looks enabled with no evidence of anything happening.

Guarding each of those catches on the token (`when (ct.IsCancellationRequested)`)
keeps real shutdown silent and lets a timeout fall through to the warning that
was already there. Swept the whole codebase: the image-prune, stack-update,
self-update and auto-deploy loops plus the reverse-proxy reconcile needed it;
MetricsSampler and CiRunnerOrchestrator already guard theirs.

The outer catches around each loop's Task.Delay stay unguarded on purpose —
with the inner ones fixed they only ever see shutdown, and rethrowing there
would stop the host (BackgroundServiceExceptionBehavior.StopHost).

Also drops the aspirational "self-update rollback" justification from the
prune's doc comment: no such rollback-by-previous-image path exists. The
sound half — a `compose up` without a pull reuses tagged images — stays.
… body

BuildImagePruneUrl becomes internal, matching ComposeCliService.BuildComposeArgs;
the test project already sees internals via InternalsVisibleTo.

PruneImagesAsync now goes through EnsureSuccessWithBodyAsync instead of
EnsureSuccessStatusCode: the prune runs unattended with no UI surface, so the
daemon's {"message":...} body is the only diagnostic there will ever be — a
read-only socket mount otherwise reports a bare 403.

Drops the two substring assertions over the whole URL from the prune test; the
exact-equality assertion above them already pins the request, and the decoded
filter JSON is asserted on its own.
Re-review follow-up on f299dd9.

Nothing exercised the real /containers/json wire format — every test
overrides the listing seam — and that binding fails closed: if "ImageID"
stopped mapping onto ImageId, no container would ever match a local image,
the badge would never clear again, and the suite would stay green. A
literal fragment now goes through DockerJsonContext and asserts the id
binds, plus that an omitted or null one reads as unset rather than as a
matchable value; the property's comment said "empty" where null is also
reachable.

Also: skip announcing pre-migration rows from the read handlers, which cost
a database read per debounce window to conclude nothing; make the digest
map's hash order- and case-independent, matching its equality; and take the
overstatement out of three comments — the write-back's remaining
read-then-write window, the per-stack cost serialization does not remove,
and the stopped-stack consequence of the running-container gate.
…them

The daemon returns an untagged reference and a deleted layer as separate
entries, so one image can account for two — say entries, not images. And a
client-side timeout abandons the request while the daemon carries the prune
through, so 'gave up on' is the honest wording, not 'failed'.
The Docker client built one HttpClient and never set Timeout, so every call
inherited the 100-second default. Buffered calls are the ones that feel it:
the container wait blocks until the container exits, and a dangling-image
prune on a host with a long layer backlog can take longer than that — both
were being abandoned mid-flight with nothing wrong on the host. (Streamed
responses read with ResponseHeadersRead are unaffected: the per-request
timeout stops applying once the headers arrive.)

Add a second HttpClient over the same SocketsHttpHandler — one connection
pool, two timeout policies — with Timeout.InfiniteTimeSpan, and route only
the wait and the prune through it. The default client keeps the 100-second
ceiling so list/inspect/stats still fail fast on a wedged socket. Neither
client owns the handler; Dispose disposes both clients and then the handler
exactly once.

With no client timeout, cancellation is the only bound. The wait is
inherently open-ended and stays bounded by its caller's token. The prune
runs from a background loop, so it links the caller's token with a 30-minute
cap and surfaces a cap hit as TimeoutException — ImagePruneBackgroundService
swallows OperationCanceledException as shutdown, and a prune that ran into
the ceiling has to be logged rather than vanish.
Moving the container wait onto the untimed client left the startup reconcile
with no bound at all. IHost.StartAsync is handed CancellationToken.None here
(the host is run with no token, and StartupTimeout is left infinite), so a
coordinator that inspects as running but never exits — paused, wedged, host
thrashing — would hold startup open forever: the app never reaches Started,
and SIGTERM cannot help because the shutdown signal never reaches the startup
path. That path is routinely entered, since the coordinator starts the
replacement before removing the old container. Before the routing change the
client's 100-second default ended the wait and startup carried on.

Link the startup token with _cts and a 60-second ceiling, so the reconcile
gives up and startup continues exactly as it did, leaving the apply stage for
a later reconcile — now with a warning saying so. The live call site keeps
threading _cts.Token and is untouched.

Also from review:
- Make the prune cap race-free: the cap gets its own CancellationTokenSource
  and the catch filter reads it, instead of inferring "the cap fired" from the
  caller's token. A shutdown landing between an expired cap and the filter no
  longer turns the TimeoutException back into a swallowed cancellation.
- Cover the owning half of the disposal invariant via an internal constructor
  that takes the handler to own — the production shape.
- Test ImagePruneBackgroundService.RunPruneAsync directly (now internal): a
  capped prune is logged as a warning, a shutdown is swallowed silently.
- Format the cap in the timeout message as a TimeSpan, so a short injected cap
  no longer renders as "0-minute".
The ceiling added for the startup reconcile was scoped too widely and to only
one of the two waits.

Bound the wait, not the bookkeeping. The ceiling was threaded through the whole
reconcile, so a coordinator exiting near the ceiling could have it fire between
clearing the stage and clearing the CoordinatorId — leaving ApplyStage="idle"
with a stale id, the container never removed, and nothing to fix it later:
StartAsync only reconciles stages of "pulling"/"restarting". The ceiling now
wraps WaitContainerAsync alone (TryWaitForExitAsync, with its own source so the
"gave up" warning cannot be misattributed to a racing shutdown), and everything
after the wait runs on the caller's token as before.

Bound the live apply watch too. The hazard is the untimed wait, not the call
site: PullAndSpawnAsync watches on _cts.Token, which only host shutdown fires,
so a wedged coordinator left _applyTask running and every retry rejected with
"already in progress" until a restart. It now gets ApplyWatchTimeout — ten
minutes rather than the startup ceiling, because this coordinator is doing the
recreate rather than picking up someone else's leftovers.

Also: describe Dispose's actual client/handler ownership; give the startup
tests a 1s ceiling instead of 200ms so the margin is not a cliff; cancel the
losing watchdog delay.
@swimmesberger swimmesberger changed the title fix: stale update badge, chart crosshair alignment, and scheduled image prune fix: stale update badge, chart crosshair alignment, scheduled image prune, and unbounded Docker calls Aug 12, 2026
@swimmesberger
swimmesberger merged commit 559db3a into main Aug 12, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Metrics History Cursor Docker prune Updatefehler

1 participant