@marcel91200 / caddy Mirror · read-only / commits / 9c44b668
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 03:30 UTC

caddyhttp: let handler write deadlines cap the idle write timeout (#8145)

A handler setting a write deadline through http.ResponseController reached the connection directly, and the next Write or Flush through IdleTimeoutWriter replaced it with now+write_idle_timeout. Handlers bounding a single write, like Mercure's dispatch_timeout, which drops SSE subscribers that can't receive an update within 5s, were silently extended to the 1 minute default. On HTTP/2, clearing between writes also wiped the handler deadline. IdleTimeoutWriter now implements SetWriteDeadline, keeps the handler deadline as a ceiling for idle resets, and restores it instead of HardDeadline alone when clearing between writes. A handler can tighten the idle timeout but not loosen it.

Kévin Dunglas committed Oct 8, 2026 at 05:11 UTC 9c44b6685e24ae6897b81aa3f86d6032ad7e0a08
2 files changed +151 -13
modules/caddyhttp/idletimeout.go
+48 -13
@@ -310,6 +310,11 @@ func (r *IdleTimeoutReader) clearDeadlineLocked() {
310 // like a hard WriteTimeout would. A 64 KiB default still preserves
311 // most of the sendfile fast path's benefit (net/sendfile.go
312 // special-cases *io.LimitedReader to keep using sendfile per chunk).
313 +//
314 +// A write deadline set by a handler through http.ResponseController
315 +// reaches SetWriteDeadline and caps every idle reset, so a handler can
316 +// tighten the idle timeout (e.g. to drop a slow SSE subscriber) but
317 +// can't loosen it.
318 type IdleTimeoutWriter struct {
319 *ResponseWriterWrapper
320 Ctrl *http.ResponseController
@@ -318,17 +323,40 @@ type IdleTimeoutWriter struct {
323 Logger *zap.Logger
324
325 // ClearBetweenWrites makes the writer clear its idle deadline after
321 - // every successful write or flush, putting back HardDeadline if any,
322 - // and arm a final one in HandlerDone if written data may still be
323 - // buffered. It is required for HTTP/2 and unnecessary (and costs a
324 - // deadline update per write) for other protocols.
326 + // every successful write or flush, putting back HardDeadline or the
327 + // deadline set by the handler if any, and arm a final one in
328 + // HandlerDone if written data may still be buffered. It is required
329 + // for HTTP/2 and unnecessary (and costs a deadline update per write)
330 + // for other protocols.
331 ClearBetweenWrites bool
332
333 unsupported bool
334 // deadlineSet is whether the idle deadline is armed. clearDeadline
335 // leaves HardDeadline armed, if any, which nothing needs to undo.
330 - deadlineSet bool
331 - unflushed bool
336 + deadlineSet bool
337 + unflushed bool
338 + handlerDeadline time.Time
339 +}
340 +
341 +// SetWriteDeadline sets the write deadline on behalf of a handler and
342 +// keeps it as a ceiling for the idle deadline, until the handler sets
343 +// another one. A zero deadline removes the ceiling.
344 +func (w *IdleTimeoutWriter) SetWriteDeadline(deadline time.Time) error {
345 + if err := w.Ctrl.SetWriteDeadline(deadline); err != nil {
346 + return err
347 + }
348 + w.handlerDeadline = deadline
349 + return nil
350 +}
351 +
352 +// ceiling returns the earliest of HardDeadline and the deadline set by
353 +// the handler, or zero if there is neither.
354 +func (w *IdleTimeoutWriter) ceiling() time.Time {
355 + hard := w.Deadline.HardDeadline
356 + if w.handlerDeadline.IsZero() || (!hard.IsZero() && hard.Before(w.handlerDeadline)) {
357 + return hard
358 + }
359 + return w.handlerDeadline
360 }
361
362 func (w *IdleTimeoutWriter) resetDeadline() {
@@ -336,7 +364,12 @@ func (w *IdleTimeoutWriter) resetDeadline() {
364 return
365 }
366
339 - if w.setWriteDeadline(w.Deadline.next(), "could not set write deadline") {
367 + deadline := w.Deadline.next()
368 + if !w.handlerDeadline.IsZero() && deadline.After(w.handlerDeadline) {
369 + deadline = w.handlerDeadline
370 + }
371 +
372 + if w.setWriteDeadline(deadline, "could not set write deadline") {
373 w.deadlineSet = true
374 }
375 }
@@ -356,20 +389,22 @@ func (w *IdleTimeoutWriter) setWriteDeadline(deadline time.Time, logMessage stri
389 }
390
391 // clearDeadline replaces the idle deadline set by resetDeadline with
359 -// HardDeadline, which is zero (no deadline) unless a hard ceiling is
360 -// configured. It does nothing unless ClearBetweenWrites is set.
392 +// the earliest of HardDeadline and the deadline set by the handler,
393 +// which is zero (no deadline) if there is neither. It does nothing
394 +// unless ClearBetweenWrites is set.
395 func (w *IdleTimeoutWriter) clearDeadline() {
396 if !w.ClearBetweenWrites || !w.deadlineSet || w.unsupported {
397 return
398 }
399
366 - // once the hard deadline has passed, the deadline set by
367 - // resetDeadline (capped to it) has already expired; keep it
368 - if !w.Deadline.HardDeadline.IsZero() && !time.Now().Before(w.Deadline.HardDeadline) {
400 + // once the ceiling has passed, the deadline set by resetDeadline
401 + // (capped to it) has already expired; keep it
402 + ceiling := w.ceiling()
403 + if !ceiling.IsZero() && !time.Now().Before(ceiling) {
404 return
405 }
406
372 - if !w.setWriteDeadline(w.Deadline.HardDeadline, "could not clear write deadline") {
407 + if !w.setWriteDeadline(ceiling, "could not clear write deadline") {
408 return
409 }
410 w.deadlineSet = false
modules/caddyhttp/idletimeout_test.go
+103
@@ -785,6 +785,109 @@ func TestIdleTimeoutWriter_ClearBetweenWrites(t *testing.T) {
785 }
786 }
787
788 +func TestIdleTimeoutWriter_HandlerDeadline(t *testing.T) {
789 + const idle = time.Hour
790 + now := time.Now()
791 + earlier := now.Add(30 * time.Minute)
792 + later := now.Add(2 * time.Hour)
793 + hard := now.Add(20 * time.Minute)
794 +
795 + // the values recorded for each SetWriteDeadline call
796 + const (
797 + idleSet = "idle"
798 + zeroSet = "zero"
799 + earlierSet = "earlier"
800 + laterSet = "later"
801 + hardSet = "hard"
802 + otherSet = "other"
803 + )
804 +
805 + for i, tc := range []struct {
806 + name string
807 + clear bool
808 + hard bool
809 + deadlines []time.Time
810 + expected []string
811 + }{
812 + {
813 + name: "earlier handler deadline caps the idle reset",
814 + deadlines: []time.Time{earlier},
815 + expected: []string{earlierSet, earlierSet},
816 + },
817 + {
818 + name: "clearing puts back the handler deadline",
819 + clear: true,
820 + deadlines: []time.Time{earlier},
821 + expected: []string{earlierSet, earlierSet, earlierSet},
822 + },
823 + {
824 + name: "later handler deadline doesn't loosen the idle timeout",
825 + deadlines: []time.Time{later},
826 + expected: []string{laterSet, idleSet},
827 + },
828 + {
829 + name: "clearing puts back a later handler deadline",
830 + clear: true,
831 + deadlines: []time.Time{later},
832 + expected: []string{laterSet, idleSet, laterSet},
833 + },
834 + {
835 + name: "zero handler deadline removes the ceiling",
836 + deadlines: []time.Time{earlier, {}},
837 + expected: []string{earlierSet, zeroSet, idleSet},
838 + },
839 + {
840 + name: "earlier hard deadline stays the ceiling",
841 + clear: true,
842 + hard: true,
843 + deadlines: []time.Time{earlier},
844 + expected: []string{earlierSet, hardSet, hardSet},
845 + },
846 + } {
847 + rec := &writeDeadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
848 + w := &IdleTimeoutWriter{
849 + ResponseWriterWrapper: &ResponseWriterWrapper{ResponseWriter: rec},
850 + Ctrl: http.NewResponseController(rec),
851 + Deadline: IdleDeadline{Timeout: idle},
852 + Logger: zap.NewNop(),
853 + ClearBetweenWrites: tc.clear,
854 + }
855 + if tc.hard {
856 + w.Deadline.HardDeadline = hard
857 + }
858 +
859 + // handlers usually reach the writer through other wrappers
860 + ctrl := http.NewResponseController(&ResponseWriterWrapper{ResponseWriter: w})
861 + for _, d := range tc.deadlines {
862 + require.NoError(t, ctrl.SetWriteDeadline(d))
863 + }
864 +
865 + before := time.Now()
866 + _, err := w.Write([]byte("x"))
867 + require.NoError(t, err)
868 + after := time.Now()
869 +
870 + var actual []string
871 + for _, d := range rec.deadlines {
872 + switch {
873 + case d.IsZero():
874 + actual = append(actual, zeroSet)
875 + case d.Equal(earlier):
876 + actual = append(actual, earlierSet)
877 + case d.Equal(later):
878 + actual = append(actual, laterSet)
879 + case d.Equal(hard):
880 + actual = append(actual, hardSet)
881 + case !d.Before(before.Add(idle)) && !d.After(after.Add(idle)):
882 + actual = append(actual, idleSet)
883 + default:
884 + actual = append(actual, otherSet)
885 + }
886 + }
887 + assert.Equal(t, tc.expected, actual, "Test %d (%s)", i, tc.name)
888 + }
889 +}
890 +
891 // readFromCounter is a fake http.ResponseWriter that records how many
892 // times ReadFrom was called on it and the size of the largest call, so
893 // tests can assert on chunking behavior deterministically instead of