From 4c5aef63bc5d8184662ea219cf10cf55b9ebf48e Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 10 Nov 2026 21:51:02 +0000 Subject: [PATCH] rproxy: send its own answers at once to a visitor that stops sending its body (closes #303) An answer rproxy gives itself, such as a deny or a refusal, waited while Go's server read up to 256 KiB of a body that had not arrived whole. A visitor that stopped sending got nothing until SWRP_MAX_REQUEST_TIME_MS ran out, and no bytes at all once that was past the 10 seconds the answer has to be sent. send now sets Connection: close in that case, as modifyResponse does for the app's answer; both call closeAfterAnswer. requestTimedOut no longer ends that read of Go's server, which no longer happens, so the time the app's answer started is no longer noted. Model: opus-5-5 --- SPEC.md | 4 +++ internal/rproxy/limits_test.go | 13 +++++----- internal/rproxy/request.go | 46 +++++++++++----------------------- 3 files changed, 26 insertions(+), 37 deletions(-) diff --git a/SPEC.md b/SPEC.md index 76f716a..348bab0 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2355,6 +2355,10 @@ These limits apply per request, in both directions: than 10 seconds. The status and headers of the app's answer also have at least 10 seconds to be sent. +`rproxy` may give its own answer, such as a refusal, before the visitor has sent +its whole body. The visitor gets that answer at once, and the connection closes +after it. + The largest request line and headers is 32 KiB. A kept-open connection is closed after 120 seconds idle. Neither has a setting. traefik, by default, closes an unused connection after 90 seconds. So traefik closes first, and never sends a diff --git a/internal/rproxy/limits_test.go b/internal/rproxy/limits_test.go index a60b9ae..2425043 100644 --- a/internal/rproxy/limits_test.go +++ b/internal/rproxy/limits_test.go @@ -974,7 +974,7 @@ func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest(t *testing } } -func TestAVisitorThatStopsSendingGetsAnAnswerRProxyGivesByTheDeadline(t *testing.T) { +func TestAVisitorThatStopsALittleShortGetsAnAnswerRProxyGivesAtOnce(t *testing.T) { t.Parallel() for _, tc := range []struct { @@ -993,14 +993,15 @@ func TestAVisitorThatStopsSendingGetsAnAnswerRProxyGivesByTheDeadline(t *testing standIn := startDiscriminator(t, deny) app := startApp(t, greet) - // The time limit is long enough for a loaded host to read the first part of - // the body before it runs out. + // The request time limit is longer than the 10 seconds an answer rproxy + // gives itself has to be sent, and than dial's deadline. addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{ - maxRequestTime: "3000", + maxRequestTime: "15000", + maxResponseTime: "300", }) - // Go's server waits for a little of the body left before it sends an answer, - // and the time for sending the request ends that wait. + // The body stops 100 bytes short, less than Go's server reads of a body + // left unread before it answers. conn := dial(t, addr) send(t, conn, tc.start+announcedLength(questionBodyMaxBytes+100)+ strings.Repeat("x", questionBodyMaxBytes)) diff --git a/internal/rproxy/request.go b/internal/rproxy/request.go index c7684ef..cbd2d03 100644 --- a/internal/rproxy/request.go +++ b/internal/rproxy/request.go @@ -83,8 +83,8 @@ type request struct { upgraded *upgradedConn // mu guards what follows. The time limits run on goroutines of their own, - // and the transport starts and stops them, and notes the times below, from - // its own; once timersStopped is set, neither time limit acts any more. + // and the transport starts and stops them, and notes requestEnd, from its + // own; once timersStopped is set, neither time limit acts any more. mu sync.Mutex timersStopped bool requestTimer *time.Timer @@ -92,8 +92,6 @@ type request struct { // requestEnd is when the app had been sent the whole request, or the answer // started, if that was sooner. requestEnd time.Time - // answerStarted is when the first byte of the app's answer arrived. - answerStarted time.Time } // newRequest starts handling r: it notes the time, works out the visitor from @@ -177,8 +175,7 @@ func (rq *request) forward(ctx context.Context) { rq.cancel = cancel ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{ - WroteRequest: rq.wroteRequest, - GotFirstResponseByte: rq.gotFirstResponseByte, + WroteRequest: rq.wroteRequest, }) out := rq.in.WithContext(ctx) @@ -273,12 +270,7 @@ func (rq *request) modifyResponse(res *http.Response) error { rq.out.Header()["Content-Type"] = nil } - // Go's server reads up to 256 KiB of a body left unread before it sends an - // answer, and so waits on a visitor that has stopped sending. With this - // header it sends the answer at once, and closes the connection after it. - if rq.body != nil && !rq.body.received.Load() { - rq.out.Header().Set("Connection", "close") - } + rq.closeAfterAnswer() res.Body = &responseBody{body: limitBody(res.Body, maxBytes), rq: rq} rq.out.afterStatus = rq.setResponseDeadline @@ -347,11 +339,22 @@ func (rq *request) send(status int, body []byte) { rq.out.Header().Set("Content-Length", strconv.Itoa(len(body))) } + rq.closeAfterAnswer() rq.out.WriteHeader(status) _, _ = rq.out.Write(body) _ = rq.rc.Flush() } +// closeAfterAnswer has Go's server send the answer at once, and close the +// connection after it, when the visitor's body has not arrived whole. Without +// it, Go's server reads up to 256 KiB of the body left before it sends an +// answer, and so waits on a visitor that has stopped sending. +func (rq *request) closeAfterAnswer() { + if rq.body != nil && !rq.body.received.Load() { + rq.out.Header().Set("Connection", "close") + } +} + // prepareOwnAnswer readies the connection for an answer rproxy gives itself: the // reading of a body that has not arrived whole ends at deadline, and the answer // has SWRP_MAX_RESPONSE_TIME_MS from now to reach the visitor, even when the app @@ -517,16 +520,6 @@ func (rq *request) requestTimedOut() { } if rq.body == nil || !rq.body.waiting.Load() { - // Once the app has answered, Go's server can read what is left of a body - // under 256 KiB without requestBody.Read, before an answer rproxy gives - // itself, so waiting is false while that read waits for the visitor. - // That read ends now, before refuse: - // should rproxy still answer itself, answer then sets the deadline it - // gives its own answers. - if !rq.answerStarted.IsZero() { - rq.stopReadingBody(time.Now()) - } - rq.refuse(http.StatusGatewayTimeout) return @@ -539,15 +532,6 @@ func (rq *request) requestTimedOut() { _ = rq.rc.SetReadDeadline(time.Now()) } -// gotFirstResponseByte is called once the first byte of the app's answer has -// arrived. -func (rq *request) gotFirstResponseByte() { - rq.mu.Lock() - defer rq.mu.Unlock() - - rq.answerStarted = time.Now() -} - // wroteRequest is called once the app has been sent the whole request: // SWRP_MAX_REQUEST_TIME_MS ends and SWRP_MAX_RESPONSE_TIME_MS starts. func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) { -- 2.54.0