Skip to content

net/http: ReadTimeout Breaks Server CloseNotify Functionality For Keep-Alive Connections #16958

Closed
@prabrisat1

Description

@prabrisat1

Issue

GO1.7's functionality for HTTP 1.1's keep-alive, net/http.Server.ReadTimeout, and net/http.CloseNotifier is not consistent with the documented and/or expected behavior, and cannot be used together properly. The timer created by setting the server's readTimeout value is reset only at the end of processing each request and is not paused/reset when we have finished reading in each request, causing CloseNotify() to signal without cause.

What did I do

This test creates a simple http server and then sends 2 requests, each of which takes 40ms of processing time with a 70ms gap between them, responding 200OK if the 40ms of work completes, and 503 if it receives a closeNotify signal before finishing that work.

Expected Result

We should receive 2 200 OK messages and not receive a signal from CloseNotify().

We have a readTimeout of 100ms, and a 70ms delay between receiving a response for the first request and sending the second request, so both requests should be read within the timeout and be able to complete their work.

Actual Result

We actually see that during the second request, we receive a closeNotify() signal approximately 30ms after the client has sent the second request. At this time we have clearly read the second request and started processing it with a still alive TCP connection, but still receive a closeNotify() signal, which should not be happening. There is also an attached wireshark log showing this.

package main

import (
    "io"
    "io/ioutil"
    "net/http"
    "net/http/httptest"
    "sync"
    "testing"
    "time"
)

func TestServerKeepAliveReadTimeoutCloseNotify(t *testing.T) {
    var startMu sync.Mutex
    var start time.Time

    testServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        cn := w.(http.CloseNotifier)
        select {
        case <-time.After(40 * time.Millisecond):
            w.WriteHeader(http.StatusOK)
        case <-cn.CloseNotify():
            startMu.Lock()
            delta := time.Since(start)
            startMu.Unlock()
            t.Errorf("Unexpected CloseNotify received after %s", delta)
            w.WriteHeader(http.StatusServiceUnavailable)
        }
    }))
    testServer.Config.ReadTimeout = 100 * time.Millisecond
    testServer.Start()
    defer testServer.Close()

    for i := 0; i < 2; i++ {
        startMu.Lock()
        start = time.Now()
        startMu.Unlock()

        resp, err := http.Get(testServer.URL)
        if err != nil {
            t.Fatal(err)
        }
        _, err = io.Copy(ioutil.Discard, resp.Body)
        if err != nil {
            t.Fatal(err)
        }
        err = resp.Body.Close()
        if err != nil {
            t.Fatal(err)
        }

        time.Sleep(70 * time.Millisecond)
    }
}

Test output

$ go test notifierIssue_test.go 
--- FAIL: TestServerKeepAliveReadTimeoutCloseNotify (0.22s)
    notifierIssue_test.go:26: Unexpected CloseNotify received after 30.752781ms
FAIL
FAIL    command-line-arguments  0.225s

wSharkLogs.pcapng.zip

Potential Fix

Currently the read timeout is only reset here: https://github.com/golang/go/blob/go1.7/src/net/http/server.go#L749 after a request has finished being processed and we start waiting on the next one. However, the timeout is never paused/reset for the period between the end of reading the request and the end of sending the response, as it should be.

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

go version go1.7 darwin/amd64

What operating system and processor architecture are you using (go env)?

GOARCH="amd64"
GOBIN=""
GOEXE=""
GOHOSTARCH="amd64"
GOHOSTOS="darwin"
GOOS="darwin"
GORACE=""
GOROOT="/usr/local/go"
GOTOOLDIR="/usr/local/go/pkg/tool/darwin_amd64"
CC="clang"
GOGCCFLAGS="-fPIC -m64 -pthread -fno-caret-diagnostics -Qunused-arguments -fmessage-length=0 -fdebug-prefix-map=/var/folders/4r/1g2c4css78b1yn2q32dmqqm0l05w9h/T/go-build102138663=/tmp/go-build -gno-record-gcc-switches -fno-common"
CXX="clang++"
CGO_ENABLED="1"

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