You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Existing open issues along with etcd frequently asked questions have been checked and this is not a duplicate.
What happened?
A Maintenance/Status gRPC call processed while the member is shutting down (e.g. after a ConfChangeRemoveNode removing that member) can crash the process with a SIGSEGV instead of letting it exit cleanly.
The shutdown sequence in the run goroutine's defer (server/etcdserver/server.go) closes s.stopping, waits for attached goroutines, and then calls Cleanup(), which closes the backend (s.be.Close()) without any synchronization against in-flight readers. A concurrent Status handler that reaches EtcdServer.StorageVersion() in that window calls schema.DetectSchemaVersion() on the closed backend. The backend's final commit (CommitAndStop) has already reset the shared read transaction to nil, so backend.(*baseReadTx).UnsafeRange() calls (*bolt.Tx).Bucket() on a nil *bolt.Tx and the process dies:
Note on discoverability: on current main this crash is masked in the Status path because the same handler panics earlier in RaftCluster.IsLocalMemberLearner (#21966). With that panic fixed (#22188), the reproducer for #21966 immediately surfaces this second, independent crash in the same shutdown window.
What did you expect to happen?
The removed/stopping member exits cleanly with code 0. Status calls racing with shutdown should either succeed or fail with a gRPC error, never crash the server.
How can we reproduce it (as minimally and precisely as possible)?
Use the reproducer program from #21966 unchanged (3-member cluster, 64 goroutines hammering Maintenance/Status on a persistent gRPC connection to m3, then removing m3 via m1), against a build that includes the #22188 fix (otherwise the IsLocalMemberLearner panic fires first and masks this one):
m3 exits with code 2 and the SIGSEGV stack above in m3.log (reproduced on the first attempt in my runs).
With StorageVersion prevented from reading a closed backend, the same reproducer gives 3/3 clean exits with code 0 (~300k Status calls per run).
Anything else we need to know?
Root cause is the unsynchronized ordering in the run goroutine's defer: close(s.stopping) → s.wg.Wait() → s.Cleanup() (which calls s.be.Close()), while gRPC handlers may still be executing. StorageVersion() takes s.bemu.RLock(), but Cleanup() does not take bemu before closing the backend, so the lock does not protect readers from the close.
Internal callers of StorageVersion (e.g. monitorStorageVersion) are safe: they run via GoAttach and are waited on by s.wg.Wait() before Cleanup() runs. Only externally driven paths (the Status RPC via serverVersionAdapter.GetStorageVersion) race.
The other backend accesses in the Status handler (Backend().Size(), SizeInUse()) read atomics and do not crash.
A fix is prepared: close the backend under s.bemu in Cleanup() and check s.stopping under the bemu read lock in StorageVersion(), returning nil (the handler already omits the field for a nil version).
Etcd version (please run commands below)
Details
$ etcd --versionetcd Version: 3.8.0-alpha.0Git SHA: Not provided (use ./build instead of go build)Go Version: go1.26.5Go OS/Arch: darwin/arm64
Built from main (14bd0ca) plus #22188. The affected code (Cleanup/StorageVersion ordering) is unchanged on main; the same pattern exists on the release-3.6 branch (StorageVersion reads under bemu.RLock while Cleanup closes the backend without taking bemu), though there the Status path is shadowed by the #21966 panic. release-3.5 is unaffected: it predates the StorageVersion/schema machinery entirely.
Etcd configuration (command line flags or environment variables)
Details
Defaults as set by the #21966 reproducer (3 members, plain HTTP, --log-format console); no non-default tuning is required.
Bug report criteria
What happened?
A
Maintenance/StatusgRPC call processed while the member is shutting down (e.g. after aConfChangeRemoveNoderemoving that member) can crash the process with a SIGSEGV instead of letting it exit cleanly.The shutdown sequence in the run goroutine's defer (
server/etcdserver/server.go) closess.stopping, waits for attached goroutines, and then callsCleanup(), which closes the backend (s.be.Close()) without any synchronization against in-flight readers. A concurrentStatushandler that reachesEtcdServer.StorageVersion()in that window callsschema.DetectSchemaVersion()on the closed backend. The backend's final commit (CommitAndStop) has already reset the shared read transaction tonil, sobackend.(*baseReadTx).UnsafeRange()calls(*bolt.Tx).Bucket()on a nil*bolt.Txand the process dies:Note on discoverability: on current
mainthis crash is masked in theStatuspath because the same handler panics earlier inRaftCluster.IsLocalMemberLearner(#21966). With that panic fixed (#22188), the reproducer for #21966 immediately surfaces this second, independent crash in the same shutdown window.What did you expect to happen?
The removed/stopping member exits cleanly with code 0.
Statuscalls racing with shutdown should either succeed or fail with a gRPC error, never crash the server.How can we reproduce it (as minimally and precisely as possible)?
Use the reproducer program from #21966 unchanged (3-member cluster, 64 goroutines hammering
Maintenance/Statuson a persistent gRPC connection to m3, then removing m3 via m1), against a build that includes the #22188 fix (otherwise theIsLocalMemberLearnerpanic fires first and masks this one):main+ membership: don't panic in IsLocalMemberLearner when local member is absent #22188 (go build -o bin/etcd ./server).IsLocalMemberLearnerwhenMaintenance/Statusis called concurrently with member removal #21966 with that binary onPATH.m3.log(reproduced on the first attempt in my runs).With
StorageVersionprevented from reading a closed backend, the same reproducer gives 3/3 clean exits with code 0 (~300k Status calls per run).Anything else we need to know?
close(s.stopping)→s.wg.Wait()→s.Cleanup()(which callss.be.Close()), while gRPC handlers may still be executing.StorageVersion()takess.bemu.RLock(), butCleanup()does not takebemubefore closing the backend, so the lock does not protect readers from the close.StorageVersion(e.g.monitorStorageVersion) are safe: they run viaGoAttachand are waited on bys.wg.Wait()beforeCleanup()runs. Only externally driven paths (theStatusRPC viaserverVersionAdapter.GetStorageVersion) race.Statushandler (Backend().Size(),SizeInUse()) read atomics and do not crash.s.bemuinCleanup()and checks.stoppingunder thebemuread lock inStorageVersion(), returning nil (the handler already omits the field for a nil version).Etcd version (please run commands below)
Details
Built from
main(14bd0ca) plus #22188. The affected code (Cleanup/StorageVersionordering) is unchanged onmain; the same pattern exists on the release-3.6 branch (StorageVersionreads underbemu.RLockwhileCleanupcloses the backend without takingbemu), though there the Status path is shadowed by the #21966 panic. release-3.5 is unaffected: it predates theStorageVersion/schema machinery entirely.Etcd configuration (command line flags or environment variables)
Details
Defaults as set by the #21966 reproducer (3 members, plain HTTP,
--log-format console); no non-default tuning is required.