Skip to content

etcdserver: don't read storage version from a closed backend#22190

Open
jojinkb wants to merge 1 commit into
etcd-io:mainfrom
jojinkb:fix-status-after-backend-close
Open

etcdserver: don't read storage version from a closed backend#22190
jojinkb wants to merge 1 commit into
etcd-io:mainfrom
jojinkb:fix-status-after-backend-close

Conversation

@jojinkb

@jojinkb jojinkb commented Jul 25, 2026

Copy link
Copy Markdown

Fixes #22189

What this PR does

Fixes a SIGSEGV when a Maintenance/Status gRPC call races with server shutdown (e.g. the member being removed from the cluster).

The run goroutine's shutdown defer closes s.stopping and then calls Cleanup(), which closes the backend without synchronizing with in-flight readers. A concurrent Status handler that reaches EtcdServer.StorageVersion() in that window calls schema.DetectSchemaVersion() on the closed backend, whose shared read transaction has already been reset to nil by the final CommitAndStop, and backend.(*baseReadTx).UnsafeRange() dereferences the nil *bolt.Tx:

SIGSEGV in bbolt.(*Tx).Bucket
  <- backend.(*baseReadTx).UnsafeRange   (server/storage/backend/read_tx.go:103)
  <- schema.UnsafeReadStorageVersion
  <- schema.DetectSchemaVersion
  <- EtcdServer.StorageVersion           (server/etcdserver/server.go:2193)
  <- serverVersionAdapter.GetStorageVersion
  <- maintenanceServer.Status

How it is fixed

Two small changes in server/etcdserver/server.go:

  1. Cleanup() now closes the backend while holding s.bemu (the mutex StorageVersion already takes for reading, because applySnapshot can swap the backend instance).
  2. StorageVersion() checks s.stopping after acquiring s.bemu.RLock() and returns nil when the server is stopping.

Since s.stopping is closed strictly before Cleanup() runs, the lock gives the needed happens-before edge: a reader that does not observe stopping as closed cannot have its backend closed while it holds the read lock, and a reader that acquires the lock after the close observes stopping as closed and returns nil. The Status handler already handles a nil storage version by omitting the field, and the internal caller (monitorStorageVersion) exits on s.stopping before Cleanup() runs, so no behavior changes outside the shutdown window.

Relationship to #21966 / #22188

This crash was discovered while verifying the fix for #21966: on current main it is masked in the Status path because the same handler panics earlier in RaftCluster.IsLocalMemberLearner. With #22188 applied, the #21966 reproducer immediately surfaces this second, independent crash. This change is standalone and does not depend on #22188.

Testing


This change was developed with AI assistance (Claude Code); I have reviewed and tested it.

When a member is being removed (or is otherwise shutting down), the run
goroutine's defer closes s.stopping and then calls Cleanup(), which
closes the backend without any synchronization against in-flight
readers. A concurrent Maintenance Status RPC that reaches
EtcdServer.StorageVersion() in that window calls
schema.DetectSchemaVersion() on the closed backend, whose read
transaction has already been reset to nil, and crashes the process:

  SIGSEGV in bbolt.(*Tx).Bucket
    <- backend.(*baseReadTx).UnsafeRange
    <- schema.UnsafeReadStorageVersion
    <- schema.DetectSchemaVersion
    <- EtcdServer.StorageVersion
    <- maintenanceServer.Status

Close the backend while holding the bemu lock in Cleanup(), and check
s.stopping under the bemu read lock in StorageVersion(). Since stopping
is closed strictly before Cleanup() runs, a reader that does not
observe stopping as closed is guaranteed the backend cannot be closed
while it holds the read lock, and a reader that acquires the lock after
the backend was closed observes stopping as closed and returns nil,
which the Status handler already tolerates by omitting the
storageVersion field.

Signed-off-by: Jojin <jojin.kb@gmail.com>
@kubernetes-prow

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: jojinkb
Once this PR has been reviewed and has the lgtm label, please assign spzala for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@kubernetes-prow

Copy link
Copy Markdown

Hi @jojinkb. Thanks for your PR.

I'm waiting for a etcd-io member to verify that this patch is reasonable to test. If it is, they should reply with /ok-to-test on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work.

Regular contributors should join the org to skip this step.

Once the patch is verified, the new status will be reflected by the ok-to-test label.

I understand the commands that are listed here.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

Maintenance Status RPC can SIGSEGV a member during shutdown: StorageVersion reads from a closed backend

1 participant