Skip to content

Commit 3e93a24

Browse files
feat: declared background workers + frankenphp_get_worker_handle()
Background workers run a script in a loop outside the HTTP request cycle, sharing the PHP runtime with the request threads. Rebuilt on Server from #2499: a background worker attaches to a php_server through WithWorkerServerScope() like any other worker. Declared with "background" in a worker block (php_server or global) or WithWorkerBackground() in Go. name is required and exposed as $_SERVER['FRANKENPHP_WORKER'], match is rejected, num >= 1. The lifecycle mirrors HTTP workers: re-run on a cooperative exit, restart with quadratic backoff on a crash, max_consecutive_failures fails Init() during startup only. drain() runs on shutdown, reboot and handler transitions so a parked script wakes up instead of waiting out the force-kill grace period. The script gets one handle, frankenphp_get_worker_handle(), a stream that reaches EOF when the worker is drained, meant to carry control messages later. It is backed by a socket pair, not a pipe: on Windows PHP's php_select() only waits properly on sockets before 8.5. Streams do not own the socket (php_sockop_close() would shutdown() it on Windows), so every call returns a fresh stream and closing one never affects another; the read timeout is infinite so a blocking read parks as well as stream_select() does. Both ends are non-inheritable. A worker counts as ready on its first wait on the handle (select cast or read), the background analog of frankenphp_handle_request(): Init() waits for it, ready_workers counts from it, and an exit before it is a boot failure. The handle's stream ops, copied from the socket ops at MINIT, report it once per run. Worker names are scoped like paths: unique within a php_server or among global workers. The script sees the declared name; metrics and logs report a scoped worker as "<server name>:<name>", with a numeric suffix on server names when two blocks resolve to the same one. The collision-driven renaming in the Caddy module is gone, and WithWorkerName() resolves within the request's server first. Supersedes #2543 and #2398.
1 parent 2e33427 commit 3e93a24

31 files changed

Lines changed: 1133 additions & 182 deletions

bgworker_test.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package frankenphp_test
2+
3+
import (
4+
"net/http"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
"time"
9+
10+
"github.com/dunglas/frankenphp"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
// requireFileEventually asserts that `path` appears on disk before the
16+
// deadline. Wraps require.Eventually so call sites stay short.
17+
func requireFileEventually(t testing.TB, path string, msgAndArgs ...any) {
18+
t.Helper()
19+
require.Eventually(t, func() bool {
20+
_, err := os.Stat(path)
21+
return err == nil
22+
}, 5*time.Second, 25*time.Millisecond, msgAndArgs...)
23+
}
24+
25+
// TestBackgroundWorkerLifecycle boots a background worker that touches a
26+
// sentinel file then parks on its handle. It proves the bg worker runs
27+
// (sentinel appears) and that Shutdown returns within a reasonable time.
28+
// The test asserts on Shutdown timing, so it manages Shutdown itself
29+
// instead of using initServers' t.Cleanup hook.
30+
func TestBackgroundWorkerLifecycle(t *testing.T) {
31+
tmp := t.TempDir()
32+
sentinel := filepath.Join(tmp, "bg-lifecycle.sentinel")
33+
34+
require.NoError(t, frankenphp.Init(
35+
frankenphp.WithWorkers("bg-lifecycle", "testdata/bgworker/basic.php", 1,
36+
frankenphp.WithWorkerBackground(),
37+
frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}),
38+
),
39+
frankenphp.WithNumThreads(2),
40+
))
41+
42+
requireFileEventually(t, sentinel, "background worker did not touch sentinel")
43+
44+
done := make(chan struct{})
45+
go func() {
46+
frankenphp.Shutdown()
47+
close(done)
48+
}()
49+
50+
select {
51+
case <-done:
52+
case <-time.After(10 * time.Second):
53+
t.Fatalf("Shutdown did not return within 10s")
54+
}
55+
}
56+
57+
// TestBackgroundWorkerCrashRestarts boots a worker that exit(1)s on its
58+
// first run and touches a "restarted" sentinel on its second run. The
59+
// sentinel proves the crash-restart loop fired.
60+
func TestBackgroundWorkerCrashRestarts(t *testing.T) {
61+
tmp := t.TempDir()
62+
crashMarker := filepath.Join(tmp, "bg-crash.marker")
63+
restarted := filepath.Join(tmp, "bg-crash.restarted")
64+
65+
initServers(t,
66+
frankenphp.WithWorkers("bg-crash", "testdata/bgworker/crash.php", 1,
67+
frankenphp.WithWorkerBackground(),
68+
frankenphp.WithWorkerEnv(map[string]string{
69+
"BG_CRASH_MARKER": crashMarker,
70+
"BG_RESTARTED_SENTINEL": restarted,
71+
}),
72+
),
73+
frankenphp.WithNumThreads(2),
74+
)
75+
76+
requireFileEventually(t, restarted, "background worker did not restart after crash")
77+
}
78+
79+
// TestBackgroundWorkerOnServer scopes a background worker to a Server. It
80+
// proves that the worker inherits the server env (the sentinel directory is
81+
// declared on the server, not on the worker), that FRANKENPHP_WORKER holds
82+
// the worker name, and that the worker does not intercept HTTP requests
83+
// served by the same server.
84+
func TestBackgroundWorkerOnServer(t *testing.T) {
85+
tmp := t.TempDir()
86+
87+
server, err := frankenphp.NewServer(
88+
testDataDir,
89+
frankenphp.WithServerName("sidekick-server"),
90+
frankenphp.WithServerEnv(map[string]string{"BG_SENTINEL_DIR": tmp}),
91+
)
92+
require.NoError(t, err)
93+
94+
globalSentinel := filepath.Join(tmp, "global.sentinel")
95+
initServers(t,
96+
frankenphp.WithServer(server),
97+
frankenphp.WithWorkers("jobs", "testdata/bgworker/named.php", 1,
98+
frankenphp.WithWorkerBackground(),
99+
frankenphp.WithWorkerServerScope(server),
100+
),
101+
// a global worker may reuse the name: names are scoped to their server
102+
frankenphp.WithWorkers("jobs", "testdata/bgworker/basic.php", 1,
103+
frankenphp.WithWorkerBackground(),
104+
frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": globalSentinel}),
105+
),
106+
frankenphp.WithNumThreads(3),
107+
)
108+
109+
// named.php touches "<BG_SENTINEL_DIR>/<FRANKENPHP_WORKER>": the script sees
110+
// the declared name, not the server-qualified one used by metrics and logs
111+
requireFileEventually(t, filepath.Join(tmp, "jobs"), "background worker did not touch its per-name sentinel")
112+
requireFileEventually(t, globalSentinel, "the global worker sharing the name did not start")
113+
114+
body := serverGet(t, server, "http://example.com/index.php")
115+
assert.Contains(t, body, "I am by birth a Genevese", "the server must still serve regular requests")
116+
}
117+
118+
// TestBackgroundWorkerValidation covers the declaration-time errors.
119+
func TestBackgroundWorkerValidation(t *testing.T) {
120+
t.Cleanup(frankenphp.Shutdown)
121+
122+
t.Run("name is required", func(t *testing.T) {
123+
err := frankenphp.Init(
124+
frankenphp.WithWorkers("", "testdata/bgworker/basic.php", 1, frankenphp.WithWorkerBackground()),
125+
frankenphp.WithNumThreads(2),
126+
)
127+
require.ErrorContains(t, err, "must have an explicit name")
128+
})
129+
130+
t.Run("num must be >= 1", func(t *testing.T) {
131+
err := frankenphp.Init(
132+
frankenphp.WithWorkers("bg-zero", "testdata/bgworker/basic.php", 0, frankenphp.WithWorkerBackground()),
133+
frankenphp.WithNumThreads(2),
134+
)
135+
require.ErrorContains(t, err, "must declare num >= 1")
136+
})
137+
138+
t.Run("names are unique within a server", func(t *testing.T) {
139+
// a global and a server-scoped worker may share a name (see
140+
// TestBackgroundWorkerOnServer), two workers of one server may not
141+
server, err := frankenphp.NewServer(testDataDir)
142+
require.NoError(t, err)
143+
err = frankenphp.Init(
144+
frankenphp.WithServer(server),
145+
frankenphp.WithWorkers("bg-shared", "testdata/bgworker/basic.php", 1,
146+
frankenphp.WithWorkerBackground(),
147+
frankenphp.WithWorkerServerScope(server),
148+
),
149+
frankenphp.WithWorkers("bg-shared", "testdata/bgworker/named.php", 1,
150+
frankenphp.WithWorkerBackground(),
151+
frankenphp.WithWorkerServerScope(server),
152+
),
153+
frankenphp.WithNumThreads(3),
154+
)
155+
require.ErrorContains(t, err, "two workers in a server cannot have the same name")
156+
})
157+
158+
t.Run("early return without the handle fails startup", func(t *testing.T) {
159+
err := frankenphp.Init(
160+
frankenphp.WithWorkers("bg-early", "testdata/bgworker/early-return.php", 1,
161+
frankenphp.WithWorkerBackground(),
162+
frankenphp.WithWorkerMaxFailures(2),
163+
),
164+
frankenphp.WithNumThreads(2),
165+
)
166+
require.ErrorContains(t, err, "frankenphp_get_worker_handle")
167+
})
168+
169+
t.Run("fetching the handle without waiting on it fails startup", func(t *testing.T) {
170+
err := frankenphp.Init(
171+
frankenphp.WithWorkers("bg-no-wait", "testdata/bgworker/fetch-no-wait.php", 1,
172+
frankenphp.WithWorkerBackground(),
173+
frankenphp.WithWorkerMaxFailures(2),
174+
),
175+
frankenphp.WithNumThreads(2),
176+
)
177+
require.ErrorContains(t, err, "waiting on its handle")
178+
})
179+
180+
t.Run("request matchers are rejected", func(t *testing.T) {
181+
err := frankenphp.Init(
182+
frankenphp.WithWorkers("bg-matched", "testdata/bgworker/basic.php", 1,
183+
frankenphp.WithWorkerBackground(),
184+
frankenphp.WithWorkerMatcher(func(*http.Request) bool { return true }),
185+
),
186+
frankenphp.WithNumThreads(2),
187+
)
188+
require.ErrorContains(t, err, "cannot match requests")
189+
})
190+
}

caddy/app.go

Lines changed: 8 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import (
1717
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
1818
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
1919
"github.com/dunglas/frankenphp"
20-
"github.com/dunglas/frankenphp/internal/fastabs"
2120
)
2221

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

63-
opts []frankenphp.Option
64-
metrics frankenphp.Metrics
65-
ctx context.Context
66-
logger *slog.Logger
67-
modules []*FrankenPHPModule
68-
usedWorkerNames map[string]bool
69-
httpApp *caddyhttp.App
70-
hasStarted atomic.Bool
71-
started chan any
62+
opts []frankenphp.Option
63+
metrics frankenphp.Metrics
64+
ctx context.Context
65+
logger *slog.Logger
66+
modules []*FrankenPHPModule
67+
httpApp *caddyhttp.App
68+
hasStarted atomic.Bool
69+
started chan any
7270
}
7371

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

225222
for _, w := range module.Workers {
226223
w.FileName = repl.ReplaceKnown(w.FileName, "")
227-
w.Name = f.createUniqueWorkerName(w, serverName)
228224
workerOptions, err := w.toWorkerOptions()
229225
if err != nil {
230226
return err
@@ -236,37 +232,6 @@ func (f *FrankenPHPApp) registerModule(repl *caddy.Replacer, module *FrankenPHPM
236232
return nil
237233
}
238234

239-
// avoid name collisions for workers
240-
// on collision, a name is first qualified with the server name
241-
// ("<serverName>:<name>") before falling back to a numeric postfix
242-
func (f *FrankenPHPApp) createUniqueWorkerName(wc workerConfig, serverName string) string {
243-
if f.usedWorkerNames == nil {
244-
f.usedWorkerNames = make(map[string]bool)
245-
}
246-
247-
if wc.Name == "" {
248-
wc.Name, _ = fastabs.FastAbs(wc.FileName)
249-
}
250-
251-
name := wc.Name
252-
suffix := 0
253-
for {
254-
if _, ok := f.usedWorkerNames[name]; !ok {
255-
f.usedWorkerNames[name] = true
256-
break
257-
}
258-
if serverName != "" {
259-
name = serverName + ":" + wc.Name
260-
serverName = ""
261-
continue
262-
}
263-
suffix++
264-
name = fmt.Sprintf("%s_%d", wc.Name, suffix)
265-
}
266-
267-
return name
268-
}
269-
270235
// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
271236
func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
272237
for d.Next() {

caddy/caddy_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -839,7 +839,7 @@ func TestWorkerMetrics(t *testing.T) {
839839
# TYPE frankenphp_worker_request_count counter
840840
frankenphp_worker_request_count{worker="` + workerName + `"} 10
841841
842-
# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
842+
# HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_get_worker_handle for background workers
843843
# TYPE frankenphp_ready_workers gauge
844844
frankenphp_ready_workers{worker="` + workerName + `"} 2
845845
`
@@ -996,7 +996,7 @@ func TestNamedWorkerMetrics(t *testing.T) {
996996
# TYPE frankenphp_worker_request_count counter
997997
frankenphp_worker_request_count{worker="my_app"} 10
998998
999-
# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
999+
# HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_get_worker_handle for background workers
10001000
# TYPE frankenphp_ready_workers gauge
10011001
frankenphp_ready_workers{worker="my_app"} 2
10021002
`
@@ -1092,7 +1092,7 @@ func TestAutoWorkerConfig(t *testing.T) {
10921092
# TYPE frankenphp_worker_request_count counter
10931093
frankenphp_worker_request_count{worker="` + workerName + `"} 10
10941094
1095-
# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
1095+
# HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_get_worker_handle for background workers
10961096
# TYPE frankenphp_ready_workers gauge
10971097
frankenphp_ready_workers{worker="` + workerName + `"} ` + workers + `
10981098
`
@@ -1460,7 +1460,7 @@ func TestMultiWorkersMetrics(t *testing.T) {
14601460
# TYPE frankenphp_worker_request_count counter
14611461
frankenphp_worker_request_count{worker="service1"} 10
14621462
1463-
# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
1463+
# HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_get_worker_handle for background workers
14641464
# TYPE frankenphp_ready_workers gauge
14651465
frankenphp_ready_workers{worker="service1"} 2
14661466
frankenphp_ready_workers{worker="service2"} 3
@@ -1614,7 +1614,7 @@ func TestWorkerRestart(t *testing.T) {
16141614

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

16431643
// frankenphp_ready_workers should be back to 1 even after worker restarts
16441644
expectedMetrics = `
1645-
# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
1645+
# HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_get_worker_handle for background workers
16461646
# TYPE frankenphp_ready_workers gauge
16471647
frankenphp_ready_workers{worker="service"} 1
16481648
# HELP frankenphp_total_workers Total number of PHP workers for this worker

0 commit comments

Comments
 (0)