Stop() deadlocks when called after GracefulStop() while a handler is still running
Use case(s) / How is this problem addressed in other systems?
The recommended pattern for a bounded graceful shutdown is: call GracefulStop(), and if it does not finish within a deadline, call Stop() to force-terminate the remaining RPCs (the SIGTERM-then-SIGKILL model). This was also discussed in #8480.
However, calling Stop() after (or concurrently with) GracefulStop() can deadlock an the server mutex and block until the handler returns on its own. In production, we observed 15-minute delays.
What version of gRPC are you using?
v1.83.1
What did you do?
Run GracefulStop() in a goroutine, let the client connections drain, then call
Stop(), while one RPC handler is still running and does not return promptly on
context cancellation.
Minimal reproduction:
// Standalone reproduction of the gRPC-go GracefulStop/Stop mutex deadlock.
//
// go mod init grpcrepro
// go get google.golang.org/[email protected]
// go run .
//
// Expected (buggy) output: "REPRODUCED: Stop() is still blocked after 15s".
package main
import (
"context"
"log"
"net"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
)
type blockingHealth struct {
healthpb.UnimplementedHealthServer
started chan struct{}
release chan struct{}
}
// Check ignores its context, standing in for a handler that does not return
// promptly on cancellation (e.g. a subprocess run under context.WithoutCancel).
func (h *blockingHealth) Check(context.Context, *healthpb.HealthCheckRequest) (*healthpb.HealthCheckResponse, error) {
close(h.started)
<-h.release
return &healthpb.HealthCheckResponse{}, nil
}
func main() {
s := grpc.NewServer(grpc.WaitForHandlers(false))
h := &blockingHealth{started: make(chan struct{}), release: make(chan struct{})}
healthpb.RegisterHealthServer(s, h)
lis, err := net.Listen("tcp", "localhost:0")
if err != nil {
log.Fatal(err)
}
go func() { _ = s.Serve(lis) }()
conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatal(err)
}
go func() { _, _ = healthpb.NewHealthClient(conn).Check(context.Background(), &healthpb.HealthCheckRequest{}) }()
<-h.started // handler is running and counted in handlersWG
log.Println("handler running; starting GracefulStop()")
go s.GracefulStop()
// The client connection drains during the "grace period"; GracefulStop then
// advances past its conns loop into handlersWG.Wait(), holding the server mutex.
time.Sleep(200 * time.Millisecond)
_ = conn.Close()
time.Sleep(time.Second)
log.Println("grace period elapsed; calling Stop()")
done := make(chan struct{})
start := time.Now()
go func() { s.Stop(); close(done) }()
select {
case <-done:
log.Printf("NOT reproduced: Stop() returned in %s", time.Since(start))
case <-time.After(15 * time.Second):
log.Printf("REPRODUCED: Stop() is still blocked after %s (deadlocked on the server mutex held by GracefulStop)", time.Since(start))
}
close(h.release) // let the handler finish so the process can exit cleanly
<-done
log.Printf("Stop() finally returned after %s", time.Since(start))
}What did you expect to see?
Stop() returns promptly, force-terminating the shutdown even though a handler is still running. It is documented to immediately close connections and cancel active RPCs).
What did you see instead?
Stop() blocks for as long as the handler runs:
2026/09/02 14:17:19 handler running; starting GracefulStop()
2026/09/02 14:17:20 grace period elapsed; calling Stop()
2026/09/02 14:17:35 REPRODUCED: Stop() is still blocked after 15.00107675s (deadlocked on the server mutex held by GracefulStop)
2026/09/02 14:17:35 Stop() finally returned after 15.001543666sHere is the walkthrough.
Stop()andGracefulStop()are the same code path. Both callstop(graceful bool):
https://github.com/grpc/grpc-go/blob/1550d9e0cddb30ce99e61a2102e8294a49461e5e/server.go#L1943-L1952
stop()takes the server mutex and holds it for the rest of the function viadefer s.mu.Unlock():
https://github.com/grpc/grpc-go/blob/1550d9e0cddb30ce99e61a2102e8294a49461e5e/server.go#L1966-L1967
- While draining connections it releases the mutex.
s.cvis async.Condwhose locker iss.mu, sos.cv.Wait()atomically unlockss.muwhile waiting:
https://github.com/grpc/grpc-go/blob/1550d9e0cddb30ce99e61a2102e8294a49461e5e/server.go#L1975-L1977
https://github.com/grpc/grpc-go/blob/1550d9e0cddb30ce99e61a2102e8294a49461e5e/server.go#L724
- Once connections are drained it waits for handlers while still holding the mutex.
handlersWG.Wait()does not touchs.mu, so thedeferfrom step 2 keeps it locked throughout:
https://github.com/grpc/grpc-go/blob/1550d9e0cddb30ce99e61a2102e8294a49461e5e/server.go#L1988-L1990
- A concurrent
Stop()therefore deadlocks at step 2. Its owns.mu.Lock()blocks on the mutex the graceful stop holds in step 4, and cannot proceed until every handler finishes.WaitForHandlers(false)does not help:Stop()never reaches its ownhandlersWG.Wait(); it is stuck on the mutex.
Note that this might be difficult to reproduce because connections must drain first, so the graceful stop moves from the mutex-releasing cv.Wait() loop (step 3) into handlersWG.Wait() (step 4). If a connection is still open when Stop() runs, the graceful stop is parked in step 3 with the mutex released, so Stop() acquires it and proceeds normally.
Possible fixes:
- Release
s.muwhile waiting inhandlersWG.Wait()(step 4), asstop()already does for thecv.Wait()loop in step 3. - Let a concurrent
Stop()signal an in-progress graceful stop to abort.
Source: grpc/grpc-go