Skip to content

app: close only the components Init reached - #770

Merged
cheggaaa merged 3 commits into
mainfrom
cheggaaa/syn-177-app-close-uninitialized
Aug 31, 2026
Merged

app: close only the components Init reached#770
cheggaaa merged 3 commits into
mainfrom
cheggaaa/syn-177-app-close-uninitialized

Conversation

@cheggaaa

@cheggaaa cheggaaa commented Aug 29, 2026

Copy link
Copy Markdown
Member

Problem

app.Close walks every registered component in reverse. After a failed Start, everything past the failing component never had Init called, 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 keycan't init service 'common.acl.syncacl') hits this on every load attempt:

space.Init -> app.Start          # Init fails at syncacl, closeServices closes [0, i]
space.Close -> app.Close         # closes ALL components, incl. [i+1, end] — never Init'd
  • linux/amd64: instant SIGSEGV in headsync.(*headSync).Close (h.syncer nil). Guard that one and it moves to objecttreebuilder.(*treeBuilder).Close.
  • darwin/arm64: app.Close never returns; the StopDeadline watchdog fires panic("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:

Init fails Run fails
before close=2 close=2
failing close=2 close=2
after init=0 close=1 ← nil deref close=1

Three defects: components closed without Init, components closed twice, and no mutual exclusion between Start and Close.

Fix

A dedicated lifecycleMu makes Start and Close mutually exclusive and guards four fields:

  • started / closedStart runs at most once (ErrAppAlreadyStarted); Start on a closed app returns ErrAppClosed; Close is idempotent.
  • initialized — the Init high-water mark, advanced only after Init returns, so a Close never sees a component that is still initialising.
  • closedUpTo — how far Start's own error path already closed. Close covers exactly [closedUpTo, initialized).

mu cannot serve here: a component's Init calls MustComponent, which takes mu.RLock, so Start can never hold mu's write lock. That is also why atomics alone were not enough — see below.

Net effect on component-visible behaviour: no component sees a Close it did not see before. The Init'd-but-never-Run tail after a Run failure is still closed by Close, 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:

  • every Start return now stops the watchdog — a failed Start leaked a goroutine and a timer, and that goroutine ranged stopStat's map while a concurrent Close wrote it (concurrent map read and map write)
  • the start warning reports startStat, not stopStat
  • currentComponentStarting is assigned, so components start in progress finally 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:

  1. the high-water mark was stored before Init ran, so a concurrent Close closed a mid-Init component — the original nil deref, via the concurrency the guards claimed to support;
  2. a Close landing before Start consumed the single cleanup token; Start then Init'd everything and nothing could ever close it.

Both are "Start and Close must 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 on main (TestCloseWaitsForInFlightStart panics there with the original nil deref):

  • TestStartInitFailureSkipsUninitialized
  • TestStartRunFailureClosesEveryInitialized
  • TestCloseWaitsForInFlightStartClose blocks on a component parked in Init
  • TestStartAfterCloseIsRejected
  • TestCloseWithoutStartClosesNothing — the case the initialized bound protects
  • TestConcurrentCloseRunsOnce / TestConcurrentStartRunsOnce — 200 rounds each, asserting the invariant (Init'd ⇒ closed exactly once, not Init'd ⇒ never closed) rather than just an upper bound

go test ./app/... -race and go test ./commonspace/... (21/21) green.

End-to-end

Reproduced by pointing a populated data dir at a nodeconf with a different networkId, then booting.

before after
unloadable space present dies (SIGSEGV / app.Close timeout) alive at t+95s, health 200, goroutines flat at 114, 0 panics, 0 leaked closes
shutdown, same state wedged 60s then panic 1.05s
correct nodeconf space active, writes 201, no regression

Out of scope

Left alone deliberately: startStat / stopStat / currentComponentStopping are still written under mu.RLock() (pre-existing), and StartStat()/StopStat() take mu.Lock() while Start/Close hold mu.RLock() and components re-enter it via MustComponent — a stats read concurrent with Start can deadlock. Both predate this change and want their own PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KipFb7V7Ys4AU7CszpHRiZ

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
@github-actions

github-actions Bot commented Aug 29, 2026

Copy link
Copy Markdown

New Coverage 60.2% of statements
Patch Coverage 91.1% of changed statements (41/45)

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
@cheggaaa
cheggaaa merged commit 00d397c into main Aug 31, 2026
4 checks passed
@cheggaaa
cheggaaa deleted the cheggaaa/syn-177-app-close-uninitialized branch August 31, 2026 18:09
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 31, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants