GH-3111: contain fatal recovery-path failures so a poison message can't crash the host#3113
Merged
Merged
Conversation
…'t crash the host When a single inbound envelope fails unrecoverably during processing/deserialization, HandlerPipeline.InvokeAsync's last-resort `catch (Exception e)` did allocating recovery work — `channel.CompleteAsync(envelope)` then `Logger.LogException(...)` — with no protection. When the original failure is resource exhaustion (e.g. an OutOfMemoryException from an unbounded deserialization), that recovery re-allocates at the memory ceiling and re-throws. The re-thrown exception escapes InvokeAsync, faults the receiver loop, stops the listener, and the host exits cleanly (exit code 0). An orchestrator (k8s) reads exit 0 as a clean shutdown and restarts straight into the same un-acked poison message → permanent CrashLoopBackOff from one bad message. Move the recovery into a guarded, allocation-safe helper (RecoverFromFailedProcessingAsync) that does the exact same ack + log on the normal path, but contains any failure of the recovery work itself so it can never escape InvokeAsync. The contained log is itself fully guarded (logging allocates too) and reports the fatal/cascading nature distinctly. The host stays alive and keeps processing other messages; the still-un-acked envelope is left for the broker to redeliver / dead-letter via its own max-receive policy. This is defense-in-depth: it does NOT fix the unbounded allocation itself (the originating serializer should bound its buffers), only keeps one poison message from taking the host down. Mirrors the existing OOM-as-fatal precedent in EncryptingMessageSerializer. Tests: handler_pipeline_failure_containment pins that the recovery never throws when CompleteAsync OOMs, when exception logging OOMs, and even when the contained log itself OOMs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jun 15, 2026
This was referenced Jul 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hardens #3111. Scope is deliberately narrow per the issue: not fixing the unbounded allocation itself, just keeping a single un-recoverable envelope from taking the whole host down.
The trap (traced against
main)HandlerPipeline.InvokeAsync's last-resortcatch (Exception e)does allocating recovery —channel.CompleteAsync(envelope)thenLogger.LogException(...)— with no protection:When the original failure is resource exhaustion (an
OutOfMemoryExceptionfrom an unbounded deserialization — the field report was a Brotli decompression bomb, JasperFx/ProductSupport#11), that recovery re-allocates at the memory ceiling and re-throws. The re-thrown OOM escapesInvokeAsync, faults the receiver loop, stops the listener, and the host exits cleanly (code 0). Kubernetes reads exit 0 as a clean shutdown and restarts straight into the same un-acked poison message → permanent CrashLoopBackOff from one bad message. (This is not the circuit breaker — that's opt-in and needs 10 failures; this fires on a single message.)The fix (issue direction #2 — "guard the last-resort catch")
Move the recovery into a guarded, allocation-safe helper. The normal path is byte-for-byte the same ack + log; the only behavioral change is that a failure of the recovery work itself is now contained instead of escaping the pipeline:
CompleteAsync/LogExceptionthrow (typically a re-thrown OOM), the helper swallows it so it can never fault the receiver loop and silently stop the host.Mirrors the existing OOM-as-fatal precedent in
EncryptingMessageSerializer(is not OutOfMemoryException).Tests
handler_pipeline_failure_containmentpins the guarantee —RecoverFromFailedProcessingAsyncnever throws when:CompleteAsyncthrowsOutOfMemoryException,OutOfMemoryException,OutOfMemoryException,and the happy path still acks + logs exactly as before.
99 pipeline-adjacent CoreTests pass; full
wolverine.slnxRelease build clean.Deliberately out of scope
The issue's broader directions — a non-zero process exit on fatal conditions, a poison-counter, and a generic max-inbound-deserialized-size guard — are larger behavioral/policy changes left for a follow-up. This PR is the minimal "keep the host alive" hardening.
🤖 Generated with Claude Code