Skip to content

Commit 4cd6b8b

Browse files
committed
🐛 fix(watcher): suppress watcher snapshot when container enumeration fails (#362)
A transient docker / socket-proxy hiccup on a remote agent (\`this.getContainers()\` throws) was logged as a warning but the watch cycle continued and \`event.emitWatcherSnapshot\` was still called with \`containers: []\`. On the controller side that snapshot is authoritative — \`AgentClient.handleWatcherSnapshotEvent\` calls \`pruneOldContainers([], watcherName)\`, which deleted every container for that agent's watcher from the controller's store. The agent's own store was untouched, so the agent's next cron still logged "25 containers watched", but the controller UI showed 0 for that agent until the next clean cron cycle landed or the controller was restarted. The watcher now records whether enumeration failed and skips the snapshot emit in that case so the controller keeps its last-known state across transient enumeration errors. Per-container events still fire normally for any containers that were successfully enumerated; only the empty-list-prune-everything path is closed.
1 parent 6953b43 commit 4cd6b8b

3 files changed

Lines changed: 43 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
142142

143143
- **GHCR token fallback treats whitespace-only tokens as missing ([commit `711d583c`](https://github.com/CodesWhat/drydock/commit/711d583c)).** `getGhcrTokenFallback` and the registry suppression logic were testing `token.length > 0`, which treated a token value of `" "` as a valid credential. All token checks now call `.trim().length > 0`. The GitHub release-notes provider also logs a warn when `api.github.com` rejects a configured token or GHCR fallback, instead of silently swallowing the auth failure.
144144

145+
- **[#362](https://github.com/CodesWhat/drydock/issues/362) — Agents intermittently showing 0 running containers in the controller UI.** When the Docker watcher's container enumeration on an agent (`this.getContainers()`) threw — transient socket-proxy hiccup, Docker daemon restart, momentary network blip — the warning was logged but execution still fell through to `event.emitWatcherSnapshot({ containers: [] })`. The controller treats the snapshot as authoritative and `pruneOldContainers([], watcherName)` then wiped every container for that agent's watcher from the controller's store. The agent's own store was untouched (which is why its next cron still logged "25 containers watched"), but the controller UI showed 0 until either the next clean cron cycle landed or the controller was restarted (re-handshake re-fetched the agent's container list). The watcher now tracks an `containerEnumerationFailed` flag and suppresses the snapshot emission on failure so the controller preserves last-known state across transient enumeration errors.
146+
145147
### i18n (rc.20)
146148

147149
- **14 new locales bundled ([discussion #329](https://github.com/CodesWhat/drydock/discussions/329)).** Italian, Spanish, German, French, Brazilian Portuguese, Dutch, Polish, Turkish, Japanese, Korean, Russian, Vietnamese, Ukrainian, and Arabic now ship with the same namespace coverage as the existing English / Simplified Chinese / Traditional Chinese catalogs. Initial machine-translation seeds were authored against the rc.17 catalog snapshot and then run through Crowdin QA — 28 real bugs were fixed via the Crowdin API (Italian accent recovery, capitalization mismatches, one zh-TW stray symbol) and ~700 false-positive brand/tech terms (Drydock, Trivy, Cosign, SBOM, GHCR, etc.) were added to the per-language Crowdin dictionaries to silence spellcheck noise. 17 locales total in the picker.

app/watchers/providers/docker/Docker.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1074,6 +1074,7 @@ class Docker extends Watcher<DockerWatcherConfiguration> {
10741074
async watch() {
10751075
this.ensureLogger();
10761076
let containers: Container[] = [];
1077+
let containerEnumerationFailed = false;
10771078
startDigestCachePollCycleForRegistries();
10781079

10791080
// Dispatch event to notify start watching
@@ -1086,6 +1087,7 @@ class Docker extends Watcher<DockerWatcherConfiguration> {
10861087
this.log.warn(
10871088
`Error when trying to get the list of the containers to watch (${getErrorMessage(e)})`,
10881089
);
1090+
containerEnumerationFailed = true;
10891091
}
10901092
try {
10911093
if (this.isCronWatchInProgress) {
@@ -1120,15 +1122,22 @@ class Docker extends Watcher<DockerWatcherConfiguration> {
11201122
}
11211123
await event.emitContainerReports(containerReports);
11221124
this.lastRunAt = new Date().toISOString();
1123-
await event.emitWatcherSnapshot({
1124-
watcher: {
1125-
type: this.type,
1126-
name: this.name,
1127-
configuration: this.maskConfiguration() as Record<string, unknown>,
1128-
metadata: this.getMetadata(),
1129-
},
1130-
containers: containerReports.map((containerReport) => containerReport.container),
1131-
});
1125+
// Skip the snapshot emit when container enumeration itself failed.
1126+
// The snapshot is authoritative — downstream prunes everything not in
1127+
// `containers` — so emitting an empty list after a transient docker /
1128+
// socket-proxy hiccup would wipe the controller's view of this agent
1129+
// (issue #362). Preserve last-known state until the next clean cycle.
1130+
if (!containerEnumerationFailed) {
1131+
await event.emitWatcherSnapshot({
1132+
watcher: {
1133+
type: this.type,
1134+
name: this.name,
1135+
configuration: this.maskConfiguration() as Record<string, unknown>,
1136+
metadata: this.getMetadata(),
1137+
},
1138+
containers: containerReports.map((containerReport) => containerReport.container),
1139+
});
1140+
}
11321141
return containerReports;
11331142
} finally {
11341143
endDigestCachePollCycleForRegistries();

app/watchers/providers/docker/Docker.watch.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,29 @@ describe('Docker Watcher', () => {
359359
expect(event.emitWatcherStop).toHaveBeenCalledWith(docker);
360360
});
361361

362+
test('should emit watcher snapshot when container enumeration succeeds', async () => {
363+
docker.getContainers = vi.fn().mockResolvedValue([]);
364+
365+
await docker.watch();
366+
367+
expect(event.emitWatcherSnapshot).toHaveBeenCalledTimes(1);
368+
expect(event.emitWatcherSnapshot).toHaveBeenCalledWith(
369+
expect.objectContaining({ containers: [] }),
370+
);
371+
});
372+
373+
test('should NOT emit watcher snapshot when container enumeration fails (issue #362)', async () => {
374+
docker.log = createMockLog(['warn']);
375+
docker.getContainers = vi.fn().mockRejectedValue(new Error('Docker unavailable'));
376+
377+
await docker.watch();
378+
379+
// Empty snapshot would prune every container for this agent on the
380+
// controller side; emission is suppressed so the controller keeps its
381+
// last-known state until the next clean cycle.
382+
expect(event.emitWatcherSnapshot).not.toHaveBeenCalled();
383+
});
384+
362385
test('should set lastRunAt after watch completes', async () => {
363386
docker.getContainers = vi.fn().mockResolvedValue([]);
364387
expect(docker.lastRunAt).toBeUndefined();

0 commit comments

Comments
 (0)