From 6cd91fb30b5236e2d3270eb6a4a6405eddfb138c Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 11 Nov 2026 02:29:14 +0000 Subject: [PATCH] rproxy: close the connection when a short answer is cut off (closes #318) When SWRP_MAX_RESPONSE_TIME_MS ran out before the last of a short answer was written, the flush failed and ReverseProxy ignored it. If the visitor had stopped short of its announced body, Go's server then read the rest until SWRP_MAX_REQUEST_TIME_MS ran out, keeping the connection open with the answer cut off. Go does that read even after an aborting panic. forward now checks for a failed flush once ReverseProxy returns. It then records a 504 refusal, ends the reading of the body at once, and panics so that Go's server closes the connection, as SPEC.md says a cut-off answer does. Model: opus-5-5 --- internal/rproxy/request.go | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/internal/rproxy/request.go b/internal/rproxy/request.go index c346dae..fd97744 100644 --- a/internal/rproxy/request.go +++ b/internal/rproxy/request.go @@ -195,6 +195,20 @@ func (rq *request) forward(ctx context.Context) { rq.startRequestTimer() rq.upstreamStart = time.Now() reverseProxy.ServeHTTP(rq.out, out) + + // ReverseProxy ignores a failed flush, so it misses a short answer cut off when + // SWRP_MAX_RESPONSE_TIME_MS runs out; once a flush has failed, every later one + // fails too. Go's server would then keep the connection open while it reads + // the rest of a body that has not arrived whole. So that reading ends now, and + // the refusal keeps finish from extending it to SWRP_MAX_REQUEST_TIME_MS. The + // panic has Go's server close the connection, and log nothing; the deferred + // finish still runs. + if rq.out.status != http.StatusSwitchingProtocols && + errors.Is(rq.rc.Flush(), os.ErrDeadlineExceeded) { + rq.refuse(http.StatusGatewayTimeout) + rq.stopPassingOnBody(time.Now()) + panic(http.ErrAbortHandler) + } } // rewrite makes the request the app receives: the visitor's request, unchanged, @@ -385,7 +399,7 @@ func (rq *request) finish(ctx context.Context) { rq.stopTimers() if rq.refused.Load() == 0 { - rq.stopPassingOnBody() + rq.stopPassingOnBody(rq.requestDeadline()) } requestEnd := rq.endRequest() @@ -473,10 +487,10 @@ func (rq *request) stopReadingBody(deadline time.Time) { } // stopPassingOnBody ends the transport's reading of a visitor's body that has -// not arrived whole, and lets Go's server read the rest until -// SWRP_MAX_REQUEST_TIME_MS runs out. Go's server reads it once the handler -// returns, but with no deadline at all if another read of it is still waiting. -func (rq *request) stopPassingOnBody() { +// not arrived whole, and lets Go's server read the rest until deadline. Go's +// server reads it once the handler returns, but with no deadline at all if +// another read of it is still waiting. +func (rq *request) stopPassingOnBody(deadline time.Time) { if rq.body == nil || rq.body.received.Load() { return } @@ -489,7 +503,7 @@ func (rq *request) stopPassingOnBody() { rq.body.stopped = true rq.body.mu.Unlock() - rq.stopReadingBody(rq.requestDeadline()) + rq.stopReadingBody(deadline) } // startRequestTimer starts SWRP_MAX_REQUEST_TIME_MS, which runs from the