#9330·grpc-go

Deadlock over unix socket with concurrent unary RPCs on one ClientConn (regression in v1.82.1)

Author: chrislusfCreated Aug 18, 2026Updated Sep 15, 2026
LabelsP0

What version of gRPC are you using?

v1.82.1 through v1.84.0-dev. v1.82.0 and earlier are unaffected.

What version of Go are you using (go version)?

go1.26.4

What operating system (Linux, Windows, …) and version?

Reproduced on both linux/arm64 (golang:1.26 container) and darwin/arm64.

What did you do?

One ClientConn over a unix domain socket, N goroutines each looping unary RPCs. All gRPC options are defaults. Repro is a single file with no codegen (raw []byte codec + hand-written ServiceDesc), attached below.

go run . -c 64        # ~660k RPCs, fine
go run . -c 512       # deadlocks within a second, permanently
go run . -tcp -c 512  # ~900k RPCs, fine

What did you expect to see?

RPCs keep completing.

What did you see instead?

Permanent deadlock — no RPC ever completes again. Both directions of the connection stop at once:

client  http2Client.reader        -> controlBuffer.throttle
server  http2Server.HandleStreams -> controlBuffer.throttle
both    loopyWriter               -> blocked in net.(*conn).Write

Each side stopped reading because its writer is blocked; each writer is blocked because the peer stopped reading. It never recovers.

Bisect:

version result
v1.82.0 665,369 RPCs, no deadlock
v1.82.1 deadlock
v1.83.0 deadlock
v1.84.0-dev deadlock

Analysis

The change is in internal/transport/controlbuf.go between v1.82.0 and v1.82.1. v1.82.0 had:

go
const maxQueuedTransportResponseFrames = 50
func (*registerStream) isTransportResponseFrame() bool { return false }

v1.82.1 replaced this with throttledItem, which registerStream and cleanupStream now embed, so both count toward maxQueuedControlBufferItems (default 100).

I instrumented controlBuffer.executeAndPut to dump the queue composition at the instant throttling latches. The budget is consumed almost entirely by those two newly-counted types:

latch queued=265 throttled=100 limit=100 : cleanupStream=100 clientHeaders=83 dataFrame=82
latch queued=100 throttled=100 limit=100 : registerStream=98 incomingWindowUpdate=1 ping=1
latch queued=211 throttled=100 limit=100 : cleanupStream=97 clientHeaders=55 dataFrame=56 outgoingWindowUpdate=2 ping=1

registerStream and cleanupStream are per-RPC bookkeeping that produce no outbound wire traffic. Throttling reads on their account means any connection with enough concurrent RPCs latches throttling in both directions simultaneously, and then neither peer can drain the other. Window updates and pings — the frames the throttle was designed for — are 1–2 items.

Unix sockets are what make it reachable: their send buffers are small and don't autotune, so loopyWriter blocks in Write readily. On TCP loopback the buffers grow enough that it effectively never happens.

GRPC_GO_EXPERIMENTAL_CONTROL_BUFFER_THROTTLE_LIMIT raises the concurrency required but does not remove the failure mode.

This is not theoretical — it deadlocks SeaweedFS's S3 gateway, whose all-in-one mode routes local gRPC over unix sockets. It surfaced as self-hosted Sentry hanging under concurrent uploads.

Repro (single file, go mod init + go get google.golang.org/[email protected])
go
// Deadlock repro: one ClientConn over a unix socket + concurrent unary RPCs.
// All grpc options are defaults. A raw []byte codec and a hand-written
// ServiceDesc avoid any codegen, so this is a single self-contained file.
//
//	go run . -c 64    # completes normally
//	go run . -c 512   # deadlocks permanently within a second
package main

import (
	"context"
	"flag"
	"fmt"
	"net"
	"os"
	"runtime"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/encoding"
)

var (
	concurrency = flag.Int("c", 512, "concurrent unary RPCs sharing one ClientConn")
	duration    = flag.Duration("d", 20*time.Second, "max run time")
	stallAfter  = flag.Duration("stall", 8*time.Second, "declare deadlock after this long with no completions")
	useTCP      = flag.Bool("tcp", false, "use TCP loopback instead of a unix socket (does not deadlock)")
)

type rawCodec struct{}

func (rawCodec) Marshal(v any) ([]byte, error) { return *(v.(*[]byte)), nil }
func (rawCodec) Unmarshal(d []byte, v any) error {
	b := make([]byte, len(d))
	copy(b, d)
	*(v.(*[]byte)) = b
	return nil
}
func (rawCodec) Name() string { return "raw" }

const svc, method = "repro.Echo", "Call"

func main() {
	flag.Parse()
	encoding.RegisterCodec(rawCodec{})

	payload := make([]byte, 1024)
	sd := grpc.ServiceDesc{
		ServiceName: svc,
		HandlerType: (*any)(nil),
		Methods: []grpc.MethodDesc{{
			MethodName: method,
			Handler: func(_ any, ctx context.Context, dec func(any) error, _ grpc.UnaryServerInterceptor) (any, error) {
				var in []byte
				if err := dec(&in); err != nil {
					return nil, err
				}
				out := payload
				return &out, nil
			},
		}},
	}

	network, addr := "unix", fmt.Sprintf("/tmp/grpc-deadlock-%d.sock", os.Getpid())
	if *useTCP {
		network, addr = "tcp", "127.0.0.1:0"
	} else {
		os.Remove(addr)
		defer os.Remove(addr)
	}
	lis, err := net.Listen(network, addr)
	if err != nil {
		panic(err)
	}
	srv := grpc.NewServer(grpc.ForceServerCodec(rawCodec{}))
	srv.RegisterService(&sd, new(any))
	go srv.Serve(lis)

	opts := []grpc.DialOption{
		grpc.WithTransportCredentials(insecure.NewCredentials()),
		grpc.WithDefaultCallOptions(grpc.ForceCodec(rawCodec{})),
	}
	target := lis.Addr().String()
	if !*useTCP {
		sock := addr
		opts = append(opts, grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
			var d net.Dialer
			return d.DialContext(ctx, "unix", sock)
		}))
		target = "passthrough:///unix"
	}
	conn, err := grpc.NewClient(target, opts...)
	if err != nil {
		panic(err)
	}

	fmt.Printf("transport=%s concurrency=%d\n", network, *concurrency)

	var ops atomic.Int64
	ctx, cancel := context.WithTimeout(context.Background(), *duration)
	defer cancel()

	var wg sync.WaitGroup
	for i := 0; i < *concurrency; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			req := make([]byte, 1024)
			for ctx.Err() == nil {
				var out []byte
				if err := conn.Invoke(ctx, "/"+svc+"/"+method, &req, &out); err != nil {
					return
				}
				ops.Add(1)
			}
		}()
	}

	go func() {
		var last int64
		lastProgress := time.Now()
		for {
			time.Sleep(500 * time.Millisecond)
			n := ops.Load()
			if n != last {
				last, lastProgress = n, time.Now()
				continue
			}
			if time.Since(lastProgress) > *stallAfter {
				buf := make([]byte, 1<<22)
				buf = buf[:runtime.Stack(buf, true)]
				s := string(buf)
				fmt.Printf("DEADLOCK: no RPC completed for %s (completed=%d)\n",
					time.Since(lastProgress).Truncate(time.Second), n)
				fmt.Printf("  goroutines in controlBuffer.throttle : %d\n", strings.Count(s, "controlBuffer).throttle"))
				fmt.Printf("  goroutines blocked on socket write   : %d\n", strings.Count(s, "internal/poll.(*FD).Write"))
				os.Exit(1)
			}
		}
	}()

	wg.Wait()
	fmt.Printf("completed=%d in %s, no deadlock\n", ops.Load(), *duration)
}