Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ec3892c
feat: declared background workers + frankenphp_get_worker_handle()
nicolas-grekas Sep 6, 2026
910ed5e
fix: a blocking receive on the handle marks a background worker ready
nicolas-grekas Sep 11, 2026
7f1a697
fix: never inherit the reserved worker variables
nicolas-grekas Sep 11, 2026
470a969
fix: cap the restart backoff, and EOF a handle a forked child still h…
nicolas-grekas Sep 11, 2026
967ac03
fix: resolve max_threads auto on the HTTP budget
nicolas-grekas Sep 11, 2026
2384f90
chore: simplifications and wording from the review
nicolas-grekas Sep 11, 2026
0feaf99
test: cover the parked run and the handle cache
nicolas-grekas Sep 11, 2026
61eb3bb
docs: flag frankenphp_get_worker_handle() experimental in the stub
nicolas-grekas Sep 13, 2026
20a5762
feat: frankenphp_worker_tick(), the explicit ready point of backgroun…
nicolas-grekas Sep 13, 2026
2348eba
feat: FRANKENPHP_WORKER_BACKGROUND holds the name, HTTP workers untou…
nicolas-grekas Sep 14, 2026
62b00bc
feat: wake a background worker once at start
nicolas-grekas Sep 14, 2026
fa129c2
docs: event loop example for background workers
nicolas-grekas Sep 14, 2026
3f29016
refactor: one lifecycle abstraction instead of two
nicolas-grekas Sep 14, 2026
9733fcf
fix: never block or race on the startup failure channel
nicolas-grekas Sep 14, 2026
b9e4dd9
feat: max_execution_time bounds the bootstrap of a background worker
nicolas-grekas Sep 14, 2026
eb8d501
docs: stop the whole loop on drain in the Revolt example
nicolas-grekas Sep 14, 2026
f30e49d
test: only assert the bounded bootstrap where the timers are known to…
nicolas-grekas Sep 14, 2026
48917b5
feat: frankenphp_get_vars() and frankenphp_set_vars()
nicolas-grekas Sep 6, 2026
fd177fc
feat: frankenphp_send_task(), frankenphp_receive_task() and friends
nicolas-grekas Sep 7, 2026
2514be8
feat: metrics for the tasks of background workers
nicolas-grekas Sep 7, 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
664 changes: 664 additions & 0 deletions bgworker_test.go

Large diffs are not rendered by default.

62 changes: 15 additions & 47 deletions caddy/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import (
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/dunglas/frankenphp"
"github.com/dunglas/frankenphp/internal/fastabs"
)

var (
Expand Down Expand Up @@ -60,15 +59,14 @@ type FrankenPHPApp struct {
// EXPERIMENTAL: MaxRequests sets the maximum number of requests a PHP thread handles before restarting (0 = unlimited)
MaxRequests int `json:"max_requests,omitempty"`

opts []frankenphp.Option
metrics frankenphp.Metrics
ctx context.Context
logger *slog.Logger
modules []*FrankenPHPModule
usedWorkerNames map[string]bool
httpApp *caddyhttp.App
hasStarted atomic.Bool
started chan any
opts []frankenphp.Option
metrics frankenphp.Metrics
ctx context.Context
logger *slog.Logger
modules []*FrankenPHPModule
httpApp *caddyhttp.App
hasStarted atomic.Bool
started chan any
}

var errIni = errors.New(`"php_ini" must be in the format: php_ini "<key>" "<value>"`)
Expand Down Expand Up @@ -133,7 +131,6 @@ func (f *FrankenPHPApp) Start() error {
// register global workers
for _, w := range f.Workers {
w.FileName = repl.ReplaceKnown(w.FileName, "")
w.Name = f.createUniqueWorkerName(w, "")
opts, err := w.toWorkerOptions()
if err != nil {
return err
Expand Down Expand Up @@ -224,7 +221,6 @@ func (f *FrankenPHPApp) registerModule(repl *caddy.Replacer, module *FrankenPHPM

for _, w := range module.Workers {
w.FileName = repl.ReplaceKnown(w.FileName, "")
w.Name = f.createUniqueWorkerName(w, serverName)
workerOptions, err := w.toWorkerOptions()
if err != nil {
return err
Expand All @@ -236,37 +232,6 @@ func (f *FrankenPHPApp) registerModule(repl *caddy.Replacer, module *FrankenPHPM
return nil
}

// avoid name collisions for workers
// on collision, a name is first qualified with the server name
// ("<serverName>:<name>") before falling back to a numeric postfix
func (f *FrankenPHPApp) createUniqueWorkerName(wc workerConfig, serverName string) string {
if f.usedWorkerNames == nil {
f.usedWorkerNames = make(map[string]bool)
}

if wc.Name == "" {
wc.Name, _ = fastabs.FastAbs(wc.FileName)
}

name := wc.Name
suffix := 0
for {
if _, ok := f.usedWorkerNames[name]; !ok {
f.usedWorkerNames[name] = true
break
}
if serverName != "" {
name = serverName + ":" + wc.Name
serverName = ""
continue
}
suffix++
name = fmt.Sprintf("%s_%d", wc.Name, suffix)
}

return name
}

// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
for d.Next() {
Expand Down Expand Up @@ -381,10 +346,13 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
if len(wc.MatchPath) != 0 {
return d.Errf(`"match" can only be used in a php_server worker block, not in a global one: %q`, wc.FileName)
}
// check for duplicate workers
for _, existingWorker := range f.Workers {
if existingWorker.FileName == wc.FileName {
return d.Errf("global workers must not have duplicate filenames: %q", wc.FileName)
// check for duplicate workers; background workers are keyed
// by name, several may share a script
if !wc.Background {
for _, existingWorker := range f.Workers {
if !existingWorker.Background && existingWorker.FileName == wc.FileName {
return d.Errf("global workers must not have duplicate filenames: %q", wc.FileName)
}
}
}

Expand Down
62 changes: 51 additions & 11 deletions caddy/caddy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,45 @@ func TestMetrics(t *testing.T) {
require.NoError(t, testutil.GatherAndCompare(ctx.GetMetricsRegistry(), strings.NewReader(expectedMetrics), "frankenphp_total_threads", "frankenphp_busy_threads"))
}

// TestBackgroundWorkerFromCaddyfile starts a background worker from a
// Caddyfile and checks it runs: the sentinel its script touches appears
func TestBackgroundWorkerFromCaddyfile(t *testing.T) {
sentinel := filepath.ToSlash(filepath.Join(t.TempDir(), "bg.sentinel"))
tester := caddytest.NewTester(t)
initServer(t, tester, `
{
skip_install_trust
admin localhost:2999
http_port `+testPort+`
https_port 9443

frankenphp {
worker {
file ../testdata/bgworker/basic.php
num 1
name bg-caddy
background
env BG_SENTINEL `+sentinel+`
}
}
}

localhost:`+testPort+` {
route {
php {
root ../testdata
}
}
}
`, "caddyfile")

require.Eventually(t, func() bool {
_, err := os.Stat(sentinel)

return err == nil
}, 5*time.Second, 25*time.Millisecond, "the background worker declared in the Caddyfile did not run")
}

func TestWorkerMetrics(t *testing.T) {
var wg sync.WaitGroup
tester := caddytest.NewTester(t)
Expand Down Expand Up @@ -827,7 +866,7 @@ func TestWorkerMetrics(t *testing.T) {
# TYPE frankenphp_busy_threads gauge
frankenphp_busy_threads 2

# HELP frankenphp_busy_workers Number of busy PHP workers for this worker
# HELP frankenphp_busy_workers Number of busy PHP workers for this worker: processing a request, or a task for a background worker
# TYPE frankenphp_busy_workers gauge
frankenphp_busy_workers{worker="` + workerName + `"} 0

Expand All @@ -839,7 +878,7 @@ func TestWorkerMetrics(t *testing.T) {
# TYPE frankenphp_worker_request_count counter
frankenphp_worker_request_count{worker="` + workerName + `"} 10

# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
# HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_tick for background workers
# TYPE frankenphp_ready_workers gauge
frankenphp_ready_workers{worker="` + workerName + `"} 2
`
Expand Down Expand Up @@ -984,7 +1023,7 @@ func TestNamedWorkerMetrics(t *testing.T) {
# TYPE frankenphp_busy_threads gauge
frankenphp_busy_threads 2

# HELP frankenphp_busy_workers Number of busy PHP workers for this worker
# HELP frankenphp_busy_workers Number of busy PHP workers for this worker: processing a request, or a task for a background worker
# TYPE frankenphp_busy_workers gauge
frankenphp_busy_workers{worker="my_app"} 0

Expand All @@ -996,7 +1035,7 @@ func TestNamedWorkerMetrics(t *testing.T) {
# TYPE frankenphp_worker_request_count counter
frankenphp_worker_request_count{worker="my_app"} 10

# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
# HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_tick for background workers
# TYPE frankenphp_ready_workers gauge
frankenphp_ready_workers{worker="my_app"} 2
`
Expand Down Expand Up @@ -1080,7 +1119,7 @@ func TestAutoWorkerConfig(t *testing.T) {
# TYPE frankenphp_busy_threads gauge
frankenphp_busy_threads ` + workers + `

# HELP frankenphp_busy_workers Number of busy PHP workers for this worker
# HELP frankenphp_busy_workers Number of busy PHP workers for this worker: processing a request, or a task for a background worker
# TYPE frankenphp_busy_workers gauge
frankenphp_busy_workers{worker="` + workerName + `"} 0

Expand All @@ -1092,7 +1131,7 @@ func TestAutoWorkerConfig(t *testing.T) {
# TYPE frankenphp_worker_request_count counter
frankenphp_worker_request_count{worker="` + workerName + `"} 10

# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
# HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_tick for background workers
# TYPE frankenphp_ready_workers gauge
frankenphp_ready_workers{worker="` + workerName + `"} ` + workers + `
`
Expand Down Expand Up @@ -1347,6 +1386,7 @@ func TestMaxWaitTimeWorker(t *testing.T) {
require.NoError(t, err)

expectedMetrics := `
# HELP frankenphp_worker_queue_depth Number of queued requests for this worker, or of tasks waiting for a thread of a background worker
# TYPE frankenphp_worker_queue_depth gauge
frankenphp_worker_queue_depth{worker="service"} 0
`
Expand Down Expand Up @@ -1447,7 +1487,7 @@ func TestMultiWorkersMetrics(t *testing.T) {
# TYPE frankenphp_busy_threads gauge
frankenphp_busy_threads 5

# HELP frankenphp_busy_workers Number of busy PHP workers for this worker
# HELP frankenphp_busy_workers Number of busy PHP workers for this worker: processing a request, or a task for a background worker
# TYPE frankenphp_busy_workers gauge
frankenphp_busy_workers{worker="service1"} 0

Expand All @@ -1460,7 +1500,7 @@ func TestMultiWorkersMetrics(t *testing.T) {
# TYPE frankenphp_worker_request_count counter
frankenphp_worker_request_count{worker="service1"} 10

# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
# HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_tick for background workers
# TYPE frankenphp_ready_workers gauge
frankenphp_ready_workers{worker="service1"} 2
frankenphp_ready_workers{worker="service2"} 3
Expand Down Expand Up @@ -1614,7 +1654,7 @@ func TestWorkerRestart(t *testing.T) {

// Check metrics
expectedMetrics := `
# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
# HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_tick for background workers
# TYPE frankenphp_ready_workers gauge
frankenphp_ready_workers{worker="service"} 1
# HELP frankenphp_total_workers Total number of PHP workers for this worker
Expand Down Expand Up @@ -1642,7 +1682,7 @@ func TestWorkerRestart(t *testing.T) {

// frankenphp_ready_workers should be back to 1 even after worker restarts
expectedMetrics = `
# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
# HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_tick for background workers
# TYPE frankenphp_ready_workers gauge
frankenphp_ready_workers{worker="service"} 1
# HELP frankenphp_total_workers Total number of PHP workers for this worker
Expand Down Expand Up @@ -2113,7 +2153,7 @@ func TestSymlinkWorkerBehavior(t *testing.T) {

// Accessing the worker script without worker configuration MUST fail
// The script checks $_SERVER['FRANKENPHP_WORKER'] and dies if not set
tester.AssertGetResponse("http://localhost:"+testPort+"/index.php", http.StatusOK, "Error: This script must be run in worker mode (FRANKENPHP_WORKER not set to '1')\n")
tester.AssertGetResponse("http://localhost:"+testPort+"/index.php", http.StatusOK, "Error: This script must be run in worker mode (FRANKENPHP_WORKER not set)\n")
})

t.Run("MultipleRequests", func(t *testing.T) {
Expand Down
Loading
Loading