From b6a2a5aec65ea96e5ce869c4ed2bd74ac955983c Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 21 Sep 2026 12:58:14 +0200 Subject: [PATCH 01/46] fix: a thread that ended on its own can still be asked to shut down RequestSafeStateChange() waits for a stable state, Ready, Inactive or Reserved, before answering. A thread that ends by itself never reaches one again: it goes to ShuttingDown, then Done, and only shutdown() sets Reserved, which is the very call left waiting. Shutdown() then hangs for ever on that thread. A worker that gives up during its boot takes exactly that route, past max_consecutive_failures during startup, so a Shutdown() racing it deadlocks today. Waiting for the terminal states as well answers the request the way it should be answered, with a refusal, and shutdown() takes the path it already has for a thread that is done. --- internal/state/state.go | 5 +++-- internal/state/state_test.go | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/internal/state/state.go b/internal/state/state.go index 839c4b5fce..c34100ac89 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -215,8 +215,9 @@ func (ts *ThreadState) RequestSafeStateChange(nextState State) bool { } ts.mu.Unlock() - // wait for the state to change to a stable state - ts.WaitFor(Ready, Inactive, Reserved) + // or Done: a thread that ended on its own goes ShuttingDown then Done without + // being stable again, and only Done means its C side is gone + ts.WaitFor(Ready, Inactive, Reserved, Done) return ts.RequestSafeStateChange(nextState) } diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 767a27606e..26863a6d8d 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -53,6 +53,25 @@ func TestWaitForStateWithTimeoutGivesUpAndDropsItsSubscriber(t *testing.T) { }) } +func TestRequestSafeStateChangeRefusesAThreadThatEndedOnItsOwn(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + threadState := &ThreadState{currentState: TransitionComplete} + + refused := make(chan bool, 1) + go func() { + refused <- threadState.RequestSafeStateChange(ShuttingDown) + }() + + // once the request is parked, the thread ends by itself without passing + // through a stable state, the way a worker that fails to boot does + synctest.Wait() + threadState.Set(ShuttingDown) + threadState.Set(Done) + + assert.False(t, <-refused, "a thread that is already done cannot be asked to shut down") + }) +} + func assertNumberOfSubscribers(t *testing.T, threadState *ThreadState, expected int) { t.Helper() From bf157b24d46b00e3e6f178bae050bdb7f5d5cf0a Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sun, 6 Sep 2026 18:35:53 +0200 Subject: [PATCH 02/46] feat: declared background workers + frankenphp_get_worker_handle() Background workers run a script in a loop outside the HTTP request cycle, sharing the PHP runtime with the request threads. Rebuilt on Server from #2499: a background worker attaches to a php_server through WithWorkerServerScope() like any other worker. Declared with "background" in a worker block (php_server or global) or WithWorkerBackground() in Go. name is required, match is rejected, num >= 1. The lifecycle mirrors HTTP workers: re-run on a cooperative exit, restart with a capped quadratic backoff on a crash, max_consecutive_failures fails Init() during startup only. drain() runs on shutdown, reboot and handler transitions so a parked script wakes up instead of waiting out the force-kill grace period. Their threads live outside the num_threads / max_threads budget, which describes HTTP capacity: those settings size the pool background workers never draw from, so calculateMaxThreads() resolves them against the HTTP workers alone and returns the background threads separately, for Init() to add to the totals. Nothing is subtracted back out. Every worker sees its declared name in $_SERVER['FRANKENPHP_WORKER'], HTTP workers included: the documented contract is to test its presence, not its value. Background workers also get $_SERVER['FRANKENPHP_WORKER_BACKGROUND'], so a script serving both roles can tell them apart with isset(). Both names are reserved: an env of the worker or of its server never leaks either into a worker of the other kind. The script gets one handle, frankenphp_get_worker_handle(), a stream that reaches EOF when the worker is drained, meant to carry control messages later. It is backed by a socket pair, not a pipe: on Windows PHP's php_select() only waits properly on sockets before 8.5. Streams do not own the socket (php_sockop_close() would shutdown() it on Windows), so a stream can be closed and fetched again without losing the drain signal; the read timeout is infinite so a blocking read parks as well as stream_select() does. Both ends are non-inheritable. A worker counts as ready on its first wait on the handle (select cast or read), the background analog of frankenphp_handle_request(): Init() waits for it, ready_workers counts from it, and an exit before it is a boot failure. The handle's stream ops, copied from the socket ops at MINIT, report it once per run. A run gets one stream: every call returns the same resource until the script closes it, so fetching the handle in a loop does not grow the resource list of a request that never ends. Worker names are scoped like paths: unique within a php_server or among global workers. The script sees the declared name; metrics and logs report a scoped worker as ":", with a numeric suffix on server names when two blocks resolve to the same one, never a name another block configured. The collision-driven renaming in the Caddy module is gone, and WithWorkerName() resolves within the request's server first. FRANKENPHP_WORKER held "1" in HTTP workers before, and workers of a php_server block were reported under their bare name unless it collided: both changes are called out in the docs. Two places absorb the new worker kind rather than growing a copy of what exists. The states a worker thread walks through between two runs live in workerLifecycle, embedded by both handlers, which supply only what differs: how a run starts, and what a reboot resets. And a worker without a scope now belongs to the fallback server, the one already serving the requests that have no server either, so a lookup is always a lookup in a server and the parallel registry of global workers is gone. Supersedes #2543 and #2398. --- bgworker_test.go | 497 ++++++++++++++++++++++++ caddy/app.go | 62 +-- caddy/caddy_test.go | 53 ++- caddy/config_test.go | 139 +++++-- caddy/module.go | 4 + caddy/workerconfig.go | 24 +- cgi.go | 2 +- context.go | 19 +- docs/config.md | 8 +- docs/library.md | 4 + docs/metrics.md | 4 +- docs/worker.md | 38 ++ frankenphp.c | 274 +++++++++++++ frankenphp.go | 62 ++- frankenphp.h | 4 + frankenphp.stub.php | 13 + frankenphp_arginfo.h | 9 +- metrics.go | 6 +- metrics_test.go | 2 +- options.go | 15 + phpmainthread.go | 4 + phpmainthread_test.go | 45 +-- phpthread.go | 15 +- requestoptions.go | 15 +- scaling.go | 6 +- scaling_test.go | 2 +- server.go | 76 +++- server_test.go | 49 ++- testdata/_executor.php | 2 +- testdata/bgworker/basic.php | 20 + testdata/bgworker/count.php | 13 + testdata/bgworker/crash-after-ready.php | 13 + testdata/bgworker/crash.php | 29 ++ testdata/bgworker/early-return.php | 5 + testdata/bgworker/fail-then-succeed.php | 16 + testdata/bgworker/fetch-no-wait.php | 7 + testdata/bgworker/flag.php | 14 + testdata/bgworker/named.php | 19 + testdata/bgworker/pool.php | 11 + testdata/bgworker/read.php | 11 + testdata/bgworker/stuck.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 | 277 +++++++++++++ threadworker.go | 100 ++--- worker.go | 125 ++++-- workerextension.go | 26 +- workerextension_test.go | 30 ++ workerlifecycle.go | 69 ++++ 51 files changed, 1989 insertions(+), 290 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/early-return.php create mode 100644 testdata/bgworker/fail-then-succeed.php create mode 100644 testdata/bgworker/fetch-no-wait.php create mode 100644 testdata/bgworker/flag.php create mode 100644 testdata/bgworker/named.php create mode 100644 testdata/bgworker/pool.php create mode 100644 testdata/bgworker/read.php create mode 100644 testdata/bgworker/stuck.php create mode 100644 testdata/handle-outside.php create mode 100644 testdata/worker-name.php create mode 100644 threadbackgroundworker.go create mode 100644 workerlifecycle.go diff --git a/bgworker_test.go b/bgworker_test.go new file mode 100644 index 0000000000..8699f40f35 --- /dev/null +++ b/bgworker_test.go @@ -0,0 +1,497 @@ +package frankenphp_test + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "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. Wraps require.Eventually so call sites stay short. +func requireFileEventually(t testing.TB, path string, msgAndArgs ...any) { + t.Helper() + require.Eventually(t, func() bool { + _, err := os.Stat(path) + return err == nil + }, 5*time.Second, 25*time.Millisecond, msgAndArgs...) +} + +// requireFileContentEventually waits for `path` to appear with content and +// returns it +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) +} + +// TestBackgroundWorkerLifecycle boots a background worker that touches a +// sentinel file then parks on its handle. It proves the bg worker runs +// (sentinel appears) and that Shutdown returns within a reasonable time. +// The test asserts on Shutdown timing, so it manages Shutdown itself +// instead of using initServers' t.Cleanup hook. +func TestBackgroundWorkerLifecycle(t *testing.T) { + tmp := t.TempDir() + sentinel := filepath.Join(tmp, "bg-lifecycle.sentinel") + + require.NoError(t, frankenphp.Init( + frankenphp.WithWorkers("bg-lifecycle", "testdata/bgworker/basic.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + )) + + requireFileEventually(t, sentinel, "background worker did not touch sentinel") + + done := make(chan struct{}) + go func() { + frankenphp.Shutdown() + close(done) + }() + + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatalf("Shutdown did not return within 10s") + } +} + +// TestBackgroundWorkerCrashRestarts boots a worker that exit(1)s on its +// first run and touches a "restarted" sentinel on its second run. The +// sentinel proves the crash-restart loop fired. +func TestBackgroundWorkerCrashRestarts(t *testing.T) { + tmp := t.TempDir() + crashMarker := filepath.Join(tmp, "bg-crash.marker") + restarted := filepath.Join(tmp, "bg-crash.restarted") + + 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") +} + +// TestBackgroundWorkerOnServer scopes a background worker to a Server. It +// proves that the worker inherits the server env (the sentinel directory is +// declared on the server, not on the worker), that FRANKENPHP_WORKER holds +// the worker name, and that the worker does not intercept HTTP requests +// served by the same server. +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") +} + +// TestBackgroundWorkerValidation covers the declaration-time errors. +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("num must be >= 1", func(t *testing.T) { + err := frankenphp.Init( + frankenphp.WithWorkers("bg-zero", "testdata/bgworker/basic.php", 0, frankenphp.WithWorkerBackground()), + frankenphp.WithNumThreads(2), + ) + require.ErrorContains(t, err, "must declare num >= 1") + }) + + 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, "frankenphp_get_worker_handle") + }) + + t.Run("fetching the handle without waiting on it fails startup", func(t *testing.T) { + err := frankenphp.Init( + frankenphp.WithWorkers("bg-no-wait", "testdata/bgworker/fetch-no-wait.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerMaxFailures(2), + ), + frankenphp.WithNumThreads(2), + ) + require.ErrorContains(t, err, "waiting on its handle") + }) + + 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") + }) +} + +// TestBackgroundWorkerCannotHandleRequests checks that a request targeting a +// background worker by name is refused rather than dispatched to it. +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`) +} + +// TestBackgroundWorkerParksOnRead checks that a blocking read on the handle +// is a wait too: Init() returns only once the worker is ready, and the EOF +// of the drain unblocks the read so Shutdown() returns promptly. +func TestBackgroundWorkerParksOnRead(t *testing.T) { + tmp := t.TempDir() + sentinel := filepath.Join(tmp, "bg-read.sentinel") + + 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") + } +} + +// TestBackgroundWorkerRestartDrainsParkedScript checks that RestartWorkers() +// wakes a parked background script through the drain and re-runs it. +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") +} + +// TestGetWorkerHandleOutsideBackgroundWorker checks the function throws on a +// regular request thread instead of handing out a stream. +func TestGetWorkerHandleOutsideBackgroundWorker(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, "can only be called from a background worker") +} + +// TestWorkerNameInServerVars checks that every worker sees its declared name +// in FRANKENPHP_WORKER and that only background workers get the +// FRANKENPHP_WORKER_BACKGROUND flag. +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), + // the flag is reserved: an env setting it must not make an HTTP + // worker look like a background one + frankenphp.WithWorkers("web", testDataDir+"worker-name.php", 1, + frankenphp.WithWorkerServerScope(server), + frankenphp.WithWorkerEnv(map[string]string{"FRANKENPHP_WORKER_BACKGROUND": "1"}), + ), + 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, "web http", serverGet(t, server, "http://example.com/worker-name.php")) + + flag := requireFileContentEventually(t, sentinel) + assert.Contains(t, flag, "'worker' => 'jobs'") + assert.Contains(t, flag, "'background' => 'set'") +} + +// TestBackgroundWorkerPool checks that num > 1 threads share the name, each +// parks on its own handle, 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") + } +} + +// TestBackgroundWorkerMultiEntrypoint checks that two named background +// workers of one server may share a script, since 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") +} + +// TestBackgroundWorkerThreadsComeOnTop checks that background threads are +// reserved on top of num_threads: one HTTP thread plus one background worker +// starts with num_threads 1. +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") +} + +// TestBackgroundWorkerBootFailuresThenSucceeds checks that 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") +} + +// TestBackgroundWorkerCrashAfterReadyRestarts checks that a crash after the +// ready point restarts right away without counting toward +// max_consecutive_failures, and that a zero-timeout stream_select() counts +// as the wait. +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")) >= 4 + }, 5*time.Second, 25*time.Millisecond, "the worker was not restarted after crashing past its ready point") +} + +// TestBackgroundWorkerRebootForceKillsStuckScript checks that a script +// ignoring its handle does not stall RestartWorkers() past the reboot grace +// period: the force-kill ends it and the next run parks normally. +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") +} diff --git a/caddy/app.go b/caddy/app.go index fcee129180..88636401c4 100644 --- a/caddy/app.go +++ b/caddy/app.go @@ -17,7 +17,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 ( @@ -60,15 +59,14 @@ type FrankenPHPApp struct { // EXPERIMENTAL: MaxRequests sets the maximum number of requests a PHP thread handles before restarting (0 = unlimited) MaxRequests int `json:"max_requests,omitempty"` - opts []frankenphp.Option - metrics frankenphp.Metrics - ctx context.Context - logger *slog.Logger - modules []*FrankenPHPModule - usedWorkerNames map[string]bool - httpApp *caddyhttp.App - hasStarted atomic.Bool - started chan any + opts []frankenphp.Option + metrics frankenphp.Metrics + ctx context.Context + logger *slog.Logger + modules []*FrankenPHPModule + httpApp *caddyhttp.App + hasStarted atomic.Bool + started chan any } var errIni = errors.New(`"php_ini" must be in the format: php_ini "" ""`) @@ -133,7 +131,6 @@ func (f *FrankenPHPApp) Start() error { // register global workers for _, w := range f.Workers { w.FileName = repl.ReplaceKnown(w.FileName, "") - w.Name = f.createUniqueWorkerName(w, "") opts, err := w.toWorkerOptions() if err != nil { return err @@ -224,7 +221,6 @@ func (f *FrankenPHPApp) registerModule(repl *caddy.Replacer, module *FrankenPHPM for _, w := range module.Workers { w.FileName = repl.ReplaceKnown(w.FileName, "") - w.Name = f.createUniqueWorkerName(w, serverName) workerOptions, err := w.toWorkerOptions() if err != nil { return err @@ -236,37 +232,6 @@ func (f *FrankenPHPApp) registerModule(repl *caddy.Replacer, module *FrankenPHPM return 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 (f *FrankenPHPApp) createUniqueWorkerName(wc workerConfig, serverName string) string { - if f.usedWorkerNames == nil { - f.usedWorkerNames = make(map[string]bool) - } - - if wc.Name == "" { - wc.Name, _ = fastabs.FastAbs(wc.FileName) - } - - name := wc.Name - suffix := 0 - for { - if _, ok := f.usedWorkerNames[name]; !ok { - f.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() { @@ -381,10 +346,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 b7d6c231eb..29004d10b7 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -757,6 +757,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) @@ -839,7 +878,7 @@ func TestWorkerMetrics(t *testing.T) { # TYPE frankenphp_worker_request_count counter frankenphp_worker_request_count{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, frankenphp_get_worker_handle for background workers # TYPE frankenphp_ready_workers gauge frankenphp_ready_workers{worker="` + workerName + `"} 2 ` @@ -996,7 +1035,7 @@ func TestNamedWorkerMetrics(t *testing.T) { # TYPE frankenphp_worker_request_count counter frankenphp_worker_request_count{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, frankenphp_get_worker_handle for background workers # TYPE frankenphp_ready_workers gauge frankenphp_ready_workers{worker="my_app"} 2 ` @@ -1092,7 +1131,7 @@ func TestAutoWorkerConfig(t *testing.T) { # TYPE frankenphp_worker_request_count counter frankenphp_worker_request_count{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, frankenphp_get_worker_handle for background workers # TYPE frankenphp_ready_workers gauge frankenphp_ready_workers{worker="` + workerName + `"} ` + workers + ` ` @@ -1460,7 +1499,7 @@ func TestMultiWorkersMetrics(t *testing.T) { # TYPE frankenphp_worker_request_count counter frankenphp_worker_request_count{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, frankenphp_get_worker_handle for background workers # TYPE frankenphp_ready_workers gauge frankenphp_ready_workers{worker="service1"} 2 frankenphp_ready_workers{worker="service2"} 3 @@ -1614,7 +1653,7 @@ 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, frankenphp_get_worker_handle for background workers # TYPE frankenphp_ready_workers gauge frankenphp_ready_workers{worker="service"} 1 # HELP frankenphp_total_workers Total number of PHP workers for this worker @@ -1642,7 +1681,7 @@ 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, frankenphp_get_worker_handle for background workers # TYPE frankenphp_ready_workers gauge frankenphp_ready_workers{worker="service"} 1 # HELP frankenphp_total_workers Total number of PHP workers for this worker @@ -2113,7 +2152,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 607051cbd8..d71b307bb4 100644 --- a/caddy/config_test.go +++ b/caddy/config_test.go @@ -1,7 +1,6 @@ package caddy import ( - "path/filepath" "testing" "time" @@ -81,6 +80,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 := ` @@ -249,38 +287,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) { - app := &FrankenPHPApp{} - filename := "../testdata/worker-with-env.php" - absFileName, _ := filepath.Abs(filename) - names := make([]string, 6) - for i := range 3 { - names[i] = app.createUniqueWorkerName(workerConfig{ - FileName: filename, - Name: "custom-worker-name", - }, "") - names[i+3] = app.createUniqueWorkerName(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 TestWorkerBackgroundConfig(t *testing.T) { + d := caddyfile.NewTestDispenser(` + { + php_server { + worker { + name jobs + file ../testdata/worker-with-env.php + num 2 + background + } + } + }`) + module := &FrankenPHPModule{} + + 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) { - app := &FrankenPHPApp{} - wc := workerConfig{FileName: "../testdata/worker-with-env.php", Name: "queue"} - - require.Equal(t, "queue", app.createUniqueWorkerName(wc, "one.example.com")) - // on collision, the name is qualified with the server name - require.Equal(t, "two.example.com:queue", app.createUniqueWorkerName(wc, "two.example.com")) - // when the qualified name is also taken, fall back to the numeric postfix - require.Equal(t, "queue_1", app.createUniqueWorkerName(wc, "two.example.com")) - // workers without a server keep the numeric postfix behavior - require.Equal(t, "queue_2", app.createUniqueWorkerName(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 TestWorkerBackgroundRequiresNum(t *testing.T) { + d := caddyfile.NewTestDispenser(` + { + php_server { + worker { + name jobs + file ../testdata/worker-with-env.php + background + } + } + }`) + module := &FrankenPHPModule{} + + err := module.UnmarshalCaddyfile(d) + require.ErrorContains(t, err, `background workers must declare "num" >= 1`) +} + +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/workerconfig.go b/caddy/workerconfig.go index b39eb731e0..7f5c15b253 100644 --- a/caddy/workerconfig.go +++ b/caddy/workerconfig.go @@ -22,7 +22,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 +38,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"` + // Background marks this worker as a background (non-HTTP) worker. + Background bool `json:"background,omitempty"` options []frankenphp.WorkerOption } @@ -139,8 +141,10 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) { } wc.MaxConsecutiveFailures = v + 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", v) } } @@ -148,6 +152,18 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) { return wc, d.Err(`the "file" argument must be specified`) } + 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.Num < 1 { + return wc, d.Err(`background workers must declare "num" >= 1`) + } + } + if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) { wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName) } @@ -166,6 +182,10 @@ 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()) + } + // 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 3c1492fbd8..4f05ae4dc5 100644 --- a/cgi.go +++ b/cgi.go @@ -225,7 +225,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 bed3b36e14..c9396cf07e 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, } @@ -162,11 +156,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 } @@ -174,9 +163,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 281f05dc75..1a009c49b7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -109,8 +109,9 @@ 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. 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 and exposed as $_SERVER['FRANKENPHP_WORKER']. Must be unique among global workers. Default: absolute path of the 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. + background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker; it runs the script in a loop without serving requests, "name" is required, "match" is not allowed. The script is ready once it waits on the stream returned by frankenphp_get_worker_handle() (stream_select() or a blocking read); an exit before that counts as a failure. $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] is set in it, test its presence rather than its value. Its threads come on top of num_threads and max_threads, and max_threads is not allowed on it. } } } @@ -187,17 +188,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. + name # Sets the name for the worker, used in logs and metrics and exposed as $_SERVER['FRANKENPHP_WORKER']. Must be unique within this php_server. In logs and metrics, the worker is reported as ":". Default: absolute path of the worker file. 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; it runs the script in a loop without serving requests, "name" is required, "match" is not allowed. The script is ready once it waits on the stream returned by frankenphp_get_worker_handle() (stream_select() or a blocking read); an exit before that counts as a failure. $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] is set in it, test its presence rather than its value. Its threads come on top of num_threads and max_threads, and max_threads is not allowed on it. } worker # Can also use the short form like in the global frankenphp block. } diff --git a/docs/library.md b/docs/library.md index 7782f03182..03f0ef5898 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, while metrics and logs report a server-scoped worker as `:`; 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 932707265a..6239ae8829 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -19,12 +19,12 @@ When [Caddy metrics](https://caddyserver.com/docs/metrics) are enabled, FrankenP - `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_ready_workers{worker="[worker_name]"}`: The number of workers that have reached their ready point at least once: `frankenphp_handle_request()` for HTTP workers, `frankenphp_get_worker_handle()` 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. -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. Workers of a `php_server` block are prefixed with the name of that block: `:`. They used to be reported under their bare name unless two blocks declared the same one, so dashboards and alerts built on those series need the prefix. ## Threads State Endpoint diff --git a/docs/worker.md b/docs/worker.md index 466c7cf684..b9fe70674a 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -199,6 +199,44 @@ frankenphp { } ``` +## 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` must be at least 1: + +```caddyfile +php_server { + worker { + file jobs.php + num 1 + name jobs + background + } +} +``` + +The script must wait on the stream returned by `frankenphp_get_worker_handle()`, which reaches EOF when FrankenPHP drains the worker on shutdown, reboot or restart. The first wait on it marks the worker ready: the server start waits for that point, and an exit before it counts as a failure. Polling `feof()` is not a wait, block in `stream_select()` or in a read: + +```php + 0) { + // drained: return, FrankenPHP re-runs or stops the script + break; + } + + doSomeWork(); +} +``` + +`$_SERVER['FRANKENPHP_WORKER']` holds the declared name, as it does in HTTP workers, and `$_SERVER['FRANKENPHP_WORKER_BACKGROUND']` is set, so a script serving both roles can tell them apart with `isset()`. Its value is unspecified, only its presence is part of the contract. `FRANKENPHP_WORKER` used to hold `1` in HTTP workers for the same reason: a script comparing it to that value must test its presence instead. 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 732dcfbdf5..e8d800f4e4 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -13,11 +13,14 @@ #include #ifdef PHP_WIN32 #include +#include #else #include #endif +#include
#include #include +#include #include #include #include @@ -28,6 +31,7 @@ #include #include #ifndef PHP_WIN32 +#include #include #endif #if defined(__linux__) @@ -126,6 +130,18 @@ 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 frankenphp_get_worker_handle(); [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 on the first wait on the handle of the current run, see + * frankenphp_worker_handle_ops */ +static THREAD_LOCAL bool worker_handle_waited = false; +/* the stream of the current run, see frankenphp_get_worker_handle(); the + * cache holds a ref, and the resource list of the run frees it at request + * shutdown, so the pointer is only reset, never released, between runs */ +static THREAD_LOCAL zend_resource *worker_handle_res = NULL; static THREAD_LOCAL HashTable *sandboxed_env = NULL; /* prepared_env holds entries from php(_server)'s `env KEY VAL`, exposed to * getenv() and merged into $_ENV when 'E' is in variables_order. Separate from @@ -342,7 +358,137 @@ void frankenphp_release_thread_for_kill(force_kill_slot slot) { #endif } +/* Stop channel of background workers: a socket pair. One end is exposed to + * the PHP script via frankenphp_get_worker_handle(), the other is handed to + * the Go side, which closes it on drain so the script's end reaches EOF and + * a stream_select() or a blocking read on it returns. A socket pair rather + * than a pipe because on Windows PHP's 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; + } +#ifdef PHP_WIN32 + closesocket(s); +#else + close(s); +#endif +} + +/* keep the pair out of processes the script may spawn: a child holding the + * Go side's 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; + } +} + +static int frankenphp_worker_open_stop_pair(void) { +#ifdef PHP_WIN32 + /* PHP's emulation, a loopback TCP pair; it only accepts AF_INET, listens + * on INADDR_ANY and accepts the first peer, so check the pair is ours */ + if (socketpair(AF_INET, SOCK_STREAM, 0, worker_stop_socks) != 0) { + worker_stop_socks[0] = SOCK_ERR; + worker_stop_socks[1] = SOCK_ERR; + + return -1; + } + + struct sockaddr_in peer = {0}, local = {0}; + int peer_len = sizeof(peer), local_len = sizeof(local); + if (getpeername(worker_stop_socks[0], (struct sockaddr *)&peer, &peer_len) != + 0 || + getsockname(worker_stop_socks[1], (struct sockaddr *)&local, + &local_len) != 0 || + peer.sin_port != local.sin_port || + peer.sin_addr.s_addr != local.sin_addr.s_addr) { + frankenphp_worker_close_stop_socks(); + + 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 is disarmed after php_request_startup() + * re-arms it, see php_thread(). */ +intptr_t frankenphp_set_background_worker_and_get_stop_sock(void) { + is_background_worker = true; + worker_handle_waited = false; + worker_handle_res = NULL; + + frankenphp_worker_close_stop_socks(); + if (frankenphp_worker_open_stop_pair() != 0) { + return -1; + } + + 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; + } + frankenphp_worker_close_sock((php_socket_t)s); +} + +/* Sets max_execution_time to 0 for the current request, which also disarms + * the timer through OnUpdateTimeout(). */ +static void frankenphp_disable_execution_timeout(void) { + zend_string *key = zend_string_init("max_execution_time", + sizeof("max_execution_time") - 1, 0); + zend_string *value = zend_string_init("0", 1, 0); + zend_alter_ini_entry(key, value, PHP_INI_USER, PHP_INI_STAGE_RUNTIME); + zend_string_release(key); + zend_string_release(value); +} + void frankenphp_update_local_thread_context(bool is_worker) { + /* A thread that ran a background worker can be recycled into an HTTP + * worker or a regular request thread: reset the bg TLS so + * frankenphp_get_worker_handle() rejects callers again, and release the + * stop socket. The streams handed out by frankenphp_get_worker_handle() + * do not own it and were destroyed by request shutdown. */ + if (is_background_worker) { + is_background_worker = false; + worker_handle_res = NULL; + frankenphp_worker_close_stop_socks(); + } + is_worker_thread = is_worker; /* workers should keep running if the user aborts the connection */ @@ -847,6 +993,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(); @@ -1007,6 +1162,100 @@ PHP_FUNCTION(frankenphp_log) { } } +/* Ops of the streams returned by frankenphp_get_worker_handle(): the socket + * ops, except that the first wait on the handle, a select cast or a read, + * reports the worker ready, and 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. Waiting is the + * background analog of an HTTP worker reaching frankenphp_handle_request(): + * it comes after the script's bootstrap by construction, where merely + * fetching the handle does not. Initialized in MINIT. */ +static php_stream_ops frankenphp_worker_handle_ops; + +static void frankenphp_worker_handle_waited(void) { + if (!worker_handle_waited) { + worker_handle_waited = true; + go_frankenphp_background_worker_ready(frankenphp_thread_index()); + } +} + +static ssize_t frankenphp_worker_handle_read(php_stream *stream, char *buf, + size_t count) { + frankenphp_worker_handle_waited(); + + return php_stream_socket_ops.read(stream, buf, count); +} + +static int frankenphp_worker_handle_cast(php_stream *stream, int castas, + void **ret) { + if (castas == PHP_STREAM_AS_FD_FOR_SELECT) { + frankenphp_worker_handle_waited(); + } + + return php_stream_socket_ops.cast(stream, castas, ret); +} + +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); +} + +PHP_FUNCTION(frankenphp_get_worker_handle) { + ZEND_PARSE_PARAMETERS_NONE(); + + if (!is_background_worker) { + zend_throw_exception(spl_ce_RuntimeException, + "frankenphp_get_worker_handle() can only be called " + "from a background worker", + 0); + RETURN_THROWS(); + } + + if (worker_stop_socks[0] == SOCK_ERR) { + zend_throw_exception(spl_ce_RuntimeException, + "the background worker stop socket is not available", + 0); + RETURN_THROWS(); + } + + /* One stream per run: the same resource is returned until the script + * closes it, so fetching the handle 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 it never affects a later one + * and the EOF of a drain reaches all. */ + if (worker_handle_res != NULL) { + if (worker_handle_res->type == php_file_le_stream()) { + GC_ADDREF(worker_handle_res); + RETURN_RES(worker_handle_res); + } + /* closed by the script: drop the cache's ref */ + zend_list_delete(worker_handle_res); + worker_handle_res = 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 */ + ((php_netstream_data_t *)stream->abstract)->timeout.tv_sec = -1; + + /* report the worker ready on its first wait on the stream */ + stream->ops = &frankenphp_worker_handle_ops; + + php_stream_to_zval(stream, return_value); + worker_handle_res = Z_RES_P(return_value); + GC_ADDREF(worker_handle_res); +} + /* {{{ thread-safe opcache reset */ PHP_FUNCTION(frankenphp_opcache_reset) { go_schedule_opcache_reset(frankenphp_thread_index()); @@ -1065,6 +1314,12 @@ 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.read = frankenphp_worker_handle_read; + frankenphp_worker_handle_ops.cast = frankenphp_worker_handle_cast; + frankenphp_worker_handle_ops.close = frankenphp_worker_handle_close; + register_frankenphp_symbols(module_number); #ifndef PHP_WIN32 /* MINIT runs once per ZTS thread — guard the atfork registration */ @@ -1530,6 +1785,16 @@ static void *php_thread(void *arg) { frankenphp_override_opcache_reset(); #endif + /* A background worker runs for the lifetime of its thread, so it has + * no execution limit, like the CLI SAPI. Disarming the timer is not + * enough: php_execute_script() re-arms it from the ini right before + * running the script whenever max_input_time is set, so change the + * setting itself. Request shutdown restores it, and the next run + * applies it again. */ + if (is_background_worker) { + frankenphp_disable_execution_timeout(); + } + zend_file_handle file_handle; zend_stream_init_filename(&file_handle, scriptName); @@ -1601,6 +1866,15 @@ static void *php_thread(void *arg) { } zend_end_try(); + /* The stop socket of a background worker is plain thread-local state that + * frankenphp_update_local_thread_context() only releases on recycle: close + * it here too so it does not outlive the thread on shutdown, reboot or an + * unhealthy exit. The Go side's end is closed by the Go side. */ + if (is_background_worker) { + is_background_worker = false; + frankenphp_worker_close_stop_socks(); + } + /* 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 d8a8c2cf61..2c76c80d0f 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -216,26 +216,42 @@ 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 < 1 { + name := w.name + if name == "" { + name = w.fileName + } + + return 0, 0, fmt.Errorf("background worker %q must declare num >= 1", name) + } + backgroundThreads += w.num + + continue + } + if w.num <= 0 { // https://github.com/php/frankenphp/issues/126 opt.workers[i].num = maxProcs } - metrics.TotalWorkers(w.name, w.num) numWorkers += opt.workers[i].num 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 @@ -259,19 +275,19 @@ func calculateMaxThreads(opt *opt) (numWorkers int, _ error) { if numThreadsIsSet && !maxThreadsIsSet { opt.maxThreads = opt.numThreads if opt.numThreads <= numWorkers { - return 0, fmt.Errorf("num_threads (%d) must be greater than the number of worker threads (%d)", opt.numThreads, numWorkers) + return 0, 0, fmt.Errorf("num_threads (%d) must be greater than the number of worker threads (%d)", opt.numThreads, numWorkers) } - 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 { @@ -283,19 +299,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 opt.numThreads <= numWorkers { - return 0, fmt.Errorf("num_threads (%d) must be greater than the number of worker threads (%d)", opt.numThreads, numWorkers) + return 0, 0, fmt.Errorf("num_threads (%d) must be greater than the number of worker threads (%d)", opt.numThreads, numWorkers) } if !maxThreadsIsAuto && 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 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 } // Init starts the PHP runtime and the configured workers. @@ -344,13 +360,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) + // 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() @@ -368,13 +386,23 @@ 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`) } } - mainThread, err := initPHPThreads(opt.numThreads, opt.maxThreads, opt.phpIni) + maxThreads := opt.maxThreads + if maxThreads > 0 { + // in auto mode (maxThreads < 0), the resolved value is floored to the + // thread count, background threads included + maxThreads += backgroundThreads + } + mainThread, err := initPHPThreads(opt.numThreads+backgroundThreads, maxThreads, opt.phpIni) if err != nil { shutdown() return err @@ -528,7 +556,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 @@ -872,8 +900,6 @@ func resetGlobals() { globalCtx = context.Background() globalLogger = slog.Default() workers = nil - workersByName = nil - globalWorkersByPath = nil servers = nil watcherIsEnabled = false maxIdleTime = defaultMaxIdleTime diff --git a/frankenphp.h b/frankenphp.h index 99ac0ab7ec..e5612ee714 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -201,6 +201,10 @@ size_t frankenphp_get_thread_memory_usage(uintptr_t thread_index); void frankenphp_force_kill_thread(force_kill_slot slot); void frankenphp_release_thread_for_kill(force_kill_slot slot); +/* 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); #endif diff --git a/frankenphp.stub.php b/frankenphp.stub.php index d6c85aa05f..bf1587cc64 100644 --- a/frankenphp.stub.php +++ b/frankenphp.stub.php @@ -54,3 +54,16 @@ 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 stop-signal stream for the current background worker. The + * stream reaches EOF when FrankenPHP drains the worker, so the script can + * park on stream_select() and exit its loop gracefully. Every call of a run + * returns the same stream, a fresh one over the same socket once the script + * closed it. The worker counts as ready, and its startup as successful, once + * it waits on the stream (stream_select() or a blocking read). Only callable + * from inside a background worker. + * + * @return resource + */ +function frankenphp_get_worker_handle() {} diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index 4f2707cbca..8223be08ba 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: 60f0d27c04f94d7b24c052e91ef294595a2bc421 */ +/* This is a generated file, edit frankenphp.stub.php instead. + * Stub hash: 2f36fc81e0981975adabf170febaaa863653817d */ 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,8 @@ 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_frankenphp_get_worker_handle, 0, 0, 0) +ZEND_END_ARG_INFO() ZEND_FUNCTION(frankenphp_handle_request); ZEND_FUNCTION(headers_send); @@ -49,7 +51,7 @@ ZEND_FUNCTION(frankenphp_request_headers); ZEND_FUNCTION(frankenphp_response_headers); ZEND_FUNCTION(mercure_publish); ZEND_FUNCTION(frankenphp_log); - +ZEND_FUNCTION(frankenphp_get_worker_handle); static const zend_function_entry ext_functions[] = { ZEND_FE(frankenphp_handle_request, arginfo_frankenphp_handle_request) @@ -63,6 +65,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_worker_handle, arginfo_frankenphp_get_worker_handle) ZEND_FE_END }; diff --git a/metrics.go b/metrics.go index fc25816506..d51a3726a8 100644 --- a/metrics.go +++ b/metrics.go @@ -11,7 +11,7 @@ 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 frankenphp_get_worker_handle for background workers ) type StopReason int @@ -144,7 +144,7 @@ func (m *PrometheusMetrics) StopWorker(name string, reason StopReason) { m.totalWorkers.WithLabelValues(name).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() } @@ -177,7 +177,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, frankenphp_get_worker_handle for background workers", }, basicLabels) m.mustRegister(m.readyWorkers) } diff --git a/metrics_test.go b/metrics_test.go index 846a926569..e5ca5e3c71 100644 --- a/metrics_test.go +++ b/metrics_test.go @@ -185,7 +185,7 @@ func TestPrometheusMetrics_TestStopReasonCrash(t *testing.T) { 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, frankenphp_get_worker_handle for background workers # TYPE frankenphp_ready_workers gauge `, expect: ` diff --git a/options.go b/options.go index e1eaeb7b55..930eb5d182 100644 --- a/options.go +++ b/options.go @@ -57,6 +57,7 @@ type workerOpt struct { onServerStartup func() onServerShutdown func() server *Server + isBackgroundWorker bool } // WithContext sets the main context to use. @@ -239,6 +240,20 @@ func WithWorkerServerScope(s *Server) WorkerOption { } } +// EXPERIMENTAL: WithWorkerBackground marks this worker as a background +// (non-HTTP) worker. Background workers run outside the request cycle: +// they share the PHP runtime with HTTP threads but never receive HTTP +// requests. The script can park on the stream returned by +// frankenphp_get_worker_handle(), 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 + } +} + // 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 5e19c0fc75..0878762341 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -161,6 +161,10 @@ func (mainThread *phpMainThread) rebootAllThreads() bool { for _, thread := range rebootingThreads { rebootWg.Go(func() { + // wake up handlers parked in a blocking C call (background + // workers' stream_select on the stop socket) so they can yield + // for the reboot without waiting for the force-kill below + thread.handler.drain() close(thread.drainChan) if thread.state.WaitForStateWithTimeout(rebootGracePeriod, state.YieldingForReboot) { return diff --git a/phpmainthread_test.go b/phpmainthread_test.go index 3ae65e68b9..08212466d9 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -252,28 +252,24 @@ 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)) + w2, err := newWorker(workerOpt{fileName: testDataPath + "/index.php", name: "other"}) + assert.NoError(t, err) + assert.ErrorContains(t, fallbackServer.addWorker(w2), "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)) + w2, err := newWorker(workerOpt{fileName: testDataPath + "/hello.php", name: "workername"}) + assert.NoError(t, err) + assert.ErrorContains(t, fallbackServer.addWorker(w2), "two global workers cannot have the same name") } func getDummyWorker(t *testing.T, fileName string) *worker { @@ -315,9 +311,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, } } @@ -371,7 +367,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") @@ -380,7 +376,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") } @@ -389,7 +385,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") @@ -398,7 +395,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 325996195d..f1d2f91f46 100644 --- a/phpthread.go +++ b/phpthread.go @@ -39,11 +39,10 @@ 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. + // drain is a hook called right before drainChan is closed on shutdown + // and reboot. Handlers that need to wake up a thread parked in a + // blocking C call (background workers' stream_select on the stop socket) + // plug their signal in here; the other handlers are no-ops. drain() } @@ -119,6 +118,9 @@ func (thread *phpThread) shutdown() { return } + // wake up handlers parked in a blocking C call (background workers' + // stream_select on the stop socket); no-op for the other handlers + thread.handler.drain() close(thread.drainChan) // Arm force-kill after the grace period to wake any thread stuck in @@ -157,6 +159,9 @@ func (thread *phpThread) setHandler(handler threadHandler) { return } + // wake up a handler parked in a blocking C call (background workers' + // stream_select on the stop socket) so it can yield for the transition + thread.handler.drain() close(thread.drainChan) thread.state.WaitFor(state.TransitionInProgress) diff --git a/requestoptions.go b/requestoptions.go index 962727562f..00c5892f05 100644 --- a/requestoptions.go +++ b/requestoptions.go @@ -2,6 +2,7 @@ package frankenphp import ( "errors" + "fmt" "log/slog" "net/http" "path/filepath" @@ -206,12 +207,22 @@ func WithRequestBodyTimeout(timeout time.Duration) RequestOption { } // 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 d2784a8eeb..a702ea8241 100644 --- a/scaling_test.go +++ b/scaling_test.go @@ -48,7 +48,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 8274f23c54..332eeafa5f 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,36 @@ func registerServers(newServers []*Server) { fallbackServer.logger = globalLogger fallbackServer.resetWorkers() + // several servers may resolve to the same name (e.g. the same host), but + // the name qualifies worker names in metrics and logs, so it must be + // unique: the first server keeps a 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 +109,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 +125,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 +151,40 @@ 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 +// scope names the worker set in errors: a server, or the global workers +func (s *Server) scope() string { + if s == fallbackServer { + return "two global workers" + } + + return "two workers in a server" +} + func (s *Server) addWorker(w *worker) error { - s.workers = append(s.workers, w) + if s.workersByName[w.name] != nil { + return fmt.Errorf("%s cannot have the same name: %q", s.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", s.scope(), w.fileName) } s.workersByPath[w.fileName] = w diff --git a/server_test.go b/server_test.go index f297db7c29..4bd078040c 100644 --- a/server_test.go +++ b/server_test.go @@ -59,12 +59,59 @@ 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("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 @@ $_SERVER['FRANKENPHP_WORKER'] ?? null, + 'background' => isset($_SERVER['FRANKENPHP_WORKER_BACKGROUND']) ? 'set' : 'unset', +], true)); +$stream = frankenphp_get_worker_handle(); +$read = [$stream]; +$write = null; +$except = null; +stream_select($read, $write, $except, null); diff --git a/testdata/bgworker/named.php b/testdata/bgworker/named.php new file mode 100644 index 0000000000..4b12f88961 --- /dev/null +++ b/testdata/bgworker/named.php @@ -0,0 +1,19 @@ + 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))); +$stream = frankenphp_get_worker_handle(); +$read = [$stream]; +$write = null; +$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..c57e38b341 --- /dev/null +++ b/testdata/bgworker/read.php @@ -0,0 +1,11 @@ +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 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 startupFailChan != nil { + startupFailChan <- 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() + } + } +} + +// a background worker counts nothing per run of the thread +func (handler *backgroundWorkerThread) resetForReboot() {} + +// 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.dummyFrankenPHPContext = fc + + handler.isBootingScript = true + metrics.StartWorker(handler.worker.qualifiedName) + handler.bootTimer = time.AfterFunc(backgroundBootWarnDelay, func() { + if globalLogger.Enabled(globalCtx, slog.LevelWarn) { + globalLogger.LogAttrs(globalCtx, slog.LevelWarn, "background worker has not waited on its handle yet, Init() and Shutdown() wait for it, see frankenphp_get_worker_handle()", slog.String("worker", handler.worker.qualifiedName), slog.Int("thread", handler.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 waits on its + // handle, see go_frankenphp_background_worker_ready + + return nil +} + +func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { + // the Go side's end of the stop socket pair belongs to this thread; + // release it 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.dummyFrankenPHPContext = nil + + handler.stopBootTimer() + handler.state.MarkAsWaiting(false) + + // cooperative exit: the script waited on its handle and returned cleanly, + // re-run it, unless the thread is being drained (beforeScriptExecution + // checks the state) + if exitStatus == 0 && !handler.isBootingScript { + handler.crashCount = 0 + metrics.StopWorker(worker.qualifiedName, 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)) + } + + return + } + + // crash after the ready point: restart without counting toward + // max_consecutive_failures, that cap is about a script that never boots. + // The wait still applies: unlike an HTTP worker, which can only crash + // after a request reached frankenphp_handle_request() and is therefore + // paced by traffic, a background worker reaches its ready point on its + // own and a script crashing right after it would spin + if !handler.isBootingScript { + metrics.StopWorker(worker.qualifiedName, 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)) + } + + time.Sleep(restartBackoff(handler.crashCount)) + handler.crashCount++ + + return + } + + // boot failure: the script exited before waiting on its handle, a clean + // exit included, which would otherwise respawn in a tight loop. + // StopReasonBootFailure skips the ready-gauge decrement, matching the + // ReadyWorker call that never happened + metrics.StopWorker(worker.qualifiedName, StopReasonBootFailure) + + // max_consecutive_failures only fails hard during startup, where it + // surfaces on startupFailChan so Init() returns the error to the + // operator. Past startup, a failing background worker keeps + // restarting with a louder log line: silently giving up would leave + // the server in a broken half-state with no clear way to recover. + pastCap := worker.maxConsecutiveFailures >= 0 && handler.failureCount >= worker.maxConsecutiveFailures + if pastCap && startupFailChan != nil && !watcherIsEnabled { + if exitStatus == 0 { + startupFailChan <- fmt.Errorf("background worker %s exits without waiting on its handle, see frankenphp_get_worker_handle()", worker.fileName) + } else { + startupFailChan <- fmt.Errorf("too many consecutive failures: background worker %s keeps crashing", worker.fileName) + } + handler.thread.state.Set(state.ShuttingDown) + return + } + + logLevel := slog.LevelWarn + logMsg := "background worker failed before waiting on its handle, restarting" + if exitStatus == 0 { + logMsg = "background worker exited without waiting on its handle, 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 on the first wait on the handle; the handler + // is a backgroundWorkerThread because frankenphp_get_worker_handle() + // throws on every other thread kind + if handler, ok := phpThreads[threadIndex].handler.(*backgroundWorkerThread); ok && handler.isBootingScript { + handler.isBootingScript = false + // the boot succeeded, only consecutive boot failures count + handler.failureCount = 0 + handler.stopBootTimer() + metrics.ReadyWorker(handler.worker.qualifiedName) + // parked from now on as far as the threads state endpoint is concerned + handler.state.MarkAsWaiting(true) + + // like an HTTP worker reaching frankenphp_handle_request(), the thread + // is ready only now: initWorkers() waits for this state, so a script + // that fails before waiting on its handle 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() { + time.Sleep(restartBackoff(handler.failureCount)) + handler.failureCount++ +} diff --git a/threadworker.go b/threadworker.go index 8a308b4d87..233a58a65a 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,23 @@ 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) - } + return handler.workerLifecycle.beforeScriptExecution(handler) +} - setupWorkerScript(handler, handler.worker) +// startScript runs the worker script; it always has one to run +func (handler *workerThread) startScript() string { + setupWorkerScript(handler, handler.worker) - 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) + return handler.worker.fileName +} - // signal to stop - return "" - default: - panic("unexpected state: " + handler.state.Name()) - } +func (handler *workerThread) resetForReboot() { + handler.requestCount = 0 } func (handler *workerThread) afterScriptExecution(exitStatus int) { @@ -90,7 +63,7 @@ func (handler *workerThread) name() string { func (handler *workerThread) drain() {} func setupWorkerScript(handler *workerThread, worker *worker) { - metrics.StartWorker(worker.name) + metrics.StartWorker(worker.qualifiedName) // Create a dummy request to set up the worker fc, err := newWorkerDummyContext(worker) @@ -103,7 +76,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 +95,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.qualifiedName, 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 +106,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.qualifiedName, StopReasonBootFailure) } else { - metrics.StopWorker(worker.name, StopReasonCrash) + metrics.StopWorker(worker.qualifiedName, StopReasonCrash) } if !handler.isBootingScript { @@ -143,7 +116,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.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 @@ -158,23 +131,26 @@ 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) +} + +// 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 +func restartBackoff(failures int) time.Duration { + return min(time.Duration(failures*failures*100)*time.Millisecond, time.Second) } // waitForWorkerRequest is called during frankenphp_handle_request in the php worker script. @@ -183,7 +159,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 +171,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.qualifiedName) } // 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 +199,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 +215,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 +274,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/worker.go b/worker.go index 388dfbd031..9824edc934 100644 --- a/worker.go +++ b/worker.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "sync" "sync/atomic" "time" @@ -20,7 +21,11 @@ import ( type worker struct { mercureContext - name string + // name as declared, unique within its server (or among global workers) + name string + // qualifiedName is unique across the process: ":" for + // server-scoped workers, name otherwise; used for metrics and logs + qualifiedName string fileName string matchRequest func(*http.Request) bool num int @@ -34,14 +39,14 @@ type worker struct { onThreadShutdown func(int) queuedRequests atomic.Int32 server *Server + // isBackgroundWorker marks this as a background (non-HTTP) worker + 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 ) func initWorkers(opts []workerOpt) error { @@ -55,8 +60,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) @@ -64,14 +68,27 @@ func initWorkers(opts []workerOpt) error { return err } - totalThreadsToStart += w.num - workers = append(workers, w) - workersByName[w.name] = w - if w.server == nil { - globalWorkersByPath[w.fileName] = w - } else if err := w.server.addWorker(w); err != nil { + if w.server != fallbackServer && !slices.Contains(servers, w.server) { + return fmt.Errorf("worker %q is scoped to a server that was not passed to WithServer()", w.name) + } + + // names and paths are unique within a server + if err := w.server.addWorker(w); err != nil { return err } + + // scoping makes qualified names unique in all but pathological + // cases: a global worker may still be named like the ":" + // of a scoped one, and metrics would merge the two series + if qualifiedNames[w.qualifiedName] { + return fmt.Errorf("two workers cannot report under the same name: %q", w.qualifiedName) + } + qualifiedNames[w.qualifiedName] = true + + totalThreadsToStart += w.num + workers = append(workers, w) + // reported here rather than in calculateMaxThreads(), where the name is not resolved yet + metrics.TotalWorkers(w.qualifiedName, w.num) } startupFailChan = make(chan error, totalThreadsToStart) @@ -79,7 +96,11 @@ func initWorkers(opts []workerOpt) error { for _, w := range workers { for range w.num { thread := getInactivePHPThread() - convertToWorkerThread(thread, w) + if w.isBackgroundWorker { + convertToBackgroundWorkerThread(thread, w) + } else { + convertToWorkerThread(thread, w) + } workersReady.Go(func() { thread.state.WaitFor(state.Ready, state.ShuttingDown, state.Done) @@ -119,23 +140,38 @@ func newWorker(o workerOpt) (*worker, error) { return nil, fmt.Errorf("worker file not found %q: %w", absFileName, err) } + if o.isBackgroundWorker { + // the name is the script's identity (exposed via FRANKENPHP_WORKER); + // empty names are reserved for the catch-all workers of a future build + if o.name == "" { + return nil, fmt.Errorf("background worker %q must have an explicit name", o.fileName) + } + if o.matchRequest != nil { + return nil, fmt.Errorf("background worker %q cannot match requests", o.name) + } + if o.maxThreads > 0 { + return nil, fmt.Errorf("background worker %q cannot set max_threads, it does not autoscale", o.name) + } + // Workers.SendRequest() and SendMessage() dispatch on requestChan, + // which no background thread reads + if o.extensionWorkers != nil { + return nil, fmt.Errorf("background worker %q cannot be an extension worker, those handle requests", o.name) + } + } + if o.name == "" { o.name = absFileName } - if o.server == nil { - if globalWorkersByPath[absFileName] != nil { - return nil, fmt.Errorf("two global workers cannot have the same filename: %q", absFileName) - } - - // no server means no set of requests to match against, the matcher would never run - if o.matchRequest != nil { - return nil, fmt.Errorf("worker %q has a request matcher but no server scope, use WithWorkerServerScope()", 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 nil, fmt.Errorf("worker %q has a request matcher but no server scope, use WithWorkerServerScope()", o.name) } - if workersByName[o.name] != nil { - return nil, fmt.Errorf("two workers cannot have the same name: %q", o.name) + // 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 @@ -152,10 +188,23 @@ func newWorker(o workerOpt) (*worker, error) { } } - o.env["FRANKENPHP_WORKER\x00"] = "1" + // $_SERVER['FRANKENPHP_WORKER'] carries the worker name; scripts are + // documented to test its presence, not its value, so HTTP workers moving + // from "1" to their name breaks nothing. FRANKENPHP_WORKER_BACKGROUND + // tells a script it runs as a background worker, and is a presence-only + // flag for the same reason: its value is not part of the contract + o.env["FRANKENPHP_WORKER\x00"] = o.name + if o.isBackgroundWorker { + o.env["FRANKENPHP_WORKER_BACKGROUND\x00"] = "1" + } else { + // both are reserved: an env of the worker or of its server must not + // make an HTTP worker look like a background one + delete(o.env, "FRANKENPHP_WORKER_BACKGROUND\x00") + } w := &worker{ name: o.name, + qualifiedName: qualifiedName, fileName: absFileName, matchRequest: o.matchRequest, requestOptions: o.requestOptions, @@ -167,6 +216,14 @@ func newWorker(o workerOpt) (*worker, error) { onThreadReady: o.onThreadReady, onThreadShutdown: o.onThreadShutdown, server: o.server, + isBackgroundWorker: o.isBackgroundWorker, + } + + // a worker declared without a scope belongs to the fallback server, the + // one serving the requests that have no server either, so a worker + // always has one + if w.server == nil { + w.server = fallbackServer } w.configureMercure(&o) @@ -235,7 +292,7 @@ func (worker *worker) isAtThreadLimit() bool { } func (worker *worker) handleRequest(fc *frankenPHPContext) error { - metrics.StartWorkerRequest(worker.name) + metrics.StartWorkerRequest(worker.qualifiedName) runtime.Gosched() @@ -247,7 +304,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.qualifiedName, time.Since(fc.startedAt)) return nil default: @@ -259,7 +316,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.qualifiedName) for { workerScaleChan := scaleChan @@ -270,9 +327,9 @@ func (worker *worker) handleRequest(fc *frankenPHPContext) error { select { case worker.requestChan <- fc: worker.queuedRequests.Add(-1) - metrics.DequeuedWorkerRequest(worker.name) + metrics.DequeuedWorkerRequest(worker.qualifiedName) <-fc.done - metrics.StopWorkerRequest(worker.name, time.Since(fc.startedAt)) + metrics.StopWorkerRequest(worker.qualifiedName, time.Since(fc.startedAt)) return nil case workerScaleChan <- fc: @@ -280,8 +337,8 @@ func (worker *worker) handleRequest(fc *frankenPHPContext) error { 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.qualifiedName) + metrics.StopWorkerRequest(worker.qualifiedName, time.Since(fc.startedAt)) fc.reject(ErrMaxWaitTimeExceeded) diff --git a/workerextension.go b/workerextension.go index 814eea1565..0965afd0c8 100644 --- a/workerextension.go +++ b/workerextension.go @@ -26,12 +26,20 @@ 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), - ) + // the worker only exists between Init() and Shutdown() + if w.internalWorker == nil { + return ErrNotRunning + } + + opts := []RequestOption{WithOriginalRequest(r), WithWorkerName(w.name)} + + // worker names are resolved within a server, so a scoped worker is only + // reachable through its own server + if server := w.internalWorker.server; server != nil { + return server.ServeHTTP(rw, r, opts...) + } + fr, err := NewRequestWithContext(r, opts...) if err != nil { return err } @@ -40,11 +48,19 @@ func (w *extensionWorkers) SendRequest(rw http.ResponseWriter, r *http.Request) } 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) { + if w.internalWorker == nil { + 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..f8dfab0a23 100644 --- a/workerextension_test.go +++ b/workerextension_test.go @@ -77,6 +77,36 @@ 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 resolves the name within that server +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") +} + +// 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..b4b524b41b --- /dev/null +++ b/workerlifecycle.go @@ -0,0 +1,69 @@ +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 supply what differs, +// see workerHandler. +type workerLifecycle struct { + state *state.ThreadState + thread *phpThread + worker *worker +} + +// workerHandler is a threadHandler running a worker script, plus the two +// steps the shared lifecycle delegates +type workerHandler interface { + threadHandler + // startScript prepares a run and returns the script to execute, or an + // empty string to stop the thread + startScript() string + // resetForReboot clears what a handler counts per run of the thread + resetForReboot() +} + +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 +func (l *workerLifecycle) beforeScriptExecution(handler workerHandler) 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 handler.startScript() + case state.Rebooting, state.ForceRebooting: + return "" + case state.RebootReady: + handler.resetForReboot() + l.state.Set(state.Ready) + + return handler.beforeScriptExecution() + 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 c1bb3d42c2fd5126b82bb172ac5cac4cd9eed68e Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Fri, 11 Sep 2026 12:05:08 +0200 Subject: [PATCH 03/46] fix: a blocking receive on the handle marks a background worker ready stream_socket_recvfrom() and the other transport receives never reach the read op: they go through the stream's transport API, which the handle inherited unchanged from the socket ops. A script parking that way was therefore never reported ready and Init() waited for it forever. The set_option op is now wrapped too, reporting the wait on a receive. The new fixture hangs Init() without it. --- bgworker_test.go | 27 +++++++++++++++++++++++++++ frankenphp.c | 14 ++++++++++++++ testdata/bgworker/recv.php | 12 ++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 testdata/bgworker/recv.php diff --git a/bgworker_test.go b/bgworker_test.go index 8699f40f35..e48a582ea6 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -292,6 +292,33 @@ func TestBackgroundWorkerParksOnRead(t *testing.T) { } } +// TestBackgroundWorkerParksOnReceive checks that a blocking receive counts +// as a wait as well: it 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") + + 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") + } +} + // TestBackgroundWorkerRestartDrainsParkedScript checks that RestartWorkers() // wakes a parked background script through the drain and re-runs it. func TestBackgroundWorkerRestartDrainsParkedScript(t *testing.T) { diff --git a/frankenphp.c b/frankenphp.c index e8d800f4e4..5bfdbfcc48 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1195,6 +1195,19 @@ static int frankenphp_worker_handle_cast(php_stream *stream, int castas, return php_stream_socket_ops.cast(stream, castas, ret); } +/* stream_socket_recvfrom() and the other transport receives do not go + * through the read op, they reach the stream through its transport API, and + * a blocking receive is a wait on the handle too */ +static int frankenphp_worker_handle_set_option(php_stream *stream, int option, + int value, void *ptrparam) { + if (option == PHP_STREAM_OPTION_XPORT_API && ptrparam != NULL && + ((php_stream_xport_param *)ptrparam)->op == STREAM_XPORT_OP_RECV) { + frankenphp_worker_handle_waited(); + } + + return php_stream_socket_ops.set_option(stream, option, value, ptrparam); +} + static int frankenphp_worker_handle_close(php_stream *stream, int close_handle) { (void)close_handle; @@ -1319,6 +1332,7 @@ PHP_MINIT_FUNCTION(frankenphp) { frankenphp_worker_handle_ops.read = frankenphp_worker_handle_read; frankenphp_worker_handle_ops.cast = frankenphp_worker_handle_cast; frankenphp_worker_handle_ops.close = frankenphp_worker_handle_close; + frankenphp_worker_handle_ops.set_option = frankenphp_worker_handle_set_option; register_frankenphp_symbols(module_number); #ifndef PHP_WIN32 diff --git a/testdata/bgworker/recv.php b/testdata/bgworker/recv.php new file mode 100644 index 0000000000..1e9f711345 --- /dev/null +++ b/testdata/bgworker/recv.php @@ -0,0 +1,12 @@ + Date: Fri, 11 Sep 2026 12:05:51 +0200 Subject: [PATCH 04/46] fix: never inherit the reserved worker variables FRANKENPHP_WORKER and FRANKENPHP_WORKER_BACKGROUND say what is running the script, so FrankenPHP owns them. Dropping the background flag from the worker's own env was not enough: $_SERVER is built from the process environment first, then the php_server env, then the worker's, so a value from any layer below survived and an HTTP worker answered the documented isset() check. They are now removed after all the layers are merged, for the kind of thread that must not carry them. The test declares the flag in the worker env, the server env and the process environment. --- bgworker_test.go | 8 +++++--- frankenphp.c | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/bgworker_test.go b/bgworker_test.go index e48a582ea6..12331c3a2d 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -360,12 +360,14 @@ func TestGetWorkerHandleOutsideBackgroundWorker(t *testing.T) { // FRANKENPHP_WORKER_BACKGROUND flag. func TestWorkerNameInServerVars(t *testing.T) { sentinel := filepath.Join(t.TempDir(), "flag.txt") - server, err := frankenphp.NewServer(testDataDir) + t.Setenv("FRANKENPHP_WORKER_BACKGROUND", "1") + server, err := frankenphp.NewServer(testDataDir, frankenphp.WithServerEnv(map[string]string{"FRANKENPHP_WORKER_BACKGROUND": "1"})) require.NoError(t, err) initServers(t, frankenphp.WithServer(server), - // the flag is reserved: an env setting it must not make an HTTP - // worker look like a background one + // both names are reserved: neither the worker env here, nor the + // server env, nor the process environment set below may make an + // HTTP worker look like a background one frankenphp.WithWorkers("web", testDataDir+"worker-name.php", 1, frankenphp.WithWorkerServerScope(server), frankenphp.WithWorkerEnv(map[string]string{"FRANKENPHP_WORKER_BACKGROUND": "1"}), diff --git a/frankenphp.c b/frankenphp.c index 5bfdbfcc48..17f1342f6e 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1656,6 +1656,21 @@ static void frankenphp_register_variables(zval *track_vars_array) { /* import environment and CGI variables from the request context in go */ go_register_server_variables(frankenphp_thread_index(), track_vars_array); + /* FRANKENPHP_WORKER and FRANKENPHP_WORKER_BACKGROUND say what is running + * the script, so FrankenPHP owns them: a value inherited from the process + * environment, from a php_server or from a worker would otherwise make a + * script take the wrong branch. The worker's own values were merged above, + * the layers below are dropped here. */ + if (!is_worker_thread) { + zend_hash_str_del(Z_ARRVAL_P(track_vars_array), "FRANKENPHP_WORKER", + sizeof("FRANKENPHP_WORKER") - 1); + } + if (!is_background_worker) { + zend_hash_str_del(Z_ARRVAL_P(track_vars_array), + "FRANKENPHP_WORKER_BACKGROUND", + sizeof("FRANKENPHP_WORKER_BACKGROUND") - 1); + } + /* Some variables are already present in SG(request_info) */ frankenphp_register_variables_from_request_info(track_vars_array); } From a20f1000a98049f5fa93c7291fcdc9af15ada93e Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Fri, 11 Sep 2026 12:07:23 +0200 Subject: [PATCH 05/46] fix: cap the restart backoff, and EOF a handle a forked child still holds Two small ones on the restart path. The quadratic backoff multiplied before capping, which overflows a duration of nanoseconds past some 300k consecutive failures and sleeps for a negative one; a background worker crashing past its ready point counts without bound and reaches that in a few days of retries. The cap now comes first, for the same schedule. Closing the Go side of a handle only lands as EOF on the script's end while no other process holds a copy, and a pcntl_fork() child inherits every descriptor of the process, the pairs of the other threads included. Shutting the write direction down first sends the FIN regardless, so a parked script still wakes up instead of waiting out the force-kill. Also drops a platform conditional: php_network.h maps closesocket to close outside Windows. --- frankenphp.c | 14 ++++++++++---- threadworker.go | 10 ++++++++-- threadworker_test.go | 20 ++++++++++++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 threadworker_test.go diff --git a/frankenphp.c b/frankenphp.c index 17f1342f6e..1dcb3342d9 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -368,11 +368,8 @@ static void frankenphp_worker_close_sock(php_socket_t s) { if (s == SOCK_ERR) { return; } -#ifdef PHP_WIN32 + /* php_network.h maps closesocket to close outside Windows */ closesocket(s); -#else - close(s); -#endif } /* keep the pair out of processes the script may spawn: a child holding the @@ -463,6 +460,15 @@ void frankenphp_worker_close_stop_sock(intptr_t s) { if (s < 0) { return; } + /* Closing this end only lands as EOF on the script's end while no other + * process holds a copy of it, and a pcntl_fork() child inherits every + * descriptor of the process, including the pairs of the other threads. + * 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); } diff --git a/threadworker.go b/threadworker.go index 233a58a65a..066c94f0c1 100644 --- a/threadworker.go +++ b/threadworker.go @@ -148,9 +148,15 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { // 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 +// 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 { - return min(time.Duration(failures*failures*100)*time.Millisecond, time.Second) + if failures >= 4 { + return time.Second + } + + return time.Duration(failures*failures*100) * time.Millisecond } // waitForWorkerRequest is called during frankenphp_handle_request in the php worker script. 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)) +} From a6642b567d014e816e7dd3974ebf85069042ea1a Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Fri, 11 Sep 2026 12:09:24 +0200 Subject: [PATCH 06/46] fix: resolve max_threads auto on the HTTP budget An explicit max_threads gets the reservation added on top, so the HTTP capacity it describes is preserved. The automatic limit did not: it resolved from a num_threads that already included the background threads, so declaring three of them turned a limit of 4 into 10, and a memory-derived estimate could be swallowed whole, leaving no room for HTTP autoscaling at all. The main thread now knows what part of its count is reserved, resolves the estimate on the rest, and adds the reservation back, like the explicit path. --- bgworker_test.go | 22 ++++++++++++++++++++++ frankenphp.go | 6 +++--- phpmainthread.go | 36 ++++++++++++++++++++++-------------- phpmainthread_test.go | 8 ++++---- threadbackgroundworker.go | 8 ++++++-- types_test.go | 2 +- 6 files changed, 58 insertions(+), 24 deletions(-) diff --git a/bgworker_test.go b/bgworker_test.go index 12331c3a2d..60f89dec71 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -458,6 +458,28 @@ func TestBackgroundWorkerThreadsComeOnTop(t *testing.T) { requireFileEventually(t, sentinel, "background worker did not start with num_threads 1") } +// TestBackgroundWorkerThreadsComeOnTopOfAutoMaxThreads checks that 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) +} + // TestBackgroundWorkerBootFailuresThenSucceeds checks that boot failures below // max_consecutive_failures are retried with the backoff and Init() still // succeeds once a run reaches its ready point. diff --git a/frankenphp.go b/frankenphp.go index 2c76c80d0f..0370bff2c8 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -398,11 +398,11 @@ func Init(options ...Option) error { maxThreads := opt.maxThreads if maxThreads > 0 { - // in auto mode (maxThreads < 0), the resolved value is floored to the - // thread count, background threads included maxThreads += backgroundThreads } - mainThread, err := initPHPThreads(opt.numThreads+backgroundThreads, maxThreads, opt.phpIni) + // 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 diff --git a/phpmainthread.go b/phpmainthread.go index 0878762341..db48197b22 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 @@ -263,18 +268,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 08212466d9..8d78e25e74 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 diff --git a/threadbackgroundworker.go b/threadbackgroundworker.go index 38967fa31b..8841455036 100644 --- a/threadbackgroundworker.go +++ b/threadbackgroundworker.go @@ -142,9 +142,13 @@ func (handler *backgroundWorkerThread) setupScript() error { handler.isBootingScript = true metrics.StartWorker(handler.worker.qualifiedName) + // the run's logger and context, not the globals: Stop() does not wait + // for a callback that already started, and a shutdown finishing + // meanwhile resets those + logger, ctx, name, threadIndex := fc.logger, fc.ctx, handler.worker.qualifiedName, handler.thread.threadIndex handler.bootTimer = time.AfterFunc(backgroundBootWarnDelay, func() { - if globalLogger.Enabled(globalCtx, slog.LevelWarn) { - globalLogger.LogAttrs(globalCtx, slog.LevelWarn, "background worker has not waited on its handle yet, Init() and Shutdown() wait for it, see frankenphp_get_worker_handle()", slog.String("worker", handler.worker.qualifiedName), slog.Int("thread", handler.thread.threadIndex)) + if logger.Enabled(ctx, slog.LevelWarn) { + logger.LogAttrs(ctx, slog.LevelWarn, "background worker has not waited on its handle yet, Init() and Shutdown() wait for it, see frankenphp_get_worker_handle()", slog.String("worker", name), slog.Int("thread", threadIndex)) } }) diff --git a/types_test.go b/types_test.go index a08f90725e..7d547e9681 100644 --- a/types_test.go +++ b/types_test.go @@ -14,7 +14,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]) From a335efdcb08239221816463be6c5c2151e388783 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Fri, 11 Sep 2026 12:12:26 +0200 Subject: [PATCH 07/46] chore: simplifications and wording from the review resetForReboot() was redundant, setupWorkerScript() already resets the request count before every run, so the lifecycle interface is down to the one step that differs. A worker always has a server now, so the extension dispatch has a single path. zend_alter_ini_entry_chars() takes the literal, and php_network.h maps closesocket to close outside Windows. The ready_workers help text and docs said fetching the handle marks a background worker ready; the first wait on its stream does, as the validation test asserts. A parked fixture no longer disables max_execution_time itself, which is how the engine disabling it went untested, and a new test parks past a one-second limit with max_input_time set, the case where php_execute_script() re-arms it. --- bgworker_test.go | 26 ++++++++++++++++++++++++++ caddy/caddy_test.go | 12 ++++++------ docs/metrics.md | 2 +- frankenphp.c | 4 +--- metrics.go | 4 ++-- metrics_test.go | 2 +- testdata/bgworker/basic.php | 8 +++----- testdata/bgworker/no-time-limit.php | 9 +++++++++ threadbackgroundworker.go | 3 --- threadworker.go | 4 ---- workerextension.go | 17 +++-------------- workerlifecycle.go | 7 ++----- 12 files changed, 54 insertions(+), 44 deletions(-) create mode 100644 testdata/bgworker/no-time-limit.php diff --git a/bgworker_test.go b/bgworker_test.go index 60f89dec71..e43925eca5 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -480,6 +480,32 @@ func TestBackgroundWorkerThreadsComeOnTopOfAutoMaxThreads(t *testing.T) { assert.Equal(t, 6, len(state.ThreadDebugStates)+state.ReservedThreadCount) } +// TestBackgroundWorkerHasNoExecutionTimeout checks that a script parking +// past max_execution_time is not cut short, without the fixture disabling +// the limit itself. max_input_time is set because php_execute_script() +// re-arms the limit from the ini when it is. +func TestBackgroundWorkerHasNoExecutionTimeout(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"}), + ) + + 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 the limit it must not enforce + time.Sleep(2500 * time.Millisecond) + assert.Equal(t, 1, runs(), "the worker was restarted, so its run was cut short by max_execution_time") +} + // TestBackgroundWorkerBootFailuresThenSucceeds checks that boot failures below // max_consecutive_failures are retried with the backoff and Init() still // succeeds once a run reaches its ready point. diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index 29004d10b7..b53abfa9a6 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -878,7 +878,7 @@ func TestWorkerMetrics(t *testing.T) { # TYPE frankenphp_worker_request_count counter 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, frankenphp_get_worker_handle for background workers + # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, the first wait on the stream of frankenphp_get_worker_handle for background workers # TYPE frankenphp_ready_workers gauge frankenphp_ready_workers{worker="` + workerName + `"} 2 ` @@ -1035,7 +1035,7 @@ func TestNamedWorkerMetrics(t *testing.T) { # TYPE frankenphp_worker_request_count counter 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, frankenphp_get_worker_handle for background workers + # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, the first wait on the stream of frankenphp_get_worker_handle for background workers # TYPE frankenphp_ready_workers gauge frankenphp_ready_workers{worker="my_app"} 2 ` @@ -1131,7 +1131,7 @@ func TestAutoWorkerConfig(t *testing.T) { # TYPE frankenphp_worker_request_count counter 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, frankenphp_get_worker_handle for background workers + # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, the first wait on the stream of frankenphp_get_worker_handle for background workers # TYPE frankenphp_ready_workers gauge frankenphp_ready_workers{worker="` + workerName + `"} ` + workers + ` ` @@ -1499,7 +1499,7 @@ func TestMultiWorkersMetrics(t *testing.T) { # TYPE frankenphp_worker_request_count counter 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, frankenphp_get_worker_handle for background workers + # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, the first wait on the stream of frankenphp_get_worker_handle for background workers # TYPE frankenphp_ready_workers gauge frankenphp_ready_workers{worker="service1"} 2 frankenphp_ready_workers{worker="service2"} 3 @@ -1653,7 +1653,7 @@ func TestWorkerRestart(t *testing.T) { // Check metrics expectedMetrics := ` - # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_get_worker_handle for background workers + # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, the first wait on the stream of frankenphp_get_worker_handle for background workers # TYPE frankenphp_ready_workers gauge frankenphp_ready_workers{worker="service"} 1 # HELP frankenphp_total_workers Total number of PHP workers for this worker @@ -1681,7 +1681,7 @@ 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 reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_get_worker_handle for background workers + # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, the first wait on the stream of frankenphp_get_worker_handle for background workers # TYPE frankenphp_ready_workers gauge frankenphp_ready_workers{worker="service"} 1 # HELP frankenphp_total_workers Total number of PHP workers for this worker diff --git a/docs/metrics.md b/docs/metrics.md index 6239ae8829..6b8c42b0fc 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -19,7 +19,7 @@ When [Caddy metrics](https://caddyserver.com/docs/metrics) are enabled, FrankenP - `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, `frankenphp_get_worker_handle()` for background 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, the first wait on the stream of `frankenphp_get_worker_handle()` 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. diff --git a/frankenphp.c b/frankenphp.c index 1dcb3342d9..bf6f9f470b 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -477,10 +477,8 @@ void frankenphp_worker_close_stop_sock(intptr_t s) { static void frankenphp_disable_execution_timeout(void) { zend_string *key = zend_string_init("max_execution_time", sizeof("max_execution_time") - 1, 0); - zend_string *value = zend_string_init("0", 1, 0); - zend_alter_ini_entry(key, value, PHP_INI_USER, PHP_INI_STAGE_RUNTIME); + zend_alter_ini_entry_chars(key, "0", 1, PHP_INI_USER, PHP_INI_STAGE_RUNTIME); zend_string_release(key); - zend_string_release(value); } void frankenphp_update_local_thread_context(bool is_worker) { diff --git a/metrics.go b/metrics.go index d51a3726a8..03fe56b6c3 100644 --- a/metrics.go +++ b/metrics.go @@ -11,7 +11,7 @@ import ( const ( StopReasonCrash = iota StopReasonRestart - StopReasonBootFailure // worker exited before reaching its ready point: frankenphp_handle_request, or frankenphp_get_worker_handle for background workers + StopReasonBootFailure // worker exited before reaching its ready point: frankenphp_handle_request, or the first wait on the handle for background workers ) type StopReason int @@ -177,7 +177,7 @@ func (m *PrometheusMetrics) TotalWorkers(string, int) { m.readyWorkers = prometheus.NewGaugeVec(prometheus.GaugeOpts{ Namespace: ns, Name: "ready_workers", - Help: "Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_get_worker_handle for background workers", + Help: "Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, the first wait on the stream of frankenphp_get_worker_handle for background workers", }, basicLabels) m.mustRegister(m.readyWorkers) } diff --git a/metrics_test.go b/metrics_test.go index e5ca5e3c71..8c06a5686a 100644 --- a/metrics_test.go +++ b/metrics_test.go @@ -185,7 +185,7 @@ func TestPrometheusMetrics_TestStopReasonCrash(t *testing.T) { name: "Testing ReadyWorkers", c: m.readyWorkers, metadata: ` - # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_get_worker_handle for background workers + # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, the first wait on the stream of frankenphp_get_worker_handle for background workers # TYPE frankenphp_ready_workers gauge `, expect: ` diff --git a/testdata/bgworker/basic.php b/testdata/bgworker/basic.php index 6e779a11cb..2530c6ee58 100644 --- a/testdata/bgworker/basic.php +++ b/testdata/bgworker/basic.php @@ -1,10 +1,8 @@ Date: Fri, 11 Sep 2026 12:14:49 +0200 Subject: [PATCH 08/46] test: cover the parked run and the handle cache Two paths the suite took for granted. A worker parked on its handle must survive default_socket_timeout as well as max_execution_time, so the fixture that disables neither now runs with both set to one second. And a run gets one handle: the second fetch is the same stream, a fetch after closing it is a fresh one, and the drain still reaches the script through that one. --- bgworker_test.go | 44 +++++++++++++++++++++++------ testdata/bgworker/no-time-limit.php | 9 +++--- testdata/bgworker/refetch.php | 16 +++++++++++ 3 files changed, 57 insertions(+), 12 deletions(-) create mode 100644 testdata/bgworker/refetch.php diff --git a/bgworker_test.go b/bgworker_test.go index e43925eca5..7f8e8bbdc0 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -480,11 +480,12 @@ func TestBackgroundWorkerThreadsComeOnTopOfAutoMaxThreads(t *testing.T) { assert.Equal(t, 6, len(state.ThreadDebugStates)+state.ReservedThreadCount) } -// TestBackgroundWorkerHasNoExecutionTimeout checks that a script parking -// past max_execution_time is not cut short, without the fixture disabling -// the limit itself. max_input_time is set because php_execute_script() -// re-arms the limit from the ini when it is. -func TestBackgroundWorkerHasNoExecutionTimeout(t *testing.T) { +// TestBackgroundWorkerParkingIsNotInterrupted checks that a script parked +// on its handle is not cut short by the two limits it never disables +// itself: max_execution_time, which php_execute_script() re-arms from the +// ini when max_input_time is set, and default_socket_timeout, which the +// handle overrides with an infinite read timeout. +func TestBackgroundWorkerParkingIsNotInterrupted(t *testing.T) { countFile := filepath.Join(t.TempDir(), "runs") initServers(t, frankenphp.WithWorkers("bg-timeout", "testdata/bgworker/no-time-limit.php", 1, @@ -492,7 +493,7 @@ func TestBackgroundWorkerHasNoExecutionTimeout(t *testing.T) { 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"}), + frankenphp.WithPhpIni(map[string]string{"max_execution_time": "1", "max_input_time": "1", "default_socket_timeout": "1"}), ) runs := func() int { @@ -501,9 +502,36 @@ func TestBackgroundWorkerHasNoExecutionTimeout(t *testing.T) { 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 the limit it must not enforce + // well past both limits time.Sleep(2500 * time.Millisecond) - assert.Equal(t, 1, runs(), "the worker was restarted, so its run was cut short by max_execution_time") + assert.Equal(t, 1, runs(), "the worker was restarted, so a limit interrupted its park") +} + +// TestBackgroundWorkerHandleClosedAndFetchedAgain checks the handle cache: +// a run gets one stream, closing it yields a fresh one on the next fetch, +// and the drain still reaches the script through it. +func TestBackgroundWorkerHandleClosedAndFetchedAgain(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "refetch.txt") + + 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", 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") + } } // TestBackgroundWorkerBootFailuresThenSucceeds checks that boot failures below diff --git a/testdata/bgworker/no-time-limit.php b/testdata/bgworker/no-time-limit.php index 476336c52d..c7356e1605 100644 --- a/testdata/bgworker/no-time-limit.php +++ b/testdata/bgworker/no-time-limit.php @@ -1,9 +1,10 @@ Date: Sun, 13 Sep 2026 12:50:15 +0200 Subject: [PATCH 09/46] docs: flag frankenphp_get_worker_handle() experimental in the stub The directive, the Go option and the docs section carry the flag, the function did not. --- frankenphp.stub.php | 14 +++++++------- frankenphp_arginfo.h | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frankenphp.stub.php b/frankenphp.stub.php index bf1587cc64..f935a48393 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 stop-signal stream for the current background worker. The - * stream reaches EOF when FrankenPHP drains the worker, so the script can - * park on stream_select() and exit its loop gracefully. Every call of a run - * returns the same stream, a fresh one over the same socket once the script - * closed it. The worker counts as ready, and its startup as successful, once - * it waits on the stream (stream_select() or a blocking read). Only callable - * from inside a background worker. + * EXPERIMENTAL: returns a stop-signal stream for the current background + * worker. The stream reaches EOF when FrankenPHP drains the worker, so the + * script can park on stream_select() and exit its loop gracefully. Every + * call of a run returns the same stream, a fresh one over the same socket + * once the script closed it. The worker counts as ready, and its startup as + * successful, once it waits on the stream (stream_select() or a blocking + * read). Only callable from inside a background worker. * * @return resource */ diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index 8223be08ba..b2b6fe1a36 100644 --- a/frankenphp_arginfo.h +++ b/frankenphp_arginfo.h @@ -1,5 +1,5 @@ -/* This is a generated file, edit frankenphp.stub.php instead. - * Stub hash: 2f36fc81e0981975adabf170febaaa863653817d */ +/* This is a generated file, edit the .stub.php file instead. + * Stub hash: 23bcea159c151578e7cb9458d2dd9595d8aab621 */ 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) From 728c2e7c6987bdb5c8f48d1d564dbba05cd9778a Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sun, 13 Sep 2026 22:36:15 +0200 Subject: [PATCH 10/46] feat: frankenphp_worker_tick(), the explicit ready point of background workers Readiness rode on intercepting every PHP path that waits on the handle: the read op, the select cast, the transport receive missed at first, and whatever a future PHP adds. The contract was also read three different ways during review. It is now a call the script makes, the background analog of frankenphp_handle_request(): the first frankenphp_worker_tick() of a run marks the worker ready, every call returns false once the worker is drained, and the read, cast and transport hooks are gone. The tick never blocks and never hands out work. It consumes whatever the runtime wrote on the handle to wake the script up, so the script only ever selects on the handle, alone or with its own streams, and the protocol on it stays private. --- bgworker_test.go | 33 ++++++- caddy/caddy_test.go | 12 +-- docs/config.md | 4 +- docs/metrics.md | 2 +- docs/worker.md | 15 +-- frankenphp.c | 118 ++++++++++++++---------- frankenphp.stub.php | 28 ++++-- frankenphp_arginfo.h | 6 +- metrics.go | 4 +- metrics_test.go | 2 +- testdata/bgworker/basic.php | 18 ++-- testdata/bgworker/count.php | 11 ++- testdata/bgworker/crash-after-ready.php | 10 +- testdata/bgworker/crash.php | 11 ++- testdata/bgworker/early-return.php | 6 +- testdata/bgworker/fail-then-succeed.php | 11 ++- testdata/bgworker/fetch-no-tick.php | 7 ++ testdata/bgworker/fetch-no-wait.php | 7 -- testdata/bgworker/flag.php | 11 ++- testdata/bgworker/named.php | 11 ++- testdata/bgworker/no-time-limit.php | 11 ++- testdata/bgworker/pool.php | 11 ++- testdata/bgworker/read.php | 7 +- testdata/bgworker/recv.php | 7 +- testdata/bgworker/refetch.php | 1 + testdata/bgworker/stuck.php | 14 +-- testdata/bgworker/tick.php | 17 ++++ testdata/handle-outside.php | 12 ++- threadbackgroundworker.go | 52 +++++------ 29 files changed, 270 insertions(+), 189 deletions(-) create mode 100644 testdata/bgworker/fetch-no-tick.php delete mode 100644 testdata/bgworker/fetch-no-wait.php create mode 100644 testdata/bgworker/tick.php diff --git a/bgworker_test.go b/bgworker_test.go index 7f8e8bbdc0..37dc5ce4ae 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -180,18 +180,18 @@ func TestBackgroundWorkerValidation(t *testing.T) { ), frankenphp.WithNumThreads(2), ) - require.ErrorContains(t, err, "frankenphp_get_worker_handle") + require.ErrorContains(t, err, "without calling frankenphp_worker_tick()") }) - t.Run("fetching the handle without waiting on it fails startup", func(t *testing.T) { + t.Run("fetching the handle without ticking fails startup", func(t *testing.T) { err := frankenphp.Init( - frankenphp.WithWorkers("bg-no-wait", "testdata/bgworker/fetch-no-wait.php", 1, + frankenphp.WithWorkers("bg-no-tick", "testdata/bgworker/fetch-no-tick.php", 1, frankenphp.WithWorkerBackground(), frankenphp.WithWorkerMaxFailures(2), ), frankenphp.WithNumThreads(2), ) - require.ErrorContains(t, err, "waiting on its handle") + require.ErrorContains(t, err, "without calling frankenphp_worker_tick()") }) t.Run("max_threads is rejected", func(t *testing.T) { @@ -352,7 +352,30 @@ func TestGetWorkerHandleOutsideBackgroundWorker(t *testing.T) { body := serverGet(t, server, "http://example.com/handle-outside.php") - assert.Contains(t, body, "can only be called from a background worker") + assert.Contains(t, body, "frankenphp_get_worker_handle() can only be called from a background worker") + assert.Contains(t, body, "frankenphp_worker_tick() can only be called from a background worker") +} + +// TestBackgroundWorkerTick checks the contract of frankenphp_worker_tick(): +// 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") + + 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)) } // TestWorkerNameInServerVars checks that every worker sees its declared name diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index b53abfa9a6..43ac10513e 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -878,7 +878,7 @@ func TestWorkerMetrics(t *testing.T) { # TYPE frankenphp_worker_request_count counter 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, the first wait on the stream of frankenphp_get_worker_handle for background workers + # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_tick for background workers # TYPE frankenphp_ready_workers gauge frankenphp_ready_workers{worker="` + workerName + `"} 2 ` @@ -1035,7 +1035,7 @@ func TestNamedWorkerMetrics(t *testing.T) { # TYPE frankenphp_worker_request_count counter 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, the first wait on the stream of frankenphp_get_worker_handle for background workers + # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_tick for background workers # TYPE frankenphp_ready_workers gauge frankenphp_ready_workers{worker="my_app"} 2 ` @@ -1131,7 +1131,7 @@ func TestAutoWorkerConfig(t *testing.T) { # TYPE frankenphp_worker_request_count counter 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, the first wait on the stream of frankenphp_get_worker_handle for background workers + # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_tick for background workers # TYPE frankenphp_ready_workers gauge frankenphp_ready_workers{worker="` + workerName + `"} ` + workers + ` ` @@ -1499,7 +1499,7 @@ func TestMultiWorkersMetrics(t *testing.T) { # TYPE frankenphp_worker_request_count counter 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, the first wait on the stream of frankenphp_get_worker_handle for background workers + # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_tick for background workers # TYPE frankenphp_ready_workers gauge frankenphp_ready_workers{worker="service1"} 2 frankenphp_ready_workers{worker="service2"} 3 @@ -1653,7 +1653,7 @@ func TestWorkerRestart(t *testing.T) { // Check metrics expectedMetrics := ` - # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, the first wait on the stream of frankenphp_get_worker_handle for background workers + # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_tick for background workers # TYPE frankenphp_ready_workers gauge frankenphp_ready_workers{worker="service"} 1 # HELP frankenphp_total_workers Total number of PHP workers for this worker @@ -1681,7 +1681,7 @@ 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 reached their ready point at least once: frankenphp_handle_request for HTTP workers, the first wait on the stream of frankenphp_get_worker_handle for background workers + # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_tick for background workers # TYPE frankenphp_ready_workers gauge frankenphp_ready_workers{worker="service"} 1 # HELP frankenphp_total_workers Total number of PHP workers for this worker diff --git a/docs/config.md b/docs/config.md index 1a009c49b7..193a591793 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 and exposed as $_SERVER['FRANKENPHP_WORKER']. Must be unique among global workers. Default: absolute path of the 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. - background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker; it runs the script in a loop without serving requests, "name" is required, "match" is not allowed. The script is ready once it waits on the stream returned by frankenphp_get_worker_handle() (stream_select() or a blocking read); an exit before that counts as a failure. $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] is set in it, test its presence rather than its value. Its threads come on top of num_threads and max_threads, and max_threads is not allowed on it. + background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker; it runs the script in a loop without serving requests, "name" is required, "match" is not allowed. The script is ready once it calls frankenphp_worker_tick(); an exit before that counts as a failure. $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] is set in it, test its presence rather than its value. Its threads come on top of num_threads and max_threads, and max_threads is not allowed on it. } } } @@ -199,7 +199,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; it runs the script in a loop without serving requests, "name" is required, "match" is not allowed. The script is ready once it waits on the stream returned by frankenphp_get_worker_handle() (stream_select() or a blocking read); an exit before that counts as a failure. $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] is set in it, test its presence rather than its value. Its threads come on top of num_threads and max_threads, and max_threads is not allowed on it. + background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker; it runs the script in a loop without serving requests, "name" is required, "match" is not allowed. The script is ready once it calls frankenphp_worker_tick(); an exit before that counts as a failure. $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] is set in it, test its presence rather than its value. Its threads come on top of num_threads and max_threads, and max_threads is not allowed on it. } worker # Can also use the short form like in the global frankenphp block. } diff --git a/docs/metrics.md b/docs/metrics.md index 6b8c42b0fc..8def3085da 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -19,7 +19,7 @@ When [Caddy metrics](https://caddyserver.com/docs/metrics) are enabled, FrankenP - `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, the first wait on the stream of `frankenphp_get_worker_handle()` for background 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, `frankenphp_worker_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. diff --git a/docs/worker.md b/docs/worker.md index b9fe70674a..c2e83b66d6 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -216,25 +216,26 @@ php_server { } ``` -The script must wait on the stream returned by `frankenphp_get_worker_handle()`, which reaches EOF when FrankenPHP drains the worker on shutdown, reboot or restart. The first wait on it marks the worker ready: the server start waits for that point, and an exit before it counts as a failure. Polling `feof()` is not a wait, block in `stream_select()` or in a read: +The script calls `frankenphp_worker_tick()` 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. 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 stream returned by `frankenphp_get_worker_handle()`, alone or together with its own streams. The stream becomes readable when FrankenPHP needs the script's attention, and `frankenphp_worker_tick()` consumes whatever was written on it, so the script does not read the stream itself. ```php 0) { - // drained: return, FrankenPHP re-runs or stops the script - break; - } + stream_select($read, $write, $except, 1); doSomeWork(); } + +// drained: return, FrankenPHP re-runs or stops the script ``` +With an event loop, register the stream as readable and call `frankenphp_worker_tick()` from the callback, stopping the loop when it returns `false`. + `$_SERVER['FRANKENPHP_WORKER']` holds the declared name, as it does in HTTP workers, and `$_SERVER['FRANKENPHP_WORKER_BACKGROUND']` is set, so a script serving both roles can tell them apart with `isset()`. Its value is unspecified, only its presence is part of the contract. `FRANKENPHP_WORKER` used to hold `1` in HTTP workers for the same reason: a script comparing it to that value must test its presence instead. 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/frankenphp.c b/frankenphp.c index bf6f9f470b..523861bc06 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -135,9 +135,9 @@ static THREAD_LOCAL bool is_background_worker = false; * exposed via frankenphp_get_worker_handle(); [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 on the first wait on the handle of the current run, see - * frankenphp_worker_handle_ops */ -static THREAD_LOCAL bool worker_handle_waited = false; +/* set by the first frankenphp_worker_tick() of the current run, the ready + * point of a background worker */ +static THREAD_LOCAL bool worker_ticked = false; /* the stream of the current run, see frankenphp_get_worker_handle(); the * cache holds a ref, and the resource list of the run frees it at request * shutdown, so the pointer is only reset, never released, between runs */ @@ -440,7 +440,7 @@ static int frankenphp_worker_open_stop_pair(void) { * re-arms it, see php_thread(). */ intptr_t frankenphp_set_background_worker_and_get_stop_sock(void) { is_background_worker = true; - worker_handle_waited = false; + worker_ticked = false; worker_handle_res = NULL; frankenphp_worker_close_stop_socks(); @@ -1167,51 +1167,11 @@ PHP_FUNCTION(frankenphp_log) { } /* Ops of the streams returned by frankenphp_get_worker_handle(): the socket - * ops, except that the first wait on the handle, a select cast or a read, - * reports the worker ready, and 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. Waiting is the - * background analog of an HTTP worker reaching frankenphp_handle_request(): - * it comes after the script's bootstrap by construction, where merely - * fetching the handle does not. Initialized in MINIT. */ + * 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 void frankenphp_worker_handle_waited(void) { - if (!worker_handle_waited) { - worker_handle_waited = true; - go_frankenphp_background_worker_ready(frankenphp_thread_index()); - } -} - -static ssize_t frankenphp_worker_handle_read(php_stream *stream, char *buf, - size_t count) { - frankenphp_worker_handle_waited(); - - return php_stream_socket_ops.read(stream, buf, count); -} - -static int frankenphp_worker_handle_cast(php_stream *stream, int castas, - void **ret) { - if (castas == PHP_STREAM_AS_FD_FOR_SELECT) { - frankenphp_worker_handle_waited(); - } - - return php_stream_socket_ops.cast(stream, castas, ret); -} - -/* stream_socket_recvfrom() and the other transport receives do not go - * through the read op, they reach the stream through its transport API, and - * a blocking receive is a wait on the handle too */ -static int frankenphp_worker_handle_set_option(php_stream *stream, int option, - int value, void *ptrparam) { - if (option == PHP_STREAM_OPTION_XPORT_API && ptrparam != NULL && - ((php_stream_xport_param *)ptrparam)->op == STREAM_XPORT_OP_RECV) { - frankenphp_worker_handle_waited(); - } - - return php_stream_socket_ops.set_option(stream, option, value, ptrparam); -} - static int frankenphp_worker_handle_close(php_stream *stream, int close_handle) { (void)close_handle; @@ -1265,7 +1225,6 @@ PHP_FUNCTION(frankenphp_get_worker_handle) { * default_socket_timeout wake-ups */ ((php_netstream_data_t *)stream->abstract)->timeout.tv_sec = -1; - /* report the worker ready on its first wait on the stream */ stream->ops = &frankenphp_worker_handle_ops; php_stream_to_zval(stream, return_value); @@ -1273,6 +1232,66 @@ PHP_FUNCTION(frankenphp_get_worker_handle) { GC_ADDREF(worker_handle_res); } +/* The ready point of a background worker and its liveness check, the + * background 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 + * its handle, alone or with its own streams, and calls this when the handle + * is readable. Whatever the runtime writes on the handle to wake the script + * up is consumed here, so the script never has to read the handle and the + * protocol on it stays private. */ +PHP_FUNCTION(frankenphp_worker_tick) { + ZEND_PARSE_PARAMETERS_NONE(); + + if (!is_background_worker) { + zend_throw_exception(spl_ce_RuntimeException, + "frankenphp_worker_tick() can only be called from a " + "background worker", + 0); + RETURN_THROWS(); + } + + if (worker_stop_socks[0] == SOCK_ERR) { + zend_throw_exception(spl_ce_RuntimeException, + "the background worker stop socket is not available", + 0); + RETURN_THROWS(); + } + + if (!worker_ticked) { + worker_ticked = true; + 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()); @@ -1333,10 +1352,7 @@ static const zend_function_entry frankenphp_test_hook_functions[] = { PHP_MINIT_FUNCTION(frankenphp) { frankenphp_worker_handle_ops = php_stream_socket_ops; frankenphp_worker_handle_ops.label = "FrankenPHP worker handle"; - frankenphp_worker_handle_ops.read = frankenphp_worker_handle_read; - frankenphp_worker_handle_ops.cast = frankenphp_worker_handle_cast; frankenphp_worker_handle_ops.close = frankenphp_worker_handle_close; - frankenphp_worker_handle_ops.set_option = frankenphp_worker_handle_set_option; register_frankenphp_symbols(module_number); #ifndef PHP_WIN32 diff --git a/frankenphp.stub.php b/frankenphp.stub.php index f935a48393..36bbd5fe7e 100644 --- a/frankenphp.stub.php +++ b/frankenphp.stub.php @@ -56,14 +56,28 @@ function mercure_publish(string|array $topics, string $data = '', bool $private function frankenphp_log(string $message, int $level = 0, array $context = []): void {} /** - * EXPERIMENTAL: returns a stop-signal stream for the current background - * worker. The stream reaches EOF when FrankenPHP drains the worker, so the - * script can park on stream_select() and exit its loop gracefully. Every - * call of a run returns the same stream, a fresh one over the same socket - * once the script closed it. The worker counts as ready, and its startup as - * successful, once it waits on the stream (stream_select() or a blocking - * read). Only callable from inside a background worker. + * EXPERIMENTAL: returns the handle of the current background worker, a + * 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, and frankenphp_worker_tick() then tells whether the worker + * still runs. Every call of a run returns the same stream, a fresh one over + * the same socket once the script closed it. Only callable from inside a + * background worker. * * @return resource */ function frankenphp_get_worker_handle() {} + +/** + * EXPERIMENTAL: 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. 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 returned by frankenphp_get_worker_handle() and calls + * this when it is readable. Whatever FrankenPHP wrote on that stream is + * consumed here, the script does not have to read it. Only callable from + * inside a background worker. + */ +function frankenphp_worker_tick(): bool {} diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index b2b6fe1a36..75fd885c16 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: 23bcea159c151578e7cb9458d2dd9595d8aab621 */ + * Stub hash: 0c68b8a074015c8d7ee667272181469768c2f9c2 */ 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) @@ -44,6 +44,8 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_INFO_EX(arginfo_frankenphp_get_worker_handle, 0, 0, 0) ZEND_END_ARG_INFO() +#define arginfo_frankenphp_worker_tick arginfo_frankenphp_finish_request + ZEND_FUNCTION(frankenphp_handle_request); ZEND_FUNCTION(headers_send); ZEND_FUNCTION(frankenphp_finish_request); @@ -52,6 +54,7 @@ ZEND_FUNCTION(frankenphp_response_headers); ZEND_FUNCTION(mercure_publish); ZEND_FUNCTION(frankenphp_log); ZEND_FUNCTION(frankenphp_get_worker_handle); +ZEND_FUNCTION(frankenphp_worker_tick); static const zend_function_entry ext_functions[] = { ZEND_FE(frankenphp_handle_request, arginfo_frankenphp_handle_request) @@ -66,6 +69,7 @@ static const zend_function_entry ext_functions[] = { ZEND_FE(mercure_publish, arginfo_mercure_publish) ZEND_FE(frankenphp_log, arginfo_frankenphp_log) ZEND_FE(frankenphp_get_worker_handle, arginfo_frankenphp_get_worker_handle) + ZEND_FE(frankenphp_worker_tick, arginfo_frankenphp_worker_tick) ZEND_FE_END }; diff --git a/metrics.go b/metrics.go index 03fe56b6c3..4011db0d41 100644 --- a/metrics.go +++ b/metrics.go @@ -11,7 +11,7 @@ import ( const ( StopReasonCrash = iota StopReasonRestart - StopReasonBootFailure // worker exited before reaching its ready point: frankenphp_handle_request, or the first wait on the handle for background workers + StopReasonBootFailure // worker exited before reaching its ready point: frankenphp_handle_request, or frankenphp_worker_tick for background workers ) type StopReason int @@ -177,7 +177,7 @@ func (m *PrometheusMetrics) TotalWorkers(string, int) { m.readyWorkers = prometheus.NewGaugeVec(prometheus.GaugeOpts{ Namespace: ns, Name: "ready_workers", - Help: "Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, the first wait on the stream of frankenphp_get_worker_handle for background workers", + Help: "Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_tick for background workers", }, basicLabels) m.mustRegister(m.readyWorkers) } diff --git a/metrics_test.go b/metrics_test.go index 8c06a5686a..7d721f0189 100644 --- a/metrics_test.go +++ b/metrics_test.go @@ -185,7 +185,7 @@ func TestPrometheusMetrics_TestStopReasonCrash(t *testing.T) { name: "Testing ReadyWorkers", c: m.readyWorkers, metadata: ` - # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, the first wait on the stream of frankenphp_get_worker_handle for background workers + # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_tick for background workers # TYPE frankenphp_ready_workers gauge `, expect: ` diff --git a/testdata/bgworker/basic.php b/testdata/bgworker/basic.php index 2530c6ee58..9565cef551 100644 --- a/testdata/bgworker/basic.php +++ b/testdata/bgworker/basic.php @@ -1,7 +1,7 @@ $_SERVER['FRANKENPHP_WORKER'] ?? null, 'background' => isset($_SERVER['FRANKENPHP_WORKER_BACKGROUND']) ? 'set' : 'unset', ], true)); -$stream = frankenphp_get_worker_handle(); -$read = [$stream]; -$write = null; -$except = null; -stream_select($read, $write, $except, null); +$handle = frankenphp_get_worker_handle(); +while (frankenphp_worker_tick()) { + $read = [$handle]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/named.php b/testdata/bgworker/named.php index 4b12f88961..435589ac47 100644 --- a/testdata/bgworker/named.php +++ b/testdata/bgworker/named.php @@ -12,8 +12,9 @@ @touch($_SERVER['BG_SENTINEL_DIR'] . DIRECTORY_SEPARATOR . $name); } -$stream = frankenphp_get_worker_handle(); -$read = [$stream]; -$write = null; -$except = null; -stream_select($read, $write, $except, null); +$handle = frankenphp_get_worker_handle(); +while (frankenphp_worker_tick()) { + $read = [$handle]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/no-time-limit.php b/testdata/bgworker/no-time-limit.php index c7356e1605..d4228e6ef0 100644 --- a/testdata/bgworker/no-time-limit.php +++ b/testdata/bgworker/no-time-limit.php @@ -2,9 +2,10 @@ // Long-lived bg worker relying on the engine for its parking: it does not // call set_time_limit(0), so max_execution_time must not cut the run -// short, and it does not set a stream timeout, so default_socket_timeout -// must not end the read either. Counts its runs in BG_COUNT_FILE, so -// anything that interrupts the park shows up as a second line. +// short, and it parks in a blocking read without a stream timeout, so +// default_socket_timeout must not end the read either. Counts its runs in +// BG_COUNT_FILE, so anything that interrupts the park shows up as a second +// line. file_put_contents($_SERVER['BG_COUNT_FILE'], "run\n", FILE_APPEND); -$stream = frankenphp_get_worker_handle(); -fgets($stream); +frankenphp_worker_tick(); +fgets(frankenphp_get_worker_handle()); diff --git a/testdata/bgworker/pool.php b/testdata/bgworker/pool.php index 9b5a9096da..8446459393 100644 --- a/testdata/bgworker/pool.php +++ b/testdata/bgworker/pool.php @@ -4,8 +4,9 @@ // 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))); -$stream = frankenphp_get_worker_handle(); -$read = [$stream]; -$write = null; -$except = null; -stream_select($read, $write, $except, null); +$handle = frankenphp_get_worker_handle(); +while (frankenphp_worker_tick()) { + $read = [$handle]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/read.php b/testdata/bgworker/read.php index c57e38b341..3ef3a66f4c 100644 --- a/testdata/bgworker/read.php +++ b/testdata/bgworker/read.php @@ -1,11 +1,10 @@ getMessage(); +foreach (['frankenphp_get_worker_handle', 'frankenphp_worker_tick'] as $function) { + try { + $function(); + echo "$function: no exception\n"; + } catch (\RuntimeException $e) { + echo $e->getMessage(), "\n"; + } } diff --git a/threadbackgroundworker.go b/threadbackgroundworker.go index 83b8db7b8d..89454305b5 100644 --- a/threadbackgroundworker.go +++ b/threadbackgroundworker.go @@ -18,8 +18,9 @@ import ( // it with a quadratic backoff when it crashes. Background workers share the // PHP runtime with HTTP threads but never receive HTTP requests. The script // can park on the stream returned by frankenphp_get_worker_handle(), which -// reaches EOF when the thread is drained, to exit gracefully on shutdown, -// reboot or handler transition. +// reaches EOF when the thread is drained, and frankenphp_worker_tick() then +// returns false, so it exits gracefully on shutdown, reboot or handler +// transition. type backgroundWorkerThread struct { workerLifecycle @@ -31,15 +32,14 @@ type backgroundWorkerThread struct { // touched on the PHP thread. crashCount int - // isBootingScript is true until the current run waits on its handle, a - // stream_select() or a read on the stream returned by - // frankenphp_get_worker_handle(), the background analog of an HTTP - // worker reaching frankenphp_handle_request(). Only touched on the PHP - // thread (setup, the C callback during execution, teardown). + // isBootingScript is true until the current run calls + // frankenphp_worker_tick(), the background analog of an HTTP worker + // reaching frankenphp_handle_request(). Only touched on the PHP thread + // (setup, the C callback during execution, teardown). isBootingScript bool - // bootTimer warns when a run has not waited on its handle after - // backgroundBootWarnDelay; only touched on the PHP thread + // bootTimer warns when a run has not called frankenphp_worker_tick() + // after backgroundBootWarnDelay; only touched on the PHP thread bootTimer *time.Timer // stopSock holds the Go side's end of this thread's stop socket pair @@ -50,9 +50,9 @@ type backgroundWorkerThread struct { stopSock atomic.Int64 } -// backgroundBootWarnDelay is how long a run may go without waiting on its -// handle before a warning: Init() and Shutdown() wait for that point, so a -// script that never gets there hangs both silently +// backgroundBootWarnDelay is how long a run may go without calling +// frankenphp_worker_tick() before a warning: Init() and Shutdown() wait for +// that point, so a script that never gets there hangs both silently const backgroundBootWarnDelay = 10 * time.Second func convertToBackgroundWorkerThread(thread *phpThread, worker *worker) { @@ -145,7 +145,7 @@ func (handler *backgroundWorkerThread) setupScript() error { 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 waited on its handle yet, Init() and Shutdown() wait for it, see frankenphp_get_worker_handle()", slog.String("worker", name), slog.Int("thread", threadIndex)) + logger.LogAttrs(ctx, slog.LevelWarn, "background worker has not called frankenphp_worker_tick() yet, Init() and Shutdown() wait for it", slog.String("worker", name), slog.Int("thread", threadIndex)) } }) @@ -153,8 +153,8 @@ func (handler *backgroundWorkerThread) setupScript() error { 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 waits on its - // handle, see go_frankenphp_background_worker_ready + // the thread stays in TransitionComplete until the script calls + // frankenphp_worker_tick(), see go_frankenphp_background_worker_ready return nil } @@ -170,9 +170,9 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { handler.stopBootTimer() handler.state.MarkAsWaiting(false) - // cooperative exit: the script waited on its handle and returned cleanly, - // re-run it, unless the thread is being drained (beforeScriptExecution - // checks the state) + // cooperative exit: the script ticked and returned cleanly, re-run it, + // unless the thread is being drained (beforeScriptExecution checks the + // state) if exitStatus == 0 && !handler.isBootingScript { handler.crashCount = 0 metrics.StopWorker(worker.qualifiedName, StopReasonRestart) @@ -203,8 +203,8 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { return } - // boot failure: the script exited before waiting on its handle, a clean - // exit included, which would otherwise respawn in a tight loop. + // boot failure: the script exited before calling frankenphp_worker_tick(), + // a clean exit included, which would otherwise respawn in a tight loop. // StopReasonBootFailure skips the ready-gauge decrement, matching the // ReadyWorker call that never happened metrics.StopWorker(worker.qualifiedName, StopReasonBootFailure) @@ -217,7 +217,7 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { pastCap := worker.maxConsecutiveFailures >= 0 && handler.failureCount >= worker.maxConsecutiveFailures if pastCap && startupFailChan != nil && !watcherIsEnabled { if exitStatus == 0 { - startupFailChan <- fmt.Errorf("background worker %s exits without waiting on its handle, see frankenphp_get_worker_handle()", worker.fileName) + startupFailChan <- fmt.Errorf("background worker %s exits without calling frankenphp_worker_tick()", worker.fileName) } else { startupFailChan <- fmt.Errorf("too many consecutive failures: background worker %s keeps crashing", worker.fileName) } @@ -226,9 +226,9 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { } logLevel := slog.LevelWarn - logMsg := "background worker failed before waiting on its handle, restarting" + logMsg := "background worker failed before calling frankenphp_worker_tick(), restarting" if exitStatus == 0 { - logMsg = "background worker exited without waiting on its handle, restarting" + logMsg = "background worker exited without calling frankenphp_worker_tick(), restarting" } if pastCap { logLevel = slog.LevelError @@ -250,8 +250,8 @@ func (handler *backgroundWorkerThread) stopBootTimer() { //export go_frankenphp_background_worker_ready func go_frankenphp_background_worker_ready(threadIndex C.uintptr_t) { - // called on the PHP thread on the first wait on the handle; the handler - // is a backgroundWorkerThread because frankenphp_get_worker_handle() + // called on the PHP thread by the first frankenphp_worker_tick() of a + // run; the handler is a backgroundWorkerThread because that function // throws on every other thread kind if handler, ok := phpThreads[threadIndex].handler.(*backgroundWorkerThread); ok && handler.isBootingScript { handler.isBootingScript = false @@ -264,7 +264,7 @@ func go_frankenphp_background_worker_ready(threadIndex C.uintptr_t) { // like an HTTP worker reaching frankenphp_handle_request(), the thread // is ready only now: initWorkers() waits for this state, so a script - // that fails before waiting on its handle still fails Init() + // that fails before its first tick still fails Init() if handler.state.Is(state.TransitionComplete) { handler.state.Set(state.Ready) } From 455805ae8bc0579fbe402522ebc8e03683e488c7 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 14 Sep 2026 07:16:37 +0200 Subject: [PATCH 11/46] feat: FRANKENPHP_WORKER_BACKGROUND holds the name, HTTP workers untouched FRANKENPHP_WORKER stays what it always was, "1" in HTTP workers, and is not set in background workers, where FRANKENPHP_WORKER_BACKGROUND holds the declared name instead. A script serving both roles tests which of the two is set. The removal of inherited values is gone with the change that motivated it: nothing about HTTP workers moves in this PR anymore. --- bgworker_test.go | 29 +++++++++++------------------ docs/config.md | 8 ++++---- docs/worker.md | 2 +- frankenphp.c | 15 --------------- testdata/bgworker/flag.php | 6 +++--- testdata/bgworker/named.php | 6 +++--- testdata/worker-name.php | 4 ++-- worker.go | 16 ++++++---------- 8 files changed, 30 insertions(+), 56 deletions(-) diff --git a/bgworker_test.go b/bgworker_test.go index 37dc5ce4ae..4ebcf2740d 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -95,8 +95,8 @@ func TestBackgroundWorkerCrashRestarts(t *testing.T) { // TestBackgroundWorkerOnServer scopes a background worker to a Server. It // proves that the worker inherits the server env (the sentinel directory is -// declared on the server, not on the worker), that FRANKENPHP_WORKER holds -// the worker name, and that the worker does not intercept HTTP requests +// declared on the server, not on the worker), that FRANKENPHP_WORKER_BACKGROUND +// holds the worker name, and that the worker does not intercept HTTP requests // served by the same server. func TestBackgroundWorkerOnServer(t *testing.T) { tmp := t.TempDir() @@ -123,7 +123,7 @@ func TestBackgroundWorkerOnServer(t *testing.T) { frankenphp.WithNumThreads(3), ) - // named.php touches "/": the script sees + // 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") @@ -378,23 +378,16 @@ func TestBackgroundWorkerTick(t *testing.T) { assert.Equal(t, "true true false false", string(b)) } -// TestWorkerNameInServerVars checks that every worker sees its declared name -// in FRANKENPHP_WORKER and that only background workers get the -// FRANKENPHP_WORKER_BACKGROUND flag. +// TestWorkerNameInServerVars checks that an HTTP worker sees FRANKENPHP_WORKER +// as it always did, and that a background worker sees its declared name in +// FRANKENPHP_WORKER_BACKGROUND and no FRANKENPHP_WORKER. func TestWorkerNameInServerVars(t *testing.T) { sentinel := filepath.Join(t.TempDir(), "flag.txt") - t.Setenv("FRANKENPHP_WORKER_BACKGROUND", "1") - server, err := frankenphp.NewServer(testDataDir, frankenphp.WithServerEnv(map[string]string{"FRANKENPHP_WORKER_BACKGROUND": "1"})) + server, err := frankenphp.NewServer(testDataDir) require.NoError(t, err) initServers(t, frankenphp.WithServer(server), - // both names are reserved: neither the worker env here, nor the - // server env, nor the process environment set below may make an - // HTTP worker look like a background one - frankenphp.WithWorkers("web", testDataDir+"worker-name.php", 1, - frankenphp.WithWorkerServerScope(server), - frankenphp.WithWorkerEnv(map[string]string{"FRANKENPHP_WORKER_BACKGROUND": "1"}), - ), + frankenphp.WithWorkers("web", testDataDir+"worker-name.php", 1, frankenphp.WithWorkerServerScope(server)), frankenphp.WithWorkers("jobs", "testdata/bgworker/flag.php", 1, frankenphp.WithWorkerBackground(), frankenphp.WithWorkerServerScope(server), @@ -403,11 +396,11 @@ func TestWorkerNameInServerVars(t *testing.T) { frankenphp.WithNumThreads(3), ) - assert.Equal(t, "web http", serverGet(t, server, "http://example.com/worker-name.php")) + assert.Equal(t, "1 http", serverGet(t, server, "http://example.com/worker-name.php")) flag := requireFileContentEventually(t, sentinel) - assert.Contains(t, flag, "'worker' => 'jobs'") - assert.Contains(t, flag, "'background' => 'set'") + assert.Contains(t, flag, "'worker' => 'unset'") + assert.Contains(t, flag, "'background' => 'jobs'") } // TestBackgroundWorkerPool checks that num > 1 threads share the name, each diff --git a/docs/config.md b/docs/config.md index 193a591793..9929ce22ea 100644 --- a/docs/config.md +++ b/docs/config.md @@ -109,9 +109,9 @@ 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. 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 and exposed as $_SERVER['FRANKENPHP_WORKER']. Must be unique among global workers. Default: absolute path of the 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. 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. - background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker; it runs the script in a loop without serving requests, "name" is required, "match" is not allowed. The script is ready once it calls frankenphp_worker_tick(); an exit before that counts as a failure. $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] is set in it, test its presence rather than its value. Its threads come on top of num_threads and max_threads, and max_threads is not allowed on it. + background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker; it runs the script in a loop without serving requests, "name" is required, "match" is not allowed. The script is ready once it calls frankenphp_worker_tick(); an exit before that counts as a failure. $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] holds its name, and FRANKENPHP_WORKER is not set in it. Its threads come on top of num_threads and max_threads, and max_threads is not allowed on it. } } } @@ -195,11 +195,11 @@ php_server [] { 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 and exposed as $_SERVER['FRANKENPHP_WORKER']. Must be unique within this php_server. In logs and metrics, the worker is reported as ":". Default: absolute path of the worker file. + name # Sets the name for the worker, used in logs and metrics. Must be unique within this php_server. In logs and metrics, the worker is reported as ":". Default: absolute path of the worker file. 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; it runs the script in a loop without serving requests, "name" is required, "match" is not allowed. The script is ready once it calls frankenphp_worker_tick(); an exit before that counts as a failure. $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] is set in it, test its presence rather than its value. Its threads come on top of num_threads and max_threads, and max_threads is not allowed on it. + background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker; it runs the script in a loop without serving requests, "name" is required, "match" is not allowed. The script is ready once it calls frankenphp_worker_tick(); an exit before that counts as a failure. $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] holds its name, and FRANKENPHP_WORKER is not set in it. Its threads come on top of num_threads and max_threads, and max_threads is not allowed on it. } worker # Can also use the short form like in the global frankenphp block. } diff --git a/docs/worker.md b/docs/worker.md index c2e83b66d6..22f37a85fb 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -236,7 +236,7 @@ while (frankenphp_worker_tick()) { With an event loop, register the stream as readable and call `frankenphp_worker_tick()` from the callback, stopping the loop when it returns `false`. -`$_SERVER['FRANKENPHP_WORKER']` holds the declared name, as it does in HTTP workers, and `$_SERVER['FRANKENPHP_WORKER_BACKGROUND']` is set, so a script serving both roles can tell them apart with `isset()`. Its value is unspecified, only its presence is part of the contract. `FRANKENPHP_WORKER` used to hold `1` in HTTP workers for the same reason: a script comparing it to that value must test its presence instead. 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 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 diff --git a/frankenphp.c b/frankenphp.c index 523861bc06..29db7ac2ee 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1676,21 +1676,6 @@ static void frankenphp_register_variables(zval *track_vars_array) { /* import environment and CGI variables from the request context in go */ go_register_server_variables(frankenphp_thread_index(), track_vars_array); - /* FRANKENPHP_WORKER and FRANKENPHP_WORKER_BACKGROUND say what is running - * the script, so FrankenPHP owns them: a value inherited from the process - * environment, from a php_server or from a worker would otherwise make a - * script take the wrong branch. The worker's own values were merged above, - * the layers below are dropped here. */ - if (!is_worker_thread) { - zend_hash_str_del(Z_ARRVAL_P(track_vars_array), "FRANKENPHP_WORKER", - sizeof("FRANKENPHP_WORKER") - 1); - } - if (!is_background_worker) { - zend_hash_str_del(Z_ARRVAL_P(track_vars_array), - "FRANKENPHP_WORKER_BACKGROUND", - sizeof("FRANKENPHP_WORKER_BACKGROUND") - 1); - } - /* Some variables are already present in SG(request_info) */ frankenphp_register_variables_from_request_info(track_vars_array); } diff --git a/testdata/bgworker/flag.php b/testdata/bgworker/flag.php index d9303d15af..24eb70a85f 100644 --- a/testdata/bgworker/flag.php +++ b/testdata/bgworker/flag.php @@ -1,11 +1,11 @@ $_SERVER['FRANKENPHP_WORKER'] ?? null, - 'background' => isset($_SERVER['FRANKENPHP_WORKER_BACKGROUND']) ? 'set' : 'unset', + 'worker' => $_SERVER['FRANKENPHP_WORKER'] ?? 'unset', + 'background' => $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] ?? 'unset', ], true)); $handle = frankenphp_get_worker_handle(); while (frankenphp_worker_tick()) { diff --git a/testdata/bgworker/named.php b/testdata/bgworker/named.php index 435589ac47..70bbfaa88c 100644 --- a/testdata/bgworker/named.php +++ b/testdata/bgworker/named.php @@ -2,12 +2,12 @@ // 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'] value is the -// declared name, so the same fixture serves multiple distinct names +// ran. The bg worker's $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] value is +// the declared name, so the same fixture serves multiple distinct names // across scopes. set_time_limit(0); -$name = $_SERVER['FRANKENPHP_WORKER'] ?? 'unknown'; +$name = $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] ?? 'unknown'; if (!empty($_SERVER['BG_SENTINEL_DIR'])) { @touch($_SERVER['BG_SENTINEL_DIR'] . DIRECTORY_SEPARATOR . $name); } diff --git a/testdata/worker-name.php b/testdata/worker-name.php index b9760c8dfc..b9f15a047d 100644 --- a/testdata/worker-name.php +++ b/testdata/worker-name.php @@ -1,7 +1,7 @@ Date: Mon, 14 Sep 2026 07:16:58 +0200 Subject: [PATCH 12/46] feat: wake a background worker once at start A script that registers its handle with an event loop and runs it only ticks when the handle is readable, so it never became ready before the drain and Init() waited for it. One wake-up written at run setup makes such a loop tick on its own: readiness then means the loop serviced the handle once. The first frankenphp_worker_tick() consumes it, and a script parked in a blocking read without ticking now fails its boot fast instead of hanging the start. --- bgworker_test.go | 16 ++++++++++++++++ docs/worker.md | 2 +- frankenphp.c | 11 +++++++++++ testdata/bgworker/loop.php | 15 +++++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 testdata/bgworker/loop.php diff --git a/bgworker_test.go b/bgworker_test.go index 4ebcf2740d..df9dfd1c40 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -356,6 +356,22 @@ func TestGetWorkerHandleOutsideBackgroundWorker(t *testing.T) { assert.Contains(t, body, "frankenphp_worker_tick() can only be called from a background worker") } +// TestBackgroundWorkerLoopTicksOnItsOwn checks the wake-up sent at start: a +// script that only ticks when its handle is readable, the shape of a script +// driven by an event loop, becomes ready without an explicit first call. +// Without the wake-up, Init() would wait for that call 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") +} + // TestBackgroundWorkerTick checks the contract of frankenphp_worker_tick(): // true while the worker runs, false once it is drained, and still false on // the next call diff --git a/docs/worker.md b/docs/worker.md index 22f37a85fb..f3f6b5289d 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -216,7 +216,7 @@ php_server { } ``` -The script calls `frankenphp_worker_tick()` 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. 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 stream returned by `frankenphp_get_worker_handle()`, alone or together with its own streams. The stream becomes readable when FrankenPHP needs the script's attention, and `frankenphp_worker_tick()` consumes whatever was written on it, so the script does not read the stream itself. +The script calls `frankenphp_worker_tick()` 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. 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 stream returned by `frankenphp_get_worker_handle()`, alone or together with its own streams. The stream becomes readable when FrankenPHP needs the script's attention, and `frankenphp_worker_tick()` consumes whatever was written on it, so the script does not read the stream itself. It is readable once right after the script starts, so a loop that services it ticks by itself and the worker is ready as soon as its loop runs. ```php Date: Mon, 14 Sep 2026 07:16:58 +0200 Subject: [PATCH 13/46] docs: event loop example for background workers --- docs/worker.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/worker.md b/docs/worker.md index f3f6b5289d..9836b05422 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -234,7 +234,27 @@ while (frankenphp_worker_tick()) { // drained: return, FrankenPHP re-runs or stops the script ``` -With an event loop, register the stream as readable and call `frankenphp_worker_tick()` from the callback, stopping the loop when it returns `false`. +With an event loop, register the stream as readable and call `frankenphp_worker_tick()` from the callback. With [Revolt](https://revolt.run), the loop of amphp: + +```php + Date: Mon, 14 Sep 2026 07:17:13 +0200 Subject: [PATCH 14/46] refactor: one lifecycle abstraction instead of two The shared lifecycle keeps its struct, embedded by both worker handlers; the interface that named the one step they supply is gone, that step is a parameter. --- threadbackgroundworker.go | 2 +- threadworker.go | 2 +- workerlifecycle.go | 22 +++++++--------------- 3 files changed, 9 insertions(+), 17 deletions(-) diff --git a/threadbackgroundworker.go b/threadbackgroundworker.go index 89454305b5..e790bd11b6 100644 --- a/threadbackgroundworker.go +++ b/threadbackgroundworker.go @@ -81,7 +81,7 @@ func (handler *backgroundWorkerThread) drain() { } func (handler *backgroundWorkerThread) beforeScriptExecution() string { - return handler.workerLifecycle.beforeScriptExecution(handler) + return handler.workerLifecycle.beforeScriptExecution(handler.startScript) } // startScript keeps trying to start the script: unlike an HTTP worker, whose diff --git a/threadworker.go b/threadworker.go index f1808ff323..e57b117b67 100644 --- a/threadworker.go +++ b/threadworker.go @@ -30,7 +30,7 @@ func convertToWorkerThread(thread *phpThread, worker *worker) { } func (handler *workerThread) beforeScriptExecution() string { - return handler.workerLifecycle.beforeScriptExecution(handler) + return handler.workerLifecycle.beforeScriptExecution(handler.startScript) } // startScript runs the worker script; it always has one to run diff --git a/workerlifecycle.go b/workerlifecycle.go index a0b0906f1f..4d1809ee22 100644 --- a/workerlifecycle.go +++ b/workerlifecycle.go @@ -4,30 +4,22 @@ 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 supply what differs, -// see workerHandler. +// 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 } -// workerHandler is a threadHandler running a worker script, plus the step -// the shared lifecycle delegates -type workerHandler interface { - threadHandler - // startScript prepares a run and returns the script to execute, or an - // empty string to stop the thread - startScript() string -} - 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 -func (l *workerLifecycle) beforeScriptExecution(handler workerHandler) string { +// 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() @@ -39,13 +31,13 @@ func (l *workerLifecycle) beforeScriptExecution(handler workerHandler) string { l.worker.onThreadReady(l.thread.threadIndex) } - return handler.startScript() + return startScript() case state.Rebooting, state.ForceRebooting: return "" case state.RebootReady: l.state.Set(state.Ready) - return handler.beforeScriptExecution() + return l.beforeScriptExecution(startScript) case state.ShuttingDown: l.detach() From bcd25757dc487d2e5bd3f3e223f15a045ebb0d6d Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 14 Sep 2026 07:17:26 +0200 Subject: [PATCH 15/46] fix: never block or race on the startup failure channel The channel was set to nil once Init() had decided, read without synchronization by the handlers, and the send blocked on a buffer sized to the thread count. A background worker can tick, exit and fail its next boot while Init() finishes, which an HTTP worker cannot since it blocks once ready: that exit could race the nil write, or block its thread on a full buffer. The channel now stays, an atomic startup flag gates the sends, and the send never blocks. --- threadbackgroundworker.go | 16 +++++++++------- threadworker.go | 4 ++-- worker.go | 25 ++++++++++++++++++++++++- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/threadbackgroundworker.go b/threadbackgroundworker.go index e790bd11b6..323f183d4e 100644 --- a/threadbackgroundworker.go +++ b/threadbackgroundworker.go @@ -99,8 +99,7 @@ func (handler *backgroundWorkerThread) startScript() string { // fail fast during startup so Init() surfaces the error to the // operator; past startup, back off and retry like a crash - if startupFailChan != nil { - startupFailChan <- err + if reportStartupFailure(err) { handler.thread.state.Set(state.ShuttingDown) return handler.beforeScriptExecution() @@ -215,14 +214,17 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { // restarting with a louder log line: silently giving up would leave // the server in a broken half-state with no clear way to recover. pastCap := worker.maxConsecutiveFailures >= 0 && handler.failureCount >= worker.maxConsecutiveFailures - if pastCap && startupFailChan != nil && !watcherIsEnabled { + if pastCap && !watcherIsEnabled { + var err error if exitStatus == 0 { - startupFailChan <- fmt.Errorf("background worker %s exits without calling frankenphp_worker_tick()", worker.fileName) + err = fmt.Errorf("background worker %s exits without calling frankenphp_worker_tick()", worker.fileName) } else { - startupFailChan <- fmt.Errorf("too many consecutive failures: background worker %s keeps crashing", worker.fileName) + err = fmt.Errorf("too many consecutive failures: background worker %s keeps crashing", worker.fileName) + } + if reportStartupFailure(err) { + handler.thread.state.Set(state.ShuttingDown) + return } - handler.thread.state.Set(state.ShuttingDown) - return } logLevel := slog.LevelWarn diff --git a/threadworker.go b/threadworker.go index e57b117b67..c37e1fb674 100644 --- a/threadworker.go +++ b/threadworker.go @@ -118,8 +118,8 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { 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 } diff --git a/worker.go b/worker.go index 7cb72aceab..44e4a020f1 100644 --- a/worker.go +++ b/worker.go @@ -47,6 +47,10 @@ var ( workers []*worker watcherIsEnabled bool startupFailChan chan error + // startupPhase is true while initWorkers() waits for the workers to + // boot, the only time a boot failure must reach startupFailChan: past + // that point the handlers log and keep restarting on their own + startupPhase atomic.Bool ) func initWorkers(opts []workerOpt) error { @@ -92,6 +96,7 @@ func initWorkers(opts []workerOpt) error { } startupFailChan = make(chan error, totalThreadsToStart) + startupPhase.Store(true) for _, w := range workers { for range w.num { @@ -109,6 +114,7 @@ func initWorkers(opts []workerOpt) error { } workersReady.Wait() + startupPhase.Store(false) select { case err := <-startupFailChan: @@ -116,12 +122,29 @@ func initWorkers(opts []workerOpt) error { return fmt.Errorf("failed to initialize workers: %w", err) default: // all workers started successfully - startupFailChan = nil } return nil } +// reportStartupFailure hands a boot failure to initWorkers() while it waits +// for the workers, so Init() fails, and reports whether it did: past that +// point the failure is dropped, the handler has logged it and keeps +// restarting. It never blocks, the buffer holds one error per thread and a +// thread failing repeatedly in the startup window must not hang on it +func reportStartupFailure(err error) bool { + if !startupPhase.Load() { + return false + } + + select { + case startupFailChan <- err: + default: + } + + return true +} + func newWorker(o workerOpt) (*worker, error) { // Order is important! // This order ensures that FrankenPHP started from inside a symlinked directory will properly resolve any paths. From 54258b55b0ced632ff9a067647b97d5fec27f122 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 14 Sep 2026 07:30:23 +0200 Subject: [PATCH 16/46] feat: max_execution_time bounds the bootstrap of a background worker Until its first frankenphp_worker_tick(), a run is under the limit like any request: a setup that outlives it ends as a boot failure, with the backoff and the cap. The first tick disarms the timer, and nothing re-arms it past that point, so the loop has no time limit, like the CLI. This replaces the per-request ini override, which exempted the bootstrap too. --- bgworker_test.go | 36 +++++++++++++++++++++++++++++---- docs/config.md | 4 ++-- docs/worker.md | 2 +- frankenphp.c | 24 +++++----------------- frankenphp.stub.php | 3 ++- frankenphp_arginfo.h | 2 +- testdata/bgworker/slow-boot.php | 11 ++++++++++ 7 files changed, 54 insertions(+), 28 deletions(-) create mode 100644 testdata/bgworker/slow-boot.php diff --git a/bgworker_test.go b/bgworker_test.go index df9dfd1c40..fedaf5f372 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -512,11 +512,39 @@ func TestBackgroundWorkerThreadsComeOnTopOfAutoMaxThreads(t *testing.T) { assert.Equal(t, 6, len(state.ThreadDebugStates)+state.ReservedThreadCount) } +// TestBackgroundWorkerBootstrapIsBounded checks that max_execution_time +// applies until the first frankenphp_worker_tick(): a setup that outlives +// it is ended as a boot failure, which fails Init() past the cap. +func TestBackgroundWorkerBootstrapIsBounded(t *testing.T) { + if !frankenphp.Config().ZendMaxExecutionTimers && runtime.GOOS != "windows" { + t.Skip("max_execution_time needs Zend max execution timers on this platform") + } + + 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") +} + // TestBackgroundWorkerParkingIsNotInterrupted checks that a script parked -// on its handle is not cut short by the two limits it never disables -// itself: max_execution_time, which php_execute_script() re-arms from the -// ini when max_input_time is set, and default_socket_timeout, which the -// handle overrides with an infinite read timeout. +// on its handle, past its first tick, is not cut short by the two limits it +// never disables itself: max_execution_time, which the first tick disarms +// after php_execute_script() re-armed it from the ini, and +// default_socket_timeout, which the handle overrides with an infinite read +// timeout. func TestBackgroundWorkerParkingIsNotInterrupted(t *testing.T) { countFile := filepath.Join(t.TempDir(), "runs") initServers(t, diff --git a/docs/config.md b/docs/config.md index 9929ce22ea..5c055400b0 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. Must be unique among global workers. Default: absolute path of the 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. - background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker; it runs the script in a loop without serving requests, "name" is required, "match" is not allowed. The script is ready once it calls frankenphp_worker_tick(); an exit before that counts as a failure. $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] holds its name, and FRANKENPHP_WORKER is not set in it. Its threads come on top of num_threads and max_threads, and max_threads is not allowed on it. + background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker; it runs the script in a loop without serving requests, "name" is required, "match" is not allowed. The script is ready once it calls frankenphp_worker_tick(); an exit before that counts as a failure, and max_execution_time applies until that call, not after. $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] holds its name, and FRANKENPHP_WORKER is not set in it. Its threads come on top of num_threads and max_threads, and max_threads is not allowed on it. } } } @@ -199,7 +199,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; it runs the script in a loop without serving requests, "name" is required, "match" is not allowed. The script is ready once it calls frankenphp_worker_tick(); an exit before that counts as a failure. $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] holds its name, and FRANKENPHP_WORKER is not set in it. Its threads come on top of num_threads and max_threads, and max_threads is not allowed on it. + background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker; it runs the script in a loop without serving requests, "name" is required, "match" is not allowed. The script is ready once it calls frankenphp_worker_tick(); an exit before that counts as a failure, and max_execution_time applies until that call, not after. $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] holds its name, and FRANKENPHP_WORKER is not set in it. Its threads come on top of num_threads and max_threads, and max_threads is not allowed on it. } worker # Can also use the short form like in the global frankenphp block. } diff --git a/docs/worker.md b/docs/worker.md index 9836b05422..67d803a98b 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -216,7 +216,7 @@ php_server { } ``` -The script calls `frankenphp_worker_tick()` 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. 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 stream returned by `frankenphp_get_worker_handle()`, alone or together with its own streams. The stream becomes readable when FrankenPHP needs the script's attention, and `frankenphp_worker_tick()` consumes whatever was written on it, so the script does not read the stream itself. It is readable once right after the script starts, so a loop that services it ticks by itself and the worker is ready as soon as its loop runs. +The script calls `frankenphp_worker_tick()` 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. 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 stream returned by `frankenphp_get_worker_handle()`, alone or together with its own streams. The stream becomes readable when FrankenPHP needs the script's attention, and `frankenphp_worker_tick()` consumes whatever was written on it, so the script does not read the stream itself. It is readable once right after the script starts, so a loop that services it ticks by itself and the worker is ready as soon as its loop runs. ```php Date: Mon, 14 Sep 2026 07:49:21 +0200 Subject: [PATCH 17/46] docs: stop the whole loop on drain in the Revolt example Cancelling the handle's watcher only removes that one callback, and run() keeps going while any other referenced watcher exists. A drained worker has to leave its loop, which is the driver's stop(). --- docs/worker.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/worker.md b/docs/worker.md index 67d803a98b..55d9bdcc12 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -242,10 +242,10 @@ With an event loop, register the stream as readable and call `frankenphp_worker_ use Revolt\EventLoop; $handle = frankenphp_get_worker_handle(); -EventLoop::onReadable($handle, function (string $id): void { +EventLoop::onReadable($handle, function (): void { if (!frankenphp_worker_tick()) { - // drained: the loop ends once nothing else is pending - EventLoop::cancel($id); + // drained: stop the loop, the script returns and FrankenPHP moves on + EventLoop::getDriver()->stop(); } }); From c751f321871119eaab52b4e127ec451579e7bd0b Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 14 Sep 2026 08:07:14 +0200 Subject: [PATCH 18/46] test: only assert the bounded bootstrap where the timers are known to fire The limit is PHP's: on Windows CI the busy bootstrap ran its full five seconds without the timer ending it, so the test now runs only with the Zend max execution timers of ZTS builds on Linux, where it passes. --- bgworker_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bgworker_test.go b/bgworker_test.go index fedaf5f372..2a99f9448d 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -514,10 +514,12 @@ func TestBackgroundWorkerThreadsComeOnTopOfAutoMaxThreads(t *testing.T) { // TestBackgroundWorkerBootstrapIsBounded checks that max_execution_time // applies until the first frankenphp_worker_tick(): a setup that outlives -// it is ended as a boot failure, which fails Init() past the cap. +// it is ended as a boot failure, which fails Init() past the cap. The limit +// itself is PHP's, so the test only runs where its timers are known to +// fire under FrankenPHP, the max execution timers of ZTS builds on Linux. func TestBackgroundWorkerBootstrapIsBounded(t *testing.T) { - if !frankenphp.Config().ZendMaxExecutionTimers && runtime.GOOS != "windows" { - t.Skip("max_execution_time needs Zend max execution timers on this platform") + if !frankenphp.Config().ZendMaxExecutionTimers { + t.Skip("max_execution_time is only reliable with Zend max execution timers") } countFile := filepath.Join(t.TempDir(), "boots") From 6d98cf40953bcdd5cdfd4b4839fd20b815972ec2 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 14 Sep 2026 21:18:36 +0200 Subject: [PATCH 19/46] feat: num defaults to one thread for background workers A background worker serves no requests, so it does not scale with the CPUs like an HTTP one and nearly every declaration wrote "num 1". It is now the default, and declaring "background" is enough; a pool still asks for the threads it wants. --- bgworker_test.go | 32 ++++++++++++++++++++++++-------- caddy/config_test.go | 6 +++--- caddy/workerconfig.go | 3 --- docs/config.md | 4 ++-- docs/worker.md | 2 +- frankenphp.go | 14 ++++++-------- 6 files changed, 36 insertions(+), 25 deletions(-) diff --git a/bgworker_test.go b/bgworker_test.go index 2a99f9448d..f395ff4c6f 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "time" @@ -144,14 +145,6 @@ func TestBackgroundWorkerValidation(t *testing.T) { require.ErrorContains(t, err, "must have an explicit name") }) - t.Run("num must be >= 1", func(t *testing.T) { - err := frankenphp.Init( - frankenphp.WithWorkers("bg-zero", "testdata/bgworker/basic.php", 0, frankenphp.WithWorkerBackground()), - frankenphp.WithNumThreads(2), - ) - require.ErrorContains(t, err, "must declare num >= 1") - }) - 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 @@ -490,6 +483,29 @@ func TestBackgroundWorkerThreadsComeOnTop(t *testing.T) { requireFileEventually(t, sentinel, "background worker did not start with num_threads 1") } +// TestBackgroundWorkerDefaultsToOneThread checks that num is optional: a +// background worker serves no requests, so it gets one thread unless a +// pool is asked for, rather than 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) +} + // TestBackgroundWorkerThreadsComeOnTopOfAutoMaxThreads checks that the // automatic limit is an HTTP one too: the reservation is added to what it // resolves, instead of eating into it diff --git a/caddy/config_test.go b/caddy/config_test.go index d71b307bb4..933769fd78 100644 --- a/caddy/config_test.go +++ b/caddy/config_test.go @@ -323,7 +323,7 @@ func TestWorkerBackgroundRequiresName(t *testing.T) { require.ErrorContains(t, err, `background workers must have an explicit "name"`) } -func TestWorkerBackgroundRequiresNum(t *testing.T) { +func TestWorkerBackgroundWithoutNumParses(t *testing.T) { d := caddyfile.NewTestDispenser(` { php_server { @@ -336,8 +336,8 @@ func TestWorkerBackgroundRequiresNum(t *testing.T) { }`) module := &FrankenPHPModule{} - err := module.UnmarshalCaddyfile(d) - require.ErrorContains(t, err, `background workers must declare "num" >= 1`) + // num is optional, it defaults to one thread when the workers start + require.NoError(t, module.UnmarshalCaddyfile(d)) } func TestWorkerBackgroundRejectsMatch(t *testing.T) { diff --git a/caddy/workerconfig.go b/caddy/workerconfig.go index 7f5c15b253..326b99a045 100644 --- a/caddy/workerconfig.go +++ b/caddy/workerconfig.go @@ -159,9 +159,6 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) { if len(wc.MatchPath) != 0 { return wc, d.Err(`"match" is not supported for background workers`) } - if wc.Num < 1 { - return wc, d.Err(`background workers must declare "num" >= 1`) - } } if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) { diff --git a/docs/config.md b/docs/config.md index 5c055400b0..f3f17ebee3 100644 --- a/docs/config.md +++ b/docs/config.md @@ -106,7 +106,7 @@ 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. Must be unique among global workers. Default: absolute path of the worker file. @@ -194,7 +194,7 @@ php_server [] { 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 + 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 and metrics, the worker is reported as ":". Default: absolute path of the worker file. 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. diff --git a/docs/worker.md b/docs/worker.md index 55d9bdcc12..09473d1f10 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -203,7 +203,7 @@ frankenphp { 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` must be at least 1: +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 { diff --git a/frankenphp.go b/frankenphp.go index 0370bff2c8..cf70cd3344 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -225,15 +225,13 @@ func calculateMaxThreads(opt *opt) (numWorkers, backgroundThreads int, _ error) for i, w := range opt.workers { if w.isBackgroundWorker { - if w.num < 1 { - name := w.name - if name == "" { - name = w.fileName - } - - return 0, 0, fmt.Errorf("background worker %q must declare num >= 1", name) + 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 += w.num + backgroundThreads += opt.workers[i].num continue } From b356adce8a44e46b3281cc86f9e16ae669b52000 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 14 Sep 2026 21:18:36 +0200 Subject: [PATCH 20/46] chore: review polish The missing stop socket of a background worker is an invariant, not a runtime error: the pair is opened before the script starts and closed at the next run setup, so the two guards are asserts now. A thread reaching the ready callback without a background handler would wait out Init() silently, so it panics instead. The context of a background run is not a dummy request, and the field says so. The scope of a name collision is a local variable rather than a method, and the Caddyfile reference keeps the short version of the "background" line, the long one lives in the worker documentation. --- docs/config.md | 4 ++-- frankenphp.c | 16 ++++------------ server.go | 15 ++++++--------- threadbackgroundworker.go | 21 ++++++++++++++------- 4 files changed, 26 insertions(+), 30 deletions(-) diff --git a/docs/config.md b/docs/config.md index f3f17ebee3..eccca79694 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. Must be unique among global workers. Default: absolute path of the 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. - background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker; it runs the script in a loop without serving requests, "name" is required, "match" is not allowed. The script is ready once it calls frankenphp_worker_tick(); an exit before that counts as a failure, and max_execution_time applies until that call, not after. $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] holds its name, and FRANKENPHP_WORKER is not set in it. Its threads come on top of num_threads and max_threads, and max_threads is not allowed on it. + background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, requiring "name" and a script calling frankenphp_worker_tick() at least once } } } @@ -199,7 +199,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; it runs the script in a loop without serving requests, "name" is required, "match" is not allowed. The script is ready once it calls frankenphp_worker_tick(); an exit before that counts as a failure, and max_execution_time applies until that call, not after. $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] holds its name, and FRANKENPHP_WORKER is not set in it. Its threads come on top of num_threads and max_threads, and max_threads is not allowed on it. + background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, requiring "name" and a script calling frankenphp_worker_tick() at least once } worker # Can also use the short form like in the global frankenphp block. } diff --git a/frankenphp.c b/frankenphp.c index b89ac5b73e..be783065af 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1193,12 +1193,9 @@ PHP_FUNCTION(frankenphp_get_worker_handle) { RETURN_THROWS(); } - if (worker_stop_socks[0] == SOCK_ERR) { - zend_throw_exception(spl_ce_RuntimeException, - "the background worker stop socket is not available", - 0); - RETURN_THROWS(); - } + /* the pair is opened before the script starts and closed at the next run + * setup or on thread exit, so a run always has one */ + ZEND_ASSERT(worker_stop_socks[0] != SOCK_ERR); /* One stream per run: the same resource is returned until the script * closes it, so fetching the handle in a loop does not grow the resource @@ -1253,12 +1250,7 @@ PHP_FUNCTION(frankenphp_worker_tick) { RETURN_THROWS(); } - if (worker_stop_socks[0] == SOCK_ERR) { - zend_throw_exception(spl_ce_RuntimeException, - "the background worker stop socket is not available", - 0); - RETURN_THROWS(); - } + ZEND_ASSERT(worker_stop_socks[0] != SOCK_ERR); if (!worker_ticked) { worker_ticked = true; diff --git a/server.go b/server.go index 332eeafa5f..70f23a335f 100644 --- a/server.go +++ b/server.go @@ -158,18 +158,15 @@ func (s *Server) Name() string { } // addWorker registers a worker scoped to this server -// scope names the worker set in errors: a server, or the global workers -func (s *Server) scope() string { +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 { - return "two global workers" + scope = "two global workers" } - return "two workers in a server" -} - -func (s *Server) addWorker(w *worker) error { if s.workersByName[w.name] != nil { - return fmt.Errorf("%s cannot have the same name: %q", s.scope(), w.name) + return fmt.Errorf("%s cannot have the same name: %q", scope, w.name) } s.workersByName[w.name] = w @@ -184,7 +181,7 @@ func (s *Server) addWorker(w *worker) error { } if s.workersByPath[w.fileName] != nil { - return fmt.Errorf("%s cannot have the same filename: %q", s.scope(), w.fileName) + return fmt.Errorf("%s cannot have the same filename: %q", scope, w.fileName) } s.workersByPath[w.fileName] = w diff --git a/threadbackgroundworker.go b/threadbackgroundworker.go index 323f183d4e..4d260b569d 100644 --- a/threadbackgroundworker.go +++ b/threadbackgroundworker.go @@ -24,8 +24,9 @@ import ( type backgroundWorkerThread struct { workerLifecycle - dummyFrankenPHPContext *frankenPHPContext - failureCount int // number of consecutive failed runs + // context of the current run, a background worker serves no request + context *frankenPHPContext + failureCount int // number of consecutive failed runs // crashCount is the number of runs that crashed past their ready point // in a row, paces their restarts; a cooperative exit resets it. Only @@ -67,7 +68,7 @@ func (handler *backgroundWorkerThread) name() string { } func (handler *backgroundWorkerThread) frankenPHPContext() *frankenPHPContext { - return handler.dummyFrankenPHPContext + return handler.context } // drain closes the Go side's end of the stop socket pair so a script @@ -134,7 +135,7 @@ func (handler *backgroundWorkerThread) setupScript() error { handler.drain() return err } - handler.dummyFrankenPHPContext = fc + handler.context = fc handler.isBootingScript = true metrics.StartWorker(handler.worker.qualifiedName) @@ -164,7 +165,7 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { // (drain() already took it when the exit was drain-triggered) handler.drain() worker := handler.worker - handler.dummyFrankenPHPContext = nil + handler.context = nil handler.stopBootTimer() handler.state.MarkAsWaiting(false) @@ -254,8 +255,14 @@ func (handler *backgroundWorkerThread) stopBootTimer() { func go_frankenphp_background_worker_ready(threadIndex C.uintptr_t) { // called on the PHP thread by the first frankenphp_worker_tick() of a // run; the handler is a backgroundWorkerThread because that function - // throws on every other thread kind - if handler, ok := phpThreads[threadIndex].handler.(*backgroundWorkerThread); ok && handler.isBootingScript { + // throws on every other thread kind, and a thread reaching this without + // one would wait out Init() instead + handler, ok := phpThreads[threadIndex].handler.(*backgroundWorkerThread) + if !ok { + panic("frankenphp_worker_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 From 6aae1f4c9e94bf4a7ac48ab79655b51c743318dc Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 14 Sep 2026 21:35:05 +0200 Subject: [PATCH 21/46] test: the tick leaves the handle quiet A loop selecting on the handle blocks rather than spinning, because the tick consumed the wake-ups, the one sent at start included. The fixture polls the handle before and after a tick and the docs say so. --- bgworker_test.go | 17 +++++++++++++++++ docs/worker.md | 2 +- testdata/bgworker/readable.php | 25 +++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 testdata/bgworker/readable.php diff --git a/bgworker_test.go b/bgworker_test.go index f395ff4c6f..63b74f6919 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -365,6 +365,23 @@ func TestBackgroundWorkerLoopTicksOnItsOwn(t *testing.T) { requireFileEventually(t, sentinel, "background worker did not start") } +// TestBackgroundWorkerTickLeavesTheHandleQuiet checks that the tick +// consumes the wake-ups: the handle is readable at start, and not anymore +// 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)) +} + // TestBackgroundWorkerTick checks the contract of frankenphp_worker_tick(): // true while the worker runs, false once it is drained, and still false on // the next call diff --git a/docs/worker.md b/docs/worker.md index 09473d1f10..1c7aa4e147 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -216,7 +216,7 @@ php_server { } ``` -The script calls `frankenphp_worker_tick()` 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. 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 stream returned by `frankenphp_get_worker_handle()`, alone or together with its own streams. The stream becomes readable when FrankenPHP needs the script's attention, and `frankenphp_worker_tick()` consumes whatever was written on it, so the script does not read the stream itself. It is readable once right after the script starts, so a loop that services it ticks by itself and the worker is ready as soon as its loop runs. +The script calls `frankenphp_worker_tick()` 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. 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 stream returned by `frankenphp_get_worker_handle()`, alone or together with its own streams. The stream becomes readable when FrankenPHP needs the script's attention, and `frankenphp_worker_tick()` consumes whatever was written on it, so the script does not read the stream itself and the stream is quiet again until the next wake-up. It is readable once right after the script starts, so a loop that services it ticks by itself and the worker is ready as soon as its loop runs. ```php 0 ? 'readable' : 'quiet'; +}; + +$seen = ['start:' . $poll()]; +frankenphp_worker_tick(); +$seen[] = 'after tick:' . $poll(); +frankenphp_worker_tick(); +$seen[] = 'after second tick:' . $poll(); +file_put_contents($_SERVER['BG_SENTINEL'], implode(' ', $seen)); + +while (frankenphp_worker_tick()) { + $read = [$handle]; + $write = $except = null; + stream_select($read, $write, $except, null); +} From 080f81132533e607f0c0fae3a9187ae6b4803d02 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Thu, 17 Sep 2026 11:09:10 +0200 Subject: [PATCH 22/46] fix: keep generating unique names for workers that share a script Two workers on one script, told apart by a matcher, are the documented way to give slow endpoints their own thread pool. The Caddy module used to make their generated names unique, this PR moved the collision check into the core and dropped that, so the configuration stopped booting. A name generated from the script path is not a declaration: it gets a numeric suffix, as before. A declared name still collides, which is what a background worker needs to keep its identity. --- docs/config.md | 4 ++-- server_test.go | 27 +++++++++++++++++++++++++++ worker.go | 24 ++++++++++++++++-------- 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/docs/config.md b/docs/config.md index eccca79694..2ed281cf4a 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. + 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. background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, requiring "name" and a script calling frankenphp_worker_tick() at least once } @@ -195,7 +195,7 @@ php_server [] { 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 and metrics, the worker is reported as ":". Default: absolute path of the worker file. + name # Sets the name for the worker, used in logs and metrics. Must be unique within this php_server. In logs and metrics, the worker is reported as ":". 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. diff --git a/server_test.go b/server_test.go index 4bd078040c..e7dd9981ab 100644 --- a/server_test.go +++ b/server_test.go @@ -101,6 +101,33 @@ func TestServer(t *testing.T) { 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) diff --git a/worker.go b/worker.go index 44e4a020f1..f2d1936afe 100644 --- a/worker.go +++ b/worker.go @@ -182,8 +182,23 @@ func newWorker(o workerOpt) (*worker, error) { } } + // a worker declared without a scope belongs to the fallback server, the + // one serving the requests that have no server either, so a worker + // always has one + scope := o.server + if scope == nil { + scope = fallbackServer + } + if o.name == "" { + // a name generated from the script path is not a declaration: + // several workers may share a script, a pool split by a matcher + // for instance, so it is made unique rather than reported as the + // collision a declared name gets o.name = absFileName + for suffix := 1; scope.workersByName[o.name] != nil; suffix++ { + o.name = fmt.Sprintf("%s_%d", absFileName, suffix) + } } // no server means no set of requests to match against, the matcher would never run @@ -234,17 +249,10 @@ func newWorker(o workerOpt) (*worker, error) { maxConsecutiveFailures: o.maxConsecutiveFailures, onThreadReady: o.onThreadReady, onThreadShutdown: o.onThreadShutdown, - server: o.server, + server: scope, isBackgroundWorker: o.isBackgroundWorker, } - // a worker declared without a scope belongs to the fallback server, the - // one serving the requests that have no server either, so a worker - // always has one - if w.server == nil { - w.server = fallbackServer - } - w.configureMercure(&o) w.requestOptions = append( From e6bb0481f10beef1f11e10d401240e0e94fe384f Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Thu, 17 Sep 2026 11:36:04 +0200 Subject: [PATCH 23/46] feat: report the server of a worker as its own metric label Packing the server into the worker name changed every label value of a php_server worker, which breaks the dashboards and alerts built on them. The two are separate labels now, worker="" and server="", empty for a global worker, so a query on the worker name alone selects that worker in every server and the values are the ones FrankenPHP always reported. --- bgworker_test.go | 19 --------- caddy/caddy_test.go | 50 ++++++++++++------------ docs/metrics.md | 20 +++++----- metrics.go | 81 +++++++++++++++++++++------------------ metrics_test.go | 28 +++++++------- threadbackgroundworker.go | 10 ++--- threadworker.go | 10 ++--- worker.go | 31 ++++++--------- 8 files changed, 115 insertions(+), 134 deletions(-) diff --git a/bgworker_test.go b/bgworker_test.go index 63b74f6919..24a346de4f 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -198,25 +198,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) diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index 43ac10513e..b51b866131 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -868,19 +868,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 reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_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() @@ -943,7 +943,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") { @@ -1025,19 +1025,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 reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_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() @@ -1121,19 +1121,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 reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_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() @@ -1387,7 +1387,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() @@ -1488,21 +1488,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 reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_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() @@ -1655,10 +1655,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, frankenphp_worker_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, @@ -1683,13 +1683,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, frankenphp_worker_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, diff --git a/docs/metrics.md b/docs/metrics.md index 8def3085da..ae72740745 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -15,16 +15,16 @@ 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 reached their ready point at least once: `frankenphp_handle_request()` for HTTP workers, `frankenphp_worker_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. - -`[worker_name]` is the worker name from the Caddyfile, or the absolute path of the worker file when it has none. Workers of a `php_server` block are prefixed with the name of that block: `:`. They used to be reported under their bare name unless two blocks declared the same one, so dashboards and alerts built on those series need the prefix. +- `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, `frankenphp_worker_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. + +`[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. ## Threads State Endpoint diff --git a/metrics.go b/metrics.go index 4011db0d41..437f471728 100644 --- a/metrics.go +++ b/metrics.go @@ -16,15 +16,20 @@ const ( type StopReason int +// Metrics reports what the workers and the threads of a FrankenPHP instance +// are doing. A worker is identified by two values: its declared name, and +// the name of the server it is scoped to, empty for a global worker. They +// stay apart rather than packed into one string, so a series keyed on the +// name alone still selects the worker of every server. type Metrics interface { // StartWorker collects started workers - StartWorker(name string) + StartWorker(name, server string) // ReadyWorker collects ready workers - ReadyWorker(name string) + ReadyWorker(name, server string) // StopWorker collects stopped workers - StopWorker(name string, reason StopReason) + StopWorker(name, server string, reason StopReason) // TotalWorkers collects expected workers - TotalWorkers(name string, num int) + TotalWorkers(name, server string, num int) // TotalThreads collects total threads TotalThreads(num int) // StartRequest collects started requests @@ -32,28 +37,28 @@ type Metrics interface { // StopRequest collects stopped requests StopRequest() // StopWorkerRequest collects stopped worker requests - StopWorkerRequest(name string, duration time.Duration) + StopWorkerRequest(name, server string, duration time.Duration) // StartWorkerRequest collects started worker requests - StartWorkerRequest(name string) + StartWorkerRequest(name, server string) Shutdown() - QueuedWorkerRequest(name string) - DequeuedWorkerRequest(name string) + QueuedWorkerRequest(name, server string) + DequeuedWorkerRequest(name, server string) QueuedRequest() DequeuedRequest() } 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) { @@ -65,18 +70,18 @@ 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() {} @@ -106,7 +111,7 @@ func (m *PrometheusMetrics) mustRegister(c prometheus.Collector) { } } -func (m *PrometheusMetrics) StartWorker(name string) { +func (m *PrometheusMetrics) StartWorker(name, server string) { m.mu.RLock() defer m.mu.RUnlock() @@ -117,10 +122,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) ReadyWorker(name, server string) { m.mu.RLock() defer m.mu.RUnlock() @@ -128,10 +133,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) StopWorker(name, server string, reason StopReason) { m.mu.RLock() defer m.mu.RUnlock() @@ -142,27 +147,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 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) TotalWorkers(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{ @@ -260,7 +267,7 @@ func (m *PrometheusMetrics) StopRequest() { m.busyThreads.Dec() } -func (m *PrometheusMetrics) StopWorkerRequest(name string, duration time.Duration) { +func (m *PrometheusMetrics) StopWorkerRequest(name, server string, duration time.Duration) { m.mu.RLock() defer m.mu.RUnlock() @@ -268,39 +275,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) StartWorkerRequest(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) QueuedWorkerRequest(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) DequeuedWorkerRequest(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 7d721f0189..fdb8c459a5 100644 --- a/metrics_test.go +++ b/metrics_test.go @@ -51,7 +51,7 @@ func TestPrometheusMetrics_TotalWorkers(t *testing.T) { require.Nil(t, m.workerRequestTime) require.Nil(t, m.workerRequestCount) - m.TotalWorkers("test_worker", 2) + m.TotalWorkers("test_worker", "test_server", 2) require.NotNil(t, m.totalWorkers) require.NotNil(t, m.busyWorkers) @@ -64,8 +64,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.TotalWorkers("test_worker", "test_server", 2) + m.StopWorkerRequest("test_worker", "test_server", 2*time.Second) inputs := []struct { name string @@ -81,7 +81,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 `, }, { @@ -92,7 +92,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 `, }, { @@ -103,7 +103,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 `, }, } @@ -118,8 +118,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.TotalWorkers("test_worker", "test_server", 2) + m.StartWorkerRequest("test_worker", "test_server") inputs := []struct { name string @@ -135,7 +135,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 `, }, } @@ -150,8 +150,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.TotalWorkers("test_worker", "test_server", 2) + m.StopWorker("test_worker", "test_server", StopReasonCrash) inputs := []struct { name string @@ -178,7 +178,7 @@ 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 `, }, { @@ -189,7 +189,7 @@ func TestPrometheusMetrics_TestStopReasonCrash(t *testing.T) { # TYPE frankenphp_ready_workers gauge `, expect: ` - frankenphp_ready_workers{worker="test_worker"} -1 + frankenphp_ready_workers{server="test_server",worker="test_worker"} -1 `, }, { @@ -200,7 +200,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 `, }, } diff --git a/threadbackgroundworker.go b/threadbackgroundworker.go index 4d260b569d..4a3332e71e 100644 --- a/threadbackgroundworker.go +++ b/threadbackgroundworker.go @@ -138,7 +138,7 @@ func (handler *backgroundWorkerThread) setupScript() error { handler.context = fc handler.isBootingScript = true - metrics.StartWorker(handler.worker.qualifiedName) + metrics.StartWorker(handler.worker.name, handler.worker.server.name) // the run's logger and context, not the globals: Stop() does not wait // for a callback that already started, and a shutdown finishing // meanwhile resets those @@ -175,7 +175,7 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { // state) if exitStatus == 0 && !handler.isBootingScript { handler.crashCount = 0 - metrics.StopWorker(worker.qualifiedName, StopReasonRestart) + 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)) @@ -191,7 +191,7 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { // paced by traffic, a background worker reaches its ready point on its // own and a script crashing right after it would spin if !handler.isBootingScript { - metrics.StopWorker(worker.qualifiedName, StopReasonCrash) + 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)) @@ -207,7 +207,7 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { // a clean exit included, which would otherwise respawn in a tight loop. // StopReasonBootFailure skips the ready-gauge decrement, matching the // ReadyWorker call that never happened - metrics.StopWorker(worker.qualifiedName, StopReasonBootFailure) + metrics.StopWorker(worker.name, worker.server.name, StopReasonBootFailure) // max_consecutive_failures only fails hard during startup, where it // surfaces on startupFailChan so Init() returns the error to the @@ -267,7 +267,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.qualifiedName) + 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) diff --git a/threadworker.go b/threadworker.go index c37e1fb674..6a2bae10a1 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.qualifiedName) + metrics.StartWorker(worker.name, worker.server.name) // Create a dummy request to set up the worker fc, err := newWorkerDummyContext(worker) @@ -91,7 +91,7 @@ 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.qualifiedName, 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.qualifiedName), slog.Int("thread", handler.thread.threadIndex), slog.Int("exit_status", exitStatus)) @@ -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.qualifiedName, StopReasonBootFailure) + metrics.StopWorker(worker.name, worker.server.name, StopReasonBootFailure) } else { - metrics.StopWorker(worker.qualifiedName, StopReasonCrash) + metrics.StopWorker(worker.name, worker.server.name, StopReasonCrash) } if !handler.isBootingScript { @@ -173,7 +173,7 @@ func (handler *workerThread) waitForWorkerRequest() (bool, any) { } // worker is truly ready only after reaching frankenphp_handle_request() - metrics.ReadyWorker(handler.worker.qualifiedName) + metrics.ReadyWorker(handler.worker.name, handler.worker.server.name) } // max_requests reached: signal reboot for full ZTS cleanup diff --git a/worker.go b/worker.go index f2d1936afe..4ce6a1b549 100644 --- a/worker.go +++ b/worker.go @@ -23,8 +23,10 @@ type worker struct { // name as declared, unique within its server (or among global workers) name string - // qualifiedName is unique across the process: ":" for - // server-scoped workers, name otherwise; used for metrics and logs + // qualifiedName identifies the worker in logs and errors, where one + // string reads better than two fields: ":" for a + // server-scoped worker, the name otherwise. Metrics keep the two apart, + // see the Metrics interface qualifiedName string fileName string matchRequest func(*http.Request) bool @@ -64,7 +66,6 @@ func initWorkers(opts []workerOpt) error { ) workers = make([]*worker, 0, len(opts)) - qualifiedNames := make(map[string]bool, len(opts)) for _, o := range opts { w, err := newWorker(o) @@ -81,18 +82,10 @@ func initWorkers(opts []workerOpt) error { return err } - // scoping makes qualified names unique in all but pathological - // cases: a global worker may still be named like the ":" - // of a scoped one, and metrics would merge the two series - if qualifiedNames[w.qualifiedName] { - return fmt.Errorf("two workers cannot report under the same name: %q", w.qualifiedName) - } - qualifiedNames[w.qualifiedName] = true - totalThreadsToStart += w.num workers = append(workers, w) // reported here rather than in calculateMaxThreads(), where the name is not resolved yet - metrics.TotalWorkers(w.qualifiedName, w.num) + metrics.TotalWorkers(w.name, w.server.name, w.num) } startupFailChan = make(chan error, totalThreadsToStart) @@ -319,7 +312,7 @@ func (worker *worker) isAtThreadLimit() bool { } func (worker *worker) handleRequest(fc *frankenPHPContext) error { - metrics.StartWorkerRequest(worker.qualifiedName) + metrics.StartWorkerRequest(worker.name, worker.server.name) runtime.Gosched() @@ -331,7 +324,7 @@ func (worker *worker) handleRequest(fc *frankenPHPContext) error { case thread.requestChan <- fc: worker.threadMutex.RUnlock() <-fc.done - metrics.StopWorkerRequest(worker.qualifiedName, time.Since(fc.startedAt)) + metrics.StopWorkerRequest(worker.name, worker.server.name, time.Since(fc.startedAt)) return nil default: @@ -343,7 +336,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.qualifiedName) + metrics.QueuedWorkerRequest(worker.name, worker.server.name) for { workerScaleChan := scaleChan @@ -354,9 +347,9 @@ func (worker *worker) handleRequest(fc *frankenPHPContext) error { select { case worker.requestChan <- fc: worker.queuedRequests.Add(-1) - metrics.DequeuedWorkerRequest(worker.qualifiedName) + metrics.DequeuedWorkerRequest(worker.name, worker.server.name) <-fc.done - metrics.StopWorkerRequest(worker.qualifiedName, time.Since(fc.startedAt)) + metrics.StopWorkerRequest(worker.name, worker.server.name, time.Since(fc.startedAt)) return nil case workerScaleChan <- fc: @@ -364,8 +357,8 @@ func (worker *worker) handleRequest(fc *frankenPHPContext) error { case <-timeoutChan(time.Duration(maxWaitTime.Load())): // the request has timed out stalling worker.queuedRequests.Add(-1) - metrics.DequeuedWorkerRequest(worker.qualifiedName) - metrics.StopWorkerRequest(worker.qualifiedName, 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) From bac97aeb91c71d92568d780ce000526e82c747e4 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Thu, 17 Sep 2026 17:13:24 +0200 Subject: [PATCH 24/46] fix: address the review of the background worker lifecycle - pace a run that ends right after its ready point, clean or crashed, and cut the wait short on drain - route extension SendRequest() to its worker directly and make SendMessage() fail after Shutdown() - keep the declared path as the default name of a global Caddy worker - guard the background run context with contextMu - one drain owner on phpThread, one background TLS reset in C, the public read-timeout stream option - docs: metric labels, the platform condition of the bootstrap bound, stream_select() and FD_SETSIZE --- bgworker_test.go | 38 ++++++++++++-- caddy/app.go | 6 +++ docs/config.md | 2 +- docs/library.md | 2 +- docs/worker.md | 4 +- frankenphp.c | 32 +++++++----- phpmainthread.go | 6 +-- phpthread.go | 19 ++++--- requestoptions.go | 9 ++++ testdata/bgworker/exit-after-tick.php | 7 +++ threadbackgroundworker.go | 72 +++++++++++++++++---------- threadworker.go | 5 +- workerextension.go | 10 ++-- workerextension_test.go | 40 ++++++++++++++- 14 files changed, 186 insertions(+), 66 deletions(-) create mode 100644 testdata/bgworker/exit-after-tick.php diff --git a/bgworker_test.go b/bgworker_test.go index 24a346de4f..2dea9343dd 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -49,6 +49,7 @@ 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(), @@ -245,6 +246,7 @@ 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(), @@ -272,6 +274,7 @@ func TestBackgroundWorkerParksOnRead(t *testing.T) { 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(), @@ -369,6 +372,7 @@ func TestBackgroundWorkerTickLeavesTheHandleQuiet(t *testing.T) { 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(), @@ -589,6 +593,7 @@ func TestBackgroundWorkerParkingIsNotInterrupted(t *testing.T) { func TestBackgroundWorkerHandleClosedAndFetchedAgain(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(), @@ -631,9 +636,9 @@ func TestBackgroundWorkerBootFailuresThenSucceeds(t *testing.T) { } // TestBackgroundWorkerCrashAfterReadyRestarts checks that a crash after the -// ready point restarts right away without counting toward -// max_consecutive_failures, and that a zero-timeout stream_select() counts -// as the wait. +// ready point restarts without counting toward max_consecutive_failures, +// that a zero-timeout stream_select() counts as the wait, and that a drain +// cuts the backoff short. func TestBackgroundWorkerCrashAfterReadyRestarts(t *testing.T) { countFile := filepath.Join(t.TempDir(), "runs") initServers(t, @@ -647,8 +652,33 @@ func TestBackgroundWorkerCrashAfterReadyRestarts(t *testing.T) { require.Eventually(t, func() bool { b, _ := os.ReadFile(countFile) - return bytes.Count(b, []byte("\n")) >= 4 + 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") +} + +// TestBackgroundWorkerCleanExitIsPaced checks that a script returning right +// after its tick is re-run with the crash backoff rather than at once. +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") } // TestBackgroundWorkerRebootForceKillsStuckScript checks that a script diff --git a/caddy/app.go b/caddy/app.go index 88636401c4..f77c834b2c 100644 --- a/caddy/app.go +++ b/caddy/app.go @@ -17,6 +17,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 ( @@ -131,6 +132,11 @@ func (f *FrankenPHPApp) Start() error { // register global workers for _, w := range f.Workers { w.FileName = repl.ReplaceKnown(w.FileName, "") + // the declared path, symlinks kept, so the metric label does not + // move when the target of a release symlink does + if w.Name == "" { + w.Name, _ = fastabs.FastAbs(w.FileName) + } opts, err := w.toWorkerOptions() if err != nil { return err diff --git a/docs/config.md b/docs/config.md index 2ed281cf4a..16dbd89b80 100644 --- a/docs/config.md +++ b/docs/config.md @@ -195,7 +195,7 @@ php_server [] { 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 and metrics, the worker is reported as ":". 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. 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. diff --git a/docs/library.md b/docs/library.md index 03f0ef5898..fa42cc13df 100644 --- a/docs/library.md +++ b/docs/library.md @@ -60,7 +60,7 @@ 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, while metrics and logs report a server-scoped worker as `:`; 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. +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. diff --git a/docs/worker.md b/docs/worker.md index 1c7aa4e147..b47c3c8141 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -216,7 +216,7 @@ php_server { } ``` -The script calls `frankenphp_worker_tick()` 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. 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 stream returned by `frankenphp_get_worker_handle()`, alone or together with its own streams. The stream becomes readable when FrankenPHP needs the script's attention, and `frankenphp_worker_tick()` consumes whatever was written on it, so the script does not read the stream itself and the stream is quiet again until the next wake-up. It is readable once right after the script starts, so a loop that services it ticks by itself and the worker is ready as soon as its loop runs. +The script calls `frankenphp_worker_tick()` 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 stream returned by `frankenphp_get_worker_handle()`, alone or together with its own streams. The stream becomes readable when FrankenPHP needs the script's attention, and `frankenphp_worker_tick()` consumes whatever was written on it, so the script does not read the stream itself and the stream is quiet again until the next wake-up. It is readable once right after the script starts, so a loop that services it ticks by itself and the worker is ready as soon as its loop runs. ```php abstract)->timeout.tv_sec = -1; + 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; @@ -1894,8 +1901,7 @@ static void *php_thread(void *arg) { * it here too so it does not outlive the thread on shutdown, reboot or an * unhealthy exit. The Go side's end is closed by the Go side. */ if (is_background_worker) { - is_background_worker = false; - frankenphp_worker_close_stop_socks(); + frankenphp_reset_background_worker(); } /* Must precede ts_free_thread: that frees the TSRM storage backing diff --git a/phpmainthread.go b/phpmainthread.go index db48197b22..441ffb6bfe 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -166,11 +166,7 @@ func (mainThread *phpMainThread) rebootAllThreads() bool { for _, thread := range rebootingThreads { rebootWg.Go(func() { - // wake up handlers parked in a blocking C call (background - // workers' stream_select on the stop socket) so they can yield - // for the reboot without waiting for the force-kill below - thread.handler.drain() - close(thread.drainChan) + thread.drain() if thread.state.WaitForStateWithTimeout(rebootGracePeriod, state.YieldingForReboot) { return } diff --git a/phpthread.go b/phpthread.go index f1d2f91f46..82e65fe8bc 100644 --- a/phpthread.go +++ b/phpthread.go @@ -118,10 +118,7 @@ func (thread *phpThread) shutdown() { return } - // wake up handlers parked in a blocking C call (background workers' - // stream_select on the stop socket); no-op for the other handlers - thread.handler.drain() - close(thread.drainChan) + thread.drain() // Arm force-kill after the grace period to wake any thread stuck in // a blocking syscall (sleep, blocking I/O). The wait remains @@ -159,10 +156,7 @@ func (thread *phpThread) setHandler(handler threadHandler) { return } - // wake up a handler parked in a blocking C call (background workers' - // stream_select on the stop socket) so it can yield for the transition - thread.handler.drain() - close(thread.drainChan) + thread.drain() thread.state.WaitFor(state.TransitionInProgress) thread.handler = handler @@ -170,6 +164,15 @@ 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 (background workers' stream_select +// on the stop socket), 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 00c5892f05..d424d20b16 100644 --- a/requestoptions.go +++ b/requestoptions.go @@ -206,6 +206,15 @@ 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 { diff --git a/testdata/bgworker/exit-after-tick.php b/testdata/bgworker/exit-after-tick.php new file mode 100644 index 0000000000..12afa21984 --- /dev/null +++ b/testdata/bgworker/exit-after-tick.php @@ -0,0 +1,7 @@ + maxRestartBackoff { + handler.crashCount = 0 + } + handler.wait(restartBackoff(handler.crashCount)) handler.crashCount++ return @@ -282,6 +290,16 @@ func go_frankenphp_background_worker_ready(threadIndex C.uintptr_t) { // backoff waits before the next run of a crashed script, see restartBackoff func (handler *backgroundWorkerThread) backoff() { - time.Sleep(restartBackoff(handler.failureCount)) + handler.wait(restartBackoff(handler.failureCount)) handler.failureCount++ } + +// wait sleeps between two runs, cut short by a drain (shutdown, reboot, +// handler transition), 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 6a2bae10a1..4b5c39a6b8 100644 --- a/threadworker.go +++ b/threadworker.go @@ -142,6 +142,9 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { handler.failureCount++ } +// maxRestartBackoff is the longest wait between two runs of a failing script +const maxRestartBackoff = time.Second + // 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 @@ -149,7 +152,7 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { // count a crash loop reaches on its own in a few days func restartBackoff(failures int) time.Duration { if failures >= 4 { - return time.Second + return maxRestartBackoff } return time.Duration(failures*failures*100) * time.Millisecond diff --git a/workerextension.go b/workerextension.go index 01dab1d58b..b83c72e007 100644 --- a/workerextension.go +++ b/workerextension.go @@ -31,9 +31,9 @@ func (w *extensionWorkers) SendRequest(rw http.ResponseWriter, r *http.Request) return ErrNotRunning } - // worker names are resolved within a server, and a worker always has - // one, the fallback server when it was declared without a scope - return w.internalWorker.server.ServeHTTP(rw, r, WithOriginalRequest(r), WithWorkerName(w.name)) + // 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 { @@ -46,7 +46,9 @@ func (w *extensionWorkers) NumThreads() int { // 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) { - if w.internalWorker == nil { + // 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 } diff --git a/workerextension_test.go b/workerextension_test.go index f8dfab0a23..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" @@ -78,7 +79,7 @@ func TestWorkerExtensionSendMessage(t *testing.T) { } // an extension worker scoped to a server stays reachable through -// SendRequest(), which resolves the name within that server +// SendRequest(), which dispatches on it whatever the URI func TestWorkerExtensionOnServer(t *testing.T) { t.Cleanup(Shutdown) @@ -97,6 +98,43 @@ func TestWorkerExtensionOnServer(t *testing.T) { 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 5c4be27d1f08c345997ed7ac88244f2b0c16b4cd Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Fri, 18 Sep 2026 08:37:12 +0200 Subject: [PATCH 25/46] fix: keep the declared path as the default worker name A worker without a name is reported under the path of its script, which newWorker() resolved through symlinks. Deployments that publish releases behind a symlink then move every worker label at each deploy, since the resolved path names the release directory. The default is now the path as declared, made absolute, for the Caddy module and the Go API alike, so naming them in the module is no longer needed; it would also make two workers sharing a script collide, where an undeclared name gets a numeric suffix instead. --- caddy/app.go | 6 ------ worker.go | 11 +++++++++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/caddy/app.go b/caddy/app.go index f77c834b2c..88636401c4 100644 --- a/caddy/app.go +++ b/caddy/app.go @@ -17,7 +17,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 ( @@ -132,11 +131,6 @@ func (f *FrankenPHPApp) Start() error { // register global workers for _, w := range f.Workers { w.FileName = repl.ReplaceKnown(w.FileName, "") - // the declared path, symlinks kept, so the metric label does not - // move when the target of a release symlink does - if w.Name == "" { - w.Name, _ = fastabs.FastAbs(w.FileName) - } opts, err := w.toWorkerOptions() if err != nil { return err diff --git a/worker.go b/worker.go index 4ce6a1b549..1a5b7c3a0c 100644 --- a/worker.go +++ b/worker.go @@ -184,13 +184,20 @@ func newWorker(o workerOpt) (*worker, error) { } 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 + declaredPath, err := fastabs.FastAbs(filepath.FromSlash(o.fileName)) + if err != nil { + declaredPath = absFileName + } + // a name generated from the script path is not a declaration: // several workers may share a script, a pool split by a matcher // for instance, so it is made unique rather than reported as the // collision a declared name gets - o.name = absFileName + o.name = declaredPath for suffix := 1; scope.workersByName[o.name] != nil; suffix++ { - o.name = fmt.Sprintf("%s_%d", absFileName, suffix) + o.name = fmt.Sprintf("%s_%d", declaredPath, suffix) } } From 8bc93f99aeaf481d23860fcac516726c590050f4 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Fri, 18 Sep 2026 08:37:12 +0200 Subject: [PATCH 26/46] fix: pace only the background runs that end too fast Every exit past the ready point was counted toward the restart backoff unless the run outlived its one second cap, so a worker processing a batch and returning, which is how a script keeps its memory fresh, was throttled to one run per second after four of them. A run now counts only when it ended too fast to have done anything, a tenth of a second, which is what tells a spinning script from a working one. A script returning at once is still paced the same way. --- bgworker_test.go | 35 +++++++++++++++++++++++++++++++++++ testdata/bgworker/cycle.php | 8 ++++++++ threadbackgroundworker.go | 5 ++++- threadworker.go | 4 ++++ 4 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 testdata/bgworker/cycle.php diff --git a/bgworker_test.go b/bgworker_test.go index 2dea9343dd..ef936186c9 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "runtime" + "strconv" "strings" "testing" "time" @@ -681,6 +682,40 @@ func TestBackgroundWorkerCleanExitIsPaced(t *testing.T) { assert.LessOrEqual(t, runs, 8, "the re-runs were not paced") } +// TestBackgroundWorkerCyclingIsNotThrottled checks the other side of the +// pacing: a worker that does some work and returns is re-run at its own +// pace, only a run ending too fast to have done anything is slowed down. +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") +} + // TestBackgroundWorkerRebootForceKillsStuckScript checks that a script // ignoring its handle does not stall RestartWorkers() past the reboot grace // period: the force-kill ends it and the next run parks normally. diff --git a/testdata/bgworker/cycle.php b/testdata/bgworker/cycle.php new file mode 100644 index 0000000000..9bde4210f9 --- /dev/null +++ b/testdata/bgworker/cycle.php @@ -0,0 +1,8 @@ + maxRestartBackoff { + // a run that lasted did something, whatever its exit status, and + // the next one starts fresh: a worker processing a batch and + // returning is not a worker spinning on an immediate exit + if time.Since(handler.runStartedAt) > minHealthyRun { handler.crashCount = 0 } handler.wait(restartBackoff(handler.crashCount)) diff --git a/threadworker.go b/threadworker.go index 4b5c39a6b8..b60e18cd47 100644 --- a/threadworker.go +++ b/threadworker.go @@ -145,6 +145,10 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { // 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 From b03f915f93b311cf602aba05fb4cf64866131360 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Fri, 18 Sep 2026 09:09:32 +0200 Subject: [PATCH 27/46] refactor: declare the labels of a worker instead of widening the metrics Splitting the identity of a worker into two labels changed the signature of every worker method of the Metrics interface, which an implementation living outside this repository has to follow. Those methods keep the single identifier they always took. One method carries the labels instead: DeclareWorker() names them once, before anything else mentions the worker, and the Prometheus implementation resolves the identifier through them. An outside implementation adds that method, empty when it has no use for the labels, and keeps the rest untouched. The identifier is the qualified name again, so two workers that would report under one are rejected at startup, as before. --- bgworker_test.go | 19 +++++++ metrics.go | 105 +++++++++++++++++++++++++++----------- metrics_test.go | 18 ++++--- threadbackgroundworker.go | 10 ++-- threadworker.go | 10 ++-- worker.go | 26 +++++++--- 6 files changed, 134 insertions(+), 54 deletions(-) diff --git a/bgworker_test.go b/bgworker_test.go index ef936186c9..242474540d 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -200,6 +200,25 @@ 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) diff --git a/metrics.go b/metrics.go index 437f471728..b00ce5fa9d 100644 --- a/metrics.go +++ b/metrics.go @@ -17,19 +17,22 @@ const ( type StopReason int // Metrics reports what the workers and the threads of a FrankenPHP instance -// are doing. A worker is identified by two values: its declared name, and -// the name of the server it is scoped to, empty for a global worker. They -// stay apart rather than packed into one string, so a series keyed on the -// name alone still selects the worker of every server. +// are doing. Every method naming a worker takes the identifier declared +// through DeclareWorker. type Metrics interface { + // DeclareWorker gives the labels of a worker, before any other method + // mentions it: id is the identifier those methods use, name the name the + // worker was declared with and server the php_server it belongs to, + // empty for a global worker + DeclareWorker(id, name, server string) // StartWorker collects started workers - StartWorker(name, server string) + StartWorker(id string) // ReadyWorker collects ready workers - ReadyWorker(name, server string) + ReadyWorker(id string) // StopWorker collects stopped workers - StopWorker(name, server string, reason StopReason) + StopWorker(id string, reason StopReason) // TotalWorkers collects expected workers - TotalWorkers(name, server string, num int) + TotalWorkers(id string, num int) // TotalThreads collects total threads TotalThreads(num int) // StartRequest collects started requests @@ -37,28 +40,31 @@ type Metrics interface { // StopRequest collects stopped requests StopRequest() // StopWorkerRequest collects stopped worker requests - StopWorkerRequest(name, server string, duration time.Duration) + StopWorkerRequest(id string, duration time.Duration) // StartWorkerRequest collects started worker requests - StartWorkerRequest(name, server string) + StartWorkerRequest(id string) Shutdown() - QueuedWorkerRequest(name, server string) - DequeuedWorkerRequest(name, server string) + QueuedWorkerRequest(id string) + DequeuedWorkerRequest(id string) QueuedRequest() DequeuedRequest() } type nullMetrics struct{} -func (n nullMetrics) StartWorker(string, string) { +func (n nullMetrics) DeclareWorker(string, string, string) { } -func (n nullMetrics) ReadyWorker(string, string) { +func (n nullMetrics) StartWorker(string) { } -func (n nullMetrics) StopWorker(string, string, StopReason) { +func (n nullMetrics) ReadyWorker(string) { } -func (n nullMetrics) TotalWorkers(string, string, int) { +func (n nullMetrics) StopWorker(string, StopReason) { +} + +func (n nullMetrics) TotalWorkers(string, int) { } func (n nullMetrics) TotalThreads(int) { @@ -70,18 +76,18 @@ 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() {} @@ -99,7 +105,32 @@ type PrometheusMetrics struct { workerRequestCount *prometheus.CounterVec workerQueueDepth *prometheus.GaugeVec queueDepth prometheus.Gauge - mu sync.RWMutex + // declaredWorkers maps the identifier of a worker to its label values, + // see DeclareWorker + declaredWorkers map[string][2]string + mu sync.RWMutex +} + +// DeclareWorker records the labels of a worker, see the Metrics interface. +func (m *PrometheusMetrics) DeclareWorker(id, name, server string) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.declaredWorkers == nil { + m.declaredWorkers = make(map[string][2]string) + } + m.declaredWorkers[id] = [2]string{name, server} +} + +// workerLabels resolves the identifier of a worker into its label values, +// the identifier itself and no server when it was never declared. Called +// with m.mu held. +func (m *PrometheusMetrics) workerLabels(id string) (string, string) { + if labels, ok := m.declaredWorkers[id]; ok { + return labels[0], labels[1] + } + + return id, "" } // mustRegister registers c, tolerating a collector that is already registered. @@ -111,10 +142,12 @@ func (m *PrometheusMetrics) mustRegister(c prometheus.Collector) { } } -func (m *PrometheusMetrics) StartWorker(name, server string) { +func (m *PrometheusMetrics) StartWorker(id string) { m.mu.RLock() defer m.mu.RUnlock() + name, server := m.workerLabels(id) + m.busyThreads.Inc() // tests do not register workers before starting them @@ -125,10 +158,12 @@ func (m *PrometheusMetrics) StartWorker(name, server string) { m.totalWorkers.WithLabelValues(name, server).Inc() } -func (m *PrometheusMetrics) ReadyWorker(name, server string) { +func (m *PrometheusMetrics) ReadyWorker(id string) { m.mu.RLock() defer m.mu.RUnlock() + name, server := m.workerLabels(id) + if m.totalWorkers == nil { return } @@ -136,10 +171,12 @@ func (m *PrometheusMetrics) ReadyWorker(name, server string) { m.readyWorkers.WithLabelValues(name, server).Inc() } -func (m *PrometheusMetrics) StopWorker(name, server string, reason StopReason) { +func (m *PrometheusMetrics) StopWorker(id string, reason StopReason) { m.mu.RLock() defer m.mu.RUnlock() + name, server := m.workerLabels(id) + m.busyThreads.Dec() // tests do not register workers before starting them @@ -162,7 +199,7 @@ func (m *PrometheusMetrics) StopWorker(name, server string, reason StopReason) { } } -func (m *PrometheusMetrics) TotalWorkers(string, string, int) { +func (m *PrometheusMetrics) TotalWorkers(string, int) { m.mu.Lock() defer m.mu.Unlock() @@ -267,10 +304,12 @@ func (m *PrometheusMetrics) StopRequest() { m.busyThreads.Dec() } -func (m *PrometheusMetrics) StopWorkerRequest(name, server string, duration time.Duration) { +func (m *PrometheusMetrics) StopWorkerRequest(id string, duration time.Duration) { m.mu.RLock() defer m.mu.RUnlock() + name, server := m.workerLabels(id) + if m.workerRequestTime == nil { return } @@ -280,30 +319,36 @@ func (m *PrometheusMetrics) StopWorkerRequest(name, server string, duration time m.workerRequestTime.WithLabelValues(name, server).Add(duration.Seconds()) } -func (m *PrometheusMetrics) StartWorkerRequest(name, server string) { +func (m *PrometheusMetrics) StartWorkerRequest(id string) { m.mu.RLock() defer m.mu.RUnlock() + name, server := m.workerLabels(id) + if m.busyWorkers == nil { return } m.busyWorkers.WithLabelValues(name, server).Inc() } -func (m *PrometheusMetrics) QueuedWorkerRequest(name, server string) { +func (m *PrometheusMetrics) QueuedWorkerRequest(id string) { m.mu.RLock() defer m.mu.RUnlock() + name, server := m.workerLabels(id) + if m.workerQueueDepth == nil { return } m.workerQueueDepth.WithLabelValues(name, server).Inc() } -func (m *PrometheusMetrics) DequeuedWorkerRequest(name, server string) { +func (m *PrometheusMetrics) DequeuedWorkerRequest(id string) { m.mu.RLock() defer m.mu.RUnlock() + name, server := m.workerLabels(id) + if m.workerQueueDepth == nil { return } @@ -328,6 +373,8 @@ func (m *PrometheusMetrics) Shutdown() { m.mu.Lock() defer m.mu.Unlock() + m.declaredWorkers = nil + m.registry.Unregister(m.totalThreads) m.registry.Unregister(m.busyThreads) m.registry.Unregister(m.queueDepth) diff --git a/metrics_test.go b/metrics_test.go index fdb8c459a5..c72d9af11b 100644 --- a/metrics_test.go +++ b/metrics_test.go @@ -42,6 +42,7 @@ func TestPrometheusMetrics_TotalThreadsReportsCurrentValue(t *testing.T) { func TestPrometheusMetrics_TotalWorkers(t *testing.T) { m := createPrometheusMetrics() + m.DeclareWorker("test_server:test_worker", "test_worker", "test_server") require.Nil(t, m.totalWorkers) require.Nil(t, m.busyWorkers) @@ -51,7 +52,7 @@ func TestPrometheusMetrics_TotalWorkers(t *testing.T) { require.Nil(t, m.workerRequestTime) require.Nil(t, m.workerRequestCount) - m.TotalWorkers("test_worker", "test_server", 2) + m.TotalWorkers("test_server:test_worker", 2) require.NotNil(t, m.totalWorkers) require.NotNil(t, m.busyWorkers) @@ -64,8 +65,9 @@ func TestPrometheusMetrics_TotalWorkers(t *testing.T) { func TestPrometheusMetrics_StopWorkerRequest(t *testing.T) { m := createPrometheusMetrics() - m.TotalWorkers("test_worker", "test_server", 2) - m.StopWorkerRequest("test_worker", "test_server", 2*time.Second) + m.DeclareWorker("test_server:test_worker", "test_worker", "test_server") + m.TotalWorkers("test_server:test_worker", 2) + m.StopWorkerRequest("test_server:test_worker", 2*time.Second) inputs := []struct { name string @@ -118,8 +120,9 @@ func TestPrometheusMetrics_StopWorkerRequest(t *testing.T) { func TestPrometheusMetrics_StartWorkerRequest(t *testing.T) { m := createPrometheusMetrics() - m.TotalWorkers("test_worker", "test_server", 2) - m.StartWorkerRequest("test_worker", "test_server") + m.DeclareWorker("test_server:test_worker", "test_worker", "test_server") + m.TotalWorkers("test_server:test_worker", 2) + m.StartWorkerRequest("test_server:test_worker") inputs := []struct { name string @@ -150,8 +153,9 @@ func TestPrometheusMetrics_StartWorkerRequest(t *testing.T) { func TestPrometheusMetrics_TestStopReasonCrash(t *testing.T) { m := createPrometheusMetrics() - m.TotalWorkers("test_worker", "test_server", 2) - m.StopWorker("test_worker", "test_server", StopReasonCrash) + m.DeclareWorker("test_server:test_worker", "test_worker", "test_server") + m.TotalWorkers("test_server:test_worker", 2) + m.StopWorker("test_server:test_worker", StopReasonCrash) inputs := []struct { name string diff --git a/threadbackgroundworker.go b/threadbackgroundworker.go index c2609ca6ab..653584f1af 100644 --- a/threadbackgroundworker.go +++ b/threadbackgroundworker.go @@ -146,7 +146,7 @@ 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.qualifiedName) // the run's logger and context, not the globals: Stop() does not wait // for a callback that already started, and a shutdown finishing // meanwhile resets those @@ -189,13 +189,13 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { // point on its own, so a script ending right after it is paced here if !handler.isBootingScript { if exitStatus == 0 { - metrics.StopWorker(worker.name, worker.server.name, StopReasonRestart) + metrics.StopWorker(worker.qualifiedName, 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) + metrics.StopWorker(worker.qualifiedName, 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)) @@ -218,7 +218,7 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { // a clean exit included, which would otherwise respawn in a tight loop. // StopReasonBootFailure skips the ready-gauge decrement, matching the // ReadyWorker call that never happened - metrics.StopWorker(worker.name, worker.server.name, StopReasonBootFailure) + metrics.StopWorker(worker.qualifiedName, StopReasonBootFailure) // max_consecutive_failures only fails hard during startup, where it // surfaces on startupFailChan so Init() returns the error to the @@ -278,7 +278,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.qualifiedName) // 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..9796625136 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.qualifiedName) // Create a dummy request to set up the worker fc, err := newWorkerDummyContext(worker) @@ -91,7 +91,7 @@ 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.qualifiedName, 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)) @@ -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.qualifiedName, StopReasonBootFailure) } else { - metrics.StopWorker(worker.name, worker.server.name, StopReasonCrash) + metrics.StopWorker(worker.qualifiedName, StopReasonCrash) } if !handler.isBootingScript { @@ -180,7 +180,7 @@ 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.qualifiedName) } // max_requests reached: signal reboot for full ZTS cleanup diff --git a/worker.go b/worker.go index 1a5b7c3a0c..c441bf81c5 100644 --- a/worker.go +++ b/worker.go @@ -66,6 +66,7 @@ func initWorkers(opts []workerOpt) error { ) workers = make([]*worker, 0, len(opts)) + qualifiedNames := make(map[string]bool, len(opts)) for _, o := range opts { w, err := newWorker(o) @@ -84,8 +85,17 @@ func initWorkers(opts []workerOpt) error { totalThreadsToStart += w.num workers = append(workers, w) + // scoping makes qualified names unique in all but pathological + // cases: 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) + metrics.DeclareWorker(w.qualifiedName, w.name, w.server.name) + metrics.TotalWorkers(w.qualifiedName, w.num) } startupFailChan = make(chan error, totalThreadsToStart) @@ -319,7 +329,7 @@ func (worker *worker) isAtThreadLimit() bool { } func (worker *worker) handleRequest(fc *frankenPHPContext) error { - metrics.StartWorkerRequest(worker.name, worker.server.name) + metrics.StartWorkerRequest(worker.qualifiedName) runtime.Gosched() @@ -331,7 +341,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.qualifiedName, time.Since(fc.startedAt)) return nil default: @@ -343,7 +353,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.qualifiedName) for { workerScaleChan := scaleChan @@ -354,9 +364,9 @@ 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.qualifiedName) <-fc.done - metrics.StopWorkerRequest(worker.name, worker.server.name, time.Since(fc.startedAt)) + metrics.StopWorkerRequest(worker.qualifiedName, time.Since(fc.startedAt)) return nil case workerScaleChan <- fc: @@ -364,8 +374,8 @@ func (worker *worker) handleRequest(fc *frankenPHPContext) error { 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.qualifiedName) + metrics.StopWorkerRequest(worker.qualifiedName, time.Since(fc.startedAt)) fc.reject(ErrMaxWaitTimeExceeded) From d56d1bcc3322eb4f7c11903307b389768fe3c1d8 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 18 Sep 2026 15:04:05 +0200 Subject: [PATCH 28/46] feat: report the server of a worker through an optional ServerMetrics interface DeclareWorker() restored the signatures of the worker methods but still added a method to Metrics, which an implementation outside this repository has to grow before it compiles again. Metrics is now the interface it was, a worker scoped to a server being reported as ":" as before. An implementation that also satisfies ServerMetrics receives the two names apart, which is what PrometheusMetrics does to label its series; the runtime picks the right shape once, in WithMetrics(). --- frankenphp.go | 4 +- metrics.go | 251 ++++++++++++++++++++++++++------------ metrics_test.go | 117 ++++++++++++++++-- threadbackgroundworker.go | 10 +- threadworker.go | 10 +- worker.go | 17 ++- 6 files changed, 302 insertions(+), 107 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index cf70cd3344..ff379765af 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -66,7 +66,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 @@ -346,7 +346,7 @@ func Init(options ...Option) error { } if opt.metrics != nil { - metrics = opt.metrics + metrics = adaptMetrics(opt.metrics) } maxWaitTime.Store(int64(opt.maxWaitTime)) diff --git a/metrics.go b/metrics.go index b00ce5fa9d..fb834f574b 100644 --- a/metrics.go +++ b/metrics.go @@ -17,22 +17,18 @@ const ( type StopReason int // Metrics reports what the workers and the threads of a FrankenPHP instance -// are doing. Every method naming a worker takes the identifier declared -// through DeclareWorker. +// 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. type Metrics interface { - // DeclareWorker gives the labels of a worker, before any other method - // mentions it: id is the identifier those methods use, name the name the - // worker was declared with and server the php_server it belongs to, - // empty for a global worker - DeclareWorker(id, name, server string) // StartWorker collects started workers - StartWorker(id string) + StartWorker(name string) // ReadyWorker collects ready workers - ReadyWorker(id string) + ReadyWorker(name string) // StopWorker collects stopped workers - StopWorker(id string, reason StopReason) + StopWorker(name string, reason StopReason) // TotalWorkers collects expected workers - TotalWorkers(id string, num int) + TotalWorkers(name string, num int) // TotalThreads collects total threads TotalThreads(num int) // StartRequest collects started requests @@ -40,31 +36,150 @@ type Metrics interface { // StopRequest collects stopped requests StopRequest() // StopWorkerRequest collects stopped worker requests - StopWorkerRequest(id string, duration time.Duration) + StopWorkerRequest(name string, duration time.Duration) // StartWorkerRequest collects started worker requests - StartWorkerRequest(id string) + StartWorkerRequest(name string) Shutdown() - QueuedWorkerRequest(id string) - DequeuedWorkerRequest(id string) + QueuedWorkerRequest(name string) + DequeuedWorkerRequest(name string) QueuedRequest() DequeuedRequest() } -type nullMetrics struct{} +// 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. When the implementation passed to +// WithMetrics() satisfies it, the runtime reports workers through these +// methods and never through the worker methods of Metrics. +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() +} + +// metricsAdapter routes the worker methods to ServerMetrics when the +// implementation has it, and packs the server into the name otherwise +type metricsAdapter struct { + Metrics + server ServerMetrics +} + +func adaptMetrics(m Metrics) workerMetrics { + a := metricsAdapter{Metrics: m} + a.server, _ = m.(ServerMetrics) + + return a +} + +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 (n nullMetrics) DeclareWorker(string, string, string) { +func (a metricsAdapter) QueuedWorkerRequest(name, server string) { + if a.server != nil { + a.server.QueuedWorkerRequestOnServer(name, server) + return + } + a.Metrics.QueuedWorkerRequest(packedWorkerName(name, server)) } -func (n nullMetrics) StartWorker(string) { +func (a metricsAdapter) DequeuedWorkerRequest(name, server string) { + if a.server != nil { + a.server.DequeuedWorkerRequestOnServer(name, server) + return + } + a.Metrics.DequeuedWorkerRequest(packedWorkerName(name, server)) } -func (n nullMetrics) ReadyWorker(string) { +type nullMetrics struct{} + +func (n nullMetrics) StartWorker(string, string) { } -func (n nullMetrics) StopWorker(string, StopReason) { +func (n nullMetrics) ReadyWorker(string, string) { } -func (n nullMetrics) TotalWorkers(string, int) { +func (n nullMetrics) StopWorker(string, string, StopReason) { +} + +func (n nullMetrics) TotalWorkers(string, string, int) { } func (n nullMetrics) TotalThreads(int) { @@ -76,18 +191,18 @@ 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() {} @@ -105,32 +220,7 @@ type PrometheusMetrics struct { workerRequestCount *prometheus.CounterVec workerQueueDepth *prometheus.GaugeVec queueDepth prometheus.Gauge - // declaredWorkers maps the identifier of a worker to its label values, - // see DeclareWorker - declaredWorkers map[string][2]string - mu sync.RWMutex -} - -// DeclareWorker records the labels of a worker, see the Metrics interface. -func (m *PrometheusMetrics) DeclareWorker(id, name, server string) { - m.mu.Lock() - defer m.mu.Unlock() - - if m.declaredWorkers == nil { - m.declaredWorkers = make(map[string][2]string) - } - m.declaredWorkers[id] = [2]string{name, server} -} - -// workerLabels resolves the identifier of a worker into its label values, -// the identifier itself and no server when it was never declared. Called -// with m.mu held. -func (m *PrometheusMetrics) workerLabels(id string) (string, string) { - if labels, ok := m.declaredWorkers[id]; ok { - return labels[0], labels[1] - } - - return id, "" + mu sync.RWMutex } // mustRegister registers c, tolerating a collector that is already registered. @@ -142,12 +232,37 @@ func (m *PrometheusMetrics) mustRegister(c prometheus.Collector) { } } -func (m *PrometheusMetrics) StartWorker(id string) { +var ( + _ Metrics = (*PrometheusMetrics)(nil) + _ ServerMetrics = (*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) { m.mu.RLock() defer m.mu.RUnlock() - name, server := m.workerLabels(id) - m.busyThreads.Inc() // tests do not register workers before starting them @@ -158,12 +273,10 @@ func (m *PrometheusMetrics) StartWorker(id string) { m.totalWorkers.WithLabelValues(name, server).Inc() } -func (m *PrometheusMetrics) ReadyWorker(id string) { +func (m *PrometheusMetrics) ReadyWorkerOnServer(name, server string) { m.mu.RLock() defer m.mu.RUnlock() - name, server := m.workerLabels(id) - if m.totalWorkers == nil { return } @@ -171,12 +284,10 @@ func (m *PrometheusMetrics) ReadyWorker(id string) { m.readyWorkers.WithLabelValues(name, server).Inc() } -func (m *PrometheusMetrics) StopWorker(id string, reason StopReason) { +func (m *PrometheusMetrics) StopWorkerOnServer(name, server string, reason StopReason) { m.mu.RLock() defer m.mu.RUnlock() - name, server := m.workerLabels(id) - m.busyThreads.Dec() // tests do not register workers before starting them @@ -199,7 +310,7 @@ func (m *PrometheusMetrics) StopWorker(id string, reason StopReason) { } } -func (m *PrometheusMetrics) TotalWorkers(string, int) { +func (m *PrometheusMetrics) TotalWorkersOnServer(string, string, int) { m.mu.Lock() defer m.mu.Unlock() @@ -304,12 +415,10 @@ func (m *PrometheusMetrics) StopRequest() { m.busyThreads.Dec() } -func (m *PrometheusMetrics) StopWorkerRequest(id string, duration time.Duration) { +func (m *PrometheusMetrics) StopWorkerRequestOnServer(name, server string, duration time.Duration) { m.mu.RLock() defer m.mu.RUnlock() - name, server := m.workerLabels(id) - if m.workerRequestTime == nil { return } @@ -319,36 +428,30 @@ func (m *PrometheusMetrics) StopWorkerRequest(id string, duration time.Duration) m.workerRequestTime.WithLabelValues(name, server).Add(duration.Seconds()) } -func (m *PrometheusMetrics) StartWorkerRequest(id string) { +func (m *PrometheusMetrics) StartWorkerRequestOnServer(name, server string) { m.mu.RLock() defer m.mu.RUnlock() - name, server := m.workerLabels(id) - if m.busyWorkers == nil { return } m.busyWorkers.WithLabelValues(name, server).Inc() } -func (m *PrometheusMetrics) QueuedWorkerRequest(id string) { +func (m *PrometheusMetrics) QueuedWorkerRequestOnServer(name, server string) { m.mu.RLock() defer m.mu.RUnlock() - name, server := m.workerLabels(id) - if m.workerQueueDepth == nil { return } m.workerQueueDepth.WithLabelValues(name, server).Inc() } -func (m *PrometheusMetrics) DequeuedWorkerRequest(id string) { +func (m *PrometheusMetrics) DequeuedWorkerRequestOnServer(name, server string) { m.mu.RLock() defer m.mu.RUnlock() - name, server := m.workerLabels(id) - if m.workerQueueDepth == nil { return } @@ -373,8 +476,6 @@ func (m *PrometheusMetrics) Shutdown() { m.mu.Lock() defer m.mu.Unlock() - m.declaredWorkers = nil - m.registry.Unregister(m.totalThreads) m.registry.Unregister(m.busyThreads) m.registry.Unregister(m.queueDepth) diff --git a/metrics_test.go b/metrics_test.go index c72d9af11b..c7e01af77d 100644 --- a/metrics_test.go +++ b/metrics_test.go @@ -7,6 +7,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -42,7 +43,6 @@ func TestPrometheusMetrics_TotalThreadsReportsCurrentValue(t *testing.T) { func TestPrometheusMetrics_TotalWorkers(t *testing.T) { m := createPrometheusMetrics() - m.DeclareWorker("test_server:test_worker", "test_worker", "test_server") require.Nil(t, m.totalWorkers) require.Nil(t, m.busyWorkers) @@ -52,7 +52,7 @@ func TestPrometheusMetrics_TotalWorkers(t *testing.T) { require.Nil(t, m.workerRequestTime) require.Nil(t, m.workerRequestCount) - m.TotalWorkers("test_server:test_worker", 2) + m.TotalWorkersOnServer("test_worker", "test_server", 2) require.NotNil(t, m.totalWorkers) require.NotNil(t, m.busyWorkers) @@ -65,9 +65,8 @@ func TestPrometheusMetrics_TotalWorkers(t *testing.T) { func TestPrometheusMetrics_StopWorkerRequest(t *testing.T) { m := createPrometheusMetrics() - m.DeclareWorker("test_server:test_worker", "test_worker", "test_server") - m.TotalWorkers("test_server:test_worker", 2) - m.StopWorkerRequest("test_server: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 @@ -120,9 +119,8 @@ func TestPrometheusMetrics_StopWorkerRequest(t *testing.T) { func TestPrometheusMetrics_StartWorkerRequest(t *testing.T) { m := createPrometheusMetrics() - m.DeclareWorker("test_server:test_worker", "test_worker", "test_server") - m.TotalWorkers("test_server:test_worker", 2) - m.StartWorkerRequest("test_server:test_worker") + m.TotalWorkersOnServer("test_worker", "test_server", 2) + m.StartWorkerRequestOnServer("test_worker", "test_server") inputs := []struct { name string @@ -153,9 +151,8 @@ func TestPrometheusMetrics_StartWorkerRequest(t *testing.T) { func TestPrometheusMetrics_TestStopReasonCrash(t *testing.T) { m := createPrometheusMetrics() - m.DeclareWorker("test_server:test_worker", "test_worker", "test_server") - m.TotalWorkers("test_server:test_worker", 2) - m.StopWorker("test_server:test_worker", StopReasonCrash) + m.TotalWorkersOnServer("test_worker", "test_server", 2) + m.StopWorkerOnServer("test_worker", "test_server", StopReasonCrash) inputs := []struct { name string @@ -216,3 +213,101 @@ func TestPrometheusMetrics_TestStopReasonCrash(t *testing.T) { } } + +// 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/threadbackgroundworker.go b/threadbackgroundworker.go index 653584f1af..c2609ca6ab 100644 --- a/threadbackgroundworker.go +++ b/threadbackgroundworker.go @@ -146,7 +146,7 @@ func (handler *backgroundWorkerThread) setupScript() error { handler.isBootingScript = true handler.runStartedAt = time.Now() - metrics.StartWorker(handler.worker.qualifiedName) + metrics.StartWorker(handler.worker.name, handler.worker.server.name) // the run's logger and context, not the globals: Stop() does not wait // for a callback that already started, and a shutdown finishing // meanwhile resets those @@ -189,13 +189,13 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { // point on its own, so a script ending right after it is paced here if !handler.isBootingScript { if exitStatus == 0 { - metrics.StopWorker(worker.qualifiedName, StopReasonRestart) + 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.qualifiedName, StopReasonCrash) + 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)) @@ -218,7 +218,7 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { // a clean exit included, which would otherwise respawn in a tight loop. // StopReasonBootFailure skips the ready-gauge decrement, matching the // ReadyWorker call that never happened - metrics.StopWorker(worker.qualifiedName, StopReasonBootFailure) + metrics.StopWorker(worker.name, worker.server.name, StopReasonBootFailure) // max_consecutive_failures only fails hard during startup, where it // surfaces on startupFailChan so Init() returns the error to the @@ -278,7 +278,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.qualifiedName) + 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) diff --git a/threadworker.go b/threadworker.go index 9796625136..b60e18cd47 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.qualifiedName) + metrics.StartWorker(worker.name, worker.server.name) // Create a dummy request to set up the worker fc, err := newWorkerDummyContext(worker) @@ -91,7 +91,7 @@ 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.qualifiedName, 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.qualifiedName), slog.Int("thread", handler.thread.threadIndex), slog.Int("exit_status", exitStatus)) @@ -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.qualifiedName, StopReasonBootFailure) + metrics.StopWorker(worker.name, worker.server.name, StopReasonBootFailure) } else { - metrics.StopWorker(worker.qualifiedName, StopReasonCrash) + metrics.StopWorker(worker.name, worker.server.name, StopReasonCrash) } if !handler.isBootingScript { @@ -180,7 +180,7 @@ func (handler *workerThread) waitForWorkerRequest() (bool, any) { } // worker is truly ready only after reaching frankenphp_handle_request() - metrics.ReadyWorker(handler.worker.qualifiedName) + metrics.ReadyWorker(handler.worker.name, handler.worker.server.name) } // max_requests reached: signal reboot for full ZTS cleanup diff --git a/worker.go b/worker.go index c441bf81c5..8c5c27281c 100644 --- a/worker.go +++ b/worker.go @@ -94,8 +94,7 @@ func initWorkers(opts []workerOpt) error { qualifiedNames[w.qualifiedName] = true // reported here rather than in calculateMaxThreads(), where the name is not resolved yet - metrics.DeclareWorker(w.qualifiedName, w.name, w.server.name) - metrics.TotalWorkers(w.qualifiedName, w.num) + metrics.TotalWorkers(w.name, w.server.name, w.num) } startupFailChan = make(chan error, totalThreadsToStart) @@ -329,7 +328,7 @@ func (worker *worker) isAtThreadLimit() bool { } func (worker *worker) handleRequest(fc *frankenPHPContext) error { - metrics.StartWorkerRequest(worker.qualifiedName) + metrics.StartWorkerRequest(worker.name, worker.server.name) runtime.Gosched() @@ -341,7 +340,7 @@ func (worker *worker) handleRequest(fc *frankenPHPContext) error { case thread.requestChan <- fc: worker.threadMutex.RUnlock() <-fc.done - metrics.StopWorkerRequest(worker.qualifiedName, time.Since(fc.startedAt)) + metrics.StopWorkerRequest(worker.name, worker.server.name, time.Since(fc.startedAt)) return nil default: @@ -353,7 +352,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.qualifiedName) + metrics.QueuedWorkerRequest(worker.name, worker.server.name) for { workerScaleChan := scaleChan @@ -364,9 +363,9 @@ func (worker *worker) handleRequest(fc *frankenPHPContext) error { select { case worker.requestChan <- fc: worker.queuedRequests.Add(-1) - metrics.DequeuedWorkerRequest(worker.qualifiedName) + metrics.DequeuedWorkerRequest(worker.name, worker.server.name) <-fc.done - metrics.StopWorkerRequest(worker.qualifiedName, time.Since(fc.startedAt)) + metrics.StopWorkerRequest(worker.name, worker.server.name, time.Since(fc.startedAt)) return nil case workerScaleChan <- fc: @@ -374,8 +373,8 @@ func (worker *worker) handleRequest(fc *frankenPHPContext) error { case <-timeoutChan(time.Duration(maxWaitTime.Load())): // the request has timed out stalling worker.queuedRequests.Add(-1) - metrics.DequeuedWorkerRequest(worker.qualifiedName) - metrics.StopWorkerRequest(worker.qualifiedName, 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) From 37f40203d14712768976b9afe2ec43b3b5a5bd90 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Fri, 18 Sep 2026 15:57:33 +0200 Subject: [PATCH 29/46] feat: the handle of a background worker becomes an object frankenphp_get_worker_handle() and frankenphp_worker_tick() are gone, replaced by FrankenPHP\WorkerHandle: tick() is the ready point and the liveness check, getStream() the stream to wait on, isValid() whether the run still holds its socket. One object instead of two global functions, and a place for the task API to land. Only waiting on the stream is supported, what it carries is not part of the contract and tick() consumes it, so the descriptor stays out of the contract. On PHP 8.6 the class can implement Io\Poll\Handle without moving anything else, which is what the polling API discussion asked for. A run still has one stream whatever the number of handles, so a script may take one wherever it needs it. --- bgworker_test.go | 61 ++++++-- caddy/caddy_test.go | 12 +- docs/config.md | 4 +- docs/metrics.md | 2 +- docs/worker.md | 43 ++++-- frankenphp.c | 194 ++++++++++++++++++++---- frankenphp.stub.php | 176 +++++++++++---------- frankenphp_arginfo.h | 32 +++- metrics.go | 4 +- metrics_test.go | 2 +- options.go | 2 +- testdata/bgworker/basic.php | 7 +- testdata/bgworker/count.php | 7 +- testdata/bgworker/crash-after-ready.php | 8 +- testdata/bgworker/crash.php | 7 +- testdata/bgworker/cycle.php | 2 +- testdata/bgworker/early-return.php | 6 +- testdata/bgworker/exit-after-tick.php | 8 +- testdata/bgworker/fail-then-succeed.php | 7 +- testdata/bgworker/fetch-no-tick.php | 8 +- testdata/bgworker/flag.php | 7 +- testdata/bgworker/loop.php | 15 +- testdata/bgworker/named.php | 7 +- testdata/bgworker/no-time-limit.php | 5 +- testdata/bgworker/poll-interface.php | 16 ++ testdata/bgworker/poll.php | 14 ++ testdata/bgworker/pool.php | 7 +- testdata/bgworker/read.php | 5 +- testdata/bgworker/readable.php | 15 +- testdata/bgworker/recv.php | 5 +- testdata/bgworker/refetch.php | 17 ++- testdata/bgworker/slow-boot.php | 8 +- testdata/bgworker/stuck.php | 9 +- testdata/bgworker/tick.php | 16 +- testdata/handle-outside.php | 12 +- threadbackgroundworker.go | 28 ++-- 36 files changed, 523 insertions(+), 255 deletions(-) create mode 100644 testdata/bgworker/poll-interface.php create mode 100644 testdata/bgworker/poll.php diff --git a/bgworker_test.go b/bgworker_test.go index 242474540d..1fa748c8e1 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -175,7 +175,7 @@ func TestBackgroundWorkerValidation(t *testing.T) { ), frankenphp.WithNumThreads(2), ) - require.ErrorContains(t, err, "without calling frankenphp_worker_tick()") + require.ErrorContains(t, err, "without calling WorkerHandle::tick()") }) t.Run("fetching the handle without ticking fails startup", func(t *testing.T) { @@ -186,7 +186,7 @@ func TestBackgroundWorkerValidation(t *testing.T) { ), frankenphp.WithNumThreads(2), ) - require.ErrorContains(t, err, "without calling frankenphp_worker_tick()") + require.ErrorContains(t, err, "without calling WorkerHandle::tick()") }) t.Run("max_threads is rejected", func(t *testing.T) { @@ -340,17 +340,16 @@ func TestBackgroundWorkerRestartDrainsParkedScript(t *testing.T) { require.Eventually(t, func() bool { return runs() == 2 }, 5*time.Second, 25*time.Millisecond, "background worker was not re-run after the restart") } -// TestGetWorkerHandleOutsideBackgroundWorker checks the function throws on a -// regular request thread instead of handing out a stream. -func TestGetWorkerHandleOutsideBackgroundWorker(t *testing.T) { +// TestWorkerHandleOutsideBackgroundWorker checks that a regular request +// thread cannot take a handle instead of being handed a stream on one. +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_get_worker_handle() can only be called from a background worker") - assert.Contains(t, body, "frankenphp_worker_tick() can only be called from a background worker") + assert.Contains(t, body, `FrankenPHP\WorkerHandle can only be created from a background worker`) } // TestBackgroundWorkerLoopTicksOnItsOwn checks the wake-up sent at start: a @@ -386,7 +385,7 @@ func TestBackgroundWorkerTickLeavesTheHandleQuiet(t *testing.T) { assert.Equal(t, "start:readable after tick:quiet after second tick:quiet", requireFileContentEventually(t, sentinel)) } -// TestBackgroundWorkerTick checks the contract of frankenphp_worker_tick(): +// TestBackgroundWorkerTick checks the contract of WorkerHandle::tick(): // true while the worker runs, false once it is drained, and still false on // the next call func TestBackgroundWorkerTick(t *testing.T) { @@ -551,7 +550,7 @@ func TestBackgroundWorkerThreadsComeOnTopOfAutoMaxThreads(t *testing.T) { } // TestBackgroundWorkerBootstrapIsBounded checks that max_execution_time -// applies until the first frankenphp_worker_tick(): a setup that outlives +// applies until the first WorkerHandle::tick(): a setup that outlives // it is ended as a boot failure, which fails Init() past the cap. The limit // itself is PHP's, so the test only runs where its timers are known to // fire under FrankenPHP, the max execution timers of ZTS builds on Linux. @@ -607,10 +606,10 @@ func TestBackgroundWorkerParkingIsNotInterrupted(t *testing.T) { assert.Equal(t, 1, runs(), "the worker was restarted, so a limit interrupted its park") } -// TestBackgroundWorkerHandleClosedAndFetchedAgain checks the handle cache: +// TestBackgroundWorkerStreamClosedAndFetchedAgain checks the stream cache: // a run gets one stream, closing it yields a fresh one on the next fetch, // and the drain still reaches the script through it. -func TestBackgroundWorkerHandleClosedAndFetchedAgain(t *testing.T) { +func TestBackgroundWorkerStreamClosedAndFetchedAgain(t *testing.T) { sentinel := filepath.Join(t.TempDir(), "refetch.txt") t.Cleanup(frankenphp.Shutdown) @@ -760,3 +759,43 @@ func TestBackgroundWorkerRebootForceKillsStuckScript(t *testing.T) { requireFileEventually(t, sentinel, "the re-run script did not park") } + +// TestBackgroundWorkerPollHandle checks that a script can wait on its handle +// through the poll API of PHP 8.6, without a stream: the handle implements +// Io\Poll\Handle, so a context takes it as is, the first tick makes the +// worker ready, and the drain ends the loop. +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)) +} + +// TestBackgroundWorkerHandleIsAPollHandle checks that the handle implements +// Io\Poll\Handle on every version: PHP 8.6 declares the interface with the +// poll API, FrankenPHP declares it below that, so one script serves both and +// symfony/polyfill-io-poll finds it already there. +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)) +} diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index b51b866131..55abff73f6 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -878,7 +878,7 @@ func TestWorkerMetrics(t *testing.T) { # TYPE frankenphp_worker_request_count counter frankenphp_worker_request_count{server="",worker="` + workerName + `"} 10 - # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_tick for background workers + # 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 ` @@ -1035,7 +1035,7 @@ func TestNamedWorkerMetrics(t *testing.T) { # TYPE frankenphp_worker_request_count counter frankenphp_worker_request_count{server="",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, frankenphp_worker_tick for background workers + # 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 ` @@ -1131,7 +1131,7 @@ func TestAutoWorkerConfig(t *testing.T) { # TYPE frankenphp_worker_request_count counter frankenphp_worker_request_count{server="",worker="` + workerName + `"} 10 - # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_tick for background workers + # 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 + ` ` @@ -1499,7 +1499,7 @@ func TestMultiWorkersMetrics(t *testing.T) { # TYPE frankenphp_worker_request_count counter frankenphp_worker_request_count{server="",worker="service1"} 10 - # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_tick for background workers + # 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 @@ -1653,7 +1653,7 @@ func TestWorkerRestart(t *testing.T) { // Check metrics expectedMetrics := ` - # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_tick for background workers + # 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 # HELP frankenphp_total_workers Total number of PHP workers for this worker @@ -1681,7 +1681,7 @@ 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 reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_tick for background workers + # 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 # HELP frankenphp_total_workers Total number of PHP workers for this worker diff --git a/docs/config.md b/docs/config.md index 16dbd89b80..0010d6db03 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. 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. - background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, requiring "name" and a script calling frankenphp_worker_tick() at least once + background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, requiring "name" and a script ticking its FrankenPHP\WorkerHandle at least once } } } @@ -199,7 +199,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 calling frankenphp_worker_tick() at least once + 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/metrics.md b/docs/metrics.md index ae72740745..a3ee664050 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -19,7 +19,7 @@ When [Caddy metrics](https://caddyserver.com/docs/metrics) are enabled, FrankenP - `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, `frankenphp_worker_tick()` for background 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. diff --git a/docs/worker.md b/docs/worker.md index b47c3c8141..b9826f4f16 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -216,15 +216,16 @@ php_server { } ``` -The script calls `frankenphp_worker_tick()` 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 stream returned by `frankenphp_get_worker_handle()`, alone or together with its own streams. The stream becomes readable when FrankenPHP needs the script's attention, and `frankenphp_worker_tick()` consumes whatever was written on it, so the script does not read the stream itself and the stream is quiet again until the next wake-up. It is readable once right after the script starts, so a loop that services it ticks by itself and the worker is ready as soon as its loop runs. +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 stream of `getStream()`, alone or together with its own streams. Waiting is all that stream supports: it becomes readable when FrankenPHP needs the script's attention, `tick()` consumes whatever was written on it, and the stream is quiet again until the next wake-up. What it carries is not part of the contract. Waiting really is all: reading the stream steals the bytes `tick()` would have consumed, and writing to it lands in a buffer FrankenPHP never drains, so a script that fills it blocks itself. Closing it is safe, the socket belongs to the thread and the next `getStream()` hands out a fresh stream over it, drain included. It is readable once right after the script starts, so a loop that services it ticks by itself and the worker is ready as soon as its loop runs. ```php getStream(); -while (frankenphp_worker_tick()) { - $read = [$handle]; // plus the streams the script waits on +while ($handle->tick()) { + $read = [$stream]; // plus the streams the script waits on $write = $except = null; stream_select($read, $write, $except, 1); @@ -234,18 +235,18 @@ while (frankenphp_worker_tick()) { // drained: return, FrankenPHP re-runs or stops the script ``` -`stream_select()` rejects a stream whose descriptor is `1024` or higher (`FD_SETSIZE`), and the handle takes the lowest free descriptor when the run starts, so a worker re-run while the server holds more than a thousand connections cannot wait this way. Under that load, park with a blocking read on the handle, such as `fgets()`, or with `Io\Poll\Context` on PHP 8.6. `frankenphp_worker_tick()` itself uses `poll()` and is not affected. +`stream_select()` rejects a stream whose descriptor is `1024` or higher (`FD_SETSIZE`), and the handle takes the lowest free descriptor when the run starts, so a worker re-run while the server holds more than a thousand connections cannot wait this way. Under that load, park with a blocking read on the stream, such as `fgets()`, or with an `Io\Poll\Context` on PHP 8.6, which takes the handle itself. `tick()` itself uses `poll()` and is not affected. -With an event loop, register the stream as readable and call `frankenphp_worker_tick()` from the callback. With [Revolt](https://revolt.run), the loop of amphp: +With an event loop, register the stream as readable and tick from the callback. 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(); } @@ -256,7 +257,29 @@ EventLoop::onReadable($handle, function (): void { 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. The polling API of PHP 8.6 works the same way: wrap the stream in a `StreamPollHandle`, add it to a context, and call `frankenphp_worker_tick()` when it triggers. +The wake-up sent at start makes the callback run as soon as the loop does, which is when the worker becomes ready. + +On PHP 8.6, the handle is an `Io\Poll\Handle`: a context takes it as it is, next to the script's own handles, and no stream is involved. + +```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 + } +} +``` + +The context polls with `epoll` or `kqueue`, so it has neither the `FD_SETSIZE` ceiling of `stream_select()` nor its cost of rebuilding the set on every wait. On 8.2 to 8.5 the class implements the interface all the same, so the same loop runs there with [symfony/polyfill-io-poll](https://github.com/symfony/polyfill/tree/1.x/src/Io/Poll), which backs the context with `stream_select()`. `$_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()`. diff --git a/frankenphp.c b/frankenphp.c index 22f32d95ff..ad2262243a 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -55,6 +55,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 @@ -132,13 +147,13 @@ 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 frankenphp_get_worker_handle(); [1] is transferred to the Go + * 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 frankenphp_worker_tick() of the current run, the ready +/* set by the first WorkerHandle::tick() of the current run, the ready * point of a background worker */ static THREAD_LOCAL bool worker_ticked = false; -/* the stream of the current run, see frankenphp_get_worker_handle(); the +/* the stream of the current run, see WorkerHandle::getStream(); the * cache holds a ref, and the resource list of the run frees it at request * shutdown, so the pointer is only reset, never released, between runs */ static THREAD_LOCAL zend_resource *worker_handle_res = NULL; @@ -359,7 +374,7 @@ void frankenphp_release_thread_for_kill(force_kill_slot slot) { } /* Stop channel of background workers: a socket pair. One end is exposed to - * the PHP script via frankenphp_get_worker_handle(), the other is handed to + * the PHP script via WorkerHandle::getStream(), the other is handed to * the Go side, which closes it on drain so the script's end reaches EOF and * a stream_select() or a blocking read on it returns. A socket pair rather * than a pipe because on Windows PHP's php_select() only really waits on @@ -390,7 +405,7 @@ static void frankenphp_worker_close_stop_socks(void) { } /* Resets the background worker state of the calling thread. The streams - * handed out by frankenphp_get_worker_handle() do not own the socket and + * handed out by WorkerHandle::getStream() do not own the socket and * were destroyed by request shutdown, so the cache is dropped, not released. */ static void frankenphp_reset_background_worker(void) { @@ -448,7 +463,7 @@ static int frankenphp_worker_open_stop_pair(void) { * 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 - * frankenphp_worker_tick() of the run, see there. */ + * WorkerHandle::tick() of the run, see there. */ intptr_t frankenphp_set_background_worker_and_get_stop_sock(void) { frankenphp_reset_background_worker(); is_background_worker = true; @@ -459,7 +474,7 @@ intptr_t frankenphp_set_background_worker_and_get_stop_sock(void) { /* One wake-up right away, so a script that registers its handle with an * event loop and runs it ticks on its own: readiness then means the loop - * serviced the handle once. The first frankenphp_worker_tick() consumes + * serviced the handle once. The first WorkerHandle::tick() consumes * it. Nothing to do on failure, the script then has to tick by itself. */ const char wakeup = '\n'; #ifdef MSG_NOSIGNAL @@ -495,7 +510,7 @@ void frankenphp_worker_close_stop_sock(intptr_t s) { void frankenphp_update_local_thread_context(bool is_worker) { /* A thread that ran a background worker can be recycled into an HTTP * worker or a regular request thread: reset the bg TLS so - * frankenphp_get_worker_handle() rejects callers again, and release the + * WorkerHandle::getStream() rejects callers again, and release the * stop socket. */ if (is_background_worker) { frankenphp_reset_background_worker(); @@ -1174,7 +1189,113 @@ PHP_FUNCTION(frankenphp_log) { } } -/* Ops of the streams returned by frankenphp_get_worker_handle(): the socket +/* 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, with the layout the poll API + * expects so that one set of ops serves both. */ +#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 is internal + * only, an internal class is what it takes. PHP 8.6 brings it with the poll + * API; below it FrankenPHP declares it, so a script keeps one spelling with + * symfony/polyfill-io-poll, whose own declaration then never loads since it + * is autoloaded from a classmap. */ +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; +} + +static frankenphp_handle_ops frankenphp_worker_handle_poll_ops = { + .get_fd = frankenphp_worker_handle_get_fd, + .is_valid = frankenphp_worker_handle_is_valid, + .cleanup = NULL, +}; + +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. */ @@ -1188,26 +1309,34 @@ static int frankenphp_worker_handle_close(php_stream *stream, return php_stream_socket_ops.close(stream, 0); } -PHP_FUNCTION(frankenphp_get_worker_handle) { +/* The handle of the current background worker: the stream to wait on, the + * tick that reports the drain, and nothing else. Constructing it outside a + * background worker throws; a run may create as many as it likes, they all + * speak for the same socket. */ +ZEND_METHOD(FrankenPHP_WorkerHandle, __construct) { ZEND_PARSE_PARAMETERS_NONE(); if (!is_background_worker) { - zend_throw_exception(spl_ce_RuntimeException, - "frankenphp_get_worker_handle() can only be called " - "from a background worker", - 0); + 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(); /* the pair is opened before the script starts and closed at the next run * setup or on thread exit, so a run always has one */ ZEND_ASSERT(worker_stop_socks[0] != SOCK_ERR); - /* One stream per run: the same resource is returned until the script - * closes it, so fetching the handle 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 it never affects a later one - * and the EOF of a drain reaches all. */ + /* One stream per run, whatever the number of handles: the same resource + * is returned 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 it never + * affects a later one and the EOF of a drain reaches all. */ if (worker_handle_res != NULL) { if (worker_handle_res->type == php_file_le_stream()) { GC_ADDREF(worker_handle_res); @@ -1242,21 +1371,13 @@ PHP_FUNCTION(frankenphp_get_worker_handle) { * background 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 - * its handle, alone or with its own streams, and calls this when the handle - * is readable. Whatever the runtime writes on the handle to wake the script - * up is consumed here, so the script never has to read the handle and the - * protocol on it stays private. */ -PHP_FUNCTION(frankenphp_worker_tick) { + * the stream of getStream(), alone or with its own streams, and calls this + * when it is readable. Whatever the runtime writes there to wake the script + * up is consumed here, so the script never has to read it and the protocol + * on it stays private. */ +ZEND_METHOD(FrankenPHP_WorkerHandle, tick) { ZEND_PARSE_PARAMETERS_NONE(); - if (!is_background_worker) { - zend_throw_exception(spl_ce_RuntimeException, - "frankenphp_worker_tick() can only be called from a " - "background worker", - 0); - RETURN_THROWS(); - } - ZEND_ASSERT(worker_stop_socks[0] != SOCK_ERR); if (!worker_ticked) { @@ -1360,7 +1481,16 @@ PHP_MINIT_FUNCTION(frankenphp) { 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; diff --git a/frankenphp.stub.php b/frankenphp.stub.php index e699125ea8..5fc5b0a8d5 100644 --- a/frankenphp.stub.php +++ b/frankenphp.stub.php @@ -2,83 +2,99 @@ /** @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 - */ -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 {} - -/** - * EXPERIMENTAL: returns the handle of the current background worker, a - * 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, and frankenphp_worker_tick() then tells whether the worker - * still runs. Every call of a run returns the same stream, a fresh one over - * the same socket once the script closed it. Only callable from inside a - * background worker. - * - * @return resource - */ -function frankenphp_get_worker_handle() {} - -/** - * EXPERIMENTAL: 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 returned by frankenphp_get_worker_handle() and calls - * this when it is readable. Whatever FrankenPHP wrote on that stream is - * consumed here, the script does not have to read it. Only callable from - * inside a background worker. - */ -function frankenphp_worker_tick(): bool {} +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 + */ + 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 { + /** + * 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 run has 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 d394b57f43..46b4c74994 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: c7ee7c3d4fea8b3575e0a02bccf871a5d2b2977f */ + * Stub hash: e41a047997eda7a6625c848460043d842b2d4fad */ 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,10 +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_frankenphp_get_worker_handle, 0, 0, 0) +ZEND_BEGIN_ARG_INFO_EX(arginfo_class_FrankenPHP_WorkerHandle___construct, 0, 0, 0) ZEND_END_ARG_INFO() -#define arginfo_frankenphp_worker_tick arginfo_frankenphp_finish_request +#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); @@ -53,8 +55,9 @@ ZEND_FUNCTION(frankenphp_request_headers); ZEND_FUNCTION(frankenphp_response_headers); ZEND_FUNCTION(mercure_publish); ZEND_FUNCTION(frankenphp_log); -ZEND_FUNCTION(frankenphp_get_worker_handle); -ZEND_FUNCTION(frankenphp_worker_tick); +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) @@ -68,8 +71,13 @@ 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_worker_handle, arginfo_frankenphp_get_worker_handle) - ZEND_FE(frankenphp_worker_tick, arginfo_frankenphp_worker_tick) + 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 }; @@ -80,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); + + return class_entry; +} diff --git a/metrics.go b/metrics.go index fb834f574b..27140aa560 100644 --- a/metrics.go +++ b/metrics.go @@ -11,7 +11,7 @@ import ( const ( StopReasonCrash = iota StopReasonRestart - StopReasonBootFailure // worker exited before reaching its ready point: frankenphp_handle_request, or frankenphp_worker_tick for background workers + StopReasonBootFailure // worker exited before reaching its ready point: frankenphp_handle_request, or WorkerHandle::tick() for background workers ) type StopReason int @@ -332,7 +332,7 @@ func (m *PrometheusMetrics) TotalWorkersOnServer(string, string, int) { m.readyWorkers = prometheus.NewGaugeVec(prometheus.GaugeOpts{ Namespace: ns, Name: "ready_workers", - Help: "Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_tick for background workers", + 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) } diff --git a/metrics_test.go b/metrics_test.go index c7e01af77d..d3b3e3c376 100644 --- a/metrics_test.go +++ b/metrics_test.go @@ -186,7 +186,7 @@ func TestPrometheusMetrics_TestStopReasonCrash(t *testing.T) { name: "Testing ReadyWorkers", c: m.readyWorkers, metadata: ` - # HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_worker_tick for background workers + # 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: ` diff --git a/options.go b/options.go index 930eb5d182..6d66c33b9a 100644 --- a/options.go +++ b/options.go @@ -244,7 +244,7 @@ func WithWorkerServerScope(s *Server) WorkerOption { // (non-HTTP) worker. Background workers run outside the request cycle: // they share the PHP runtime with HTTP threads but never receive HTTP // requests. The script can park on the stream returned by -// frankenphp_get_worker_handle(), which reaches EOF when FrankenPHP +// 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 { diff --git a/testdata/bgworker/basic.php b/testdata/bgworker/basic.php index 9565cef551..fcfaffc7df 100644 --- a/testdata/bgworker/basic.php +++ b/testdata/bgworker/basic.php @@ -12,9 +12,10 @@ // Ready, then park on the handle until FrankenPHP drains us: the drain // closes the other end of the socket pair, which lands as EOF here and // makes the next tick return false. -$handle = frankenphp_get_worker_handle(); -while (frankenphp_worker_tick()) { - $read = [$handle]; +$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/count.php b/testdata/bgworker/count.php index 9c654699ba..cb7d1e2f86 100644 --- a/testdata/bgworker/count.php +++ b/testdata/bgworker/count.php @@ -6,9 +6,10 @@ if (!empty($_SERVER['BG_COUNT_FILE'])) { @file_put_contents($_SERVER['BG_COUNT_FILE'], "run\n", FILE_APPEND); } -$handle = frankenphp_get_worker_handle(); -while (frankenphp_worker_tick()) { - $read = [$handle]; +$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/crash-after-ready.php b/testdata/bgworker/crash-after-ready.php index 4d997c17f5..633f7af61c 100644 --- a/testdata/bgworker/crash-after-ready.php +++ b/testdata/bgworker/crash-after-ready.php @@ -1,9 +1,9 @@ tick(); exit(1); diff --git a/testdata/bgworker/crash.php b/testdata/bgworker/crash.php index 96d224bd9f..30eb27d664 100644 --- a/testdata/bgworker/crash.php +++ b/testdata/bgworker/crash.php @@ -22,9 +22,10 @@ @touch($restarted); -$handle = frankenphp_get_worker_handle(); -while (frankenphp_worker_tick()) { - $read = [$handle]; +$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/cycle.php b/testdata/bgworker/cycle.php index 9bde4210f9..a171626ea6 100644 --- a/testdata/bgworker/cycle.php +++ b/testdata/bgworker/cycle.php @@ -4,5 +4,5 @@ // FrankenPHP re-run it, which must not be throttled. Records when each run // started, so a test can see whether the interval between them grows. file_put_contents($_SERVER['BG_COUNT_FILE'], microtime(true) . "\n", FILE_APPEND); -frankenphp_worker_tick(); +(new \FrankenPHP\WorkerHandle())->tick(); usleep(150000); diff --git a/testdata/bgworker/early-return.php b/testdata/bgworker/early-return.php index 3162273799..590cd686ff 100644 --- a/testdata/bgworker/early-return.php +++ b/testdata/bgworker/early-return.php @@ -1,5 +1,5 @@ tick(); diff --git a/testdata/bgworker/fail-then-succeed.php b/testdata/bgworker/fail-then-succeed.php index 71d82199a7..6897a237e0 100644 --- a/testdata/bgworker/fail-then-succeed.php +++ b/testdata/bgworker/fail-then-succeed.php @@ -9,9 +9,10 @@ exit(1); } @touch($_SERVER['BG_SENTINEL']); -$handle = frankenphp_get_worker_handle(); -while (frankenphp_worker_tick()) { - $read = [$handle]; +$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/fetch-no-tick.php b/testdata/bgworker/fetch-no-tick.php index a8d5ec5770..92c7e8c639 100644 --- a/testdata/bgworker/fetch-no-tick.php +++ b/testdata/bgworker/fetch-no-tick.php @@ -1,7 +1,7 @@ $_SERVER['FRANKENPHP_WORKER'] ?? 'unset', 'background' => $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] ?? 'unset', ], true)); -$handle = frankenphp_get_worker_handle(); -while (frankenphp_worker_tick()) { - $read = [$handle]; +$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 index f7541eb7dd..8e9c8d9b12 100644 --- a/testdata/bgworker/loop.php +++ b/testdata/bgworker/loop.php @@ -1,15 +1,16 @@ getStream(); do { - $read = [$handle]; + $read = [$stream]; $write = $except = null; stream_select($read, $write, $except, null); -} while (frankenphp_worker_tick()); +} while ($handle->tick()); diff --git a/testdata/bgworker/named.php b/testdata/bgworker/named.php index 70bbfaa88c..d3a72355c1 100644 --- a/testdata/bgworker/named.php +++ b/testdata/bgworker/named.php @@ -12,9 +12,10 @@ @touch($_SERVER['BG_SENTINEL_DIR'] . DIRECTORY_SEPARATOR . $name); } -$handle = frankenphp_get_worker_handle(); -while (frankenphp_worker_tick()) { - $read = [$handle]; +$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/no-time-limit.php b/testdata/bgworker/no-time-limit.php index d4228e6ef0..3585f8a19f 100644 --- a/testdata/bgworker/no-time-limit.php +++ b/testdata/bgworker/no-time-limit.php @@ -7,5 +7,6 @@ // BG_COUNT_FILE, so anything that interrupts the park shows up as a second // line. file_put_contents($_SERVER['BG_COUNT_FILE'], "run\n", FILE_APPEND); -frankenphp_worker_tick(); -fgets(frankenphp_get_worker_handle()); +$handle = new \FrankenPHP\WorkerHandle(); +$handle->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 index 8446459393..d9d53024ef 100644 --- a/testdata/bgworker/pool.php +++ b/testdata/bgworker/pool.php @@ -4,9 +4,10 @@ // 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 = frankenphp_get_worker_handle(); -while (frankenphp_worker_tick()) { - $read = [$handle]; +$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 index 3ef3a66f4c..f90affa1f1 100644 --- a/testdata/bgworker/read.php +++ b/testdata/bgworker/read.php @@ -6,5 +6,6 @@ if (!empty($_SERVER['BG_SENTINEL'])) { @touch($_SERVER['BG_SENTINEL']); } -frankenphp_worker_tick(); -fgets(frankenphp_get_worker_handle()); +$handle = new \FrankenPHP\WorkerHandle(); +$handle->tick(); +fgets($handle->getStream()); diff --git a/testdata/bgworker/readable.php b/testdata/bgworker/readable.php index 47f1b13d41..86db0d0761 100644 --- a/testdata/bgworker/readable.php +++ b/testdata/bgworker/readable.php @@ -3,23 +3,24 @@ // Reports whether the handle is still readable after a tick: the tick // consumes the wake-ups, including the one sent at start, so a script that // ticked has nothing left to read until the next one or the drain. -$handle = frankenphp_get_worker_handle(); -$poll = static function () use ($handle): string { - $read = [$handle]; +$handle = new \FrankenPHP\WorkerHandle(); +$stream = $handle->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()]; -frankenphp_worker_tick(); +$handle->tick(); $seen[] = 'after tick:' . $poll(); -frankenphp_worker_tick(); +$handle->tick(); $seen[] = 'after second tick:' . $poll(); file_put_contents($_SERVER['BG_SENTINEL'], implode(' ', $seen)); -while (frankenphp_worker_tick()) { - $read = [$handle]; +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 index 10ffd47262..048066c6a7 100644 --- a/testdata/bgworker/recv.php +++ b/testdata/bgworker/recv.php @@ -7,5 +7,6 @@ if (!empty($_SERVER['BG_SENTINEL'])) { @touch($_SERVER['BG_SENTINEL']); } -frankenphp_worker_tick(); -stream_socket_recvfrom(frankenphp_get_worker_handle(), 1); +$handle = new \FrankenPHP\WorkerHandle(); +$handle->tick(); +stream_socket_recvfrom($handle->getStream(), 1); diff --git a/testdata/bgworker/refetch.php b/testdata/bgworker/refetch.php index 6ca7014aa7..aecc10bdc6 100644 --- a/testdata/bgworker/refetch.php +++ b/testdata/bgworker/refetch.php @@ -1,17 +1,18 @@ getStream(); +$second = (new \FrankenPHP\WorkerHandle())->getStream(); $result = $first === $second ? 'same' : 'different'; fclose($first); -$third = frankenphp_get_worker_handle(); +$handle = new \FrankenPHP\WorkerHandle(); +$third = $handle->getStream(); $result .= $third === $second ? ' then same' : ' then fresh'; file_put_contents($_SERVER['BG_SENTINEL'], $result); -frankenphp_worker_tick(); +$handle->tick(); fgets($third); diff --git a/testdata/bgworker/slow-boot.php b/testdata/bgworker/slow-boot.php index fb0f60c208..56fc7b4a9c 100644 --- a/testdata/bgworker/slow-boot.php +++ b/testdata/bgworker/slow-boot.php @@ -1,11 +1,11 @@ tick(); diff --git a/testdata/bgworker/stuck.php b/testdata/bgworker/stuck.php index 51c15e3f6b..578986908d 100644 --- a/testdata/bgworker/stuck.php +++ b/testdata/bgworker/stuck.php @@ -4,15 +4,16 @@ // run: only the force-kill can end that run, which is what the reboot test // proves. Later runs park properly so the shutdown stays quick. set_time_limit(0); -frankenphp_worker_tick(); +$handle = new \FrankenPHP\WorkerHandle(); +$handle->tick(); if (!file_exists($_SERVER['BG_ONCE'])) { @touch($_SERVER['BG_ONCE']); sleep(60); } @touch($_SERVER['BG_SENTINEL']); -$handle = frankenphp_get_worker_handle(); -while (frankenphp_worker_tick()) { - $read = [$handle]; +$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 index 5614f16458..ed9f24d02e 100644 --- a/testdata/bgworker/tick.php +++ b/testdata/bgworker/tick.php @@ -1,17 +1,17 @@ tick() ? 'true' : 'false'; +$seen[] = $handle->tick() ? 'true' : 'false'; file_put_contents($_SERVER['BG_SENTINEL'], implode(' ', $seen)); -$read = [$handle]; +$read = [$handle->getStream()]; $write = $except = null; stream_select($read, $write, $except, null); -$seen[] = frankenphp_worker_tick() ? 'true' : 'false'; -$seen[] = frankenphp_worker_tick() ? 'true' : 'false'; +$seen[] = $handle->tick() ? 'true' : 'false'; +$seen[] = $handle->tick() ? 'true' : 'false'; file_put_contents($_SERVER['BG_SENTINEL'], implode(' ', $seen)); diff --git a/testdata/handle-outside.php b/testdata/handle-outside.php index c338d9b8cf..12f6141aa7 100644 --- a/testdata/handle-outside.php +++ b/testdata/handle-outside.php @@ -1,10 +1,8 @@ getMessage(), "\n"; - } +try { + new \FrankenPHP\WorkerHandle(); + echo 'no exception'; +} catch (\RuntimeException $e) { + echo $e->getMessage(); } diff --git a/threadbackgroundworker.go b/threadbackgroundworker.go index c2609ca6ab..76fd0c27ae 100644 --- a/threadbackgroundworker.go +++ b/threadbackgroundworker.go @@ -17,8 +17,8 @@ import ( // It owns their lifecycle: boot the script, re-run it when it exits, restart // it with a quadratic backoff when it crashes. Background workers share the // PHP runtime with HTTP threads but never receive HTTP requests. The script -// can park on the stream returned by frankenphp_get_worker_handle(), which -// reaches EOF when the thread is drained, and frankenphp_worker_tick() then +// can park on the stream returned by WorkerHandle::getStream(), which +// reaches EOF when the thread is drained, and WorkerHandle::tick() then // returns false, so it exits gracefully on shutdown, reboot or handler // transition. type backgroundWorkerThread struct { @@ -39,25 +39,25 @@ type backgroundWorkerThread struct { runStartedAt time.Time // isBootingScript is true until the current run calls - // frankenphp_worker_tick(), the background analog of an HTTP worker + // WorkerHandle::tick(), the background analog of an HTTP worker // reaching frankenphp_handle_request(). Only touched on the PHP thread // (setup, the C callback during execution, teardown). isBootingScript bool - // bootTimer warns when a run has not called frankenphp_worker_tick() + // bootTimer warns when a run has not called WorkerHandle::tick() // after backgroundBootWarnDelay; only touched on the PHP thread bootTimer *time.Timer // stopSock holds the Go side's end of this thread's stop socket pair // (per thread so pool workers drain independently); the other end is - // exposed to the script via frankenphp_get_worker_handle(). Wide enough + // exposed to the script via WorkerHandle::getStream(). Wide enough // for a Windows SOCKET, -1 when not held. Atomic because drain() closes // it from another goroutine. stopSock atomic.Int64 } // backgroundBootWarnDelay is how long a run may go without calling -// frankenphp_worker_tick() before a warning: Init() and Shutdown() wait for +// WorkerHandle::tick() before a warning: Init() and Shutdown() wait for // that point, so a script that never gets there hangs both silently const backgroundBootWarnDelay = 10 * time.Second @@ -153,7 +153,7 @@ func (handler *backgroundWorkerThread) setupScript() error { 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 frankenphp_worker_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, Init() and Shutdown() wait for it", slog.String("worker", name), slog.Int("thread", threadIndex)) } }) @@ -162,7 +162,7 @@ func (handler *backgroundWorkerThread) setupScript() error { } // the thread stays in TransitionComplete until the script calls - // frankenphp_worker_tick(), see go_frankenphp_background_worker_ready + // WorkerHandle::tick(), see go_frankenphp_background_worker_ready return nil } @@ -214,7 +214,7 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { return } - // boot failure: the script exited before calling frankenphp_worker_tick(), + // boot failure: the script exited before calling WorkerHandle::tick(), // a clean exit included, which would otherwise respawn in a tight loop. // StopReasonBootFailure skips the ready-gauge decrement, matching the // ReadyWorker call that never happened @@ -229,7 +229,7 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { if pastCap && !watcherIsEnabled { var err error if exitStatus == 0 { - err = fmt.Errorf("background worker %s exits without calling frankenphp_worker_tick()", worker.fileName) + 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) } @@ -240,9 +240,9 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { } logLevel := slog.LevelWarn - logMsg := "background worker failed before calling frankenphp_worker_tick(), restarting" + logMsg := "background worker failed before calling WorkerHandle::tick(), restarting" if exitStatus == 0 { - logMsg = "background worker exited without calling frankenphp_worker_tick(), restarting" + logMsg = "background worker exited without calling WorkerHandle::tick(), restarting" } if pastCap { logLevel = slog.LevelError @@ -264,13 +264,13 @@ func (handler *backgroundWorkerThread) stopBootTimer() { //export go_frankenphp_background_worker_ready func go_frankenphp_background_worker_ready(threadIndex C.uintptr_t) { - // called on the PHP thread by the first frankenphp_worker_tick() of a + // called on the PHP thread by the first WorkerHandle::tick() of a // run; the handler is a backgroundWorkerThread because that function // throws on every other thread kind, and a thread reaching this without // one would wait out Init() instead handler, ok := phpThreads[threadIndex].handler.(*backgroundWorkerThread) if !ok { - panic("frankenphp_worker_tick() called on a thread that is not a background worker") + panic("WorkerHandle::tick() called on a thread that is not a background worker") } if handler.isBootingScript { From 79737414a43792ee19a26458abf0e0e4625c2147 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sun, 20 Sep 2026 08:17:38 +0200 Subject: [PATCH 30/46] refactor: keep the stream of a handle on the handle, not on the thread The cache that keeps a loop from growing the resource list of a run moves from a thread-local slot to the handle that hands the stream out, where the rest of a handle's state already lives. A handle gives the same stream every time, a fresh one once the script closed it, and another handle has its own over the same socket, which is harmless since the stream does not own it and the drain reaches every one of them. Nothing of it survives the run any more: the handle takes its stream with it, so the thread no longer carries one to reset between runs. Asking a throwaway handle for a stream in a loop stays flat, the object frees its stream as it goes. --- bgworker_test.go | 8 ++--- frankenphp.c | 56 +++++++++++++++++++++-------------- frankenphp.stub.php | 4 +-- frankenphp_arginfo.h | 2 +- testdata/bgworker/refetch.php | 21 +++++++------ 5 files changed, 52 insertions(+), 39 deletions(-) diff --git a/bgworker_test.go b/bgworker_test.go index 1fa748c8e1..e4b9ad7317 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -606,9 +606,9 @@ func TestBackgroundWorkerParkingIsNotInterrupted(t *testing.T) { assert.Equal(t, 1, runs(), "the worker was restarted, so a limit interrupted its park") } -// TestBackgroundWorkerStreamClosedAndFetchedAgain checks the stream cache: -// a run gets one stream, closing it yields a fresh one on the next fetch, -// and the drain still reaches the script through it. +// TestBackgroundWorkerStreamClosedAndFetchedAgain checks the stream a +// handle hands out: the same one every time, a fresh one once the script +// closed it, one per handle, and the drain reaches the script through it. func TestBackgroundWorkerStreamClosedAndFetchedAgain(t *testing.T) { sentinel := filepath.Join(t.TempDir(), "refetch.txt") @@ -620,7 +620,7 @@ func TestBackgroundWorkerStreamClosedAndFetchedAgain(t *testing.T) { ), frankenphp.WithNumThreads(2), )) - assert.Equal(t, "same then fresh", requireFileContentEventually(t, sentinel)) + assert.Equal(t, "same then fresh then its own", requireFileContentEventually(t, sentinel)) done := make(chan struct{}) go func() { diff --git a/frankenphp.c b/frankenphp.c index ad2262243a..e25783e065 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -153,10 +153,6 @@ 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; -/* the stream of the current run, see WorkerHandle::getStream(); the - * cache holds a ref, and the resource list of the run frees it at request - * shutdown, so the pointer is only reset, never released, between runs */ -static THREAD_LOCAL zend_resource *worker_handle_res = NULL; static THREAD_LOCAL HashTable *sandboxed_env = NULL; /* prepared_env holds entries from php(_server)'s `env KEY VAL`, exposed to * getenv() and merged into $_ENV when 'E' is in variables_order. Separate from @@ -405,13 +401,11 @@ static void frankenphp_worker_close_stop_socks(void) { } /* Resets the background worker state of the calling thread. The streams - * handed out by WorkerHandle::getStream() do not own the socket and - * were destroyed by request shutdown, so the cache is dropped, not released. - */ + * handed out by WorkerHandle::getStream() do not own the socket, and they + * belong to the handles of the run, which request shutdown destroyed. */ static void frankenphp_reset_background_worker(void) { is_background_worker = false; worker_ticked = false; - worker_handle_res = NULL; frankenphp_worker_close_stop_socks(); } @@ -1285,10 +1279,24 @@ 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. It lives on the handle rather than on the thread: a handle + * that goes away takes its stream with it, and nothing of it survives the + * run. */ +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 = NULL, + .cleanup = frankenphp_worker_handle_cleanup, }; static zend_object *frankenphp_worker_handle_new(zend_class_entry *ce) { @@ -1332,19 +1340,21 @@ ZEND_METHOD(FrankenPHP_WorkerHandle, getStream) { * setup or on thread exit, so a run always has one */ ZEND_ASSERT(worker_stop_socks[0] != SOCK_ERR); - /* One stream per run, whatever the number of handles: the same resource - * is returned 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 it never - * affects a later one and the EOF of a drain reaches all. */ - if (worker_handle_res != NULL) { - if (worker_handle_res->type == php_file_le_stream()) { - GC_ADDREF(worker_handle_res); - RETURN_RES(worker_handle_res); + /* One stream per handle: the same resource is returned 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 it never affects the stream + * of another handle and the EOF of a drain reaches all of them. */ + 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 cache's ref */ - zend_list_delete(worker_handle_res); - worker_handle_res = NULL; + /* closed by the script: drop the handle's ref */ + zend_list_delete(cached); + handle->handle_data = NULL; } php_stream *stream = @@ -1363,8 +1373,8 @@ ZEND_METHOD(FrankenPHP_WorkerHandle, getStream) { stream->ops = &frankenphp_worker_handle_ops; php_stream_to_zval(stream, return_value); - worker_handle_res = Z_RES_P(return_value); - GC_ADDREF(worker_handle_res); + 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 diff --git a/frankenphp.stub.php b/frankenphp.stub.php index 5fc5b0a8d5..902701e628 100644 --- a/frankenphp.stub.php +++ b/frankenphp.stub.php @@ -90,8 +90,8 @@ public function tick(): bool {} * 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 run has one stream, a fresh one over the same socket - * once the script closed it. + * safe: a handle hands out one stream, a fresh one over the same + * socket once the script closed it. * * @return resource */ diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index 46b4c74994..39af0408ae 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: e41a047997eda7a6625c848460043d842b2d4fad */ + * Stub hash: 5305ddd8e19f8ceaed7e5dc5a8b372c9389705b2 */ 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) diff --git a/testdata/bgworker/refetch.php b/testdata/bgworker/refetch.php index aecc10bdc6..a0948e4e44 100644 --- a/testdata/bgworker/refetch.php +++ b/testdata/bgworker/refetch.php @@ -1,17 +1,20 @@ getStream(); -$second = (new \FrankenPHP\WorkerHandle())->getStream(); -$result = $first === $second ? 'same' : 'different'; +// Bg worker checking the stream of a handle: asking one handle twice gives +// the same stream, closing it and asking again gives a fresh one, another +// handle has its own, and the drain reaches the script through any of +// them. Writes what it saw to BG_SENTINEL, then parks. +$handle = new \FrankenPHP\WorkerHandle(); +$first = $handle->getStream(); +$again = $handle->getStream(); +$result = $first === $again ? 'same' : 'different'; fclose($first); -$handle = new \FrankenPHP\WorkerHandle(); $third = $handle->getStream(); -$result .= $third === $second ? ' then same' : ' then fresh'; +$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(); From 15bec04e9258564a98ebf3a20a5c292b4a57407b Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sun, 20 Sep 2026 08:17:38 +0200 Subject: [PATCH 31/46] docs: lead with the poll loop, the stream is for the libraries that take one The waiting a script does is shown with an Io\Poll\Context first, which is what a background worker should reach for on 8.6 and, through the polyfill, below it. The stream keeps its paragraph, as what an event loop takes and as the fallback for a script with no loop of its own, with the FD_SETSIZE ceiling of stream_select() named there rather than in the middle of the explanation. --- docs/worker.md | 45 +++++++++++++++++---------------------------- 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/docs/worker.md b/docs/worker.md index b9826f4f16..d3ed42cdd9 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -216,18 +216,25 @@ 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. Between two calls, the script waits on the stream of `getStream()`, alone or together with its own streams. Waiting is all that stream supports: it becomes readable when FrankenPHP needs the script's attention, `tick()` consumes whatever was written on it, and the stream is quiet again until the next wake-up. What it carries is not part of the contract. Waiting really is all: reading the stream steals the bytes `tick()` would have consumed, and writing to it lands in a buffer FrankenPHP never drains, so a script that fills it blocks itself. Closing it is safe, the socket belongs to the thread and the next `getStream()` hands out a fresh stream over it, drain included. It is readable once right after the script starts, so a loop that services it ticks by itself and the worker is ready as soon as its loop runs. +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. ```php getStream(); + +$poll = new Context(); +$poll->add($handle, [Event::Read]); +// plus the handles the script waits on while ($handle->tick()) { - $read = [$stream]; // plus the streams the script waits on - $write = $except = null; - stream_select($read, $write, $except, 1); + foreach ($poll->wait() as $watcher) { + // the worker's handle triggered, or one of the script's + } doSomeWork(); } @@ -235,9 +242,11 @@ while ($handle->tick()) { // drained: return, FrankenPHP re-runs or stops the script ``` -`stream_select()` rejects a stream whose descriptor is `1024` or higher (`FD_SETSIZE`), and the handle takes the lowest free descriptor when the run starts, so a worker re-run while the server holds more than a thousand connections cannot wait this way. Under that load, park with a blocking read on the stream, such as `fgets()`, or with an `Io\Poll\Context` on PHP 8.6, which takes the handle itself. `tick()` itself uses `poll()` and is not affected. +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. -With an event loop, register the stream as readable and tick from the callback. With [Revolt](https://revolt.run), the loop of amphp: +`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 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 - } -} -``` - -The context polls with `epoll` or `kqueue`, so it has neither the `FD_SETSIZE` ceiling of `stream_select()` nor its cost of rebuilding the set on every wait. On 8.2 to 8.5 the class implements the interface all the same, so the same loop runs there with [symfony/polyfill-io-poll](https://github.com/symfony/polyfill/tree/1.x/src/Io/Poll), which backs the context with `stream_select()`. +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()`. From 22f6864c9d3e06c7187c7cc4364d0c84b3085851 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 21 Sep 2026 11:03:33 +0200 Subject: [PATCH 32/46] fix: a handle built behind the constructor must not reach the Go side unserialize('O:23:"FrankenPHP\WorkerHandle":0:{}') builds one on a request thread without calling the constructor, and tick() on it then panics go_frankenphp_background_worker_ready() from a cgo callback, taking the process down: the ZEND_ASSERT that stood there is compiled out of release builds. getStream() handed out a stream over fd -1 the same way. The class is now @not-serializable, which refuses that reconstruction, and being internal and final with a create_object handler it was already out of newInstanceWithoutConstructor()'s reach. The methods no longer take the constructor's word for it either: both check the thread they run on and throw, so the Go side keeps its invariant with nothing able to break it. --- bgworker_test.go | 15 +++++++++++++++ frankenphp.c | 24 ++++++++++++++++++++---- frankenphp.stub.php | 3 +++ frankenphp_arginfo.h | 4 ++-- testdata/handle-bypass.php | 18 ++++++++++++++++++ 5 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 testdata/handle-bypass.php diff --git a/bgworker_test.go b/bgworker_test.go index e4b9ad7317..474103e11a 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -799,3 +799,18 @@ func TestBackgroundWorkerHandleIsAPollHandle(t *testing.T) { assert.JSONEq(t, `{"handle":true,"internal":true}`, requireFileContentEventually(t, sentinel)) } + +// TestWorkerHandleBuiltBehindTheConstructor checks that 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") +} diff --git a/frankenphp.c b/frankenphp.c index e25783e065..b1ec88c15e 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1321,6 +1321,20 @@ static int frankenphp_worker_handle_close(php_stream *stream, * tick that reports the drain, and nothing else. Constructing it outside a * background worker throws; a run may create as many as it likes, they all * speak for the same socket. */ +/* 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(); @@ -1336,9 +1350,9 @@ ZEND_METHOD(FrankenPHP_WorkerHandle, __construct) { ZEND_METHOD(FrankenPHP_WorkerHandle, getStream) { ZEND_PARSE_PARAMETERS_NONE(); - /* the pair is opened before the script starts and closed at the next run - * setup or on thread exit, so a run always has one */ - ZEND_ASSERT(worker_stop_socks[0] != SOCK_ERR); + if (!frankenphp_worker_handle_usable()) { + RETURN_THROWS(); + } /* One stream per handle: the same resource is returned until the script * closes it, so asking for it in a loop does not grow the resource list @@ -1388,7 +1402,9 @@ ZEND_METHOD(FrankenPHP_WorkerHandle, getStream) { ZEND_METHOD(FrankenPHP_WorkerHandle, tick) { ZEND_PARSE_PARAMETERS_NONE(); - ZEND_ASSERT(worker_stop_socks[0] != SOCK_ERR); + if (!frankenphp_worker_handle_usable()) { + RETURN_THROWS(); + } if (!worker_ticked) { worker_ticked = true; diff --git a/frankenphp.stub.php b/frankenphp.stub.php index 902701e628..0269e3e2a6 100644 --- a/frankenphp.stub.php +++ b/frankenphp.stub.php @@ -59,6 +59,9 @@ function frankenphp_log(string $message, int $level = 0, array $context = []): v 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 diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index 39af0408ae..cb6560ec7b 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: 5305ddd8e19f8ceaed7e5dc5a8b372c9389705b2 */ + * Stub hash: bef9b3db168eabf3acabe7c1f5a107b466809146 */ 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) @@ -94,7 +94,7 @@ 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); + 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/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()); From ea9a619cb1b615ab2c4d8e36488bf87b73f2f2bb Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 21 Sep 2026 11:03:33 +0200 Subject: [PATCH 33/46] fix: pace a crash loop whatever the run lasted crashCount was reset by any run longer than 100ms, so a script exiting non-zero after, say, 150ms restarted with no backoff at all, some seven times a second, each one logging a warning and counting a crash. Only a clean exit resets it now: a worker processing a batch and returning still starts fresh, a crashing one is paced by the backoff however long it took to fail. --- threadbackgroundworker.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/threadbackgroundworker.go b/threadbackgroundworker.go index 76fd0c27ae..f7095ec035 100644 --- a/threadbackgroundworker.go +++ b/threadbackgroundworker.go @@ -202,10 +202,12 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { } } - // a run that lasted did something, whatever its exit status, and - // the next one starts fresh: a worker processing a batch and - // returning is not a worker spinning on an immediate exit - if time.Since(handler.runStartedAt) > minHealthyRun { + // a run that returned cleanly did something, and the next one + // starts fresh: 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, so a script failing every + // few hundred milliseconds still ends up paced by the backoff + if exitStatus == 0 && time.Since(handler.runStartedAt) > minHealthyRun { handler.crashCount = 0 } handler.wait(restartBackoff(handler.crashCount)) From 06442ab20f9df9b1a18166eb7aebc7e7f7ed767c Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 21 Sep 2026 11:03:33 +0200 Subject: [PATCH 34/46] chore: check the server the same way in SendRequest and SendMessage SendMessage() refused a server that is not registered while SendRequest() left it to Server.ServeHTTP(). Same ErrNotRunning either way, one less thing to wonder about when reading the two next to each other. --- workerextension.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/workerextension.go b/workerextension.go index b83c72e007..eafa27b14e 100644 --- a/workerextension.go +++ b/workerextension.go @@ -26,8 +26,9 @@ 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() - if w.internalWorker == nil { + // 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 } From e4bf6569a0e4539b9eec9f7f532b82fc8f0d56ac Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 21 Sep 2026 11:07:24 +0200 Subject: [PATCH 35/46] fix: form the Windows socket pair on loopback, and only with ourselves PHP's socketpair() emulation is not one: it binds a listener to INADDR_ANY, so the port is reachable from off the machine while the pair forms, and hands back whichever connection arrives first. The pair is built here instead, the way libevent and Tor do it: the listener takes the loopback address alone and SO_EXCLUSIVEADDRUSE, and a connection is kept only when its peer is the socket we connected with. Another process racing a connect is dropped and the next one accepted, where the check we had before failed the whole pair and left the worker to retry. --- frankenphp.c | 97 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 81 insertions(+), 16 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index b1ec88c15e..f3d57870e1 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -409,27 +409,92 @@ static void frankenphp_reset_background_worker(void) { frankenphp_worker_close_stop_socks(); } -static int frankenphp_worker_open_stop_pair(void) { #ifdef PHP_WIN32 - /* PHP's emulation, a loopback TCP pair; it only accepts AF_INET, listens - * on INADDR_ANY and accepts the first peer, so check the pair is ours */ - if (socketpair(AF_INET, SOCK_STREAM, 0, worker_stop_socks) != 0) { - worker_stop_socks[0] = SOCK_ERR; - worker_stop_socks[1] = SOCK_ERR; - +/* 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 here + * instead, the way libevent and Tor do it: the listener takes the + * loopback address alone, and a connection is kept only when it comes + * from the socket we connected with, so a process racing a connect is + * rejected and the next one accepted rather than failing the pair. */ +#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; } - struct sockaddr_in peer = {0}, local = {0}; - int peer_len = sizeof(peer), local_len = sizeof(local); - if (getpeername(worker_stop_socks[0], (struct sockaddr *)&peer, &peer_len) != - 0 || - getsockname(worker_stop_socks[1], (struct sockaddr *)&local, - &local_len) != 0 || - peer.sin_port != local.sin_port || - peer.sin_addr.s_addr != local.sin_addr.s_addr) { - frankenphp_worker_close_stop_socks(); + /* 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 From 786b82a54ba783bcdfe394f1bdd3e2a0e6425796 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 21 Sep 2026 12:59:56 +0200 Subject: [PATCH 36/46] 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, kept 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, for whoever wants the old behaviour, and HTTP workers are untouched. --- bgworker_test.go | 18 ++++++++++++ caddy/workerconfig.go | 28 +++++++++++++++++- docs/config.md | 1 + docs/worker.md | 2 +- options.go | 20 +++++++++++++ testdata/bgworker/never-ticks.php | 10 +++++++ threadbackgroundworker.go | 8 ++++++ worker.go | 47 ++++++++++++++++++++++++++++++- 8 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 testdata/bgworker/never-ticks.php diff --git a/bgworker_test.go b/bgworker_test.go index 474103e11a..15cff9e372 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -814,3 +814,21 @@ 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") } + +// TestBackgroundWorkerBootTimeout checks the bound on the wait for a +// worker to become ready: a script that parks without ever calling +// WorkerHandle::tick() fails Init() instead of hanging it, which is 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 326b99a045..7cc0363fa4 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 = 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 { if wc.Name == "" { return wc, d.Err(`background workers must have an explicit "name"`) @@ -181,6 +203,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 0010d6db03..71ed113002 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. 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 } } diff --git a/docs/worker.md b/docs/worker.md index d3ed42cdd9..7a2ba4beea 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -216,7 +216,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 6d66c33b9a..d1d1747df9 100644 --- a/options.go +++ b/options.go @@ -58,6 +58,8 @@ type workerOpt struct { onServerShutdown func() server *Server isBackgroundWorker bool + bootTimeout time.Duration + bootTimeoutIsSet bool } // WithContext sets the main context to use. @@ -255,6 +257,24 @@ func WithWorkerBackground() WorkerOption { } // WithWorkerMaxFailures sets the maximum number of consecutive failures before panicking +// 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, which is what +// a build with Zend max execution timers does anyway, since +// max_execution_time ends the bootstrap there. 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 + } +} + func WithWorkerMaxFailures(maxFailures int) WorkerOption { return func(w *workerOpt) error { if maxFailures < -1 { 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 f7095ec035..1d76bddc8e 100644 --- a/threadbackgroundworker.go +++ b/threadbackgroundworker.go @@ -227,7 +227,15 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { // operator. Past startup, a failing background worker keeps // restarting with a louder log line: silently giving up would leave // the server in a broken half-state with no clear way to recover. + // 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 8c5c27281c..66d09a8afd 100644 --- a/worker.go +++ b/worker.go @@ -37,6 +37,10 @@ type worker struct { threads []*phpThread threadMutex sync.RWMutex maxConsecutiveFailures int + bootTimeout time.Duration + // bootTimedOut is set when Init() gave up waiting for this worker to + // reach its ready point: the run is drained and must not start again + bootTimedOut atomic.Bool onThreadReady func(int) onThreadShutdown func(int) queuedRequests atomic.Int32 @@ -55,6 +59,13 @@ var ( startupPhase atomic.Bool ) +// DefaultWorkerBootTimeout is how long a background worker may take to +// reach its first WorkerHandle::tick() before Init() fails, matching PHP's +// own default max_execution_time, which bounds the same bootstrap on builds +// with Zend max execution timers. WithWorkerBootTimeout() changes it, zero +// waits for ever. +const DefaultWorkerBootTimeout = 30 * time.Second + func initWorkers(opts []workerOpt) error { if len(opts) == 0 { return nil @@ -109,8 +120,33 @@ 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 is still in its bootstrap, or parked without + // ever ticking: tell it to drain, so the run ends and the + // thread reaches a state Shutdown() can act on, and say + // what happened rather than wait for ever + 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 { + // wake the script: its run then ends on the boot + // failure path, which stops the thread instead of + // starting it again, see afterScriptExecution() + handler.drain() + } }) } } @@ -245,6 +281,14 @@ 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(); where Zend max execution + // timers are around, max_execution_time ends its bootstrap first + bootTimeout := DefaultWorkerBootTimeout + if o.bootTimeoutIsSet { + bootTimeout = o.bootTimeout + } + w := &worker{ name: o.name, qualifiedName: qualifiedName, @@ -256,6 +300,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: scope, From 5b603d92c6a0575b42a671bd305bcb03bd6a55af Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 21 Sep 2026 17:39:22 +0200 Subject: [PATCH 37/46] chore: fewer comments, the ones left say why --- bgworker_test.go | 151 +++++++++++++------------------------- frankenphp.c | 138 +++++++++++++++------------------- metrics.go | 9 +-- options.go | 20 +++-- phpthread.go | 13 ++-- server.go | 3 +- threadbackgroundworker.go | 117 +++++++++++------------------ worker.go | 79 +++++++------------- 8 files changed, 201 insertions(+), 329 deletions(-) diff --git a/bgworker_test.go b/bgworker_test.go index 15cff9e372..62ef7fbb8a 100644 --- a/bgworker_test.go +++ b/bgworker_test.go @@ -17,8 +17,7 @@ import ( "github.com/stretchr/testify/require" ) -// requireFileEventually asserts that `path` appears on disk before the -// deadline. Wraps require.Eventually so call sites stay short. +// 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 { @@ -27,8 +26,7 @@ func requireFileEventually(t testing.TB, path string, msgAndArgs ...any) { }, 5*time.Second, 25*time.Millisecond, msgAndArgs...) } -// requireFileContentEventually waits for `path` to appear with content and -// returns it +// requireFileContentEventually waits for `path` to appear with content func requireFileContentEventually(t *testing.T, path string) string { t.Helper() require.Eventually(t, func() bool { @@ -41,11 +39,8 @@ func requireFileContentEventually(t *testing.T, path string) string { return string(b) } -// TestBackgroundWorkerLifecycle boots a background worker that touches a -// sentinel file then parks on its handle. It proves the bg worker runs -// (sentinel appears) and that Shutdown returns within a reasonable time. -// The test asserts on Shutdown timing, so it manages Shutdown itself -// instead of using initServers' t.Cleanup hook. +// 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") @@ -74,9 +69,6 @@ func TestBackgroundWorkerLifecycle(t *testing.T) { } } -// TestBackgroundWorkerCrashRestarts boots a worker that exit(1)s on its -// first run and touches a "restarted" sentinel on its second run. The -// sentinel proves the crash-restart loop fired. func TestBackgroundWorkerCrashRestarts(t *testing.T) { tmp := t.TempDir() crashMarker := filepath.Join(tmp, "bg-crash.marker") @@ -96,11 +88,7 @@ func TestBackgroundWorkerCrashRestarts(t *testing.T) { requireFileEventually(t, restarted, "background worker did not restart after crash") } -// TestBackgroundWorkerOnServer scopes a background worker to a Server. It -// proves that the worker inherits the server env (the sentinel directory is -// declared on the server, not on the worker), that FRANKENPHP_WORKER_BACKGROUND -// holds the worker name, and that the worker does not intercept HTTP requests -// served by the same server. +// the sentinel directory is declared on the server, not on the worker func TestBackgroundWorkerOnServer(t *testing.T) { tmp := t.TempDir() @@ -135,7 +123,6 @@ func TestBackgroundWorkerOnServer(t *testing.T) { assert.Contains(t, body, "I am by birth a Genevese", "the server must still serve regular requests") } -// TestBackgroundWorkerValidation covers the declaration-time errors. func TestBackgroundWorkerValidation(t *testing.T) { t.Cleanup(frankenphp.Shutdown) @@ -244,8 +231,6 @@ func TestBackgroundWorkerValidation(t *testing.T) { }) } -// TestBackgroundWorkerCannotHandleRequests checks that a request targeting a -// background worker by name is refused rather than dispatched to it. func TestBackgroundWorkerCannotHandleRequests(t *testing.T) { server, err := frankenphp.NewServer(testDataDir) require.NoError(t, err) @@ -259,9 +244,8 @@ func TestBackgroundWorkerCannotHandleRequests(t *testing.T) { require.ErrorContains(t, err, `background worker "jobs" cannot handle requests`) } -// TestBackgroundWorkerParksOnRead checks that a blocking read on the handle -// is a wait too: Init() returns only once the worker is ready, and the EOF -// of the drain unblocks the read so Shutdown() returns promptly. +// 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") @@ -288,9 +272,8 @@ func TestBackgroundWorkerParksOnRead(t *testing.T) { } } -// TestBackgroundWorkerParksOnReceive checks that a blocking receive counts -// as a wait as well: it reaches the stream through the transport API rather -// than the read op, so Init() would hang if only reads reported readiness. +// 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") @@ -316,8 +299,6 @@ func TestBackgroundWorkerParksOnReceive(t *testing.T) { } } -// TestBackgroundWorkerRestartDrainsParkedScript checks that RestartWorkers() -// wakes a parked background script through the drain and re-runs it. func TestBackgroundWorkerRestartDrainsParkedScript(t *testing.T) { tmp := t.TempDir() countFile := filepath.Join(tmp, "bg-count.log") @@ -340,8 +321,6 @@ func TestBackgroundWorkerRestartDrainsParkedScript(t *testing.T) { require.Eventually(t, func() bool { return runs() == 2 }, 5*time.Second, 25*time.Millisecond, "background worker was not re-run after the restart") } -// TestWorkerHandleOutsideBackgroundWorker checks that a regular request -// thread cannot take a handle instead of being handed a stream on one. func TestWorkerHandleOutsideBackgroundWorker(t *testing.T) { server, err := frankenphp.NewServer(testDataDir) require.NoError(t, err) @@ -352,10 +331,8 @@ func TestWorkerHandleOutsideBackgroundWorker(t *testing.T) { assert.Contains(t, body, `FrankenPHP\WorkerHandle can only be created from a background worker`) } -// TestBackgroundWorkerLoopTicksOnItsOwn checks the wake-up sent at start: a -// script that only ticks when its handle is readable, the shape of a script -// driven by an event loop, becomes ready without an explicit first call. -// Without the wake-up, Init() would wait for that call until the drain. +// 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, @@ -368,10 +345,8 @@ func TestBackgroundWorkerLoopTicksOnItsOwn(t *testing.T) { requireFileEventually(t, sentinel, "background worker did not start") } -// TestBackgroundWorkerTickLeavesTheHandleQuiet checks that the tick -// consumes the wake-ups: the handle is readable at start, and not anymore -// once a tick returned, so a loop selecting on it blocks instead of -// spinning. +// 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, @@ -385,9 +360,8 @@ func TestBackgroundWorkerTickLeavesTheHandleQuiet(t *testing.T) { assert.Equal(t, "start:readable after tick:quiet after second tick:quiet", requireFileContentEventually(t, sentinel)) } -// TestBackgroundWorkerTick checks the contract of WorkerHandle::tick(): -// true while the worker runs, false once it is drained, and still false on -// the next call +// 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") @@ -408,9 +382,8 @@ func TestBackgroundWorkerTick(t *testing.T) { assert.Equal(t, "true true false false", string(b)) } -// TestWorkerNameInServerVars checks that an HTTP worker sees FRANKENPHP_WORKER -// as it always did, and that a background worker sees its declared name in -// FRANKENPHP_WORKER_BACKGROUND and no FRANKENPHP_WORKER. +// 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) @@ -433,8 +406,8 @@ func TestWorkerNameInServerVars(t *testing.T) { assert.Contains(t, flag, "'background' => 'jobs'") } -// TestBackgroundWorkerPool checks that num > 1 threads share the name, each -// parks on its own handle, and one drain wakes them all. +// 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, @@ -462,8 +435,8 @@ func TestBackgroundWorkerPool(t *testing.T) { } } -// TestBackgroundWorkerMultiEntrypoint checks that two named background -// workers of one server may share a script, since they are not matched by path. +// 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") @@ -488,9 +461,7 @@ func TestBackgroundWorkerMultiEntrypoint(t *testing.T) { requireFileEventually(t, second, "the second worker on the shared script did not start") } -// TestBackgroundWorkerThreadsComeOnTop checks that background threads are -// reserved on top of num_threads: one HTTP thread plus one background worker -// starts with num_threads 1. +// background threads are reserved on top of num_threads func TestBackgroundWorkerThreadsComeOnTop(t *testing.T) { sentinel := filepath.Join(t.TempDir(), "bg-only.sentinel") initServers(t, @@ -504,9 +475,8 @@ func TestBackgroundWorkerThreadsComeOnTop(t *testing.T) { requireFileEventually(t, sentinel, "background worker did not start with num_threads 1") } -// TestBackgroundWorkerDefaultsToOneThread checks that num is optional: a -// background worker serves no requests, so it gets one thread unless a -// pool is asked for, rather than the CPU count of an HTTP worker. +// 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, @@ -527,8 +497,7 @@ func TestBackgroundWorkerDefaultsToOneThread(t *testing.T) { assert.Equal(t, 1, background) } -// TestBackgroundWorkerThreadsComeOnTopOfAutoMaxThreads checks that the -// automatic limit is an HTTP one too: the reservation is added to what it +// 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") @@ -549,11 +518,9 @@ func TestBackgroundWorkerThreadsComeOnTopOfAutoMaxThreads(t *testing.T) { assert.Equal(t, 6, len(state.ThreadDebugStates)+state.ReservedThreadCount) } -// TestBackgroundWorkerBootstrapIsBounded checks that max_execution_time -// applies until the first WorkerHandle::tick(): a setup that outlives -// it is ended as a boot failure, which fails Init() past the cap. The limit -// itself is PHP's, so the test only runs where its timers are known to -// fire under FrankenPHP, the max execution timers of ZTS builds on Linux. +// 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") @@ -578,12 +545,9 @@ func TestBackgroundWorkerBootstrapIsBounded(t *testing.T) { assert.Equal(t, 1, bytes.Count(b, []byte("\n")), "the limit should have ended the one and only boot") } -// TestBackgroundWorkerParkingIsNotInterrupted checks that a script parked -// on its handle, past its first tick, is not cut short by the two limits it -// never disables itself: max_execution_time, which the first tick disarms -// after php_execute_script() re-armed it from the ini, and -// default_socket_timeout, which the handle overrides with an infinite read -// timeout. +// 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, @@ -606,9 +570,8 @@ func TestBackgroundWorkerParkingIsNotInterrupted(t *testing.T) { assert.Equal(t, 1, runs(), "the worker was restarted, so a limit interrupted its park") } -// TestBackgroundWorkerStreamClosedAndFetchedAgain checks the stream a -// handle hands out: the same one every time, a fresh one once the script -// closed it, one per handle, and the drain reaches the script through it. +// 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") @@ -634,9 +597,8 @@ func TestBackgroundWorkerStreamClosedAndFetchedAgain(t *testing.T) { } } -// TestBackgroundWorkerBootFailuresThenSucceeds checks that boot failures below -// max_consecutive_failures are retried with the backoff and Init() still -// succeeds once a run reaches its ready point. +// 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") @@ -654,10 +616,8 @@ func TestBackgroundWorkerBootFailuresThenSucceeds(t *testing.T) { assert.Equal(t, "3", string(boots), "two boot failures then a success") } -// TestBackgroundWorkerCrashAfterReadyRestarts checks that a crash after the -// ready point restarts without counting toward max_consecutive_failures, -// that a zero-timeout stream_select() counts as the wait, and that a drain -// cuts the backoff short. +// 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, @@ -680,8 +640,7 @@ func TestBackgroundWorkerCrashAfterReadyRestarts(t *testing.T) { assert.Less(t, time.Since(start), 500*time.Millisecond, "Shutdown() waited for the backoff") } -// TestBackgroundWorkerCleanExitIsPaced checks that a script returning right -// after its tick is re-run with the crash backoff rather than at once. +// 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, @@ -700,9 +659,8 @@ func TestBackgroundWorkerCleanExitIsPaced(t *testing.T) { assert.LessOrEqual(t, runs, 8, "the re-runs were not paced") } -// TestBackgroundWorkerCyclingIsNotThrottled checks the other side of the -// pacing: a worker that does some work and returns is re-run at its own -// pace, only a run ending too fast to have done anything is slowed down. +// 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, @@ -734,9 +692,8 @@ func TestBackgroundWorkerCyclingIsNotThrottled(t *testing.T) { assert.Less(t, last-first, 0.5, "the interval between runs grew, the worker was throttled") } -// TestBackgroundWorkerRebootForceKillsStuckScript checks that a script -// ignoring its handle does not stall RestartWorkers() past the reboot grace -// period: the force-kill ends it and the next run parks normally. +// 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) @@ -760,10 +717,7 @@ func TestBackgroundWorkerRebootForceKillsStuckScript(t *testing.T) { requireFileEventually(t, sentinel, "the re-run script did not park") } -// TestBackgroundWorkerPollHandle checks that a script can wait on its handle -// through the poll API of PHP 8.6, without a stream: the handle implements -// Io\Poll\Handle, so a context takes it as is, the first tick makes the -// worker ready, and the drain ends the loop. +// 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") @@ -783,10 +737,8 @@ func TestBackgroundWorkerPollHandle(t *testing.T) { assert.NotEmpty(t, requireFileContentEventually(t, sentinel)) } -// TestBackgroundWorkerHandleIsAPollHandle checks that the handle implements -// Io\Poll\Handle on every version: PHP 8.6 declares the interface with the -// poll API, FrankenPHP declares it below that, so one script serves both and -// symfony/polyfill-io-poll finds it already there. +// 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, @@ -800,10 +752,9 @@ func TestBackgroundWorkerHandleIsAPollHandle(t *testing.T) { assert.JSONEq(t, `{"handle":true,"internal":true}`, requireFileContentEventually(t, sentinel)) } -// TestWorkerHandleBuiltBehindTheConstructor checks that 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. +// 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) @@ -815,10 +766,8 @@ func TestWorkerHandleBuiltBehindTheConstructor(t *testing.T) { assert.Contains(t, body, "construct: RuntimeException: FrankenPHP\\WorkerHandle can only be created from a background worker") } -// TestBackgroundWorkerBootTimeout checks the bound on the wait for a -// worker to become ready: a script that parks without ever calling -// WorkerHandle::tick() fails Init() instead of hanging it, which is the -// only failure mode available where Zend max execution timers are not. +// 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, diff --git a/frankenphp.c b/frankenphp.c index f3d57870e1..83571a8ffb 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -369,12 +369,10 @@ void frankenphp_release_thread_for_kill(force_kill_slot slot) { #endif } -/* Stop channel of background workers: a socket pair. One end is exposed to - * the PHP script via WorkerHandle::getStream(), the other is handed to - * the Go side, which closes it on drain so the script's end reaches EOF and - * a stream_select() or a blocking read on it returns. A socket pair rather - * than a pipe because on Windows PHP's php_select() only really waits on - * sockets: before 8.5 it reports any other handle as always ready. */ +/* 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; @@ -383,8 +381,8 @@ static void frankenphp_worker_close_sock(php_socket_t s) { closesocket(s); } -/* keep the pair out of processes the script may spawn: a child holding the - * Go side's end would keep the script's end from ever reaching EOF */ +/* 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); @@ -400,9 +398,9 @@ static void frankenphp_worker_close_stop_socks(void) { } } -/* Resets the background worker state of the calling thread. The streams - * handed out by WorkerHandle::getStream() do not own the socket, and they - * belong to the handles of the run, which request shutdown destroyed. */ +/* 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; @@ -411,12 +409,10 @@ static void frankenphp_reset_background_worker(void) { #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 here - * instead, the way libevent and Tor do it: the listener takes the - * loopback address alone, and a connection is kept only when it comes - * from the socket we connected with, so a process racing a connect is - * rejected and the next one accepted rather than failing the pair. */ + * 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]) { @@ -518,11 +514,10 @@ static int frankenphp_worker_open_stop_pair(void) { 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 - * WorkerHandle::tick() of the run, see there. */ +/* 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; @@ -531,10 +526,9 @@ intptr_t frankenphp_set_background_worker_and_get_stop_sock(void) { return -1; } - /* One wake-up right away, so a script that registers its handle with an - * event loop and runs it ticks on its own: readiness then means the loop - * serviced the handle once. The first WorkerHandle::tick() consumes - * it. Nothing to do on failure, the script then has to tick by itself. */ + /* 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); @@ -554,10 +548,9 @@ void frankenphp_worker_close_stop_sock(intptr_t s) { if (s < 0) { return; } - /* Closing this end only lands as EOF on the script's end while no other - * process holds a copy of it, and a pcntl_fork() child inherits every - * descriptor of the process, including the pairs of the other threads. - * Shutting the write direction down sends the FIN regardless. */ + /* 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 @@ -567,10 +560,8 @@ void frankenphp_worker_close_stop_sock(intptr_t s) { } void frankenphp_update_local_thread_context(bool is_worker) { - /* A thread that ran a background worker can be recycled into an HTTP - * worker or a regular request thread: reset the bg TLS so - * WorkerHandle::getStream() rejects callers again, and release the - * stop socket. */ + /* 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(); } @@ -1248,12 +1239,11 @@ PHP_FUNCTION(frankenphp_log) { } } -/* 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, with the layout the poll API - * expects so that one set of ops serves both. */ +/* 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; @@ -1309,11 +1299,10 @@ static void frankenphp_handle_obj_free(zend_object *object) { zend_object_std_dtor(&obj->std); } -/* Declares Io\Poll\Handle on a handle class. The interface is internal - * only, an internal class is what it takes. PHP 8.6 brings it with the poll - * API; below it FrankenPHP declares it, so a script keeps one spelling with - * symfony/polyfill-io-poll, whose own declaration then never loads since it - * is autoloaded from a classmap. */ +/* 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); @@ -1344,11 +1333,9 @@ 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. It lives on the handle rather than on the thread: a handle - * that goes away takes its stream with it, and nothing of it survives the - * run. */ +/* 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; @@ -1368,10 +1355,10 @@ 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. */ +/* 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, @@ -1382,10 +1369,9 @@ 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, the - * tick that reports the drain, and nothing else. Constructing it outside a - * background worker throws; a run may create as many as it likes, they all - * speak for the same socket. */ +/* 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) { @@ -1419,11 +1405,10 @@ ZEND_METHOD(FrankenPHP_WorkerHandle, getStream) { RETURN_THROWS(); } - /* One stream per handle: the same resource is returned 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 it never affects the stream - * of another handle and the EOF of a drain reaches all of them. */ + /* 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; @@ -1456,14 +1441,13 @@ ZEND_METHOD(FrankenPHP_WorkerHandle, getStream) { GC_ADDREF(Z_RES_P(return_value)); } -/* The ready point of a background worker and its liveness check, the - * background 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(), alone or with its own streams, and calls this - * when it is readable. Whatever the runtime writes there to wake the script - * up is consumed here, so the script never has to read it and the protocol - * on it stays private. */ +/* 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(); @@ -1473,10 +1457,9 @@ ZEND_METHOD(FrankenPHP_WorkerHandle, tick) { if (!worker_ticked) { worker_ticked = true; - /* The bootstrap ran under max_execution_time like any request; the loop + /* 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: php_execute_script() did so before the script - * started, request shutdown comes after it ended. */ + * timer past this point. */ zend_unset_timeout(); go_frankenphp_background_worker_ready(frankenphp_thread_index()); } @@ -2117,10 +2100,9 @@ static void *php_thread(void *arg) { } zend_end_try(); - /* The stop socket of a background worker is plain thread-local state that - * frankenphp_update_local_thread_context() only releases on recycle: close - * it here too so it does not outlive the thread on shutdown, reboot or an - * unhealthy exit. The Go side's end is closed by the Go side. */ + /* 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(); } diff --git a/metrics.go b/metrics.go index 27140aa560..53ac12d541 100644 --- a/metrics.go +++ b/metrics.go @@ -47,11 +47,10 @@ type Metrics interface { } // 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. When the implementation passed to -// WithMetrics() satisfies it, the runtime reports workers through these -// methods and never through the worker methods of Metrics. +// 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) diff --git a/options.go b/options.go index d1d1747df9..b3fff52846 100644 --- a/options.go +++ b/options.go @@ -243,11 +243,10 @@ func WithWorkerServerScope(s *Server) WorkerOption { } // EXPERIMENTAL: WithWorkerBackground marks this worker as a background -// (non-HTTP) worker. Background workers run outside the request cycle: -// they share the PHP runtime with HTTP threads but never receive HTTP -// requests. The script can park on the stream returned by -// WorkerHandle::getStream(), which reaches EOF when FrankenPHP -// drains the worker, to exit gracefully on shutdown or restart. +// (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 @@ -257,12 +256,11 @@ func WithWorkerBackground() WorkerOption { } // WithWorkerMaxFailures sets the maximum number of consecutive failures before panicking -// 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, which is what -// a build with Zend max execution timers does anyway, since -// max_execution_time ends the bootstrap there. Defaults to -// DefaultWorkerBootTimeout, and has no effect on HTTP workers. +// 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 { diff --git a/phpthread.go b/phpthread.go index 82e65fe8bc..627fc1ae67 100644 --- a/phpthread.go +++ b/phpthread.go @@ -39,10 +39,8 @@ type threadHandler interface { beforeScriptExecution() string afterScriptExecution(exitStatus int) frankenPHPContext() *frankenPHPContext - // drain is a hook called right before drainChan is closed on shutdown - // and reboot. Handlers that need to wake up a thread parked in a - // blocking C call (background workers' stream_select on the stop socket) - // plug their signal in here; the other handlers are no-ops. + // 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() } @@ -164,10 +162,9 @@ 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 (background workers' stream_select -// on the stop socket), so a thread parked either way leaves without waiting -// for the force-kill +// 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) diff --git a/server.go b/server.go index 70f23a335f..f17087fd97 100644 --- a/server.go +++ b/server.go @@ -56,9 +56,8 @@ func registerServers(newServers []*Server) { fallbackServer.logger = globalLogger fallbackServer.resetWorkers() - // several servers may resolve to the same name (e.g. the same host), but // the name qualifies worker names in metrics and logs, so it must be - // unique: the first server keeps a name, the next ones get a numeric + // 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 { diff --git a/threadbackgroundworker.go b/threadbackgroundworker.go index 1d76bddc8e..b23bdd1cd9 100644 --- a/threadbackgroundworker.go +++ b/threadbackgroundworker.go @@ -13,14 +13,11 @@ import ( "github.com/dunglas/frankenphp/internal/state" ) -// backgroundWorkerThread is the threadHandler of background worker scripts. -// It owns their lifecycle: boot the script, re-run it when it exits, restart -// it with a quadratic backoff when it crashes. Background workers share the -// PHP runtime with HTTP threads but never receive HTTP requests. The script -// can park on the stream returned by WorkerHandle::getStream(), which -// reaches EOF when the thread is drained, and WorkerHandle::tick() then -// returns false, so it exits gracefully on shutdown, reboot or handler -// transition. +// 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. type backgroundWorkerThread struct { workerLifecycle @@ -28,31 +25,20 @@ type backgroundWorkerThread struct { context *frankenPHPContext failureCount int // number of consecutive failed runs - // crashCount is the number of runs that ended past their ready point - // within maxRestartBackoff in a row, crashed or not, and paces their - // restarts; a run that outlives it resets the count. Only touched on - // the PHP thread. - crashCount int - - // runStartedAt is when the current run started, only touched on the - // PHP thread + // consecutive short runs, crashed or not, pacing the restarts; a run + // that outlives maxRestartBackoff resets the count + crashCount int runStartedAt time.Time - // isBootingScript is true until the current run calls - // WorkerHandle::tick(), the background analog of an HTTP worker - // reaching frankenphp_handle_request(). Only touched on the PHP thread - // (setup, the C callback during execution, teardown). + // true until the run calls WorkerHandle::tick(), the background analog + // of an HTTP worker reaching frankenphp_handle_request() isBootingScript bool + bootTimer *time.Timer - // bootTimer warns when a run has not called WorkerHandle::tick() - // after backgroundBootWarnDelay; only touched on the PHP thread - bootTimer *time.Timer - - // stopSock holds the Go side's end of this thread's stop socket pair - // (per thread so pool workers drain independently); the other end is - // exposed to the script via WorkerHandle::getStream(). Wide enough - // for a Windows SOCKET, -1 when not held. Atomic because drain() closes - // it from another goroutine. + // 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 + // independently. Wide enough for a Windows SOCKET, -1 when not held, + // atomic because drain() closes it from another goroutine stopSock atomic.Int64 } @@ -76,10 +62,9 @@ func (handler *backgroundWorkerThread) frankenPHPContext() *frankenPHPContext { return handler.context } -// drain closes the Go side's end of the stop socket pair so a script -// parked on the other end wakes up with EOF and can exit its loop. Called -// right before drainChan is closed on shutdown and reboot; also reused -// internally to release the socket on the other exit paths. +// drain closes the Go side's end of the stop socket pair, so a script parked +// on the other end wakes up with EOF. Called right before drainChan is closed +// on shutdown and reboot, and on the other exit paths to release the socket. func (handler *backgroundWorkerThread) drain() { if s := handler.stopSock.Swap(-1); s >= 0 { C.frankenphp_worker_close_stop_sock(C.intptr_t(s)) @@ -119,8 +104,8 @@ func (handler *backgroundWorkerThread) startScript() string { } } -// 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. +// 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 { @@ -147,9 +132,8 @@ func (handler *backgroundWorkerThread) setupScript() error { 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: Stop() does not wait - // for a callback that already started, and a shutdown finishing - // meanwhile resets those + // 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) { @@ -168,9 +152,8 @@ func (handler *backgroundWorkerThread) setupScript() error { } func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { - // the Go side's end of the stop socket pair belongs to this thread; - // release it on every exit path so the next run gets a fresh pair - // (drain() already took it when the exit was drain-triggered) + // 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() @@ -180,13 +163,10 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { handler.stopBootTimer() handler.state.MarkAsWaiting(false) - // exit past the ready point, cooperative or a crash: re-run the script, - // unless the thread is being drained (beforeScriptExecution checks the - // state), without counting toward max_consecutive_failures, that cap is - // about a script that never boots. Unlike an HTTP worker, which can only - // exit after a request reached frankenphp_handle_request() and is - // therefore paced by traffic, a background worker reaches its ready - // point on its own, so a script ending right after it is paced here + // 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) @@ -202,11 +182,9 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { } } - // a run that returned cleanly did something, and the next one - // starts fresh: 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, so a script failing every - // few hundred milliseconds still ends up paced by the backoff + // 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 } @@ -216,18 +194,15 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { return } - // boot failure: the script exited before calling WorkerHandle::tick(), - // a clean exit included, which would otherwise respawn in a tight loop. - // StopReasonBootFailure skips the ready-gauge decrement, matching the - // ReadyWorker call that never happened + // 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, where it - // surfaces on startupFailChan so Init() returns the error to the - // operator. Past startup, a failing background worker keeps - // restarting with a louder log line: silently giving up would leave - // the server in a broken half-state with no clear way to recover. - // a worker Init() gave up on stops here, whatever the cap says: it was + // 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() { @@ -274,10 +249,8 @@ func (handler *backgroundWorkerThread) stopBootTimer() { //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; the handler is a backgroundWorkerThread because that function - // throws on every other thread kind, and a thread reaching this without - // one would wait out Init() instead + // 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") @@ -292,9 +265,8 @@ func go_frankenphp_background_worker_ready(threadIndex C.uintptr_t) { // parked from now on as far as the threads state endpoint is concerned handler.state.MarkAsWaiting(true) - // like an HTTP worker reaching frankenphp_handle_request(), the thread - // is ready only now: initWorkers() waits for this state, so a script - // that fails before its first tick still fails Init() + // 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) } @@ -307,9 +279,8 @@ func (handler *backgroundWorkerThread) backoff() { handler.failureCount++ } -// wait sleeps between two runs, cut short by a drain (shutdown, reboot, -// handler transition), which the next beforeScriptExecution() picks up -// from the state +// 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: diff --git a/worker.go b/worker.go index 66d09a8afd..30b92cd9f4 100644 --- a/worker.go +++ b/worker.go @@ -23,10 +23,7 @@ type worker struct { // name as declared, unique within its server (or among global workers) name string - // qualifiedName identifies the worker in logs and errors, where one - // string reads better than two fields: ":" for a - // server-scoped worker, the name otherwise. Metrics keep the two apart, - // see the Metrics interface + // ":" for a server-scoped worker, the name otherwise, for logs and errors qualifiedName string fileName string matchRequest func(*http.Request) bool @@ -38,14 +35,12 @@ type worker struct { threadMutex sync.RWMutex maxConsecutiveFailures int bootTimeout time.Duration - // bootTimedOut is set when Init() gave up waiting for this worker to - // reach its 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 marks this as a background (non-HTTP) worker + // 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 } @@ -53,17 +48,14 @@ var ( workers []*worker watcherIsEnabled bool startupFailChan chan error - // startupPhase is true while initWorkers() waits for the workers to - // boot, the only time a boot failure must reach startupFailChan: past - // that point the handlers log and keep restarting on their own + // 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, matching PHP's -// own default max_execution_time, which bounds the same bootstrap on builds -// with Zend max execution timers. WithWorkerBootTimeout() changes it, zero -// waits for ever. +// 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 { @@ -96,9 +88,8 @@ func initWorkers(opts []workerOpt) error { totalThreadsToStart += w.num workers = append(workers, w) - // scoping makes qualified names unique in all but pathological - // cases: a global worker may still be named like the ":" - // of a scoped one, and metrics would report them as one + // 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) } @@ -135,16 +126,13 @@ func initWorkers(opts []workerOpt) error { return } - // the script is still in its bootstrap, or parked without - // ever ticking: tell it to drain, so the run ends and the - // thread reaches a state Shutdown() can act on, and say - // what happened rather than wait for ever + // 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 { - // wake the script: its run then ends on the boot - // failure path, which stops the thread instead of - // starting it again, see afterScriptExecution() + // the run then ends on the boot failure path, which stops + // the thread instead of starting it again, see afterScriptExecution() handler.drain() } }) @@ -165,11 +153,9 @@ func initWorkers(opts []workerOpt) error { return nil } -// reportStartupFailure hands a boot failure to initWorkers() while it waits -// for the workers, so Init() fails, and reports whether it did: past that -// point the failure is dropped, the handler has logged it and keeps -// restarting. It never blocks, the buffer holds one error per thread and a -// thread failing repeatedly in the startup window must not hang on it +// 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 @@ -202,8 +188,7 @@ func newWorker(o workerOpt) (*worker, error) { } if o.isBackgroundWorker { - // the name is the script's identity (exposed via FRANKENPHP_WORKER); - // empty names are reserved for the catch-all workers of a future build + // the name is the script's identity, exposed as FRANKENPHP_WORKER_BACKGROUND if o.name == "" { return nil, fmt.Errorf("background worker %q must have an explicit name", o.fileName) } @@ -220,9 +205,7 @@ func newWorker(o workerOpt) (*worker, error) { } } - // a worker declared without a scope belongs to the fallback server, the - // one serving the requests that have no server either, so a worker - // always has one + // the fallback server serves the requests that have no server either, so a worker always has one scope := o.server if scope == nil { scope = fallbackServer @@ -236,10 +219,8 @@ func newWorker(o workerOpt) (*worker, error) { declaredPath = absFileName } - // a name generated from the script path is not a declaration: - // several workers may share a script, a pool split by a matcher - // for instance, so it is made unique rather than reported as the - // collision a declared name gets + // a generated name is not a declaration: several workers may share a + // script, so it is made unique instead of reported as a collision o.name = declaredPath for suffix := 1; scope.workersByName[o.name] != nil; suffix++ { o.name = fmt.Sprintf("%s_%d", declaredPath, suffix) @@ -271,19 +252,15 @@ func newWorker(o workerOpt) (*worker, error) { } } - // $_SERVER['FRANKENPHP_WORKER'] identifies an HTTP worker, as it always - // did, and $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] a background one, - // holding its declared name: a script serving both roles tests which of - // the two is set + // 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(); where Zend max execution - // timers are around, max_execution_time ends its bootstrap first + // 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 From 2e985ad37f6276e653980584bfd19b8321a33e8a Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sun, 6 Sep 2026 22:08:10 +0200 Subject: [PATCH 38/46] feat: frankenphp_get_vars() and WorkerHandle::setVars() The shared-state half of #2287, on top of the background workers: a worker publishes a snapshot with frankenphp_set_vars(), requests and other workers read it with frankenphp_get_vars(). The persistent-zval toolkit from #2366 does the cross-thread copies; this adds the two functions and a per-worker slot. set_vars() validates the tree, persists it and swaps it into the slot under a write lock; readers copy it into request memory under the read lock, so the previous table is only freed once no reader is on it. The slot belongs to the worker rather than a thread: it survives script restarts, serving the last snapshot meanwhile, and several threads of one worker simply publish last-writer-wins. The tables are freed in drainPHPThreads() once every PHP thread is gone and before the engine is, since freeing walks string headers. get_vars() resolves the name the way requests do, within the caller's server then among global workers. It blocks until the worker reached its ready point once: activateServers() runs after initWorkers(), so requests never wait, and a blocked caller is another background worker still booting. Those waits form a graph and a cycle is refused with an exception instead of deadlocking Init(); the wait also aborts on shutdown. A ready worker that never published throws. Publishing before the first wait on the handle therefore guarantees the snapshot exists before the server accepts requests. Being the first consumer keeping persistent trees across requests and exposing them repeatedly, this also fixes two fast paths of the toolkit: opcache-immutable arrays were exposed through refcounted zvals, and opcache only keeps their refcount at 2, so the second reader's release destroyed shared memory; and every interned string was shared by pointer, while only permanent ones (opcache, startup) outlive the request that interned them, so trees built from request-interned literals dangled once that request ended (the Windows job runs the embed without opcache). Immutable arrays now go through zvals without type flags, as php-src does for literals, and sharing a string requires IS_STR_PERMANENT. Left out on purpose, see #2287: the per-request cache with === identity, the unchanged-data skip in set_vars(), ensure_background_worker() and lazy or catch-all workers, CLI hiding of the functions. --- docs/worker.md | 16 ++++ frankenphp.c | 63 ++++++++++++- 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/publisher.php | 21 +++++ testdata/persist-roundtrip.php | 14 +++ testdata/vars.php | 7 ++ threadbackgroundworker.go | 1 + worker.go | 12 +++ workervars.go | 156 ++++++++++++++++++++++++++++++++ workervars_test.go | 94 +++++++++++++++++++ zval.h | 29 ++++-- zval_test.go | 1 + 17 files changed, 470 insertions(+), 14 deletions(-) create mode 100644 testdata/bgworker/bad-vars.php create mode 100644 testdata/bgworker/consumer.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 7a2ba4beea..45d52e1328 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -272,6 +272,22 @@ That stream is there for the libraries that take one, and waiting is all it supp `$_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()`. +### 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 83571a8ffb..98e6f16b07 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -71,12 +71,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() @@ -1493,6 +1488,64 @@ 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); +} + +/* Holding a handle means being a background worker: the constructor is the + * gate, so no caller check is needed here. */ +ZEND_METHOD(FrankenPHP_WorkerHandle, setVars) { + zval *vars; + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_ARRAY(vars) + ZEND_PARSE_PARAMETERS_END(); + + /* 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 e5612ee714..a74ab493a9 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -204,6 +204,8 @@ void frankenphp_release_thread_for_kill(force_kill_slot slot); /* 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 0269e3e2a6..26f9b464c4 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 cb6560ec7b..910f7f16d5 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: bef9b3db168eabf3acabe7c1f5a107b466809146 */ + * Stub hash: 4253ec747c57562891e9be84155bf86467f37e5b */ 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 441ffb6bfe..6aa69661b9 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -110,6 +110,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/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..25e0c9dd3a 100644 --- a/testdata/persist-roundtrip.php +++ b/testdata/persist-roundtrip.php @@ -86,3 +86,17 @@ function same(mixed $actual, mixed $expected, string $label): void { } catch (\LogicException) { echo "OK nested stdClass rejected\n"; } + +// A literal array is opcache-immutable and exposed zero-copy: opcache keeps +// its refcount at 2, so exposing it through a refcounted zval would destroy +// shared memory on the second release. Round-trip the same literal more +// times than that. +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 b23bdd1cd9..f9d6c0844c 100644 --- a/threadbackgroundworker.go +++ b/threadbackgroundworker.go @@ -261,6 +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() + handler.worker.markReady() 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) diff --git a/worker.go b/worker.go index 30b92cd9f4..78bbd4d464 100644 --- a/worker.go +++ b/worker.go @@ -42,6 +42,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 ( @@ -282,6 +293,7 @@ func newWorker(o workerOpt) (*worker, error) { onThreadShutdown: o.onThreadShutdown, server: scope, isBackgroundWorker: o.isBackgroundWorker, + readyOnce: make(chan struct{}), } w.configureMercure(&o) diff --git a/workervars.go b/workervars.go new file mode 100644 index 0000000000..af1d607232 --- /dev/null +++ b/workervars.go @@ -0,0 +1,156 @@ +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 a worker name the way requests do: within the caller's +// server first, then among global workers +func varsWorker(fc *frankenPHPContext, name string) (*worker, error) { + var w *worker + if fc != nil && fc.server != nil { + w = fc.server.workersByName[name] + } + if w == nil { + w = fallbackServer.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(thread.handler.frankenPHPContext(), 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..6c49bd8ad7 --- /dev/null +++ b/workervars_test.go @@ -0,0 +1,94 @@ +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") +} + +func TestVarsScopedToServer(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), + bgWorker("cfg", "publisher.php", map[string]string{"BG_PUBLISH_VALUE": "one"}, server1), + bgWorker("cfg", "publisher.php", map[string]string{"BG_PUBLISH_VALUE": "two"}, server2), + frankenphp.WithNumThreads(3), + ) + + assert.Contains(t, serverGet(t, server1, "http://example.com/vars.php?name=cfg"), `"value":"one"`) + assert.Contains(t, serverGet(t, server2, "http://example.com/vars.php?name=cfg"), `"value":"two"`) +} + +// 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") +} diff --git a/zval.h b/zval.h index f75cccdc60..cb165670d4 100644 --- a/zval.h +++ b/zval.h @@ -12,7 +12,8 @@ * one request). * * Fast paths: - * - Interned strings: shared memory, no copy. + * - Permanent interned strings (opcache, startup): shared, no copy. + * Strings interned during a request die with it, they are copied. * - Opcache-immutable arrays: shared pointer, no copy, no free. * * Included by frankenphp.c; not a standalone compilation unit. */ @@ -81,6 +82,12 @@ static bool persistent_zval_validate(zval *z) { return persistent_zval_validate_depth(z, 0); } +/* Only permanent interned strings outlive the request that interned them: + * those are the ones a persistent tree may share by pointer. */ +static bool persistent_zval_str_is_shared(zend_string *s) { + return ZSTR_IS_INTERNED(s) && (GC_FLAGS(s) & IS_STR_PERMANENT) != 0; +} + /* Deep-copy a zval from request memory into persistent (pemalloc) memory. * Callers must have already passed persistent_zval_validate on src. * @@ -103,8 +110,8 @@ 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 */ + if (persistent_zval_str_is_shared(s)) { + ZVAL_STR(dst, s); } else { ZVAL_NEW_STR(dst, zend_string_init(ZSTR_VAL(s), ZSTR_LEN(s), 1)); } @@ -115,13 +122,13 @@ static void persistent_zval_persist(zval *dst, zval *src) { 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) + persistent_zval_str_is_shared(ce->name) ? ce->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) + persistent_zval_str_is_shared(case_str) ? case_str : zend_string_init(ZSTR_VAL(case_str), ZSTR_LEN(case_str), 1); ZVAL_PTR(dst, e); @@ -131,8 +138,10 @@ static void persistent_zval_persist(zval *dst, zval *src) { 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. */ + * safe to share across threads by pointer. Zero-copy, zero-free. + * Not refcounted: the zval must not count on the array. */ ZVAL_ARR(dst, src_ht); + Z_TYPE_FLAGS_P(dst) = 0; break; } HashTable *dst_ht = pemalloc(sizeof(HashTable), 1); @@ -146,7 +155,7 @@ static void persistent_zval_persist(zval *dst, zval *src) { zval pval; persistent_zval_persist(&pval, val); if (key) { - if (ZSTR_IS_INTERNED(key)) { + if (persistent_zval_str_is_shared(key)) { zend_hash_add_new(dst_ht, key, &pval); } else { zend_string *pkey = zend_string_init(ZSTR_VAL(key), ZSTR_LEN(key), 1); @@ -258,8 +267,12 @@ 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. */ + /* Zero-copy: immutable arrays are safe to expose directly, as long + * as the zval does not count on them: opcache keeps their refcount + * at 2 as a safety net, so a refcounted zval exposing the same array + * twice would destroy shared memory on the second release. */ ZVAL_ARR(dst, src_ht); + Z_TYPE_FLAGS_P(dst) = 0; break; } array_init_size(dst, zend_hash_num_elements(src_ht)); 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 d374bef203c03fb94de1e5f3a716b69f575e0c33 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 21 Sep 2026 11:08:09 +0200 Subject: [PATCH 39/46] fix: setVars() checks the thread like the rest of the handle The constructor is not a gate, see the handle's other methods: a caller that got hold of one some other way would otherwise publish vars from a thread that is not a background worker, where the Go side has no slot to put them in. --- frankenphp.c | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 98e6f16b07..52acd2c253 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1503,14 +1503,16 @@ void frankenphp_vars_free(HashTable *table) { persistent_zval_free(&persistent); } -/* Holding a handle means being a background worker: the constructor is the - * gate, so no caller check is needed here. */ 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)) { From 8347a7b187e03223f0304525362f98e68439f56b Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sat, 19 Sep 2026 20:12:27 +0200 Subject: [PATCH 40/46] feat: SentTaskHandle and ReceivedTaskHandle The task half of #2319, on top of the background workers and their shared vars: a request, an HTTP worker or another background worker hands work to a named background worker with frankenphp_send_task(), which returns a stream carrying the updates the worker sends back with frankenphp_update_task(). The worker dequeues tasks with frankenphp_receive_task() once frankenphp_worker_tick() returned: the one handle of #2617 carries both the drain EOF and the wake-ups, so a script keeps a single stream_select() loop, and the tick consumes what the runtime wrote on it. A wake-up is not a count: a pool wakes one thread per task and the others get null, and in a pool it may belong to a task a sibling took. The tick also parks the thread for the senders, or makes the coming wait return at once when tasks are already queued. send_task() blocks until a thread of the worker picks the task up and throws on timeout, so a busy worker pushes back on its senders instead of queueing without bounds; tasks queued while a thread restarts are signaled again on its next run. The wait also ends when the sender's own thread is drained for a restart or the shutdown, since the target's threads are drained too. Names resolve like frankenphp_get_vars() does. Each task gets a socket pair. The sender's stream is a socket stream over one end, one byte per update and EOF at completion, so stream_select() bounds the wait or multiplexes tasks, and a blocking read parks as well; closing it abandons the task. The receiver's stream is a socket stream over the other end: updates go through update_task(), the stream itself reports the sender's close as EOF to stream_select() and feof(), so a long task learns that nobody waits for its result, and update_task() throws. Closing it completes the task, unless the close is the resource cleanup of request shutdown, which means the script ended with the task open: the sender's next read throws instead of returning null. Sixteen updates are buffered per task, past that update_task() waits for the sender to read. Waking threads is what a task costs, so wake-ups are kept to a minimum. A send wakes one parked thread of the worker, round-robin, with the line on its handle; a thread that reads its handle while tasks are queued gets the line from the read op itself, so no wake-up is lost whichever loop shape the script uses, and after 10ms without pickup every thread is woken as a fallback. The sender waits for the pickup in the kernel, on its end of the task's pair, rather than in a Go select: waking a PHP thread parked inside a cgo callback costs Go a P hand-off, a byte on a socket does not. The thread taking the task writes that byte, a watcher goroutine does when the wait must end without a pickup. The queue mutex is never held across a syscall and taken once per wake-up, as a thread inside a cgo callback that loses it parks the same expensive way. In the Docker builder image this takes a task from 549 to 285us with one thread, a pool of 8 from 1745 to 320us, and 8 senders on 8 threads from 2k to 32k tasks/s. Each side of a task waits on its own descriptor of the task's channel, an eventfd on Linux, one end of a socket pair elsewhere for Windows's php_select(): the streams carry no data, they are what stream_select() waits on and what fclose() ends, the Go side holds the state the functions report. The descriptors belong to the task until both sides closed, then the pair is drained and pooled, so a task costs no socketpair, fcntl or close: about 12 syscalls instead of 18, 13% off the latency and up to a third more throughput under load in the same measurement. Payloads and updates follow the set_vars() whitelist and travel as persistent tables through the Go side, which owns them until they are copied into request memory. The streams reference their task through a cgo handle; the task is freed once both sides closed, or by the sender when no thread picked it up. The stop sockets of a worker's threads are now guarded by its task queue mutex, since senders write to them. Two more savings on the wake-ups. The sender polls a first 10ms slice on its own: only a pickup that outlasts it brings the Go side in, to wake every thread of the worker and to start the goroutine that ends the wait on a drain or the shutdown, so the common case, a pickup within microseconds, spawns no goroutine and wakes no M. And a side's descriptor gets one signal per sleep: an event signals it only while the other side sleeps on it with no signal outstanding, that one is consumed on the next event and the rest is read from the task's state, so a completion behind an update, or a burst of updates, costs no syscall. Together they take a task from 24 to 15 syscalls and its futexes from 4.7 to 0.6: 15% off a plain task, 29% off one carrying 16 updates, and 5 to 15% more throughput under load. The existing worker metrics carry over: busy_workers counts a thread holding a task, from pickup to the close of the task's stream, and worker_queue_depth counts the tasks waiting for a thread, the only queue a background worker has. Two new ones break tasks down: worker_task_count{worker,server,outcome} with completed, aborted (the script ended with the task open), abandoned (the sender gave up first) or timeout (no thread picked the task up in time), settled by whichever side closes first so every task counts once, and worker_task_time, the seconds spent on tasks. The threads endpoint follows: a background thread is busy while it holds a task and waiting otherwise, counted per thread since a script may hold several. Compared to #2319: no queue ahead of pickup and no cancellation before it, no dedicated signaling stream, no global task table. --- caddy/caddy_test.go | 9 +- docs/metrics.md | 10 +- docs/worker.md | 45 ++ frankenphp.c | 801 +++++++++++++++++++++++-- frankenphp.h | 14 + frankenphp.stub.php | 90 +++ frankenphp_arginfo.h | 81 ++- metrics.go | 113 +++- metrics_test.go | 72 ++- phpmainthread.go | 1 + testdata/bgworker/task-over-worker.php | 31 + testdata/bgworker/task-relay.php | 15 + testdata/bgworker/task-worker.php | 54 ++ testdata/task-busy.php | 16 + testdata/task-errors.php | 18 + testdata/task-over.php | 24 + testdata/task-poll.php | 30 + testdata/task-pool.php | 29 + testdata/task-shutdown.php | 13 + testdata/task.php | 23 + threadbackgroundworker.go | 59 +- worker.go | 3 + workertask.go | 679 +++++++++++++++++++++ workertask_test.go | 292 +++++++++ workervars.go | 17 +- 25 files changed, 2471 insertions(+), 68 deletions(-) create mode 100644 testdata/bgworker/task-over-worker.php create mode 100644 testdata/bgworker/task-relay.php create mode 100644 testdata/bgworker/task-worker.php create mode 100644 testdata/task-busy.php create mode 100644 testdata/task-errors.php create mode 100644 testdata/task-over.php create mode 100644 testdata/task-poll.php create mode 100644 testdata/task-pool.php create mode 100644 testdata/task-shutdown.php create mode 100644 testdata/task.php create mode 100644 workertask.go create mode 100644 workertask_test.go diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index 55abff73f6..daeda3f4a3 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -866,7 +866,7 @@ func TestWorkerMetrics(t *testing.T) { # TYPE frankenphp_busy_threads gauge frankenphp_busy_threads 2 - # HELP frankenphp_busy_workers Number of busy PHP workers for this worker + # HELP frankenphp_busy_workers Number of busy PHP workers for this worker: processing a request, or a task for a background worker # TYPE frankenphp_busy_workers gauge frankenphp_busy_workers{server="",worker="` + workerName + `"} 0 @@ -1023,7 +1023,7 @@ func TestNamedWorkerMetrics(t *testing.T) { # TYPE frankenphp_busy_threads gauge frankenphp_busy_threads 2 - # HELP frankenphp_busy_workers Number of busy PHP workers for this worker + # HELP frankenphp_busy_workers Number of busy PHP workers for this worker: processing a request, or a task for a background worker # TYPE frankenphp_busy_workers gauge frankenphp_busy_workers{server="",worker="my_app"} 0 @@ -1119,7 +1119,7 @@ func TestAutoWorkerConfig(t *testing.T) { # TYPE frankenphp_busy_threads gauge frankenphp_busy_threads ` + workers + ` - # HELP frankenphp_busy_workers Number of busy PHP workers for this worker + # HELP frankenphp_busy_workers Number of busy PHP workers for this worker: processing a request, or a task for a background worker # TYPE frankenphp_busy_workers gauge frankenphp_busy_workers{server="",worker="` + workerName + `"} 0 @@ -1386,6 +1386,7 @@ func TestMaxWaitTimeWorker(t *testing.T) { require.NoError(t, err) expectedMetrics := ` + # HELP frankenphp_worker_queue_depth Number of queued requests for this worker, or of tasks waiting for a thread of a background worker # TYPE frankenphp_worker_queue_depth gauge frankenphp_worker_queue_depth{server="",worker="service"} 0 ` @@ -1486,7 +1487,7 @@ func TestMultiWorkersMetrics(t *testing.T) { # TYPE frankenphp_busy_threads gauge frankenphp_busy_threads 5 - # HELP frankenphp_busy_workers Number of busy PHP workers for this worker + # HELP frankenphp_busy_workers Number of busy PHP workers for this worker: processing a request, or a task for a background worker # TYPE frankenphp_busy_workers gauge frankenphp_busy_workers{server="",worker="service1"} 0 diff --git a/docs/metrics.md b/docs/metrics.md index a3ee664050..2cf9d67feb 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -16,13 +16,15 @@ When [Caddy metrics](https://caddyserver.com/docs/metrics) are enabled, FrankenP - `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_busy_workers{worker="[worker_name]",server="[server_name]"}`: The number of workers currently processing a request, or a task for a background worker. - `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_worker_queue_depth{worker="[worker_name]",server="[server_name]"}`: The number of queued requests, or of tasks waiting for a thread of a background worker. +- `frankenphp_worker_task_count{worker="[worker_name]",server="[server_name]",outcome="[outcome]"}`: The number of tasks sent to a background worker, by outcome: `completed`, `aborted` (the script ended with the task open), `abandoned` (the sender closed its stream first) or `timeout` (no thread picked the task up in time). +- `frankenphp_worker_task_time{worker="[worker_name]",server="[server_name]"}`: The time spent on tasks by all threads of a background worker, from pickup to the close of the task's stream. `[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. @@ -73,8 +75,8 @@ Each entry in `ThreadDebugStates` contains: | `Index` | integer | The index of the thread. | | `Name` | string | The name of the thread (e.g., the worker file path). | | `State` | string | The internal state of the thread (e.g., `ready`, `shutting down`). | -| `IsWaiting` | boolean | Whether the thread is waiting for a request. | -| `IsBusy` | boolean | Whether the thread is currently processing a request. | +| `IsWaiting` | boolean | Whether the thread is waiting for a request, or for a task in a background worker. | +| `IsBusy` | boolean | Whether the thread is currently processing a request, or a task in a background worker. | | `WaitingSinceMilliseconds` | integer | How long the thread has been idle, in milliseconds. `0` if the thread is busy. | | `CurrentURI` | string | The URI currently being processed. Empty if the thread is idle. | | `CurrentMethod` | string | The HTTP method of the current request (e.g., `GET`, `POST`). Empty if the thread is idle. | diff --git a/docs/worker.md b/docs/worker.md index 45d52e1328..f7d07e2add 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -288,6 +288,51 @@ $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. +### Sending tasks to background workers + +A request, an HTTP worker or another background worker hands work to a background worker by constructing a `FrankenPHP\SentTaskHandle`, naming it the way `frankenphp_get_vars()` does. The payload follows the same rules as `setVars()`: null, scalars, arrays or enums. Constructing blocks until a thread of the worker picks the task up and throws if none did before the timeout, so a busy worker pushes back on its senders instead of queueing without bounds. `read()` then blocks for the next update and returns `null` once the worker completed the task, `getStream()` gives the stream `stream_select()` waits on, to follow several tasks at once or to bound the wait, and `abandon()`, like dropping the handle, gives up on the task. The streams of a task are for waiting too: reading one steals the signal `read()` needs, writing to one goes nowhere, and closing one ends the task on that side. + +On the worker side, each task sent wakes one parked thread of the worker through its handle, in the loop of the previous section: `tick()` consumes the wake-up, which is not a count and in a pool may belong to a task a sibling thread took. `receive()` dequeues a task without blocking, a `FrankenPHP\ReceivedTaskHandle`, or `null` when another thread of the pool got there first, so the example below drains the queue on each wake-up and treats `null` as the normal outcome. A thread that ticks while tasks are queued finds its handle readable at once, whichever loop shape it uses. `update()` sends progress back, `complete()` ends the task with a last update when one is given, and a script that ends with a task still open, a `complete()` from a destructor or a shutdown function at that point included, makes the sender's next `read()` throw. When the sender gives up instead, the stream of the worker's handle reaches EOF, so `stream_select()` or `feof()` on it tell a long task that nobody waits for its result, and `update()` throws. + +```php +// background worker +$handle = new FrankenPHP\WorkerHandle(); +$stream = $handle->getStream(); + +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); + + while ($task = $handle->receive()) { + $task->update(['progress' => 50]); + $task->complete(['result' => process($task->getPayload())]); + } +} + +// request, HTTP worker or another background worker +$task = new FrankenPHP\SentTaskHandle('jobs', ['file' => 'photo.jpg']); +while (null !== $update = $task->read()) { + // ['progress' => 50], then ['result' => ...] +} +``` + +On PHP 8.6 both handles are `Io\Poll\Handle`, so a context follows several tasks without a stream: + +```php +use Io\Poll\{Context, Event}; + +$poll = new Context(); +$task = new FrankenPHP\SentTaskHandle('jobs', ['file' => 'photo.jpg']); +$poll->add($task, [Event::Read], 'photo'); + +foreach ($poll->wait() as $watcher) { + $update = $task->read(); +} +``` + +Sixteen updates are buffered per task; past that, `update()` waits for the sender to read, and it throws once the sender gave up. The streams of a task are backed by eventfd descriptors on Linux, pooled between tasks, and by a socket pair elsewhere. + ## 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 52acd2c253..8fde847a9e 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -364,11 +364,15 @@ 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) { +/* Socket pairs of background workers: the handle of a thread, see + * WorkerHandle::getStream(), and one per task, see + * SentTaskHandle. One end is exposed to a PHP script as a stream, + * the other is held by the Go side, which writes wake-ups to it and closes + * it to land EOF on the script's end, so a stream_select() or a blocking + * read there returns. A socket pair rather than a pipe because on Windows + * PHP's php_select() only really waits on sockets: before 8.5 it reports + * any other handle as always ready. */ +static void frankenphp_sock_close(php_socket_t s) { if (s == SOCK_ERR) { return; } @@ -378,7 +382,7 @@ static void frankenphp_worker_close_sock(php_socket_t 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) { +static void frankenphp_sock_no_inherit(php_socket_t s) { #ifdef PHP_WIN32 SetHandleInformation((HANDLE)s, HANDLE_FLAG_INHERIT, 0); #else @@ -386,22 +390,9 @@ static void frankenphp_worker_sock_no_inherit(php_socket_t s) { #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(); -} - +/* Opens a pair: [0] for the script, [1] for the Go side. The Go side's end + * never blocks: its writes are wake-ups, a full buffer means the peer has + * plenty of unread ones already. */ #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 @@ -483,9 +474,9 @@ static int frankenphp_sock_pair_win32(php_socket_t socks[2]) { } #endif -static int frankenphp_worker_open_stop_pair(void) { +static int frankenphp_sock_pair_open(php_socket_t socks[2]) { #ifdef PHP_WIN32 - if (frankenphp_sock_pair_win32(worker_stop_socks) != 0) { + if (frankenphp_sock_pair_win32(socks) != 0) { return -1; } #else @@ -494,30 +485,72 @@ static int frankenphp_worker_open_stop_pair(void) { #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; + if (socketpair(AF_UNIX, type, 0, socks) != 0) { + socks[0] = SOCK_ERR; + 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]); + frankenphp_sock_no_inherit(socks[0]); + frankenphp_sock_no_inherit(socks[1]); + php_set_sock_blocking(socks[1], 0); +#ifdef PHP_WIN32 + /* loopback TCP: a byte-sized wake-up must not wait for Nagle and the + * delayed ACK */ + int nodelay = 1; + setsockopt(socks[0], IPPROTO_TCP, TCP_NODELAY, (const char *)&nodelay, + sizeof(nodelay)); + setsockopt(socks[1], IPPROTO_TCP, TCP_NODELAY, (const char *)&nodelay, + sizeof(nodelay)); +#endif +#ifdef SO_NOSIGPIPE + /* a wake-up to a closed peer must fail, not raise (MSG_NOSIGNAL elsewhere) */ + int one = 1; + setsockopt(socks[1], SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one)); +#endif return 0; } +/* best-effort wake-up on the Go side's end of a pair */ +static void frankenphp_sock_send(php_socket_t s, const char *buf, size_t len) { +#ifdef MSG_NOSIGNAL + int flags = MSG_NOSIGNAL; +#else + int flags = 0; +#endif + (void)send(s, buf, (int)len, flags); +} + +static void frankenphp_worker_close_stop_socks(void) { + for (int i = 0; i < 2; i++) { + frankenphp_sock_close(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(); +} + /* 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) { + if (frankenphp_sock_pair_open(worker_stop_socks) != 0) { return -1; } @@ -537,21 +570,113 @@ intptr_t frankenphp_set_background_worker_and_get_stop_sock(void) { return s; } +/* Closes the Go side's end of a pair, which lands as EOF on the script's + * end so its stream_select() or blocking read returns promptly. */ +void frankenphp_close_sock(intptr_t s) { + if (s < 0) { + return; + } + frankenphp_sock_close((php_socket_t)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. */ + * script's end so its wait returns promptly. Closing alone only does while + * no other process holds a copy of it, and a pcntl_fork() child inherits + * every descriptor of the process, the pairs of the other threads included: + * shutting the write direction down sends the FIN regardless. */ 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); + frankenphp_sock_close((php_socket_t)s); +} + +/* Wakes a background worker thread with a line on its handle, one per task + * sent to the worker, see SentTaskHandle. The line is a wake-up + * rather than a description: it says something may be pending, the script + * finds out what by polling. Its content is therefore not part of the + * contract, see WorkerHandle::receive(). */ +void frankenphp_worker_signal_task(intptr_t s) { + frankenphp_sock_send((php_socket_t)s, "\n", 1); +} + +/* The first slice of a sender's wait for a pickup, in ms; past it the Go + * side gets involved, see go_frankenphp_task_linger. */ +#define FRANKENPHP_TASK_LINGER_MS 10 + +/* Task channels: one descriptor per side of a task, the sender's [0] and + * the receiver's [1], each waited on by its stream and signaled by the other + * side through the Go side. On Linux they are eventfds: a counter, no + * buffer, nothing to close between two tasks, so the Go side pools them. + * Elsewhere a socket pair, for Windows's php_select(); a signal to one end + * is a byte written to the other. Both descriptors are non-blocking: waits + * go through poll(), consuming a signal never blocks. Signals and events + * match one to one, EFD_SEMAPHORE makes a read consume a single one. */ +#ifdef __linux__ +#include +#define FRANKENPHP_TASK_CHAN_EVENTFD 1 +#endif + +int frankenphp_task_chan_open(intptr_t fds[2]) { +#ifdef FRANKENPHP_TASK_CHAN_EVENTFD + int a = eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK | EFD_SEMAPHORE); + if (a < 0) { + return -1; + } + int b = eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK | EFD_SEMAPHORE); + if (b < 0) { + close(a); + + return -1; + } + fds[0] = a; + fds[1] = b; +#else + php_socket_t pair[2]; + if (frankenphp_sock_pair_open(pair) != 0) { + return -1; + } + php_set_sock_blocking(pair[0], 0); + fds[0] = (intptr_t)pair[0]; + fds[1] = (intptr_t)pair[1]; +#endif + + return 0; +} + +/* Wakes the side waiting on fds[side]. */ +void frankenphp_task_chan_signal(intptr_t fd0, intptr_t fd1, int side) { +#ifdef FRANKENPHP_TASK_CHAN_EVENTFD + uint64_t one = 1; + (void)!write((int)(side ? fd1 : fd0), &one, sizeof(one)); +#else + /* a byte on one end lands on the other */ + frankenphp_sock_send((php_socket_t)(side ? fd0 : fd1), "1", 1); +#endif +} + +/* Consumes one signal, false when none is pending. */ +bool frankenphp_task_chan_consume(intptr_t fd) { +#ifdef FRANKENPHP_TASK_CHAN_EVENTFD + uint64_t v; + + return read((int)fd, &v, sizeof(v)) == (ssize_t)sizeof(v); +#else + char b; + + return recv((php_socket_t)fd, &b, 1, 0) == 1; +#endif +} + +/* Empties a descriptor before its pair goes back to the pool. */ +void frankenphp_task_chan_drain(intptr_t fd) { + while (frankenphp_task_chan_consume(fd)) { + } } void frankenphp_update_local_thread_context(bool is_worker) { @@ -1464,7 +1589,7 @@ ZEND_METHOD(FrankenPHP_WorkerHandle, tick) { 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; + break; } #ifdef PHP_WIN32 @@ -1479,13 +1604,20 @@ ZEND_METHOD(FrankenPHP_WorkerHandle, tick) { if (n < 0) { int err = php_socket_errno(); if (err == EINTR || PHP_IS_TRANSIENT_ERROR(err)) { - RETURN_TRUE; + break; } /* a broken socket carries no drain anymore, stop the loop */ RETURN_FALSE; } } + + /* the script is about to wait on the handle: park the thread, or make the + * wait return at once when tasks are queued, see + * go_frankenphp_background_worker_park */ + go_frankenphp_background_worker_park(frankenphp_thread_index()); + + RETURN_TRUE; } /* Shared vars of background workers, see WorkerHandle::setVars() and @@ -1548,6 +1680,593 @@ PHP_FUNCTION(frankenphp_get_vars) { } } +/* Tasks, see SentTaskHandle: the sender hands a persistent copy of the + * payload to the Go side, which queues it for the named background worker + * and wakes one of its threads; WorkerHandle::receive() dequeues it + * there. Updates flow back the same way, persistent copies through the Go + * side. Each side has a stream over its descriptor of the task's channel: + * the stream carries no data, it is what stream_select() waits on and what + * fclose() ends, and the Go side holds the state the functions report. The + * descriptors belong to the task until both sides closed. */ +typedef struct { + uintptr_t task; + intptr_t fd; /* the side's descriptor, see frankenphp_task_chan_open */ + int timeout_ms; /* stream_set_timeout(), -1 waits forever */ + bool sender; + bool timed_out; +} frankenphp_task_stream_data; + +static ssize_t frankenphp_task_stream_write(php_stream *stream, const char *buf, + size_t count) { + (void)stream; + (void)buf; + (void)count; + + return -1; +} + +/* the data goes through the frankenphp_*_task() functions */ +static ssize_t frankenphp_task_stream_read(php_stream *stream, char *buf, + size_t count) { + (void)buf; + (void)count; + frankenphp_task_stream_data *data = stream->abstract; + if (go_frankenphp_task_side_gone(data->task, data->sender)) { + stream->eof = 1; + } + + return -1; +} + +/* Closing the receiver's stream completes the task, unless the close is the + * resource cleanup of request shutdown, where the script ended with the task + * open and the sender is told so; closing the sender's abandons it. The Go + * side learns it before signaling the other side, which then finds it. */ +static int frankenphp_task_stream_close(php_stream *stream, int close_handle) { + (void)close_handle; + frankenphp_task_stream_data *data = stream->abstract; + if (data->sender) { + go_frankenphp_task_sender_close(data->task); + } else { + go_frankenphp_task_receiver_close(data->task, + (EG(flags) & EG_FLAGS_IN_SHUTDOWN) != 0); + } + efree(data); + + return 0; +} + +static int frankenphp_task_stream_cast(php_stream *stream, int castas, + void **ret) { + if (castas != PHP_STREAM_AS_FD_FOR_SELECT) { + return FAILURE; + } + frankenphp_task_stream_data *data = stream->abstract; + if (data->sender) { + /* the sender parks for the select, unless an event is already there */ + go_frankenphp_task_sender_wait(data->task); + } + if (ret != NULL) { + *(php_socket_t *)ret = (php_socket_t)data->fd; + } + + return SUCCESS; +} + +static int frankenphp_task_stream_set_option(php_stream *stream, int option, + int value, void *ptrparam) { + (void)value; + frankenphp_task_stream_data *data = stream->abstract; + switch (option) { + case PHP_STREAM_OPTION_READ_TIMEOUT: { + struct timeval *tv = ptrparam; + data->timeout_ms = + tv->tv_sec < 0 ? -1 : (int)(tv->tv_sec * 1000 + tv->tv_usec / 1000); + + return PHP_STREAM_OPTION_RETURN_OK; + } + case PHP_STREAM_OPTION_CHECK_LIVENESS: + /* feof(): the other side closed its stream */ + return go_frankenphp_task_side_gone(data->task, data->sender) + ? PHP_STREAM_OPTION_RETURN_ERR + : PHP_STREAM_OPTION_RETURN_OK; + case PHP_STREAM_OPTION_META_DATA_API: + add_assoc_bool((zval *)ptrparam, "timed_out", data->timed_out); + add_assoc_bool((zval *)ptrparam, "blocked", 1); + add_assoc_bool((zval *)ptrparam, "eof", stream->eof); + + return PHP_STREAM_OPTION_RETURN_OK; + default: + return PHP_STREAM_OPTION_RETURN_NOTIMPL; + } +} + +#define FRANKENPHP_TASK_STREAM_OPS(label) \ + { \ + frankenphp_task_stream_write, frankenphp_task_stream_read, \ + frankenphp_task_stream_close, NULL, label, NULL, \ + frankenphp_task_stream_cast, NULL, frankenphp_task_stream_set_option \ + } +static const php_stream_ops frankenphp_task_sender_ops = + FRANKENPHP_TASK_STREAM_OPS("FrankenPHP task sender"); +static const php_stream_ops frankenphp_task_receiver_ops = + FRANKENPHP_TASK_STREAM_OPS("FrankenPHP task receiver"); + +static php_stream *frankenphp_task_stream_open(uintptr_t task, intptr_t fd, + bool sender) { + frankenphp_task_stream_data *data = ecalloc(1, sizeof(*data)); + data->task = task; + data->fd = fd; + data->timeout_ms = -1; + data->sender = sender; + + return php_stream_alloc(sender ? &frankenphp_task_sender_ops + : &frankenphp_task_receiver_ops, + data, NULL, "r"); +} + +/* Waits for a signal on the side's descriptor without consuming it: 1 when + * one is pending, 0 on timeout. Interrupted polls are retried, like PHP's + * own stream code does. */ +static int frankenphp_task_stream_poll(frankenphp_task_stream_data *data, + int timeout_ms) { + for (;;) { + int n = + php_pollfd_for_ms((php_socket_t)data->fd, PHP_POLLREADABLE, timeout_ms); + if (n < 0 && php_socket_errno() == EINTR) { + continue; + } + + return n > 0; + } +} + +/* Consumes the signal of an event the Go side reported, waiting for it if + * the other side has not written it yet: the state is set before the + * signal, so the wait is momentary, and one signal per event keeps + * stream_select() exact. */ +static void frankenphp_task_stream_consume(frankenphp_task_stream_data *data) { + while (!frankenphp_task_chan_consume(data->fd)) { + frankenphp_task_stream_poll(data, -1); + } +} + +/* Handles of a task: the state of one side, carried by the handle object + * of that side. The stream is what stream_select() waits on, so the handle + * holds its resource and dropping the handle ends the task, as closing the + * stream always did. On PHP 8.6 the handles are Io\Poll\Handle too, and a + * context waits on the task's descriptor without any stream. */ +typedef struct { + zend_resource *res; + zval payload; /* the receiver's side only */ +} frankenphp_task_data; + +static zend_class_entry *frankenphp_sent_task_ce; +static zend_class_entry *frankenphp_received_task_ce; + +static frankenphp_task_data *frankenphp_task_data_of(zend_object *object) { + return FRANKENPHP_HANDLE_OF(object)->handle_data; +} + +#define FRANKENPHP_TASK_DATA(zthis) frankenphp_task_data_of(Z_OBJ_P(zthis)) + +/* The stream of a handle, NULL once the task ended: complete(), abandon() + * and fclose() all close it, and every method but getStream() throws from + * there on. */ +static php_stream *frankenphp_task_data_stream(frankenphp_task_data *data) { + if (data == NULL || data->res == NULL || data->res->ptr == NULL || + data->res->type != php_file_le_stream()) { + return NULL; + } + + return data->res->ptr; +} + +static php_stream *frankenphp_task_obj_stream(zval *zthis) { + return frankenphp_task_data_stream(FRANKENPHP_TASK_DATA(zthis)); +} + +static php_stream *frankenphp_task_obj_open_stream(zval *zthis) { + php_stream *stream = frankenphp_task_obj_stream(zthis); + if (stream == NULL) { + zend_throw_exception(spl_ce_RuntimeException, "the task is over", 0); + } + + return stream; +} + +/* The descriptor a task waits on, the same one its stream reports to + * stream_select(); invalid once the task ended, which is what the poll + * context and read() both report. */ +static php_socket_t frankenphp_task_get_fd(frankenphp_handle_obj *handle) { + php_stream *stream = frankenphp_task_data_stream(handle->handle_data); + if (stream == NULL) { + return SOCK_ERR; + } + + frankenphp_task_stream_data *data = stream->abstract; + if (data->sender) { + /* a context waits without a hook of its own, see the Go side */ + go_frankenphp_task_sender_watch(data->task); + } + + return (php_socket_t)data->fd; +} + +static int frankenphp_task_is_valid(frankenphp_handle_obj *handle) { + return frankenphp_task_data_stream(handle->handle_data) != NULL; +} + +static void frankenphp_task_cleanup(frankenphp_handle_obj *handle) { + frankenphp_task_data *data = handle->handle_data; + if (data == NULL) { + return; + } + + if (data->res != NULL) { + /* the last reference closes the stream, which settles the task */ + zend_list_delete(data->res); + } + zval_ptr_dtor(&data->payload); + efree(data); + handle->handle_data = NULL; +} + +static frankenphp_handle_ops frankenphp_task_poll_ops = { + .get_fd = frankenphp_task_get_fd, + .is_valid = frankenphp_task_is_valid, + .cleanup = frankenphp_task_cleanup, +}; + +static zend_object *frankenphp_task_handle_new(zend_class_entry *ce) { + zend_object *object = + frankenphp_handle_obj_create(ce, &frankenphp_task_poll_ops); + frankenphp_task_data *data = ecalloc(1, sizeof(*data)); + + ZVAL_UNDEF(&data->payload); + FRANKENPHP_HANDLE_OF(object)->handle_data = data; + + return object; +} + +static zend_object *frankenphp_sent_task_new(zend_class_entry *ce) { + return frankenphp_task_handle_new(ce); +} + +static zend_object *frankenphp_received_task_new(zend_class_entry *ce) { + return frankenphp_task_handle_new(ce); +} + +/* Takes over the reference the stream's registration holds, so the handle + * owns the task from here on. */ +static void frankenphp_task_obj_take(zval *zthis, php_stream *stream) { + FRANKENPHP_TASK_DATA(zthis)->res = stream->res; +} + +/* Hands the resource to the script, which may select on it and close it. + * Past the end of the task it is handed over closed, as the poll hooks + * report an invalid descriptor there: a waiter learns that the task is + * over from is_resource(), the way it would from Io\Poll. */ +static void frankenphp_task_obj_get_stream(zval *zthis, zval *return_value) { + frankenphp_task_data *data = FRANKENPHP_TASK_DATA(zthis); + + if (data == NULL || data->res == NULL) { + zend_throw_exception(spl_ce_RuntimeException, "the task is over", 0); + RETURN_THROWS(); + } + + GC_ADDREF(data->res); + RETURN_RES(data->res); +} + +/* Ends the task from this side: the stream's close reports it, see + * frankenphp_task_stream_close(). */ +static void frankenphp_task_obj_close(zval *zthis) { + if (frankenphp_task_obj_stream(zthis) != NULL) { + zend_list_close(FRANKENPHP_TASK_DATA(zthis)->res); + } +} + +ZEND_METHOD(FrankenPHP_SentTaskHandle, __construct) { + zend_string *name; + zval *payload; + double timeout = 30.0; + bool timeout_is_null = false; + ZEND_PARSE_PARAMETERS_START(2, 3) + Z_PARAM_STR(name) + Z_PARAM_ARRAY(payload) + Z_PARAM_OPTIONAL + Z_PARAM_DOUBLE_OR_NULL(timeout, timeout_is_null) + ZEND_PARSE_PARAMETERS_END(); + + if (!timeout_is_null && (zend_isnan(timeout) || timeout < 0)) { + zend_argument_value_error(3, "must be greater than or equal to 0"); + RETURN_THROWS(); + } + /* past what a duration holds, infinity included, waits forever like null */ + int timeout_ms = -1; + if (!timeout_is_null && timeout * 1000 < (double)INT_MAX) { + timeout_ms = (int)(timeout * 1000); + } + if (!persistent_zval_validate(payload)) { + zend_value_error("FrankenPHP\\SentTaskHandle::__construct(): payload " + "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, payload); + + /* the Go side owns the payload from here on, it frees it on failure */ + struct go_frankenphp_send_task_return task = + go_frankenphp_send_task(frankenphp_thread_index(), ZSTR_VAL(name), + ZSTR_LEN(name), Z_ARRVAL(persistent)); + if (task.r2 != NULL) { + zend_throw_exception(spl_ce_RuntimeException, task.r2, 0); + free(task.r2); + RETURN_THROWS(); + } + + /* the task is queued from here on: a bailout (memory limit) must not + * leave the sender's side open, the receiver would wait on it forever */ + php_stream *stream = NULL; + zend_try { stream = frankenphp_task_stream_open(task.r0, task.r1, true); } + zend_catch { + go_frankenphp_task_cancel(task.r0, false); + go_frankenphp_task_sender_close(task.r0); + zend_bailout(); + } + zend_end_try(); + + /* wait for the pickup in the kernel: the thread taking the task signals + * the sender's side, so does the Go side when the wait must end without a + * pickup, see go_frankenphp_send_task */ + frankenphp_task_stream_data *data = stream->abstract; + /* the first slice of the wait is short: past it, the Go side escalates the + * wake-up and starts watching for a drain or the shutdown, neither of + * which the common case, a pickup within microseconds, needs */ + int remaining = timeout_ms; + bool lingering = false; + for (;;) { + int slice = remaining; + if (!lingering && + (remaining < 0 || remaining > FRANKENPHP_TASK_LINGER_MS)) { + slice = FRANKENPHP_TASK_LINGER_MS; + } + if (!frankenphp_task_stream_poll(data, slice)) { + if (remaining > 0) { + remaining -= slice; + } + if (!lingering && remaining != 0) { + lingering = true; + go_frankenphp_task_linger(task.r0); + continue; + } + /* nobody took the task in time, unless right now */ + if (go_frankenphp_task_cancel(task.r0, true)) { + php_stream_close(stream); + zend_throw_exception_ex(spl_ce_RuntimeException, 0, + "FrankenPHP\\SentTaskHandle: no thread of " + "background worker \"%s\" picked up the " + "task in time", + ZSTR_VAL(name)); + RETURN_THROWS(); + } + frankenphp_task_stream_consume(data); + + break; + } + + struct go_frankenphp_task_await_return state = + go_frankenphp_task_await(task.r0); + if (state.r0 == 0) { + /* a signal ahead of its event, or a stale one */ + frankenphp_task_chan_consume(data->fd); + continue; + } + frankenphp_task_stream_consume(data); + if (state.r0 == 1) { + break; + } + go_frankenphp_task_cancel(task.r0, false); + php_stream_close(stream); + zend_throw_exception(spl_ce_RuntimeException, state.r1, 0); + free(state.r1); + RETURN_THROWS(); + } + + frankenphp_task_obj_take(ZEND_THIS, stream); +} + +ZEND_METHOD(FrankenPHP_SentTaskHandle, read) { + ZEND_PARSE_PARAMETERS_NONE(); + + php_stream *stream = frankenphp_task_obj_open_stream(ZEND_THIS); + if (stream == NULL) { + RETURN_THROWS(); + } + frankenphp_task_stream_data *data = stream->abstract; + + for (;;) { + struct go_frankenphp_read_task_return update = + go_frankenphp_read_task(data->task); + switch (update.r1) { + case FRANKENPHP_TASK_READ_UPDATE: + if (update.r2) { + frankenphp_task_stream_consume(data); + } + zend_try { frankenphp_vars_to_request(return_value, update.r0); } + zend_catch { + frankenphp_vars_free(update.r0); + zend_bailout(); + } + zend_end_try(); + frankenphp_vars_free(update.r0); + return; + case FRANKENPHP_TASK_READ_COMPLETED: + case FRANKENPHP_TASK_READ_ABORTED: + if (update.r2) { + frankenphp_task_stream_consume(data); + } + stream->eof = 1; + if (update.r1 == FRANKENPHP_TASK_READ_COMPLETED) { + RETURN_NULL(); + } + zend_throw_exception(spl_ce_RuntimeException, + "FrankenPHP\\SentTaskHandle::read(): the background " + "worker exited without completing the task", + 0); + RETURN_THROWS(); + default: + /* nothing yet: wait for the next signal, without consuming it, the + * event it announces does */ + if (!frankenphp_task_stream_poll(data, data->timeout_ms)) { + data->timed_out = true; + zend_throw_exception(spl_ce_RuntimeException, + "FrankenPHP\\SentTaskHandle::read(): timed out " + "waiting for the next update", + 0); + RETURN_THROWS(); + } + } + } +} + +ZEND_METHOD(FrankenPHP_SentTaskHandle, getStream) { + ZEND_PARSE_PARAMETERS_NONE(); + + frankenphp_task_obj_get_stream(ZEND_THIS, return_value); +} + +ZEND_METHOD(FrankenPHP_SentTaskHandle, abandon) { + ZEND_PARSE_PARAMETERS_NONE(); + + frankenphp_task_obj_close(ZEND_THIS); +} + +ZEND_METHOD(FrankenPHP_ReceivedTaskHandle, __construct) { + zend_throw_error(NULL, "Cannot directly construct FrankenPHP\\" + "ReceivedTaskHandle, use WorkerHandle::receive()"); +} + +ZEND_METHOD(FrankenPHP_ReceivedTaskHandle, getPayload) { + ZEND_PARSE_PARAMETERS_NONE(); + + frankenphp_task_data *data = FRANKENPHP_TASK_DATA(ZEND_THIS); + /* the payload outlives complete(), only an instance made behind the + * private constructor's back has none */ + if (Z_ISUNDEF(data->payload)) { + zend_throw_error(NULL, "FrankenPHP\\ReceivedTaskHandle was not handed " + "out by WorkerHandle::receive()"); + RETURN_THROWS(); + } + + RETURN_COPY(&data->payload); +} + +/* Queues an update for the sender, which reads it with read(). */ +static void frankenphp_task_obj_update(zval *zthis, zval *data) { + php_stream *stream = frankenphp_task_obj_open_stream(zthis); + if (stream == NULL) { + return; + } + if (!persistent_zval_validate(data)) { + zend_value_error("FrankenPHP\\ReceivedTaskHandle: values must be null, " + "scalars, arrays or enums, nested no deeper than %d " + "levels", + PERSISTENT_ZVAL_MAX_DEPTH); + return; + } + + zval persistent; + persistent_zval_persist(&persistent, data); + + /* the Go side owns the update from here on, it frees it on failure */ + char *error = go_frankenphp_update_task( + ((frankenphp_task_stream_data *)stream->abstract)->task, + Z_ARRVAL(persistent)); + if (error != NULL) { + zend_throw_exception(spl_ce_RuntimeException, error, 0); + free(error); + } +} + +ZEND_METHOD(FrankenPHP_ReceivedTaskHandle, update) { + zval *data; + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_ARRAY(data) + ZEND_PARSE_PARAMETERS_END(); + + frankenphp_task_obj_update(ZEND_THIS, data); +} + +ZEND_METHOD(FrankenPHP_ReceivedTaskHandle, complete) { + zval *data = NULL; + ZEND_PARSE_PARAMETERS_START(0, 1) + Z_PARAM_OPTIONAL + Z_PARAM_ARRAY_OR_NULL(data) + ZEND_PARSE_PARAMETERS_END(); + + if (data != NULL) { + frankenphp_task_obj_update(ZEND_THIS, data); + if (EG(exception)) { + RETURN_THROWS(); + } + } + + frankenphp_task_obj_close(ZEND_THIS); +} + +ZEND_METHOD(FrankenPHP_ReceivedTaskHandle, getStream) { + ZEND_PARSE_PARAMETERS_NONE(); + + frankenphp_task_obj_get_stream(ZEND_THIS, return_value); +} + +ZEND_METHOD(FrankenPHP_WorkerHandle, receive) { + ZEND_PARSE_PARAMETERS_NONE(); + + struct go_frankenphp_receive_task_return task = + go_frankenphp_receive_task(frankenphp_thread_index()); + if (task.r0 == 0) { + RETURN_NULL(); + } + + /* the task is this thread's from here on: a bailout while copying the + * payload (memory limit, a fatal error in an autoloader) or creating the + * stream must not leave it open, the sender would wait on it forever */ + zval payload; + php_stream *stream = NULL; + zend_try { + frankenphp_vars_to_request(&payload, task.r1); + if (!EG(exception)) { + stream = frankenphp_task_stream_open(task.r0, task.r2, false); + } + } + zend_catch { + frankenphp_vars_free(task.r1); + go_frankenphp_task_receiver_close(task.r0, true); + zend_bailout(); + } + zend_end_try(); + frankenphp_vars_free(task.r1); + + if (EG(exception)) { + /* an enum of the payload does not resolve here: the task cannot be + * processed, the sender is told so */ + zval_ptr_dtor(&payload); + go_frankenphp_task_receiver_close(task.r0, true); + RETURN_THROWS(); + } + + object_init_ex(return_value, frankenphp_received_task_ce); + frankenphp_task_data *data = FRANKENPHP_TASK_DATA(return_value); + data->res = stream->res; + ZVAL_COPY_VALUE(&data->payload, &payload); +} + /* {{{ thread-safe opcache reset */ PHP_FUNCTION(frankenphp_opcache_reset) { go_schedule_opcache_reset(frankenphp_thread_index()); @@ -1620,6 +2339,14 @@ PHP_MINIT_FUNCTION(frankenphp) { 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); + + frankenphp_sent_task_ce = register_class_FrankenPHP_SentTaskHandle(); + frankenphp_sent_task_ce->create_object = frankenphp_sent_task_new; + frankenphp_handle_implements_poll(frankenphp_sent_task_ce); + + frankenphp_received_task_ce = register_class_FrankenPHP_ReceivedTaskHandle(); + frankenphp_received_task_ce->create_object = frankenphp_received_task_new; + frankenphp_handle_implements_poll(frankenphp_received_task_ce); #ifndef PHP_WIN32 /* MINIT runs once per ZTS thread — guard the atfork registration */ static pthread_once_t atfork_once = PTHREAD_ONCE_INIT; diff --git a/frankenphp.h b/frankenphp.h index a74ab493a9..407804a174 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -203,10 +203,24 @@ void frankenphp_release_thread_for_kill(force_kill_slot slot); /* Background worker primitives. */ intptr_t frankenphp_set_background_worker_and_get_stop_sock(void); +void frankenphp_close_sock(intptr_t s); void frankenphp_worker_close_stop_sock(intptr_t s); +void frankenphp_worker_signal_task(intptr_t s); +int frankenphp_task_chan_open(intptr_t fds[2]); +void frankenphp_task_chan_signal(intptr_t fd0, intptr_t fd1, int side); +bool frankenphp_task_chan_consume(intptr_t fd); +void frankenphp_task_chan_drain(intptr_t fd); void frankenphp_vars_to_request(zval *return_value, HashTable *table); void frankenphp_vars_free(HashTable *table); +/* Results of go_frankenphp_read_task. */ +enum { + FRANKENPHP_TASK_READ_UPDATE, + FRANKENPHP_TASK_READ_COMPLETED, + FRANKENPHP_TASK_READ_ABORTED, + FRANKENPHP_TASK_READ_PENDING, +}; + void register_extensions(zend_module_entry **m, int len); #endif diff --git a/frankenphp.stub.php b/frankenphp.stub.php index 26f9b464c4..8aa4fbc174 100644 --- a/frankenphp.stub.php +++ b/frankenphp.stub.php @@ -114,5 +114,95 @@ public function getStream() {} * Values must be null, scalars, arrays or enums. */ public function setVars(array $vars): void {} + + /** + * Dequeues a task sent to this background worker, without blocking, + * or null when there is none. Each task sent wakes one thread of the + * worker through its handle, so a script calls this after tick() + * returned. A wake-up is not a count, and null after one is expected + * in a pool. + */ + public function receive(): ?ReceivedTaskHandle {} + } + + /** + * EXPERIMENTAL: the sender's side of a task, an Io\Poll\Handle on PHP + * 8.6. Constructing it sends the + * task to the named background worker, resolved within the current + * php_server then among global workers, and waits for one of its threads + * to pick it up, at most $timeout seconds, forever when null. Payload + * values must be null, scalars, arrays or enums. + * + * @strict-properties + * @not-serializable + */ + final class SentTaskHandle + { + public function __construct(string $worker, array $payload, ?float $timeout = 30.0) {} + + /** + * The next update published by the worker, waiting for it, or null + * once the task is complete. Throws when the worker ended without + * completing it, and when the wait passes the stream's read timeout. + */ + public function read(): ?array {} + + /** + * The stream to wait on, alone or with other tasks: it becomes + * readable when an update is there and reaches EOF when the task + * ends. Only waiting on it is supported, read() consumes what it + * carries and reading it steals that signal. Closing it abandons + * the task, and past its end the stream comes back closed. + * + * @return resource + */ + public function getStream() {} + + /** + * Gives up on the task: the worker's next update throws. Dropping + * the handle does the same. + */ + public function abandon(): void {} + } + + /** + * EXPERIMENTAL: the worker's side of a task, handed out by + * WorkerHandle::receive(), an Io\Poll\Handle on PHP 8.6. Ending the run with a task open aborts it, + * which the sender is told about. + * + * @strict-properties + * @not-serializable + */ + final class ReceivedTaskHandle + { + private function __construct() {} + + /** + * The payload the sender passed. + */ + public function getPayload(): array {} + + /** + * Publishes an update for the sender, which reads it with read(). + * Values must be null, scalars, arrays or enums. Sixteen updates are + * buffered per task, past that this waits for the sender to read. + */ + public function update(array $data): void {} + + /** + * Completes the task, with a last update when one is given: the + * sender's read() returns it, then null. + */ + public function complete(?array $data = null): void {} + + /** + * The stream to wait on: it reaches EOF when the sender abandons the + * task, for stream_select() and feof(). Only waiting on it is + * supported. Closing it completes the task, and past its end the + * stream comes back closed. + * + * @return resource + */ + public function getStream() {} } } diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index 910f7f16d5..eec3500305 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: 4253ec747c57562891e9be84155bf86467f37e5b */ + * Stub hash: 93fefcc42a0d1fae50072ac5ca2a8155cbf5b576 */ 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) @@ -56,6 +56,37 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_FrankenPHP_WorkerHandle_se ZEND_ARG_TYPE_INFO(0, vars, IS_ARRAY, 0) ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_FrankenPHP_WorkerHandle_receive, 0, 0, FrankenPHP\\ReceivedTaskHandle, 1) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_INFO_EX(arginfo_class_FrankenPHP_SentTaskHandle___construct, 0, 0, 2) + ZEND_ARG_TYPE_INFO(0, worker, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, payload, IS_ARRAY, 0) + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, timeout, IS_DOUBLE, 1, "30.0") +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_FrankenPHP_SentTaskHandle_read, 0, 0, IS_ARRAY, 1) +ZEND_END_ARG_INFO() + +#define arginfo_class_FrankenPHP_SentTaskHandle_getStream arginfo_class_FrankenPHP_WorkerHandle___construct + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_FrankenPHP_SentTaskHandle_abandon, 0, 0, IS_VOID, 0) +ZEND_END_ARG_INFO() + +#define arginfo_class_FrankenPHP_ReceivedTaskHandle___construct arginfo_class_FrankenPHP_WorkerHandle___construct + +#define arginfo_class_FrankenPHP_ReceivedTaskHandle_getPayload arginfo_frankenphp_request_headers + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_FrankenPHP_ReceivedTaskHandle_update, 0, 1, IS_VOID, 0) + ZEND_ARG_TYPE_INFO(0, data, IS_ARRAY, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_FrankenPHP_ReceivedTaskHandle_complete, 0, 0, IS_VOID, 0) + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, data, IS_ARRAY, 1, "null") +ZEND_END_ARG_INFO() + +#define arginfo_class_FrankenPHP_ReceivedTaskHandle_getStream arginfo_class_FrankenPHP_WorkerHandle___construct + ZEND_FUNCTION(frankenphp_handle_request); ZEND_FUNCTION(headers_send); ZEND_FUNCTION(frankenphp_finish_request); @@ -68,6 +99,16 @@ ZEND_METHOD(FrankenPHP_WorkerHandle, __construct); ZEND_METHOD(FrankenPHP_WorkerHandle, tick); ZEND_METHOD(FrankenPHP_WorkerHandle, getStream); ZEND_METHOD(FrankenPHP_WorkerHandle, setVars); +ZEND_METHOD(FrankenPHP_WorkerHandle, receive); +ZEND_METHOD(FrankenPHP_SentTaskHandle, __construct); +ZEND_METHOD(FrankenPHP_SentTaskHandle, read); +ZEND_METHOD(FrankenPHP_SentTaskHandle, getStream); +ZEND_METHOD(FrankenPHP_SentTaskHandle, abandon); +ZEND_METHOD(FrankenPHP_ReceivedTaskHandle, __construct); +ZEND_METHOD(FrankenPHP_ReceivedTaskHandle, getPayload); +ZEND_METHOD(FrankenPHP_ReceivedTaskHandle, update); +ZEND_METHOD(FrankenPHP_ReceivedTaskHandle, complete); +ZEND_METHOD(FrankenPHP_ReceivedTaskHandle, getStream); static const zend_function_entry ext_functions[] = { ZEND_FE(frankenphp_handle_request, arginfo_frankenphp_handle_request) @@ -90,6 +131,24 @@ static const zend_function_entry class_FrankenPHP_WorkerHandle_methods[] = { 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_ME(FrankenPHP_WorkerHandle, receive, arginfo_class_FrankenPHP_WorkerHandle_receive, ZEND_ACC_PUBLIC) + ZEND_FE_END +}; + +static const zend_function_entry class_FrankenPHP_SentTaskHandle_methods[] = { + ZEND_ME(FrankenPHP_SentTaskHandle, __construct, arginfo_class_FrankenPHP_SentTaskHandle___construct, ZEND_ACC_PUBLIC) + ZEND_ME(FrankenPHP_SentTaskHandle, read, arginfo_class_FrankenPHP_SentTaskHandle_read, ZEND_ACC_PUBLIC) + ZEND_ME(FrankenPHP_SentTaskHandle, getStream, arginfo_class_FrankenPHP_SentTaskHandle_getStream, ZEND_ACC_PUBLIC) + ZEND_ME(FrankenPHP_SentTaskHandle, abandon, arginfo_class_FrankenPHP_SentTaskHandle_abandon, ZEND_ACC_PUBLIC) + ZEND_FE_END +}; + +static const zend_function_entry class_FrankenPHP_ReceivedTaskHandle_methods[] = { + ZEND_ME(FrankenPHP_ReceivedTaskHandle, __construct, arginfo_class_FrankenPHP_ReceivedTaskHandle___construct, ZEND_ACC_PRIVATE) + ZEND_ME(FrankenPHP_ReceivedTaskHandle, getPayload, arginfo_class_FrankenPHP_ReceivedTaskHandle_getPayload, ZEND_ACC_PUBLIC) + ZEND_ME(FrankenPHP_ReceivedTaskHandle, update, arginfo_class_FrankenPHP_ReceivedTaskHandle_update, ZEND_ACC_PUBLIC) + ZEND_ME(FrankenPHP_ReceivedTaskHandle, complete, arginfo_class_FrankenPHP_ReceivedTaskHandle_complete, ZEND_ACC_PUBLIC) + ZEND_ME(FrankenPHP_ReceivedTaskHandle, getStream, arginfo_class_FrankenPHP_ReceivedTaskHandle_getStream, ZEND_ACC_PUBLIC) ZEND_FE_END }; @@ -110,3 +169,23 @@ static zend_class_entry *register_class_FrankenPHP_WorkerHandle(void) return class_entry; } + +static zend_class_entry *register_class_FrankenPHP_SentTaskHandle(void) +{ + zend_class_entry ce, *class_entry; + + INIT_NS_CLASS_ENTRY(ce, "FrankenPHP", "SentTaskHandle", class_FrankenPHP_SentTaskHandle_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; +} + +static zend_class_entry *register_class_FrankenPHP_ReceivedTaskHandle(void) +{ + zend_class_entry ce, *class_entry; + + INIT_NS_CLASS_ENTRY(ce, "FrankenPHP", "ReceivedTaskHandle", class_FrankenPHP_ReceivedTaskHandle_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/metrics.go b/metrics.go index 53ac12d541..4fd548750f 100644 --- a/metrics.go +++ b/metrics.go @@ -16,6 +16,16 @@ const ( type StopReason int +// TaskOutcome is how a task sent to a background worker ended +type TaskOutcome string + +const ( + TaskOutcomeCompleted TaskOutcome = "completed" // the worker closed the task's stream + TaskOutcomeAborted TaskOutcome = "aborted" // the worker's script ended with the task open + TaskOutcomeAbandoned TaskOutcome = "abandoned" // the sender closed its stream first + TaskOutcomeTimeout TaskOutcome = "timeout" // no thread picked the task up in time +) + // 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 @@ -60,6 +70,14 @@ type ServerMetrics interface { StartWorkerRequestOnServer(name, server string) QueuedWorkerRequestOnServer(name, server string) DequeuedWorkerRequestOnServer(name, server string) + // the tasks of background workers have no packed counterpart in + // Metrics, so they take the pair under their own name + // StartWorkerTask collects tasks picked up by a thread of a background worker + StartWorkerTask(name, server string) + // StopWorkerTask collects tasks a thread of a background worker is done with + StopWorkerTask(name, server string, duration time.Duration) + // WorkerTaskOutcome collects how tasks sent to a background worker ended + WorkerTaskOutcome(name, server string, outcome TaskOutcome) } // workerMetrics is what the runtime reports on: Metrics with the worker @@ -79,6 +97,9 @@ type workerMetrics interface { DequeuedWorkerRequest(name, server string) QueuedRequest() DequeuedRequest() + StartWorkerTask(name, server string) + StopWorkerTask(name, server string, duration time.Duration) + WorkerTaskOutcome(name, server string, outcome TaskOutcome) } // metricsAdapter routes the worker methods to ServerMetrics when the @@ -103,6 +124,26 @@ func packedWorkerName(name, server string) string { return server + ":" + name } +// the task methods have no packed form: an implementation that does not +// satisfy ServerMetrics simply does not collect them +func (a metricsAdapter) StartWorkerTask(name, server string) { + if a.server != nil { + a.server.StartWorkerTask(name, server) + } +} + +func (a metricsAdapter) StopWorkerTask(name, server string, duration time.Duration) { + if a.server != nil { + a.server.StopWorkerTask(name, server, duration) + } +} + +func (a metricsAdapter) WorkerTaskOutcome(name, server string, outcome TaskOutcome) { + if a.server != nil { + a.server.WorkerTaskOutcome(name, server, outcome) + } +} + func (a metricsAdapter) StartWorker(name, server string) { if a.server != nil { a.server.StartWorkerOnServer(name, server) @@ -206,6 +247,12 @@ func (n nullMetrics) DequeuedWorkerRequest(string, string) {} func (n nullMetrics) QueuedRequest() {} func (n nullMetrics) DequeuedRequest() {} +func (n nullMetrics) StartWorkerTask(string, string) {} + +func (n nullMetrics) StopWorkerTask(string, string, time.Duration) {} + +func (n nullMetrics) WorkerTaskOutcome(string, string, TaskOutcome) {} + type PrometheusMetrics struct { registry prometheus.Registerer totalThreads prometheus.Gauge @@ -218,6 +265,8 @@ type PrometheusMetrics struct { workerRequestTime *prometheus.CounterVec workerRequestCount *prometheus.CounterVec workerQueueDepth *prometheus.GaugeVec + workerTaskCount *prometheus.CounterVec + workerTaskTime *prometheus.CounterVec queueDepth prometheus.Gauge mu sync.RWMutex } @@ -340,7 +389,7 @@ func (m *PrometheusMetrics) TotalWorkersOnServer(string, string, int) { m.busyWorkers = prometheus.NewGaugeVec(prometheus.GaugeOpts{ Namespace: ns, Name: "busy_workers", - Help: "Number of busy PHP workers for this worker", + Help: "Number of busy PHP workers for this worker: processing a request, or a task for a background worker", }, basicLabels) m.mustRegister(m.busyWorkers) } @@ -388,9 +437,30 @@ func (m *PrometheusMetrics) TotalWorkersOnServer(string, string, int) { Namespace: "frankenphp", Subsystem: sub, Name: "queue_depth", + Help: "Number of queued requests for this worker, or of tasks waiting for a thread of a background worker", }, basicLabels) m.mustRegister(m.workerQueueDepth) } + + if m.workerTaskCount == nil { + m.workerTaskCount = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: ns, + Subsystem: sub, + Name: "task_count", + Help: "Number of tasks sent to this background worker, by outcome: completed, aborted (the script ended with the task open), abandoned (the sender closed its stream first) or timeout (no thread picked the task up in time)", + }, []string{"worker", "server", "outcome"}) + m.mustRegister(m.workerTaskCount) + } + + if m.workerTaskTime == nil { + m.workerTaskTime = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: ns, + Subsystem: sub, + Name: "task_time", + Help: "Time spent on tasks by all threads of this background worker, from pickup to the close of the task's stream", + }, basicLabels) + m.mustRegister(m.workerTaskTime) + } } func (m *PrometheusMetrics) TotalThreads(num int) { @@ -471,6 +541,37 @@ func (m *PrometheusMetrics) DequeuedRequest() { m.queueDepth.Dec() } +func (m *PrometheusMetrics) StartWorkerTask(name, server string) { + m.mu.RLock() + defer m.mu.RUnlock() + + if m.busyWorkers == nil { + return + } + m.busyWorkers.WithLabelValues(name, server).Inc() +} + +func (m *PrometheusMetrics) StopWorkerTask(name, server string, duration time.Duration) { + m.mu.RLock() + defer m.mu.RUnlock() + + if m.workerTaskTime == nil { + return + } + m.busyWorkers.WithLabelValues(name, server).Dec() + m.workerTaskTime.WithLabelValues(name, server).Add(duration.Seconds()) +} + +func (m *PrometheusMetrics) WorkerTaskOutcome(name, server string, outcome TaskOutcome) { + m.mu.RLock() + defer m.mu.RUnlock() + + if m.workerTaskCount == nil { + return + } + m.workerTaskCount.WithLabelValues(name, server, string(outcome)).Inc() +} + func (m *PrometheusMetrics) Shutdown() { m.mu.Lock() defer m.mu.Unlock() @@ -510,6 +611,14 @@ func (m *PrometheusMetrics) Shutdown() { if m.workerQueueDepth != nil { m.registry.Unregister(m.workerQueueDepth) } + + if m.workerTaskCount != nil { + m.registry.Unregister(m.workerTaskCount) + } + + if m.workerTaskTime != nil { + m.registry.Unregister(m.workerTaskTime) + } } func NewPrometheusMetrics(registry prometheus.Registerer) *PrometheusMetrics { @@ -539,6 +648,8 @@ func NewPrometheusMetrics(registry prometheus.Registerer) *PrometheusMetrics { workerCrashes: nil, readyWorkers: nil, workerQueueDepth: nil, + workerTaskCount: nil, + workerTaskTime: nil, } m.mustRegister(m.totalThreads) diff --git a/metrics_test.go b/metrics_test.go index d3b3e3c376..914e900b67 100644 --- a/metrics_test.go +++ b/metrics_test.go @@ -51,6 +51,8 @@ func TestPrometheusMetrics_TotalWorkers(t *testing.T) { require.Nil(t, m.workerRestarts) require.Nil(t, m.workerRequestTime) require.Nil(t, m.workerRequestCount) + require.Nil(t, m.workerTaskCount) + require.Nil(t, m.workerTaskTime) m.TotalWorkersOnServer("test_worker", "test_server", 2) @@ -61,6 +63,65 @@ func TestPrometheusMetrics_TotalWorkers(t *testing.T) { require.NotNil(t, m.workerRestarts) require.NotNil(t, m.workerRequestTime) require.NotNil(t, m.workerRequestCount) + require.NotNil(t, m.workerTaskCount) + require.NotNil(t, m.workerTaskTime) +} + +func TestPrometheusMetrics_WorkerTask(t *testing.T) { + m := createPrometheusMetrics() + m.TotalWorkersOnServer("bg_worker", "bg_server", 1) + m.StartWorkerTask("bg_worker", "bg_server") + m.StopWorkerTask("bg_worker", "bg_server", 3*time.Second) + m.WorkerTaskOutcome("bg_worker", "bg_server", TaskOutcomeCompleted) + m.WorkerTaskOutcome("bg_worker", "bg_server", TaskOutcomeTimeout) + + inputs := []struct { + name string + c prometheus.Collector + metadata string + expect string + }{ + { + name: "Testing BusyWorkers", + c: m.busyWorkers, + metadata: ` + # HELP frankenphp_busy_workers Number of busy PHP workers for this worker: processing a request, or a task for a background worker + # TYPE frankenphp_busy_workers gauge + `, + expect: ` + frankenphp_busy_workers{server="bg_server",worker="bg_worker"} 0 + `, + }, + { + name: "Testing WorkerTaskTime", + c: m.workerTaskTime, + metadata: ` + # HELP frankenphp_worker_task_time Time spent on tasks by all threads of this background worker, from pickup to the close of the task's stream + # TYPE frankenphp_worker_task_time counter + `, + expect: ` + frankenphp_worker_task_time{server="bg_server",worker="bg_worker"} 3 + `, + }, + { + name: "Testing WorkerTaskCount", + c: m.workerTaskCount, + metadata: ` + # HELP frankenphp_worker_task_count Number of tasks sent to this background worker, by outcome: completed, aborted (the script ended with the task open), abandoned (the sender closed its stream first) or timeout (no thread picked the task up in time) + # TYPE frankenphp_worker_task_count counter + `, + expect: ` + frankenphp_worker_task_count{outcome="completed",server="bg_server",worker="bg_worker"} 1 + frankenphp_worker_task_count{outcome="timeout",server="bg_server",worker="bg_worker"} 1 + `, + }, + } + + for _, input := range inputs { + t.Run(input.name, func(t *testing.T) { + require.NoError(t, testutil.CollectAndCompare(input.c, strings.NewReader(input.metadata+input.expect))) + }) + } } func TestPrometheusMetrics_StopWorkerRequest(t *testing.T) { @@ -89,7 +150,7 @@ func TestPrometheusMetrics_StopWorkerRequest(t *testing.T) { name: "Testing BusyWorkers", c: m.busyWorkers, metadata: ` - # HELP frankenphp_busy_workers Number of busy PHP workers for this worker + # HELP frankenphp_busy_workers Number of busy PHP workers for this worker: processing a request, or a task for a background worker # TYPE frankenphp_busy_workers gauge `, expect: ` @@ -132,7 +193,7 @@ func TestPrometheusMetrics_StartWorkerRequest(t *testing.T) { name: "Testing BusyWorkers", c: m.busyWorkers, metadata: ` - # HELP frankenphp_busy_workers Number of busy PHP workers for this worker + # HELP frankenphp_busy_workers Number of busy PHP workers for this worker: processing a request, or a task for a background worker # TYPE frankenphp_busy_workers gauge `, expect: ` @@ -253,6 +314,13 @@ func (m *splitMetrics) StopWorkerRequestOnServer(name, server string, _ time.Dur 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) StartWorkerTask(name, server string) { m.record(name, server) } +func (m *splitMetrics) StopWorkerTask(name, server string, _ time.Duration) { + m.record(name, server) +} +func (m *splitMetrics) WorkerTaskOutcome(name, server string, _ TaskOutcome) { + 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") } diff --git a/phpmainthread.go b/phpmainthread.go index 6aa69661b9..441f92c0b3 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -112,6 +112,7 @@ func drainPHPThreads() { doneWG.Wait() // no PHP thread can read them anymore, and the engine is still up freeWorkerVars() + freeTaskChans() mainThread.state.Set(state.Done) mainThread.state.WaitFor(state.Reserved) C.frankenphp_destroy_thread_metrics() diff --git a/testdata/bgworker/task-over-worker.php b/testdata/bgworker/task-over-worker.php new file mode 100644 index 0000000000..3588dd7e5c --- /dev/null +++ b/testdata/bgworker/task-over-worker.php @@ -0,0 +1,31 @@ +getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); + while ($task = $handle->receive()) { + $task->complete(['result' => 'over']); + $lines = []; + foreach ([ + 'getPayload' => fn () => json_encode($task->getPayload()), + 'update' => fn () => $task->update(['late' => true]), + 'complete' => fn () => $task->complete(), + 'complete-data' => fn () => $task->complete(['late' => true]), + 'getStream' => fn () => get_debug_type($task->getStream()), + ] as $name => $case) { + try { + $result = $case(); + $lines[] = $name . ': ' . (null === $result ? 'ok' : $result); + } catch (\Throwable $e) { + $lines[] = $name . ': ' . get_class($e) . ': ' . $e->getMessage(); + } + } + file_put_contents($_SERVER['BG_SENTINEL'], implode("\n", $lines)); + } +} diff --git a/testdata/bgworker/task-relay.php b/testdata/bgworker/task-relay.php new file mode 100644 index 0000000000..0bd2b1dc70 --- /dev/null +++ b/testdata/bgworker/task-relay.php @@ -0,0 +1,15 @@ + 'relayed']); + $result = json_encode($task->read()); +} catch (\Throwable $e) { + $result = get_class($e) . ': ' . $e->getMessage(); +} +file_put_contents($_SERVER['BG_SENTINEL'], $result); +$handle = new \FrankenPHP\WorkerHandle(); +$handle->tick(); +fgets($handle->getStream()); diff --git a/testdata/bgworker/task-worker.php b/testdata/bgworker/task-worker.php new file mode 100644 index 0000000000..26ae4c382b --- /dev/null +++ b/testdata/bgworker/task-worker.php @@ -0,0 +1,54 @@ +getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); + while ($task = $handle->receive()) { + $payload = $task->getPayload(); + if (!empty($payload['crash'])) { + exit(1); + } + try { + if (!empty($payload['mark'])) { + touch($payload['mark']); + } + if (!empty($payload['sleep_ms'])) { + $read = [$task->getStream()]; + $write = $except = null; + if (stream_select($read, $write, $except, intdiv($payload['sleep_ms'], 1000), 1000 * ($payload['sleep_ms'] % 1000)) > 0 && feof($task->getStream())) { + throw new \RuntimeException('the sender closed the task before the update'); + } + } + for ($i = 1, $steps = $payload['steps'] ?? 0; $i <= $steps; ++$i) { + $task->update(['step' => $i, 'of' => $steps]); + } + $task->complete([ + 'result' => 'processed:' . ($payload['input'] ?? ''), + 'worker' => $_SERVER['FRANKENPHP_WORKER_BACKGROUND'], + 'tag' => $_SERVER['BG_TAG'] ?? '', + 'thread' => $threadId ??= bin2hex(random_bytes(4)), + ]); + } catch (\Throwable $e) { + if (!empty($_SERVER['BG_SENTINEL'])) { + file_put_contents($_SERVER['BG_SENTINEL'], get_class($e) . ': ' . $e->getMessage()); + } + $task->complete(); + } + if (!$drain) { + break; + } + } +} diff --git a/testdata/task-busy.php b/testdata/task-busy.php new file mode 100644 index 0000000000..9c2b588644 --- /dev/null +++ b/testdata/task-busy.php @@ -0,0 +1,16 @@ + 'slow', 'sleep_ms' => 500]); + try { + new \FrankenPHP\SentTaskHandle('echo', ['input' => 'late'], 0.1); + echo "no timeout\n"; + } catch (\RuntimeException $e) { + echo $e->getMessage(), "\n"; + } + echo json_encode($slow->read()); +} catch (\Throwable $e) { + echo get_class($e), ': ', $e->getMessage(); +} diff --git a/testdata/task-errors.php b/testdata/task-errors.php new file mode 100644 index 0000000000..b52a0f44e5 --- /dev/null +++ b/testdata/task-errors.php @@ -0,0 +1,18 @@ + fn () => new \FrankenPHP\SentTaskHandle('nope', []), + 'payload' => fn () => new \FrankenPHP\SentTaskHandle('echo', ['object' => new stdClass()]), + 'timeout' => fn () => new \FrankenPHP\SentTaskHandle('echo', [], -1), + 'received' => fn () => new \FrankenPHP\ReceivedTaskHandle(), + 'worker' => fn () => new \FrankenPHP\WorkerHandle(), +]; +foreach ($cases as $name => $case) { + try { + $case(); + echo $name, ": no exception\n"; + } catch (\Throwable $e) { + echo $name, ': ', get_class($e), ': ', $e->getMessage(), "\n"; + } +} diff --git a/testdata/task-over.php b/testdata/task-over.php new file mode 100644 index 0000000000..916de0c60c --- /dev/null +++ b/testdata/task-over.php @@ -0,0 +1,24 @@ + fn () => $task->read(), + 'getStream' => fn () => get_debug_type($task->getStream()), + 'abandon' => fn () => $task->abandon(), + ] as $name => $case) { + try { + $result = $case(); + echo $phase, ' ', $name, ': ', null === $result ? 'ok' : $result, "\n"; + } catch (\Throwable $e) { + echo $phase, ' ', $name, ': ', get_class($e), ': ', $e->getMessage(), "\n"; + } + } +}; + +$task = new \FrankenPHP\SentTaskHandle('echo', ['input' => 'over']); +while (null !== $task->read()) { +} +$probe('completed', $task); +$probe('abandoned', $task); diff --git a/testdata/task-poll.php b/testdata/task-poll.php new file mode 100644 index 0000000000..be8a1fbfa6 --- /dev/null +++ b/testdata/task-poll.php @@ -0,0 +1,30 @@ + $input, 'sleep_ms' => 200]); + $tasks[spl_object_id($task)] = $task; + $poll->add($task, [Event::Read], spl_object_id($task)); + } + $results = []; + while ($tasks) { + foreach ($poll->wait() as $watcher) { + if (null === $update = $tasks[$watcher->getData()]->read()) { + $watcher->remove(); + unset($tasks[$watcher->getData()]); + continue; + } + $results[] = $update['result']; + } + } + sort($results); + echo json_encode($results); +} catch (\Throwable $e) { + echo get_class($e), ': ', $e->getMessage(); +} diff --git a/testdata/task-pool.php b/testdata/task-pool.php new file mode 100644 index 0000000000..3395d7962b --- /dev/null +++ b/testdata/task-pool.php @@ -0,0 +1,29 @@ + 'a', 'sleep_ms' => 300]), + new \FrankenPHP\SentTaskHandle('pool', ['input' => 'b', 'sleep_ms' => 300]), + ]; + $threads = []; + while ($tasks) { + $read = array_map(fn ($task) => $task->getStream(), $tasks); + $write = $except = null; + if (!stream_select($read, $write, $except, 5)) { + throw new \RuntimeException('stream_select() timed out'); + } + foreach ($read as $i => $stream) { + if (null === $update = $tasks[$i]->read()) { + unset($tasks[$i]); + continue; + } + $threads[$update['result']] = $update['thread']; + } + } + ksort($threads); + echo json_encode(array_keys($threads)), "\n", 2 === count(array_unique($threads)) ? 'two threads' : 'one thread'; +} catch (\Throwable $e) { + echo get_class($e), ': ', $e->getMessage(); +} diff --git a/testdata/task-shutdown.php b/testdata/task-shutdown.php new file mode 100644 index 0000000000..288d52550c --- /dev/null +++ b/testdata/task-shutdown.php @@ -0,0 +1,13 @@ + 'slow', 'sleep_ms' => 1500, 'mark' => $_GET['mark']]); + new \FrankenPHP\SentTaskHandle('echo', ['input' => 'never'], null); + echo 'picked up'; +} catch (\Throwable $e) { + echo get_class($e), ': ', $e->getMessage(); +} diff --git a/testdata/task.php b/testdata/task.php new file mode 100644 index 0000000000..7d9d689a27 --- /dev/null +++ b/testdata/task.php @@ -0,0 +1,23 @@ + is_numeric($v) ? (int) $v : $v, $payload); + $task = new \FrankenPHP\SentTaskHandle($_GET['name'] ?? 'echo', $payload, isset($_GET['timeout']) ? (float) $_GET['timeout'] : 30.0); + if (isset($_GET['close_early'])) { + $task->abandon(); + echo 'closed'; + + return; + } + while (null !== $update = $task->read()) { + echo json_encode($update), "\n"; + } + echo 'done'; +} catch (\Throwable $e) { + echo get_class($e), ': ', $e->getMessage(); +} diff --git a/threadbackgroundworker.go b/threadbackgroundworker.go index f9d6c0844c..dcbc41b105 100644 --- a/threadbackgroundworker.go +++ b/threadbackgroundworker.go @@ -7,6 +7,7 @@ import "C" import ( "fmt" "log/slog" + "runtime" "sync/atomic" "time" @@ -16,8 +17,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. +// WorkerHandle::getStream(), which reaches EOF when the thread is drained, so +// it exits on shutdown, reboot or handler transition; the handle also carries +// a wake-up per task sent to the worker, see SentTaskHandle. type backgroundWorkerThread struct { workerLifecycle @@ -35,11 +37,27 @@ type backgroundWorkerThread struct { isBootingScript bool bootTimer *time.Timer - // 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 - // independently. Wide enough for a Windows SOCKET, -1 when not held, - // atomic because drain() closes it from another goroutine - stopSock atomic.Int64 + // tasks picked up and not closed yet: the thread is busy rather than + // waiting on the threads endpoint meanwhile. Only touched on the PHP + // thread, pickup and close both happen there + openTasks int + + // 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 independently. Wide + // enough for a Windows SOCKET, -1 when not held. Guarded by + // worker.tasks.mu, senders write their wake-up line to it + stopSock int64 + + // set by WorkerHandle::tick() when no task is queued, as the script is + // about to wait on its handle: senders wake one parked thread per task. + // Guarded by worker.tasks.mu + parked bool + + // senders writing to stopSock outside of worker.tasks.mu, so the socket is + // only closed once they are done: holding the mutex across that syscall + // would park every contending thread, and a thread inside a cgo callback + // parks at the price of a scheduler hand-off + signaling atomic.Int32 } // backgroundBootWarnDelay is how long a run may go without calling @@ -48,8 +66,10 @@ type backgroundWorkerThread struct { const backgroundBootWarnDelay = 10 * time.Second func convertToBackgroundWorkerThread(thread *phpThread, worker *worker) { - handler := &backgroundWorkerThread{workerLifecycle: newWorkerLifecycle(thread, worker)} - handler.stopSock.Store(-1) + handler := &backgroundWorkerThread{ + workerLifecycle: newWorkerLifecycle(thread, worker), + stopSock: -1, + } thread.setHandler(handler) worker.attachThread(thread) } @@ -66,7 +86,18 @@ func (handler *backgroundWorkerThread) frankenPHPContext() *frankenPHPContext { // on the other end wakes up with EOF. Called right before drainChan is closed // on shutdown and reboot, and on the other exit paths to release the socket. func (handler *backgroundWorkerThread) drain() { - if s := handler.stopSock.Swap(-1); s >= 0 { + q := &handler.worker.tasks + q.mu.Lock() + s := handler.stopSock + handler.stopSock = -1 + handler.parked = false + q.mu.Unlock() + + if s >= 0 { + // senders that took the socket before it was withdrawn finish their write first + for handler.signaling.Load() > 0 { + runtime.Gosched() + } C.frankenphp_worker_close_stop_sock(C.intptr_t(s)) } } @@ -111,7 +142,12 @@ func (handler *backgroundWorkerThread) setupScript() error { if s < 0 { return fmt.Errorf("failed to create the stop socket pair of background worker %q", handler.worker.qualifiedName) } - handler.stopSock.Store(s) + // tasks queued meanwhile reach the new run when it parks, see + // go_frankenphp_background_worker_park + q := &handler.worker.tasks + q.mu.Lock() + handler.stopSock = s + q.mu.Unlock() switch handler.state.Get() { case state.ShuttingDown, state.Rebooting, state.ForceRebooting, state.TransitionRequested: @@ -131,6 +167,7 @@ func (handler *backgroundWorkerThread) setupScript() error { handler.isBootingScript = true handler.runStartedAt = time.Now() + handler.openTasks = 0 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 diff --git a/worker.go b/worker.go index 78bbd4d464..c446ad86e0 100644 --- a/worker.go +++ b/worker.go @@ -48,6 +48,9 @@ type worker struct { readyClose sync.Once // vars is the snapshot published with WorkerHandle::setVars() vars varsSlot + // tasks holds the tasks sent with SentTaskHandle until a thread + // picks them up + tasks taskQueue } // markReady records that the background worker reached its ready point once diff --git a/workertask.go b/workertask.go new file mode 100644 index 0000000000..4027de152b --- /dev/null +++ b/workertask.go @@ -0,0 +1,679 @@ +package frankenphp + +// #include "frankenphp.h" +import "C" +import ( + "runtime/cgo" + "slices" + "strconv" + "sync" + "time" +) + +// taskUpdatesMax bounds the updates buffered per task: past it, +// ReceivedTaskHandle::update() waits for the sender to read +const taskUpdatesMax = 16 + +// workerTask is a unit of work handed by a PHP thread to a thread of a +// background worker, see SentTaskHandle. The payload and the +// updates flowing back are persistent HashTables, copied into request +// memory on arrival. Each side waits on its descriptor of the task's channel +// and is signaled there by the other, one signal per event: pickup, update, +// completion and abort for the sender, abandonment for the receiver. +type workerTask struct { + handle cgo.Handle + worker *worker + payload *C.HashTable // owned by the task until a thread picks it up + pickedUp chan struct{} // closed when a thread picks the task up + // cancelled is closed when the sender gave up before any pickup, ending + // the watcher; abortReason is set by the watcher, under the queue mutex, + // when the wait must end without a pickup; drainChan and shutdown are + // the channels the watcher ends the wait on, read on the sender's thread + // at send time + cancelled chan struct{} + abortReason string + drainChan, shutdown <-chan struct{} + // receiver and pickedUpAt are set by the thread that picked the task + // up and read by its close, on the same thread + receiver *backgroundWorkerThread + pickedUpAt time.Time + // fds[0] is the sender's descriptor, fds[1] the receiver's; the streams + // wait on them but the task owns them, until both sides closed and the + // pair goes back to the pool + fds [2]int64 + + mu sync.Mutex + cond *sync.Cond // signaled on pop and close + // one wake-up per sleep of the sender's reads: a signal goes out only + // while the sender sleeps on its descriptor and none is outstanding, the + // sender consumes it on its next event and finds the rest in updates + // and the flags below + senderParked, senderSignaled bool + // senderWatched is set once the sender handed its descriptor to a poll + // context, which parks without a hook to tell us: every event then + // signals, and the signal stays outstanding while there is more to take, + // so the descriptor is readable exactly when something waits there + senderWatched bool + updates []*C.HashTable + closed bool // the receiver closed its stream + aborted bool // ...during request shutdown: the script ended with the task open + senderGone bool // the sender closed its stream + retired int // sides done with the task, freed at 2 +} + +// taskQueue holds the tasks sent to a background worker until a thread picks +// them up. Its mutex also guards the stop sockets of the worker's threads: +// senders write the wake-up line to them, so they must not be closed +// meanwhile. +type taskQueue struct { + mu sync.Mutex + pending []*workerTask + next int // thread to signal first, spreads tasks over a pool +} + +// remove takes t out of the queue; false if a thread picked it up already +func (q *taskQueue) remove(t *workerTask) bool { + q.mu.Lock() + defer q.mu.Unlock() + + i := slices.Index(q.pending, t) + if i < 0 { + return false + } + q.pending = slices.Delete(q.pending, i, i+1) + + return true +} + +// claimParkedThread picks one parked thread of the worker, round-robin over +// the pool, and returns its stop socket to write the wake-up line to, or -1 +// when no thread is parked: the task then waits in the queue for a thread to +// drain it or to park, see go_frankenphp_background_worker_park. The thread +// is no longer parked once claimed. Called with tasks.mu held; the caller +// writes after releasing it, see signalThreads +func (worker *worker) claimParkedThread() (*backgroundWorkerThread, int64) { + worker.threadMutex.RLock() + defer worker.threadMutex.RUnlock() + + n := len(worker.threads) + for i := range n { + thread := worker.threads[(worker.tasks.next+i)%n] + if handler, ok := thread.handler.(*backgroundWorkerThread); ok && handler.parked && handler.stopSock >= 0 { + handler.parked = false + handler.signaling.Add(1) + worker.tasks.next = (worker.tasks.next + i + 1) % n + + return handler, handler.stopSock + } + } + + return nil, -1 +} + +// claimAllThreads is the fallback of go_frankenphp_task_linger: every thread of +// the worker gets the line, parked or not. Called with tasks.mu held, the +// caller writes to the sockets after releasing it +func (worker *worker) claimAllThreads() (handlers []*backgroundWorkerThread, socks []int64) { + worker.threadMutex.RLock() + for _, thread := range worker.threads { + if handler, ok := thread.handler.(*backgroundWorkerThread); ok && handler.stopSock >= 0 { + handler.parked = false + handler.signaling.Add(1) + handlers = append(handlers, handler) + socks = append(socks, handler.stopSock) + } + } + worker.threadMutex.RUnlock() + + return handlers, socks +} + +// signalThreads writes the wake-up line to sockets claimed under tasks.mu, +// after it was released: the write is a syscall, and a thread contending +// for the mutex meanwhile would park at the price of a scheduler hand-off +func signalThreads(handlers []*backgroundWorkerThread, socks []int64) { + for i, s := range socks { + C.frankenphp_worker_signal_task(C.intptr_t(s)) + handlers[i].signaling.Add(-1) + } +} + +// taskChanPool keeps the descriptor pairs of finished tasks for the next +// ones: drained, they are as good as new, and creating and closing them was +// most of a task's syscalls. Bounded so an idle server does not hold the +// descriptors of a past peak. +var taskChanPool struct { + mu sync.Mutex + free [][2]int64 +} + +const taskChanPoolMax = 256 + +// taskChanGet returns a drained pair from the pool, or a new one +func taskChanGet() ([2]int64, bool) { + taskChanPool.mu.Lock() + if n := len(taskChanPool.free); n > 0 { + fds := taskChanPool.free[n-1] + taskChanPool.free = taskChanPool.free[:n-1] + taskChanPool.mu.Unlock() + + return fds, true + } + taskChanPool.mu.Unlock() + + var fds [2]C.intptr_t + if C.frankenphp_task_chan_open(&fds[0]) != 0 { + return [2]int64{}, false + } + + return [2]int64{int64(fds[0]), int64(fds[1])}, true +} + +// taskChanPut returns a pair to the pool, closed if the pool is full; the +// syscalls happen outside of the pool mutex +func taskChanPut(fds [2]int64) { + C.frankenphp_task_chan_drain(C.intptr_t(fds[0])) + C.frankenphp_task_chan_drain(C.intptr_t(fds[1])) + + taskChanPool.mu.Lock() + if len(taskChanPool.free) < taskChanPoolMax { + taskChanPool.free = append(taskChanPool.free, fds) + taskChanPool.mu.Unlock() + + return + } + taskChanPool.mu.Unlock() + + C.frankenphp_close_sock(C.intptr_t(fds[0])) + C.frankenphp_close_sock(C.intptr_t(fds[1])) +} + +// freeTaskChans closes the pooled pairs on shutdown +func freeTaskChans() { + taskChanPool.mu.Lock() + free := taskChanPool.free + taskChanPool.free = nil + taskChanPool.mu.Unlock() + + for _, fds := range free { + C.frankenphp_close_sock(C.intptr_t(fds[0])) + C.frankenphp_close_sock(C.intptr_t(fds[1])) + } +} + +// signalSender wakes the sender's wait: a pickup, an update, the end of +// the task or an abort +func (t *workerTask) signalSender() { + C.frankenphp_task_chan_signal(C.intptr_t(t.fds[0]), C.intptr_t(t.fds[1]), 0) +} + +// wakeSenderLocked tells whether an event calls for a signal: only when the +// sender sleeps on its descriptor and none is outstanding. Called with mu +// held, the caller signals after releasing it +func (t *workerTask) wakeSenderLocked() bool { + if (!t.senderParked && !t.senderWatched) || t.senderSignaled { + return false + } + t.senderSignaled = true + t.senderParked = false + + return true +} + +// signalReceiver wakes the receiver's stream_select(): the sender is gone +func (t *workerTask) signalReceiver() { + C.frankenphp_task_chan_signal(C.intptr_t(t.fds[0]), C.intptr_t(t.fds[1]), 1) +} + +// retire counts a side done with the task; the last one frees it +func (t *workerTask) retire() { + t.mu.Lock() + t.retired++ + last := t.retired == 2 + t.mu.Unlock() + + if last { + t.free() + } +} + +// free releases whatever the task still holds: called by the last side to +// close its stream, or by the sender when no thread picked the task up +func (t *workerTask) free() { + if t.payload != nil { + C.frankenphp_vars_free(t.payload) + } + for _, update := range t.updates { + C.frankenphp_vars_free(update) + } + taskChanPut(t.fds) + t.handle.Delete() +} + +//export go_frankenphp_send_task +func go_frankenphp_send_task(threadIndex C.uintptr_t, name *C.char, nameLen C.size_t, payload *C.HashTable) (C.uintptr_t, C.intptr_t, *C.char) { + thread := phpThreads[threadIndex] + workerName := C.GoStringN(name, C.int(nameLen)) + w := backgroundWorkerByName(thread.handler.frankenPHPContext(), workerName) + if w == nil { + C.frankenphp_vars_free(payload) + + return 0, -1, C.CString("FrankenPHP\\SentTaskHandle: unknown background worker " + strconv.Quote(workerName)) + } + if handler, ok := thread.handler.(*backgroundWorkerThread); ok && handler.worker == w && w.countThreads() == 1 { + C.frankenphp_vars_free(payload) + + return 0, -1, C.CString("FrankenPHP\\SentTaskHandle: background worker " + strconv.Quote(workerName) + " has a single thread and cannot send a task to itself") + } + fds, ok := taskChanGet() + if !ok { + C.frankenphp_vars_free(payload) + + return 0, -1, C.CString("FrankenPHP\\SentTaskHandle: failed to create the channel of the task") + } + + t := &workerTask{ + worker: w, + payload: payload, + pickedUp: make(chan struct{}), + cancelled: make(chan struct{}), + fds: fds, + // closed when this thread is drained for a restart or the shutdown: + // the target's threads are drained too, nobody would pick the task + // up. Read here, on the PHP thread: a goroutine may only get to run + // after Shutdown() replaced them + drainChan: thread.drainChan, + shutdown: mainThread.done, + } + t.cond = sync.NewCond(&t.mu) + t.handle = cgo.NewHandle(t) + + // queued like a request would be: a background worker has no other queue + metrics.QueuedWorkerRequest(w.name, w.server.name) + q := &w.tasks + q.mu.Lock() + q.pending = append(q.pending, t) + handler, sock := w.claimParkedThread() + q.mu.Unlock() + if handler != nil { + signalThreads([]*backgroundWorkerThread{handler}, []int64{sock}) + } + + // the C side waits for the pickup on the sender's descriptor, in the + // kernel rather than in a Go select: waking a thread parked inside a Go + // callback costs the scheduler a hand-off, a signal on a descriptor does + // not. The thread taking the task sends it; a pickup that takes longer + // than the first wait slice brings in go_frankenphp_task_linger + + return C.uintptr_t(t.handle), C.intptr_t(t.fds[0]), nil +} + +// go_frankenphp_task_linger is called by a sender whose first wait slice +// passed without a pickup, the uncommon case: the thread signaled first did +// not come, so every thread gets the line, and a watcher starts to end the +// wait if the sender's thread is drained or FrankenPHP shuts down. Neither +// costs the common case, a pickup within microseconds, a goroutine +// +//export go_frankenphp_task_linger +func go_frankenphp_task_linger(handle C.uintptr_t) { + t := cgo.Handle(handle).Value().(*workerTask) + + q := &t.worker.tasks + q.mu.Lock() + var handlers []*backgroundWorkerThread + var socks []int64 + pending := slices.Contains(q.pending, t) + if pending { + handlers, socks = t.worker.claimAllThreads() + } + q.mu.Unlock() + signalThreads(handlers, socks) + + if pending { + go t.watch() + } +} + +// watch ends the sender's wait when its thread is drained or FrankenPHP +// shuts down; it returns once the task is picked up or the sender gave up +func (t *workerTask) watch() { + select { + case <-t.pickedUp: + case <-t.cancelled: + case <-t.drainChan: + t.abort("FrankenPHP\\SentTaskHandle: the calling thread is restarting or shutting down") + case <-t.shutdown: + t.abort("FrankenPHP\\SentTaskHandle: FrankenPHP is shutting down") + } +} + +// abort ends the sender's wait for a pickup that must not happen anymore +func (t *workerTask) abort(reason string) { + q := &t.worker.tasks + q.mu.Lock() + if slices.Contains(q.pending, t) { + t.abortReason = reason + t.signalSender() + } + q.mu.Unlock() +} + +// go_frankenphp_task_side_gone tells a stream whether the other side closed +// its own: what feof() reports on the task streams +// +//export go_frankenphp_task_side_gone +func go_frankenphp_task_side_gone(handle C.uintptr_t, sender C.bool) C.bool { + t := cgo.Handle(handle).Value().(*workerTask) + + t.mu.Lock() + defer t.mu.Unlock() + if bool(sender) { + return C.bool(t.closed) + } + + return C.bool(t.senderGone) +} + +// go_frankenphp_task_await tells the sender, woken on its socket, where its +// task stands: 1 picked up, 2 aborted with the reason, 0 neither +// +//export go_frankenphp_task_await +func go_frankenphp_task_await(handle C.uintptr_t) (C.int, *C.char) { + t := cgo.Handle(handle).Value().(*workerTask) + + select { + case <-t.pickedUp: + return 1, nil + default: + } + + q := &t.worker.tasks + q.mu.Lock() + reason := t.abortReason + q.mu.Unlock() + if reason != "" { + return 2, C.CString(reason) + } + + return 0, nil +} + +// go_frankenphp_task_cancel takes a task nobody picked up out of the queue +// and releases the receiver's side of it, the sender's stream close releases +// the rest; false when a thread got the task first +// +//export go_frankenphp_task_cancel +func go_frankenphp_task_cancel(handle C.uintptr_t, timedOut C.bool) C.bool { + t := cgo.Handle(handle).Value().(*workerTask) + if !t.worker.tasks.remove(t) { + return false + } + close(t.cancelled) + + name, server := t.worker.name, t.worker.server.name + metrics.DequeuedWorkerRequest(name, server) + if bool(timedOut) { + metrics.WorkerTaskOutcome(name, server, TaskOutcomeTimeout) + } + + C.frankenphp_vars_free(t.payload) + t.payload = nil + t.mu.Lock() + // nothing for the sender's close to settle + t.closed = true + t.mu.Unlock() + t.retire() + + return true +} + +// go_frankenphp_background_worker_park is called by WorkerHandle::tick() +// as the script is about to wait on its handle: the thread parks unless +// tasks are queued, in which case a wake-up is written on its own handle so +// the wait returns at once and the script dequeues them. Under tasks.mu, so +// a task queued after the check finds the thread parked and signals it: no +// wake-up is lost either way. The flag stays set when the wait returns for +// another reason than a claim: a claim meanwhile writes a wake-up the +// script's next wait returns on. +// +//export go_frankenphp_background_worker_park +func go_frankenphp_background_worker_park(threadIndex C.uintptr_t) { + handler, ok := phpThreads[threadIndex].handler.(*backgroundWorkerThread) + if !ok { + return + } + + q := &handler.worker.tasks + q.mu.Lock() + defer q.mu.Unlock() + + if len(q.pending) == 0 { + handler.parked = true + + return + } + if handler.stopSock >= 0 { + C.frankenphp_worker_signal_task(C.intptr_t(handler.stopSock)) + } +} + +//export go_frankenphp_receive_task +func go_frankenphp_receive_task(threadIndex C.uintptr_t) (C.uintptr_t, *C.HashTable, C.intptr_t) { + handler, ok := phpThreads[threadIndex].handler.(*backgroundWorkerThread) + if !ok { + // refused on the C side already + return 0, nil, -1 + } + + q := &handler.worker.tasks + q.mu.Lock() + if len(q.pending) == 0 { + q.mu.Unlock() + + return 0, nil, -1 + } + t := q.pending[0] + q.pending = slices.Delete(q.pending, 0, 1) + // the payload moves to request memory on the C side + payload := t.payload + t.payload = nil + q.mu.Unlock() + metrics.DequeuedWorkerRequest(handler.worker.name, handler.worker.server.name) + close(t.pickedUp) + // wakes the sender's wait for the pickup, see go_frankenphp_send_task; + // after the channel, so the sender finds it closed once woken + t.signalSender() + + t.receiver = handler + t.pickedUpAt = time.Now() + metrics.StartWorkerTask(handler.worker.name, handler.worker.server.name) + // busy on the threads endpoint while it holds a task + if handler.openTasks++; handler.openTasks == 1 { + handler.state.MarkAsWaiting(false) + } + + return C.uintptr_t(t.handle), payload, C.intptr_t(t.fds[1]) +} + +//export go_frankenphp_update_task +func go_frankenphp_update_task(handle C.uintptr_t, update *C.HashTable) *C.char { + t := cgo.Handle(handle).Value().(*workerTask) + + t.mu.Lock() + for len(t.updates) >= taskUpdatesMax && !t.senderGone { + t.cond.Wait() + } + if t.senderGone { + t.mu.Unlock() + C.frankenphp_vars_free(update) + + return C.CString("FrankenPHP\\ReceivedTaskHandle::update(): the sender closed the task") + } + t.updates = append(t.updates, update) + signal := t.wakeSenderLocked() + t.mu.Unlock() + + if signal { + t.signalSender() + } + + return nil +} + +// go_frankenphp_read_task hands the sender its next event, and whether a +// signal is outstanding on its descriptor for it to consume; with nothing to +// hand, the sender parks and the next event signals it +// +//export go_frankenphp_read_task +func go_frankenphp_read_task(handle C.uintptr_t) (*C.HashTable, C.int, C.bool) { + t := cgo.Handle(handle).Value().(*workerTask) + + t.mu.Lock() + + consume := C.bool(t.senderSignaled) + if len(t.updates) > 0 { + update := t.updates[0] + t.updates = slices.Delete(t.updates, 0, 1) + t.cond.Signal() + t.senderParked = false + if t.senderWatched && (len(t.updates) > 0 || t.closed) { + signal := !t.senderSignaled + t.senderSignaled = true + t.mu.Unlock() + if signal { + t.signalSender() + } + + return update, C.int(C.FRANKENPHP_TASK_READ_UPDATE), false + } + t.senderSignaled = false + t.mu.Unlock() + + return update, C.int(C.FRANKENPHP_TASK_READ_UPDATE), consume + } + switch { + case t.aborted: + t.senderSignaled, t.senderParked = false, false + t.mu.Unlock() + + return nil, C.int(C.FRANKENPHP_TASK_READ_ABORTED), consume + case t.closed: + t.senderSignaled, t.senderParked = false, false + t.mu.Unlock() + + return nil, C.int(C.FRANKENPHP_TASK_READ_COMPLETED), consume + } + t.senderParked = true + t.mu.Unlock() + + return nil, C.int(C.FRANKENPHP_TASK_READ_PENDING), false +} + +// go_frankenphp_task_sender_wait is called when the sender casts its stream +// for a select: it parks, unless an event is already there, in which case +// a signal makes the select return at once +// +//export go_frankenphp_task_sender_wait +func go_frankenphp_task_sender_wait(handle C.uintptr_t) { + t := cgo.Handle(handle).Value().(*workerTask) + + t.mu.Lock() + signal := false + if len(t.updates) > 0 || t.closed { + if !t.senderSignaled { + t.senderSignaled = true + signal = true + } + } else { + t.senderParked = true + } + t.mu.Unlock() + + if signal { + t.signalSender() + } +} + +// go_frankenphp_task_sender_watch is called when the sender hands its +// descriptor to a poll context: from there on every event signals it, since +// nothing tells us when it waits, and an event already there signals at once +// +//export go_frankenphp_task_sender_watch +func go_frankenphp_task_sender_watch(handle C.uintptr_t) { + t := cgo.Handle(handle).Value().(*workerTask) + + t.mu.Lock() + t.senderWatched = true + signal := false + if (len(t.updates) > 0 || t.closed) && !t.senderSignaled { + t.senderSignaled = true + signal = true + } + t.mu.Unlock() + + if signal { + t.signalSender() + } +} + +//export go_frankenphp_task_receiver_close +func go_frankenphp_task_receiver_close(handle C.uintptr_t, aborted C.bool) { + t := cgo.Handle(handle).Value().(*workerTask) + + t.mu.Lock() + t.closed = true + t.aborted = bool(aborted) + // the first side to close settles the outcome; a gone sender is never + // parked, wakeSenderLocked knows + settled := !t.senderGone + signal := t.wakeSenderLocked() + t.cond.Broadcast() + t.mu.Unlock() + // the sender finds the end of the task behind the updates still queued + if signal { + t.signalSender() + } + + name, server := t.worker.name, t.worker.server.name + metrics.StopWorkerTask(name, server, time.Since(t.pickedUpAt)) + if settled { + outcome := TaskOutcomeCompleted + if aborted { + outcome = TaskOutcomeAborted + } + metrics.WorkerTaskOutcome(name, server, outcome) + } + handler := t.receiver + handler.openTasks-- + if handler.openTasks == 0 && !handler.isBootingScript { + handler.state.MarkAsWaiting(true) + } + + t.retire() +} + +//export go_frankenphp_task_sender_close +func go_frankenphp_task_sender_close(handle C.uintptr_t) { + t := cgo.Handle(handle).Value().(*workerTask) + + t.mu.Lock() + t.senderGone = true + t.senderParked = false + // the first side to close settles the outcome + settled := !t.closed + updates := t.updates + t.updates = nil + t.cond.Broadcast() + t.mu.Unlock() + + if settled { + // the receiver's stream_select() and feof() see it; once the + // receiver closed, nobody waits on its descriptor + t.signalReceiver() + metrics.WorkerTaskOutcome(t.worker.name, t.worker.server.name, TaskOutcomeAbandoned) + } + for _, update := range updates { + C.frankenphp_vars_free(update) + } + t.retire() +} diff --git a/workertask_test.go b/workertask_test.go new file mode 100644 index 0000000000..1dced91657 --- /dev/null +++ b/workertask_test.go @@ -0,0 +1,292 @@ +package frankenphp_test + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/dunglas/frankenphp" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTaskRoundTrip(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), bgWorker("echo", "task-worker.php", nil, server), frankenphp.WithNumThreads(2)) + + body := serverGet(t, server, "http://example.com/task.php?input=hello") + assert.Contains(t, body, `"result":"processed:hello"`) + assert.Contains(t, body, `"worker":"echo"`) + assert.True(t, strings.HasSuffix(body, "\ndone"), body) + + // the worker loops: a second task on the same thread + assert.Contains(t, serverGet(t, server, "http://example.com/task.php?input=again"), `"result":"processed:again"`) + + // progress updates come in order, before the result + lines := strings.Split(serverGet(t, server, "http://example.com/task.php?input=steps&steps=2"), "\n") + require.Len(t, lines, 4) + assert.Equal(t, `{"step":1,"of":2}`, lines[0]) + assert.Equal(t, `{"step":2,"of":2}`, lines[1]) + assert.Contains(t, lines[2], `"result":"processed:steps"`) + assert.Equal(t, "done", lines[3]) +} + +func TestTaskScopedToServer(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), + bgWorker("jobs", "task-worker.php", map[string]string{"BG_TAG": "one"}, server1), + bgWorker("jobs", "task-worker.php", map[string]string{"BG_TAG": "two"}, server2), + frankenphp.WithNumThreads(3), + ) + + assert.Contains(t, serverGet(t, server1, "http://example.com/task.php?name=jobs"), `"tag":"one"`) + assert.Contains(t, serverGet(t, server2, "http://example.com/task.php?name=jobs"), `"tag":"two"`) +} + +// a background worker may send tasks too, here while booting +func TestTaskFromBackgroundWorker(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "relay.json") + initServers(t, + bgWorker("relay", "task-relay.php", map[string]string{"BG_TARGET": "echo", "BG_SENTINEL": sentinel}, nil), + bgWorker("echo", "task-worker.php", nil, nil), + frankenphp.WithNumThreads(3), + ) + + assert.Contains(t, requireFileContentEventually(t, sentinel), `"result":"processed:relayed"`) +} + +// a sender waits for a thread to pick its task up, up to the timeout +func TestTaskPickupTimeout(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), bgWorker("echo", "task-worker.php", nil, server), frankenphp.WithNumThreads(2)) + + body := serverGet(t, server, "http://example.com/task-busy.php") + assert.Contains(t, body, `picked up the task in time`) + assert.Contains(t, body, `"result":"processed:slow"`) +} + +// a worker exiting with a task open fails the sender's read, then restarts +func TestTaskCrashMidTask(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), bgWorker("echo", "task-worker.php", nil, server), frankenphp.WithNumThreads(2)) + + assert.Contains(t, serverGet(t, server, "http://example.com/task.php?crash=1"), "exited without completing the task") + assert.Contains(t, serverGet(t, server, "http://example.com/task.php?input=after"), `"result":"processed:after"`) +} + +// closing the stream abandons the task: the worker sees it on its own stream +func TestTaskAbandoned(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "abandoned.txt") + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), bgWorker("echo", "task-worker.php", map[string]string{"BG_SENTINEL": sentinel}, server), frankenphp.WithNumThreads(2)) + + assert.Equal(t, "closed", serverGet(t, server, "http://example.com/task.php?sleep_ms=200&close_early=1")) + assert.Contains(t, requireFileContentEventually(t, sentinel), "the sender closed the task before the update") +} + +// a task queued while the only thread is busy reaches it when it reads its +// handle again, even with a loop taking one task per wake-up +func TestTaskQueuedWhileBusy(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), bgWorker("echo", "task-worker.php", map[string]string{"BG_LOOP": "if"}, server), frankenphp.WithNumThreads(3)) + + bodies := make(chan string, 2) + for _, input := range []string{"first", "second"} { + go func() { + w := httptest.NewRecorder() + _ = server.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "http://example.com/task.php?sleep_ms=100&input="+input, nil)) + b, _ := io.ReadAll(w.Result().Body) + bodies <- string(b) + }() + } + results := <-bodies + <-bodies + assert.Contains(t, results, `"result":"processed:first"`) + assert.Contains(t, results, `"result":"processed:second"`) +} + +// the threads of a pool share the queue, and stream_select() works on the +// sender's streams +func TestTaskPool(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, + frankenphp.WithServer(server), + frankenphp.WithWorkers("pool", "testdata/bgworker/task-worker.php", 2, frankenphp.WithWorkerBackground(), frankenphp.WithWorkerServerScope(server)), + frankenphp.WithNumThreads(3), + ) + + assert.Equal(t, "[\"processed:a\",\"processed:b\"]\ntwo threads", serverGet(t, server, "http://example.com/task-pool.php")) +} + +func TestTaskErrors(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), bgWorker("echo", "task-worker.php", nil, server), frankenphp.WithNumThreads(2)) + + body := serverGet(t, server, "http://example.com/task-errors.php") + assert.Contains(t, body, `unknown: RuntimeException: FrankenPHP\SentTaskHandle: unknown background worker "nope"`) + assert.Contains(t, body, `payload: ValueError: FrankenPHP\SentTaskHandle::__construct(): payload values must be null, scalars, arrays or enums`) + assert.Contains(t, body, `timeout: ValueError: FrankenPHP\SentTaskHandle::__construct(): Argument #3 ($timeout) must be greater than or equal to 0`) + assert.Contains(t, body, `received: Error: Call to private FrankenPHP\ReceivedTaskHandle::__construct()`) + assert.Contains(t, body, `worker: RuntimeException: FrankenPHP\WorkerHandle can only be created from a background worker`) +} + +// the metrics of a background worker follow its tasks: busy while a thread +// holds one, queued while nobody picked it up, counted by outcome +func TestTaskMetrics(t *testing.T) { + registry := prometheus.NewRegistry() + sentinel := filepath.Join(t.TempDir(), "abandoned.txt") + server, err := frankenphp.NewServer(testDataDir, frankenphp.WithServerName("api")) + require.NoError(t, err) + initServers(t, + frankenphp.WithServer(server), + bgWorker("echo", "task-worker.php", map[string]string{"BG_SENTINEL": sentinel}, server), + frankenphp.WithNumThreads(2), + frankenphp.WithMetrics(frankenphp.NewPrometheusMetrics(registry)), + ) + + assert.Contains(t, serverGet(t, server, "http://example.com/task.php?input=done"), `"result":"processed:done"`) + assert.Contains(t, serverGet(t, server, "http://example.com/task.php?crash=1"), "exited without completing the task") + assert.Equal(t, "closed", serverGet(t, server, "http://example.com/task.php?sleep_ms=200&close_early=1")) + requireFileContentEventually(t, sentinel) + assert.Contains(t, serverGet(t, server, "http://example.com/task-busy.php"), "picked up the task in time") + + expected := ` + # HELP frankenphp_worker_task_count Number of tasks sent to this background worker, by outcome: completed, aborted (the script ended with the task open), abandoned (the sender closed its stream first) or timeout (no thread picked the task up in time) + # TYPE frankenphp_worker_task_count counter + frankenphp_worker_task_count{outcome="abandoned",server="api",worker="echo"} 1 + frankenphp_worker_task_count{outcome="aborted",server="api",worker="echo"} 1 + frankenphp_worker_task_count{outcome="completed",server="api",worker="echo"} 2 + frankenphp_worker_task_count{outcome="timeout",server="api",worker="echo"} 1 + # HELP frankenphp_busy_workers Number of busy PHP workers for this worker: processing a request, or a task for a background worker + # TYPE frankenphp_busy_workers gauge + frankenphp_busy_workers{server="api",worker="echo"} 0 + # HELP frankenphp_worker_queue_depth Number of queued requests for this worker, or of tasks waiting for a thread of a background worker + # TYPE frankenphp_worker_queue_depth gauge + frankenphp_worker_queue_depth{server="api",worker="echo"} 0 + ` + // the abandoned task is closed by the worker after the response + require.EventuallyWithT(t, func(c *assert.CollectT) { + assert.NoError(c, testutil.GatherAndCompare(registry, strings.NewReader(expected), "frankenphp_worker_task_count", "frankenphp_busy_workers", "frankenphp_worker_queue_depth")) + }, 5*time.Second, 25*time.Millisecond) +} + +// a sender waiting for a busy worker to pick its task up is released by +// Shutdown() instead of holding it +func TestTaskSenderUnblockedOnShutdown(t *testing.T) { + mark := filepath.Join(t.TempDir(), "picked") + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + t.Cleanup(frankenphp.Shutdown) + require.NoError(t, frankenphp.Init(frankenphp.WithServer(server), bgWorker("echo", "task-worker.php", nil, server), frankenphp.WithNumThreads(2))) + + body := sendWhileWorkerBusy(t, server, mark) + 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") + } + assert.Contains(t, <-body, "FrankenPHP is shutting down") +} + +// a restart drains the sender's thread too: the wait for a pickup ends +// instead of stalling the restart until the timeout +func TestTaskSenderUnblockedOnRestart(t *testing.T) { + mark := filepath.Join(t.TempDir(), "picked") + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), bgWorker("echo", "task-worker.php", nil, server), frankenphp.WithNumThreads(2)) + + body := sendWhileWorkerBusy(t, server, mark) + start := time.Now() + frankenphp.RestartWorkers() + assert.WithinDuration(t, start, time.Now(), 10*time.Second, "the restart must not wait for the sender's timeout") + assert.Contains(t, <-body, "the calling thread is restarting or shutting down") + + // the restarted worker serves tasks again + assert.Contains(t, serverGet(t, server, "http://example.com/task.php?input=after"), `"result":"processed:after"`) +} + +// sendWhileWorkerBusy runs task-shutdown.php in the background: its first +// task keeps the only thread of the worker busy, its second one has no +// timeout; returns the channel carrying the response body once the first +// task was picked up +func sendWhileWorkerBusy(t *testing.T, server *frankenphp.Server, mark string) <-chan string { + t.Helper() + body := make(chan string, 1) + go func() { + w := httptest.NewRecorder() + _ = server.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "http://example.com/task-shutdown.php?mark="+url.QueryEscape(mark), nil)) + b, _ := io.ReadAll(w.Result().Body) + body <- string(b) + }() + requireFileEventually(t, mark, "the worker did not pick the first task up") + + return body +} + +// TestTaskMethodsAfterTheEnd pins what a handle does once the task is over, +// on both sides. +func TestTaskMethodsAfterTheEnd(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "over.txt") + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), + bgWorker("echo", "task-over-worker.php", map[string]string{"BG_SENTINEL": sentinel}, server), + frankenphp.WithNumThreads(2)) + + // the task being complete does not close the sender's handle, giving up + // on it does, and anything that needs the other side then throws + assert.Equal(t, `completed read: ok +completed getStream: resource (stream) +completed abandon: ok +abandoned read: RuntimeException: the task is over +abandoned getStream: resource (closed) +abandoned abandon: ok +`, serverGet(t, server, "http://example.com/task-over.php")) + + // the payload outlives the task, closing twice is free + assert.Equal(t, `getPayload: {"input":"over"} +update: RuntimeException: the task is over +complete: ok +complete-data: RuntimeException: the task is over +getStream: resource (closed)`, requireFileContentEventually(t, sentinel)) +} + +// TestTaskPoll follows two tasks through an Io\Poll\Context: the handles +// implement Io\Poll\Handle, so the script never touches a stream. +func TestTaskPoll(t *testing.T) { + if frankenphp.Version().VersionID < 80600 { + t.Skip("the poll API needs PHP 8.6") + } + + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, + frankenphp.WithServer(server), + frankenphp.WithWorkers("pool", "testdata/bgworker/task-worker.php", 2, frankenphp.WithWorkerBackground(), frankenphp.WithWorkerServerScope(server)), + frankenphp.WithNumThreads(3), + ) + + assert.Equal(t, `["processed:a","processed:b"]`, serverGet(t, server, "http://example.com/task-poll.php")) +} diff --git a/workervars.go b/workervars.go index af1d607232..dbf6372d56 100644 --- a/workervars.go +++ b/workervars.go @@ -24,9 +24,9 @@ var ( varsWaitOn = map[*worker]map[*worker]int{} ) -// varsWorker resolves a worker name the way requests do: within the caller's -// server first, then among global workers -func varsWorker(fc *frankenPHPContext, name string) (*worker, error) { +// backgroundWorkerByName resolves a background worker the way requests +// resolve workers: within the caller's server first, then among global ones +func backgroundWorkerByName(fc *frankenPHPContext, name string) *worker { var w *worker if fc != nil && fc.server != nil { w = fc.server.workersByName[name] @@ -35,10 +35,10 @@ func varsWorker(fc *frankenPHPContext, name string) (*worker, error) { w = fallbackServer.workersByName[name] } if w == nil || !w.isBackgroundWorker { - return nil, errors.New("frankenphp_get_vars(): unknown background worker " + strconv.Quote(name)) + return nil } - return w, nil + return w } // waitVarsReady blocks until target reached its ready point once. Requests @@ -131,9 +131,10 @@ func go_frankenphp_set_vars(threadIndex C.uintptr_t, table *C.HashTable) *C.Hash //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(thread.handler.frankenPHPContext(), C.GoStringN(name, C.int(nameLen))) - if err != nil { - return C.CString(err.Error()) + workerName := C.GoStringN(name, C.int(nameLen)) + target := backgroundWorkerByName(thread.handler.frankenPHPContext(), workerName) + if target == nil { + return C.CString("frankenphp_get_vars(): unknown background worker " + strconv.Quote(workerName)) } var caller *worker From 5eff671dfe85acea14cd001cca7b72a46e4b9bbf Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sat, 19 Sep 2026 21:59:42 +0200 Subject: [PATCH 41/46] perf: build the stream of a task only when a script asks for one The state of each side, the descriptor of the task's channel and what a wait on it costs, moves from the stream to the handle that owns it, and the task settles in the handle's cleanup rather than in the stream's close. getStream() then builds the stream on demand and keeps it, so a script waiting through an Io\Poll\Context allocates neither a stream nor a resource per task, while a stream_select() one gets what it always got. A stream the script took still ends the task when it closes, and past the end of a task it comes back closed; asking for a first one then throws, since there is nothing left to wait on. --- docs/worker.md | 2 +- frankenphp.c | 294 ++++++++++++++++++++++------------------- frankenphp.stub.php | 8 +- frankenphp_arginfo.h | 2 +- testdata/task-poll.php | 4 +- workertask_test.go | 7 +- 6 files changed, 174 insertions(+), 143 deletions(-) diff --git a/docs/worker.md b/docs/worker.md index f7d07e2add..ac25bab32d 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -317,7 +317,7 @@ while (null !== $update = $task->read()) { } ``` -On PHP 8.6 both handles are `Io\Poll\Handle`, so a context follows several tasks without a stream: +On PHP 8.6 both handles are `Io\Poll\Handle`, so a context follows several tasks without a stream, and a script that never calls `getStream()` has none built for it: ```php use Io\Poll\{Context, Event}; diff --git a/frankenphp.c b/frankenphp.c index 8fde847a9e..4bfc176fe4 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1682,19 +1682,66 @@ PHP_FUNCTION(frankenphp_get_vars) { /* Tasks, see SentTaskHandle: the sender hands a persistent copy of the * payload to the Go side, which queues it for the named background worker - * and wakes one of its threads; WorkerHandle::receive() dequeues it - * there. Updates flow back the same way, persistent copies through the Go - * side. Each side has a stream over its descriptor of the task's channel: - * the stream carries no data, it is what stream_select() waits on and what - * fclose() ends, and the Go side holds the state the functions report. The - * descriptors belong to the task until both sides closed. */ + * and wakes one of its threads; WorkerHandle::receive() dequeues it there. + * Updates flow back the same way, persistent copies through the Go side. + * Each side keeps the state of its end on its handle: the descriptor of + * the task's channel, what a wait on it costs, and whether it ended the + * task. Nothing travels on the descriptor, it is what a wait returns on + * and what the Go side signals; the Go side holds the state the methods + * report. The descriptors belong to the task until both sides closed. */ typedef struct { uintptr_t task; intptr_t fd; /* the side's descriptor, see frankenphp_task_chan_open */ int timeout_ms; /* stream_set_timeout(), -1 waits forever */ bool sender; bool timed_out; -} frankenphp_task_stream_data; + bool closed; /* this side ended the task */ + unsigned refs; /* the handle, plus the stream of getStream() */ + zend_resource *res; /* that stream, NULL until a script asks for one */ + zval payload; /* the receiver's side only */ +} frankenphp_task_data; + +static zend_class_entry *frankenphp_sent_task_ce; +static zend_class_entry *frankenphp_received_task_ce; + +/* Ends the task from this side, once: the sender abandons it, the receiver + * completes it, unless the script ended with the task open, where the + * sender is told so. The Go side learns it before signaling the other + * side, which then finds it. */ +static void frankenphp_task_settle(frankenphp_task_data *data) { + if (data->closed || data->task == 0) { + return; + } + data->closed = true; + + if (data->sender) { + go_frankenphp_task_sender_close(data->task); + } else { + go_frankenphp_task_receiver_close(data->task, + (EG(flags) & EG_FLAGS_IN_SHUTDOWN) != 0); + } +} + +/* The state outlives whichever of the handle and the stream goes first. */ +static void frankenphp_task_data_release(frankenphp_task_data *data) { + if (--data->refs > 0) { + return; + } + + zval_ptr_dtor(&data->payload); + efree(data); +} + +/* The stream a script took for this side, NULL when it took none or + * closed it. */ +static php_stream *frankenphp_task_data_stream(frankenphp_task_data *data) { + if (data->res == NULL || data->res->ptr == NULL || + data->res->type != php_file_le_stream()) { + return NULL; + } + + return data->res->ptr; +} static ssize_t frankenphp_task_stream_write(php_stream *stream, const char *buf, size_t count) { @@ -1705,12 +1752,12 @@ static ssize_t frankenphp_task_stream_write(php_stream *stream, const char *buf, return -1; } -/* the data goes through the frankenphp_*_task() functions */ +/* the data goes through the methods of the handles */ static ssize_t frankenphp_task_stream_read(php_stream *stream, char *buf, size_t count) { (void)buf; (void)count; - frankenphp_task_stream_data *data = stream->abstract; + frankenphp_task_data *data = stream->abstract; if (go_frankenphp_task_side_gone(data->task, data->sender)) { stream->eof = 1; } @@ -1718,20 +1765,14 @@ static ssize_t frankenphp_task_stream_read(php_stream *stream, char *buf, return -1; } -/* Closing the receiver's stream completes the task, unless the close is the - * resource cleanup of request shutdown, where the script ended with the task - * open and the sender is told so; closing the sender's abandons it. The Go - * side learns it before signaling the other side, which then finds it. */ +/* Closing the stream ends the task from this side, as complete() and + * abandon() do, and the state stays until the handle lets go of it too. */ static int frankenphp_task_stream_close(php_stream *stream, int close_handle) { (void)close_handle; - frankenphp_task_stream_data *data = stream->abstract; - if (data->sender) { - go_frankenphp_task_sender_close(data->task); - } else { - go_frankenphp_task_receiver_close(data->task, - (EG(flags) & EG_FLAGS_IN_SHUTDOWN) != 0); - } - efree(data); + frankenphp_task_data *data = stream->abstract; + + frankenphp_task_settle(data); + frankenphp_task_data_release(data); return 0; } @@ -1741,7 +1782,7 @@ static int frankenphp_task_stream_cast(php_stream *stream, int castas, if (castas != PHP_STREAM_AS_FD_FOR_SELECT) { return FAILURE; } - frankenphp_task_stream_data *data = stream->abstract; + frankenphp_task_data *data = stream->abstract; if (data->sender) { /* the sender parks for the select, unless an event is already there */ go_frankenphp_task_sender_wait(data->task); @@ -1756,7 +1797,7 @@ static int frankenphp_task_stream_cast(php_stream *stream, int castas, static int frankenphp_task_stream_set_option(php_stream *stream, int option, int value, void *ptrparam) { (void)value; - frankenphp_task_stream_data *data = stream->abstract; + frankenphp_task_data *data = stream->abstract; switch (option) { case PHP_STREAM_OPTION_READ_TIMEOUT: { struct timeval *tv = ptrparam; @@ -1792,24 +1833,10 @@ static const php_stream_ops frankenphp_task_sender_ops = static const php_stream_ops frankenphp_task_receiver_ops = FRANKENPHP_TASK_STREAM_OPS("FrankenPHP task receiver"); -static php_stream *frankenphp_task_stream_open(uintptr_t task, intptr_t fd, - bool sender) { - frankenphp_task_stream_data *data = ecalloc(1, sizeof(*data)); - data->task = task; - data->fd = fd; - data->timeout_ms = -1; - data->sender = sender; - - return php_stream_alloc(sender ? &frankenphp_task_sender_ops - : &frankenphp_task_receiver_ops, - data, NULL, "r"); -} - /* Waits for a signal on the side's descriptor without consuming it: 1 when * one is pending, 0 on timeout. Interrupted polls are retried, like PHP's * own stream code does. */ -static int frankenphp_task_stream_poll(frankenphp_task_stream_data *data, - int timeout_ms) { +static int frankenphp_task_poll(frankenphp_task_data *data, int timeout_ms) { for (;;) { int n = php_pollfd_for_ms((php_socket_t)data->fd, PHP_POLLREADABLE, timeout_ms); @@ -1825,66 +1852,45 @@ static int frankenphp_task_stream_poll(frankenphp_task_stream_data *data, * the other side has not written it yet: the state is set before the * signal, so the wait is momentary, and one signal per event keeps * stream_select() exact. */ -static void frankenphp_task_stream_consume(frankenphp_task_stream_data *data) { +static void frankenphp_task_consume(frankenphp_task_data *data) { while (!frankenphp_task_chan_consume(data->fd)) { - frankenphp_task_stream_poll(data, -1); + frankenphp_task_poll(data, -1); } } -/* Handles of a task: the state of one side, carried by the handle object - * of that side. The stream is what stream_select() waits on, so the handle - * holds its resource and dropping the handle ends the task, as closing the - * stream always did. On PHP 8.6 the handles are Io\Poll\Handle too, and a - * context waits on the task's descriptor without any stream. */ -typedef struct { - zend_resource *res; - zval payload; /* the receiver's side only */ -} frankenphp_task_data; - -static zend_class_entry *frankenphp_sent_task_ce; -static zend_class_entry *frankenphp_received_task_ce; - +/* Handles of a task: the state above belongs to the handle of its side. + * On PHP 8.6 they are Io\Poll\Handle, so a context waits on the descriptor + * and no stream is built at all; getStream() builds one on demand for + * stream_select() and for the loops that take a stream. */ static frankenphp_task_data *frankenphp_task_data_of(zend_object *object) { return FRANKENPHP_HANDLE_OF(object)->handle_data; } #define FRANKENPHP_TASK_DATA(zthis) frankenphp_task_data_of(Z_OBJ_P(zthis)) -/* The stream of a handle, NULL once the task ended: complete(), abandon() - * and fclose() all close it, and every method but getStream() throws from - * there on. */ -static php_stream *frankenphp_task_data_stream(frankenphp_task_data *data) { - if (data == NULL || data->res == NULL || data->res->ptr == NULL || - data->res->type != php_file_le_stream()) { - return NULL; - } - - return data->res->ptr; -} - -static php_stream *frankenphp_task_obj_stream(zval *zthis) { - return frankenphp_task_data_stream(FRANKENPHP_TASK_DATA(zthis)); -} - -static php_stream *frankenphp_task_obj_open_stream(zval *zthis) { - php_stream *stream = frankenphp_task_obj_stream(zthis); - if (stream == NULL) { +/* The state of a live task, NULL once this side ended it: complete(), + * abandon() and closing the stream all do, and every method but + * getStream() throws from there on. */ +static frankenphp_task_data *frankenphp_task_obj_open(zval *zthis) { + frankenphp_task_data *data = FRANKENPHP_TASK_DATA(zthis); + if (data == NULL || data->task == 0 || data->closed) { zend_throw_exception(spl_ce_RuntimeException, "the task is over", 0); + + return NULL; } - return stream; + return data; } /* The descriptor a task waits on, the same one its stream reports to - * stream_select(); invalid once the task ended, which is what the poll - * context and read() both report. */ + * stream_select(); invalid once this side ended the task, which is what + * the poll context and read() both report. */ static php_socket_t frankenphp_task_get_fd(frankenphp_handle_obj *handle) { - php_stream *stream = frankenphp_task_data_stream(handle->handle_data); - if (stream == NULL) { + frankenphp_task_data *data = handle->handle_data; + if (data == NULL || data->task == 0 || data->closed) { return SOCK_ERR; } - frankenphp_task_stream_data *data = stream->abstract; if (data->sender) { /* a context waits without a hook of its own, see the Go side */ go_frankenphp_task_sender_watch(data->task); @@ -1894,7 +1900,9 @@ static php_socket_t frankenphp_task_get_fd(frankenphp_handle_obj *handle) { } static int frankenphp_task_is_valid(frankenphp_handle_obj *handle) { - return frankenphp_task_data_stream(handle->handle_data) != NULL; + frankenphp_task_data *data = handle->handle_data; + + return data != NULL && data->task != 0 && !data->closed; } static void frankenphp_task_cleanup(frankenphp_handle_obj *handle) { @@ -1902,14 +1910,16 @@ static void frankenphp_task_cleanup(frankenphp_handle_obj *handle) { if (data == NULL) { return; } + handle->handle_data = NULL; + frankenphp_task_settle(data); if (data->res != NULL) { - /* the last reference closes the stream, which settles the task */ - zend_list_delete(data->res); + zend_resource *res = data->res; + data->res = NULL; + /* the last reference frees the stream, which releases the state too */ + zend_list_delete(res); } - zval_ptr_dtor(&data->payload); - efree(data); - handle->handle_data = NULL; + frankenphp_task_data_release(data); } static frankenphp_handle_ops frankenphp_task_poll_ops = { @@ -1923,6 +1933,8 @@ static zend_object *frankenphp_task_handle_new(zend_class_entry *ce) { frankenphp_handle_obj_create(ce, &frankenphp_task_poll_ops); frankenphp_task_data *data = ecalloc(1, sizeof(*data)); + data->timeout_ms = -1; + data->refs = 1; ZVAL_UNDEF(&data->payload); FRANKENPHP_HANDLE_OF(object)->handle_data = data; @@ -1937,19 +1949,36 @@ static zend_object *frankenphp_received_task_new(zend_class_entry *ce) { return frankenphp_task_handle_new(ce); } -/* Takes over the reference the stream's registration holds, so the handle - * owns the task from here on. */ -static void frankenphp_task_obj_take(zval *zthis, php_stream *stream) { - FRANKENPHP_TASK_DATA(zthis)->res = stream->res; +/* The handle takes the task the Go side just handed out. */ +static void frankenphp_task_obj_take(zval *zthis, uintptr_t task, intptr_t fd, + bool sender) { + frankenphp_task_data *data = FRANKENPHP_TASK_DATA(zthis); + + data->task = task; + data->fd = fd; + data->sender = sender; } -/* Hands the resource to the script, which may select on it and close it. - * Past the end of the task it is handed over closed, as the poll hooks - * report an invalid descriptor there: a waiter learns that the task is - * over from is_resource(), the way it would from Io\Poll. */ +/* Builds the stream of this side on demand, so a script waiting through a + * poll context allocates none, and hands the resource over: it may select + * on it and close it. Past the end of the task it comes back closed when + * one was built, as the poll hooks report an invalid descriptor there: a + * waiter learns that the task is over from is_resource(), the way it would + * from Io\Poll. */ static void frankenphp_task_obj_get_stream(zval *zthis, zval *return_value) { frankenphp_task_data *data = FRANKENPHP_TASK_DATA(zthis); + if (data != NULL && data->res == NULL && data->task != 0 && !data->closed) { + php_stream *stream = + php_stream_alloc(data->sender ? &frankenphp_task_sender_ops + : &frankenphp_task_receiver_ops, + data, NULL, "r"); + + /* the handle keeps the reference the registration holds */ + data->res = stream->res; + ++data->refs; + } + if (data == NULL || data->res == NULL) { zend_throw_exception(spl_ce_RuntimeException, "the task is over", 0); RETURN_THROWS(); @@ -1959,11 +1988,17 @@ static void frankenphp_task_obj_get_stream(zval *zthis, zval *return_value) { RETURN_RES(data->res); } -/* Ends the task from this side: the stream's close reports it, see - * frankenphp_task_stream_close(). */ +/* Ends the task from this side, and closes the stream when the script took + * one, so its stream_select() and feof() report the end. */ static void frankenphp_task_obj_close(zval *zthis) { - if (frankenphp_task_obj_stream(zthis) != NULL) { - zend_list_close(FRANKENPHP_TASK_DATA(zthis)->res); + frankenphp_task_data *data = FRANKENPHP_TASK_DATA(zthis); + if (data == NULL || data->closed) { + return; + } + + frankenphp_task_settle(data); + if (data->res != NULL && data->res->type == php_file_le_stream()) { + zend_list_close(data->res); } } @@ -2009,21 +2044,15 @@ ZEND_METHOD(FrankenPHP_SentTaskHandle, __construct) { RETURN_THROWS(); } - /* the task is queued from here on: a bailout (memory limit) must not - * leave the sender's side open, the receiver would wait on it forever */ - php_stream *stream = NULL; - zend_try { stream = frankenphp_task_stream_open(task.r0, task.r1, true); } - zend_catch { - go_frankenphp_task_cancel(task.r0, false); - go_frankenphp_task_sender_close(task.r0); - zend_bailout(); - } - zend_end_try(); + /* the task is the handle's from here on: a bailout below, or a throw, + * settles it through the handle rather than leaving the receiver to wait + * on it forever */ + frankenphp_task_obj_take(ZEND_THIS, task.r0, task.r1, true); /* wait for the pickup in the kernel: the thread taking the task signals * the sender's side, so does the Go side when the wait must end without a * pickup, see go_frankenphp_send_task */ - frankenphp_task_stream_data *data = stream->abstract; + frankenphp_task_data *data = FRANKENPHP_TASK_DATA(ZEND_THIS); /* the first slice of the wait is short: past it, the Go side escalates the * wake-up and starts watching for a drain or the shutdown, neither of * which the common case, a pickup within microseconds, needs */ @@ -2035,7 +2064,7 @@ ZEND_METHOD(FrankenPHP_SentTaskHandle, __construct) { (remaining < 0 || remaining > FRANKENPHP_TASK_LINGER_MS)) { slice = FRANKENPHP_TASK_LINGER_MS; } - if (!frankenphp_task_stream_poll(data, slice)) { + if (!frankenphp_task_poll(data, slice)) { if (remaining > 0) { remaining -= slice; } @@ -2046,7 +2075,7 @@ ZEND_METHOD(FrankenPHP_SentTaskHandle, __construct) { } /* nobody took the task in time, unless right now */ if (go_frankenphp_task_cancel(task.r0, true)) { - php_stream_close(stream); + frankenphp_task_settle(data); zend_throw_exception_ex(spl_ce_RuntimeException, 0, "FrankenPHP\\SentTaskHandle: no thread of " "background worker \"%s\" picked up the " @@ -2054,7 +2083,7 @@ ZEND_METHOD(FrankenPHP_SentTaskHandle, __construct) { ZSTR_VAL(name)); RETURN_THROWS(); } - frankenphp_task_stream_consume(data); + frankenphp_task_consume(data); break; } @@ -2066,28 +2095,25 @@ ZEND_METHOD(FrankenPHP_SentTaskHandle, __construct) { frankenphp_task_chan_consume(data->fd); continue; } - frankenphp_task_stream_consume(data); + frankenphp_task_consume(data); if (state.r0 == 1) { break; } go_frankenphp_task_cancel(task.r0, false); - php_stream_close(stream); + frankenphp_task_settle(data); zend_throw_exception(spl_ce_RuntimeException, state.r1, 0); free(state.r1); RETURN_THROWS(); } - - frankenphp_task_obj_take(ZEND_THIS, stream); } ZEND_METHOD(FrankenPHP_SentTaskHandle, read) { ZEND_PARSE_PARAMETERS_NONE(); - php_stream *stream = frankenphp_task_obj_open_stream(ZEND_THIS); - if (stream == NULL) { + frankenphp_task_data *data = frankenphp_task_obj_open(ZEND_THIS); + if (data == NULL) { RETURN_THROWS(); } - frankenphp_task_stream_data *data = stream->abstract; for (;;) { struct go_frankenphp_read_task_return update = @@ -2095,7 +2121,7 @@ ZEND_METHOD(FrankenPHP_SentTaskHandle, read) { switch (update.r1) { case FRANKENPHP_TASK_READ_UPDATE: if (update.r2) { - frankenphp_task_stream_consume(data); + frankenphp_task_consume(data); } zend_try { frankenphp_vars_to_request(return_value, update.r0); } zend_catch { @@ -2108,9 +2134,14 @@ ZEND_METHOD(FrankenPHP_SentTaskHandle, read) { case FRANKENPHP_TASK_READ_COMPLETED: case FRANKENPHP_TASK_READ_ABORTED: if (update.r2) { - frankenphp_task_stream_consume(data); + frankenphp_task_consume(data); + } + php_stream *stream = frankenphp_task_data_stream(data); + if (stream != NULL) { + /* a script waiting on it with stream_select() or feof() sees the + * end of the task there too */ + stream->eof = 1; } - stream->eof = 1; if (update.r1 == FRANKENPHP_TASK_READ_COMPLETED) { RETURN_NULL(); } @@ -2122,7 +2153,7 @@ ZEND_METHOD(FrankenPHP_SentTaskHandle, read) { default: /* nothing yet: wait for the next signal, without consuming it, the * event it announces does */ - if (!frankenphp_task_stream_poll(data, data->timeout_ms)) { + if (!frankenphp_task_poll(data, data->timeout_ms)) { data->timed_out = true; zend_throw_exception(spl_ce_RuntimeException, "FrankenPHP\\SentTaskHandle::read(): timed out " @@ -2168,8 +2199,8 @@ ZEND_METHOD(FrankenPHP_ReceivedTaskHandle, getPayload) { /* Queues an update for the sender, which reads it with read(). */ static void frankenphp_task_obj_update(zval *zthis, zval *data) { - php_stream *stream = frankenphp_task_obj_open_stream(zthis); - if (stream == NULL) { + frankenphp_task_data *task = frankenphp_task_obj_open(zthis); + if (task == NULL) { return; } if (!persistent_zval_validate(data)) { @@ -2184,9 +2215,7 @@ static void frankenphp_task_obj_update(zval *zthis, zval *data) { persistent_zval_persist(&persistent, data); /* the Go side owns the update from here on, it frees it on failure */ - char *error = go_frankenphp_update_task( - ((frankenphp_task_stream_data *)stream->abstract)->task, - Z_ARRVAL(persistent)); + char *error = go_frankenphp_update_task(task->task, Z_ARRVAL(persistent)); if (error != NULL) { zend_throw_exception(spl_ce_RuntimeException, error, 0); free(error); @@ -2235,14 +2264,13 @@ ZEND_METHOD(FrankenPHP_WorkerHandle, receive) { } /* the task is this thread's from here on: a bailout while copying the - * payload (memory limit, a fatal error in an autoloader) or creating the - * stream must not leave it open, the sender would wait on it forever */ + * payload (memory limit, a fatal error in an autoloader) or while making + * the handle must not leave it open, the sender would wait on it forever */ zval payload; - php_stream *stream = NULL; zend_try { frankenphp_vars_to_request(&payload, task.r1); if (!EG(exception)) { - stream = frankenphp_task_stream_open(task.r0, task.r2, false); + object_init_ex(return_value, frankenphp_received_task_ce); } } zend_catch { @@ -2261,10 +2289,8 @@ ZEND_METHOD(FrankenPHP_WorkerHandle, receive) { RETURN_THROWS(); } - object_init_ex(return_value, frankenphp_received_task_ce); - frankenphp_task_data *data = FRANKENPHP_TASK_DATA(return_value); - data->res = stream->res; - ZVAL_COPY_VALUE(&data->payload, &payload); + frankenphp_task_obj_take(return_value, task.r0, task.r2, false); + ZVAL_COPY_VALUE(&FRANKENPHP_TASK_DATA(return_value)->payload, &payload); } /* {{{ thread-safe opcache reset */ diff --git a/frankenphp.stub.php b/frankenphp.stub.php index 8aa4fbc174..0efd6a52ae 100644 --- a/frankenphp.stub.php +++ b/frankenphp.stub.php @@ -152,7 +152,8 @@ public function read(): ?array {} * readable when an update is there and reaches EOF when the task * ends. Only waiting on it is supported, read() consumes what it * carries and reading it steals that signal. Closing it abandons - * the task, and past its end the stream comes back closed. + * the task. Past its end a stream the script already took comes + * back closed, and asking for a first one throws. * * @return resource */ @@ -198,8 +199,9 @@ public function complete(?array $data = null): void {} /** * The stream to wait on: it reaches EOF when the sender abandons the * task, for stream_select() and feof(). Only waiting on it is - * supported. Closing it completes the task, and past its end the - * stream comes back closed. + * supported. Closing it completes the task. Past its end a stream + * the script already took comes back closed, and asking for a + * first one throws. * * @return resource */ diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index eec3500305..bc0b747f66 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: 93fefcc42a0d1fae50072ac5ca2a8155cbf5b576 */ + * Stub hash: 7df0c4cdf6927ed7603fd81801dd047b14c19b9d */ 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) diff --git a/testdata/task-poll.php b/testdata/task-poll.php index be8a1fbfa6..7b21e51325 100644 --- a/testdata/task-poll.php +++ b/testdata/task-poll.php @@ -13,6 +13,7 @@ $poll->add($task, [Event::Read], spl_object_id($task)); } $results = []; + $resources = count(get_resources()); while ($tasks) { foreach ($poll->wait() as $watcher) { if (null === $update = $tasks[$watcher->getData()]->read()) { @@ -24,7 +25,8 @@ } } sort($results); - echo json_encode($results); + // waiting through the context builds no stream and no resource + echo json_encode($results), ' ', count(get_resources()) - $resources; } catch (\Throwable $e) { echo get_class($e), ': ', $e->getMessage(); } diff --git a/workertask_test.go b/workertask_test.go index 1dced91657..b3913d48f2 100644 --- a/workertask_test.go +++ b/workertask_test.go @@ -265,12 +265,13 @@ abandoned getStream: resource (closed) abandoned abandon: ok `, serverGet(t, server, "http://example.com/task-over.php")) - // the payload outlives the task, closing twice is free + // the payload outlives the task, closing twice is free, and a stream + // the script never took is not built past the end of the task assert.Equal(t, `getPayload: {"input":"over"} update: RuntimeException: the task is over complete: ok complete-data: RuntimeException: the task is over -getStream: resource (closed)`, requireFileContentEventually(t, sentinel)) +getStream: RuntimeException: the task is over`, requireFileContentEventually(t, sentinel)) } // TestTaskPoll follows two tasks through an Io\Poll\Context: the handles @@ -288,5 +289,5 @@ func TestTaskPoll(t *testing.T) { frankenphp.WithNumThreads(3), ) - assert.Equal(t, `["processed:a","processed:b"]`, serverGet(t, server, "http://example.com/task-poll.php")) + assert.Equal(t, `["processed:a","processed:b"] 0`, serverGet(t, server, "http://example.com/task-poll.php")) } From becaa361b0d157c1591adec49f14361bdb8970d3 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sat, 19 Sep 2026 22:27:09 +0200 Subject: [PATCH 42/46] perf: wake a task through a kqueue user event on macOS A socket pair costs 16.1us per wake-up round trip on a macos-latest runner, against 11.7us for a kqueue descriptor carrying an EVFILT_USER event, the same quarter the eventfd takes off the pair on Linux (23.1 -> 17.5us there). Each side of a task gets one, signaled by the other with NOTE_TRIGGER and consumed by a kevent() that does not wait. A user event coalesces where an EFD_SEMAPHORE eventfd counts, which is exact here because a side never has more than one signal outstanding: the sender's is dropped when it consumes it, and the receiver's single event is the sender giving up. The descriptors are pooled as the eventfds are, and Windows keeps the socket pair its php_select() needs. --- frankenphp.c | 55 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 4bfc176fe4..522673a93c 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -611,15 +611,23 @@ void frankenphp_worker_signal_task(intptr_t s) { /* Task channels: one descriptor per side of a task, the sender's [0] and * the receiver's [1], each waited on by its stream and signaled by the other - * side through the Go side. On Linux they are eventfds: a counter, no - * buffer, nothing to close between two tasks, so the Go side pools them. - * Elsewhere a socket pair, for Windows's php_select(); a signal to one end - * is a byte written to the other. Both descriptors are non-blocking: waits - * go through poll(), consuming a signal never blocks. Signals and events - * match one to one, EFD_SEMAPHORE makes a read consume a single one. */ + * side through the Go side. On Linux they are eventfds and on macOS kqueue + * descriptors carrying a user event: a side signals the other without a + * pair, there is nothing to close between two tasks, so the Go side pools + * them. Elsewhere a socket pair, for Windows's php_select(); a signal to + * one end is a byte written to the other. Waits go through poll() on the + * descriptor, consuming a signal never blocks. Signals and events match + * one to one: EFD_SEMAPHORE makes a read consume a single one, and a side + * never has more than one signal outstanding, which is what lets the + * coalescing user event of kqueue stand in for a counter. */ #ifdef __linux__ #include #define FRANKENPHP_TASK_CHAN_EVENTFD 1 +#elif defined(__APPLE__) +#include +#define FRANKENPHP_TASK_CHAN_KQUEUE 1 +/* the one event of a channel's queue */ +#define FRANKENPHP_TASK_CHAN_IDENT 1 #endif int frankenphp_task_chan_open(intptr_t fds[2]) { @@ -636,6 +644,31 @@ int frankenphp_task_chan_open(intptr_t fds[2]) { } fds[0] = a; fds[1] = b; +#elif defined(FRANKENPHP_TASK_CHAN_KQUEUE) + int a = kqueue(); + if (a < 0) { + return -1; + } + int b = kqueue(); + if (b < 0) { + close(a); + + return -1; + } + + /* EV_CLEAR: a trigger is reported once, so a consume takes one signal */ + struct kevent ev; + EV_SET(&ev, FRANKENPHP_TASK_CHAN_IDENT, EVFILT_USER, EV_ADD | EV_CLEAR, 0, 0, + NULL); + if (kevent(a, &ev, 1, NULL, 0, NULL) != 0 || + kevent(b, &ev, 1, NULL, 0, NULL) != 0) { + close(a); + close(b); + + return -1; + } + fds[0] = a; + fds[1] = b; #else php_socket_t pair[2]; if (frankenphp_sock_pair_open(pair) != 0) { @@ -654,6 +687,11 @@ void frankenphp_task_chan_signal(intptr_t fd0, intptr_t fd1, int side) { #ifdef FRANKENPHP_TASK_CHAN_EVENTFD uint64_t one = 1; (void)!write((int)(side ? fd1 : fd0), &one, sizeof(one)); +#elif defined(FRANKENPHP_TASK_CHAN_KQUEUE) + struct kevent ev; + EV_SET(&ev, FRANKENPHP_TASK_CHAN_IDENT, EVFILT_USER, 0, NOTE_TRIGGER, 0, + NULL); + kevent((int)(side ? fd1 : fd0), &ev, 1, NULL, 0, NULL); #else /* a byte on one end lands on the other */ frankenphp_sock_send((php_socket_t)(side ? fd0 : fd1), "1", 1); @@ -666,6 +704,11 @@ bool frankenphp_task_chan_consume(intptr_t fd) { uint64_t v; return read((int)fd, &v, sizeof(v)) == (ssize_t)sizeof(v); +#elif defined(FRANKENPHP_TASK_CHAN_KQUEUE) + struct kevent ev; + struct timespec immediately = {0, 0}; + + return kevent((int)fd, NULL, 0, &ev, 1, &immediately) == 1; #else char b; From a56caadab436283f05f5a74171f2dad1882a2ec9 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sun, 20 Sep 2026 08:18:50 +0200 Subject: [PATCH 43/46] docs: wait on tasks through a context too, the stream second The worker loop of the task section no longer repeats a stream_select(), it continues the loop of the section above, and the paragraph names the context first and getStream() as what a library that takes a stream uses. The channel note gains macOS. --- docs/worker.md | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/docs/worker.md b/docs/worker.md index ac25bab32d..731826a33b 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -290,19 +290,15 @@ $vars = frankenphp_get_vars('config'); ### Sending tasks to background workers -A request, an HTTP worker or another background worker hands work to a background worker by constructing a `FrankenPHP\SentTaskHandle`, naming it the way `frankenphp_get_vars()` does. The payload follows the same rules as `setVars()`: null, scalars, arrays or enums. Constructing blocks until a thread of the worker picks the task up and throws if none did before the timeout, so a busy worker pushes back on its senders instead of queueing without bounds. `read()` then blocks for the next update and returns `null` once the worker completed the task, `getStream()` gives the stream `stream_select()` waits on, to follow several tasks at once or to bound the wait, and `abandon()`, like dropping the handle, gives up on the task. The streams of a task are for waiting too: reading one steals the signal `read()` needs, writing to one goes nowhere, and closing one ends the task on that side. +A request, an HTTP worker or another background worker hands work to a background worker by constructing a `FrankenPHP\SentTaskHandle`, naming it the way `frankenphp_get_vars()` does. The payload follows the same rules as `setVars()`: null, scalars, arrays or enums. Constructing blocks until a thread of the worker picks the task up and throws if none did before the timeout, so a busy worker pushes back on its senders instead of queueing without bounds. `read()` then blocks for the next update and returns `null` once the worker completed the task, and `abandon()`, like dropping the handle, gives up on the task. Waiting on a task, alone or with others, goes through a context as it does for the worker's handle, or through `getStream()` for a library that takes a stream: reading that stream steals the signal `read()` needs, writing to it goes nowhere, and closing it ends the task on that side. -On the worker side, each task sent wakes one parked thread of the worker through its handle, in the loop of the previous section: `tick()` consumes the wake-up, which is not a count and in a pool may belong to a task a sibling thread took. `receive()` dequeues a task without blocking, a `FrankenPHP\ReceivedTaskHandle`, or `null` when another thread of the pool got there first, so the example below drains the queue on each wake-up and treats `null` as the normal outcome. A thread that ticks while tasks are queued finds its handle readable at once, whichever loop shape it uses. `update()` sends progress back, `complete()` ends the task with a last update when one is given, and a script that ends with a task still open, a `complete()` from a destructor or a shutdown function at that point included, makes the sender's next `read()` throw. When the sender gives up instead, the stream of the worker's handle reaches EOF, so `stream_select()` or `feof()` on it tell a long task that nobody waits for its result, and `update()` throws. +On the worker side, each task sent wakes one parked thread of the worker through its handle, in the loop of the previous section: `tick()` consumes the wake-up, which is not a count and in a pool may belong to a task a sibling thread took. `receive()` dequeues a task without blocking, a `FrankenPHP\ReceivedTaskHandle`, or `null` when another thread of the pool got there first, so the example below drains the queue on each wake-up and treats `null` as the normal outcome. A thread that ticks while tasks are queued finds its handle readable at once, whichever loop shape it uses. `update()` sends progress back, `complete()` ends the task with a last update when one is given, and a script that ends with a task still open, a `complete()` from a destructor or a shutdown function at that point included, makes the sender's next `read()` throw. When the sender gives up instead, the task's side of the worker becomes readable and its stream reaches EOF, so a context or a `feof()` tells a long task that nobody waits for its result, and `update()` throws. ```php -// background worker -$handle = new FrankenPHP\WorkerHandle(); -$stream = $handle->getStream(); - +// background worker, in the loop of the previous section while ($handle->tick()) { - $read = [$stream]; - $write = $except = null; - stream_select($read, $write, $except, null); + foreach ($poll->wait() as $watcher) { + } while ($task = $handle->receive()) { $task->update(['progress' => 50]); @@ -317,7 +313,7 @@ while (null !== $update = $task->read()) { } ``` -On PHP 8.6 both handles are `Io\Poll\Handle`, so a context follows several tasks without a stream, and a script that never calls `getStream()` has none built for it: +Both task handles are `Io\Poll\Handle` as well, so a context follows several tasks at once, and a script that never asks for a stream has none built for it: ```php use Io\Poll\{Context, Event}; @@ -331,7 +327,9 @@ foreach ($poll->wait() as $watcher) { } ``` -Sixteen updates are buffered per task; past that, `update()` waits for the sender to read, and it throws once the sender gave up. The streams of a task are backed by eventfd descriptors on Linux, pooled between tasks, and by a socket pair elsewhere. +`getStream()` is there for the libraries that take a stream, and `stream_select()` or a blocking read work on it as they do on the worker's handle. + +Sixteen updates are buffered per task; past that, `update()` waits for the sender to read, and it throws once the sender gave up. The channel of a task is a pair of eventfd descriptors on Linux and of kqueue descriptors on macOS, pooled between tasks, and a socket pair elsewhere. ## Superglobals behavior From b80ce89fc6f541b1d71a65b48d457ce1faf11479 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sun, 20 Sep 2026 08:32:09 +0200 Subject: [PATCH 44/46] test: let the busy-worker fixture see its task complete It read one update from the slow task and ended, so the worker's complete(), which sends that update and closes the task in one go, raced the end of the request: the sender's handle abandoned a task the worker was about to complete, and the metrics test then saw an abandoned task where it expected a completed one, about once in four runs. Reading to the end settles the task before the script returns. --- testdata/task-busy.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/testdata/task-busy.php b/testdata/task-busy.php index 9c2b588644..e5ebe713b6 100644 --- a/testdata/task-busy.php +++ b/testdata/task-busy.php @@ -10,7 +10,13 @@ } catch (\RuntimeException $e) { echo $e->getMessage(), "\n"; } - echo json_encode($slow->read()); + // read to the end: leaving with the task open would abandon it, and + // the worker completes it right after the update below + $update = null; + while (null !== $next = $slow->read()) { + $update = $next; + } + echo json_encode($update); } catch (\Throwable $e) { echo get_class($e), ': ', $e->getMessage(); } From 6b0c639107091270572f085d09d6bb0d08ce25de Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 21 Sep 2026 11:09:36 +0200 Subject: [PATCH 45/46] fix: receive() checks the thread like the rest of the handle Same reason as setVars(): the constructor is not a gate, and dequeuing a task on a thread that is not a background worker has nothing to dequeue from. --- frankenphp.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frankenphp.c b/frankenphp.c index 522673a93c..13102605da 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -2300,6 +2300,10 @@ ZEND_METHOD(FrankenPHP_ReceivedTaskHandle, getStream) { ZEND_METHOD(FrankenPHP_WorkerHandle, receive) { ZEND_PARSE_PARAMETERS_NONE(); + if (!frankenphp_worker_handle_usable()) { + RETURN_THROWS(); + } + struct go_frankenphp_receive_task_return task = go_frankenphp_receive_task(frankenphp_thread_index()); if (task.r0 == 0) { From 89250052122f737e49cb888030b1fbbab707d2ad Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 21 Sep 2026 17:42:23 +0200 Subject: [PATCH 46/46] chore: fewer comments, the ones left say why --- frankenphp.c | 95 ++++++++++++++++++++-------------------------- workertask.go | 95 +++++++++++++++++++--------------------------- workertask_test.go | 7 ++-- 3 files changed, 84 insertions(+), 113 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 13102605da..614439be7e 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -365,12 +365,10 @@ void frankenphp_release_thread_for_kill(force_kill_slot slot) { } /* Socket pairs of background workers: the handle of a thread, see - * WorkerHandle::getStream(), and one per task, see - * SentTaskHandle. One end is exposed to a PHP script as a stream, - * the other is held by the Go side, which writes wake-ups to it and closes - * it to land EOF on the script's end, so a stream_select() or a blocking - * read there returns. A socket pair rather than a pipe because on Windows - * PHP's php_select() only really waits on sockets: before 8.5 it reports + * WorkerHandle::getStream(). One end is exposed to a PHP script as a stream, + * the other is held by the Go side, which writes wake-ups to it and closes it + * to land EOF on the script's end. 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_sock_close(php_socket_t s) { if (s == SOCK_ERR) { @@ -596,11 +594,9 @@ void frankenphp_worker_close_stop_sock(intptr_t s) { frankenphp_sock_close((php_socket_t)s); } -/* Wakes a background worker thread with a line on its handle, one per task - * sent to the worker, see SentTaskHandle. The line is a wake-up - * rather than a description: it says something may be pending, the script - * finds out what by polling. Its content is therefore not part of the - * contract, see WorkerHandle::receive(). */ +/* Wakes a background worker thread with a line on its handle, one per task sent + * to the worker. The line says something may be pending, the script finds out + * what by polling, so its content is no part of the contract. */ void frankenphp_worker_signal_task(intptr_t s) { frankenphp_sock_send((php_socket_t)s, "\n", 1); } @@ -609,17 +605,15 @@ void frankenphp_worker_signal_task(intptr_t s) { * side gets involved, see go_frankenphp_task_linger. */ #define FRANKENPHP_TASK_LINGER_MS 10 -/* Task channels: one descriptor per side of a task, the sender's [0] and - * the receiver's [1], each waited on by its stream and signaled by the other - * side through the Go side. On Linux they are eventfds and on macOS kqueue - * descriptors carrying a user event: a side signals the other without a - * pair, there is nothing to close between two tasks, so the Go side pools - * them. Elsewhere a socket pair, for Windows's php_select(); a signal to - * one end is a byte written to the other. Waits go through poll() on the - * descriptor, consuming a signal never blocks. Signals and events match - * one to one: EFD_SEMAPHORE makes a read consume a single one, and a side - * never has more than one signal outstanding, which is what lets the - * coalescing user event of kqueue stand in for a counter. */ +/* Task channels: one descriptor per side of a task, the sender's [0] and the + * receiver's [1], each waited on by its stream and signaled by the other side + * through the Go side. On Linux they are eventfds and on macOS kqueue + * descriptors carrying a user event: nothing to close between two tasks, so + * the Go side pools them. Elsewhere a socket pair, for Windows's php_select(). + * Waits go through poll(), consuming a signal never blocks, and signals match + * events one to one: EFD_SEMAPHORE makes a read consume a single one, and a + * side never has more than one outstanding, which is what lets the coalescing + * user event of kqueue stand in for a counter. */ #ifdef __linux__ #include #define FRANKENPHP_TASK_CHAN_EVENTFD 1 @@ -1723,15 +1717,12 @@ PHP_FUNCTION(frankenphp_get_vars) { } } -/* Tasks, see SentTaskHandle: the sender hands a persistent copy of the - * payload to the Go side, which queues it for the named background worker - * and wakes one of its threads; WorkerHandle::receive() dequeues it there. - * Updates flow back the same way, persistent copies through the Go side. - * Each side keeps the state of its end on its handle: the descriptor of - * the task's channel, what a wait on it costs, and whether it ended the - * task. Nothing travels on the descriptor, it is what a wait returns on - * and what the Go side signals; the Go side holds the state the methods - * report. The descriptors belong to the task until both sides closed. */ +/* Tasks, see SentTaskHandle: the sender hands a persistent copy of the payload + * to the Go side, which queues it for the named background worker and wakes one + * of its threads; WorkerHandle::receive() dequeues it there. Updates flow back + * the same way. Each side keeps the state of its end on its handle, nothing + * travels on the descriptor: it is what a wait returns on and what the Go side + * signals, which holds the state the methods report. */ typedef struct { uintptr_t task; intptr_t fd; /* the side's descriptor, see frankenphp_task_chan_open */ @@ -1891,20 +1882,18 @@ static int frankenphp_task_poll(frankenphp_task_data *data, int timeout_ms) { } } -/* Consumes the signal of an event the Go side reported, waiting for it if - * the other side has not written it yet: the state is set before the - * signal, so the wait is momentary, and one signal per event keeps - * stream_select() exact. */ +/* Consumes the signal of an event the Go side reported, waiting for it if the + * other side has not written it yet: the state is set before the signal, so the + * wait is momentary. */ static void frankenphp_task_consume(frankenphp_task_data *data) { while (!frankenphp_task_chan_consume(data->fd)) { frankenphp_task_poll(data, -1); } } -/* Handles of a task: the state above belongs to the handle of its side. - * On PHP 8.6 they are Io\Poll\Handle, so a context waits on the descriptor - * and no stream is built at all; getStream() builds one on demand for - * stream_select() and for the loops that take a stream. */ +/* Handles of a task: the state above belongs to the handle of its side. On PHP + * 8.6 they are Io\Poll\Handle, so a context waits on the descriptor and no + * stream is built at all. */ static frankenphp_task_data *frankenphp_task_data_of(zend_object *object) { return FRANKENPHP_HANDLE_OF(object)->handle_data; } @@ -2002,12 +1991,11 @@ static void frankenphp_task_obj_take(zval *zthis, uintptr_t task, intptr_t fd, data->sender = sender; } -/* Builds the stream of this side on demand, so a script waiting through a - * poll context allocates none, and hands the resource over: it may select - * on it and close it. Past the end of the task it comes back closed when - * one was built, as the poll hooks report an invalid descriptor there: a - * waiter learns that the task is over from is_resource(), the way it would - * from Io\Poll. */ +/* Builds the stream of this side on demand, so a script waiting through a poll + * context allocates none, and hands the resource over: it may select on it and + * close it. Past the end of the task it comes back closed, as the poll hooks + * report an invalid descriptor there, so a waiter learns the task is over from + * is_resource(). */ static void frankenphp_task_obj_get_stream(zval *zthis, zval *return_value) { frankenphp_task_data *data = FRANKENPHP_TASK_DATA(zthis); @@ -2087,18 +2075,17 @@ ZEND_METHOD(FrankenPHP_SentTaskHandle, __construct) { RETURN_THROWS(); } - /* the task is the handle's from here on: a bailout below, or a throw, - * settles it through the handle rather than leaving the receiver to wait - * on it forever */ + /* the task is the handle's from here on: a bailout below, or a throw, settles + * it rather than leaving the receiver to wait on it forever */ frankenphp_task_obj_take(ZEND_THIS, task.r0, task.r1, true); /* wait for the pickup in the kernel: the thread taking the task signals * the sender's side, so does the Go side when the wait must end without a * pickup, see go_frankenphp_send_task */ frankenphp_task_data *data = FRANKENPHP_TASK_DATA(ZEND_THIS); - /* the first slice of the wait is short: past it, the Go side escalates the - * wake-up and starts watching for a drain or the shutdown, neither of - * which the common case, a pickup within microseconds, needs */ + /* past the first slice, the Go side escalates the wake-up and starts watching + * for a drain or the shutdown, neither of which a pickup within microseconds + * needs */ int remaining = timeout_ms; bool lingering = false; for (;;) { @@ -2310,9 +2297,9 @@ ZEND_METHOD(FrankenPHP_WorkerHandle, receive) { RETURN_NULL(); } - /* the task is this thread's from here on: a bailout while copying the - * payload (memory limit, a fatal error in an autoloader) or while making - * the handle must not leave it open, the sender would wait on it forever */ + /* the task is this thread's from here on: a bailout while copying the payload + * or while making the handle must not leave it open, the sender would wait on + * it forever */ zval payload; zend_try { frankenphp_vars_to_request(&payload, task.r1); diff --git a/workertask.go b/workertask.go index 4027de152b..e5cdcdbbf6 100644 --- a/workertask.go +++ b/workertask.go @@ -15,50 +15,43 @@ import ( const taskUpdatesMax = 16 // workerTask is a unit of work handed by a PHP thread to a thread of a -// background worker, see SentTaskHandle. The payload and the -// updates flowing back are persistent HashTables, copied into request -// memory on arrival. Each side waits on its descriptor of the task's channel -// and is signaled there by the other, one signal per event: pickup, update, -// completion and abort for the sender, abandonment for the receiver. +// background worker, see SentTaskHandle. The payload and the updates flowing +// back are persistent HashTables, copied into request memory on arrival. Each +// side waits on its descriptor of the task's channel and is signaled there by +// the other, one signal per event. type workerTask struct { handle cgo.Handle worker *worker payload *C.HashTable // owned by the task until a thread picks it up pickedUp chan struct{} // closed when a thread picks the task up // cancelled is closed when the sender gave up before any pickup, ending - // the watcher; abortReason is set by the watcher, under the queue mutex, - // when the wait must end without a pickup; drainChan and shutdown are - // the channels the watcher ends the wait on, read on the sender's thread - // at send time + // the watcher, which sets abortReason under the queue mutex when the wait + // must end without a pickup. drainChan and shutdown are what it waits on, + // read on the sender's thread at send time cancelled chan struct{} abortReason string drainChan, shutdown <-chan struct{} - // receiver and pickedUpAt are set by the thread that picked the task - // up and read by its close, on the same thread + // set by the thread that picked the task up, read by its close receiver *backgroundWorkerThread pickedUpAt time.Time - // fds[0] is the sender's descriptor, fds[1] the receiver's; the streams - // wait on them but the task owns them, until both sides closed and the - // pair goes back to the pool + // fds[0] is the sender's descriptor, fds[1] the receiver's; the task owns + // them until both sides closed and the pair goes back to the pool fds [2]int64 mu sync.Mutex cond *sync.Cond // signaled on pop and close - // one wake-up per sleep of the sender's reads: a signal goes out only - // while the sender sleeps on its descriptor and none is outstanding, the - // sender consumes it on its next event and finds the rest in updates - // and the flags below + // one wake-up per sleep of the sender's reads: a signal goes out only while + // the sender sleeps on its descriptor and none is outstanding senderParked, senderSignaled bool - // senderWatched is set once the sender handed its descriptor to a poll - // context, which parks without a hook to tell us: every event then - // signals, and the signal stays outstanding while there is more to take, - // so the descriptor is readable exactly when something waits there + // set once the sender handed its descriptor to a poll context, which parks + // without a hook to tell us: every event then signals, and the signal stays + // outstanding while there is more to take senderWatched bool - updates []*C.HashTable - closed bool // the receiver closed its stream - aborted bool // ...during request shutdown: the script ended with the task open - senderGone bool // the sender closed its stream - retired int // sides done with the task, freed at 2 + updates []*C.HashTable + closed bool // the receiver closed its stream + aborted bool // ...during request shutdown: the script ended with the task open + senderGone bool // the sender closed its stream + retired int // sides done with the task, freed at 2 } // taskQueue holds the tasks sent to a background worker until a thread picks @@ -88,9 +81,8 @@ func (q *taskQueue) remove(t *workerTask) bool { // claimParkedThread picks one parked thread of the worker, round-robin over // the pool, and returns its stop socket to write the wake-up line to, or -1 // when no thread is parked: the task then waits in the queue for a thread to -// drain it or to park, see go_frankenphp_background_worker_park. The thread -// is no longer parked once claimed. Called with tasks.mu held; the caller -// writes after releasing it, see signalThreads +// drain it or to park, see go_frankenphp_background_worker_park. Called with +// tasks.mu held, the caller writes after releasing it, see signalThreads func (worker *worker) claimParkedThread() (*backgroundWorkerThread, int64) { worker.threadMutex.RLock() defer worker.threadMutex.RUnlock() @@ -138,10 +130,9 @@ func signalThreads(handlers []*backgroundWorkerThread, socks []int64) { } } -// taskChanPool keeps the descriptor pairs of finished tasks for the next -// ones: drained, they are as good as new, and creating and closing them was -// most of a task's syscalls. Bounded so an idle server does not hold the -// descriptors of a past peak. +// taskChanPool keeps the descriptor pairs of finished tasks for the next ones: +// creating and closing them was most of a task's syscalls. Bounded so an idle +// server does not hold the descriptors of a past peak. var taskChanPool struct { mu sync.Mutex free [][2]int64 @@ -278,10 +269,9 @@ func go_frankenphp_send_task(threadIndex C.uintptr_t, name *C.char, nameLen C.si pickedUp: make(chan struct{}), cancelled: make(chan struct{}), fds: fds, - // closed when this thread is drained for a restart or the shutdown: - // the target's threads are drained too, nobody would pick the task - // up. Read here, on the PHP thread: a goroutine may only get to run - // after Shutdown() replaced them + // the target's threads are drained too, nobody would pick the task up. + // Read here, on the PHP thread: a goroutine may only get to run after + // Shutdown() replaced them drainChan: thread.drainChan, shutdown: mainThread.done, } @@ -299,20 +289,18 @@ func go_frankenphp_send_task(threadIndex C.uintptr_t, name *C.char, nameLen C.si signalThreads([]*backgroundWorkerThread{handler}, []int64{sock}) } - // the C side waits for the pickup on the sender's descriptor, in the - // kernel rather than in a Go select: waking a thread parked inside a Go - // callback costs the scheduler a hand-off, a signal on a descriptor does - // not. The thread taking the task sends it; a pickup that takes longer + // the C side waits for the pickup in the kernel rather than in a Go select: + // waking a thread parked inside a Go callback costs the scheduler a + // hand-off, a signal on a descriptor does not. A pickup that takes longer // than the first wait slice brings in go_frankenphp_task_linger return C.uintptr_t(t.handle), C.intptr_t(t.fds[0]), nil } -// go_frankenphp_task_linger is called by a sender whose first wait slice -// passed without a pickup, the uncommon case: the thread signaled first did -// not come, so every thread gets the line, and a watcher starts to end the -// wait if the sender's thread is drained or FrankenPHP shuts down. Neither -// costs the common case, a pickup within microseconds, a goroutine +// go_frankenphp_task_linger is called by a sender whose first wait slice passed +// without a pickup: every thread gets the line then, and a watcher starts to +// end the wait if the sender's thread is drained or FrankenPHP shuts down. +// Neither costs the common case, a pickup within microseconds, a goroutine // //export go_frankenphp_task_linger func go_frankenphp_task_linger(handle C.uintptr_t) { @@ -427,14 +415,11 @@ func go_frankenphp_task_cancel(handle C.uintptr_t, timedOut C.bool) C.bool { return true } -// go_frankenphp_background_worker_park is called by WorkerHandle::tick() -// as the script is about to wait on its handle: the thread parks unless -// tasks are queued, in which case a wake-up is written on its own handle so -// the wait returns at once and the script dequeues them. Under tasks.mu, so -// a task queued after the check finds the thread parked and signals it: no -// wake-up is lost either way. The flag stays set when the wait returns for -// another reason than a claim: a claim meanwhile writes a wake-up the -// script's next wait returns on. +// go_frankenphp_background_worker_park is called by WorkerHandle::tick() as the +// script is about to wait on its handle: the thread parks unless tasks are +// queued, in which case a wake-up is written on its own handle so the wait +// returns at once. Under tasks.mu, so a task queued after the check finds the +// thread parked and signals it: no wake-up is lost either way. // //export go_frankenphp_background_worker_park func go_frankenphp_background_worker_park(threadIndex C.uintptr_t) { diff --git a/workertask_test.go b/workertask_test.go index b3913d48f2..a86f6d1933 100644 --- a/workertask_test.go +++ b/workertask_test.go @@ -245,8 +245,7 @@ func sendWhileWorkerBusy(t *testing.T, server *frankenphp.Server, mark string) < return body } -// TestTaskMethodsAfterTheEnd pins what a handle does once the task is over, -// on both sides. +// what a handle does once the task is over, on both sides func TestTaskMethodsAfterTheEnd(t *testing.T) { sentinel := filepath.Join(t.TempDir(), "over.txt") server, err := frankenphp.NewServer(testDataDir) @@ -274,8 +273,8 @@ complete-data: RuntimeException: the task is over getStream: RuntimeException: the task is over`, requireFileContentEventually(t, sentinel)) } -// TestTaskPoll follows two tasks through an Io\Poll\Context: the handles -// implement Io\Poll\Handle, so the script never touches a stream. +// two tasks followed through an Io\Poll\Context: the handles implement +// Io\Poll\Handle, so the script never touches a stream func TestTaskPoll(t *testing.T) { if frankenphp.Version().VersionID < 80600 { t.Skip("the poll API needs PHP 8.6")