Skip to content

Commit 11d70ae

Browse files
authored
fix: reuse created-once channels so queued requests don't get stuck on replaced channels (#2470)
fixes #2469
1 parent 3de9d99 commit 11d70ae

6 files changed

Lines changed: 77 additions & 8 deletions

File tree

.github/actions/watcher/action.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ runs:
1818
- if: steps.cache-watcher.outputs.cache-hit != 'true'
1919
name: Compile e-dant/watcher
2020
run: |
21-
mkdir watcher
21+
mkdir -p watcher
22+
rm -rf watcher/*
2223
gh release download --repo e-dant/watcher -A tar.gz -O - | tar -xz -C watcher --strip-components 1
2324
cd watcher
2425
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release

frankenphp.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import (
3232
"runtime"
3333
"strings"
3434
"sync"
35+
"sync/atomic"
3536
"syscall"
3637
"time"
3738
"unsafe"
@@ -66,7 +67,8 @@ var (
6667

6768
metrics Metrics = nullMetrics{}
6869

69-
maxWaitTime time.Duration
70+
// atomic: read by in-flight requests while a reload may rewrite it
71+
maxWaitTime atomic.Int64
7072
maxRequestsPerThread int
7173
)
7274

@@ -275,7 +277,7 @@ func Init(options ...Option) error {
275277
metrics = opt.metrics
276278
}
277279

278-
maxWaitTime = opt.maxWaitTime
280+
maxWaitTime.Store(int64(opt.maxWaitTime))
279281
maxRequestsPerThread = opt.maxRequests
280282

281283
if opt.maxIdleTime > 0 {
@@ -317,7 +319,10 @@ func Init(options ...Option) error {
317319
return err
318320
}
319321

320-
regularRequestChan = make(chan contextHolder)
322+
// reused across reloads so queued requests aren't orphaned on a stale channel
323+
if regularRequestChan == nil {
324+
regularRequestChan = make(chan contextHolder)
325+
}
321326
regularThreads = make([]*phpThread, 0, opt.numThreads-workerThreadCount)
322327
for i := 0; i < opt.numThreads-workerThreadCount; i++ {
323328
convertToRegularThread(getInactivePHPThread())

phpmainthread_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,65 @@ func TestTransitionThreadsWhileDoingRequests(t *testing.T) {
166166
transitionsWG.Wait()
167167
}
168168

169+
// Test https://github.com/php/frankenphp/issues/2469
170+
// A request queued for a thread when the server reloads must be served
171+
// by the new threads, not rejected with ErrMaxWaitTimeExceeded.
172+
func TestQueuedRequestSurvivesReload(t *testing.T) {
173+
t.Cleanup(Shutdown)
174+
175+
initOpts := []Option{
176+
WithNumThreads(1),
177+
WithMaxThreads(1),
178+
WithMaxWaitTime(5 * time.Second),
179+
}
180+
assert.NoError(t, Init(initOpts...))
181+
182+
doRequest := func(query string) error {
183+
r := httptest.NewRequest("GET", "http://localhost/sleep.php?"+query, nil)
184+
w := httptest.NewRecorder()
185+
req, err := NewRequestWithContext(r, WithRequestDocumentRoot(testDataPath, false))
186+
if err != nil {
187+
return err
188+
}
189+
190+
return ServeHTTP(w, req)
191+
}
192+
193+
// Occupy the single PHP thread so the next request has to wait in the queue.
194+
started := make(chan struct{})
195+
go func() {
196+
close(started)
197+
_ = doRequest("sleep=800")
198+
}()
199+
<-started
200+
time.Sleep(100 * time.Millisecond)
201+
202+
queued := make(chan error, 1)
203+
go func() {
204+
queued <- doRequest("sleep=0")
205+
}()
206+
207+
// Wait until the second request is genuinely queued waiting for a thread.
208+
deadline := time.Now().Add(2 * time.Second)
209+
for queuedRegularThreads.Load() == 0 {
210+
if !assert.True(t, time.Now().Before(deadline), "request was never queued") {
211+
return
212+
}
213+
time.Sleep(time.Millisecond)
214+
}
215+
216+
// Reload while the request is queued, exactly like the Caddy module does.
217+
Shutdown()
218+
assert.NoError(t, Init(initOpts...))
219+
220+
select {
221+
case err := <-queued:
222+
assert.NoError(t, err, "a queued request must not be rejected by a reload")
223+
case <-time.After(15 * time.Second):
224+
t.Fatal("the queued request never completed after the reload")
225+
}
226+
}
227+
169228
func TestFinishBootingAWorkerScript(t *testing.T) {
170229
setupGlobals(t)
171230

scaling.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,19 @@ var (
3535
)
3636

3737
func initAutoScaling(mainThread *phpMainThread) {
38+
// reused across reloads so queued requests aren't orphaned on a stale channel
39+
if scaleChan == nil {
40+
scaleChan = make(chan *frankenPHPContext)
41+
}
42+
3843
if mainThread.maxThreads <= mainThread.numThreads {
39-
scaleChan = nil
4044
return
4145
}
4246

4347
done := mainThread.done
4448
mstate := mainThread.state
4549

4650
scalingMu.Lock()
47-
scaleChan = make(chan *frankenPHPContext)
4851
maxScaledThreads := mainThread.maxThreads - mainThread.numThreads
4952
autoScaledThreads = make([]*phpThread, 0, maxScaledThreads)
5053
scalingMu.Unlock()

threadregular.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"runtime"
77
"sync"
88
"sync/atomic"
9+
"time"
910

1011
"github.com/dunglas/frankenphp/internal/state"
1112
)
@@ -169,7 +170,7 @@ func handleRequestWithRegularPHPThreads(ch contextHolder) error {
169170
return nil
170171
case scaleChan <- ch.frankenPHPContext:
171172
// the request has triggered scaling, continue to wait for a thread
172-
case <-timeoutChan(maxWaitTime):
173+
case <-timeoutChan(time.Duration(maxWaitTime.Load())):
173174
// the request has timed out stalling
174175
queuedRegularThreads.Add(-1)
175176
metrics.DequeuedRequest()

worker.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ func (worker *worker) handleRequest(ch contextHolder) error {
330330
return nil
331331
case workerScaleChan <- ch.frankenPHPContext:
332332
// the request has triggered scaling, continue to wait for a thread
333-
case <-timeoutChan(maxWaitTime):
333+
case <-timeoutChan(time.Duration(maxWaitTime.Load())):
334334
// the request has timed out stalling
335335
worker.queuedRequests.Add(-1)
336336
metrics.DequeuedWorkerRequest(worker.name)

0 commit comments

Comments
 (0)