From c77319c9bd0f5307d64409f931cca2fa4f19de44 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sun, 6 Sep 2026 18:35:53 +0200 Subject: [PATCH 01/13] feat: background workers and FrankenPHP\WorkerHandle --- bgworker_test.go | 783 ++++++++++++++++++++++++ caddy/app.go | 51 +- caddy/caddy_test.go | 103 +++- caddy/config_test.go | 137 ++++- caddy/module.go | 4 + caddy/serveridx_test.go | 6 +- caddy/workerconfig.go | 47 +- cgi.go | 2 +- context.go | 19 +- docs/config.md | 13 +- docs/library.md | 4 + docs/metrics.md | 18 +- docs/worker.md | 73 +++ frankenphp.c | 506 +++++++++++++++ frankenphp.go | 95 +-- frankenphp.h | 4 + frankenphp.stub.php | 151 +++-- frankenphp_arginfo.h | 29 +- frankenphp_test.go | 16 +- metrics.go | 231 +++++-- metrics_test.go | 137 ++++- options.go | 33 + phpmainthread.go | 38 +- phpmainthread_test.go | 51 +- phpthread.go | 19 +- requestoptions.go | 24 +- scaling.go | 6 +- scaling_test.go | 2 +- server.go | 72 ++- server_test.go | 76 ++- testdata/_executor.php | 2 +- testdata/bgworker/basic.php | 21 + testdata/bgworker/count.php | 15 + testdata/bgworker/crash-after-ready.php | 9 + testdata/bgworker/crash.php | 31 + testdata/bgworker/cycle.php | 8 + testdata/bgworker/early-return.php | 5 + testdata/bgworker/exit-after-tick.php | 7 + testdata/bgworker/fail-then-succeed.php | 18 + testdata/bgworker/fetch-no-tick.php | 7 + testdata/bgworker/flag.php | 16 + testdata/bgworker/loop.php | 16 + testdata/bgworker/named.php | 21 + testdata/bgworker/never-ticks.php | 10 + testdata/bgworker/no-time-limit.php | 12 + testdata/bgworker/poll-interface.php | 16 + testdata/bgworker/poll.php | 14 + testdata/bgworker/pool.php | 13 + testdata/bgworker/read.php | 11 + testdata/bgworker/readable.php | 26 + testdata/bgworker/recv.php | 12 + testdata/bgworker/refetch.php | 21 + testdata/bgworker/slow-boot.php | 11 + testdata/bgworker/stuck.php | 19 + testdata/bgworker/tick.php | 17 + testdata/handle-bypass.php | 18 + testdata/handle-outside.php | 8 + testdata/symlinks/test/index.php | 2 +- testdata/symlinks/test/nested/index.php | 2 +- testdata/worker-name.php | 11 + threadbackgroundworker.go | 289 +++++++++ threadworker.go | 115 ++-- threadworker_test.go | 20 + types_test.go | 2 +- worker.go | 241 ++++++-- worker_internal_test.go | 2 +- workerextension.go | 26 +- workerextension_test.go | 68 ++ workerlifecycle.go | 58 ++ 69 files changed, 3476 insertions(+), 494 deletions(-) create mode 100644 bgworker_test.go create mode 100644 testdata/bgworker/basic.php create mode 100644 testdata/bgworker/count.php create mode 100644 testdata/bgworker/crash-after-ready.php create mode 100644 testdata/bgworker/crash.php create mode 100644 testdata/bgworker/cycle.php create mode 100644 testdata/bgworker/early-return.php create mode 100644 testdata/bgworker/exit-after-tick.php create mode 100644 testdata/bgworker/fail-then-succeed.php create mode 100644 testdata/bgworker/fetch-no-tick.php create mode 100644 testdata/bgworker/flag.php create mode 100644 testdata/bgworker/loop.php create mode 100644 testdata/bgworker/named.php create mode 100644 testdata/bgworker/never-ticks.php create mode 100644 testdata/bgworker/no-time-limit.php create mode 100644 testdata/bgworker/poll-interface.php create mode 100644 testdata/bgworker/poll.php create mode 100644 testdata/bgworker/pool.php create mode 100644 testdata/bgworker/read.php create mode 100644 testdata/bgworker/readable.php create mode 100644 testdata/bgworker/recv.php create mode 100644 testdata/bgworker/refetch.php create mode 100644 testdata/bgworker/slow-boot.php create mode 100644 testdata/bgworker/stuck.php create mode 100644 testdata/bgworker/tick.php create mode 100644 testdata/handle-bypass.php create mode 100644 testdata/handle-outside.php create mode 100644 testdata/worker-name.php create mode 100644 threadbackgroundworker.go create mode 100644 threadworker_test.go create mode 100644 workerlifecycle.go diff --git a/bgworker_test.go b/bgworker_test.go new file mode 100644 index 0000000000..62ef7fbb8a --- /dev/null +++ b/bgworker_test.go @@ -0,0 +1,783 @@ +package frankenphp_test + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/dunglas/frankenphp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// requireFileEventually asserts that `path` appears on disk before the deadline +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...) +} + +// requireFileContentEventually waits for `path` to appear with content +func requireFileContentEventually(t *testing.T, path string) string { + t.Helper() + require.Eventually(t, func() bool { + b, err := os.ReadFile(path) + return err == nil && len(b) > 0 + }, 5*time.Second, 25*time.Millisecond, "file %q did not appear", path) + b, err := os.ReadFile(path) + require.NoError(t, err) + + return string(b) +} + +// asserts on the timing of Shutdown(), so it calls it itself rather than +// through initServers' t.Cleanup hook +func TestBackgroundWorkerLifecycle(t *testing.T) { + tmp := t.TempDir() + sentinel := filepath.Join(tmp, "bg-lifecycle.sentinel") + + t.Cleanup(frankenphp.Shutdown) + 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), + )) + + 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") + } +} + +func TestBackgroundWorkerCrashRestarts(t *testing.T) { + tmp := t.TempDir() + crashMarker := filepath.Join(tmp, "bg-crash.marker") + restarted := filepath.Join(tmp, "bg-crash.restarted") + + initServers(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") +} + +// the sentinel directory is declared on the server, not on the worker +func TestBackgroundWorkerOnServer(t *testing.T) { + tmp := t.TempDir() + + server, err := frankenphp.NewServer( + testDataDir, + frankenphp.WithServerName("sidekick-server"), + frankenphp.WithServerEnv(map[string]string{"BG_SENTINEL_DIR": tmp}), + ) + require.NoError(t, err) + + globalSentinel := filepath.Join(tmp, "global.sentinel") + initServers(t, + frankenphp.WithServer(server), + frankenphp.WithWorkers("jobs", "testdata/bgworker/named.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerServerScope(server), + ), + // a global worker may reuse the name: names are scoped to their server + frankenphp.WithWorkers("jobs", "testdata/bgworker/basic.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": globalSentinel}), + ), + frankenphp.WithNumThreads(3), + ) + + // named.php touches "/": the script sees + // the declared name, not the server-qualified one used by metrics and logs + requireFileEventually(t, filepath.Join(tmp, "jobs"), "background worker did not touch its per-name sentinel") + requireFileEventually(t, globalSentinel, "the global worker sharing the name did not start") + + body := serverGet(t, server, "http://example.com/index.php") + assert.Contains(t, body, "I am by birth a Genevese", "the server must still serve regular requests") +} + +func TestBackgroundWorkerValidation(t *testing.T) { + t.Cleanup(frankenphp.Shutdown) + + t.Run("name is required", func(t *testing.T) { + err := frankenphp.Init( + frankenphp.WithWorkers("", "testdata/bgworker/basic.php", 1, frankenphp.WithWorkerBackground()), + frankenphp.WithNumThreads(2), + ) + require.ErrorContains(t, err, "must have an explicit name") + }) + + t.Run("names are unique within a server", func(t *testing.T) { + // a global and a server-scoped worker may share a name (see + // TestBackgroundWorkerOnServer), two workers of one server may not + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + err = frankenphp.Init( + frankenphp.WithServer(server), + frankenphp.WithWorkers("bg-shared", "testdata/bgworker/basic.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerServerScope(server), + ), + frankenphp.WithWorkers("bg-shared", "testdata/bgworker/named.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerServerScope(server), + ), + frankenphp.WithNumThreads(3), + ) + require.ErrorContains(t, err, "two workers in a server cannot have the same name") + }) + + t.Run("early return without the handle fails startup", func(t *testing.T) { + err := frankenphp.Init( + frankenphp.WithWorkers("bg-early", "testdata/bgworker/early-return.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerMaxFailures(2), + ), + frankenphp.WithNumThreads(2), + ) + require.ErrorContains(t, err, "without calling WorkerHandle::tick()") + }) + + t.Run("fetching the handle without ticking fails startup", func(t *testing.T) { + err := frankenphp.Init( + frankenphp.WithWorkers("bg-no-tick", "testdata/bgworker/fetch-no-tick.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerMaxFailures(2), + ), + frankenphp.WithNumThreads(2), + ) + require.ErrorContains(t, err, "without calling WorkerHandle::tick()") + }) + + t.Run("max_threads is rejected", func(t *testing.T) { + err := frankenphp.Init( + frankenphp.WithWorkers("bg-scaled", "testdata/bgworker/basic.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerMaxThreads(2), + ), + frankenphp.WithNumThreads(2), + ) + require.ErrorContains(t, err, "cannot set max_threads") + }) + + t.Run("two workers cannot report under the same name", func(t *testing.T) { + // scoping keeps names apart, except for a global name shaped like + // the ":" of a scoped one + server, err := frankenphp.NewServer(testDataDir, frankenphp.WithServerName("api")) + require.NoError(t, err) + err = frankenphp.Init( + frankenphp.WithServer(server), + frankenphp.WithWorkers("jobs", "testdata/bgworker/basic.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerServerScope(server), + ), + frankenphp.WithWorkers("api:jobs", "testdata/bgworker/named.php", 1, + frankenphp.WithWorkerBackground(), + ), + frankenphp.WithNumThreads(3), + ) + require.ErrorContains(t, err, `two workers cannot report under the same name: "api:jobs"`) + }) + + t.Run("an unregistered server scope is rejected", func(t *testing.T) { + unregistered, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + err = frankenphp.Init( + frankenphp.WithWorkers("bg-orphan", "testdata/bgworker/basic.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerServerScope(unregistered), + ), + frankenphp.WithNumThreads(2), + ) + require.ErrorContains(t, err, "not passed to WithServer()") + }) + + t.Run("request matchers are rejected", func(t *testing.T) { + err := frankenphp.Init( + frankenphp.WithWorkers("bg-matched", "testdata/bgworker/basic.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerMatcher(func(*http.Request) bool { return true }), + ), + frankenphp.WithNumThreads(2), + ) + require.ErrorContains(t, err, "cannot match requests") + }) +} + +func TestBackgroundWorkerCannotHandleRequests(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, + frankenphp.WithServer(server), + frankenphp.WithWorkers("jobs", "testdata/bgworker/basic.php", 1, frankenphp.WithWorkerBackground(), frankenphp.WithWorkerServerScope(server)), + frankenphp.WithNumThreads(2), + ) + + err = server.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "http://example.com/index.php", nil), frankenphp.WithWorkerName("jobs")) + require.ErrorContains(t, err, `background worker "jobs" cannot handle requests`) +} + +// a blocking read is a wait too: it reports the worker ready, and the EOF of +// the drain unblocks it +func TestBackgroundWorkerParksOnRead(t *testing.T) { + tmp := t.TempDir() + sentinel := filepath.Join(tmp, "bg-read.sentinel") + + t.Cleanup(frankenphp.Shutdown) + require.NoError(t, frankenphp.Init( + frankenphp.WithWorkers("bg-read", "testdata/bgworker/read.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + )) + requireFileEventually(t, sentinel, "background worker parked on a read did not start") + + done := make(chan struct{}) + go func() { + frankenphp.Shutdown() + close(done) + }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("Shutdown did not return within 10s: the read did not observe EOF") + } +} + +// a blocking receive reaches the stream through the transport API rather than +// the read op, so Init() would hang if only reads reported readiness +func TestBackgroundWorkerParksOnReceive(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "bg-recv.sentinel") + + t.Cleanup(frankenphp.Shutdown) + require.NoError(t, frankenphp.Init( + frankenphp.WithWorkers("bg-recv", "testdata/bgworker/recv.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + )) + requireFileEventually(t, sentinel, "background worker parked on a receive did not start") + + done := make(chan struct{}) + go func() { + frankenphp.Shutdown() + close(done) + }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("Shutdown did not return within 10s: the receive did not observe EOF") + } +} + +func TestBackgroundWorkerRestartDrainsParkedScript(t *testing.T) { + tmp := t.TempDir() + countFile := filepath.Join(tmp, "bg-count.log") + + initServers(t, + frankenphp.WithWorkers("bg-count", "testdata/bgworker/count.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_COUNT_FILE": countFile}), + ), + frankenphp.WithNumThreads(2), + ) + runs := func() int { + b, _ := os.ReadFile(countFile) + return bytes.Count(b, []byte("\n")) + } + require.Eventually(t, func() bool { return runs() == 1 }, 5*time.Second, 25*time.Millisecond, "background worker did not start") + + frankenphp.RestartWorkers() + + require.Eventually(t, func() bool { return runs() == 2 }, 5*time.Second, 25*time.Millisecond, "background worker was not re-run after the restart") +} + +func TestWorkerHandleOutsideBackgroundWorker(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), frankenphp.WithNumThreads(1)) + + body := serverGet(t, server, "http://example.com/handle-outside.php") + + assert.Contains(t, body, `FrankenPHP\WorkerHandle can only be created from a background worker`) +} + +// without the wake-up sent at start, a script that only ticks when its handle +// is readable would keep Init() waiting until the drain +func TestBackgroundWorkerLoopTicksOnItsOwn(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "loop.sentinel") + initServers(t, + frankenphp.WithWorkers("bg-loop", "testdata/bgworker/loop.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + ) + requireFileEventually(t, sentinel, "background worker did not start") +} + +// the handle is readable at start and quiet once a tick returned, so a loop +// selecting on it blocks instead of spinning +func TestBackgroundWorkerTickLeavesTheHandleQuiet(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "readable.txt") + initServers(t, + frankenphp.WithWorkers("bg-readable", "testdata/bgworker/readable.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + ) + + assert.Equal(t, "start:readable after tick:quiet after second tick:quiet", requireFileContentEventually(t, sentinel)) +} + +// true while the worker runs, false once it is drained, and still false on the +// next call +func TestBackgroundWorkerTick(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "ticks.txt") + + t.Cleanup(frankenphp.Shutdown) + require.NoError(t, frankenphp.Init( + frankenphp.WithWorkers("bg-tick", "testdata/bgworker/tick.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + )) + assert.Equal(t, "true true", requireFileContentEventually(t, sentinel)) + + // Shutdown() waits for the script to leave, so the file is final after it + frankenphp.Shutdown() + b, err := os.ReadFile(sentinel) + require.NoError(t, err) + assert.Equal(t, "true true false false", string(b)) +} + +// an HTTP worker sees FRANKENPHP_WORKER as it always did, a background one its +// declared name in FRANKENPHP_WORKER_BACKGROUND and no FRANKENPHP_WORKER +func TestWorkerNameInServerVars(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "flag.txt") + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, + frankenphp.WithServer(server), + frankenphp.WithWorkers("web", testDataDir+"worker-name.php", 1, frankenphp.WithWorkerServerScope(server)), + frankenphp.WithWorkers("jobs", "testdata/bgworker/flag.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerServerScope(server), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(3), + ) + + assert.Equal(t, "1 http", serverGet(t, server, "http://example.com/worker-name.php")) + + flag := requireFileContentEventually(t, sentinel) + assert.Contains(t, flag, "'worker' => 'unset'") + assert.Contains(t, flag, "'background' => 'jobs'") +} + +// the threads of a pool share the name, park on a handle each, and one drain +// wakes them all +func TestBackgroundWorkerPool(t *testing.T) { + dir := t.TempDir() + initServers(t, + frankenphp.WithWorkers("pool", "testdata/bgworker/pool.php", 3, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL_DIR": dir}), + ), + frankenphp.WithNumThreads(4), + ) + + require.Eventually(t, func() bool { + entries, _ := os.ReadDir(dir) + return len(entries) == 3 + }, 5*time.Second, 25*time.Millisecond, "the three pool threads did not all start") + + done := make(chan struct{}) + go func() { + frankenphp.Shutdown() + close(done) + }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("Shutdown did not drain the whole pool within 10s") + } +} + +// two named background workers of one server may share a script, they are not +// matched by path +func TestBackgroundWorkerMultiEntrypoint(t *testing.T) { + tmp := t.TempDir() + first, second := filepath.Join(tmp, "first"), filepath.Join(tmp, "second") + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, + frankenphp.WithServer(server), + frankenphp.WithWorkers("first", "testdata/bgworker/basic.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerServerScope(server), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": first}), + ), + frankenphp.WithWorkers("second", "testdata/bgworker/basic.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerServerScope(server), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": second}), + ), + frankenphp.WithNumThreads(3), + ) + + requireFileEventually(t, first, "the first worker on the shared script did not start") + requireFileEventually(t, second, "the second worker on the shared script did not start") +} + +// background threads are reserved on top of num_threads +func TestBackgroundWorkerThreadsComeOnTop(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "bg-only.sentinel") + initServers(t, + frankenphp.WithWorkers("bg-only", "testdata/bgworker/basic.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(1), + ) + + requireFileEventually(t, sentinel, "background worker did not start with num_threads 1") +} + +// a background worker serves no requests, so num defaults to one thread rather +// than to the CPU count of an HTTP worker +func TestBackgroundWorkerDefaultsToOneThread(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "bg-default.sentinel") + initServers(t, + frankenphp.WithWorkers("bg-default", "testdata/bgworker/basic.php", 0, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(1), + ) + requireFileEventually(t, sentinel, "background worker did not start without an explicit num") + + background := 0 + for _, thread := range frankenphp.DebugState().ThreadDebugStates { + if strings.Contains(thread.Name, "Background Worker") { + background++ + } + } + assert.Equal(t, 1, background) +} + +// the automatic limit is an HTTP one too: the reservation is added to what it +// resolves, instead of eating into it +func TestBackgroundWorkerThreadsComeOnTopOfAutoMaxThreads(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "bg-auto.sentinel") + initServers(t, + frankenphp.WithWorkers("bg-auto", "testdata/bgworker/basic.php", 2, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + frankenphp.WithMaxThreads(-1), + frankenphp.WithPhpIni(map[string]string{"memory_limit": "-1"}), + ) + requireFileEventually(t, sentinel, "background worker did not start") + + // an unlimited memory_limit falls back to twice the HTTP threads, so + // 2*2 HTTP plus the 2 reserved, not (2+2)*2 + state := frankenphp.DebugState() + assert.Equal(t, 6, len(state.ThreadDebugStates)+state.ReservedThreadCount) +} + +// max_execution_time applies until the first tick, a setup that outlives it +// fails Init(). The limit is PHP's, so the test only runs where its timers are +// known to fire under FrankenPHP: the max execution timers of Linux ZTS builds +func TestBackgroundWorkerBootstrapIsBounded(t *testing.T) { + if !frankenphp.Config().ZendMaxExecutionTimers { + t.Skip("max_execution_time is only reliable with Zend max execution timers") + } + + countFile := filepath.Join(t.TempDir(), "boots") + err := frankenphp.Init( + frankenphp.WithWorkers("bg-slow", "testdata/bgworker/slow-boot.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerMaxFailures(0), + frankenphp.WithWorkerEnv(map[string]string{"BG_COUNT_FILE": countFile}), + ), + frankenphp.WithNumThreads(2), + frankenphp.WithPhpIni(map[string]string{"max_execution_time": "1"}), + ) + if err == nil { + frankenphp.Shutdown() + } + require.ErrorContains(t, err, "keeps crashing") + + b, _ := os.ReadFile(countFile) + assert.Equal(t, 1, bytes.Count(b, []byte("\n")), "the limit should have ended the one and only boot") +} + +// past its first tick, a parked script is cut short by neither of the two +// limits it never disables itself: max_execution_time, disarmed by the tick, +// and default_socket_timeout, which the handle's stream overrides +func TestBackgroundWorkerParkingIsNotInterrupted(t *testing.T) { + countFile := filepath.Join(t.TempDir(), "runs") + initServers(t, + frankenphp.WithWorkers("bg-timeout", "testdata/bgworker/no-time-limit.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_COUNT_FILE": countFile}), + ), + frankenphp.WithNumThreads(2), + frankenphp.WithPhpIni(map[string]string{"max_execution_time": "1", "max_input_time": "1", "default_socket_timeout": "1"}), + ) + + runs := func() int { + b, _ := os.ReadFile(countFile) + + return bytes.Count(b, []byte("\n")) + } + require.Eventually(t, func() bool { return runs() == 1 }, 5*time.Second, 25*time.Millisecond, "background worker did not start") + // well past both limits + time.Sleep(2500 * time.Millisecond) + assert.Equal(t, 1, runs(), "the worker was restarted, so a limit interrupted its park") +} + +// the same stream every time, a fresh one once the script closed it, one per +// handle, and the drain reaches the script through all of them +func TestBackgroundWorkerStreamClosedAndFetchedAgain(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "refetch.txt") + + t.Cleanup(frankenphp.Shutdown) + require.NoError(t, frankenphp.Init( + frankenphp.WithWorkers("bg-refetch", "testdata/bgworker/refetch.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + )) + assert.Equal(t, "same then fresh then its own", requireFileContentEventually(t, sentinel)) + + done := make(chan struct{}) + go func() { + frankenphp.Shutdown() + close(done) + }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("Shutdown did not return within 10s: the re-fetched handle missed the drain") + } +} + +// boot failures below max_consecutive_failures are retried with the backoff, +// and Init() still succeeds once a run reaches its ready point +func TestBackgroundWorkerBootFailuresThenSucceeds(t *testing.T) { + tmp := t.TempDir() + countFile, sentinel := filepath.Join(tmp, "boots"), filepath.Join(tmp, "ready") + initServers(t, + frankenphp.WithWorkers("bg-flaky", "testdata/bgworker/fail-then-succeed.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_COUNT_FILE": countFile, "BG_SENTINEL": sentinel, "BG_FAIL_UNTIL": "2"}), + ), + frankenphp.WithNumThreads(2), + ) + + requireFileEventually(t, sentinel, "background worker did not recover from its boot failures") + boots, err := os.ReadFile(countFile) + require.NoError(t, err) + assert.Equal(t, "3", string(boots), "two boot failures then a success") +} + +// a crash past the ready point restarts without counting toward +// max_consecutive_failures, and a drain cuts the backoff short +func TestBackgroundWorkerCrashAfterReadyRestarts(t *testing.T) { + countFile := filepath.Join(t.TempDir(), "runs") + initServers(t, + frankenphp.WithWorkers("bg-crashy", "testdata/bgworker/crash-after-ready.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerMaxFailures(2), + frankenphp.WithWorkerEnv(map[string]string{"BG_COUNT_FILE": countFile}), + ), + frankenphp.WithNumThreads(2), + ) + + require.Eventually(t, func() bool { + b, _ := os.ReadFile(countFile) + return bytes.Count(b, []byte("\n")) >= 5 + }, 5*time.Second, 25*time.Millisecond, "the worker was not restarted after crashing past its ready point") + + // past four crashes the wait is at its 1s cap + start := time.Now() + frankenphp.Shutdown() + assert.Less(t, time.Since(start), 500*time.Millisecond, "Shutdown() waited for the backoff") +} + +// a script returning right after its tick is re-run with the crash backoff +func TestBackgroundWorkerCleanExitIsPaced(t *testing.T) { + countFile := filepath.Join(t.TempDir(), "runs") + initServers(t, + frankenphp.WithWorkers("bg-exit", "testdata/bgworker/exit-after-tick.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_COUNT_FILE": countFile}), + ), + frankenphp.WithNumThreads(2), + ) + + time.Sleep(time.Second) + b, err := os.ReadFile(countFile) + require.NoError(t, err) + runs := bytes.Count(b, []byte("\n")) + assert.GreaterOrEqual(t, runs, 2, "the script was not re-run") + assert.LessOrEqual(t, runs, 8, "the re-runs were not paced") +} + +// the other side of the pacing: a worker that does some work and returns is +// re-run at its own pace +func TestBackgroundWorkerCyclingIsNotThrottled(t *testing.T) { + countFile := filepath.Join(t.TempDir(), "runs") + initServers(t, + frankenphp.WithWorkers("bg-cycle", "testdata/bgworker/cycle.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_COUNT_FILE": countFile}), + ), + frankenphp.WithNumThreads(2), + ) + + // the interval between two runs is the work plus whatever FrankenPHP + // waits: comparing the last to the first cancels how fast the machine + // is, and a backoff counting these runs would have grown it by a second + starts := func() []float64 { + b, _ := os.ReadFile(countFile) + var times []float64 + for _, line := range strings.Fields(string(b)) { + if t, err := strconv.ParseFloat(line, 64); err == nil { + times = append(times, t) + } + } + + return times + } + require.Eventually(t, func() bool { return len(starts()) >= 6 }, 15*time.Second, 50*time.Millisecond, "the worker did not run six times") + + times := starts() + first, last := times[1]-times[0], times[5]-times[4] + assert.Less(t, last-first, 0.5, "the interval between runs grew, the worker was throttled") +} + +// a script ignoring its handle does not stall RestartWorkers() past the reboot +// grace period, the force-kill ends it +func TestBackgroundWorkerRebootForceKillsStuckScript(t *testing.T) { + if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { + t.Skipf("force-kill cannot interrupt a blocking syscall on %s", runtime.GOOS) + } + + tmp := t.TempDir() + once, sentinel := filepath.Join(tmp, "once"), filepath.Join(tmp, "parked") + initServers(t, + frankenphp.WithWorkers("bg-stuck", "testdata/bgworker/stuck.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_ONCE": once, "BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + ) + requireFileEventually(t, once, "background worker never entered its sleep") + + start := time.Now() + frankenphp.RestartWorkers() + assert.WithinDuration(t, start, time.Now(), 10*time.Second, "the reboot must force-kill the stuck script within its grace period") + + requireFileEventually(t, sentinel, "the re-run script did not park") +} + +// the poll API of PHP 8.6 waits on the handle itself, no stream involved +func TestBackgroundWorkerPollHandle(t *testing.T) { + if frankenphp.Version().VersionID < 80600 { + t.Skip("the poll API needs PHP 8.6") + } + + sentinel := filepath.Join(t.TempDir(), "poll.backend") + initServers(t, + frankenphp.WithWorkers("bg-poll", "testdata/bgworker/poll.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + ) + + // the server only starts once the worker ticked, which it does through + // the context: a backend name means the wait came back on its own + assert.NotEmpty(t, requireFileContentEventually(t, sentinel)) +} + +// the handle implements Io\Poll\Handle on every version: PHP 8.6 declares the +// interface with the poll API, FrankenPHP declares it below that +func TestBackgroundWorkerHandleIsAPollHandle(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "poll.json") + initServers(t, + frankenphp.WithWorkers("bg-iface", "testdata/bgworker/poll-interface.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + ) + + assert.JSONEq(t, `{"handle":true,"internal":true}`, requireFileContentEventually(t, sentinel)) +} + +// the methods do not trust the constructor's guard: unserialize() is refused +// outright, and a handle Reflection built on a request thread throws instead +// of reaching the Go side, which would panic and take the process with it +func TestWorkerHandleBuiltBehindTheConstructor(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), frankenphp.WithNumThreads(1)) + + body := serverGet(t, server, "http://example.com/handle-bypass.php") + assert.Contains(t, body, "unserialize: Exception: Unserialization of 'FrankenPHP\\WorkerHandle' is not allowed") + assert.Contains(t, body, "reflection: ReflectionException:") + assert.Contains(t, body, "construct: RuntimeException: FrankenPHP\\WorkerHandle can only be created from a background worker") +} + +// a script that parks without ever ticking fails Init() instead of hanging it, +// the only failure mode available where Zend max execution timers are not +func TestBackgroundWorkerBootTimeout(t *testing.T) { + err := frankenphp.Init( + frankenphp.WithWorkers("bg-mute", "testdata/bgworker/never-ticks.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerBootTimeout(300*time.Millisecond), + ), + frankenphp.WithNumThreads(2), + ) + if err == nil { + frankenphp.Shutdown() + } + require.ErrorContains(t, err, `background worker "bg-mute" did not call WorkerHandle::tick() within 300ms`) +} diff --git a/caddy/app.go b/caddy/app.go index 266322d788..fe06fcfb24 100644 --- a/caddy/app.go +++ b/caddy/app.go @@ -16,7 +16,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 ( @@ -140,12 +139,9 @@ func (f *FrankenPHPApp) collectOptions(repl *caddy.Replacer, keep bool) ([]frank frankenphp.WithMaxRequests(f.MaxRequests), ) - usedWorkerNames := make(map[string]bool, len(f.Workers)) - // register global workers for _, w := range f.Workers { w.FileName = repl.ReplaceKnown(w.FileName, "") - w.Name = createUniqueWorkerName(usedWorkerNames, w, "") workerOptions, err := w.toWorkerOptions() if err != nil { return nil, err @@ -153,7 +149,7 @@ func (f *FrankenPHPApp) collectOptions(repl *caddy.Replacer, keep bool) ([]frank opts = append(opts, frankenphp.WithWorkers(w.Name, w.FileName, w.Num, workerOptions...)) } - moduleOpts, err := f.collectModuleOptions(repl, usedWorkerNames, keep) + moduleOpts, err := f.collectModuleOptions(repl, keep) if err != nil { return nil, err } @@ -205,7 +201,7 @@ func (f *FrankenPHPApp) Stop() error { } // register workers and servers for "php" and "php_server" modules -func (f *FrankenPHPApp) collectModuleOptions(repl *caddy.Replacer, usedWorkerNames map[string]bool, keep bool) ([]frankenphp.Option, error) { +func (f *FrankenPHPApp) collectModuleOptions(repl *caddy.Replacer, keep bool) ([]frankenphp.Option, error) { opts := make([]frankenphp.Option, 0, len(f.modules)) serversByIndex := make(map[int]*frankenphp.Server, len(f.modules)) @@ -223,7 +219,7 @@ func (f *FrankenPHPApp) collectModuleOptions(repl *caddy.Replacer, usedWorkerNam } } - server, moduleOpts, err := f.collectModule(repl, module, usedWorkerNames) + server, moduleOpts, err := f.collectModule(repl, module) if err != nil { return nil, err } @@ -240,7 +236,7 @@ func (f *FrankenPHPApp) collectModuleOptions(repl *caddy.Replacer, usedWorkerNam return opts, nil } -func (f *FrankenPHPApp) collectModule(repl *caddy.Replacer, module *FrankenPHPModule, usedWorkerNames map[string]bool) (*frankenphp.Server, []frankenphp.Option, error) { +func (f *FrankenPHPApp) collectModule(repl *caddy.Replacer, module *FrankenPHPModule) (*frankenphp.Server, []frankenphp.Option, error) { serverName := f.resolveServerName(module) server, err := frankenphp.NewServer( module.resolvedDocumentRoot, @@ -257,7 +253,6 @@ func (f *FrankenPHPApp) collectModule(repl *caddy.Replacer, module *FrankenPHPMo for _, w := range module.Workers { w.FileName = repl.ReplaceKnown(w.FileName, "") - w.Name = createUniqueWorkerName(usedWorkerNames, w, serverName) workerOptions, err := w.toWorkerOptions() if err != nil { return nil, nil, err @@ -269,33 +264,6 @@ func (f *FrankenPHPApp) collectModule(repl *caddy.Replacer, module *FrankenPHPMo return server, opts, nil } -// avoid name collisions for workers -// on collision, a name is first qualified with the server name -// (":") before falling back to a numeric postfix -func createUniqueWorkerName(usedWorkerNames map[string]bool, wc workerConfig, serverName string) string { - if wc.Name == "" { - wc.Name, _ = fastabs.FastAbs(wc.FileName) - } - - name := wc.Name - suffix := 0 - for { - if _, ok := usedWorkerNames[name]; !ok { - 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() { @@ -410,10 +378,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) + } } } diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index 945f1a3d68..8f72a71822 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -788,6 +788,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) @@ -860,19 +899,19 @@ func TestWorkerMetrics(t *testing.T) { # HELP frankenphp_busy_workers Number of busy PHP workers for this worker # TYPE frankenphp_busy_workers gauge - frankenphp_busy_workers{worker="` + workerName + `"} 0 + frankenphp_busy_workers{server="",worker="` + workerName + `"} 0 # HELP frankenphp_total_workers Total number of PHP workers for this worker # TYPE frankenphp_total_workers gauge - frankenphp_total_workers{worker="` + workerName + `"} 2 + frankenphp_total_workers{server="",worker="` + workerName + `"} 2 # HELP frankenphp_worker_request_count # TYPE frankenphp_worker_request_count counter - frankenphp_worker_request_count{worker="` + workerName + `"} 10 + frankenphp_worker_request_count{server="",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, WorkerHandle::tick() for background workers # TYPE frankenphp_ready_workers gauge - frankenphp_ready_workers{worker="` + workerName + `"} 2 + frankenphp_ready_workers{server="",worker="` + workerName + `"} 2 ` ctx := caddy.ActiveContext() @@ -935,7 +974,7 @@ func TestPhpServerWorkerMatchPoolCount(t *testing.T) { var pools []string for line := range strings.SplitSeq(metrics.String(), "\n") { - if !strings.HasPrefix(line, "frankenphp_total_workers{worker=") { + if !strings.HasPrefix(line, "frankenphp_total_workers{") { continue } if !strings.Contains(line, "dedup-match-worker.php") && !strings.Contains(line, "dedup-plain-worker.php") { @@ -1017,19 +1056,19 @@ func TestNamedWorkerMetrics(t *testing.T) { # HELP frankenphp_busy_workers Number of busy PHP workers for this worker # TYPE frankenphp_busy_workers gauge - frankenphp_busy_workers{worker="my_app"} 0 + frankenphp_busy_workers{server="",worker="my_app"} 0 # HELP frankenphp_total_workers Total number of PHP workers for this worker # TYPE frankenphp_total_workers gauge - frankenphp_total_workers{worker="my_app"} 2 + frankenphp_total_workers{server="",worker="my_app"} 2 # HELP frankenphp_worker_request_count # TYPE frankenphp_worker_request_count counter - frankenphp_worker_request_count{worker="my_app"} 10 + frankenphp_worker_request_count{server="",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, WorkerHandle::tick() for background workers # TYPE frankenphp_ready_workers gauge - frankenphp_ready_workers{worker="my_app"} 2 + frankenphp_ready_workers{server="",worker="my_app"} 2 ` ctx := caddy.ActiveContext() @@ -1113,19 +1152,19 @@ func TestAutoWorkerConfig(t *testing.T) { # HELP frankenphp_busy_workers Number of busy PHP workers for this worker # TYPE frankenphp_busy_workers gauge - frankenphp_busy_workers{worker="` + workerName + `"} 0 + frankenphp_busy_workers{server="",worker="` + workerName + `"} 0 # HELP frankenphp_total_workers Total number of PHP workers for this worker # TYPE frankenphp_total_workers gauge - frankenphp_total_workers{worker="` + workerName + `"} ` + workers + ` + frankenphp_total_workers{server="",worker="` + workerName + `"} ` + workers + ` # HELP frankenphp_worker_request_count # TYPE frankenphp_worker_request_count counter - frankenphp_worker_request_count{worker="` + workerName + `"} 10 + frankenphp_worker_request_count{server="",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, WorkerHandle::tick() for background workers # TYPE frankenphp_ready_workers gauge - frankenphp_ready_workers{worker="` + workerName + `"} ` + workers + ` + frankenphp_ready_workers{server="",worker="` + workerName + `"} ` + workers + ` ` ctx := caddy.ActiveContext() @@ -1386,7 +1425,7 @@ func TestMaxWaitTimeWorker(t *testing.T) { expectedMetrics := ` # TYPE frankenphp_worker_queue_depth gauge - frankenphp_worker_queue_depth{worker="service"} 0 + frankenphp_worker_queue_depth{server="",worker="service"} 0 ` ctx := caddy.ActiveContext() @@ -1487,21 +1526,21 @@ func TestMultiWorkersMetrics(t *testing.T) { # HELP frankenphp_busy_workers Number of busy PHP workers for this worker # TYPE frankenphp_busy_workers gauge - frankenphp_busy_workers{worker="service1"} 0 + frankenphp_busy_workers{server="",worker="service1"} 0 # HELP frankenphp_total_workers Total number of PHP workers for this worker # TYPE frankenphp_total_workers gauge - frankenphp_total_workers{worker="service1"} 2 - frankenphp_total_workers{worker="service2"} 3 + frankenphp_total_workers{server="",worker="service1"} 2 + frankenphp_total_workers{server="",worker="service2"} 3 # HELP frankenphp_worker_request_count # TYPE frankenphp_worker_request_count counter - frankenphp_worker_request_count{worker="service1"} 10 + frankenphp_worker_request_count{server="",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, WorkerHandle::tick() for background workers # TYPE frankenphp_ready_workers gauge - frankenphp_ready_workers{worker="service1"} 2 - frankenphp_ready_workers{worker="service2"} 3 + frankenphp_ready_workers{server="",worker="service1"} 2 + frankenphp_ready_workers{server="",worker="service2"} 3 ` ctx := caddy.ActiveContext() @@ -1652,12 +1691,12 @@ 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, WorkerHandle::tick() for background workers # TYPE frankenphp_ready_workers gauge - frankenphp_ready_workers{worker="service"} 1 + frankenphp_ready_workers{server="",worker="service"} 1 # HELP frankenphp_total_workers Total number of PHP workers for this worker # TYPE frankenphp_total_workers gauge - frankenphp_total_workers{worker="service"} 1 + frankenphp_total_workers{server="",worker="service"} 1 ` require.NoError(t, @@ -1680,15 +1719,15 @@ 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, WorkerHandle::tick() for background workers # TYPE frankenphp_ready_workers gauge - frankenphp_ready_workers{worker="service"} 1 + frankenphp_ready_workers{server="",worker="service"} 1 # HELP frankenphp_total_workers Total number of PHP workers for this worker # TYPE frankenphp_total_workers gauge - frankenphp_total_workers{worker="service"} 1 + frankenphp_total_workers{server="",worker="service"} 1 # HELP frankenphp_worker_restarts Number of PHP worker restarts for this worker # TYPE frankenphp_worker_restarts counter - frankenphp_worker_restarts{worker="service"} 3 + frankenphp_worker_restarts{server="",worker="service"} 3 ` require.NoError(t, @@ -2176,7 +2215,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) { diff --git a/caddy/config_test.go b/caddy/config_test.go index 041c2a47bc..fd54c6a141 100644 --- a/caddy/config_test.go +++ b/caddy/config_test.go @@ -1,7 +1,6 @@ package caddy import ( - "path/filepath" "testing" "time" @@ -92,6 +91,45 @@ func TestModuleWorkerDuplicateFilenamesFail(t *testing.T) { require.Contains(t, err.Error(), "must not have duplicate filenames", "Error message should mention duplicate filenames") } +// two global background workers may share a script, like their php_server +// counterparts and the Go API: they are keyed by name +func TestGlobalBackgroundWorkersShareAFilename(t *testing.T) { + d := caddyfile.NewTestDispenser(` + { + frankenphp { + worker { + name first + file ../testdata/worker-with-env.php + num 1 + background + } + worker { + name second + file ../testdata/worker-with-env.php + num 1 + background + } + } + }`) + app := &FrankenPHPApp{} + + require.NoError(t, app.UnmarshalCaddyfile(d)) + require.Len(t, app.Workers, 2) +} + +func TestGlobalWorkerDuplicateFilenamesFail(t *testing.T) { + d := caddyfile.NewTestDispenser(` + { + frankenphp { + worker ../testdata/worker-with-env.php + worker ../testdata/worker-with-env.php + } + }`) + app := &FrankenPHPApp{} + + require.ErrorContains(t, app.UnmarshalCaddyfile(d), "must not have duplicate filenames") +} + func TestModuleWorkersWithDifferentFilenames(t *testing.T) { // Create a test configuration with different worker filenames configWithDifferentFilenames := ` @@ -260,38 +298,73 @@ func TestModuleWorkerWithCustomName(t *testing.T) { require.Equal(t, "../testdata/worker-with-env.php", module.Workers[0].FileName, "Worker should have the correct filename") } -func TestCreateUniqueWorkerNames(t *testing.T) { - usedWorkerNames := map[string]bool{} - filename := "../testdata/worker-with-env.php" - absFileName, _ := filepath.Abs(filename) - names := make([]string, 6) - for i := range 3 { - names[i] = createUniqueWorkerName(usedWorkerNames, workerConfig{ - FileName: filename, - Name: "custom-worker-name", - }, "") - names[i+3] = createUniqueWorkerName(usedWorkerNames, workerConfig{ - FileName: filename, - }, "") - } +func TestWorkerBackgroundConfig(t *testing.T) { + d := caddyfile.NewTestDispenser(` + { + php_server { + worker { + name jobs + file ../testdata/worker-with-env.php + num 2 + background + } + } + }`) + module := &FrankenPHPModule{} - require.Equal(t, "custom-worker-name", names[0]) - require.Equal(t, "custom-worker-name_1", names[1]) - require.Equal(t, "custom-worker-name_2", names[2]) - require.Equal(t, absFileName, names[3]) - require.Equal(t, absFileName+"_1", names[4]) - require.Equal(t, absFileName+"_2", names[5]) + require.NoError(t, module.UnmarshalCaddyfile(d)) + require.Len(t, module.Workers, 1) + require.True(t, module.Workers[0].Background) + require.Equal(t, "jobs", module.Workers[0].Name) } -func TestCreateUniqueWorkerNamesQualifiedByServer(t *testing.T) { - usedWorkerNames := map[string]bool{} - wc := workerConfig{FileName: "../testdata/worker-with-env.php", Name: "queue"} - - require.Equal(t, "queue", createUniqueWorkerName(usedWorkerNames, wc, "one.example.com")) - // on collision, the name is qualified with the server name - require.Equal(t, "two.example.com:queue", createUniqueWorkerName(usedWorkerNames, wc, "two.example.com")) - // when the qualified name is also taken, fall back to the numeric postfix - require.Equal(t, "queue_1", createUniqueWorkerName(usedWorkerNames, wc, "two.example.com")) - // workers without a server keep the numeric postfix behavior - require.Equal(t, "queue_2", createUniqueWorkerName(usedWorkerNames, wc, "")) +func TestWorkerBackgroundRequiresName(t *testing.T) { + d := caddyfile.NewTestDispenser(` + { + php_server { + worker { + file ../testdata/worker-with-env.php + background + } + } + }`) + module := &FrankenPHPModule{} + + err := module.UnmarshalCaddyfile(d) + require.ErrorContains(t, err, `background workers must have an explicit "name"`) +} + +func TestWorkerBackgroundWithoutNumParses(t *testing.T) { + d := caddyfile.NewTestDispenser(` + { + php_server { + worker { + name jobs + file ../testdata/worker-with-env.php + background + } + } + }`) + module := &FrankenPHPModule{} + + // num is optional, it defaults to one thread when the workers start + require.NoError(t, module.UnmarshalCaddyfile(d)) +} + +func TestWorkerBackgroundRejectsMatch(t *testing.T) { + d := caddyfile.NewTestDispenser(` + { + php_server { + worker { + name jobs + file ../testdata/worker-with-env.php + match /jobs/* + background + } + } + }`) + module := &FrankenPHPModule{} + + err := module.UnmarshalCaddyfile(d) + require.ErrorContains(t, err, `"match" is not supported for background workers`) } diff --git a/caddy/module.go b/caddy/module.go index 20dcec9ee2..ff6e2db5ff 100644 --- a/caddy/module.go +++ b/caddy/module.go @@ -315,6 +315,10 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { // Check if a worker with this filename already exists in this module fileNames := make(map[string]struct{}, len(f.Workers)) for _, w := range f.Workers { + // background workers are keyed by name, several may share a script + if w.Background { + continue + } if _, ok := fileNames[w.FileName]; ok { return fmt.Errorf(`workers in a single "php" or "php_server" block must not have duplicate filenames: %q`, w.FileName) } diff --git a/caddy/serveridx_test.go b/caddy/serveridx_test.go index 1fd401429d..aa24918b02 100644 --- a/caddy/serveridx_test.go +++ b/caddy/serveridx_test.go @@ -26,7 +26,7 @@ func TestRegisterModulesWithSameServerIndexShareOneServer(t *testing.T) { shared2 := &FrankenPHPModule{ServerIndex: 1, resolvedDocumentRoot: "../testdata"} app.modules = []*FrankenPHPModule{shared1, shared2} - _, err := app.collectModuleOptions(caddy.NewReplacer(), map[string]bool{}, true) + _, err := app.collectModuleOptions(caddy.NewReplacer(), true) require.NoError(t, err) require.NotNil(t, shared1.server) @@ -40,7 +40,7 @@ func TestRegisterModulesWithoutServerIndexGetOwnServers(t *testing.T) { indexed := &FrankenPHPModule{ServerIndex: 1, resolvedDocumentRoot: "../testdata"} app.modules = []*FrankenPHPModule{auto1, indexed, auto2} - _, err := app.collectModuleOptions(caddy.NewReplacer(), map[string]bool{}, true) + _, err := app.collectModuleOptions(caddy.NewReplacer(), true) require.NoError(t, err) require.NotNil(t, auto1.server) @@ -61,7 +61,7 @@ func TestRegisterModulesFirstModuleWinsPerIdx(t *testing.T) { second := &FrankenPHPModule{ServerIndex: 2, resolvedDocumentRoot: "../testdata/env"} app.modules = []*FrankenPHPModule{first, second} - opts, err := app.collectModuleOptions(caddy.NewReplacer(), map[string]bool{}, true) + opts, err := app.collectModuleOptions(caddy.NewReplacer(), true) require.NoError(t, err) require.Same(t, first.server, second.server) diff --git a/caddy/workerconfig.go b/caddy/workerconfig.go index f229c71487..f50ff3ac30 100644 --- a/caddy/workerconfig.go +++ b/caddy/workerconfig.go @@ -3,6 +3,7 @@ package caddy import ( "path/filepath" "strconv" + "time" "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" @@ -22,7 +23,7 @@ import ( type workerConfig struct { mercureContext - // Name for the worker. Default: the absolute path of the worker file, postfixed with a number if the name is already used. + // Name for the worker, unique within its php_server (or among global workers). Default: the absolute path of the worker file. Name string `json:"name,omitempty"` // FileName sets the path to the worker script. FileName string `json:"file_name,omitempty"` @@ -38,6 +39,10 @@ type workerConfig struct { MatchPath []string `json:"match_path,omitempty"` // MaxConsecutiveFailures sets the maximum number of consecutive failures before panicking (defaults to 6, set to -1 to never panick) MaxConsecutiveFailures *int `json:"max_consecutive_failures,omitempty"` + // BootTimeout bounds how long a background worker may take to reach its first WorkerHandle::tick() before the start fails (defaults to 30s, set to 0 to wait forever) + BootTimeout *caddy.Duration `json:"boot_timeout,omitempty"` + // Background marks this worker as a background (non-HTTP) worker. + Background bool `json:"background,omitempty"` options []frankenphp.WorkerOption } @@ -139,8 +144,25 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) { } wc.MaxConsecutiveFailures = new(v) + case "boot_timeout": + if !d.NextArg() { + return wc, d.ArgErr() + } + + v, err := caddy.ParseDuration(d.Val()) + if err != nil { + return wc, d.WrapErr(err) + } + if v < 0 { + return wc, d.Errf("boot_timeout must be >= 0") + } + + timeout := caddy.Duration(v) + wc.BootTimeout = &timeout + case "background": + wc.Background = true default: - return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads", v) + return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads, background, boot_timeout", v) } } @@ -148,6 +170,19 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) { return wc, d.Err(`the "file" argument must be specified`) } + if !wc.Background && wc.BootTimeout != nil { + return wc, d.Err(`"boot_timeout" is only supported for background workers`) + } + + if wc.Background { + if wc.Name == "" { + return wc, d.Err(`background workers must have an explicit "name"`) + } + if len(wc.MatchPath) != 0 { + return wc, d.Err(`"match" is not supported for background workers`) + } + } + if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) { wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName) } @@ -169,6 +204,14 @@ func (wc *workerConfig) toWorkerOptions() ([]frankenphp.WorkerOption, error) { // options collected while provisioning the module, e.g. the Mercure hub opts = append(opts, wc.options...) + if wc.Background { + opts = append(opts, frankenphp.WithWorkerBackground()) + + if wc.BootTimeout != nil { + opts = append(opts, frankenphp.WithWorkerBootTimeout(time.Duration(*wc.BootTimeout))) + } + } + // copy the caddy match logic and create a unique matcher function for this worker // inject the matcher into frankenphp if len(wc.MatchPath) > 0 { diff --git a/cgi.go b/cgi.go index 65229b2a54..35e95a21b7 100644 --- a/cgi.go +++ b/cgi.go @@ -224,7 +224,7 @@ func splitCgiPath(fc *frankenPHPContext) { // see if a php_server worker or global worker matches the request path // aka: root + request path == worker.filename if fc.worker = fc.server.workersByPath[fc.scriptFilename]; fc.worker == nil { - fc.worker = globalWorkersByPath[fc.scriptFilename] + fc.worker = fallbackServer.workersByPath[fc.scriptFilename] } } diff --git a/context.go b/context.go index ace1e3dcdf..8e20462c43 100644 --- a/context.go +++ b/context.go @@ -132,20 +132,14 @@ func newWorkerDummyContext(w *worker) (*frankenPHPContext, error) { return nil, err } - server := w.server - if server == nil { - // global worker, not associated with a server - server = fallbackServer - } - fc := &frankenPHPContext{ done: make(chan any), ctx: r.Context(), - server: server, + server: w.server, request: r, startedAt: time.Now(), // startup output of a scoped worker belongs to its server's logger - logger: server.logger, + logger: w.server.logger, worker: w, } @@ -166,11 +160,6 @@ func newWorkerDummyContext(w *worker) (*frankenPHPContext, error) { // newContextFromMessage creates a context from a message (external workers) func newContextFromMessage(message any, rw http.ResponseWriter, ctx context.Context, w *worker) *frankenPHPContext { - server := w.server - if server == nil { - server = fallbackServer - } - if ctx == nil { ctx = globalCtx } @@ -178,9 +167,9 @@ func newContextFromMessage(message any, rw http.ResponseWriter, ctx context.Cont return &frankenPHPContext{ done: make(chan any), startedAt: time.Now(), - server: server, + server: w.server, worker: w, - logger: server.logger, + logger: w.server.logger, responseWriter: rw, handlerParameters: message, ctx: ctx, diff --git a/docs/config.md b/docs/config.md index 3dc5a9cbbe..e933f53d84 100644 --- a/docs/config.md +++ b/docs/config.md @@ -106,11 +106,13 @@ You can also explicitly configure FrankenPHP using the [global option](https://c php_ini # Set a php.ini directive. Can be used several times to set multiple directives. worker { file # Sets the path to the worker script. - num # Sets the number of PHP threads to start, defaults to 2x the number of available CPUs. + num # Sets the number of PHP threads to start, defaults to 2x the number of available CPUs, 1 for background workers. env # Sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables. watch # Sets the path to watch for file changes. Can be specified more than once for multiple paths. - name # Sets the name of the worker, used in logs and metrics. Default: absolute path of worker file + name # Sets the name of the worker, used in logs and metrics. Must be unique among global workers. Default: absolute path of the worker file, with a number appended when several workers share a script. max_consecutive_failures # Sets the maximum number of consecutive failures before the worker is considered unhealthy, -1 means the worker will always restart. Default: 6. + boot_timeout # EXPERIMENTAL: how long a background worker may take to reach its first tick() before the start fails, 0 waits forever. Default: 30s. + background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, requiring "name" and a script ticking its FrankenPHP\WorkerHandle at least once } } } @@ -187,17 +189,18 @@ php_server [] { root # Sets the root folder to the site. Default: `root` directive. split_path # Sets the substrings for splitting the URI into two parts. The first matching substring will be used to split the "path info" from the path. The first piece is suffixed with the matching substring and will be assumed as the actual resource (CGI script) name. The second piece will be set to PATH_INFO for the script to use. Default: `.php` resolve_root_symlink false # Disables resolving the `root` directory to its actual value by evaluating a symbolic link, if one exists (enabled by default). - name # Sets the name for this server, used to attribute workers, metrics and logs. Default: the first host matcher of the enclosing route, or the first listener address. + name # Sets the name for this server, used to attribute workers, metrics and logs. Default: the first host matcher of the enclosing route, or the first listener address. Suffixed with a number if another php_server resolves to the same name. env # Sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables. file_server off # Disables the built-in file_server directive. request_body_timeout # Sets an idle timeout on request body reads: a stalled (slow POST) client is cut off while a steady upload of any size succeeds. Default: 60s. Set to 0 to disable. worker { # Creates a worker specific to this server. Can be specified more than once for multiple workers. file # Sets the path to the worker script, can be relative to the php_server root - num # Sets the number of PHP threads to start, defaults to 2x the number of available - name # Sets the name for the worker, used in logs and metrics. Default: absolute path of worker file. Postfixed with a number if name is already in use. + num # Sets the number of PHP threads to start, defaults to 2x the number of available CPUs, 1 for background workers. + name # Sets the name for the worker, used in logs and metrics. Must be unique within this php_server. In logs, the worker is reported as ":", metrics carry the two as separate labels. Default: absolute path of the worker file, with a number appended when several workers share a script. watch # Sets the path to watch for file changes. Can be specified more than once for multiple paths. env # Sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables. Environment variables for this worker are also inherited from the php_server parent, but can be overwritten here. match # match the worker to a path pattern. Overrides try_files and can only be used in the php_server directive. + background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, requiring "name" and a script ticking its FrankenPHP\WorkerHandle at least once } worker # Can also use the short form like in the global frankenphp block. } diff --git a/docs/library.md b/docs/library.md index 968d2bd971..b226f2c0c7 100644 --- a/docs/library.md +++ b/docs/library.md @@ -60,6 +60,10 @@ err := frankenphp.Init( Workers declared without a server scope are global: they match by file path on any server. Since a global worker has no set of requests to match against, combining `WithWorkerMatcher()` with a global worker is a configuration error and `Init()` rejects it. +Worker names are unique within their server (or among global workers), so two servers may each declare a worker named `queue`. The script sees the declared name, logs report a server-scoped worker as `:`, and metrics carry the two as separate labels; server names are made unique with a numeric suffix when needed. `WithWorkerName()` resolves a name within the request's server first, then among global workers. + +`WithWorkerBackground()` declares a [background worker](worker.md#background-workers), which runs outside the request cycle. + ## Per-request options `Server.ServeHTTP()` accepts `RequestOption`s to override the server configuration for a single request, e.g. `WithRequestDocumentRoot()`, `WithRequestSplitPath()`, `WithRequestEnv()` or `WithRequestLogger()`. diff --git a/docs/metrics.md b/docs/metrics.md index b87d2231f4..165ad86881 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -15,17 +15,17 @@ When [Caddy metrics](https://caddyserver.com/docs/metrics) are enabled, FrankenP - `frankenphp_total_threads`: The total number of PHP threads. - `frankenphp_busy_threads`: The number of PHP threads currently processing a request (running workers always consume a thread). - `frankenphp_queue_depth`: The number of regular queued requests. -- `frankenphp_total_workers{worker="[worker_name]"}`: The total number of workers. -- `frankenphp_busy_workers{worker="[worker_name]"}`: The number of workers currently processing a request. -- `frankenphp_worker_request_time{worker="[worker_name]"}`: The time spent processing requests by all workers. -- `frankenphp_worker_request_count{worker="[worker_name]"}`: The number of requests processed by all workers. -- `frankenphp_ready_workers{worker="[worker_name]"}`: The number of workers that have called `frankenphp_handle_request` at least once. -- `frankenphp_worker_crashes{worker="[worker_name]"}`: The number of times a worker has unexpectedly terminated. -- `frankenphp_worker_restarts{worker="[worker_name]"}`: The number of times a worker has been deliberately restarted. -- `frankenphp_worker_queue_depth{worker="[worker_name]"}`: The number of queued requests. +- `frankenphp_total_workers{worker="[worker_name]",server="[server_name]"}`: The total number of workers. +- `frankenphp_busy_workers{worker="[worker_name]",server="[server_name]"}`: The number of workers currently processing a request. +- `frankenphp_worker_request_time{worker="[worker_name]",server="[server_name]"}`: The time spent processing requests by all workers. +- `frankenphp_worker_request_count{worker="[worker_name]",server="[server_name]"}`: The number of requests processed by all workers. +- `frankenphp_ready_workers{worker="[worker_name]",server="[server_name]"}`: The number of workers that have reached their ready point at least once: `frankenphp_handle_request()` for HTTP workers, `WorkerHandle::tick()` for background workers. +- `frankenphp_worker_crashes{worker="[worker_name]",server="[server_name]"}`: The number of times a worker has unexpectedly terminated. +- `frankenphp_worker_restarts{worker="[worker_name]",server="[server_name]"}`: The number of times a worker has been deliberately restarted. +- `frankenphp_worker_queue_depth{worker="[worker_name]",server="[server_name]"}`: The number of queued requests. - `frankenphp_opcache_restarts{reason="[reason]"}`: (experimental) The number of restarts of opcache's shared memory scheduled, by reason (`oom`, `hash`, `manual`). PHP 8.4 and up. -For worker metrics, the `[worker_name]` placeholder is replaced by the worker name in the Caddyfile, otherwise the absolute path of the worker file will be used. +`[worker_name]` is the worker name from the Caddyfile, or the absolute path of the worker file when it has none, with a number appended when several workers share a script. `[server_name]` is the name of the `php_server` block the worker belongs to, and is empty for a worker declared in the global `frankenphp` block. The two stay apart, so a query on the worker name alone still selects that worker in every server. opcache schedules a restart when its cache is full and more than `opcache.max_wasted_percentage` of it was wasted by invalidations. The restart runs at the next request start; caching is off until then, and running PHP threads may still reference the old memory, which can crash the process. The counter should stay at zero: raise `opcache.memory_consumption`, `opcache.max_accelerated_files` or `opcache.max_wasted_percentage`. `manual` should never appear, FrankenPHP overrides `opcache_reset()`. Each restart is also logged. This metric will be removed once opcache handles restarts safely under ZTS. diff --git a/docs/worker.md b/docs/worker.md index 1b6c86a225..108b4943a5 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -206,6 +206,79 @@ so the default `num_threads` value is `1 + 2x(number of available CPUs)`. Thus, in that mode if `num_threads` and `num` are explicitly set, `num_threads` must be strictly greater then `num`. +## Background workers + +This feature is experimental. + +A background worker runs its script in a loop outside the HTTP request cycle, on its own PHP thread. It is declared like any worker, with the `background` option; `name` is required and `num` defaults to one thread: + +```caddyfile +php_server { + worker { + file jobs.php + num 1 + name jobs + background + } +} +``` + +The script takes a handle on the worker, `new FrankenPHP\WorkerHandle()`, and calls `tick()` on it once it is set up. The first call marks the worker ready: the server start waits for that point, and an exit before it counts as a failure. Until that call, `max_execution_time` applies as in any request, 30 seconds by default: a setup that outlives it is ended and counts as a boot failure, so raise the limit in `php_ini`, or call `set_time_limit()` in the script, when the setup legitimately takes longer. This bound only holds on PHP builds with Zend max execution timers (`--enable-zend-max-execution-timers`, see [the compilation guide](compile.md)): elsewhere FrankenPHP disables `max_execution_time`, and the `boot_timeout` of the worker is what ends the wait: 30 seconds by default, matching PHP's own `max_execution_time`, after which the start fails with the name of the worker that never ticked. Set it to `0` to wait forever, which is what happened before, with a warning logged after 10 seconds. From the first call on, the run has no time limit, like the CLI. Every call returns `false` once FrankenPHP drains the worker, on shutdown, reboot or restart, and `true` otherwise. It never blocks and never hands out work: like `frankenphp_handle_request()`, it is where the runtime and the script meet. + +Between two calls the script waits on the handle, which is an `Io\Poll\Handle`: an `Io\Poll\Context` takes it as it is, next to whatever else the script waits on. + +```php +add($handle, [Event::Read]); +// plus the handles the script waits on + +while ($handle->tick()) { + foreach ($poll->wait() as $watcher) { + // the worker's handle triggered, or one of the script's + } + + doSomeWork(); +} + +// drained: return, FrankenPHP re-runs or stops the script +``` + +The handle becomes readable when FrankenPHP needs the script's attention, its drain included, and `tick()` consumes whatever was written there. What it carries is not part of the contract. It is readable once right after the script starts, so a loop that waits on it ticks by itself and the worker is ready as soon as its loop runs. + +`Io\Poll` comes with PHP 8.6 and polls with `epoll` or `kqueue`. On 8.2 to 8.5 the handle implements the same interface, so the loop above runs unchanged with [symfony/polyfill-io-poll](https://github.com/symfony/polyfill/tree/1.x/src/Io/Poll), which backs the context with `stream_select()`. + +An event loop takes the handle through its stream, `getStream()`. With [Revolt](https://revolt.run), the loop of amphp: + +```php +getStream(), function () use ($handle): void { + if (!$handle->tick()) { + // drained: stop the loop, the script returns and FrankenPHP moves on + EventLoop::getDriver()->stop(); + } +}); + +// the script's own watchers go here + +EventLoop::run(); +``` + +The wake-up sent at start makes the callback run as soon as the loop does, which is when the worker becomes ready. + +That stream is there for the libraries that take one, and waiting is all it supports: reading it steals the bytes `tick()` would have consumed, writing to it lands in a buffer FrankenPHP never drains, so a script that fills it blocks itself, and closing it is safe since the next `getStream()` on the same handle opens a fresh one over the same socket. A script with no loop of its own can wait on it directly, with a blocking read such as `fgets()` or with `stream_select()`, keeping in mind that `stream_select()` rejects a descriptor of `1024` or higher (`FD_SETSIZE`), which a worker re-run while the server holds more than a thousand connections will have. `tick()` itself uses `poll()` and is not affected. + +`$_SERVER['FRANKENPHP_WORKER_BACKGROUND']` holds the declared name. `FRANKENPHP_WORKER`, the variable of HTTP workers, is not set, so a script serving both roles tests which of the two is set. Background threads come on top of `num_threads` and `max_threads`; they don't autoscale, so `max_threads` is not allowed on them. From Go, declare one with `WithWorkerBackground()`. + ## Superglobals behavior [PHP superglobals](https://www.php.net/manual/language.variables.superglobals.php) (`$_SERVER`, `$_ENV`, `$_GET`...) diff --git a/frankenphp.c b/frankenphp.c index 4066845349..c59f166778 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -17,11 +17,14 @@ #include #ifdef PHP_WIN32 #include +#include #else #include #endif +#include
#include #include +#include #include #include #include @@ -32,6 +35,7 @@ #include #include #ifndef PHP_WIN32 +#include #include #endif #if defined(__linux__) @@ -55,6 +59,21 @@ #include "emulate_php_cli.h" #include "_cgo_export.h" + +#if PHP_VERSION_ID < 80400 +/* gen_stub.php registers classes with an API that only exists since 8.4. */ +static zend_always_inline zend_class_entry * +zend_register_internal_class_with_flags(zend_class_entry *class_entry, + zend_class_entry *parent_ce, + uint32_t flags) { + zend_class_entry *ce = + zend_register_internal_class_ex(class_entry, parent_ce); + ce->ce_flags |= flags; + + return ce; +} +#endif + #include "frankenphp_arginfo.h" #ifdef FRANKENPHP_TEST /* The persistent_zval helpers are only compiled in when a consumer needs @@ -130,6 +149,14 @@ HashTable *main_thread_env = NULL; static THREAD_LOCAL uintptr_t thread_index; static THREAD_LOCAL bool is_worker_thread = false; +static THREAD_LOCAL bool is_background_worker = false; +/* Stop socket pair of a background worker thread: [0] is the script's end, + * exposed via WorkerHandle::getStream(); [1] is transferred to the Go + * side, which closes it to signal a drain. */ +static THREAD_LOCAL php_socket_t worker_stop_socks[2] = {SOCK_ERR, SOCK_ERR}; +/* set by the first WorkerHandle::tick() of the current run, the ready + * point of a background worker */ +static THREAD_LOCAL bool worker_ticked = false; /* putenv() overrides layered over the other env tables, an IS_NULL entry * unsets the variable */ static THREAD_LOCAL HashTable *sandboxed_env = NULL; @@ -361,7 +388,203 @@ void frankenphp_release_thread_for_kill(force_kill_slot slot) { #endif } +/* Stop channel of background workers: a socket pair, whose Go end is closed + * on drain so the script's end reaches EOF. A pair rather than a pipe + * because on Windows php_select() only really waits on sockets: before 8.5 + * it reports any other handle as always ready. */ +static void frankenphp_worker_close_sock(php_socket_t s) { + if (s == SOCK_ERR) { + return; + } + /* php_network.h maps closesocket to close outside Windows */ + closesocket(s); +} + +/* keep the pair out of processes the script may spawn: a child holding the Go + * end would keep the script's end from ever reaching EOF */ +static void frankenphp_worker_sock_no_inherit(php_socket_t s) { +#ifdef PHP_WIN32 + SetHandleInformation((HANDLE)s, HANDLE_FLAG_INHERIT, 0); +#else + fcntl(s, F_SETFD, FD_CLOEXEC); +#endif +} + +static void frankenphp_worker_close_stop_socks(void) { + for (int i = 0; i < 2; i++) { + frankenphp_worker_close_sock(worker_stop_socks[i]); + worker_stop_socks[i] = SOCK_ERR; + } +} + +/* Resets the background worker state of the calling thread. The streams of + * WorkerHandle::getStream() do not own the socket, and request shutdown + * destroyed the handles they belong to. */ +static void frankenphp_reset_background_worker(void) { + is_background_worker = false; + worker_ticked = false; + frankenphp_worker_close_stop_socks(); +} + +#ifdef PHP_WIN32 +/* Windows has no socketpair() and PHP's emulation is not one: it binds a + * listener to INADDR_ANY, reachable off the machine, and hands back whichever + * connection arrives first. The pair is formed by hand instead, the way + * libevent and Tor do it: loopback alone, and a connection is kept only when + * it comes from the socket we connected with. */ +#define FRANKENPHP_SOCK_PAIR_TRIES 8 + +static int frankenphp_sock_pair_win32(php_socket_t socks[2]) { + SOCKET listener = INVALID_SOCKET, client = INVALID_SOCKET, + accepted = INVALID_SOCKET; + struct sockaddr_in addr, local, peer; + int addr_len = sizeof(addr), local_len = sizeof(local), peer_len, + exclusive = 1; + + socks[0] = SOCK_ERR; + socks[1] = SOCK_ERR; + + listener = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (listener == INVALID_SOCKET) { + return -1; + } + + /* nobody else may take this port from under the listener */ + setsockopt(listener, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, + (const char *)&exclusive, sizeof(exclusive)); + + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; + + if (bind(listener, (struct sockaddr *)&addr, sizeof(addr)) != 0 || + getsockname(listener, (struct sockaddr *)&addr, &addr_len) != 0 || + listen(listener, 1) != 0) { + goto error; + } + + client = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (client == INVALID_SOCKET || + connect(client, (struct sockaddr *)&addr, sizeof(addr)) != 0 || + getsockname(client, (struct sockaddr *)&local, &local_len) != 0) { + goto error; + } + + for (int attempt = 0; attempt < FRANKENPHP_SOCK_PAIR_TRIES; attempt++) { + memset(&peer, 0, sizeof(peer)); + peer_len = sizeof(peer); + accepted = accept(listener, (struct sockaddr *)&peer, &peer_len); + if (accepted == INVALID_SOCKET) { + goto error; + } + if (peer.sin_port == local.sin_port && + peer.sin_addr.s_addr == local.sin_addr.s_addr) { + closesocket(listener); + socks[0] = accepted; + socks[1] = client; + + return 0; + } + + /* somebody else got in: drop them and wait for our own connection */ + closesocket(accepted); + accepted = INVALID_SOCKET; + } + +error: + if (listener != INVALID_SOCKET) { + closesocket(listener); + } + if (client != INVALID_SOCKET) { + closesocket(client); + } + if (accepted != INVALID_SOCKET) { + closesocket(accepted); + } + + return -1; +} +#endif + +static int frankenphp_worker_open_stop_pair(void) { +#ifdef PHP_WIN32 + if (frankenphp_sock_pair_win32(worker_stop_socks) != 0) { + return -1; + } +#else +#ifdef SOCK_CLOEXEC + int type = SOCK_STREAM | SOCK_CLOEXEC; +#else + int type = SOCK_STREAM; +#endif + if (socketpair(AF_UNIX, type, 0, worker_stop_socks) != 0) { + worker_stop_socks[0] = SOCK_ERR; + worker_stop_socks[1] = SOCK_ERR; + + return -1; + } +#endif + /* redundant where SOCK_CLOEXEC applied; a fork()ed child (pcntl) still + * inherits both ends and delays the EOF until it exits */ + frankenphp_worker_sock_no_inherit(worker_stop_socks[0]); + frankenphp_worker_sock_no_inherit(worker_stop_socks[1]); + + return 0; +} + +/* Marks the calling thread as a background worker, opens its stop socket pair + * and transfers the Go side's end to the caller, clearing the TLS slot so a + * later recycle won't double-close it. Returns -1 if the pair could not be + * created. max_execution_time stays armed until the first tick(), see there. */ +intptr_t frankenphp_set_background_worker_and_get_stop_sock(void) { + frankenphp_reset_background_worker(); + is_background_worker = true; + + if (frankenphp_worker_open_stop_pair() != 0) { + return -1; + } + + /* One wake-up right away, so a script that hands its handle to an event + * loop ticks on its own: readiness then means the loop serviced the handle + * once. Nothing to do on failure, the script has to tick by itself. */ + const char wakeup = '\n'; +#ifdef MSG_NOSIGNAL + send(worker_stop_socks[1], &wakeup, 1, MSG_NOSIGNAL); +#else + send(worker_stop_socks[1], &wakeup, 1, 0); +#endif + + intptr_t s = (intptr_t)worker_stop_socks[1]; + worker_stop_socks[1] = SOCK_ERR; + + return s; +} + +/* Closes the Go side's end of a stop socket pair, which lands as EOF on the + * script's end so its stream_select() or blocking read returns promptly. */ +void frankenphp_worker_close_stop_sock(intptr_t s) { + if (s < 0) { + return; + } + /* A pcntl_fork() child inherits every descriptor of the process, and while + * it holds a copy of this end closing it lands no EOF: shutting the write + * direction down sends the FIN regardless. */ +#ifdef PHP_WIN32 + shutdown((php_socket_t)s, SD_SEND); +#else + shutdown((php_socket_t)s, SHUT_WR); +#endif + frankenphp_worker_close_sock((php_socket_t)s); +} + void frankenphp_update_local_thread_context(bool is_worker) { + /* A thread that ran a background worker can be recycled into an HTTP or a + * request thread: reset the TLS so the handle rejects callers again. */ + if (is_background_worker) { + frankenphp_reset_background_worker(); + } + is_worker_thread = is_worker; /* workers should keep running if the user aborts the connection */ @@ -967,6 +1190,15 @@ PHP_FUNCTION(frankenphp_handle_request) { RETURN_THROWS(); } + if (is_background_worker) { + /* background workers never receive HTTP requests */ + zend_throw_exception( + spl_ce_RuntimeException, + "frankenphp_handle_request() cannot be called from a background worker", + 0); + RETURN_THROWS(); + } + #ifdef ZEND_MAX_EXECUTION_TIMERS /* Disable timeouts while waiting for a request to handle */ zend_unset_timeout(); @@ -1165,6 +1397,260 @@ static void frankenphp_opcache_restart_hook(int reason) { } #endif +/* Handles of FrankenPHP: objects a script waits on. On PHP 8.6 they implement + * Io\Poll\Handle, so an Io\Poll\Context waits on them next to the script's + * own handles; the interface is a marker, the contract is the three hooks + * below, and the descriptor never reaches userland. Before 8.6 the same hooks + * carry the object's lifetime, in the layout the poll API expects. */ +#if PHP_VERSION_ID >= 80600 +#include +typedef php_poll_handle_object frankenphp_handle_obj; +typedef php_poll_handle_ops frankenphp_handle_ops; +#define FRANKENPHP_HANDLE_OF(zobj) PHP_POLL_HANDLE_OBJ_FROM_ZOBJ(zobj) +#else +typedef struct frankenphp_handle_obj frankenphp_handle_obj; +typedef struct { + php_socket_t (*get_fd)(frankenphp_handle_obj *handle); + int (*is_valid)(frankenphp_handle_obj *handle); + void (*cleanup)(frankenphp_handle_obj *handle); +} frankenphp_handle_ops; +struct frankenphp_handle_obj { + frankenphp_handle_ops *ops; + void *handle_data; + zend_object std; +}; +#define FRANKENPHP_HANDLE_OF(zobj) \ + ((frankenphp_handle_obj *)((char *)(zobj) - \ + offsetof(frankenphp_handle_obj, std))) +#endif + +/* The state a handle carries, NULL when it needs none. */ +#define FRANKENPHP_HANDLE_DATA(zthis) \ + (FRANKENPHP_HANDLE_OF(Z_OBJ_P(zthis))->handle_data) + +static zend_object_handlers frankenphp_handle_obj_handlers; + +static zend_object *frankenphp_handle_obj_create(zend_class_entry *ce, + frankenphp_handle_ops *ops) { +#if PHP_VERSION_ID >= 80600 + frankenphp_handle_obj *obj = + php_poll_handle_object_create(sizeof(*obj), ce, ops); +#else + frankenphp_handle_obj *obj = zend_object_alloc(sizeof(*obj), ce); + + zend_object_std_init(&obj->std, ce); + object_properties_init(&obj->std, ce); + obj->ops = ops; + obj->handle_data = NULL; +#endif + obj->std.handlers = &frankenphp_handle_obj_handlers; + + return &obj->std; +} + +static void frankenphp_handle_obj_free(zend_object *object) { + frankenphp_handle_obj *obj = FRANKENPHP_HANDLE_OF(object); + + if (obj->ops != NULL && obj->ops->cleanup != NULL) { + obj->ops->cleanup(obj); + } + zend_object_std_dtor(&obj->std); +} + +/* Declares Io\Poll\Handle on a handle class: the interface accepts internal + * classes only. PHP 8.6 brings it with the poll API, below it FrankenPHP + * declares it, and the classmap-autoloaded declaration of + * symfony/polyfill-io-poll then never loads. */ +static void frankenphp_handle_implements_poll(zend_class_entry *ce) { + zend_class_entry *poll_handle_ce = zend_hash_str_find_ptr( + CG(class_table), "io\\poll\\handle", sizeof("io\\poll\\handle") - 1); + +#if PHP_VERSION_ID < 80600 + if (poll_handle_ce == NULL) { + zend_class_entry poll_handle; + INIT_NS_CLASS_ENTRY(poll_handle, "Io\\Poll", "Handle", NULL); + poll_handle_ce = zend_register_internal_interface(&poll_handle); + } +#endif + + if (poll_handle_ce != NULL) { + zend_class_implements(ce, 1, poll_handle_ce); + } +} + +/* The socket of the current background worker, invalid outside one and + * once the thread let it go: is_valid() then reports what tick() does. */ +static php_socket_t +frankenphp_worker_handle_get_fd(frankenphp_handle_obj *handle) { + (void)handle; + + return is_background_worker ? worker_stop_socks[0] : SOCK_ERR; +} + +static int frankenphp_worker_handle_is_valid(frankenphp_handle_obj *handle) { + return frankenphp_worker_handle_get_fd(handle) != SOCK_ERR; +} + +/* The stream a script took from this handle, so asking twice hands out the + * same one and a loop does not grow the resource list of a run that never + * ends. On the handle rather than the thread, so it goes away with it. */ +static void frankenphp_worker_handle_cleanup(frankenphp_handle_obj *handle) { + if (handle->handle_data == NULL) { + return; + } + + zend_list_delete(handle->handle_data); + handle->handle_data = NULL; +} + +static frankenphp_handle_ops frankenphp_worker_handle_poll_ops = { + .get_fd = frankenphp_worker_handle_get_fd, + .is_valid = frankenphp_worker_handle_is_valid, + .cleanup = frankenphp_worker_handle_cleanup, +}; + +static zend_object *frankenphp_worker_handle_new(zend_class_entry *ce) { + return frankenphp_handle_obj_create(ce, &frankenphp_worker_handle_poll_ops); +} + +/* Ops of the streams returned by WorkerHandle::getStream(): the socket ops, + * except that closing a stream leaves the socket alone. It belongs to the + * thread, every handle of a run shares it, and it is closed at the next run + * setup or on thread exit. Initialized in MINIT. */ +static php_stream_ops frankenphp_worker_handle_ops; + +static int frankenphp_worker_handle_close(php_stream *stream, + int close_handle) { + (void)close_handle; + + /* free the stream data only, never the shared socket */ + return php_stream_socket_ops.close(stream, 0); +} + +/* The handle of the current background worker: the stream to wait on and the + * tick that reports the drain. Constructing it outside a background worker + * throws, a run may create as many as it likes. */ +/* The constructor is not a gate: unserialize() and Reflection make an + * instance without it, so every method checks the thread it runs on. */ +static bool frankenphp_worker_handle_usable(void) { + if (is_background_worker && worker_stop_socks[0] != SOCK_ERR) { + return true; + } + + zend_throw_exception( + spl_ce_RuntimeException, + "FrankenPHP\\WorkerHandle can only be used from a background worker", 0); + + return false; +} + +ZEND_METHOD(FrankenPHP_WorkerHandle, __construct) { + ZEND_PARSE_PARAMETERS_NONE(); + + if (!is_background_worker) { + zend_throw_exception( + spl_ce_RuntimeException, + "FrankenPHP\\WorkerHandle can only be created from a background worker", + 0); + RETURN_THROWS(); + } +} + +ZEND_METHOD(FrankenPHP_WorkerHandle, getStream) { + ZEND_PARSE_PARAMETERS_NONE(); + + if (!frankenphp_worker_handle_usable()) { + RETURN_THROWS(); + } + + /* The same resource until the script closes it, so asking for it in a loop + * does not grow the resource list of a run that never ends. The stream does + * not own the socket (see frankenphp_worker_handle_ops), so closing one + * leaves the streams of the other handles alone. */ + frankenphp_handle_obj *handle = FRANKENPHP_HANDLE_OF(Z_OBJ_P(ZEND_THIS)); + if (handle->handle_data != NULL) { + zend_resource *cached = handle->handle_data; + if (cached->type == php_file_le_stream()) { + GC_ADDREF(cached); + RETURN_RES(cached); + } + /* closed by the script: drop the handle's ref */ + zend_list_delete(cached); + handle->handle_data = NULL; + } + + php_stream *stream = + php_stream_sock_open_from_socket(worker_stop_socks[0], NULL); + if (stream == NULL) { + zend_throw_exception(spl_ce_RuntimeException, + "failed to create a stream over the stop socket", 0); + RETURN_THROWS(); + } + + /* a blocking read is a valid way to park: wait without the + * default_socket_timeout wake-ups */ + struct timeval no_timeout = {-1, 0}; + php_stream_set_option(stream, PHP_STREAM_OPTION_READ_TIMEOUT, 0, &no_timeout); + + stream->ops = &frankenphp_worker_handle_ops; + + php_stream_to_zval(stream, return_value); + handle->handle_data = Z_RES_P(return_value); + GC_ADDREF(Z_RES_P(return_value)); +} + +/* The ready point of a background worker and its liveness check, the analog + * of frankenphp_handle_request(): the first call of a run reports the worker + * ready, and every call returns false once FrankenPHP drains it. It never + * blocks and never hands out work, the script waits on the stream of + * getStream() and calls this when it is readable. Whatever the runtime wrote + * there to wake the script up is consumed here, so the protocol stays + * private. */ +ZEND_METHOD(FrankenPHP_WorkerHandle, tick) { + ZEND_PARSE_PARAMETERS_NONE(); + + if (!frankenphp_worker_handle_usable()) { + RETURN_THROWS(); + } + + if (!worker_ticked) { + worker_ticked = true; + /* The bootstrap ran under max_execution_time like any request, the loop + * that starts now has no time limit, like the CLI. Nothing re-arms the + * timer past this point. */ + zend_unset_timeout(); + go_frankenphp_background_worker_ready(frankenphp_thread_index()); + } + + /* consume the wake-ups without blocking; EOF is the drain */ + char buf[64]; + for (;;) { + if (php_pollfd_for_ms(worker_stop_socks[0], PHP_POLLREADABLE, 0) <= 0) { + /* nothing pending, or a transient poll error: still running */ + RETURN_TRUE; + } + +#ifdef PHP_WIN32 + int n = recv(worker_stop_socks[0], buf, (int)sizeof(buf), 0); +#else + ssize_t n = recv(worker_stop_socks[0], buf, sizeof(buf), 0); +#endif + if (n == 0) { + /* the Go side closed its end: drained */ + RETURN_FALSE; + } + if (n < 0) { + int err = php_socket_errno(); + if (err == EINTR || PHP_IS_TRANSIENT_ERROR(err)) { + RETURN_TRUE; + } + + /* a broken socket carries no drain anymore, stop the loop */ + RETURN_FALSE; + } + } +} + /* {{{ thread-safe opcache reset */ PHP_FUNCTION(frankenphp_opcache_reset) { go_schedule_opcache_reset(frankenphp_thread_index()); @@ -1223,7 +1709,20 @@ static const zend_function_entry frankenphp_test_hook_functions[] = { #endif PHP_MINIT_FUNCTION(frankenphp) { + frankenphp_worker_handle_ops = php_stream_socket_ops; + frankenphp_worker_handle_ops.label = "FrankenPHP worker handle"; + frankenphp_worker_handle_ops.close = frankenphp_worker_handle_close; + + frankenphp_handle_obj_handlers = std_object_handlers; + frankenphp_handle_obj_handlers.offset = offsetof(frankenphp_handle_obj, std); + frankenphp_handle_obj_handlers.free_obj = frankenphp_handle_obj_free; + frankenphp_handle_obj_handlers.clone_obj = NULL; + register_frankenphp_symbols(module_number); + + zend_class_entry *worker_handle_ce = register_class_FrankenPHP_WorkerHandle(); + worker_handle_ce->create_object = frankenphp_worker_handle_new; + frankenphp_handle_implements_poll(worker_handle_ce); #ifndef PHP_WIN32 /* MINIT runs once per ZTS thread — guard the atfork registration */ static pthread_once_t atfork_once = PTHREAD_ONCE_INIT; @@ -1858,6 +2357,13 @@ static void *php_thread(void *arg) { } zend_end_try(); + /* frankenphp_update_local_thread_context() only releases the stop socket on + * recycle: close it here too, so it does not outlive the thread on shutdown, + * reboot or an unhealthy exit. */ + if (is_background_worker) { + frankenphp_reset_background_worker(); + } + /* Must precede ts_free_thread: that frees the TSRM storage backing * the slot's &EG() pointers. Clearing first means any concurrent * force-kill either ran before us or sees a zero slot. */ diff --git a/frankenphp.go b/frankenphp.go index e560031e62..60ce157828 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -64,7 +64,7 @@ var ( globalCtx = context.Background() globalLogger = slog.Default() - metrics Metrics = nullMetrics{} + metrics workerMetrics = nullMetrics{} // atomic: read by in-flight requests while a reload may rewrite it maxWaitTime atomic.Int64 @@ -216,11 +216,26 @@ func checkPHPConfig(config PHPConfig) error { return nil } -func calculateMaxThreads(opt *opt) (numWorkers int, _ error) { +// calculateMaxThreads resolves num_threads and max_threads against the HTTP +// workers and returns their thread count, plus the threads of the background +// workers, which take no part in that budget: they come on top of it +func calculateMaxThreads(opt *opt) (numWorkers, backgroundThreads int, _ error) { maxProcs := runtime.GOMAXPROCS(0) * 2 maxThreadsFromWorkers := 0 for i, w := range opt.workers { + if w.isBackgroundWorker { + if w.num <= 0 { + // one thread unless a pool is asked for: a background + // worker serves no requests, so it does not scale with + // the CPUs like an HTTP one + opt.workers[i].num = 1 + } + backgroundThreads += opt.workers[i].num + + continue + } + if w.num <= 0 { // https://github.com/php/frankenphp/issues/126 opt.workers[i].num = maxProcs @@ -229,11 +244,11 @@ func calculateMaxThreads(opt *opt) (numWorkers int, _ error) { if w.maxThreads > 0 { if w.maxThreads < w.num { - return 0, fmt.Errorf("worker max_threads (%d) must be greater or equal to worker num (%d) (%q)", w.maxThreads, w.num, w.fileName) + return 0, 0, fmt.Errorf("worker max_threads (%d) must be greater or equal to worker num (%d) (%q)", w.maxThreads, w.num, w.fileName) } if w.maxThreads > opt.maxThreads && opt.maxThreads > 0 { - return 0, fmt.Errorf("worker max_threads (%d) cannot be greater than total max_threads (%d) (%q)", w.maxThreads, opt.maxThreads, w.fileName) + return 0, 0, fmt.Errorf("worker max_threads (%d) cannot be greater than total max_threads (%d) (%q)", w.maxThreads, opt.maxThreads, w.fileName) } maxThreadsFromWorkers += w.maxThreads - w.num @@ -264,16 +279,16 @@ func calculateMaxThreads(opt *opt) (numWorkers int, _ error) { if numThreadsIsSet && !maxThreadsIsSet { opt.maxThreads = opt.numThreads - return numWorkers, nil + return numWorkers, backgroundThreads, nil } if maxThreadsIsSet && !numThreadsIsSet { opt.numThreads = numWorkers + 1 if !maxThreadsIsAuto && opt.numThreads > opt.maxThreads { - return 0, fmt.Errorf("max_threads (%d) must be greater than the number of worker threads (%d)", opt.maxThreads, numWorkers) + return 0, 0, fmt.Errorf("max_threads (%d) must be greater than the number of worker threads (%d)", opt.maxThreads, numWorkers) } - return numWorkers, nil + return numWorkers, backgroundThreads, nil } if !numThreadsIsSet { @@ -286,19 +301,19 @@ func calculateMaxThreads(opt *opt) (numWorkers int, _ error) { } opt.maxThreads = opt.numThreads - return numWorkers, nil + return numWorkers, backgroundThreads, nil } // both num_threads and max_threads are set if !maxThreadsIsAuto && opt.maxThreads < opt.numThreads { if numWorkers > 0 { - return 0, fmt.Errorf("max_threads (%d) must be greater than or equal to num_threads (%d) plus the worker threads (%d)", opt.maxThreads, opt.numThreads-numWorkers, numWorkers) + return 0, 0, fmt.Errorf("max_threads (%d) must be greater than or equal to num_threads (%d) plus the worker threads (%d)", opt.maxThreads, opt.numThreads-numWorkers, numWorkers) } - return 0, fmt.Errorf("max_threads (%d) must be greater than or equal to num_threads (%d)", opt.maxThreads, opt.numThreads) + return 0, 0, fmt.Errorf("max_threads (%d) must be greater than or equal to num_threads (%d)", opt.maxThreads, opt.numThreads) } - return numWorkers, nil + return numWorkers, backgroundThreads, nil } // Validate reports whether Init() would accept a configuration, without @@ -314,7 +329,7 @@ func Validate(options ...Option) error { } } - if _, err := calculateMaxThreads(opt); err != nil { + if _, _, err := calculateMaxThreads(opt); err != nil { return err } @@ -322,26 +337,30 @@ func Validate(options ...Option) error { return err } - takenNames := make(map[string]bool, len(opt.workers)) + // what each scope took so far, a nil server for the global workers + takenNames := make(map[*Server]map[string]bool, 1) takenPaths := make(map[*Server]map[string]bool, 1) - nameTaken := func(name string) bool { return takenNames[name] } + nameTaken := func(server *Server, name string) bool { return takenNames[server][name] } pathTaken := func(server *Server, path string) bool { return takenPaths[server][path] } + take := func(taken map[*Server]map[string]bool, server *Server, key string) { + if taken[server] == nil { + taken[server] = make(map[string]bool) + } + taken[server][key] = true + } for _, w := range opt.workers { - w, err := resolveWorkerFile(w) + w, err := resolveWorkerFile(w, nameTaken) if err != nil { return err } - if err := checkWorkerDeclaration(w, nameTaken, pathTaken); err != nil { + if err := checkWorkerDeclaration(w, opt.servers, nameTaken, pathTaken); err != nil { return err } - takenNames[w.name] = true - if w.matchRequest == nil { - if takenPaths[w.server] == nil { - takenPaths[w.server] = make(map[string]bool) - } - takenPaths[w.server][w.fileName] = true + take(takenNames, w.server, w.name) + if !w.isBackgroundWorker && w.matchRequest == nil { + take(takenPaths, w.server, w.fileName) } } @@ -382,7 +401,7 @@ func Init(options ...Option) error { } if opt.metrics != nil { - metrics = opt.metrics + metrics = adaptMetrics(opt.metrics) } maxWaitTime.Store(int64(opt.maxWaitTime)) @@ -394,16 +413,15 @@ func Init(options ...Option) error { registerServers(opt.servers) - workerThreadCount, err := calculateMaxThreads(opt) + workerThreadCount, backgroundThreads, err := calculateMaxThreads(opt) if err != nil { shutdown() return err } - metrics.TotalThreads(opt.numThreads) - for _, w := range opt.workers { - metrics.TotalWorkers(w.name, w.num) - } + // background workers run on threads of their own, on top of the budget + // num_threads and max_threads describe for HTTP traffic + metrics.TotalThreads(opt.numThreads + backgroundThreads) config := Config() @@ -421,6 +439,10 @@ func Init(options ...Option) error { } } else { opt.numThreads = 1 + if workerThreadCount > 1 || backgroundThreads > 0 { + shutdown() + return fmt.Errorf("%d worker threads are declared, but this PHP build is not ZTS and runs a single thread", workerThreadCount+backgroundThreads) + } if globalLogger.Enabled(globalCtx, slog.LevelWarn) { globalLogger.LogAttrs(globalCtx, slog.LevelWarn, `ZTS is not enabled, only 1 thread will be available, recompile PHP using the "--enable-zts" configuration option or performance will be degraded`) @@ -429,7 +451,13 @@ func Init(options ...Option) error { hotReloadEnabled = opt.hasHotReload() - mainThread, err := initPHPThreads(opt.numThreads, opt.maxThreads, opt.phpIni) + maxThreads := opt.maxThreads + if maxThreads > 0 { + maxThreads += backgroundThreads + } + // in auto mode (maxThreads < 0), the main thread adds the reservation to + // the limit it resolves, see setAutomaticMaxThreads() + mainThread, err := initPHPThreads(opt.numThreads+backgroundThreads, maxThreads, backgroundThreads, opt.phpIni) if err != nil { shutdown() return err @@ -577,7 +605,7 @@ func go_apache_request_headers(threadIndex C.uintptr_t) (*C.go_string, C.size_t) // worker mode, not handling a request if fc.logger.Enabled(fc.ctx, slog.LevelDebug) { - fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "apache_request_headers() called in non-HTTP context", slog.String("worker", fc.worker.name)) + fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "apache_request_headers() called in non-HTTP context", slog.String("worker", fc.worker.qualifiedName)) } return nil, 0 @@ -911,9 +939,7 @@ func opcacheRestartScheduled(reason int) { reasonText = opcacheRestartReasons[reason] } - if m, ok := metrics.(OpcacheMetrics); ok { - m.OpcacheRestart(reasonText) - } + metrics.OpcacheRestart(reasonText) if !globalLogger.Enabled(globalCtx, slog.LevelWarn) { return @@ -954,8 +980,7 @@ func resetGlobals() { globalCtx = context.Background() globalLogger = slog.Default() workers = nil - workersByName = nil - globalWorkersByPath = nil + fallbackServer.resetWorkers() servers = nil watcherIsEnabled = false hotReloadEnabled = false diff --git a/frankenphp.h b/frankenphp.h index 5eb6316947..5f94e65826 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -219,6 +219,10 @@ typedef enum { FRANKENPHP_MERCURE_UNSUPPORTED = 4, } frankenphp_mercure_status; +/* Background worker primitives. */ +intptr_t frankenphp_set_background_worker_and_get_stop_sock(void); +void frankenphp_worker_close_stop_sock(intptr_t s); + void register_extensions(zend_module_entry **m, int len); // This function is exported for use by extensions, as proposed in PR #2305 diff --git a/frankenphp.stub.php b/frankenphp.stub.php index d61397caaa..b2bf5d729a 100644 --- a/frankenphp.stub.php +++ b/frankenphp.stub.php @@ -2,55 +2,102 @@ /** @generate-class-entries */ -/** @var int */ -const FRANKENPHP_LOG_LEVEL_DEBUG = -4; - -/** @var int */ -const FRANKENPHP_LOG_LEVEL_INFO = 0; - -/** @var int */ -const FRANKENPHP_LOG_LEVEL_WARN = 4; - -/** @var int */ -const FRANKENPHP_LOG_LEVEL_ERROR = 8; - -function frankenphp_handle_request(callable $callback): bool {} - -function headers_send(int $status = 200): int {} - -function frankenphp_finish_request(): bool {} - -/** - * @alias frankenphp_finish_request - */ -function fastcgi_finish_request(): bool {} - -function frankenphp_request_headers(): array {} - -/** - * @alias frankenphp_request_headers - */ -function apache_request_headers(): array {} - -/** - * @alias frankenphp_request_headers -*/ -function getallheaders(): array {} - -function frankenphp_response_headers(): array|bool {} - -/** - * @alias frankenphp_response_headers - */ -function apache_response_headers(): array|bool {} - -/** - * @param string|string[] $topics The first topic is the canonical one, the others are alternate topics - */ -function mercure_publish(string|array $topics, string $data = '', bool $private = false, ?string $id = null, ?string $type = null, ?int $retry = null): string {} - -/** - * @param int $level The importance or severity of a log event. The higher the level, the more important or severe the event. For more details, see: https://pkg.go.dev/log/slog#Level - * array $context Values of the array will be converted to the corresponding Go type (if supported by FrankenPHP) and added to the context of the structured logs using https://pkg.go.dev/log/slog#Attr - */ -function frankenphp_log(string $message, int $level = 0, array $context = []): void {} +namespace { + /** @var int */ + const FRANKENPHP_LOG_LEVEL_DEBUG = -4; + + /** @var int */ + const FRANKENPHP_LOG_LEVEL_INFO = 0; + + /** @var int */ + const FRANKENPHP_LOG_LEVEL_WARN = 4; + + /** @var int */ + const FRANKENPHP_LOG_LEVEL_ERROR = 8; + + function frankenphp_handle_request(callable $callback): bool {} + + function headers_send(int $status = 200): int {} + + function frankenphp_finish_request(): bool {} + + /** + * @alias frankenphp_finish_request + */ + function fastcgi_finish_request(): bool {} + + function frankenphp_request_headers(): array {} + + /** + * @alias frankenphp_request_headers + */ + function apache_request_headers(): array {} + + /** + * @alias frankenphp_request_headers + */ + function getallheaders(): array {} + + function frankenphp_response_headers(): array|bool {} + + /** + * @alias frankenphp_response_headers + */ + function apache_response_headers(): array|bool {} + + /** + * @param string|string[] $topics The first topic is the canonical one, the others are alternate topics + */ + function mercure_publish(string|array $topics, string $data = '', bool $private = false, ?string $id = null, ?string $type = null, ?int $retry = null): string {} + + /** + * @param int $level The importance or severity of a log event. The higher the level, the more important or severe the event. For more details, see: https://pkg.go.dev/log/slog#Level + * array $context Values of the array will be converted to the corresponding Go type (if supported by FrankenPHP) and added to the context of the structured logs using https://pkg.go.dev/log/slog#Attr + */ + function frankenphp_log(string $message, int $level = 0, array $context = []): void {} +} + +namespace FrankenPHP { + /** + * @strict-properties + * @not-serializable + * + * EXPERIMENTAL: the handle of the current background worker, the one + * point where the script and FrankenPHP meet. Constructing it outside + * a background worker throws. It implements Io\Poll\Handle, so an + * Io\Poll\Context waits on it directly, the one of PHP 8.6 or the one + * of symfony/polyfill-io-poll below that. + */ + final class WorkerHandle + { + public function __construct() {} + + /** + * The ready point and liveness check of a background worker, the + * background analog of frankenphp_handle_request(). The first call + * of a run marks the worker ready: the server start waits for it, + * and an exit before it counts as a failure. max_execution_time + * applies until that call and not after. It returns false once + * FrankenPHP drains the worker, on shutdown, reboot or restart, so + * the script can leave its loop, and true otherwise. It never + * blocks and never hands out work: the script waits on the stream + * of getStream() and calls this when it is readable, which also + * consumes whatever FrankenPHP wrote there. + */ + public function tick(): bool {} + + /** + * The stream to wait on, alone or with the script's own streams: it + * becomes readable when FrankenPHP needs the script's attention, + * its drain included. Only waiting on it is supported, through + * stream_select() or a blocking read; what it carries is not part + * of the contract and tick() consumes it. Reading it steals those + * bytes from tick(), writing to it goes nowhere. Closing it is + * safe: a handle hands out one stream, a fresh one over the same + * socket once the script closed it. + * + * @return resource + */ + public function getStream() {} + } +} diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index 052cfc67c6..76d28dcf1d 100644 --- a/frankenphp_arginfo.h +++ b/frankenphp_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit the .stub.php file instead. - * Stub hash: 85fabd314ab885307e0032b9f7635fa13cc0c4b5 */ + * Stub hash: 98816e2cc582ef5c787dd12140a0c41fd19b484f */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_handle_request, 0, 1, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, callback, IS_CALLABLE, 0) @@ -41,6 +41,12 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_log, 0, 1, IS_VOID, 0 ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, context, IS_ARRAY, 0, "[]") ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_INFO_EX(arginfo_class_FrankenPHP_WorkerHandle___construct, 0, 0, 0) +ZEND_END_ARG_INFO() + +#define arginfo_class_FrankenPHP_WorkerHandle_tick arginfo_frankenphp_finish_request + +#define arginfo_class_FrankenPHP_WorkerHandle_getStream arginfo_class_FrankenPHP_WorkerHandle___construct ZEND_FUNCTION(frankenphp_handle_request); ZEND_FUNCTION(headers_send); @@ -49,7 +55,9 @@ ZEND_FUNCTION(frankenphp_request_headers); ZEND_FUNCTION(frankenphp_response_headers); ZEND_FUNCTION(mercure_publish); ZEND_FUNCTION(frankenphp_log); - +ZEND_METHOD(FrankenPHP_WorkerHandle, __construct); +ZEND_METHOD(FrankenPHP_WorkerHandle, tick); +ZEND_METHOD(FrankenPHP_WorkerHandle, getStream); static const zend_function_entry ext_functions[] = { ZEND_FE(frankenphp_handle_request, arginfo_frankenphp_handle_request) @@ -66,6 +74,13 @@ static const zend_function_entry ext_functions[] = { ZEND_FE_END }; +static const zend_function_entry class_FrankenPHP_WorkerHandle_methods[] = { + ZEND_ME(FrankenPHP_WorkerHandle, __construct, arginfo_class_FrankenPHP_WorkerHandle___construct, ZEND_ACC_PUBLIC) + ZEND_ME(FrankenPHP_WorkerHandle, tick, arginfo_class_FrankenPHP_WorkerHandle_tick, ZEND_ACC_PUBLIC) + ZEND_ME(FrankenPHP_WorkerHandle, getStream, arginfo_class_FrankenPHP_WorkerHandle_getStream, ZEND_ACC_PUBLIC) + ZEND_FE_END +}; + static void register_frankenphp_symbols(int module_number) { REGISTER_LONG_CONSTANT("FRANKENPHP_LOG_LEVEL_DEBUG", -4, CONST_PERSISTENT); @@ -73,3 +88,13 @@ static void register_frankenphp_symbols(int module_number) REGISTER_LONG_CONSTANT("FRANKENPHP_LOG_LEVEL_WARN", 4, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("FRANKENPHP_LOG_LEVEL_ERROR", 8, CONST_PERSISTENT); } + +static zend_class_entry *register_class_FrankenPHP_WorkerHandle(void) +{ + zend_class_entry ce, *class_entry; + + INIT_NS_CLASS_ENTRY(ce, "FrankenPHP", "WorkerHandle", class_FrankenPHP_WorkerHandle_methods); + class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE); + + return class_entry; +} diff --git a/frankenphp_test.go b/frankenphp_test.go index 84199ba81d..961860732c 100644 --- a/frankenphp_test.go +++ b/frankenphp_test.go @@ -1667,7 +1667,7 @@ func TestValidateReportsDeclarationErrors(t *testing.T) { assert.ErrorContains(t, frankenphp.Validate( frankenphp.WithWorkers("same", "testdata/worker.php", 1), frankenphp.WithWorkers("same", "testdata/index.php", 1), - ), "two workers cannot have the same name") + ), "two global workers cannot have the same name") assert.ErrorContains(t, frankenphp.Validate( frankenphp.WithWorkers("one", "testdata/worker.php", 1), @@ -1690,6 +1690,20 @@ func TestValidateReportsDeclarationErrors(t *testing.T) { frankenphp.WithWorkers("two", "testdata/worker.php", 1, frankenphp.WithWorkerServerScope(server), matchAll), )) + other, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + assert.ErrorContains(t, frankenphp.Validate( + frankenphp.WithServer(server), + frankenphp.WithWorkers("one", "testdata/worker.php", 1, frankenphp.WithWorkerServerScope(other)), + ), "was not passed to WithServer()") + + // names are unique within their scope, not across servers + assert.NoError(t, frankenphp.Validate( + frankenphp.WithServer(server), + frankenphp.WithWorkers("same", "testdata/worker.php", 1), + frankenphp.WithWorkers("same", "testdata/index.php", 1, frankenphp.WithWorkerServerScope(server)), + )) + assert.ErrorContains(t, frankenphp.Validate( frankenphp.WithNumThreads(2), frankenphp.WithMaxThreads(1), diff --git a/metrics.go b/metrics.go index 852305aa35..a134776ccd 100644 --- a/metrics.go +++ b/metrics.go @@ -11,14 +11,16 @@ import ( const ( StopReasonCrash = iota StopReasonRestart - StopReasonBootFailure // worker crashed before reaching frankenphp_handle_request + StopReasonBootFailure // worker exited before reaching its ready point: frankenphp_handle_request, or WorkerHandle::tick() for background workers ) type StopReason int // Metrics reports what the workers and the threads of a FrankenPHP instance -// are doing. An implementation that also satisfies OpcacheMetrics is told -// about opcache restarts as well. +// are doing. A worker is identified by its name alone, where a worker scoped +// to a server is reported as ":". An implementation that +// also satisfies ServerMetrics gets the two apart instead, and one that +// satisfies OpcacheMetrics is told about opcache restarts as well. type Metrics interface { // StartWorker collects started workers StartWorker(name string) @@ -53,18 +55,149 @@ type OpcacheMetrics interface { OpcacheRestart(reason string) } +// ServerMetrics is the optional part of a Metrics implementation that keeps +// the name of a worker and the name of its server apart, the latter empty for +// a global worker, so a series keyed on the name alone still selects the +// worker of every server. An implementation passed to WithMetrics() that +// satisfies it never has the worker methods of Metrics called. +type ServerMetrics interface { + StartWorkerOnServer(name, server string) + ReadyWorkerOnServer(name, server string) + StopWorkerOnServer(name, server string, reason StopReason) + TotalWorkersOnServer(name, server string, num int) + StopWorkerRequestOnServer(name, server string, duration time.Duration) + StartWorkerRequestOnServer(name, server string) + QueuedWorkerRequestOnServer(name, server string) + DequeuedWorkerRequestOnServer(name, server string) +} + +// workerMetrics is what the runtime reports on: Metrics with the worker +// methods taking the server as well +type workerMetrics interface { + StartWorker(name, server string) + ReadyWorker(name, server string) + StopWorker(name, server string, reason StopReason) + TotalWorkers(name, server string, num int) + TotalThreads(num int) + StartRequest() + StopRequest() + StopWorkerRequest(name, server string, duration time.Duration) + StartWorkerRequest(name, server string) + Shutdown() + QueuedWorkerRequest(name, server string) + DequeuedWorkerRequest(name, server string) + QueuedRequest() + DequeuedRequest() + OpcacheRestart(reason string) +} + +// metricsAdapter routes the worker methods to ServerMetrics when the +// implementation has it, and packs the server into the name otherwise; +// opcache restarts reach it only when it satisfies OpcacheMetrics +type metricsAdapter struct { + Metrics + server ServerMetrics + opcache OpcacheMetrics +} + +func adaptMetrics(m Metrics) workerMetrics { + a := metricsAdapter{Metrics: m} + a.server, _ = m.(ServerMetrics) + a.opcache, _ = m.(OpcacheMetrics) + + return a +} + +func (a metricsAdapter) OpcacheRestart(reason string) { + if a.opcache != nil { + a.opcache.OpcacheRestart(reason) + } +} + +func packedWorkerName(name, server string) string { + if server == "" { + return name + } + + return server + ":" + name +} + +func (a metricsAdapter) StartWorker(name, server string) { + if a.server != nil { + a.server.StartWorkerOnServer(name, server) + return + } + a.Metrics.StartWorker(packedWorkerName(name, server)) +} + +func (a metricsAdapter) ReadyWorker(name, server string) { + if a.server != nil { + a.server.ReadyWorkerOnServer(name, server) + return + } + a.Metrics.ReadyWorker(packedWorkerName(name, server)) +} + +func (a metricsAdapter) StopWorker(name, server string, reason StopReason) { + if a.server != nil { + a.server.StopWorkerOnServer(name, server, reason) + return + } + a.Metrics.StopWorker(packedWorkerName(name, server), reason) +} + +func (a metricsAdapter) TotalWorkers(name, server string, num int) { + if a.server != nil { + a.server.TotalWorkersOnServer(name, server, num) + return + } + a.Metrics.TotalWorkers(packedWorkerName(name, server), num) +} + +func (a metricsAdapter) StopWorkerRequest(name, server string, duration time.Duration) { + if a.server != nil { + a.server.StopWorkerRequestOnServer(name, server, duration) + return + } + a.Metrics.StopWorkerRequest(packedWorkerName(name, server), duration) +} + +func (a metricsAdapter) StartWorkerRequest(name, server string) { + if a.server != nil { + a.server.StartWorkerRequestOnServer(name, server) + return + } + a.Metrics.StartWorkerRequest(packedWorkerName(name, server)) +} + +func (a metricsAdapter) QueuedWorkerRequest(name, server string) { + if a.server != nil { + a.server.QueuedWorkerRequestOnServer(name, server) + return + } + a.Metrics.QueuedWorkerRequest(packedWorkerName(name, server)) +} + +func (a metricsAdapter) DequeuedWorkerRequest(name, server string) { + if a.server != nil { + a.server.DequeuedWorkerRequestOnServer(name, server) + return + } + a.Metrics.DequeuedWorkerRequest(packedWorkerName(name, server)) +} + type nullMetrics struct{} -func (n nullMetrics) StartWorker(string) { +func (n nullMetrics) StartWorker(string, string) { } -func (n nullMetrics) ReadyWorker(string) { +func (n nullMetrics) ReadyWorker(string, string) { } -func (n nullMetrics) StopWorker(string, StopReason) { +func (n nullMetrics) StopWorker(string, string, StopReason) { } -func (n nullMetrics) TotalWorkers(string, int) { +func (n nullMetrics) TotalWorkers(string, string, int) { } func (n nullMetrics) TotalThreads(int) { @@ -76,21 +209,22 @@ func (n nullMetrics) StartRequest() { func (n nullMetrics) StopRequest() { } -func (n nullMetrics) StopWorkerRequest(string, time.Duration) { +func (n nullMetrics) StopWorkerRequest(string, string, time.Duration) { } -func (n nullMetrics) StartWorkerRequest(string) { +func (n nullMetrics) StartWorkerRequest(string, string) { } func (n nullMetrics) Shutdown() { } -func (n nullMetrics) QueuedWorkerRequest(string) {} +func (n nullMetrics) QueuedWorkerRequest(string, string) {} -func (n nullMetrics) DequeuedWorkerRequest(string) {} +func (n nullMetrics) DequeuedWorkerRequest(string, string) {} -func (n nullMetrics) QueuedRequest() {} -func (n nullMetrics) DequeuedRequest() {} +func (n nullMetrics) QueuedRequest() {} +func (n nullMetrics) DequeuedRequest() {} +func (n nullMetrics) OpcacheRestart(string) {} type PrometheusMetrics struct { registry prometheus.Registerer @@ -120,10 +254,33 @@ func (m *PrometheusMetrics) mustRegister(c prometheus.Collector) { var ( _ Metrics = (*PrometheusMetrics)(nil) + _ ServerMetrics = (*PrometheusMetrics)(nil) _ OpcacheMetrics = (*PrometheusMetrics)(nil) ) -func (m *PrometheusMetrics) StartWorker(name string) { +func (m *PrometheusMetrics) StartWorker(name string) { m.StartWorkerOnServer(name, "") } + +func (m *PrometheusMetrics) ReadyWorker(name string) { m.ReadyWorkerOnServer(name, "") } + +func (m *PrometheusMetrics) StopWorker(name string, reason StopReason) { + m.StopWorkerOnServer(name, "", reason) +} + +func (m *PrometheusMetrics) TotalWorkers(name string, num int) { m.TotalWorkersOnServer(name, "", num) } + +func (m *PrometheusMetrics) StopWorkerRequest(name string, duration time.Duration) { + m.StopWorkerRequestOnServer(name, "", duration) +} + +func (m *PrometheusMetrics) StartWorkerRequest(name string) { m.StartWorkerRequestOnServer(name, "") } + +func (m *PrometheusMetrics) QueuedWorkerRequest(name string) { m.QueuedWorkerRequestOnServer(name, "") } + +func (m *PrometheusMetrics) DequeuedWorkerRequest(name string) { + m.DequeuedWorkerRequestOnServer(name, "") +} + +func (m *PrometheusMetrics) StartWorkerOnServer(name, server string) { m.mu.RLock() defer m.mu.RUnlock() @@ -134,10 +291,10 @@ func (m *PrometheusMetrics) StartWorker(name string) { return } - m.totalWorkers.WithLabelValues(name).Inc() + m.totalWorkers.WithLabelValues(name, server).Inc() } -func (m *PrometheusMetrics) ReadyWorker(name string) { +func (m *PrometheusMetrics) ReadyWorkerOnServer(name, server string) { m.mu.RLock() defer m.mu.RUnlock() @@ -145,10 +302,10 @@ func (m *PrometheusMetrics) ReadyWorker(name string) { return } - m.readyWorkers.WithLabelValues(name).Inc() + m.readyWorkers.WithLabelValues(name, server).Inc() } -func (m *PrometheusMetrics) StopWorker(name string, reason StopReason) { +func (m *PrometheusMetrics) StopWorkerOnServer(name, server string, reason StopReason) { m.mu.RLock() defer m.mu.RUnlock() @@ -159,27 +316,29 @@ func (m *PrometheusMetrics) StopWorker(name string, reason StopReason) { return } - m.totalWorkers.WithLabelValues(name).Dec() + m.totalWorkers.WithLabelValues(name, server).Dec() - // only decrement readyWorkers if the worker actually reached frankenphp_handle_request + // only decrement readyWorkers if the worker actually reached its ready point if reason != StopReasonBootFailure { - m.readyWorkers.WithLabelValues(name).Dec() + m.readyWorkers.WithLabelValues(name, server).Dec() } switch reason { case StopReasonCrash, StopReasonBootFailure: - m.workerCrashes.WithLabelValues(name).Inc() + m.workerCrashes.WithLabelValues(name, server).Inc() case StopReasonRestart: - m.workerRestarts.WithLabelValues(name).Inc() + m.workerRestarts.WithLabelValues(name, server).Inc() } } -func (m *PrometheusMetrics) TotalWorkers(string, int) { +func (m *PrometheusMetrics) TotalWorkersOnServer(string, string, int) { m.mu.Lock() defer m.mu.Unlock() const ns, sub = "frankenphp", "worker" - basicLabels := []string{"worker"} + // a worker of a php_server keeps its declared name, the block it belongs + // to is a label of its own, empty for a global worker + basicLabels := []string{"worker", "server"} if m.totalWorkers == nil { m.totalWorkers = prometheus.NewGaugeVec(prometheus.GaugeOpts{ @@ -194,7 +353,7 @@ func (m *PrometheusMetrics) TotalWorkers(string, int) { m.readyWorkers = prometheus.NewGaugeVec(prometheus.GaugeOpts{ Namespace: ns, Name: "ready_workers", - Help: "Running workers that have successfully called frankenphp_handle_request at least once", + Help: "Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, WorkerHandle::tick() for background workers", }, basicLabels) m.mustRegister(m.readyWorkers) } @@ -277,7 +436,7 @@ func (m *PrometheusMetrics) StopRequest() { m.busyThreads.Dec() } -func (m *PrometheusMetrics) StopWorkerRequest(name string, duration time.Duration) { +func (m *PrometheusMetrics) StopWorkerRequestOnServer(name, server string, duration time.Duration) { m.mu.RLock() defer m.mu.RUnlock() @@ -285,39 +444,39 @@ func (m *PrometheusMetrics) StopWorkerRequest(name string, duration time.Duratio return } - m.workerRequestCount.WithLabelValues(name).Inc() - m.busyWorkers.WithLabelValues(name).Dec() - m.workerRequestTime.WithLabelValues(name).Add(duration.Seconds()) + m.workerRequestCount.WithLabelValues(name, server).Inc() + m.busyWorkers.WithLabelValues(name, server).Dec() + m.workerRequestTime.WithLabelValues(name, server).Add(duration.Seconds()) } -func (m *PrometheusMetrics) StartWorkerRequest(name string) { +func (m *PrometheusMetrics) StartWorkerRequestOnServer(name, server string) { m.mu.RLock() defer m.mu.RUnlock() if m.busyWorkers == nil { return } - m.busyWorkers.WithLabelValues(name).Inc() + m.busyWorkers.WithLabelValues(name, server).Inc() } -func (m *PrometheusMetrics) QueuedWorkerRequest(name string) { +func (m *PrometheusMetrics) QueuedWorkerRequestOnServer(name, server string) { m.mu.RLock() defer m.mu.RUnlock() if m.workerQueueDepth == nil { return } - m.workerQueueDepth.WithLabelValues(name).Inc() + m.workerQueueDepth.WithLabelValues(name, server).Inc() } -func (m *PrometheusMetrics) DequeuedWorkerRequest(name string) { +func (m *PrometheusMetrics) DequeuedWorkerRequestOnServer(name, server string) { m.mu.RLock() defer m.mu.RUnlock() if m.workerQueueDepth == nil { return } - m.workerQueueDepth.WithLabelValues(name).Dec() + m.workerQueueDepth.WithLabelValues(name, server).Dec() } func (m *PrometheusMetrics) QueuedRequest() { diff --git a/metrics_test.go b/metrics_test.go index 4d5ec1a637..dc47d82df0 100644 --- a/metrics_test.go +++ b/metrics_test.go @@ -54,7 +54,7 @@ func TestPrometheusMetrics_TotalWorkers(t *testing.T) { require.Nil(t, m.workerRequestTime) require.Nil(t, m.workerRequestCount) - m.TotalWorkers("test_worker", 2) + m.TotalWorkersOnServer("test_worker", "test_server", 2) require.NotNil(t, m.totalWorkers) require.NotNil(t, m.busyWorkers) @@ -67,8 +67,8 @@ func TestPrometheusMetrics_TotalWorkers(t *testing.T) { func TestPrometheusMetrics_StopWorkerRequest(t *testing.T) { m := createPrometheusMetrics() - m.TotalWorkers("test_worker", 2) - m.StopWorkerRequest("test_worker", 2*time.Second) + m.TotalWorkersOnServer("test_worker", "test_server", 2) + m.StopWorkerRequestOnServer("test_worker", "test_server", 2*time.Second) inputs := []struct { name string @@ -84,7 +84,7 @@ func TestPrometheusMetrics_StopWorkerRequest(t *testing.T) { # TYPE frankenphp_worker_request_count counter `, expect: ` - frankenphp_worker_request_count{worker="test_worker"} 1 + frankenphp_worker_request_count{server="test_server",worker="test_worker"} 1 `, }, { @@ -95,7 +95,7 @@ func TestPrometheusMetrics_StopWorkerRequest(t *testing.T) { # TYPE frankenphp_busy_workers gauge `, expect: ` - frankenphp_busy_workers{worker="test_worker"} -1 + frankenphp_busy_workers{server="test_server",worker="test_worker"} -1 `, }, { @@ -106,7 +106,7 @@ func TestPrometheusMetrics_StopWorkerRequest(t *testing.T) { # TYPE frankenphp_worker_request_time counter `, expect: ` - frankenphp_worker_request_time{worker="test_worker"} 2 + frankenphp_worker_request_time{server="test_server",worker="test_worker"} 2 `, }, } @@ -121,8 +121,8 @@ func TestPrometheusMetrics_StopWorkerRequest(t *testing.T) { func TestPrometheusMetrics_StartWorkerRequest(t *testing.T) { m := createPrometheusMetrics() - m.TotalWorkers("test_worker", 2) - m.StartWorkerRequest("test_worker") + m.TotalWorkersOnServer("test_worker", "test_server", 2) + m.StartWorkerRequestOnServer("test_worker", "test_server") inputs := []struct { name string @@ -138,7 +138,7 @@ func TestPrometheusMetrics_StartWorkerRequest(t *testing.T) { # TYPE frankenphp_busy_workers gauge `, expect: ` - frankenphp_busy_workers{worker="test_worker"} 1 + frankenphp_busy_workers{server="test_server",worker="test_worker"} 1 `, }, } @@ -153,8 +153,8 @@ func TestPrometheusMetrics_StartWorkerRequest(t *testing.T) { func TestPrometheusMetrics_TestStopReasonCrash(t *testing.T) { m := createPrometheusMetrics() - m.TotalWorkers("test_worker", 2) - m.StopWorker("test_worker", StopReasonCrash) + m.TotalWorkersOnServer("test_worker", "test_server", 2) + m.StopWorkerOnServer("test_worker", "test_server", StopReasonCrash) inputs := []struct { name string @@ -181,18 +181,18 @@ func TestPrometheusMetrics_TestStopReasonCrash(t *testing.T) { # TYPE frankenphp_total_workers gauge `, expect: ` - frankenphp_total_workers{worker="test_worker"} -1 + frankenphp_total_workers{server="test_server",worker="test_worker"} -1 `, }, { name: "Testing ReadyWorkers", c: m.readyWorkers, metadata: ` - # 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, WorkerHandle::tick() for background workers # TYPE frankenphp_ready_workers gauge `, expect: ` - frankenphp_ready_workers{worker="test_worker"} -1 + frankenphp_ready_workers{server="test_server",worker="test_worker"} -1 `, }, { @@ -203,7 +203,7 @@ func TestPrometheusMetrics_TestStopReasonCrash(t *testing.T) { # TYPE frankenphp_worker_crashes counter `, expect: ` - frankenphp_worker_crashes{worker="test_worker"} 1 + frankenphp_worker_crashes{server="test_server",worker="test_worker"} 1 `, }, } @@ -256,7 +256,7 @@ func TestOpcacheRestartScheduledLogsAndCounts(t *testing.T) { var buf bytes.Buffer m := NewPrometheusMetrics(prometheus.NewRegistry()) prevLogger, prevMetrics := globalLogger, metrics - globalLogger, metrics = slog.New(slog.NewTextHandler(&buf, nil)), m + globalLogger, metrics = slog.New(slog.NewTextHandler(&buf, nil)), adaptMetrics(m) t.Cleanup(func() { globalLogger, metrics = prevLogger, prevMetrics }) opcacheRestartScheduled(1) @@ -270,16 +270,115 @@ func TestOpcacheRestartScheduledLogsAndCounts(t *testing.T) { } func TestOpcacheRestartScheduledWithoutOpcacheMetrics(t *testing.T) { - // nullMetrics is a Metrics without the optional part, like an + // splitMetrics is a Metrics without the optional part, like an // implementation from before OpcacheMetrics existed - _, ok := Metrics(nullMetrics{}).(OpcacheMetrics) + m := &splitMetrics{t: t} + _, ok := Metrics(m).(OpcacheMetrics) require.False(t, ok) var buf bytes.Buffer prevLogger, prevMetrics := globalLogger, metrics - globalLogger, metrics = slog.New(slog.NewTextHandler(&buf, nil)), nullMetrics{} + globalLogger, metrics = slog.New(slog.NewTextHandler(&buf, nil)), adaptMetrics(m) t.Cleanup(func() { globalLogger, metrics = prevLogger, prevMetrics }) assert.NotPanics(t, func() { opcacheRestartScheduled(0) }) assert.Contains(t, buf.String(), "reason=oom") } + +// packedMetrics records the worker names a Metrics implementation without +// ServerMetrics receives +type packedMetrics struct { + nullMetrics + names []string +} + +func (m *packedMetrics) StartWorker(name string) { m.names = append(m.names, name) } +func (m *packedMetrics) ReadyWorker(name string) { m.names = append(m.names, name) } +func (m *packedMetrics) StopWorker(name string, _ StopReason) { m.names = append(m.names, name) } +func (m *packedMetrics) TotalWorkers(name string, _ int) { m.names = append(m.names, name) } +func (m *packedMetrics) StopWorkerRequest(name string, _ time.Duration) { + m.names = append(m.names, name) +} +func (m *packedMetrics) StartWorkerRequest(name string) { m.names = append(m.names, name) } +func (m *packedMetrics) QueuedWorkerRequest(name string) { m.names = append(m.names, name) } +func (m *packedMetrics) DequeuedWorkerRequest(name string) { m.names = append(m.names, name) } + +// splitMetrics records the name and server pairs a ServerMetrics +// implementation receives, and fails the test if a packed method is called +type splitMetrics struct { + t *testing.T + pairs [][2]string +} + +func (m *splitMetrics) record(name, server string) { + m.pairs = append(m.pairs, [2]string{name, server}) +} + +func (m *splitMetrics) StartWorkerOnServer(name, server string) { m.record(name, server) } +func (m *splitMetrics) ReadyWorkerOnServer(name, server string) { m.record(name, server) } +func (m *splitMetrics) StopWorkerOnServer(name, server string, _ StopReason) { m.record(name, server) } +func (m *splitMetrics) TotalWorkersOnServer(name, server string, _ int) { m.record(name, server) } +func (m *splitMetrics) StopWorkerRequestOnServer(name, server string, _ time.Duration) { + m.record(name, server) +} +func (m *splitMetrics) StartWorkerRequestOnServer(name, server string) { m.record(name, server) } +func (m *splitMetrics) QueuedWorkerRequestOnServer(name, server string) { m.record(name, server) } +func (m *splitMetrics) DequeuedWorkerRequestOnServer(name, server string) { m.record(name, server) } + +func (m *splitMetrics) StartWorker(string) { m.t.Fatal("packed StartWorker called") } +func (m *splitMetrics) ReadyWorker(string) { m.t.Fatal("packed ReadyWorker called") } +func (m *splitMetrics) StopWorker(string, StopReason) { m.t.Fatal("packed StopWorker called") } +func (m *splitMetrics) TotalWorkers(string, int) { m.t.Fatal("packed TotalWorkers called") } +func (m *splitMetrics) TotalThreads(int) {} +func (m *splitMetrics) StartRequest() {} +func (m *splitMetrics) StopRequest() {} +func (m *splitMetrics) StopWorkerRequest(string, time.Duration) { + m.t.Fatal("packed StopWorkerRequest called") +} +func (m *splitMetrics) StartWorkerRequest(string) { m.t.Fatal("packed StartWorkerRequest called") } +func (m *splitMetrics) Shutdown() {} +func (m *splitMetrics) QueuedWorkerRequest(string) { m.t.Fatal("packed QueuedWorkerRequest called") } +func (m *splitMetrics) DequeuedWorkerRequest(string) { + m.t.Fatal("packed DequeuedWorkerRequest called") +} +func (m *splitMetrics) QueuedRequest() {} +func (m *splitMetrics) DequeuedRequest() {} + +func reportEveryWorkerMethod(m workerMetrics, name, server string) { + m.StartWorker(name, server) + m.ReadyWorker(name, server) + m.StopWorker(name, server, StopReasonRestart) + m.TotalWorkers(name, server, 1) + m.StopWorkerRequest(name, server, time.Second) + m.StartWorkerRequest(name, server) + m.QueuedWorkerRequest(name, server) + m.DequeuedWorkerRequest(name, server) +} + +// a Metrics implementation without ServerMetrics gets the packed name, the +// bare name for a global worker +func TestMetricsAdapterPacksTheServerIntoTheName(t *testing.T) { + m := &packedMetrics{} + a := adaptMetrics(m) + + reportEveryWorkerMethod(a, "queue", "api") + reportEveryWorkerMethod(a, "queue", "") + + require.Len(t, m.names, 16) + assert.Equal(t, []string{"api:queue", "api:queue", "api:queue", "api:queue", "api:queue", "api:queue", "api:queue", "api:queue"}, m.names[:8]) + assert.Equal(t, []string{"queue", "queue", "queue", "queue", "queue", "queue", "queue", "queue"}, m.names[8:]) +} + +// a ServerMetrics implementation gets the two names apart and its packed +// methods are never called +func TestMetricsAdapterKeepsTheServerApart(t *testing.T) { + m := &splitMetrics{t: t} + a := adaptMetrics(m) + + reportEveryWorkerMethod(a, "queue", "api") + + require.Len(t, m.pairs, 8) + for _, pair := range m.pairs { + assert.Equal(t, [2]string{"queue", "api"}, pair) + } +} diff --git a/options.go b/options.go index 40f50a17ee..c95473209c 100644 --- a/options.go +++ b/options.go @@ -58,6 +58,9 @@ type workerOpt struct { onServerStartup func() onServerShutdown func() server *Server + isBackgroundWorker bool + bootTimeout time.Duration + bootTimeoutIsSet bool } // WithContext sets the main context to use. @@ -242,6 +245,36 @@ func WithWorkerServerScope(s *Server) WorkerOption { } } +// EXPERIMENTAL: WithWorkerBackground marks this worker as a background +// (non-HTTP) worker: it shares the PHP runtime with the HTTP threads but +// never receives requests. The script parks on the stream returned by +// WorkerHandle::getStream(), which reaches EOF when FrankenPHP drains the +// worker, to exit gracefully on shutdown or restart. +func WithWorkerBackground() WorkerOption { + return func(w *workerOpt) error { + w.isBackgroundWorker = true + + return nil + } +} + +// EXPERIMENTAL: WithWorkerBootTimeout bounds how long a background worker may +// take to reach its ready point, its first WorkerHandle::tick(), before Init() +// gives up and returns an error. Zero waits for ever, as a build with Zend max +// execution timers does anyway. Defaults to DefaultWorkerBootTimeout, and has +// no effect on HTTP workers. +func WithWorkerBootTimeout(timeout time.Duration) WorkerOption { + return func(w *workerOpt) error { + if timeout < 0 { + return fmt.Errorf("worker boot timeout must be >= 0, got %s", timeout) + } + w.bootTimeout = timeout + w.bootTimeoutIsSet = true + + return nil + } +} + // WithWorkerMaxFailures sets the maximum number of consecutive failures before panicking func WithWorkerMaxFailures(maxFailures int) WorkerOption { return func(w *workerOpt) error { diff --git a/phpmainthread.go b/phpmainthread.go index 23a544ced8..31714bba94 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -20,12 +20,16 @@ import ( // represents the main PHP thread // the thread needs to keep running as long as all other threads are running type phpMainThread struct { - state *state.ThreadState - done chan struct{} - numThreads int - maxThreads int - phpIni map[string]string - isRebooting atomic.Bool + state *state.ThreadState + done chan struct{} + numThreads int + maxThreads int + // backgroundThreads is the part of numThreads and maxThreads that + // background workers reserve: it takes no part in the HTTP budget, + // see calculateMaxThreads() + backgroundThreads int + phpIni map[string]string + isRebooting atomic.Bool } var ( @@ -41,13 +45,14 @@ var ( // initPHPThreads starts the main PHP thread, // a fixed number of inactive PHP threads // and reserves a fixed number of possible PHP threads -func initPHPThreads(numThreads int, numMaxThreads int, phpIni map[string]string) (*phpMainThread, error) { +func initPHPThreads(numThreads int, numMaxThreads int, backgroundThreads int, phpIni map[string]string) (*phpMainThread, error) { mainThread = &phpMainThread{ - state: state.NewThreadState(), - done: make(chan struct{}), - numThreads: numThreads, - maxThreads: numMaxThreads, - phpIni: phpIni, + state: state.NewThreadState(), + done: make(chan struct{}), + numThreads: numThreads, + maxThreads: numMaxThreads, + backgroundThreads: backgroundThreads, + phpIni: phpIni, } // initialize the first thread @@ -161,7 +166,7 @@ func (mainThread *phpMainThread) rebootAllThreads() bool { for _, thread := range rebootingThreads { rebootWg.Go(func() { - close(thread.drainChan) + thread.drain() if thread.state.WaitForStateWithTimeout(rebootGracePeriod, state.YieldingForReboot) { return } @@ -259,18 +264,21 @@ func go_frankenphp_main_thread_is_ready() { // max_threads = auto // setAutomaticMaxThreads estimates the amount of threads based on php.ini and system memory_limit // If unable to get the system's memory limit, simply double num_threads +// The estimate is an HTTP one, like an explicit max_threads: the threads +// background workers reserve are added to it rather than taken out of it func (mainThread *phpMainThread) setAutomaticMaxThreads() { if mainThread.maxThreads >= 0 { return } + httpThreads := mainThread.numThreads - mainThread.backgroundThreads perThreadMemoryLimit := int64(C.frankenphp_get_current_memory_limit()) totalSysMemory := memory.TotalSysMemory() if perThreadMemoryLimit <= 0 || totalSysMemory == 0 { - mainThread.maxThreads = mainThread.numThreads * 2 + mainThread.maxThreads = httpThreads*2 + mainThread.backgroundThreads return } maxAllowedThreads := totalSysMemory / uint64(perThreadMemoryLimit) - mainThread.maxThreads = int(maxAllowedThreads) + mainThread.maxThreads = int(maxAllowedThreads) + mainThread.backgroundThreads if globalLogger.Enabled(globalCtx, slog.LevelDebug) { globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "Automatic thread limit", slog.Int("perThreadMemoryLimitMB", int(perThreadMemoryLimit/1024/1024)), slog.Int("maxThreads", mainThread.maxThreads)) diff --git a/phpmainthread_test.go b/phpmainthread_test.go index d8a5b6742d..69428f67de 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -26,7 +26,7 @@ func setupGlobals(t *testing.T) { } func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) { - _, err := initPHPThreads(1, 1, nil) // boot 1 thread + _, err := initPHPThreads(1, 1, 0, nil) // boot 1 thread assert.NoError(t, err) assert.Len(t, phpThreads, 1) @@ -41,7 +41,7 @@ func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) { func TestTransitionRegularThreadToWorkerThread(t *testing.T) { setupGlobals(t) - _, err := initPHPThreads(1, 1, nil) + _, err := initPHPThreads(1, 1, 0, nil) assert.NoError(t, err) // transition to regular thread @@ -66,7 +66,7 @@ func TestTransitionRegularThreadToWorkerThread(t *testing.T) { func TestTransitionAThreadBetween2DifferentWorkers(t *testing.T) { setupGlobals(t) - _, err := initPHPThreads(1, 1, nil) + _, err := initPHPThreads(1, 1, 0, nil) assert.NoError(t, err) firstWorker := getDummyWorker(t, "transition-worker-1.php") secondWorker := getDummyWorker(t, "transition-worker-2.php") @@ -228,7 +228,7 @@ func TestQueuedRequestSurvivesReload(t *testing.T) { func TestFinishBootingAWorkerScript(t *testing.T) { setupGlobals(t) - _, err := initPHPThreads(1, 1, nil) + _, err := initPHPThreads(1, 1, 0, nil) assert.NoError(t, err) // boot the worker @@ -252,28 +252,22 @@ func TestFinishBootingAWorkerScript(t *testing.T) { func TestReturnAnErrorIf2WorkersHaveTheSameFileName(t *testing.T) { resetGlobals() - workers = []*worker{} - workersByName = map[string]*worker{} - globalWorkersByPath = map[string]*worker{} - w, err1 := newWorker(workerOpt{fileName: testDataPath + "/index.php"}) - assert.NoError(t, err1) - workers = append(workers, w) - workersByName[w.name] = w - globalWorkersByPath[w.fileName] = w - _, err2 := newWorker(workerOpt{fileName: testDataPath + "/index.php"}) - assert.Error(t, err2, "two workers cannot have the same filename") + fallbackServer.resetWorkers() + w1, err := newWorker(workerOpt{fileName: testDataPath + "/index.php"}) + assert.NoError(t, err) + assert.NoError(t, fallbackServer.addWorker(w1)) + _, err = newWorker(workerOpt{fileName: testDataPath + "/index.php", name: "other"}) + assert.ErrorContains(t, err, "two global workers cannot have the same filename") } func TestReturnAnErrorIf2ModuleWorkersHaveTheSameName(t *testing.T) { resetGlobals() - workers = []*worker{} - workersByName = map[string]*worker{} - w, err1 := newWorker(workerOpt{fileName: testDataPath + "/index.php", name: "workername"}) - assert.NoError(t, err1) - workers = append(workers, w) - workersByName[w.name] = w - _, err2 := newWorker(workerOpt{fileName: testDataPath + "/hello.php", name: "workername"}) - assert.Error(t, err2, "two workers cannot have the same name") + fallbackServer.resetWorkers() + w1, err := newWorker(workerOpt{fileName: testDataPath + "/index.php", name: "workername"}) + assert.NoError(t, err) + assert.NoError(t, fallbackServer.addWorker(w1)) + _, err = newWorker(workerOpt{fileName: testDataPath + "/hello.php", name: "workername"}) + assert.ErrorContains(t, err, "two global workers cannot have the same name") } func getDummyWorker(t *testing.T, fileName string) *worker { @@ -315,9 +309,9 @@ func allPossibleTransitions(worker1Path string, worker2Path string) []func(*phpT thread.boot() } }, - func(thread *phpThread) { convertToWorkerThread(thread, globalWorkersByPath[worker1Path]) }, + func(thread *phpThread) { convertToWorkerThread(thread, fallbackServer.workersByPath[worker1Path]) }, convertToInactiveThread, - func(thread *phpThread) { convertToWorkerThread(thread, globalWorkersByPath[worker2Path]) }, + func(thread *phpThread) { convertToWorkerThread(thread, fallbackServer.workersByPath[worker2Path]) }, convertToInactiveThread, } } @@ -375,7 +369,7 @@ func TestCorrectThreadCalculation(t *testing.T) { func testThreadCalculation(t *testing.T, expectedNumThreads int, expectedMaxThreads int, o *opt) { t.Helper() - _, err := calculateMaxThreads(o) + _, _, err := calculateMaxThreads(o) assert.NoError(t, err, "no error should be returned") assert.Equal(t, expectedNumThreads, o.numThreads, "num_threads must be correct") assert.Equal(t, expectedMaxThreads, o.maxThreads, "max_threads must be correct") @@ -384,7 +378,7 @@ func testThreadCalculation(t *testing.T, expectedNumThreads int, expectedMaxThre func testThreadCalculationError(t *testing.T, o *opt) { t.Helper() - _, err := calculateMaxThreads(o) + _, _, err := calculateMaxThreads(o) assert.Error(t, err, "configuration must error") } @@ -393,7 +387,8 @@ func TestContextAndLoggerMustNotBeNil(t *testing.T) { assert.NotNil(t, log, "logger is defined if all threads are inactive") assert.NotNil(t, ctx, "context is defined if all threads are inactive") - fc := newContextFromMessage(nil, nil, nil, &worker{}) + // a worker always belongs to a server, see newWorker() + fc := newContextFromMessage(nil, nil, nil, &worker{server: fallbackServer}) assert.NotNil(t, fc.logger, "logger is defined for message context") assert.NotNil(t, fc.ctx, "context is defined for message context") @@ -402,7 +397,7 @@ func TestContextAndLoggerMustNotBeNil(t *testing.T) { assert.NotNil(t, fc.logger, "logger is defined for request context") assert.NotNil(t, fc.ctx, "context is defined for request context") - fc, _ = newWorkerDummyContext(&worker{}) + fc, _ = newWorkerDummyContext(&worker{server: fallbackServer}) assert.NotNil(t, fc.logger, "logger is defined for worker dummy context") assert.NotNil(t, fc.ctx, "context is defined for worker dummy context") } diff --git a/phpthread.go b/phpthread.go index 6a285e0452..2dc47ccf73 100644 --- a/phpthread.go +++ b/phpthread.go @@ -39,11 +39,8 @@ type threadHandler interface { beforeScriptExecution() string afterScriptExecution(exitStatus int) frankenPHPContext() *frankenPHPContext - // drain is a hook called by drainWorkerThreads right before drainChan is - // closed. Handlers that need to wake up a thread parked in a blocking C - // call (e.g. by closing a stop pipe) plug their signal in here. All - // current handlers are no-ops; this is the seam later handler types use - // without having to modify drainWorkerThreads. + // called right before drainChan is closed, for the handlers that have a + // thread parked in a blocking C call to wake up; a no-op for the others drain() } @@ -123,7 +120,7 @@ func (thread *phpThread) shutdown() { return } - close(thread.drainChan) + thread.drain() thread.waitForExit(state.Done) @@ -168,7 +165,7 @@ func (thread *phpThread) setHandler(handler threadHandler) { return } - close(thread.drainChan) + thread.drain() thread.state.WaitFor(state.TransitionInProgress) thread.handler = handler @@ -176,6 +173,14 @@ func (thread *phpThread) setHandler(handler threadHandler) { thread.state.Set(state.TransitionComplete) } +// drain tells the handler to yield: drainChan wakes it up from a Go wait, the +// handler hook from a blocking C call, so a thread parked either way leaves +// without waiting for the force-kill +func (thread *phpThread) drain() { + thread.handler.drain() + close(thread.drainChan) +} + // transition to a new handler safely // is triggered by setHandler and executed on the PHP thread func (thread *phpThread) transitionToNewHandler() string { diff --git a/requestoptions.go b/requestoptions.go index 962727562f..d424d20b16 100644 --- a/requestoptions.go +++ b/requestoptions.go @@ -2,6 +2,7 @@ package frankenphp import ( "errors" + "fmt" "log/slog" "net/http" "path/filepath" @@ -205,12 +206,31 @@ func WithRequestBodyTimeout(timeout time.Duration) RequestOption { } } +// withWorker dispatches the request on a worker directly, no lookup +func withWorker(w *worker) RequestOption { + return func(o *frankenPHPContext) error { + o.worker = w + + return nil + } +} + // WithWorkerName sets the worker that should handle the request +// the name is resolved among the workers of the request's server first, then among global workers func WithWorkerName(name string) RequestOption { return func(o *frankenPHPContext) error { - if name != "" { - o.worker = workersByName[name] + if name == "" { + return nil + } + + w := o.server.workersByName[name] + if w == nil { + w = fallbackServer.workersByName[name] + } + if w != nil && w.isBackgroundWorker { + return fmt.Errorf("background worker %q cannot handle requests", name) } + o.worker = w return nil } diff --git a/scaling.go b/scaling.go index dd21a7e37c..c26465efbd 100644 --- a/scaling.go +++ b/scaling.go @@ -96,7 +96,7 @@ func scaleWorkerThread(worker *worker, done chan struct{}, mstate *state.ThreadS thread, err := addWorkerThread(worker) if err != nil { if globalLogger.Enabled(globalCtx, slog.LevelWarn) { - globalLogger.LogAttrs(globalCtx, slog.LevelWarn, "could not increase max_threads, consider raising this limit", slog.String("worker", worker.name), slog.Any("error", err)) + globalLogger.LogAttrs(globalCtx, slog.LevelWarn, "could not increase max_threads, consider raising this limit", slog.String("worker", worker.qualifiedName), slog.Any("error", err)) } return @@ -105,7 +105,7 @@ func scaleWorkerThread(worker *worker, done chan struct{}, mstate *state.ThreadS autoScaledThreads = append(autoScaledThreads, thread) if globalLogger.Enabled(globalCtx, slog.LevelInfo) { - globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "upscaling worker thread", slog.String("worker", worker.name), slog.Int("thread", thread.threadIndex), slog.Int("num_threads", len(autoScaledThreads))) + globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "upscaling worker thread", slog.String("worker", worker.qualifiedName), slog.Int("thread", thread.threadIndex), slog.Int("num_threads", len(autoScaledThreads))) } } @@ -177,7 +177,7 @@ func startUpscalingThreads(maxScaledThreads int, scale chan *frankenPHPContext, // check for max worker threads here again in case requests overflowed while waiting if fc.worker.isAtThreadLimit() { if globalLogger.Enabled(globalCtx, slog.LevelInfo) { - globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "cannot scale worker thread, max threads reached for worker", slog.String("worker", fc.worker.name)) + globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "cannot scale worker thread, max threads reached for worker", slog.String("worker", fc.worker.qualifiedName)) } continue diff --git a/scaling_test.go b/scaling_test.go index 9a6d12da6d..af198e3b54 100644 --- a/scaling_test.go +++ b/scaling_test.go @@ -49,7 +49,7 @@ func TestScaleAWorkerThreadUpAndDown(t *testing.T) { autoScaledThread := phpThreads[2] // scale up - scaleWorkerThread(globalWorkersByPath[workerPath], mainThread.done, mainThread.state) + scaleWorkerThread(fallbackServer.workersByPath[workerPath], mainThread.done, mainThread.state) assert.Equal(t, state.Ready, autoScaledThread.state.Get()) // on down-scale, the thread will be marked as inactive diff --git a/server.go b/server.go index e757ab37f6..3de9c72123 100644 --- a/server.go +++ b/server.go @@ -20,7 +20,7 @@ type Server struct { root string splitPath []string env PreparedEnv - workers []*worker + workersByName map[string]*worker workersByPath map[string]*worker workersWithRequestMatcher []*worker @@ -35,13 +35,15 @@ var ( fallbackServer = newFallbackServer() ) +// newFallbackServer creates the server of requests and workers that are not +// scoped to one, so a lookup is always a lookup in a server func newFallbackServer() *Server { s := &Server{ - idx: -1, - workersByPath: make(map[string]*worker), - env: make(map[string]string), - logger: globalLogger, + idx: -1, + env: make(map[string]string), + logger: globalLogger, } + s.resetWorkers() return s } @@ -54,12 +56,35 @@ func registerServers(newServers []*Server) { fallbackServer.logger = globalLogger fallbackServer.resetWorkers() + // the name qualifies worker names in metrics and logs, so it must be + // unique: the first server keeps its name, the next ones get a numeric + // suffix that never takes a name another server configured + configured := make(map[string]struct{}, len(servers)) + for _, s := range servers { + if s.configuredName != "" { + configured[s.configuredName] = struct{}{} + } + } + + taken := make(map[string]struct{}, len(servers)) for i, s := range servers { s.idx = i - s.name = s.configuredName - if s.name == "" { - s.name = "server_" + strconv.Itoa(i) + name := s.configuredName + if name == "" { + name = "server_" + strconv.Itoa(i) } + + for base, n := name, 1; ; n++ { + _, isTaken := taken[name] + _, isConfigured := configured[name] + if !isTaken && (!isConfigured || name == s.configuredName) { + break + } + name = base + "_" + strconv.Itoa(n) + } + taken[name] = struct{}{} + + s.name = name s.resetWorkers() } } @@ -83,7 +108,7 @@ func unregisterServers() { // resetWorkers drops the workers of a previous run; initWorkers() adds them back func (s *Server) resetWorkers() { - s.workers = nil + s.workersByName = make(map[string]*worker) s.workersByPath = make(map[string]*worker) s.workersWithRequestMatcher = nil } @@ -99,9 +124,9 @@ func NewServer(root string, options ...ServerOption) (*Server, error) { } s := &Server{ - root: root, - workersByPath: make(map[string]*worker), + root: root, } + s.resetWorkers() for _, option := range options { if err := option(s); err != nil { @@ -125,20 +150,37 @@ func NewServer(root string, options ...ServerOption) (*Server, error) { } // Name returns the human-readable name of the server. -// It is empty until registration if none was passed to NewServer(). +// It is empty until registration if none was passed to NewServer(), and gets +// a numeric suffix if another registered server has the same name. func (s *Server) Name() string { return s.name } +// addWorker registers a worker scoped to this server func (s *Server) addWorker(w *worker) error { - s.workers = append(s.workers, w) + // the fallback server holds the workers declared without a scope + scope := "two workers in a server" + if s == fallbackServer { + scope = "two global workers" + } + + if s.workersByName[w.name] != nil { + return fmt.Errorf("%s cannot have the same name: %q", scope, w.name) + } + s.workersByName[w.name] = w + + // background workers never serve requests, so they are not matched at all + if w.isBackgroundWorker { + return nil + } + if w.matchRequest != nil { s.workersWithRequestMatcher = append(s.workersWithRequestMatcher, w) return nil } - if _, exists := s.workersByPath[w.fileName]; exists { - return fmt.Errorf("two workers in a server cannot have the same filename: %q", w.fileName) + if s.workersByPath[w.fileName] != nil { + return fmt.Errorf("%s cannot have the same filename: %q", scope, w.fileName) } s.workersByPath[w.fileName] = w // a document root whose symlinks are kept yields the declared path, not the resolved one diff --git a/server_test.go b/server_test.go index 3ad2032bbe..5f75d20667 100644 --- a/server_test.go +++ b/server_test.go @@ -59,12 +59,86 @@ func TestServer(t *testing.T) { t.Run("name", func(t *testing.T) { named, _ := frankenphp.NewServer(testDataDir, frankenphp.WithServerName("api")) unnamed, _ := frankenphp.NewServer(testDataDir) + alsoNamed, _ := frankenphp.NewServer(testDataDir, frankenphp.WithServerName("api")) + explicit, _ := frankenphp.NewServer(testDataDir, frankenphp.WithServerName("api_1")) - initServers(t, frankenphp.WithServer(named), frankenphp.WithServer(unnamed)) + initServers(t, frankenphp.WithServer(named), frankenphp.WithServer(unnamed), frankenphp.WithServer(alsoNamed), frankenphp.WithServer(explicit)) assert.Equal(t, "api", named.Name()) // an empty name defaults to the server index at registration assert.Equal(t, "server_1", unnamed.Name()) + // names qualify worker names in metrics, so they are made unique, and + // a generated suffix never takes a name another server configured + assert.Equal(t, "api_2", alsoNamed.Name()) + assert.Equal(t, "api_1", explicit.Name()) + }) + + t.Run("same_worker_name_in_two_servers", func(t *testing.T) { + server1, _ := frankenphp.NewServer(testDataDir, frankenphp.WithServerName("one")) + server2, _ := frankenphp.NewServer(testDataDir, frankenphp.WithServerName("two")) + initServers( + t, + frankenphp.WithServer(server1), + frankenphp.WithServer(server2), + frankenphp.WithWorkers("counter", testDataDir+"worker-with-counter.php", 1, frankenphp.WithWorkerServerScope(server1)), + frankenphp.WithWorkers("counter", testDataDir+"worker-with-counter.php", 1, frankenphp.WithWorkerServerScope(server2)), + ) + + // WithWorkerName resolves the name within the request's server + byName := func(server *frankenphp.Server) string { + t.Helper() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "http://example.com/index.php", nil) + require.NoError(t, server.ServeHTTP(w, req, frankenphp.WithWorkerName("counter"))) + body, err := io.ReadAll(w.Result().Body) + require.NoError(t, err) + + return string(body) + } + + assert.Equal(t, "requests:1", byName(server1)) + assert.Equal(t, "requests:1", byName(server2), "server 2 must get its own worker, not server 1's") + assert.Equal(t, "requests:2", byName(server1)) + }) + + t.Run("two_workers_may_share_a_script_when_matchers_tell_them_apart", func(t *testing.T) { + // the documented way to give slow endpoints their own thread pool, + // see docs/performance.md; neither worker is named, so the names + // generated from their shared script must not collide + t.Cleanup(frankenphp.Shutdown) + + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + require.NoError(t, frankenphp.Init( + frankenphp.WithServer(server), + frankenphp.WithWorkers("", testDataDir+"worker-with-counter.php", 1, + frankenphp.WithWorkerServerScope(server), + frankenphp.WithWorkerMatcher(func(r *http.Request) bool { return strings.HasPrefix(r.URL.Path, "/slow/") }), + ), + frankenphp.WithWorkers("", testDataDir+"worker-with-counter.php", 1, + frankenphp.WithWorkerServerScope(server), + frankenphp.WithWorkerMatcher(func(*http.Request) bool { return true }), + ), + frankenphp.WithNumThreads(3), + )) + + // each pool counts the requests its matcher took + assert.Equal(t, "requests:1", serverGet(t, server, "http://example.com/slow/one")) + assert.Equal(t, "requests:2", serverGet(t, server, "http://example.com/slow/two")) + assert.Equal(t, "requests:1", serverGet(t, server, "http://example.com/index.php")) + }) + + t.Run("error_on_duplicate_worker_names", func(t *testing.T) { + t.Cleanup(frankenphp.Shutdown) + + server, _ := frankenphp.NewServer(testDataDir) + err := frankenphp.Init( + frankenphp.WithServer(server), + frankenphp.WithWorkers("same", testDataDir+"worker-with-counter.php", 1, frankenphp.WithWorkerServerScope(server)), + frankenphp.WithWorkers("same", testDataDir+"index.php", 1, frankenphp.WithWorkerServerScope(server)), + ) + + assert.ErrorContains(t, err, "two workers in a server cannot have the same name") }) t.Run("root", func(t *testing.T) { diff --git a/testdata/_executor.php b/testdata/_executor.php index 61a5319f11..31b87c79cb 100644 --- a/testdata/_executor.php +++ b/testdata/_executor.php @@ -1,7 +1,7 @@ getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/count.php b/testdata/bgworker/count.php new file mode 100644 index 0000000000..cb7d1e2f86 --- /dev/null +++ b/testdata/bgworker/count.php @@ -0,0 +1,15 @@ +getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/crash-after-ready.php b/testdata/bgworker/crash-after-ready.php new file mode 100644 index 0000000000..633f7af61c --- /dev/null +++ b/testdata/bgworker/crash-after-ready.php @@ -0,0 +1,9 @@ +tick(); +exit(1); diff --git a/testdata/bgworker/crash.php b/testdata/bgworker/crash.php new file mode 100644 index 0000000000..30eb27d664 --- /dev/null +++ b/testdata/bgworker/crash.php @@ -0,0 +1,31 @@ +getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/cycle.php b/testdata/bgworker/cycle.php new file mode 100644 index 0000000000..a171626ea6 --- /dev/null +++ b/testdata/bgworker/cycle.php @@ -0,0 +1,8 @@ +tick(); +usleep(150000); diff --git a/testdata/bgworker/early-return.php b/testdata/bgworker/early-return.php new file mode 100644 index 0000000000..590cd686ff --- /dev/null +++ b/testdata/bgworker/early-return.php @@ -0,0 +1,5 @@ +tick(); diff --git a/testdata/bgworker/fail-then-succeed.php b/testdata/bgworker/fail-then-succeed.php new file mode 100644 index 0000000000..6897a237e0 --- /dev/null +++ b/testdata/bgworker/fail-then-succeed.php @@ -0,0 +1,18 @@ +getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/fetch-no-tick.php b/testdata/bgworker/fetch-no-tick.php new file mode 100644 index 0000000000..92c7e8c639 --- /dev/null +++ b/testdata/bgworker/fetch-no-tick.php @@ -0,0 +1,7 @@ + $_SERVER['FRANKENPHP_WORKER'] ?? 'unset', + 'background' => $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] ?? 'unset', +], true)); +$handle = new \FrankenPHP\WorkerHandle(); +$stream = $handle->getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/loop.php b/testdata/bgworker/loop.php new file mode 100644 index 0000000000..8e9c8d9b12 --- /dev/null +++ b/testdata/bgworker/loop.php @@ -0,0 +1,16 @@ +getStream(); +do { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} while ($handle->tick()); diff --git a/testdata/bgworker/named.php b/testdata/bgworker/named.php new file mode 100644 index 0000000000..d3a72355c1 --- /dev/null +++ b/testdata/bgworker/named.php @@ -0,0 +1,21 @@ +getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/never-ticks.php b/testdata/bgworker/never-ticks.php new file mode 100644 index 0000000000..a36c990c4b --- /dev/null +++ b/testdata/bgworker/never-ticks.php @@ -0,0 +1,10 @@ +getStream(); +fgets($stream); +fgets($stream); diff --git a/testdata/bgworker/no-time-limit.php b/testdata/bgworker/no-time-limit.php new file mode 100644 index 0000000000..3585f8a19f --- /dev/null +++ b/testdata/bgworker/no-time-limit.php @@ -0,0 +1,12 @@ +tick(); +fgets($handle->getStream()); diff --git a/testdata/bgworker/poll-interface.php b/testdata/bgworker/poll-interface.php new file mode 100644 index 0000000000..917ea897f1 --- /dev/null +++ b/testdata/bgworker/poll-interface.php @@ -0,0 +1,16 @@ + $handle instanceof \Io\Poll\Handle, + 'internal' => (new ReflectionClass(\Io\Poll\Handle::class))->isInternal(), +])); +$stream = $handle->getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/poll.php b/testdata/bgworker/poll.php new file mode 100644 index 0000000000..ee8d50a8a5 --- /dev/null +++ b/testdata/bgworker/poll.php @@ -0,0 +1,14 @@ +add($handle, [\Io\Poll\Event::Read]); + +while ($handle->tick()) { + file_put_contents($_SERVER['BG_SENTINEL'], $poll->getBackend()->name); + $poll->wait(); +} diff --git a/testdata/bgworker/pool.php b/testdata/bgworker/pool.php new file mode 100644 index 0000000000..d9d53024ef --- /dev/null +++ b/testdata/bgworker/pool.php @@ -0,0 +1,13 @@ + 1 threads share the name; each touches a file of +// its own under BG_SENTINEL_DIR, then parks on its own handle. +set_time_limit(0); +@touch($_SERVER['BG_SENTINEL_DIR'] . DIRECTORY_SEPARATOR . bin2hex(random_bytes(8))); +$handle = new \FrankenPHP\WorkerHandle(); +$stream = $handle->getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/read.php b/testdata/bgworker/read.php new file mode 100644 index 0000000000..f90affa1f1 --- /dev/null +++ b/testdata/bgworker/read.php @@ -0,0 +1,11 @@ +tick(); +fgets($handle->getStream()); diff --git a/testdata/bgworker/readable.php b/testdata/bgworker/readable.php new file mode 100644 index 0000000000..86db0d0761 --- /dev/null +++ b/testdata/bgworker/readable.php @@ -0,0 +1,26 @@ +getStream(); +$poll = static function () use ($stream): string { + $read = [$stream]; + $write = $except = null; + + return stream_select($read, $write, $except, 0) > 0 ? 'readable' : 'quiet'; +}; + +$seen = ['start:' . $poll()]; +$handle->tick(); +$seen[] = 'after tick:' . $poll(); +$handle->tick(); +$seen[] = 'after second tick:' . $poll(); +file_put_contents($_SERVER['BG_SENTINEL'], implode(' ', $seen)); + +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/recv.php b/testdata/bgworker/recv.php new file mode 100644 index 0000000000..048066c6a7 --- /dev/null +++ b/testdata/bgworker/recv.php @@ -0,0 +1,12 @@ +tick(); +stream_socket_recvfrom($handle->getStream(), 1); diff --git a/testdata/bgworker/refetch.php b/testdata/bgworker/refetch.php new file mode 100644 index 0000000000..a0948e4e44 --- /dev/null +++ b/testdata/bgworker/refetch.php @@ -0,0 +1,21 @@ +getStream(); +$again = $handle->getStream(); +$result = $first === $again ? 'same' : 'different'; + +fclose($first); +$third = $handle->getStream(); +$result .= $third === $first ? ' then same' : ' then fresh'; + +$other = (new \FrankenPHP\WorkerHandle())->getStream(); +$result .= $other === $third ? ' then shared' : ' then its own'; + +file_put_contents($_SERVER['BG_SENTINEL'], $result); +$handle->tick(); +fgets($third); diff --git a/testdata/bgworker/slow-boot.php b/testdata/bgworker/slow-boot.php new file mode 100644 index 0000000000..56fc7b4a9c --- /dev/null +++ b/testdata/bgworker/slow-boot.php @@ -0,0 +1,11 @@ +tick(); diff --git a/testdata/bgworker/stuck.php b/testdata/bgworker/stuck.php new file mode 100644 index 0000000000..578986908d --- /dev/null +++ b/testdata/bgworker/stuck.php @@ -0,0 +1,19 @@ +tick(); +if (!file_exists($_SERVER['BG_ONCE'])) { + @touch($_SERVER['BG_ONCE']); + sleep(60); +} +@touch($_SERVER['BG_SENTINEL']); +$stream = $handle->getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/tick.php b/testdata/bgworker/tick.php new file mode 100644 index 0000000000..ed9f24d02e --- /dev/null +++ b/testdata/bgworker/tick.php @@ -0,0 +1,17 @@ +tick() ? 'true' : 'false'; +$seen[] = $handle->tick() ? 'true' : 'false'; +file_put_contents($_SERVER['BG_SENTINEL'], implode(' ', $seen)); + +$read = [$handle->getStream()]; +$write = $except = null; +stream_select($read, $write, $except, null); + +$seen[] = $handle->tick() ? 'true' : 'false'; +$seen[] = $handle->tick() ? 'true' : 'false'; +file_put_contents($_SERVER['BG_SENTINEL'], implode(' ', $seen)); diff --git a/testdata/handle-bypass.php b/testdata/handle-bypass.php new file mode 100644 index 0000000000..93dea76c33 --- /dev/null +++ b/testdata/handle-bypass.php @@ -0,0 +1,18 @@ +getMessage(), "\n"; + } +}; + +$report('unserialize', static fn () => unserialize('O:23:"FrankenPHP\WorkerHandle":0:{}')); +$report('reflection', static fn () => (new ReflectionClass(\FrankenPHP\WorkerHandle::class))->newInstanceWithoutConstructor()); +$report('construct', static fn () => new \FrankenPHP\WorkerHandle()); diff --git a/testdata/handle-outside.php b/testdata/handle-outside.php new file mode 100644 index 0000000000..12f6141aa7 --- /dev/null +++ b/testdata/handle-outside.php @@ -0,0 +1,8 @@ +getMessage(); +} diff --git a/testdata/symlinks/test/index.php b/testdata/symlinks/test/index.php index 15aa1a9cf1..9037dbe762 100644 --- a/testdata/symlinks/test/index.php +++ b/testdata/symlinks/test/index.php @@ -1,7 +1,7 @@ = 0 { + C.frankenphp_worker_close_stop_sock(C.intptr_t(s)) + } +} + +func (handler *backgroundWorkerThread) beforeScriptExecution() string { + return handler.workerLifecycle.beforeScriptExecution(handler.startScript) +} + +// startScript keeps trying to start the script: unlike an HTTP worker, whose +// setup cannot fail, a background worker needs a socket pair per run +func (handler *backgroundWorkerThread) startScript() string { + for { + err := handler.setupScript() + if err == nil { + return handler.worker.fileName + } + + if globalLogger.Enabled(globalCtx, slog.LevelError) { + globalLogger.LogAttrs(globalCtx, slog.LevelError, "failed to start background worker", slog.String("worker", handler.worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex), slog.Any("error", err)) + } + + // fail fast during startup so Init() surfaces the error to the + // operator; past startup, back off and retry like a crash + if reportStartupFailure(err) { + handler.thread.state.Set(state.ShuttingDown) + + return handler.beforeScriptExecution() + } + + handler.backoff() + if !handler.state.Is(state.Ready) && !handler.state.Is(state.TransitionComplete) { + // drained during the backoff (shutdown, reboot, transition) + return handler.beforeScriptExecution() + } + } +} + +// setupScript marks the thread as a background worker on the C side and takes +// ownership of the Go side's end of its stop socket pair. +func (handler *backgroundWorkerThread) setupScript() error { + s := int64(C.frankenphp_set_background_worker_and_get_stop_sock()) + if s < 0 { + return fmt.Errorf("failed to create the stop socket pair of background worker %q", handler.worker.qualifiedName) + } + handler.stopSock.Store(s) + + switch handler.state.Get() { + case state.ShuttingDown, state.Rebooting, state.ForceRebooting, state.TransitionRequested: + // a concurrent drain may have run before the socket was published; + // close it now so the script observes EOF immediately + handler.drain() + } + + fc, err := newWorkerDummyContext(handler.worker) + if err != nil { + handler.drain() + return err + } + handler.thread.contextMu.Lock() + handler.context = fc + handler.thread.contextMu.Unlock() + + handler.isBootingScript = true + handler.runStartedAt = time.Now() + metrics.StartWorker(handler.worker.name, handler.worker.server.name) + // the run's logger and context, not the globals: a shutdown finishing + // meanwhile resets those, and Stop() does not wait for this callback + logger, ctx, name, threadIndex := fc.logger, fc.ctx, handler.worker.qualifiedName, handler.thread.threadIndex + handler.bootTimer = time.AfterFunc(backgroundBootWarnDelay, func() { + if logger.Enabled(ctx, slog.LevelWarn) { + logger.LogAttrs(ctx, slog.LevelWarn, "background worker has not called WorkerHandle::tick() yet, Init() and Shutdown() wait for it", slog.String("worker", name), slog.Int("thread", threadIndex)) + } + }) + + if fc.logger.Enabled(fc.ctx, slog.LevelDebug) { + fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "starting background worker", slog.String("worker", handler.worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex)) + } + + // the thread stays in TransitionComplete until the script calls + // WorkerHandle::tick(), see go_frankenphp_background_worker_ready + + return nil +} + +func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { + // release the socket on every exit path so the next run gets a fresh + // pair; drain() already took it when the exit was drain-triggered + handler.drain() + worker := handler.worker + handler.thread.contextMu.Lock() + handler.context = nil + handler.thread.contextMu.Unlock() + + handler.stopBootTimer() + handler.state.MarkAsWaiting(false) + + // exit past the ready point, cooperative or a crash: re-run the script + // without counting toward max_consecutive_failures, that cap is about a + // script that never boots. An HTTP worker is paced by traffic, a + // background one reaches its ready point on its own, so pace it here + if !handler.isBootingScript { + if exitStatus == 0 { + metrics.StopWorker(worker.name, worker.server.name, StopReasonRestart) + + if globalLogger.Enabled(globalCtx, slog.LevelDebug) { + globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "restarting background worker", slog.String("worker", worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex)) + } + } else { + metrics.StopWorker(worker.name, worker.server.name, StopReasonCrash) + + if globalLogger.Enabled(globalCtx, slog.LevelWarn) { + globalLogger.LogAttrs(globalCtx, slog.LevelWarn, "background worker crashed, restarting", slog.String("worker", worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex), slog.Int("exit_status", exitStatus), slog.Int("crashes", handler.crashCount)) + } + } + + // a worker processing a batch and returning is not a worker spinning + // on an immediate exit; a crash never resets the count, however long + // the run lasted + if exitStatus == 0 && time.Since(handler.runStartedAt) > minHealthyRun { + handler.crashCount = 0 + } + handler.wait(restartBackoff(handler.crashCount)) + handler.crashCount++ + + return + } + + // the script exited before calling WorkerHandle::tick(), a clean exit + // included; StopReasonBootFailure skips the ready-gauge decrement, + // matching the ReadyWorker call that never happened + metrics.StopWorker(worker.name, worker.server.name, StopReasonBootFailure) + + // max_consecutive_failures only fails hard during startup, through + // startupFailChan; past it a failing worker keeps restarting with a + // louder log line rather than leave the server in a broken half-state. + // A worker Init() gave up on stops here whatever the cap says, it was + // drained precisely so this path could end the thread + pastCap := worker.maxConsecutiveFailures >= 0 && handler.failureCount >= worker.maxConsecutiveFailures + if worker.bootTimedOut.Load() { + reportStartupFailure(fmt.Errorf("background worker %s did not call WorkerHandle::tick() in time", worker.fileName)) + handler.thread.state.Set(state.ShuttingDown) + + return + } + if pastCap && !watcherIsEnabled { + var err error + if exitStatus == 0 { + err = fmt.Errorf("background worker %s exits without calling WorkerHandle::tick()", worker.fileName) + } else { + err = fmt.Errorf("too many consecutive failures: background worker %s keeps crashing", worker.fileName) + } + if reportStartupFailure(err) { + handler.thread.state.Set(state.ShuttingDown) + return + } + } + + logLevel := slog.LevelWarn + logMsg := "background worker failed before calling WorkerHandle::tick(), restarting" + if exitStatus == 0 { + logMsg = "background worker exited without calling WorkerHandle::tick(), restarting" + } + if pastCap { + logLevel = slog.LevelError + logMsg = "background worker exceeded max_consecutive_failures, still restarting" + } + if globalLogger.Enabled(globalCtx, logLevel) { + globalLogger.LogAttrs(globalCtx, logLevel, logMsg, slog.String("worker", worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex), slog.Int("failures", handler.failureCount), slog.Int("exit_status", exitStatus)) + } + + handler.backoff() +} + +func (handler *backgroundWorkerThread) stopBootTimer() { + if handler.bootTimer != nil { + handler.bootTimer.Stop() + handler.bootTimer = nil + } +} + +//export go_frankenphp_background_worker_ready +func go_frankenphp_background_worker_ready(threadIndex C.uintptr_t) { + // called on the PHP thread by the first WorkerHandle::tick() of a run; + // that function throws on every other thread kind + handler, ok := phpThreads[threadIndex].handler.(*backgroundWorkerThread) + if !ok { + panic("WorkerHandle::tick() called on a thread that is not a background worker") + } + + if handler.isBootingScript { + handler.isBootingScript = false + // the boot succeeded, only consecutive boot failures count + handler.failureCount = 0 + handler.stopBootTimer() + metrics.ReadyWorker(handler.worker.name, handler.worker.server.name) + // parked from now on as far as the threads state endpoint is concerned + handler.state.MarkAsWaiting(true) + + // initWorkers() waits for this state, so a script that fails before + // its first tick still fails Init() + if handler.state.Is(state.TransitionComplete) { + handler.state.Set(state.Ready) + } + } +} + +// backoff waits before the next run of a crashed script, see restartBackoff +func (handler *backgroundWorkerThread) backoff() { + handler.wait(restartBackoff(handler.failureCount)) + handler.failureCount++ +} + +// wait sleeps between two runs, cut short by a drain, which the next +// beforeScriptExecution() picks up from the state +func (handler *backgroundWorkerThread) wait(d time.Duration) { + select { + case <-handler.thread.drainChan: + case <-time.After(d): + } +} diff --git a/threadworker.go b/threadworker.go index 8a308b4d87..b60e18cd47 100644 --- a/threadworker.go +++ b/threadworker.go @@ -15,9 +15,8 @@ import ( // executes the PHP worker script in a loop // implements the threadHandler interface type workerThread struct { - state *state.ThreadState - thread *phpThread - worker *worker + workerLifecycle + dummyFrankenPHPContext *frankenPHPContext workerFrankenPHPContext *frankenPHPContext isBootingScript bool // true if the worker has not reached frankenphp_handle_request yet @@ -26,49 +25,19 @@ type workerThread struct { } func convertToWorkerThread(thread *phpThread, worker *worker) { - thread.setHandler(&workerThread{ - state: thread.state, - thread: thread, - worker: worker, - }) + thread.setHandler(&workerThread{workerLifecycle: newWorkerLifecycle(thread, worker)}) worker.attachThread(thread) } -// beforeScriptExecution returns the name of the script or an empty string on shutdown func (handler *workerThread) beforeScriptExecution() string { - switch handler.state.Get() { - case state.TransitionRequested: - if handler.worker.onThreadShutdown != nil { - handler.worker.onThreadShutdown(handler.thread.threadIndex) - } - handler.worker.detachThread(handler.thread) - return handler.thread.transitionToNewHandler() - case state.Ready, state.TransitionComplete: - handler.thread.updateContext(true) - if handler.worker.onThreadReady != nil { - handler.worker.onThreadReady(handler.thread.threadIndex) - } - - setupWorkerScript(handler, handler.worker) + return handler.workerLifecycle.beforeScriptExecution(handler.startScript) +} - return handler.worker.fileName - case state.Rebooting, state.ForceRebooting: - return "" - case state.RebootReady: - handler.requestCount = 0 - handler.state.Set(state.Ready) - return handler.beforeScriptExecution() - case state.ShuttingDown: - if handler.worker.onThreadShutdown != nil { - handler.worker.onThreadShutdown(handler.thread.threadIndex) - } - handler.worker.detachThread(handler.thread) +// startScript runs the worker script; it always has one to run +func (handler *workerThread) startScript() string { + setupWorkerScript(handler, handler.worker) - // signal to stop - return "" - default: - panic("unexpected state: " + handler.state.Name()) - } + return handler.worker.fileName } func (handler *workerThread) afterScriptExecution(exitStatus int) { @@ -90,7 +59,7 @@ func (handler *workerThread) name() string { func (handler *workerThread) drain() {} func setupWorkerScript(handler *workerThread, worker *worker) { - metrics.StartWorker(worker.name) + metrics.StartWorker(worker.name, worker.server.name) // Create a dummy request to set up the worker fc, err := newWorkerDummyContext(worker) @@ -103,7 +72,7 @@ func setupWorkerScript(handler *workerThread, worker *worker) { handler.requestCount = 0 if fc.logger.Enabled(fc.ctx, slog.LevelDebug) { - fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "starting", slog.String("worker", worker.name), slog.Int("thread", handler.thread.threadIndex)) + fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "starting", slog.String("worker", worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex)) } } @@ -122,10 +91,10 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { // on exit status 0 we just run the worker script again if exitStatus == 0 && !handler.isBootingScript { - metrics.StopWorker(worker.name, StopReasonRestart) + metrics.StopWorker(worker.name, worker.server.name, StopReasonRestart) if globalLogger.Enabled(globalCtx, slog.LevelDebug) { - globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "restarting", slog.String("worker", worker.name), slog.Int("thread", handler.thread.threadIndex), slog.Int("exit_status", exitStatus)) + globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "restarting", slog.String("worker", worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex), slog.Int("exit_status", exitStatus)) } return @@ -133,9 +102,9 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { // worker has thrown a fatal error or has not reached frankenphp_handle_request if handler.isBootingScript { - metrics.StopWorker(worker.name, StopReasonBootFailure) + metrics.StopWorker(worker.name, worker.server.name, StopReasonBootFailure) } else { - metrics.StopWorker(worker.name, StopReasonCrash) + metrics.StopWorker(worker.name, worker.server.name, StopReasonCrash) } if !handler.isBootingScript { @@ -143,14 +112,14 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { // unlike a clean restart, this took down any in-flight request, so // surface it above debug level, with the exit status needed to triage it if globalLogger.Enabled(globalCtx, slog.LevelWarn) { - globalLogger.LogAttrs(globalCtx, slog.LevelWarn, "unexpected termination, restarting", slog.String("worker", worker.name), slog.Int("thread", handler.thread.threadIndex), slog.Int("exit_status", exitStatus)) + globalLogger.LogAttrs(globalCtx, slog.LevelWarn, "unexpected termination, restarting", slog.String("worker", worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex), slog.Int("exit_status", exitStatus)) } return } - if worker.maxConsecutiveFailures >= 0 && startupFailChan != nil && !watcherIsEnabled && handler.failureCount >= worker.maxConsecutiveFailures { - startupFailChan <- fmt.Errorf("too many consecutive failures: worker %s has not reached frankenphp_handle_request()", worker.fileName) + if worker.maxConsecutiveFailures >= 0 && !watcherIsEnabled && handler.failureCount >= worker.maxConsecutiveFailures && + reportStartupFailure(fmt.Errorf("too many consecutive failures: worker %s has not reached frankenphp_handle_request()", worker.fileName)) { handler.thread.state.Set(state.ShuttingDown) return } @@ -158,23 +127,39 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { if watcherIsEnabled { // worker script has probably failed due to script changes while watcher is enabled if globalLogger.Enabled(globalCtx, slog.LevelError) { - globalLogger.LogAttrs(globalCtx, slog.LevelError, "(watcher enabled) worker script has not reached frankenphp_handle_request()", slog.String("worker", worker.name), slog.Int("thread", handler.thread.threadIndex)) + globalLogger.LogAttrs(globalCtx, slog.LevelError, "(watcher enabled) worker script has not reached frankenphp_handle_request()", slog.String("worker", worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex)) } } else { // rare case where worker script has failed on a restart during normal operation // this can happen if startup success depends on external resources if globalLogger.Enabled(globalCtx, slog.LevelWarn) { - globalLogger.LogAttrs(globalCtx, slog.LevelWarn, "worker script has failed on restart", slog.String("worker", worker.name), slog.Int("thread", handler.thread.threadIndex), slog.Int("failures", handler.failureCount)) + globalLogger.LogAttrs(globalCtx, slog.LevelWarn, "worker script has failed on restart", slog.String("worker", worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex), slog.Int("failures", handler.failureCount)) } } - // wait a bit and try again (exponential backoff) - backoffDuration := time.Duration(handler.failureCount*handler.failureCount*100) * time.Millisecond - if backoffDuration > time.Second { - backoffDuration = time.Second - } + // wait a bit and try again + time.Sleep(restartBackoff(handler.failureCount)) handler.failureCount++ - time.Sleep(backoffDuration) +} + +// maxRestartBackoff is the longest wait between two runs of a failing script +const maxRestartBackoff = time.Second + +// minHealthyRun is how long a run has to last to count as one that did +// something: a shorter one paces the next, see restartBackoff +const minHealthyRun = 100 * time.Millisecond + +// restartBackoff is the wait before a worker script is re-run after a +// failure: quadratic in the number of consecutive failures, capped at one +// second; shared by HTTP and background workers. The cap comes before the +// multiplication, which overflows a duration past some 300k failures, a +// count a crash loop reaches on its own in a few days +func restartBackoff(failures int) time.Duration { + if failures >= 4 { + return maxRestartBackoff + } + + return time.Duration(failures*failures*100) * time.Millisecond } // waitForWorkerRequest is called during frankenphp_handle_request in the php worker script. @@ -183,7 +168,7 @@ func (handler *workerThread) waitForWorkerRequest() (bool, any) { handler.thread.Unpin() if globalLogger.Enabled(globalCtx, slog.LevelDebug) { - globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "waiting for request", slog.String("worker", handler.worker.name), slog.Int("thread", handler.thread.threadIndex)) + globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "waiting for request", slog.String("worker", handler.worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex)) } // Clear the first dummy request created to initialize the worker @@ -195,14 +180,14 @@ func (handler *workerThread) waitForWorkerRequest() (bool, any) { } // worker is truly ready only after reaching frankenphp_handle_request() - metrics.ReadyWorker(handler.worker.name) + metrics.ReadyWorker(handler.worker.name, handler.worker.server.name) } // max_requests reached: signal reboot for full ZTS cleanup if maxRequestsPerThread > 0 && handler.requestCount >= maxRequestsPerThread { if globalLogger.Enabled(globalCtx, slog.LevelDebug) { globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "max requests reached, restarting", - slog.String("worker", handler.worker.name), + slog.String("worker", handler.worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex), slog.Int("max_requests", maxRequestsPerThread), ) @@ -223,7 +208,7 @@ func (handler *workerThread) waitForWorkerRequest() (bool, any) { select { case <-handler.thread.drainChan: if globalLogger.Enabled(globalCtx, slog.LevelDebug) { - globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "shutting down", slog.String("worker", handler.worker.name), slog.Int("thread", handler.thread.threadIndex)) + globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "shutting down", slog.String("worker", handler.worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex)) } return false, nil @@ -239,9 +224,9 @@ func (handler *workerThread) waitForWorkerRequest() (bool, any) { if fc.logger.Enabled(fc.ctx, slog.LevelDebug) { if handler.workerFrankenPHPContext.request == nil { - fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "request handling started", slog.String("worker", handler.worker.name), slog.Int("thread", handler.thread.threadIndex)) + fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "request handling started", slog.String("worker", handler.worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex)) } else { - fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "request handling started", slog.String("worker", handler.worker.name), slog.Int("thread", handler.thread.threadIndex), slog.String("url", handler.workerFrankenPHPContext.request.RequestURI)) + fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "request handling started", slog.String("worker", handler.worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex), slog.String("url", handler.workerFrankenPHPContext.request.RequestURI)) } } @@ -298,9 +283,9 @@ func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t, retval *C.zval if fc.logger.Enabled(fc.ctx, slog.LevelDebug) { if fc.request == nil { - fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "request handling finished", slog.String("worker", fc.worker.name), slog.Int("thread", thread.threadIndex)) + fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "request handling finished", slog.String("worker", fc.worker.qualifiedName), slog.Int("thread", thread.threadIndex)) } else { - fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "request handling finished", slog.String("worker", fc.worker.name), slog.Int("thread", thread.threadIndex), slog.String("url", fc.request.RequestURI)) + fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "request handling finished", slog.String("worker", fc.worker.qualifiedName), slog.Int("thread", thread.threadIndex), slog.String("url", fc.request.RequestURI)) } } } diff --git a/threadworker_test.go b/threadworker_test.go new file mode 100644 index 0000000000..24e5a2e410 --- /dev/null +++ b/threadworker_test.go @@ -0,0 +1,20 @@ +package frankenphp + +import ( + "math" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestRestartBackoff(t *testing.T) { + assert.Equal(t, time.Duration(0), restartBackoff(0)) + assert.Equal(t, 100*time.Millisecond, restartBackoff(1)) + assert.Equal(t, 400*time.Millisecond, restartBackoff(2)) + assert.Equal(t, 900*time.Millisecond, restartBackoff(3)) + assert.Equal(t, time.Second, restartBackoff(4)) + // a crash loop counts without bound, the quadratic must not overflow + assert.Equal(t, time.Second, restartBackoff(303701)) + assert.Equal(t, time.Second, restartBackoff(math.MaxInt32)) +} diff --git a/types_test.go b/types_test.go index 1efe1a90d9..349d8a56b7 100644 --- a/types_test.go +++ b/types_test.go @@ -18,7 +18,7 @@ func testOnDummyPHPThread(t *testing.T, test func()) { t.Helper() globalLogger = slog.Default() - _, err := initPHPThreads(1, 1, nil) // boot 1 thread + _, err := initPHPThreads(1, 1, 0, nil) // boot 1 thread assert.NoError(t, err) handler := convertToTaskThread(phpThreads[0]) diff --git a/worker.go b/worker.go index 8f1fe524f2..3861b75e6a 100644 --- a/worker.go +++ b/worker.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "sync" "sync/atomic" "time" @@ -20,7 +21,10 @@ import ( type worker struct { mercureContext - name string + // name as declared, unique within its server (or among global workers) + name string + // ":" for a server-scoped worker, the name otherwise, for logs and errors + qualifiedName string fileName string declaredFileName string matchRequest func(*http.Request) bool @@ -31,20 +35,30 @@ type worker struct { threads []*phpThread threadMutex sync.RWMutex maxConsecutiveFailures int - onThreadReady func(int) - onThreadShutdown func(int) - queuedRequests atomic.Int32 - server *Server + bootTimeout time.Duration + // Init() gave up waiting for the ready point: the run is drained and must not start again + bootTimedOut atomic.Bool + onThreadReady func(int) + onThreadShutdown func(int) + queuedRequests atomic.Int32 + server *Server + isBackgroundWorker bool } var ( - workers []*worker - workersByName map[string]*worker - globalWorkersByPath map[string]*worker - watcherIsEnabled bool - startupFailChan chan error + workers []*worker + watcherIsEnabled bool + startupFailChan chan error + // the only window where a boot failure reaches startupFailChan: past it, the handlers log and keep restarting + startupPhase atomic.Bool ) +// DefaultWorkerBootTimeout is how long a background worker may take to reach +// its first WorkerHandle::tick() before Init() fails. It matches PHP's default +// max_execution_time, which bounds the same bootstrap where Zend timers are +// around. WithWorkerBootTimeout() changes it, zero waits for ever. +const DefaultWorkerBootTimeout = 30 * time.Second + func initWorkers(opts []workerOpt) error { if len(opts) == 0 { return nil @@ -56,8 +70,7 @@ func initWorkers(opts []workerOpt) error { ) workers = make([]*worker, 0, len(opts)) - workersByName = make(map[string]*worker, len(opts)) - globalWorkersByPath = make(map[string]*worker, len(opts)) + qualifiedNames := make(map[string]bool, len(opts)) for _, o := range opts { w, err := newWorker(o) @@ -65,31 +78,66 @@ func initWorkers(opts []workerOpt) error { return err } + // names and paths are unique within a server + if err := w.server.addWorker(w); err != nil { + return err + } + totalThreadsToStart += w.num workers = append(workers, w) - workersByName[w.name] = w - if w.server == nil { - globalWorkersByPath[w.fileName] = w - globalWorkersByPath[w.declaredFileName] = w - } else if err := w.server.addWorker(w); err != nil { - return err + // a global worker may still be named like the ":" of a + // scoped one, and metrics would report them as one + if qualifiedNames[w.qualifiedName] { + return fmt.Errorf("two workers cannot report under the same name: %q", w.qualifiedName) } + qualifiedNames[w.qualifiedName] = true + + // reported here rather than in calculateMaxThreads(), where the name is not resolved yet + metrics.TotalWorkers(w.name, w.server.name, w.num) } startupFailChan = make(chan error, totalThreadsToStart) + startupPhase.Store(true) for _, w := range workers { for range w.num { thread := getInactivePHPThread() - convertToWorkerThread(thread, w) + if w.isBackgroundWorker { + convertToBackgroundWorkerThread(thread, w) + } else { + convertToWorkerThread(thread, w) + } + bootTimeout := time.Duration(0) + if w.isBackgroundWorker { + bootTimeout = w.bootTimeout + } workersReady.Go(func() { - thread.state.WaitFor(state.Ready, state.ShuttingDown, state.Done) + if bootTimeout <= 0 { + thread.state.WaitFor(state.Ready, state.ShuttingDown, state.Done) + + return + } + + if thread.state.WaitForStateWithTimeout(bootTimeout, state.Ready, state.ShuttingDown, state.Done) { + return + } + + // the script never ticked: drain it, so the run ends and the + // thread reaches a state Shutdown() can act on + startupFailChan <- fmt.Errorf("background worker %q did not call WorkerHandle::tick() within %s", w.qualifiedName, bootTimeout) + w.bootTimedOut.Store(true) + if handler, ok := thread.handler.(*backgroundWorkerThread); ok { + // the run then ends on the boot failure path, which stops + // the thread instead of starting it again, see afterScriptExecution() + handler.drain() + } }) } } workersReady.Wait() + startupPhase.Store(false) select { case err := <-startupFailChan: @@ -97,19 +145,35 @@ func initWorkers(opts []workerOpt) error { return fmt.Errorf("failed to initialize workers: %w", err) default: // all workers started successfully - startupFailChan = nil } return nil } -// resolveWorkerFile turns a declared filename into the path a worker runs, -// and names the worker after it when the declaration left the name out. +// reportStartupFailure hands a boot failure to initWorkers() while it waits, +// so Init() fails, and reports whether it did: past the startup phase the +// handler logs it and keeps restarting. It never blocks. +func reportStartupFailure(err error) bool { + if !startupPhase.Load() { + return false + } + + select { + case startupFailChan <- err: + default: + } + + return true +} + +// resolveWorkerFile turns a declared filename into the path a worker runs, and +// names the worker after it when the declaration left the name out, unique +// within its scope since several workers may share a script. // // Order is important! // This order ensures that FrankenPHP started from inside a symlinked directory will properly resolve any paths. // If it is started from outside a symlinked directory, it is resolved to the same path that we use in the Caddy module. -func resolveWorkerFile(o workerOpt) (workerOpt, error) { +func resolveWorkerFile(o workerOpt, nameTaken func(*Server, string) bool) (workerOpt, error) { declaredFileName, err := fastabs.FastAbs(filepath.FromSlash(o.fileName)) if err != nil { return o, fmt.Errorf("worker filename is invalid %q: %w", o.fileName, err) @@ -132,58 +196,102 @@ func resolveWorkerFile(o workerOpt) (workerOpt, error) { o.declaredFileName = declaredFileName o.fileName = absFileName if o.name == "" { - o.name = absFileName + // the name is the script's identity, exposed as FRANKENPHP_WORKER_BACKGROUND + if o.isBackgroundWorker { + return o, fmt.Errorf("background worker %q must have an explicit name", declaredFileName) + } + + // the path as it was declared, symlinks kept, so the name does not + // move when the target of a release symlink does + declaredPath, err := fastabs.FastAbs(filepath.FromSlash(declaredFileName)) + if err != nil { + declaredPath = absFileName + } + + o.name = declaredPath + for suffix := 1; nameTaken(o.server, o.name); suffix++ { + o.name = fmt.Sprintf("%s_%d", declaredPath, suffix) + } } return o, nil } // checkWorkerDeclaration holds the rules a set of workers must follow, so -// Validate() and newWorker() answer the same way; pathTaken is asked for the -// global workers with a nil server. -func checkWorkerDeclaration(o workerOpt, nameTaken func(string) bool, pathTaken func(*Server, string) bool) error { +// Validate() and newWorker() answer the same way. Names and paths are +// unique within the scope a worker registers in, a nil server standing for +// the global workers. +func checkWorkerDeclaration(o workerOpt, servers []*Server, nameTaken, pathTaken func(*Server, string) bool) error { + scope := "two workers in a server" if o.server == nil { - if pathTaken(nil, o.fileName) { - return fmt.Errorf("two global workers cannot have the same filename: %q", o.fileName) - } + scope = "two global workers" + } else if !slices.Contains(servers, o.server) { + return fmt.Errorf("worker %q is scoped to a server that was not passed to WithServer()", o.name) + } - // no server means no set of requests to match against, the matcher would never run + if o.isBackgroundWorker { if o.matchRequest != nil { - return fmt.Errorf("worker %q has a request matcher but no server scope, use WithWorkerServerScope()", o.name) + return fmt.Errorf("background worker %q cannot match requests", o.name) + } + if o.maxThreads > 0 { + return fmt.Errorf("background worker %q cannot set max_threads, it does not autoscale", o.name) } - } else if o.matchRequest == nil && pathTaken(o.server, o.fileName) { - // a matcher tells the workers of a server sharing a file apart - return fmt.Errorf("two workers in a server cannot have the same filename: %q", o.fileName) + // Workers.SendRequest() and SendMessage() dispatch on requestChan, + // which no background thread reads + if o.extensionWorkers != nil { + return fmt.Errorf("background worker %q cannot be an extension worker, those handle requests", o.name) + } + } + + // no server means no set of requests to match against, the matcher would never run + if o.server == nil && o.matchRequest != nil { + return fmt.Errorf("worker %q has a request matcher but no server scope, use WithWorkerServerScope()", o.name) } - if nameTaken(o.name) { - return fmt.Errorf("two workers cannot have the same name: %q", o.name) + if nameTaken(o.server, o.name) { + return fmt.Errorf("%s cannot have the same name: %q", scope, o.name) + } + + // background workers are keyed by name, several may share a script, and + // a matcher tells the workers of a server sharing a file apart + if !o.isBackgroundWorker && o.matchRequest == nil && pathTaken(o.server, o.fileName) { + return fmt.Errorf("%s cannot have the same filename: %q", scope, o.fileName) } return nil } func newWorker(o workerOpt) (*worker, error) { - o, err := resolveWorkerFile(o) - if err != nil { - return nil, err - } - - nameTaken := func(name string) bool { return workersByName[name] != nil } - pathTaken := func(server *Server, path string) bool { + // the fallback server serves the requests that have no server either, so a worker always has one + registeredIn := func(server *Server) *Server { if server == nil { - return globalWorkersByPath[path] != nil + return fallbackServer } - return server.workersByPath[path] != nil + return server + } + nameTaken := func(server *Server, name string) bool { return registeredIn(server).workersByName[name] != nil } + pathTaken := func(server *Server, path string) bool { return registeredIn(server).workersByPath[path] != nil } + + o, err := resolveWorkerFile(o, nameTaken) + if err != nil { + return nil, err } - if err := checkWorkerDeclaration(o, nameTaken, pathTaken); err != nil { + if err := checkWorkerDeclaration(o, servers, nameTaken, pathTaken); err != nil { return nil, err } + scope := registeredIn(o.server) + absFileName := o.fileName + // the same name may be declared in several servers, metrics and logs need a unique one + qualifiedName := o.name + if o.server != nil { + qualifiedName = o.server.name + ":" + o.name + } + // env should always contain FRANKENPHP_WORKER and the parent php_server env if o.env == nil { o.env = make(PreparedEnv, 1) @@ -198,10 +306,23 @@ func newWorker(o workerOpt) (*worker, error) { } } - o.env["FRANKENPHP_WORKER\x00"] = "1" + // a script serving both roles tests which of the two is set + if o.isBackgroundWorker { + o.env["FRANKENPHP_WORKER_BACKGROUND\x00"] = o.name + } else { + o.env["FRANKENPHP_WORKER\x00"] = "1" + } + + // a background worker that never reaches its ready point would keep Init() + // waiting for ever, see initWorkers() + bootTimeout := DefaultWorkerBootTimeout + if o.bootTimeoutIsSet { + bootTimeout = o.bootTimeout + } w := &worker{ name: o.name, + qualifiedName: qualifiedName, fileName: absFileName, declaredFileName: o.declaredFileName, matchRequest: o.matchRequest, @@ -211,9 +332,11 @@ func newWorker(o workerOpt) (*worker, error) { requestChan: make(chan *frankenPHPContext), threads: make([]*phpThread, 0, o.num), maxConsecutiveFailures: o.maxConsecutiveFailures, + bootTimeout: bootTimeout, onThreadReady: o.onThreadReady, onThreadShutdown: o.onThreadShutdown, - server: o.server, + server: scope, + isBackgroundWorker: o.isBackgroundWorker, } w.configureMercure(&o) @@ -282,7 +405,7 @@ func (worker *worker) isAtThreadLimit() bool { } func (worker *worker) handleRequest(fc *frankenPHPContext) error { - metrics.StartWorkerRequest(worker.name) + metrics.StartWorkerRequest(worker.name, worker.server.name) runtime.Gosched() @@ -294,7 +417,7 @@ func (worker *worker) handleRequest(fc *frankenPHPContext) error { case thread.requestChan <- fc: worker.threadMutex.RUnlock() <-fc.done - metrics.StopWorkerRequest(worker.name, time.Since(fc.startedAt)) + metrics.StopWorkerRequest(worker.name, worker.server.name, time.Since(fc.startedAt)) return nil default: @@ -306,7 +429,7 @@ func (worker *worker) handleRequest(fc *frankenPHPContext) error { // if no thread was available, mark the request as queued and apply the scaling strategy worker.queuedRequests.Add(1) - metrics.QueuedWorkerRequest(worker.name) + metrics.QueuedWorkerRequest(worker.name, worker.server.name) for { workerScaleChan := scaleChan @@ -317,24 +440,24 @@ func (worker *worker) handleRequest(fc *frankenPHPContext) error { select { case worker.requestChan <- fc: worker.queuedRequests.Add(-1) - metrics.DequeuedWorkerRequest(worker.name) + metrics.DequeuedWorkerRequest(worker.name, worker.server.name) <-fc.done - metrics.StopWorkerRequest(worker.name, time.Since(fc.startedAt)) + metrics.StopWorkerRequest(worker.name, worker.server.name, time.Since(fc.startedAt)) return nil case workerScaleChan <- fc: // the request has triggered scaling, continue to wait for a thread case <-fc.ctx.Done(): worker.queuedRequests.Add(-1) - metrics.DequeuedWorkerRequest(worker.name) - metrics.StopWorkerRequest(worker.name, time.Since(fc.startedAt)) + metrics.DequeuedWorkerRequest(worker.name, worker.server.name) + metrics.StopWorkerRequest(worker.name, worker.server.name, time.Since(fc.startedAt)) return nil case <-timeoutChan(time.Duration(maxWaitTime.Load())): // the request has timed out stalling worker.queuedRequests.Add(-1) - metrics.DequeuedWorkerRequest(worker.name) - metrics.StopWorkerRequest(worker.name, time.Since(fc.startedAt)) + metrics.DequeuedWorkerRequest(worker.name, worker.server.name) + metrics.StopWorkerRequest(worker.name, worker.server.name, time.Since(fc.startedAt)) fc.reject(ErrMaxWaitTimeExceeded) diff --git a/worker_internal_test.go b/worker_internal_test.go index a54ff41b43..ff3441df38 100644 --- a/worker_internal_test.go +++ b/worker_internal_test.go @@ -29,7 +29,7 @@ func TestCancelledRequestLeavesWorkerQueue(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) req := httptest.NewRequest("GET", "/index.php", nil).WithContext(ctx) cancel() - w := &worker{name: "test", requestChan: make(chan *frankenPHPContext), maxThreads: 1} + w := &worker{name: "test", requestChan: make(chan *frankenPHPContext), maxThreads: 1, server: fallbackServer} err := w.handleRequest(&frankenPHPContext{ request: req, diff --git a/workerextension.go b/workerextension.go index 814eea1565..eafa27b14e 100644 --- a/workerextension.go +++ b/workerextension.go @@ -26,25 +26,33 @@ type extensionWorkers struct { // EXPERIMENTAL: SendRequest sends an HTTP request to the worker and writes the response to the provided ResponseWriter. func (w *extensionWorkers) SendRequest(rw http.ResponseWriter, r *http.Request) error { - fr, err := NewRequestWithContext( - r, - WithOriginalRequest(r), - WithWorkerName(w.name), - ) - - if err != nil { - return err + // the worker only exists between Init() and Shutdown(), and its server + // only serves once registered, which SendMessage checks the same way + if w.internalWorker == nil || !w.internalWorker.server.isRegistered.Load() { + return ErrNotRunning } - return ServeHTTP(rw, fr) + // a worker always has a server, the fallback one when it was declared + // without a scope + return w.internalWorker.server.ServeHTTP(rw, r, WithOriginalRequest(r), withWorker(w.internalWorker)) } func (w *extensionWorkers) NumThreads() int { + if w.internalWorker == nil { + return 0 + } + return w.internalWorker.countThreads() } // EXPERIMENTAL: SendMessage sends a message to the worker and waits for a response. func (w *extensionWorkers) SendMessage(ctx context.Context, message any, rw http.ResponseWriter) (any, error) { + // the worker only exists between Init() and Shutdown(), and stays + // referenced past the latter + if w.internalWorker == nil || !w.internalWorker.server.isRegistered.Load() { + return nil, ErrNotRunning + } + fc := newContextFromMessage(message, rw, ctx, w.internalWorker) err := w.internalWorker.handleRequest(fc) diff --git a/workerextension_test.go b/workerextension_test.go index e861ec97a3..1d812f4893 100644 --- a/workerextension_test.go +++ b/workerextension_test.go @@ -4,6 +4,7 @@ import ( "io" "net/http/httptest" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -77,6 +78,73 @@ func TestWorkerExtensionSendMessage(t *testing.T) { assert.Equal(t, "received message: Hello Workers", ret) } +// an extension worker scoped to a server stays reachable through +// SendRequest(), which dispatches on it whatever the URI +func TestWorkerExtensionOnServer(t *testing.T) { + t.Cleanup(Shutdown) + + server, err := NewServer("testdata/", WithServerName("api")) + require.NoError(t, err) + externalWorker, o := WithExtensionWorkers("scopedWorker", "testdata/worker.php", 1, WithWorkerServerScope(server)) + require.NoError(t, Init(o, WithServer(server))) + + // a URI that does not match the worker's own script, so only the name + // lookup can route this to it + w := httptest.NewRecorder() + require.NoError(t, externalWorker.SendRequest(w, httptest.NewRequest("GET", "http://example.com/index.php", nil))) + + body, err := io.ReadAll(w.Result().Body) + require.NoError(t, err) + assert.Contains(t, string(body), "Requests handled: 0") +} + +// an extension worker without a name is still the one SendRequest() +// dispatches on +func TestWorkerExtensionSendRequestWithoutName(t *testing.T) { + t.Cleanup(Shutdown) + + externalWorker, o := WithExtensionWorkers("", "testdata/worker.php", 1) + require.NoError(t, Init(o)) + + w := httptest.NewRecorder() + require.NoError(t, externalWorker.SendRequest(w, httptest.NewRequest("GET", "http://example.com/index.php", nil))) + + body, err := io.ReadAll(w.Result().Body) + require.NoError(t, err) + assert.Contains(t, string(body), "Requests handled: 0") +} + +// SendMessage() after Shutdown() reports the stopped runtime instead of +// waiting for a thread +func TestWorkerExtensionSendMessageAfterShutdown(t *testing.T) { + externalWorker, o := WithExtensionWorkers("extensionWorkers", "testdata/message-worker.php", 1) + require.NoError(t, Init(o)) + Shutdown() + + errChan := make(chan error, 1) + go func() { + _, err := externalWorker.SendMessage(t.Context(), "Hello Workers", nil) + errChan <- err + }() + + select { + case err := <-errChan: + require.ErrorIs(t, err, ErrNotRunning) + case <-time.After(2 * time.Second): + t.Fatal("SendMessage() did not return after Shutdown()") + } +} + +// background workers never read requestChan, so an extension cannot send +// them requests or messages +func TestErrorIfExtensionWorkerIsBackground(t *testing.T) { + t.Cleanup(Shutdown) + + _, o := WithExtensionWorkers("backgroundExtension", "testdata/bgworker/basic.php", 1, WithWorkerBackground()) + + require.ErrorContains(t, Init(o), "cannot be an extension worker") +} + func TestErrorIf2WorkersHaveSameName(t *testing.T) { _, o1 := WithExtensionWorkers("duplicateWorker", "testdata/worker.php", 1) _, o2 := WithExtensionWorkers("duplicateWorker", "testdata/worker2.php", 1) diff --git a/workerlifecycle.go b/workerlifecycle.go new file mode 100644 index 0000000000..4d1809ee22 --- /dev/null +++ b/workerlifecycle.go @@ -0,0 +1,58 @@ +package frankenphp + +import "github.com/dunglas/frankenphp/internal/state" + +// workerLifecycle is the part of a worker thread that HTTP and background +// workers share: the thread, its worker, and the states both walk through +// between two runs of the script. Handlers embed it and pass the one step +// that differs, how a run starts. +type workerLifecycle struct { + state *state.ThreadState + thread *phpThread + worker *worker +} + +func newWorkerLifecycle(thread *phpThread, worker *worker) workerLifecycle { + return workerLifecycle{state: thread.state, thread: thread, worker: worker} +} + +// beforeScriptExecution returns the name of the script to run, or an empty +// string to stop the thread; startScript prepares a run and returns the +// script to execute, or an empty string to stop the thread +func (l *workerLifecycle) beforeScriptExecution(startScript func() string) string { + switch l.state.Get() { + case state.TransitionRequested: + l.detach() + + return l.thread.transitionToNewHandler() + case state.Ready, state.TransitionComplete: + l.thread.updateContext(true) + if l.worker.onThreadReady != nil { + l.worker.onThreadReady(l.thread.threadIndex) + } + + return startScript() + case state.Rebooting, state.ForceRebooting: + return "" + case state.RebootReady: + l.state.Set(state.Ready) + + return l.beforeScriptExecution(startScript) + case state.ShuttingDown: + l.detach() + + // signal to stop + return "" + default: + panic("unexpected state: " + l.state.Name()) + } +} + +// detach takes the thread off its worker, on the paths that stop running its +// script for good +func (l *workerLifecycle) detach() { + if l.worker.onThreadShutdown != nil { + l.worker.onThreadShutdown(l.thread.threadIndex) + } + l.worker.detachThread(l.thread) +} From 47da57053c2026d65baf6be9c4944a2f40ecedda Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 27 Oct 2026 10:04:37 +0100 Subject: [PATCH 02/13] feat: a background worker no longer needs a name --- bgworker_test.go | 29 +++++++++++++++++++++-------- caddy/config_test.go | 8 +++++--- caddy/workerconfig.go | 9 ++------- docs/config.md | 4 ++-- docs/worker.md | 4 ++-- worker.go | 14 ++------------ 6 files changed, 34 insertions(+), 34 deletions(-) diff --git a/bgworker_test.go b/bgworker_test.go index 62ef7fbb8a..9eb0d4f7e6 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -126,14 +126,6 @@ func TestBackgroundWorkerOnServer(t *testing.T) { func TestBackgroundWorkerValidation(t *testing.T) { t.Cleanup(frankenphp.Shutdown) - t.Run("name is required", func(t *testing.T) { - err := frankenphp.Init( - frankenphp.WithWorkers("", "testdata/bgworker/basic.php", 1, frankenphp.WithWorkerBackground()), - frankenphp.WithNumThreads(2), - ) - require.ErrorContains(t, err, "must have an explicit name") - }) - t.Run("names are unique within a server", func(t *testing.T) { // a global and a server-scoped worker may share a name (see // TestBackgroundWorkerOnServer), two workers of one server may not @@ -461,6 +453,27 @@ func TestBackgroundWorkerMultiEntrypoint(t *testing.T) { requireFileEventually(t, second, "the second worker on the shared script did not start") } +// a name left out is generated from the script, unique within the scope like +// an HTTP worker's, so two workers sharing a script need none +func TestBackgroundWorkerNameIsGenerated(t *testing.T) { + tmp := t.TempDir() + first, second := filepath.Join(tmp, "first"), filepath.Join(tmp, "second") + initServers(t, + frankenphp.WithWorkers("", "testdata/bgworker/basic.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": first}), + ), + frankenphp.WithWorkers("", "testdata/bgworker/basic.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": second}), + ), + frankenphp.WithNumThreads(1), + ) + + requireFileEventually(t, first, "the first unnamed worker did not start") + requireFileEventually(t, second, "the second unnamed worker did not start") +} + // background threads are reserved on top of num_threads func TestBackgroundWorkerThreadsComeOnTop(t *testing.T) { sentinel := filepath.Join(t.TempDir(), "bg-only.sentinel") diff --git a/caddy/config_test.go b/caddy/config_test.go index fd54c6a141..33d15c8623 100644 --- a/caddy/config_test.go +++ b/caddy/config_test.go @@ -318,7 +318,8 @@ func TestWorkerBackgroundConfig(t *testing.T) { require.Equal(t, "jobs", module.Workers[0].Name) } -func TestWorkerBackgroundRequiresName(t *testing.T) { +// the name is generated on the Go side, like for an HTTP worker +func TestWorkerBackgroundNameIsOptional(t *testing.T) { d := caddyfile.NewTestDispenser(` { php_server { @@ -330,8 +331,9 @@ func TestWorkerBackgroundRequiresName(t *testing.T) { }`) module := &FrankenPHPModule{} - err := module.UnmarshalCaddyfile(d) - require.ErrorContains(t, err, `background workers must have an explicit "name"`) + require.NoError(t, module.UnmarshalCaddyfile(d)) + require.True(t, module.Workers[0].Background) + require.Empty(t, module.Workers[0].Name) } func TestWorkerBackgroundWithoutNumParses(t *testing.T) { diff --git a/caddy/workerconfig.go b/caddy/workerconfig.go index f50ff3ac30..d301c4e585 100644 --- a/caddy/workerconfig.go +++ b/caddy/workerconfig.go @@ -174,13 +174,8 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) { return wc, d.Err(`"boot_timeout" is only supported for background workers`) } - if wc.Background { - if wc.Name == "" { - return wc, d.Err(`background workers must have an explicit "name"`) - } - if len(wc.MatchPath) != 0 { - return wc, d.Err(`"match" is not supported for background workers`) - } + if wc.Background && len(wc.MatchPath) != 0 { + return wc, d.Err(`"match" is not supported for background workers`) } if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) { diff --git a/docs/config.md b/docs/config.md index e933f53d84..4bc54e41c0 100644 --- a/docs/config.md +++ b/docs/config.md @@ -112,7 +112,7 @@ You can also explicitly configure FrankenPHP using the [global option](https://c name # Sets the name of the worker, used in logs and metrics. Must be unique among global workers. Default: absolute path of the worker file, with a number appended when several workers share a script. max_consecutive_failures # Sets the maximum number of consecutive failures before the worker is considered unhealthy, -1 means the worker will always restart. Default: 6. boot_timeout # EXPERIMENTAL: how long a background worker may take to reach its first tick() before the start fails, 0 waits forever. Default: 30s. - background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, requiring "name" and a script ticking its FrankenPHP\WorkerHandle at least once + background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, whose script ticks its FrankenPHP\WorkerHandle at least once } } } @@ -200,7 +200,7 @@ php_server [] { watch # Sets the path to watch for file changes. Can be specified more than once for multiple paths. env # Sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables. Environment variables for this worker are also inherited from the php_server parent, but can be overwritten here. match # match the worker to a path pattern. Overrides try_files and can only be used in the php_server directive. - background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, requiring "name" and a script ticking its FrankenPHP\WorkerHandle at least once + background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, whose script ticks its FrankenPHP\WorkerHandle at least once } worker # Can also use the short form like in the global frankenphp block. } diff --git a/docs/worker.md b/docs/worker.md index 108b4943a5..e055c0e3f9 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -210,7 +210,7 @@ Thus, in that mode if `num_threads` and `num` are explicitly set, `num_threads` This feature is experimental. -A background worker runs its script in a loop outside the HTTP request cycle, on its own PHP thread. It is declared like any worker, with the `background` option; `name` is required and `num` defaults to one thread: +A background worker runs its script in a loop outside the HTTP request cycle, on its own PHP thread. It is declared like any worker, with the `background` option; `num` defaults to one thread, and a `name` left out is generated from the script path as for HTTP workers: ```caddyfile php_server { @@ -277,7 +277,7 @@ The wake-up sent at start makes the callback run as soon as the loop does, which That stream is there for the libraries that take one, and waiting is all it supports: reading it steals the bytes `tick()` would have consumed, writing to it lands in a buffer FrankenPHP never drains, so a script that fills it blocks itself, and closing it is safe since the next `getStream()` on the same handle opens a fresh one over the same socket. A script with no loop of its own can wait on it directly, with a blocking read such as `fgets()` or with `stream_select()`, keeping in mind that `stream_select()` rejects a descriptor of `1024` or higher (`FD_SETSIZE`), which a worker re-run while the server holds more than a thousand connections will have. `tick()` itself uses `poll()` and is not affected. -`$_SERVER['FRANKENPHP_WORKER_BACKGROUND']` holds the declared name. `FRANKENPHP_WORKER`, the variable of HTTP workers, is not set, so a script serving both roles tests which of the two is set. Background threads come on top of `num_threads` and `max_threads`; they don't autoscale, so `max_threads` is not allowed on them. From Go, declare one with `WithWorkerBackground()`. +`$_SERVER['FRANKENPHP_WORKER_BACKGROUND']` holds the name of the worker. `FRANKENPHP_WORKER`, the variable of HTTP workers, is not set, so a script serving both roles tests which of the two is set. Background threads come on top of `num_threads` and `max_threads`; they don't autoscale, so `max_threads` is not allowed on them. From Go, declare one with `WithWorkerBackground()`. ## Superglobals behavior diff --git a/worker.go b/worker.go index 3861b75e6a..7ea1d9040a 100644 --- a/worker.go +++ b/worker.go @@ -196,21 +196,11 @@ func resolveWorkerFile(o workerOpt, nameTaken func(*Server, string) bool) (worke o.declaredFileName = declaredFileName o.fileName = absFileName if o.name == "" { - // the name is the script's identity, exposed as FRANKENPHP_WORKER_BACKGROUND - if o.isBackgroundWorker { - return o, fmt.Errorf("background worker %q must have an explicit name", declaredFileName) - } - // the path as it was declared, symlinks kept, so the name does not // move when the target of a release symlink does - declaredPath, err := fastabs.FastAbs(filepath.FromSlash(declaredFileName)) - if err != nil { - declaredPath = absFileName - } - - o.name = declaredPath + o.name = declaredFileName for suffix := 1; nameTaken(o.server, o.name); suffix++ { - o.name = fmt.Sprintf("%s_%d", declaredPath, suffix) + o.name = fmt.Sprintf("%s_%d", declaredFileName, suffix) } } From 24185e3ac1359a1e7fc85d88302beaead49ee85e Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 27 Oct 2026 16:14:20 +0100 Subject: [PATCH 03/13] refactor: worker names, registries and metrics labels are main's again Names were scoped per server so a script could address a background worker by name within its php_server. The namespaces of #2635 and #2636 do that now and no PHP API takes a worker name, so naming goes back to what main does: Caddy makes generated names unique, and the server label and the ServerMetrics interface are gone. The extension worker fixes leave too, main has the same bugs and they get a pull request of their own. --- bgworker_test.go | 83 ++----------- caddy/app.go | 44 ++++++- caddy/caddy_test.go | 50 ++++---- caddy/config_test.go | 41 ++++++- caddy/module.go | 2 +- caddy/serveridx_test.go | 6 +- caddy/workerconfig.go | 2 +- cgi.go | 2 +- context.go | 19 ++- docs/config.md | 6 +- docs/library.md | 2 - docs/metrics.md | 18 +-- frankenphp.go | 38 +++--- frankenphp_test.go | 16 +-- metrics.go | 225 ++++++------------------------------ metrics_test.go | 135 +++------------------- phpmainthread_test.go | 39 ++++--- requestoptions.go | 15 +-- scaling.go | 6 +- scaling_test.go | 2 +- server.go | 66 +++-------- server_test.go | 76 +----------- testdata/bgworker/named.php | 3 +- threadbackgroundworker.go | 24 ++-- threadworker.go | 34 +++--- worker.go | 146 ++++++++++------------- worker_internal_test.go | 2 +- workerextension.go | 26 ++--- workerextension_test.go | 58 ---------- 29 files changed, 358 insertions(+), 828 deletions(-) diff --git a/bgworker_test.go b/bgworker_test.go index 9eb0d4f7e6..2011e7551f 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -99,25 +99,17 @@ func TestBackgroundWorkerOnServer(t *testing.T) { ) require.NoError(t, err) - globalSentinel := filepath.Join(tmp, "global.sentinel") initServers(t, frankenphp.WithServer(server), frankenphp.WithWorkers("jobs", "testdata/bgworker/named.php", 1, frankenphp.WithWorkerBackground(), frankenphp.WithWorkerServerScope(server), ), - // a global worker may reuse the name: names are scoped to their server - frankenphp.WithWorkers("jobs", "testdata/bgworker/basic.php", 1, - frankenphp.WithWorkerBackground(), - frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": globalSentinel}), - ), - frankenphp.WithNumThreads(3), + frankenphp.WithNumThreads(2), ) - // named.php touches "/": the script sees - // the declared name, not the server-qualified one used by metrics and logs + // named.php touches "/" requireFileEventually(t, filepath.Join(tmp, "jobs"), "background worker did not touch its per-name sentinel") - requireFileEventually(t, globalSentinel, "the global worker sharing the name did not start") body := serverGet(t, server, "http://example.com/index.php") assert.Contains(t, body, "I am by birth a Genevese", "the server must still serve regular requests") @@ -126,26 +118,6 @@ func TestBackgroundWorkerOnServer(t *testing.T) { func TestBackgroundWorkerValidation(t *testing.T) { t.Cleanup(frankenphp.Shutdown) - t.Run("names are unique within a server", func(t *testing.T) { - // a global and a server-scoped worker may share a name (see - // TestBackgroundWorkerOnServer), two workers of one server may not - server, err := frankenphp.NewServer(testDataDir) - require.NoError(t, err) - err = frankenphp.Init( - frankenphp.WithServer(server), - frankenphp.WithWorkers("bg-shared", "testdata/bgworker/basic.php", 1, - frankenphp.WithWorkerBackground(), - frankenphp.WithWorkerServerScope(server), - ), - frankenphp.WithWorkers("bg-shared", "testdata/bgworker/named.php", 1, - frankenphp.WithWorkerBackground(), - frankenphp.WithWorkerServerScope(server), - ), - frankenphp.WithNumThreads(3), - ) - require.ErrorContains(t, err, "two workers in a server cannot have the same name") - }) - t.Run("early return without the handle fails startup", func(t *testing.T) { err := frankenphp.Init( frankenphp.WithWorkers("bg-early", "testdata/bgworker/early-return.php", 1, @@ -179,38 +151,6 @@ func TestBackgroundWorkerValidation(t *testing.T) { require.ErrorContains(t, err, "cannot set max_threads") }) - t.Run("two workers cannot report under the same name", func(t *testing.T) { - // scoping keeps names apart, except for a global name shaped like - // the ":" of a scoped one - server, err := frankenphp.NewServer(testDataDir, frankenphp.WithServerName("api")) - require.NoError(t, err) - err = frankenphp.Init( - frankenphp.WithServer(server), - frankenphp.WithWorkers("jobs", "testdata/bgworker/basic.php", 1, - frankenphp.WithWorkerBackground(), - frankenphp.WithWorkerServerScope(server), - ), - frankenphp.WithWorkers("api:jobs", "testdata/bgworker/named.php", 1, - frankenphp.WithWorkerBackground(), - ), - frankenphp.WithNumThreads(3), - ) - require.ErrorContains(t, err, `two workers cannot report under the same name: "api:jobs"`) - }) - - t.Run("an unregistered server scope is rejected", func(t *testing.T) { - unregistered, err := frankenphp.NewServer(testDataDir) - require.NoError(t, err) - err = frankenphp.Init( - frankenphp.WithWorkers("bg-orphan", "testdata/bgworker/basic.php", 1, - frankenphp.WithWorkerBackground(), - frankenphp.WithWorkerServerScope(unregistered), - ), - frankenphp.WithNumThreads(2), - ) - require.ErrorContains(t, err, "not passed to WithServer()") - }) - t.Run("request matchers are rejected", func(t *testing.T) { err := frankenphp.Init( frankenphp.WithWorkers("bg-matched", "testdata/bgworker/basic.php", 1, @@ -453,25 +393,20 @@ func TestBackgroundWorkerMultiEntrypoint(t *testing.T) { requireFileEventually(t, second, "the second worker on the shared script did not start") } -// a name left out is generated from the script, unique within the scope like -// an HTTP worker's, so two workers sharing a script need none +// a name left out is generated from the script, like an HTTP worker's func TestBackgroundWorkerNameIsGenerated(t *testing.T) { - tmp := t.TempDir() - first, second := filepath.Join(tmp, "first"), filepath.Join(tmp, "second") + sentinel := filepath.Join(t.TempDir(), "flag.txt") initServers(t, - frankenphp.WithWorkers("", "testdata/bgworker/basic.php", 1, - frankenphp.WithWorkerBackground(), - frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": first}), - ), - frankenphp.WithWorkers("", "testdata/bgworker/basic.php", 1, + frankenphp.WithWorkers("", "testdata/bgworker/flag.php", 1, frankenphp.WithWorkerBackground(), - frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": second}), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), ), frankenphp.WithNumThreads(1), ) - requireFileEventually(t, first, "the first unnamed worker did not start") - requireFileEventually(t, second, "the second unnamed worker did not start") + script, err := filepath.Abs("testdata/bgworker/flag.php") + require.NoError(t, err) + assert.Contains(t, requireFileContentEventually(t, sentinel), "'background' => '"+script+"'") } // background threads are reserved on top of num_threads diff --git a/caddy/app.go b/caddy/app.go index fe06fcfb24..1ef018362b 100644 --- a/caddy/app.go +++ b/caddy/app.go @@ -16,6 +16,7 @@ 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 ( @@ -139,9 +140,12 @@ func (f *FrankenPHPApp) collectOptions(repl *caddy.Replacer, keep bool) ([]frank frankenphp.WithMaxRequests(f.MaxRequests), ) + usedWorkerNames := make(map[string]bool, len(f.Workers)) + // register global workers for _, w := range f.Workers { w.FileName = repl.ReplaceKnown(w.FileName, "") + w.Name = createUniqueWorkerName(usedWorkerNames, w, "") workerOptions, err := w.toWorkerOptions() if err != nil { return nil, err @@ -149,7 +153,7 @@ func (f *FrankenPHPApp) collectOptions(repl *caddy.Replacer, keep bool) ([]frank opts = append(opts, frankenphp.WithWorkers(w.Name, w.FileName, w.Num, workerOptions...)) } - moduleOpts, err := f.collectModuleOptions(repl, keep) + moduleOpts, err := f.collectModuleOptions(repl, usedWorkerNames, keep) if err != nil { return nil, err } @@ -201,7 +205,7 @@ func (f *FrankenPHPApp) Stop() error { } // register workers and servers for "php" and "php_server" modules -func (f *FrankenPHPApp) collectModuleOptions(repl *caddy.Replacer, keep bool) ([]frankenphp.Option, error) { +func (f *FrankenPHPApp) collectModuleOptions(repl *caddy.Replacer, usedWorkerNames map[string]bool, keep bool) ([]frankenphp.Option, error) { opts := make([]frankenphp.Option, 0, len(f.modules)) serversByIndex := make(map[int]*frankenphp.Server, len(f.modules)) @@ -219,7 +223,7 @@ func (f *FrankenPHPApp) collectModuleOptions(repl *caddy.Replacer, keep bool) ([ } } - server, moduleOpts, err := f.collectModule(repl, module) + server, moduleOpts, err := f.collectModule(repl, module, usedWorkerNames) if err != nil { return nil, err } @@ -236,7 +240,7 @@ func (f *FrankenPHPApp) collectModuleOptions(repl *caddy.Replacer, keep bool) ([ return opts, nil } -func (f *FrankenPHPApp) collectModule(repl *caddy.Replacer, module *FrankenPHPModule) (*frankenphp.Server, []frankenphp.Option, error) { +func (f *FrankenPHPApp) collectModule(repl *caddy.Replacer, module *FrankenPHPModule, usedWorkerNames map[string]bool) (*frankenphp.Server, []frankenphp.Option, error) { serverName := f.resolveServerName(module) server, err := frankenphp.NewServer( module.resolvedDocumentRoot, @@ -253,6 +257,7 @@ func (f *FrankenPHPApp) collectModule(repl *caddy.Replacer, module *FrankenPHPMo for _, w := range module.Workers { w.FileName = repl.ReplaceKnown(w.FileName, "") + w.Name = createUniqueWorkerName(usedWorkerNames, w, serverName) workerOptions, err := w.toWorkerOptions() if err != nil { return nil, nil, err @@ -264,6 +269,33 @@ func (f *FrankenPHPApp) collectModule(repl *caddy.Replacer, module *FrankenPHPMo return server, opts, nil } +// avoid name collisions for workers +// on collision, a name is first qualified with the server name +// (":") before falling back to a numeric postfix +func createUniqueWorkerName(usedWorkerNames map[string]bool, wc workerConfig, serverName string) string { + if wc.Name == "" { + wc.Name, _ = fastabs.FastAbs(wc.FileName) + } + + name := wc.Name + suffix := 0 + for { + if _, ok := usedWorkerNames[name]; !ok { + 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() { @@ -378,8 +410,8 @@ 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; background workers are keyed - // by name, several may share a script + // check for duplicate workers; background workers are not + // matched by path, several may share a script if !wc.Background { for _, existingWorker := range f.Workers { if !existingWorker.Background && existingWorker.FileName == wc.FileName { diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index 8f72a71822..5e03baaa83 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -899,19 +899,19 @@ func TestWorkerMetrics(t *testing.T) { # HELP frankenphp_busy_workers Number of busy PHP workers for this worker # TYPE frankenphp_busy_workers gauge - frankenphp_busy_workers{server="",worker="` + workerName + `"} 0 + frankenphp_busy_workers{worker="` + workerName + `"} 0 # HELP frankenphp_total_workers Total number of PHP workers for this worker # TYPE frankenphp_total_workers gauge - frankenphp_total_workers{server="",worker="` + workerName + `"} 2 + frankenphp_total_workers{worker="` + workerName + `"} 2 # HELP frankenphp_worker_request_count # TYPE frankenphp_worker_request_count counter - frankenphp_worker_request_count{server="",worker="` + workerName + `"} 10 + frankenphp_worker_request_count{worker="` + workerName + `"} 10 # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, WorkerHandle::tick() for background workers # TYPE frankenphp_ready_workers gauge - frankenphp_ready_workers{server="",worker="` + workerName + `"} 2 + frankenphp_ready_workers{worker="` + workerName + `"} 2 ` ctx := caddy.ActiveContext() @@ -974,7 +974,7 @@ func TestPhpServerWorkerMatchPoolCount(t *testing.T) { var pools []string for line := range strings.SplitSeq(metrics.String(), "\n") { - if !strings.HasPrefix(line, "frankenphp_total_workers{") { + if !strings.HasPrefix(line, "frankenphp_total_workers{worker=") { continue } if !strings.Contains(line, "dedup-match-worker.php") && !strings.Contains(line, "dedup-plain-worker.php") { @@ -1056,19 +1056,19 @@ func TestNamedWorkerMetrics(t *testing.T) { # HELP frankenphp_busy_workers Number of busy PHP workers for this worker # TYPE frankenphp_busy_workers gauge - frankenphp_busy_workers{server="",worker="my_app"} 0 + frankenphp_busy_workers{worker="my_app"} 0 # HELP frankenphp_total_workers Total number of PHP workers for this worker # TYPE frankenphp_total_workers gauge - frankenphp_total_workers{server="",worker="my_app"} 2 + frankenphp_total_workers{worker="my_app"} 2 # HELP frankenphp_worker_request_count # TYPE frankenphp_worker_request_count counter - frankenphp_worker_request_count{server="",worker="my_app"} 10 + frankenphp_worker_request_count{worker="my_app"} 10 # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, WorkerHandle::tick() for background workers # TYPE frankenphp_ready_workers gauge - frankenphp_ready_workers{server="",worker="my_app"} 2 + frankenphp_ready_workers{worker="my_app"} 2 ` ctx := caddy.ActiveContext() @@ -1152,19 +1152,19 @@ func TestAutoWorkerConfig(t *testing.T) { # HELP frankenphp_busy_workers Number of busy PHP workers for this worker # TYPE frankenphp_busy_workers gauge - frankenphp_busy_workers{server="",worker="` + workerName + `"} 0 + frankenphp_busy_workers{worker="` + workerName + `"} 0 # HELP frankenphp_total_workers Total number of PHP workers for this worker # TYPE frankenphp_total_workers gauge - frankenphp_total_workers{server="",worker="` + workerName + `"} ` + workers + ` + frankenphp_total_workers{worker="` + workerName + `"} ` + workers + ` # HELP frankenphp_worker_request_count # TYPE frankenphp_worker_request_count counter - frankenphp_worker_request_count{server="",worker="` + workerName + `"} 10 + frankenphp_worker_request_count{worker="` + workerName + `"} 10 # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, WorkerHandle::tick() for background workers # TYPE frankenphp_ready_workers gauge - frankenphp_ready_workers{server="",worker="` + workerName + `"} ` + workers + ` + frankenphp_ready_workers{worker="` + workerName + `"} ` + workers + ` ` ctx := caddy.ActiveContext() @@ -1425,7 +1425,7 @@ func TestMaxWaitTimeWorker(t *testing.T) { expectedMetrics := ` # TYPE frankenphp_worker_queue_depth gauge - frankenphp_worker_queue_depth{server="",worker="service"} 0 + frankenphp_worker_queue_depth{worker="service"} 0 ` ctx := caddy.ActiveContext() @@ -1526,21 +1526,21 @@ func TestMultiWorkersMetrics(t *testing.T) { # HELP frankenphp_busy_workers Number of busy PHP workers for this worker # TYPE frankenphp_busy_workers gauge - frankenphp_busy_workers{server="",worker="service1"} 0 + frankenphp_busy_workers{worker="service1"} 0 # HELP frankenphp_total_workers Total number of PHP workers for this worker # TYPE frankenphp_total_workers gauge - frankenphp_total_workers{server="",worker="service1"} 2 - frankenphp_total_workers{server="",worker="service2"} 3 + frankenphp_total_workers{worker="service1"} 2 + frankenphp_total_workers{worker="service2"} 3 # HELP frankenphp_worker_request_count # TYPE frankenphp_worker_request_count counter - frankenphp_worker_request_count{server="",worker="service1"} 10 + frankenphp_worker_request_count{worker="service1"} 10 # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, WorkerHandle::tick() for background workers # TYPE frankenphp_ready_workers gauge - frankenphp_ready_workers{server="",worker="service1"} 2 - frankenphp_ready_workers{server="",worker="service2"} 3 + frankenphp_ready_workers{worker="service1"} 2 + frankenphp_ready_workers{worker="service2"} 3 ` ctx := caddy.ActiveContext() @@ -1693,10 +1693,10 @@ func TestWorkerRestart(t *testing.T) { expectedMetrics := ` # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, WorkerHandle::tick() for background workers # TYPE frankenphp_ready_workers gauge - frankenphp_ready_workers{server="",worker="service"} 1 + frankenphp_ready_workers{worker="service"} 1 # HELP frankenphp_total_workers Total number of PHP workers for this worker # TYPE frankenphp_total_workers gauge - frankenphp_total_workers{server="",worker="service"} 1 + frankenphp_total_workers{worker="service"} 1 ` require.NoError(t, @@ -1721,13 +1721,13 @@ func TestWorkerRestart(t *testing.T) { expectedMetrics = ` # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, WorkerHandle::tick() for background workers # TYPE frankenphp_ready_workers gauge - frankenphp_ready_workers{server="",worker="service"} 1 + frankenphp_ready_workers{worker="service"} 1 # HELP frankenphp_total_workers Total number of PHP workers for this worker # TYPE frankenphp_total_workers gauge - frankenphp_total_workers{server="",worker="service"} 1 + frankenphp_total_workers{worker="service"} 1 # HELP frankenphp_worker_restarts Number of PHP worker restarts for this worker # TYPE frankenphp_worker_restarts counter - frankenphp_worker_restarts{server="",worker="service"} 3 + frankenphp_worker_restarts{worker="service"} 3 ` require.NoError(t, diff --git a/caddy/config_test.go b/caddy/config_test.go index 33d15c8623..345ba7bcef 100644 --- a/caddy/config_test.go +++ b/caddy/config_test.go @@ -1,6 +1,7 @@ package caddy import ( + "path/filepath" "testing" "time" @@ -92,7 +93,7 @@ func TestModuleWorkerDuplicateFilenamesFail(t *testing.T) { } // two global background workers may share a script, like their php_server -// counterparts and the Go API: they are keyed by name +// counterparts and the Go API: they are not matched by path func TestGlobalBackgroundWorkersShareAFilename(t *testing.T) { d := caddyfile.NewTestDispenser(` { @@ -298,6 +299,42 @@ func TestModuleWorkerWithCustomName(t *testing.T) { require.Equal(t, "../testdata/worker-with-env.php", module.Workers[0].FileName, "Worker should have the correct filename") } +func TestCreateUniqueWorkerNames(t *testing.T) { + usedWorkerNames := map[string]bool{} + filename := "../testdata/worker-with-env.php" + absFileName, _ := filepath.Abs(filename) + names := make([]string, 6) + for i := range 3 { + names[i] = createUniqueWorkerName(usedWorkerNames, workerConfig{ + FileName: filename, + Name: "custom-worker-name", + }, "") + names[i+3] = createUniqueWorkerName(usedWorkerNames, workerConfig{ + FileName: filename, + }, "") + } + + require.Equal(t, "custom-worker-name", names[0]) + require.Equal(t, "custom-worker-name_1", names[1]) + require.Equal(t, "custom-worker-name_2", names[2]) + require.Equal(t, absFileName, names[3]) + require.Equal(t, absFileName+"_1", names[4]) + require.Equal(t, absFileName+"_2", names[5]) +} + +func TestCreateUniqueWorkerNamesQualifiedByServer(t *testing.T) { + usedWorkerNames := map[string]bool{} + wc := workerConfig{FileName: "../testdata/worker-with-env.php", Name: "queue"} + + require.Equal(t, "queue", createUniqueWorkerName(usedWorkerNames, wc, "one.example.com")) + // on collision, the name is qualified with the server name + require.Equal(t, "two.example.com:queue", createUniqueWorkerName(usedWorkerNames, wc, "two.example.com")) + // when the qualified name is also taken, fall back to the numeric postfix + require.Equal(t, "queue_1", createUniqueWorkerName(usedWorkerNames, wc, "two.example.com")) + // workers without a server keep the numeric postfix behavior + require.Equal(t, "queue_2", createUniqueWorkerName(usedWorkerNames, wc, "")) +} + func TestWorkerBackgroundConfig(t *testing.T) { d := caddyfile.NewTestDispenser(` { @@ -318,7 +355,7 @@ func TestWorkerBackgroundConfig(t *testing.T) { require.Equal(t, "jobs", module.Workers[0].Name) } -// the name is generated on the Go side, like for an HTTP worker +// the name is generated when the workers are collected, like for an HTTP worker func TestWorkerBackgroundNameIsOptional(t *testing.T) { d := caddyfile.NewTestDispenser(` { diff --git a/caddy/module.go b/caddy/module.go index ff6e2db5ff..0bab580086 100644 --- a/caddy/module.go +++ b/caddy/module.go @@ -315,7 +315,7 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { // Check if a worker with this filename already exists in this module fileNames := make(map[string]struct{}, len(f.Workers)) for _, w := range f.Workers { - // background workers are keyed by name, several may share a script + // background workers are not matched by path, several may share a script if w.Background { continue } diff --git a/caddy/serveridx_test.go b/caddy/serveridx_test.go index aa24918b02..1fd401429d 100644 --- a/caddy/serveridx_test.go +++ b/caddy/serveridx_test.go @@ -26,7 +26,7 @@ func TestRegisterModulesWithSameServerIndexShareOneServer(t *testing.T) { shared2 := &FrankenPHPModule{ServerIndex: 1, resolvedDocumentRoot: "../testdata"} app.modules = []*FrankenPHPModule{shared1, shared2} - _, err := app.collectModuleOptions(caddy.NewReplacer(), true) + _, err := app.collectModuleOptions(caddy.NewReplacer(), map[string]bool{}, true) require.NoError(t, err) require.NotNil(t, shared1.server) @@ -40,7 +40,7 @@ func TestRegisterModulesWithoutServerIndexGetOwnServers(t *testing.T) { indexed := &FrankenPHPModule{ServerIndex: 1, resolvedDocumentRoot: "../testdata"} app.modules = []*FrankenPHPModule{auto1, indexed, auto2} - _, err := app.collectModuleOptions(caddy.NewReplacer(), true) + _, err := app.collectModuleOptions(caddy.NewReplacer(), map[string]bool{}, true) require.NoError(t, err) require.NotNil(t, auto1.server) @@ -61,7 +61,7 @@ func TestRegisterModulesFirstModuleWinsPerIdx(t *testing.T) { second := &FrankenPHPModule{ServerIndex: 2, resolvedDocumentRoot: "../testdata/env"} app.modules = []*FrankenPHPModule{first, second} - opts, err := app.collectModuleOptions(caddy.NewReplacer(), true) + opts, err := app.collectModuleOptions(caddy.NewReplacer(), map[string]bool{}, true) require.NoError(t, err) require.Same(t, first.server, second.server) diff --git a/caddy/workerconfig.go b/caddy/workerconfig.go index d301c4e585..cfd692f199 100644 --- a/caddy/workerconfig.go +++ b/caddy/workerconfig.go @@ -23,7 +23,7 @@ import ( type workerConfig struct { mercureContext - // Name for the worker, unique within its php_server (or among global workers). Default: the absolute path of the worker file. + // Name for the worker. Default: the absolute path of the worker file, postfixed with a number if the name is already used. Name string `json:"name,omitempty"` // FileName sets the path to the worker script. FileName string `json:"file_name,omitempty"` diff --git a/cgi.go b/cgi.go index 35e95a21b7..65229b2a54 100644 --- a/cgi.go +++ b/cgi.go @@ -224,7 +224,7 @@ func splitCgiPath(fc *frankenPHPContext) { // see if a php_server worker or global worker matches the request path // aka: root + request path == worker.filename if fc.worker = fc.server.workersByPath[fc.scriptFilename]; fc.worker == nil { - fc.worker = fallbackServer.workersByPath[fc.scriptFilename] + fc.worker = globalWorkersByPath[fc.scriptFilename] } } diff --git a/context.go b/context.go index 8e20462c43..ace1e3dcdf 100644 --- a/context.go +++ b/context.go @@ -132,14 +132,20 @@ func newWorkerDummyContext(w *worker) (*frankenPHPContext, error) { return nil, err } + server := w.server + if server == nil { + // global worker, not associated with a server + server = fallbackServer + } + fc := &frankenPHPContext{ done: make(chan any), ctx: r.Context(), - server: w.server, + server: server, request: r, startedAt: time.Now(), // startup output of a scoped worker belongs to its server's logger - logger: w.server.logger, + logger: server.logger, worker: w, } @@ -160,6 +166,11 @@ func newWorkerDummyContext(w *worker) (*frankenPHPContext, error) { // newContextFromMessage creates a context from a message (external workers) func newContextFromMessage(message any, rw http.ResponseWriter, ctx context.Context, w *worker) *frankenPHPContext { + server := w.server + if server == nil { + server = fallbackServer + } + if ctx == nil { ctx = globalCtx } @@ -167,9 +178,9 @@ func newContextFromMessage(message any, rw http.ResponseWriter, ctx context.Cont return &frankenPHPContext{ done: make(chan any), startedAt: time.Now(), - server: w.server, + server: server, worker: w, - logger: w.server.logger, + logger: server.logger, responseWriter: rw, handlerParameters: message, ctx: ctx, diff --git a/docs/config.md b/docs/config.md index 4bc54e41c0..1aa9e62060 100644 --- a/docs/config.md +++ b/docs/config.md @@ -109,7 +109,7 @@ You can also explicitly configure FrankenPHP using the [global option](https://c num # Sets the number of PHP threads to start, defaults to 2x the number of available CPUs, 1 for background workers. env # Sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables. watch # Sets the path to watch for file changes. Can be specified more than once for multiple paths. - name # Sets the name of the worker, used in logs and metrics. Must be unique among global workers. Default: absolute path of the worker file, with a number appended when several workers share a script. + name # Sets the name of the worker, used in logs and metrics. Default: absolute path of worker file max_consecutive_failures # Sets the maximum number of consecutive failures before the worker is considered unhealthy, -1 means the worker will always restart. Default: 6. boot_timeout # EXPERIMENTAL: how long a background worker may take to reach its first tick() before the start fails, 0 waits forever. Default: 30s. background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, whose script ticks its FrankenPHP\WorkerHandle at least once @@ -189,14 +189,14 @@ php_server [] { root # Sets the root folder to the site. Default: `root` directive. split_path # Sets the substrings for splitting the URI into two parts. The first matching substring will be used to split the "path info" from the path. The first piece is suffixed with the matching substring and will be assumed as the actual resource (CGI script) name. The second piece will be set to PATH_INFO for the script to use. Default: `.php` resolve_root_symlink false # Disables resolving the `root` directory to its actual value by evaluating a symbolic link, if one exists (enabled by default). - name # Sets the name for this server, used to attribute workers, metrics and logs. Default: the first host matcher of the enclosing route, or the first listener address. Suffixed with a number if another php_server resolves to the same name. + name # Sets the name for this server, used to attribute workers, metrics and logs. Default: the first host matcher of the enclosing route, or the first listener address. env # Sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables. file_server off # Disables the built-in file_server directive. request_body_timeout # Sets an idle timeout on request body reads: a stalled (slow POST) client is cut off while a steady upload of any size succeeds. Default: 60s. Set to 0 to disable. worker { # Creates a worker specific to this server. Can be specified more than once for multiple workers. file # Sets the path to the worker script, can be relative to the php_server root num # Sets the number of PHP threads to start, defaults to 2x the number of available CPUs, 1 for background workers. - name # Sets the name for the worker, used in logs and metrics. Must be unique within this php_server. In logs, the worker is reported as ":", metrics carry the two as separate labels. Default: absolute path of the worker file, with a number appended when several workers share a script. + name # Sets the name for the worker, used in logs and metrics. Default: absolute path of worker file. Postfixed with a number if name is already in use. watch # Sets the path to watch for file changes. Can be specified more than once for multiple paths. env # Sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables. Environment variables for this worker are also inherited from the php_server parent, but can be overwritten here. match # match the worker to a path pattern. Overrides try_files and can only be used in the php_server directive. diff --git a/docs/library.md b/docs/library.md index b226f2c0c7..b21e04e67c 100644 --- a/docs/library.md +++ b/docs/library.md @@ -60,8 +60,6 @@ err := frankenphp.Init( Workers declared without a server scope are global: they match by file path on any server. Since a global worker has no set of requests to match against, combining `WithWorkerMatcher()` with a global worker is a configuration error and `Init()` rejects it. -Worker names are unique within their server (or among global workers), so two servers may each declare a worker named `queue`. The script sees the declared name, logs report a server-scoped worker as `:`, and metrics carry the two as separate labels; server names are made unique with a numeric suffix when needed. `WithWorkerName()` resolves a name within the request's server first, then among global workers. - `WithWorkerBackground()` declares a [background worker](worker.md#background-workers), which runs outside the request cycle. ## Per-request options diff --git a/docs/metrics.md b/docs/metrics.md index 165ad86881..0a68938959 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -15,17 +15,17 @@ When [Caddy metrics](https://caddyserver.com/docs/metrics) are enabled, FrankenP - `frankenphp_total_threads`: The total number of PHP threads. - `frankenphp_busy_threads`: The number of PHP threads currently processing a request (running workers always consume a thread). - `frankenphp_queue_depth`: The number of regular queued requests. -- `frankenphp_total_workers{worker="[worker_name]",server="[server_name]"}`: The total number of workers. -- `frankenphp_busy_workers{worker="[worker_name]",server="[server_name]"}`: The number of workers currently processing a request. -- `frankenphp_worker_request_time{worker="[worker_name]",server="[server_name]"}`: The time spent processing requests by all workers. -- `frankenphp_worker_request_count{worker="[worker_name]",server="[server_name]"}`: The number of requests processed by all workers. -- `frankenphp_ready_workers{worker="[worker_name]",server="[server_name]"}`: The number of workers that have reached their ready point at least once: `frankenphp_handle_request()` for HTTP workers, `WorkerHandle::tick()` for background workers. -- `frankenphp_worker_crashes{worker="[worker_name]",server="[server_name]"}`: The number of times a worker has unexpectedly terminated. -- `frankenphp_worker_restarts{worker="[worker_name]",server="[server_name]"}`: The number of times a worker has been deliberately restarted. -- `frankenphp_worker_queue_depth{worker="[worker_name]",server="[server_name]"}`: The number of queued requests. +- `frankenphp_total_workers{worker="[worker_name]"}`: The total number of workers. +- `frankenphp_busy_workers{worker="[worker_name]"}`: The number of workers currently processing a request. +- `frankenphp_worker_request_time{worker="[worker_name]"}`: The time spent processing requests by all workers. +- `frankenphp_worker_request_count{worker="[worker_name]"}`: The number of requests processed by all workers. +- `frankenphp_ready_workers{worker="[worker_name]"}`: The number of workers that have reached their ready point at least once: `frankenphp_handle_request()` for HTTP workers, `WorkerHandle::tick()` for background workers. +- `frankenphp_worker_crashes{worker="[worker_name]"}`: The number of times a worker has unexpectedly terminated. +- `frankenphp_worker_restarts{worker="[worker_name]"}`: The number of times a worker has been deliberately restarted. +- `frankenphp_worker_queue_depth{worker="[worker_name]"}`: The number of queued requests. - `frankenphp_opcache_restarts{reason="[reason]"}`: (experimental) The number of restarts of opcache's shared memory scheduled, by reason (`oom`, `hash`, `manual`). PHP 8.4 and up. -`[worker_name]` is the worker name from the Caddyfile, or the absolute path of the worker file when it has none, with a number appended when several workers share a script. `[server_name]` is the name of the `php_server` block the worker belongs to, and is empty for a worker declared in the global `frankenphp` block. The two stay apart, so a query on the worker name alone still selects that worker in every server. +For worker metrics, the `[worker_name]` placeholder is replaced by the worker name in the Caddyfile, otherwise the absolute path of the worker file will be used. opcache schedules a restart when its cache is full and more than `opcache.max_wasted_percentage` of it was wasted by invalidations. The restart runs at the next request start; caching is off until then, and running PHP threads may still reference the old memory, which can crash the process. The counter should stay at zero: raise `opcache.memory_consumption`, `opcache.max_accelerated_files` or `opcache.max_wasted_percentage`. `manual` should never appear, FrankenPHP overrides `opcache_reset()`. Each restart is also logged. This metric will be removed once opcache handles restarts safely under ZTS. diff --git a/frankenphp.go b/frankenphp.go index 60ce157828..1a4a134d56 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -64,7 +64,7 @@ var ( globalCtx = context.Background() globalLogger = slog.Default() - metrics workerMetrics = nullMetrics{} + metrics Metrics = nullMetrics{} // atomic: read by in-flight requests while a reload may rewrite it maxWaitTime atomic.Int64 @@ -337,30 +337,26 @@ func Validate(options ...Option) error { return err } - // what each scope took so far, a nil server for the global workers - takenNames := make(map[*Server]map[string]bool, 1) + takenNames := make(map[string]bool, len(opt.workers)) takenPaths := make(map[*Server]map[string]bool, 1) - nameTaken := func(server *Server, name string) bool { return takenNames[server][name] } + nameTaken := func(name string) bool { return takenNames[name] } pathTaken := func(server *Server, path string) bool { return takenPaths[server][path] } - take := func(taken map[*Server]map[string]bool, server *Server, key string) { - if taken[server] == nil { - taken[server] = make(map[string]bool) - } - taken[server][key] = true - } for _, w := range opt.workers { - w, err := resolveWorkerFile(w, nameTaken) + w, err := resolveWorkerFile(w) if err != nil { return err } - if err := checkWorkerDeclaration(w, opt.servers, nameTaken, pathTaken); err != nil { + if err := checkWorkerDeclaration(w, nameTaken, pathTaken); err != nil { return err } - take(takenNames, w.server, w.name) + takenNames[w.name] = true if !w.isBackgroundWorker && w.matchRequest == nil { - take(takenPaths, w.server, w.fileName) + if takenPaths[w.server] == nil { + takenPaths[w.server] = make(map[string]bool) + } + takenPaths[w.server][w.fileName] = true } } @@ -401,7 +397,7 @@ func Init(options ...Option) error { } if opt.metrics != nil { - metrics = adaptMetrics(opt.metrics) + metrics = opt.metrics } maxWaitTime.Store(int64(opt.maxWaitTime)) @@ -422,6 +418,9 @@ func Init(options ...Option) error { // background workers run on threads of their own, on top of the budget // num_threads and max_threads describe for HTTP traffic metrics.TotalThreads(opt.numThreads + backgroundThreads) + for _, w := range opt.workers { + metrics.TotalWorkers(w.name, w.num) + } config := Config() @@ -605,7 +604,7 @@ func go_apache_request_headers(threadIndex C.uintptr_t) (*C.go_string, C.size_t) // worker mode, not handling a request if fc.logger.Enabled(fc.ctx, slog.LevelDebug) { - fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "apache_request_headers() called in non-HTTP context", slog.String("worker", fc.worker.qualifiedName)) + fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "apache_request_headers() called in non-HTTP context", slog.String("worker", fc.worker.name)) } return nil, 0 @@ -939,7 +938,9 @@ func opcacheRestartScheduled(reason int) { reasonText = opcacheRestartReasons[reason] } - metrics.OpcacheRestart(reasonText) + if m, ok := metrics.(OpcacheMetrics); ok { + m.OpcacheRestart(reasonText) + } if !globalLogger.Enabled(globalCtx, slog.LevelWarn) { return @@ -980,7 +981,8 @@ func resetGlobals() { globalCtx = context.Background() globalLogger = slog.Default() workers = nil - fallbackServer.resetWorkers() + workersByName = nil + globalWorkersByPath = nil servers = nil watcherIsEnabled = false hotReloadEnabled = false diff --git a/frankenphp_test.go b/frankenphp_test.go index 961860732c..84199ba81d 100644 --- a/frankenphp_test.go +++ b/frankenphp_test.go @@ -1667,7 +1667,7 @@ func TestValidateReportsDeclarationErrors(t *testing.T) { assert.ErrorContains(t, frankenphp.Validate( frankenphp.WithWorkers("same", "testdata/worker.php", 1), frankenphp.WithWorkers("same", "testdata/index.php", 1), - ), "two global workers cannot have the same name") + ), "two workers cannot have the same name") assert.ErrorContains(t, frankenphp.Validate( frankenphp.WithWorkers("one", "testdata/worker.php", 1), @@ -1690,20 +1690,6 @@ func TestValidateReportsDeclarationErrors(t *testing.T) { frankenphp.WithWorkers("two", "testdata/worker.php", 1, frankenphp.WithWorkerServerScope(server), matchAll), )) - other, err := frankenphp.NewServer(testDataDir) - require.NoError(t, err) - assert.ErrorContains(t, frankenphp.Validate( - frankenphp.WithServer(server), - frankenphp.WithWorkers("one", "testdata/worker.php", 1, frankenphp.WithWorkerServerScope(other)), - ), "was not passed to WithServer()") - - // names are unique within their scope, not across servers - assert.NoError(t, frankenphp.Validate( - frankenphp.WithServer(server), - frankenphp.WithWorkers("same", "testdata/worker.php", 1), - frankenphp.WithWorkers("same", "testdata/index.php", 1, frankenphp.WithWorkerServerScope(server)), - )) - assert.ErrorContains(t, frankenphp.Validate( frankenphp.WithNumThreads(2), frankenphp.WithMaxThreads(1), diff --git a/metrics.go b/metrics.go index a134776ccd..a27e02630d 100644 --- a/metrics.go +++ b/metrics.go @@ -17,10 +17,8 @@ const ( type StopReason int // Metrics reports what the workers and the threads of a FrankenPHP instance -// are doing. A worker is identified by its name alone, where a worker scoped -// to a server is reported as ":". An implementation that -// also satisfies ServerMetrics gets the two apart instead, and one that -// satisfies OpcacheMetrics is told about opcache restarts as well. +// are doing. An implementation that also satisfies OpcacheMetrics is told +// about opcache restarts as well. type Metrics interface { // StartWorker collects started workers StartWorker(name string) @@ -55,149 +53,18 @@ type OpcacheMetrics interface { OpcacheRestart(reason string) } -// ServerMetrics is the optional part of a Metrics implementation that keeps -// the name of a worker and the name of its server apart, the latter empty for -// a global worker, so a series keyed on the name alone still selects the -// worker of every server. An implementation passed to WithMetrics() that -// satisfies it never has the worker methods of Metrics called. -type ServerMetrics interface { - StartWorkerOnServer(name, server string) - ReadyWorkerOnServer(name, server string) - StopWorkerOnServer(name, server string, reason StopReason) - TotalWorkersOnServer(name, server string, num int) - StopWorkerRequestOnServer(name, server string, duration time.Duration) - StartWorkerRequestOnServer(name, server string) - QueuedWorkerRequestOnServer(name, server string) - DequeuedWorkerRequestOnServer(name, server string) -} - -// workerMetrics is what the runtime reports on: Metrics with the worker -// methods taking the server as well -type workerMetrics interface { - StartWorker(name, server string) - ReadyWorker(name, server string) - StopWorker(name, server string, reason StopReason) - TotalWorkers(name, server string, num int) - TotalThreads(num int) - StartRequest() - StopRequest() - StopWorkerRequest(name, server string, duration time.Duration) - StartWorkerRequest(name, server string) - Shutdown() - QueuedWorkerRequest(name, server string) - DequeuedWorkerRequest(name, server string) - QueuedRequest() - DequeuedRequest() - OpcacheRestart(reason string) -} - -// metricsAdapter routes the worker methods to ServerMetrics when the -// implementation has it, and packs the server into the name otherwise; -// opcache restarts reach it only when it satisfies OpcacheMetrics -type metricsAdapter struct { - Metrics - server ServerMetrics - opcache OpcacheMetrics -} - -func adaptMetrics(m Metrics) workerMetrics { - a := metricsAdapter{Metrics: m} - a.server, _ = m.(ServerMetrics) - a.opcache, _ = m.(OpcacheMetrics) - - return a -} - -func (a metricsAdapter) OpcacheRestart(reason string) { - if a.opcache != nil { - a.opcache.OpcacheRestart(reason) - } -} - -func packedWorkerName(name, server string) string { - if server == "" { - return name - } - - return server + ":" + name -} - -func (a metricsAdapter) StartWorker(name, server string) { - if a.server != nil { - a.server.StartWorkerOnServer(name, server) - return - } - a.Metrics.StartWorker(packedWorkerName(name, server)) -} - -func (a metricsAdapter) ReadyWorker(name, server string) { - if a.server != nil { - a.server.ReadyWorkerOnServer(name, server) - return - } - a.Metrics.ReadyWorker(packedWorkerName(name, server)) -} - -func (a metricsAdapter) StopWorker(name, server string, reason StopReason) { - if a.server != nil { - a.server.StopWorkerOnServer(name, server, reason) - return - } - a.Metrics.StopWorker(packedWorkerName(name, server), reason) -} - -func (a metricsAdapter) TotalWorkers(name, server string, num int) { - if a.server != nil { - a.server.TotalWorkersOnServer(name, server, num) - return - } - a.Metrics.TotalWorkers(packedWorkerName(name, server), num) -} - -func (a metricsAdapter) StopWorkerRequest(name, server string, duration time.Duration) { - if a.server != nil { - a.server.StopWorkerRequestOnServer(name, server, duration) - return - } - a.Metrics.StopWorkerRequest(packedWorkerName(name, server), duration) -} - -func (a metricsAdapter) StartWorkerRequest(name, server string) { - if a.server != nil { - a.server.StartWorkerRequestOnServer(name, server) - return - } - a.Metrics.StartWorkerRequest(packedWorkerName(name, server)) -} - -func (a metricsAdapter) QueuedWorkerRequest(name, server string) { - if a.server != nil { - a.server.QueuedWorkerRequestOnServer(name, server) - return - } - a.Metrics.QueuedWorkerRequest(packedWorkerName(name, server)) -} - -func (a metricsAdapter) DequeuedWorkerRequest(name, server string) { - if a.server != nil { - a.server.DequeuedWorkerRequestOnServer(name, server) - return - } - a.Metrics.DequeuedWorkerRequest(packedWorkerName(name, server)) -} - type nullMetrics struct{} -func (n nullMetrics) StartWorker(string, string) { +func (n nullMetrics) StartWorker(string) { } -func (n nullMetrics) ReadyWorker(string, string) { +func (n nullMetrics) ReadyWorker(string) { } -func (n nullMetrics) StopWorker(string, string, StopReason) { +func (n nullMetrics) StopWorker(string, StopReason) { } -func (n nullMetrics) TotalWorkers(string, string, int) { +func (n nullMetrics) TotalWorkers(string, int) { } func (n nullMetrics) TotalThreads(int) { @@ -209,22 +76,21 @@ func (n nullMetrics) StartRequest() { func (n nullMetrics) StopRequest() { } -func (n nullMetrics) StopWorkerRequest(string, string, time.Duration) { +func (n nullMetrics) StopWorkerRequest(string, time.Duration) { } -func (n nullMetrics) StartWorkerRequest(string, string) { +func (n nullMetrics) StartWorkerRequest(string) { } func (n nullMetrics) Shutdown() { } -func (n nullMetrics) QueuedWorkerRequest(string, string) {} +func (n nullMetrics) QueuedWorkerRequest(string) {} -func (n nullMetrics) DequeuedWorkerRequest(string, string) {} +func (n nullMetrics) DequeuedWorkerRequest(string) {} -func (n nullMetrics) QueuedRequest() {} -func (n nullMetrics) DequeuedRequest() {} -func (n nullMetrics) OpcacheRestart(string) {} +func (n nullMetrics) QueuedRequest() {} +func (n nullMetrics) DequeuedRequest() {} type PrometheusMetrics struct { registry prometheus.Registerer @@ -254,33 +120,10 @@ func (m *PrometheusMetrics) mustRegister(c prometheus.Collector) { var ( _ Metrics = (*PrometheusMetrics)(nil) - _ ServerMetrics = (*PrometheusMetrics)(nil) _ OpcacheMetrics = (*PrometheusMetrics)(nil) ) -func (m *PrometheusMetrics) StartWorker(name string) { m.StartWorkerOnServer(name, "") } - -func (m *PrometheusMetrics) ReadyWorker(name string) { m.ReadyWorkerOnServer(name, "") } - -func (m *PrometheusMetrics) StopWorker(name string, reason StopReason) { - m.StopWorkerOnServer(name, "", reason) -} - -func (m *PrometheusMetrics) TotalWorkers(name string, num int) { m.TotalWorkersOnServer(name, "", num) } - -func (m *PrometheusMetrics) StopWorkerRequest(name string, duration time.Duration) { - m.StopWorkerRequestOnServer(name, "", duration) -} - -func (m *PrometheusMetrics) StartWorkerRequest(name string) { m.StartWorkerRequestOnServer(name, "") } - -func (m *PrometheusMetrics) QueuedWorkerRequest(name string) { m.QueuedWorkerRequestOnServer(name, "") } - -func (m *PrometheusMetrics) DequeuedWorkerRequest(name string) { - m.DequeuedWorkerRequestOnServer(name, "") -} - -func (m *PrometheusMetrics) StartWorkerOnServer(name, server string) { +func (m *PrometheusMetrics) StartWorker(name string) { m.mu.RLock() defer m.mu.RUnlock() @@ -291,10 +134,10 @@ func (m *PrometheusMetrics) StartWorkerOnServer(name, server string) { return } - m.totalWorkers.WithLabelValues(name, server).Inc() + m.totalWorkers.WithLabelValues(name).Inc() } -func (m *PrometheusMetrics) ReadyWorkerOnServer(name, server string) { +func (m *PrometheusMetrics) ReadyWorker(name string) { m.mu.RLock() defer m.mu.RUnlock() @@ -302,10 +145,10 @@ func (m *PrometheusMetrics) ReadyWorkerOnServer(name, server string) { return } - m.readyWorkers.WithLabelValues(name, server).Inc() + m.readyWorkers.WithLabelValues(name).Inc() } -func (m *PrometheusMetrics) StopWorkerOnServer(name, server string, reason StopReason) { +func (m *PrometheusMetrics) StopWorker(name string, reason StopReason) { m.mu.RLock() defer m.mu.RUnlock() @@ -316,29 +159,27 @@ func (m *PrometheusMetrics) StopWorkerOnServer(name, server string, reason StopR return } - m.totalWorkers.WithLabelValues(name, server).Dec() + m.totalWorkers.WithLabelValues(name).Dec() // only decrement readyWorkers if the worker actually reached its ready point if reason != StopReasonBootFailure { - m.readyWorkers.WithLabelValues(name, server).Dec() + m.readyWorkers.WithLabelValues(name).Dec() } switch reason { case StopReasonCrash, StopReasonBootFailure: - m.workerCrashes.WithLabelValues(name, server).Inc() + m.workerCrashes.WithLabelValues(name).Inc() case StopReasonRestart: - m.workerRestarts.WithLabelValues(name, server).Inc() + m.workerRestarts.WithLabelValues(name).Inc() } } -func (m *PrometheusMetrics) TotalWorkersOnServer(string, string, int) { +func (m *PrometheusMetrics) TotalWorkers(string, int) { m.mu.Lock() defer m.mu.Unlock() const ns, sub = "frankenphp", "worker" - // a worker of a php_server keeps its declared name, the block it belongs - // to is a label of its own, empty for a global worker - basicLabels := []string{"worker", "server"} + basicLabels := []string{"worker"} if m.totalWorkers == nil { m.totalWorkers = prometheus.NewGaugeVec(prometheus.GaugeOpts{ @@ -436,7 +277,7 @@ func (m *PrometheusMetrics) StopRequest() { m.busyThreads.Dec() } -func (m *PrometheusMetrics) StopWorkerRequestOnServer(name, server string, duration time.Duration) { +func (m *PrometheusMetrics) StopWorkerRequest(name string, duration time.Duration) { m.mu.RLock() defer m.mu.RUnlock() @@ -444,39 +285,39 @@ func (m *PrometheusMetrics) StopWorkerRequestOnServer(name, server string, durat return } - m.workerRequestCount.WithLabelValues(name, server).Inc() - m.busyWorkers.WithLabelValues(name, server).Dec() - m.workerRequestTime.WithLabelValues(name, server).Add(duration.Seconds()) + m.workerRequestCount.WithLabelValues(name).Inc() + m.busyWorkers.WithLabelValues(name).Dec() + m.workerRequestTime.WithLabelValues(name).Add(duration.Seconds()) } -func (m *PrometheusMetrics) StartWorkerRequestOnServer(name, server string) { +func (m *PrometheusMetrics) StartWorkerRequest(name string) { m.mu.RLock() defer m.mu.RUnlock() if m.busyWorkers == nil { return } - m.busyWorkers.WithLabelValues(name, server).Inc() + m.busyWorkers.WithLabelValues(name).Inc() } -func (m *PrometheusMetrics) QueuedWorkerRequestOnServer(name, server string) { +func (m *PrometheusMetrics) QueuedWorkerRequest(name string) { m.mu.RLock() defer m.mu.RUnlock() if m.workerQueueDepth == nil { return } - m.workerQueueDepth.WithLabelValues(name, server).Inc() + m.workerQueueDepth.WithLabelValues(name).Inc() } -func (m *PrometheusMetrics) DequeuedWorkerRequestOnServer(name, server string) { +func (m *PrometheusMetrics) DequeuedWorkerRequest(name string) { m.mu.RLock() defer m.mu.RUnlock() if m.workerQueueDepth == nil { return } - m.workerQueueDepth.WithLabelValues(name, server).Dec() + m.workerQueueDepth.WithLabelValues(name).Dec() } func (m *PrometheusMetrics) QueuedRequest() { diff --git a/metrics_test.go b/metrics_test.go index dc47d82df0..5910c67808 100644 --- a/metrics_test.go +++ b/metrics_test.go @@ -54,7 +54,7 @@ func TestPrometheusMetrics_TotalWorkers(t *testing.T) { require.Nil(t, m.workerRequestTime) require.Nil(t, m.workerRequestCount) - m.TotalWorkersOnServer("test_worker", "test_server", 2) + m.TotalWorkers("test_worker", 2) require.NotNil(t, m.totalWorkers) require.NotNil(t, m.busyWorkers) @@ -67,8 +67,8 @@ func TestPrometheusMetrics_TotalWorkers(t *testing.T) { func TestPrometheusMetrics_StopWorkerRequest(t *testing.T) { m := createPrometheusMetrics() - m.TotalWorkersOnServer("test_worker", "test_server", 2) - m.StopWorkerRequestOnServer("test_worker", "test_server", 2*time.Second) + m.TotalWorkers("test_worker", 2) + m.StopWorkerRequest("test_worker", 2*time.Second) inputs := []struct { name string @@ -84,7 +84,7 @@ func TestPrometheusMetrics_StopWorkerRequest(t *testing.T) { # TYPE frankenphp_worker_request_count counter `, expect: ` - frankenphp_worker_request_count{server="test_server",worker="test_worker"} 1 + frankenphp_worker_request_count{worker="test_worker"} 1 `, }, { @@ -95,7 +95,7 @@ func TestPrometheusMetrics_StopWorkerRequest(t *testing.T) { # TYPE frankenphp_busy_workers gauge `, expect: ` - frankenphp_busy_workers{server="test_server",worker="test_worker"} -1 + frankenphp_busy_workers{worker="test_worker"} -1 `, }, { @@ -106,7 +106,7 @@ func TestPrometheusMetrics_StopWorkerRequest(t *testing.T) { # TYPE frankenphp_worker_request_time counter `, expect: ` - frankenphp_worker_request_time{server="test_server",worker="test_worker"} 2 + frankenphp_worker_request_time{worker="test_worker"} 2 `, }, } @@ -121,8 +121,8 @@ func TestPrometheusMetrics_StopWorkerRequest(t *testing.T) { func TestPrometheusMetrics_StartWorkerRequest(t *testing.T) { m := createPrometheusMetrics() - m.TotalWorkersOnServer("test_worker", "test_server", 2) - m.StartWorkerRequestOnServer("test_worker", "test_server") + m.TotalWorkers("test_worker", 2) + m.StartWorkerRequest("test_worker") inputs := []struct { name string @@ -138,7 +138,7 @@ func TestPrometheusMetrics_StartWorkerRequest(t *testing.T) { # TYPE frankenphp_busy_workers gauge `, expect: ` - frankenphp_busy_workers{server="test_server",worker="test_worker"} 1 + frankenphp_busy_workers{worker="test_worker"} 1 `, }, } @@ -153,8 +153,8 @@ func TestPrometheusMetrics_StartWorkerRequest(t *testing.T) { func TestPrometheusMetrics_TestStopReasonCrash(t *testing.T) { m := createPrometheusMetrics() - m.TotalWorkersOnServer("test_worker", "test_server", 2) - m.StopWorkerOnServer("test_worker", "test_server", StopReasonCrash) + m.TotalWorkers("test_worker", 2) + m.StopWorker("test_worker", StopReasonCrash) inputs := []struct { name string @@ -181,7 +181,7 @@ func TestPrometheusMetrics_TestStopReasonCrash(t *testing.T) { # TYPE frankenphp_total_workers gauge `, expect: ` - frankenphp_total_workers{server="test_server",worker="test_worker"} -1 + frankenphp_total_workers{worker="test_worker"} -1 `, }, { @@ -192,7 +192,7 @@ func TestPrometheusMetrics_TestStopReasonCrash(t *testing.T) { # TYPE frankenphp_ready_workers gauge `, expect: ` - frankenphp_ready_workers{server="test_server",worker="test_worker"} -1 + frankenphp_ready_workers{worker="test_worker"} -1 `, }, { @@ -203,7 +203,7 @@ func TestPrometheusMetrics_TestStopReasonCrash(t *testing.T) { # TYPE frankenphp_worker_crashes counter `, expect: ` - frankenphp_worker_crashes{server="test_server",worker="test_worker"} 1 + frankenphp_worker_crashes{worker="test_worker"} 1 `, }, } @@ -256,7 +256,7 @@ func TestOpcacheRestartScheduledLogsAndCounts(t *testing.T) { var buf bytes.Buffer m := NewPrometheusMetrics(prometheus.NewRegistry()) prevLogger, prevMetrics := globalLogger, metrics - globalLogger, metrics = slog.New(slog.NewTextHandler(&buf, nil)), adaptMetrics(m) + globalLogger, metrics = slog.New(slog.NewTextHandler(&buf, nil)), m t.Cleanup(func() { globalLogger, metrics = prevLogger, prevMetrics }) opcacheRestartScheduled(1) @@ -270,115 +270,16 @@ func TestOpcacheRestartScheduledLogsAndCounts(t *testing.T) { } func TestOpcacheRestartScheduledWithoutOpcacheMetrics(t *testing.T) { - // splitMetrics is a Metrics without the optional part, like an + // nullMetrics is a Metrics without the optional part, like an // implementation from before OpcacheMetrics existed - m := &splitMetrics{t: t} - _, ok := Metrics(m).(OpcacheMetrics) + _, ok := Metrics(nullMetrics{}).(OpcacheMetrics) require.False(t, ok) var buf bytes.Buffer prevLogger, prevMetrics := globalLogger, metrics - globalLogger, metrics = slog.New(slog.NewTextHandler(&buf, nil)), adaptMetrics(m) + globalLogger, metrics = slog.New(slog.NewTextHandler(&buf, nil)), nullMetrics{} t.Cleanup(func() { globalLogger, metrics = prevLogger, prevMetrics }) assert.NotPanics(t, func() { opcacheRestartScheduled(0) }) assert.Contains(t, buf.String(), "reason=oom") } - -// packedMetrics records the worker names a Metrics implementation without -// ServerMetrics receives -type packedMetrics struct { - nullMetrics - names []string -} - -func (m *packedMetrics) StartWorker(name string) { m.names = append(m.names, name) } -func (m *packedMetrics) ReadyWorker(name string) { m.names = append(m.names, name) } -func (m *packedMetrics) StopWorker(name string, _ StopReason) { m.names = append(m.names, name) } -func (m *packedMetrics) TotalWorkers(name string, _ int) { m.names = append(m.names, name) } -func (m *packedMetrics) StopWorkerRequest(name string, _ time.Duration) { - m.names = append(m.names, name) -} -func (m *packedMetrics) StartWorkerRequest(name string) { m.names = append(m.names, name) } -func (m *packedMetrics) QueuedWorkerRequest(name string) { m.names = append(m.names, name) } -func (m *packedMetrics) DequeuedWorkerRequest(name string) { m.names = append(m.names, name) } - -// splitMetrics records the name and server pairs a ServerMetrics -// implementation receives, and fails the test if a packed method is called -type splitMetrics struct { - t *testing.T - pairs [][2]string -} - -func (m *splitMetrics) record(name, server string) { - m.pairs = append(m.pairs, [2]string{name, server}) -} - -func (m *splitMetrics) StartWorkerOnServer(name, server string) { m.record(name, server) } -func (m *splitMetrics) ReadyWorkerOnServer(name, server string) { m.record(name, server) } -func (m *splitMetrics) StopWorkerOnServer(name, server string, _ StopReason) { m.record(name, server) } -func (m *splitMetrics) TotalWorkersOnServer(name, server string, _ int) { m.record(name, server) } -func (m *splitMetrics) StopWorkerRequestOnServer(name, server string, _ time.Duration) { - m.record(name, server) -} -func (m *splitMetrics) StartWorkerRequestOnServer(name, server string) { m.record(name, server) } -func (m *splitMetrics) QueuedWorkerRequestOnServer(name, server string) { m.record(name, server) } -func (m *splitMetrics) DequeuedWorkerRequestOnServer(name, server string) { m.record(name, server) } - -func (m *splitMetrics) StartWorker(string) { m.t.Fatal("packed StartWorker called") } -func (m *splitMetrics) ReadyWorker(string) { m.t.Fatal("packed ReadyWorker called") } -func (m *splitMetrics) StopWorker(string, StopReason) { m.t.Fatal("packed StopWorker called") } -func (m *splitMetrics) TotalWorkers(string, int) { m.t.Fatal("packed TotalWorkers called") } -func (m *splitMetrics) TotalThreads(int) {} -func (m *splitMetrics) StartRequest() {} -func (m *splitMetrics) StopRequest() {} -func (m *splitMetrics) StopWorkerRequest(string, time.Duration) { - m.t.Fatal("packed StopWorkerRequest called") -} -func (m *splitMetrics) StartWorkerRequest(string) { m.t.Fatal("packed StartWorkerRequest called") } -func (m *splitMetrics) Shutdown() {} -func (m *splitMetrics) QueuedWorkerRequest(string) { m.t.Fatal("packed QueuedWorkerRequest called") } -func (m *splitMetrics) DequeuedWorkerRequest(string) { - m.t.Fatal("packed DequeuedWorkerRequest called") -} -func (m *splitMetrics) QueuedRequest() {} -func (m *splitMetrics) DequeuedRequest() {} - -func reportEveryWorkerMethod(m workerMetrics, name, server string) { - m.StartWorker(name, server) - m.ReadyWorker(name, server) - m.StopWorker(name, server, StopReasonRestart) - m.TotalWorkers(name, server, 1) - m.StopWorkerRequest(name, server, time.Second) - m.StartWorkerRequest(name, server) - m.QueuedWorkerRequest(name, server) - m.DequeuedWorkerRequest(name, server) -} - -// a Metrics implementation without ServerMetrics gets the packed name, the -// bare name for a global worker -func TestMetricsAdapterPacksTheServerIntoTheName(t *testing.T) { - m := &packedMetrics{} - a := adaptMetrics(m) - - reportEveryWorkerMethod(a, "queue", "api") - reportEveryWorkerMethod(a, "queue", "") - - require.Len(t, m.names, 16) - assert.Equal(t, []string{"api:queue", "api:queue", "api:queue", "api:queue", "api:queue", "api:queue", "api:queue", "api:queue"}, m.names[:8]) - assert.Equal(t, []string{"queue", "queue", "queue", "queue", "queue", "queue", "queue", "queue"}, m.names[8:]) -} - -// a ServerMetrics implementation gets the two names apart and its packed -// methods are never called -func TestMetricsAdapterKeepsTheServerApart(t *testing.T) { - m := &splitMetrics{t: t} - a := adaptMetrics(m) - - reportEveryWorkerMethod(a, "queue", "api") - - require.Len(t, m.pairs, 8) - for _, pair := range m.pairs { - assert.Equal(t, [2]string{"queue", "api"}, pair) - } -} diff --git a/phpmainthread_test.go b/phpmainthread_test.go index 69428f67de..d236cd5d40 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -252,22 +252,28 @@ func TestFinishBootingAWorkerScript(t *testing.T) { func TestReturnAnErrorIf2WorkersHaveTheSameFileName(t *testing.T) { resetGlobals() - fallbackServer.resetWorkers() - w1, err := newWorker(workerOpt{fileName: testDataPath + "/index.php"}) - assert.NoError(t, err) - assert.NoError(t, fallbackServer.addWorker(w1)) - _, err = newWorker(workerOpt{fileName: testDataPath + "/index.php", name: "other"}) - assert.ErrorContains(t, err, "two global workers cannot have the same filename") + workers = []*worker{} + workersByName = map[string]*worker{} + globalWorkersByPath = map[string]*worker{} + w, err1 := newWorker(workerOpt{fileName: testDataPath + "/index.php"}) + assert.NoError(t, err1) + workers = append(workers, w) + workersByName[w.name] = w + globalWorkersByPath[w.fileName] = w + _, err2 := newWorker(workerOpt{fileName: testDataPath + "/index.php"}) + assert.Error(t, err2, "two workers cannot have the same filename") } func TestReturnAnErrorIf2ModuleWorkersHaveTheSameName(t *testing.T) { resetGlobals() - fallbackServer.resetWorkers() - w1, err := newWorker(workerOpt{fileName: testDataPath + "/index.php", name: "workername"}) - assert.NoError(t, err) - assert.NoError(t, fallbackServer.addWorker(w1)) - _, err = newWorker(workerOpt{fileName: testDataPath + "/hello.php", name: "workername"}) - assert.ErrorContains(t, err, "two global workers cannot have the same name") + workers = []*worker{} + workersByName = map[string]*worker{} + w, err1 := newWorker(workerOpt{fileName: testDataPath + "/index.php", name: "workername"}) + assert.NoError(t, err1) + workers = append(workers, w) + workersByName[w.name] = w + _, err2 := newWorker(workerOpt{fileName: testDataPath + "/hello.php", name: "workername"}) + assert.Error(t, err2, "two workers cannot have the same name") } func getDummyWorker(t *testing.T, fileName string) *worker { @@ -309,9 +315,9 @@ func allPossibleTransitions(worker1Path string, worker2Path string) []func(*phpT thread.boot() } }, - func(thread *phpThread) { convertToWorkerThread(thread, fallbackServer.workersByPath[worker1Path]) }, + func(thread *phpThread) { convertToWorkerThread(thread, globalWorkersByPath[worker1Path]) }, convertToInactiveThread, - func(thread *phpThread) { convertToWorkerThread(thread, fallbackServer.workersByPath[worker2Path]) }, + func(thread *phpThread) { convertToWorkerThread(thread, globalWorkersByPath[worker2Path]) }, convertToInactiveThread, } } @@ -387,8 +393,7 @@ func TestContextAndLoggerMustNotBeNil(t *testing.T) { assert.NotNil(t, log, "logger is defined if all threads are inactive") assert.NotNil(t, ctx, "context is defined if all threads are inactive") - // a worker always belongs to a server, see newWorker() - fc := newContextFromMessage(nil, nil, nil, &worker{server: fallbackServer}) + fc := newContextFromMessage(nil, nil, nil, &worker{}) assert.NotNil(t, fc.logger, "logger is defined for message context") assert.NotNil(t, fc.ctx, "context is defined for message context") @@ -397,7 +402,7 @@ func TestContextAndLoggerMustNotBeNil(t *testing.T) { assert.NotNil(t, fc.logger, "logger is defined for request context") assert.NotNil(t, fc.ctx, "context is defined for request context") - fc, _ = newWorkerDummyContext(&worker{server: fallbackServer}) + fc, _ = newWorkerDummyContext(&worker{}) assert.NotNil(t, fc.logger, "logger is defined for worker dummy context") assert.NotNil(t, fc.ctx, "context is defined for worker dummy context") } diff --git a/requestoptions.go b/requestoptions.go index d424d20b16..5c05eee0e3 100644 --- a/requestoptions.go +++ b/requestoptions.go @@ -206,27 +206,14 @@ func WithRequestBodyTimeout(timeout time.Duration) RequestOption { } } -// withWorker dispatches the request on a worker directly, no lookup -func withWorker(w *worker) RequestOption { - return func(o *frankenPHPContext) error { - o.worker = w - - return nil - } -} - // WithWorkerName sets the worker that should handle the request -// the name is resolved among the workers of the request's server first, then among global workers func WithWorkerName(name string) RequestOption { return func(o *frankenPHPContext) error { if name == "" { return nil } - w := o.server.workersByName[name] - if w == nil { - w = fallbackServer.workersByName[name] - } + w := workersByName[name] if w != nil && w.isBackgroundWorker { return fmt.Errorf("background worker %q cannot handle requests", name) } diff --git a/scaling.go b/scaling.go index c26465efbd..dd21a7e37c 100644 --- a/scaling.go +++ b/scaling.go @@ -96,7 +96,7 @@ func scaleWorkerThread(worker *worker, done chan struct{}, mstate *state.ThreadS thread, err := addWorkerThread(worker) if err != nil { if globalLogger.Enabled(globalCtx, slog.LevelWarn) { - globalLogger.LogAttrs(globalCtx, slog.LevelWarn, "could not increase max_threads, consider raising this limit", slog.String("worker", worker.qualifiedName), slog.Any("error", err)) + globalLogger.LogAttrs(globalCtx, slog.LevelWarn, "could not increase max_threads, consider raising this limit", slog.String("worker", worker.name), slog.Any("error", err)) } return @@ -105,7 +105,7 @@ func scaleWorkerThread(worker *worker, done chan struct{}, mstate *state.ThreadS autoScaledThreads = append(autoScaledThreads, thread) if globalLogger.Enabled(globalCtx, slog.LevelInfo) { - globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "upscaling worker thread", slog.String("worker", worker.qualifiedName), slog.Int("thread", thread.threadIndex), slog.Int("num_threads", len(autoScaledThreads))) + globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "upscaling worker thread", slog.String("worker", worker.name), slog.Int("thread", thread.threadIndex), slog.Int("num_threads", len(autoScaledThreads))) } } @@ -177,7 +177,7 @@ func startUpscalingThreads(maxScaledThreads int, scale chan *frankenPHPContext, // check for max worker threads here again in case requests overflowed while waiting if fc.worker.isAtThreadLimit() { if globalLogger.Enabled(globalCtx, slog.LevelInfo) { - globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "cannot scale worker thread, max threads reached for worker", slog.String("worker", fc.worker.qualifiedName)) + globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "cannot scale worker thread, max threads reached for worker", slog.String("worker", fc.worker.name)) } continue diff --git a/scaling_test.go b/scaling_test.go index af198e3b54..9a6d12da6d 100644 --- a/scaling_test.go +++ b/scaling_test.go @@ -49,7 +49,7 @@ func TestScaleAWorkerThreadUpAndDown(t *testing.T) { autoScaledThread := phpThreads[2] // scale up - scaleWorkerThread(fallbackServer.workersByPath[workerPath], mainThread.done, mainThread.state) + scaleWorkerThread(globalWorkersByPath[workerPath], mainThread.done, mainThread.state) assert.Equal(t, state.Ready, autoScaledThread.state.Get()) // on down-scale, the thread will be marked as inactive diff --git a/server.go b/server.go index 3de9c72123..ef25cdc30f 100644 --- a/server.go +++ b/server.go @@ -20,7 +20,7 @@ type Server struct { root string splitPath []string env PreparedEnv - workersByName map[string]*worker + workers []*worker workersByPath map[string]*worker workersWithRequestMatcher []*worker @@ -35,15 +35,13 @@ var ( fallbackServer = newFallbackServer() ) -// newFallbackServer creates the server of requests and workers that are not -// scoped to one, so a lookup is always a lookup in a server func newFallbackServer() *Server { s := &Server{ - idx: -1, - env: make(map[string]string), - logger: globalLogger, + idx: -1, + workersByPath: make(map[string]*worker), + env: make(map[string]string), + logger: globalLogger, } - s.resetWorkers() return s } @@ -56,35 +54,12 @@ func registerServers(newServers []*Server) { fallbackServer.logger = globalLogger fallbackServer.resetWorkers() - // the name qualifies worker names in metrics and logs, so it must be - // unique: the first server keeps its name, the next ones get a numeric - // suffix that never takes a name another server configured - configured := make(map[string]struct{}, len(servers)) - for _, s := range servers { - if s.configuredName != "" { - configured[s.configuredName] = struct{}{} - } - } - - taken := make(map[string]struct{}, len(servers)) for i, s := range servers { s.idx = i - name := s.configuredName - if name == "" { - name = "server_" + strconv.Itoa(i) - } - - for base, n := name, 1; ; n++ { - _, isTaken := taken[name] - _, isConfigured := configured[name] - if !isTaken && (!isConfigured || name == s.configuredName) { - break - } - name = base + "_" + strconv.Itoa(n) + s.name = s.configuredName + if s.name == "" { + s.name = "server_" + strconv.Itoa(i) } - taken[name] = struct{}{} - - s.name = name s.resetWorkers() } } @@ -108,7 +83,7 @@ func unregisterServers() { // resetWorkers drops the workers of a previous run; initWorkers() adds them back func (s *Server) resetWorkers() { - s.workersByName = make(map[string]*worker) + s.workers = nil s.workersByPath = make(map[string]*worker) s.workersWithRequestMatcher = nil } @@ -124,9 +99,9 @@ func NewServer(root string, options ...ServerOption) (*Server, error) { } s := &Server{ - root: root, + root: root, + workersByPath: make(map[string]*worker), } - s.resetWorkers() for _, option := range options { if err := option(s); err != nil { @@ -150,24 +125,13 @@ func NewServer(root string, options ...ServerOption) (*Server, error) { } // Name returns the human-readable name of the server. -// It is empty until registration if none was passed to NewServer(), and gets -// a numeric suffix if another registered server has the same name. +// It is empty until registration if none was passed to NewServer(). func (s *Server) Name() string { return s.name } -// addWorker registers a worker scoped to this server func (s *Server) addWorker(w *worker) error { - // the fallback server holds the workers declared without a scope - scope := "two workers in a server" - if s == fallbackServer { - scope = "two global workers" - } - - if s.workersByName[w.name] != nil { - return fmt.Errorf("%s cannot have the same name: %q", scope, w.name) - } - s.workersByName[w.name] = w + s.workers = append(s.workers, w) // background workers never serve requests, so they are not matched at all if w.isBackgroundWorker { @@ -179,8 +143,8 @@ func (s *Server) addWorker(w *worker) error { return nil } - if s.workersByPath[w.fileName] != nil { - return fmt.Errorf("%s cannot have the same filename: %q", scope, w.fileName) + if _, exists := s.workersByPath[w.fileName]; exists { + return fmt.Errorf("two workers in a server cannot have the same filename: %q", w.fileName) } s.workersByPath[w.fileName] = w // a document root whose symlinks are kept yields the declared path, not the resolved one diff --git a/server_test.go b/server_test.go index 5f75d20667..3ad2032bbe 100644 --- a/server_test.go +++ b/server_test.go @@ -59,86 +59,12 @@ func TestServer(t *testing.T) { t.Run("name", func(t *testing.T) { named, _ := frankenphp.NewServer(testDataDir, frankenphp.WithServerName("api")) unnamed, _ := frankenphp.NewServer(testDataDir) - alsoNamed, _ := frankenphp.NewServer(testDataDir, frankenphp.WithServerName("api")) - explicit, _ := frankenphp.NewServer(testDataDir, frankenphp.WithServerName("api_1")) - initServers(t, frankenphp.WithServer(named), frankenphp.WithServer(unnamed), frankenphp.WithServer(alsoNamed), frankenphp.WithServer(explicit)) + initServers(t, frankenphp.WithServer(named), frankenphp.WithServer(unnamed)) assert.Equal(t, "api", named.Name()) // an empty name defaults to the server index at registration assert.Equal(t, "server_1", unnamed.Name()) - // names qualify worker names in metrics, so they are made unique, and - // a generated suffix never takes a name another server configured - assert.Equal(t, "api_2", alsoNamed.Name()) - assert.Equal(t, "api_1", explicit.Name()) - }) - - t.Run("same_worker_name_in_two_servers", func(t *testing.T) { - server1, _ := frankenphp.NewServer(testDataDir, frankenphp.WithServerName("one")) - server2, _ := frankenphp.NewServer(testDataDir, frankenphp.WithServerName("two")) - initServers( - t, - frankenphp.WithServer(server1), - frankenphp.WithServer(server2), - frankenphp.WithWorkers("counter", testDataDir+"worker-with-counter.php", 1, frankenphp.WithWorkerServerScope(server1)), - frankenphp.WithWorkers("counter", testDataDir+"worker-with-counter.php", 1, frankenphp.WithWorkerServerScope(server2)), - ) - - // WithWorkerName resolves the name within the request's server - byName := func(server *frankenphp.Server) string { - t.Helper() - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "http://example.com/index.php", nil) - require.NoError(t, server.ServeHTTP(w, req, frankenphp.WithWorkerName("counter"))) - body, err := io.ReadAll(w.Result().Body) - require.NoError(t, err) - - return string(body) - } - - assert.Equal(t, "requests:1", byName(server1)) - assert.Equal(t, "requests:1", byName(server2), "server 2 must get its own worker, not server 1's") - assert.Equal(t, "requests:2", byName(server1)) - }) - - t.Run("two_workers_may_share_a_script_when_matchers_tell_them_apart", func(t *testing.T) { - // the documented way to give slow endpoints their own thread pool, - // see docs/performance.md; neither worker is named, so the names - // generated from their shared script must not collide - t.Cleanup(frankenphp.Shutdown) - - server, err := frankenphp.NewServer(testDataDir) - require.NoError(t, err) - require.NoError(t, frankenphp.Init( - frankenphp.WithServer(server), - frankenphp.WithWorkers("", testDataDir+"worker-with-counter.php", 1, - frankenphp.WithWorkerServerScope(server), - frankenphp.WithWorkerMatcher(func(r *http.Request) bool { return strings.HasPrefix(r.URL.Path, "/slow/") }), - ), - frankenphp.WithWorkers("", testDataDir+"worker-with-counter.php", 1, - frankenphp.WithWorkerServerScope(server), - frankenphp.WithWorkerMatcher(func(*http.Request) bool { return true }), - ), - frankenphp.WithNumThreads(3), - )) - - // each pool counts the requests its matcher took - assert.Equal(t, "requests:1", serverGet(t, server, "http://example.com/slow/one")) - assert.Equal(t, "requests:2", serverGet(t, server, "http://example.com/slow/two")) - assert.Equal(t, "requests:1", serverGet(t, server, "http://example.com/index.php")) - }) - - t.Run("error_on_duplicate_worker_names", func(t *testing.T) { - t.Cleanup(frankenphp.Shutdown) - - server, _ := frankenphp.NewServer(testDataDir) - err := frankenphp.Init( - frankenphp.WithServer(server), - frankenphp.WithWorkers("same", testDataDir+"worker-with-counter.php", 1, frankenphp.WithWorkerServerScope(server)), - frankenphp.WithWorkers("same", testDataDir+"index.php", 1, frankenphp.WithWorkerServerScope(server)), - ) - - assert.ErrorContains(t, err, "two workers in a server cannot have the same name") }) t.Run("root", func(t *testing.T) { diff --git a/testdata/bgworker/named.php b/testdata/bgworker/named.php index d3a72355c1..3daf46d4dd 100644 --- a/testdata/bgworker/named.php +++ b/testdata/bgworker/named.php @@ -3,8 +3,7 @@ // Long-lived bg worker that touches a per-name sentinel under // $_SERVER['BG_SENTINEL_DIR'] so tests can confirm the right instance // ran. The bg worker's $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] value is -// the declared name, so the same fixture serves multiple distinct names -// across scopes. +// its name, so the same fixture serves several workers. set_time_limit(0); $name = $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] ?? 'unknown'; diff --git a/threadbackgroundworker.go b/threadbackgroundworker.go index b23bdd1cd9..212447a05a 100644 --- a/threadbackgroundworker.go +++ b/threadbackgroundworker.go @@ -85,7 +85,7 @@ func (handler *backgroundWorkerThread) startScript() string { } if globalLogger.Enabled(globalCtx, slog.LevelError) { - globalLogger.LogAttrs(globalCtx, slog.LevelError, "failed to start background worker", slog.String("worker", handler.worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex), slog.Any("error", err)) + globalLogger.LogAttrs(globalCtx, slog.LevelError, "failed to start background worker", slog.String("worker", handler.worker.name), slog.Int("thread", handler.thread.threadIndex), slog.Any("error", err)) } // fail fast during startup so Init() surfaces the error to the @@ -109,7 +109,7 @@ func (handler *backgroundWorkerThread) startScript() string { func (handler *backgroundWorkerThread) setupScript() error { s := int64(C.frankenphp_set_background_worker_and_get_stop_sock()) if s < 0 { - return fmt.Errorf("failed to create the stop socket pair of background worker %q", handler.worker.qualifiedName) + return fmt.Errorf("failed to create the stop socket pair of background worker %q", handler.worker.name) } handler.stopSock.Store(s) @@ -131,10 +131,10 @@ func (handler *backgroundWorkerThread) setupScript() error { handler.isBootingScript = true handler.runStartedAt = time.Now() - metrics.StartWorker(handler.worker.name, handler.worker.server.name) + metrics.StartWorker(handler.worker.name) // the run's logger and context, not the globals: a shutdown finishing // meanwhile resets those, and Stop() does not wait for this callback - logger, ctx, name, threadIndex := fc.logger, fc.ctx, handler.worker.qualifiedName, handler.thread.threadIndex + logger, ctx, name, threadIndex := fc.logger, fc.ctx, handler.worker.name, handler.thread.threadIndex handler.bootTimer = time.AfterFunc(backgroundBootWarnDelay, func() { if logger.Enabled(ctx, slog.LevelWarn) { logger.LogAttrs(ctx, slog.LevelWarn, "background worker has not called WorkerHandle::tick() yet, Init() and Shutdown() wait for it", slog.String("worker", name), slog.Int("thread", threadIndex)) @@ -142,7 +142,7 @@ func (handler *backgroundWorkerThread) setupScript() error { }) if fc.logger.Enabled(fc.ctx, slog.LevelDebug) { - fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "starting background worker", slog.String("worker", handler.worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex)) + fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "starting background worker", slog.String("worker", handler.worker.name), slog.Int("thread", handler.thread.threadIndex)) } // the thread stays in TransitionComplete until the script calls @@ -169,16 +169,16 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { // background one reaches its ready point on its own, so pace it here if !handler.isBootingScript { if exitStatus == 0 { - metrics.StopWorker(worker.name, worker.server.name, StopReasonRestart) + metrics.StopWorker(worker.name, StopReasonRestart) if globalLogger.Enabled(globalCtx, slog.LevelDebug) { - globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "restarting background worker", slog.String("worker", worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex)) + globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "restarting background worker", slog.String("worker", worker.name), slog.Int("thread", handler.thread.threadIndex)) } } else { - metrics.StopWorker(worker.name, worker.server.name, StopReasonCrash) + metrics.StopWorker(worker.name, StopReasonCrash) if globalLogger.Enabled(globalCtx, slog.LevelWarn) { - globalLogger.LogAttrs(globalCtx, slog.LevelWarn, "background worker crashed, restarting", slog.String("worker", worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex), slog.Int("exit_status", exitStatus), slog.Int("crashes", handler.crashCount)) + globalLogger.LogAttrs(globalCtx, slog.LevelWarn, "background worker crashed, restarting", slog.String("worker", worker.name), slog.Int("thread", handler.thread.threadIndex), slog.Int("exit_status", exitStatus), slog.Int("crashes", handler.crashCount)) } } @@ -197,7 +197,7 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { // the script exited before calling WorkerHandle::tick(), a clean exit // included; StopReasonBootFailure skips the ready-gauge decrement, // matching the ReadyWorker call that never happened - metrics.StopWorker(worker.name, worker.server.name, StopReasonBootFailure) + metrics.StopWorker(worker.name, StopReasonBootFailure) // max_consecutive_failures only fails hard during startup, through // startupFailChan; past it a failing worker keeps restarting with a @@ -234,7 +234,7 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { logMsg = "background worker exceeded max_consecutive_failures, still restarting" } if globalLogger.Enabled(globalCtx, logLevel) { - globalLogger.LogAttrs(globalCtx, logLevel, logMsg, slog.String("worker", worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex), slog.Int("failures", handler.failureCount), slog.Int("exit_status", exitStatus)) + globalLogger.LogAttrs(globalCtx, logLevel, logMsg, slog.String("worker", worker.name), slog.Int("thread", handler.thread.threadIndex), slog.Int("failures", handler.failureCount), slog.Int("exit_status", exitStatus)) } handler.backoff() @@ -261,7 +261,7 @@ func go_frankenphp_background_worker_ready(threadIndex C.uintptr_t) { // the boot succeeded, only consecutive boot failures count handler.failureCount = 0 handler.stopBootTimer() - metrics.ReadyWorker(handler.worker.name, handler.worker.server.name) + metrics.ReadyWorker(handler.worker.name) // parked from now on as far as the threads state endpoint is concerned handler.state.MarkAsWaiting(true) diff --git a/threadworker.go b/threadworker.go index b60e18cd47..8e823fbbe7 100644 --- a/threadworker.go +++ b/threadworker.go @@ -59,7 +59,7 @@ func (handler *workerThread) name() string { func (handler *workerThread) drain() {} func setupWorkerScript(handler *workerThread, worker *worker) { - metrics.StartWorker(worker.name, worker.server.name) + metrics.StartWorker(worker.name) // Create a dummy request to set up the worker fc, err := newWorkerDummyContext(worker) @@ -72,7 +72,7 @@ func setupWorkerScript(handler *workerThread, worker *worker) { handler.requestCount = 0 if fc.logger.Enabled(fc.ctx, slog.LevelDebug) { - fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "starting", slog.String("worker", worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex)) + fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "starting", slog.String("worker", worker.name), slog.Int("thread", handler.thread.threadIndex)) } } @@ -91,10 +91,10 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { // on exit status 0 we just run the worker script again if exitStatus == 0 && !handler.isBootingScript { - metrics.StopWorker(worker.name, worker.server.name, StopReasonRestart) + metrics.StopWorker(worker.name, StopReasonRestart) if globalLogger.Enabled(globalCtx, slog.LevelDebug) { - globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "restarting", slog.String("worker", worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex), slog.Int("exit_status", exitStatus)) + globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "restarting", slog.String("worker", worker.name), slog.Int("thread", handler.thread.threadIndex), slog.Int("exit_status", exitStatus)) } return @@ -102,9 +102,9 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { // worker has thrown a fatal error or has not reached frankenphp_handle_request if handler.isBootingScript { - metrics.StopWorker(worker.name, worker.server.name, StopReasonBootFailure) + metrics.StopWorker(worker.name, StopReasonBootFailure) } else { - metrics.StopWorker(worker.name, worker.server.name, StopReasonCrash) + metrics.StopWorker(worker.name, StopReasonCrash) } if !handler.isBootingScript { @@ -112,7 +112,7 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { // unlike a clean restart, this took down any in-flight request, so // surface it above debug level, with the exit status needed to triage it if globalLogger.Enabled(globalCtx, slog.LevelWarn) { - globalLogger.LogAttrs(globalCtx, slog.LevelWarn, "unexpected termination, restarting", slog.String("worker", worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex), slog.Int("exit_status", exitStatus)) + globalLogger.LogAttrs(globalCtx, slog.LevelWarn, "unexpected termination, restarting", slog.String("worker", worker.name), slog.Int("thread", handler.thread.threadIndex), slog.Int("exit_status", exitStatus)) } return @@ -127,13 +127,13 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { if watcherIsEnabled { // worker script has probably failed due to script changes while watcher is enabled if globalLogger.Enabled(globalCtx, slog.LevelError) { - globalLogger.LogAttrs(globalCtx, slog.LevelError, "(watcher enabled) worker script has not reached frankenphp_handle_request()", slog.String("worker", worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex)) + globalLogger.LogAttrs(globalCtx, slog.LevelError, "(watcher enabled) worker script has not reached frankenphp_handle_request()", slog.String("worker", worker.name), slog.Int("thread", handler.thread.threadIndex)) } } else { // rare case where worker script has failed on a restart during normal operation // this can happen if startup success depends on external resources if globalLogger.Enabled(globalCtx, slog.LevelWarn) { - globalLogger.LogAttrs(globalCtx, slog.LevelWarn, "worker script has failed on restart", slog.String("worker", worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex), slog.Int("failures", handler.failureCount)) + globalLogger.LogAttrs(globalCtx, slog.LevelWarn, "worker script has failed on restart", slog.String("worker", worker.name), slog.Int("thread", handler.thread.threadIndex), slog.Int("failures", handler.failureCount)) } } @@ -168,7 +168,7 @@ func (handler *workerThread) waitForWorkerRequest() (bool, any) { handler.thread.Unpin() if globalLogger.Enabled(globalCtx, slog.LevelDebug) { - globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "waiting for request", slog.String("worker", handler.worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex)) + globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "waiting for request", slog.String("worker", handler.worker.name), slog.Int("thread", handler.thread.threadIndex)) } // Clear the first dummy request created to initialize the worker @@ -180,14 +180,14 @@ func (handler *workerThread) waitForWorkerRequest() (bool, any) { } // worker is truly ready only after reaching frankenphp_handle_request() - metrics.ReadyWorker(handler.worker.name, handler.worker.server.name) + metrics.ReadyWorker(handler.worker.name) } // max_requests reached: signal reboot for full ZTS cleanup if maxRequestsPerThread > 0 && handler.requestCount >= maxRequestsPerThread { if globalLogger.Enabled(globalCtx, slog.LevelDebug) { globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "max requests reached, restarting", - slog.String("worker", handler.worker.qualifiedName), + slog.String("worker", handler.worker.name), slog.Int("thread", handler.thread.threadIndex), slog.Int("max_requests", maxRequestsPerThread), ) @@ -208,7 +208,7 @@ func (handler *workerThread) waitForWorkerRequest() (bool, any) { select { case <-handler.thread.drainChan: if globalLogger.Enabled(globalCtx, slog.LevelDebug) { - globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "shutting down", slog.String("worker", handler.worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex)) + globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "shutting down", slog.String("worker", handler.worker.name), slog.Int("thread", handler.thread.threadIndex)) } return false, nil @@ -224,9 +224,9 @@ func (handler *workerThread) waitForWorkerRequest() (bool, any) { if fc.logger.Enabled(fc.ctx, slog.LevelDebug) { if handler.workerFrankenPHPContext.request == nil { - fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "request handling started", slog.String("worker", handler.worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex)) + fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "request handling started", slog.String("worker", handler.worker.name), slog.Int("thread", handler.thread.threadIndex)) } else { - fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "request handling started", slog.String("worker", handler.worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex), slog.String("url", handler.workerFrankenPHPContext.request.RequestURI)) + fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "request handling started", slog.String("worker", handler.worker.name), slog.Int("thread", handler.thread.threadIndex), slog.String("url", handler.workerFrankenPHPContext.request.RequestURI)) } } @@ -283,9 +283,9 @@ func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t, retval *C.zval if fc.logger.Enabled(fc.ctx, slog.LevelDebug) { if fc.request == nil { - fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "request handling finished", slog.String("worker", fc.worker.qualifiedName), slog.Int("thread", thread.threadIndex)) + fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "request handling finished", slog.String("worker", fc.worker.name), slog.Int("thread", thread.threadIndex)) } else { - fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "request handling finished", slog.String("worker", fc.worker.qualifiedName), slog.Int("thread", thread.threadIndex), slog.String("url", fc.request.RequestURI)) + fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "request handling finished", slog.String("worker", fc.worker.name), slog.Int("thread", thread.threadIndex), slog.String("url", fc.request.RequestURI)) } } } diff --git a/worker.go b/worker.go index 7ea1d9040a..dba6ae3391 100644 --- a/worker.go +++ b/worker.go @@ -8,7 +8,6 @@ import ( "os" "path/filepath" "runtime" - "slices" "sync" "sync/atomic" "time" @@ -21,10 +20,7 @@ import ( type worker struct { mercureContext - // name as declared, unique within its server (or among global workers) - name string - // ":" for a server-scoped worker, the name otherwise, for logs and errors - qualifiedName string + name string fileName string declaredFileName string matchRequest func(*http.Request) bool @@ -46,9 +42,11 @@ type worker struct { } var ( - workers []*worker - watcherIsEnabled bool - startupFailChan chan error + workers []*worker + workersByName map[string]*worker + globalWorkersByPath map[string]*worker + watcherIsEnabled bool + startupFailChan chan error // the only window where a boot failure reaches startupFailChan: past it, the handlers log and keep restarting startupPhase atomic.Bool ) @@ -70,7 +68,8 @@ func initWorkers(opts []workerOpt) error { ) workers = make([]*worker, 0, len(opts)) - qualifiedNames := make(map[string]bool, len(opts)) + workersByName = make(map[string]*worker, len(opts)) + globalWorkersByPath = make(map[string]*worker, len(opts)) for _, o := range opts { w, err := newWorker(o) @@ -78,22 +77,18 @@ func initWorkers(opts []workerOpt) error { return err } - // names and paths are unique within a server - if err := w.server.addWorker(w); err != nil { - return err - } - totalThreadsToStart += w.num workers = append(workers, w) - // a global worker may still be named like the ":" of a - // scoped one, and metrics would report them as one - if qualifiedNames[w.qualifiedName] { - return fmt.Errorf("two workers cannot report under the same name: %q", w.qualifiedName) + workersByName[w.name] = w + if w.server != nil { + if err := w.server.addWorker(w); err != nil { + return err + } + } else if !w.isBackgroundWorker { + // background workers are never matched against requests + globalWorkersByPath[w.fileName] = w + globalWorkersByPath[w.declaredFileName] = w } - qualifiedNames[w.qualifiedName] = true - - // reported here rather than in calculateMaxThreads(), where the name is not resolved yet - metrics.TotalWorkers(w.name, w.server.name, w.num) } startupFailChan = make(chan error, totalThreadsToStart) @@ -125,7 +120,7 @@ func initWorkers(opts []workerOpt) error { // the script never ticked: drain it, so the run ends and the // thread reaches a state Shutdown() can act on - startupFailChan <- fmt.Errorf("background worker %q did not call WorkerHandle::tick() within %s", w.qualifiedName, bootTimeout) + startupFailChan <- fmt.Errorf("background worker %q did not call WorkerHandle::tick() within %s", w.name, bootTimeout) w.bootTimedOut.Store(true) if handler, ok := thread.handler.(*backgroundWorkerThread); ok { // the run then ends on the boot failure path, which stops @@ -166,14 +161,13 @@ func reportStartupFailure(err error) bool { return true } -// resolveWorkerFile turns a declared filename into the path a worker runs, and -// names the worker after it when the declaration left the name out, unique -// within its scope since several workers may share a script. +// resolveWorkerFile turns a declared filename into the path a worker runs, +// and names the worker after it when the declaration left the name out. // // Order is important! // This order ensures that FrankenPHP started from inside a symlinked directory will properly resolve any paths. // If it is started from outside a symlinked directory, it is resolved to the same path that we use in the Caddy module. -func resolveWorkerFile(o workerOpt, nameTaken func(*Server, string) bool) (workerOpt, error) { +func resolveWorkerFile(o workerOpt) (workerOpt, error) { declaredFileName, err := fastabs.FastAbs(filepath.FromSlash(o.fileName)) if err != nil { return o, fmt.Errorf("worker filename is invalid %q: %w", o.fileName, err) @@ -196,30 +190,18 @@ func resolveWorkerFile(o workerOpt, nameTaken func(*Server, string) bool) (worke o.declaredFileName = declaredFileName o.fileName = absFileName if o.name == "" { - // the path as it was declared, symlinks kept, so the name does not - // move when the target of a release symlink does - o.name = declaredFileName - for suffix := 1; nameTaken(o.server, o.name); suffix++ { - o.name = fmt.Sprintf("%s_%d", declaredFileName, suffix) - } + o.name = absFileName } return o, nil } // checkWorkerDeclaration holds the rules a set of workers must follow, so -// Validate() and newWorker() answer the same way. Names and paths are -// unique within the scope a worker registers in, a nil server standing for -// the global workers. -func checkWorkerDeclaration(o workerOpt, servers []*Server, nameTaken, pathTaken func(*Server, string) bool) error { - scope := "two workers in a server" - if o.server == nil { - scope = "two global workers" - } else if !slices.Contains(servers, o.server) { - return fmt.Errorf("worker %q is scoped to a server that was not passed to WithServer()", o.name) - } - +// Validate() and newWorker() answer the same way; pathTaken is asked for the +// global workers with a nil server. +func checkWorkerDeclaration(o workerOpt, nameTaken func(string) bool, pathTaken func(*Server, string) bool) error { if o.isBackgroundWorker { + // never matched against requests, so several may share a script if o.matchRequest != nil { return fmt.Errorf("background worker %q cannot match requests", o.name) } @@ -231,57 +213,48 @@ func checkWorkerDeclaration(o workerOpt, servers []*Server, nameTaken, pathTaken if o.extensionWorkers != nil { return fmt.Errorf("background worker %q cannot be an extension worker, those handle requests", o.name) } - } - - // no server means no set of requests to match against, the matcher would never run - if o.server == nil && o.matchRequest != nil { - return fmt.Errorf("worker %q has a request matcher but no server scope, use WithWorkerServerScope()", o.name) - } + } else if o.server == nil { + if pathTaken(nil, o.fileName) { + return fmt.Errorf("two global workers cannot have the same filename: %q", o.fileName) + } - if nameTaken(o.server, o.name) { - return fmt.Errorf("%s cannot have the same name: %q", scope, o.name) + // no server means no set of requests to match against, the matcher would never run + if o.matchRequest != nil { + return fmt.Errorf("worker %q has a request matcher but no server scope, use WithWorkerServerScope()", o.name) + } + } else if o.matchRequest == nil && pathTaken(o.server, o.fileName) { + // a matcher tells the workers of a server sharing a file apart + return fmt.Errorf("two workers in a server cannot have the same filename: %q", o.fileName) } - // background workers are keyed by name, several may share a script, and - // a matcher tells the workers of a server sharing a file apart - if !o.isBackgroundWorker && o.matchRequest == nil && pathTaken(o.server, o.fileName) { - return fmt.Errorf("%s cannot have the same filename: %q", scope, o.fileName) + if nameTaken(o.name) { + return fmt.Errorf("two workers cannot have the same name: %q", o.name) } return nil } func newWorker(o workerOpt) (*worker, error) { - // the fallback server serves the requests that have no server either, so a worker always has one - registeredIn := func(server *Server) *Server { + o, err := resolveWorkerFile(o) + if err != nil { + return nil, err + } + + nameTaken := func(name string) bool { return workersByName[name] != nil } + pathTaken := func(server *Server, path string) bool { if server == nil { - return fallbackServer + return globalWorkersByPath[path] != nil } - return server - } - nameTaken := func(server *Server, name string) bool { return registeredIn(server).workersByName[name] != nil } - pathTaken := func(server *Server, path string) bool { return registeredIn(server).workersByPath[path] != nil } - - o, err := resolveWorkerFile(o, nameTaken) - if err != nil { - return nil, err + return server.workersByPath[path] != nil } - if err := checkWorkerDeclaration(o, servers, nameTaken, pathTaken); err != nil { + if err := checkWorkerDeclaration(o, nameTaken, pathTaken); err != nil { return nil, err } - scope := registeredIn(o.server) - absFileName := o.fileName - // the same name may be declared in several servers, metrics and logs need a unique one - qualifiedName := o.name - if o.server != nil { - qualifiedName = o.server.name + ":" + o.name - } - // env should always contain FRANKENPHP_WORKER and the parent php_server env if o.env == nil { o.env = make(PreparedEnv, 1) @@ -312,7 +285,6 @@ func newWorker(o workerOpt) (*worker, error) { w := &worker{ name: o.name, - qualifiedName: qualifiedName, fileName: absFileName, declaredFileName: o.declaredFileName, matchRequest: o.matchRequest, @@ -325,7 +297,7 @@ func newWorker(o workerOpt) (*worker, error) { bootTimeout: bootTimeout, onThreadReady: o.onThreadReady, onThreadShutdown: o.onThreadShutdown, - server: scope, + server: o.server, isBackgroundWorker: o.isBackgroundWorker, } @@ -395,7 +367,7 @@ func (worker *worker) isAtThreadLimit() bool { } func (worker *worker) handleRequest(fc *frankenPHPContext) error { - metrics.StartWorkerRequest(worker.name, worker.server.name) + metrics.StartWorkerRequest(worker.name) runtime.Gosched() @@ -407,7 +379,7 @@ func (worker *worker) handleRequest(fc *frankenPHPContext) error { case thread.requestChan <- fc: worker.threadMutex.RUnlock() <-fc.done - metrics.StopWorkerRequest(worker.name, worker.server.name, time.Since(fc.startedAt)) + metrics.StopWorkerRequest(worker.name, time.Since(fc.startedAt)) return nil default: @@ -419,7 +391,7 @@ func (worker *worker) handleRequest(fc *frankenPHPContext) error { // if no thread was available, mark the request as queued and apply the scaling strategy worker.queuedRequests.Add(1) - metrics.QueuedWorkerRequest(worker.name, worker.server.name) + metrics.QueuedWorkerRequest(worker.name) for { workerScaleChan := scaleChan @@ -430,24 +402,24 @@ func (worker *worker) handleRequest(fc *frankenPHPContext) error { select { case worker.requestChan <- fc: worker.queuedRequests.Add(-1) - metrics.DequeuedWorkerRequest(worker.name, worker.server.name) + metrics.DequeuedWorkerRequest(worker.name) <-fc.done - metrics.StopWorkerRequest(worker.name, worker.server.name, time.Since(fc.startedAt)) + metrics.StopWorkerRequest(worker.name, time.Since(fc.startedAt)) return nil case workerScaleChan <- fc: // the request has triggered scaling, continue to wait for a thread case <-fc.ctx.Done(): worker.queuedRequests.Add(-1) - metrics.DequeuedWorkerRequest(worker.name, worker.server.name) - metrics.StopWorkerRequest(worker.name, worker.server.name, time.Since(fc.startedAt)) + metrics.DequeuedWorkerRequest(worker.name) + metrics.StopWorkerRequest(worker.name, time.Since(fc.startedAt)) return nil case <-timeoutChan(time.Duration(maxWaitTime.Load())): // the request has timed out stalling worker.queuedRequests.Add(-1) - metrics.DequeuedWorkerRequest(worker.name, worker.server.name) - metrics.StopWorkerRequest(worker.name, worker.server.name, time.Since(fc.startedAt)) + metrics.DequeuedWorkerRequest(worker.name) + metrics.StopWorkerRequest(worker.name, time.Since(fc.startedAt)) fc.reject(ErrMaxWaitTimeExceeded) diff --git a/worker_internal_test.go b/worker_internal_test.go index ff3441df38..a54ff41b43 100644 --- a/worker_internal_test.go +++ b/worker_internal_test.go @@ -29,7 +29,7 @@ func TestCancelledRequestLeavesWorkerQueue(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) req := httptest.NewRequest("GET", "/index.php", nil).WithContext(ctx) cancel() - w := &worker{name: "test", requestChan: make(chan *frankenPHPContext), maxThreads: 1, server: fallbackServer} + w := &worker{name: "test", requestChan: make(chan *frankenPHPContext), maxThreads: 1} err := w.handleRequest(&frankenPHPContext{ request: req, diff --git a/workerextension.go b/workerextension.go index eafa27b14e..814eea1565 100644 --- a/workerextension.go +++ b/workerextension.go @@ -26,33 +26,25 @@ type extensionWorkers struct { // EXPERIMENTAL: SendRequest sends an HTTP request to the worker and writes the response to the provided ResponseWriter. func (w *extensionWorkers) SendRequest(rw http.ResponseWriter, r *http.Request) error { - // the worker only exists between Init() and Shutdown(), and its server - // only serves once registered, which SendMessage checks the same way - if w.internalWorker == nil || !w.internalWorker.server.isRegistered.Load() { - return ErrNotRunning + fr, err := NewRequestWithContext( + r, + WithOriginalRequest(r), + WithWorkerName(w.name), + ) + + if err != nil { + return err } - // a worker always has a server, the fallback one when it was declared - // without a scope - return w.internalWorker.server.ServeHTTP(rw, r, WithOriginalRequest(r), withWorker(w.internalWorker)) + return ServeHTTP(rw, fr) } func (w *extensionWorkers) NumThreads() int { - if w.internalWorker == nil { - return 0 - } - return w.internalWorker.countThreads() } // EXPERIMENTAL: SendMessage sends a message to the worker and waits for a response. func (w *extensionWorkers) SendMessage(ctx context.Context, message any, rw http.ResponseWriter) (any, error) { - // the worker only exists between Init() and Shutdown(), and stays - // referenced past the latter - if w.internalWorker == nil || !w.internalWorker.server.isRegistered.Load() { - return nil, ErrNotRunning - } - fc := newContextFromMessage(message, rw, ctx, w.internalWorker) err := w.internalWorker.handleRequest(fc) diff --git a/workerextension_test.go b/workerextension_test.go index 1d812f4893..dd268da702 100644 --- a/workerextension_test.go +++ b/workerextension_test.go @@ -4,7 +4,6 @@ import ( "io" "net/http/httptest" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -78,63 +77,6 @@ func TestWorkerExtensionSendMessage(t *testing.T) { assert.Equal(t, "received message: Hello Workers", ret) } -// an extension worker scoped to a server stays reachable through -// SendRequest(), which dispatches on it whatever the URI -func TestWorkerExtensionOnServer(t *testing.T) { - t.Cleanup(Shutdown) - - server, err := NewServer("testdata/", WithServerName("api")) - require.NoError(t, err) - externalWorker, o := WithExtensionWorkers("scopedWorker", "testdata/worker.php", 1, WithWorkerServerScope(server)) - require.NoError(t, Init(o, WithServer(server))) - - // a URI that does not match the worker's own script, so only the name - // lookup can route this to it - w := httptest.NewRecorder() - require.NoError(t, externalWorker.SendRequest(w, httptest.NewRequest("GET", "http://example.com/index.php", nil))) - - body, err := io.ReadAll(w.Result().Body) - require.NoError(t, err) - assert.Contains(t, string(body), "Requests handled: 0") -} - -// an extension worker without a name is still the one SendRequest() -// dispatches on -func TestWorkerExtensionSendRequestWithoutName(t *testing.T) { - t.Cleanup(Shutdown) - - externalWorker, o := WithExtensionWorkers("", "testdata/worker.php", 1) - require.NoError(t, Init(o)) - - w := httptest.NewRecorder() - require.NoError(t, externalWorker.SendRequest(w, httptest.NewRequest("GET", "http://example.com/index.php", nil))) - - body, err := io.ReadAll(w.Result().Body) - require.NoError(t, err) - assert.Contains(t, string(body), "Requests handled: 0") -} - -// SendMessage() after Shutdown() reports the stopped runtime instead of -// waiting for a thread -func TestWorkerExtensionSendMessageAfterShutdown(t *testing.T) { - externalWorker, o := WithExtensionWorkers("extensionWorkers", "testdata/message-worker.php", 1) - require.NoError(t, Init(o)) - Shutdown() - - errChan := make(chan error, 1) - go func() { - _, err := externalWorker.SendMessage(t.Context(), "Hello Workers", nil) - errChan <- err - }() - - select { - case err := <-errChan: - require.ErrorIs(t, err, ErrNotRunning) - case <-time.After(2 * time.Second): - t.Fatal("SendMessage() did not return after Shutdown()") - } -} - // background workers never read requestChan, so an extension cannot send // them requests or messages func TestErrorIfExtensionWorkerIsBackground(t *testing.T) { From 8180d15f13add3c74fcbf392a0eb49bb7849e7af Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 27 Oct 2026 16:17:22 +0100 Subject: [PATCH 04/13] refactor: background threads count like the threads of any worker Since #2660 worker threads come on top of num_threads and max_threads bounds the whole process. Background threads were the one exception, which took a reservation through calculateMaxThreads(), initPHPThreads() and the automatic max_threads. They now follow the same rule as HTTP workers. --- bgworker_test.go | 35 ------------------------ docs/worker.md | 2 +- frankenphp.go | 63 ++++++++++++++++--------------------------- phpmainthread.go | 36 ++++++++++--------------- phpmainthread_test.go | 12 ++++----- types_test.go | 2 +- 6 files changed, 45 insertions(+), 105 deletions(-) diff --git a/bgworker_test.go b/bgworker_test.go index 2011e7551f..f2652110d3 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -409,20 +409,6 @@ func TestBackgroundWorkerNameIsGenerated(t *testing.T) { assert.Contains(t, requireFileContentEventually(t, sentinel), "'background' => '"+script+"'") } -// background threads are reserved on top of num_threads -func TestBackgroundWorkerThreadsComeOnTop(t *testing.T) { - sentinel := filepath.Join(t.TempDir(), "bg-only.sentinel") - initServers(t, - frankenphp.WithWorkers("bg-only", "testdata/bgworker/basic.php", 1, - frankenphp.WithWorkerBackground(), - frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), - ), - frankenphp.WithNumThreads(1), - ) - - requireFileEventually(t, sentinel, "background worker did not start with num_threads 1") -} - // a background worker serves no requests, so num defaults to one thread rather // than to the CPU count of an HTTP worker func TestBackgroundWorkerDefaultsToOneThread(t *testing.T) { @@ -445,27 +431,6 @@ func TestBackgroundWorkerDefaultsToOneThread(t *testing.T) { assert.Equal(t, 1, background) } -// the automatic limit is an HTTP one too: the reservation is added to what it -// resolves, instead of eating into it -func TestBackgroundWorkerThreadsComeOnTopOfAutoMaxThreads(t *testing.T) { - sentinel := filepath.Join(t.TempDir(), "bg-auto.sentinel") - initServers(t, - frankenphp.WithWorkers("bg-auto", "testdata/bgworker/basic.php", 2, - frankenphp.WithWorkerBackground(), - frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), - ), - frankenphp.WithNumThreads(2), - frankenphp.WithMaxThreads(-1), - frankenphp.WithPhpIni(map[string]string{"memory_limit": "-1"}), - ) - requireFileEventually(t, sentinel, "background worker did not start") - - // an unlimited memory_limit falls back to twice the HTTP threads, so - // 2*2 HTTP plus the 2 reserved, not (2+2)*2 - state := frankenphp.DebugState() - assert.Equal(t, 6, len(state.ThreadDebugStates)+state.ReservedThreadCount) -} - // max_execution_time applies until the first tick, a setup that outlives it // fails Init(). The limit is PHP's, so the test only runs where its timers are // known to fire under FrankenPHP: the max execution timers of Linux ZTS builds diff --git a/docs/worker.md b/docs/worker.md index e055c0e3f9..bab77b1de4 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -277,7 +277,7 @@ The wake-up sent at start makes the callback run as soon as the loop does, which That stream is there for the libraries that take one, and waiting is all it supports: reading it steals the bytes `tick()` would have consumed, writing to it lands in a buffer FrankenPHP never drains, so a script that fills it blocks itself, and closing it is safe since the next `getStream()` on the same handle opens a fresh one over the same socket. A script with no loop of its own can wait on it directly, with a blocking read such as `fgets()` or with `stream_select()`, keeping in mind that `stream_select()` rejects a descriptor of `1024` or higher (`FD_SETSIZE`), which a worker re-run while the server holds more than a thousand connections will have. `tick()` itself uses `poll()` and is not affected. -`$_SERVER['FRANKENPHP_WORKER_BACKGROUND']` holds the name of the worker. `FRANKENPHP_WORKER`, the variable of HTTP workers, is not set, so a script serving both roles tests which of the two is set. Background threads come on top of `num_threads` and `max_threads`; they don't autoscale, so `max_threads` is not allowed on them. From Go, declare one with `WithWorkerBackground()`. +`$_SERVER['FRANKENPHP_WORKER_BACKGROUND']` holds the name of the worker. `FRANKENPHP_WORKER`, the variable of HTTP workers, is not set, so a script serving both roles tests which of the two is set. Their threads count like the threads of HTTP workers, on top of `num_threads` and within `max_threads`; they don't autoscale, so `max_threads` is not allowed on them. From Go, declare one with `WithWorkerBackground()`. ## Superglobals behavior diff --git a/frankenphp.go b/frankenphp.go index 1a4a134d56..96b0a47567 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -28,6 +28,7 @@ import ( "os" "os/signal" "runtime" + "slices" "strings" "sync" "sync/atomic" @@ -216,27 +217,16 @@ func checkPHPConfig(config PHPConfig) error { return nil } -// calculateMaxThreads resolves num_threads and max_threads against the HTTP -// workers and returns their thread count, plus the threads of the background -// workers, which take no part in that budget: they come on top of it -func calculateMaxThreads(opt *opt) (numWorkers, backgroundThreads int, _ error) { +func calculateMaxThreads(opt *opt) (numWorkers int, _ error) { maxProcs := runtime.GOMAXPROCS(0) * 2 maxThreadsFromWorkers := 0 for i, w := range opt.workers { - if w.isBackgroundWorker { - if w.num <= 0 { - // one thread unless a pool is asked for: a background - // worker serves no requests, so it does not scale with - // the CPUs like an HTTP one - opt.workers[i].num = 1 - } - backgroundThreads += opt.workers[i].num - - continue - } - - if w.num <= 0 { + if w.num <= 0 && w.isBackgroundWorker { + // a background worker serves no requests, so it does not scale + // with the CPUs like an HTTP one + opt.workers[i].num = 1 + } else if w.num <= 0 { // https://github.com/php/frankenphp/issues/126 opt.workers[i].num = maxProcs } @@ -244,11 +234,11 @@ func calculateMaxThreads(opt *opt) (numWorkers, backgroundThreads int, _ error) if w.maxThreads > 0 { if w.maxThreads < w.num { - return 0, 0, fmt.Errorf("worker max_threads (%d) must be greater or equal to worker num (%d) (%q)", w.maxThreads, w.num, w.fileName) + return 0, fmt.Errorf("worker max_threads (%d) must be greater or equal to worker num (%d) (%q)", w.maxThreads, w.num, w.fileName) } if w.maxThreads > opt.maxThreads && opt.maxThreads > 0 { - return 0, 0, fmt.Errorf("worker max_threads (%d) cannot be greater than total max_threads (%d) (%q)", w.maxThreads, opt.maxThreads, w.fileName) + return 0, fmt.Errorf("worker max_threads (%d) cannot be greater than total max_threads (%d) (%q)", w.maxThreads, opt.maxThreads, w.fileName) } maxThreadsFromWorkers += w.maxThreads - w.num @@ -279,16 +269,16 @@ func calculateMaxThreads(opt *opt) (numWorkers, backgroundThreads int, _ error) if numThreadsIsSet && !maxThreadsIsSet { opt.maxThreads = opt.numThreads - return numWorkers, backgroundThreads, nil + return numWorkers, nil } if maxThreadsIsSet && !numThreadsIsSet { opt.numThreads = numWorkers + 1 if !maxThreadsIsAuto && opt.numThreads > opt.maxThreads { - return 0, 0, fmt.Errorf("max_threads (%d) must be greater than the number of worker threads (%d)", opt.maxThreads, numWorkers) + return 0, fmt.Errorf("max_threads (%d) must be greater than the number of worker threads (%d)", opt.maxThreads, numWorkers) } - return numWorkers, backgroundThreads, nil + return numWorkers, nil } if !numThreadsIsSet { @@ -301,19 +291,19 @@ func calculateMaxThreads(opt *opt) (numWorkers, backgroundThreads int, _ error) } opt.maxThreads = opt.numThreads - return numWorkers, backgroundThreads, nil + return numWorkers, nil } // both num_threads and max_threads are set if !maxThreadsIsAuto && opt.maxThreads < opt.numThreads { if numWorkers > 0 { - return 0, 0, fmt.Errorf("max_threads (%d) must be greater than or equal to num_threads (%d) plus the worker threads (%d)", opt.maxThreads, opt.numThreads-numWorkers, numWorkers) + return 0, fmt.Errorf("max_threads (%d) must be greater than or equal to num_threads (%d) plus the worker threads (%d)", opt.maxThreads, opt.numThreads-numWorkers, numWorkers) } - return 0, 0, fmt.Errorf("max_threads (%d) must be greater than or equal to num_threads (%d)", opt.maxThreads, opt.numThreads) + return 0, fmt.Errorf("max_threads (%d) must be greater than or equal to num_threads (%d)", opt.maxThreads, opt.numThreads) } - return numWorkers, backgroundThreads, nil + return numWorkers, nil } // Validate reports whether Init() would accept a configuration, without @@ -329,7 +319,7 @@ func Validate(options ...Option) error { } } - if _, _, err := calculateMaxThreads(opt); err != nil { + if _, err := calculateMaxThreads(opt); err != nil { return err } @@ -409,15 +399,13 @@ func Init(options ...Option) error { registerServers(opt.servers) - workerThreadCount, backgroundThreads, err := calculateMaxThreads(opt) + workerThreadCount, err := calculateMaxThreads(opt) if err != nil { shutdown() return err } - // background workers run on threads of their own, on top of the budget - // num_threads and max_threads describe for HTTP traffic - metrics.TotalThreads(opt.numThreads + backgroundThreads) + metrics.TotalThreads(opt.numThreads) for _, w := range opt.workers { metrics.TotalWorkers(w.name, w.num) } @@ -438,9 +426,10 @@ func Init(options ...Option) error { } } else { opt.numThreads = 1 - if workerThreadCount > 1 || backgroundThreads > 0 { + // a background worker would take the only thread + if slices.ContainsFunc(opt.workers, func(w workerOpt) bool { return w.isBackgroundWorker }) { shutdown() - return fmt.Errorf("%d worker threads are declared, but this PHP build is not ZTS and runs a single thread", workerThreadCount+backgroundThreads) + return errors.New("background workers need a ZTS build of PHP, this one runs a single thread") } if globalLogger.Enabled(globalCtx, slog.LevelWarn) { @@ -450,13 +439,7 @@ func Init(options ...Option) error { hotReloadEnabled = opt.hasHotReload() - maxThreads := opt.maxThreads - if maxThreads > 0 { - maxThreads += backgroundThreads - } - // in auto mode (maxThreads < 0), the main thread adds the reservation to - // the limit it resolves, see setAutomaticMaxThreads() - mainThread, err := initPHPThreads(opt.numThreads+backgroundThreads, maxThreads, backgroundThreads, opt.phpIni) + mainThread, err := initPHPThreads(opt.numThreads, opt.maxThreads, opt.phpIni) if err != nil { shutdown() return err diff --git a/phpmainthread.go b/phpmainthread.go index 31714bba94..8a3b0a7c5c 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -20,16 +20,12 @@ import ( // represents the main PHP thread // the thread needs to keep running as long as all other threads are running type phpMainThread struct { - state *state.ThreadState - done chan struct{} - numThreads int - maxThreads int - // backgroundThreads is the part of numThreads and maxThreads that - // background workers reserve: it takes no part in the HTTP budget, - // see calculateMaxThreads() - backgroundThreads int - phpIni map[string]string - isRebooting atomic.Bool + state *state.ThreadState + done chan struct{} + numThreads int + maxThreads int + phpIni map[string]string + isRebooting atomic.Bool } var ( @@ -45,14 +41,13 @@ var ( // initPHPThreads starts the main PHP thread, // a fixed number of inactive PHP threads // and reserves a fixed number of possible PHP threads -func initPHPThreads(numThreads int, numMaxThreads int, backgroundThreads int, phpIni map[string]string) (*phpMainThread, error) { +func initPHPThreads(numThreads int, numMaxThreads int, phpIni map[string]string) (*phpMainThread, error) { mainThread = &phpMainThread{ - state: state.NewThreadState(), - done: make(chan struct{}), - numThreads: numThreads, - maxThreads: numMaxThreads, - backgroundThreads: backgroundThreads, - phpIni: phpIni, + state: state.NewThreadState(), + done: make(chan struct{}), + numThreads: numThreads, + maxThreads: numMaxThreads, + phpIni: phpIni, } // initialize the first thread @@ -264,21 +259,18 @@ func go_frankenphp_main_thread_is_ready() { // max_threads = auto // setAutomaticMaxThreads estimates the amount of threads based on php.ini and system memory_limit // If unable to get the system's memory limit, simply double num_threads -// The estimate is an HTTP one, like an explicit max_threads: the threads -// background workers reserve are added to it rather than taken out of it func (mainThread *phpMainThread) setAutomaticMaxThreads() { if mainThread.maxThreads >= 0 { return } - httpThreads := mainThread.numThreads - mainThread.backgroundThreads perThreadMemoryLimit := int64(C.frankenphp_get_current_memory_limit()) totalSysMemory := memory.TotalSysMemory() if perThreadMemoryLimit <= 0 || totalSysMemory == 0 { - mainThread.maxThreads = httpThreads*2 + mainThread.backgroundThreads + mainThread.maxThreads = mainThread.numThreads * 2 return } maxAllowedThreads := totalSysMemory / uint64(perThreadMemoryLimit) - mainThread.maxThreads = int(maxAllowedThreads) + mainThread.backgroundThreads + mainThread.maxThreads = int(maxAllowedThreads) if globalLogger.Enabled(globalCtx, slog.LevelDebug) { globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "Automatic thread limit", slog.Int("perThreadMemoryLimitMB", int(perThreadMemoryLimit/1024/1024)), slog.Int("maxThreads", mainThread.maxThreads)) diff --git a/phpmainthread_test.go b/phpmainthread_test.go index d236cd5d40..d8a5b6742d 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -26,7 +26,7 @@ func setupGlobals(t *testing.T) { } func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) { - _, err := initPHPThreads(1, 1, 0, nil) // boot 1 thread + _, err := initPHPThreads(1, 1, nil) // boot 1 thread assert.NoError(t, err) assert.Len(t, phpThreads, 1) @@ -41,7 +41,7 @@ func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) { func TestTransitionRegularThreadToWorkerThread(t *testing.T) { setupGlobals(t) - _, err := initPHPThreads(1, 1, 0, nil) + _, err := initPHPThreads(1, 1, nil) assert.NoError(t, err) // transition to regular thread @@ -66,7 +66,7 @@ func TestTransitionRegularThreadToWorkerThread(t *testing.T) { func TestTransitionAThreadBetween2DifferentWorkers(t *testing.T) { setupGlobals(t) - _, err := initPHPThreads(1, 1, 0, nil) + _, err := initPHPThreads(1, 1, nil) assert.NoError(t, err) firstWorker := getDummyWorker(t, "transition-worker-1.php") secondWorker := getDummyWorker(t, "transition-worker-2.php") @@ -228,7 +228,7 @@ func TestQueuedRequestSurvivesReload(t *testing.T) { func TestFinishBootingAWorkerScript(t *testing.T) { setupGlobals(t) - _, err := initPHPThreads(1, 1, 0, nil) + _, err := initPHPThreads(1, 1, nil) assert.NoError(t, err) // boot the worker @@ -375,7 +375,7 @@ func TestCorrectThreadCalculation(t *testing.T) { func testThreadCalculation(t *testing.T, expectedNumThreads int, expectedMaxThreads int, o *opt) { t.Helper() - _, _, err := calculateMaxThreads(o) + _, err := calculateMaxThreads(o) assert.NoError(t, err, "no error should be returned") assert.Equal(t, expectedNumThreads, o.numThreads, "num_threads must be correct") assert.Equal(t, expectedMaxThreads, o.maxThreads, "max_threads must be correct") @@ -384,7 +384,7 @@ func testThreadCalculation(t *testing.T, expectedNumThreads int, expectedMaxThre func testThreadCalculationError(t *testing.T, o *opt) { t.Helper() - _, _, err := calculateMaxThreads(o) + _, err := calculateMaxThreads(o) assert.Error(t, err, "configuration must error") } diff --git a/types_test.go b/types_test.go index 349d8a56b7..1efe1a90d9 100644 --- a/types_test.go +++ b/types_test.go @@ -18,7 +18,7 @@ func testOnDummyPHPThread(t *testing.T, test func()) { t.Helper() globalLogger = slog.Default() - _, err := initPHPThreads(1, 1, 0, nil) // boot 1 thread + _, err := initPHPThreads(1, 1, nil) // boot 1 thread assert.NoError(t, err) handler := convertToTaskThread(phpThreads[0]) From d13828c1112e7f64ef3349ec9d814beecc706b65 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 27 Oct 2026 16:20:11 +0100 Subject: [PATCH 05/13] refactor: leave the boot timeout to a pull request of its own HTTP workers wait for their ready point without a bound too, on builds without Zend max execution timers, so the bound belongs with both kinds of worker. Background workers keep the bound of max_execution_time where those timers exist, and the warning logged after 10 seconds elsewhere. --- bgworker_test.go | 16 ---------- caddy/workerconfig.go | 28 +---------------- docs/config.md | 1 - docs/worker.md | 2 +- options.go | 19 ------------ testdata/bgworker/never-ticks.php | 10 ------ threadbackgroundworker.go | 10 +----- worker.go | 51 ++++--------------------------- 8 files changed, 9 insertions(+), 128 deletions(-) delete mode 100644 testdata/bgworker/never-ticks.php diff --git a/bgworker_test.go b/bgworker_test.go index f2652110d3..8bcc79a4d9 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -678,19 +678,3 @@ func TestWorkerHandleBuiltBehindTheConstructor(t *testing.T) { assert.Contains(t, body, "reflection: ReflectionException:") assert.Contains(t, body, "construct: RuntimeException: FrankenPHP\\WorkerHandle can only be created from a background worker") } - -// a script that parks without ever ticking fails Init() instead of hanging it, -// the only failure mode available where Zend max execution timers are not -func TestBackgroundWorkerBootTimeout(t *testing.T) { - err := frankenphp.Init( - frankenphp.WithWorkers("bg-mute", "testdata/bgworker/never-ticks.php", 1, - frankenphp.WithWorkerBackground(), - frankenphp.WithWorkerBootTimeout(300*time.Millisecond), - ), - frankenphp.WithNumThreads(2), - ) - if err == nil { - frankenphp.Shutdown() - } - require.ErrorContains(t, err, `background worker "bg-mute" did not call WorkerHandle::tick() within 300ms`) -} diff --git a/caddy/workerconfig.go b/caddy/workerconfig.go index cfd692f199..bdb3ac0ad5 100644 --- a/caddy/workerconfig.go +++ b/caddy/workerconfig.go @@ -3,7 +3,6 @@ package caddy import ( "path/filepath" "strconv" - "time" "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" @@ -39,8 +38,6 @@ type workerConfig struct { MatchPath []string `json:"match_path,omitempty"` // MaxConsecutiveFailures sets the maximum number of consecutive failures before panicking (defaults to 6, set to -1 to never panick) MaxConsecutiveFailures *int `json:"max_consecutive_failures,omitempty"` - // BootTimeout bounds how long a background worker may take to reach its first WorkerHandle::tick() before the start fails (defaults to 30s, set to 0 to wait forever) - BootTimeout *caddy.Duration `json:"boot_timeout,omitempty"` // Background marks this worker as a background (non-HTTP) worker. Background bool `json:"background,omitempty"` @@ -144,25 +141,10 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) { } wc.MaxConsecutiveFailures = new(v) - case "boot_timeout": - if !d.NextArg() { - return wc, d.ArgErr() - } - - v, err := caddy.ParseDuration(d.Val()) - if err != nil { - return wc, d.WrapErr(err) - } - if v < 0 { - return wc, d.Errf("boot_timeout must be >= 0") - } - - timeout := caddy.Duration(v) - wc.BootTimeout = &timeout case "background": wc.Background = true default: - return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads, background, boot_timeout", v) + return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads, background", v) } } @@ -170,10 +152,6 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) { return wc, d.Err(`the "file" argument must be specified`) } - if !wc.Background && wc.BootTimeout != nil { - return wc, d.Err(`"boot_timeout" is only supported for background workers`) - } - if wc.Background && len(wc.MatchPath) != 0 { return wc, d.Err(`"match" is not supported for background workers`) } @@ -201,10 +179,6 @@ func (wc *workerConfig) toWorkerOptions() ([]frankenphp.WorkerOption, error) { if wc.Background { opts = append(opts, frankenphp.WithWorkerBackground()) - - if wc.BootTimeout != nil { - opts = append(opts, frankenphp.WithWorkerBootTimeout(time.Duration(*wc.BootTimeout))) - } } // copy the caddy match logic and create a unique matcher function for this worker diff --git a/docs/config.md b/docs/config.md index 1aa9e62060..8f5eb0bcdd 100644 --- a/docs/config.md +++ b/docs/config.md @@ -111,7 +111,6 @@ You can also explicitly configure FrankenPHP using the [global option](https://c watch # Sets the path to watch for file changes. Can be specified more than once for multiple paths. name # Sets the name of the worker, used in logs and metrics. Default: absolute path of worker file max_consecutive_failures # Sets the maximum number of consecutive failures before the worker is considered unhealthy, -1 means the worker will always restart. Default: 6. - boot_timeout # EXPERIMENTAL: how long a background worker may take to reach its first tick() before the start fails, 0 waits forever. Default: 30s. background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, whose script ticks its FrankenPHP\WorkerHandle at least once } } diff --git a/docs/worker.md b/docs/worker.md index bab77b1de4..37218f0cab 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -223,7 +223,7 @@ php_server { } ``` -The script takes a handle on the worker, `new FrankenPHP\WorkerHandle()`, and calls `tick()` on it once it is set up. The first call marks the worker ready: the server start waits for that point, and an exit before it counts as a failure. Until that call, `max_execution_time` applies as in any request, 30 seconds by default: a setup that outlives it is ended and counts as a boot failure, so raise the limit in `php_ini`, or call `set_time_limit()` in the script, when the setup legitimately takes longer. This bound only holds on PHP builds with Zend max execution timers (`--enable-zend-max-execution-timers`, see [the compilation guide](compile.md)): elsewhere FrankenPHP disables `max_execution_time`, and the `boot_timeout` of the worker is what ends the wait: 30 seconds by default, matching PHP's own `max_execution_time`, after which the start fails with the name of the worker that never ticked. Set it to `0` to wait forever, which is what happened before, with a warning logged after 10 seconds. From the first call on, the run has no time limit, like the CLI. Every call returns `false` once FrankenPHP drains the worker, on shutdown, reboot or restart, and `true` otherwise. It never blocks and never hands out work: like `frankenphp_handle_request()`, it is where the runtime and the script meet. +The script takes a handle on the worker, `new FrankenPHP\WorkerHandle()`, and calls `tick()` on it once it is set up. The first call marks the worker ready: the server start waits for that point, and an exit before it counts as a failure. Until that call, `max_execution_time` applies as in any request, 30 seconds by default: a setup that outlives it is ended and counts as a boot failure, so raise the limit in `php_ini`, or call `set_time_limit()` in the script, when the setup legitimately takes longer. This bound only holds on PHP builds with Zend max execution timers (`--enable-zend-max-execution-timers`, see [the compilation guide](compile.md)): elsewhere FrankenPHP disables `max_execution_time`, so a setup that never reaches its first call keeps the server start waiting, with a warning logged after 10 seconds. From the first call on, the run has no time limit, like the CLI. Every call returns `false` once FrankenPHP drains the worker, on shutdown, reboot or restart, and `true` otherwise. It never blocks and never hands out work: like `frankenphp_handle_request()`, it is where the runtime and the script meet. Between two calls the script waits on the handle, which is an `Io\Poll\Handle`: an `Io\Poll\Context` takes it as it is, next to whatever else the script waits on. diff --git a/options.go b/options.go index c95473209c..d709669477 100644 --- a/options.go +++ b/options.go @@ -59,8 +59,6 @@ type workerOpt struct { onServerShutdown func() server *Server isBackgroundWorker bool - bootTimeout time.Duration - bootTimeoutIsSet bool } // WithContext sets the main context to use. @@ -258,23 +256,6 @@ func WithWorkerBackground() WorkerOption { } } -// EXPERIMENTAL: WithWorkerBootTimeout bounds how long a background worker may -// take to reach its ready point, its first WorkerHandle::tick(), before Init() -// gives up and returns an error. Zero waits for ever, as a build with Zend max -// execution timers does anyway. Defaults to DefaultWorkerBootTimeout, and has -// no effect on HTTP workers. -func WithWorkerBootTimeout(timeout time.Duration) WorkerOption { - return func(w *workerOpt) error { - if timeout < 0 { - return fmt.Errorf("worker boot timeout must be >= 0, got %s", timeout) - } - w.bootTimeout = timeout - w.bootTimeoutIsSet = true - - return nil - } -} - // WithWorkerMaxFailures sets the maximum number of consecutive failures before panicking func WithWorkerMaxFailures(maxFailures int) WorkerOption { return func(w *workerOpt) error { diff --git a/testdata/bgworker/never-ticks.php b/testdata/bgworker/never-ticks.php deleted file mode 100644 index a36c990c4b..0000000000 --- a/testdata/bgworker/never-ticks.php +++ /dev/null @@ -1,10 +0,0 @@ -getStream(); -fgets($stream); -fgets($stream); diff --git a/threadbackgroundworker.go b/threadbackgroundworker.go index 212447a05a..100f71de9f 100644 --- a/threadbackgroundworker.go +++ b/threadbackgroundworker.go @@ -201,16 +201,8 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { // max_consecutive_failures only fails hard during startup, through // startupFailChan; past it a failing worker keeps restarting with a - // louder log line rather than leave the server in a broken half-state. - // A worker Init() gave up on stops here whatever the cap says, it was - // drained precisely so this path could end the thread + // louder log line rather than leave the server in a broken half-state pastCap := worker.maxConsecutiveFailures >= 0 && handler.failureCount >= worker.maxConsecutiveFailures - if worker.bootTimedOut.Load() { - reportStartupFailure(fmt.Errorf("background worker %s did not call WorkerHandle::tick() in time", worker.fileName)) - handler.thread.state.Set(state.ShuttingDown) - - return - } if pastCap && !watcherIsEnabled { var err error if exitStatus == 0 { diff --git a/worker.go b/worker.go index dba6ae3391..99f7c92da9 100644 --- a/worker.go +++ b/worker.go @@ -31,14 +31,11 @@ type worker struct { threads []*phpThread threadMutex sync.RWMutex maxConsecutiveFailures int - bootTimeout time.Duration - // Init() gave up waiting for the ready point: the run is drained and must not start again - bootTimedOut atomic.Bool - onThreadReady func(int) - onThreadShutdown func(int) - queuedRequests atomic.Int32 - server *Server - isBackgroundWorker bool + onThreadReady func(int) + onThreadShutdown func(int) + queuedRequests atomic.Int32 + server *Server + isBackgroundWorker bool } var ( @@ -51,12 +48,6 @@ var ( startupPhase atomic.Bool ) -// DefaultWorkerBootTimeout is how long a background worker may take to reach -// its first WorkerHandle::tick() before Init() fails. It matches PHP's default -// max_execution_time, which bounds the same bootstrap where Zend timers are -// around. WithWorkerBootTimeout() changes it, zero waits for ever. -const DefaultWorkerBootTimeout = 30 * time.Second - func initWorkers(opts []workerOpt) error { if len(opts) == 0 { return nil @@ -103,30 +94,8 @@ func initWorkers(opts []workerOpt) error { convertToWorkerThread(thread, w) } - bootTimeout := time.Duration(0) - if w.isBackgroundWorker { - bootTimeout = w.bootTimeout - } workersReady.Go(func() { - if bootTimeout <= 0 { - thread.state.WaitFor(state.Ready, state.ShuttingDown, state.Done) - - return - } - - if thread.state.WaitForStateWithTimeout(bootTimeout, state.Ready, state.ShuttingDown, state.Done) { - return - } - - // the script never ticked: drain it, so the run ends and the - // thread reaches a state Shutdown() can act on - startupFailChan <- fmt.Errorf("background worker %q did not call WorkerHandle::tick() within %s", w.name, bootTimeout) - w.bootTimedOut.Store(true) - if handler, ok := thread.handler.(*backgroundWorkerThread); ok { - // the run then ends on the boot failure path, which stops - // the thread instead of starting it again, see afterScriptExecution() - handler.drain() - } + thread.state.WaitFor(state.Ready, state.ShuttingDown, state.Done) }) } } @@ -276,13 +245,6 @@ func newWorker(o workerOpt) (*worker, error) { o.env["FRANKENPHP_WORKER\x00"] = "1" } - // a background worker that never reaches its ready point would keep Init() - // waiting for ever, see initWorkers() - bootTimeout := DefaultWorkerBootTimeout - if o.bootTimeoutIsSet { - bootTimeout = o.bootTimeout - } - w := &worker{ name: o.name, fileName: absFileName, @@ -294,7 +256,6 @@ func newWorker(o workerOpt) (*worker, error) { requestChan: make(chan *frankenPHPContext), threads: make([]*phpThread, 0, o.num), maxConsecutiveFailures: o.maxConsecutiveFailures, - bootTimeout: bootTimeout, onThreadReady: o.onThreadReady, onThreadShutdown: o.onThreadShutdown, server: o.server, From ae431fa15f99782b942d916aa25a07f92c73d063 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 27 Oct 2026 16:22:20 +0100 Subject: [PATCH 06/13] chore: drop the leftovers of FRANKENPHP_WORKER holding the worker name --- caddy/caddy_test.go | 2 +- frankenphp.c | 6 +++--- testdata/_executor.php | 2 +- testdata/symlinks/test/index.php | 2 +- testdata/symlinks/test/nested/index.php | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index 5e03baaa83..5424b9943b 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -2215,7 +2215,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)\n") + 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") }) t.Run("MultipleRequests", func(t *testing.T) { diff --git a/frankenphp.c b/frankenphp.c index c59f166778..bef3251f1b 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1527,9 +1527,6 @@ static int frankenphp_worker_handle_close(php_stream *stream, return php_stream_socket_ops.close(stream, 0); } -/* The handle of the current background worker: the stream to wait on and the - * tick that reports the drain. Constructing it outside a background worker - * throws, a run may create as many as it likes. */ /* The constructor is not a gate: unserialize() and Reflection make an * instance without it, so every method checks the thread it runs on. */ static bool frankenphp_worker_handle_usable(void) { @@ -1544,6 +1541,9 @@ static bool frankenphp_worker_handle_usable(void) { return false; } +/* The handle of the current background worker: the stream to wait on and the + * tick that reports the drain. Constructing it outside a background worker + * throws, a run may create as many as it likes. */ ZEND_METHOD(FrankenPHP_WorkerHandle, __construct) { ZEND_PARSE_PARAMETERS_NONE(); diff --git a/testdata/_executor.php b/testdata/_executor.php index 31b87c79cb..61a5319f11 100644 --- a/testdata/_executor.php +++ b/testdata/_executor.php @@ -1,7 +1,7 @@ Date: Tue, 27 Oct 2026 16:48:47 +0100 Subject: [PATCH 07/13] feat: bound the wait for a background worker to become ready Init() waits for a background worker to reach its ready point, its first WorkerHandle::tick(). On a build with Zend max execution timers, max_execution_time ends a bootstrap that overstays; without them FrankenPHP disables that limit, so a script that parks before ticking, or one whose wake-up at start is lost, keeps the server start waiting for ever with a warning as the only trace. boot_timeout bounds that wait, 30 seconds by default, the same figure PHP's own max_execution_time uses for the bootstrap it does bound. The worker is then drained and stopped through the boot-failure path it already has, and Init() returns the name of the worker that never ticked. Zero waits for ever. --- bgworker_test.go | 16 ++++++++++ caddy/workerconfig.go | 28 ++++++++++++++++- docs/config.md | 1 + docs/worker.md | 2 +- options.go | 19 ++++++++++++ testdata/bgworker/never-ticks.php | 10 ++++++ threadbackgroundworker.go | 10 +++++- worker.go | 51 +++++++++++++++++++++++++++---- 8 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 testdata/bgworker/never-ticks.php diff --git a/bgworker_test.go b/bgworker_test.go index 8bcc79a4d9..f2652110d3 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -678,3 +678,19 @@ func TestWorkerHandleBuiltBehindTheConstructor(t *testing.T) { assert.Contains(t, body, "reflection: ReflectionException:") assert.Contains(t, body, "construct: RuntimeException: FrankenPHP\\WorkerHandle can only be created from a background worker") } + +// a script that parks without ever ticking fails Init() instead of hanging it, +// the only failure mode available where Zend max execution timers are not +func TestBackgroundWorkerBootTimeout(t *testing.T) { + err := frankenphp.Init( + frankenphp.WithWorkers("bg-mute", "testdata/bgworker/never-ticks.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerBootTimeout(300*time.Millisecond), + ), + frankenphp.WithNumThreads(2), + ) + if err == nil { + frankenphp.Shutdown() + } + require.ErrorContains(t, err, `background worker "bg-mute" did not call WorkerHandle::tick() within 300ms`) +} diff --git a/caddy/workerconfig.go b/caddy/workerconfig.go index bdb3ac0ad5..cfd692f199 100644 --- a/caddy/workerconfig.go +++ b/caddy/workerconfig.go @@ -3,6 +3,7 @@ package caddy import ( "path/filepath" "strconv" + "time" "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" @@ -38,6 +39,8 @@ type workerConfig struct { MatchPath []string `json:"match_path,omitempty"` // MaxConsecutiveFailures sets the maximum number of consecutive failures before panicking (defaults to 6, set to -1 to never panick) MaxConsecutiveFailures *int `json:"max_consecutive_failures,omitempty"` + // BootTimeout bounds how long a background worker may take to reach its first WorkerHandle::tick() before the start fails (defaults to 30s, set to 0 to wait forever) + BootTimeout *caddy.Duration `json:"boot_timeout,omitempty"` // Background marks this worker as a background (non-HTTP) worker. Background bool `json:"background,omitempty"` @@ -141,10 +144,25 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) { } wc.MaxConsecutiveFailures = new(v) + case "boot_timeout": + if !d.NextArg() { + return wc, d.ArgErr() + } + + v, err := caddy.ParseDuration(d.Val()) + if err != nil { + return wc, d.WrapErr(err) + } + if v < 0 { + return wc, d.Errf("boot_timeout must be >= 0") + } + + timeout := caddy.Duration(v) + wc.BootTimeout = &timeout case "background": wc.Background = true default: - return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads, background", v) + return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads, background, boot_timeout", v) } } @@ -152,6 +170,10 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) { return wc, d.Err(`the "file" argument must be specified`) } + if !wc.Background && wc.BootTimeout != nil { + return wc, d.Err(`"boot_timeout" is only supported for background workers`) + } + if wc.Background && len(wc.MatchPath) != 0 { return wc, d.Err(`"match" is not supported for background workers`) } @@ -179,6 +201,10 @@ func (wc *workerConfig) toWorkerOptions() ([]frankenphp.WorkerOption, error) { if wc.Background { opts = append(opts, frankenphp.WithWorkerBackground()) + + if wc.BootTimeout != nil { + opts = append(opts, frankenphp.WithWorkerBootTimeout(time.Duration(*wc.BootTimeout))) + } } // copy the caddy match logic and create a unique matcher function for this worker diff --git a/docs/config.md b/docs/config.md index 8f5eb0bcdd..1aa9e62060 100644 --- a/docs/config.md +++ b/docs/config.md @@ -111,6 +111,7 @@ You can also explicitly configure FrankenPHP using the [global option](https://c watch # Sets the path to watch for file changes. Can be specified more than once for multiple paths. name # Sets the name of the worker, used in logs and metrics. Default: absolute path of worker file max_consecutive_failures # Sets the maximum number of consecutive failures before the worker is considered unhealthy, -1 means the worker will always restart. Default: 6. + boot_timeout # EXPERIMENTAL: how long a background worker may take to reach its first tick() before the start fails, 0 waits forever. Default: 30s. background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, whose script ticks its FrankenPHP\WorkerHandle at least once } } diff --git a/docs/worker.md b/docs/worker.md index 37218f0cab..bab77b1de4 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -223,7 +223,7 @@ php_server { } ``` -The script takes a handle on the worker, `new FrankenPHP\WorkerHandle()`, and calls `tick()` on it once it is set up. The first call marks the worker ready: the server start waits for that point, and an exit before it counts as a failure. Until that call, `max_execution_time` applies as in any request, 30 seconds by default: a setup that outlives it is ended and counts as a boot failure, so raise the limit in `php_ini`, or call `set_time_limit()` in the script, when the setup legitimately takes longer. This bound only holds on PHP builds with Zend max execution timers (`--enable-zend-max-execution-timers`, see [the compilation guide](compile.md)): elsewhere FrankenPHP disables `max_execution_time`, so a setup that never reaches its first call keeps the server start waiting, with a warning logged after 10 seconds. From the first call on, the run has no time limit, like the CLI. Every call returns `false` once FrankenPHP drains the worker, on shutdown, reboot or restart, and `true` otherwise. It never blocks and never hands out work: like `frankenphp_handle_request()`, it is where the runtime and the script meet. +The script takes a handle on the worker, `new FrankenPHP\WorkerHandle()`, and calls `tick()` on it once it is set up. The first call marks the worker ready: the server start waits for that point, and an exit before it counts as a failure. Until that call, `max_execution_time` applies as in any request, 30 seconds by default: a setup that outlives it is ended and counts as a boot failure, so raise the limit in `php_ini`, or call `set_time_limit()` in the script, when the setup legitimately takes longer. This bound only holds on PHP builds with Zend max execution timers (`--enable-zend-max-execution-timers`, see [the compilation guide](compile.md)): elsewhere FrankenPHP disables `max_execution_time`, and the `boot_timeout` of the worker is what ends the wait: 30 seconds by default, matching PHP's own `max_execution_time`, after which the start fails with the name of the worker that never ticked. Set it to `0` to wait forever, which is what happened before, with a warning logged after 10 seconds. From the first call on, the run has no time limit, like the CLI. Every call returns `false` once FrankenPHP drains the worker, on shutdown, reboot or restart, and `true` otherwise. It never blocks and never hands out work: like `frankenphp_handle_request()`, it is where the runtime and the script meet. Between two calls the script waits on the handle, which is an `Io\Poll\Handle`: an `Io\Poll\Context` takes it as it is, next to whatever else the script waits on. diff --git a/options.go b/options.go index d709669477..c95473209c 100644 --- a/options.go +++ b/options.go @@ -59,6 +59,8 @@ type workerOpt struct { onServerShutdown func() server *Server isBackgroundWorker bool + bootTimeout time.Duration + bootTimeoutIsSet bool } // WithContext sets the main context to use. @@ -256,6 +258,23 @@ func WithWorkerBackground() WorkerOption { } } +// EXPERIMENTAL: WithWorkerBootTimeout bounds how long a background worker may +// take to reach its ready point, its first WorkerHandle::tick(), before Init() +// gives up and returns an error. Zero waits for ever, as a build with Zend max +// execution timers does anyway. Defaults to DefaultWorkerBootTimeout, and has +// no effect on HTTP workers. +func WithWorkerBootTimeout(timeout time.Duration) WorkerOption { + return func(w *workerOpt) error { + if timeout < 0 { + return fmt.Errorf("worker boot timeout must be >= 0, got %s", timeout) + } + w.bootTimeout = timeout + w.bootTimeoutIsSet = true + + return nil + } +} + // WithWorkerMaxFailures sets the maximum number of consecutive failures before panicking func WithWorkerMaxFailures(maxFailures int) WorkerOption { return func(w *workerOpt) error { diff --git a/testdata/bgworker/never-ticks.php b/testdata/bgworker/never-ticks.php new file mode 100644 index 0000000000..a36c990c4b --- /dev/null +++ b/testdata/bgworker/never-ticks.php @@ -0,0 +1,10 @@ +getStream(); +fgets($stream); +fgets($stream); diff --git a/threadbackgroundworker.go b/threadbackgroundworker.go index 100f71de9f..212447a05a 100644 --- a/threadbackgroundworker.go +++ b/threadbackgroundworker.go @@ -201,8 +201,16 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { // max_consecutive_failures only fails hard during startup, through // startupFailChan; past it a failing worker keeps restarting with a - // louder log line rather than leave the server in a broken half-state + // louder log line rather than leave the server in a broken half-state. + // A worker Init() gave up on stops here whatever the cap says, it was + // drained precisely so this path could end the thread pastCap := worker.maxConsecutiveFailures >= 0 && handler.failureCount >= worker.maxConsecutiveFailures + if worker.bootTimedOut.Load() { + reportStartupFailure(fmt.Errorf("background worker %s did not call WorkerHandle::tick() in time", worker.fileName)) + handler.thread.state.Set(state.ShuttingDown) + + return + } if pastCap && !watcherIsEnabled { var err error if exitStatus == 0 { diff --git a/worker.go b/worker.go index 99f7c92da9..dba6ae3391 100644 --- a/worker.go +++ b/worker.go @@ -31,11 +31,14 @@ type worker struct { threads []*phpThread threadMutex sync.RWMutex maxConsecutiveFailures int - onThreadReady func(int) - onThreadShutdown func(int) - queuedRequests atomic.Int32 - server *Server - isBackgroundWorker bool + bootTimeout time.Duration + // Init() gave up waiting for the ready point: the run is drained and must not start again + bootTimedOut atomic.Bool + onThreadReady func(int) + onThreadShutdown func(int) + queuedRequests atomic.Int32 + server *Server + isBackgroundWorker bool } var ( @@ -48,6 +51,12 @@ var ( startupPhase atomic.Bool ) +// DefaultWorkerBootTimeout is how long a background worker may take to reach +// its first WorkerHandle::tick() before Init() fails. It matches PHP's default +// max_execution_time, which bounds the same bootstrap where Zend timers are +// around. WithWorkerBootTimeout() changes it, zero waits for ever. +const DefaultWorkerBootTimeout = 30 * time.Second + func initWorkers(opts []workerOpt) error { if len(opts) == 0 { return nil @@ -94,8 +103,30 @@ func initWorkers(opts []workerOpt) error { convertToWorkerThread(thread, w) } + bootTimeout := time.Duration(0) + if w.isBackgroundWorker { + bootTimeout = w.bootTimeout + } workersReady.Go(func() { - thread.state.WaitFor(state.Ready, state.ShuttingDown, state.Done) + if bootTimeout <= 0 { + thread.state.WaitFor(state.Ready, state.ShuttingDown, state.Done) + + return + } + + if thread.state.WaitForStateWithTimeout(bootTimeout, state.Ready, state.ShuttingDown, state.Done) { + return + } + + // the script never ticked: drain it, so the run ends and the + // thread reaches a state Shutdown() can act on + startupFailChan <- fmt.Errorf("background worker %q did not call WorkerHandle::tick() within %s", w.name, bootTimeout) + w.bootTimedOut.Store(true) + if handler, ok := thread.handler.(*backgroundWorkerThread); ok { + // the run then ends on the boot failure path, which stops + // the thread instead of starting it again, see afterScriptExecution() + handler.drain() + } }) } } @@ -245,6 +276,13 @@ func newWorker(o workerOpt) (*worker, error) { o.env["FRANKENPHP_WORKER\x00"] = "1" } + // a background worker that never reaches its ready point would keep Init() + // waiting for ever, see initWorkers() + bootTimeout := DefaultWorkerBootTimeout + if o.bootTimeoutIsSet { + bootTimeout = o.bootTimeout + } + w := &worker{ name: o.name, fileName: absFileName, @@ -256,6 +294,7 @@ func newWorker(o workerOpt) (*worker, error) { requestChan: make(chan *frankenPHPContext), threads: make([]*phpThread, 0, o.num), maxConsecutiveFailures: o.maxConsecutiveFailures, + bootTimeout: bootTimeout, onThreadReady: o.onThreadReady, onThreadShutdown: o.onThreadShutdown, server: o.server, From 732718ddb2821a93ee9e25915c4fbaff86926244 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 27 Oct 2026 17:39:52 +0100 Subject: [PATCH 08/13] test: expect the generated name as var_export() writes a Windows path --- bgworker_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bgworker_test.go b/bgworker_test.go index f2652110d3..578781912c 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -406,7 +406,8 @@ func TestBackgroundWorkerNameIsGenerated(t *testing.T) { script, err := filepath.Abs("testdata/bgworker/flag.php") require.NoError(t, err) - assert.Contains(t, requireFileContentEventually(t, sentinel), "'background' => '"+script+"'") + // var_export() doubles the backslashes of a Windows path + assert.Contains(t, requireFileContentEventually(t, sentinel), "'background' => '"+strings.ReplaceAll(script, `\`, `\\`)+"'") } // a background worker serves no requests, so num defaults to one thread rather From 486940a6bb26e97f835c3baf106da9fc8f5864f4 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 27 Oct 2026 18:48:10 +0100 Subject: [PATCH 09/13] fix: a boot timeout stops its thread and never blocks Init() The timeout reported through a blocking send, so a second timeout could wait for room in startupFailChan for good, Init() with it. It now reports like the handlers do and moves the thread to ShuttingDown, so no run starts after the one that overstayed, whether the timeout lands in a run or between two failed boots. The 30s default now only applies where Zend max execution timers are missing, as the docs said: elsewhere max_execution_time bounds the bootstrap, and a set_time_limit() raising it is no longer cut short. Comments and docs that drifted are updated, and the thread budget and the Caddy directive get the tests they lacked. --- bgworker_test.go | 69 ++++++++++++++++++++++++++----- caddy/config_test.go | 33 +++++++++++++++ caddy/workerconfig.go | 2 +- docs/config.md | 3 +- docs/worker.md | 2 +- options.go | 11 ++--- phpmainthread_test.go | 7 ++++ testdata/bgworker/never-ticks.php | 10 ++++- threadbackgroundworker.go | 21 +++++----- worker.go | 26 +++++++----- 10 files changed, 144 insertions(+), 40 deletions(-) diff --git a/bgworker_test.go b/bgworker_test.go index 578781912c..ffa7904f0c 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -423,13 +423,16 @@ func TestBackgroundWorkerDefaultsToOneThread(t *testing.T) { ) requireFileEventually(t, sentinel, "background worker did not start without an explicit num") + debug := frankenphp.DebugState() background := 0 - for _, thread := range frankenphp.DebugState().ThreadDebugStates { + for _, thread := range debug.ThreadDebugStates { if strings.Contains(thread.Name, "Background Worker") { background++ } } assert.Equal(t, 1, background) + // on top of num_threads, which keeps its regular thread + assert.Len(t, debug.ThreadDebugStates, 2) } // max_execution_time applies until the first tick, a setup that outlives it @@ -683,15 +686,61 @@ func TestWorkerHandleBuiltBehindTheConstructor(t *testing.T) { // a script that parks without ever ticking fails Init() instead of hanging it, // the only failure mode available where Zend max execution timers are not func TestBackgroundWorkerBootTimeout(t *testing.T) { - err := frankenphp.Init( - frankenphp.WithWorkers("bg-mute", "testdata/bgworker/never-ticks.php", 1, + // a hang fails the test instead of the whole run + initWithin := func(t *testing.T, options ...frankenphp.Option) error { + t.Helper() + done := make(chan error, 1) + go func() { done <- frankenphp.Init(options...) }() + + select { + case err := <-done: + if err == nil { + frankenphp.Shutdown() + } + + return err + case <-time.After(10 * time.Second): + t.Fatal("Init() did not return") + + return nil + } + } + mute := func(name string, timeout time.Duration, env map[string]string) frankenphp.Option { + return frankenphp.WithWorkers(name, "testdata/bgworker/never-ticks.php", 1, frankenphp.WithWorkerBackground(), - frankenphp.WithWorkerBootTimeout(300*time.Millisecond), - ), - frankenphp.WithNumThreads(2), - ) - if err == nil { - frankenphp.Shutdown() + frankenphp.WithWorkerBootTimeout(timeout), + frankenphp.WithWorkerEnv(env), + ) } - require.ErrorContains(t, err, `background worker "bg-mute" did not call WorkerHandle::tick() within 300ms`) + + t.Run("one worker", func(t *testing.T) { + err := initWithin(t, mute("bg-mute", 300*time.Millisecond, nil), frankenphp.WithNumThreads(2)) + require.ErrorContains(t, err, `background worker "bg-mute" did not call WorkerHandle::tick() within 300ms`) + }) + + // each timeout reports once and never waits for room to do so + t.Run("two workers timing out one after the other", func(t *testing.T) { + err := initWithin(t, + mute("bg-mute-a", 200*time.Millisecond, nil), + mute("bg-mute-b", time.Second, nil), + frankenphp.WithNumThreads(1), + ) + require.ErrorContains(t, err, `background worker "bg-mute-a" did not call WorkerHandle::tick() within 200ms`) + }) + + // a timeout landing between two failed boots stops the next one from starting + t.Run("timeout between two failed boots", func(t *testing.T) { + countFile := filepath.Join(t.TempDir(), "boots") + err := initWithin(t, + mute("bg-flaky", 300*time.Millisecond, map[string]string{"BG_COUNT_FILE": countFile, "BG_FAIL_UNTIL": "3"}), + frankenphp.WithNumThreads(1), + ) + require.ErrorContains(t, err, `background worker "bg-flaky" did not call WorkerHandle::tick() within 300ms`) + + b, err := os.ReadFile(countFile) + require.NoError(t, err) + boots, err := strconv.Atoi(string(b)) + require.NoError(t, err) + assert.LessOrEqual(t, boots, 3, "a run started after the timeout") + }) } diff --git a/caddy/config_test.go b/caddy/config_test.go index 345ba7bcef..1226a0223f 100644 --- a/caddy/config_test.go +++ b/caddy/config_test.go @@ -390,6 +390,39 @@ func TestWorkerBackgroundWithoutNumParses(t *testing.T) { require.NoError(t, module.UnmarshalCaddyfile(d)) } +func TestWorkerBootTimeout(t *testing.T) { + parse := func(t *testing.T, directives string) (*FrankenPHPModule, error) { + t.Helper() + module := &FrankenPHPModule{} + err := module.UnmarshalCaddyfile(caddyfile.NewTestDispenser(` + { + php_server { + worker { + file ../testdata/worker-with-env.php + ` + directives + ` + } + } + }`)) + + return module, err + } + + module, err := parse(t, "background\nboot_timeout 5s") + require.NoError(t, err) + require.Equal(t, caddy.Duration(5*time.Second), *module.Workers[0].BootTimeout) + + // zero waits forever + module, err = parse(t, "background\nboot_timeout 0") + require.NoError(t, err) + require.Equal(t, caddy.Duration(0), *module.Workers[0].BootTimeout) + + _, err = parse(t, "boot_timeout 5s") + require.ErrorContains(t, err, `"boot_timeout" is only supported for background workers`) + + _, err = parse(t, "background\nboot_timeout -1s") + require.ErrorContains(t, err, "boot_timeout must be >= 0") +} + func TestWorkerBackgroundRejectsMatch(t *testing.T) { d := caddyfile.NewTestDispenser(` { diff --git a/caddy/workerconfig.go b/caddy/workerconfig.go index cfd692f199..e3841924df 100644 --- a/caddy/workerconfig.go +++ b/caddy/workerconfig.go @@ -39,7 +39,7 @@ type workerConfig struct { MatchPath []string `json:"match_path,omitempty"` // MaxConsecutiveFailures sets the maximum number of consecutive failures before panicking (defaults to 6, set to -1 to never panick) MaxConsecutiveFailures *int `json:"max_consecutive_failures,omitempty"` - // BootTimeout bounds how long a background worker may take to reach its first WorkerHandle::tick() before the start fails (defaults to 30s, set to 0 to wait forever) + // BootTimeout bounds how long a background worker may take to reach its first WorkerHandle::tick() before the start fails (defaults to 30s on PHP builds without Zend max execution timers, where max_execution_time is disabled, set to 0 to wait forever) BootTimeout *caddy.Duration `json:"boot_timeout,omitempty"` // Background marks this worker as a background (non-HTTP) worker. Background bool `json:"background,omitempty"` diff --git a/docs/config.md b/docs/config.md index 1aa9e62060..8d813aaef6 100644 --- a/docs/config.md +++ b/docs/config.md @@ -111,7 +111,7 @@ You can also explicitly configure FrankenPHP using the [global option](https://c watch # Sets the path to watch for file changes. Can be specified more than once for multiple paths. name # Sets the name of the worker, used in logs and metrics. Default: absolute path of worker file max_consecutive_failures # Sets the maximum number of consecutive failures before the worker is considered unhealthy, -1 means the worker will always restart. Default: 6. - boot_timeout # EXPERIMENTAL: how long a background worker may take to reach its first tick() before the start fails, 0 waits forever. Default: 30s. + boot_timeout # EXPERIMENTAL: how long a background worker may take to reach its first tick() before the start fails, 0 waits forever. Default: 30s on PHP builds without Zend max execution timers, none elsewhere. background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, whose script ticks its FrankenPHP\WorkerHandle at least once } } @@ -200,6 +200,7 @@ php_server [] { watch # Sets the path to watch for file changes. Can be specified more than once for multiple paths. env # Sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables. Environment variables for this worker are also inherited from the php_server parent, but can be overwritten here. match # match the worker to a path pattern. Overrides try_files and can only be used in the php_server directive. + boot_timeout # EXPERIMENTAL: how long a background worker may take to reach its first tick() before the start fails, 0 waits forever. Default: 30s on PHP builds without Zend max execution timers, none elsewhere. background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, whose script ticks its FrankenPHP\WorkerHandle at least once } worker # Can also use the short form like in the global frankenphp block. diff --git a/docs/worker.md b/docs/worker.md index bab77b1de4..6aa2d49959 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -223,7 +223,7 @@ php_server { } ``` -The script takes a handle on the worker, `new FrankenPHP\WorkerHandle()`, and calls `tick()` on it once it is set up. The first call marks the worker ready: the server start waits for that point, and an exit before it counts as a failure. Until that call, `max_execution_time` applies as in any request, 30 seconds by default: a setup that outlives it is ended and counts as a boot failure, so raise the limit in `php_ini`, or call `set_time_limit()` in the script, when the setup legitimately takes longer. This bound only holds on PHP builds with Zend max execution timers (`--enable-zend-max-execution-timers`, see [the compilation guide](compile.md)): elsewhere FrankenPHP disables `max_execution_time`, and the `boot_timeout` of the worker is what ends the wait: 30 seconds by default, matching PHP's own `max_execution_time`, after which the start fails with the name of the worker that never ticked. Set it to `0` to wait forever, which is what happened before, with a warning logged after 10 seconds. From the first call on, the run has no time limit, like the CLI. Every call returns `false` once FrankenPHP drains the worker, on shutdown, reboot or restart, and `true` otherwise. It never blocks and never hands out work: like `frankenphp_handle_request()`, it is where the runtime and the script meet. +The script takes a handle on the worker, `new FrankenPHP\WorkerHandle()`, and calls `tick()` on it once it is set up. The first call marks the worker ready: the server start waits for that point, and an exit before it counts as a failure. Until that call, `max_execution_time` applies as in any request, 30 seconds by default: a setup that outlives it is ended and counts as a boot failure, so raise the limit in `php_ini`, or call `set_time_limit()` in the script, when the setup legitimately takes longer. This bound only holds on PHP builds with Zend max execution timers (`--enable-zend-max-execution-timers`, see [the compilation guide](compile.md)): elsewhere FrankenPHP disables `max_execution_time`, and the `boot_timeout` of the worker ends the wait instead: 30 seconds by default, matching PHP's own `max_execution_time`, after which the start fails with the name of the worker that never ticked. Its script is then stopped, at once if it waits on its handle, or by the force-kill of the shutdown otherwise. Set `boot_timeout` to `0` to wait forever, with a warning logged after 10 seconds; it applies on every build when set. From the first call on, the run has no time limit, like the CLI. Every call returns `false` once FrankenPHP drains the worker, on shutdown, reboot or restart, and `true` otherwise. It never blocks and never hands out work: like `frankenphp_handle_request()`, it is where the runtime and the script meet. Between two calls the script waits on the handle, which is an `Io\Poll\Handle`: an `Io\Poll\Context` takes it as it is, next to whatever else the script waits on. diff --git a/options.go b/options.go index c95473209c..a807d8f0cd 100644 --- a/options.go +++ b/options.go @@ -247,9 +247,9 @@ func WithWorkerServerScope(s *Server) WorkerOption { // EXPERIMENTAL: WithWorkerBackground marks this worker as a background // (non-HTTP) worker: it shares the PHP runtime with the HTTP threads but -// never receives requests. The script parks on the stream returned by -// WorkerHandle::getStream(), which reaches EOF when FrankenPHP drains the -// worker, to exit gracefully on shutdown or restart. +// never receives requests. Its script ticks a FrankenPHP\WorkerHandle: the +// first tick() marks the worker ready, and tick() returns false once +// FrankenPHP drains the worker, on shutdown or restart. func WithWorkerBackground() WorkerOption { return func(w *workerOpt) error { w.isBackgroundWorker = true @@ -260,8 +260,9 @@ func WithWorkerBackground() WorkerOption { // EXPERIMENTAL: WithWorkerBootTimeout bounds how long a background worker may // take to reach its ready point, its first WorkerHandle::tick(), before Init() -// gives up and returns an error. Zero waits for ever, as a build with Zend max -// execution timers does anyway. Defaults to DefaultWorkerBootTimeout, and has +// gives up and returns an error. Zero waits for ever. Defaults to +// DefaultWorkerBootTimeout on PHP builds without Zend max execution timers, +// and to zero elsewhere, where max_execution_time bounds the bootstrap. It has // no effect on HTTP workers. func WithWorkerBootTimeout(timeout time.Duration) WorkerOption { return func(w *workerOpt) error { diff --git a/phpmainthread_test.go b/phpmainthread_test.go index d8a5b6742d..a58a361ade 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -357,6 +357,13 @@ func TestCorrectThreadCalculation(t *testing.T) { testThreadCalculation(t, 2, 5, &opt{maxThreads: 5, workers: []workerOpt{{num: 1, maxThreads: 3}}}) testThreadCalculation(t, 3, 5, &opt{maxThreads: 5, workers: []workerOpt{{num: 1, maxThreads: 4}, {num: 1, maxThreads: 4}}}) + // a background worker counts like any worker, with one thread unless told otherwise + oneBackgroundWorker := []workerOpt{{isBackgroundWorker: true}} + testThreadCalculation(t, maxProcs, maxProcs, &opt{workers: oneBackgroundWorker}) + testThreadCalculation(t, 2, 2, &opt{numThreads: 1, workers: oneBackgroundWorker}) + testThreadCalculation(t, 2, 4, &opt{maxThreads: 4, workers: oneBackgroundWorker}) + testThreadCalculationError(t, &opt{numThreads: 1, maxThreads: 1, workers: oneBackgroundWorker}) + // a worker never eats into num_threads, so there is no floor to miss; // max_threads still has to fit what starts testThreadCalculationError(t, &opt{numThreads: 1, maxThreads: 1, workers: oneWorkerThread}) diff --git a/testdata/bgworker/never-ticks.php b/testdata/bgworker/never-ticks.php index a36c990c4b..5f33d5f1cb 100644 --- a/testdata/bgworker/never-ticks.php +++ b/testdata/bgworker/never-ticks.php @@ -2,8 +2,16 @@ // Bg worker that parks on its handle without ever calling tick(): it eats // the wake-up sent at start, then waits for the drain, so only the boot -// timeout can end the wait Init() is in. +// timeout can end the wait Init() is in. With BG_FAIL_UNTIL, its first boots +// crash before that, counted in BG_COUNT_FILE. set_time_limit(0); +if (isset($_SERVER['BG_FAIL_UNTIL'])) { + $count = (int) @file_get_contents($_SERVER['BG_COUNT_FILE']) + 1; + file_put_contents($_SERVER['BG_COUNT_FILE'], (string) $count); + if ($count <= (int) $_SERVER['BG_FAIL_UNTIL']) { + exit(1); + } +} $handle = new \FrankenPHP\WorkerHandle(); $stream = $handle->getStream(); fgets($stream); diff --git a/threadbackgroundworker.go b/threadbackgroundworker.go index 212447a05a..ae5547933c 100644 --- a/threadbackgroundworker.go +++ b/threadbackgroundworker.go @@ -15,9 +15,9 @@ import ( // backgroundWorkerThread is the threadHandler of background worker scripts: // boot the script, re-run it when it exits, restart it with a quadratic -// backoff when it crashes. The script parks on the stream returned by -// WorkerHandle::getStream(), which reaches EOF when the thread is drained, -// so it exits on shutdown, reboot or handler transition. +// backoff when it crashes. The script waits on its WorkerHandle between two +// tick() calls, and the drain makes tick() return false, so it exits on +// shutdown, reboot or handler transition. type backgroundWorkerThread struct { workerLifecycle @@ -25,8 +25,8 @@ type backgroundWorkerThread struct { context *frankenPHPContext failureCount int // number of consecutive failed runs - // consecutive short runs, crashed or not, pacing the restarts; a run - // that outlives maxRestartBackoff resets the count + // consecutive runs past the ready point that crashed or ended too + // fast, pacing the restarts; a clean exit after minHealthyRun resets it crashCount int runStartedAt time.Time @@ -43,8 +43,8 @@ type backgroundWorkerThread struct { } // backgroundBootWarnDelay is how long a run may go without calling -// WorkerHandle::tick() before a warning: Init() and Shutdown() wait for -// that point, so a script that never gets there hangs both silently +// WorkerHandle::tick() before a warning: until then the worker is not ready, +// and Init() waits for that point unless a boot timeout bounds the wait const backgroundBootWarnDelay = 10 * time.Second func convertToBackgroundWorkerThread(thread *phpThread, worker *worker) { @@ -137,7 +137,7 @@ func (handler *backgroundWorkerThread) setupScript() error { logger, ctx, name, threadIndex := fc.logger, fc.ctx, handler.worker.name, handler.thread.threadIndex handler.bootTimer = time.AfterFunc(backgroundBootWarnDelay, func() { if logger.Enabled(ctx, slog.LevelWarn) { - logger.LogAttrs(ctx, slog.LevelWarn, "background worker has not called WorkerHandle::tick() yet, Init() and Shutdown() wait for it", slog.String("worker", name), slog.Int("thread", threadIndex)) + logger.LogAttrs(ctx, slog.LevelWarn, "background worker has not called WorkerHandle::tick() yet, it is not ready", slog.String("worker", name), slog.Int("thread", threadIndex)) } }) @@ -202,11 +202,10 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { // max_consecutive_failures only fails hard during startup, through // startupFailChan; past it a failing worker keeps restarting with a // louder log line rather than leave the server in a broken half-state. - // A worker Init() gave up on stops here whatever the cap says, it was - // drained precisely so this path could end the thread + // A worker Init() gave up on stops here whatever the cap says, its + // timeout already reported it pastCap := worker.maxConsecutiveFailures >= 0 && handler.failureCount >= worker.maxConsecutiveFailures if worker.bootTimedOut.Load() { - reportStartupFailure(fmt.Errorf("background worker %s did not call WorkerHandle::tick() in time", worker.fileName)) handler.thread.state.Set(state.ShuttingDown) return diff --git a/worker.go b/worker.go index dba6ae3391..1e2d3e2110 100644 --- a/worker.go +++ b/worker.go @@ -52,9 +52,10 @@ var ( ) // DefaultWorkerBootTimeout is how long a background worker may take to reach -// its first WorkerHandle::tick() before Init() fails. It matches PHP's default -// max_execution_time, which bounds the same bootstrap where Zend timers are -// around. WithWorkerBootTimeout() changes it, zero waits for ever. +// its first WorkerHandle::tick() before Init() fails, on PHP builds without +// Zend max execution timers: FrankenPHP disables max_execution_time there, +// and this matches its default. Elsewhere max_execution_time bounds the +// bootstrap instead. WithWorkerBootTimeout() sets it on any build. const DefaultWorkerBootTimeout = 30 * time.Second func initWorkers(opts []workerOpt) error { @@ -118,13 +119,15 @@ func initWorkers(opts []workerOpt) error { return } - // the script never ticked: drain it, so the run ends and the - // thread reaches a state Shutdown() can act on - startupFailChan <- fmt.Errorf("background worker %q did not call WorkerHandle::tick() within %s", w.name, bootTimeout) + // the script never ticked: stop the thread, so no run starts + // after this one, which ends at once if it waits on its handle + // and through the force-kill of the shutdown otherwise + if !thread.state.CompareAndSwap(state.TransitionComplete, state.ShuttingDown) { + return + } w.bootTimedOut.Store(true) + reportStartupFailure(fmt.Errorf("background worker %q did not call WorkerHandle::tick() within %s", w.name, bootTimeout)) if handler, ok := thread.handler.(*backgroundWorkerThread); ok { - // the run then ends on the boot failure path, which stops - // the thread instead of starting it again, see afterScriptExecution() handler.drain() } }) @@ -277,8 +280,11 @@ func newWorker(o workerOpt) (*worker, error) { } // a background worker that never reaches its ready point would keep Init() - // waiting for ever, see initWorkers() - bootTimeout := DefaultWorkerBootTimeout + // waiting for ever where max_execution_time is disabled, see initWorkers() + bootTimeout := time.Duration(0) + if !Config().ZendMaxExecutionTimers { + bootTimeout = DefaultWorkerBootTimeout + } if o.bootTimeoutIsSet { bootTimeout = o.bootTimeout } From f94e054d6ecdce75a47057101467143d36c01f6b Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sun, 6 Sep 2026 22:08:10 +0200 Subject: [PATCH 10/13] feat: frankenphp_get_vars() and WorkerHandle::setVars() --- docs/worker.md | 16 ++++ frankenphp.c | 65 ++++++++++++- frankenphp.h | 2 + frankenphp.stub.php | 15 +++ frankenphp_arginfo.h | 14 ++- phpmainthread.go | 2 + testdata/bgworker/bad-vars.php | 19 ++++ testdata/bgworker/consumer.php | 18 ++++ testdata/bgworker/publish-once.php | 17 ++++ testdata/bgworker/publisher.php | 21 ++++ testdata/persist-roundtrip.php | 13 +++ testdata/vars.php | 7 ++ threadbackgroundworker.go | 1 + worker.go | 12 +++ workervars.go | 149 +++++++++++++++++++++++++++++ workervars_test.go | 95 ++++++++++++++++++ zval.h | 90 ++++------------- zval_test.go | 1 + 18 files changed, 482 insertions(+), 75 deletions(-) create mode 100644 testdata/bgworker/bad-vars.php create mode 100644 testdata/bgworker/consumer.php create mode 100644 testdata/bgworker/publish-once.php create mode 100644 testdata/bgworker/publisher.php create mode 100644 testdata/vars.php create mode 100644 workervars.go create mode 100644 workervars_test.go diff --git a/docs/worker.md b/docs/worker.md index 6aa2d49959..51e85e83e8 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -279,6 +279,22 @@ That stream is there for the libraries that take one, and waiting is all it supp `$_SERVER['FRANKENPHP_WORKER_BACKGROUND']` holds the name of the worker. `FRANKENPHP_WORKER`, the variable of HTTP workers, is not set, so a script serving both roles tests which of the two is set. Their threads count like the threads of HTTP workers, on top of `num_threads` and within `max_threads`; they don't autoscale, so `max_threads` is not allowed on them. From Go, declare one with `WithWorkerBackground()`. +### Sharing state with background workers + +A background worker publishes a snapshot with `setVars()` on its handle; requests and other workers read it with `frankenphp_get_vars()`, by worker name, resolved like requests are: within the `php_server`, then among global workers. Publishing needs a handle because only a worker publishes its own vars, while reading only needs a name, since anyone reads anyone's. Values must be null, scalars, arrays or enums. Each call replaces the whole snapshot and readers get a copy, so the worker can publish at any time and a request always sees a consistent one. Publish before the first tick and the snapshot is in place before the server accepts requests; while the worker restarts, readers keep getting the last one. + +```php +// background worker +$handle = new FrankenPHP\WorkerHandle(); +$handle->setVars(['maintenance' => false, 'flags' => ['beta' => true]]); +// ... + +// request, HTTP worker or another background worker +$vars = frankenphp_get_vars('config'); +``` + +`frankenphp_get_vars()` blocks until the worker reached its ready point, which can only happen between background workers reading each other while booting; a cycle between them throws instead of hanging. It also throws when the name is unknown, or when the worker is ready but has not published anything. + ## Superglobals behavior [PHP superglobals](https://www.php.net/manual/language.variables.superglobals.php) (`$_SERVER`, `$_ENV`, `$_GET`...) diff --git a/frankenphp.c b/frankenphp.c index bef3251f1b..1b559612af 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -75,12 +75,7 @@ zend_register_internal_class_with_flags(zend_class_entry *class_entry, #endif #include "frankenphp_arginfo.h" -#ifdef FRANKENPHP_TEST -/* The persistent_zval helpers are only compiled in when a consumer needs - * them. The step that lands the first real caller (background workers) - * will drop this guard. */ #include "zval.h" -#endif #if defined(PHP_WIN32) && defined(ZTS) ZEND_TSRMLS_CACHE_DEFINE() @@ -1651,6 +1646,66 @@ ZEND_METHOD(FrankenPHP_WorkerHandle, tick) { } } +/* Shared vars of background workers, see WorkerHandle::setVars() and + * frankenphp_get_vars(): the persistent tables live in slots owned by the + * Go side, which copies and frees them through these two helpers. */ +void frankenphp_vars_to_request(zval *return_value, HashTable *table) { + zval persistent; + ZVAL_ARR(&persistent, table); + persistent_zval_to_request(return_value, &persistent); +} + +void frankenphp_vars_free(HashTable *table) { + zval persistent; + ZVAL_ARR(&persistent, table); + persistent_zval_free(&persistent); +} + +ZEND_METHOD(FrankenPHP_WorkerHandle, setVars) { + zval *vars; + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_ARRAY(vars) + ZEND_PARSE_PARAMETERS_END(); + + if (!frankenphp_worker_handle_usable()) { + RETURN_THROWS(); + } + + /* validate the whole tree first: persist and free recurse without a + * guard of their own */ + if (!persistent_zval_validate(vars)) { + zend_value_error( + "FrankenPHP\\WorkerHandle::setVars(): values must be null, scalars, " + "arrays or enums, nested no deeper than %d levels", + PERSISTENT_ZVAL_MAX_DEPTH); + RETURN_THROWS(); + } + + zval persistent; + persistent_zval_persist(&persistent, vars); + + HashTable *old = + go_frankenphp_set_vars(frankenphp_thread_index(), Z_ARRVAL(persistent)); + if (old != NULL) { + frankenphp_vars_free(old); + } +} + +PHP_FUNCTION(frankenphp_get_vars) { + zend_string *name; + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_STR(name) + ZEND_PARSE_PARAMETERS_END(); + + char *error = go_frankenphp_get_vars( + frankenphp_thread_index(), ZSTR_VAL(name), ZSTR_LEN(name), return_value); + if (error != NULL) { + zend_throw_exception(spl_ce_RuntimeException, error, 0); + free(error); + RETURN_THROWS(); + } +} + /* {{{ thread-safe opcache reset */ PHP_FUNCTION(frankenphp_opcache_reset) { go_schedule_opcache_reset(frankenphp_thread_index()); diff --git a/frankenphp.h b/frankenphp.h index 5f94e65826..afbf295992 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -222,6 +222,8 @@ typedef enum { /* Background worker primitives. */ intptr_t frankenphp_set_background_worker_and_get_stop_sock(void); void frankenphp_worker_close_stop_sock(intptr_t s); +void frankenphp_vars_to_request(zval *return_value, HashTable *table); +void frankenphp_vars_free(HashTable *table); void register_extensions(zend_module_entry **m, int len); diff --git a/frankenphp.stub.php b/frankenphp.stub.php index b2bf5d729a..51e771e853 100644 --- a/frankenphp.stub.php +++ b/frankenphp.stub.php @@ -55,6 +55,14 @@ function mercure_publish(string|array $topics, string $data = '', bool $private * array $context Values of the array will be converted to the corresponding Go type (if supported by FrankenPHP) and added to the context of the structured logs using https://pkg.go.dev/log/slog#Attr */ function frankenphp_log(string $message, int $level = 0, array $context = []): void {} + /** + * Returns a copy of the vars last published by the named background worker, + * resolved within the current php_server, then among global workers. Blocks + * until that worker reached its ready point. Throws if the worker is + * unknown, if it is ready but has not published any vars, or if background + * workers wait on each other in a cycle. + */ + function frankenphp_get_vars(string $name): array {} } namespace FrankenPHP { @@ -99,5 +107,12 @@ public function tick(): bool {} * @return resource */ public function getStream() {} + + /** + * Publishes the vars of this background worker: the array replaces + * the previous snapshot, atomically for readers, which get copies. + * Values must be null, scalars, arrays or enums. + */ + public function setVars(array $vars): void {} } } diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index 76d28dcf1d..7f69a5f910 100644 --- a/frankenphp_arginfo.h +++ b/frankenphp_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit the .stub.php file instead. - * Stub hash: 98816e2cc582ef5c787dd12140a0c41fd19b484f */ + * Stub hash: f7af1f79ac730e1c61c5293e436776ae4c83fa8d */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_handle_request, 0, 1, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, callback, IS_CALLABLE, 0) @@ -41,6 +41,10 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_log, 0, 1, IS_VOID, 0 ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, context, IS_ARRAY, 0, "[]") ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_get_vars, 0, 1, IS_ARRAY, 0) + ZEND_ARG_TYPE_INFO(0, name, IS_STRING, 0) +ZEND_END_ARG_INFO() + ZEND_BEGIN_ARG_INFO_EX(arginfo_class_FrankenPHP_WorkerHandle___construct, 0, 0, 0) ZEND_END_ARG_INFO() @@ -48,6 +52,10 @@ ZEND_END_ARG_INFO() #define arginfo_class_FrankenPHP_WorkerHandle_getStream arginfo_class_FrankenPHP_WorkerHandle___construct +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_FrankenPHP_WorkerHandle_setVars, 0, 1, IS_VOID, 0) + ZEND_ARG_TYPE_INFO(0, vars, IS_ARRAY, 0) +ZEND_END_ARG_INFO() + ZEND_FUNCTION(frankenphp_handle_request); ZEND_FUNCTION(headers_send); ZEND_FUNCTION(frankenphp_finish_request); @@ -55,9 +63,11 @@ ZEND_FUNCTION(frankenphp_request_headers); ZEND_FUNCTION(frankenphp_response_headers); ZEND_FUNCTION(mercure_publish); ZEND_FUNCTION(frankenphp_log); +ZEND_FUNCTION(frankenphp_get_vars); ZEND_METHOD(FrankenPHP_WorkerHandle, __construct); ZEND_METHOD(FrankenPHP_WorkerHandle, tick); ZEND_METHOD(FrankenPHP_WorkerHandle, getStream); +ZEND_METHOD(FrankenPHP_WorkerHandle, setVars); static const zend_function_entry ext_functions[] = { ZEND_FE(frankenphp_handle_request, arginfo_frankenphp_handle_request) @@ -71,6 +81,7 @@ static const zend_function_entry ext_functions[] = { ZEND_FALIAS(apache_response_headers, frankenphp_response_headers, arginfo_apache_response_headers) ZEND_FE(mercure_publish, arginfo_mercure_publish) ZEND_FE(frankenphp_log, arginfo_frankenphp_log) + ZEND_FE(frankenphp_get_vars, arginfo_frankenphp_get_vars) ZEND_FE_END }; @@ -78,6 +89,7 @@ static const zend_function_entry class_FrankenPHP_WorkerHandle_methods[] = { ZEND_ME(FrankenPHP_WorkerHandle, __construct, arginfo_class_FrankenPHP_WorkerHandle___construct, ZEND_ACC_PUBLIC) ZEND_ME(FrankenPHP_WorkerHandle, tick, arginfo_class_FrankenPHP_WorkerHandle_tick, ZEND_ACC_PUBLIC) ZEND_ME(FrankenPHP_WorkerHandle, getStream, arginfo_class_FrankenPHP_WorkerHandle_getStream, ZEND_ACC_PUBLIC) + ZEND_ME(FrankenPHP_WorkerHandle, setVars, arginfo_class_FrankenPHP_WorkerHandle_setVars, ZEND_ACC_PUBLIC) ZEND_FE_END }; diff --git a/phpmainthread.go b/phpmainthread.go index 8a3b0a7c5c..55ef42d8b8 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -105,6 +105,8 @@ func drainPHPThreads() { } doneWG.Wait() + // no PHP thread can read them anymore, and the engine is still up + freeWorkerVars() mainThread.state.Set(state.Done) mainThread.state.WaitFor(state.Reserved) C.frankenphp_destroy_thread_metrics() diff --git a/testdata/bgworker/bad-vars.php b/testdata/bgworker/bad-vars.php new file mode 100644 index 0000000000..9b8a3dc93d --- /dev/null +++ b/testdata/bgworker/bad-vars.php @@ -0,0 +1,19 @@ +setVars(['object' => new stdClass()]); + $result = 'no exception'; +} catch (\Throwable $e) { + $result = get_class($e) . ': ' . $e->getMessage(); +} +file_put_contents($_SERVER['BG_SENTINEL'], $result); +$stream = $handle->getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/consumer.php b/testdata/bgworker/consumer.php new file mode 100644 index 0000000000..16cab6d9c2 --- /dev/null +++ b/testdata/bgworker/consumer.php @@ -0,0 +1,18 @@ +getMessage(); +} +file_put_contents($_SERVER['BG_SENTINEL'], $result); +$handle = new \FrankenPHP\WorkerHandle(); +$stream = $handle->getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/publish-once.php b/testdata/bgworker/publish-once.php new file mode 100644 index 0000000000..c4e2fae4b1 --- /dev/null +++ b/testdata/bgworker/publish-once.php @@ -0,0 +1,17 @@ +setVars(['answer' => 42, 'nested' => ['a' => 1, 'list' => [true, null, 1.5, 'x']]]); +} +$stream = $handle->getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/publisher.php b/testdata/bgworker/publisher.php new file mode 100644 index 0000000000..2f8eff5d85 --- /dev/null +++ b/testdata/bgworker/publisher.php @@ -0,0 +1,21 @@ +setVars([ + 'answer' => 42, + 'value' => $_SERVER['BG_PUBLISH_VALUE'] ?? 'default', + 'nested' => ['a' => 1, 'list' => [true, null, 1.5, 'x']], + 'worker' => $_SERVER['FRANKENPHP_WORKER_BACKGROUND'], +]); +$stream = $handle->getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/persist-roundtrip.php b/testdata/persist-roundtrip.php index f2bcec0627..fa78a876ed 100644 --- a/testdata/persist-roundtrip.php +++ b/testdata/persist-roundtrip.php @@ -86,3 +86,16 @@ function same(mixed $actual, mixed $expected, string $label): void { } catch (\LogicException) { echo "OK nested stdClass rejected\n"; } + +// A literal array is opcache-immutable, which the persistent tree copies +// rather than borrows: round-trip the same literal more times than the +// refcount of 2 opcache keeps on it, so nothing shared is ever released. +for ($i = 0; $i < 3; ++$i) { + $out = $rt(['immutable' => ['nested' => [1, 2, 3]], 'i' => 'literal']); + if ($out !== ['immutable' => ['nested' => [1, 2, 3]], 'i' => 'literal']) { + echo "FAIL immutable literal exposed repeatedly (round $i)\n"; + return; + } + unset($out); +} +echo "OK immutable literal exposed repeatedly\n"; diff --git a/testdata/vars.php b/testdata/vars.php new file mode 100644 index 0000000000..0555109953 --- /dev/null +++ b/testdata/vars.php @@ -0,0 +1,7 @@ +getMessage(); +} diff --git a/threadbackgroundworker.go b/threadbackgroundworker.go index ae5547933c..70c9d2f81d 100644 --- a/threadbackgroundworker.go +++ b/threadbackgroundworker.go @@ -260,6 +260,7 @@ func go_frankenphp_background_worker_ready(threadIndex C.uintptr_t) { // the boot succeeded, only consecutive boot failures count handler.failureCount = 0 handler.stopBootTimer() + handler.worker.markReady() metrics.ReadyWorker(handler.worker.name) // parked from now on as far as the threads state endpoint is concerned handler.state.MarkAsWaiting(true) diff --git a/worker.go b/worker.go index 1e2d3e2110..87219df0c3 100644 --- a/worker.go +++ b/worker.go @@ -39,6 +39,17 @@ type worker struct { queuedRequests atomic.Int32 server *Server isBackgroundWorker bool + // readyOnce is closed the first time a thread of a background worker + // reaches its ready point; frankenphp_get_vars() readers wait on it + readyOnce chan struct{} + readyClose sync.Once + // vars is the snapshot published with WorkerHandle::setVars() + vars varsSlot +} + +// markReady records that the background worker reached its ready point once +func (worker *worker) markReady() { + worker.readyClose.Do(func() { close(worker.readyOnce) }) } var ( @@ -305,6 +316,7 @@ func newWorker(o workerOpt) (*worker, error) { onThreadShutdown: o.onThreadShutdown, server: o.server, isBackgroundWorker: o.isBackgroundWorker, + readyOnce: make(chan struct{}), } w.configureMercure(&o) diff --git a/workervars.go b/workervars.go new file mode 100644 index 0000000000..42da880b52 --- /dev/null +++ b/workervars.go @@ -0,0 +1,149 @@ +package frankenphp + +// #include "frankenphp.h" +import "C" +import ( + "errors" + "strconv" + "sync" +) + +// varsSlot holds the snapshot a background worker published through +// WorkerHandle::setVars(): a persistent HashTable, copied into request memory +// by each frankenphp_get_vars() reader. It belongs to the worker, not to a +// thread, so it survives script restarts and serves stale data meanwhile. +type varsSlot struct { + mu sync.RWMutex + table *C.HashTable +} + +var ( + // booting background workers blocked in frankenphp_get_vars() on other + // workers, keyed by waiter: a cycle would deadlock Init(), refuse it + varsWaitMu sync.Mutex + varsWaitOn = map[*worker]map[*worker]int{} +) + +// varsWorker resolves the name of a background worker +func varsWorker(name string) (*worker, error) { + w := workersByName[name] + if w == nil || !w.isBackgroundWorker { + return nil, errors.New("frankenphp_get_vars(): unknown background worker " + strconv.Quote(name)) + } + + return w, nil +} + +// waitVarsReady blocks until target reached its ready point once. Requests +// cannot get here before that (activateServers() runs after initWorkers()), +// so a blocked caller is a background worker still booting: waits between +// workers form a graph, and a cycle is refused instead of deadlocking Init() +func waitVarsReady(target, caller *worker) error { + select { + case <-target.readyOnce: + return nil + default: + } + + if caller != nil { + varsWaitMu.Lock() + if caller == target || varsWaitReaches(target, caller) { + varsWaitMu.Unlock() + + return errors.New("frankenphp_get_vars(): circular dependency between background workers " + strconv.Quote(caller.name) + " and " + strconv.Quote(target.name)) + } + if varsWaitOn[caller] == nil { + varsWaitOn[caller] = map[*worker]int{} + } + varsWaitOn[caller][target]++ + varsWaitMu.Unlock() + + defer func() { + varsWaitMu.Lock() + if varsWaitOn[caller][target]--; varsWaitOn[caller][target] == 0 { + delete(varsWaitOn[caller], target) + } + if len(varsWaitOn[caller]) == 0 { + delete(varsWaitOn, caller) + } + varsWaitMu.Unlock() + }() + } + + select { + case <-target.readyOnce: + return nil + case <-mainThread.done: + return errors.New("frankenphp_get_vars(): FrankenPHP is shutting down") + } +} + +// varsWaitReaches reports whether from waits, transitively, on to; called +// with varsWaitMu held +func varsWaitReaches(from, to *worker) bool { + for next := range varsWaitOn[from] { + if next == to || varsWaitReaches(next, to) { + return true + } + } + + return false +} + +// freeWorkerVars releases the snapshots once no PHP thread can read them +// and before the engine goes away +func freeWorkerVars() { + for _, w := range workers { + w.vars.mu.Lock() + if w.vars.table != nil { + C.frankenphp_vars_free(w.vars.table) + w.vars.table = nil + } + w.vars.mu.Unlock() + } +} + +//export go_frankenphp_set_vars +func go_frankenphp_set_vars(threadIndex C.uintptr_t, table *C.HashTable) *C.HashTable { + handler, ok := phpThreads[threadIndex].handler.(*backgroundWorkerThread) + if !ok { + // only a background worker can hold a handle; hand the table back so + // it is freed + return table + } + + slot := &handler.worker.vars + slot.mu.Lock() + old := slot.table + slot.table = table + slot.mu.Unlock() + + return old +} + +//export go_frankenphp_get_vars +func go_frankenphp_get_vars(threadIndex C.uintptr_t, name *C.char, nameLen C.size_t, returnValue *C.zval) *C.char { + thread := phpThreads[threadIndex] + target, err := varsWorker(C.GoStringN(name, C.int(nameLen))) + if err != nil { + return C.CString(err.Error()) + } + + var caller *worker + if handler, ok := thread.handler.(*backgroundWorkerThread); ok && handler.isBootingScript { + caller = handler.worker + } + if err := waitVarsReady(target, caller); err != nil { + return C.CString(err.Error()) + } + + slot := &target.vars + slot.mu.RLock() + defer slot.mu.RUnlock() + if slot.table == nil { + return C.CString("frankenphp_get_vars(): background worker " + strconv.Quote(target.name) + " has not published any vars yet") + } + C.frankenphp_vars_to_request(returnValue, slot.table) + + return nil +} diff --git a/workervars_test.go b/workervars_test.go new file mode 100644 index 0000000000..63e5a1dc36 --- /dev/null +++ b/workervars_test.go @@ -0,0 +1,95 @@ +package frankenphp_test + +import ( + "path/filepath" + "testing" + + "github.com/dunglas/frankenphp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// bgWorker declares a background worker from testdata/bgworker, scoped to +// server when one is given +func bgWorker(name, file string, env map[string]string, server *frankenphp.Server) frankenphp.Option { + opts := []frankenphp.WorkerOption{frankenphp.WithWorkerBackground(), frankenphp.WithWorkerEnv(env)} + if server != nil { + opts = append(opts, frankenphp.WithWorkerServerScope(server)) + } + + return frankenphp.WithWorkers(name, "testdata/bgworker/"+file, 1, opts...) +} + +func TestVarsRoundTrip(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), bgWorker("publisher", "publisher.php", nil, server), frankenphp.WithNumThreads(2)) + + body := serverGet(t, server, "http://example.com/vars.php?name=publisher") + assert.JSONEq(t, `{"answer":42,"value":"default","nested":{"a":1,"list":[true,null,1.5,"x"]},"worker":"publisher"}`, body) + + // a second read is a fresh copy of the same snapshot + assert.JSONEq(t, body, serverGet(t, server, "http://example.com/vars.php?name=publisher")) + assert.Contains(t, serverGet(t, server, "http://example.com/vars.php?name=nope"), "unknown background worker") +} + +// a worker booting before its dependency has published blocks in +// frankenphp_get_vars() until the dependency is ready +func TestVarsBlockUntilReady(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "consumer.json") + initServers(t, + bgWorker("consumer", "consumer.php", map[string]string{"BG_CONSUME": "publisher", "BG_SENTINEL": sentinel}, nil), + bgWorker("publisher", "publisher.php", map[string]string{"BG_PUBLISH_DELAY_MS": "500"}, nil), + frankenphp.WithNumThreads(3), + ) + + assert.Contains(t, requireFileContentEventually(t, sentinel), `"answer":42`) +} + +func TestVarsCycleIsRefused(t *testing.T) { + tmp := t.TempDir() + s1, s2 := filepath.Join(tmp, "c1.txt"), filepath.Join(tmp, "c2.txt") + initServers(t, + bgWorker("c1", "consumer.php", map[string]string{"BG_CONSUME": "c2", "BG_SENTINEL": s1}, nil), + bgWorker("c2", "consumer.php", map[string]string{"BG_CONSUME": "c1", "BG_SENTINEL": s2}, nil), + frankenphp.WithNumThreads(3), + ) + + // one side sees the cycle, the other then reads a ready worker that never published + results := requireFileContentEventually(t, s1) + "\n" + requireFileContentEventually(t, s2) + assert.Contains(t, results, "circular dependency") + assert.Contains(t, results, "has not published any vars yet") +} + +func TestVarsNotPublished(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), bgWorker("silent", "basic.php", nil, server), frankenphp.WithNumThreads(2)) + + assert.Contains(t, serverGet(t, server, "http://example.com/vars.php?name=silent"), "has not published any vars yet") +} + +func TestVarsRejectInvalidValues(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "bad.txt") + initServers(t, bgWorker("bad", "bad-vars.php", map[string]string{"BG_SENTINEL": sentinel}, nil), frankenphp.WithNumThreads(2)) + + result := requireFileContentEventually(t, sentinel) + assert.Contains(t, result, "ValueError") + assert.Contains(t, result, "must be null, scalars, arrays or enums") +} + +// a reboot restarts the PHP engine, and a snapshot published before it is +// still served: it must not point into the memory of the engine that is gone +func TestVarsSurviveAReboot(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + sentinel := filepath.Join(t.TempDir(), "published") + initServers(t, frankenphp.WithServer(server), bgWorker("once", "publish-once.php", map[string]string{"BG_SENTINEL": sentinel}, server), frankenphp.WithNumThreads(2)) + + before := serverGet(t, server, "http://example.com/vars.php?name=once") + require.JSONEq(t, `{"answer":42,"nested":{"a":1,"list":[true,null,1.5,"x"]}}`, before) + + frankenphp.RestartWorkers() + + assert.JSONEq(t, before, serverGet(t, server, "http://example.com/vars.php?name=once")) +} diff --git a/zval.h b/zval.h index f75cccdc60..4141bc9582 100644 --- a/zval.h +++ b/zval.h @@ -11,9 +11,9 @@ * depth and a native stack overflow (crashes the whole process, not just * one request). * - * Fast paths: - * - Interned strings: shared memory, no copy. - * - Opcache-immutable arrays: shared pointer, no copy, no free. + * A persistent tree owns all its memory, interned strings and + * opcache-immutable arrays included: it can outlive an engine reboot, which + * frees the former, and an opcache restart, which rewinds the latter. * * Included by frankenphp.c; not a standalone compilation unit. */ @@ -58,12 +58,6 @@ static bool persistent_zval_validate_depth(zval *z, int depth) { case IS_OBJECT: return (Z_OBJCE_P(z)->ce_flags & ZEND_ACC_ENUM) != 0; case IS_ARRAY: { - /* Opcache-immutable arrays are compile-time constants in shared - * memory; their leaves are guaranteed scalars or further immutable - * arrays. The copy/free paths below already trust this flag, so a - * recursive walk here would just be cycles. */ - if ((GC_FLAGS(Z_ARRVAL_P(z)) & IS_ARRAY_IMMUTABLE) != 0) - return true; zval *val; ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(z), val) { if (!persistent_zval_validate_depth(val, depth + 1)) @@ -103,38 +97,22 @@ static void persistent_zval_persist(zval *dst, zval *src) { break; case IS_STRING: { zend_string *s = Z_STR_P(src); - if (ZSTR_IS_INTERNED(s)) { - ZVAL_STR(dst, s); /* interned strings live process-wide */ - } else { - ZVAL_NEW_STR(dst, zend_string_init(ZSTR_VAL(s), ZSTR_LEN(s), 1)); - } + ZVAL_NEW_STR(dst, zend_string_init(ZSTR_VAL(s), ZSTR_LEN(s), 1)); break; } case IS_OBJECT: { /* Must be an enum (validated earlier in persistent_zval_validate). */ zend_class_entry *ce = Z_OBJCE_P(src); persistent_zval_enum_t *e = pemalloc(sizeof(*e), 1); - e->class_name = - ZSTR_IS_INTERNED(ce->name) - ? ce->name - : zend_string_init(ZSTR_VAL(ce->name), ZSTR_LEN(ce->name), 1); + e->class_name = zend_string_init(ZSTR_VAL(ce->name), ZSTR_LEN(ce->name), 1); zval *case_name_zval = zend_enum_fetch_case_name(Z_OBJ_P(src)); zend_string *case_str = Z_STR_P(case_name_zval); - e->case_name = - ZSTR_IS_INTERNED(case_str) - ? case_str - : zend_string_init(ZSTR_VAL(case_str), ZSTR_LEN(case_str), 1); + e->case_name = zend_string_init(ZSTR_VAL(case_str), ZSTR_LEN(case_str), 1); ZVAL_PTR(dst, e); break; } case IS_ARRAY: { HashTable *src_ht = Z_ARRVAL_P(src); - if ((GC_FLAGS(src_ht) & IS_ARRAY_IMMUTABLE) != 0) { - /* Opcache-immutable arrays live for the process lifetime and are - * safe to share across threads by pointer. Zero-copy, zero-free. */ - ZVAL_ARR(dst, src_ht); - break; - } HashTable *dst_ht = pemalloc(sizeof(HashTable), 1); zend_hash_init(dst_ht, zend_hash_num_elements(src_ht), NULL, NULL, 1); ZVAL_ARR(dst, dst_ht); @@ -146,16 +124,12 @@ static void persistent_zval_persist(zval *dst, zval *src) { zval pval; persistent_zval_persist(&pval, val); if (key) { - if (ZSTR_IS_INTERNED(key)) { - zend_hash_add_new(dst_ht, key, &pval); - } else { - zend_string *pkey = zend_string_init(ZSTR_VAL(key), ZSTR_LEN(key), 1); - /* Iteration guarantees the source key has its hash set. - * Propagating it lets zend_hash_add_new skip the re-hash. */ - ZSTR_H(pkey) = ZSTR_H(key); - zend_hash_add_new(dst_ht, pkey, &pval); - zend_string_release(pkey); - } + zend_string *pkey = zend_string_init(ZSTR_VAL(key), ZSTR_LEN(key), 1); + /* Iteration guarantees the source key has its hash set. + * Propagating it lets zend_hash_add_new skip the re-hash. */ + ZSTR_H(pkey) = ZSTR_H(key); + zend_hash_add_new(dst_ht, pkey, &pval); + zend_string_release(pkey); } else { zend_hash_index_add_new(dst_ht, idx, &pval); } @@ -169,30 +143,21 @@ static void persistent_zval_persist(zval *dst, zval *src) { } } -/* Deep-free a persistent zval tree. Idempotent on scalars. Skips - * interned strings and immutable arrays (they are borrowed, not owned). */ +/* Deep-free a persistent zval tree. Idempotent on scalars. */ static void persistent_zval_free(zval *z) { switch (Z_TYPE_P(z)) { case IS_STRING: - if (!ZSTR_IS_INTERNED(Z_STR_P(z))) { - zend_string_free(Z_STR_P(z)); - } + zend_string_free(Z_STR_P(z)); break; case IS_PTR: { persistent_zval_enum_t *e = (persistent_zval_enum_t *)Z_PTR_P(z); - if (!ZSTR_IS_INTERNED(e->class_name)) - zend_string_free(e->class_name); - if (!ZSTR_IS_INTERNED(e->case_name)) - zend_string_free(e->case_name); + zend_string_free(e->class_name); + zend_string_free(e->case_name); pefree(e, 1); break; } case IS_ARRAY: { HashTable *ht = Z_ARRVAL_P(z); - if ((GC_FLAGS(ht) & IS_ARRAY_IMMUTABLE) != 0) { - /* Borrowed from opcache, do not touch. */ - break; - } zval *val; ZEND_HASH_FOREACH_VAL(ht, val) { persistent_zval_free(val); } ZEND_HASH_FOREACH_END(); @@ -223,11 +188,7 @@ static void persistent_zval_to_request(zval *dst, zval *src) { ZVAL_DOUBLE(dst, Z_DVAL_P(src)); break; case IS_STRING: - if (ZSTR_IS_INTERNED(Z_STR_P(src))) { - ZVAL_STR(dst, Z_STR_P(src)); - } else { - ZVAL_STRINGL(dst, Z_STRVAL_P(src), Z_STRLEN_P(src)); - } + ZVAL_STRINGL(dst, Z_STRVAL_P(src), Z_STRLEN_P(src)); break; case IS_PTR: { persistent_zval_enum_t *e = (persistent_zval_enum_t *)Z_PTR_P(src); @@ -257,11 +218,6 @@ static void persistent_zval_to_request(zval *dst, zval *src) { } case IS_ARRAY: { HashTable *src_ht = Z_ARRVAL_P(src); - if ((GC_FLAGS(src_ht) & IS_ARRAY_IMMUTABLE) != 0) { - /* Zero-copy: immutable arrays are safe to expose directly. */ - ZVAL_ARR(dst, src_ht); - break; - } array_init_size(dst, zend_hash_num_elements(src_ht)); HashTable *dst_ht = Z_ARRVAL_P(dst); @@ -276,14 +232,10 @@ static void persistent_zval_to_request(zval *dst, zval *src) { break; } if (key) { - if (ZSTR_IS_INTERNED(key)) { - zend_hash_add_new(dst_ht, key, &rval); - } else { - zend_string *rkey = zend_string_init(ZSTR_VAL(key), ZSTR_LEN(key), 0); - ZSTR_H(rkey) = ZSTR_H(key); - zend_hash_add_new(dst_ht, rkey, &rval); - zend_string_release(rkey); - } + zend_string *rkey = zend_string_init(ZSTR_VAL(key), ZSTR_LEN(key), 0); + ZSTR_H(rkey) = ZSTR_H(key); + zend_hash_add_new(dst_ht, rkey, &rval); + zend_string_release(rkey); } else { zend_hash_index_add_new(dst_ht, idx, &rval); } diff --git a/zval_test.go b/zval_test.go index 80aad7f16f..e25903b0b7 100644 --- a/zval_test.go +++ b/zval_test.go @@ -47,4 +47,5 @@ func TestPersistentZvalRoundtrip(t *testing.T) { require.Contains(t, out, "OK stdClass rejected") require.Contains(t, out, "OK resource rejected") require.Contains(t, out, "OK nested stdClass rejected") + require.Contains(t, out, "OK immutable literal exposed repeatedly") } From 1a6163797f85f0c50c16f8ce272402f81d59149a Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 27 Oct 2026 10:16:26 +0100 Subject: [PATCH 11/13] feat: vars are addressed by namespace, declared on the worker that publishes them --- caddy/config_test.go | 32 ++++++ caddy/workerconfig.go | 18 +++- docs/config.md | 2 + docs/worker.md | 23 ++++- frankenphp.c | 53 ++++++++-- frankenphp.go | 15 ++- frankenphp.h | 1 + frankenphp.stub.php | 30 ++++-- frankenphp_arginfo.h | 11 +- options.go | 18 ++++ testdata/bgworker/bad-vars.php | 20 ++-- testdata/bgworker/consumer.php | 7 +- testdata/bgworker/multi-vars.php | 16 +++ testdata/bgworker/publish-once.php | 17 ---- testdata/bgworker/publisher.php | 7 +- testdata/bgworker/republish.php | 18 ++++ testdata/vars.php | 2 +- threadbackgroundworker.go | 15 ++- worker.go | 109 +++++++++++++++++--- workervars.go | 78 +++++++++----- workervars_test.go | 158 ++++++++++++++++++++++++----- 21 files changed, 524 insertions(+), 126 deletions(-) create mode 100644 testdata/bgworker/multi-vars.php delete mode 100644 testdata/bgworker/publish-once.php create mode 100644 testdata/bgworker/republish.php diff --git a/caddy/config_test.go b/caddy/config_test.go index 1226a0223f..3ebbd29780 100644 --- a/caddy/config_test.go +++ b/caddy/config_test.go @@ -373,6 +373,38 @@ func TestWorkerBackgroundNameIsOptional(t *testing.T) { require.Empty(t, module.Workers[0].Name) } +func TestWorkerBackgroundVars(t *testing.T) { + d := caddyfile.NewTestDispenser(` + { + php_server { + worker { + file ../testdata/worker-with-env.php + background + vars redis feature_flags + } + } + }`) + module := &FrankenPHPModule{} + + require.NoError(t, module.UnmarshalCaddyfile(d)) + require.Equal(t, []string{"redis", "feature_flags"}, module.Workers[0].Vars) +} + +func TestWorkerVarsRequireBackground(t *testing.T) { + d := caddyfile.NewTestDispenser(` + { + php_server { + worker { + file ../testdata/worker-with-env.php + vars redis + } + } + }`) + module := &FrankenPHPModule{} + + require.ErrorContains(t, module.UnmarshalCaddyfile(d), `"vars" is only supported for background workers`) +} + func TestWorkerBackgroundWithoutNumParses(t *testing.T) { d := caddyfile.NewTestDispenser(` { diff --git a/caddy/workerconfig.go b/caddy/workerconfig.go index e3841924df..48bee5b722 100644 --- a/caddy/workerconfig.go +++ b/caddy/workerconfig.go @@ -43,6 +43,8 @@ type workerConfig struct { BootTimeout *caddy.Duration `json:"boot_timeout,omitempty"` // Background marks this worker as a background (non-HTTP) worker. Background bool `json:"background,omitempty"` + // Vars lists the namespaces a background worker publishes with WorkerHandle::setVars(), read by frankenphp_get_vars(). + Vars []string `json:"vars,omitempty"` options []frankenphp.WorkerOption } @@ -161,8 +163,14 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) { wc.BootTimeout = &timeout case "background": wc.Background = true + case "vars": + namespaces := d.RemainingArgs() + if len(namespaces) == 0 { + return wc, d.ArgErr() + } + wc.Vars = append(wc.Vars, namespaces...) default: - return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads, background, boot_timeout", v) + return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads, background, boot_timeout, vars", v) } } @@ -178,6 +186,10 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) { return wc, d.Err(`"match" is not supported for background workers`) } + if !wc.Background && len(wc.Vars) != 0 { + return wc, d.Err(`"vars" is only supported for background workers`) + } + if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) { wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName) } @@ -205,6 +217,10 @@ func (wc *workerConfig) toWorkerOptions() ([]frankenphp.WorkerOption, error) { if wc.BootTimeout != nil { opts = append(opts, frankenphp.WithWorkerBootTimeout(time.Duration(*wc.BootTimeout))) } + + if len(wc.Vars) > 0 { + opts = append(opts, frankenphp.WithWorkerVars(wc.Vars...)) + } } // copy the caddy match logic and create a unique matcher function for this worker diff --git a/docs/config.md b/docs/config.md index 8d813aaef6..be8bf8cb02 100644 --- a/docs/config.md +++ b/docs/config.md @@ -113,6 +113,7 @@ You can also explicitly configure FrankenPHP using the [global option](https://c max_consecutive_failures # Sets the maximum number of consecutive failures before the worker is considered unhealthy, -1 means the worker will always restart. Default: 6. boot_timeout # EXPERIMENTAL: how long a background worker may take to reach its first tick() before the start fails, 0 waits forever. Default: 30s on PHP builds without Zend max execution timers, none elsewhere. background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, whose script ticks its FrankenPHP\WorkerHandle at least once + vars # EXPERIMENTAL: the vars namespaces a background worker publishes, read with frankenphp_get_vars() } } } @@ -202,6 +203,7 @@ php_server [] { match # match the worker to a path pattern. Overrides try_files and can only be used in the php_server directive. boot_timeout # EXPERIMENTAL: how long a background worker may take to reach its first tick() before the start fails, 0 waits forever. Default: 30s on PHP builds without Zend max execution timers, none elsewhere. background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, whose script ticks its FrankenPHP\WorkerHandle at least once + vars # EXPERIMENTAL: the vars namespaces a background worker publishes, read with frankenphp_get_vars() } worker # Can also use the short form like in the global frankenphp block. } diff --git a/docs/worker.md b/docs/worker.md index 51e85e83e8..fa93edab5a 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -281,19 +281,34 @@ That stream is there for the libraries that take one, and waiting is all it supp ### Sharing state with background workers -A background worker publishes a snapshot with `setVars()` on its handle; requests and other workers read it with `frankenphp_get_vars()`, by worker name, resolved like requests are: within the `php_server`, then among global workers. Publishing needs a handle because only a worker publishes its own vars, while reading only needs a name, since anyone reads anyone's. Values must be null, scalars, arrays or enums. Each call replaces the whole snapshot and readers get a copy, so the worker can publish at any time and a request always sees a consistent one. Publish before the first tick and the snapshot is in place before the server accepts requests; while the worker restarts, readers keep getting the last one. +A background worker publishes snapshots in vars namespaces, declared with `vars` on the worker, and requests and other workers read them with `frankenphp_get_vars()`, by namespace: + +```caddyfile +php_server { + worker { + file config.php + background + vars redis feature_flags + } +} +``` ```php // background worker $handle = new FrankenPHP\WorkerHandle(); -$handle->setVars(['maintenance' => false, 'flags' => ['beta' => true]]); +$handle->setVars('redis', ['dsn' => 'redis://cache:6379']); +$handle->setVars('feature_flags', ['beta' => true]); // ... // request, HTTP worker or another background worker -$vars = frankenphp_get_vars('config'); +$flags = frankenphp_get_vars('feature_flags'); ``` -`frankenphp_get_vars()` blocks until the worker reached its ready point, which can only happen between background workers reading each other while booting; a cycle between them throws instead of hanging. It also throws when the name is unknown, or when the worker is ready but has not published anything. +A namespace has one publisher in a `php_server`, and one among the global workers; a reader gets the one of its `php_server` first, then the global one. One worker may publish as many namespaces as the application wants, so splitting them across workers is a matter of threads, failure caps and timeouts, not of addressing. A worker publishing vars runs a single thread, so each snapshot has one writer, and `getNamespaces()` on its handle returns what the configuration declared. + +Values must be null, scalars, arrays or enums. Each call replaces the whole snapshot of its namespace and readers get a copy, so the worker can publish at any time and a request always sees a consistent one. Every declared namespace is published before the first tick of a run, which `tick()` enforces: since the server accepts requests once the background workers are ready, requests never wait for a snapshot, and while a worker restarts they keep getting its last ones. + +`frankenphp_get_vars()` can only block between background workers reading each other while booting; a cycle between them throws instead of hanging. It also throws when no worker publishes the namespace. ## Superglobals behavior diff --git a/frankenphp.c b/frankenphp.c index 1b559612af..79f053fff7 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1609,12 +1609,19 @@ ZEND_METHOD(FrankenPHP_WorkerHandle, tick) { } if (!worker_ticked) { + /* refused while a declared vars namespace has no snapshot of this run */ + char *error = + go_frankenphp_background_worker_ready(frankenphp_thread_index()); + if (error != NULL) { + zend_throw_exception(spl_ce_LogicException, error, 0); + free(error); + RETURN_THROWS(); + } worker_ticked = true; /* The bootstrap ran under max_execution_time like any request, the loop * that starts now has no time limit, like the CLI. Nothing re-arms the * timer past this point. */ zend_unset_timeout(); - go_frankenphp_background_worker_ready(frankenphp_thread_index()); } /* consume the wake-ups without blocking; EOF is the drain */ @@ -1661,9 +1668,30 @@ void frankenphp_vars_free(HashTable *table) { persistent_zval_free(&persistent); } +void frankenphp_array_append_string(zval *array, char *str, size_t len) { + add_next_index_stringl(array, str, len); +} + +ZEND_METHOD(FrankenPHP_WorkerHandle, getNamespaces) { + ZEND_PARSE_PARAMETERS_NONE(); + + if (!frankenphp_worker_handle_usable()) { + RETURN_THROWS(); + } + + zval vars; + array_init(&vars); + go_frankenphp_worker_vars(frankenphp_thread_index(), &vars); + + array_init(return_value); + add_assoc_zval(return_value, "vars", &vars); +} + ZEND_METHOD(FrankenPHP_WorkerHandle, setVars) { + zend_string *namespace; zval *vars; - ZEND_PARSE_PARAMETERS_START(1, 1) + ZEND_PARSE_PARAMETERS_START(2, 2) + Z_PARAM_STR(namespace) Z_PARAM_ARRAY(vars) ZEND_PARSE_PARAMETERS_END(); @@ -1684,21 +1712,30 @@ ZEND_METHOD(FrankenPHP_WorkerHandle, setVars) { zval persistent; persistent_zval_persist(&persistent, vars); - HashTable *old = - go_frankenphp_set_vars(frankenphp_thread_index(), Z_ARRVAL(persistent)); + HashTable *old = NULL; + char *error = + go_frankenphp_set_vars(frankenphp_thread_index(), ZSTR_VAL(namespace), + ZSTR_LEN(namespace), Z_ARRVAL(persistent), &old); + if (error != NULL) { + frankenphp_vars_free(Z_ARRVAL(persistent)); + zend_throw_exception(spl_ce_LogicException, error, 0); + free(error); + RETURN_THROWS(); + } if (old != NULL) { frankenphp_vars_free(old); } } PHP_FUNCTION(frankenphp_get_vars) { - zend_string *name; + zend_string *namespace; ZEND_PARSE_PARAMETERS_START(1, 1) - Z_PARAM_STR(name) + Z_PARAM_STR(namespace) ZEND_PARSE_PARAMETERS_END(); - char *error = go_frankenphp_get_vars( - frankenphp_thread_index(), ZSTR_VAL(name), ZSTR_LEN(name), return_value); + char *error = + go_frankenphp_get_vars(frankenphp_thread_index(), ZSTR_VAL(namespace), + ZSTR_LEN(namespace), return_value); if (error != NULL) { zend_throw_exception(spl_ce_RuntimeException, error, 0); free(error); diff --git a/frankenphp.go b/frankenphp.go index 96b0a47567..35274c75fd 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -328,25 +328,24 @@ func Validate(options ...Option) error { } takenNames := make(map[string]bool, len(opt.workers)) - takenPaths := make(map[*Server]map[string]bool, 1) - nameTaken := func(name string) bool { return takenNames[name] } - pathTaken := func(server *Server, path string) bool { return takenPaths[server][path] } + paths, vars := scopeSet{}, scopeSet{} + taken := declarationIndex{names: func(name string) bool { return takenNames[name] }, paths: paths.has, vars: vars.has} for _, w := range opt.workers { w, err := resolveWorkerFile(w) if err != nil { return err } - if err := checkWorkerDeclaration(w, nameTaken, pathTaken); err != nil { + if err := checkWorkerDeclaration(w, taken); err != nil { return err } takenNames[w.name] = true if !w.isBackgroundWorker && w.matchRequest == nil { - if takenPaths[w.server] == nil { - takenPaths[w.server] = make(map[string]bool) - } - takenPaths[w.server][w.fileName] = true + paths.add(w.server, w.fileName) + } + for _, ns := range w.vars { + vars.add(w.server, ns) } } diff --git a/frankenphp.h b/frankenphp.h index afbf295992..81d2d0fcdd 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -224,6 +224,7 @@ intptr_t frankenphp_set_background_worker_and_get_stop_sock(void); void frankenphp_worker_close_stop_sock(intptr_t s); void frankenphp_vars_to_request(zval *return_value, HashTable *table); void frankenphp_vars_free(HashTable *table); +void frankenphp_array_append_string(zval *array, char *str, size_t len); void register_extensions(zend_module_entry **m, int len); diff --git a/frankenphp.stub.php b/frankenphp.stub.php index 51e771e853..681cf4d81e 100644 --- a/frankenphp.stub.php +++ b/frankenphp.stub.php @@ -56,13 +56,13 @@ function mercure_publish(string|array $topics, string $data = '', bool $private */ function frankenphp_log(string $message, int $level = 0, array $context = []): void {} /** - * Returns a copy of the vars last published by the named background worker, - * resolved within the current php_server, then among global workers. Blocks - * until that worker reached its ready point. Throws if the worker is - * unknown, if it is ready but has not published any vars, or if background - * workers wait on each other in a cycle. + * Returns a copy of the snapshot last published in a vars namespace, + * resolved within the current php_server, then among global workers. + * Blocks until the publishing worker reached its ready point. Throws if no + * background worker publishes the namespace, or if background workers wait + * on each other in a cycle. */ - function frankenphp_get_vars(string $name): array {} + function frankenphp_get_vars(string $namespace): array {} } namespace FrankenPHP { @@ -109,10 +109,20 @@ public function tick(): bool {} public function getStream() {} /** - * Publishes the vars of this background worker: the array replaces - * the previous snapshot, atomically for readers, which get copies. - * Values must be null, scalars, arrays or enums. + * Returns the namespaces this background worker serves, as declared in + * its configuration. + * + * @return array{vars: list} + */ + public function getNamespaces(): array {} + + /** + * Publishes a snapshot in one of the vars namespaces of this background + * worker: the array replaces the previous snapshot of that namespace, + * atomically for readers, which get copies. Values must be null, + * scalars, arrays or enums. Every declared namespace is published + * before the first tick() of a run. */ - public function setVars(array $vars): void {} + public function setVars(string $namespace, array $vars): void {} } } diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index 7f69a5f910..260dc31c98 100644 --- a/frankenphp_arginfo.h +++ b/frankenphp_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit the .stub.php file instead. - * Stub hash: f7af1f79ac730e1c61c5293e436776ae4c83fa8d */ + * Stub hash: cf6b08b8a5459dc24b22ac90056be5860dfcc72d */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_handle_request, 0, 1, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, callback, IS_CALLABLE, 0) @@ -42,7 +42,7 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_log, 0, 1, IS_VOID, 0 ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_get_vars, 0, 1, IS_ARRAY, 0) - ZEND_ARG_TYPE_INFO(0, name, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, namespace, IS_STRING, 0) ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_INFO_EX(arginfo_class_FrankenPHP_WorkerHandle___construct, 0, 0, 0) @@ -52,7 +52,10 @@ ZEND_END_ARG_INFO() #define arginfo_class_FrankenPHP_WorkerHandle_getStream arginfo_class_FrankenPHP_WorkerHandle___construct -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_FrankenPHP_WorkerHandle_setVars, 0, 1, IS_VOID, 0) +#define arginfo_class_FrankenPHP_WorkerHandle_getNamespaces arginfo_frankenphp_request_headers + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_FrankenPHP_WorkerHandle_setVars, 0, 2, IS_VOID, 0) + ZEND_ARG_TYPE_INFO(0, namespace, IS_STRING, 0) ZEND_ARG_TYPE_INFO(0, vars, IS_ARRAY, 0) ZEND_END_ARG_INFO() @@ -67,6 +70,7 @@ ZEND_FUNCTION(frankenphp_get_vars); ZEND_METHOD(FrankenPHP_WorkerHandle, __construct); ZEND_METHOD(FrankenPHP_WorkerHandle, tick); ZEND_METHOD(FrankenPHP_WorkerHandle, getStream); +ZEND_METHOD(FrankenPHP_WorkerHandle, getNamespaces); ZEND_METHOD(FrankenPHP_WorkerHandle, setVars); static const zend_function_entry ext_functions[] = { @@ -89,6 +93,7 @@ static const zend_function_entry class_FrankenPHP_WorkerHandle_methods[] = { ZEND_ME(FrankenPHP_WorkerHandle, __construct, arginfo_class_FrankenPHP_WorkerHandle___construct, ZEND_ACC_PUBLIC) ZEND_ME(FrankenPHP_WorkerHandle, tick, arginfo_class_FrankenPHP_WorkerHandle_tick, ZEND_ACC_PUBLIC) ZEND_ME(FrankenPHP_WorkerHandle, getStream, arginfo_class_FrankenPHP_WorkerHandle_getStream, ZEND_ACC_PUBLIC) + ZEND_ME(FrankenPHP_WorkerHandle, getNamespaces, arginfo_class_FrankenPHP_WorkerHandle_getNamespaces, ZEND_ACC_PUBLIC) ZEND_ME(FrankenPHP_WorkerHandle, setVars, arginfo_class_FrankenPHP_WorkerHandle_setVars, ZEND_ACC_PUBLIC) ZEND_FE_END }; diff --git a/options.go b/options.go index a807d8f0cd..9b10bd01be 100644 --- a/options.go +++ b/options.go @@ -2,9 +2,11 @@ package frankenphp import ( "context" + "errors" "fmt" "log/slog" "net/http" + "slices" "time" ) @@ -61,6 +63,7 @@ type workerOpt struct { isBackgroundWorker bool bootTimeout time.Duration bootTimeoutIsSet bool + vars []string } // WithContext sets the main context to use. @@ -276,6 +279,21 @@ func WithWorkerBootTimeout(timeout time.Duration) WorkerOption { } } +// EXPERIMENTAL: WithWorkerVars declares the vars namespaces a background +// worker publishes with WorkerHandle::setVars(), which frankenphp_get_vars() +// reads by namespace. A namespace has one publisher within a server, and one +// among the global workers. +func WithWorkerVars(namespaces ...string) WorkerOption { + return func(w *workerOpt) error { + if slices.Contains(namespaces, "") { + return errors.New("a vars namespace cannot be empty") + } + w.vars = append(w.vars, namespaces...) + + return nil + } +} + // WithWorkerMaxFailures sets the maximum number of consecutive failures before panicking func WithWorkerMaxFailures(maxFailures int) WorkerOption { return func(w *workerOpt) error { diff --git a/testdata/bgworker/bad-vars.php b/testdata/bgworker/bad-vars.php index 9b8a3dc93d..b788550e79 100644 --- a/testdata/bgworker/bad-vars.php +++ b/testdata/bgworker/bad-vars.php @@ -1,16 +1,24 @@ getNamespaces()['vars'][0]; +$errors = []; try { - $handle->setVars(['object' => new stdClass()]); - $result = 'no exception'; + $handle->setVars($namespace, ['object' => new stdClass()]); } catch (\Throwable $e) { - $result = get_class($e) . ': ' . $e->getMessage(); + $errors[] = get_class($e) . ': ' . $e->getMessage(); } -file_put_contents($_SERVER['BG_SENTINEL'], $result); +try { + $handle->setVars('undeclared', []); +} catch (\Throwable $e) { + $errors[] = get_class($e) . ': ' . $e->getMessage(); +} +file_put_contents($_SERVER['BG_SENTINEL'], implode("\n", $errors)); +$handle->setVars($namespace, []); $stream = $handle->getStream(); while ($handle->tick()) { $read = [$stream]; diff --git a/testdata/bgworker/consumer.php b/testdata/bgworker/consumer.php index 16cab6d9c2..32cdee5b5a 100644 --- a/testdata/bgworker/consumer.php +++ b/testdata/bgworker/consumer.php @@ -1,7 +1,7 @@ getNamespaces()['vars'] as $namespace) { + $handle->setVars($namespace, ['consumed' => $result]); +} $stream = $handle->getStream(); while ($handle->tick()) { $read = [$stream]; diff --git a/testdata/bgworker/multi-vars.php b/testdata/bgworker/multi-vars.php new file mode 100644 index 0000000000..67d9dcde81 --- /dev/null +++ b/testdata/bgworker/multi-vars.php @@ -0,0 +1,16 @@ +getNamespaces(); +foreach ($namespaces['vars'] as $namespace) { + $handle->setVars($namespace, ['namespace' => $namespace, 'all' => $namespaces]); +} +$stream = $handle->getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/publish-once.php b/testdata/bgworker/publish-once.php deleted file mode 100644 index c4e2fae4b1..0000000000 --- a/testdata/bgworker/publish-once.php +++ /dev/null @@ -1,17 +0,0 @@ -setVars(['answer' => 42, 'nested' => ['a' => 1, 'list' => [true, null, 1.5, 'x']]]); -} -$stream = $handle->getStream(); -while ($handle->tick()) { - $read = [$stream]; - $write = $except = null; - stream_select($read, $write, $except, null); -} diff --git a/testdata/bgworker/publisher.php b/testdata/bgworker/publisher.php index 2f8eff5d85..10fa2ef869 100644 --- a/testdata/bgworker/publisher.php +++ b/testdata/bgworker/publisher.php @@ -1,13 +1,14 @@ setVars([ +$handle->setVars($handle->getNamespaces()['vars'][0], [ 'answer' => 42, 'value' => $_SERVER['BG_PUBLISH_VALUE'] ?? 'default', 'nested' => ['a' => 1, 'list' => [true, null, 1.5, 'x']], diff --git a/testdata/bgworker/republish.php b/testdata/bgworker/republish.php new file mode 100644 index 0000000000..2589e34b3d --- /dev/null +++ b/testdata/bgworker/republish.php @@ -0,0 +1,18 @@ +setVars($handle->getNamespaces()['vars'][0], ['answer' => 42, 'nested' => ['a' => 1, 'list' => [true, null, 1.5, 'x']]]); +$stream = $handle->getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/vars.php b/testdata/vars.php index 0555109953..cac7ffd6bf 100644 --- a/testdata/vars.php +++ b/testdata/vars.php @@ -1,7 +1,7 @@ getMessage(); } diff --git a/threadbackgroundworker.go b/threadbackgroundworker.go index 70c9d2f81d..9e443ae62c 100644 --- a/threadbackgroundworker.go +++ b/threadbackgroundworker.go @@ -7,6 +7,7 @@ import "C" import ( "fmt" "log/slog" + "strconv" "sync/atomic" "time" @@ -34,6 +35,8 @@ type backgroundWorkerThread struct { // of an HTTP worker reaching frankenphp_handle_request() isBootingScript bool bootTimer *time.Timer + // the vars namespaces this run published, all of them due by its first tick + publishedVars map[string]bool // the Go side's end of this thread's stop socket pair, the script // holding the other end; one per thread so pool workers drain @@ -130,6 +133,7 @@ func (handler *backgroundWorkerThread) setupScript() error { handler.thread.contextMu.Unlock() handler.isBootingScript = true + handler.publishedVars = make(map[string]bool, len(handler.worker.varsNamespaces)) handler.runStartedAt = time.Now() metrics.StartWorker(handler.worker.name) // the run's logger and context, not the globals: a shutdown finishing @@ -247,7 +251,7 @@ func (handler *backgroundWorkerThread) stopBootTimer() { } //export go_frankenphp_background_worker_ready -func go_frankenphp_background_worker_ready(threadIndex C.uintptr_t) { +func go_frankenphp_background_worker_ready(threadIndex C.uintptr_t) *C.char { // called on the PHP thread by the first WorkerHandle::tick() of a run; // that function throws on every other thread kind handler, ok := phpThreads[threadIndex].handler.(*backgroundWorkerThread) @@ -256,6 +260,13 @@ func go_frankenphp_background_worker_ready(threadIndex C.uintptr_t) { } if handler.isBootingScript { + // readers never wait for a snapshot past the ready point + for _, ns := range handler.worker.varsNamespaces { + if !handler.publishedVars[ns] { + return C.CString("FrankenPHP\\WorkerHandle::tick(): publish the vars namespace " + strconv.Quote(ns) + " with setVars() before the first tick") + } + } + handler.isBootingScript = false // the boot succeeded, only consecutive boot failures count handler.failureCount = 0 @@ -271,6 +282,8 @@ func go_frankenphp_background_worker_ready(threadIndex C.uintptr_t) { handler.state.Set(state.Ready) } } + + return nil } // backoff waits before the next run of a crashed script, see restartBackoff diff --git a/worker.go b/worker.go index 87219df0c3..54f2c1788c 100644 --- a/worker.go +++ b/worker.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "sync" "sync/atomic" "time" @@ -43,8 +44,10 @@ type worker struct { // reaches its ready point; frankenphp_get_vars() readers wait on it readyOnce chan struct{} readyClose sync.Once - // vars is the snapshot published with WorkerHandle::setVars() - vars varsSlot + // the vars namespaces the worker publishes, in declaration order, and + // the snapshot of each, see WorkerHandle::setVars() + varsNamespaces []string + vars map[string]*varsSlot } // markReady records that the background worker reached its ready point once @@ -70,6 +73,7 @@ var ( const DefaultWorkerBootTimeout = 30 * time.Second func initWorkers(opts []workerOpt) error { + varsOwners = namespaceOwners{} if len(opts) == 0 { return nil } @@ -92,6 +96,7 @@ func initWorkers(opts []workerOpt) error { totalThreadsToStart += w.num workers = append(workers, w) workersByName[w.name] = w + varsOwners.add(w, w.varsNamespaces) if w.server != nil { if err := w.server.addWorker(w); err != nil { return err @@ -210,10 +215,53 @@ func resolveWorkerFile(o workerOpt) (workerOpt, error) { return o, nil } +// declarationIndex tells what the workers declared before took: names are +// global, paths and namespaces belong to a scope, a nil server standing for +// the global workers +type declarationIndex struct { + names func(name string) bool + paths, vars func(server *Server, key string) bool +} + +// scopeSet records keys per scope, a nil server for the global workers +type scopeSet map[*Server]map[string]bool + +func (s scopeSet) has(server *Server, key string) bool { return s[server][key] } + +func (s scopeSet) add(server *Server, key string) { + if s[server] == nil { + s[server] = make(map[string]bool) + } + s[server][key] = true +} + +// namespaceOwners maps the namespaces declared in a scope to the worker that +// owns them, a nil server standing for the global workers +type namespaceOwners map[*Server]map[string]*worker + +func (o namespaceOwners) add(w *worker, namespaces []string) { + for _, ns := range namespaces { + if o[w.server] == nil { + o[w.server] = make(map[string]*worker) + } + o[w.server][ns] = w + } +} + +// resolve finds the owner of a namespace within a server first, then among +// the global workers +func (o namespaceOwners) resolve(server *Server, ns string) *worker { + if w := o[server][ns]; w != nil { + return w + } + + return o[nil][ns] +} + // checkWorkerDeclaration holds the rules a set of workers must follow, so -// Validate() and newWorker() answer the same way; pathTaken is asked for the -// global workers with a nil server. -func checkWorkerDeclaration(o workerOpt, nameTaken func(string) bool, pathTaken func(*Server, string) bool) error { +// Validate() and newWorker() answer the same way; the paths and namespaces of +// the global workers are asked for with a nil server. +func checkWorkerDeclaration(o workerOpt, taken declarationIndex) error { if o.isBackgroundWorker { // never matched against requests, so several may share a script if o.matchRequest != nil { @@ -228,7 +276,7 @@ func checkWorkerDeclaration(o workerOpt, nameTaken func(string) bool, pathTaken return fmt.Errorf("background worker %q cannot be an extension worker, those handle requests", o.name) } } else if o.server == nil { - if pathTaken(nil, o.fileName) { + if taken.paths(nil, o.fileName) { return fmt.Errorf("two global workers cannot have the same filename: %q", o.fileName) } @@ -236,15 +284,38 @@ func checkWorkerDeclaration(o workerOpt, nameTaken func(string) bool, pathTaken if o.matchRequest != nil { return fmt.Errorf("worker %q has a request matcher but no server scope, use WithWorkerServerScope()", o.name) } - } else if o.matchRequest == nil && pathTaken(o.server, o.fileName) { + } else if o.matchRequest == nil && taken.paths(o.server, o.fileName) { // a matcher tells the workers of a server sharing a file apart return fmt.Errorf("two workers in a server cannot have the same filename: %q", o.fileName) } - if nameTaken(o.name) { + if taken.names(o.name) { return fmt.Errorf("two workers cannot have the same name: %q", o.name) } + scope := "two global workers" + if o.server != nil { + scope = "two workers in a server" + } + + if len(o.vars) > 0 { + if !o.isBackgroundWorker { + return fmt.Errorf("worker %q declares vars, only background workers publish them", o.name) + } + // one thread, so a snapshot has a single writer + if o.num > 1 { + return fmt.Errorf("background worker %q publishes vars, it runs a single thread", o.name) + } + } + for i, ns := range o.vars { + if slices.Contains(o.vars[:i], ns) { + return fmt.Errorf("worker %q declares the vars namespace %q twice", o.name, ns) + } + if taken.vars(o.server, ns) { + return fmt.Errorf("%s cannot publish the same vars namespace: %q", scope, ns) + } + } + return nil } @@ -254,16 +325,19 @@ func newWorker(o workerOpt) (*worker, error) { return nil, err } - nameTaken := func(name string) bool { return workersByName[name] != nil } - pathTaken := func(server *Server, path string) bool { - if server == nil { - return globalWorkersByPath[path] != nil - } + taken := declarationIndex{ + names: func(name string) bool { return workersByName[name] != nil }, + paths: func(server *Server, path string) bool { + if server == nil { + return globalWorkersByPath[path] != nil + } - return server.workersByPath[path] != nil + return server.workersByPath[path] != nil + }, + vars: func(server *Server, ns string) bool { return varsOwners[server][ns] != nil }, } - if err := checkWorkerDeclaration(o, nameTaken, pathTaken); err != nil { + if err := checkWorkerDeclaration(o, taken); err != nil { return nil, err } @@ -317,6 +391,11 @@ func newWorker(o workerOpt) (*worker, error) { server: o.server, isBackgroundWorker: o.isBackgroundWorker, readyOnce: make(chan struct{}), + varsNamespaces: o.vars, + vars: make(map[string]*varsSlot, len(o.vars)), + } + for _, ns := range o.vars { + w.vars[ns] = &varsSlot{} } w.configureMercure(&o) diff --git a/workervars.go b/workervars.go index 42da880b52..498514cb44 100644 --- a/workervars.go +++ b/workervars.go @@ -8,7 +8,7 @@ import ( "sync" ) -// varsSlot holds the snapshot a background worker published through +// varsSlot holds the snapshot of a vars namespace, published through // WorkerHandle::setVars(): a persistent HashTable, copied into request memory // by each frankenphp_get_vars() reader. It belongs to the worker, not to a // thread, so it survives script restarts and serves stale data meanwhile. @@ -18,17 +18,25 @@ type varsSlot struct { } var ( + // the publisher of each vars namespace, per scope + varsOwners namespaceOwners + // booting background workers blocked in frankenphp_get_vars() on other // workers, keyed by waiter: a cycle would deadlock Init(), refuse it varsWaitMu sync.Mutex varsWaitOn = map[*worker]map[*worker]int{} ) -// varsWorker resolves the name of a background worker -func varsWorker(name string) (*worker, error) { - w := workersByName[name] - if w == nil || !w.isBackgroundWorker { - return nil, errors.New("frankenphp_get_vars(): unknown background worker " + strconv.Quote(name)) +// varsOwner resolves the publisher of a vars namespace: within the caller's +// server first, then among global workers +func varsOwner(fc *frankenPHPContext, ns string) (*worker, error) { + var server *Server + if fc != nil { + server = fc.server + } + w := varsOwners.resolve(server, ns) + if w == nil { + return nil, errors.New("frankenphp_get_vars(): no background worker publishes the vars namespace " + strconv.Quote(ns)) } return w, nil @@ -94,37 +102,49 @@ func varsWaitReaches(from, to *worker) bool { // and before the engine goes away func freeWorkerVars() { for _, w := range workers { - w.vars.mu.Lock() - if w.vars.table != nil { - C.frankenphp_vars_free(w.vars.table) - w.vars.table = nil + for _, slot := range w.vars { + slot.mu.Lock() + if slot.table != nil { + C.frankenphp_vars_free(slot.table) + slot.table = nil + } + slot.mu.Unlock() } - w.vars.mu.Unlock() } } +// go_frankenphp_set_vars stores a snapshot in a namespace of the calling +// worker and hands back the one it replaces, for the C side to free; on +// error, the C side frees the snapshot it passed +// //export go_frankenphp_set_vars -func go_frankenphp_set_vars(threadIndex C.uintptr_t, table *C.HashTable) *C.HashTable { +func go_frankenphp_set_vars(threadIndex C.uintptr_t, namespace *C.char, namespaceLen C.size_t, table *C.HashTable, old **C.HashTable) *C.char { handler, ok := phpThreads[threadIndex].handler.(*backgroundWorkerThread) if !ok { - // only a background worker can hold a handle; hand the table back so - // it is freed - return table + // refused on the C side already + return C.CString("FrankenPHP\\WorkerHandle::setVars() can only be called from a background worker") + } + + ns := C.GoStringN(namespace, C.int(namespaceLen)) + slot := handler.worker.vars[ns] + if slot == nil { + return C.CString("FrankenPHP\\WorkerHandle::setVars(): this worker does not publish the vars namespace " + strconv.Quote(ns)) } - slot := &handler.worker.vars slot.mu.Lock() - old := slot.table + *old = slot.table slot.table = table slot.mu.Unlock() + handler.publishedVars[ns] = true - return old + return nil } //export go_frankenphp_get_vars -func go_frankenphp_get_vars(threadIndex C.uintptr_t, name *C.char, nameLen C.size_t, returnValue *C.zval) *C.char { +func go_frankenphp_get_vars(threadIndex C.uintptr_t, namespace *C.char, namespaceLen C.size_t, returnValue *C.zval) *C.char { thread := phpThreads[threadIndex] - target, err := varsWorker(C.GoStringN(name, C.int(nameLen))) + ns := C.GoStringN(namespace, C.int(namespaceLen)) + target, err := varsOwner(thread.handler.frankenPHPContext(), ns) if err != nil { return C.CString(err.Error()) } @@ -137,13 +157,23 @@ func go_frankenphp_get_vars(threadIndex C.uintptr_t, name *C.char, nameLen C.siz return C.CString(err.Error()) } - slot := &target.vars + // a worker is ready only once every namespace it declares has a snapshot + slot := target.vars[ns] slot.mu.RLock() defer slot.mu.RUnlock() - if slot.table == nil { - return C.CString("frankenphp_get_vars(): background worker " + strconv.Quote(target.name) + " has not published any vars yet") - } C.frankenphp_vars_to_request(returnValue, slot.table) return nil } + +// go_frankenphp_worker_vars lists the vars namespaces of the calling worker, +// for WorkerHandle::getNamespaces() +// +//export go_frankenphp_worker_vars +func go_frankenphp_worker_vars(threadIndex C.uintptr_t, array *C.zval) { + if handler, ok := phpThreads[threadIndex].handler.(*backgroundWorkerThread); ok { + for _, ns := range handler.worker.varsNamespaces { + C.frankenphp_array_append_string(array, toUnsafeChar(ns), C.size_t(len(ns))) + } + } +} diff --git a/workervars_test.go b/workervars_test.go index 63e5a1dc36..88798f4545 100644 --- a/workervars_test.go +++ b/workervars_test.go @@ -3,21 +3,22 @@ package frankenphp_test import ( "path/filepath" "testing" + "time" "github.com/dunglas/frankenphp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// bgWorker declares a background worker from testdata/bgworker, scoped to -// server when one is given -func bgWorker(name, file string, env map[string]string, server *frankenphp.Server) frankenphp.Option { - opts := []frankenphp.WorkerOption{frankenphp.WithWorkerBackground(), frankenphp.WithWorkerEnv(env)} +// bgWorker declares a background worker from testdata/bgworker publishing the +// vars namespace named like it, scoped to server when one is given +func bgWorker(name, file string, env map[string]string, server *frankenphp.Server, extra ...frankenphp.WorkerOption) frankenphp.Option { + opts := []frankenphp.WorkerOption{frankenphp.WithWorkerBackground(), frankenphp.WithWorkerEnv(env), frankenphp.WithWorkerVars(name)} if server != nil { opts = append(opts, frankenphp.WithWorkerServerScope(server)) } - return frankenphp.WithWorkers(name, "testdata/bgworker/"+file, 1, opts...) + return frankenphp.WithWorkers(name, "testdata/bgworker/"+file, 1, append(opts, extra...)...) } func TestVarsRoundTrip(t *testing.T) { @@ -25,12 +26,67 @@ func TestVarsRoundTrip(t *testing.T) { require.NoError(t, err) initServers(t, frankenphp.WithServer(server), bgWorker("publisher", "publisher.php", nil, server), frankenphp.WithNumThreads(2)) - body := serverGet(t, server, "http://example.com/vars.php?name=publisher") + body := serverGet(t, server, "http://example.com/vars.php?ns=publisher") assert.JSONEq(t, `{"answer":42,"value":"default","nested":{"a":1,"list":[true,null,1.5,"x"]},"worker":"publisher"}`, body) // a second read is a fresh copy of the same snapshot - assert.JSONEq(t, body, serverGet(t, server, "http://example.com/vars.php?name=publisher")) - assert.Contains(t, serverGet(t, server, "http://example.com/vars.php?name=nope"), "unknown background worker") + assert.JSONEq(t, body, serverGet(t, server, "http://example.com/vars.php?ns=publisher")) + assert.Contains(t, serverGet(t, server, "http://example.com/vars.php?ns=nope"), `no background worker publishes the vars namespace "nope"`) +} + +// one worker publishes several namespaces, and readers address the +// namespace, never the worker +func TestVarsNamespaces(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, + frankenphp.WithServer(server), + frankenphp.WithWorkers("config", "testdata/bgworker/multi-vars.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerServerScope(server), + frankenphp.WithWorkerVars("redis", "feature_flags"), + ), + frankenphp.WithNumThreads(2), + ) + + all := `{"vars":["redis","feature_flags"]}` + assert.JSONEq(t, `{"namespace":"redis","all":`+all+`}`, serverGet(t, server, "http://example.com/vars.php?ns=redis")) + assert.JSONEq(t, `{"namespace":"feature_flags","all":`+all+`}`, serverGet(t, server, "http://example.com/vars.php?ns=feature_flags")) + assert.Contains(t, serverGet(t, server, "http://example.com/vars.php?ns=config"), "no background worker publishes") +} + +// a namespace resolves within the server of the reader first, then among the +// global workers +func TestVarsScopedToServer(t *testing.T) { + server1, _ := frankenphp.NewServer(testDataDir, frankenphp.WithServerName("one")) + server2, _ := frankenphp.NewServer(testDataDir, frankenphp.WithServerName("two")) + server3, _ := frankenphp.NewServer(testDataDir, frankenphp.WithServerName("three")) + // worker names are global, the namespace is what the three share + publisher := func(name, value string, server *frankenphp.Server) frankenphp.Option { + opts := []frankenphp.WorkerOption{ + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerVars("cfg"), + frankenphp.WithWorkerEnv(map[string]string{"BG_PUBLISH_VALUE": value}), + } + if server != nil { + opts = append(opts, frankenphp.WithWorkerServerScope(server)) + } + + return frankenphp.WithWorkers(name, "testdata/bgworker/publisher.php", 1, opts...) + } + initServers(t, + frankenphp.WithServer(server1), + frankenphp.WithServer(server2), + frankenphp.WithServer(server3), + publisher("cfg-one", "one", server1), + publisher("cfg-two", "two", server2), + publisher("cfg-global", "global", nil), + frankenphp.WithNumThreads(3), + ) + + assert.Contains(t, serverGet(t, server1, "http://example.com/vars.php?ns=cfg"), `"value":"one"`) + assert.Contains(t, serverGet(t, server2, "http://example.com/vars.php?ns=cfg"), `"value":"two"`) + assert.Contains(t, serverGet(t, server3, "http://example.com/vars.php?ns=cfg"), `"value":"global"`) } // a worker booting before its dependency has published blocks in @@ -55,41 +111,97 @@ func TestVarsCycleIsRefused(t *testing.T) { frankenphp.WithNumThreads(3), ) - // one side sees the cycle, the other then reads a ready worker that never published + // one side sees the cycle and publishes the exception, the other reads it results := requireFileContentEventually(t, s1) + "\n" + requireFileContentEventually(t, s2) assert.Contains(t, results, "circular dependency") - assert.Contains(t, results, "has not published any vars yet") + assert.Contains(t, results, `{"consumed":"RuntimeException: frankenphp_get_vars(): circular dependency`) } -func TestVarsNotPublished(t *testing.T) { - server, err := frankenphp.NewServer(testDataDir) - require.NoError(t, err) - initServers(t, frankenphp.WithServer(server), bgWorker("silent", "basic.php", nil, server), frankenphp.WithNumThreads(2)) +// the first tick of a run waits for every declared namespace, so readers +// never ask a ready worker for a snapshot it does not have +func TestVarsArePublishedBeforeTheFirstTick(t *testing.T) { + t.Cleanup(frankenphp.Shutdown) - assert.Contains(t, serverGet(t, server, "http://example.com/vars.php?name=silent"), "has not published any vars yet") + err := frankenphp.Init(bgWorker("silent", "basic.php", nil, nil, frankenphp.WithWorkerMaxFailures(1)), frankenphp.WithNumThreads(1)) + require.Error(t, err) } -func TestVarsRejectInvalidValues(t *testing.T) { +func TestVarsRejectInvalidPublications(t *testing.T) { sentinel := filepath.Join(t.TempDir(), "bad.txt") initServers(t, bgWorker("bad", "bad-vars.php", map[string]string{"BG_SENTINEL": sentinel}, nil), frankenphp.WithNumThreads(2)) result := requireFileContentEventually(t, sentinel) assert.Contains(t, result, "ValueError") assert.Contains(t, result, "must be null, scalars, arrays or enums") + assert.Contains(t, result, `LogicException: FrankenPHP\WorkerHandle::setVars(): this worker does not publish the vars namespace "undeclared"`) } -// a reboot restarts the PHP engine, and a snapshot published before it is -// still served: it must not point into the memory of the engine that is gone +// a reboot restarts the PHP engine, and readers keep getting the snapshot +// published before it until the worker publishes again: it must not point +// into the memory of the engine that is gone func TestVarsSurviveAReboot(t *testing.T) { server, err := frankenphp.NewServer(testDataDir) require.NoError(t, err) - sentinel := filepath.Join(t.TempDir(), "published") - initServers(t, frankenphp.WithServer(server), bgWorker("once", "publish-once.php", map[string]string{"BG_SENTINEL": sentinel}, server), frankenphp.WithNumThreads(2)) + env := map[string]string{"BG_SENTINEL": filepath.Join(t.TempDir(), "published"), "BG_REPUBLISH_DELAY_MS": "2000"} + initServers(t, frankenphp.WithServer(server), bgWorker("once", "republish.php", env, server), frankenphp.WithNumThreads(2)) - before := serverGet(t, server, "http://example.com/vars.php?name=once") + before := serverGet(t, server, "http://example.com/vars.php?ns=once") require.JSONEq(t, `{"answer":42,"nested":{"a":1,"list":[true,null,1.5,"x"]}}`, before) - frankenphp.RestartWorkers() + rebooted := make(chan struct{}) + go func() { + frankenphp.RestartWorkers() + close(rebooted) + }() - assert.JSONEq(t, before, serverGet(t, server, "http://example.com/vars.php?name=once")) + // the request threads come back while the worker still waits to publish + time.Sleep(500 * time.Millisecond) + assert.JSONEq(t, before, serverGet(t, server, "http://example.com/vars.php?ns=once")) + + <-rebooted + assert.JSONEq(t, before, serverGet(t, server, "http://example.com/vars.php?ns=once")) +} + +func TestValidateVarsNamespaces(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + publisher := func(name string, num int, extra ...frankenphp.WorkerOption) frankenphp.Option { + return frankenphp.WithWorkers(name, "testdata/bgworker/publisher.php", num, append([]frankenphp.WorkerOption{frankenphp.WithWorkerBackground()}, extra...)...) + } + + assert.ErrorContains(t, frankenphp.Validate( + publisher("one", 1, frankenphp.WithWorkerVars("cfg")), + publisher("two", 1, frankenphp.WithWorkerVars("other", "cfg")), + ), `two global workers cannot publish the same vars namespace: "cfg"`) + + // the same namespace in a server and globally: the server's comes first for its requests + assert.NoError(t, frankenphp.Validate( + frankenphp.WithServer(server), + publisher("one", 1, frankenphp.WithWorkerVars("cfg")), + publisher("two", 1, frankenphp.WithWorkerVars("cfg"), frankenphp.WithWorkerServerScope(server)), + )) + + assert.ErrorContains(t, frankenphp.Validate( + publisher("one", 1, frankenphp.WithWorkerVars("cfg", "cfg")), + ), `declares the vars namespace "cfg" twice`) + + assert.ErrorContains(t, frankenphp.Validate( + publisher("pool", 2, frankenphp.WithWorkerVars("cfg")), + ), "publishes vars, it runs a single thread") + + assert.ErrorContains(t, frankenphp.Validate( + frankenphp.WithWorkers("http", "testdata/worker.php", 1, frankenphp.WithWorkerVars("cfg")), + ), "only background workers publish them") +} + +// the rules are the ones Init() applies +func TestInitRefusesASharedVarsNamespace(t *testing.T) { + t.Cleanup(frankenphp.Shutdown) + + err := frankenphp.Init( + bgWorker("cfg", "publisher.php", nil, nil), + frankenphp.WithWorkers("other", "testdata/bgworker/publisher.php", 1, frankenphp.WithWorkerBackground(), frankenphp.WithWorkerVars("cfg")), + frankenphp.WithNumThreads(1), + ) + require.ErrorContains(t, err, `two global workers cannot publish the same vars namespace: "cfg"`) } From 1d8594a77978ae9e8af4c4fdb6e0c07e6299c15c Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 27 Oct 2026 19:04:21 +0100 Subject: [PATCH 12/13] fix: a vars read survives a bailout and never hands out a shared string frankenphp_get_vars() copied the snapshot inside a Go callback with its slot read-locked, so a bailout during the copy, past memory_limit or in an autoloader an enum triggers, left the lock held: the publisher and the next readers waited for good, Shutdown() with them. Snapshots are now counted references, the copy runs in C with no lock held and lets go of the snapshot on every path. The class name of an enum went to the autoloaders as the persistent string of the snapshot, so one keeping it pointed into memory the next publication freed, and readers raced on its refcount. The lookup now uses a request copy. A worker waiting on another one at boot now gives up when the start fails, so an HTTP worker reading vars in its bootstrap no longer keeps Init() waiting on a publisher that never comes up, and a Caddy JSON config no longer drops vars declared on an HTTP worker. --- caddy/config_test.go | 10 ++++ caddy/workerconfig.go | 8 +-- docs/worker.md | 2 +- frankenphp.c | 20 ++++++- testdata/bgworker/big-vars.php | 21 ++++++++ testdata/bgworker/enum-vars.php | 15 ++++++ testdata/enum-suit.php | 7 +++ testdata/vars-at-boot.php | 13 +++++ testdata/vars-enum.php | 15 ++++++ worker.go | 6 +++ workervars.go | 90 ++++++++++++++++++++++++------- workervars_test.go | 96 +++++++++++++++++++++++++++++++++ zval.h | 7 ++- 13 files changed, 286 insertions(+), 24 deletions(-) create mode 100644 testdata/bgworker/big-vars.php create mode 100644 testdata/bgworker/enum-vars.php create mode 100644 testdata/enum-suit.php create mode 100644 testdata/vars-at-boot.php create mode 100644 testdata/vars-enum.php diff --git a/caddy/config_test.go b/caddy/config_test.go index 3ebbd29780..94db973fb9 100644 --- a/caddy/config_test.go +++ b/caddy/config_test.go @@ -472,3 +472,13 @@ func TestWorkerBackgroundRejectsMatch(t *testing.T) { err := module.UnmarshalCaddyfile(d) require.ErrorContains(t, err, `"match" is not supported for background workers`) } + +// a JSON config skips the Caddyfile check: the core refuses vars on an HTTP +// worker instead of the option being dropped +func TestWorkerVarsReachTheCoreWithoutBackground(t *testing.T) { + wc := workerConfig{FileName: "../testdata/worker-with-env.php", Vars: []string{"cfg"}} + opts, err := wc.toWorkerOptions() + require.NoError(t, err) + + require.ErrorContains(t, frankenphp.Validate(frankenphp.WithWorkers("http", wc.FileName, 1, opts...)), "only background workers publish them") +} diff --git a/caddy/workerconfig.go b/caddy/workerconfig.go index 48bee5b722..38bc16f4e4 100644 --- a/caddy/workerconfig.go +++ b/caddy/workerconfig.go @@ -217,10 +217,12 @@ func (wc *workerConfig) toWorkerOptions() ([]frankenphp.WorkerOption, error) { if wc.BootTimeout != nil { opts = append(opts, frankenphp.WithWorkerBootTimeout(time.Duration(*wc.BootTimeout))) } + } - if len(wc.Vars) > 0 { - opts = append(opts, frankenphp.WithWorkerVars(wc.Vars...)) - } + // whatever the kind of worker: the core refuses vars on an HTTP one, which + // a JSON config reaches without the Caddyfile check + if len(wc.Vars) > 0 { + opts = append(opts, frankenphp.WithWorkerVars(wc.Vars...)) } // copy the caddy match logic and create a unique matcher function for this worker diff --git a/docs/worker.md b/docs/worker.md index fa93edab5a..f2cf8572cd 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -308,7 +308,7 @@ A namespace has one publisher in a `php_server`, and one among the global worker Values must be null, scalars, arrays or enums. Each call replaces the whole snapshot of its namespace and readers get a copy, so the worker can publish at any time and a request always sees a consistent one. Every declared namespace is published before the first tick of a run, which `tick()` enforces: since the server accepts requests once the background workers are ready, requests never wait for a snapshot, and while a worker restarts they keep getting its last ones. -`frankenphp_get_vars()` can only block between background workers reading each other while booting; a cycle between them throws instead of hanging. It also throws when no worker publishes the namespace. +`frankenphp_get_vars()` can only block while workers boot, a background worker or the bootstrap of an HTTP worker waiting for the publisher of a namespace. A cycle between background workers throws instead of hanging, and so does a wait once the start failed. It also throws when no worker publishes the namespace. ## Superglobals behavior diff --git a/frankenphp.c b/frankenphp.c index 79f053fff7..563d5e1ad2 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1733,14 +1733,32 @@ PHP_FUNCTION(frankenphp_get_vars) { Z_PARAM_STR(namespace) ZEND_PARSE_PARAMETERS_END(); + HashTable *table = NULL; + uintptr_t snapshot = 0; char *error = go_frankenphp_get_vars(frankenphp_thread_index(), ZSTR_VAL(namespace), - ZSTR_LEN(namespace), return_value); + ZSTR_LEN(namespace), &table, &snapshot); if (error != NULL) { zend_throw_exception(spl_ce_RuntimeException, error, 0); free(error); RETURN_THROWS(); } + + /* the copy may bail out, past memory_limit or in an autoloader an enum + * triggers: the snapshot is let go of whatever happens, so its publisher + * can replace and free it */ + bool bailed_out = false; + zend_try { frankenphp_vars_to_request(return_value, table); } + zend_catch { bailed_out = true; } + zend_end_try(); + + HashTable *unused = go_frankenphp_vars_done(snapshot); + if (unused != NULL) { + frankenphp_vars_free(unused); + } + if (bailed_out) { + zend_bailout(); + } } /* {{{ thread-safe opcache reset */ diff --git a/testdata/bgworker/big-vars.php b/testdata/bgworker/big-vars.php new file mode 100644 index 0000000000..a9d26a5e5f --- /dev/null +++ b/testdata/bgworker/big-vars.php @@ -0,0 +1,21 @@ +setVars('big', $big); +file_put_contents($_SERVER['BG_SENTINEL'], (string) ++$publications); +$stream = $handle->getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, 0, 50000); + $handle->setVars('big', $big); + file_put_contents($_SERVER['BG_SENTINEL'], (string) ++$publications); +} diff --git a/testdata/bgworker/enum-vars.php b/testdata/bgworker/enum-vars.php new file mode 100644 index 0000000000..fc64e4c581 --- /dev/null +++ b/testdata/bgworker/enum-vars.php @@ -0,0 +1,15 @@ +setVars('enum', ['suit' => EnumSuit::Hearts, 'n' => ++$publications]); +$stream = $handle->getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, 0, 10000); + $handle->setVars('enum', ['suit' => EnumSuit::Hearts, 'n' => ++$publications]); +} diff --git a/testdata/enum-suit.php b/testdata/enum-suit.php new file mode 100644 index 0000000000..f7c35e4abb --- /dev/null +++ b/testdata/enum-suit.php @@ -0,0 +1,7 @@ +getMessage(); +} +while (frankenphp_handle_request(function () use ($vars): void { + echo json_encode($vars); +})) { +} diff --git a/testdata/vars-enum.php b/testdata/vars-enum.php new file mode 100644 index 0000000000..d562d921cf --- /dev/null +++ b/testdata/vars-enum.php @@ -0,0 +1,15 @@ +name, ' kept:', implode(',', array_map('hex2bin', array_keys($seen))); diff --git a/worker.go b/worker.go index 54f2c1788c..1c88856714 100644 --- a/worker.go +++ b/worker.go @@ -63,6 +63,10 @@ var ( startupFailChan chan error // the only window where a boot failure reaches startupFailChan: past it, the handlers log and keep restarting startupPhase atomic.Bool + // closed by the first boot failure of the startup, so the threads waiting + // on the ready point of another worker give up, see waitVarsReady() + startupFailed chan struct{} + startupFailedOnce *sync.Once ) // DefaultWorkerBootTimeout is how long a background worker may take to reach @@ -109,6 +113,7 @@ func initWorkers(opts []workerOpt) error { } startupFailChan = make(chan error, totalThreadsToStart) + startupFailed, startupFailedOnce = make(chan struct{}), &sync.Once{} startupPhase.Store(true) for _, w := range workers { @@ -176,6 +181,7 @@ func reportStartupFailure(err error) bool { case startupFailChan <- err: default: } + startupFailedOnce.Do(func() { close(startupFailed) }) return true } diff --git a/workervars.go b/workervars.go index 498514cb44..c2d160588f 100644 --- a/workervars.go +++ b/workervars.go @@ -4,17 +4,38 @@ package frankenphp import "C" import ( "errors" + "runtime/cgo" "strconv" "sync" + "sync/atomic" ) -// varsSlot holds the snapshot of a vars namespace, published through -// WorkerHandle::setVars(): a persistent HashTable, copied into request memory -// by each frankenphp_get_vars() reader. It belongs to the worker, not to a -// thread, so it survives script restarts and serves stale data meanwhile. -type varsSlot struct { - mu sync.RWMutex +// varsSnapshot is one publication of a vars namespace: a persistent +// HashTable, copied into request memory by each frankenphp_get_vars() +// reader. The copy runs in C with no lock held, a bailout may end it, so refs +// counts the slot and the readers holding the snapshot, and the last one to +// let go frees it. +type varsSnapshot struct { table *C.HashTable + refs atomic.Int32 +} + +// release lets go of a reference, and hands back the table to free once +// nobody holds it anymore +func (s *varsSnapshot) release() *C.HashTable { + if s.refs.Add(-1) == 0 { + return s.table + } + + return nil +} + +// varsSlot holds the current snapshot of a vars namespace, published through +// WorkerHandle::setVars(). It belongs to the worker, not to a thread, so it +// survives script restarts and serves stale data meanwhile. +type varsSlot struct { + mu sync.RWMutex + current *varsSnapshot } var ( @@ -44,8 +65,10 @@ func varsOwner(fc *frankenPHPContext, ns string) (*worker, error) { // waitVarsReady blocks until target reached its ready point once. Requests // cannot get here before that (activateServers() runs after initWorkers()), -// so a blocked caller is a background worker still booting: waits between -// workers form a graph, and a cycle is refused instead of deadlocking Init() +// so a blocked caller is a worker still booting, a background one or the +// bootstrap of an HTTP one. Waits between background workers form a graph, +// and a cycle is refused instead of deadlocking Init(), as is any wait once +// the start failed func waitVarsReady(target, caller *worker) error { select { case <-target.readyOnce: @@ -83,6 +106,8 @@ func waitVarsReady(target, caller *worker) error { return nil case <-mainThread.done: return errors.New("frankenphp_get_vars(): FrankenPHP is shutting down") + case <-startupFailed: + return errors.New("frankenphp_get_vars(): FrankenPHP failed to start, " + strconv.Quote(target.name) + " is not ready") } } @@ -104,9 +129,11 @@ func freeWorkerVars() { for _, w := range workers { for _, slot := range w.vars { slot.mu.Lock() - if slot.table != nil { - C.frankenphp_vars_free(slot.table) - slot.table = nil + if slot.current != nil { + if table := slot.current.release(); table != nil { + C.frankenphp_vars_free(table) + } + slot.current = nil } slot.mu.Unlock() } @@ -114,8 +141,8 @@ func freeWorkerVars() { } // go_frankenphp_set_vars stores a snapshot in a namespace of the calling -// worker and hands back the one it replaces, for the C side to free; on -// error, the C side frees the snapshot it passed +// worker and hands back the one it replaces, for the C side to free unless a +// reader still copies it; on error, the C side frees the snapshot it passed // //export go_frankenphp_set_vars func go_frankenphp_set_vars(threadIndex C.uintptr_t, namespace *C.char, namespaceLen C.size_t, table *C.HashTable, old **C.HashTable) *C.char { @@ -131,17 +158,27 @@ func go_frankenphp_set_vars(threadIndex C.uintptr_t, namespace *C.char, namespac return C.CString("FrankenPHP\\WorkerHandle::setVars(): this worker does not publish the vars namespace " + strconv.Quote(ns)) } + s := &varsSnapshot{table: table} + s.refs.Store(1) + slot.mu.Lock() - *old = slot.table - slot.table = table + previous := slot.current + slot.current = s slot.mu.Unlock() + if previous != nil { + *old = previous.release() + } handler.publishedVars[ns] = true return nil } +// go_frankenphp_get_vars hands the current snapshot of a namespace to the C +// side, which copies it into request memory and then lets go of it through +// go_frankenphp_vars_done +// //export go_frankenphp_get_vars -func go_frankenphp_get_vars(threadIndex C.uintptr_t, namespace *C.char, namespaceLen C.size_t, returnValue *C.zval) *C.char { +func go_frankenphp_get_vars(threadIndex C.uintptr_t, namespace *C.char, namespaceLen C.size_t, table **C.HashTable, snapshot *C.uintptr_t) *C.char { thread := phpThreads[threadIndex] ns := C.GoStringN(namespace, C.int(namespaceLen)) target, err := varsOwner(thread.handler.frankenPHPContext(), ns) @@ -160,12 +197,29 @@ func go_frankenphp_get_vars(threadIndex C.uintptr_t, namespace *C.char, namespac // a worker is ready only once every namespace it declares has a snapshot slot := target.vars[ns] slot.mu.RLock() - defer slot.mu.RUnlock() - C.frankenphp_vars_to_request(returnValue, slot.table) + s := slot.current + s.refs.Add(1) + slot.mu.RUnlock() + + *table = s.table + *snapshot = C.uintptr_t(cgo.NewHandle(s)) return nil } +// go_frankenphp_vars_done lets go of the snapshot a frankenphp_get_vars() +// call copied, and hands back its table for the C side to free when its slot +// moved on meanwhile +// +//export go_frankenphp_vars_done +func go_frankenphp_vars_done(snapshot C.uintptr_t) *C.HashTable { + h := cgo.Handle(snapshot) + s := h.Value().(*varsSnapshot) + h.Delete() + + return s.release() +} + // go_frankenphp_worker_vars lists the vars namespaces of the calling worker, // for WorkerHandle::getNamespaces() // diff --git a/workervars_test.go b/workervars_test.go index 88798f4545..c1242c3481 100644 --- a/workervars_test.go +++ b/workervars_test.go @@ -1,7 +1,9 @@ package frankenphp_test import ( + "os" "path/filepath" + "strconv" "testing" "time" @@ -174,6 +176,16 @@ func TestValidateVarsNamespaces(t *testing.T) { publisher("two", 1, frankenphp.WithWorkerVars("other", "cfg")), ), `two global workers cannot publish the same vars namespace: "cfg"`) + // one publisher per server + other, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + assert.NoError(t, frankenphp.Validate( + frankenphp.WithServer(server), + frankenphp.WithServer(other), + publisher("one", 1, frankenphp.WithWorkerVars("cfg"), frankenphp.WithWorkerServerScope(server)), + publisher("two", 1, frankenphp.WithWorkerVars("cfg"), frankenphp.WithWorkerServerScope(other)), + )) + // the same namespace in a server and globally: the server's comes first for its requests assert.NoError(t, frankenphp.Validate( frankenphp.WithServer(server), @@ -205,3 +217,87 @@ func TestInitRefusesASharedVarsNamespace(t *testing.T) { ) require.ErrorContains(t, err, `two global workers cannot publish the same vars namespace: "cfg"`) } + +// a bailout while copying a snapshot, here past memory_limit, lets go of it: +// the publisher keeps publishing and the next reader is served +func TestVarsBailoutInTheCopy(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "publications") + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, + frankenphp.WithServer(server), + bgWorker("big", "big-vars.php", map[string]string{"BG_SENTINEL": sentinel}, server), + frankenphp.WithNumThreads(3), + frankenphp.WithPhpIni(map[string]string{"memory_limit": "8M"}), + ) + + publications := func() int { + b, _ := os.ReadFile(sentinel) + n, _ := strconv.Atoi(string(b)) + + return n + } + assert.Contains(t, serverGet(t, server, "http://example.com/vars.php?ns=big"), "Allowed memory size") + after := publications() + require.Eventually(t, func() bool { return publications() > after }, 5*time.Second, 25*time.Millisecond, "the publisher is stuck") + assert.Contains(t, serverGet(t, server, "http://example.com/vars.php?ns=big"), "Allowed memory size") +} + +// an autoloader may keep the class name it is given: a request string, not +// the one of the snapshot, which the next publication frees +func TestVarsEnumClassNameOutlivesTheSnapshot(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), bgWorker("enum", "enum-vars.php", nil, server), frankenphp.WithNumThreads(2)) + + assert.Equal(t, "Hearts kept:EnumSuit", serverGet(t, server, "http://example.com/vars-enum.php")) +} + +// an HTTP worker reading vars in its bootstrap gives up once the start +// failed, here because the publisher never publishes, instead of keeping +// Init() waiting +func TestVarsWaitEndsWhenTheStartFails(t *testing.T) { + t.Cleanup(frankenphp.Shutdown) + done := make(chan error, 1) + go func() { + done <- frankenphp.Init( + bgWorker("silent", "basic.php", nil, nil, frankenphp.WithWorkerMaxFailures(1)), + frankenphp.WithWorkers("reader", "testdata/vars-at-boot.php", 1), + frankenphp.WithNumThreads(3), + ) + }() + + select { + case err := <-done: + require.ErrorContains(t, err, "keeps crashing") + case <-time.After(10 * time.Second): + t.Fatal("Init() did not return") + } +} + +// the namespaces of a run go with it, even when the next run has no workers +func TestVarsOwnersEndWithTheRun(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), bgWorker("publisher", "publisher.php", nil, server), frankenphp.WithNumThreads(2)) + require.Contains(t, serverGet(t, server, "http://example.com/vars.php?ns=publisher"), `"answer":42`) + frankenphp.Shutdown() + + initServers(t, frankenphp.WithServer(server), frankenphp.WithNumThreads(1)) + assert.Contains(t, serverGet(t, server, "http://example.com/vars.php?ns=publisher"), `no background worker publishes the vars namespace "publisher"`) +} + +// a worker of a server falls back to the global namespace like a request does +func TestVarsServerWorkerReadsAGlobalNamespace(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "consumer.json") + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, + frankenphp.WithServer(server), + bgWorker("publisher", "publisher.php", map[string]string{"BG_PUBLISH_VALUE": "global"}, nil), + bgWorker("consumer", "consumer.php", map[string]string{"BG_CONSUME": "publisher", "BG_SENTINEL": sentinel}, server), + frankenphp.WithNumThreads(3), + ) + + assert.Contains(t, requireFileContentEventually(t, sentinel), `"value":"global"`) +} diff --git a/zval.h b/zval.h index 4141bc9582..5c62cab573 100644 --- a/zval.h +++ b/zval.h @@ -192,7 +192,12 @@ static void persistent_zval_to_request(zval *dst, zval *src) { break; case IS_PTR: { persistent_zval_enum_t *e = (persistent_zval_enum_t *)Z_PTR_P(src); - zend_class_entry *ce = zend_lookup_class(e->class_name); + /* autoloaders get the name and may keep it: a request copy, since the + * persistent one is shared with other threads and freed with its tree */ + zend_string *class_name = + zend_string_init(ZSTR_VAL(e->class_name), ZSTR_LEN(e->class_name), 0); + zend_class_entry *ce = zend_lookup_class(class_name); + zend_string_release(class_name); if (EG(exception)) { /* Autoloader threw; let that exception propagate untouched. */ ZVAL_NULL(dst); From f05fb059344be6628c4cd59fb31bc1fdbd1ea345 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 27 Oct 2026 20:09:40 +0100 Subject: [PATCH 13/13] test: bail out of a vars copy without relying on memory_limit The sanitizer jobs run without the Zend allocator, which enforces no memory_limit. A fatal error in the autoloader an enum triggers bails out on every allocator. --- testdata/bgworker/big-vars.php | 21 --------------------- testdata/vars-fatal.php | 12 ++++++++++++ workervars_test.go | 30 ++++++++++-------------------- 3 files changed, 22 insertions(+), 41 deletions(-) delete mode 100644 testdata/bgworker/big-vars.php create mode 100644 testdata/vars-fatal.php diff --git a/testdata/bgworker/big-vars.php b/testdata/bgworker/big-vars.php deleted file mode 100644 index a9d26a5e5f..0000000000 --- a/testdata/bgworker/big-vars.php +++ /dev/null @@ -1,21 +0,0 @@ -setVars('big', $big); -file_put_contents($_SERVER['BG_SENTINEL'], (string) ++$publications); -$stream = $handle->getStream(); -while ($handle->tick()) { - $read = [$stream]; - $write = $except = null; - stream_select($read, $write, $except, 0, 50000); - $handle->setVars('big', $big); - file_put_contents($_SERVER['BG_SENTINEL'], (string) ++$publications); -} diff --git a/testdata/vars-fatal.php b/testdata/vars-fatal.php new file mode 100644 index 0000000000..a83d7a00c4 --- /dev/null +++ b/testdata/vars-fatal.php @@ -0,0 +1,12 @@ + after }, 5*time.Second, 25*time.Millisecond, "the publisher is stuck") - assert.Contains(t, serverGet(t, server, "http://example.com/vars.php?ns=big"), "Allowed memory size") + first := serverGet(t, server, "http://example.com/vars-fatal.php") + require.Eventually(t, func() bool { + return serverGet(t, server, "http://example.com/vars-fatal.php") != first + }, 5*time.Second, 25*time.Millisecond, "the publisher is stuck") } // an autoloader may keep the class name it is given: a request string, not