Compare commits

..
1 Commits
Author SHA1 Message Date
sneak cd30f0c719 rproxy tests: a cut-off answer closes the connection, also after its first part reached the visitor
check / check (push) Waiting to run
The app now sends its late parts only once rproxy has ended the request to
it, not after a sleep timed against the limit. With the body in one part it
stalls after its headers; with two parts, after the first part, which
reaches the visitor. Both cases fail on the current code: nothing cuts the
answer off, and the connection stays open. The 2-second limit lets rproxy
pass on a part sent at once, even on a busy host.

A new test, which passes on the current code, has every flush fail with no
refusal, so that only the failed-flush check in finish ends the reading of
the visitor's body.

Model: opus-5-5
2026-11-11 08:41:35 +00:00
2 changed files with 66 additions and 34 deletions

No files matched your search

+55 -6
View File
@@ -382,6 +382,19 @@ type deadlineRecorder struct {
// stalled, if set, sends the part of the request body that never arrives. A
// read deadline that has passed ends a read of it, as on a connection.
stalled *io.PipeWriter
// flushError, if set, is what every flush returns, and nothing is flushed.
flushError error
}
// FlushError returns flushError if it is set, and otherwise flushes.
func (w *deadlineRecorder) FlushError() error {
if w.flushError != nil {
return w.flushError
}
w.Flush()
return nil
}
// SetReadDeadline notes deadline.
@@ -447,6 +460,45 @@ func TestABodyLeftUnreadIsReadOnlyUntilTheRequestTimeLimit(t *testing.T) {
}
}
func TestABodyLeftUnreadIsReadNoMoreOnceTheTimeToSendTheAnswerRunsOut(t *testing.T) {
t.Parallel()
standIn := startWAF(t, allow)
app := startApp(t, answerBeforeTheBody)
_, _, server := startServer(t, standIn.URL, app.URL, nil)
// The visitor sends the first part and a little more, and then nothing.
stalled, stop := io.Pipe()
t.Cleanup(func() { _ = stop.Close() })
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/",
io.MultiReader(bytes.NewReader(make([]byte, questionBodyMaxBytes+1)), stalled))
req.ContentLength = 2 * questionBodyMaxBytes
req.Header.Set(contentLengthHeader, strconv.Itoa(2*questionBodyMaxBytes))
// Every flush fails, as once the time to send the answer has run out. The
// default time limits are far longer than the test, so rproxy records no
// refusal.
w := &deadlineRecorder{
ResponseRecorder: httptest.NewRecorder(),
stalled: stop,
flushError: os.ErrDeadlineExceeded,
}
before := time.Now()
server.Handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status %d, want the app's 200", w.Code)
}
if w.readDeadline.Before(before) || w.readDeadline.After(time.Now()) {
t.Errorf("the reading of the body ends at %s, want now", w.readDeadline)
}
}
func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
t.Parallel()
@@ -917,7 +969,8 @@ func TestAVisitorThatStopsALittleShortHasTheConnectionClosedWhenTheAnswerIsCutOf
) {
t.Parallel()
// A busy host passes on a part sent at once well within this limit.
// rproxy passes on a part sent at once well within this limit, even on a busy
// host.
const limit = 2 * time.Second
// The app sends the parts in atOnce with its status and headers. It sends the
@@ -928,11 +981,7 @@ func TestAVisitorThatStopsALittleShortHasTheConnectionClosedWhenTheAnswerIsCutOf
atOnce, late []string
}{
{"with the body in one part", nil, []string{greeting}},
{"with the body in two parts", nil, []string{greeting[:5], greeting[5:]}},
{
"with the first part of the body at once",
[]string{greeting[:5]}, []string{greeting[5:]},
},
{"with the body in two parts", []string{greeting[:5]}, []string{greeting[5:]}},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
+11 -28
View File
@@ -272,10 +272,6 @@ func (rq *request) modifyResponse(res *http.Response) error {
rq.closeAfterAnswer()
// An answer that arrives before the app has been sent the whole request ends
// the request, and SWRP_MAX_RESPONSE_TIME_MS starts.
rq.startResponseTimer(requestEnd)
res.Body = &responseBody{body: limitBody(res.Body, maxBytes), rq: rq}
rq.out.afterStatus = rq.setResponseDeadline
@@ -390,17 +386,14 @@ func (rq *request) finish(ctx context.Context) {
// After a cut-off answer, the reading of a body that has not arrived whole
// ends now. Go's server would otherwise keep the connection open while it
// reads the rest. The app's answer is cut off by a refusal once it is passed
// on, as afterStatus shows, since a refusal ends the request to the app. Any
// answer is cut off when the time to send it runs out: a flush then fails, and
// reads the rest. A flush fails if the time to send the answer ran out, and
// every flush after a failed one fails too. ReverseProxy ignores the failure,
// but panics if it was still waiting for more of the app's answer; finish runs
// either way. Before a status, a flush would send a 200; after a 101,
// ReverseProxy has taken over the connection.
switch {
case rq.out.afterStatus != nil && rq.refused.Load() != 0,
rq.out.status != 0 && rq.out.status != http.StatusSwitchingProtocols &&
errors.Is(rq.rc.Flush(), os.ErrDeadlineExceeded):
case rq.out.status != 0 && rq.out.status != http.StatusSwitchingProtocols &&
errors.Is(rq.rc.Flush(), os.ErrDeadlineExceeded):
rq.stopPassingOnBody(time.Now())
case rq.refused.Load() == 0:
rq.stopPassingOnBody(rq.requestDeadline())
@@ -559,30 +552,20 @@ func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) {
requestEnd := rq.endRequest()
rq.mu.Lock()
stopTimer(rq.requestTimer)
rq.mu.Unlock()
rq.startResponseTimer(requestEnd)
}
// startResponseTimer starts SWRP_MAX_RESPONSE_TIME_MS, which runs from
// requestEnd, unless it has started already.
func (rq *request) startResponseTimer(requestEnd time.Time) {
limit := rq.h.config.MaxResponseTime
if limit == 0 {
return
}
rq.mu.Lock()
defer rq.mu.Unlock()
if rq.timersStopped || rq.responseTimer != nil {
if rq.timersStopped {
return
}
rq.responseTimer = time.AfterFunc(time.Until(requestEnd.Add(limit)),
rq.responseTimedOut)
stopTimer(rq.requestTimer)
limit := rq.h.config.MaxResponseTime
if limit > 0 {
rq.responseTimer = time.AfterFunc(time.Until(requestEnd.Add(limit)),
rq.responseTimedOut)
}
}
// responseTimedOut is called when SWRP_MAX_RESPONSE_TIME_MS runs out before the