fix(uploads): bound the silence in an upload body with a re-armed read deadline
CI / Lint / Test / Vet (pull_request) Successful in 35s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped

A body that stopped arriving held one of the receive slots (#47) and a
spool file indefinitely. Every read of the upload body now pushes the
connection read deadline UploadIdleTimeout (default 60 s) ahead, so a
stalled body fails its read, Accept returns and the slot is released,
while a slow but moving body is never cut (ReadTimeout would cut it).

Two things found by running it over a socket:
- metrics.statusWriter had no Unwrap, so http.ResponseController could
  not reach the connection and the bound silently did not apply in the
  production handler stack. It now unwraps; a writer that still cannot
  set a deadline logs "upload idle bound unavailable".
- clearing the deadline on any read error left net/http's pre-reply
  drain of the unread remainder unbounded, so the connection hung. The
  deadline is now cleared only on a clean io.EOF.

ADR-033 limitation amended. A 1-byte-per-59-s drip is still unbounded.

Closes #56

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 22:57:33 +02:00
co-authored by Claude Opus 5.5
parent cace7d179d
commit b106b0dab3
5 changed files with 181 additions and 5 deletions
+47 -1
View File
@@ -82,7 +82,7 @@ func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, a.UploadLimits.MaxBytes+uploadOverhead)
r.Body = http.MaxBytesReader(w, a.idleBoundedBody(w, r.Body), a.UploadLimits.MaxBytes+uploadOverhead)
mr, err := r.MultipartReader()
if err != nil {
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose an audio file to upload.")
@@ -137,6 +137,52 @@ func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) {
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose an audio file to upload.")
}
// defaultUploadIdleTimeout is how long an upload body may deliver nothing before its
// connection read is cut off (#56). A stall this long is not a slow link but a dead or
// hostile one; a legitimate upload, however slow, keeps delivering bytes.
const defaultUploadIdleTimeout = 60 * time.Second
// idleBoundedBody bounds the SILENCE in an upload body, not its total duration (#56): every
// read first pushes the connection's read deadline idle into the future, so a body that
// keeps moving is never cut, and one that stops fails its read with a timeout. That read
// error reaches Accept as a client read error, so Accept returns and its receive slot is
// released. A whole-request ReadTimeout would instead cut a large upload on a slow link.
//
// If the writer cannot set a read deadline (a wrapper without Unwrap, or a test recorder)
// the body is returned unbounded and that is logged: a control that silently does not
// apply is worse than one that says so.
func (a *App) idleBoundedBody(w http.ResponseWriter, body io.ReadCloser) io.ReadCloser {
idle := a.UploadIdleTimeout
if idle <= 0 {
idle = defaultUploadIdleTimeout
}
rc := http.NewResponseController(w)
if err := rc.SetReadDeadline(time.Now().Add(idle)); err != nil {
a.logger().Warn("upload idle bound unavailable", "err", err)
return body
}
return &idleDeadlineBody{ReadCloser: body, rc: rc, idle: idle}
}
type idleDeadlineBody struct {
io.ReadCloser
rc *http.ResponseController
idle time.Duration
}
func (b *idleDeadlineBody) Read(p []byte) (int, error) {
_ = b.rc.SetReadDeadline(time.Now().Add(b.idle))
n, err := b.ReadCloser.Read(p)
if errors.Is(err, io.EOF) {
// The body is complete: disarm, so the deadline cannot fire on the server's own
// background read while the handler is still probing the file. On any other error
// it stays armed: net/http drains an unread remainder before replying, and that
// drain must stay bounded too.
_ = b.rc.SetReadDeadline(time.Time{})
}
return n, err
}
// abandoned reports, logs at INFO and answers 499, because the client has already gone: its request
// context is cancelled, so there is nobody to answer and rendering a page would run its
// database read on a dead context and log a false ERROR. A hang-up mid-upload is routine.