diff --git a/bgworker.go b/bgworker.go new file mode 100644 index 0000000000..9b4b3cf0d0 --- /dev/null +++ b/bgworker.go @@ -0,0 +1,471 @@ +package frankenphp + +// #include "frankenphp.h" +import "C" +import ( + "errors" + "fmt" + "log/slog" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + "unsafe" +) + +const ( + // defaultMaxBackgroundWorkers is the default safety cap for catch-all + // background workers when the user doesn't set max_threads. Caps the + // number of distinct lazy-started instances from a single catch-all. + defaultMaxBackgroundWorkers = 16 + + // defaultEnsureTimeout is the default deadline applied when ensure() is + // called without an explicit timeout. + defaultEnsureTimeout = 30 * time.Second +) + +// backgroundWorkerExtras holds bg-only lifecycle state. +type backgroundWorkerExtras struct { + // ready is shared by named workers and a catch-all's eager pool; + // lazy-spawned catch-all instances each get their own slot in + // catchAllNames. + ready *backgroundWorkerState + + // catchAllNames != nil marks this *worker as a scope's catch-all + // template. Lazy-spawned threads register here, up to catchAllCap. + catchAllCap int + catchAllMu sync.Mutex + catchAllNames map[string]*backgroundWorkerState + + // lazyMu/lazyStarted gate the first thread spawn for a num=0 named + // bg worker. Unused for eager (num > 0) or catch-all templates. + lazyMu sync.Mutex + lazyStarted bool +} + +// backgroundWorkerState is the per-instance readiness signal ensure() +// blocks on. ready closes once the worker calls +// frankenphp_get_worker_handle(); aborted closes on max_consecutive_failures +// before that. +type backgroundWorkerState struct { + ready chan struct{} + readyOnce sync.Once + + aborted chan struct{} + abortOnce sync.Once + abortErr error + + // bootFailure carries the most recent pre-readiness crash so a + // timing-out ensure() can report it. + bootFailure atomic.Pointer[bootFailureInfo] +} + +// bootFailureInfo is the boot-phase crash metadata surfaced by ensure(). +type bootFailureInfo struct { + entrypoint string + exitStatus int + failureCount int + // TODO(sidekicks): capture PG(last_error_message) once the C-side + // helper lands in the set_vars/get_vars step. + phpError string +} + +func newBackgroundWorkerState() *backgroundWorkerState { + return &backgroundWorkerState{ + ready: make(chan struct{}), + aborted: make(chan struct{}), + } +} + +// markReady fires on the first frankenphp_get_worker_handle() call after +// each (re)boot. Idempotent: the channel only closes once. +func (r *backgroundWorkerState) markReady() { + r.readyOnce.Do(func() { close(r.ready) }) +} + +// abort unblocks ensure() waiters when the boot sequence is abandoned +// (max_consecutive_failures hit before readiness). Idempotent. +func (r *backgroundWorkerState) abort(err error) { + r.abortOnce.Do(func() { + r.abortErr = err + close(r.aborted) + }) +} + +// Scope isolates background workers between php_server blocks; the +// zero value is the global/embed scope. Obtain values via NextScope. +type Scope uint64 + +var scopeCounter atomic.Uint64 + +// NextScope returns a fresh scope value. Each php_server block should +// call this once during provisioning. +func NextScope() Scope { + return Scope(scopeCounter.Add(1)) +} + +// scopeLabels maps Scope -> human-readable label registered by the +// embedder (e.g. the Caddy module). +var scopeLabels sync.Map + +// SetScopeLabel attaches a human-readable label to a scope; the bg-worker +// metric emitter renders it as e.g. server="api.example.com" instead of +// an opaque numeric id. Empty labels are ignored. +func SetScopeLabel(s Scope, label string) { + if label == "" { + return + } + scopeLabels.Store(s, label) +} + +// scopeLabelOrID returns the label registered for s, or the numeric id +// when none is set (including the zero/global scope), so callers always +// get a non-empty value. +func scopeLabelOrID(s Scope) string { + if label, ok := lookupScopeLabel(s); ok { + return label + } + return strconv.FormatUint(uint64(s), 10) +} + +// lookupScopeLabel reports whether a label has been registered for s, +// returning ("", false) when none has. Distinguishes "unset" from +// "explicitly empty" without the numeric fallback. +func lookupScopeLabel(s Scope) (string, bool) { + v, ok := scopeLabels.Load(s) + if !ok { + return "", false + } + return v.(string), true +} + +// bgWorkerMetricName formats the metric label for a background worker: +// "m#:". scopeLabel is empty when the scope +// has no registered label (embed/global, or before the embedder calls +// SetScopeLabel). The "m#" prefix mirrors the m# convention used for +// module workers; the colon keeps the format uniform so a single regex +// (m#([^:]*):(.+)) parses both labelled and unlabelled forms. +func bgWorkerMetricName(scope Scope, runtimeName string) string { + label, _ := lookupScopeLabel(scope) + return "m#" + label + ":" + runtimeName +} + +// backgroundLookups maps scope -> lookup. Scope 0 is the global/embed scope. +var backgroundLookups map[Scope]*backgroundWorkerLookup + +// backgroundWorkerLookup resolves a user-facing worker name to its *worker; +// catchAll is the fallback when byName misses. +type backgroundWorkerLookup struct { + byName map[string]*worker + catchAll *worker +} + +func newBackgroundWorkerLookup() *backgroundWorkerLookup { + return &backgroundWorkerLookup{ + byName: make(map[string]*worker), + } +} + +// resolve returns the worker for the given name, falling back to catchAll. +func (l *backgroundWorkerLookup) resolve(name string) *worker { + if w, ok := l.byName[name]; ok { + return w + } + return l.catchAll +} + +// isCatchAllName reports whether (name, fileName) designates a catch-all +// (no user-supplied name; newWorker defaults the name to the absolute +// file path). m# is stripped because module workers carry the prefix. +func isCatchAllName(name, fileName string) bool { + phpName := strings.TrimPrefix(name, "m#") + return phpName == "" || phpName == fileName +} + +func isCatchAllByName(w *worker) bool { + return isCatchAllName(w.name, w.fileName) +} + +// buildBackgroundWorkerLookups maps each declared bg worker into its scope's +// lookup. Per-scope name collisions are caught here because bg workers +// intentionally skip the global workersByName map (so two scopes can share +// a user-facing name). +func buildBackgroundWorkerLookups(workers []*worker, opts []workerOpt) (map[Scope]*backgroundWorkerLookup, error) { + lookups := make(map[Scope]*backgroundWorkerLookup) + + for i, o := range opts { + if !o.isBackgroundWorker { + continue + } + + scope := o.scope + lookup, ok := lookups[scope] + if !ok { + lookup = newBackgroundWorkerLookup() + lookups[scope] = lookup + } + + w := workers[i] + w.scope = scope + + if isCatchAllByName(w) { + if lookup.catchAll != nil { + return nil, fmt.Errorf("duplicate catch-all background worker in the same scope") + } + w.bg.catchAllCap = defaultMaxBackgroundWorkers + if o.maxThreads > 0 { + w.bg.catchAllCap = o.maxThreads + } + w.bg.catchAllNames = make(map[string]*backgroundWorkerState) + lookup.catchAll = w + } else { + phpName := strings.TrimPrefix(w.name, "m#") + if _, exists := lookup.byName[phpName]; exists { + return nil, fmt.Errorf("duplicate background worker name %q in the same scope", phpName) + } + lookup.byName[phpName] = w + } + } + + if len(lookups) == 0 { + return nil, nil + } + return lookups, nil +} + +// reserveBackgroundWorkerThreads resolves max_threads defaults and +// returns the thread budget to add to the pool. Mutates opt.workers +// in place and pre-registers totalWorkers so a bg-only deployment +// has the metric initialised. +func reserveBackgroundWorkerThreads(opt *opt) int { + reserved := 0 + for i, w := range opt.workers { + if !w.isBackgroundWorker { + continue + } + isCatchAll := isCatchAllName(w.name, w.fileName) + + if w.maxThreads == 0 { + switch { + case isCatchAll: + // Lazy cap default for any catch-all. + opt.workers[i].maxThreads = defaultMaxBackgroundWorkers + case w.num == 0: + // Single-thread budget for a lazy named worker. + opt.workers[i].maxThreads = 1 + } + } + + var extra int + if isCatchAll { + // eager pool + lazy cap (independent budgets) + extra = w.num + opt.workers[i].maxThreads + } else { + extra = w.num + if opt.workers[i].maxThreads > extra { + extra = opt.workers[i].maxThreads + } + } + if extra < 1 { + extra = 1 + } + reserved += extra + metrics.TotalWorkers(bgWorkerMetricName(w.scope, w.name), extra) + } + return reserved +} + +// getLookup returns the background-worker lookup for the calling thread, +// resolving the scope via worker handler -> request context -> global. A +// scope with no declared workers falls through to scope 0 so embed-mode +// workers stay reachable; declared scopes stay strictly isolated. +func getLookup(thread *phpThread) *backgroundWorkerLookup { + if backgroundLookups == nil { + return nil + } + var scope Scope + if thread != nil { + if w := thread.handler.scopedWorker(); w != nil { + scope = w.scope + } else if fc, ok := fromContext(thread.context()); ok { + scope = fc.scope + } + } + if scope != 0 { + if l := backgroundLookups[scope]; l != nil { + return l + } + } + return backgroundLookups[0] +} + +// ensureBackgroundWorker lazy-starts the named worker if needed and blocks +// until it reaches readiness, aborts (max_consecutive_failures during boot), +// or times out. Safe to call concurrently. +func ensureBackgroundWorker(thread *phpThread, bgWorkerName string, timeout time.Duration) error { + if bgWorkerName == "" { + return fmt.Errorf("background worker name must not be empty") + } + lookup := getLookup(thread) + if lookup == nil { + return fmt.Errorf("no background worker configured") + } + + // byName is keyed by the user-facing (m#-stripped) name. + if w, ok := lookup.byName[bgWorkerName]; ok { + r, err := lazyStartNamedWorker(w) + if err != nil { + return err + } + return waitForBackgroundWorkerReady(bgWorkerName, r, timeout) + } + + catchAll := lookup.catchAll + if catchAll == nil { + return fmt.Errorf("no background worker configured for name %q", bgWorkerName) + } + + // Reject so behavior doesn't silently split-brain across the eager + // pool and a lazy-spawned instance. m#-strip matches + // buildBackgroundWorkerLookups: module catch-alls carry the prefix, + // bgWorkerName from PHP never does. + if bgWorkerName == strings.TrimPrefix(catchAll.name, "m#") { + return fmt.Errorf("cannot ensure() against %q: it matches the catch-all's own name; use a distinct user-facing name", bgWorkerName) + } + + // Hold catchAllMu across thread reservation + entry publication so a + // failed allocation can't leave a phantom registration visible to + // concurrent callers. + bg := catchAll.bg + bg.catchAllMu.Lock() + + if r, ok := bg.catchAllNames[bgWorkerName]; ok { + bg.catchAllMu.Unlock() + return waitForBackgroundWorkerReady(bgWorkerName, r, timeout) + } + + if bg.catchAllCap > 0 && len(bg.catchAllNames) >= bg.catchAllCap { + bg.catchAllMu.Unlock() + return fmt.Errorf("cannot start background worker %q: limit of %d reached (increase max threads or declare it as a named worker)", bgWorkerName, bg.catchAllCap) + } + + r := newBackgroundWorkerState() + bg.catchAllNames[bgWorkerName] = r + bg.catchAllMu.Unlock() + + if _, err := addBackgroundWorkerThread(catchAll, bgWorkerName, r); err != nil { + // Wake any concurrent waiter that picked up r from catchAllNames + // between our publish and this rollback so they see the start + // failure instead of timing out. + r.abort(err) + bg.catchAllMu.Lock() + delete(bg.catchAllNames, bgWorkerName) + bg.catchAllMu.Unlock() + return fmt.Errorf("cannot start background worker %q: %w (increase max threads)", bgWorkerName, err) + } + + if globalLogger.Enabled(globalCtx, slog.LevelInfo) { + globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "background worker started", + slog.String("name", bgWorkerName)) + } + + return waitForBackgroundWorkerReady(bgWorkerName, r, timeout) +} + +// lazyStartNamedWorker returns the readiness slot the caller should +// wait on. For num=0 workers it spawns the first thread under +// bg.lazyMu (idempotent); the snapshot captured under the lock stays +// consistent with any concurrent invalidateBackgroundEntry. +func lazyStartNamedWorker(w *worker) (*backgroundWorkerState, error) { + if w.num > 0 { + return w.bg.ready, nil + } + w.bg.lazyMu.Lock() + defer w.bg.lazyMu.Unlock() + if w.bg.lazyStarted { + return w.bg.ready, nil + } + r := w.bg.ready + if _, err := addBackgroundWorkerThread(w, w.name, r); err != nil { + return nil, fmt.Errorf("cannot start background worker %q: %w (increase max threads)", w.name, err) + } + w.bg.lazyStarted = true + return r, nil +} + +// waitForBackgroundWorkerReady blocks until the worker reaches readiness, +// aborts, or the timeout elapses. A nil state degrades to ready-immediately +// to avoid a deadlock for workers declared without an allocated slot. +func waitForBackgroundWorkerReady(name string, r *backgroundWorkerState, timeout time.Duration) error { + if r == nil { + return nil + } + if timeout <= 0 { + timeout = defaultEnsureTimeout + } + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case <-r.ready: + return nil + case <-r.aborted: + return fmt.Errorf("background worker %q failed to start: %w", name, r.abortErr) + case <-timer.C: + return formatEnsureTimeoutError(name, r, timeout) + } +} + +func formatEnsureTimeoutError(name string, r *backgroundWorkerState, timeout time.Duration) error { + if bf := r.bootFailure.Load(); bf != nil { + msg := fmt.Sprintf("background worker %q did not become ready within %s; last attempt %d failed (exit status %d, entrypoint %s)", + name, timeout, bf.failureCount, bf.exitStatus, bf.entrypoint) + if bf.phpError != "" { + msg += ": " + bf.phpError + } + return errors.New(msg) + } + return fmt.Errorf("background worker %q did not call frankenphp_get_worker_handle() within %s", name, timeout) +} + +// go_frankenphp_ensure_background_worker lazy-starts each named bg worker +// (the C side has validated names are non-empty and unique) and blocks +// until each signals readiness, aborts, or timeoutMs (ms; <=0 = default) +// elapses. +// +//export go_frankenphp_ensure_background_worker +func go_frankenphp_ensure_background_worker(threadIndex C.uintptr_t, names **C.char, nameLens *C.size_t, nameCount C.int, timeoutMs C.int64_t) *C.char { + thread := phpThreads[threadIndex] + n := int(nameCount) + if n <= 0 { + return nil + } + timeout := time.Duration(int64(timeoutMs)) * time.Millisecond + nameSlice := unsafe.Slice(names, n) + nameLenSlice := unsafe.Slice(nameLens, n) + for i := 0; i < n; i++ { + goName := C.GoStringN(nameSlice[i], C.int(nameLenSlice[i])) + if err := ensureBackgroundWorker(thread, goName, timeout); err != nil { + return C.CString(err.Error()) + } + } + return nil +} + +// go_frankenphp_worker_ready closes the per-thread readiness channel on +// the first frankenphp_get_worker_handle() call. Idempotent. +// +//export go_frankenphp_worker_ready +func go_frankenphp_worker_ready(threadIndex C.uintptr_t) { + thread := phpThreads[threadIndex] + if thread == nil { + return + } + handler, ok := thread.handler.(*backgroundWorkerThread) + if !ok || handler == nil { + return + } + if r := handler.backgroundReady; r != nil { + r.markReady() + } +} diff --git a/bgworker_test.go b/bgworker_test.go new file mode 100644 index 0000000000..5ef6ec6e42 --- /dev/null +++ b/bgworker_test.go @@ -0,0 +1,86 @@ +package frankenphp_test + +import ( + "path/filepath" + "testing" + "time" + + "github.com/dunglas/frankenphp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestBackgroundWorkerLifecycle boots a background worker that touches a +// sentinel file then parks on the stop pipe. It proves the bg worker runs +// (sentinel appears) and that Shutdown returns within a reasonable time. +func TestBackgroundWorkerLifecycle(t *testing.T) { + tmp := t.TempDir() + sentinel := filepath.Join(tmp, "bg-lifecycle.sentinel") + + require.NoError(t, frankenphp.Init( + frankenphp.WithWorkers("bg-lifecycle", "testdata/bgworker/basic.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + )) + // Note: this test asserts on Shutdown timing, so it manages Shutdown + // itself instead of using setupFrankenPHP's t.Cleanup hook. + + requireFileEventually(t, sentinel, "background worker did not touch sentinel") + + done := make(chan struct{}) + go func() { + frankenphp.Shutdown() + close(done) + }() + + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatalf("Shutdown did not return within 10s") + } +} + +// TestBackgroundWorkerCrashRestarts boots a worker that exit(1)s on its +// first run and touches a "restarted" sentinel on its second run. The +// sentinel proves the crash-restart loop fired. +func TestBackgroundWorkerCrashRestarts(t *testing.T) { + tmp := t.TempDir() + crashMarker := filepath.Join(tmp, "bg-crash.marker") + restarted := filepath.Join(tmp, "bg-crash.restarted") + + setupFrankenPHP(t, + frankenphp.WithWorkers("bg-crash", "testdata/bgworker/crash.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{ + "BG_CRASH_MARKER": crashMarker, + "BG_RESTARTED_SENTINEL": restarted, + }), + ), + frankenphp.WithNumThreads(2), + ) + + requireFileEventually(t, restarted, "background worker did not restart after crash") +} + +// TestBackgroundWorkerWithoutHTTP confirms that a request to a script +// unrelated to the bg worker still works: the bg worker doesn't intercept +// HTTP traffic. +func TestBackgroundWorkerWithoutHTTP(t *testing.T) { + tmp := t.TempDir() + sentinel := filepath.Join(tmp, "bg-nohttp.sentinel") + + testDataDir := setupFrankenPHP(t, + frankenphp.WithWorkers("bg-nohttp", "testdata/bgworker/basic.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + ) + + requireFileEventually(t, sentinel, "background worker did not touch sentinel") + + body := serveBody(t, testDataDir, "index.php") + assert.NotEmpty(t, body, "expected non-empty body from index.php") +} diff --git a/bgworkerbatch_test.go b/bgworkerbatch_test.go new file mode 100644 index 0000000000..12c5b734cd --- /dev/null +++ b/bgworkerbatch_test.go @@ -0,0 +1,107 @@ +package frankenphp_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/dunglas/frankenphp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestEnsureBackgroundWorkerBatch declares a single catch-all bg worker +// and ensures three distinct names from a single ensure() call. Each +// catch-all instance touches a per-name sentinel; the test asserts that +// all three appear, proving the array form started one worker per name. +func TestEnsureBackgroundWorkerBatch(t *testing.T) { + tmp := t.TempDir() + testDataDir := setupFrankenPHP(t, + frankenphp.WithWorkers("", "testdata/bgworker/named.php", 0, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerMaxThreads(8), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL_DIR": tmp}), + ), + frankenphp.WithNumThreads(8), + ) + + body := serveBody(t, testDataDir, "bgworker/batch-ensure.php") + assert.Contains(t, body, "ok", "batch ensure script should echo ok, got: %q", body) + + for _, name := range []string{"batch-a", "batch-b", "batch-c"} { + requireFileEventually(t, filepath.Join(tmp, name), + "catch-all instance %q should have written its sentinel", name) + } +} + +// TestEnsureBackgroundWorkerBatchEmpty exercises the C-side validation +// that an empty array raises a ValueError before any worker is started. +// The fixture catches the throwable and echoes its class. +func TestEnsureBackgroundWorkerBatchEmpty(t *testing.T) { + testDataDir := setupFrankenPHP(t, + frankenphp.WithWorkers("", "testdata/bgworker/named.php", 0, + frankenphp.WithWorkerBackground(), + ), + frankenphp.WithNumThreads(2), + ) + + body := serveBody(t, testDataDir, "bgworker/batch-errors.php?mode=empty") + assert.Contains(t, body, "ValueError", "empty array should raise ValueError, got: %q", body) + assert.Contains(t, body, "must not be empty") +} + +// TestEnsureBackgroundWorkerBatchNonString verifies a non-string element +// raises a TypeError (PHP's standard for argument-type mismatches inside +// our parsed array). +func TestEnsureBackgroundWorkerBatchNonString(t *testing.T) { + testDataDir := setupFrankenPHP(t, + frankenphp.WithWorkers("", "testdata/bgworker/named.php", 0, + frankenphp.WithWorkerBackground(), + ), + frankenphp.WithNumThreads(2), + ) + + body := serveBody(t, testDataDir, "bgworker/batch-errors.php?mode=nonstring") + assert.Contains(t, body, "TypeError", "non-string element should raise TypeError, got: %q", body) +} + +// TestEnsureBackgroundWorkerBatchDuplicate verifies that duplicate names +// in the same batch are rejected as a ValueError, matching the e17577e +// reference behavior (no silent dedup). +func TestEnsureBackgroundWorkerBatchDuplicate(t *testing.T) { + testDataDir := setupFrankenPHP(t, + frankenphp.WithWorkers("", "testdata/bgworker/named.php", 0, + frankenphp.WithWorkerBackground(), + ), + frankenphp.WithNumThreads(2), + ) + + body := serveBody(t, testDataDir, "bgworker/batch-errors.php?mode=duplicate") + assert.Contains(t, body, "ValueError", "duplicate name should raise ValueError, got: %q", body) + assert.Contains(t, body, "duplicate") +} + +// TestBackgroundWorkerBgFlag asserts that a bg worker script sees +// $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] === true. The fixture writes +// var_export() of the value to a sentinel so the test can read the exact +// PHP-level representation. +func TestBackgroundWorkerBgFlag(t *testing.T) { + tmp := t.TempDir() + sentinel := filepath.Join(tmp, "bg-flag.sentinel") + + setupFrankenPHP(t, + frankenphp.WithWorkers("bg-flag", "testdata/bgworker/bg-flag.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + ) + + requireFileEventually(t, sentinel, + "bg worker should have written the FRANKENPHP_WORKER_BACKGROUND sentinel") + + contents, err := os.ReadFile(sentinel) + require.NoError(t, err) + assert.Equal(t, "true", string(contents), + "$_SERVER['FRANKENPHP_WORKER_BACKGROUND'] should be the bool true") +} diff --git a/bgworkerensure_test.go b/bgworkerensure_test.go new file mode 100644 index 0000000000..478e219c9f --- /dev/null +++ b/bgworkerensure_test.go @@ -0,0 +1,224 @@ +package frankenphp + +import ( + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestEnsureBackgroundWorkerNamedLazy declares a num=0 named worker, then +// calls ensure() to lazy-start it. The fixture writes a sentinel named +// after FRANKENPHP_WORKER so we can confirm the right instance ran. +func TestEnsureBackgroundWorkerNamedLazy(t *testing.T) { + tmp := t.TempDir() + setupBgWorker(t, + WithWorkers("bg-lazy", "testdata/bgworker/named.php", 0, + WithWorkerBackground(), + WithWorkerEnv(map[string]string{"BG_SENTINEL_DIR": tmp}), + ), + WithNumThreads(2), + ) + + // num=0 means no eager start: the sentinel should not exist yet. + require.NoFileExists(t, filepath.Join(tmp, "bg-lazy"), "lazy worker should not have started yet") + + require.NoError(t, ensureBackgroundWorker(nil, "bg-lazy", 5*time.Second)) + requireSentinelEventually(t, filepath.Join(tmp, "bg-lazy"), + "ensure() should have lazy-started the named bg worker") +} + +// TestEnsureBackgroundWorkerCatchAll declares a single catch-all (no name) +// and invokes ensure() with two distinct names. Each name should spawn an +// independent instance from the same entrypoint and write its own sentinel. +func TestEnsureBackgroundWorkerCatchAll(t *testing.T) { + tmp := t.TempDir() + setupBgWorker(t, + // Name-less bg worker = catch-all. + WithWorkers("", "testdata/bgworker/named.php", 0, + WithWorkerBackground(), + WithWorkerEnv(map[string]string{"BG_SENTINEL_DIR": tmp}), + ), + WithNumThreads(4), + ) + + for _, name := range []string{"job-a", "job-b"} { + require.NoError(t, ensureBackgroundWorker(nil, name, 5*time.Second), "ensure(%s)", name) + } + + for _, name := range []string{"job-a", "job-b"} { + requireSentinelEventually(t, filepath.Join(tmp, name), + "catch-all instance %q should have written its sentinel", name) + } +} + +// TestEnsureBackgroundWorkerCatchAllCap exercises max_threads on the +// catch-all: third distinct name beyond the cap should error. +func TestEnsureBackgroundWorkerCatchAllCap(t *testing.T) { + tmp := t.TempDir() + setupBgWorker(t, + WithWorkers("", "testdata/bgworker/named.php", 0, + WithWorkerBackground(), + WithWorkerMaxThreads(2), + WithWorkerEnv(map[string]string{"BG_SENTINEL_DIR": tmp}), + ), + WithNumThreads(4), + ) + + require.NoError(t, ensureBackgroundWorker(nil, "cap-a", 5*time.Second)) + require.NoError(t, ensureBackgroundWorker(nil, "cap-b", 5*time.Second)) + + require.ErrorContains(t, + ensureBackgroundWorker(nil, "cap-c", 5*time.Second), + "limit of 2 reached", + "third ensure must hit the catch-all cap") +} + +// TestEnsureBackgroundWorkerUndeclared confirms ensure() on an undeclared +// name with no catch-all returns the configuration error. +func TestEnsureBackgroundWorkerUndeclared(t *testing.T) { + setupBgWorker(t, + WithWorkers("bg-known", "testdata/bgworker/named.php", 0, + WithWorkerBackground(), + ), + WithNumThreads(2), + ) + + require.ErrorContains(t, + ensureBackgroundWorker(nil, "other-name", 5*time.Second), + "no background worker configured for name") +} + +// TestEnsureBackgroundWorkerCatchAllSelfIdentityRejected verifies +// ensure() rejects the catch-all's own filepath uniformly across +// num=0 and num=1. +func TestEnsureBackgroundWorkerCatchAllSelfIdentityRejected(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + // The catch-all's user-facing name is its absolute file path + // (set by newWorker when the declaration leaves name empty). + absPath := filepath.Join(cwd, "testdata/bgworker/named.php") + + for _, tc := range []struct { + label string + num int + }{ + {"lazy num=0", 0}, + {"eager num=1", 1}, + } { + t.Run(tc.label, func(t *testing.T) { + setupBgWorker(t, + WithWorkers("", "testdata/bgworker/named.php", tc.num, + WithWorkerBackground(), + ), + WithNumThreads(2), + ) + + err := ensureBackgroundWorker(nil, absPath, 1*time.Second) + require.Error(t, err, "ensure() with the catch-all's file path must be rejected") + // Echoes the rejected input so a PHP caller can see what + // they passed; "catch-all's own name" surfaces the cause. + assert.Contains(t, err.Error(), absPath, "error must echo the rejected input") + assert.Contains(t, err.Error(), "catch-all's own name", "error must explain why the input was rejected") + }) + } +} + +// TestEnsureBackgroundWorkerConcurrent confirms the doc claim that ensure() +// is safe to call concurrently: 16 goroutines hitting the same lazy-named +// declaration produce exactly one spawned thread. Without serialising the +// lazy-start gate (bgLazyStartMu), the second caller could observe the +// flag before the first caller has completed thread reservation, leaving +// the worker in an inconsistent state. +func TestEnsureBackgroundWorkerConcurrent(t *testing.T) { + setupBgWorker(t, + WithWorkers("bg-concurrent", "testdata/bgworker/named.php", 0, + WithWorkerBackground(), + ), + WithNumThreads(8), + ) + + const goroutines = 16 + var wg sync.WaitGroup + wg.Add(goroutines) + errs := make([]error, goroutines) + start := make(chan struct{}) + for i := 0; i < goroutines; i++ { + go func(idx int) { + defer wg.Done() + <-start + errs[idx] = ensureBackgroundWorker(nil, "bg-concurrent", 5*time.Second) + }(i) + } + close(start) + wg.Wait() + + for i, err := range errs { + require.NoError(t, err, "goroutine %d", i) + } + + // The lazy-named declaration should resolve to its single *worker, + // and exactly one thread should have been spawned by the lazy-start + // path despite the 16 concurrent ensure() callers. + lookup := backgroundLookups[0] + require.NotNil(t, lookup) + w := lookup.byName["bg-concurrent"] + require.NotNil(t, w) + assert.Equal(t, 1, w.countThreads(), "exactly one worker thread expected") +} + +// TestEnsureBackgroundWorkerTimeout proves ensure() blocks until either +// the worker hits its readiness boundary (frankenphp_get_worker_handle()) +// or the timeout expires. The fixture sleep()s without ever calling the +// readiness function, so the second branch must fire. +func TestEnsureBackgroundWorkerTimeout(t *testing.T) { + setupBgWorker(t, + WithWorkers("bg-no-handle", "testdata/bgworker/no-handle.php", 0, + WithWorkerBackground(), + ), + WithNumThreads(2), + ) + + start := time.Now() + err := ensureBackgroundWorker(nil, "bg-no-handle", 1*time.Second) + deadline := start.Add(1 * time.Second) + + require.ErrorContains(t, err, + "did not call frankenphp_get_worker_handle()", + "ensure() must time out at the readiness boundary") + // Lower bound: timer must actually have run; allow a little slop for + // timer scheduling. + assert.GreaterOrEqual(t, time.Since(start), 900*time.Millisecond, "ensure() must wait the full timeout") + // Upper bound: ensure() returned close to the deadline (didn't hang). + assert.WithinDuration(t, deadline, time.Now(), 4*time.Second, "ensure() must return within a small slack window after the timeout") +} + +// TestEnsureBackgroundWorkerBootFailure declares a worker whose entrypoint +// throws on its very first line. ensure() should surface the boot crash +// metadata (entrypoint, exit status, attempt count) instead of just +// reporting a generic timeout. We use a max_consecutive_failures cap so +// the worker stops respawning and the abort path fires deterministically. +func TestEnsureBackgroundWorkerBootFailure(t *testing.T) { + setupBgWorker(t, + WithWorkers("bg-boot-fail", "testdata/bgworker/boot-fail.php", 0, + WithWorkerBackground(), + WithWorkerMaxFailures(2), + ), + WithNumThreads(2), + ) + + err := ensureBackgroundWorker(nil, "bg-boot-fail", 5*time.Second) + require.Error(t, err, "ensure() must surface the boot failure") + msg := err.Error() + // One of two paths: either the abort fired (cap reached) and the error + // mentions max_consecutive_failures, or the timeout fired with the + // bootFailureInfo attached so the message mentions exit status. + assert.True(t, + strings.Contains(msg, "exit status") || strings.Contains(msg, "max_consecutive_failures") || strings.Contains(msg, "failed to start"), + "ensure() error must reflect the boot crash, got: %s", msg) +} diff --git a/bgworkerhelpers_test.go b/bgworkerhelpers_test.go new file mode 100644 index 0000000000..a51a0042f8 --- /dev/null +++ b/bgworkerhelpers_test.go @@ -0,0 +1,57 @@ +package frankenphp_test + +import ( + "io" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/dunglas/frankenphp" + "github.com/stretchr/testify/require" +) + +// requireFileEventually asserts that `path` appears on disk before the +// deadline. Wraps require.Eventually so call sites stay short. +func requireFileEventually(t testing.TB, path string, msgAndArgs ...any) { + t.Helper() + require.Eventually(t, func() bool { + _, err := os.Stat(path) + return err == nil + }, 5*time.Second, 25*time.Millisecond, msgAndArgs...) +} + +// setupFrankenPHP boots FrankenPHP with the given options, registers +// Shutdown as a t.Cleanup, and returns the absolute path to the testdata +// directory. Saves the boilerplate every bg-worker test repeats. +func setupFrankenPHP(t *testing.T, opts ...frankenphp.Option) (testDataDir string) { + t.Helper() + cwd, err := os.Getwd() + require.NoError(t, err) + testDataDir = cwd + "/testdata/" + require.NoError(t, frankenphp.Init(opts...)) + t.Cleanup(frankenphp.Shutdown) + return +} + +// serveBody runs `script` (relative to testDataDir, may include a query +// string) through FrankenPHP and returns the response body. ErrRejected is +// treated as a non-fatal outcome so worker-mode quirks don't fail tests +// that only care about the script's stdout. +func serveBody(t *testing.T, testDataDir, scriptAndQuery string, opts ...frankenphp.RequestOption) string { + t.Helper() + req := httptest.NewRequest("GET", "http://example.com/"+scriptAndQuery, nil) + reqOpts := append([]frankenphp.RequestOption{ + frankenphp.WithRequestDocumentRoot(testDataDir, false), + }, opts...) + fr, err := frankenphp.NewRequestWithContext(req, reqOpts...) + require.NoError(t, err) + + w := httptest.NewRecorder() + if err := frankenphp.ServeHTTP(w, fr); err != nil { + require.ErrorAs(t, err, &frankenphp.ErrRejected{}) + } + body, err := io.ReadAll(w.Result().Body) + require.NoError(t, err) + return string(body) +} diff --git a/bgworkerinternal_test.go b/bgworkerinternal_test.go new file mode 100644 index 0000000000..83cfdffd49 --- /dev/null +++ b/bgworkerinternal_test.go @@ -0,0 +1,149 @@ +package frankenphp + +import ( + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestBackgroundWorkerRestartForceKillsStuckThread exercises the force-kill +// path: the fixture sleep()s without watching the stop pipe, so +// handler.drain() cannot wake it. RestartWorkers must go through the +// grace-period timeout and the force-kill primitive (pthread_kill on +// Linux/FreeBSD) to finish within the budget. Skips platforms where +// force-kill cannot interrupt a blocking syscall. +func TestBackgroundWorkerRestartForceKillsStuckThread(t *testing.T) { + if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { + t.Skipf("force-kill cannot interrupt blocking syscalls on %s", runtime.GOOS) + } + + prev := drainGracePeriod + drainGracePeriod = 2 * time.Second + t.Cleanup(func() { drainGracePeriod = prev }) + + tmp := t.TempDir() + sentinel := filepath.Join(tmp, "bg-stuck.sentinel") + + setupBgWorker(t, + WithWorkers("bg-stuck", "testdata/bgworker/stuck.php", 1, + WithWorkerBackground(), + WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + WithNumThreads(2), + ) + + // Wait until the bg worker touched the sentinel (the line right + // before sleep(60)) so we know it is parked in the blocking syscall + // when the drain fires - that's the only way to prove the + // force-kill code path was exercised, not the stop-pipe EOF path + // (the fixture doesn't open the stop pipe at all). + requireSentinelEventually(t, sentinel, "bg worker never entered sleep()") + + start := time.Now() + RestartWorkers() + // Drain budget = grace period (2s) + slack for signal dispatch and + // drain completion. + assert.WithinDuration(t, start, time.Now(), 5*time.Second, + "drain must force-kill the stuck bg worker within the grace period") +} + +// TestEnsureBackgroundWorkerCatchAllNumPlusLazy exercises a catch-all +// with both an eager pool (num=1) and lazy ensures: every ensure() must +// succeed alongside the eager thread, since num and the catch-all cap +// reserve independent thread budgets. +func TestEnsureBackgroundWorkerCatchAllNumPlusLazy(t *testing.T) { + tmp := t.TempDir() + setupBgWorker(t, + WithWorkers("", "testdata/bgworker/named.php", 1, + WithWorkerBackground(), + WithWorkerEnv(map[string]string{"BG_SENTINEL_DIR": tmp}), + ), + WithNumThreads(2), + ) + + for _, name := range []string{"a", "b", "c", "d"} { + require.NoError(t, ensureBackgroundWorker(nil, name, 5*time.Second), "ensure(%s)", name) + } + for _, name := range []string{"a", "b", "c", "d"} { + requireSentinelEventually(t, filepath.Join(tmp, name), + "lazy catch-all instance %q should have written its sentinel", name) + } +} + +// TestBackgroundWorkerCatchAllEagerInstance verifies that a catch-all +// declared with num>0 actually boots and runs the script — the +// catch-all flag must not silently disable the eager pool. +func TestBackgroundWorkerCatchAllEagerInstance(t *testing.T) { + tmp := t.TempDir() + sentinel := filepath.Join(tmp, "eager") + setupBgWorker(t, + WithWorkers("", "testdata/bgworker/eager-catchall.php", 1, + WithWorkerBackground(), + WithWorkerEnv(map[string]string{"BG_EAGER_SENTINEL": sentinel}), + ), + WithNumThreads(2), + ) + requireSentinelEventually(t, sentinel, + "eager catch-all instance must reach readiness") +} + +// TestEnsureBackgroundWorkerPostBootCrashLoopAborts verifies that a +// worker which reaches readiness once and then keeps crashing aborts +// ensure() callers, instead of leaving them stuck on the stale "ready" +// signal. +func TestEnsureBackgroundWorkerPostBootCrashLoopAborts(t *testing.T) { + setupBgWorker(t, + WithWorkers("flapper", "testdata/bgworker/readiness-then-crash.php", 0, + WithWorkerBackground(), + WithWorkerMaxFailures(2), + ), + WithNumThreads(2), + ) + + // Returns once the fixture signals readiness via frankenphp_get_worker_handle(). + require.NoError(t, ensureBackgroundWorker(nil, "flapper", 5*time.Second)) + + // Subsequent ensure()s must surface the failure: prior abort or a + // fresh thread that crashes again. Never nil. + require.Eventually(t, func() bool { + return ensureBackgroundWorker(nil, "flapper", 1*time.Second) != nil + }, 10*time.Second, 100*time.Millisecond, + "ensure() must surface post-boot crash-loop") +} + +// TestEnsureBackgroundWorkerCatchAllRespawnAfterCap verifies that once +// a catch-all instance trips the failure cap, the slot is released so +// a retried ensure() can lazy-spawn a fresh thread under the same name. +func TestEnsureBackgroundWorkerCatchAllRespawnAfterCap(t *testing.T) { + tmp := t.TempDir() + setupBgWorker(t, + WithWorkers("", "testdata/bgworker/fail-then-succeed.php", 0, + WithWorkerBackground(), + WithWorkerMaxThreads(2), + WithWorkerMaxFailures(2), + WithWorkerEnv(map[string]string{ + "BG_MARKER_DIR": tmp, + // Crash boots 1..3, succeed on boot 4. The cap fires on + // the 3rd crash because failureCount is checked before + // the increment in afterScriptExecution. + "BG_FAIL_UNTIL": "3", + }), + ), + WithNumThreads(3), + ) + + err := ensureBackgroundWorker(nil, "respawn", 5*time.Second) + require.Error(t, err, "first ensure() must hit the cap") + + require.Eventually(t, func() bool { + return ensureBackgroundWorker(nil, "respawn", 5*time.Second) == nil + }, 15*time.Second, 100*time.Millisecond, + "retry must lazy-spawn a fresh thread after the cap") + + requireSentinelEventually(t, filepath.Join(tmp, "ready"), + "recovered worker must reach readiness") +} diff --git a/bgworkerinternalhelpers_test.go b/bgworkerinternalhelpers_test.go new file mode 100644 index 0000000000..cd028a84ba --- /dev/null +++ b/bgworkerinternalhelpers_test.go @@ -0,0 +1,32 @@ +package frankenphp + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// setupBgWorker boots FrankenPHP with the given options (internal-package +// variant), registers Shutdown as a t.Cleanup, and returns the absolute +// path to the testdata directory. +func setupBgWorker(t *testing.T, opts ...Option) (testDataDir string) { + t.Helper() + cwd, err := os.Getwd() + require.NoError(t, err) + testDataDir = cwd + "/testdata/" + require.NoError(t, Init(opts...)) + t.Cleanup(Shutdown) + return +} + +// requireSentinelEventually asserts that `path` appears on disk before the +// deadline. Wraps require.Eventually so call sites stay short. +func requireSentinelEventually(t testing.TB, path string, msgAndArgs ...any) { + t.Helper() + require.Eventually(t, func() bool { + _, err := os.Stat(path) + return err == nil + }, 5*time.Second, 10*time.Millisecond, msgAndArgs...) +} diff --git a/bgworkerpool_test.go b/bgworkerpool_test.go new file mode 100644 index 0000000000..e82127aa97 --- /dev/null +++ b/bgworkerpool_test.go @@ -0,0 +1,58 @@ +package frankenphp_test + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/dunglas/frankenphp" + "github.com/stretchr/testify/require" +) + +// TestBackgroundWorkerPool declares a named bg worker with num=3 (pool of +// three threads). Each thread touches a unique sentinel under +// BG_SENTINEL_DIR via tempnam(), so the test can assert that all three +// pool threads booted independently. Covers the lifted num>1 + +// max_threads>1 constraints and the per-thread stop pipe. +func TestBackgroundWorkerPool(t *testing.T) { + tmp := t.TempDir() + setupFrankenPHP(t, + frankenphp.WithWorkers("pool-worker", "testdata/bgworker/pool.php", 3, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerMaxThreads(3), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL_DIR": tmp}), + ), + frankenphp.WithNumThreads(6), + ) + + require.Eventually(t, func() bool { + entries, err := os.ReadDir(tmp) + return err == nil && len(entries) == 3 + }, 5*time.Second, 25*time.Millisecond, + "expected 3 distinct pool sentinels under %s", tmp) +} + +// TestBackgroundWorkerMultiEntrypoint declares two named bg workers that +// share the same entrypoint file. Each gets its own *worker, so both Init +// successfully (no filename-collision rejection) and both produce +// sentinels. +func TestBackgroundWorkerMultiEntrypoint(t *testing.T) { + tmp := t.TempDir() + setupFrankenPHP(t, + frankenphp.WithWorkers("shared-a", "testdata/bgworker/named.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL_DIR": tmp}), + ), + frankenphp.WithWorkers("shared-b", "testdata/bgworker/named.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL_DIR": tmp}), + ), + frankenphp.WithNumThreads(5), + ) + + for _, name := range []string{"shared-a", "shared-b"} { + requireFileEventually(t, filepath.Join(tmp, name), + "shared bg worker %q did not touch its sentinel", name) + } +} diff --git a/bgworkerscope_test.go b/bgworkerscope_test.go new file mode 100644 index 0000000000..8f23822233 --- /dev/null +++ b/bgworkerscope_test.go @@ -0,0 +1,133 @@ +package frankenphp + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestNextScopeIsDistinct verifies the scope counter +// hands out unique values on consecutive calls. +func TestNextScopeIsDistinct(t *testing.T) { + a := NextScope() + b := NextScope() + assert.NotEqual(t, a, b, "consecutive scopes must differ") + assert.NotZero(t, a, "scopes must be non-zero (zero is the global scope)") + assert.NotZero(t, b, "scopes must be non-zero (zero is the global scope)") +} + +// TestBackgroundWorkerSameNameDifferentScope declares two named bg +// workers with the same user-facing name in distinct scopes. Both must +// Init successfully (the global workersByName collision check must +// recognize bg workers as scope-isolated). +func TestBackgroundWorkerSameNameDifferentScope(t *testing.T) { + scopeA := NextScope() + scopeB := NextScope() + + tmp := t.TempDir() + + setupBgWorker(t, + WithWorkers("shared", "testdata/bgworker/named.php", 1, + WithWorkerBackground(), + WithWorkerScope(scopeA), + WithWorkerEnv(map[string]string{"BG_SENTINEL_DIR": tmp + "/a"}), + ), + WithWorkers("shared", "testdata/bgworker/named.php", 1, + WithWorkerBackground(), + WithWorkerScope(scopeB), + WithWorkerEnv(map[string]string{"BG_SENTINEL_DIR": tmp + "/b"}), + ), + WithNumThreads(4), + ) + + // Both lookups must exist and resolve "shared" to a *worker. + require.NotNil(t, backgroundLookups[scopeA], "scope A lookup missing") + require.NotNil(t, backgroundLookups[scopeB], "scope B lookup missing") + assert.NotNil(t, backgroundLookups[scopeA].byName["shared"], "scope A must resolve 'shared'") + assert.NotNil(t, backgroundLookups[scopeB].byName["shared"], "scope B must resolve 'shared'") + // And they must be distinct *worker instances (not the same pointer). + assert.NotSame(t, + backgroundLookups[scopeA].byName["shared"], + backgroundLookups[scopeB].byName["shared"], + "each scope must own a distinct *worker for the same name") +} + +// TestBackgroundWorkerCatchAllPerScope declares a catch-all in two +// distinct scopes and verifies that ensure() in scope A consumes a slot +// from scope A's catch-all only, leaving scope B's catch-all untouched. +func TestBackgroundWorkerCatchAllPerScope(t *testing.T) { + scopeA := NextScope() + scopeB := NextScope() + + tmp := t.TempDir() + dirA := filepath.Join(tmp, "a") + dirB := filepath.Join(tmp, "b") + require.NoError(t, os.MkdirAll(dirA, 0o755)) + require.NoError(t, os.MkdirAll(dirB, 0o755)) + + setupBgWorker(t, + WithWorkers("", "testdata/bgworker/named.php", 0, + WithWorkerBackground(), + WithWorkerScope(scopeA), + WithWorkerEnv(map[string]string{"BG_SENTINEL_DIR": dirA}), + ), + WithWorkers("", "testdata/bgworker/named.php", 0, + WithWorkerBackground(), + WithWorkerScope(scopeB), + WithWorkerEnv(map[string]string{"BG_SENTINEL_DIR": dirB}), + ), + WithNumThreads(4), + ) + + // Pre-conditions: each scope's catch-all is empty. + require.Empty(t, backgroundLookups[scopeA].catchAll.bg.catchAllNames, "scope A catch-all must start empty") + require.Empty(t, backgroundLookups[scopeB].catchAll.bg.catchAllNames, "scope B catch-all must start empty") + + // Drive ensure() through a fake "request" context tagged to scope A. + // We use a request-scope tag (rather than a worker handler) because + // no scope-specific handler is running in the test. + fc := newFrankenPHPContext() + fc.scope = scopeA + ctx := context.WithValue(context.Background(), contextKey, fc) + thread := &phpThread{} + thread.handler = &fakeContextThread{ctx: ctx} + require.NoError(t, ensureBackgroundWorker(thread, "job-a", 5*time.Second)) + + // The lazy-started instance must land in scope A's catch-all only. + require.Eventually(t, func() bool { + _, ok := backgroundLookups[scopeA].catchAll.bg.catchAllNames["job-a"] + return ok + }, 2*time.Second, 10*time.Millisecond, "ensure() must populate scope A's catch-all") + assert.Empty(t, backgroundLookups[scopeB].catchAll.bg.catchAllNames, "scope B catch-all must remain untouched") + + // All catch-all threads attach to the scope's catch-all *worker, so + // scope A's catch-all should have one thread (for "job-a") while + // scope B's catch-all should have none. + assert.Equal(t, 1, backgroundLookups[scopeA].catchAll.countThreads(), "scope A catch-all must host exactly one thread") + assert.Equal(t, 0, backgroundLookups[scopeB].catchAll.countThreads(), "scope B catch-all must host zero threads") + + // Wait for the worker to write its sentinel so we know the right + // fixture ran (sanity check that env was inherited from scope A). + requireSentinelEventually(t, filepath.Join(dirA, "job-a"), + "scope A catch-all instance must write its sentinel under scope A's BG_SENTINEL_DIR") +} + +// fakeContextThread is a threadHandler stub that lets a test drive +// ensureBackgroundWorker via the request-context fallback path in +// getLookup, without spinning up a real PHP thread. +type fakeContextThread struct { + ctx context.Context +} + +func (h *fakeContextThread) name() string { return "fake" } +func (h *fakeContextThread) beforeScriptExecution() string { return "" } +func (h *fakeContextThread) afterScriptExecution(int) {} +func (h *fakeContextThread) frankenPHPContext() *frankenPHPContext { return nil } +func (h *fakeContextThread) drain() {} +func (h *fakeContextThread) context() context.Context { return h.ctx } +func (h *fakeContextThread) scopedWorker() *worker { return nil } diff --git a/caddy/app.go b/caddy/app.go index fbe72eb620..8283718f94 100644 --- a/caddy/app.go +++ b/caddy/app.go @@ -1,7 +1,6 @@ package caddy import ( - "context" "errors" "fmt" "log/slog" @@ -62,8 +61,15 @@ type FrankenPHPApp struct { opts []frankenphp.Option metrics frankenphp.Metrics - ctx context.Context + ctx caddy.Context logger *slog.Logger + + // scopeOwners is the per-php_server module registry used by Start + // to resolve a human-readable scope label (via the cascade in + // scopelabel.go) before frankenphp.Init starts any bg worker, so + // the very first metric call already carries the right prefix. + scopeOwnersMu sync.Mutex + scopeOwners map[frankenphp.Scope]*FrankenPHPModule } var iniError = errors.New(`"php_ini" must be in the format: php_ini "" ""`) @@ -139,6 +145,41 @@ func (f *FrankenPHPApp) addModuleWorkers(workers ...workerConfig) ([]workerConfi return workers, nil } +// registerScopeOwner records the FrankenPHPModule that allocated the +// given scope so Start() can resolve its label before frankenphp.Init. +func (f *FrankenPHPApp) registerScopeOwner(scope frankenphp.Scope, mod *FrankenPHPModule) { + f.scopeOwnersMu.Lock() + defer f.scopeOwnersMu.Unlock() + if f.scopeOwners == nil { + f.scopeOwners = make(map[frankenphp.Scope]*FrankenPHPModule) + } + f.scopeOwners[scope] = mod +} + +// resolveScopeLabels walks the http app's servers once per registered +// module, picks the one whose route tree contains the module, runs the +// scopelabel.go cascade, and stores the result via SetScopeLabel. Runs +// before frankenphp.Init so the first metric emit on every bg worker +// already carries the right "m#