fix(#1749): decouple watchdog emit from blocking I/O (root cause) - #1853
fix(#1749): decouple watchdog emit from blocking I/O (root cause)#1853SaarMesh-Bot wants to merge 4 commits into
Conversation
|
Polish review @ carmack (BLOCKER): dijkstra (MAJOR): goroutine leak by design — if munger (MINOR): async emit converts an operator-visible hang into a silent drop counter. kent-beck (MAJOR): red-commit bar violation — reverting the fix makes Verdict: BLOCKER (stop-race panic). |
…mit hang PR Kpa-clawbot#1810 added defer/recover around the watchdog's per-source work so a PANIC inside emit cannot kill the loop. It does not address the actual production failure mode: emit is log.Print in production, and log.Print's underlying write() can BLOCK -- rather than panic -- when the sink is backpressured (Docker JSON-file log driver falling behind under load, a full stderr pipe, journald hiccups, etc.). A blocked syscall is not a panic; recover() does nothing for it. Because emit was called SYNCHRONOUSLY inside the per-source work, a single stuck write() used to freeze the entire tick loop forever. This commit adds the API surface the fix will need (newAsyncEmit, logQueueCapacity, WatchdogLogDropCount, and the corresponding /api/mqtt/status + ingestor stats-snapshot plumbing) but wires newAsyncEmit to a trivial synchronous passthrough stub -- the bug is still present. Two of the new tests are RED as a result, failing on a real assertion/timeout (not a compile error): - TestNewAsyncEmit_NeverBlocksWhenWriterStuck_1749 - TestMQTTStallWatchdog_LoopSurvivesStuckWriter_1749 The remaining two new tests pass already at this stage (they exercise plumbing the stub already satisfies -- production wiring smoke test, and WatchdogLogDropCount JSON round-trip) and are included here rather than split further. All pre-existing watchdog/liveness/status tests are unaffected. Co-Authored-By: Claude <noreply@anthropic.com>
…locking I/O Replaces the synchronous passthrough stub from the previous commit with the real fix: emit now performs a non-blocking channel send. A single background goroutine drains the channel and performs the (potentially blocking) real write; if that goroutine itself gets stuck, the bounded queue (256) fills and further sends are dropped -- counted via WatchdogLogDropCount -- instead of blocking. This exactly closes the gap left by Kpa-clawbot#1810: a synchronous log.Print whose underlying write() blocks (Docker JSON-file log driver backpressure, full stderr pipe, etc.) used to freeze the entire tick loop forever -- no further source was ever checked and no further tick was ever processed again, reproducing the original Kpa-clawbot#1749 incident (3 MQTT sources silent simultaneously, zero WATCHDOG log lines for 75+ minutes, container otherwise healthy, only a restart recovering it) even after Kpa-clawbot#1810 landed. Worst case under a persistent backpressure event is now lost log lines (visible and counted via WatchdogLogDropCount, surfaced through /api/mqtt/status and the ingestor stats snapshot), not a silently dead watchdog (invisible and undetectable -- the actual Kpa-clawbot#1749 failure). Both previously-red tests now pass: - TestNewAsyncEmit_NeverBlocksWhenWriterStuck_1749 - TestMQTTStallWatchdog_LoopSurvivesStuckWriter_1749 All pre-existing watchdog/liveness/status tests continue to pass unmodified. Co-Authored-By: Claude <noreply@anthropic.com>
Review finding on this PR: runLivenessWatchdog's stop() called
close(done) followed immediately by stopEmit() (closing the async
queue) without waiting for the loop goroutine to actually exit. done
being closed only makes the loop exit on its NEXT select check -- it
does not abort in-flight per-source work already past that check. If
the loop was mid processLivenessTransition -> emit() (actively
sending on the queue) at the exact moment stopEmit() closed it, that
send raced a closed channel and panicked ("send on closed channel").
The existing per-source defer/recover from Kpa-clawbot#1810 catches this panic,
so it does not crash the process -- but it does inflate
WatchdogPanicCount and spam recovery log lines on ordinary,
correctly-sequenced shutdown, which is exactly the kind of noise that
trains operators to ignore that counter.
TestRunLivenessWatchdog_StopDoesNotRaceQueueClose_1749 drives 100
rapid start/stop cycles against an always-stalled source (maximizing
the chance stop() is called while the loop is mid emit()) and asserts
WatchdogPanicCount does not move. RED on this commit: reliably
reproduces dozens of recovered panics per run.
Co-Authored-By: Claude <noreply@anthropic.com>
…review follow-ups
Fixes the race from the previous commit: runLivenessWatchdog's
goroutine now signals its own exit via loopExited, and stop() waits
for that signal before calling stopEmit() (which closes the async
queue). The loop is therefore guaranteed to have fully returned --
including finishing any in-flight emit() call -- before the queue it
sends on can possibly be closed, eliminating the send-on-closed-
channel panic. TestRunLivenessWatchdog_StopDoesNotRaceQueueClose_1749
now passes (100 rapid start/stop cycles, zero panics).
Also addresses two non-blocking review findings on this PR:
- newAsyncEmit's doc comment now explicitly states the goroutine-
leak characteristic when realEmit is genuinely stuck at stop()
time (close(queue) cannot interrupt a blocked write()) and that
the function is intended for once-per-process-lifetime use, not
repeated start/stop cycles against the same realEmit within one
process.
- WatchdogLogDropCount's doc comment now calls out that it is a
raw monotonic counter, not a rate -- external monitoring should
alert on rate-of-increase, not on the absolute value ever being
nonzero or crossing a fixed threshold.
Full ingestor suite verified green across repeated runs; the one
observed flake (TestMQTTStallWatchdog_DisconnectedEscalationThrottled_1749)
is the same pre-existing, timing-sensitive test confirmed reproducible
on unmodified master earlier in review -- unrelated to this change.
Co-Authored-By: Claude <noreply@anthropic.com>
a0e5a06 to
a158676
Compare
|
Thanks for the thorough review — all four points addressed, pushed as 4 separate red/green commits on top of the original squash so the fix history is auditable. carmack (BLOCKER, stop()/queue-close race): Confirmed and fixed. dijkstra (MAJOR, goroutine leak by design): Agreed, and correct that nothing can force a blocked syscall to return early. Expanded munger (MINOR, drop counter observability): Agreed. Doc comment now spells out that kent-beck (MAJOR, red-commit bar): Fair catch, redone properly. The branch is now 4 commits:
Reverting any single Full ingestor suite verified green across repeated runs at each stage. One pre-existing, timing-sensitive test ( |
|
Re-review @ Prior findings — verification
Verdict: APPROVED. Zero must-fix remaining. Ready to merge once CI runs (currently |
Closes the gap left by #1810: that PR added defer/recover around the watchdog per-source work so a panic inside emit cannot kill the loop, but the actual production incident is caused by emit blocking, not panicking.
Root cause
In production
emitislog.Print.log.Print's underlyingwrite()can block indefinitely if the sink is backpressured (Docker JSON-file log driver falling behind under load, a full stderr pipe, journald hiccups, etc.). A blocked syscall is not a panic --recover()does nothing for it.Because emit was called synchronously inside the per-source work, a single stuck
write()froze the entire tick loop forever -- no further source was ever checked and no further tick was ever processed again. This exactly reproduces the original #1749 incident even after #1810 landed: 3 independent MQTT sources going silent within ~60s of each other (one shared dependency -- the watchdog goroutine itself -- died, not 3 independent paho clients), zero WATCHDOG log lines for the rest of the 75-minute window, every other goroutine in the process continuing to run fine (a hang, not a crash), and only a full container restart recovering it.Fix
newAsyncEmitdecouples "decide to log" from "perform the write": the watchdog loop now only ever does a non-blocking channel send. A single background goroutine drains the channel and performs the (potentially blocking) write. If that goroutine itself gets stuck, the bounded queue (256) fills and further sends are dropped -- counted via the newWatchdogLogDropCount, surfaced through/api/mqtt/statusand the ingestor stats snapshot alongsideWatchdogLastTickUnix/WatchdogPanicCount. Worst case under a persistent backpressure event is now lost log lines (visible and counted), not a silently dead watchdog (invisible and undetectable -- the actual #1749 failure).Tests
TestNewAsyncEmit_NeverBlocksWhenWriterStuck_1749-- floods emit() past queue capacity while the writer is permanently blocked; every call must return immediately and drops must be counted.TestMQTTStallWatchdog_LoopSurvivesStuckWriter_1749-- end-to-end, wiresrunLivenessWatchdogLoopexactly as production does (vianewAsyncEmitaround a permanently-blockingrealEmit) with 3 registered sources, reproducing the incident shape and asserting the loop keeps ticking regardless.TestRunLivenessWatchdog_ProductionWiringUsesAsyncEmit_1749-- smoke-tests the real entrypoint starts, ticks, and stops cleanly.WatchdogLogDropCountround-trip tests in both the ingestor stats snapshot and the server's/api/mqtt/statushandler, mirroring the existingWatchdogPanicCountcoverage from fix(mqtt): escalate persistent paho disconnect + recover from emit panic + expose watchdog tick (#1749) #1810.All pre-existing watchdog/liveness tests (#1749, #1810 r1, force-reconnect) continue to pass unmodified; full ingestor suite green (verified 5x consecutive runs for flake-freedom). Note: the server package has pre-existing test-suite-wide flakiness in unrelated
TestHandleNodePaths_*tests (confirmed reproducible on unmodified master too, non-deterministic which subset fails per run) -- unrelated to this change and out of scope here.