Skip to content

Commit d74a07c

Browse files
feat: background workers (config + ensure)
Adds background workers — long-lived non-HTTP PHP scripts that share the FrankenPHP runtime with HTTP workers but stay outside the request cycle. Implements: - WithWorkerBackground option / Caddyfile `worker { background }` declares a worker as a bg worker. $_SERVER['FRANKENPHP_WORKER'] carries the user-facing worker name, $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] is true for bg workers so scripts can branch without checking every function independently. - frankenphp_ensure_background_worker(string|array $name, ?float $timeout) lazy-starts a named bg worker (num=0 declarations stay parked until ensure() is called) or matches a catch-all declaration to spawn a named instance. Accepts an array of names sharing one deadline. Two-mode: fail-fast in HTTP-worker bootstrap so a broken dependency surfaces at boot; tolerant inside requests so the restart cycle can recover from transient boot failures. - Per-php_server scope isolation: each php_server block gets its own Scope (opaque uint64). Workers in distinct scopes can share a name without colliding. The Caddy module resolves a human-friendly label via cascade (route host matcher -> user-set Caddy server name -> first listener address) and registers it via SetScopeLabel so future metric/log emitters can render server="api.example.com". - Catch-all dispatch: a name-less bg worker declaration matches any ensure() name at runtime. max_threads on a catch-all caps how many distinct lazy-started instance names it can host (default 16). - bg-worker bootstrap routes the runtime name through the CGI pipeline so $_SERVER['FRANKENPHP_WORKER'] reflects the user-facing name on every request, not just bg-worker boot. - Bg workers expose a stop pipe (frankenphp_get_worker_handle) so PHP scripts can park on stream_select and exit gracefully when FrankenPHP drains. - max_consecutive_failures cap fails Init fast (HTTP-worker mode) or shuts the bg-worker thread down cleanly, with a deterministic abort message instead of a generic ensure() timeout. Tests cover: Caddyfile + Go-API declarations, ensure() lazy-start, catch-all dispatch + cap, batch ensure with shared deadline, error paths (undeclared name, boot failure metadata, type-validation), per- php_server scope isolation including same-named workers in distinct scopes.
1 parent 24be668 commit d74a07c

40 files changed

Lines changed: 2416 additions & 23 deletions

bgworker.go

Lines changed: 422 additions & 0 deletions
Large diffs are not rendered by default.

bgworker_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package frankenphp_test
2+
3+
import (
4+
"path/filepath"
5+
"testing"
6+
"time"
7+
8+
"github.com/dunglas/frankenphp"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// TestBackgroundWorkerLifecycle boots a background worker that touches a
14+
// sentinel file then parks on the stop pipe. It proves the bg worker runs
15+
// (sentinel appears) and that Shutdown returns within a reasonable time.
16+
func TestBackgroundWorkerLifecycle(t *testing.T) {
17+
tmp := t.TempDir()
18+
sentinel := filepath.Join(tmp, "bg-lifecycle.sentinel")
19+
20+
require.NoError(t, frankenphp.Init(
21+
frankenphp.WithWorkers("bg-lifecycle", "testdata/background-worker.php", 1,
22+
frankenphp.WithWorkerBackground(),
23+
frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}),
24+
),
25+
frankenphp.WithNumThreads(2),
26+
))
27+
// Note: this test asserts on Shutdown timing, so it manages Shutdown
28+
// itself instead of using setupFrankenPHP's t.Cleanup hook.
29+
30+
requireFileEventually(t, sentinel, "background worker did not touch sentinel")
31+
32+
done := make(chan struct{})
33+
go func() {
34+
frankenphp.Shutdown()
35+
close(done)
36+
}()
37+
38+
select {
39+
case <-done:
40+
case <-time.After(10 * time.Second):
41+
t.Fatalf("Shutdown did not return within 10s")
42+
}
43+
}
44+
45+
// TestBackgroundWorkerCrashRestarts boots a worker that exit(1)s on its
46+
// first run and touches a "restarted" sentinel on its second run. The
47+
// sentinel proves the crash-restart loop fired.
48+
func TestBackgroundWorkerCrashRestarts(t *testing.T) {
49+
tmp := t.TempDir()
50+
crashMarker := filepath.Join(tmp, "bg-crash.marker")
51+
restarted := filepath.Join(tmp, "bg-crash.restarted")
52+
53+
setupFrankenPHP(t,
54+
frankenphp.WithWorkers("bg-crash", "testdata/background-worker-crash.php", 1,
55+
frankenphp.WithWorkerBackground(),
56+
frankenphp.WithWorkerEnv(map[string]string{
57+
"BG_CRASH_MARKER": crashMarker,
58+
"BG_RESTARTED_SENTINEL": restarted,
59+
}),
60+
),
61+
frankenphp.WithNumThreads(2),
62+
)
63+
64+
requireFileEventually(t, restarted, "background worker did not restart after crash")
65+
}
66+
67+
// TestBackgroundWorkerWithoutHTTP confirms that a request to a script
68+
// unrelated to the bg worker still works: the bg worker doesn't intercept
69+
// HTTP traffic.
70+
func TestBackgroundWorkerWithoutHTTP(t *testing.T) {
71+
tmp := t.TempDir()
72+
sentinel := filepath.Join(tmp, "bg-nohttp.sentinel")
73+
74+
testDataDir := setupFrankenPHP(t,
75+
frankenphp.WithWorkers("bg-nohttp", "testdata/background-worker.php", 1,
76+
frankenphp.WithWorkerBackground(),
77+
frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}),
78+
),
79+
frankenphp.WithNumThreads(2),
80+
)
81+
82+
requireFileEventually(t, sentinel, "background worker did not touch sentinel")
83+
84+
body := serveBody(t, testDataDir, "index.php")
85+
assert.NotEmpty(t, body, "expected non-empty body from index.php")
86+
}

bgworkerbatch_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package frankenphp_test
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/dunglas/frankenphp"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// TestEnsureBackgroundWorkerBatch declares a single catch-all bg worker
14+
// and ensures three distinct names from a single ensure() call. Each
15+
// catch-all instance touches a per-name sentinel; the test asserts that
16+
// all three appear, proving the array form started one worker per name.
17+
func TestEnsureBackgroundWorkerBatch(t *testing.T) {
18+
tmp := t.TempDir()
19+
testDataDir := setupFrankenPHP(t,
20+
frankenphp.WithWorkers("", "testdata/background-worker-named.php", 0,
21+
frankenphp.WithWorkerBackground(),
22+
frankenphp.WithWorkerMaxThreads(8),
23+
frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL_DIR": tmp}),
24+
),
25+
frankenphp.WithNumThreads(8),
26+
)
27+
28+
body := serveBody(t, testDataDir, "background-worker-batch-ensure.php")
29+
assert.Contains(t, body, "ok", "batch ensure script should echo ok, got: %q", body)
30+
31+
for _, name := range []string{"batch-a", "batch-b", "batch-c"} {
32+
requireFileEventually(t, filepath.Join(tmp, name),
33+
"catch-all instance %q should have written its sentinel", name)
34+
}
35+
}
36+
37+
// TestEnsureBackgroundWorkerBatchEmpty exercises the C-side validation
38+
// that an empty array raises a ValueError before any worker is started.
39+
// The fixture catches the throwable and echoes its class.
40+
func TestEnsureBackgroundWorkerBatchEmpty(t *testing.T) {
41+
testDataDir := setupFrankenPHP(t,
42+
frankenphp.WithWorkers("", "testdata/background-worker-named.php", 0,
43+
frankenphp.WithWorkerBackground(),
44+
),
45+
frankenphp.WithNumThreads(2),
46+
)
47+
48+
body := serveBody(t, testDataDir, "background-worker-batch-errors.php?mode=empty")
49+
assert.Contains(t, body, "ValueError", "empty array should raise ValueError, got: %q", body)
50+
assert.Contains(t, body, "must not be empty")
51+
}
52+
53+
// TestEnsureBackgroundWorkerBatchNonString verifies a non-string element
54+
// raises a TypeError (PHP's standard for argument-type mismatches inside
55+
// our parsed array).
56+
func TestEnsureBackgroundWorkerBatchNonString(t *testing.T) {
57+
testDataDir := setupFrankenPHP(t,
58+
frankenphp.WithWorkers("", "testdata/background-worker-named.php", 0,
59+
frankenphp.WithWorkerBackground(),
60+
),
61+
frankenphp.WithNumThreads(2),
62+
)
63+
64+
body := serveBody(t, testDataDir, "background-worker-batch-errors.php?mode=nonstring")
65+
assert.Contains(t, body, "TypeError", "non-string element should raise TypeError, got: %q", body)
66+
}
67+
68+
// TestEnsureBackgroundWorkerBatchDuplicate verifies that duplicate names
69+
// in the same batch are rejected as a ValueError, matching the e17577e
70+
// reference behavior (no silent dedup).
71+
func TestEnsureBackgroundWorkerBatchDuplicate(t *testing.T) {
72+
testDataDir := setupFrankenPHP(t,
73+
frankenphp.WithWorkers("", "testdata/background-worker-named.php", 0,
74+
frankenphp.WithWorkerBackground(),
75+
),
76+
frankenphp.WithNumThreads(2),
77+
)
78+
79+
body := serveBody(t, testDataDir, "background-worker-batch-errors.php?mode=duplicate")
80+
assert.Contains(t, body, "ValueError", "duplicate name should raise ValueError, got: %q", body)
81+
assert.Contains(t, body, "duplicate")
82+
}
83+
84+
// TestBackgroundWorkerBgFlag asserts that a bg worker script sees
85+
// $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] === true. The fixture writes
86+
// var_export() of the value to a sentinel so the test can read the exact
87+
// PHP-level representation.
88+
func TestBackgroundWorkerBgFlag(t *testing.T) {
89+
tmp := t.TempDir()
90+
sentinel := filepath.Join(tmp, "bg-flag.sentinel")
91+
92+
setupFrankenPHP(t,
93+
frankenphp.WithWorkers("bg-flag", "testdata/background-worker-bg-flag.php", 1,
94+
frankenphp.WithWorkerBackground(),
95+
frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}),
96+
),
97+
frankenphp.WithNumThreads(2),
98+
)
99+
100+
requireFileEventually(t, sentinel,
101+
"bg worker should have written the FRANKENPHP_WORKER_BACKGROUND sentinel")
102+
103+
contents, err := os.ReadFile(sentinel)
104+
require.NoError(t, err)
105+
assert.Equal(t, "true", string(contents),
106+
"$_SERVER['FRANKENPHP_WORKER_BACKGROUND'] should be the bool true")
107+
}

0 commit comments

Comments
 (0)