Skip to content

net/http: context cancellation can leave HTTP client with deadlocked HTTP/1.1 connections in Go1.22 #65705

@tibbes

Description

@tibbes

Go version

go version go1.22.0 darwin/arm64

Output of go env in your module/workspace:

GO111MODULE=''
GOARCH='arm64'
GOBIN=''
GOCACHE='/Users/tibbes/Library/Caches/go-build'
GOENV='/Users/tibbes/Library/Application Support/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFLAGS=''
GOHOSTARCH='arm64'
GOHOSTOS='darwin'
GOINSECURE=''
GOMODCACHE='/Users/tibbes/go/pkg/mod'
GONOPROXY=''
GONOSUMDB=''
GOOS='darwin'
GOPATH='/Users/tibbes/go'
GOPRIVATE=''
GOPROXY='https://proxy.golang.org,direct'
GOROOT='/usr/local/go'
GOSUMDB='sum.golang.org'
GOTMPDIR=''
GOTOOLCHAIN='auto'
GOTOOLDIR='/usr/local/go/pkg/tool/darwin_arm64'
GOVCS=''
GOVERSION='go1.22.0'
GCCGO='gccgo'
AR='ar'
CC='clang'
CXX='clang++'
CGO_ENABLED='1'
GOMOD='/dev/null'
GOWORK=''
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
PKG_CONFIG='pkg-config'
GOGCCFLAGS='-fPIC -arch arm64 -pthread -fno-caret-diagnostics -Qunused-arguments -fmessage-length=0 -ffile-prefix-map=/var/folders/18/_76dkntd3f76rb3fql9q5jrw0000gn/T/go-build1573940289=/tmp/go-build -gno-record-gcc-switches -fno-common'

What did you do?

There seems to be a bug in go 1.22.0 (not present in earlier versions), that can leave an HTTP client deadlocked when MaxConnsPerHost is used.

The following program reproduces the problem. It:

  • starts a local HTTP server
  • creates an HTTP client with MaxConnsPerHost, MaxIdleConns, and MaxIdleConnsPerHost all set to 1
  • sends 3 requests (with a 1s context timeout) to show the client is working normally
  • stresses the HTTP client by sending 10 requests sequentially with a 1 microsecond context timeout
  • attempts to send 3 requests (with a 1s context timeout)
package main

import (
	"bytes"
	"context"
	"fmt"
	"net/http"
	"time"
)

func main() {
	go serve()
	client := createHTTPClient()

	fmt.Println("=== Before ===")
	fmt.Println(download(client, 1*time.Second))
	fmt.Println(download(client, 1*time.Second))
	fmt.Println(download(client, 1*time.Second))

	// Try and put the connection in a bad state
	for i := 0; i < 10; i++ {
		download(client, time.Microsecond)
	}
	fmt.Println()

	fmt.Println("=== After ===")
	fmt.Println(download(client, 1*time.Second))
	fmt.Println(download(client, 1*time.Second))
	fmt.Println(download(client, 1*time.Second))
}

func download(client *http.Client, timeout time.Duration) (string, error) {
	ctx, cancel := context.WithTimeout(context.Background(), timeout)
	defer cancel()
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:8888", nil)
	if err != nil {
		return "", err
	}

	resp, err := client.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	buf := bytes.Buffer{}
	_, err = buf.ReadFrom(resp.Body)
	if err != nil {
		return "", err
	}
	return buf.String(), nil
}

func createHTTPClient() *http.Client {
	// Make sure that the same connection is reused.
	transport := &http.Transport{
		MaxIdleConns:        1,
		MaxIdleConnsPerHost: 1,
		MaxConnsPerHost:     1,
	}
	return &http.Client{Transport: transport}
}

func serve() {
	http.ListenAndServe("127.0.0.1:8888", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Println("Server received a request")
		w.Write([]byte("Hello, world!"))
	}))
}

What did you see happen?

With go 1.22.0, the HTTP client is not usable after stressing it with short timeouts:

=== Before ===
Server received a request
Hello, world! <nil>
Server received a request
Hello, world! <nil>
Server received a request
Hello, world! <nil>
Server received a request

=== After ===
 Get "http://127.0.0.1:8888": context deadline exceeded
 Get "http://127.0.0.1:8888": context deadline exceeded
 Get "http://127.0.0.1:8888": context deadline exceeded

What did you expect to see?

I would expect to see the same behaviour as go 1.21.7:

=== Before ===
Server received a request
Hello, world! <nil>
Server received a request
Hello, world! <nil>
Server received a request
Hello, world! <nil>

=== After ===
Server received a request
Server received a request
Hello, world! <nil>
Server received a request
Hello, world! <nil>
Server received a request
Hello, world! <nil>

Metadata

Metadata

Assignees

No one assigned

    Labels

    FrozenDueToAgeNeedsFixThe path to resolution is known, but the work has not been done.

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions