From 6d044ba4033f69f4a671f0762540c7a37b073269 Mon Sep 17 00:00:00 2001 From: Mathias Date: Fri, 2 Oct 2026 17:36:42 +0200 Subject: [PATCH] fix(web): a client that hangs up is logged at INFO and not answered; the 400 follows the marker, not io.ErrUnexpectedEOF; hasNote (third review) Co-Authored-By: Claude Sonnet 5.5 --- internal/web/uploads.go | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/internal/web/uploads.go b/internal/web/uploads.go index 1d59e14..fa48d5b 100644 --- a/internal/web/uploads.go +++ b/internal/web/uploads.go @@ -95,6 +95,9 @@ func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) { } if err != nil { var tooBig *http.MaxBytesError + if a.abandoned(r) { + return + } if !errors.As(err, &tooBig) { // A malformed multipart body (a header with a control byte, a truncated // part) is the sender's to fix: a 400, not a server error, and the error @@ -130,10 +133,24 @@ func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) { a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose an audio file to upload.") } +// abandoned reports, and logs at INFO, that 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. +func (a *App) abandoned(r *http.Request) bool { + if r.Context().Err() == nil { + return false + } + a.logger().Info("upload abandoned", "reason", "client_gone") + return true +} + // uploadFailure maps an Accept (or multipart) error to a response: a validation // rejection is the user's to fix (422, or 413 for size) and carries its own // message; anything else is ours. func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID string, err error) { + if a.abandoned(r) { + return + } var rej *uploads.RejectError var tooBig *http.MaxBytesError switch { @@ -152,8 +169,9 @@ func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID strin a.logger().Info("upload rejected", "reason", string(uploads.ReasonTooLarge)) a.renderUploadsPage(w, r, userID, http.StatusRequestEntityTooLarge, "This file is larger than the upload limit.") - case errors.Is(err, io.ErrUnexpectedEOF): - // The sender hung up mid-file: theirs to retry, not a server fault. + case errors.Is(err, uploads.ErrClientRead): + // An error reading the CLIENT's body: theirs to retry, not a server fault. Only that + // marker counts: io.ErrUnexpectedEOF alone is also what a database drop looks like. a.logger().Info("upload rejected", "reason", "truncated") a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "The upload was cut off before it finished. Please try again.") default: @@ -219,6 +237,17 @@ func titleFromFilename(name string) string { return t } +// hasNote reports whether any phase carries a note. A note is the only warning for a +// recording that is shorter than its file claimed, so a record that has one is shown open. +func hasNote(steps []domain.ProgressStep) bool { + for _, s := range steps { + if s.Note != "" { + return true + } + } + return false +} + // uuidShape is the form of an upload id. Anything else cannot be one, so it is a 404. var uuidShape = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)