Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5a41ea6
puts regular requests on main thread
AlliBalliBaba Oct 8, 2026
4256e99
requeues requests
AlliBalliBaba Oct 8, 2026
af4bd87
Moves the fallbackserver to the main thread
AlliBalliBaba Oct 8, 2026
b64b778
moves servers to current mainthread
AlliBalliBaba Oct 8, 2026
13015cb
makes worker requests survive reloads
AlliBalliBaba Oct 8, 2026
d1aae96
optimize the queue test
AlliBalliBaba Oct 8, 2026
03cc9de
requeues requests only on no config change
AlliBalliBaba Oct 11, 2026
e8bcb9a
also rejects if no worker is found
AlliBalliBaba Oct 11, 2026
1fc1286
fixes tests
AlliBalliBaba Oct 11, 2026
46e3f19
removes reload test on the FrankenPHP side and only keeps them on the…
AlliBalliBaba Oct 11, 2026
1d572d7
Merge branch 'main' into refactor/reloads
AlliBalliBaba Oct 11, 2026
0dc8cf4
fixes tests
AlliBalliBaba Oct 11, 2026
88820ad
catches nil mainThreads
AlliBalliBaba Oct 11, 2026
ee43087
makes reject test more consistent
AlliBalliBaba Oct 11, 2026
58b13c8
cleanup
AlliBalliBaba Oct 15, 2026
354ac19
unregister servers after new main thread is done
AlliBalliBaba Oct 15, 2026
24e9e0a
prevents races on workers
AlliBalliBaba Oct 16, 2026
af6f0c6
moves more vars onto the main thread
AlliBalliBaba Oct 16, 2026
02d295a
moves more config onto the main thread
AlliBalliBaba Oct 16, 2026
36cec47
moves maxidletime onto mainthread
AlliBalliBaba Oct 16, 2026
c961c70
moves regular threads onto mainthread
AlliBalliBaba Oct 16, 2026
93a42a4
moves scaled threads onto main thread
AlliBalliBaba Oct 16, 2026
dcb7726
moves scalechan onto main thread
AlliBalliBaba Oct 16, 2026
e8638b6
moves watcherIsEnabled onto main thread
AlliBalliBaba Oct 16, 2026
9b60fcc
makes RestartWorkers reload-safe
AlliBalliBaba Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion caddy/app.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package caddy

import (
"bytes"
"context"
"errors"
"fmt"
Expand Down Expand Up @@ -69,6 +70,7 @@ type FrankenPHPApp struct {
httpApp *caddyhttp.App
hasStarted atomic.Bool
started chan any
shouldRequeue bool
}

var errIni = errors.New(`"php_ini" must be in the format: php_ini "<key>" "<value>"`)
Expand Down Expand Up @@ -104,6 +106,27 @@ func (f *FrankenPHPApp) Provision(ctx caddy.Context) error {
}
}

// if FrankenPHP was running previously and the configuration has not changed, instruct request re-queueing
if old := caddy.ActiveContext(); old.Context != nil {
if prev, err := old.AppIfConfigured("frankenphp"); err == nil && prev != nil {
if p, ok := prev.(*FrankenPHPApp); ok {
hasChanged := false
if !bytes.Equal(caddyconfig.JSON(p, nil), caddyconfig.JSON(f, nil)) {
hasChanged = true
}
for i, oldModule := range p.modules {
if i >= len(f.modules) {
break
}
if !bytes.Equal(caddyconfig.JSON(oldModule, nil), caddyconfig.JSON(f.modules[i], nil)) {
break
}
}
f.shouldRequeue = !hasChanged
}
}
}

return nil
}

Expand Down Expand Up @@ -139,6 +162,7 @@ func (f *FrankenPHPApp) collectOptions(repl *caddy.Replacer, keep bool) ([]frank
frankenphp.WithMaxWaitTime(f.MaxWaitTime),
frankenphp.WithMaxIdleTime(f.MaxIdleTime),
frankenphp.WithMaxRequests(f.MaxRequests),
frankenphp.WithRequestRequeueOnReload(f.shouldRequeue),
)

usedWorkerNames := make(map[string]bool, len(f.Workers))
Expand All @@ -159,7 +183,9 @@ func (f *FrankenPHPApp) collectOptions(repl *caddy.Replacer, keep bool) ([]frank
return nil, err
}

return append(opts, moduleOpts...), nil
opts = append(opts, moduleOpts...)

return opts, nil
}

func (f *FrankenPHPApp) Start() error {
Expand Down
266 changes: 266 additions & 0 deletions caddy/reload_queue_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
package caddy_test

import (
"bytes"
"io"
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"

"github.com/caddyserver/caddy/v2/caddytest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// https://github.com/php/frankenphp/issues/2469 — queued requests must survive
// a Caddy reload that restarts FrankenPHP with the same configuration.
func TestQueuedRequestSurvivesReload_module(t *testing.T) {
testQueuedRequestSurvivesReload(t, `
{
skip_install_trust
admin localhost:2999
http_port `+testPort+`
metrics

frankenphp {
num_threads 1
max_threads 1
max_wait_time 5s
}
}

localhost:`+testPort+` {
route {
root ../testdata
rewrite sleep.php
php
}
}
`)
}

func TestQueuedRequestSurvivesReload_worker(t *testing.T) {
testQueuedRequestSurvivesReload(t, `
{
skip_install_trust
admin localhost:2999
http_port `+testPort+`
metrics

frankenphp {
num_threads 1
max_threads 2
max_wait_time 5s
worker ../testdata/sleep.php 1
}
}

localhost:`+testPort+` {
route {
root ../testdata
rewrite sleep.php
php
}
}
`)
}

func testQueuedRequestSurvivesReload(t *testing.T, config string) {
tester := caddytest.NewTester(t)
initServer(t, tester, config, "caddyfile")

client := &http.Client{Timeout: 30 * time.Second}
baseURL := "http://localhost:" + testPort + "/sleep.php"
workerMode := strings.Contains(config, "worker ../testdata/sleep.php")

var wg sync.WaitGroup
done := make(chan struct{})

doRequest := func(query string) {
wg.Add(1)
go func() {
defer wg.Done()
req, err := http.NewRequest(http.MethodGet, baseURL+"?"+query, nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err, "request must not be rejected")
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode, "request must not be rejected")
}()
}

// Occupy the only PHP thread so the next request has to wait in the queue.
doRequest("sleep=10")
doRequest("sleep=10")

maxTries := 2000
queued := false
for i := 0; i <= maxTries; i++ {
depth, err := requestQueueDepth(workerMode)
require.NoError(t, err)
if depth > 0 {
queued = true
break
}

doRequest("sleep=" + strconv.Itoa(i))
time.Sleep(time.Millisecond)
if !assert.NotEqual(t, i, maxTries, "request was never queued") {
return
}
}
require.True(t, queued)

// Reload while the request is queued, exactly like the Caddy module does.
reloadCaddyConfig(t, config)

go func() {
wg.Wait()
close(done)
}()

select {
case <-done:
case <-time.After(15 * time.Second):
t.Fatal("the queued request never completed after the reload")
}
}

func TestQueuedRequestRejectedWhenFrankenPHPConfigChanges(t *testing.T) {
memoryLimit := 5000000 // ~5MB
initial := `
{
skip_install_trust
admin localhost:2999
http_port ` + testPort + `
metrics

frankenphp {
num_threads 1
max_threads 1
php_ini memory_limit ` + strconv.Itoa(memoryLimit) + `
}
}

localhost:` + testPort + ` {
route {
root ../testdata
rewrite sleep.php
php
}
}
`

tester := caddytest.NewTester(t)
initServer(t, tester, initial, "caddyfile")

client := &http.Client{Timeout: 30 * time.Second}
baseURL := "http://localhost:" + testPort + "/sleep.php"
requestWasRejected := atomic.Bool{}

doRequest := func(query string) {
go func() {
req, err := http.NewRequest(http.MethodGet, baseURL+"?"+query, nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err, "request must not be rejected")
require.NoError(t, resp.Body.Close())
if resp.StatusCode != http.StatusOK {
requestWasRejected.Store(true)
}
}()
}

sleepMilliseconds := 0
require.Eventually(t, func() bool {
sleepMilliseconds++
changedCaddyConfig := strings.Replace(initial, strconv.Itoa(memoryLimit), strconv.Itoa(memoryLimit+sleepMilliseconds), 1)
doRequest("sleep=" + strconv.Itoa(sleepMilliseconds))
doRequest("sleep=" + strconv.Itoa(sleepMilliseconds))
reloadCaddyConfig(t, changedCaddyConfig)
return requestWasRejected.Load()
}, 10*time.Second, time.Millisecond, "no queued request was ever rejected")
}

func reloadCaddyConfig(t *testing.T, config string) {
t.Helper()

r, err := http.NewRequest(http.MethodPost, "http://localhost:2999/load?force=true", bytes.NewBufferString(config))
require.NoError(t, err)
r.Header.Set("Content-Type", "text/caddyfile")
r.Header.Set("Cache-Control", "must-revalidate")

resp, err := http.DefaultClient.Do(r)
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode, "reload failed: %s", body)
}

func requestQueueDepth(workerMode bool) (float64, error) {
resp, err := http.Get("http://localhost:2999/metrics")
if err != nil {
return 0, err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
_ = resp.Body.Close()
return 0, err
}
if err := resp.Body.Close(); err != nil {
return 0, err
}

text := string(body)
if workerMode {
return sumPrometheusGauge(text, "frankenphp_worker_queue_depth"), nil
}

return prometheusGaugeValue(text, "frankenphp_queue_depth"), nil
}

func prometheusGaugeValue(body, name string) float64 {
for line := range strings.SplitSeq(body, "\n") {
if strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, name+" ") {
fields := strings.Fields(line)
if len(fields) < 2 {
return 0
}
v, err := strconv.ParseFloat(fields[1], 64)
if err != nil {
return 0
}
return v
}
}

return 0
}

func sumPrometheusGauge(body, prefix string) float64 {
var total float64
for line := range strings.SplitSeq(body, "\n") {
if strings.HasPrefix(line, "#") || !strings.HasPrefix(line, prefix) {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
v, err := strconv.ParseFloat(fields[len(fields)-1], 64)
if err != nil {
continue
}
total += v
}

return total
}
2 changes: 1 addition & 1 deletion cgi.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ func splitCgiPath(fc *frankenPHPContext) {
// see if a php_server worker or global worker matches the request path
// aka: root + request path == worker.filename
if fc.worker = fc.server.workersByPath[fc.scriptFilename]; fc.worker == nil {
fc.worker = globalWorkersByPath[fc.scriptFilename]
fc.worker = fc.server.mainThread.workersByPath[fc.scriptFilename]
}
}

Expand Down
4 changes: 2 additions & 2 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ func newWorkerDummyContext(w *worker) (*frankenPHPContext, error) {
server := w.server
if server == nil {
// global worker, not associated with a server
server = fallbackServer
server = mainThread.fallbackServer
}

fc := &frankenPHPContext{
Expand Down Expand Up @@ -168,7 +168,7 @@ func newWorkerDummyContext(w *worker) (*frankenPHPContext, error) {
func newContextFromMessage(message any, rw http.ResponseWriter, ctx context.Context, w *worker) *frankenPHPContext {
server := w.server
if server == nil {
server = fallbackServer
server = mainThread.fallbackServer
}

if ctx == nil {
Expand Down
Loading
Loading