Skip to content

Commit 4bd508b

Browse files
committed
fix: spool queued request bodies to break HTTP/2 upload deadlock
Concurrent uploads multiplexed on one HTTP/2 connection hang when there are more streams than PHP threads. A stream queued waiting for a thread keeps its flow-control window open while no one reads the body; enough queued streams exhaust the connection-level window and stall every stream on the connection, including those a thread is already serving. Drain a request body into a buffer (spilling past 2 MiB to a temp file) before it enters the queue, releasing the window so every stream is read by someone. Requests that get a thread immediately still stream live. Bodies overrunning a request_body max_size limit are rejected with 413 instead of reaching PHP truncated. Closes #1074
1 parent 85e2d40 commit 4bd508b

7 files changed

Lines changed: 379 additions & 3 deletions

context.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ type frankenPHPContext struct {
3636
// Whether the request is already closed by us
3737
isDone bool
3838

39+
// Whether the request body has been drained into a buffer while queued
40+
bodySpooled bool
41+
// Releases the spooled body's backing temp file, if any
42+
cleanupBody func()
43+
3944
responseWriter http.ResponseWriter
4045
responseController *http.ResponseController
4146
handlerParameters any
@@ -132,6 +137,11 @@ func (fc *frankenPHPContext) closeContext() {
132137

133138
close(fc.done)
134139
fc.isDone = true
140+
141+
if fc.cleanupBody != nil {
142+
fc.cleanupBody()
143+
fc.cleanupBody = nil
144+
}
135145
}
136146

137147
// validate checks if the request should be outright rejected

frankenphp.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ var (
5353
ErrInvalidRequestPath = ErrRejected{"invalid request path", http.StatusBadRequest}
5454
ErrInvalidContentLengthHeader = ErrRejected{"invalid Content-Length header", http.StatusBadRequest}
5555
ErrMaxWaitTimeExceeded = ErrRejected{"maximum request handling time exceeded", http.StatusServiceUnavailable}
56+
ErrRequestBodyTooLarge = ErrRejected{"request body too large", http.StatusRequestEntityTooLarge}
5657

5758
contextKey = contextKeyStruct{}
5859
serverHeader = []string{"FrankenPHP"}
@@ -649,7 +650,7 @@ func go_read_post(threadIndex C.uintptr_t, cBuf *C.char, countBytes C.size_t) (r
649650
// deadline on a finalized HTTP/2 stream, dereferencing a nil pointer and
650651
// crashing the process. See https://github.com/php/frankenphp/issues/2535.
651652
var rc *http.ResponseController
652-
if fc.requestBodyTimeout > 0 && !fc.isDone {
653+
if fc.requestBodyTimeout > 0 && !fc.isDone && !fc.bodySpooled {
653654
if fc.responseController == nil {
654655
fc.responseController = http.NewResponseController(fc.responseWriter)
655656
}

requestbodyspool.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package frankenphp
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"io"
7+
"log/slog"
8+
"net/http"
9+
"os"
10+
"time"
11+
)
12+
13+
// bodySpoolMemoryThreshold is how much of a queued request body is kept in
14+
// memory before spilling to a temp file.
15+
const bodySpoolMemoryThreshold = 2 << 20 // 2 MiB
16+
17+
// spoolRequestBody drains the request body and replaces it with a buffered copy.
18+
//
19+
// A request stalled in the thread queue keeps its HTTP/2 stream flow-control
20+
// window open while no one reads the body. Enough stalled uploads multiplexed on
21+
// a single connection exhaust the connection-level window and deadlock every
22+
// stream on that connection, including those a thread is already serving.
23+
// Draining a queued body up front releases the window and breaks the deadlock.
24+
// See https://github.com/php/frankenphp/issues/1074.
25+
//
26+
// Only bodies with a known, non-zero Content-Length are spooled. Streaming
27+
// requests (chunked or unknown length, e.g. long-lived uploads) keep their live
28+
// stream and their current behavior.
29+
// It returns ErrRequestBodyTooLarge (and rejects the request with 413) when a
30+
// request_body max_size limit wraps the body and the client exceeds it; callers
31+
// must stop handling the request in that case.
32+
func (fc *frankenPHPContext) spoolRequestBody() error {
33+
r := fc.request
34+
if fc.bodySpooled || r == nil || r.Body == nil || r.Body == http.NoBody || r.ContentLength <= 0 {
35+
return nil
36+
}
37+
38+
src := io.Reader(r.Body)
39+
40+
// keep bounding slow uploads while draining, mirroring go_read_post
41+
if fc.requestBodyTimeout > 0 && !fc.isDone && fc.responseWriter != nil {
42+
if fc.responseController == nil {
43+
fc.responseController = http.NewResponseController(fc.responseWriter)
44+
}
45+
src = &deadlineReader{r: r.Body, rc: fc.responseController, timeout: fc.requestBodyTimeout}
46+
}
47+
48+
sw := &spoolWriter{threshold: bodySpoolMemoryThreshold}
49+
n, err := io.Copy(sw, src)
50+
51+
if fc.requestBodyTimeout > 0 && fc.responseController != nil {
52+
_ = fc.responseController.SetReadDeadline(time.Time{})
53+
}
54+
_ = r.Body.Close()
55+
56+
// A body larger than the configured max_size cannot be handled at all;
57+
// reject it up front instead of feeding PHP a truncated request.
58+
var maxBytesErr *http.MaxBytesError
59+
if errors.As(err, &maxBytesErr) {
60+
sw.cleanup()
61+
fc.reject(ErrRequestBodyTooLarge)
62+
63+
return ErrRequestBodyTooLarge
64+
}
65+
66+
if err != nil {
67+
// The stream is already (partially) drained and cannot be replayed.
68+
// Hand PHP whatever was read; a short read surfaces as EOF, matching
69+
// what a live read would have produced after the same failure.
70+
if fc.logger.Enabled(fc.request.Context(), slog.LevelWarn) {
71+
fc.logger.LogAttrs(fc.request.Context(), slog.LevelWarn, "error while spooling request body", slog.Any("error", err))
72+
}
73+
}
74+
75+
fc.bodySpooled = true
76+
r.ContentLength = n
77+
78+
if sw.file == nil {
79+
r.Body = io.NopCloser(bytes.NewReader(sw.buf.Bytes()))
80+
81+
return nil
82+
}
83+
84+
if _, err := sw.file.Seek(0, io.SeekStart); err != nil {
85+
sw.cleanup()
86+
r.Body = io.NopCloser(bytes.NewReader(nil))
87+
r.ContentLength = 0
88+
89+
return nil
90+
}
91+
92+
r.Body = sw.file
93+
fc.cleanupBody = sw.cleanup
94+
95+
return nil
96+
}
97+
98+
// spoolWriter buffers in memory up to threshold, then spills the rest to a temp
99+
// file so a large queued body never grows the heap unbounded.
100+
type spoolWriter struct {
101+
buf bytes.Buffer
102+
file *os.File
103+
threshold int
104+
}
105+
106+
func (s *spoolWriter) Write(p []byte) (int, error) {
107+
if s.file == nil {
108+
if s.buf.Len()+len(p) <= s.threshold {
109+
return s.buf.Write(p)
110+
}
111+
112+
f, err := os.CreateTemp("", "frankenphp-upload-*")
113+
if err != nil {
114+
return 0, err
115+
}
116+
117+
s.file = f
118+
if _, err := s.file.Write(s.buf.Bytes()); err != nil {
119+
return 0, err
120+
}
121+
s.buf.Reset()
122+
}
123+
124+
return s.file.Write(p)
125+
}
126+
127+
// cleanup releases the backing temp file, if any. Safe to call when nothing spilled.
128+
func (s *spoolWriter) cleanup() {
129+
if s.file == nil {
130+
return
131+
}
132+
133+
name := s.file.Name()
134+
_ = s.file.Close()
135+
_ = os.Remove(name)
136+
}
137+
138+
// deadlineReader resets the read deadline before every read to bound a stall
139+
// without capping a steady upload.
140+
type deadlineReader struct {
141+
r io.Reader
142+
rc *http.ResponseController
143+
timeout time.Duration
144+
}
145+
146+
func (d *deadlineReader) Read(p []byte) (int, error) {
147+
_ = d.rc.SetReadDeadline(time.Now().Add(d.timeout))
148+
149+
return d.r.Read(p)
150+
}

requestbodyspool_internal_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package frankenphp
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"log/slog"
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func newSpoolContext(body []byte) *frankenPHPContext {
16+
fc := newFrankenPHPContext()
17+
fc.logger = slog.Default()
18+
fc.request = httptest.NewRequest("POST", "/", bytes.NewReader(body))
19+
20+
return fc
21+
}
22+
23+
// TestSpoolRequestBodyInMemory drains a small body into memory, leaving no temp
24+
// file to clean up.
25+
func TestSpoolRequestBodyInMemory(t *testing.T) {
26+
body := bytes.Repeat([]byte("a"), 1024)
27+
fc := newSpoolContext(body)
28+
29+
fc.spoolRequestBody()
30+
31+
assert.True(t, fc.bodySpooled)
32+
assert.Nil(t, fc.cleanupBody, "a small body stays in memory")
33+
assert.Equal(t, int64(len(body)), fc.request.ContentLength)
34+
35+
got, err := io.ReadAll(fc.request.Body)
36+
require.NoError(t, err)
37+
assert.Equal(t, body, got)
38+
}
39+
40+
// TestSpoolRequestBodyToFile spills a body larger than the memory threshold to a
41+
// temp file, serves identical bytes, and removes the file on cleanup.
42+
func TestSpoolRequestBodyToFile(t *testing.T) {
43+
body := bytes.Repeat([]byte("b"), bodySpoolMemoryThreshold+4096)
44+
fc := newSpoolContext(body)
45+
46+
fc.spoolRequestBody()
47+
48+
assert.True(t, fc.bodySpooled)
49+
require.NotNil(t, fc.cleanupBody, "a large body spills to a temp file")
50+
assert.Equal(t, int64(len(body)), fc.request.ContentLength)
51+
52+
got, err := io.ReadAll(fc.request.Body)
53+
require.NoError(t, err)
54+
assert.Equal(t, body, got)
55+
56+
fc.cleanupBody()
57+
}
58+
59+
// TestSpoolRequestBodySkipsStreaming leaves a body of unknown length untouched so
60+
// long-lived streaming uploads keep their live stream.
61+
func TestSpoolRequestBodySkipsStreaming(t *testing.T) {
62+
fc := newSpoolContext([]byte("streamed"))
63+
fc.request.ContentLength = -1
64+
65+
fc.spoolRequestBody()
66+
67+
assert.False(t, fc.bodySpooled)
68+
got, err := io.ReadAll(fc.request.Body)
69+
require.NoError(t, err)
70+
assert.Equal(t, "streamed", string(got))
71+
}
72+
73+
// TestSpoolRequestBodyIdempotent does not re-drain an already spooled body: a
74+
// second call must not touch the already consumed stream.
75+
func TestSpoolRequestBodyIdempotent(t *testing.T) {
76+
body := bytes.Repeat([]byte("c"), 512)
77+
fc := newSpoolContext(body)
78+
79+
require.NoError(t, fc.spoolRequestBody())
80+
require.NoError(t, fc.spoolRequestBody())
81+
82+
assert.True(t, fc.bodySpooled)
83+
got, err := io.ReadAll(fc.request.Body)
84+
require.NoError(t, err)
85+
assert.Equal(t, body, got)
86+
}
87+
88+
// TestSpoolRequestBodyRejectsOversized rejects a body that overruns a
89+
// request_body max_size limit (an http.MaxBytesReader) with 413 instead of
90+
// feeding PHP a truncated request.
91+
func TestSpoolRequestBodyRejectsOversized(t *testing.T) {
92+
fc := newSpoolContext(bytes.Repeat([]byte("d"), 4096))
93+
rec := httptest.NewRecorder()
94+
fc.responseWriter = rec
95+
fc.request.Body = http.MaxBytesReader(rec, fc.request.Body, 1024)
96+
97+
err := fc.spoolRequestBody()
98+
99+
require.ErrorIs(t, err, ErrRequestBodyTooLarge)
100+
assert.False(t, fc.bodySpooled)
101+
assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code)
102+
}

requestbodyspool_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package frankenphp_test
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"os"
9+
"sync"
10+
"testing"
11+
"time"
12+
13+
"github.com/dunglas/frankenphp"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
// TestConcurrentUploadsHTTP2 reproduces php/frankenphp#1074: many request bodies
19+
// multiplexed on a single HTTP/2 connection, with more streams than PHP threads.
20+
// A queued stream that no thread reads keeps its flow-control window open; enough
21+
// of them exhaust the connection window and deadlock every stream, including the
22+
// ones a thread is serving. Draining queued bodies up front must let all of them
23+
// complete.
24+
func TestConcurrentUploadsHTTP2(t *testing.T) {
25+
require.NoError(t, frankenphp.Init(
26+
frankenphp.WithNumThreads(2),
27+
frankenphp.WithMaxThreads(2),
28+
))
29+
defer frankenphp.Shutdown()
30+
31+
cwd, _ := os.Getwd()
32+
handler := func(w http.ResponseWriter, r *http.Request) {
33+
req, err := frankenphp.NewRequestWithContext(r,
34+
frankenphp.WithRequestDocumentRoot(cwd+"/testdata/", false),
35+
)
36+
require.NoError(t, err)
37+
require.NoError(t, frankenphp.ServeHTTP(w, req))
38+
}
39+
40+
addr, client := newH2CServer(t, handler)
41+
42+
const (
43+
concurrency = 30
44+
bodySize = 512 << 10 // large enough to exhaust the connection window
45+
)
46+
body := bytes.Repeat([]byte("x"), bodySize)
47+
want := fmt.Sprintf("read=%d", bodySize)
48+
49+
var wg sync.WaitGroup
50+
errs := make(chan error, concurrency)
51+
for i := range concurrency {
52+
wg.Add(1)
53+
go func() {
54+
defer wg.Done()
55+
56+
req, err := http.NewRequest(http.MethodPost, "http://"+addr+"/read-input.php", bytes.NewReader(body))
57+
if err != nil {
58+
errs <- err
59+
return
60+
}
61+
req.ContentLength = bodySize
62+
req.Header.Set("Content-Type", "application/octet-stream")
63+
64+
resp, err := client.Do(req)
65+
if err != nil {
66+
errs <- fmt.Errorf("request %d: %w", i, err)
67+
return
68+
}
69+
defer func() { _ = resp.Body.Close() }()
70+
71+
got, err := io.ReadAll(resp.Body)
72+
if err != nil {
73+
errs <- fmt.Errorf("request %d: %w", i, err)
74+
return
75+
}
76+
if string(got) != want {
77+
errs <- fmt.Errorf("request %d: got %q, want %q", i, got, want)
78+
}
79+
}()
80+
}
81+
82+
done := make(chan struct{})
83+
go func() {
84+
wg.Wait()
85+
close(done)
86+
}()
87+
88+
select {
89+
case <-done:
90+
close(errs)
91+
for err := range errs {
92+
assert.NoError(t, err)
93+
}
94+
case <-time.After(30 * time.Second):
95+
t.Fatal("concurrent uploads deadlocked: streams stalled waiting for a PHP thread")
96+
}
97+
}

0 commit comments

Comments
 (0)