From 021e5c4325e8e41ac3fd0a7ee11e9fb415fd7146 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 14 Oct 2026 17:11:53 +0000 Subject: [PATCH] A client that stops sending after the app answers is timed out (closes #130) When the app answered before the client had sent its whole body, and its answer ended with its last bytes, every timeout was stopped, SWWAF_CLIENT_REQUEST_TIMEOUT included. The transport went on waiting to read the body, and Go's server cannot send the answer until that read returns, so a client that stopped sending held the request until it closed the connection. The timeouts now run on until the client has sent its body, as they do before the app answers: once SWWAF_CLIENT_REQUEST_TIMEOUT runs out the client is sent the app's answer, the connection is closed, and the log line says timed_out. Model: opus-5-5 --- internal/proxy/request.go | 12 +++++++++++- internal/proxy/timeouts_test.go | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/internal/proxy/request.go b/internal/proxy/request.go index 1b60d07..8881934 100644 --- a/internal/proxy/request.go +++ b/internal/proxy/request.go @@ -789,9 +789,19 @@ func (rq *request) responseTimedOut() { } } -// responseReceived is called once the app has sent its whole answer. +// responseReceived is called once the app has sent its whole answer. An +// app may answer before the client has sent its whole body, as with a 404 +// it sends without reading it. The timeouts then run on: until the +// transport's read of the body returns, Go's server cannot send the +// answer, and SWWAF_CLIENT_REQUEST_TIMEOUT is what ends that read for a +// client that stops sending. func (rq *request) responseReceived() { rq.complete = true + + if rq.body != nil && !rq.body.received.Load() { + return + } + rq.stopTimers() } diff --git a/internal/proxy/timeouts_test.go b/internal/proxy/timeouts_test.go index dab7791..1616d4f 100644 --- a/internal/proxy/timeouts_test.go +++ b/internal/proxy/timeouts_test.go @@ -194,6 +194,28 @@ func sendLargeBody(t *testing.T, addr string) net.Conn { return conn } +func TestClientStopsSendingAfterTheAppHasAnswered(t *testing.T) { + t.Parallel() + + // The app answers at once without reading the body, as an app does + // for a path it does not serve. + app := startApp(t, http.NotFound) + addr, out := startProxy(t, app.URL, map[string]string{ + clientRequestTimeout: shortTimeoutSetting, + metricsToken: token, + }) + + start := time.Now() + got := readResponse(t, sendPartOfBody(t, addr)) + wantTimedOut(t, start) + + // The app's answer came before the time ran out, so the client is + // sent that answer, not smallwebwaf's 408. + wantStatus(t, got, http.StatusNotFound) + wantLine(t, out.requestLine(t), http.StatusNotFound, requestlog.ActionTimedOut) + wantLimitHits(t, addr, clientRequestTimeout, 1) +} + func TestAppTooSlowToAnswer(t *testing.T) { t.Parallel() -- 2.54.0