fix(hosting): keep the API alive when a background service throws - #527
Merged
Conversation
.NET's default BackgroundServiceExceptionBehavior is StopHost: an unhandled exception in any one BackgroundService stops the whole host and the process exits with code 0. A zero exit is a clean shutdown, so no restart policy treats it as a failure — the container simply stays down — and the Critical log line explaining it is never persisted, because the process is already going away. That is how a healthy e2e `api` container vanished ~35s into a run while the identical `api-free` stayed up, leaving nginx to answer HTML to a spec that expected JSON. Both process hosts now call AddResilientBackgroundServices(), which sets the behaviour to Ignore: the faulted loop stops, everything else (including the HTTP surface) keeps serving, and the framework logs the fault at Error — the level ErrorLogChannelLoggerProvider captures into the in-product error log, so the crash is visible instead of dying with the process. The option only covers BackgroundService.ExecuteAsync. Startup-critical work — schema initialization/migrations, the secret/preview/tool backfills, the seeders — is plain IHostedService with the work in StartAsync, which still aborts startup when it throws. An API must not serve traffic against an unmigrated database, and that stays true. Also tee a bounded (200-line) tail of every service's log into the e2e job log, inside a collapsed group. The per-service logs already reached the e2e-stack-logs artifact, but the artifact is not always reachable from wherever the run is triaged, and a container that died takes its "why" with it. Closes #522 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KXC9kTqXPUL8tdgDx7yzWK
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.
Closes #522.
The failure
.NET's default
HostOptions.BackgroundServiceExceptionBehaviorisStopHost: an unhandled exception in any oneBackgroundServicestops the whole host and the process exits with code 0.That zero exit is the trap. It is indistinguishable from an intentional shutdown, so no restart policy treats it as a failure — the container just stays down — and the Critical log line explaining it never gets persisted, because the process is already going away. In #522 a licensed e2e
apicontainer passed its healthcheck, then vanished ~35 s into the run while the identicalapi-freestayed healthy; every subsequent request hit nginx's error page and the setup spec died parsing HTML as JSON, with no evidence of which service threw.What changed
1. A faulted background loop no longer kills the host. Both process hosts (
Proxytrace.Api,Proxytrace.Proxy.Api) now callAddResilientBackgroundServices(), a new extension inProxytrace.Common/Hosting/, which sets the behaviour toIgnore. The faulted loop stops; everything else — including the HTTP surface — keeps serving. The framework logs the fault atError, which is exactly the levelErrorLogChannelLoggerProvidercaptures, so the crash lands in the in-product error log instead of dying with the process.The trade is deliberate: losing one background loop is a degraded feature, losing the host is a total outage.
Ignorekeeps the host alive — it does not restart the loop, so a loop that wants to survive its own transient failures still has to catch them itself (the shapeErrorLogWriterandLicenseCheckService.SafeRunCheckAsyncalready use).Startup-critical work is unaffected, and this is load-bearing: the option only covers
BackgroundService.ExecuteAsync. Schema initialization/migrations (DatabaseInitializationService), the secret/preview/tool backfills and the seeders are all plainIHostedServicewith their work inStartAsync, which still aborts startup when it throws. An API must not serve traffic against an unmigrated database, and that stays true.2. e2e failures are diagnosable from the run page. The
Capture e2e stack logsstep now also tees a bounded 200-line tail of every service into the job log, one collapsed group per service. The per-service logs already reached thee2e-stack-logsartifact, but the artifact is not always reachable from wherever the run is triaged — which is why #522 could not name the throwing service at all.Tests
Three new tests in
Proxytrace.Common.Tests/Hosting/, building a realIHostbecause the behaviour under test is the host's own fault handling:Ignore;BackgroundServicefault leaves the host running with it applied;Verified with the full backend suite (
dotnet test Proxytrace.sln) — cross-cutting change: it touchesProxytrace.Common, both composition roots, and adds a package reference. All green except two pre-existingRedisIngestionStreamIntegrationTestsfailures caused by this environment having no Docker socket; their skip guard misses the throw and is filed separately as #526.Docs
docs/architecture.md— new section on theBackgroundService(loop, non-fatal) vsIHostedService(startup-critical, fatal) split and which to reach for.docs/testing.md— the job-log tails alongside the artifact.manual/admin/error-log.md— an operator-facing warning: a failed background service is recorded here, the deployment stays up, but the loop does not come back until the API is restarted.CHANGELOG.mdunder[Unreleased]→ Fixed.Generated by Claude Code