Skip to content

Commit 5b2e8cd

Browse files
feat: declared background workers + frankenphp_get_worker_handle()
Adds a minimal background-worker surface: long-lived non-HTTP PHP scripts declared via WithWorkerBackground() / `background` in the Caddyfile worker block. Each worker exposes a stop-pipe stream via frankenphp_get_worker_handle(); on FrankenPHP shutdown / restart Go closes the write end so the script's stream_select returns EOF and the loop can exit cleanly. Crash-restart with quadratic backoff, halted by max_consecutive_failures. Per-php_server scope isolation so two server blocks can declare the same worker name without colliding. Deferred to follow-ups (kept out for review surface): - frankenphp_ensure_background_worker() / lazy-start machinery - Catch-all (empty-name) workers - Shared-state APIs (frankenphp_set_vars / frankenphp_get_vars) What lands: - frankenphp_get_worker_handle() zif + stop-fd plumbing in C - WithWorkerBackground() / WithWorkerScope() Go options; num >= 1 required - backgroundWorkerThread handler: boot, restart with backoff, cap-on-crash, graceful exit on stop-fd close - Per-scope lookup map keyed by user-facing name - Caddy `background` worker flag + scope-label cascade (host matcher -> first listener address) so the metric prefix m#<scope-label>:<name> is set before the very first metric emit. Tests: - TestBackgroundWorkerLifecycle: bg worker boots, touches sentinel, parks, exits within 10s of Shutdown. - TestBackgroundWorkerCrashRestarts: exit(1) -> respawn -> restarted sentinel. - TestBackgroundWorkerWithoutHTTP: a regular HTTP request alongside a bg worker still serves. - TestBackgroundWorkerSameNameDifferentScope: two servers each declare "shared", both run, distinct *worker per scope.
1 parent 68573a9 commit 5b2e8cd

21 files changed

Lines changed: 1017 additions & 20 deletions

bgworker.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package frankenphp
2+
3+
import (
4+
"fmt"
5+
"strconv"
6+
"strings"
7+
"sync"
8+
"sync/atomic"
9+
)
10+
11+
// Scope isolates background workers between php_server blocks; the
12+
// zero value is the global/embed scope. Obtain values via NextScope.
13+
type Scope uint64
14+
15+
var scopeCounter atomic.Uint64
16+
17+
// NextScope returns a fresh scope value. Each php_server block should
18+
// call this once during provisioning.
19+
func NextScope() Scope {
20+
return Scope(scopeCounter.Add(1))
21+
}
22+
23+
// scopeLabels maps Scope -> human-readable label registered by the
24+
// embedder (e.g. the Caddy module).
25+
var scopeLabels sync.Map
26+
27+
// SetScopeLabel attaches a human-readable label to a scope; the bg-worker
28+
// metric emitter renders it as e.g. server="api.example.com" instead of
29+
// an opaque numeric id. Empty labels are ignored.
30+
func SetScopeLabel(s Scope, label string) {
31+
if label == "" {
32+
return
33+
}
34+
scopeLabels.Store(s, label)
35+
}
36+
37+
// scopeLabelOrID returns the label registered for s, or the numeric id
38+
// when none is set (including the zero/global scope), so callers always
39+
// get a non-empty value.
40+
func scopeLabelOrID(s Scope) string {
41+
if label, ok := lookupScopeLabel(s); ok {
42+
return label
43+
}
44+
return strconv.FormatUint(uint64(s), 10)
45+
}
46+
47+
// lookupScopeLabel reports whether a label has been registered for s,
48+
// returning ("", false) when none has. Distinguishes "unset" from
49+
// "explicitly empty" without the numeric fallback.
50+
func lookupScopeLabel(s Scope) (string, bool) {
51+
v, ok := scopeLabels.Load(s)
52+
if !ok {
53+
return "", false
54+
}
55+
return v.(string), true
56+
}
57+
58+
// bgWorkerMetricName formats the metric label for a background worker:
59+
// "m#<scopeLabel>:<runtimeName>". scopeLabel is empty when the scope
60+
// has no registered label (embed/global, or before the embedder calls
61+
// SetScopeLabel). The "m#" prefix mirrors the m# convention used for
62+
// module workers; the colon keeps the format uniform so a single regex
63+
// (m#([^:]*):(.+)) parses both labelled and unlabelled forms.
64+
func bgWorkerMetricName(scope Scope, runtimeName string) string {
65+
label, _ := lookupScopeLabel(scope)
66+
return "m#" + label + ":" + runtimeName
67+
}
68+
69+
// backgroundLookups maps scope -> name -> *worker. Scope 0 is the
70+
// global/embed scope. nil when no background worker is declared.
71+
var backgroundLookups map[Scope]map[string]*worker
72+
73+
// buildBackgroundWorkerLookups maps each declared bg worker into its scope's
74+
// lookup. Per-scope name collisions are caught here because bg workers
75+
// intentionally skip the global workersByName map (so two scopes can share
76+
// a user-facing name). Names are not allowed to be empty in this minimal
77+
// build; catch-all bg workers are deferred to a follow-up PR.
78+
func buildBackgroundWorkerLookups(workers []*worker, opts []workerOpt) (map[Scope]map[string]*worker, error) {
79+
lookups := make(map[Scope]map[string]*worker)
80+
81+
for i, o := range opts {
82+
if !o.isBackgroundWorker {
83+
continue
84+
}
85+
w := workers[i]
86+
w.scope = o.scope
87+
88+
phpName := strings.TrimPrefix(w.name, "m#")
89+
if phpName == "" || phpName == w.fileName {
90+
return nil, fmt.Errorf("background worker must have an explicit name (got %q)", w.name)
91+
}
92+
93+
byName := lookups[o.scope]
94+
if byName == nil {
95+
byName = make(map[string]*worker)
96+
lookups[o.scope] = byName
97+
}
98+
if _, exists := byName[phpName]; exists {
99+
return nil, fmt.Errorf("duplicate background worker name %q in the same scope", phpName)
100+
}
101+
byName[phpName] = w
102+
}
103+
104+
if len(lookups) == 0 {
105+
return nil, nil
106+
}
107+
return lookups, nil
108+
}
109+
110+
// reserveBackgroundWorkerThreads returns the thread budget to add to the
111+
// pool for declared bg workers, and pre-registers totalWorkers so a bg-only
112+
// deployment has the metric initialised. num must be >= 1 for bg workers
113+
// in this build.
114+
func reserveBackgroundWorkerThreads(opt *opt) (int, error) {
115+
reserved := 0
116+
for _, w := range opt.workers {
117+
if !w.isBackgroundWorker {
118+
continue
119+
}
120+
if w.num < 1 {
121+
return 0, fmt.Errorf("background worker %q must declare num >= 1 (lazy/ensure() machinery is not in this build)", w.name)
122+
}
123+
reserved += w.num
124+
metrics.TotalWorkers(bgWorkerMetricName(w.scope, w.name), w.num)
125+
}
126+
return reserved, nil
127+
}

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/bgworker/basic.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/bgworker/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/bgworker/basic.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+
}

bgworkerhelpers_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package frankenphp_test
2+
3+
import (
4+
"io"
5+
"net/http/httptest"
6+
"os"
7+
"testing"
8+
"time"
9+
10+
"github.com/dunglas/frankenphp"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// requireFileEventually asserts that `path` appears on disk before the
15+
// deadline. Wraps require.Eventually so call sites stay short.
16+
func requireFileEventually(t testing.TB, path string, msgAndArgs ...any) {
17+
t.Helper()
18+
require.Eventually(t, func() bool {
19+
_, err := os.Stat(path)
20+
return err == nil
21+
}, 5*time.Second, 25*time.Millisecond, msgAndArgs...)
22+
}
23+
24+
// setupFrankenPHP boots FrankenPHP with the given options, registers
25+
// Shutdown as a t.Cleanup, and returns the absolute path to the testdata
26+
// directory. Saves the boilerplate every bg-worker test repeats.
27+
func setupFrankenPHP(t *testing.T, opts ...frankenphp.Option) (testDataDir string) {
28+
t.Helper()
29+
cwd, err := os.Getwd()
30+
require.NoError(t, err)
31+
testDataDir = cwd + "/testdata/"
32+
require.NoError(t, frankenphp.Init(opts...))
33+
t.Cleanup(frankenphp.Shutdown)
34+
return
35+
}
36+
37+
// serveBody runs `script` (relative to testDataDir, may include a query
38+
// string) through FrankenPHP and returns the response body. ErrRejected is
39+
// treated as a non-fatal outcome so worker-mode quirks don't fail tests
40+
// that only care about the script's stdout.
41+
func serveBody(t *testing.T, testDataDir, scriptAndQuery string, opts ...frankenphp.RequestOption) string {
42+
t.Helper()
43+
req := httptest.NewRequest("GET", "http://example.com/"+scriptAndQuery, nil)
44+
reqOpts := append([]frankenphp.RequestOption{
45+
frankenphp.WithRequestDocumentRoot(testDataDir, false),
46+
}, opts...)
47+
fr, err := frankenphp.NewRequestWithContext(req, reqOpts...)
48+
require.NoError(t, err)
49+
50+
w := httptest.NewRecorder()
51+
if err := frankenphp.ServeHTTP(w, fr); err != nil {
52+
require.ErrorAs(t, err, &frankenphp.ErrRejected{})
53+
}
54+
body, err := io.ReadAll(w.Result().Body)
55+
require.NoError(t, err)
56+
return string(body)
57+
}

bgworkerscope_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package frankenphp
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
"time"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// setupBgWorker boots FrankenPHP with the given options (internal-package
14+
// variant), registers Shutdown as a t.Cleanup, and returns the absolute
15+
// path to the testdata directory.
16+
func setupBgWorker(t *testing.T, opts ...Option) (testDataDir string) {
17+
t.Helper()
18+
cwd, err := os.Getwd()
19+
require.NoError(t, err)
20+
testDataDir = cwd + "/testdata/"
21+
require.NoError(t, Init(opts...))
22+
t.Cleanup(Shutdown)
23+
return
24+
}
25+
26+
// requireSentinelEventually asserts that `path` appears on disk before the
27+
// deadline. Wraps require.Eventually so call sites stay short.
28+
func requireSentinelEventually(t testing.TB, path string, msgAndArgs ...any) {
29+
t.Helper()
30+
require.Eventually(t, func() bool {
31+
_, err := os.Stat(path)
32+
return err == nil
33+
}, 5*time.Second, 10*time.Millisecond, msgAndArgs...)
34+
}
35+
36+
// TestNextScopeIsDistinct verifies the scope counter
37+
// hands out unique values on consecutive calls.
38+
func TestNextScopeIsDistinct(t *testing.T) {
39+
a := NextScope()
40+
b := NextScope()
41+
assert.NotEqual(t, a, b, "consecutive scopes must differ")
42+
assert.NotZero(t, a, "scopes must be non-zero (zero is the global scope)")
43+
assert.NotZero(t, b, "scopes must be non-zero (zero is the global scope)")
44+
}
45+
46+
// TestBackgroundWorkerSameNameDifferentScope declares two named bg
47+
// workers with the same user-facing name in distinct scopes. Both must
48+
// Init successfully (the global workersByName collision check must
49+
// recognize bg workers as scope-isolated), each must produce its own
50+
// sentinel under its scope-specific directory.
51+
func TestBackgroundWorkerSameNameDifferentScope(t *testing.T) {
52+
scopeA := NextScope()
53+
scopeB := NextScope()
54+
55+
tmp := t.TempDir()
56+
dirA := filepath.Join(tmp, "a")
57+
dirB := filepath.Join(tmp, "b")
58+
require.NoError(t, os.MkdirAll(dirA, 0o755))
59+
require.NoError(t, os.MkdirAll(dirB, 0o755))
60+
61+
setupBgWorker(t,
62+
WithWorkers("shared", "testdata/bgworker/named.php", 1,
63+
WithWorkerBackground(),
64+
WithWorkerScope(scopeA),
65+
WithWorkerEnv(map[string]string{"BG_SENTINEL_DIR": dirA}),
66+
),
67+
WithWorkers("shared", "testdata/bgworker/named.php", 1,
68+
WithWorkerBackground(),
69+
WithWorkerScope(scopeB),
70+
WithWorkerEnv(map[string]string{"BG_SENTINEL_DIR": dirB}),
71+
),
72+
WithNumThreads(4),
73+
)
74+
75+
// Both lookups must exist and resolve "shared" to a *worker.
76+
require.NotNil(t, backgroundLookups[scopeA], "scope A lookup missing")
77+
require.NotNil(t, backgroundLookups[scopeB], "scope B lookup missing")
78+
assert.NotNil(t, backgroundLookups[scopeA]["shared"], "scope A must resolve 'shared'")
79+
assert.NotNil(t, backgroundLookups[scopeB]["shared"], "scope B must resolve 'shared'")
80+
// And they must be distinct *worker instances (not the same pointer).
81+
assert.NotSame(t,
82+
backgroundLookups[scopeA]["shared"],
83+
backgroundLookups[scopeB]["shared"],
84+
"each scope must own a distinct *worker for the same name")
85+
86+
// Each scope's worker writes its sentinel under its own dir.
87+
requireSentinelEventually(t, filepath.Join(dirA, "shared"), "scope A worker did not touch sentinel")
88+
requireSentinelEventually(t, filepath.Join(dirB, "shared"), "scope B worker did not touch sentinel")
89+
}

0 commit comments

Comments
 (0)