app: close only the components Init reached - #770
Merged
Conversation
app.Close walked every registered component, including those after a failed Init whose fields are still nil — closing them dereferences nil and takes the process down. It also re-closed the prefix that Start's own error path had already closed. Lifecycle state is now three atomics: Start runs once, Close runs once, and Close is bounded by how far Init got. mu cannot serve here — a component's Init calls MustComponent, which takes mu.RLock, so Start can never hold the write lock. A Run failure now closes the whole Init'd range instead of [0, i], so components that were Init'd but never Run still release their Init-time resources. SYN-177 Claude-Session: https://claude.ai/code/session_01KipFb7V7Ys4AU7CszpHRiZ
Coverage provided by https://github.com/seriousben/go-patch-cover-action |
Review of the atomics-only version found two holes it could not close. The Init high-water mark was advanced before Init ran, so a Close racing a Start closed a component whose Init had not finished — the same nil deref this change exists to prevent. And a Close landing before Start consumed the single cleanup token: Start then Init'd every component and none could ever be closed. Both need Start and Close to be mutually exclusive, which three independent atomics cannot express. A dedicated lifecycle mutex guards started/closed and the two bounds; Close waits for an in-flight Start, and Start on a closed app returns ErrAppClosed. Close now covers [closedUpTo, initialized) — what Start's own error path already closed is not closed again, and the Init'd-but-never-Run tail after a Run failure is closed by Close as before rather than by Start, so no component sees a Close it did not previously see. Also in the touched function: every Start return now stops the watchdog (a failed Start leaked a goroutine and a timer, and that goroutine read stopStat's map while a concurrent Close wrote it), the start warning reports startStat instead of stopStat, and currentComponentStarting is assigned so the warning names the component it is waiting on. Claude-Session: https://claude.ai/code/session_01KipFb7V7Ys4AU7CszpHRiZ
They shared no harness with the tests already in the package: the new file carried its own component double while app_test.go had newTestService. testComponent gains what the second double existed for — Init/Close counters, a Run-only error, a hook to park a component mid-Init, and a field populated in Init and dereferenced in Close, so closing a component whose Init never ran panics in tests exactly as it does in production. newTestService now builds each component in place; it used to copy a struct literal, which vet rejects once that struct holds a mutex. Claude-Session: https://claude.ai/code/session_01KipFb7V7Ys4AU7CszpHRiZ
requilence
approved these changes
Aug 31, 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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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.
Problem
app.Closewalks every registered component in reverse. After a failedStart, everything past the failing component never hadInitcalled, so its fields are nil — closing it dereferences nil and takes the whole process down.A space whose ACL can't be built (a data dir carried across networks:
acceptor <K> is not the network key→can't init service 'common.acl.syncacl') hits this on every load attempt:SIGSEGVinheadsync.(*headSync).Close(h.syncernil). Guard that one and it moves toobjecttreebuilder.(*treeBuilder).Close.app.Closenever returns; theStopDeadlinewatchdog firespanic("app.Close timeout")60s later.Either way one unloadable space kills the process, and the caller's retry re-arms it every few seconds.
Measured on
main, three components with the middle one failing:close=2close=2close=2close=2init=0 close=1← nil derefclose=1Three defects: components closed without
Init, components closed twice, and no mutual exclusion betweenStartandClose.Fix
A dedicated
lifecycleMumakesStartandClosemutually exclusive and guards four fields:started/closed—Startruns at most once (ErrAppAlreadyStarted);Starton a closed app returnsErrAppClosed;Closeis idempotent.initialized— the Init high-water mark, advanced only afterInitreturns, so aClosenever sees a component that is still initialising.closedUpTo— how farStart's own error path already closed.Closecovers exactly[closedUpTo, initialized).mucannot serve here: a component'sInitcallsMustComponent, which takesmu.RLock, soStartcan never holdmu's write lock. That is also why atomics alone were not enough — see below.Net effect on component-visible behaviour: no component sees a
Closeit did not see before. TheInit'd-but-never-Runtail after aRunfailure is still closed byClose, exactly as today; it is simply no longer closed twice, and never closed early.Also in the touched function, all three pre-existing and all three load-bearing for this bug's diagnosis:
Startreturn now stops the watchdog — a failedStartleaked a goroutine and a timer, and that goroutinerangedstopStat's map while a concurrentClosewrote it (concurrent map read and map write)startStat, notstopStatcurrentComponentStartingis assigned, socomponents start in progressfinally names the component it is waiting on instead of""Why not atomics
The first version of this PR used three CAS'd atomics. Review found two holes that no amount of atomics closes, both reproduced as tests before rework:
Initran, so a concurrentCloseclosed a mid-Initcomponent — the original nil deref, via the concurrency the guards claimed to support;Closelanding beforeStartconsumed the single cleanup token;StartthenInit'd everything and nothing could ever close it.Both are "
StartandClosemust not interleave", which is a mutual-exclusion property, not a set of independent flags.Tests
app/app_test.go(folded into the package's existing tests and harness), green under-race; the behavioural ones fail onmain(TestCloseWaitsForInFlightStartpanics there with the original nil deref):TestStartInitFailureSkipsUninitializedTestStartRunFailureClosesEveryInitializedTestCloseWaitsForInFlightStart—Closeblocks on a component parked inInitTestStartAfterCloseIsRejectedTestCloseWithoutStartClosesNothing— the case theinitializedbound protectsTestConcurrentCloseRunsOnce/TestConcurrentStartRunsOnce— 200 rounds each, asserting the invariant (Init'd ⇒ closed exactly once, not Init'd ⇒ never closed) rather than just an upper boundgo test ./app/... -raceandgo test ./commonspace/...(21/21) green.End-to-end
Reproduced by pointing a populated data dir at a nodeconf with a different
networkId, then booting.app.Close timeout)active, writes 201, no regressionOut of scope
Left alone deliberately:
startStat/stopStat/currentComponentStoppingare still written undermu.RLock()(pre-existing), andStartStat()/StopStat()takemu.Lock()whileStart/Closeholdmu.RLock()and components re-enter it viaMustComponent— a stats read concurrent withStartcan deadlock. Both predate this change and want their own PR.🤖 Generated with Claude Code
https://claude.ai/code/session_01KipFb7V7Ys4AU7CszpHRiZ