Exit non-zero when the HTTP listener fails (closes #200) #218
Reference in New Issue
Block a user
Delete Branch "issue-200-listen-failure-shutdown"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #200
Problem
Server's fxOnStarthook returnsnilas soon as it spawns the serving goroutine, so a failed listen was only ever discovered inside that goroutine. It logged the error and calleds.cancelFunc(), which nothing outside the goroutine observes: fx reported RUNNING and the process stayed alive with nothing bound. The service was down and looked up, so systemdRestart=on-failureand Docker restart policies never fired.Approach
ServerParamsnow takesfx.Shutdowner. On a listen failure the server logs the error as before and then asks the Shutdowner to stop the app withserver.ListenFailureExitCode(1). Shutdown goes through fx's normal stop sequence, so everyOnStophook — the HTTP drain, the delivery engine, the healthcheck, the DB manager, the database close — still runs. The clean-shutdown path itself is untouched.shutdownOnListenFailure, where it only unwindsserve()'s own wait.Server.exitCodeis removed. Its only writer set it to zero incleanShutdownand its only reader wasserve()'s return value, whichRundiscards; the process status is fx's to decide. Left in place it would have become a real data race, since the new path makescleanShutdownandserve()'s return run concurrently under-race.Regression test
TestListenFailure_ShutsDownTheApp(internal/server/listen_failure_test.go) occupies a kernel-chosen port, starts the wired app on it, and requires a non-zero shutdown signal fromapp.Wait()within two seconds, then requires the stop sequence to complete. It reusesnewTestEnv's wired collaborators rather than stubs, so theServerunder test is the one that ships.It fails without the fix. Reverting only the
http.gohunk:Definition of done
fx.Shutdowner.Shutdown(), process exits non-zeroPORTexits non-zero within a second or so, logging the bind errorTestListenFailure_ShutsDownTheApp, fails without the fixOnStophooks runGate evidence
make check— exit 0.Cache-defeated container build on the rebased head,
docker build --no-cache-filter=lint --no-cache-filter=builder --progress=plain .— exit 0, zero(cached)lines anywhere in the log:The built image was removed; no containers were started.
Manual repro
Two instances of
make build's binary on the samePORT, second one started while the first holds the port:The whole stop sequence, including the database close, runs before the non-zero exit. Before this change the same command sat at
[Fx] RUNNINGindefinitely.Clean shutdown of a healthy instance, unchanged:
TODO.mduntouched, per #112.PASS — satisfies every item of the definition of done in #200; no defects found.
Disclosure: my independent cache-defeated gate on
bfbf416did not go green overall.make testfailed withinternal/handlerstiming out at 90s (TestFailedLogin_LogLineDoesNotTrackUsernameSize, 14 parallel subtests at ~50s each). Not attributable to this PR:internal/handlersis byte-identical betweennextand this head and does not importinternal/server, and I reproduced the identical timeout onorigin/nextwith no change applied (host load average 122 on 48 cores). Everything the change is responsible for was green — lint0 issues.in 122.3s, zero(cached)lines,internal/serverclean under-race, and reverting only theinternal/server/http.gohunk reproduceslisten_failure_test.go:84: listen failure left the app runningexactly as claimed. Gitea CI onbfbf416is green.Forward flag for #201, not a defect here: on this new path
fx.App.Runcallsos.Exit(1)itself, so adeferinmainnever runs — whereas the clean-shutdown path exits 0 by returning frommain, where adeferwould run. ADATA_DIRlock released viadeferinmainwould therefore work ondocker stopand silently leak on listen failure; the release has to be an fxOnStophook.