#9393·grpc-go

Stop() deadlocks when called after GracefulStop() while a handler is still running

Author: stanhuCreated Sep 2, 2026Updated Sep 15, 2026
LabelsP2Type: Bug

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:

go
// 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.001543666s

Here is the walkthrough.

  1. Stop() and GracefulStop() are the same code path. Both call stop(graceful bool):

https://github.com/grpc/grpc-go/blob/1550d9e0cddb30ce99e61a2102e8294a49461e5e/server.go#L1943-L1952

  1. stop() takes the server mutex and holds it for the rest of the function via defer s.mu.Unlock():

https://github.com/grpc/grpc-go/blob/1550d9e0cddb30ce99e61a2102e8294a49461e5e/server.go#L1966-L1967

  1. While draining connections it releases the mutex. s.cv is a sync.Cond whose locker is s.mu, so s.cv.Wait() atomically unlocks s.mu while waiting:

https://github.com/grpc/grpc-go/blob/1550d9e0cddb30ce99e61a2102e8294a49461e5e/server.go#L1975-L1977

https://github.com/grpc/grpc-go/blob/1550d9e0cddb30ce99e61a2102e8294a49461e5e/server.go#L724

  1. Once connections are drained it waits for handlers while still holding the mutex. handlersWG.Wait() does not touch s.mu, so the defer from step 2 keeps it locked throughout:

https://github.com/grpc/grpc-go/blob/1550d9e0cddb30ce99e61a2102e8294a49461e5e/server.go#L1988-L1990

  1. A concurrent Stop() therefore deadlocks at step 2. Its own s.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 own handlersWG.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:

  1. Release s.mu while waiting in handlersWG.Wait() (step 4), as stop() already does for the cv.Wait() loop in step 3.
  2. Let a concurrent Stop() signal an in-progress graceful stop to abort.