From 509f74792008bb564bc89abad42a4c24e1299897 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Wed, 11 Nov 2026 20:53:41 +0100 Subject: [PATCH 1/3] feat: background workers and FrankenPHP\WorkerHandle --- bgworker_test.go | 840 ++++++++++++++++++++++++ caddy/app.go | 11 +- caddy/caddy_test.go | 51 +- caddy/config_test.go | 145 ++++ caddy/module.go | 4 + caddy/workerconfig.go | 40 +- docs/config.md | 8 +- docs/library.md | 2 + docs/metrics.md | 4 +- docs/worker.md | 91 +++ frankenphp.c | 612 +++++++++++++++++ frankenphp.go | 14 +- frankenphp.h | 5 + frankenphp.stub.php | 53 ++ frankenphp_arginfo.h | 41 +- metrics.go | 6 +- metrics_test.go | 2 +- options.go | 34 + phpmainthread.go | 2 +- phpmainthread_test.go | 32 + phpthread.go | 26 +- requestoptions.go | 7 +- server.go | 6 + testdata/bgworker/basic.php | 21 + testdata/bgworker/count.php | 15 + testdata/bgworker/crash-after-ready.php | 9 + testdata/bgworker/crash.php | 31 + testdata/bgworker/cycle.php | 9 + testdata/bgworker/early-return.php | 5 + testdata/bgworker/exit-after-tick.php | 7 + testdata/bgworker/fail-then-succeed.php | 18 + testdata/bgworker/fetch-no-tick.php | 7 + testdata/bgworker/flag.php | 17 + testdata/bgworker/loop.php | 16 + testdata/bgworker/named.php | 19 + testdata/bgworker/never-ticks.php | 24 + testdata/bgworker/no-time-limit.php | 12 + testdata/bgworker/poll-interface.php | 16 + testdata/bgworker/poll.php | 14 + testdata/bgworker/pool.php | 13 + testdata/bgworker/read-before-tick.php | 14 + testdata/bgworker/read.php | 11 + testdata/bgworker/readable.php | 26 + testdata/bgworker/recv.php | 12 + testdata/bgworker/refetch.php | 21 + testdata/bgworker/slow-boot.php | 11 + testdata/bgworker/stuck.php | 19 + testdata/bgworker/tick-wait.php | 17 + testdata/bgworker/tick.php | 17 + testdata/handle-bypass.php | 18 + testdata/handle-outside.php | 8 + testdata/worker-name.php | 11 + threadbackgroundworker.go | 307 +++++++++ threadworker.go | 81 +-- threadworker_test.go | 20 + worker.go | 121 +++- workerextension_test.go | 10 + workerlifecycle.go | 58 ++ 58 files changed, 2979 insertions(+), 92 deletions(-) create mode 100644 bgworker_test.go create mode 100644 testdata/bgworker/basic.php create mode 100644 testdata/bgworker/count.php create mode 100644 testdata/bgworker/crash-after-ready.php create mode 100644 testdata/bgworker/crash.php create mode 100644 testdata/bgworker/cycle.php create mode 100644 testdata/bgworker/early-return.php create mode 100644 testdata/bgworker/exit-after-tick.php create mode 100644 testdata/bgworker/fail-then-succeed.php create mode 100644 testdata/bgworker/fetch-no-tick.php create mode 100644 testdata/bgworker/flag.php create mode 100644 testdata/bgworker/loop.php create mode 100644 testdata/bgworker/named.php create mode 100644 testdata/bgworker/never-ticks.php create mode 100644 testdata/bgworker/no-time-limit.php create mode 100644 testdata/bgworker/poll-interface.php create mode 100644 testdata/bgworker/poll.php create mode 100644 testdata/bgworker/pool.php create mode 100644 testdata/bgworker/read-before-tick.php create mode 100644 testdata/bgworker/read.php create mode 100644 testdata/bgworker/readable.php create mode 100644 testdata/bgworker/recv.php create mode 100644 testdata/bgworker/refetch.php create mode 100644 testdata/bgworker/slow-boot.php create mode 100644 testdata/bgworker/stuck.php create mode 100644 testdata/bgworker/tick-wait.php create mode 100644 testdata/bgworker/tick.php create mode 100644 testdata/handle-bypass.php create mode 100644 testdata/handle-outside.php create mode 100644 testdata/worker-name.php create mode 100644 threadbackgroundworker.go create mode 100644 threadworker_test.go create mode 100644 workerlifecycle.go diff --git a/bgworker_test.go b/bgworker_test.go new file mode 100644 index 0000000000..cbe6c1965b --- /dev/null +++ b/bgworker_test.go @@ -0,0 +1,840 @@ +package frankenphp_test + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/dunglas/frankenphp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// requireFileEventually asserts that `path` appears on disk before the deadline +func requireFileEventually(t testing.TB, path string, msgAndArgs ...any) { + t.Helper() + require.Eventually(t, func() bool { + _, err := os.Stat(path) + return err == nil + }, 5*time.Second, 25*time.Millisecond, msgAndArgs...) +} + +// requireFileContentEventually waits for `path` to appear with content +func requireFileContentEventually(t *testing.T, path string) string { + t.Helper() + require.Eventually(t, func() bool { + b, err := os.ReadFile(path) + return err == nil && len(b) > 0 + }, 5*time.Second, 25*time.Millisecond, "file %q did not appear", path) + b, err := os.ReadFile(path) + require.NoError(t, err) + + return string(b) +} + +// asserts on the timing of Shutdown(), so it calls it itself rather than +// through initServers' t.Cleanup hook +func TestBackgroundWorkerLifecycle(t *testing.T) { + tmp := t.TempDir() + sentinel := filepath.Join(tmp, "bg-lifecycle.sentinel") + + t.Cleanup(frankenphp.Shutdown) + require.NoError(t, frankenphp.Init( + frankenphp.WithWorkers("bg-lifecycle", "testdata/bgworker/basic.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + )) + + requireFileEventually(t, sentinel, "background worker did not touch sentinel") + + done := make(chan struct{}) + go func() { + frankenphp.Shutdown() + close(done) + }() + + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatalf("Shutdown did not return within 10s") + } +} + +func TestBackgroundWorkerCrashRestarts(t *testing.T) { + tmp := t.TempDir() + crashMarker := filepath.Join(tmp, "bg-crash.marker") + restarted := filepath.Join(tmp, "bg-crash.restarted") + + initServers(t, + frankenphp.WithWorkers("bg-crash", "testdata/bgworker/crash.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{ + "BG_CRASH_MARKER": crashMarker, + "BG_RESTARTED_SENTINEL": restarted, + }), + ), + frankenphp.WithNumThreads(2), + ) + + requireFileEventually(t, restarted, "background worker did not restart after crash") +} + +// the sentinel directory is declared on the server, not on the worker +func TestBackgroundWorkerOnServer(t *testing.T) { + tmp := t.TempDir() + + server, err := frankenphp.NewServer( + testDataDir, + frankenphp.WithServerName("sidekick-server"), + frankenphp.WithServerEnv(map[string]string{"BG_SENTINEL_DIR": tmp}), + ) + require.NoError(t, err) + + initServers(t, + frankenphp.WithServer(server), + frankenphp.WithWorkers("jobs", "testdata/bgworker/named.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerServerScope(server), + ), + frankenphp.WithNumThreads(2), + ) + + // named.php touches "/" + requireFileEventually(t, filepath.Join(tmp, "jobs"), "background worker did not touch its per-name sentinel") + + body := serverGet(t, server, "http://example.com/index.php") + assert.Contains(t, body, "I am by birth a Genevese", "the server must still serve regular requests") +} + +func TestBackgroundWorkerValidation(t *testing.T) { + t.Cleanup(frankenphp.Shutdown) + + t.Run("early return without the handle fails startup", func(t *testing.T) { + err := frankenphp.Init( + frankenphp.WithWorkers("bg-early", "testdata/bgworker/early-return.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerMaxFailures(2), + ), + frankenphp.WithNumThreads(2), + ) + require.ErrorContains(t, err, "without calling WorkerHandle::tick()") + }) + + t.Run("fetching the handle without ticking fails startup", func(t *testing.T) { + err := frankenphp.Init( + frankenphp.WithWorkers("bg-no-tick", "testdata/bgworker/fetch-no-tick.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerMaxFailures(2), + ), + frankenphp.WithNumThreads(2), + ) + require.ErrorContains(t, err, "without calling WorkerHandle::tick()") + }) + + t.Run("a read that would block before the first tick fails startup", func(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "error.txt") + err := frankenphp.Init( + frankenphp.WithWorkers("bg-reads", "testdata/bgworker/read-before-tick.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerMaxFailures(1), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + ) + require.ErrorContains(t, err, "keeps crashing") + assert.Equal(t, `FrankenPHP\WorkerHandle: reading the stream would block before the first tick()`, requireFileContentEventually(t, sentinel)) + }) + + 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("request matchers are rejected", func(t *testing.T) { + err := frankenphp.Init( + frankenphp.WithWorkers("bg-matched", "testdata/bgworker/basic.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerMatcher(func(*http.Request) bool { return true }), + ), + frankenphp.WithNumThreads(2), + ) + require.ErrorContains(t, err, "cannot match requests") + }) +} + +func TestBackgroundWorkerCannotHandleRequests(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, + frankenphp.WithServer(server), + frankenphp.WithWorkers("jobs", "testdata/bgworker/basic.php", 1, frankenphp.WithWorkerBackground(), frankenphp.WithWorkerServerScope(server)), + frankenphp.WithNumThreads(2), + ) + + err = server.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "http://example.com/index.php", nil), frankenphp.WithWorkerName("jobs")) + require.ErrorContains(t, err, `background worker "jobs" cannot handle requests`) +} + +// a blocking read is a wait too: it reports the worker ready, and the EOF of +// the drain unblocks it +func TestBackgroundWorkerParksOnRead(t *testing.T) { + tmp := t.TempDir() + sentinel := filepath.Join(tmp, "bg-read.sentinel") + + t.Cleanup(frankenphp.Shutdown) + require.NoError(t, frankenphp.Init( + frankenphp.WithWorkers("bg-read", "testdata/bgworker/read.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + )) + requireFileEventually(t, sentinel, "background worker parked on a read did not start") + + done := make(chan struct{}) + go func() { + frankenphp.Shutdown() + close(done) + }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("Shutdown did not return within 10s: the read did not observe EOF") + } +} + +// a blocking receive reaches the stream through the transport API rather than +// the read op, so Init() would hang if only reads reported readiness +func TestBackgroundWorkerParksOnReceive(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "bg-recv.sentinel") + + t.Cleanup(frankenphp.Shutdown) + require.NoError(t, frankenphp.Init( + frankenphp.WithWorkers("bg-recv", "testdata/bgworker/recv.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + )) + requireFileEventually(t, sentinel, "background worker parked on a receive did not start") + + done := make(chan struct{}) + go func() { + frankenphp.Shutdown() + close(done) + }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("Shutdown did not return within 10s: the receive did not observe EOF") + } +} + +func TestBackgroundWorkerRestartDrainsParkedScript(t *testing.T) { + tmp := t.TempDir() + countFile := filepath.Join(tmp, "bg-count.log") + + initServers(t, + frankenphp.WithWorkers("bg-count", "testdata/bgworker/count.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_COUNT_FILE": countFile}), + ), + frankenphp.WithNumThreads(2), + ) + runs := func() int { + b, _ := os.ReadFile(countFile) + return bytes.Count(b, []byte("\n")) + } + require.Eventually(t, func() bool { return runs() == 1 }, 5*time.Second, 25*time.Millisecond, "background worker did not start") + + frankenphp.RestartWorkers() + + require.Eventually(t, func() bool { return runs() == 2 }, 5*time.Second, 25*time.Millisecond, "background worker was not re-run after the restart") +} + +func TestWorkerHandleOutsideBackgroundWorker(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), frankenphp.WithNumThreads(1)) + + body := serverGet(t, server, "http://example.com/handle-outside.php") + + assert.Contains(t, body, `FrankenPHP\WorkerHandle can only be created from a background worker`) +} + +// without the wake-up sent at start, a script that only ticks when its handle +// is readable would keep Init() waiting until the drain +func TestBackgroundWorkerLoopTicksOnItsOwn(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "loop.sentinel") + initServers(t, + frankenphp.WithWorkers("bg-loop", "testdata/bgworker/loop.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + ) + requireFileEventually(t, sentinel, "background worker did not start") +} + +// the handle is readable at start and quiet once a tick returned, so a loop +// selecting on it blocks instead of spinning +func TestBackgroundWorkerTickLeavesTheHandleQuiet(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "readable.txt") + initServers(t, + frankenphp.WithWorkers("bg-readable", "testdata/bgworker/readable.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + ) + + assert.Equal(t, "start:readable after tick:quiet after second tick:quiet", requireFileContentEventually(t, sentinel)) +} + +// true while the worker runs, false once it is drained, and still false on the +// next call +func TestBackgroundWorkerTick(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "ticks.txt") + + t.Cleanup(frankenphp.Shutdown) + require.NoError(t, frankenphp.Init( + frankenphp.WithWorkers("bg-tick", "testdata/bgworker/tick.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + )) + assert.Equal(t, "true true", requireFileContentEventually(t, sentinel)) + + // Shutdown() waits for the script to leave, so the file is final after it + frankenphp.Shutdown() + b, err := os.ReadFile(sentinel) + require.NoError(t, err) + assert.Equal(t, "true true false false", string(b)) +} + +// tickWaitLines starts a worker that waits in tick() with the given timeout +// and returns what reads the returns of tick() it recorded so far +func tickWaitLines(t *testing.T, timeout string) func() []string { + sentinel := filepath.Join(t.TempDir(), "tick-wait.txt") + + t.Cleanup(frankenphp.Shutdown) + require.NoError(t, frankenphp.Init( + frankenphp.WithWorkers("bg-tick-wait", "testdata/bgworker/tick-wait.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel, "BG_TIMEOUT": timeout}), + ), + frankenphp.WithNumThreads(2), + )) + + return func() []string { + b, _ := os.ReadFile(sentinel) + + return strings.Split(strings.TrimSuffix(string(b), "\n"), "\n") + } +} + +// given a timeout, tick() is where the script waits: the timeout ends each +// wait, the first one included whatever was written on the handle at start, +// and the drain ends the last one +func TestBackgroundWorkerTickWaits(t *testing.T) { + lines := tickWaitLines(t, "0.2") + require.Eventually(t, func() bool { return len(lines()) >= 3 }, 5*time.Second, 25*time.Millisecond, "tick() did not return on its timeout") + + seen := lines() + assert.Equal(t, `FrankenPHP\WorkerHandle::tick(): Argument #1 ($timeout) must be greater than or equal to 0`, seen[0]) + for _, line := range seen[1:] { + ms, err := strconv.Atoi(strings.TrimPrefix(line, "tick ")) + require.NoError(t, err, line) + assert.GreaterOrEqual(t, ms, 190, "tick() returned ahead of its timeout") + } + + frankenphp.Shutdown() + seen = lines() + assert.True(t, strings.HasPrefix(seen[len(seen)-1], "drained "), seen[len(seen)-1]) +} + +// with null, tick() waits until FrankenPHP needs the script: nothing but the +// drain here, which ends the wait at once +func TestBackgroundWorkerTickWaitsForTheDrain(t *testing.T) { + lines := tickWaitLines(t, "null") + require.Eventually(t, func() bool { return len(lines()) == 1 }, 5*time.Second, 25*time.Millisecond, "the script did not start") + require.Never(t, func() bool { return len(lines()) > 1 }, 300*time.Millisecond, 25*time.Millisecond, "tick() did not wait") + + start := time.Now() + frankenphp.Shutdown() + assert.Less(t, time.Since(start), 5*time.Second, "the drain did not end the wait") + + seen := lines() + require.Len(t, seen, 2) + assert.True(t, strings.HasPrefix(seen[1], "drained "), seen[1]) +} + +// an HTTP worker sees FRANKENPHP_WORKER as it always did, a background one +// FRANKENPHP_WORKER_BACKGROUND and no FRANKENPHP_WORKER, its declared name +// coming from WorkerHandle::getName() +func TestWorkerNameInServerVars(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "flag.txt") + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, + frankenphp.WithServer(server), + frankenphp.WithWorkers("web", testDataDir+"worker-name.php", 1, frankenphp.WithWorkerServerScope(server)), + frankenphp.WithWorkers("jobs", "testdata/bgworker/flag.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerServerScope(server), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(3), + ) + + assert.Equal(t, "1 http", serverGet(t, server, "http://example.com/worker-name.php")) + + flag := requireFileContentEventually(t, sentinel) + assert.Contains(t, flag, "'worker' => 'unset'") + assert.Contains(t, flag, "'background' => '1'") + assert.Contains(t, flag, "'name' => 'jobs'") +} + +// the threads of a pool share the name, park on a handle each, and one drain +// wakes them all +func TestBackgroundWorkerPool(t *testing.T) { + dir := t.TempDir() + initServers(t, + frankenphp.WithWorkers("pool", "testdata/bgworker/pool.php", 3, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL_DIR": dir}), + ), + frankenphp.WithNumThreads(4), + ) + + require.Eventually(t, func() bool { + entries, _ := os.ReadDir(dir) + return len(entries) == 3 + }, 5*time.Second, 25*time.Millisecond, "the three pool threads did not all start") + + done := make(chan struct{}) + go func() { + frankenphp.Shutdown() + close(done) + }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("Shutdown did not drain the whole pool within 10s") + } +} + +// two named background workers of one server may share a script, they are not +// matched by path +func TestBackgroundWorkerMultiEntrypoint(t *testing.T) { + tmp := t.TempDir() + first, second := filepath.Join(tmp, "first"), filepath.Join(tmp, "second") + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, + frankenphp.WithServer(server), + frankenphp.WithWorkers("first", "testdata/bgworker/basic.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerServerScope(server), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": first}), + ), + frankenphp.WithWorkers("second", "testdata/bgworker/basic.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerServerScope(server), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": second}), + ), + frankenphp.WithNumThreads(3), + ) + + requireFileEventually(t, first, "the first worker on the shared script did not start") + requireFileEventually(t, second, "the second worker on the shared script did not start") + + // the threads endpoint tells them apart by name, not by script + names := make([]string, 0, 3) + for _, thread := range frankenphp.DebugState().ThreadDebugStates { + names = append(names, thread.Name) + } + assert.Contains(t, names, "Background Worker PHP Thread - first") + assert.Contains(t, names, "Background Worker PHP Thread - second") +} + +// a name left out is generated from the script, like an HTTP worker's +func TestBackgroundWorkerNameIsGenerated(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "flag.txt") + initServers(t, + frankenphp.WithWorkers("", "testdata/bgworker/flag.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(1), + ) + + script, err := filepath.Abs("testdata/bgworker/flag.php") + require.NoError(t, err) + // var_export() doubles the backslashes of a Windows path + assert.Contains(t, requireFileContentEventually(t, sentinel), "'name' => '"+strings.ReplaceAll(script, `\`, `\\`)+"'") +} + +// a background worker serves no requests, so num defaults to one thread rather +// than to the CPU count of an HTTP worker +func TestBackgroundWorkerDefaultsToOneThread(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "bg-default.sentinel") + initServers(t, + frankenphp.WithWorkers("bg-default", "testdata/bgworker/basic.php", 0, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(1), + ) + requireFileEventually(t, sentinel, "background worker did not start without an explicit num") + + debug := frankenphp.DebugState() + background := 0 + for _, thread := range debug.ThreadDebugStates { + if strings.Contains(thread.Name, "Background Worker") { + background++ + } + } + assert.Equal(t, 1, background) + // on top of num_threads, which keeps its regular thread + assert.Len(t, debug.ThreadDebugStates, 2) +} + +// max_execution_time applies until the first tick, a setup that outlives it +// fails Init(). The limit is PHP's, so the test only runs where its timers are +// known to fire under FrankenPHP: the max execution timers of Linux ZTS builds +func TestBackgroundWorkerBootstrapIsBounded(t *testing.T) { + if !frankenphp.Config().ZendMaxExecutionTimers { + t.Skip("max_execution_time is only reliable with Zend max execution timers") + } + + countFile := filepath.Join(t.TempDir(), "boots") + err := frankenphp.Init( + frankenphp.WithWorkers("bg-slow", "testdata/bgworker/slow-boot.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerMaxFailures(0), + frankenphp.WithWorkerEnv(map[string]string{"BG_COUNT_FILE": countFile}), + ), + frankenphp.WithNumThreads(2), + frankenphp.WithPhpIni(map[string]string{"max_execution_time": "1"}), + ) + if err == nil { + frankenphp.Shutdown() + } + require.ErrorContains(t, err, "keeps crashing") + + b, _ := os.ReadFile(countFile) + assert.Equal(t, 1, bytes.Count(b, []byte("\n")), "the limit should have ended the one and only boot") +} + +// past its first tick, a parked script is cut short by neither of the two +// limits it never disables itself: max_execution_time, disarmed by the tick, +// and default_socket_timeout, which the handle's stream overrides +func TestBackgroundWorkerParkingIsNotInterrupted(t *testing.T) { + countFile := filepath.Join(t.TempDir(), "runs") + initServers(t, + frankenphp.WithWorkers("bg-timeout", "testdata/bgworker/no-time-limit.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_COUNT_FILE": countFile}), + ), + frankenphp.WithNumThreads(2), + frankenphp.WithPhpIni(map[string]string{"max_execution_time": "1", "max_input_time": "1", "default_socket_timeout": "1"}), + ) + + runs := func() int { + b, _ := os.ReadFile(countFile) + + return bytes.Count(b, []byte("\n")) + } + require.Eventually(t, func() bool { return runs() == 1 }, 5*time.Second, 25*time.Millisecond, "background worker did not start") + // well past both limits + time.Sleep(2500 * time.Millisecond) + assert.Equal(t, 1, runs(), "the worker was restarted, so a limit interrupted its park") +} + +// the same stream every time, a fresh one once the script closed it, one per +// handle, and the drain reaches the script through all of them +func TestBackgroundWorkerStreamClosedAndFetchedAgain(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "refetch.txt") + + t.Cleanup(frankenphp.Shutdown) + require.NoError(t, frankenphp.Init( + frankenphp.WithWorkers("bg-refetch", "testdata/bgworker/refetch.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + )) + assert.Equal(t, "same then fresh then its own", requireFileContentEventually(t, sentinel)) + + done := make(chan struct{}) + go func() { + frankenphp.Shutdown() + close(done) + }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("Shutdown did not return within 10s: the re-fetched handle missed the drain") + } +} + +// boot failures below max_consecutive_failures are retried with the backoff, +// and Init() still succeeds once a run reaches its ready point +func TestBackgroundWorkerBootFailuresThenSucceeds(t *testing.T) { + tmp := t.TempDir() + countFile, sentinel := filepath.Join(tmp, "boots"), filepath.Join(tmp, "ready") + initServers(t, + frankenphp.WithWorkers("bg-flaky", "testdata/bgworker/fail-then-succeed.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_COUNT_FILE": countFile, "BG_SENTINEL": sentinel, "BG_FAIL_UNTIL": "2"}), + ), + frankenphp.WithNumThreads(2), + ) + + requireFileEventually(t, sentinel, "background worker did not recover from its boot failures") + boots, err := os.ReadFile(countFile) + require.NoError(t, err) + assert.Equal(t, "3", string(boots), "two boot failures then a success") +} + +// a crash past the ready point restarts without counting toward +// max_consecutive_failures, and a drain cuts the backoff short +func TestBackgroundWorkerCrashAfterReadyRestarts(t *testing.T) { + countFile := filepath.Join(t.TempDir(), "runs") + initServers(t, + frankenphp.WithWorkers("bg-crashy", "testdata/bgworker/crash-after-ready.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerMaxFailures(2), + frankenphp.WithWorkerEnv(map[string]string{"BG_COUNT_FILE": countFile}), + ), + frankenphp.WithNumThreads(2), + ) + + require.Eventually(t, func() bool { + b, _ := os.ReadFile(countFile) + return bytes.Count(b, []byte("\n")) >= 5 + }, 5*time.Second, 25*time.Millisecond, "the worker was not restarted after crashing past its ready point") + + // past four crashes the wait is at its 1s cap + start := time.Now() + frankenphp.Shutdown() + assert.Less(t, time.Since(start), 500*time.Millisecond, "Shutdown() waited for the backoff") +} + +// a script returning right after its tick is re-run with the crash backoff +func TestBackgroundWorkerCleanExitIsPaced(t *testing.T) { + countFile := filepath.Join(t.TempDir(), "runs") + initServers(t, + frankenphp.WithWorkers("bg-exit", "testdata/bgworker/exit-after-tick.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_COUNT_FILE": countFile}), + ), + frankenphp.WithNumThreads(2), + ) + + time.Sleep(time.Second) + b, err := os.ReadFile(countFile) + require.NoError(t, err) + runs := bytes.Count(b, []byte("\n")) + assert.GreaterOrEqual(t, runs, 2, "the script was not re-run") + assert.LessOrEqual(t, runs, 8, "the re-runs were not paced") +} + +// the other side of the pacing: a worker that does some work and returns is +// re-run at its own pace +func TestBackgroundWorkerCyclingIsNotThrottled(t *testing.T) { + countFile := filepath.Join(t.TempDir(), "runs") + initServers(t, + frankenphp.WithWorkers("bg-cycle", "testdata/bgworker/cycle.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_COUNT_FILE": countFile}), + ), + frankenphp.WithNumThreads(2), + ) + + // the interval between two runs is the work plus whatever FrankenPHP + // waits: comparing the last to the first cancels how fast the machine + // is, and a backoff counting these runs would have grown it by a second + starts := func() []float64 { + b, _ := os.ReadFile(countFile) + var times []float64 + for _, line := range strings.Fields(string(b)) { + if t, err := strconv.ParseFloat(line, 64); err == nil { + times = append(times, t) + } + } + + return times + } + require.Eventually(t, func() bool { return len(starts()) >= 6 }, 15*time.Second, 50*time.Millisecond, "the worker did not run six times") + + times := starts() + first, last := times[1]-times[0], times[5]-times[4] + assert.Less(t, last-first, 0.5, "the interval between runs grew, the worker was throttled") +} + +// a script ignoring its handle does not stall RestartWorkers() past the reboot +// grace period, the force-kill ends it +func TestBackgroundWorkerRebootForceKillsStuckScript(t *testing.T) { + if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { + t.Skipf("force-kill cannot interrupt a blocking syscall on %s", runtime.GOOS) + } + + tmp := t.TempDir() + once, sentinel := filepath.Join(tmp, "once"), filepath.Join(tmp, "parked") + initServers(t, + frankenphp.WithWorkers("bg-stuck", "testdata/bgworker/stuck.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_ONCE": once, "BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + ) + requireFileEventually(t, once, "background worker never entered its sleep") + + start := time.Now() + frankenphp.RestartWorkers() + assert.WithinDuration(t, start, time.Now(), 10*time.Second, "the reboot must force-kill the stuck script within its grace period") + + requireFileEventually(t, sentinel, "the re-run script did not park") +} + +// the poll API of PHP 8.6 waits on the handle itself, no stream involved +func TestBackgroundWorkerPollHandle(t *testing.T) { + if frankenphp.Version().VersionID < 80600 { + t.Skip("the poll API needs PHP 8.6") + } + + sentinel := filepath.Join(t.TempDir(), "poll.backend") + initServers(t, + frankenphp.WithWorkers("bg-poll", "testdata/bgworker/poll.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + ) + + // the server only starts once the worker ticked, which it does through + // the context: a backend name means the wait came back on its own + assert.NotEmpty(t, requireFileContentEventually(t, sentinel)) +} + +// the handle implements Io\Poll\Handle on every version: PHP 8.6 declares the +// interface with the poll API, FrankenPHP declares it below that +func TestBackgroundWorkerHandleIsAPollHandle(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "poll.json") + initServers(t, + frankenphp.WithWorkers("bg-iface", "testdata/bgworker/poll-interface.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}), + ), + frankenphp.WithNumThreads(2), + ) + + assert.JSONEq(t, `{"handle":true,"internal":true}`, requireFileContentEventually(t, sentinel)) +} + +// the methods do not trust the constructor's guard: unserialize() is refused +// outright, and a handle Reflection built on a request thread throws instead +// of reaching the Go side, which would panic and take the process with it +func TestWorkerHandleBuiltBehindTheConstructor(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), frankenphp.WithNumThreads(1)) + + body := serverGet(t, server, "http://example.com/handle-bypass.php") + assert.Contains(t, body, "unserialize: Exception: Unserialization of 'FrankenPHP\\WorkerHandle' is not allowed") + assert.Contains(t, body, "reflection: ReflectionException:") + assert.Contains(t, body, "construct: LogicException: FrankenPHP\\WorkerHandle can only be created from a background worker") +} + +// a script that parks without ever ticking fails Init() instead of hanging it, +// the only failure mode available where Zend max execution timers are not +func TestBackgroundWorkerBootTimeout(t *testing.T) { + // a hang fails the test instead of the whole run + initWithin := func(t *testing.T, options ...frankenphp.Option) error { + t.Helper() + done := make(chan error, 1) + go func() { done <- frankenphp.Init(options...) }() + + select { + case err := <-done: + if err == nil { + frankenphp.Shutdown() + } + + return err + case <-time.After(10 * time.Second): + t.Fatal("Init() did not return") + + return nil + } + } + mute := func(name string, timeout time.Duration, env map[string]string) frankenphp.Option { + return frankenphp.WithWorkers(name, "testdata/bgworker/never-ticks.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerBootTimeout(timeout), + frankenphp.WithWorkerEnv(env), + ) + } + + t.Run("one worker", func(t *testing.T) { + err := initWithin(t, mute("bg-mute", 300*time.Millisecond, nil), frankenphp.WithNumThreads(2)) + require.ErrorContains(t, err, `background worker "bg-mute" did not call WorkerHandle::tick() within 300ms`) + }) + + // a boot stuck out of the drain's reach is force-killed, Init() does not + // wait for the grace period of the shutdown + t.Run("one worker stuck in a blocking call", func(t *testing.T) { + if runtime.GOOS == "darwin" { + t.Skip("force-kill cannot interrupt a blocking syscall on darwin") + } + + start := time.Now() + err := initWithin(t, mute("bg-asleep", 300*time.Millisecond, map[string]string{"BG_SLEEP": "1"}), frankenphp.WithNumThreads(2)) + require.ErrorContains(t, err, `background worker "bg-asleep" did not call WorkerHandle::tick() within 300ms`) + assert.Less(t, time.Since(start), 5*time.Second) + }) + + // each timeout reports once and never waits for room to do so + t.Run("two workers timing out one after the other", func(t *testing.T) { + err := initWithin(t, + mute("bg-mute-a", 200*time.Millisecond, nil), + mute("bg-mute-b", time.Second, nil), + frankenphp.WithNumThreads(1), + ) + require.ErrorContains(t, err, `background worker "bg-mute-a" did not call WorkerHandle::tick() within 200ms`) + }) + + // a timeout landing between two failed boots stops the next one from starting + t.Run("timeout between two failed boots", func(t *testing.T) { + countFile := filepath.Join(t.TempDir(), "boots") + err := initWithin(t, + mute("bg-flaky", 300*time.Millisecond, map[string]string{"BG_COUNT_FILE": countFile, "BG_FAIL_UNTIL": "3"}), + frankenphp.WithNumThreads(1), + ) + require.ErrorContains(t, err, `background worker "bg-flaky" did not call WorkerHandle::tick() within 300ms`) + + b, err := os.ReadFile(countFile) + require.NoError(t, err) + boots, err := strconv.Atoi(string(b)) + require.NoError(t, err) + assert.LessOrEqual(t, boots, 3, "a run started after the timeout") + }) +} diff --git a/caddy/app.go b/caddy/app.go index 266322d788..1ef018362b 100644 --- a/caddy/app.go +++ b/caddy/app.go @@ -410,10 +410,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 not + // matched by path, 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 11b33d51e2..a9d5f2256e 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -880,6 +880,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) @@ -962,7 +1001,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, WorkerHandle::tick() for background workers # TYPE frankenphp_ready_workers gauge frankenphp_ready_workers{worker="` + workerName + `"} 2 ` @@ -1119,7 +1158,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, WorkerHandle::tick() for background workers # TYPE frankenphp_ready_workers gauge frankenphp_ready_workers{worker="my_app"} 2 ` @@ -1215,7 +1254,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, WorkerHandle::tick() for background workers # TYPE frankenphp_ready_workers gauge frankenphp_ready_workers{worker="` + workerName + `"} ` + workers + ` ` @@ -1590,7 +1629,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, WorkerHandle::tick() for background workers # TYPE frankenphp_ready_workers gauge frankenphp_ready_workers{worker="service1"} 2 frankenphp_ready_workers{worker="service2"} 3 @@ -1744,7 +1783,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, WorkerHandle::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 @@ -1772,7 +1811,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, WorkerHandle::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/caddy/config_test.go b/caddy/config_test.go index 041c2a47bc..1226a0223f 100644 --- a/caddy/config_test.go +++ b/caddy/config_test.go @@ -92,6 +92,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 not matched by path +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 := ` @@ -295,3 +334,109 @@ func TestCreateUniqueWorkerNamesQualifiedByServer(t *testing.T) { // workers without a server keep the numeric postfix behavior require.Equal(t, "queue_2", createUniqueWorkerName(usedWorkerNames, wc, "")) } + +func TestWorkerBackgroundConfig(t *testing.T) { + d := caddyfile.NewTestDispenser(` + { + 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) +} + +// the name is generated when the workers are collected, like for an HTTP worker +func TestWorkerBackgroundNameIsOptional(t *testing.T) { + d := caddyfile.NewTestDispenser(` + { + php_server { + worker { + file ../testdata/worker-with-env.php + background + } + } + }`) + module := &FrankenPHPModule{} + + require.NoError(t, module.UnmarshalCaddyfile(d)) + require.True(t, module.Workers[0].Background) + require.Empty(t, module.Workers[0].Name) +} + +func TestWorkerBackgroundWithoutNumParses(t *testing.T) { + d := caddyfile.NewTestDispenser(` + { + php_server { + worker { + name jobs + file ../testdata/worker-with-env.php + background + } + } + }`) + module := &FrankenPHPModule{} + + // num is optional, it defaults to one thread when the workers start + require.NoError(t, module.UnmarshalCaddyfile(d)) +} + +func TestWorkerBootTimeout(t *testing.T) { + parse := func(t *testing.T, directives string) (*FrankenPHPModule, error) { + t.Helper() + module := &FrankenPHPModule{} + err := module.UnmarshalCaddyfile(caddyfile.NewTestDispenser(` + { + php_server { + worker { + file ../testdata/worker-with-env.php + ` + directives + ` + } + } + }`)) + + return module, err + } + + module, err := parse(t, "background\nboot_timeout 5s") + require.NoError(t, err) + require.Equal(t, caddy.Duration(5*time.Second), *module.Workers[0].BootTimeout) + + // zero waits forever + module, err = parse(t, "background\nboot_timeout 0") + require.NoError(t, err) + require.Equal(t, caddy.Duration(0), *module.Workers[0].BootTimeout) + + _, err = parse(t, "boot_timeout 5s") + require.ErrorContains(t, err, `"boot_timeout" is only supported for background workers`) + + _, err = parse(t, "background\nboot_timeout -1s") + require.ErrorContains(t, err, "boot_timeout must be >= 0") +} + +func TestWorkerBackgroundRejectsMatch(t *testing.T) { + d := caddyfile.NewTestDispenser(` + { + 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 d2f36a162d..0a9df072a8 100644 --- a/caddy/module.go +++ b/caddy/module.go @@ -317,6 +317,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 not matched by path, 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 f229c71487..d66fcf24eb 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,10 @@ type workerConfig struct { MatchPath []string `json:"match_path,omitempty"` // MaxConsecutiveFailures sets the maximum number of consecutive failures before panicking (defaults to 6, set to -1 to never panick) MaxConsecutiveFailures *int `json:"max_consecutive_failures,omitempty"` + // EXPERIMENTAL: BootTimeout bounds how long a background worker may take to reach its first WorkerHandle::tick() before the start fails (defaults to 30s on PHP builds without Zend max execution timers, where max_execution_time is disabled, set to 0 to wait forever) + BootTimeout *caddy.Duration `json:"boot_timeout,omitempty"` + // EXPERIMENTAL: Background marks this worker as a background (non-HTTP) worker. + Background bool `json:"background,omitempty"` options []frankenphp.WorkerOption } @@ -139,8 +144,25 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) { } wc.MaxConsecutiveFailures = new(v) + case "boot_timeout": + if !d.NextArg() { + return wc, d.ArgErr() + } + + v, err := caddy.ParseDuration(d.Val()) + if err != nil { + return wc, d.WrapErr(err) + } + if v < 0 { + return wc, d.Errf("boot_timeout must be >= 0") + } + + timeout := caddy.Duration(v) + wc.BootTimeout = &timeout + case "background": + wc.Background = true default: - return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads", v) + return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads, background, boot_timeout", v) } } @@ -148,6 +170,14 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) { return wc, d.Err(`the "file" argument must be specified`) } + if !wc.Background && wc.BootTimeout != nil { + return wc, d.Err(`"boot_timeout" is only supported for background workers`) + } + + if wc.Background && len(wc.MatchPath) != 0 { + return wc, d.Err(`"match" is not supported for background workers`) + } + if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) { wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName) } @@ -169,6 +199,14 @@ func (wc *workerConfig) toWorkerOptions() ([]frankenphp.WorkerOption, error) { // options collected while provisioning the module, e.g. the Mercure hub opts = append(opts, wc.options...) + if wc.Background { + opts = append(opts, frankenphp.WithWorkerBackground()) + + if wc.BootTimeout != nil { + opts = append(opts, frankenphp.WithWorkerBootTimeout(time.Duration(*wc.BootTimeout))) + } + } + // copy the caddy match logic and create a unique matcher function for this worker // inject the matcher into frankenphp if len(wc.MatchPath) > 0 { diff --git a/docs/config.md b/docs/config.md index bccb5ed2dc..611ff392d5 100644 --- a/docs/config.md +++ b/docs/config.md @@ -106,11 +106,13 @@ You can also explicitly configure FrankenPHP using the [global option](https://c php_ini # Set a php.ini directive. Can be used several times to set multiple directives. worker { file # Sets the path to the worker script. - num # Sets the number of PHP threads to start, defaults to 2x the number of available CPUs. + num # Sets the number of PHP threads to start, defaults to 2x the number of available CPUs, 1 for background workers. env # Sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables. watch # Sets the path to watch for file changes. Can be specified more than once for multiple paths. name # Sets the name of the worker, used in logs and metrics. Default: absolute path of worker file max_consecutive_failures # Sets the maximum number of consecutive failures before the worker is considered unhealthy, -1 means the worker will always restart. Default: 6. + boot_timeout # EXPERIMENTAL: how long a background worker may take to reach its first tick() before the start fails, 0 waits forever. Default: 30s on PHP builds without Zend max execution timers, none elsewhere. + background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, whose script ticks its FrankenPHP\WorkerHandle at least once } } } @@ -194,11 +196,13 @@ 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. Default: absolute path of worker file. Postfixed with a number if name is already in use. watch # Sets the path to watch for file changes. Can be specified more than once for multiple paths. env # Sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables. Environment variables for this worker are also inherited from the php_server parent, but can be overwritten here. match # match the worker to a path pattern. Overrides try_files and can only be used in the php_server directive. + boot_timeout # EXPERIMENTAL: how long a background worker may take to reach its first tick() before the start fails, 0 waits forever. Default: 30s on PHP builds without Zend max execution timers, none elsewhere. + background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, whose script ticks its FrankenPHP\WorkerHandle at least once } worker # Can also use the short form like in the global frankenphp block. } diff --git a/docs/library.md b/docs/library.md index 968d2bd971..b21e04e67c 100644 --- a/docs/library.md +++ b/docs/library.md @@ -60,6 +60,8 @@ 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. +`WithWorkerBackground()` declares a [background worker](worker.md#background-workers), which runs outside the request cycle. + ## Per-request options `Server.ServeHTTP()` accepts `RequestOption`s to override the server configuration for a single request, e.g. `WithRequestDocumentRoot()`, `WithRequestSplitPath()`, `WithRequestEnv()` or `WithRequestLogger()`. diff --git a/docs/metrics.md b/docs/metrics.md index b87d2231f4..b6636e9e5c 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 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, `WorkerHandle::tick()` for background workers. - `frankenphp_worker_crashes{worker="[worker_name]"}`: The number of times a worker has unexpectedly terminated. - `frankenphp_worker_restarts{worker="[worker_name]"}`: The number of times a worker has been deliberately restarted. - `frankenphp_worker_queue_depth{worker="[worker_name]"}`: The number of queued requests. @@ -74,7 +74,7 @@ Each entry in `ThreadDebugStates` contains: | Field | Type | Description | |---|---|---| | `Index` | integer | The index of the thread. | -| `Name` | string | The name of the thread (e.g., the worker file path). | +| `Name` | string | The name of the thread (e.g., the worker file path, or the worker name for a background worker). | | `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. | diff --git a/docs/worker.md b/docs/worker.md index 220b6fee2a..94e5bf5dba 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -206,6 +206,97 @@ so the default `num_threads` value is `1 + 2x(number of available CPUs)`. Thus, in that mode if `num_threads` and `num` are explicitly set, `num_threads` must be strictly greater then `num`. +## Background workers + +This feature is experimental. + +A background worker runs its script in a loop outside the HTTP request cycle, on its own PHP thread. It is declared like any worker, with the `background` option; `num` defaults to one thread, and a `name` left out is generated from the script path as for HTTP workers: + +```caddyfile +php_server { + worker { + file jobs.php + num 1 + name jobs + background + } +} +``` + +The script takes a handle on the worker, `new FrankenPHP\WorkerHandle()`, and calls `tick()` on it once it is set up. The first call marks the worker ready: the server start waits for that point, and an exit before it counts as a failure. Until that call, `max_execution_time` applies as in any request, 30 seconds by default: a setup that outlives it is ended and counts as a boot failure, so raise the limit in `php_ini`, or call `set_time_limit()` in the script, when the setup legitimately takes longer. This bound only holds on PHP builds with Zend max execution timers (`--enable-zend-max-execution-timers`, see [the compilation guide](compile.md)): elsewhere FrankenPHP disables `max_execution_time`, and the `boot_timeout` of the worker ends the wait instead: 30 seconds by default, matching PHP's own `max_execution_time`, after which the start fails with the name of the worker that never ticked. Its script is then stopped, at once if it waits on its handle, or by the force-kill a second later otherwise. Set `boot_timeout` to `0` to wait forever, with a warning logged after 10 seconds; it applies on every build when set. From the first call on, the run has no time limit, like the CLI. Every call returns `false` once FrankenPHP drains the worker, on shutdown, reboot or restart, and `true` otherwise. It never hands out work: like `frankenphp_handle_request()`, it is where the runtime and the script meet. + +A script with nothing else to wait on waits in `tick()` itself, by giving it a timeout in seconds: the call returns once FrankenPHP needs the script, or after that long, and `null` waits without a limit. A worker doing something every minute is a loop on it, which a drain leaves at once: + +```php +tick(60.0)); + +// drained: return, FrankenPHP re-runs or stops the script +``` + +Without a timeout, `tick()` never waits: a script with more to wait on waits on the handle, which is an `Io\Poll\Handle`, and ticks between two waits. An `Io\Poll\Context` takes the handle as it is, next to whatever else the script waits on. + +```php +add($handle, [Event::Read]); +// plus the handles the script waits on + +while ($handle->tick()) { + foreach ($poll->wait() as $watcher) { + // the worker's handle triggered, or one of the script's + } + + doSomeWork(); +} + +// drained: return, FrankenPHP re-runs or stops the script +``` + +The handle becomes readable when FrankenPHP needs the script's attention, its drain included, and `tick()` consumes whatever was written there. What it carries is not part of the contract. It is readable once right after the script starts, so a loop that waits on it ticks by itself and the worker is ready as soon as its loop runs. + +`Io\Poll` comes with PHP 8.6 and polls with `epoll` or `kqueue`. On 8.2 to 8.5 the handle implements the same interface, so the loop above runs unchanged with [symfony/polyfill-io-poll](https://github.com/symfony/polyfill/tree/1.x/src/Io/Poll), which backs the context with `stream_select()`. + +An event loop takes the handle through its stream, `getStream()`. With [Revolt](https://revolt.run), the loop of amphp: + +```php +getStream(), function () use ($handle): void { + if (!$handle->tick()) { + // drained: stop the loop, the script returns and FrankenPHP moves on + EventLoop::getDriver()->stop(); + } +}); + +// the script's own watchers go here + +EventLoop::run(); +``` + +The wake-up sent at start makes the callback run as soon as the loop does, which is when the worker becomes ready. + +That stream is there for the libraries that take one, and waiting is all it supports: reading it steals the bytes `tick()` would have consumed, and throws before the first `tick()` when it would block, since the start would wait on it for good; 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 can also 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. + +Misuse, such as a handle outside of a background worker, throws a `LogicException`, and conditions of the moment throw a `RuntimeException`. + +A background worker serves no request, but its script is a PHP file like any other: under the document root, a request for it runs it on a regular thread, where `new FrankenPHP\WorkerHandle()` throws, so keep it outside of the root, with a path such as `../workers/jobs.php` since worker paths are resolved against it. + +`$_SERVER['FRANKENPHP_WORKER_BACKGROUND']` is set to `1`, and `FRANKENPHP_WORKER`, the variable of HTTP workers, is not, so a script serving both roles tests which of the two is set. Several workers may run the same script: `getName()` on the handle tells them apart, and the [threads endpoint](metrics.md#threads-state-endpoint) of the admin API names their threads after them. Background worker threads count like the threads of HTTP workers, on top of `num_threads` and within `max_threads`; they don't autoscale, so `max_threads` is not allowed on them. From Go, declare one with `WithWorkerBackground()`. + ## Superglobals behavior [PHP superglobals](https://www.php.net/manual/language.variables.superglobals.php) (`$_SERVER`, `$_ENV`, `$_GET`...) diff --git a/frankenphp.c b/frankenphp.c index ca7ec27b14..f173da1109 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -18,11 +18,14 @@ #include #ifdef PHP_WIN32 #include +#include #else #include #endif +#include
#include #include +#include #include #include #include @@ -34,6 +37,7 @@ #include #include #ifndef PHP_WIN32 +#include #include #endif #if defined(__linux__) @@ -132,6 +136,16 @@ HashTable *main_thread_env = NULL; static THREAD_LOCAL uintptr_t thread_index; static THREAD_LOCAL bool is_worker_thread = false; +static THREAD_LOCAL bool is_background_worker = false; +/* Stop socket pair of a background worker thread: [0] is the script's end, + * exposed via WorkerHandle::getStream(); [1] is transferred to the Go + * side, which closes it to signal a drain. */ +static THREAD_LOCAL php_socket_t worker_stop_socks[2] = {SOCK_ERR, SOCK_ERR}; +/* set by the first WorkerHandle::tick() of the current run, the ready + * point of a background worker */ +static THREAD_LOCAL bool worker_ticked = false; +/* name of the background worker the thread runs, for WorkerHandle::getName() */ +static THREAD_LOCAL zend_string *worker_name = NULL; /* putenv() overrides layered over the other env tables, an IS_NULL entry * unsets the variable */ static THREAD_LOCAL HashTable *sandboxed_env = NULL; @@ -363,7 +377,239 @@ void frankenphp_release_thread_for_kill(force_kill_slot slot) { #endif } +/* Milliseconds on a clock that never goes back, for the deadlines of the + * waits. */ +static int64_t frankenphp_now_ms(void) { +#ifdef PHP_WIN32 + return (int64_t)GetTickCount64(); +#else + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + + return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000; +#endif +} + +/* A thread that waits does so in slices of this long, so that a force-kill no + * signal carries to it is noticed at the next one. */ +#define FRANKENPHP_WAIT_SLICE_MS 1000 + +/* Stop channel of background workers: a socket pair, whose Go end is closed + * on drain so the script's end reaches EOF. A pair rather than a pipe + * because on Windows php_select() only really waits on sockets: before 8.5 + * it reports any other handle as always ready. */ +static void frankenphp_worker_close_sock(php_socket_t s) { + if (s == SOCK_ERR) { + return; + } + /* php_network.h maps closesocket to close outside Windows */ + closesocket(s); +} + +/* keep the pair out of processes the script may spawn: a child holding the Go + * end would keep the script's end from ever reaching EOF */ +static void frankenphp_worker_sock_no_inherit(php_socket_t s) { +#ifdef PHP_WIN32 + SetHandleInformation((HANDLE)s, HANDLE_FLAG_INHERIT, 0); +#else + fcntl(s, F_SETFD, FD_CLOEXEC); +#endif +} + +static void frankenphp_worker_close_stop_socks(void) { + for (int i = 0; i < 2; i++) { + frankenphp_worker_close_sock(worker_stop_socks[i]); + worker_stop_socks[i] = SOCK_ERR; + } +} + +/* Resets the background worker state of the calling thread. The streams of + * WorkerHandle::getStream() do not own the socket, and request shutdown + * destroyed the handles they belong to. */ +static void frankenphp_reset_background_worker(void) { + is_background_worker = false; + worker_ticked = false; + if (worker_name != NULL) { + zend_string_release_ex(worker_name, 1); + worker_name = NULL; + } + frankenphp_worker_close_stop_socks(); +} + +#ifdef PHP_WIN32 +/* Windows has no socketpair() and PHP's emulation is not one: it binds a + * listener to INADDR_ANY, reachable off the machine, and hands back whichever + * connection arrives first. The pair is formed by hand instead, the way + * libevent and Tor do it: loopback alone, and a connection is kept only when + * it comes from the socket we connected with. */ +#define FRANKENPHP_SOCK_PAIR_TRIES 8 + +#ifndef WSA_FLAG_NO_HANDLE_INHERIT +#define WSA_FLAG_NO_HANDLE_INHERIT 0x80 +#endif + +/* Not inheritable from the start: a process another thread spawns before + * SetHandleInformation() would keep a copy, and delay the EOF of the drain + * until it exits. Overlapped, as socket() makes them. */ +static SOCKET frankenphp_sock_win32(void) { + return WSASocketW(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, + WSA_FLAG_OVERLAPPED | WSA_FLAG_NO_HANDLE_INHERIT); +} + +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 = frankenphp_sock_win32(); + if (listener == INVALID_SOCKET) { + return -1; + } + + /* nobody else may take this port from under the listener */ + setsockopt(listener, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, + (const char *)&exclusive, sizeof(exclusive)); + + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; + + if (bind(listener, (struct sockaddr *)&addr, sizeof(addr)) != 0 || + getsockname(listener, (struct sockaddr *)&addr, &addr_len) != 0 || + listen(listener, 1) != 0) { + goto error; + } + + client = frankenphp_sock_win32(); + if (client == INVALID_SOCKET || + connect(client, (struct sockaddr *)&addr, sizeof(addr)) != 0 || + getsockname(client, (struct sockaddr *)&local, &local_len) != 0) { + goto error; + } + + for (int attempt = 0; attempt < FRANKENPHP_SOCK_PAIR_TRIES; attempt++) { + memset(&peer, 0, sizeof(peer)); + peer_len = sizeof(peer); + accepted = accept(listener, (struct sockaddr *)&peer, &peer_len); + if (accepted == INVALID_SOCKET) { + goto error; + } + if (peer.sin_port == local.sin_port && + peer.sin_addr.s_addr == local.sin_addr.s_addr) { + closesocket(listener); + socks[0] = accepted; + socks[1] = client; + + return 0; + } + + /* somebody else got in: drop them and wait for our own connection */ + closesocket(accepted); + accepted = INVALID_SOCKET; + } + +error: + if (listener != INVALID_SOCKET) { + closesocket(listener); + } + if (client != INVALID_SOCKET) { + closesocket(client); + } + if (accepted != INVALID_SOCKET) { + closesocket(accepted); + } + + return -1; +} +#endif + +static int frankenphp_worker_open_stop_pair(void) { +#ifdef PHP_WIN32 + if (frankenphp_sock_pair_win32(worker_stop_socks) != 0) { + return -1; + } +#else +#ifdef SOCK_CLOEXEC + int type = SOCK_STREAM | SOCK_CLOEXEC; +#else + int type = SOCK_STREAM; +#endif + if (socketpair(AF_UNIX, type, 0, worker_stop_socks) != 0) { + worker_stop_socks[0] = SOCK_ERR; + worker_stop_socks[1] = SOCK_ERR; + + return -1; + } +#endif + /* redundant where SOCK_CLOEXEC applied; a fork()ed child (pcntl) still + * inherits both ends and delays the EOF until it exits */ + frankenphp_worker_sock_no_inherit(worker_stop_socks[0]); + frankenphp_worker_sock_no_inherit(worker_stop_socks[1]); + + return 0; +} + +/* Marks the calling thread as the background worker of that name, 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(const char *name, + size_t name_len) { + frankenphp_reset_background_worker(); + is_background_worker = true; + worker_name = zend_string_init(name, name_len, 1); + + if (frankenphp_worker_open_stop_pair() != 0) { + return -1; + } + + /* One wake-up right away, so a script that hands its handle to an event + * loop ticks on its own: readiness then means the loop serviced the handle + * once. Nothing to do on failure, the script has to tick by itself. */ + const char wakeup = '\n'; +#ifdef MSG_NOSIGNAL + send(worker_stop_socks[1], &wakeup, 1, MSG_NOSIGNAL); +#else + send(worker_stop_socks[1], &wakeup, 1, 0); +#endif + + intptr_t s = (intptr_t)worker_stop_socks[1]; + worker_stop_socks[1] = SOCK_ERR; + + return s; +} + +/* Closes the Go side's end of a stop socket pair, which lands as EOF on the + * script's end so its stream_select() or blocking read returns promptly. */ +void frankenphp_worker_close_stop_sock(intptr_t s) { + if (s < 0) { + return; + } + /* A pcntl_fork() child inherits every descriptor of the process, and while + * it holds a copy of this end closing it lands no EOF: shutting the write + * direction down sends the FIN regardless. */ +#ifdef PHP_WIN32 + shutdown((php_socket_t)s, SD_SEND); +#else + shutdown((php_socket_t)s, SHUT_WR); +#endif + frankenphp_worker_close_sock((php_socket_t)s); +} + void frankenphp_update_local_thread_context(bool is_worker) { + /* A thread that ran a background worker can be recycled into an HTTP or a + * request thread: reset the TLS so the handle rejects callers again. */ + if (is_background_worker) { + frankenphp_reset_background_worker(); + } + is_worker_thread = is_worker; /* workers should keep running if the user aborts the connection */ @@ -972,6 +1218,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(); + } + frankenphp_release_super_globals(); if (EG(exception)) { RETURN_THROWS(); @@ -1176,6 +1431,342 @@ static void frankenphp_opcache_restart_hook(int reason) { } #endif +/* Handles of FrankenPHP: objects a script waits on. On PHP 8.6 they implement + * Io\Poll\Handle, so an Io\Poll\Context waits on them next to the script's + * own handles; the interface is a marker, the contract is the three hooks + * below, and the descriptor never reaches userland. Before 8.6 the same hooks + * carry the object's lifetime, in the layout the poll API expects. */ +#if PHP_VERSION_ID >= 80600 +#include +typedef php_poll_handle_object frankenphp_handle_obj; +typedef php_poll_handle_ops frankenphp_handle_ops; +#define FRANKENPHP_HANDLE_OF(zobj) PHP_POLL_HANDLE_OBJ_FROM_ZOBJ(zobj) +#else +typedef struct frankenphp_handle_obj frankenphp_handle_obj; +typedef struct { + php_socket_t (*get_fd)(frankenphp_handle_obj *handle); + int (*is_valid)(frankenphp_handle_obj *handle); + void (*cleanup)(frankenphp_handle_obj *handle); +} frankenphp_handle_ops; +struct frankenphp_handle_obj { + frankenphp_handle_ops *ops; + void *handle_data; + zend_object std; +}; +#define FRANKENPHP_HANDLE_OF(zobj) \ + ((frankenphp_handle_obj *)((char *)(zobj) - \ + offsetof(frankenphp_handle_obj, std))) +#endif + +/* The state a handle carries, NULL when it needs none. */ +#define FRANKENPHP_HANDLE_DATA(zthis) \ + (FRANKENPHP_HANDLE_OF(Z_OBJ_P(zthis))->handle_data) + +static zend_object_handlers frankenphp_handle_obj_handlers; + +static zend_object *frankenphp_handle_obj_create(zend_class_entry *ce, + frankenphp_handle_ops *ops) { +#if PHP_VERSION_ID >= 80600 + frankenphp_handle_obj *obj = + php_poll_handle_object_create(sizeof(*obj), ce, ops); +#else + frankenphp_handle_obj *obj = zend_object_alloc(sizeof(*obj), ce); + + zend_object_std_init(&obj->std, ce); + object_properties_init(&obj->std, ce); + obj->ops = ops; + obj->handle_data = NULL; +#endif + obj->std.handlers = &frankenphp_handle_obj_handlers; + + return &obj->std; +} + +static void frankenphp_handle_obj_free(zend_object *object) { + frankenphp_handle_obj *obj = FRANKENPHP_HANDLE_OF(object); + + if (obj->ops != NULL && obj->ops->cleanup != NULL) { + obj->ops->cleanup(obj); + } + zend_object_std_dtor(&obj->std); +} + +/* Declares Io\Poll\Handle on a handle class: the interface accepts internal + * classes only. PHP 8.6 brings it with the poll API, below it FrankenPHP + * declares it, and the classmap-autoloaded declaration of + * symfony/polyfill-io-poll then never loads. */ +static void frankenphp_handle_implements_poll(zend_class_entry *ce) { + zend_class_entry *poll_handle_ce = zend_hash_str_find_ptr( + CG(class_table), "io\\poll\\handle", sizeof("io\\poll\\handle") - 1); + +#if PHP_VERSION_ID < 80600 + if (poll_handle_ce == NULL) { + zend_class_entry poll_handle; + INIT_NS_CLASS_ENTRY(poll_handle, "Io\\Poll", "Handle", NULL); + poll_handle_ce = zend_register_internal_interface(&poll_handle); + } +#endif + + if (poll_handle_ce != NULL) { + zend_class_implements(ce, 1, poll_handle_ce); + } +} + +/* The socket of the current background worker, invalid outside one and + * once the thread let it go: is_valid() then reports what tick() does. */ +static php_socket_t +frankenphp_worker_handle_get_fd(frankenphp_handle_obj *handle) { + (void)handle; + + return is_background_worker ? worker_stop_socks[0] : SOCK_ERR; +} + +static int frankenphp_worker_handle_is_valid(frankenphp_handle_obj *handle) { + return frankenphp_worker_handle_get_fd(handle) != SOCK_ERR; +} + +/* The stream a script took from this handle, so asking twice hands out the + * same one and a loop does not grow the resource list of a run that never + * ends. On the handle rather than the thread, so it goes away with it. */ +static void frankenphp_worker_handle_cleanup(frankenphp_handle_obj *handle) { + if (handle->handle_data == NULL) { + return; + } + + zend_list_delete(handle->handle_data); + handle->handle_data = NULL; +} + +static frankenphp_handle_ops frankenphp_worker_handle_poll_ops = { + .get_fd = frankenphp_worker_handle_get_fd, + .is_valid = frankenphp_worker_handle_is_valid, + .cleanup = frankenphp_worker_handle_cleanup, +}; + +static zend_object *frankenphp_worker_handle_new(zend_class_entry *ce) { + return frankenphp_handle_obj_create(ce, &frankenphp_worker_handle_poll_ops); +} + +/* Ops of the streams returned by WorkerHandle::getStream(): the socket ops, + * except that closing a stream leaves the socket alone, and that a read which + * would block before the first tick() throws. The socket belongs to the + * thread, every handle of a run shares it, and it is closed at the next run + * setup or on thread exit. Initialized in MINIT. */ +static php_stream_ops frankenphp_worker_handle_ops; + +static int frankenphp_worker_handle_close(php_stream *stream, + int close_handle) { + (void)close_handle; + + /* free the stream data only, never the shared socket */ + return php_stream_socket_ops.close(stream, 0); +} + +/* Before the first tick of a run, the wake-up sent at start is all the handle + * gets until the drain: once a read took it, the next one would park the run + * short of its ready point, and the start with it, for good. */ +static ssize_t frankenphp_worker_handle_read(php_stream *stream, char *buf, + size_t count) { + php_netstream_data_t *sock = (php_netstream_data_t *)stream->abstract; + + if (!worker_ticked && sock->is_blocked && + php_pollfd_for_ms(sock->socket, PHP_POLLREADABLE, 0) <= 0) { + zend_throw_exception(spl_ce_LogicException, + "FrankenPHP\\WorkerHandle: reading the stream would " + "block before the first tick()", + 0); + return -1; + } + + return php_stream_socket_ops.read(stream, buf, count); +} + +/* 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_LogicException, + "FrankenPHP\\WorkerHandle can only be used from a background worker", 0); + + return false; +} + +/* The handle of the current background worker: the stream to wait on and the + * tick that reports the drain. Constructing it outside a background worker + * throws, a run may create as many as it likes. */ +ZEND_METHOD(FrankenPHP_WorkerHandle, __construct) { + ZEND_PARSE_PARAMETERS_NONE(); + + if (!is_background_worker) { + zend_throw_exception( + spl_ce_LogicException, + "FrankenPHP\\WorkerHandle can only be created from a background worker", + 0); + RETURN_THROWS(); + } +} + +ZEND_METHOD(FrankenPHP_WorkerHandle, getName) { + ZEND_PARSE_PARAMETERS_NONE(); + + if (!frankenphp_worker_handle_usable()) { + RETURN_THROWS(); + } + + /* the TLS copy is persistent and goes with the run: return a request one */ + RETURN_STRINGL(ZSTR_VAL(worker_name), ZSTR_LEN(worker_name)); +} + +ZEND_METHOD(FrankenPHP_WorkerHandle, getStream) { + ZEND_PARSE_PARAMETERS_NONE(); + + if (!frankenphp_worker_handle_usable()) { + RETURN_THROWS(); + } + + /* The same resource until the script closes it, so asking for it in a loop + * does not grow the resource list of a run that never ends. The stream does + * not own the socket (see frankenphp_worker_handle_ops), so closing one + * leaves the streams of the other handles alone. */ + frankenphp_handle_obj *handle = FRANKENPHP_HANDLE_OF(Z_OBJ_P(ZEND_THIS)); + if (handle->handle_data != NULL) { + zend_resource *cached = handle->handle_data; + if (cached->type == php_file_le_stream()) { + GC_ADDREF(cached); + RETURN_RES(cached); + } + /* closed by the script: drop the handle's ref */ + zend_list_delete(cached); + handle->handle_data = NULL; + } + + php_stream *stream = + php_stream_sock_open_from_socket(worker_stop_socks[0], NULL); + if (stream == NULL) { + zend_throw_exception(spl_ce_RuntimeException, + "failed to create a stream over the stop socket", 0); + RETURN_THROWS(); + } + + /* a blocking read is a valid way to park: wait without the + * default_socket_timeout wake-ups */ + struct timeval no_timeout = {-1, 0}; + php_stream_set_option(stream, PHP_STREAM_OPTION_READ_TIMEOUT, 0, &no_timeout); + + stream->ops = &frankenphp_worker_handle_ops; + + php_stream_to_zval(stream, return_value); + handle->handle_data = Z_RES_P(return_value); + GC_ADDREF(Z_RES_P(return_value)); +} + +/* Consumes the wake-ups written on the handle, without blocking: false once + * FrankenPHP drained the worker, which closes its end of the socket, or when + * the socket is broken and carries no drain anymore. */ +static bool frankenphp_worker_handle_consume(void) { + 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) { + return false; + } + if (n < 0) { + int err = php_socket_errno(); + + return err == EINTR || PHP_IS_TRANSIENT_ERROR(err); + } + } +} + +/* Waits for the handle to become readable, which is FrankenPHP needing the + * script, its drain included: timeout_ms at most, without a limit when + * negative. */ +static void frankenphp_worker_handle_wait(int timeout_ms) { + int64_t deadline = timeout_ms < 0 ? -1 : frankenphp_now_ms() + timeout_ms; + while (!zend_atomic_bool_load(&EG(timed_out))) { + int slice = FRANKENPHP_WAIT_SLICE_MS; + if (deadline >= 0) { + int64_t remaining = deadline - frankenphp_now_ms(); + if (remaining <= 0) { + return; + } + if (remaining < slice) { + slice = (int)remaining; + } + } + int n = php_pollfd_for_ms(worker_stop_socks[0], PHP_POLLREADABLE, slice); + if (n > 0 || (n < 0 && php_socket_errno() != EINTR)) { + /* readable, or broken: the caller tells which */ + return; + } + } +} + +/* 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 + * hands out work, and never waits unless it is given a timeout: a script + * with more to wait on waits on its handle, or 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) { + double timeout = 0; + bool timeout_is_null = false; + ZEND_PARSE_PARAMETERS_START(0, 1) + Z_PARAM_OPTIONAL + Z_PARAM_DOUBLE_OR_NULL(timeout, timeout_is_null) + ZEND_PARSE_PARAMETERS_END(); + + if (!frankenphp_worker_handle_usable()) { + RETURN_THROWS(); + } + if (!timeout_is_null && (zend_isnan(timeout) || timeout < 0)) { + zend_argument_value_error(1, "must be greater than or equal to 0"); + RETURN_THROWS(); + } + + if (!worker_ticked) { + worker_ticked = true; + /* The bootstrap ran under max_execution_time like any request, the loop + * that starts now has no time limit, like the CLI. Nothing re-arms the + * timer past this point. */ + zend_unset_timeout(); + go_frankenphp_background_worker_ready(frankenphp_thread_index()); + } + + if (!frankenphp_worker_handle_consume()) { + RETURN_FALSE; + } + /* 0 never waits; null, or past what a duration holds, waits until + * FrankenPHP needs the script. What was written before the call was for a + * script that waits on its handle, and does not cut this wait short */ + if (timeout_is_null || timeout > 0) { + int timeout_ms = -1; + if (!timeout_is_null && timeout * 1000 < (double)INT_MAX) { + /* a wait shorter than the clock's grain still waits */ + timeout_ms = timeout < 0.001 ? 1 : (int)(timeout * 1000); + } + frankenphp_worker_handle_wait(timeout_ms); + + RETURN_BOOL(frankenphp_worker_handle_consume()); + } + + RETURN_TRUE; +} + /* {{{ thread-safe opcache reset */ PHP_FUNCTION(frankenphp_opcache_reset) { go_schedule_opcache_reset(frankenphp_thread_index()); @@ -1236,9 +1827,23 @@ static const zend_function_entry frankenphp_test_hook_functions[] = { static zend_class_entry *frankenphp_ce_request_body_too_large_exception; PHP_MINIT_FUNCTION(frankenphp) { + frankenphp_worker_handle_ops = php_stream_socket_ops; + frankenphp_worker_handle_ops.label = "FrankenPHP worker handle"; + frankenphp_worker_handle_ops.close = frankenphp_worker_handle_close; + frankenphp_worker_handle_ops.read = frankenphp_worker_handle_read; + + 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); frankenphp_ce_request_body_too_large_exception = register_class_FrankenPHP_RequestBodyTooLargeException(zend_ce_exception); + + 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; @@ -1985,6 +2590,13 @@ static void *php_thread(void *arg) { } zend_end_try(); + /* frankenphp_update_local_thread_context() only releases the stop socket on + * recycle: close it here too, so it does not outlive the thread on shutdown, + * reboot or an unhealthy exit. */ + if (is_background_worker) { + frankenphp_reset_background_worker(); + } + /* Must precede ts_free_thread: that frees the TSRM storage backing * the slot's &EG() pointers. Clearing first means any concurrent * force-kill either ran before us or sees a zero slot. */ diff --git a/frankenphp.go b/frankenphp.go index f18ed6c999..e53f86fc0a 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -29,6 +29,7 @@ import ( "os" "os/signal" "runtime" + "slices" "strings" "sync" "sync/atomic" @@ -225,7 +226,11 @@ func calculateMaxThreads(opt *opt) (numWorkers int, _ error) { maxThreadsFromWorkers := 0 for i, w := range opt.workers { - if w.num <= 0 { + if w.num <= 0 && w.isBackgroundWorker { + // a background worker serves no requests, so it does not scale + // with the CPUs like an HTTP one + opt.workers[i].num = 1 + } else if w.num <= 0 { // https://github.com/php/frankenphp/issues/126 opt.workers[i].num = maxProcs } @@ -341,7 +346,7 @@ func Validate(options ...Option) error { } takenNames[w.name] = true - if w.matchRequest == nil { + if !w.isBackgroundWorker && w.matchRequest == nil { if takenPaths[w.server] == nil { takenPaths[w.server] = make(map[string]bool) } @@ -425,6 +430,11 @@ func Init(options ...Option) error { } } else { opt.numThreads = 1 + // a background worker would take the only thread + if slices.ContainsFunc(opt.workers, func(w workerOpt) bool { return w.isBackgroundWorker }) { + shutdown() + return errors.New("background workers need a ZTS build of PHP, this one runs a single thread") + } 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`) diff --git a/frankenphp.h b/frankenphp.h index fba2078305..ee2bac2b0f 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -235,6 +235,11 @@ typedef enum { FRANKENPHP_MERCURE_UNSUPPORTED = 4, } frankenphp_mercure_status; +/* Background worker primitives. */ +intptr_t frankenphp_set_background_worker_and_get_stop_sock(const char *name, + size_t name_len); +void frankenphp_worker_close_stop_sock(intptr_t s); + void register_extensions(zend_module_entry **m, int len); // This function is exported for use by extensions, as proposed in PR #2305 diff --git a/frankenphp.stub.php b/frankenphp.stub.php index 0fc316d40a..8f3d58168d 100644 --- a/frankenphp.stub.php +++ b/frankenphp.stub.php @@ -63,4 +63,57 @@ function frankenphp_log(string $message, int $level = 0, array $context = []): v namespace FrankenPHP { /** Thrown when reading a request body larger than the configured limit */ class RequestBodyTooLargeException extends \Exception {} + + /** + * @strict-properties + * @not-serializable + * + * EXPERIMENTAL: the handle of the current background worker, the one + * point where the script and FrankenPHP meet. Constructing it outside + * a background worker throws. It implements Io\Poll\Handle, so an + * Io\Poll\Context waits on it directly, the one of PHP 8.6 or the one + * of symfony/polyfill-io-poll below that. + */ + final class WorkerHandle + { + public function __construct() {} + + /** + * The name of the worker, as declared or generated from the script + * path: what tells apart the workers running the same script. + */ + public function getName(): string {} + + /** + * 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 + * hands out work. Given a timeout, in seconds, it waits that long + * at most for FrankenPHP to need the script, without a limit when + * null: all a script with nothing else to wait on needs. Without + * one it never waits: the script waits on the handle, or on the + * stream of getStream(), and calls this when it is readable, which + * also consumes whatever FrankenPHP wrote there. + */ + public function tick(?float $timeout = 0.0): 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(), and throws before the first tick() when the + * read would block. Writing to it goes nowhere. Closing it is + * safe: a handle hands out one stream, a fresh one over the same + * socket once the script closed it. + * + * @return resource + */ + public function getStream() {} + } } diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index f6ddc4696e..7c77f041ce 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: 57299a756b23c7443d6b09f759106ddf0ec8df20 */ + * Stub hash: e3269e20cf63fd5db56edf049e496ccfc7795a45 */ 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,18 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_log, 0, 1, IS_VOID, 0 ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, context, IS_ARRAY, 0, "[]") ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_INFO_EX(arginfo_class_FrankenPHP_WorkerHandle___construct, 0, 0, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_FrankenPHP_WorkerHandle_getName, 0, 0, IS_STRING, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_FrankenPHP_WorkerHandle_tick, 0, 0, _IS_BOOL, 0) + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, timeout, IS_DOUBLE, 1, "0.0") +ZEND_END_ARG_INFO() + +#define arginfo_class_FrankenPHP_WorkerHandle_getStream arginfo_class_FrankenPHP_WorkerHandle___construct + ZEND_FUNCTION(frankenphp_handle_request); ZEND_FUNCTION(headers_send); ZEND_FUNCTION(frankenphp_finish_request); @@ -48,6 +60,10 @@ ZEND_FUNCTION(frankenphp_request_headers); ZEND_FUNCTION(frankenphp_response_headers); ZEND_FUNCTION(mercure_publish); ZEND_FUNCTION(frankenphp_log); +ZEND_METHOD(FrankenPHP_WorkerHandle, __construct); +ZEND_METHOD(FrankenPHP_WorkerHandle, getName); +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) @@ -80,6 +96,14 @@ static const zend_function_entry ext_functions[] = { ZEND_FE_END }; +static const zend_function_entry class_FrankenPHP_WorkerHandle_methods[] = { + ZEND_ME(FrankenPHP_WorkerHandle, __construct, arginfo_class_FrankenPHP_WorkerHandle___construct, ZEND_ACC_PUBLIC) + ZEND_ME(FrankenPHP_WorkerHandle, getName, arginfo_class_FrankenPHP_WorkerHandle_getName, ZEND_ACC_PUBLIC) + ZEND_ME(FrankenPHP_WorkerHandle, tick, arginfo_class_FrankenPHP_WorkerHandle_tick, ZEND_ACC_PUBLIC) + ZEND_ME(FrankenPHP_WorkerHandle, getStream, arginfo_class_FrankenPHP_WorkerHandle_getStream, ZEND_ACC_PUBLIC) + ZEND_FE_END +}; + static void register_frankenphp_symbols(int module_number) { REGISTER_LONG_CONSTANT("FRANKENPHP_LOG_LEVEL_DEBUG", -4, CONST_PERSISTENT); @@ -101,3 +125,18 @@ static zend_class_entry *register_class_FrankenPHP_RequestBodyTooLargeException( return class_entry; } + +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); +#if (PHP_VERSION_ID >= 80400) + class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE); +#else + class_entry = zend_register_internal_class_ex(&ce, NULL); + class_entry->ce_flags |= ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE; +#endif + + return class_entry; +} diff --git a/metrics.go b/metrics.go index 852305aa35..a27e02630d 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 WorkerHandle::tick() for background workers ) type StopReason int @@ -161,7 +161,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() } @@ -194,7 +194,7 @@ func (m *PrometheusMetrics) TotalWorkers(string, int) { m.readyWorkers = prometheus.NewGaugeVec(prometheus.GaugeOpts{ Namespace: ns, Name: "ready_workers", - Help: "Running workers that have successfully called frankenphp_handle_request at least once", + Help: "Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, WorkerHandle::tick() for background workers", }, basicLabels) m.mustRegister(m.readyWorkers) } diff --git a/metrics_test.go b/metrics_test.go index 4d5ec1a637..5910c67808 100644 --- a/metrics_test.go +++ b/metrics_test.go @@ -188,7 +188,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, WorkerHandle::tick() for background workers # TYPE frankenphp_ready_workers gauge `, expect: ` diff --git a/options.go b/options.go index 40f50a17ee..9ab4863131 100644 --- a/options.go +++ b/options.go @@ -58,6 +58,9 @@ type workerOpt struct { onServerStartup func() onServerShutdown func() server *Server + isBackgroundWorker bool + bootTimeout time.Duration + bootTimeoutIsSet bool } // WithContext sets the main context to use. @@ -242,6 +245,37 @@ func WithWorkerServerScope(s *Server) WorkerOption { } } +// EXPERIMENTAL: WithWorkerBackground marks this worker as a background +// (non-HTTP) worker: it shares the PHP runtime with the HTTP threads but +// never receives requests. Its script ticks a FrankenPHP\WorkerHandle: the +// first tick() marks the worker ready, and tick() returns false once +// FrankenPHP drains the worker, on shutdown or restart. +func WithWorkerBackground() WorkerOption { + return func(w *workerOpt) error { + w.isBackgroundWorker = true + + return nil + } +} + +// EXPERIMENTAL: WithWorkerBootTimeout bounds how long a background worker may +// take to reach its ready point, its first WorkerHandle::tick(), before Init() +// gives up and returns an error. Zero waits for ever. Defaults to 30 seconds +// on PHP builds without Zend max execution timers, and to zero elsewhere, +// where max_execution_time bounds the bootstrap. It has no effect on HTTP +// workers. +func WithWorkerBootTimeout(timeout time.Duration) WorkerOption { + return func(w *workerOpt) error { + if timeout < 0 { + return fmt.Errorf("worker boot timeout must be >= 0, got %s", timeout) + } + w.bootTimeout = timeout + w.bootTimeoutIsSet = true + + return nil + } +} + // WithWorkerMaxFailures sets the maximum number of consecutive failures before panicking func WithWorkerMaxFailures(maxFailures int) WorkerOption { return func(w *workerOpt) error { diff --git a/phpmainthread.go b/phpmainthread.go index 40adf13c21..d000141839 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -167,7 +167,7 @@ func (mainThread *phpMainThread) rebootAllThreads() bool { for _, thread := range rebootingThreads { rebootWg.Go(func() { - close(thread.drainChan) + thread.drain() if thread.state.WaitForStateWithTimeout(rebootGracePeriod, state.YieldingForReboot) { return } diff --git a/phpmainthread_test.go b/phpmainthread_test.go index a0682f092f..ee5099b6d9 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -357,6 +357,13 @@ func TestCorrectThreadCalculation(t *testing.T) { testThreadCalculation(t, 2, 5, &opt{maxThreads: 5, workers: []workerOpt{{num: 1, maxThreads: 3}}}) testThreadCalculation(t, 3, 5, &opt{maxThreads: 5, workers: []workerOpt{{num: 1, maxThreads: 4}, {num: 1, maxThreads: 4}}}) + // a background worker counts like any worker, with one thread unless told otherwise + oneBackgroundWorker := []workerOpt{{isBackgroundWorker: true}} + testThreadCalculation(t, maxProcs, maxProcs, &opt{workers: oneBackgroundWorker}) + testThreadCalculation(t, 2, 2, &opt{numThreads: 1, workers: oneBackgroundWorker}) + testThreadCalculation(t, 2, 4, &opt{maxThreads: 4, workers: oneBackgroundWorker}) + testThreadCalculationError(t, &opt{numThreads: 1, maxThreads: 1, workers: oneBackgroundWorker}) + // a worker never eats into num_threads, so there is no floor to miss; // max_threads still has to fit what starts testThreadCalculationError(t, &opt{numThreads: 1, maxThreads: 1, workers: oneWorkerThread}) @@ -512,6 +519,31 @@ func TestRebootThreadCreationFailure(t *testing.T) { } } +// a background worker gets its thread back too +func TestRebootThreadCreationFailureBackgroundWorker(t *testing.T) { + assert.NoError(t, Init(WithNumThreads(1), WithWorkers("basic", filepath.Join(testDataPath, "bgworker", "basic.php"), 1, WithWorkerBackground()))) + + var failing atomic.Bool + failing.Store(true) + original := newCThread + newCThread = func(threadIndex int) bool { return !failing.Load() && original(threadIndex) } + t.Cleanup(func() { newCThread = original }) + t.Cleanup(Shutdown) + + w := workers[0] + thread := w.threadsSnapshot()[0] + RestartWorkers() + thread.state.WaitFor(state.Reserved) + assert.NotContains(t, w.threadsSnapshot(), thread) + + failing.Store(false) + assert.Eventually(t, func() bool { + threads := w.threadsSnapshot() + + return len(threads) == 1 && threads[0].state.Is(state.Ready) + }, 10*time.Second, 10*time.Millisecond, "the pool must get its thread back once threads can be created again") +} + func TestFailedRebootDoesNotBlockHandlerChange(t *testing.T) { assert.NoError(t, Init(WithNumThreads(1), WithMaxRequests(1))) diff --git a/phpthread.go b/phpthread.go index 8f324af95c..2c3d1d10fb 100644 --- a/phpthread.go +++ b/phpthread.go @@ -41,11 +41,8 @@ type threadHandler interface { beforeScriptExecution() string afterScriptExecution(exitStatus int) frankenPHPContext() *frankenPHPContext - // drain is a hook called by drainWorkerThreads right before drainChan is - // closed. Handlers that need to wake up a thread parked in a blocking C - // call (e.g. by closing a stop pipe) plug their signal in here. All - // current handlers are no-ops; this is the seam later handler types use - // without having to modify drainWorkerThreads. + // called right before drainChan is closed, for the handlers that have a + // thread parked in a blocking C call to wake up; a no-op for the others drain() } @@ -153,6 +150,13 @@ func (thread *phpThread) releaseAfterFailedReboot(handler threadHandler) { } w.detachThread(thread) go restoreThread(mt, w.num, w.threadsSnapshot, func() (*phpThread, error) { return addWorkerThread(w) }) + case *backgroundWorkerThread: + w := handler.worker + if w.onThreadShutdown != nil { + w.onThreadShutdown(thread.threadIndex) + } + w.detachThread(thread) + go restoreThread(mt, w.num, w.threadsSnapshot, func() (*phpThread, error) { return addBackgroundWorkerThread(w) }) } thread.state.Set(state.Reserved) @@ -230,7 +234,7 @@ func (thread *phpThread) shutdown() { return } - close(thread.drainChan) + thread.drain() thread.waitForExit(state.Done) @@ -275,7 +279,7 @@ func (thread *phpThread) setHandler(handler threadHandler) { return } - close(thread.drainChan) + thread.drain() thread.state.WaitFor(state.TransitionInProgress) thread.handler = handler @@ -283,6 +287,14 @@ func (thread *phpThread) setHandler(handler threadHandler) { thread.state.Set(state.TransitionComplete) } +// drain tells the handler to yield: drainChan wakes it up from a Go wait, the +// handler hook from a blocking C call, so a thread parked either way leaves +// without waiting for the force-kill +func (thread *phpThread) drain() { + thread.handler.drain() + close(thread.drainChan) +} + // transition to a new handler safely // is triggered by setHandler and executed on the PHP thread func (thread *phpThread) transitionToNewHandler() string { diff --git a/requestoptions.go b/requestoptions.go index 6792727939..be9877a1f7 100644 --- a/requestoptions.go +++ b/requestoptions.go @@ -224,9 +224,14 @@ func WithWorkerName(name string) RequestOption { return nil } - if o.worker = workersByName[name]; o.worker == nil { + w := workersByName[name] + if w == nil { return fmt.Errorf("%w: %q", ErrUnknownWorker, name) } + if w.isBackgroundWorker { + return fmt.Errorf("background worker %q cannot handle requests", name) + } + o.worker = w return nil } diff --git a/server.go b/server.go index e757ab37f6..ef25cdc30f 100644 --- a/server.go +++ b/server.go @@ -132,6 +132,12 @@ func (s *Server) Name() string { func (s *Server) addWorker(w *worker) error { s.workers = append(s.workers, 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 diff --git a/testdata/bgworker/basic.php b/testdata/bgworker/basic.php new file mode 100644 index 0000000000..fcfaffc7df --- /dev/null +++ b/testdata/bgworker/basic.php @@ -0,0 +1,21 @@ +getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/count.php b/testdata/bgworker/count.php new file mode 100644 index 0000000000..cb7d1e2f86 --- /dev/null +++ b/testdata/bgworker/count.php @@ -0,0 +1,15 @@ +getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/crash-after-ready.php b/testdata/bgworker/crash-after-ready.php new file mode 100644 index 0000000000..633f7af61c --- /dev/null +++ b/testdata/bgworker/crash-after-ready.php @@ -0,0 +1,9 @@ +tick(); +exit(1); diff --git a/testdata/bgworker/crash.php b/testdata/bgworker/crash.php new file mode 100644 index 0000000000..30eb27d664 --- /dev/null +++ b/testdata/bgworker/crash.php @@ -0,0 +1,31 @@ +getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/cycle.php b/testdata/bgworker/cycle.php new file mode 100644 index 0000000000..eb86c364c8 --- /dev/null +++ b/testdata/bgworker/cycle.php @@ -0,0 +1,9 @@ +tick(); +usleep(150000); diff --git a/testdata/bgworker/early-return.php b/testdata/bgworker/early-return.php new file mode 100644 index 0000000000..590cd686ff --- /dev/null +++ b/testdata/bgworker/early-return.php @@ -0,0 +1,5 @@ +tick(); diff --git a/testdata/bgworker/fail-then-succeed.php b/testdata/bgworker/fail-then-succeed.php new file mode 100644 index 0000000000..6897a237e0 --- /dev/null +++ b/testdata/bgworker/fail-then-succeed.php @@ -0,0 +1,18 @@ +getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/fetch-no-tick.php b/testdata/bgworker/fetch-no-tick.php new file mode 100644 index 0000000000..92c7e8c639 --- /dev/null +++ b/testdata/bgworker/fetch-no-tick.php @@ -0,0 +1,7 @@ + $_SERVER['FRANKENPHP_WORKER'] ?? 'unset', + 'background' => $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] ?? 'unset', + 'name' => $handle->getName(), +], true)); +$stream = $handle->getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/loop.php b/testdata/bgworker/loop.php new file mode 100644 index 0000000000..8e9c8d9b12 --- /dev/null +++ b/testdata/bgworker/loop.php @@ -0,0 +1,16 @@ +getStream(); +do { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} while ($handle->tick()); diff --git a/testdata/bgworker/named.php b/testdata/bgworker/named.php new file mode 100644 index 0000000000..9edba369e0 --- /dev/null +++ b/testdata/bgworker/named.php @@ -0,0 +1,19 @@ +getName()); +} + +$stream = $handle->getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/never-ticks.php b/testdata/bgworker/never-ticks.php new file mode 100644 index 0000000000..6a8347015c --- /dev/null +++ b/testdata/bgworker/never-ticks.php @@ -0,0 +1,24 @@ +getStream(); +fgets($stream); +$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 new file mode 100644 index 0000000000..3585f8a19f --- /dev/null +++ b/testdata/bgworker/no-time-limit.php @@ -0,0 +1,12 @@ +tick(); +fgets($handle->getStream()); diff --git a/testdata/bgworker/poll-interface.php b/testdata/bgworker/poll-interface.php new file mode 100644 index 0000000000..917ea897f1 --- /dev/null +++ b/testdata/bgworker/poll-interface.php @@ -0,0 +1,16 @@ + $handle instanceof \Io\Poll\Handle, + 'internal' => (new ReflectionClass(\Io\Poll\Handle::class))->isInternal(), +])); +$stream = $handle->getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/poll.php b/testdata/bgworker/poll.php new file mode 100644 index 0000000000..ee8d50a8a5 --- /dev/null +++ b/testdata/bgworker/poll.php @@ -0,0 +1,14 @@ +add($handle, [\Io\Poll\Event::Read]); + +while ($handle->tick()) { + file_put_contents($_SERVER['BG_SENTINEL'], $poll->getBackend()->name); + $poll->wait(); +} diff --git a/testdata/bgworker/pool.php b/testdata/bgworker/pool.php new file mode 100644 index 0000000000..d9d53024ef --- /dev/null +++ b/testdata/bgworker/pool.php @@ -0,0 +1,13 @@ + 1 threads share the name; each touches a file of +// its own under BG_SENTINEL_DIR, then parks on its own handle. +set_time_limit(0); +@touch($_SERVER['BG_SENTINEL_DIR'] . DIRECTORY_SEPARATOR . bin2hex(random_bytes(8))); +$handle = new \FrankenPHP\WorkerHandle(); +$stream = $handle->getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/read-before-tick.php b/testdata/bgworker/read-before-tick.php new file mode 100644 index 0000000000..e46b44e8bd --- /dev/null +++ b/testdata/bgworker/read-before-tick.php @@ -0,0 +1,14 @@ +getStream(); +fgets($stream); +try { + fgets($stream); +} catch (\LogicException $e) { + file_put_contents($_SERVER['BG_SENTINEL'], $e->getMessage()); + + throw $e; +} diff --git a/testdata/bgworker/read.php b/testdata/bgworker/read.php new file mode 100644 index 0000000000..f90affa1f1 --- /dev/null +++ b/testdata/bgworker/read.php @@ -0,0 +1,11 @@ +tick(); +fgets($handle->getStream()); diff --git a/testdata/bgworker/readable.php b/testdata/bgworker/readable.php new file mode 100644 index 0000000000..86db0d0761 --- /dev/null +++ b/testdata/bgworker/readable.php @@ -0,0 +1,26 @@ +getStream(); +$poll = static function () use ($stream): string { + $read = [$stream]; + $write = $except = null; + + return stream_select($read, $write, $except, 0) > 0 ? 'readable' : 'quiet'; +}; + +$seen = ['start:' . $poll()]; +$handle->tick(); +$seen[] = 'after tick:' . $poll(); +$handle->tick(); +$seen[] = 'after second tick:' . $poll(); +file_put_contents($_SERVER['BG_SENTINEL'], implode(' ', $seen)); + +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/recv.php b/testdata/bgworker/recv.php new file mode 100644 index 0000000000..048066c6a7 --- /dev/null +++ b/testdata/bgworker/recv.php @@ -0,0 +1,12 @@ +tick(); +stream_socket_recvfrom($handle->getStream(), 1); diff --git a/testdata/bgworker/refetch.php b/testdata/bgworker/refetch.php new file mode 100644 index 0000000000..a0948e4e44 --- /dev/null +++ b/testdata/bgworker/refetch.php @@ -0,0 +1,21 @@ +getStream(); +$again = $handle->getStream(); +$result = $first === $again ? 'same' : 'different'; + +fclose($first); +$third = $handle->getStream(); +$result .= $third === $first ? ' then same' : ' then fresh'; + +$other = (new \FrankenPHP\WorkerHandle())->getStream(); +$result .= $other === $third ? ' then shared' : ' then its own'; + +file_put_contents($_SERVER['BG_SENTINEL'], $result); +$handle->tick(); +fgets($third); diff --git a/testdata/bgworker/slow-boot.php b/testdata/bgworker/slow-boot.php new file mode 100644 index 0000000000..56fc7b4a9c --- /dev/null +++ b/testdata/bgworker/slow-boot.php @@ -0,0 +1,11 @@ +tick(); diff --git a/testdata/bgworker/stuck.php b/testdata/bgworker/stuck.php new file mode 100644 index 0000000000..578986908d --- /dev/null +++ b/testdata/bgworker/stuck.php @@ -0,0 +1,19 @@ +tick(); +if (!file_exists($_SERVER['BG_ONCE'])) { + @touch($_SERVER['BG_ONCE']); + sleep(60); +} +@touch($_SERVER['BG_SENTINEL']); +$stream = $handle->getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/tick-wait.php b/testdata/bgworker/tick-wait.php new file mode 100644 index 0000000000..1846a041ba --- /dev/null +++ b/testdata/bgworker/tick-wait.php @@ -0,0 +1,17 @@ +tick(-1.0); +} catch (\ValueError $e) { + file_put_contents($_SERVER['BG_SENTINEL'], $e->getMessage() . "\n", FILE_APPEND); +} +do { + $start = hrtime(true); + $running = $handle->tick($timeout); + file_put_contents($_SERVER['BG_SENTINEL'], sprintf("%s %d\n", $running ? 'tick' : 'drained', (hrtime(true) - $start) / 1e6), FILE_APPEND); +} while ($running); diff --git a/testdata/bgworker/tick.php b/testdata/bgworker/tick.php new file mode 100644 index 0000000000..ed9f24d02e --- /dev/null +++ b/testdata/bgworker/tick.php @@ -0,0 +1,17 @@ +tick() ? 'true' : 'false'; +$seen[] = $handle->tick() ? 'true' : 'false'; +file_put_contents($_SERVER['BG_SENTINEL'], implode(' ', $seen)); + +$read = [$handle->getStream()]; +$write = $except = null; +stream_select($read, $write, $except, null); + +$seen[] = $handle->tick() ? 'true' : 'false'; +$seen[] = $handle->tick() ? 'true' : 'false'; +file_put_contents($_SERVER['BG_SENTINEL'], implode(' ', $seen)); diff --git a/testdata/handle-bypass.php b/testdata/handle-bypass.php new file mode 100644 index 0000000000..93dea76c33 --- /dev/null +++ b/testdata/handle-bypass.php @@ -0,0 +1,18 @@ +getMessage(), "\n"; + } +}; + +$report('unserialize', static fn () => unserialize('O:23:"FrankenPHP\WorkerHandle":0:{}')); +$report('reflection', static fn () => (new ReflectionClass(\FrankenPHP\WorkerHandle::class))->newInstanceWithoutConstructor()); +$report('construct', static fn () => new \FrankenPHP\WorkerHandle()); diff --git a/testdata/handle-outside.php b/testdata/handle-outside.php new file mode 100644 index 0000000000..22d96ed37b --- /dev/null +++ b/testdata/handle-outside.php @@ -0,0 +1,8 @@ +getMessage(); +} diff --git a/testdata/worker-name.php b/testdata/worker-name.php new file mode 100644 index 0000000000..b9f15a047d --- /dev/null +++ b/testdata/worker-name.php @@ -0,0 +1,11 @@ += 0 { + C.frankenphp_worker_close_stop_sock(C.intptr_t(s)) + } +} + +func (handler *backgroundWorkerThread) beforeScriptExecution() string { + return handler.workerLifecycle.beforeScriptExecution(handler.startScript) +} + +// startScript keeps trying to start the script: unlike an HTTP worker, whose +// setup cannot fail, a background worker needs a socket pair per run +func (handler *backgroundWorkerThread) startScript() string { + for { + err := handler.setupScript() + if err == nil { + return handler.worker.fileName + } + + if globalLogger.Enabled(globalCtx, slog.LevelError) { + globalLogger.LogAttrs(globalCtx, slog.LevelError, "failed to start background worker", slog.String("worker", handler.worker.name), slog.Int("thread", handler.thread.threadIndex), slog.Any("error", err)) + } + + // fail fast during startup so Init() surfaces the error to the + // operator; past startup, back off and retry like a crash + if reportStartupFailure(err) { + handler.thread.state.Set(state.ShuttingDown) + + return handler.beforeScriptExecution() + } + + handler.backoff() + if !handler.state.Is(state.Ready) && !handler.state.Is(state.TransitionComplete) { + // drained during the backoff (shutdown, reboot, transition) + return handler.beforeScriptExecution() + } + } +} + +// setupScript marks the thread as a background worker on the C side and takes +// ownership of the Go side's end of its stop socket pair. +func (handler *backgroundWorkerThread) setupScript() error { + name := handler.worker.name + s := int64(C.frankenphp_set_background_worker_and_get_stop_sock(toUnsafeChar(name), C.size_t(len(name)))) + if s < 0 { + return fmt.Errorf("failed to create the stop socket pair of background worker %q", handler.worker.name) + } + handler.stopSock.Store(s) + + switch handler.state.Get() { + case state.ShuttingDown, state.Rebooting, state.ForceRebooting, state.TransitionRequested: + // a concurrent drain may have run before the socket was published; + // close it now so the script observes EOF immediately + handler.drain() + } + + fc, err := newWorkerDummyContext(handler.worker) + if err != nil { + handler.drain() + return err + } + handler.thread.contextMu.Lock() + handler.context = fc + handler.thread.contextMu.Unlock() + + handler.isBootingScript = true + handler.runStartedAt = time.Now() + metrics.StartWorker(handler.worker.name) + // 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.name, handler.thread.threadIndex + handler.bootTimer = time.AfterFunc(backgroundBootWarnDelay, func() { + if logger.Enabled(ctx, slog.LevelWarn) { + logger.LogAttrs(ctx, slog.LevelWarn, "background worker has not called WorkerHandle::tick() yet, it is not ready", slog.String("worker", name), slog.Int("thread", threadIndex)) + } + }) + + if fc.logger.Enabled(fc.ctx, slog.LevelDebug) { + fc.logger.LogAttrs(fc.ctx, slog.LevelDebug, "starting background worker", slog.String("worker", handler.worker.name), slog.Int("thread", handler.thread.threadIndex)) + } + + // the thread stays in TransitionComplete until the script calls + // WorkerHandle::tick(), see go_frankenphp_background_worker_ready + + return nil +} + +func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { + // release the socket on every exit path so the next run gets a fresh + // pair; drain() already took it when the exit was drain-triggered + handler.drain() + worker := handler.worker + handler.thread.contextMu.Lock() + handler.context = nil + handler.thread.contextMu.Unlock() + + handler.stopBootTimer() + handler.state.MarkAsWaiting(false) + + // exit past the ready point, cooperative or a crash: re-run the script + // without counting toward max_consecutive_failures, that cap is about a + // script that never boots. An HTTP worker is paced by traffic, a + // background one reaches its ready point on its own, so pace it here + if !handler.isBootingScript { + if exitStatus == 0 { + metrics.StopWorker(worker.name, StopReasonRestart) + + if globalLogger.Enabled(globalCtx, slog.LevelDebug) { + globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "restarting background worker", slog.String("worker", worker.name), slog.Int("thread", handler.thread.threadIndex)) + } + } else { + metrics.StopWorker(worker.name, StopReasonCrash) + + if globalLogger.Enabled(globalCtx, slog.LevelWarn) { + globalLogger.LogAttrs(globalCtx, slog.LevelWarn, "background worker crashed, restarting", slog.String("worker", worker.name), slog.Int("thread", handler.thread.threadIndex), slog.Int("exit_status", exitStatus), slog.Int("crashes", handler.crashCount)) + } + } + + // a worker processing a batch and returning is not a worker spinning + // on an immediate exit; a crash never resets the count, however long + // the run lasted + if exitStatus == 0 && time.Since(handler.runStartedAt) > minHealthyRun { + handler.crashCount = 0 + } + handler.wait(restartBackoff(handler.crashCount)) + handler.crashCount++ + + return + } + + // the script exited before calling WorkerHandle::tick(), a clean exit + // included; StopReasonBootFailure skips the ready-gauge decrement, + // matching the ReadyWorker call that never happened + metrics.StopWorker(worker.name, StopReasonBootFailure) + + // max_consecutive_failures only fails hard during startup, through + // startupFailChan; past it a failing worker keeps restarting with a + // louder log line rather than leave the server in a broken half-state. + // A worker Init() gave up on stops here whatever the cap says, its + // timeout already reported it + pastCap := worker.maxConsecutiveFailures >= 0 && handler.failureCount >= worker.maxConsecutiveFailures + if worker.bootTimedOut.Load() { + handler.thread.state.Set(state.ShuttingDown) + + return + } + if pastCap && !watcherIsEnabled { + var err error + if exitStatus == 0 { + err = fmt.Errorf("background worker %s exits without calling WorkerHandle::tick()", worker.fileName) + } else { + err = fmt.Errorf("too many consecutive failures: background worker %s keeps crashing", worker.fileName) + } + if reportStartupFailure(err) { + handler.thread.state.Set(state.ShuttingDown) + return + } + } + + logLevel := slog.LevelWarn + logMsg := "background worker failed before calling WorkerHandle::tick(), restarting" + if exitStatus == 0 { + logMsg = "background worker exited without calling WorkerHandle::tick(), restarting" + } + if pastCap { + logLevel = slog.LevelError + logMsg = "background worker exceeded max_consecutive_failures, still restarting" + } + if globalLogger.Enabled(globalCtx, logLevel) { + globalLogger.LogAttrs(globalCtx, logLevel, logMsg, slog.String("worker", worker.name), slog.Int("thread", handler.thread.threadIndex), slog.Int("failures", handler.failureCount), slog.Int("exit_status", exitStatus)) + } + + handler.backoff() +} + +func (handler *backgroundWorkerThread) stopBootTimer() { + if handler.bootTimer != nil { + handler.bootTimer.Stop() + handler.bootTimer = nil + } +} + +//export go_frankenphp_background_worker_ready +func go_frankenphp_background_worker_ready(threadIndex C.uintptr_t) { + // called on the PHP thread by the first WorkerHandle::tick() of a run; + // that function throws on every other thread kind + handler, ok := phpThreads[threadIndex].handler.(*backgroundWorkerThread) + if !ok { + panic("WorkerHandle::tick() called on a thread that is not a background worker") + } + + if handler.isBootingScript { + handler.isBootingScript = false + // the boot succeeded, only consecutive boot failures count + handler.failureCount = 0 + handler.stopBootTimer() + metrics.ReadyWorker(handler.worker.name) + // parked from now on as far as the threads state endpoint is concerned + handler.state.MarkAsWaiting(true) + + // initWorkers() waits for this state, so a script that fails before + // its first tick still fails Init() + if handler.state.Is(state.TransitionComplete) { + handler.state.Set(state.Ready) + } + } +} + +// backoff waits before the next run of a crashed script, see restartBackoff +func (handler *backgroundWorkerThread) backoff() { + handler.wait(restartBackoff(handler.failureCount)) + handler.failureCount++ +} + +// wait sleeps between two runs, cut short by a drain, which the next +// beforeScriptExecution() picks up from the state +func (handler *backgroundWorkerThread) wait(d time.Duration) { + select { + case <-handler.thread.drainChan: + case <-time.After(d): + } +} diff --git a/threadworker.go b/threadworker.go index b542b9aa35..faa1ae1a12 100644 --- a/threadworker.go +++ b/threadworker.go @@ -15,9 +15,8 @@ import ( // executes the PHP worker script in a loop // implements the threadHandler interface type workerThread struct { - state *state.ThreadState - thread *phpThread - worker *worker + workerLifecycle + dummyFrankenPHPContext *frankenPHPContext workerFrankenPHPContext *frankenPHPContext isBootingScript bool // true if the worker has not reached frankenphp_handle_request yet @@ -26,49 +25,19 @@ type workerThread struct { } func convertToWorkerThread(thread *phpThread, worker *worker) { - thread.setHandler(&workerThread{ - state: thread.state, - thread: thread, - worker: worker, - }) + thread.setHandler(&workerThread{workerLifecycle: newWorkerLifecycle(thread, worker)}) worker.attachThread(thread) } -// beforeScriptExecution returns the name of the script or an empty string on shutdown func (handler *workerThread) beforeScriptExecution() string { - switch handler.state.Get() { - case state.TransitionRequested: - if handler.worker.onThreadShutdown != nil { - handler.worker.onThreadShutdown(handler.thread.threadIndex) - } - handler.worker.detachThread(handler.thread) - return handler.thread.transitionToNewHandler() - case state.Ready, state.TransitionComplete: - handler.thread.updateContext(true) - if handler.worker.onThreadReady != nil { - handler.worker.onThreadReady(handler.thread.threadIndex) - } - - setupWorkerScript(handler, handler.worker) + return handler.workerLifecycle.beforeScriptExecution(handler.startScript) +} - return handler.worker.fileName - case state.Rebooting, state.ForceRebooting: - return "" - case state.RebootReady: - handler.requestCount = 0 - handler.state.Set(state.Ready) - return handler.beforeScriptExecution() - case state.ShuttingDown: - if handler.worker.onThreadShutdown != nil { - handler.worker.onThreadShutdown(handler.thread.threadIndex) - } - handler.worker.detachThread(handler.thread) +// startScript runs the worker script; it always has one to run +func (handler *workerThread) startScript() string { + setupWorkerScript(handler, handler.worker) - // signal to stop - return "" - default: - panic("unexpected state: " + handler.state.Name()) - } + return handler.worker.fileName } func (handler *workerThread) afterScriptExecution(exitStatus int) { @@ -149,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 } @@ -168,13 +137,29 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { } } - // wait a bit and try again (exponential backoff) - backoffDuration := time.Duration(handler.failureCount*handler.failureCount*100) * time.Millisecond - if backoffDuration > time.Second { - backoffDuration = time.Second - } + // wait a bit and try again + time.Sleep(restartBackoff(handler.failureCount)) handler.failureCount++ - time.Sleep(backoffDuration) +} + +// maxRestartBackoff is the longest wait between two runs of a failing script +const maxRestartBackoff = time.Second + +// minHealthyRun is how long a run has to last to count as one that did +// something: a shorter one paces the next, see restartBackoff +const minHealthyRun = 100 * time.Millisecond + +// restartBackoff is the wait before a worker script is re-run after a +// failure: quadratic in the number of consecutive failures, capped at one +// second; shared by HTTP and background workers. The cap comes before the +// multiplication, which overflows a duration past some 300k failures, a +// count a crash loop reaches on its own in a few days +func restartBackoff(failures int) time.Duration { + if failures >= 4 { + return maxRestartBackoff + } + + return time.Duration(failures*failures*100) * time.Millisecond } // waitForWorkerRequest is called during frankenphp_handle_request in the php worker script. diff --git a/threadworker_test.go b/threadworker_test.go new file mode 100644 index 0000000000..24e5a2e410 --- /dev/null +++ b/threadworker_test.go @@ -0,0 +1,20 @@ +package frankenphp + +import ( + "math" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestRestartBackoff(t *testing.T) { + assert.Equal(t, time.Duration(0), restartBackoff(0)) + assert.Equal(t, 100*time.Millisecond, restartBackoff(1)) + assert.Equal(t, 400*time.Millisecond, restartBackoff(2)) + assert.Equal(t, 900*time.Millisecond, restartBackoff(3)) + assert.Equal(t, time.Second, restartBackoff(4)) + // a crash loop counts without bound, the quadratic must not overflow + assert.Equal(t, time.Second, restartBackoff(303701)) + assert.Equal(t, time.Second, restartBackoff(math.MaxInt32)) +} diff --git a/worker.go b/worker.go index 84bcfbe439..7a2e31128d 100644 --- a/worker.go +++ b/worker.go @@ -32,11 +32,15 @@ type worker struct { threads []*phpThread threadMutex sync.RWMutex maxConsecutiveFailures int - onThreadReady func(int) - onThreadShutdown func(int) - queuedRequests atomic.Int32 - server *Server - stopped atomic.Bool + bootTimeout time.Duration + // Init() gave up waiting for the ready point: the run is drained and must not start again + bootTimedOut atomic.Bool + onThreadReady func(int) + onThreadShutdown func(int) + queuedRequests atomic.Int32 + server *Server + stopped atomic.Bool + isBackgroundWorker bool } var ( @@ -45,8 +49,17 @@ var ( globalWorkersByPath map[string]*worker watcherIsEnabled bool startupFailChan chan error + // the only window where a boot failure reaches startupFailChan: past it, the handlers log and keep restarting + startupPhase atomic.Bool ) +// defaultWorkerBootTimeout is how long a background worker may take to reach +// its first WorkerHandle::tick() before Init() fails, on PHP builds without +// Zend max execution timers: FrankenPHP disables max_execution_time there, +// and this matches its default. Elsewhere max_execution_time bounds the +// bootstrap instead. WithWorkerBootTimeout() sets it on any build. +const defaultWorkerBootTimeout = 30 * time.Second + func initWorkers(opts []workerOpt) error { if len(opts) == 0 { return nil @@ -70,28 +83,64 @@ func initWorkers(opts []workerOpt) error { totalThreadsToStart += w.num workers = append(workers, w) workersByName[w.name] = w - if w.server == nil { + if w.server != nil { + if err := w.server.addWorker(w); err != nil { + return err + } + } else if !w.isBackgroundWorker { + // background workers are never matched against requests globalWorkersByPath[w.fileName] = w globalWorkersByPath[w.declaredFileName] = w - } else if err := w.server.addWorker(w); err != nil { - return err } } startupFailChan = make(chan error, totalThreadsToStart) + startupPhase.Store(true) for _, w := range workers { for range w.num { thread := getInactivePHPThread() - convertToWorkerThread(thread, w) + if w.isBackgroundWorker { + convertToBackgroundWorkerThread(thread, w) + } else { + convertToWorkerThread(thread, w) + } + bootTimeout := time.Duration(0) + if w.isBackgroundWorker { + bootTimeout = w.bootTimeout + } workersReady.Go(func() { - thread.state.WaitFor(state.Ready, state.ShuttingDown, state.Done) + if bootTimeout <= 0 { + thread.state.WaitFor(state.Ready, state.ShuttingDown, state.Done) + + return + } + + if thread.state.WaitForStateWithTimeout(bootTimeout, state.Ready, state.ShuttingDown, state.Done) { + return + } + + // the script never ticked: stop the thread, so no run starts + // after this one, which ends at once if it waits on its handle + // and through the force-kill otherwise, the boot is over + if !thread.state.CompareAndSwap(state.TransitionComplete, state.ShuttingDown) { + return + } + w.bootTimedOut.Store(true) + reportStartupFailure(fmt.Errorf("background worker %q did not call WorkerHandle::tick() within %s", w.name, bootTimeout)) + if handler, ok := thread.handler.(*backgroundWorkerThread); ok { + handler.drain() + } + if !thread.state.WaitForStateWithTimeout(bootTimeoutKillDelay, state.Done) { + thread.sendKillSignal() + } }) } } workersReady.Wait() + startupPhase.Store(false) select { case err := <-startupFailChan: @@ -99,12 +148,27 @@ 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, +// so Init() fails, and reports whether it did: past the startup phase the +// handler logs it and keeps restarting. It never blocks. +func reportStartupFailure(err error) bool { + if !startupPhase.Load() { + return false + } + + select { + case startupFailChan <- err: + default: + } + + return true +} + // resolveWorkerFile turns a declared filename into the path a worker runs, // and names the worker after it when the declaration left the name out. // @@ -144,7 +208,20 @@ func resolveWorkerFile(o workerOpt) (workerOpt, error) { // Validate() and newWorker() answer the same way; pathTaken is asked for the // global workers with a nil server. func checkWorkerDeclaration(o workerOpt, nameTaken func(string) bool, pathTaken func(*Server, string) bool) error { - if o.server == nil { + if o.isBackgroundWorker { + // never matched against requests, so several may share a script + if o.matchRequest != nil { + return fmt.Errorf("background worker %q cannot match requests", o.name) + } + if o.maxThreads > 0 { + return 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 fmt.Errorf("background worker %q cannot be an extension worker, those handle requests", o.name) + } + } else if o.server == nil { if pathTaken(nil, o.fileName) { return fmt.Errorf("two global workers cannot have the same filename: %q", o.fileName) } @@ -200,7 +277,23 @@ func newWorker(o workerOpt) (*worker, error) { } } - o.env["FRANKENPHP_WORKER\x00"] = "1" + // a script serving both roles tests which of the two is set; a background + // worker gets its name from WorkerHandle::getName() + if o.isBackgroundWorker { + o.env["FRANKENPHP_WORKER_BACKGROUND\x00"] = "1" + } else { + o.env["FRANKENPHP_WORKER\x00"] = "1" + } + + // a background worker that never reaches its ready point would keep Init() + // waiting for ever where max_execution_time is disabled, see initWorkers() + bootTimeout := time.Duration(0) + if !Config().ZendMaxExecutionTimers { + bootTimeout = defaultWorkerBootTimeout + } + if o.bootTimeoutIsSet { + bootTimeout = o.bootTimeout + } w := &worker{ name: o.name, @@ -213,9 +306,11 @@ func newWorker(o workerOpt) (*worker, error) { requestChan: make(chan *frankenPHPContext), threads: make([]*phpThread, 0, o.num), maxConsecutiveFailures: o.maxConsecutiveFailures, + bootTimeout: bootTimeout, onThreadReady: o.onThreadReady, onThreadShutdown: o.onThreadShutdown, server: o.server, + isBackgroundWorker: o.isBackgroundWorker, } w.configureMercure(&o) diff --git a/workerextension_test.go b/workerextension_test.go index de724747c1..c974835e20 100644 --- a/workerextension_test.go +++ b/workerextension_test.go @@ -178,6 +178,16 @@ func TestWorkerExtensionSendMessageRejectsUnsupportedZval(t *testing.T) { assert.Nil(t, ret) } +// background workers never read requestChan, so an extension cannot send +// them requests or messages +func TestErrorIfExtensionWorkerIsBackground(t *testing.T) { + t.Cleanup(Shutdown) + + _, o := WithExtensionWorkers("backgroundExtension", "testdata/bgworker/basic.php", 1, WithWorkerBackground()) + + require.ErrorContains(t, Init(o), "cannot be an extension worker") +} + func TestErrorIf2WorkersHaveSameName(t *testing.T) { _, o1 := WithExtensionWorkers("duplicateWorker", "testdata/worker.php", 1) _, o2 := WithExtensionWorkers("duplicateWorker", "testdata/worker2.php", 1) diff --git a/workerlifecycle.go b/workerlifecycle.go new file mode 100644 index 0000000000..4d1809ee22 --- /dev/null +++ b/workerlifecycle.go @@ -0,0 +1,58 @@ +package frankenphp + +import "github.com/dunglas/frankenphp/internal/state" + +// workerLifecycle is the part of a worker thread that HTTP and background +// workers share: the thread, its worker, and the states both walk through +// between two runs of the script. Handlers embed it and pass the one step +// that differs, how a run starts. +type workerLifecycle struct { + state *state.ThreadState + thread *phpThread + worker *worker +} + +func newWorkerLifecycle(thread *phpThread, worker *worker) workerLifecycle { + return workerLifecycle{state: thread.state, thread: thread, worker: worker} +} + +// beforeScriptExecution returns the name of the script to run, or an empty +// string to stop the thread; startScript prepares a run and returns the +// script to execute, or an empty string to stop the thread +func (l *workerLifecycle) beforeScriptExecution(startScript func() string) string { + switch l.state.Get() { + case state.TransitionRequested: + l.detach() + + return l.thread.transitionToNewHandler() + case state.Ready, state.TransitionComplete: + l.thread.updateContext(true) + if l.worker.onThreadReady != nil { + l.worker.onThreadReady(l.thread.threadIndex) + } + + return startScript() + case state.Rebooting, state.ForceRebooting: + return "" + case state.RebootReady: + l.state.Set(state.Ready) + + return l.beforeScriptExecution(startScript) + case state.ShuttingDown: + l.detach() + + // signal to stop + return "" + default: + panic("unexpected state: " + l.state.Name()) + } +} + +// detach takes the thread off its worker, on the paths that stop running its +// script for good +func (l *workerLifecycle) detach() { + if l.worker.onThreadShutdown != nil { + l.worker.onThreadShutdown(l.thread.threadIndex) + } + l.worker.detachThread(l.thread) +} From 064fb51b1020db6a4b395f7de714c08f377a7c81 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Wed, 11 Nov 2026 20:53:41 +0100 Subject: [PATCH 2/3] feat: frankenphp_get_vars() and WorkerHandle::setVars() --- caddy/config_test.go | 42 +++ caddy/workerconfig.go | 20 +- docs/config.md | 2 + docs/worker.md | 31 +++ frankenphp.c | 132 +++++++++- frankenphp.go | 15 +- frankenphp.h | 3 + frankenphp.stub.php | 25 ++ frankenphp_arginfo.h | 19 +- options.go | 18 ++ phpmainthread.go | 2 + testdata/bgworker/bad-vars.php | 34 +++ testdata/bgworker/consumer.php | 21 ++ testdata/bgworker/enum-vars.php | 17 ++ testdata/bgworker/multi-vars.php | 16 ++ testdata/bgworker/publisher.php | 22 ++ testdata/bgworker/republish.php | 18 ++ testdata/enum-suit.php | 12 + testdata/persist-roundtrip.php | 36 +++ testdata/vars-at-boot.php | 16 ++ testdata/vars-between-requests.php | 18 ++ testdata/vars-enum-mismatch.php | 15 ++ testdata/vars-enum.php | 15 ++ testdata/vars-fatal.php | 12 + testdata/vars-fibers.php | 18 ++ testdata/vars-over-limit.php | 11 + testdata/vars.php | 7 + threadbackgroundworker.go | 21 +- threadworker.go | 13 + worker.go | 178 ++++++++++++- workervars.go | 229 ++++++++++++++++ workervars_test.go | 402 +++++++++++++++++++++++++++++ zval.h | 261 +++++++++++++------ zval_test.go | 4 + 34 files changed, 1587 insertions(+), 118 deletions(-) create mode 100644 testdata/bgworker/bad-vars.php create mode 100644 testdata/bgworker/consumer.php create mode 100644 testdata/bgworker/enum-vars.php create mode 100644 testdata/bgworker/multi-vars.php create mode 100644 testdata/bgworker/publisher.php create mode 100644 testdata/bgworker/republish.php create mode 100644 testdata/enum-suit.php create mode 100644 testdata/vars-at-boot.php create mode 100644 testdata/vars-between-requests.php create mode 100644 testdata/vars-enum-mismatch.php create mode 100644 testdata/vars-enum.php create mode 100644 testdata/vars-fatal.php create mode 100644 testdata/vars-fibers.php create mode 100644 testdata/vars-over-limit.php create mode 100644 testdata/vars.php create mode 100644 workervars.go create mode 100644 workervars_test.go diff --git a/caddy/config_test.go b/caddy/config_test.go index 1226a0223f..94db973fb9 100644 --- a/caddy/config_test.go +++ b/caddy/config_test.go @@ -373,6 +373,38 @@ func TestWorkerBackgroundNameIsOptional(t *testing.T) { require.Empty(t, module.Workers[0].Name) } +func TestWorkerBackgroundVars(t *testing.T) { + d := caddyfile.NewTestDispenser(` + { + php_server { + worker { + file ../testdata/worker-with-env.php + background + vars redis feature_flags + } + } + }`) + module := &FrankenPHPModule{} + + require.NoError(t, module.UnmarshalCaddyfile(d)) + require.Equal(t, []string{"redis", "feature_flags"}, module.Workers[0].Vars) +} + +func TestWorkerVarsRequireBackground(t *testing.T) { + d := caddyfile.NewTestDispenser(` + { + php_server { + worker { + file ../testdata/worker-with-env.php + vars redis + } + } + }`) + module := &FrankenPHPModule{} + + require.ErrorContains(t, module.UnmarshalCaddyfile(d), `"vars" is only supported for background workers`) +} + func TestWorkerBackgroundWithoutNumParses(t *testing.T) { d := caddyfile.NewTestDispenser(` { @@ -440,3 +472,13 @@ func TestWorkerBackgroundRejectsMatch(t *testing.T) { err := module.UnmarshalCaddyfile(d) require.ErrorContains(t, err, `"match" is not supported for background workers`) } + +// a JSON config skips the Caddyfile check: the core refuses vars on an HTTP +// worker instead of the option being dropped +func TestWorkerVarsReachTheCoreWithoutBackground(t *testing.T) { + wc := workerConfig{FileName: "../testdata/worker-with-env.php", Vars: []string{"cfg"}} + opts, err := wc.toWorkerOptions() + require.NoError(t, err) + + require.ErrorContains(t, frankenphp.Validate(frankenphp.WithWorkers("http", wc.FileName, 1, opts...)), "only background workers publish them") +} diff --git a/caddy/workerconfig.go b/caddy/workerconfig.go index d66fcf24eb..02ae66061f 100644 --- a/caddy/workerconfig.go +++ b/caddy/workerconfig.go @@ -43,6 +43,8 @@ type workerConfig struct { BootTimeout *caddy.Duration `json:"boot_timeout,omitempty"` // EXPERIMENTAL: Background marks this worker as a background (non-HTTP) worker. Background bool `json:"background,omitempty"` + // EXPERIMENTAL: Vars lists the namespaces a background worker publishes with WorkerHandle::setVars(), read by frankenphp_get_vars(). + Vars []string `json:"vars,omitempty"` options []frankenphp.WorkerOption } @@ -161,8 +163,14 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) { wc.BootTimeout = &timeout case "background": wc.Background = true + case "vars": + namespaces := d.RemainingArgs() + if len(namespaces) == 0 { + return wc, d.ArgErr() + } + wc.Vars = append(wc.Vars, namespaces...) default: - return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads, background, boot_timeout", v) + return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads, background, boot_timeout, vars", v) } } @@ -178,6 +186,10 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) { return wc, d.Err(`"match" is not supported for background workers`) } + if !wc.Background && len(wc.Vars) != 0 { + return wc, d.Err(`"vars" is only supported for background workers`) + } + if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) { wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName) } @@ -207,6 +219,12 @@ func (wc *workerConfig) toWorkerOptions() ([]frankenphp.WorkerOption, error) { } } + // whatever the kind of worker: the core refuses vars on an HTTP one, which + // a JSON config reaches without the Caddyfile check + if len(wc.Vars) > 0 { + opts = append(opts, frankenphp.WithWorkerVars(wc.Vars...)) + } + // copy the caddy match logic and create a unique matcher function for this worker // inject the matcher into frankenphp if len(wc.MatchPath) > 0 { diff --git a/docs/config.md b/docs/config.md index 611ff392d5..9818583ddb 100644 --- a/docs/config.md +++ b/docs/config.md @@ -113,6 +113,7 @@ You can also explicitly configure FrankenPHP using the [global option](https://c max_consecutive_failures # Sets the maximum number of consecutive failures before the worker is considered unhealthy, -1 means the worker will always restart. Default: 6. boot_timeout # EXPERIMENTAL: how long a background worker may take to reach its first tick() before the start fails, 0 waits forever. Default: 30s on PHP builds without Zend max execution timers, none elsewhere. background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, whose script ticks its FrankenPHP\WorkerHandle at least once + vars # EXPERIMENTAL: the vars namespaces a background worker publishes, read with frankenphp_get_vars() } } } @@ -203,6 +204,7 @@ php_server [] { match # match the worker to a path pattern. Overrides try_files and can only be used in the php_server directive. boot_timeout # EXPERIMENTAL: how long a background worker may take to reach its first tick() before the start fails, 0 waits forever. Default: 30s on PHP builds without Zend max execution timers, none elsewhere. background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, whose script ticks its FrankenPHP\WorkerHandle at least once + vars # EXPERIMENTAL: the vars namespaces a background worker publishes, read with frankenphp_get_vars() } worker # Can also use the short form like in the global frankenphp block. } diff --git a/docs/worker.md b/docs/worker.md index 94e5bf5dba..3cda614d24 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -297,6 +297,37 @@ A background worker serves no request, but its script is a PHP file like any oth `$_SERVER['FRANKENPHP_WORKER_BACKGROUND']` is set to `1`, and `FRANKENPHP_WORKER`, the variable of HTTP workers, is not, so a script serving both roles tests which of the two is set. Several workers may run the same script: `getName()` on the handle tells them apart, and the [threads endpoint](metrics.md#threads-state-endpoint) of the admin API names their threads after them. Background worker threads count like the threads of HTTP workers, on top of `num_threads` and within `max_threads`; they don't autoscale, so `max_threads` is not allowed on them. From Go, declare one with `WithWorkerBackground()`. +### Sharing state with background workers + +A background worker publishes snapshots in vars namespaces, declared with `vars` on the worker, and requests and other workers read them with `frankenphp_get_vars()`, by namespace: + +```caddyfile +php_server { + worker { + file config.php + background + vars redis feature_flags + } +} +``` + +```php +// background worker +$handle = new FrankenPHP\WorkerHandle(); +$handle->setVars('redis', ['dsn' => 'redis://cache:6379']); +$handle->setVars('feature_flags', ['beta' => true]); +// ... + +// request, HTTP worker or another background worker +$flags = frankenphp_get_vars('feature_flags'); +``` + +A namespace has one publisher in a `php_server`, and one among the global workers; a reader gets the one of its `php_server` first, then the global one. In a worker script, what runs between two requests reads as the request before it did, and what runs before the first one as the worker does. One worker may publish as many namespaces as the application wants, so splitting them across workers is a matter of threads, failure caps and timeouts, not of addressing. A worker publishing vars runs a single thread, so each snapshot has one writer, and `getNamespaces()` on its handle returns what the configuration declared. + +Values must be null, scalars, arrays or enums, and a snapshot can't be larger than `memory_limit`, as its copy lives outside of request memory. Each call replaces the whole snapshot of its namespace and readers get a copy, so the worker can publish at any time and a request always sees a consistent one. Every declared namespace is published before the first tick of a run, which `tick()` enforces: since the server accepts requests once the background workers are ready, requests never wait for a snapshot, and while a worker restarts they keep getting its last ones. + +`frankenphp_get_vars()` can only block while workers boot, a background worker or the bootstrap of an HTTP worker waiting for the publisher of a namespace. A cycle between background workers throws instead of hanging, and so does a wait once the start failed. A namespace no worker publishes throws a `RuntimeException`. + ## 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 f173da1109..82717ff2c8 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -62,12 +62,7 @@ #include "_cgo_export.h" #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() @@ -1739,12 +1734,19 @@ ZEND_METHOD(FrankenPHP_WorkerHandle, tick) { } if (!worker_ticked) { + /* refused while a declared vars namespace has no snapshot of this run */ + char *error = + go_frankenphp_background_worker_ready(frankenphp_thread_index()); + if (error != NULL) { + zend_throw_exception(spl_ce_LogicException, error, 0); + free(error); + RETURN_THROWS(); + } worker_ticked = true; /* The bootstrap ran under max_execution_time like any request, the loop * that starts now has no time limit, like the CLI. Nothing re-arms the * timer past this point. */ zend_unset_timeout(); - go_frankenphp_background_worker_ready(frankenphp_thread_index()); } if (!frankenphp_worker_handle_consume()) { @@ -1767,6 +1769,124 @@ ZEND_METHOD(FrankenPHP_WorkerHandle, tick) { RETURN_TRUE; } +/* 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 helpers. */ +void frankenphp_vars_to_request(zval *return_value, HashTable *table) { + zval persistent; + ZVAL_ARR(&persistent, table); + + /* the writer checked the tree against its own memory_limit, not ours */ + size_t size; + if (!persistent_zval_fits_request(&persistent, &size)) { + ZVAL_NULL(return_value); + zend_throw_exception_ex(spl_ce_RuntimeException, 0, + "FrankenPHP: the values to copy take %zu bytes, " + "more than what is left of memory_limit", + size); + + return; + } + + persistent_zval_to_request(return_value, &persistent); +} + +void frankenphp_vars_free(HashTable *table) { + zval persistent; + ZVAL_ARR(&persistent, table); + persistent_zval_free(&persistent); +} + +/* Lets go of a reference on a snapshot. Its slot holds one and so does each + * reader while it copies the table, on a count only touched atomically: the + * last to let go frees the snapshot, with no lock and no call into the Go + * side. */ +void frankenphp_vars_release(HashTable *table) { + if (__atomic_sub_fetch(&table->gc.refcount, 1, __ATOMIC_SEQ_CST) == 0) { + frankenphp_vars_free(table); + } +} + +ZEND_METHOD(FrankenPHP_WorkerHandle, getNamespaces) { + ZEND_PARSE_PARAMETERS_NONE(); + + if (!frankenphp_worker_handle_usable()) { + RETURN_THROWS(); + } + + go_frankenphp_worker_namespaces(frankenphp_thread_index(), return_value); +} + +ZEND_METHOD(FrankenPHP_WorkerHandle, setVars) { + zend_string *namespace; + zval *vars; + ZEND_PARSE_PARAMETERS_START(2, 2) + Z_PARAM_STR(namespace) + 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 */ + const char *invalid = persistent_zval_invalid(vars); + if (invalid != NULL) { + zend_value_error("FrankenPHP\\WorkerHandle::setVars(): %s", invalid); + RETURN_THROWS(); + } + + zval persistent; + persistent_zval_persist(&persistent, vars); + + HashTable *old = NULL; + char *error = + go_frankenphp_set_vars(frankenphp_thread_index(), ZSTR_VAL(namespace), + ZSTR_LEN(namespace), Z_ARRVAL(persistent), &old); + if (error != NULL) { + frankenphp_vars_free(Z_ARRVAL(persistent)); + zend_throw_exception(spl_ce_LogicException, error, 0); + free(error); + RETURN_THROWS(); + } + if (old != NULL) { + frankenphp_vars_release(old); + } +} + +PHP_FUNCTION(frankenphp_get_vars) { + zend_string *namespace; + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_STR(namespace) + ZEND_PARSE_PARAMETERS_END(); + + HashTable *table = NULL; + struct go_frankenphp_get_vars_return found = + go_frankenphp_get_vars(frankenphp_thread_index(), ZSTR_VAL(namespace), + ZSTR_LEN(namespace), &table); + if (found.r0 != NULL) { + zend_throw_exception(found.r1 ? spl_ce_LogicException + : spl_ce_RuntimeException, + found.r0, 0); + free(found.r0); + RETURN_THROWS(); + } + + /* the copy may bail out, past memory_limit or in an autoloader an enum + * triggers: the snapshot is let go of whatever happens, so its publisher + * can replace and free it */ + bool bailed_out = false; + zend_try { frankenphp_vars_to_request(return_value, table); } + zend_catch { bailed_out = true; } + zend_end_try(); + + frankenphp_vars_release(table); + if (bailed_out) { + zend_bailout(); + } +} + /* {{{ thread-safe opcache reset */ PHP_FUNCTION(frankenphp_opcache_reset) { go_schedule_opcache_reset(frankenphp_thread_index()); diff --git a/frankenphp.go b/frankenphp.go index e53f86fc0a..5dd585faf9 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -332,25 +332,24 @@ func Validate(options ...Option) error { } takenNames := make(map[string]bool, len(opt.workers)) - takenPaths := make(map[*Server]map[string]bool, 1) - nameTaken := func(name string) bool { return takenNames[name] } - pathTaken := func(server *Server, path string) bool { return takenPaths[server][path] } + paths, vars := scopeSet{}, scopeSet{} + taken := declarationIndex{names: func(name string) bool { return takenNames[name] }, paths: paths.has, vars: vars.owner} for _, w := range opt.workers { w, err := resolveWorkerFile(w) if err != nil { return err } - if err := checkWorkerDeclaration(w, nameTaken, pathTaken); err != nil { + if err := checkWorkerDeclaration(w, taken); err != nil { return err } takenNames[w.name] = true if !w.isBackgroundWorker && w.matchRequest == nil { - if takenPaths[w.server] == nil { - takenPaths[w.server] = make(map[string]bool) - } - takenPaths[w.server][w.fileName] = true + paths.add(w.server, w.fileName, w.name) + } + for _, ns := range w.vars { + vars.add(w.server, ns, w.name) } } diff --git a/frankenphp.h b/frankenphp.h index ee2bac2b0f..0bdab0767a 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -239,6 +239,9 @@ typedef enum { intptr_t frankenphp_set_background_worker_and_get_stop_sock(const char *name, size_t name_len); void frankenphp_worker_close_stop_sock(intptr_t s); +void frankenphp_vars_to_request(zval *return_value, HashTable *table); +void frankenphp_vars_free(HashTable *table); +void frankenphp_vars_release(HashTable *table); void register_extensions(zend_module_entry **m, int len); diff --git a/frankenphp.stub.php b/frankenphp.stub.php index 8f3d58168d..cb7294ecca 100644 --- a/frankenphp.stub.php +++ b/frankenphp.stub.php @@ -58,6 +58,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 {} + /** + * EXPERIMENTAL: returns a copy of the snapshot last published in a vars + * namespace, resolved within the current php_server, then among global + * workers. Blocks until the publishing worker reached its ready point. + * Throws if no background worker publishes the namespace, or if + * background workers wait on each other in a cycle. + */ + function frankenphp_get_vars(string $namespace): array {} } namespace FrankenPHP { @@ -115,5 +123,22 @@ public function tick(?float $timeout = 0.0): bool {} * @return resource */ public function getStream() {} + + /** + * Returns the namespaces this background worker serves, as declared in + * its configuration. + * + * @return array{vars: list} + */ + public function getNamespaces(): array {} + + /** + * Publishes a snapshot in one of the vars namespaces of this background + * worker: the array replaces the previous snapshot of that namespace, + * atomically for readers, which get copies. Values must be null, + * scalars, arrays or enums. Every declared namespace is published + * before the first tick() of a run. + */ + public function setVars(string $namespace, array $vars): void {} } } diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index 7c77f041ce..9372895b62 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: e3269e20cf63fd5db56edf049e496ccfc7795a45 */ + * Stub hash: b7d45f1d97394a73b3026aa2e09a3d01914ab8d0 */ 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, namespace, IS_STRING, 0) +ZEND_END_ARG_INFO() + ZEND_BEGIN_ARG_INFO_EX(arginfo_class_FrankenPHP_WorkerHandle___construct, 0, 0, 0) ZEND_END_ARG_INFO() @@ -53,6 +57,13 @@ ZEND_END_ARG_INFO() #define arginfo_class_FrankenPHP_WorkerHandle_getStream arginfo_class_FrankenPHP_WorkerHandle___construct +#define arginfo_class_FrankenPHP_WorkerHandle_getNamespaces arginfo_frankenphp_request_headers + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_FrankenPHP_WorkerHandle_setVars, 0, 2, IS_VOID, 0) + ZEND_ARG_TYPE_INFO(0, namespace, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, vars, IS_ARRAY, 0) +ZEND_END_ARG_INFO() + ZEND_FUNCTION(frankenphp_handle_request); ZEND_FUNCTION(headers_send); ZEND_FUNCTION(frankenphp_finish_request); @@ -60,10 +71,13 @@ 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, getName); ZEND_METHOD(FrankenPHP_WorkerHandle, tick); ZEND_METHOD(FrankenPHP_WorkerHandle, getStream); +ZEND_METHOD(FrankenPHP_WorkerHandle, getNamespaces); +ZEND_METHOD(FrankenPHP_WorkerHandle, setVars); static const zend_function_entry ext_functions[] = { ZEND_FE(frankenphp_handle_request, arginfo_frankenphp_handle_request) @@ -93,6 +107,7 @@ static const zend_function_entry ext_functions[] = { #endif 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 }; @@ -101,6 +116,8 @@ static const zend_function_entry class_FrankenPHP_WorkerHandle_methods[] = { ZEND_ME(FrankenPHP_WorkerHandle, getName, arginfo_class_FrankenPHP_WorkerHandle_getName, ZEND_ACC_PUBLIC) ZEND_ME(FrankenPHP_WorkerHandle, tick, arginfo_class_FrankenPHP_WorkerHandle_tick, ZEND_ACC_PUBLIC) ZEND_ME(FrankenPHP_WorkerHandle, getStream, arginfo_class_FrankenPHP_WorkerHandle_getStream, ZEND_ACC_PUBLIC) + ZEND_ME(FrankenPHP_WorkerHandle, getNamespaces, arginfo_class_FrankenPHP_WorkerHandle_getNamespaces, ZEND_ACC_PUBLIC) + ZEND_ME(FrankenPHP_WorkerHandle, setVars, arginfo_class_FrankenPHP_WorkerHandle_setVars, ZEND_ACC_PUBLIC) ZEND_FE_END }; diff --git a/options.go b/options.go index 9ab4863131..5a2d73fde5 100644 --- a/options.go +++ b/options.go @@ -2,9 +2,11 @@ package frankenphp import ( "context" + "errors" "fmt" "log/slog" "net/http" + "slices" "time" ) @@ -61,6 +63,7 @@ type workerOpt struct { isBackgroundWorker bool bootTimeout time.Duration bootTimeoutIsSet bool + vars []string } // WithContext sets the main context to use. @@ -276,6 +279,21 @@ func WithWorkerBootTimeout(timeout time.Duration) WorkerOption { } } +// EXPERIMENTAL: WithWorkerVars declares the vars namespaces a background +// worker publishes with WorkerHandle::setVars(), which frankenphp_get_vars() +// reads by namespace. A namespace has one publisher within a server, and one +// among the global workers. +func WithWorkerVars(namespaces ...string) WorkerOption { + return func(w *workerOpt) error { + if slices.Contains(namespaces, "") { + return errors.New("a vars namespace cannot be empty") + } + w.vars = append(w.vars, namespaces...) + + return nil + } +} + // WithWorkerMaxFailures sets the maximum number of consecutive failures before panicking func WithWorkerMaxFailures(maxFailures int) WorkerOption { return func(w *workerOpt) error { diff --git a/phpmainthread.go b/phpmainthread.go index d000141839..b357614384 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -111,6 +111,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..d984c68b3e --- /dev/null +++ b/testdata/bgworker/bad-vars.php @@ -0,0 +1,34 @@ +getNamespaces()['vars'][0]; +$errors = []; +try { + $handle->setVars($namespace, ['object' => new stdClass()]); +} catch (\Throwable $e) { + $errors[] = get_class($e) . ': ' . $e->getMessage(); +} +// one megabyte shared a hundred times: a hundred copies once persisted +ini_set('memory_limit', '64M'); +try { + $handle->setVars($namespace, array_fill(0, 100, str_repeat('x', 1 << 20))); +} catch (\Throwable $e) { + $errors[] = get_class($e) . ': ' . $e->getMessage(); +} +try { + $handle->setVars('undeclared', []); +} catch (\Throwable $e) { + $errors[] = get_class($e) . ': ' . $e->getMessage(); +} +file_put_contents($_SERVER['BG_SENTINEL'], implode("\n", $errors)); +$handle->setVars($namespace, []); +$stream = $handle->getStream(); +while ($handle->tick()) { + $read = [$stream]; + $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..32cdee5b5a --- /dev/null +++ b/testdata/bgworker/consumer.php @@ -0,0 +1,21 @@ +getMessage(); +} +file_put_contents($_SERVER['BG_SENTINEL'], $result); +$handle = new \FrankenPHP\WorkerHandle(); +foreach ($handle->getNamespaces()['vars'] as $namespace) { + $handle->setVars($namespace, ['consumed' => $result]); +} +$stream = $handle->getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/enum-vars.php b/testdata/bgworker/enum-vars.php new file mode 100644 index 0000000000..96ee367ed0 --- /dev/null +++ b/testdata/bgworker/enum-vars.php @@ -0,0 +1,17 @@ +getNamespaces()['vars'][0]; +$suit = constant($_SERVER['BG_ENUM'] ?? 'EnumSuit::Hearts'); +$publications = 0; +$handle->setVars($namespace, ['suit' => $suit, 'n' => ++$publications]); +$stream = $handle->getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, 0, 10000); + $handle->setVars($namespace, ['suit' => $suit, 'n' => ++$publications]); +} diff --git a/testdata/bgworker/multi-vars.php b/testdata/bgworker/multi-vars.php new file mode 100644 index 0000000000..67d9dcde81 --- /dev/null +++ b/testdata/bgworker/multi-vars.php @@ -0,0 +1,16 @@ +getNamespaces(); +foreach ($namespaces['vars'] as $namespace) { + $handle->setVars($namespace, ['namespace' => $namespace, 'all' => $namespaces]); +} +$stream = $handle->getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/publisher.php b/testdata/bgworker/publisher.php new file mode 100644 index 0000000000..86ebc7d1aa --- /dev/null +++ b/testdata/bgworker/publisher.php @@ -0,0 +1,22 @@ +setVars($handle->getNamespaces()['vars'][0], [ + 'answer' => 42, + 'value' => $_SERVER['BG_PUBLISH_VALUE'] ?? 'default', + 'nested' => ['a' => 1, 'list' => [true, null, 1.5, 'x']], + 'worker' => $handle->getName(), +] + (empty($_SERVER['BG_PUBLISH_BLOB_MB']) ? [] : ['blob' => str_repeat('x', $_SERVER['BG_PUBLISH_BLOB_MB'] << 20)])); +$stream = $handle->getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/bgworker/republish.php b/testdata/bgworker/republish.php new file mode 100644 index 0000000000..2589e34b3d --- /dev/null +++ b/testdata/bgworker/republish.php @@ -0,0 +1,18 @@ +setVars($handle->getNamespaces()['vars'][0], ['answer' => 42, 'nested' => ['a' => 1, 'list' => [true, null, 1.5, 'x']]]); +$stream = $handle->getStream(); +while ($handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); +} diff --git a/testdata/enum-suit.php b/testdata/enum-suit.php new file mode 100644 index 0000000000..44467865c5 --- /dev/null +++ b/testdata/enum-suit.php @@ -0,0 +1,12 @@ + &$shared, 'b' => [&$shared]]), ['a' => ['x'], 'b' => [['x']]], 'shared reference'); + +// An array that holds itself is nested past any depth. +$self = []; +$self['self'] = &$self; +try { + $rt($self); + echo "FAIL recursive array should throw\n"; +} catch (\LogicException) { + echo "OK recursive array rejected\n"; +} +unset($self['self']); + // Invalid inputs throw LogicException. try { $rt(new stdClass()); @@ -86,3 +109,16 @@ function same(mixed $actual, mixed $expected, string $label): void { } catch (\LogicException) { echo "OK nested stdClass rejected\n"; } + +// A literal array is opcache-immutable, which the persistent tree copies +// rather than borrows: round-trip the same literal more times than the +// refcount of 2 opcache keeps on it, so nothing shared is ever released. +for ($i = 0; $i < 3; ++$i) { + $out = $rt(['immutable' => ['nested' => [1, 2, 3]], 'i' => 'literal']); + if ($out !== ['immutable' => ['nested' => [1, 2, 3]], 'i' => 'literal']) { + echo "FAIL immutable literal exposed repeatedly (round $i)\n"; + return; + } + unset($out); +} +echo "OK immutable literal exposed repeatedly\n"; diff --git a/testdata/vars-at-boot.php b/testdata/vars-at-boot.php new file mode 100644 index 0000000000..e6b3c08c3e --- /dev/null +++ b/testdata/vars-at-boot.php @@ -0,0 +1,16 @@ +getMessage(); +} +while (frankenphp_handle_request(function () use ($vars): void { + echo json_encode($vars); +})) { +} diff --git a/testdata/vars-between-requests.php b/testdata/vars-between-requests.php new file mode 100644 index 0000000000..b901a65ec8 --- /dev/null +++ b/testdata/vars-between-requests.php @@ -0,0 +1,18 @@ +getMessage(); + } +}; +$after = null; +while (frankenphp_handle_request(static function () use ($read, &$after): void { + echo json_encode(['request' => $read(), 'after' => $after]); +})) { + $after = $read(); +} diff --git a/testdata/vars-enum-mismatch.php b/testdata/vars-enum-mismatch.php new file mode 100644 index 0000000000..78feb1e43d --- /dev/null +++ b/testdata/vars-enum-mismatch.php @@ -0,0 +1,15 @@ +getMessage(); +} diff --git a/testdata/vars-enum.php b/testdata/vars-enum.php new file mode 100644 index 0000000000..d562d921cf --- /dev/null +++ b/testdata/vars-enum.php @@ -0,0 +1,15 @@ +name, ' kept:', implode(',', array_map('hex2bin', array_keys($seen))); diff --git a/testdata/vars-fatal.php b/testdata/vars-fatal.php new file mode 100644 index 0000000000..a83d7a00c4 --- /dev/null +++ b/testdata/vars-fatal.php @@ -0,0 +1,12 @@ + frankenphp_get_vars('enum-a')['suit']->name); +$b = new Fiber(fn () => frankenphp_get_vars('enum-b')['suit']->name); +$a->start(); +$b->start(); +$a->resume(); +// time for the publisher of the second snapshot to replace it +usleep(100_000); +$b->resume(); +echo $a->getReturn(), ' ', $b->getReturn(); diff --git a/testdata/vars-over-limit.php b/testdata/vars-over-limit.php new file mode 100644 index 0000000000..a04d085f0d --- /dev/null +++ b/testdata/vars-over-limit.php @@ -0,0 +1,11 @@ +getMessage(); +} diff --git a/testdata/vars.php b/testdata/vars.php new file mode 100644 index 0000000000..cac7ffd6bf --- /dev/null +++ b/testdata/vars.php @@ -0,0 +1,7 @@ +getMessage(); +} diff --git a/threadbackgroundworker.go b/threadbackgroundworker.go index ea53da18e8..cdfd47d4a3 100644 --- a/threadbackgroundworker.go +++ b/threadbackgroundworker.go @@ -7,6 +7,7 @@ import "C" import ( "fmt" "log/slog" + "strconv" "sync/atomic" "time" @@ -34,6 +35,8 @@ type backgroundWorkerThread struct { // of an HTTP worker reaching frankenphp_handle_request() isBootingScript bool bootTimer *time.Timer + // the vars namespaces this run published, all of them due by its first tick + publishedVars map[string]bool // the Go side's end of this thread's stop socket pair, the script // holding the other end; one per thread so pool workers drain @@ -149,6 +152,7 @@ func (handler *backgroundWorkerThread) setupScript() error { handler.thread.contextMu.Unlock() handler.isBootingScript = true + handler.publishedVars = make(map[string]bool, len(handler.worker.varsNamespaces)) handler.runStartedAt = time.Now() metrics.StartWorker(handler.worker.name) // the run's logger and context, not the globals: a shutdown finishing @@ -222,9 +226,10 @@ func (handler *backgroundWorkerThread) afterScriptExecution(exitStatus int) { // 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, its - // timeout already reported it + // timeout already reported it, and so does any worker once the start + // failed pastCap := worker.maxConsecutiveFailures >= 0 && handler.failureCount >= worker.maxConsecutiveFailures - if worker.bootTimedOut.Load() { + if worker.bootTimedOut.Load() || startupHasFailed() { handler.thread.state.Set(state.ShuttingDown) return @@ -266,7 +271,7 @@ func (handler *backgroundWorkerThread) stopBootTimer() { } //export go_frankenphp_background_worker_ready -func go_frankenphp_background_worker_ready(threadIndex C.uintptr_t) { +func go_frankenphp_background_worker_ready(threadIndex C.uintptr_t) *C.char { // called on the PHP thread by the first WorkerHandle::tick() of a run; // that function throws on every other thread kind handler, ok := phpThreads[threadIndex].handler.(*backgroundWorkerThread) @@ -275,10 +280,18 @@ func go_frankenphp_background_worker_ready(threadIndex C.uintptr_t) { } if handler.isBootingScript { + // readers never wait for a snapshot past the ready point + for _, ns := range handler.worker.varsNamespaces { + if !handler.publishedVars[ns] { + return C.CString("FrankenPHP\\WorkerHandle::tick(): publish the vars namespace " + strconv.Quote(ns) + " with setVars() before the first tick") + } + } + handler.isBootingScript = false // the boot succeeded, only consecutive boot failures count handler.failureCount = 0 handler.stopBootTimer() + handler.worker.markReady() metrics.ReadyWorker(handler.worker.name) // parked from now on as far as the threads state endpoint is concerned handler.state.MarkAsWaiting(true) @@ -289,6 +302,8 @@ func go_frankenphp_background_worker_ready(threadIndex C.uintptr_t) { handler.state.Set(state.Ready) } } + + return nil } // backoff waits before the next run of a crashed script, see restartBackoff diff --git a/threadworker.go b/threadworker.go index faa1ae1a12..6d23b21055 100644 --- a/threadworker.go +++ b/threadworker.go @@ -22,6 +22,10 @@ type workerThread struct { isBootingScript bool // true if the worker has not reached frankenphp_handle_request yet failureCount int // number of consecutive startup failures requestCount int // number of requests handled since last restart + + // the server of the request being served, or of the last one served: what + // runs between two requests belongs to the one before, see namespaceScope() + scope *Server } func convertToWorkerThread(thread *phpThread, worker *worker) { @@ -68,6 +72,7 @@ func setupWorkerScript(handler *workerThread, worker *worker) { } handler.dummyFrankenPHPContext = fc + handler.scope = worker.server handler.isBootingScript = true handler.requestCount = 0 @@ -118,6 +123,13 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { return } + // a boot that fails because the start did, a read of the vars of a worker + // that never got ready for instance, would fail again + if startupHasFailed() { + handler.thread.state.Set(state.ShuttingDown) + return + } + 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) @@ -217,6 +229,7 @@ func (handler *workerThread) waitForWorkerRequest() (bool, any) { } handler.requestCount++ + handler.scope = fc.server handler.thread.contextMu.Lock() handler.workerFrankenPHPContext = fc handler.thread.contextMu.Unlock() diff --git a/worker.go b/worker.go index 7a2e31128d..c97e03cc28 100644 --- a/worker.go +++ b/worker.go @@ -9,6 +9,7 @@ import ( "path/filepath" "runtime" "slices" + "strconv" "sync" "sync/atomic" "time" @@ -41,6 +42,19 @@ type worker struct { server *Server stopped atomic.Bool 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 + // the vars namespaces the worker publishes, in declaration order, and + // the snapshot of each, see WorkerHandle::setVars() + varsNamespaces []string + vars map[string]*varsSlot +} + +// markReady records that the background worker reached its ready point once +func (worker *worker) markReady() { + worker.readyClose.Do(func() { close(worker.readyOnce) }) } var ( @@ -51,6 +65,10 @@ var ( startupFailChan chan error // the only window where a boot failure reaches startupFailChan: past it, the handlers log and keep restarting startupPhase atomic.Bool + // closed by the first boot failure of the startup, so the threads waiting + // on the ready point of another worker give up, see waitVarsReady() + startupFailed chan struct{} + startupFailedOnce *sync.Once ) // defaultWorkerBootTimeout is how long a background worker may take to reach @@ -61,6 +79,7 @@ var ( const defaultWorkerBootTimeout = 30 * time.Second func initWorkers(opts []workerOpt) error { + varsOwners = namespaceOwners{} if len(opts) == 0 { return nil } @@ -83,6 +102,7 @@ func initWorkers(opts []workerOpt) error { totalThreadsToStart += w.num workers = append(workers, w) workersByName[w.name] = w + varsOwners.add(w, w.varsNamespaces) if w.server != nil { if err := w.server.addWorker(w); err != nil { return err @@ -95,6 +115,7 @@ func initWorkers(opts []workerOpt) error { } startupFailChan = make(chan error, totalThreadsToStart) + startupFailed, startupFailedOnce = make(chan struct{}), &sync.Once{} startupPhase.Store(true) for _, w := range workers { @@ -165,10 +186,27 @@ func reportStartupFailure(err error) bool { case startupFailChan <- err: default: } + startupFailedOnce.Do(func() { close(startupFailed) }) return true } +// startupHasFailed reports whether a boot failure already failed the start in +// progress: Init() then only waits for the threads still booting to stop, so +// their runs are not retried, whatever their worker's cap on failures says +func startupHasFailed() bool { + if !startupPhase.Load() { + return false + } + + select { + case <-startupFailed: + return true + default: + return false + } +} + // resolveWorkerFile turns a declared filename into the path a worker runs, // and names the worker after it when the declaration left the name out. // @@ -204,10 +242,86 @@ func resolveWorkerFile(o workerOpt) (workerOpt, error) { return o, nil } +// declarationIndex tells what the workers declared before took: names are +// global, paths and namespaces belong to a scope, a nil server standing for +// the global workers +type declarationIndex struct { + names func(name string) bool + paths func(server *Server, path string) bool + // the name of the worker publishing a vars namespace, empty when none does + vars func(server *Server, ns string) string +} + +// scopeSet records keys per scope, with the name of the worker that took +// each, a nil server for the global workers +type scopeSet map[*Server]map[string]string + +func (s scopeSet) has(server *Server, key string) bool { return s[server][key] != "" } + +func (s scopeSet) owner(server *Server, key string) string { return s[server][key] } + +func (s scopeSet) add(server *Server, key, owner string) { + if s[server] == nil { + s[server] = make(map[string]string) + } + s[server][key] = owner +} + +// namespaceOwners maps the namespaces declared in a scope to the worker that +// owns them, a nil server standing for the global workers +type namespaceOwners map[*Server]map[string]*worker + +func (o namespaceOwners) add(w *worker, namespaces []string) { + for _, ns := range namespaces { + if o[w.server] == nil { + o[w.server] = make(map[string]*worker) + } + o[w.server][ns] = w + } +} + +// resolve finds the owner of a namespace within a server first, then among +// the global workers +func (o namespaceOwners) resolve(server *Server, ns string) *worker { + if w := o[server][ns]; w != nil { + return w + } + + return o[nil][ns] +} + +// quoteNamespace quotes a namespace nobody declares for an error message, cut +// short: the name may come from a request, and the message is copied twice on +// its way into the memory of that request +func quoteNamespace(ns string) string { + const shown = 64 + if len(ns) > shown { + return strconv.Quote(ns[:shown]) + "..." + } + + return strconv.Quote(ns) +} + +// namespaceScope is the server a thread resolves its namespaces in first: the +// one of the request it serves. In a worker script, what runs between two +// requests still belongs to the one before, like its superglobals: the work +// a script does once the response is sent. What runs before the first +// request belongs to the worker. +func namespaceScope(thread *phpThread) *Server { + if handler, ok := thread.handler.(*workerThread); ok { + return handler.scope + } + if fc := thread.handler.frankenPHPContext(); fc != nil { + return fc.server + } + + return nil +} + // checkWorkerDeclaration holds the rules a set of workers must follow, so -// Validate() and newWorker() answer the same way; pathTaken is asked for the -// global workers with a nil server. -func checkWorkerDeclaration(o workerOpt, nameTaken func(string) bool, pathTaken func(*Server, string) bool) error { +// Validate() and newWorker() answer the same way; the paths and namespaces of +// the global workers are asked for with a nil server. +func checkWorkerDeclaration(o workerOpt, taken declarationIndex) error { if o.isBackgroundWorker { // never matched against requests, so several may share a script if o.matchRequest != nil { @@ -222,7 +336,7 @@ func checkWorkerDeclaration(o workerOpt, nameTaken func(string) bool, pathTaken return fmt.Errorf("background worker %q cannot be an extension worker, those handle requests", o.name) } } else if o.server == nil { - if pathTaken(nil, o.fileName) { + if taken.paths(nil, o.fileName) { return fmt.Errorf("two global workers cannot have the same filename: %q", o.fileName) } @@ -230,15 +344,38 @@ func checkWorkerDeclaration(o workerOpt, nameTaken func(string) bool, pathTaken if o.matchRequest != nil { return fmt.Errorf("worker %q has a request matcher but no server scope, use WithWorkerServerScope()", o.name) } - } else if o.matchRequest == nil && pathTaken(o.server, o.fileName) { + } else if o.matchRequest == nil && taken.paths(o.server, o.fileName) { // a matcher tells the workers of a server sharing a file apart return fmt.Errorf("two workers in a server cannot have the same filename: %q", o.fileName) } - if nameTaken(o.name) { + if taken.names(o.name) { return fmt.Errorf("two workers cannot have the same name: %q", o.name) } + scope := "two global workers" + if o.server != nil { + scope = "two workers in a server" + } + + if len(o.vars) > 0 { + if !o.isBackgroundWorker { + return fmt.Errorf("worker %q declares vars, only background workers publish them", o.name) + } + // one thread, so a snapshot has a single writer + if o.num > 1 { + return fmt.Errorf("background worker %q publishes vars, so it runs a single thread, got num %d", o.name, o.num) + } + } + for i, ns := range o.vars { + if slices.Contains(o.vars[:i], ns) { + return fmt.Errorf("worker %q declares the vars namespace %q twice", o.name, ns) + } + if owner := taken.vars(o.server, ns); owner != "" { + return fmt.Errorf("%s cannot publish the same vars namespace: %q, declared by %q and %q", scope, ns, owner, o.name) + } + } + return nil } @@ -248,16 +385,25 @@ func newWorker(o workerOpt) (*worker, error) { return nil, err } - nameTaken := func(name string) bool { return workersByName[name] != nil } - pathTaken := func(server *Server, path string) bool { - if server == nil { - return globalWorkersByPath[path] != nil - } + taken := declarationIndex{ + names: func(name string) bool { return workersByName[name] != nil }, + paths: func(server *Server, path string) bool { + if server == nil { + return globalWorkersByPath[path] != nil + } - return server.workersByPath[path] != nil + return server.workersByPath[path] != nil + }, + vars: func(server *Server, ns string) string { + if w := varsOwners[server][ns]; w != nil { + return w.name + } + + return "" + }, } - if err := checkWorkerDeclaration(o, nameTaken, pathTaken); err != nil { + if err := checkWorkerDeclaration(o, taken); err != nil { return nil, err } @@ -311,6 +457,12 @@ func newWorker(o workerOpt) (*worker, error) { onThreadShutdown: o.onThreadShutdown, server: o.server, isBackgroundWorker: o.isBackgroundWorker, + readyOnce: make(chan struct{}), + varsNamespaces: o.vars, + vars: make(map[string]*varsSlot, len(o.vars)), + } + for _, ns := range o.vars { + w.vars[ns] = &varsSlot{} } w.configureMercure(&o) diff --git a/workervars.go b/workervars.go new file mode 100644 index 0000000000..ac8437ab7b --- /dev/null +++ b/workervars.go @@ -0,0 +1,229 @@ +package frankenphp + +// #include "frankenphp.h" +import "C" +import ( + "errors" + "strconv" + "strings" + "sync" + "sync/atomic" + "unsafe" +) + +// varsSlot holds the current snapshot of a vars namespace, published through +// WorkerHandle::setVars(): a persistent HashTable, copied into request memory +// by each frankenphp_get_vars() reader. The copy runs in C with no lock held, +// so the slot and the readers copying the table each hold a reference on it, +// and the last to let go frees it, see frankenphp_vars_release(). The slot +// belongs to the worker, not to a thread, so it survives script restarts and +// serves stale data meanwhile. +type varsSlot struct { + mu sync.RWMutex + current *C.HashTable +} + +var ( + // the publisher of each vars namespace, per scope + varsOwners namespaceOwners + + // booting background workers blocked in frankenphp_get_vars() on other + // workers, keyed by waiter: a cycle would deadlock Init(), refuse it + varsWaitMu sync.Mutex + varsWaitOn = map[*worker]map[*worker]int{} +) + +// misuseError is an error PHP gets as a LogicException, a mistake in the +// code: the others depend on the configuration in place or on the moment, and +// are thrown as RuntimeException +type misuseError string + +func (e misuseError) Error() string { + return string(e) +} + +// phpError hands an error over to the C side, with whether it is a misuse +func phpError(err error) (*C.char, C.bool) { + var misuse misuseError + + return C.CString(err.Error()), C.bool(errors.As(err, &misuse)) +} + +// varsOwner resolves the publisher of a vars namespace: within the caller's +// server first, then among global workers +func varsOwner(thread *phpThread, ns string) (*worker, error) { + w := varsOwners.resolve(namespaceScope(thread), ns) + if w == nil { + // what the configuration lacks, like a Mercure hub for frankenphp_mercure_publish() + return nil, errors.New("frankenphp_get_vars(): no background worker publishes the vars namespace " + quoteNamespace(ns) + ", in this php_server or among the global workers") + } + + 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 worker still booting, a background one or the +// bootstrap of an HTTP one. Waits between background workers form a graph, +// and a cycle is refused instead of deadlocking Init(), as is any wait once +// the start failed +func waitVarsReady(target, caller *worker) error { + select { + case <-target.readyOnce: + return nil + default: + } + + if caller != nil { + varsWaitMu.Lock() + if caller == target { + varsWaitMu.Unlock() + + return misuseError("frankenphp_get_vars(): background worker " + strconv.Quote(caller.name) + " reads its own vars before its first tick, they are not published yet") + } + if varsWaitReaches(target, caller) { + varsWaitMu.Unlock() + + return misuseError("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") + case <-startupFailed: + return errors.New("frankenphp_get_vars(): FrankenPHP failed to start, " + strconv.Quote(target.name) + " is not ready") + } +} + +// 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 { + for _, slot := range w.vars { + slot.mu.Lock() + if slot.current != nil { + C.frankenphp_vars_release(slot.current) + slot.current = nil + } + slot.mu.Unlock() + } + } +} + +// go_frankenphp_set_vars stores a snapshot in a namespace of the calling +// worker and hands back the one it replaces, for the C side to let go of; on +// error, the C side frees the snapshot it passed +// +//export go_frankenphp_set_vars +func go_frankenphp_set_vars(threadIndex C.uintptr_t, namespace *C.char, namespaceLen C.size_t, table *C.HashTable, old **C.HashTable) *C.char { + handler, ok := phpThreads[threadIndex].handler.(*backgroundWorkerThread) + if !ok { + // refused on the C side already + return C.CString("FrankenPHP\\WorkerHandle::setVars() can only be called from a background worker") + } + + // looked up first, and only kept once it is known + ns := unsafe.String((*byte)(unsafe.Pointer(namespace)), int(namespaceLen)) + slot := handler.worker.vars[ns] + if slot == nil { + return C.CString("FrankenPHP\\WorkerHandle::setVars(): this worker does not publish the vars namespace " + quoteNamespace(ns)) + } + ns = strings.Clone(ns) + + // the table comes with one reference, the slot's + slot.mu.Lock() + *old = slot.current + slot.current = table + slot.mu.Unlock() + handler.publishedVars[ns] = true + + return nil +} + +// go_frankenphp_get_vars hands the current snapshot of a namespace to the C +// side with a reference on it, to copy it into request memory and then let go +// of it. Nothing of the read is kept here: a thread that borrows the index of +// another, as the children of ext-parallel do, reads at the same time +// +//export go_frankenphp_get_vars +func go_frankenphp_get_vars(threadIndex C.uintptr_t, namespace *C.char, namespaceLen C.size_t, table **C.HashTable) (*C.char, C.bool) { + thread := phpThreads[threadIndex] + // looked up, never kept + ns := unsafe.String((*byte)(unsafe.Pointer(namespace)), int(namespaceLen)) + target, err := varsOwner(thread, ns) + if err != nil { + return phpError(err) + } + + var caller *worker + if handler, ok := thread.handler.(*backgroundWorkerThread); ok && handler.isBootingScript { + caller = handler.worker + } + if err := waitVarsReady(target, caller); err != nil { + return phpError(err) + } + + // a worker is ready only once every namespace it declares has a snapshot + slot := target.vars[ns] + slot.mu.RLock() + // under the lock, so the slot still holds its own reference + atomic.AddUint32((*uint32)(unsafe.Pointer(&slot.current.gc.refcount)), 1) + *table = slot.current + slot.mu.RUnlock() + + return nil, false +} + +// go_frankenphp_worker_namespaces builds what WorkerHandle::getNamespaces() +// returns for the calling worker; the C side refuses the other threads +// +//export go_frankenphp_worker_namespaces +func go_frankenphp_worker_namespaces(threadIndex C.uintptr_t, namespaces *C.zval) { + worker := phpThreads[threadIndex].handler.(*backgroundWorkerThread).worker + + phpValueInto(AssociativeArray[any]{ + Map: map[string]any{"vars": namespaceList(worker.varsNamespaces)}, + Order: []string{"vars"}, + }, namespaces) +} + +// namespaceList is a list of namespaces as phpValueInto() takes it +func namespaceList(namespaces []string) []any { + list := make([]any, len(namespaces)) + for i, ns := range namespaces { + list[i] = ns + } + + return list +} diff --git a/workervars_test.go b/workervars_test.go new file mode 100644 index 0000000000..9d2cb73168 --- /dev/null +++ b/workervars_test.go @@ -0,0 +1,402 @@ +package frankenphp_test + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "github.com/dunglas/frankenphp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// bgWorker declares a background worker from testdata/bgworker publishing the +// vars namespace named like it, scoped to server when one is given +func bgWorker(name, file string, env map[string]string, server *frankenphp.Server, extra ...frankenphp.WorkerOption) frankenphp.Option { + opts := []frankenphp.WorkerOption{frankenphp.WithWorkerBackground(), frankenphp.WithWorkerEnv(env), frankenphp.WithWorkerVars(name)} + if server != nil { + opts = append(opts, frankenphp.WithWorkerServerScope(server)) + } + + return frankenphp.WithWorkers(name, "testdata/bgworker/"+file, 1, append(opts, extra...)...) +} + +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?ns=publisher") + assert.JSONEq(t, `{"answer":42,"value":"default","nested":{"a":1,"list":[true,null,1.5,"x"]},"worker":"publisher"}`, body) + + // a second read is a fresh copy of the same snapshot + assert.JSONEq(t, body, serverGet(t, server, "http://example.com/vars.php?ns=publisher")) + assert.Contains(t, serverGet(t, server, "http://example.com/vars.php?ns=nope"), `RuntimeException: frankenphp_get_vars(): no background worker publishes the vars namespace "nope"`) + + // a name nobody declares may come from the request: the message shows its start + long := serverGet(t, server, "http://example.com/vars.php?ns="+strings.Repeat("x", 5000)) + assert.Contains(t, long, `the vars namespace "`+strings.Repeat("x", 64)+`"...`) + assert.Less(t, len(long), 300) +} + +// one worker publishes several namespaces, and readers address the +// namespace, never the worker +func TestVarsNamespaces(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, + frankenphp.WithServer(server), + frankenphp.WithWorkers("config", "testdata/bgworker/multi-vars.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerServerScope(server), + frankenphp.WithWorkerVars("redis", "feature_flags"), + ), + frankenphp.WithNumThreads(2), + ) + + all := `{"vars":["redis","feature_flags"]}` + assert.JSONEq(t, `{"namespace":"redis","all":`+all+`}`, serverGet(t, server, "http://example.com/vars.php?ns=redis")) + assert.JSONEq(t, `{"namespace":"feature_flags","all":`+all+`}`, serverGet(t, server, "http://example.com/vars.php?ns=feature_flags")) + assert.Contains(t, serverGet(t, server, "http://example.com/vars.php?ns=config"), "no background worker publishes") +} + +// a namespace resolves within the server of the reader first, then among the +// global workers +func TestVarsScopedToServer(t *testing.T) { + server1, _ := frankenphp.NewServer(testDataDir, frankenphp.WithServerName("one")) + server2, _ := frankenphp.NewServer(testDataDir, frankenphp.WithServerName("two")) + server3, _ := frankenphp.NewServer(testDataDir, frankenphp.WithServerName("three")) + // worker names are global, the namespace is what the three share + publisher := func(name, value string, server *frankenphp.Server) frankenphp.Option { + opts := []frankenphp.WorkerOption{ + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerVars("cfg"), + frankenphp.WithWorkerEnv(map[string]string{"BG_PUBLISH_VALUE": value}), + } + if server != nil { + opts = append(opts, frankenphp.WithWorkerServerScope(server)) + } + + return frankenphp.WithWorkers(name, "testdata/bgworker/publisher.php", 1, opts...) + } + initServers(t, + frankenphp.WithServer(server1), + frankenphp.WithServer(server2), + frankenphp.WithServer(server3), + publisher("cfg-one", "one", server1), + publisher("cfg-two", "two", server2), + publisher("cfg-global", "global", nil), + frankenphp.WithNumThreads(3), + ) + + assert.Contains(t, serverGet(t, server1, "http://example.com/vars.php?ns=cfg"), `"value":"one"`) + assert.Contains(t, serverGet(t, server2, "http://example.com/vars.php?ns=cfg"), `"value":"two"`) + assert.Contains(t, serverGet(t, server3, "http://example.com/vars.php?ns=cfg"), `"value":"global"`) +} + +// between two requests, a worker serving any server still reads the vars of +// the one it just served +func TestVarsBetweenTwoRequests(t *testing.T) { + server1, _ := frankenphp.NewServer(testDataDir, frankenphp.WithServerName("one")) + server2, _ := frankenphp.NewServer(testDataDir, frankenphp.WithServerName("two")) + publisher := func(name, value string, server *frankenphp.Server) frankenphp.Option { + return frankenphp.WithWorkers(name, "testdata/bgworker/publisher.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerVars("cfg"), + frankenphp.WithWorkerEnv(map[string]string{"BG_PUBLISH_VALUE": value}), + frankenphp.WithWorkerServerScope(server), + ) + } + initServers(t, + frankenphp.WithServer(server1), + frankenphp.WithServer(server2), + publisher("cfg-one", "one", server1), + publisher("cfg-two", "two", server2), + frankenphp.WithWorkers("reader", testDataDir+"vars-between-requests.php", 1), + frankenphp.WithNumThreads(4), + ) + + url := "http://example.com/vars-between-requests.php" + assert.JSONEq(t, `{"request":"one","after":null}`, serverGet(t, server1, url)) + assert.JSONEq(t, `{"request":"two","after":"one"}`, serverGet(t, server2, url)) + assert.JSONEq(t, `{"request":"one","after":"two"}`, serverGet(t, server1, url)) +} + +// 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`) +} + +// a worker reading its own namespace before its first tick would wait for +// itself: refused with a message saying so +func TestVarsOwnReadBeforeTheFirstTick(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "self.txt") + initServers(t, bgWorker("self", "consumer.php", map[string]string{"BG_CONSUME": "self", "BG_SENTINEL": sentinel}, nil), frankenphp.WithNumThreads(2)) + + assert.Contains(t, requireFileContentEventually(t, sentinel), `background worker "self" reads its own vars before its first tick`) +} + +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 and publishes the exception, the other reads it + results := requireFileContentEventually(t, s1) + "\n" + requireFileContentEventually(t, s2) + assert.Contains(t, results, "circular dependency") + assert.Contains(t, results, `{"consumed":"LogicException: frankenphp_get_vars(): circular dependency`) +} + +// the first tick of a run waits for every declared namespace, so readers +// never ask a ready worker for a snapshot it does not have +func TestVarsArePublishedBeforeTheFirstTick(t *testing.T) { + t.Cleanup(frankenphp.Shutdown) + + err := frankenphp.Init(bgWorker("silent", "basic.php", nil, nil, frankenphp.WithWorkerMaxFailures(1)), frankenphp.WithNumThreads(1)) + require.Error(t, err) +} + +func TestVarsRejectInvalidPublications(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "bad.txt") + initServers(t, bgWorker("bad", "bad-vars.php", map[string]string{"BG_SENTINEL": sentinel}, nil), frankenphp.WithNumThreads(2)) + + result := requireFileContentEventually(t, sentinel) + assert.Contains(t, result, "ValueError") + assert.Contains(t, result, `ValueError: FrankenPHP\WorkerHandle::setVars(): values must be null, scalars, arrays or enums`) + assert.Contains(t, result, `ValueError: FrankenPHP\WorkerHandle::setVars(): values must fit in memory_limit once copied`) + assert.Contains(t, result, `LogicException: FrankenPHP\WorkerHandle::setVars(): this worker does not publish the vars namespace "undeclared"`) +} + +// a snapshot fits in the memory_limit of its publisher, not always in the one +// of its readers: their copy is refused before it starts +func TestVarsLargerThanTheMemoryLimitOfTheReader(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), bgWorker("big", "publisher.php", map[string]string{"BG_PUBLISH_BLOB_MB": "32"}, server), frankenphp.WithNumThreads(2)) + + body := serverGet(t, server, "http://example.com/vars-over-limit.php") + assert.Contains(t, body, "RuntimeException: FrankenPHP: the values to copy take 33") + assert.Contains(t, body, "more than what is left of memory_limit") +} + +// a reboot restarts the PHP engine, and readers keep getting the snapshot +// published before it until the worker publishes again: it must not point +// into the memory of the engine that is gone +func TestVarsSurviveAReboot(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + env := map[string]string{"BG_SENTINEL": filepath.Join(t.TempDir(), "published"), "BG_REPUBLISH_DELAY_MS": "2000"} + initServers(t, frankenphp.WithServer(server), bgWorker("once", "republish.php", env, server), frankenphp.WithNumThreads(2)) + + before := serverGet(t, server, "http://example.com/vars.php?ns=once") + require.JSONEq(t, `{"answer":42,"nested":{"a":1,"list":[true,null,1.5,"x"]}}`, before) + + rebooted := make(chan struct{}) + go func() { + frankenphp.RestartWorkers() + close(rebooted) + }() + + // the request threads come back while the worker still waits to publish + time.Sleep(500 * time.Millisecond) + assert.JSONEq(t, before, serverGet(t, server, "http://example.com/vars.php?ns=once")) + + <-rebooted + assert.JSONEq(t, before, serverGet(t, server, "http://example.com/vars.php?ns=once")) +} + +func TestValidateVarsNamespaces(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + publisher := func(name string, num int, extra ...frankenphp.WorkerOption) frankenphp.Option { + return frankenphp.WithWorkers(name, "testdata/bgworker/publisher.php", num, append([]frankenphp.WorkerOption{frankenphp.WithWorkerBackground()}, extra...)...) + } + + assert.ErrorContains(t, frankenphp.Validate( + publisher("one", 1, frankenphp.WithWorkerVars("cfg")), + publisher("two", 1, frankenphp.WithWorkerVars("other", "cfg")), + ), `two global workers cannot publish the same vars namespace: "cfg", declared by "one" and "two"`) + + // one publisher per server + other, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + assert.NoError(t, frankenphp.Validate( + frankenphp.WithServer(server), + frankenphp.WithServer(other), + publisher("one", 1, frankenphp.WithWorkerVars("cfg"), frankenphp.WithWorkerServerScope(server)), + publisher("two", 1, frankenphp.WithWorkerVars("cfg"), frankenphp.WithWorkerServerScope(other)), + )) + + // the same namespace in a server and globally: the server's comes first for its requests + assert.NoError(t, frankenphp.Validate( + frankenphp.WithServer(server), + publisher("one", 1, frankenphp.WithWorkerVars("cfg")), + publisher("two", 1, frankenphp.WithWorkerVars("cfg"), frankenphp.WithWorkerServerScope(server)), + )) + + assert.ErrorContains(t, frankenphp.Validate( + publisher("one", 1, frankenphp.WithWorkerVars("cfg", "cfg")), + ), `declares the vars namespace "cfg" twice`) + + assert.ErrorContains(t, frankenphp.Validate( + publisher("pool", 2, frankenphp.WithWorkerVars("cfg")), + ), `background worker "pool" publishes vars, so it runs a single thread, got num 2`) + + assert.ErrorContains(t, frankenphp.Validate( + frankenphp.WithWorkers("http", "testdata/worker.php", 1, frankenphp.WithWorkerVars("cfg")), + ), "only background workers publish them") +} + +// the rules are the ones Init() applies +func TestInitRefusesASharedVarsNamespace(t *testing.T) { + t.Cleanup(frankenphp.Shutdown) + + err := frankenphp.Init( + bgWorker("cfg", "publisher.php", nil, nil), + frankenphp.WithWorkers("other", "testdata/bgworker/publisher.php", 1, frankenphp.WithWorkerBackground(), frankenphp.WithWorkerVars("cfg")), + frankenphp.WithNumThreads(1), + ) + require.ErrorContains(t, err, `two global workers cannot publish the same vars namespace: "cfg"`) +} + +// a bailout while copying a snapshot, here in an autoloader its enum +// triggers, lets go of it: the next readers are served, by a publisher that +// keeps publishing. memory_limit would do too, but not where the Zend +// allocator is off, as under the sanitizers. +func TestVarsBailoutInTheCopy(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), bgWorker("enum", "enum-vars.php", nil, server), frankenphp.WithNumThreads(2)) + + assert.Contains(t, serverGet(t, server, "http://example.com/vars-fatal.php?fatal=1"), "Cannot redeclare") + + first := serverGet(t, server, "http://example.com/vars-fatal.php") + require.Eventually(t, func() bool { + return serverGet(t, server, "http://example.com/vars-fatal.php") != first + }, 5*time.Second, 25*time.Millisecond, "the publisher is stuck") +} + +// an autoloader may keep the class name it is given: a request string, not +// the one of the snapshot, which the next publication frees +func TestVarsEnumClassNameOutlivesTheSnapshot(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), bgWorker("enum", "enum-vars.php", nil, server), frankenphp.WithNumThreads(2)) + + assert.Equal(t, "Hearts kept:EnumSuit", serverGet(t, server, "http://example.com/vars-enum.php")) +} + +// an HTTP worker reading vars in its bootstrap gives up once the start +// failed, here because the publisher never publishes, instead of keeping +// Init() waiting +func TestVarsWaitEndsWhenTheStartFails(t *testing.T) { + t.Cleanup(frankenphp.Shutdown) + done := make(chan error, 1) + go func() { + done <- frankenphp.Init( + bgWorker("silent", "basic.php", nil, nil, frankenphp.WithWorkerMaxFailures(1)), + frankenphp.WithWorkers("reader", "testdata/vars-at-boot.php", 1), + frankenphp.WithNumThreads(3), + ) + }() + + select { + case err := <-done: + require.ErrorContains(t, err, "keeps crashing") + case <-time.After(10 * time.Second): + t.Fatal("Init() did not return") + } +} + +// a worker whose boot fails because the start did is not run again, even one +// that never gives up restarting: Init() would wait for it for ever +func TestStartFailureStopsTheWorkersStillBooting(t *testing.T) { + t.Cleanup(frankenphp.Shutdown) + done := make(chan error, 1) + go func() { + done <- frankenphp.Init( + bgWorker("silent", "basic.php", nil, nil, frankenphp.WithWorkerMaxFailures(1)), + frankenphp.WithWorkers("reader", "testdata/vars-at-boot.php", 1, + frankenphp.WithWorkerEnv(map[string]string{"VARS_UNCAUGHT": "1"}), + frankenphp.WithWorkerMaxFailures(-1), + ), + frankenphp.WithNumThreads(3), + ) + }() + + select { + case err := <-done: + require.ErrorContains(t, err, "keeps crashing") + case <-time.After(10 * time.Second): + t.Fatal("Init() did not return") + } +} + +// the namespaces of a run go with it, even when the next run has no workers +func TestVarsOwnersEndWithTheRun(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), bgWorker("publisher", "publisher.php", nil, server), frankenphp.WithNumThreads(2)) + require.Contains(t, serverGet(t, server, "http://example.com/vars.php?ns=publisher"), `"answer":42`) + frankenphp.Shutdown() + + initServers(t, frankenphp.WithServer(server), frankenphp.WithNumThreads(1)) + assert.Contains(t, serverGet(t, server, "http://example.com/vars.php?ns=publisher"), `no background worker publishes the vars namespace "publisher"`) +} + +// a worker of a server falls back to the global namespace like a request does +func TestVarsServerWorkerReadsAGlobalNamespace(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "consumer.json") + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, + frankenphp.WithServer(server), + bgWorker("publisher", "publisher.php", map[string]string{"BG_PUBLISH_VALUE": "global"}, nil), + bgWorker("consumer", "consumer.php", map[string]string{"BG_CONSUME": "publisher", "BG_SENTINEL": sentinel}, server), + frankenphp.WithNumThreads(3), + ) + + assert.Contains(t, requireFileContentEventually(t, sentinel), `"value":"global"`) +} + +// an autoloader may suspend the fiber reading vars in the middle of the +// copy: each read lets go of the snapshot it holds, whatever the order the +// fibers resume in +func TestVarsReadsInterleavedByFibers(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, + frankenphp.WithServer(server), + bgWorker("enum-a", "enum-vars.php", nil, server), + bgWorker("enum-b", "enum-vars.php", map[string]string{"BG_ENUM": "EnumRank::Ace"}, server), + frankenphp.WithNumThreads(3), + ) + + for range 5 { + require.Equal(t, "Hearts Ace", serverGet(t, server, "http://example.com/vars-fibers.php")) + } +} + +// a snapshot written against another version of an enum fails the read with +// an exception, whatever the reader's class has instead of the case +func TestVarsEnumCaseMissingInTheReader(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), bgWorker("enum", "enum-vars.php", nil, server), frankenphp.WithNumThreads(2)) + + assert.Equal(t, `LogicException: persistent_zval: enum case "EnumSuit::Hearts" not found`, serverGet(t, server, "http://example.com/vars-enum-mismatch.php")) + assert.Equal(t, `LogicException: persistent_zval: enum case "EnumSuit::Hearts" not found`, serverGet(t, server, "http://example.com/vars-enum-mismatch.php?constant=1")) +} diff --git a/zval.h b/zval.h index f75cccdc60..18a4ba6302 100644 --- a/zval.h +++ b/zval.h @@ -11,9 +11,9 @@ * depth and a native stack overflow (crashes the whole process, not just * one request). * - * Fast paths: - * - Interned strings: shared memory, no copy. - * - Opcache-immutable arrays: shared pointer, no copy, no free. + * A persistent tree owns all its memory, interned strings and + * opcache-immutable arrays included: it can outlive an engine reboot, which + * frees the former, and an opcache restart, which rewinds the latter. * * Included by frankenphp.c; not a standalone compilation unit. */ @@ -38,47 +38,92 @@ typedef struct { zend_string *case_name; } persistent_zval_enum_t; +/* Takes bytes out of what the copy of a tree may still use. */ +static bool persistent_zval_spend(size_t *left, size_t bytes) { + if (*left < bytes) { + return false; + } + *left -= bytes; + + return true; +} + /* Whitelist check: only scalars, arrays of allowed values, and enum - * instances pass. Returns false for objects other than enums, resources, - * closures, references, etc. Also enforces PERSISTENT_ZVAL_MAX_DEPTH, - * bailing out before recursing further once hit - see the file header for - * why this is the only place that's safe to do so. */ -static bool persistent_zval_validate_depth(zval *z, int depth) { + * instances pass, objects other than enums, resources, closures etc. do + * not. A reference stands for its value, as a foreach by reference leaves + * one in its array; an array that holds itself through one is nested past + * any depth. Also enforces PERSISTENT_ZVAL_MAX_DEPTH, bailing out before + * recursing further once hit - see the file header for why this is the only + * place that's safe to do so - and counts the bytes the copy would take. */ +static const char *persistent_zval_check(zval *z, int depth, size_t *left) { if (depth > PERSISTENT_ZVAL_MAX_DEPTH) { - return false; + return "values must be nested no deeper than " ZEND_TOSTR( + PERSISTENT_ZVAL_MAX_DEPTH) " levels"; } + ZVAL_DEREF(z); switch (Z_TYPE_P(z)) { case IS_NULL: case IS_FALSE: case IS_TRUE: case IS_LONG: case IS_DOUBLE: + return NULL; case IS_STRING: - return true; + return persistent_zval_spend(left, _ZSTR_STRUCT_SIZE(Z_STRLEN_P(z))) + ? NULL + : "values must fit in memory_limit once copied"; case IS_OBJECT: - return (Z_OBJCE_P(z)->ce_flags & ZEND_ACC_ENUM) != 0; + if (!(Z_OBJCE_P(z)->ce_flags & ZEND_ACC_ENUM)) { + return "values must be null, scalars, arrays or enums"; + } + return persistent_zval_spend( + left, sizeof(persistent_zval_enum_t) + + _ZSTR_STRUCT_SIZE(ZSTR_LEN(Z_OBJCE_P(z)->name)) + + _ZSTR_STRUCT_SIZE( + Z_STRLEN_P(zend_enum_fetch_case_name(Z_OBJ_P(z))))) + ? NULL + : "values must fit in memory_limit once copied"; case IS_ARRAY: { - /* Opcache-immutable arrays are compile-time constants in shared - * memory; their leaves are guaranteed scalars or further immutable - * arrays. The copy/free paths below already trust this flag, so a - * recursive walk here would just be cycles. */ - if ((GC_FLAGS(Z_ARRVAL_P(z)) & IS_ARRAY_IMMUTABLE) != 0) - return true; + if (!persistent_zval_spend(left, sizeof(HashTable) + + zend_hash_num_elements(Z_ARRVAL_P(z)) * + sizeof(Bucket))) { + return "values must fit in memory_limit once copied"; + } + zend_string *key; zval *val; - ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(z), val) { - if (!persistent_zval_validate_depth(val, depth + 1)) - return false; + ZEND_HASH_FOREACH_STR_KEY_VAL(Z_ARRVAL_P(z), key, val) { + if (key != NULL && + !persistent_zval_spend(left, _ZSTR_STRUCT_SIZE(ZSTR_LEN(key)))) { + return "values must fit in memory_limit once copied"; + } + const char *invalid = persistent_zval_check(val, depth + 1, left); + if (invalid != NULL) { + return invalid; + } } ZEND_HASH_FOREACH_END(); - return true; + return NULL; } default: - return false; + return "values must be null, scalars, arrays or enums"; } } -static bool persistent_zval_validate(zval *z) { - return persistent_zval_validate_depth(z, 0); +/* Why a tree cannot be persisted, NULL when it can. Its copy is malloc + * memory, outside the memory_limit of the request making it, and every + * occurrence of a value shared in the tree gets a copy of its own: a tree + * whose copy would not fit in that limit is refused, which also bounds the + * walk of a value shared many times over. */ +static const char *persistent_zval_invalid(zval *z) { + size_t left = PG(memory_limit) > 0 ? (size_t)PG(memory_limit) : SIZE_MAX; + + return persistent_zval_check(z, 0, &left); +} + +/* inline: the callers that want a reason use persistent_zval_invalid(), and a + * build may leave this one without any */ +static inline bool persistent_zval_validate(zval *z) { + return persistent_zval_invalid(z) == NULL; } /* Deep-copy a zval from request memory into persistent (pemalloc) memory. @@ -89,6 +134,7 @@ static bool persistent_zval_validate(zval *z) { * should never expose a persistent zval to PHP directly, only via * persistent_zval_to_request. */ static void persistent_zval_persist(zval *dst, zval *src) { + ZVAL_DEREF(src); switch (Z_TYPE_P(src)) { case IS_NULL: case IS_FALSE: @@ -103,38 +149,22 @@ static void persistent_zval_persist(zval *dst, zval *src) { break; case IS_STRING: { zend_string *s = Z_STR_P(src); - if (ZSTR_IS_INTERNED(s)) { - ZVAL_STR(dst, s); /* interned strings live process-wide */ - } else { - ZVAL_NEW_STR(dst, zend_string_init(ZSTR_VAL(s), ZSTR_LEN(s), 1)); - } + ZVAL_NEW_STR(dst, zend_string_init(ZSTR_VAL(s), ZSTR_LEN(s), 1)); break; } case IS_OBJECT: { /* Must be an enum (validated earlier in persistent_zval_validate). */ zend_class_entry *ce = Z_OBJCE_P(src); persistent_zval_enum_t *e = pemalloc(sizeof(*e), 1); - e->class_name = - ZSTR_IS_INTERNED(ce->name) - ? ce->name - : zend_string_init(ZSTR_VAL(ce->name), ZSTR_LEN(ce->name), 1); + e->class_name = zend_string_init(ZSTR_VAL(ce->name), ZSTR_LEN(ce->name), 1); zval *case_name_zval = zend_enum_fetch_case_name(Z_OBJ_P(src)); zend_string *case_str = Z_STR_P(case_name_zval); - e->case_name = - ZSTR_IS_INTERNED(case_str) - ? case_str - : zend_string_init(ZSTR_VAL(case_str), ZSTR_LEN(case_str), 1); + e->case_name = zend_string_init(ZSTR_VAL(case_str), ZSTR_LEN(case_str), 1); ZVAL_PTR(dst, e); break; } case IS_ARRAY: { HashTable *src_ht = Z_ARRVAL_P(src); - if ((GC_FLAGS(src_ht) & IS_ARRAY_IMMUTABLE) != 0) { - /* Opcache-immutable arrays live for the process lifetime and are - * safe to share across threads by pointer. Zero-copy, zero-free. */ - ZVAL_ARR(dst, src_ht); - break; - } HashTable *dst_ht = pemalloc(sizeof(HashTable), 1); zend_hash_init(dst_ht, zend_hash_num_elements(src_ht), NULL, NULL, 1); ZVAL_ARR(dst, dst_ht); @@ -146,16 +176,12 @@ static void persistent_zval_persist(zval *dst, zval *src) { zval pval; persistent_zval_persist(&pval, val); if (key) { - if (ZSTR_IS_INTERNED(key)) { - zend_hash_add_new(dst_ht, key, &pval); - } else { - zend_string *pkey = zend_string_init(ZSTR_VAL(key), ZSTR_LEN(key), 1); - /* Iteration guarantees the source key has its hash set. - * Propagating it lets zend_hash_add_new skip the re-hash. */ - ZSTR_H(pkey) = ZSTR_H(key); - zend_hash_add_new(dst_ht, pkey, &pval); - zend_string_release(pkey); - } + zend_string *pkey = zend_string_init(ZSTR_VAL(key), ZSTR_LEN(key), 1); + /* Iteration guarantees the source key has its hash set. + * Propagating it lets zend_hash_add_new skip the re-hash. */ + ZSTR_H(pkey) = ZSTR_H(key); + zend_hash_add_new(dst_ht, pkey, &pval); + zend_string_release(pkey); } else { zend_hash_index_add_new(dst_ht, idx, &pval); } @@ -169,30 +195,21 @@ static void persistent_zval_persist(zval *dst, zval *src) { } } -/* Deep-free a persistent zval tree. Idempotent on scalars. Skips - * interned strings and immutable arrays (they are borrowed, not owned). */ +/* Deep-free a persistent zval tree. Idempotent on scalars. */ static void persistent_zval_free(zval *z) { switch (Z_TYPE_P(z)) { case IS_STRING: - if (!ZSTR_IS_INTERNED(Z_STR_P(z))) { - zend_string_free(Z_STR_P(z)); - } + zend_string_free(Z_STR_P(z)); break; case IS_PTR: { persistent_zval_enum_t *e = (persistent_zval_enum_t *)Z_PTR_P(z); - if (!ZSTR_IS_INTERNED(e->class_name)) - zend_string_free(e->class_name); - if (!ZSTR_IS_INTERNED(e->case_name)) - zend_string_free(e->case_name); + zend_string_free(e->class_name); + zend_string_free(e->case_name); pefree(e, 1); break; } case IS_ARRAY: { HashTable *ht = Z_ARRVAL_P(z); - if ((GC_FLAGS(ht) & IS_ARRAY_IMMUTABLE) != 0) { - /* Borrowed from opcache, do not touch. */ - break; - } zval *val; ZEND_HASH_FOREACH_VAL(ht, val) { persistent_zval_free(val); } ZEND_HASH_FOREACH_END(); @@ -209,6 +226,82 @@ static void persistent_zval_free(zval *z) { * resolved from their class+case names on each call. If the enum class * or case can't be found in the current thread's class table, an * exception is thrown and dst is set to IS_NULL. */ +/* The case of an enum by name, NULL when the reader's class has no such case: + * the engine's own lookup asserts the case exists, which a snapshot written + * against another version of the class breaks. */ +static zend_object *persistent_zval_enum_case(zend_class_entry *ce, + zend_string *name) { + /* by value: hashing the persistent name would write to a string other + * threads read */ + zend_class_constant *c = zend_hash_str_find_ptr( + CE_CONSTANTS_TABLE(ce), ZSTR_VAL(name), ZSTR_LEN(name)); + if (c == NULL || !(ZEND_CLASS_CONST_FLAGS(c) & ZEND_CLASS_CONST_IS_CASE)) { + return NULL; + } + if (Z_TYPE(c->value) == IS_CONSTANT_AST && + zval_update_constant_ex(&c->value, c->ce) == FAILURE) { + return NULL; + } + + return Z_TYPE(c->value) == IS_OBJECT ? Z_OBJ(c->value) : NULL; +} + +/* The request memory the copy of a persistent tree takes: each string with + * its header, each table at the size the engine allocates for that many + * elements, a list or a hash as the persistent one is. */ +static size_t persistent_zval_request_size(zval *src) { + switch (Z_TYPE_P(src)) { + case IS_STRING: + return ZEND_MM_ALIGNED_SIZE(_ZSTR_STRUCT_SIZE(Z_STRLEN_P(src))); + case IS_ARRAY: { + HashTable *ht = Z_ARRVAL_P(src); + size_t size = sizeof(HashTable); + uint32_t count = zend_hash_num_elements(ht); + if (count > 0) { + /* the next power of two */ + size_t slots = HT_MIN_SIZE; + while (slots < count) { + slots <<= 1; + } + size += HT_IS_PACKED(ht) + ? HT_PACKED_SIZE_EX(slots, HT_MIN_MASK) + : slots * (sizeof(Bucket) + 2 * sizeof(uint32_t)); + } + + zend_string *key; + zval *val; + ZEND_HASH_FOREACH_STR_KEY_VAL(ht, key, val) { + if (key != NULL) { + size += ZEND_MM_ALIGNED_SIZE(_ZSTR_STRUCT_SIZE(ZSTR_LEN(key))); + } + size += persistent_zval_request_size(val); + } + ZEND_HASH_FOREACH_END(); + + return size; + } + default: + return 0; + } +} + +/* Whether the copy of a persistent tree fits in what the calling thread has + * left of its memory_limit, which may be lower than the one the tree was + * checked against when it was persisted; size gets what the copy takes. Past + * its limit the engine only raises its error once the copy is done, and past + * its hard limit it ends the whole process: such a tree has to be refused + * before the copy starts. */ +static bool persistent_zval_fits_request(zval *src, size_t *size) { + *size = persistent_zval_request_size(src); + if (PG(memory_limit) <= 0) { + return true; + } + + size_t limit = (size_t)PG(memory_limit), used = zend_memory_usage(0); + + return used < limit && *size <= limit - used; +} + static void persistent_zval_to_request(zval *dst, zval *src) { switch (Z_TYPE_P(src)) { case IS_NULL: @@ -223,15 +316,16 @@ static void persistent_zval_to_request(zval *dst, zval *src) { ZVAL_DOUBLE(dst, Z_DVAL_P(src)); break; case IS_STRING: - if (ZSTR_IS_INTERNED(Z_STR_P(src))) { - ZVAL_STR(dst, Z_STR_P(src)); - } else { - ZVAL_STRINGL(dst, Z_STRVAL_P(src), Z_STRLEN_P(src)); - } + ZVAL_STRINGL(dst, Z_STRVAL_P(src), Z_STRLEN_P(src)); break; case IS_PTR: { persistent_zval_enum_t *e = (persistent_zval_enum_t *)Z_PTR_P(src); - zend_class_entry *ce = zend_lookup_class(e->class_name); + /* autoloaders get the name and may keep it: a request copy, since the + * persistent one is shared with other threads and freed with its tree */ + zend_string *class_name = + zend_string_init(ZSTR_VAL(e->class_name), ZSTR_LEN(e->class_name), 0); + zend_class_entry *ce = zend_lookup_class(class_name); + zend_string_release(class_name); if (EG(exception)) { /* Autoloader threw; let that exception propagate untouched. */ ZVAL_NULL(dst); @@ -244,7 +338,11 @@ static void persistent_zval_to_request(zval *dst, zval *src) { ZVAL_NULL(dst); break; } - zend_object *enum_obj = zend_enum_get_case_cstr(ce, ZSTR_VAL(e->case_name)); + zend_object *enum_obj = persistent_zval_enum_case(ce, e->case_name); + if (EG(exception)) { + ZVAL_NULL(dst); + break; + } if (!enum_obj) { zend_throw_exception_ex(spl_ce_LogicException, 0, "persistent_zval: enum case \"%s::%s\" not found", @@ -257,11 +355,6 @@ static void persistent_zval_to_request(zval *dst, zval *src) { } case IS_ARRAY: { HashTable *src_ht = Z_ARRVAL_P(src); - if ((GC_FLAGS(src_ht) & IS_ARRAY_IMMUTABLE) != 0) { - /* Zero-copy: immutable arrays are safe to expose directly. */ - ZVAL_ARR(dst, src_ht); - break; - } array_init_size(dst, zend_hash_num_elements(src_ht)); HashTable *dst_ht = Z_ARRVAL_P(dst); @@ -276,14 +369,10 @@ static void persistent_zval_to_request(zval *dst, zval *src) { break; } if (key) { - if (ZSTR_IS_INTERNED(key)) { - zend_hash_add_new(dst_ht, key, &rval); - } else { - zend_string *rkey = zend_string_init(ZSTR_VAL(key), ZSTR_LEN(key), 0); - ZSTR_H(rkey) = ZSTR_H(key); - zend_hash_add_new(dst_ht, rkey, &rval); - zend_string_release(rkey); - } + zend_string *rkey = zend_string_init(ZSTR_VAL(key), ZSTR_LEN(key), 0); + ZSTR_H(rkey) = ZSTR_H(key); + zend_hash_add_new(dst_ht, rkey, &rval); + zend_string_release(rkey); } else { zend_hash_index_add_new(dst_ht, idx, &rval); } diff --git a/zval_test.go b/zval_test.go index 80aad7f16f..f0cd2d465e 100644 --- a/zval_test.go +++ b/zval_test.go @@ -44,7 +44,11 @@ func TestPersistentZvalRoundtrip(t *testing.T) { require.NotContains(t, out, "FAIL", "persist-roundtrip.php reported a failure:\n"+out) require.Contains(t, out, "OK null") require.Contains(t, out, "OK enum active") + require.Contains(t, out, "OK array holding a reference") + require.Contains(t, out, "OK shared reference") + require.Contains(t, out, "OK recursive array rejected") 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 e1a2103ca08d26a27ab506f3c133d3edcdd3d0ee Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Wed, 11 Nov 2026 20:53:41 +0100 Subject: [PATCH 3/3] feat: SentTaskHandle and ReceivedTaskHandle --- caddy/caddy_test.go | 9 +- caddy/config_test.go | 41 + caddy/workerconfig.go | 22 +- docs/config.md | 2 + docs/metrics.md | 10 +- docs/worker.md | 66 ++ frankenphp.c | 1253 ++++++++++++++++++++++-- frankenphp.go | 7 +- frankenphp.h | 35 + frankenphp.stub.php | 116 ++- frankenphp_arginfo.h | 99 +- metrics.go | 107 +- metrics_test.go | 65 +- options.go | 17 + phpmainthread.go | 5 + phpthread.go | 6 + testdata/bgworker/task-over-worker.php | 31 + testdata/bgworker/task-relay.php | 15 + testdata/bgworker/task-wait-worker.php | 31 + testdata/bgworker/task-worker.php | 87 ++ testdata/task-between-requests.php | 19 + testdata/task-busy.php | 23 + testdata/task-closed-stream.php | 24 + testdata/task-errors.php | 19 + testdata/task-fanout.php | 23 + testdata/task-feof.php | 19 + testdata/task-kept-stream.php | 19 + testdata/task-latency.php | 15 + testdata/task-over.php | 24 + testdata/task-poll.php | 32 + testdata/task-pool.php | 29 + testdata/task-queue.php | 78 ++ testdata/task-read-timeout.php | 14 + testdata/task-reconstruct.php | 12 + testdata/task-result.php | 11 + testdata/task-shutdown.php | 14 + testdata/task-stale-update.php | 19 + testdata/task-stale-watcher.php | 26 + testdata/task-stream-poll.php | 25 + testdata/task.php | 28 + threadbackgroundworker.go | 63 +- worker.go | 34 +- workertask.go | 720 ++++++++++++++ workertask_internal_test.go | 69 ++ workertask_test.go | 738 ++++++++++++++ workervars.go | 7 +- workervars_test.go | 3 +- zval.h | 16 +- 48 files changed, 4016 insertions(+), 131 deletions(-) create mode 100644 testdata/bgworker/task-over-worker.php create mode 100644 testdata/bgworker/task-relay.php create mode 100644 testdata/bgworker/task-wait-worker.php create mode 100644 testdata/bgworker/task-worker.php create mode 100644 testdata/task-between-requests.php create mode 100644 testdata/task-busy.php create mode 100644 testdata/task-closed-stream.php create mode 100644 testdata/task-errors.php create mode 100644 testdata/task-fanout.php create mode 100644 testdata/task-feof.php create mode 100644 testdata/task-kept-stream.php create mode 100644 testdata/task-latency.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-queue.php create mode 100644 testdata/task-read-timeout.php create mode 100644 testdata/task-reconstruct.php create mode 100644 testdata/task-result.php create mode 100644 testdata/task-shutdown.php create mode 100644 testdata/task-stale-update.php create mode 100644 testdata/task-stale-watcher.php create mode 100644 testdata/task-stream-poll.php create mode 100644 testdata/task.php create mode 100644 workertask.go create mode 100644 workertask_internal_test.go create mode 100644 workertask_test.go diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index a9d5f2256e..cbce5fb4b1 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -989,7 +989,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{worker="` + workerName + `"} 0 @@ -1146,7 +1146,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{worker="my_app"} 0 @@ -1242,7 +1242,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{worker="` + workerName + `"} 0 @@ -1516,6 +1516,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{worker="service"} 0 ` @@ -1616,7 +1617,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{worker="service1"} 0 diff --git a/caddy/config_test.go b/caddy/config_test.go index 94db973fb9..c9d3cf0cdd 100644 --- a/caddy/config_test.go +++ b/caddy/config_test.go @@ -390,6 +390,39 @@ func TestWorkerBackgroundVars(t *testing.T) { require.Equal(t, []string{"redis", "feature_flags"}, module.Workers[0].Vars) } +func TestWorkerBackgroundTasks(t *testing.T) { + d := caddyfile.NewTestDispenser(` + { + php_server { + worker { + file ../testdata/worker-with-env.php + background + num 4 + tasks image.resize image.thumbnail + } + } + }`) + module := &FrankenPHPModule{} + + require.NoError(t, module.UnmarshalCaddyfile(d)) + require.Equal(t, []string{"image.resize", "image.thumbnail"}, module.Workers[0].Tasks) +} + +func TestWorkerTasksRequireBackground(t *testing.T) { + d := caddyfile.NewTestDispenser(` + { + php_server { + worker { + file ../testdata/worker-with-env.php + tasks jobs + } + } + }`) + module := &FrankenPHPModule{} + + require.ErrorContains(t, module.UnmarshalCaddyfile(d), `"tasks" is only supported for background workers`) +} + func TestWorkerVarsRequireBackground(t *testing.T) { d := caddyfile.NewTestDispenser(` { @@ -482,3 +515,11 @@ func TestWorkerVarsReachTheCoreWithoutBackground(t *testing.T) { require.ErrorContains(t, frankenphp.Validate(frankenphp.WithWorkers("http", wc.FileName, 1, opts...)), "only background workers publish them") } + +func TestWorkerTasksReachTheCoreWithoutBackground(t *testing.T) { + wc := workerConfig{FileName: "../testdata/worker-with-env.php", Tasks: []string{"jobs"}} + opts, err := wc.toWorkerOptions() + require.NoError(t, err) + + require.ErrorContains(t, frankenphp.Validate(frankenphp.WithWorkers("http", wc.FileName, 1, opts...)), "only background workers receive them") +} diff --git a/caddy/workerconfig.go b/caddy/workerconfig.go index 02ae66061f..feed0c9edc 100644 --- a/caddy/workerconfig.go +++ b/caddy/workerconfig.go @@ -45,6 +45,8 @@ type workerConfig struct { Background bool `json:"background,omitempty"` // EXPERIMENTAL: Vars lists the namespaces a background worker publishes with WorkerHandle::setVars(), read by frankenphp_get_vars(). Vars []string `json:"vars,omitempty"` + // EXPERIMENTAL: Tasks lists the namespaces a background worker receives, the ones a FrankenPHP\SentTaskHandle is sent to. + Tasks []string `json:"tasks,omitempty"` options []frankenphp.WorkerOption } @@ -169,8 +171,14 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) { return wc, d.ArgErr() } wc.Vars = append(wc.Vars, namespaces...) + case "tasks": + namespaces := d.RemainingArgs() + if len(namespaces) == 0 { + return wc, d.ArgErr() + } + wc.Tasks = append(wc.Tasks, namespaces...) default: - return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads, background, boot_timeout, vars", v) + return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads, background, boot_timeout, vars, tasks", v) } } @@ -190,6 +198,10 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) { return wc, d.Err(`"vars" is only supported for background workers`) } + if !wc.Background && len(wc.Tasks) != 0 { + return wc, d.Err(`"tasks" is only supported for background workers`) + } + if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) { wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName) } @@ -219,12 +231,16 @@ func (wc *workerConfig) toWorkerOptions() ([]frankenphp.WorkerOption, error) { } } - // whatever the kind of worker: the core refuses vars on an HTTP one, which - // a JSON config reaches without the Caddyfile check + // whatever the kind of worker: the core refuses vars and tasks on an HTTP + // one, which a JSON config reaches without the Caddyfile check if len(wc.Vars) > 0 { opts = append(opts, frankenphp.WithWorkerVars(wc.Vars...)) } + if len(wc.Tasks) > 0 { + opts = append(opts, frankenphp.WithWorkerTasks(wc.Tasks...)) + } + // 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/docs/config.md b/docs/config.md index 9818583ddb..7b43e3466e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -114,6 +114,7 @@ You can also explicitly configure FrankenPHP using the [global option](https://c boot_timeout # EXPERIMENTAL: how long a background worker may take to reach its first tick() before the start fails, 0 waits forever. Default: 30s on PHP builds without Zend max execution timers, none elsewhere. background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, whose script ticks its FrankenPHP\WorkerHandle at least once vars # EXPERIMENTAL: the vars namespaces a background worker publishes, read with frankenphp_get_vars() + tasks # EXPERIMENTAL: the task namespaces a background worker receives, sent with FrankenPHP\SentTaskHandle } } } @@ -205,6 +206,7 @@ php_server [] { boot_timeout # EXPERIMENTAL: how long a background worker may take to reach its first tick() before the start fails, 0 waits forever. Default: 30s on PHP builds without Zend max execution timers, none elsewhere. background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, whose script ticks its FrankenPHP\WorkerHandle at least once vars # EXPERIMENTAL: the vars namespaces a background worker publishes, read with frankenphp_get_vars() + tasks # EXPERIMENTAL: the task namespaces a background worker receives, sent with FrankenPHP\SentTaskHandle } worker # Can also use the short form like in the global frankenphp block. } diff --git a/docs/metrics.md b/docs/metrics.md index b6636e9e5c..d0e7495963 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]"}`: The total number of workers. -- `frankenphp_busy_workers{worker="[worker_name]"}`: The number of workers currently processing a request. +- `frankenphp_busy_workers{worker="[worker_name]"}`: The number of workers currently processing a request, or a task for a background worker. - `frankenphp_worker_request_time{worker="[worker_name]"}`: The time spent processing requests by all workers. - `frankenphp_worker_request_count{worker="[worker_name]"}`: The number of requests processed by all workers. - `frankenphp_ready_workers{worker="[worker_name]"}`: The number of workers that have reached their ready point at least once: `frankenphp_handle_request()` for HTTP workers, `WorkerHandle::tick()` for background workers. - `frankenphp_worker_crashes{worker="[worker_name]"}`: The number of times a worker has unexpectedly terminated. - `frankenphp_worker_restarts{worker="[worker_name]"}`: The number of times a worker has been deliberately restarted. -- `frankenphp_worker_queue_depth{worker="[worker_name]"}`: The number of queued requests. +- `frankenphp_worker_queue_depth{worker="[worker_name]"}`: The number of queued requests, or of tasks waiting for a thread of a background worker. +- `frankenphp_worker_task_count{worker="[worker_name]",outcome="[outcome]"}`: (experimental) The number of tasks sent to a background worker, by outcome: `completed`, `aborted` (the worker let go of the task without completing it), `abandoned` (the sender gave up on the task first), `timeout` (no thread picked the task up in time) or `cancelled` (the wait for a thread ended on a restart or the shutdown). +- `frankenphp_worker_task_time{worker="[worker_name]"}`: (experimental) The time spent on tasks by all threads of a background worker, from pickup to the end of the task on the worker's side. - `frankenphp_opcache_restarts{reason="[reason]"}`: (experimental) The number of restarts of opcache's shared memory scheduled, by reason (`oom`, `hash`, `manual`). PHP 8.4 and up. For worker metrics, the `[worker_name]` placeholder is replaced by the worker name in the Caddyfile, otherwise the absolute path of the worker file will be used. @@ -76,8 +78,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, or the worker name for a background worker). | | `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 3cda614d24..69eb65d734 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -328,6 +328,72 @@ Values must be null, scalars, arrays or enums, and a snapshot can't be larger th `frankenphp_get_vars()` can only block while workers boot, a background worker or the bootstrap of an HTTP worker waiting for the publisher of a namespace. A cycle between background workers throws instead of hanging, and so does a wait once the start failed. A namespace no worker publishes throws a `RuntimeException`. +### Sending tasks to background workers + +A background worker receives the task namespaces declared with `tasks` on it, and a request, an HTTP worker or another background worker hands work to it by constructing a `FrankenPHP\SentTaskHandle` with one of those namespaces, resolved the way `frankenphp_get_vars()` resolves vars: within the `php_server` first, then among global workers, and for what a worker script runs between two requests, as for the request before. A namespace no worker receives throws a `RuntimeException`. + +```caddyfile +php_server { + worker { + file images.php + background + num 4 + tasks image.resize image.thumbnail + } +} +``` + +A namespace has one receiving worker per scope, and the threads of that worker share one queue for all its namespaces, so a pool is sized for the work it takes on, and `getNamespace()` on a received task tells which namespace it was sent to. The payload follows the same rules as `setVars()`: null, scalars, arrays or enums. Constructing queues the task and returns, without waiting: the task stays queued for as long as its handle lives. A thread has `$pickupTimeout` seconds to pick the task up, 30 by default, counted from the construction: past it the task leaves the queue and `read()` throws. `read()` blocks for the next update and returns `null` once the worker completed the task. It takes no timeout: a wait that must end goes through a context or `stream_select()` first, which take one. `abandon()`, like dropping the handle, gives up on the task, which leaves the queue if no thread took it yet. 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 returns nothing, at once, it is not writable, and closing it leaves the task alone: the handle hands out a fresh one. + +On the worker side, each task sent wakes one waiting thread of the worker: in `tick()` when it waits there, as in the example below, or through its handle, in the other loops of the previous section. A wake-up is not a count, and in a pool it may belong to a task a sibling thread took: `receive()` dequeues a task, a `FrankenPHP\ReceivedTaskHandle`, or returns `null` when there is none, without ever waiting, so the example drains the queue on each wake-up and treats `null` as the normal outcome. A thread that ticks while tasks are queued does not wait, whichever loop shape it uses: `tick()` returns at once, and so does a wait on the handle. Before it sleeps, a thread waiting in `tick()`, or in `read()` for an answer, first watches for what it waits for, about as long as its own wake-ups take: what comes meanwhile costs no system call, and a thread whose waits keep outlasting the watch goes straight to sleep. A busy worker shows it as CPU time, an idle one does not. `update()` sends progress back, `complete()` ends the task with a last update when one is given, and a task let go of without `complete()`, by `abort()`, by dropping its handle or ending the script, 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()` returns `false`. + +```php +// background worker +$handle = new FrankenPHP\WorkerHandle(); +while ($handle->tick(null)) { + while ($task = $handle->receive()) { + $task->update(['progress' => 50]); + $task->complete(['result' => process($task->getNamespace(), $task->getPayload())]); + } +} + +// request, HTTP worker or another background worker +$task = new FrankenPHP\SentTaskHandle('image.resize', ['file' => 'photo.jpg']); +while (null !== $update = $task->read()) { + // ['progress' => 50], then ['result' => ...] +} +``` + +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}; + +$poll = new Context(); +$tasks = []; +foreach (['photo.jpg', 'logo.png'] as $file) { + $tasks[$file] = new FrankenPHP\SentTaskHandle('image.resize', ['file' => $file]); + $poll->add($tasks[$file], [Event::Read], $file); +} + +while ($tasks) { + foreach ($poll->wait() as $watcher) { + $file = $watcher->getData(); + if (null === $update = $tasks[$file]->read()) { + // complete: stop watching it + $watcher->remove(); + unset($tasks[$file]); + } + } +} +``` + +A task stays readable while it has something to read, so watch it level-triggered, the default: with `Event::EdgeTriggered`, what comes while an update is unread is not reported. + +`getStream()` is there for the libraries that take a stream, and `stream_select()` works on it as it does on the worker's handle, `FD_SETSIZE` limit included: `read()` and `Io\Poll` have none. Unlike the worker's handle, a read on it never waits. + +Nothing in this API waits but `tick()` when it is given a timeout, `read()`, and the contexts or streams a script waits on, so both sides fit in an event loop, and neither can hold the other. `update()` and `complete()` return at once: the updates a sender has not read yet are kept for it, and `update()` returns `false` once the sender gave up. The price is that FrankenPHP does not push back: a sender that queues tasks faster than the worker takes them, or a worker that streams to a sender that does not read, grows the memory of the server, outside of `memory_limit`. The `frankenphp_worker_queue_depth` [metric](metrics.md) shows the former. The channel of a task is a pair of eventfd descriptors on Linux and a socket pair elsewhere, made when a script first waits on the task that way and closed with it. + ## 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 82717ff2c8..bdcdbc9ab4 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -167,6 +167,14 @@ uintptr_t frankenphp_thread_index(void) { return ctx == NULL ? thread_index : ctx->thread_index; } +/* Whether the calling thread is one FrankenPHP did not start, a child of + * ext-parallel for instance: it runs with the server context of its parent, + * and so with its index, while what is thread-local here is its own. */ +static bool frankenphp_is_foreign_thread(void) { + return SG(server_context) != NULL && + SG(server_context) != &frankenphp_local_server_ctx; +} + #ifndef PHP_WIN32 static bool is_forked_child = false; static pid_t fork_parent_pid = 0; @@ -389,11 +397,13 @@ static int64_t frankenphp_now_ms(void) { * signal carries to it is noticed at the next one. */ #define FRANKENPHP_WAIT_SLICE_MS 1000 -/* 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(). 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) { return; } @@ -403,7 +413,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 @@ -411,26 +421,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; - if (worker_name != NULL) { - zend_string_release_ex(worker_name, 1); - worker_name = NULL; - } - 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 @@ -524,9 +517,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 @@ -535,45 +528,86 @@ 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; + if (worker_name != NULL) { + zend_string_release_ex(worker_name, 1); + worker_name = NULL; + } + frankenphp_worker_close_stop_socks(); +} + /* Marks the calling thread as the background worker of that name, 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(const char *name, size_t name_len) { frankenphp_reset_background_worker(); is_background_worker = true; worker_name = zend_string_init(name, name_len, 1); - if (frankenphp_worker_open_stop_pair() != 0) { + if (frankenphp_sock_pair_open(worker_stop_socks) != 0) { return -1; } /* One wake-up right away, so a script that hands its handle to an event * loop ticks on its own: readiness then means the loop serviced the handle * once. Nothing to do on failure, the script has to tick by itself. */ - const char wakeup = '\n'; -#ifdef MSG_NOSIGNAL - send(worker_stop_socks[1], &wakeup, 1, MSG_NOSIGNAL); -#else - send(worker_stop_socks[1], &wakeup, 1, 0); -#endif + frankenphp_sock_send(worker_stop_socks[1], "\n", 1); intptr_t s = (intptr_t)worker_stop_socks[1]; worker_stop_socks[1] = SOCK_ERR; @@ -581,21 +615,441 @@ intptr_t frankenphp_set_background_worker_and_get_stop_sock(const char *name, 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. 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); +} + +/* Mailboxes: what a PHP thread sleeps on when it waits for the other threads + * without a descriptor, one per thread index. A mailbox is a counter the wakers + * bump: the owner reads it, checks what it waits for, and sleeps only while + * the counter did not move since, so a wake-up between the check and the + * sleep is never lost, and one that finds the owner awake costs no syscall. + * Wake-ups say "look again", never what happened: the owner may get one that + * was meant for an earlier wait and goes back to sleep then. On 64-bit Linux + * the counter is a futex; elsewhere a condition variable, or an event on + * Windows, stands behind it: the futex syscall of the 32-bit ports takes a + * timeout whose layout depends on the libc. */ +#if defined(__linux__) && defined(__LP64__) && \ + !defined(FRANKENPHP_MAILBOX_NO_FUTEX) +#include +#define FRANKENPHP_MAILBOX_FUTEX 1 +/* FUTEX_WAIT_PRIVATE and FUTEX_WAKE_PRIVATE of linux/futex.h, which comes + * with the kernel headers rather than with the libc */ +#define FRANKENPHP_FUTEX_WAIT (0 | 128) +#define FRANKENPHP_FUTEX_WAKE (1 | 128) +#endif + +typedef struct { + _Atomic uint32_t seq; + /* set while the owner sleeps or is about to: wakers skip the syscall + * otherwise */ + _Atomic uint32_t sleeping; + /* when the last waker found the owner asleep, see frankenphp_wake_stamp(): + * the owner measures its wake-ups with it. 32 bits, as wide as the atomics + * of every port go without a library */ + _Atomic uint32_t woken_at; +#if defined(PHP_WIN32) + HANDLE event; +#elif !defined(FRANKENPHP_MAILBOX_FUTEX) + pthread_mutex_t mu; + pthread_cond_t cond; +#endif + /* keeps the counters of two threads on different cache lines */ + char pad[64]; +} frankenphp_mailbox; + +static frankenphp_mailbox *mailboxes = NULL; +static int mailboxes_len = 0; + +/* What a thread waits for on its mailbox: an answer to what it sent, or work + * to do. */ +enum { + FRANKENPHP_WAIT_ANSWER, + FRANKENPHP_WAIT_WORK, + FRANKENPHP_WAIT_KINDS, +}; + +/* Watching before sleeping: a thread put to sleep costs both sides a syscall + * and the sleeper the time of its wake-up, tens of microseconds on most + * machines, so a thread first watches its mailbox for a moment. What comes + * meanwhile then costs neither, and a busy worker passes tasks around without + * a syscall. The watch keeps a core busy, so it is bounded twice, by what each + * thread measures of itself: + * - it lasts twice the time the thread's own wake-ups take, which it knows + * from the wakers' timestamps: a watch that ends on a sleep then costs about + * what the sleep does, never much more, and it outlasts the wake-up of a + * peer the thread just woke, so one sleeper does not put the other to sleep; + * - it has to pay off: each thread keeps a score per kind of wait, up for a + * watch that ended on a wake-up and down twice as fast for one that ended + * on a sleep. At zero the thread sleeps at once, and watches again every + * FRANKENPHP_WATCH_PROBE waits to notice when things speed up. */ +#define FRANKENPHP_WATCH_SCORE_MAX 4 +#define FRANKENPHP_WATCH_PROBE 16 +/* the wake-up time assumed before the first one was measured, and the bounds + * of the watch whatever was measured, in ns */ +#define FRANKENPHP_WAKE_DEFAULT_NS 25000 +#define FRANKENPHP_WATCH_MIN_NS 10000 +#define FRANKENPHP_WATCH_MAX_NS 200000 + +#ifndef PHP_WIN32 +typedef struct { + uint8_t score; + uint8_t skipped; +} frankenphp_watch; + +/* a new thread watches: its first waits tell */ +static THREAD_LOCAL frankenphp_watch watches[FRANKENPHP_WAIT_KINDS] = {{2, 0}, + {2, 0}}; +/* how long the wake-ups of the thread take, a moving average in ns */ +static THREAD_LOCAL int64_t wake_ns = FRANKENPHP_WAKE_DEFAULT_NS; +/* watching only helps when another core can run what the thread waits for */ +static bool mailboxes_watch = false; +#endif + +void frankenphp_init_mailboxes(int max_threads, bool watch) { +#ifndef PHP_WIN32 + mailboxes_watch = watch; +#else + /* no watch there yet: a wait sleeps at once */ + (void)watch; +#endif + mailboxes = calloc(max_threads, sizeof(frankenphp_mailbox)); + mailboxes_len = mailboxes == NULL ? 0 : max_threads; + for (int i = 0; i < mailboxes_len; i++) { +#if defined(PHP_WIN32) + mailboxes[i].event = CreateEvent(NULL, FALSE, FALSE, NULL); +#elif !defined(FRANKENPHP_MAILBOX_FUTEX) + pthread_mutex_init(&mailboxes[i].mu, NULL); +#ifdef __APPLE__ + /* no clock to pick there, the wait takes a duration instead */ + pthread_cond_init(&mailboxes[i].cond, NULL); +#else + /* timed waits on a clock that a change of the wall time does not move */ + pthread_condattr_t attr; + pthread_condattr_init(&attr); + pthread_condattr_setclock(&attr, CLOCK_MONOTONIC); + pthread_cond_init(&mailboxes[i].cond, &attr); + pthread_condattr_destroy(&attr); +#endif +#endif + } +} + +void frankenphp_destroy_mailboxes(void) { + for (int i = 0; i < mailboxes_len; i++) { +#if defined(PHP_WIN32) + if (mailboxes[i].event != NULL) { + CloseHandle(mailboxes[i].event); + } +#elif !defined(FRANKENPHP_MAILBOX_FUTEX) + pthread_cond_destroy(&mailboxes[i].cond); + pthread_mutex_destroy(&mailboxes[i].mu); +#endif + } + free(mailboxes); + mailboxes = NULL; + mailboxes_len = 0; +} + +#ifndef PHP_WIN32 +static int64_t frankenphp_now_ns(void) { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + + return (int64_t)now.tv_sec * 1000000000 + now.tv_nsec; +} + +/* The time of a wake-up in microseconds, wrapping around and never 0, which + * says that no waker left one. */ +static uint32_t frankenphp_wake_stamp(void) { + return (uint32_t)(frankenphp_now_ns() / 1000) | 1; +} +#endif + +/* Wakes the thread of that index if it sleeps on its mailbox, and makes its + * next sleep return at once if it is about to. */ +void frankenphp_mailbox_wake(uintptr_t index) { + if (index >= (uintptr_t)mailboxes_len) { + return; + } + frankenphp_mailbox *mb = &mailboxes[index]; + +#if defined(FRANKENPHP_MAILBOX_FUTEX) + atomic_fetch_add(&mb->seq, 1); + if (atomic_load(&mb->sleeping)) { + atomic_store(&mb->woken_at, frankenphp_wake_stamp()); + syscall(SYS_futex, &mb->seq, FRANKENPHP_FUTEX_WAKE, 1, NULL, NULL, 0); + } +#elif defined(PHP_WIN32) + atomic_fetch_add(&mb->seq, 1); + if (atomic_load(&mb->sleeping)) { + SetEvent(mb->event); + } +#else + /* under the mutex: the owner cannot check the counter and miss the signal */ + pthread_mutex_lock(&mb->mu); + atomic_fetch_add(&mb->seq, 1); + bool sleeping = atomic_load(&mb->sleeping); + if (sleeping) { + atomic_store(&mb->woken_at, frankenphp_wake_stamp()); + } + pthread_mutex_unlock(&mb->mu); + if (sleeping) { + pthread_cond_signal(&mb->cond); + } +#endif +} + +/* The counter of the calling thread's mailbox, to read before checking what + * the thread waits for and to hand to frankenphp_mailbox_wait(). */ +static uint32_t frankenphp_mailbox_seq(void) { + return atomic_load(&mailboxes[thread_index].seq); +} + +#ifndef PHP_WIN32 +static inline void frankenphp_cpu_relax(void) { +#if defined(__x86_64__) || defined(__i386__) + __builtin_ia32_pause(); +#elif defined(__aarch64__) + __asm__ __volatile__("yield"); +#endif +} + +/* Watches the mailbox instead of sleeping on it, see above: + * true once its counter moved on from seq, false when the thread has to + * sleep, because the watch ended without that or because watching stopped + * paying off for this kind of wait. */ +static bool frankenphp_mailbox_watch(frankenphp_mailbox *mb, uint32_t seq, + int kind) { + frankenphp_watch *watch = &watches[kind]; + if (watch->score == 0 && ++watch->skipped < FRANKENPHP_WATCH_PROBE) { + return false; + } + watch->skipped = 0; + + int64_t budget = 2 * wake_ns; + if (budget < FRANKENPHP_WATCH_MIN_NS) { + budget = FRANKENPHP_WATCH_MIN_NS; + } else if (budget > FRANKENPHP_WATCH_MAX_NS) { + budget = FRANKENPHP_WATCH_MAX_NS; + } + int64_t until = frankenphp_now_ns() + budget; + do { + for (int i = 0; i < 32; i++) { + if (atomic_load(&mb->seq) != seq) { + if (watch->score < FRANKENPHP_WATCH_SCORE_MAX) { + watch->score++; + } + + return true; + } + frankenphp_cpu_relax(); + } + } while (frankenphp_now_ns() < until); + watch->score = watch->score > 2 ? watch->score - 2 : 0; + + return false; +} +#endif + +/* Waits until the counter of the calling thread's mailbox moves on from seq, + * watching it first when that pays off for this kind of wait, sleeping on it + * then: 1 once it did, 0 after timeout_ms without it, forever when negative, + * -1 when max_execution_time or a force-kill interrupted the wait, for the VM + * to bail out once the method returns. */ +static int frankenphp_mailbox_wait(uint32_t seq, int timeout_ms, int kind) { + frankenphp_mailbox *mb = &mailboxes[thread_index]; + +#ifndef PHP_WIN32 + if (mailboxes_watch && timeout_ms != 0 && + frankenphp_mailbox_watch(mb, seq, kind)) { + return 1; + } +#endif + + int64_t deadline = timeout_ms < 0 ? -1 : frankenphp_now_ms() + timeout_ms; + int result; +#ifndef PHP_WIN32 + bool woken = false; +#endif + + atomic_store(&mb->sleeping, 1); +#if !defined(FRANKENPHP_MAILBOX_FUTEX) && !defined(PHP_WIN32) + pthread_mutex_lock(&mb->mu); +#endif + for (;;) { + if (atomic_load(&mb->seq) != seq) { + result = 1; + break; + } + if (zend_atomic_bool_load(&EG(timed_out))) { + result = -1; + break; + } + int64_t remaining = -1; + if (deadline >= 0) { + remaining = deadline - frankenphp_now_ms(); + if (remaining <= 0) { + result = 0; + break; + } + } + + int64_t slice = FRANKENPHP_WAIT_SLICE_MS; + if (remaining >= 0 && remaining < slice) { + slice = remaining; + } +#if defined(FRANKENPHP_MAILBOX_FUTEX) + /* always with a timeout: the kernel then never restarts the wait after + * a signal, whatever the flags of its handler, so the signals of + * max_execution_time and of the force-kill both land here as EINTR */ + struct timespec rel = {slice / 1000, (slice % 1000) * 1000000}; + woken = syscall(SYS_futex, &mb->seq, FRANKENPHP_FUTEX_WAIT, seq, &rel, NULL, + 0) == 0; +#elif defined(PHP_WIN32) + /* alertable, for the APC of the force-kill */ + WaitForSingleObjectEx(mb->event, (DWORD)slice, TRUE); +#elif defined(__APPLE__) + struct timespec rel = {slice / 1000, (slice % 1000) * 1000000}; + woken = pthread_cond_timedwait_relative_np(&mb->cond, &mb->mu, &rel) == 0; +#else + struct timespec until; + clock_gettime(CLOCK_MONOTONIC, &until); + until.tv_sec += slice / 1000; + until.tv_nsec += (slice % 1000) * 1000000; + if (until.tv_nsec >= 1000000000) { + until.tv_sec++; + until.tv_nsec -= 1000000000; + } + woken = pthread_cond_timedwait(&mb->cond, &mb->mu, &until) == 0; +#endif +#ifndef PHP_WIN32 + /* out of an actual sleep: how long this wake-up took tells how long the + * next watch is worth */ + uint32_t at = woken ? atomic_exchange(&mb->woken_at, 0) : 0; + if (at != 0) { + /* in microseconds, a millisecond at most: beyond it something else than + * a wake-up held the thread */ + uint32_t took = frankenphp_wake_stamp() - at; + if (took > 1000) { + took = 1000; + } + wake_ns = (7 * wake_ns + (int64_t)took * 1000) / 8; + } +#endif + /* woken, timed out, interrupted or none of it: the loop tells */ + } +#if !defined(FRANKENPHP_MAILBOX_FUTEX) && !defined(PHP_WIN32) + pthread_mutex_unlock(&mb->mu); +#endif + atomic_store(&mb->sleeping, 0); + + return result; +} + +/* 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, elsewhere the ends of a + * socket pair, which is what Windows's php_select() waits on. A pair is made + * for the task that needs one and closed with it: a poller may hold a + * descriptor past the end of its task, and would get the signals of the next + * one from a pair handed over. The Go side signals a descriptor once, and + * takes the signal back when nothing is left to read there, see + * senderReadableLocked(). */ +#if defined(__linux__) && !defined(FRANKENPHP_TASK_CHAN_NO_EVENTFD) +#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); + if (a < 0) { + return -1; + } + int b = eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK); + 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 +} + +/* Takes back the signal written for the side waiting on fd. */ +void frankenphp_task_chan_consume(intptr_t fd) { +#ifdef FRANKENPHP_TASK_CHAN_EVENTFD + uint64_t v; + (void)!read((int)fd, &v, sizeof(v)); +#else + /* the byte was sent, and may still be on its way where the pair is a + * loopback connection: a moment at most */ + for (;;) { + char b; + int n = recv((php_socket_t)fd, &b, 1, 0); + if (n >= 0) { + return; + } + int err = php_socket_errno(); + if (err != EINTR && !PHP_IS_TRANSIENT_ERROR(err)) { + return; + } + php_pollfd_for_ms((php_socket_t)fd, PHP_POLLREADABLE, + FRANKENPHP_WAIT_SLICE_MS); + } +#endif } void frankenphp_update_local_thread_context(bool is_worker) { @@ -1686,30 +2140,6 @@ static bool frankenphp_worker_handle_consume(void) { } } -/* Waits for the handle to become readable, which is FrankenPHP needing the - * script, its drain included: timeout_ms at most, without a limit when - * negative. */ -static void frankenphp_worker_handle_wait(int timeout_ms) { - int64_t deadline = timeout_ms < 0 ? -1 : frankenphp_now_ms() + timeout_ms; - while (!zend_atomic_bool_load(&EG(timed_out))) { - int slice = FRANKENPHP_WAIT_SLICE_MS; - if (deadline >= 0) { - int64_t remaining = deadline - frankenphp_now_ms(); - if (remaining <= 0) { - return; - } - if (remaining < slice) { - slice = (int)remaining; - } - } - int n = php_pollfd_for_ms(worker_stop_socks[0], PHP_POLLREADABLE, slice); - if (n > 0 || (n < 0 && php_socket_errno() != EINTR)) { - /* readable, or broken: the caller tells which */ - return; - } - } -} - /* 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 @@ -1749,24 +2179,60 @@ ZEND_METHOD(FrankenPHP_WorkerHandle, tick) { zend_unset_timeout(); } - if (!frankenphp_worker_handle_consume()) { - RETURN_FALSE; + /* 0 never waits: the script waits on its handle, and the thread parks + * there. Otherwise the wait happens here, on the mailbox of the thread: + * null, or past what a duration holds, waits until FrankenPHP needs the + * script */ + bool wait = timeout_is_null || timeout > 0; + int timeout_ms = -1; + if (!timeout_is_null && timeout * 1000 < (double)INT_MAX) { + /* a wait shorter than the clock's grain still waits */ + timeout_ms = timeout > 0 && timeout < 0.001 ? 1 : (int)(timeout * 1000); } - /* 0 never waits; null, or past what a duration holds, waits until - * FrankenPHP needs the script. What was written before the call was for a - * script that waits on its handle, and does not cut this wait short */ - if (timeout_is_null || timeout > 0) { - int timeout_ms = -1; - if (!timeout_is_null && timeout * 1000 < (double)INT_MAX) { - /* a wait shorter than the clock's grain still waits */ - timeout_ms = timeout < 0.001 ? 1 : (int)(timeout * 1000); + int64_t deadline = -1; + for (;;) { + /* read before asking, so a task sent right after the answer is not slept + * through */ + uint32_t seq = frankenphp_mailbox_seq(); + + /* what was written on the handle was for a script that waits there, and + * does not cut a wait in here short: its end is the drain */ + if (!frankenphp_worker_handle_consume()) { + RETURN_FALSE; } - frankenphp_worker_handle_wait(timeout_ms); - RETURN_BOOL(frankenphp_worker_handle_consume()); - } + /* the thread parks where it is about to wait, unless tasks are queued: + * the script then finds its handle readable, and a wait in here is over, + * see go_frankenphp_background_worker_park. The Go side also knows of a + * drain before the socket tells */ + int parked = go_frankenphp_background_worker_park( + frankenphp_thread_index(), + wait ? FRANKENPHP_PARK_ON_MAILBOX : FRANKENPHP_PARK_ON_HANDLE); + if (parked == FRANKENPHP_PARK_DRAINED) { + RETURN_FALSE; + } + if (!wait || parked == FRANKENPHP_PARK_READY) { + RETURN_TRUE; + } - RETURN_TRUE; + int remaining = -1; + if (timeout_ms >= 0) { + int64_t now = frankenphp_now_ms(); + if (deadline < 0) { + deadline = now + timeout_ms; + } + remaining = deadline > now ? (int)(deadline - now) : 0; + } + if (remaining == 0 || + frankenphp_mailbox_wait(seq, remaining, FRANKENPHP_WAIT_WORK) <= 0) { + /* out of time, or interrupted, and the VM bails out once the method + * returns: the thread leaves its park */ + RETURN_BOOL(go_frankenphp_background_worker_park( + frankenphp_thread_index(), FRANKENPHP_PARK_LEAVE) != + FRANKENPHP_PARK_DRAINED); + } + /* woken, for a task or for the drain: the loop tells which */ + } } /* Shared vars of background workers, see WorkerHandle::setVars() and @@ -1887,6 +2353,617 @@ 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 background worker receiving its + * namespace 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; + /* the side's descriptor, see frankenphp_task_chan_open: -1 until the script + * waits through stream_select() or a poll context, see frankenphp_task_fd */ + intptr_t fd; + bool sender; + bool closed; /* this side ended the task */ + zend_resource *res; /* the stream of getStream(), NULL until a script asks */ + zval payload; /* the receiver's side only */ + zval namespace; /* 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 through complete() only, before the script ends, with the + * result when it gave one. abort(), dropping the handle or ending the script + * aborts the task instead, so a sender never takes a task let go of for a + * completed one. The Go side learns it before signaling the other side, which + * then finds it. */ +static bool frankenphp_task_settle(frankenphp_task_data *data, bool completed, + HashTable *result) { + if (data->closed || data->task == 0) { + return false; + } + data->closed = true; + + if (data->sender) { + go_frankenphp_task_sender_close(data->task); + + return true; + } + + /* whether the sender was still there to learn it */ + return go_frankenphp_task_receiver_close( + data->task, !completed || (EG(flags) & EG_FLAGS_IN_SHUTDOWN) != 0, + result); +} + +/* 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; +} + +/* 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_data *data = stream->abstract; + if (data->closed || go_frankenphp_task_side_gone(data->task, data->sender)) { + stream->eof = 1; + } + + return -1; +} + +/* Closing the stream leaves the task alone, only the methods of its handle + * end it: getStream() builds another one. The state is the handle's, which + * closes the stream it handed out before it lets go of it. */ +static int frankenphp_task_stream_close(php_stream *stream, int close_handle) { + (void)stream; + (void)close_handle; + + return 0; +} + +/* The descriptor of this side, made the first time the script waits on the + * task next to something else: until then its waits go through the mailbox + * of the thread and the task has none. -1 when it could not be made. */ +static intptr_t frankenphp_task_fd(frankenphp_task_data *data) { + if (data->fd < 0) { + data->fd = go_frankenphp_task_fd(data->task, data->sender); + } + + return data->fd; +} + +static int frankenphp_task_stream_cast(php_stream *stream, int castas, + void **ret) { + /* for a poller of any kind: one that asks again before each wait, as + * stream_select() does, or one that keeps the descriptor, as an event loop + * extension or a StreamPollHandle does */ + if (castas != PHP_STREAM_AS_FD_FOR_SELECT +#ifdef PHP_STREAM_AS_FD_FOR_POLL + && castas != PHP_STREAM_AS_FD_FOR_POLL +#endif + ) { + return FAILURE; + } + frankenphp_task_data *data = stream->abstract; + if (data->closed) { + return FAILURE; + } + if (frankenphp_task_fd(data) < 0) { + return FAILURE; + } + if (ret != NULL) { + *(php_socket_t *)ret = (php_socket_t)data->fd; + } + + return SUCCESS; +} + +/* feof() is all the stream answers: it has no timeout, a wait that must end + * goes through stream_select() or a poll context, which take one */ +static int frankenphp_task_stream_set_option(php_stream *stream, int option, + int value, void *ptrparam) { + (void)value; + (void)ptrparam; + frankenphp_task_data *data = stream->abstract; + if (option != PHP_STREAM_OPTION_CHECK_LIVENESS) { + return PHP_STREAM_OPTION_RETURN_NOTIMPL; + } + + /* the other side ended the task */ + return data->closed || go_frankenphp_task_side_gone(data->task, data->sender) + ? PHP_STREAM_OPTION_RETURN_ERR + : PHP_STREAM_OPTION_RETURN_OK; +} + +/* no write: the stream is not writable */ +static const php_stream_ops frankenphp_task_stream_ops = { + NULL, + frankenphp_task_stream_read, + frankenphp_task_stream_close, + NULL, + "FrankenPHP task", + NULL, + frankenphp_task_stream_cast, + NULL, + frankenphp_task_stream_set_option, +}; + +/* Waits for the next event of the sender's side: false when + * max_execution_time or a force-kill interrupted the wait, for the VM to bail + * out once the method returns. On the mailbox of the thread, seq being its + * counter read before the Go side was asked for the event; on the side's + * descriptor once the script took it, without consuming the signal, and other + * interruptions are retried there, like PHP's own stream code does. */ +static bool frankenphp_task_wait(frankenphp_task_data *data, uint32_t seq) { + if (data->fd < 0) { + return frankenphp_mailbox_wait(seq, -1, FRANKENPHP_WAIT_ANSWER) >= 0; + } + + /* in slices, like the sleep on a mailbox: a force-kill that no signal + * carries to this thread, or one that landed right before the wait, is + * noticed at the next one */ + while (!zend_atomic_bool_load(&EG(timed_out))) { + int n = php_pollfd_for_ms((php_socket_t)data->fd, PHP_POLLREADABLE, + FRANKENPHP_WAIT_SLICE_MS); + if (n > 0 || (n < 0 && php_socket_errno() != EINTR)) { + return true; + } + } + + return false; +} + +/* 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; +} + +#define FRANKENPHP_TASK_DATA(zthis) frankenphp_task_data_of(Z_OBJ_P(zthis)) + +/* The state of a live task, NULL once this side ended it: complete(), abort() + * and abandon() 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_LogicException, "the task is over", 0); + + return NULL; + } + + return data; +} + +/* The descriptor a task waits on, the same one its stream reports to + * 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) { + frankenphp_task_data *data = handle->handle_data; + if (data == NULL || data->task == 0 || data->closed || + frankenphp_task_fd(data) < 0) { + return SOCK_ERR; + } + + return (php_socket_t)data->fd; +} + +static int frankenphp_task_is_valid(frankenphp_handle_obj *handle) { + frankenphp_task_data *data = handle->handle_data; + + return data != NULL && data->task != 0 && !data->closed; +} + +static void frankenphp_task_cleanup(frankenphp_handle_obj *handle) { + frankenphp_task_data *data = handle->handle_data; + if (data == NULL) { + return; + } + handle->handle_data = NULL; + + frankenphp_task_settle(data, false, NULL); + if (data->res != NULL) { + zend_resource *res = data->res; + data->res = NULL; + /* the task went with the handle, so does a stream the script kept: its + * descriptor, its state and the Go side of the task are gone */ + if (res->type == php_file_le_stream()) { + zend_list_close(res); + } + zend_list_delete(res); + } + zval_ptr_dtor(&data->payload); + zval_ptr_dtor(&data->namespace); + efree(data); +} + +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)); + + data->fd = -1; + ZVAL_UNDEF(&data->payload); + ZVAL_UNDEF(&data->namespace); + FRANKENPHP_HANDLE_OF(object)->handle_data = data; + + return object; +} + +/* The handle takes the task the Go side just handed out. */ +static void frankenphp_task_obj_take(zval *zthis, uintptr_t task, bool sender) { + frankenphp_task_data *data = FRANKENPHP_TASK_DATA(zthis); + + data->task = task; + 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, the next call then builds another one. Past the end of the task + * the last one 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); + + if (data != NULL && data->task != 0 && !data->closed && + frankenphp_task_data_stream(data) == NULL) { + if (data->res != NULL) { + /* the script closed the one it had */ + zend_list_delete(data->res); + } + php_stream *stream = + php_stream_alloc(&frankenphp_task_stream_ops, data, NULL, "r"); + + /* the handle keeps the reference the registration holds */ + data->res = stream->res; + } + + if (data == NULL || data->res == NULL) { + zend_throw_exception(spl_ce_LogicException, "the task is over", 0); + RETURN_THROWS(); + } + + GC_ADDREF(data->res); + RETURN_RES(data->res); +} + +/* Ends the task from this side, with the result of a task being completed + * when there is one, and closes the stream when the script took one, so its + * stream_select() and feof() report the end. False when the task was over + * already, or the other side gone. */ +static bool frankenphp_task_obj_close(zval *zthis, bool completed, + HashTable *result) { + frankenphp_task_data *data = FRANKENPHP_TASK_DATA(zthis); + if (data == NULL || data->closed) { + return false; + } + + bool present = frankenphp_task_settle(data, completed, result); + if (data->res != NULL && data->res->type == php_file_le_stream()) { + zend_list_close(data->res); + } + + return present; +} + +ZEND_METHOD(FrankenPHP_SentTaskHandle, __construct) { + zend_string *namespace; + zval *payload; + double pickup_timeout = 30.0; + bool pickup_timeout_is_null = false; + ZEND_PARSE_PARAMETERS_START(2, 3) + Z_PARAM_STR(namespace) + Z_PARAM_ARRAY(payload) + Z_PARAM_OPTIONAL + Z_PARAM_DOUBLE_OR_NULL(pickup_timeout, pickup_timeout_is_null) + ZEND_PARSE_PARAMETERS_END(); + + /* the waits of a task go through the mailbox of its thread, and the wake-ups + * to the index the thread calls in with: a thread that borrows the index of + * another would sleep on a mailbox nobody wakes */ + if (frankenphp_is_foreign_thread()) { + zend_throw_exception(spl_ce_LogicException, + "FrankenPHP\\SentTaskHandle cannot be used from a " + "thread FrankenPHP did not start", + 0); + RETURN_THROWS(); + } + + /* a handle sends one task: a second construction would orphan the first */ + frankenphp_task_data *current = FRANKENPHP_TASK_DATA(ZEND_THIS); + if (current == NULL || current->task != 0 || current->closed) { + zend_throw_error( + NULL, + "FrankenPHP\\SentTaskHandle::__construct() cannot be called twice"); + RETURN_THROWS(); + } + + if (!pickup_timeout_is_null && + (zend_isnan(pickup_timeout) || pickup_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 (!pickup_timeout_is_null && pickup_timeout * 1000 < (double)INT_MAX) { + timeout_ms = (int)(pickup_timeout * 1000); + } + const char *invalid = persistent_zval_invalid(payload); + if (invalid != NULL) { + zend_value_error("FrankenPHP\\SentTaskHandle::__construct(): payload %s", + invalid); + RETURN_THROWS(); + } + + zval persistent; + persistent_zval_persist(&persistent, payload); + + /* the Go side owns the payload from here on, it frees it on failure, and + * watches the time a thread has to pick the task up */ + struct go_frankenphp_send_task_return task = go_frankenphp_send_task( + frankenphp_thread_index(), ZSTR_VAL(namespace), ZSTR_LEN(namespace), + Z_ARRVAL(persistent), timeout_ms); + if (task.r1 != NULL) { + /* a namespace nobody receives is a mistake, a failure to set the task up + * is not */ + zend_throw_exception( + task.r2 ? spl_ce_LogicException : spl_ce_RuntimeException, task.r1, 0); + free(task.r1); + RETURN_THROWS(); + } + + /* the task is the handle's from here on: a bailout below, or a throw, settles + * it rather than leaving it in the queue */ + frankenphp_task_obj_take(ZEND_THIS, task.r0, true); + /* queued: read() waits for the pickup along with the first update */ +} + +ZEND_METHOD(FrankenPHP_SentTaskHandle, read) { + ZEND_PARSE_PARAMETERS_NONE(); + + frankenphp_task_data *data = frankenphp_task_obj_open(ZEND_THIS); + if (data == NULL) { + RETURN_THROWS(); + } + + for (;;) { + /* read before asking, so an event landing right after the answer is not + * slept through */ + uint32_t seq = frankenphp_mailbox_seq(); + struct go_frankenphp_read_task_return update = + go_frankenphp_read_task(data->task); + switch (update.r1) { + case FRANKENPHP_TASK_READ_UPDATE: + 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_OVER: + 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; + } + if (update.r2 == NULL) { + RETURN_NULL(); + } + /* the task failed: the worker let go of it without completing it, or no + * thread will pick it up, on a drain, the shutdown, or because none did + * in time. It left the queue then, so what the sender is told stands */ + zend_throw_exception(spl_ce_RuntimeException, update.r2, 0); + free(update.r2); + RETURN_THROWS(); + default: + /* nothing yet: wait for the next event. The Go side watches what a + * queued task waits for */ + if (!frankenphp_task_wait(data, seq)) { + zend_throw_exception(spl_ce_RuntimeException, + "FrankenPHP\\SentTaskHandle::read(): the wait " + "was interrupted", + 0); + RETURN_THROWS(); + } + /* the loop tells what the wait ended on */ + } + } +} + +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, false, NULL); +} + +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); +} + +ZEND_METHOD(FrankenPHP_ReceivedTaskHandle, getNamespace) { + ZEND_PARSE_PARAMETERS_NONE(); + + frankenphp_task_data *data = FRANKENPHP_TASK_DATA(ZEND_THIS); + if (Z_ISUNDEF(data->namespace)) { + zend_throw_error(NULL, "FrankenPHP\\ReceivedTaskHandle was not handed " + "out by WorkerHandle::receive()"); + RETURN_THROWS(); + } + + RETURN_COPY(&data->namespace); +} + +/* The persistent copy of an update of a live task, which the Go side owns + * once it got it and frees when the update goes nowhere: NULL with an + * exception when the task is over or the values cannot be copied. */ +static HashTable *frankenphp_task_obj_persist(zval *zthis, zval *data, + frankenphp_task_data **task) { + *task = frankenphp_task_obj_open(zthis); + if (*task == NULL) { + return NULL; + } + const char *invalid = persistent_zval_invalid(data); + if (invalid != NULL) { + zend_value_error("FrankenPHP\\ReceivedTaskHandle: %s", invalid); + return NULL; + } + + zval persistent; + persistent_zval_persist(&persistent, data); + + return Z_ARRVAL(persistent); +} + +/* Queues an update for the sender, which reads it with read(): false when + * the sender gave up, or stopped reading while the worker drains, the update + * goes nowhere then. A sender leaving is no mistake of the worker, so it is + * no exception either. */ +ZEND_METHOD(FrankenPHP_ReceivedTaskHandle, update) { + zval *data; + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_ARRAY(data) + ZEND_PARSE_PARAMETERS_END(); + + frankenphp_task_data *task; + HashTable *update = frankenphp_task_obj_persist(ZEND_THIS, data, &task); + if (update == NULL) { + RETURN_THROWS(); + } + + RETURN_BOOL(go_frankenphp_update_task(task->task, update)); +} + +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(); + + /* the result goes with the end of the task, in one step: a sender that + * leaves once it has it left a completed task */ + HashTable *result = NULL; + if (data != NULL) { + frankenphp_task_data *task; + result = frankenphp_task_obj_persist(ZEND_THIS, data, &task); + if (result == NULL) { + RETURN_THROWS(); + } + } + + RETURN_BOOL(frankenphp_task_obj_close(ZEND_THIS, true, result)); +} + +ZEND_METHOD(FrankenPHP_ReceivedTaskHandle, abort) { + ZEND_PARSE_PARAMETERS_NONE(); + + frankenphp_task_obj_close(ZEND_THIS, false, NULL); +} + +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(); + + if (!frankenphp_worker_handle_usable()) { + RETURN_THROWS(); + } + + /* never waits: tick() does, or the script on its handle */ + 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 its + * namespace or its payload, or while making the handle, must not leave it + * open, the sender would wait on it forever. Which is why the namespace + * comes as C memory to copy here, not as a string made inside the call into + * the Go side, where a bailout would have nothing to end the task */ + zval namespace, payload; + ZVAL_UNDEF(&namespace); + ZVAL_UNDEF(&payload); + zend_try { + ZVAL_STRINGL(&namespace, task.r2, task.r3); + frankenphp_vars_to_request(&payload, task.r1); + if (!EG(exception)) { + object_init_ex(return_value, frankenphp_received_task_ce); + } + } + zend_catch { + free(task.r2); + frankenphp_vars_free(task.r1); + zval_ptr_dtor(&namespace); + go_frankenphp_task_receiver_close(task.r0, true, NULL); + zend_bailout(); + } + zend_end_try(); + free(task.r2); + 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); + zval_ptr_dtor(&namespace); + go_frankenphp_task_receiver_close(task.r0, true, NULL); + RETURN_THROWS(); + } + + frankenphp_task_obj_take(return_value, task.r0, false); + ZVAL_COPY_VALUE(&FRANKENPHP_TASK_DATA(return_value)->payload, &payload); + ZVAL_COPY_VALUE(&FRANKENPHP_TASK_DATA(return_value)->namespace, &namespace); +} + /* {{{ thread-safe opcache reset */ PHP_FUNCTION(frankenphp_opcache_reset) { go_schedule_opcache_reset(frankenphp_thread_index()); @@ -1924,7 +3001,7 @@ PHP_FUNCTION(frankenphp_test_persist_roundtrip) { Z_PARAM_ZVAL(input) ZEND_PARSE_PARAMETERS_END(); - if (!persistent_zval_validate(input)) { + if (persistent_zval_invalid(input) != NULL) { zend_throw_exception(spl_ce_LogicException, "persistent_zval: value not supported (only " "scalars, arrays, and enums are allowed, nested " @@ -1964,6 +3041,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_task_handle_new; + frankenphp_handle_implements_poll(frankenphp_sent_task_ce); + + frankenphp_received_task_ce = register_class_FrankenPHP_ReceivedTaskHandle(); + frankenphp_received_task_ce->create_object = frankenphp_task_handle_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.go b/frankenphp.go index 5dd585faf9..b8f1919463 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -332,8 +332,8 @@ func Validate(options ...Option) error { } takenNames := make(map[string]bool, len(opt.workers)) - paths, vars := scopeSet{}, scopeSet{} - taken := declarationIndex{names: func(name string) bool { return takenNames[name] }, paths: paths.has, vars: vars.owner} + paths, vars, tasks := scopeSet{}, scopeSet{}, scopeSet{} + taken := declarationIndex{names: func(name string) bool { return takenNames[name] }, paths: paths.has, vars: vars.owner, tasks: tasks.owner} for _, w := range opt.workers { w, err := resolveWorkerFile(w) if err != nil { @@ -351,6 +351,9 @@ func Validate(options ...Option) error { for _, ns := range w.vars { vars.add(w.server, ns, w.name) } + for _, ns := range w.tasks { + tasks.add(w.server, ns, w.name) + } } return nil diff --git a/frankenphp.h b/frankenphp.h index 0bdab0767a..34a5e7f27e 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -211,6 +211,9 @@ typedef struct { void frankenphp_init_thread_metrics(int max_threads); void frankenphp_destroy_thread_metrics(void); +void frankenphp_init_mailboxes(int max_threads, bool watch); +void frankenphp_destroy_mailboxes(void); +void frankenphp_mailbox_wake(uintptr_t index); size_t frankenphp_get_thread_memory_usage(uintptr_t thread_index); /* Best-effort force-kill primitives. The slot is populated by each PHP @@ -238,11 +241,43 @@ typedef enum { /* Background worker primitives. */ intptr_t frankenphp_set_background_worker_and_get_stop_sock(const char *name, size_t name_len); +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); +void frankenphp_task_chan_consume(intptr_t fd); void frankenphp_vars_to_request(zval *return_value, HashTable *table); void frankenphp_vars_free(HashTable *table); void frankenphp_vars_release(HashTable *table); +/* Results of go_frankenphp_read_task. */ +enum { + FRANKENPHP_TASK_READ_UPDATE, + /* the task is over: completed, unless the reason it failed for comes with + * it */ + FRANKENPHP_TASK_READ_OVER, + FRANKENPHP_TASK_READ_PENDING, +}; + +/* Where WorkerHandle::tick() parks its thread, see + * go_frankenphp_background_worker_park: on its handle, where the script is + * about to wait, or on its mailbox, where tick() waits itself, which + * FRANKENPHP_PARK_LEAVE ends. */ +enum { + FRANKENPHP_PARK_ON_HANDLE, + FRANKENPHP_PARK_ON_MAILBOX, + FRANKENPHP_PARK_LEAVE, +}; + +/* What it answers: the thread is parked, tasks are queued so it has nothing + * to wait for, or it is drained. */ +enum { + FRANKENPHP_PARK_PARKED, + FRANKENPHP_PARK_READY, + FRANKENPHP_PARK_DRAINED, +}; + void register_extensions(zend_module_entry **m, int len); // This function is exported for use by extensions, as proposed in PR #2305 diff --git a/frankenphp.stub.php b/frankenphp.stub.php index cb7294ecca..29010fe0b9 100644 --- a/frankenphp.stub.php +++ b/frankenphp.stub.php @@ -128,7 +128,7 @@ public function getStream() {} * Returns the namespaces this background worker serves, as declared in * its configuration. * - * @return array{vars: list} + * @return array{vars: list, tasks: list} */ public function getNamespaces(): array {} @@ -140,5 +140,119 @@ public function getNamespaces(): array {} * before the first tick() of a run. */ public function setVars(string $namespace, array $vars): void {} + + /** + * Dequeues a task sent to this background worker, or null when there + * is none. It never waits: each task sent wakes one thread of the + * worker, in tick() when it waits there or through its handle, so a + * script calls this once 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 queues the task for the background worker + * receiving the task namespace, resolved within the current php_server + * then among global workers, and returns without waiting: the task stays + * queued for as long as its handle lives, and nothing else bounds the + * queue. A thread has $pickupTimeout seconds from there to pick the task + * up, forever when null: past it the task leaves the queue and read() + * throws. Payload values must be null, scalars, arrays or enums. + * + * @strict-properties + * @not-serializable + */ + final class SentTaskHandle + { + public function __construct(string $namespace, array $payload, ?float $pickupTimeout = 30.0) {} + + /** + * The next update published by the worker, waiting for it, or null + * once the task is complete. Throws when no thread picked the task up + * in time and when the worker let go of it without completing it. + * The wait has no timeout: one that must end goes through a poll + * context or stream_select() first, which take one. + */ + 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: a read on it returns + * nothing, at once, the updates come through read(). Closing it is + * safe: the handle hands out a fresh one. Past the end of the task, + * the stream the script took last comes back closed, and asking for a + * first one throws. + * + * @return resource + */ + public function getStream() {} + + /** + * Gives up on the task: it leaves the queue if no thread picked it up + * yet, and the worker's next update() returns false otherwise. + * 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. Only complete() + * completes the task: abort(), dropping the handle or ending the run + * 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 {} + + /** + * The task namespace the sender addressed, one of those the worker + * declares. + */ + public function getNamespace(): string {} + + /** + * Publishes an update for the sender, which reads it with read(). + * Values must be null, scalars, arrays or enums. It never waits: + * the updates the sender has not read yet are kept for it, without + * a bound. False when the sender gave up: the update went nowhere. + */ + public function update(array $data): bool {} + + /** + * Completes the task, with a last update when one is given: the + * sender's read() returns it, then null. It never waits. False when + * the sender gave up. + */ + public function complete(?array $data = null): bool {} + + /** + * Lets go of the task without completing it: the sender's read() + * throws. Dropping the handle does the same, once nothing holds it + * anymore. + */ + public function abort(): 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 is safe: the handle hands out a fresh one. + * Past the end of the task, the stream the script took last comes + * back closed, and asking for a first one throws. + * + * @return resource + */ + public function getStream() {} } } diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index 9372895b62..3892865a78 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: b7d45f1d97394a73b3026aa2e09a3d01914ab8d0 */ + * Stub hash: 7a0d5b80ac01ed8a99750d5b0568f0e78b3819cc */ 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) @@ -64,6 +64,41 @@ 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, namespace, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, payload, IS_ARRAY, 0) + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, pickupTimeout, 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 + +#define arginfo_class_FrankenPHP_ReceivedTaskHandle_getNamespace arginfo_class_FrankenPHP_WorkerHandle_getName + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_FrankenPHP_ReceivedTaskHandle_update, 0, 1, _IS_BOOL, 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_BOOL, 0) + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, data, IS_ARRAY, 1, "null") +ZEND_END_ARG_INFO() + +#define arginfo_class_FrankenPHP_ReceivedTaskHandle_abort arginfo_class_FrankenPHP_SentTaskHandle_abandon + +#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); @@ -78,6 +113,18 @@ ZEND_METHOD(FrankenPHP_WorkerHandle, tick); ZEND_METHOD(FrankenPHP_WorkerHandle, getStream); ZEND_METHOD(FrankenPHP_WorkerHandle, getNamespaces); 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, getNamespace); +ZEND_METHOD(FrankenPHP_ReceivedTaskHandle, update); +ZEND_METHOD(FrankenPHP_ReceivedTaskHandle, complete); +ZEND_METHOD(FrankenPHP_ReceivedTaskHandle, abort); +ZEND_METHOD(FrankenPHP_ReceivedTaskHandle, getStream); static const zend_function_entry ext_functions[] = { ZEND_FE(frankenphp_handle_request, arginfo_frankenphp_handle_request) @@ -118,6 +165,26 @@ static const zend_function_entry class_FrankenPHP_WorkerHandle_methods[] = { ZEND_ME(FrankenPHP_WorkerHandle, getStream, arginfo_class_FrankenPHP_WorkerHandle_getStream, ZEND_ACC_PUBLIC) ZEND_ME(FrankenPHP_WorkerHandle, getNamespaces, arginfo_class_FrankenPHP_WorkerHandle_getNamespaces, ZEND_ACC_PUBLIC) ZEND_ME(FrankenPHP_WorkerHandle, setVars, arginfo_class_FrankenPHP_WorkerHandle_setVars, ZEND_ACC_PUBLIC) + ZEND_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, getNamespace, arginfo_class_FrankenPHP_ReceivedTaskHandle_getNamespace, 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, abort, arginfo_class_FrankenPHP_ReceivedTaskHandle_abort, ZEND_ACC_PUBLIC) + ZEND_ME(FrankenPHP_ReceivedTaskHandle, getStream, arginfo_class_FrankenPHP_ReceivedTaskHandle_getStream, ZEND_ACC_PUBLIC) ZEND_FE_END }; @@ -157,3 +224,33 @@ 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); +#if (PHP_VERSION_ID >= 80400) + class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE); +#else + class_entry = zend_register_internal_class_ex(&ce, NULL); + class_entry->ce_flags |= ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE; +#endif + + 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); +#if (PHP_VERSION_ID >= 80400) + class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE); +#else + class_entry = zend_register_internal_class_ex(&ce, NULL); + class_entry->ce_flags |= ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE; +#endif + + return class_entry; +} diff --git a/metrics.go b/metrics.go index a27e02630d..4678ab3890 100644 --- a/metrics.go +++ b/metrics.go @@ -16,9 +16,19 @@ const ( type StopReason int +// how a task sent to a background worker ended, see TaskMetrics +const ( + taskCompleted = "completed" // the worker completed it + taskAborted = "aborted" // the worker let go of the task without completing it + taskAbandoned = "abandoned" // the sender gave up on it first + taskTimeout = "timeout" // no thread picked the task up in time + taskCancelled = "cancelled" // the wait for a thread ended on a restart or the shutdown +) + // Metrics reports what the workers and the threads of a FrankenPHP instance // are doing. An implementation that also satisfies OpcacheMetrics is told -// about opcache restarts as well. +// about opcache restarts as well, and one that satisfies TaskMetrics about +// the tasks of background workers. type Metrics interface { // StartWorker collects started workers StartWorker(name string) @@ -53,6 +63,28 @@ type OpcacheMetrics interface { OpcacheRestart(reason string) } +// EXPERIMENTAL: TaskMetrics is the optional part of a Metrics implementation +// that collects the tasks of background workers +type TaskMetrics interface { + // StartWorkerTask collects tasks picked up by a thread of a background worker + StartWorkerTask(name string) + // StopWorkerTask collects tasks a thread of a background worker is done with + StopWorkerTask(name string, duration time.Duration) + // WorkerTaskOutcome collects how tasks sent to a background worker ended: + // completed, aborted, abandoned, timeout or cancelled + WorkerTaskOutcome(name string, outcome string) +} + +// taskMetrics is the TaskMetrics part of the metrics in use, a no-op when +// they do not collect tasks +func taskMetrics() TaskMetrics { + if m, ok := metrics.(TaskMetrics); ok { + return m + } + + return nullMetrics{} +} + type nullMetrics struct{} func (n nullMetrics) StartWorker(string) { @@ -92,6 +124,12 @@ func (n nullMetrics) DequeuedWorkerRequest(string) {} func (n nullMetrics) QueuedRequest() {} func (n nullMetrics) DequeuedRequest() {} +func (n nullMetrics) StartWorkerTask(string) {} + +func (n nullMetrics) StopWorkerTask(string, time.Duration) {} + +func (n nullMetrics) WorkerTaskOutcome(string, string) {} + type PrometheusMetrics struct { registry prometheus.Registerer totalThreads prometheus.Gauge @@ -104,6 +142,8 @@ type PrometheusMetrics struct { workerRequestTime *prometheus.CounterVec workerRequestCount *prometheus.CounterVec workerQueueDepth *prometheus.GaugeVec + workerTaskCount *prometheus.CounterVec + workerTaskTime *prometheus.CounterVec queueDepth prometheus.Gauge opcacheRestarts *prometheus.CounterVec mu sync.RWMutex @@ -121,6 +161,7 @@ func (m *PrometheusMetrics) mustRegister(c prometheus.Collector) { var ( _ Metrics = (*PrometheusMetrics)(nil) _ OpcacheMetrics = (*PrometheusMetrics)(nil) + _ TaskMetrics = (*PrometheusMetrics)(nil) ) func (m *PrometheusMetrics) StartWorker(name string) { @@ -203,7 +244,7 @@ func (m *PrometheusMetrics) TotalWorkers(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) } @@ -251,9 +292,30 @@ func (m *PrometheusMetrics) TotalWorkers(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 worker let go of the task without completing it), abandoned (the sender gave up on the task first), timeout (no thread picked the task up in time) or cancelled (the wait for a thread ended on a restart or the shutdown)", + }, []string{"worker", "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 end of the task on the worker's side", + }, basicLabels) + m.mustRegister(m.workerTaskTime) + } } func (m *PrometheusMetrics) TotalThreads(num int) { @@ -343,6 +405,37 @@ func (m *PrometheusMetrics) OpcacheRestart(reason string) { } } +func (m *PrometheusMetrics) StartWorkerTask(name string) { + m.mu.RLock() + defer m.mu.RUnlock() + + if m.busyWorkers == nil { + return + } + m.busyWorkers.WithLabelValues(name).Inc() +} + +func (m *PrometheusMetrics) StopWorkerTask(name string, duration time.Duration) { + m.mu.RLock() + defer m.mu.RUnlock() + + if m.workerTaskTime == nil { + return + } + m.busyWorkers.WithLabelValues(name).Dec() + m.workerTaskTime.WithLabelValues(name).Add(duration.Seconds()) +} + +func (m *PrometheusMetrics) WorkerTaskOutcome(name string, outcome string) { + m.mu.RLock() + defer m.mu.RUnlock() + + if m.workerTaskCount == nil { + return + } + m.workerTaskCount.WithLabelValues(name, outcome).Inc() +} + func (m *PrometheusMetrics) Shutdown() { m.mu.Lock() defer m.mu.Unlock() @@ -386,6 +479,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 { @@ -420,6 +521,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 5910c67808..42978fe3c2 100644 --- a/metrics_test.go +++ b/metrics_test.go @@ -53,6 +53,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.TotalWorkers("test_worker", 2) @@ -63,6 +65,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.TotalWorkers("bg_worker", 1) + m.StartWorkerTask("bg_worker") + m.StopWorkerTask("bg_worker", 3*time.Second) + m.WorkerTaskOutcome("bg_worker", taskCompleted) + m.WorkerTaskOutcome("bg_worker", taskTimeout) + + 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{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 end of the task on the worker's side + # TYPE frankenphp_worker_task_time counter + `, + expect: ` + frankenphp_worker_task_time{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 worker let go of the task without completing it), abandoned (the sender gave up on the task first), timeout (no thread picked the task up in time) or cancelled (the wait for a thread ended on a restart or the shutdown) + # TYPE frankenphp_worker_task_count counter + `, + expect: ` + frankenphp_worker_task_count{outcome="completed",worker="bg_worker"} 1 + frankenphp_worker_task_count{outcome="timeout",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) { @@ -91,7 +152,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: ` @@ -134,7 +195,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: ` diff --git a/options.go b/options.go index 5a2d73fde5..1725ef9d3e 100644 --- a/options.go +++ b/options.go @@ -64,6 +64,7 @@ type workerOpt struct { bootTimeout time.Duration bootTimeoutIsSet bool vars []string + tasks []string } // WithContext sets the main context to use. @@ -294,6 +295,22 @@ func WithWorkerVars(namespaces ...string) WorkerOption { } } +// EXPERIMENTAL: WithWorkerTasks declares the task namespaces a background +// worker receives, the ones a SentTaskHandle is sent to. A namespace has one +// receiving worker within a server, and one among the global workers; its +// pool shares one queue for all of them, and ReceivedTaskHandle::getNamespace() +// tells them apart. +func WithWorkerTasks(namespaces ...string) WorkerOption { + return func(w *workerOpt) error { + if slices.Contains(namespaces, "") { + return errors.New("a task namespace cannot be empty") + } + w.tasks = append(w.tasks, namespaces...) + + return nil + } +} + // WithWorkerMaxFailures sets the maximum number of consecutive failures before panicking func WithWorkerMaxFailures(maxFailures int) WorkerOption { return func(w *workerOpt) error { diff --git a/phpmainthread.go b/phpmainthread.go index b357614384..1afe31dceb 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -8,6 +8,7 @@ import "C" import ( "errors" "log/slog" + "runtime" "strings" "sync" "sync/atomic" @@ -65,6 +66,9 @@ func initPHPThreads(numThreads int, numMaxThreads int, phpIni map[string]string) // Must follow start(): maxThreads is only final once // setAutomaticMaxThreads runs on the main PHP thread (before Ready). C.frankenphp_init_thread_metrics(C.int(mainThread.maxThreads)) + // a thread only watches its mailbox before sleeping where another core can + // run what it waits for + C.frankenphp_init_mailboxes(C.int(mainThread.maxThreads), C.bool(runtime.NumCPU() > 1)) // initialize all other threads phpThreads = make([]*phpThread, mainThread.maxThreads) @@ -116,6 +120,7 @@ func drainPHPThreads() { mainThread.state.Set(state.Done) mainThread.state.WaitFor(state.Reserved) C.frankenphp_destroy_thread_metrics() + C.frankenphp_destroy_mailboxes() phpThreads = nil } diff --git a/phpthread.go b/phpthread.go index 2c3d1d10fb..22073a0a6e 100644 --- a/phpthread.go +++ b/phpthread.go @@ -333,6 +333,12 @@ func (thread *phpThread) currentContext() *frankenPHPContext { func (thread *phpThread) sendKillSignal() { thread.forceKillMu.RLock() C.frankenphp_force_kill_thread(thread.forceKill) + // a thread asleep on its mailbox hears of the kill there, on the platforms + // where no signal gets it out of its sleep too. Only while its slot is + // set: the thread is then still there, and the mailboxes with it + if thread.forceKill.vm_interrupt != nil { + C.frankenphp_mailbox_wake(C.uintptr_t(thread.threadIndex)) + } thread.forceKillMu.RUnlock() } diff --git a/testdata/bgworker/task-over-worker.php b/testdata/bgworker/task-over-worker.php new file mode 100644 index 0000000000..824c7795b9 --- /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' : (is_bool($result) ? var_export($result, true) : $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-wait-worker.php b/testdata/bgworker/task-wait-worker.php new file mode 100644 index 0000000000..12f31c7268 --- /dev/null +++ b/testdata/bgworker/task-wait-worker.php @@ -0,0 +1,31 @@ +tick($timeout)) { + for ($tasks = 0; $task = $handle->receive(); ++$tasks) { + $payload = $task->getPayload(); + if (!empty($payload['sleep_ms'])) { + usleep(1000 * $payload['sleep_ms']); + } + $task->complete([ + 'result' => 'processed:' . ($payload['input'] ?? ''), + 'worker' => $handle->getName(), + 'thread' => $threadId ??= bin2hex(random_bytes(4)), + 'idle' => $idle, + ]); + } + if (0 === $tasks) { + ++$idle; + } +} +if (!empty($_SERVER['BG_SENTINEL'])) { + file_put_contents($_SERVER['BG_SENTINEL'], "drained after $idle waits without a task\n", FILE_APPEND); +} diff --git a/testdata/bgworker/task-worker.php b/testdata/bgworker/task-worker.php new file mode 100644 index 0000000000..f9128adffb --- /dev/null +++ b/testdata/bgworker/task-worker.php @@ -0,0 +1,87 @@ +getStream(); +while ($callback || $handle->tick()) { + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, null); + if ($callback && !$handle->tick()) { + break; + } + while ($task = $handle->receive()) { + $payload = $task->getPayload(); + if (!empty($payload['crash'])) { + exit(1); + } + if (!empty($payload['drop'])) { + // the next receive() replaces the handle, which aborts the task + continue; + } + if (!empty($payload['abort'])) { + // the handle stays, the task does not wait for it to go + $aborted = $task; + $task->abort(); + continue; + } + try { + if (!empty($payload['mark'])) { + touch($payload['mark']); + } + if (!empty($payload['close_stream'])) { + // the task does not go with its stream + fclose($task->getStream()); + } + 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'); + } + } + if (!empty($payload['pause_ms'])) { + usleep(1000 * $payload['pause_ms']); + } + for ($i = 1, $steps = $payload['steps'] ?? 0; $i <= $steps; ++$i) { + if (!$task->update(['step' => $i, 'of' => $steps])) { + throw new \RuntimeException("update() returned false at step $i"); + } + } + if (!empty($payload['hold_ms'])) { + usleep(1000 * $payload['hold_ms']); + } + $task->complete([ + 'result' => 'processed:' . ($payload['input'] ?? ''), + 'worker' => $handle->getName(), + 'namespace' => $task->getNamespace(), + '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-between-requests.php b/testdata/task-between-requests.php new file mode 100644 index 0000000000..4c11121b18 --- /dev/null +++ b/testdata/task-between-requests.php @@ -0,0 +1,19 @@ +read()['tag']; + } catch (\Throwable $e) { + return get_class($e).': '.$e->getMessage(); + } +}; +$after = null; +while (frankenphp_handle_request(static function () use ($send, &$after): void { + echo json_encode(['request' => $send(), 'after' => $after]); +})) { + $after = $send(); +} diff --git a/testdata/task-busy.php b/testdata/task-busy.php new file mode 100644 index 0000000000..37b8f885b2 --- /dev/null +++ b/testdata/task-busy.php @@ -0,0 +1,23 @@ + 'slow', 'sleep_ms' => 500]); + try { + $late = new \FrankenPHP\SentTaskHandle('echo', ['input' => 'late'], 0.1); + $late->read(); + echo "no timeout\n"; + } catch (\RuntimeException $e) { + echo $e->getMessage(), "\n"; + } + // read to the end: the result is the last update + $update = null; + while (null !== $next = $slow->read()) { + $update = $next; + } + echo json_encode($update); +} catch (\Throwable $e) { + echo get_class($e), ': ', $e->getMessage(); +} diff --git a/testdata/task-closed-stream.php b/testdata/task-closed-stream.php new file mode 100644 index 0000000000..57cc2b8e51 --- /dev/null +++ b/testdata/task-closed-stream.php @@ -0,0 +1,24 @@ + 'x', 'close_stream' => 1]); + $first = $task->getStream(); + $read = [$first]; + $write = $except = null; + stream_select($read, $write, $except, 0, 0); + fclose($first); + $second = $task->getStream(); + echo get_debug_type($first), ', ', get_debug_type($second), "\n"; + + $read = [$second]; + echo stream_select($read, $write, $except, 5), " ready\n"; + while (null !== $update = $task->read()) { + echo $update['result'], "\n"; + } + echo var_export(feof($second), true), "\n"; +} 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..abe5291b74 --- /dev/null +++ b/testdata/task-errors.php @@ -0,0 +1,19 @@ + fn () => new \FrankenPHP\SentTaskHandle('nope', []), + 'payload' => fn () => new \FrankenPHP\SentTaskHandle('echo', ['object' => new stdClass()]), + 'timeout' => fn () => new \FrankenPHP\SentTaskHandle('echo', [], -1), + 'big' => fn () => ini_set('memory_limit', '64M') && new \FrankenPHP\SentTaskHandle('echo', array_fill(0, 100, str_repeat('x', 1 << 20))), + '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-fanout.php b/testdata/task-fanout.php new file mode 100644 index 0000000000..2b42a1c931 --- /dev/null +++ b/testdata/task-fanout.php @@ -0,0 +1,23 @@ + "t$i", 'steps' => (int) $_GET['steps']], 5.0); + } + if ('reverse' === ($_GET['order'] ?? '')) { + $tasks = array_reverse($tasks); + } + $updates = 0; + foreach ($tasks as $task) { + while (null !== $task->read()) { + ++$updates; + } + } + echo count($tasks), ' tasks, ', $updates, ' updates'; +} catch (\Throwable $e) { + echo get_class($e), ': ', $e->getMessage(); +} diff --git a/testdata/task-feof.php b/testdata/task-feof.php new file mode 100644 index 0000000000..a4bdc6fa29 --- /dev/null +++ b/testdata/task-feof.php @@ -0,0 +1,19 @@ + 'x', 'steps' => 2]); + $stream = $task->getStream(); + $read = [$stream]; + $write = $except = null; + stream_select($read, $write, $except, 5); + // time for the worker to complete the task + usleep(100_000); + $before = feof($stream); + for ($updates = 0; null !== $task->read(); ++$updates) { + } + echo json_encode(['before' => $before, 'updates' => $updates, 'after' => feof($stream)]); +} catch (\Throwable $e) { + echo get_class($e), ': ', $e->getMessage(); +} diff --git a/testdata/task-kept-stream.php b/testdata/task-kept-stream.php new file mode 100644 index 0000000000..6642488702 --- /dev/null +++ b/testdata/task-kept-stream.php @@ -0,0 +1,19 @@ + 'x']); +$stream = $task->getStream(); +while (null !== $task->read()) { +} +unset($task); +echo is_resource($stream) ? 'open' : 'closed', "\n"; + +// the same with handles overwritten in a loop, before the tasks are done +$streams = []; +foreach (['a', 'b'] as $input) { + $task = new \FrankenPHP\SentTaskHandle('echo', ['input' => $input, 'sleep_ms' => 50]); + $streams[] = $task->getStream(); +} +unset($task); +echo implode(',', array_map(fn ($s) => is_resource($s) ? 'open' : 'closed', $streams)), "\n"; diff --git a/testdata/task-latency.php b/testdata/task-latency.php new file mode 100644 index 0000000000..4fdc4294ec --- /dev/null +++ b/testdata/task-latency.php @@ -0,0 +1,15 @@ + "$i"]); + while (null !== $task->read()) { + } + $times[] = intdiv(hrtime(true) - $start, 1000); +} +sort($times); +echo $times[10]; 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..7b21e51325 --- /dev/null +++ b/testdata/task-poll.php @@ -0,0 +1,32 @@ + $input, 'sleep_ms' => 200]); + $tasks[spl_object_id($task)] = $task; + $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()) { + $watcher->remove(); + unset($tasks[$watcher->getData()]); + continue; + } + $results[] = $update['result']; + } + } + sort($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/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-queue.php b/testdata/task-queue.php new file mode 100644 index 0000000000..d1dd8acd62 --- /dev/null +++ b/testdata/task-queue.php @@ -0,0 +1,78 @@ + (int) ((hrtime(true) - $since) / 1e6); +$drain = static function (\FrankenPHP\SentTaskHandle $task): ?array { + $last = null; + while (null !== $update = $task->read()) { + $last = $update; + } + + return $last; +}; + +try { + $slow = new \FrankenPHP\SentTaskHandle('echo', ['input' => 'slow', 'sleep_ms' => (int) ($_GET['sleep_ms'] ?? 300), 'mark' => $_GET['mark']]); + for ($i = 0; !file_exists($_GET['mark']) && $i < 5000; ++$i) { + usleep(1000); + } + + switch ($_GET['case']) { + case 'abandon': + $queued = new \FrankenPHP\SentTaskHandle('echo', ['input' => 'queued', 'mark' => $_GET['never']]); + $queued->abandon(); + $drain($slow); + echo $drain(new \FrankenPHP\SentTaskHandle('echo', ['input' => 'after']))['result'], "\n"; + echo file_exists($_GET['never']) ? 'processed' : 'never processed'; + break; + + case 'refused': + case 'unwatched': + $late = new \FrankenPHP\SentTaskHandle('echo', ['input' => 'late', 'mark' => $_GET['never']], 0.1); + if ('refused' === $_GET['case']) { + $read = [$late->getStream()]; + $write = $except = null; + stream_select($read, $write, $except, 5); + } + // the thread is free from here on, past the timeout of the task + $drain($slow); + usleep(50_000); + $start = hrtime(true); + try { + $late->read(); + echo "no timeout\n"; + } catch (\RuntimeException $e) { + echo $ms($start) < 50 ? 'at once' : 'after a wait', ': ', $e->getMessage(), "\n"; + } + echo file_exists($_GET['never']) ? 'processed' : 'never processed'; + break; + + case 'select': + $late = new \FrankenPHP\SentTaskHandle('echo', ['input' => 'late'], 0.1); + $start = hrtime(true); + $read = [$late->getStream()]; + $write = $except = null; + $ready = stream_select($read, $write, $except, 5); + $waited = $ms($start); + echo $ready, ' ready ', $waited >= 70 && $waited < 2000 ? 'at the timeout' : "after {$waited}ms", "\n"; + try { + $late->read(); + echo 'no timeout'; + } catch (\RuntimeException $e) { + echo $e->getMessage(); + } + $drain($slow); + break; + } +} catch (\Throwable $e) { + echo get_class($e), ': ', $e->getMessage(); +} diff --git a/testdata/task-read-timeout.php b/testdata/task-read-timeout.php new file mode 100644 index 0000000000..6eb0cefa19 --- /dev/null +++ b/testdata/task-read-timeout.php @@ -0,0 +1,14 @@ + 'x', 'sleep_ms' => 200]); + stream_set_timeout($task->getStream(), 0, 10_000); + while (null !== $update = $task->read()) { + echo $update['result'], "\n"; + } + echo 'done'; +} catch (\Throwable $e) { + echo get_class($e), ': ', $e->getMessage(); +} diff --git a/testdata/task-reconstruct.php b/testdata/task-reconstruct.php new file mode 100644 index 0000000000..a3b4b673fe --- /dev/null +++ b/testdata/task-reconstruct.php @@ -0,0 +1,12 @@ + 'first']); +try { + $task->__construct('echo', ['input' => 'second']); +} catch (\Error $e) { + echo get_class($e), ': ', $e->getMessage(), "\n"; +} +while (null !== $update = $task->read()) { + echo json_encode($update), "\n"; +} diff --git a/testdata/task-result.php b/testdata/task-result.php new file mode 100644 index 0000000000..45ce2aba8d --- /dev/null +++ b/testdata/task-result.php @@ -0,0 +1,11 @@ + (string) $i]); + $result = $task->read(); + unset($task); +} +echo $result['result'] ?? 'no result'; diff --git a/testdata/task-shutdown.php b/testdata/task-shutdown.php new file mode 100644 index 0000000000..4e87085143 --- /dev/null +++ b/testdata/task-shutdown.php @@ -0,0 +1,14 @@ + 'slow', 'sleep_ms' => 1500, 'mark' => $_GET['mark']]); + $never = new \FrankenPHP\SentTaskHandle('echo', ['input' => 'never'], null); + $never->read(); + echo 'picked up'; +} catch (\Throwable $e) { + echo get_class($e), ': ', $e->getMessage(); +} diff --git a/testdata/task-stale-update.php b/testdata/task-stale-update.php new file mode 100644 index 0000000000..d84e6d782d --- /dev/null +++ b/testdata/task-stale-update.php @@ -0,0 +1,19 @@ + 'held', 'steps' => 1, 'hold_ms' => 300]); + $poll->add($task, [Event::Read], 'task'); + // the update is there + $ready = count($poll->wait(Duration::fromMilliseconds(5000))); + $task->abandon(); + echo $ready, ' ready, then ', count($poll->wait(Duration::fromMilliseconds(50))); +} catch (\Throwable $e) { + echo get_class($e), ': ', $e->getMessage(); +} diff --git a/testdata/task-stale-watcher.php b/testdata/task-stale-watcher.php new file mode 100644 index 0000000000..41a8bf5488 --- /dev/null +++ b/testdata/task-stale-watcher.php @@ -0,0 +1,26 @@ + 'busy', 'sleep_ms' => 200]); + $poll = new Context(); + $ended = new \FrankenPHP\SentTaskHandle('echo', ['input' => 'ended']); + $poll->add($ended, [Event::Read], 'ended'); + $ended->abandon(); + + $next = new \FrankenPHP\SentTaskHandle('echo', ['input' => 'next']); + $read = [$next->getStream()]; + $write = $except = null; + stream_select($read, $write, $except, 5); + // the result of the next task is there, unread, and its descriptor readable + $woken = array_map(static fn ($watcher) => $watcher->getData(), $poll->wait(Duration::fromMilliseconds(50))); + echo json_encode($woken), ' ', $next->read()['result']; +} catch (\Throwable $e) { + echo get_class($e), ': ', $e->getMessage(); +} diff --git a/testdata/task-stream-poll.php b/testdata/task-stream-poll.php new file mode 100644 index 0000000000..4175cd99a6 --- /dev/null +++ b/testdata/task-stream-poll.php @@ -0,0 +1,25 @@ + 'x', 'steps' => 3, 'sleep_ms' => 20]); + $poll = new Context(); + $poll->add(new StreamPollHandle($task->getStream()), [Event::Read]); + $events = 0; + while ($poll->wait(Duration::fromMilliseconds(2000))) { + ++$events; + if (null === $task->read()) { + echo "done after $events events"; + + return; + } + } + echo "missed a wake-up after $events events"; +} 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..bad28bb722 --- /dev/null +++ b/testdata/task.php @@ -0,0 +1,28 @@ + is_numeric($v) ? (int) $v : $v, $payload); + $task = new \FrankenPHP\SentTaskHandle($_GET['ns'] ?? 'echo', $payload, isset($_GET['timeout']) ? (float) $_GET['timeout'] : 30.0); + if (isset($_GET['close_early'])) { + // a task abandoned before its pickup never reaches the worker + for ($i = 0; isset($_GET['mark']) && !file_exists($_GET['mark']) && $i < 5000; ++$i) { + usleep(1000); + } + $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 cdfd47d4a3..dfe9b6fa9b 100644 --- a/threadbackgroundworker.go +++ b/threadbackgroundworker.go @@ -7,6 +7,7 @@ import "C" import ( "fmt" "log/slog" + "runtime" "strconv" "sync/atomic" "time" @@ -18,7 +19,8 @@ import ( // boot the script, re-run it when it exits, restart it with a quadratic // backoff when it crashes. The script waits on its WorkerHandle between two // tick() calls, and the drain makes tick() return false, so it exits on -// shutdown, reboot or handler transition. +// shutdown, reboot or handler transition; the handle also carries a wake-up +// per task sent to the worker, see SentTaskHandle. type backgroundWorkerThread struct { workerLifecycle @@ -38,11 +40,29 @@ type backgroundWorkerThread struct { // the vars namespaces this run published, all of them due by its first tick publishedVars map[string]bool - // the Go side's end of this thread's stop socket pair, the script - // holding the other end; one per thread so pool workers drain - // 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. + // onMailbox says the thread waits in tick() itself instead, on its mailbox, + // where the wake-up then goes. Guarded by worker.tasks.mu + parked, onMailbox bool + + // senders waking the thread outside of worker.tasks.mu, on stopSock or on + // its mailbox, so the socket is only closed, and the mailboxes freed, 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 @@ -56,8 +76,10 @@ const backgroundBootWarnDelay = 10 * time.Second const bootTimeoutKillDelay = 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) } @@ -87,7 +109,22 @@ 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, handler.onMailbox = false, false + q.mu.Unlock() + + // a thread asleep in tick() learns the drain there + C.frankenphp_mailbox_wake(C.uintptr_t(handler.thread.threadIndex)) + // senders that claimed the thread before it was withdrawn finish waking it + // first: its socket is closed next, and the mailboxes go with the last + // thread drained + for handler.signaling.Load() > 0 { + runtime.Gosched() + } + if s >= 0 { C.frankenphp_worker_close_stop_sock(C.intptr_t(s)) } } @@ -133,7 +170,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.name) } - 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: @@ -154,6 +196,7 @@ func (handler *backgroundWorkerThread) setupScript() error { handler.isBootingScript = true handler.publishedVars = make(map[string]bool, len(handler.worker.varsNamespaces)) handler.runStartedAt = time.Now() + handler.openTasks = 0 metrics.StartWorker(handler.worker.name) // the run's logger and context, not the globals: a shutdown finishing // meanwhile resets those, and Stop() does not wait for this callback diff --git a/worker.go b/worker.go index c97e03cc28..45881cc570 100644 --- a/worker.go +++ b/worker.go @@ -50,6 +50,11 @@ type worker struct { // the snapshot of each, see WorkerHandle::setVars() varsNamespaces []string vars map[string]*varsSlot + // the task namespaces the worker receives, in declaration order + taskNamespaces []string + // 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 @@ -79,7 +84,7 @@ var ( const defaultWorkerBootTimeout = 30 * time.Second func initWorkers(opts []workerOpt) error { - varsOwners = namespaceOwners{} + varsOwners, taskOwners = namespaceOwners{}, namespaceOwners{} if len(opts) == 0 { return nil } @@ -103,6 +108,7 @@ func initWorkers(opts []workerOpt) error { workers = append(workers, w) workersByName[w.name] = w varsOwners.add(w, w.varsNamespaces) + taskOwners.add(w, w.taskNamespaces) if w.server != nil { if err := w.server.addWorker(w); err != nil { return err @@ -248,8 +254,9 @@ func resolveWorkerFile(o workerOpt) (workerOpt, error) { type declarationIndex struct { names func(name string) bool paths func(server *Server, path string) bool - // the name of the worker publishing a vars namespace, empty when none does - vars func(server *Server, ns string) string + // the name of the worker publishing a vars namespace, or receiving a task + // namespace, empty when none does + vars, tasks func(server *Server, ns string) string } // scopeSet records keys per scope, with the name of the worker that took @@ -376,6 +383,18 @@ func checkWorkerDeclaration(o workerOpt, taken declarationIndex) error { } } + if len(o.tasks) > 0 && !o.isBackgroundWorker { + return fmt.Errorf("worker %q declares tasks, only background workers receive them", o.name) + } + for i, ns := range o.tasks { + if slices.Contains(o.tasks[:i], ns) { + return fmt.Errorf("worker %q declares the task namespace %q twice", o.name, ns) + } + if owner := taken.tasks(o.server, ns); owner != "" { + return fmt.Errorf("%s cannot receive the same task namespace: %q, declared by %q and %q", scope, ns, owner, o.name) + } + } + return nil } @@ -399,6 +418,13 @@ func newWorker(o workerOpt) (*worker, error) { return w.name } + return "" + }, + tasks: func(server *Server, ns string) string { + if w := taskOwners[server][ns]; w != nil { + return w.name + } + return "" }, } @@ -459,12 +485,12 @@ func newWorker(o workerOpt) (*worker, error) { isBackgroundWorker: o.isBackgroundWorker, readyOnce: make(chan struct{}), varsNamespaces: o.vars, + taskNamespaces: o.tasks, vars: make(map[string]*varsSlot, len(o.vars)), } for _, ns := range o.vars { w.vars[ns] = &varsSlot{} } - w.configureMercure(&o) w.requestOptions = append( diff --git a/workertask.go b/workertask.go new file mode 100644 index 0000000000..f4d503d724 --- /dev/null +++ b/workertask.go @@ -0,0 +1,720 @@ +package frankenphp + +// #include "frankenphp.h" +import "C" +import ( + "runtime/cgo" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + "unsafe" +) + +// taskLinger is how often the watcher of a queue looks at what waits in it, +// see watchTasks(): a pickup takes microseconds +const taskLinger = 10 * time.Millisecond + +// 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 the mailbox of its thread, or on its descriptor of the task's +// channel once it asked for one, and is woken there by the other. +type workerTask struct { + handle cgo.Handle + worker *worker + // the thread of the sender, woken through its mailbox while it waits without + // a descriptor + sender C.uintptr_t + namespace string + payload *C.HashTable // owned by the task until a thread picks it up + pickedUp atomic.Bool // a thread picked the task up + // what the watcher of the queue refuses a waiting task on, see + // watchTasks(): the time its sender gave a thread to pick it up, none when + // zero, the drain of the sender's thread and the shutdown, both read on that + // thread at send time + deadline time.Time + drainChan, shutdown <-chan struct{} + // the watcher of the queue found the task waiting already; only touched by it + lingers bool + // set by the thread that picked the task up, read by its close + receiver *backgroundWorkerThread + pickedUpAt time.Time + + mu sync.Mutex + // why the task ended without being completed, for the sender's read() to + // throw: no thread will pick it up, see abort(), or the worker let go of it + failure string + // the sender sleeps in read(), on its mailbox, or is about to: the next + // event wakes it there, once + senderParked bool + // the sender's descriptor is readable, once it took it: nothing tells when + // it waits there, a poller may keep it for good, so it is readable for as + // long as read() has something to take, see senderReadableLocked() + senderReadable bool + // fds[0] is the sender's descriptor, fds[1] the receiver's: the pair is made + // when a side first hands its own to a stream_select() or a poll context, + // and that side is signaled there from then on. The task owns the pair and + // closes it once both sides are done, never before: a poller may still + // hold a descriptor of a task its side ended, and the kernel only takes it + // off that poller when it is closed + fds [2]int64 + chanOpen bool + senderOnFd, receiverOnFd bool + updates []*C.HashTable + closed bool // the task is over for the sender to read: completed, or failed + 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: as many as their senders hold handles on, nothing else bounds it. +// 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 + // the watcher runs, see watchTasks() + watching bool +} + +// 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, -1 for a +// thread to wake through its mailbox, or no thread when none is parked: the +// task then waits in the queue for a thread to drain it or to park, see +// go_frankenphp_background_worker_park. Called with tasks.mu held, the caller +// wakes the thread 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 + worker.tasks.next = (worker.tasks.next + i + 1) % n + // drain() waits for the wake-up, whichever way it goes + handler.signaling.Add(1) + if handler.onMailbox { + handler.onMailbox = false + + return handler, -1 + } + + return handler, handler.stopSock + } + } + + return nil, -1 +} + +// claimAllThreads is the fallback of watchTasks(): every thread of the worker +// gets the line, parked or not, or a wake-up on its mailbox when it sleeps +// there. Called with tasks.mu held, the caller wakes the threads 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 { + handlers = append(handlers, handler) + handler.signaling.Add(1) + if handler.parked && handler.onMailbox { + socks = append(socks, -1) + } else { + socks = append(socks, handler.stopSock) + } + handler.parked, handler.onMailbox = false, false + } + } + worker.threadMutex.RUnlock() + + return handlers, socks +} + +// signalThreads wakes the threads claimed under tasks.mu, after it was +// released: the wake-up 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 { + if s < 0 { + C.frankenphp_mailbox_wake(C.uintptr_t(handlers[i].thread.threadIndex)) + } else { + C.frankenphp_worker_signal_task(C.intptr_t(s)) + } + handlers[i].signaling.Add(-1) + } +} + +// notifySenderLocked tells the sender that read() has something for it: an +// update, or the end of the task. On its descriptor once it took it; true +// when it sleeps on its mailbox instead, for the caller to wake it there. +// Called with mu held +func (t *workerTask) notifySenderLocked() bool { + if t.senderOnFd { + t.senderReadableLocked(true) + + return false + } + parked := t.senderParked + t.senderParked = false + + return parked +} + +// senderReadableLocked makes the sender's descriptor readable, or no longer. +// With mu held from the change of what read() has to take to the write or +// the read on the descriptor, so the two never part +func (t *workerTask) senderReadableLocked(readable bool) { + if !t.senderOnFd || t.senderReadable == readable { + return + } + t.senderReadable = readable + if readable { + C.frankenphp_task_chan_signal(C.intptr_t(t.fds[0]), C.intptr_t(t.fds[1]), 0) + } else { + C.frankenphp_task_chan_consume(C.intptr_t(t.fds[0])) + } +} + +// 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 what the task still holds once both sides are done with it: +// its payload went to the thread that picked it up, or was freed when it left +// the queue, and the updates nobody read went with the sender +func (t *workerTask) free() { + if t.chanOpen { + C.frankenphp_close_sock(C.intptr_t(t.fds[0])) + C.frankenphp_close_sock(C.intptr_t(t.fds[1])) + } + t.handle.Delete() +} + +// the receiver of each task namespace, per scope +var taskOwners namespaceOwners + +// taskOwner resolves the receiver of a task namespace: within the sender's +// server first, then among global workers +func taskOwner(thread *phpThread, ns string) *worker { + return taskOwners.resolve(namespaceScope(thread), ns) +} + +//export go_frankenphp_send_task +func go_frankenphp_send_task(threadIndex C.uintptr_t, namespace *C.char, namespaceLen C.size_t, payload *C.HashTable, pickupMs C.int) (C.uintptr_t, *C.char, C.bool) { + thread := phpThreads[threadIndex] + // looked up first, and only kept once it is known + ns := unsafe.String((*byte)(unsafe.Pointer(namespace)), int(namespaceLen)) + w := taskOwner(thread, ns) + if w == nil { + C.frankenphp_vars_free(payload) + + // what the configuration lacks, not a misuse: see varsOwner + return 0, C.CString("FrankenPHP\\SentTaskHandle: no background worker receives the task namespace " + quoteNamespace(ns)), false + } + ns = strings.Clone(ns) + if handler, ok := thread.handler.(*backgroundWorkerThread); ok && handler.worker == w && w.countThreads() == 1 { + C.frankenphp_vars_free(payload) + + return 0, C.CString("FrankenPHP\\SentTaskHandle: the task namespace " + strconv.Quote(ns) + " is received by this very worker, which has a single thread"), true + } + + t := &workerTask{ + worker: w, + sender: threadIndex, + namespace: ns, + payload: payload, + fds: [2]int64{-1, -1}, + // 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, + } + if pickupMs >= 0 { + t.deadline = time.Now().Add(time.Duration(pickupMs) * time.Millisecond) + } + t.handle = cgo.NewHandle(t) + + // queued like a request would be: a background worker has no other queue + metrics.QueuedWorkerRequest(w.name) + q := &w.tasks + q.mu.Lock() + q.pending = append(q.pending, t) + handler, sock := w.claimParkedThread() + watch := !q.watching + q.watching = true + q.mu.Unlock() + if handler != nil { + signalThreads([]*backgroundWorkerThread{handler}, []int64{sock}) + } + if watch { + go w.watchTasks() + } + + // the sender goes on: its read() waits for the pickup along with the first + // update, on the mailbox of its thread rather than in a Go select, since + // waking a thread parked inside a Go callback costs the scheduler a + // hand-off and a wake-up in the kernel does not + + return C.uintptr_t(t.handle), nil, false +} + +// watchTasks looks after the tasks that wait in the queue of the worker, every +// taskLinger for as long as some do: nothing else watches a waiting task, its +// sender only sleeps, and a pickup within microseconds costs neither a thing. +// A task found waiting gets every thread of the worker woken, once, parked or +// not: the one its sender woke may be busy with something else. And a task is +// refused once no thread should pick it up anymore: the time its sender gave +// has passed, or, a look later so that a pickup on its way gets through, +// FrankenPHP shuts down or the thread of its sender is drained +func (worker *worker) watchTasks() { + q := &worker.tasks + var tasks []*workerTask + for { + time.Sleep(taskLinger) + + q.mu.Lock() + if len(q.pending) == 0 { + q.watching = false + q.mu.Unlock() + + return + } + // looked at once the mutex is released: the queue may be long + tasks = append(tasks[:0], q.pending...) + q.mu.Unlock() + + now, lingering := time.Now(), false + for _, t := range tasks { + switch { + case !t.deadline.IsZero() && !now.Before(t.deadline): + t.abort("FrankenPHP\\SentTaskHandle: no thread picked up the task sent to "+strconv.Quote(t.namespace)+" in time", taskTimeout) + case !t.lingers: + t.lingers, lingering = true, true + // the shutdown drains the threads too: it is the reason then + case isClosed(t.shutdown): + t.abort("FrankenPHP\\SentTaskHandle: FrankenPHP is shutting down", taskCancelled) + case isClosed(t.drainChan): + t.abort("FrankenPHP\\SentTaskHandle: the calling thread is restarting or shutting down", taskCancelled) + } + } + + if lingering { + q.mu.Lock() + handlers, socks := worker.claimAllThreads() + q.mu.Unlock() + signalThreads(handlers, socks) + } + } +} + +func isClosed(c <-chan struct{}) bool { + select { + case <-c: + return true + default: + return false + } +} + +// abort refuses a task still in the queue, which it leaves at once: no thread +// takes it from there on, so what the sender is told stands. Woken wherever it +// waits, the sender finds a task that failed for that reason. A thread that +// took the task first wins, and so does a sender that left +func (t *workerTask) abort(reason, outcome string) { + if !t.withdraw(outcome, reason) { + return + } + + t.mu.Lock() + defer t.mu.Unlock() + // under the mutex, which the sender's close takes: its thread, and the + // mailbox with it, are there for the wake-up + if !t.senderGone && t.notifySenderLocked() { + C.frankenphp_mailbox_wake(t.sender) + } +} + +// withdraw takes a task nobody picked up out of the queue and releases the +// receiver's side of it, the sender's close releases the rest; false when a +// thread got the task first. reason is why, for a sender that did not ask +func (t *workerTask) withdraw(outcome, reason string) bool { + if !t.worker.tasks.remove(t) { + return false + } + metrics.DequeuedWorkerRequest(t.worker.name) + + t.mu.Lock() + // the first to end the task settles its outcome, and a sender that left + // meanwhile did + settled := !t.senderGone + if settled { + // set along with the end of the task: read() tells a refusal from a + // completion + t.failure = reason + } + // nothing for the sender's close to settle + t.closed = true + t.mu.Unlock() + if settled { + taskMetrics().WorkerTaskOutcome(t.worker.name, outcome) + } + + C.frankenphp_vars_free(t.payload) + t.payload = nil + t.retire() + + return true +} + +// go_frankenphp_task_fd hands a side its descriptor of the task's channel, as +// it is about to wait on it through stream_select() or a poll context: the +// pair is made then, and what the side would have found on it is signaled. +// -1 when the pair could not be made +// +//export go_frankenphp_task_fd +func go_frankenphp_task_fd(handle C.uintptr_t, sender C.bool) C.intptr_t { + t := cgo.Handle(handle).Value().(*workerTask) + + t.mu.Lock() + if !t.chanOpen { + var fds [2]C.intptr_t + if C.frankenphp_task_chan_open(&fds[0]) != 0 { + t.mu.Unlock() + + return -1 + } + t.fds, t.chanOpen = [2]int64{int64(fds[0]), int64(fds[1])}, true + } + side := 0 + if bool(sender) { + // what is already there to read is on the descriptor from the start + t.senderOnFd = true + t.senderReadableLocked(t.eventLocked()) + } else { + side = 1 + // a sender gone already is what the descriptor is there to tell + if !t.receiverOnFd && t.senderGone && !t.closed { + t.signalReceiver() + } + t.receiverOnFd = true + } + fd := t.fds[side] + t.mu.Unlock() + + return C.intptr_t(fd) +} + +// 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) { + // the end comes behind the updates still to read + return C.bool(t.closed && len(t.updates) == 0) + } + + return C.bool(t.senderGone) +} + +// go_frankenphp_background_worker_park is called by WorkerHandle::tick() as the +// thread is about to wait, where says on what: its handle, when the script +// waits there next, or its mailbox, when tick() waits itself. The thread parks +// unless tasks are queued: a wait in tick() is over then, and a script about +// to wait on its handle gets a wake-up written there, so that its wait returns +// at once. Under tasks.mu, so a task queued after the check finds the thread +// parked and wakes it: no wake-up is lost either way. FRANKENPHP_PARK_LEAVE +// ends the park of a thread whose wait in tick() is over. A drained thread is +// told so, ahead of the end of its handle's stream. +// +//export go_frankenphp_background_worker_park +func go_frankenphp_background_worker_park(threadIndex C.uintptr_t, where C.int) C.int { + handler, ok := phpThreads[threadIndex].handler.(*backgroundWorkerThread) + if !ok { + // refused on the C side already + return C.FRANKENPHP_PARK_READY + } + + q := &handler.worker.tasks + q.mu.Lock() + s := handler.stopSock + if s < 0 { + q.mu.Unlock() + + return C.FRANKENPHP_PARK_DRAINED + } + if where == C.FRANKENPHP_PARK_LEAVE { + handler.parked, handler.onMailbox = false, false + q.mu.Unlock() + + return C.FRANKENPHP_PARK_READY + } + if len(q.pending) == 0 { + handler.parked, handler.onMailbox = true, where == C.FRANKENPHP_PARK_ON_MAILBOX + q.mu.Unlock() + + return C.FRANKENPHP_PARK_PARKED + } + if where == C.FRANKENPHP_PARK_ON_MAILBOX { + // awake, whatever an earlier tick() parked the thread on + handler.parked, handler.onMailbox = false, false + q.mu.Unlock() + + return C.FRANKENPHP_PARK_READY + } + // drain() waits for the write before closing the socket + handler.signaling.Add(1) + q.mu.Unlock() + + // the syscall happens outside of the mutex, see signalThreads + C.frankenphp_worker_signal_task(C.intptr_t(s)) + handler.signaling.Add(-1) + + return C.FRANKENPHP_PARK_READY +} + +// go_frankenphp_receive_task dequeues a task for the calling thread, none when +// the queue is empty. It never waits, WorkerHandle::tick() does, and a park +// set there stays: the script may be about to wait on its handle, where the +// next task has to wake it +// +//export go_frankenphp_receive_task +func go_frankenphp_receive_task(threadIndex C.uintptr_t) (C.uintptr_t, *C.HashTable, *C.char, C.size_t) { + handler, ok := phpThreads[threadIndex].handler.(*backgroundWorkerThread) + if !ok { + // refused on the C side already + return 0, nil, nil, 0 + } + + q := &handler.worker.tasks + q.mu.Lock() + if len(q.pending) == 0 { + q.mu.Unlock() + + return 0, nil, nil, 0 + } + 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) + // nothing wakes the sender for a pickup: its read() waits for what the + // thread has to say + t.pickedUp.Store(true) + + t.receiver = handler + t.pickedUpAt = time.Now() + taskMetrics().StartWorkerTask(handler.worker.name) + // busy on the threads endpoint while it holds a task + if handler.openTasks++; handler.openTasks == 1 { + handler.state.MarkAsWaiting(false) + } + + // the namespace goes as C memory, for the C side to copy and free: request + // memory is not allocated from here, see WorkerHandle::receive() + return C.uintptr_t(t.handle), payload, C.CString(t.namespace), C.size_t(len(t.namespace)) +} + +// go_frankenphp_update_task queues an update for the sender, which reads them +// at its own pace: nothing bounds what waits for it. False when the sender is +// gone, the update is freed then +// +//export go_frankenphp_update_task +func go_frankenphp_update_task(handle C.uintptr_t, update *C.HashTable) C.bool { + t := cgo.Handle(handle).Value().(*workerTask) + + t.mu.Lock() + if t.senderGone { + t.mu.Unlock() + C.frankenphp_vars_free(update) + + return false + } + t.updates = append(t.updates, update) + wake := t.notifySenderLocked() + t.mu.Unlock() + if wake { + C.frankenphp_mailbox_wake(t.sender) + } + + return true +} + +// go_frankenphp_read_task hands the sender its next event; with nothing to +// hand, the sender parks and the next event wakes it. A task that failed is +// over with the reason why, for the C side to throw and free +// +//export go_frankenphp_read_task +func go_frankenphp_read_task(handle C.uintptr_t) (*C.HashTable, C.int, *C.char) { + t := cgo.Handle(handle).Value().(*workerTask) + + t.mu.Lock() + defer t.mu.Unlock() + + if len(t.updates) > 0 { + update := t.updates[0] + if len(t.updates) == 1 { + // the array serves again + t.updates = t.updates[:0] + } else { + // not moved: the sender may be far behind + t.updates = t.updates[1:] + } + t.senderParked = false + // the end of the task comes behind its updates + t.senderReadableLocked(len(t.updates) > 0 || t.closed) + + return update, C.int(C.FRANKENPHP_TASK_READ_UPDATE), nil + } + + status := C.int(C.FRANKENPHP_TASK_READ_PENDING) + var failure *C.char + if t.closed { + status = C.FRANKENPHP_TASK_READ_OVER + if t.failure != "" { + failure = C.CString(t.failure) + } + } + // nothing is left to read, the end of the task is only told once there + t.senderReadableLocked(false) + t.senderParked = !t.closed + + return nil, status, failure +} + +// eventLocked tells whether read() has something for the sender right now: an +// update or the end of the task. Called with mu held +func (t *workerTask) eventLocked() bool { + return len(t.updates) > 0 || t.closed +} + +// go_frankenphp_task_receiver_close ends the task on the receiver's side, and +// tells whether the sender was still there to learn it. The result of a task +// being completed is queued with its end, so the sender never finds one +// without the other; a result that goes nowhere makes it an abort, the sender +// must not take the updates it got for the whole of it +// +//export go_frankenphp_task_receiver_close +func go_frankenphp_task_receiver_close(handle C.uintptr_t, abort C.bool, result *C.HashTable) C.bool { + t := cgo.Handle(handle).Value().(*workerTask) + aborted := bool(abort) + + t.mu.Lock() + if result != nil { + if !aborted && !t.senderGone { + t.updates = append(t.updates, result) + result = nil + } else { + aborted = true + } + } + t.closed = true + if aborted { + t.failure = "FrankenPHP\\SentTaskHandle::read(): the background worker let go of the task without completing it" + } + // the first side to close settles the outcome + settled := !t.senderGone + // the sender finds the end of the task behind the updates still queued + wake := settled && t.notifySenderLocked() + t.mu.Unlock() + if wake { + C.frankenphp_mailbox_wake(t.sender) + } + dropped := result != nil + if dropped { + C.frankenphp_vars_free(result) + } + + m := taskMetrics() + m.StopWorkerTask(t.worker.name, time.Since(t.pickedUpAt)) + if settled { + outcome := taskCompleted + if aborted { + outcome = taskAborted + } + m.WorkerTaskOutcome(t.worker.name, outcome) + } + handler := t.receiver + handler.openTasks-- + if handler.openTasks == 0 && !handler.isBootingScript { + handler.state.MarkAsWaiting(true) + } + + t.retire() + + return C.bool(settled && !dropped) +} + +//export go_frankenphp_task_sender_close +func go_frankenphp_task_sender_close(handle C.uintptr_t) { + t := cgo.Handle(handle).Value().(*workerTask) + + if !t.pickedUp.Load() { + // no thread took the task yet: it leaves the queue with its sender, + // unless it was refused and left it already + t.withdraw(taskAbandoned, "") + } + + t.mu.Lock() + t.senderGone = true + t.senderParked = false + // a poller left with the descriptor of a task its side ended stays quiet + t.senderReadableLocked(false) + // the first side to close settles the outcome + settled := !t.closed + // the receiver's stream_select() and feof() see it; once the receiver + // closed, nobody waits on its descriptor + signal := settled && t.receiverOnFd + updates := t.updates + t.updates = nil + t.mu.Unlock() + + if signal { + t.signalReceiver() + } + if settled { + taskMetrics().WorkerTaskOutcome(t.worker.name, taskAbandoned) + } + for _, update := range updates { + C.frankenphp_vars_free(update) + } + t.retire() +} diff --git a/workertask_internal_test.go b/workertask_internal_test.go new file mode 100644 index 0000000000..e7441fe4b7 --- /dev/null +++ b/workertask_internal_test.go @@ -0,0 +1,69 @@ +package frankenphp + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// a force-kill ends the wait of a sender in read(), as max_execution_time +// does: the thread sleeps on its mailbox there, which the signal of the kill +// gets it out of, or the pace at which the ports without one look up +func TestTaskReadEndsOnAForceKill(t *testing.T) { + mark := filepath.Join(t.TempDir(), "picked") + server, err := NewServer(testDataPath) + require.NoError(t, err) + require.NoError(t, Init( + WithServer(server), + WithWorkers("echo", filepath.Join(testDataPath, "bgworker", "task-worker.php"), 1, + WithWorkerBackground(), + WithWorkerServerScope(server), + WithWorkerTasks("echo"), + ), + WithNumThreads(2), + )) + t.Cleanup(Shutdown) + + // the worker holds the task for 10 seconds, unless its sender leaves + body := make(chan string, 1) + go func() { + w := httptest.NewRecorder() + _ = server.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "http://example.com/task.php?input=slow&sleep_ms=10000&mark="+url.QueryEscape(mark), nil)) + b, _ := io.ReadAll(w.Result().Body) + body <- string(b) + }() + require.Eventually(t, func() bool { + _, err := os.Stat(mark) + + return err == nil + }, 5*time.Second, 10*time.Millisecond, "the worker did not pick the task up") + // the sender is in its read() by now + time.Sleep(100 * time.Millisecond) + + killed := 0 + for _, thread := range phpThreads { + thread.handlerMu.RLock() + _, regular := thread.handler.(*regularThread) + thread.handlerMu.RUnlock() + if regular && !thread.state.IsInWaitingState() { + thread.sendKillSignal() + killed++ + } + } + require.Equal(t, 1, killed, "the thread of the request was not found") + + select { + case b := <-body: + assert.NotContains(t, b, "processed:slow") + case <-time.After(5 * time.Second): + t.Fatal("the force-kill did not end the read()") + } +} diff --git a/workertask_test.go b/workertask_test.go new file mode 100644 index 0000000000..99473f9b5b --- /dev/null +++ b/workertask_test.go @@ -0,0 +1,738 @@ +package frankenphp_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strconv" + "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" +) + +// taskWorker declares a background worker from testdata/bgworker receiving +// the task namespace named like it, scoped to server when one is given +func taskWorker(name, file string, env map[string]string, server *frankenphp.Server, extra ...frankenphp.WorkerOption) frankenphp.Option { + opts := []frankenphp.WorkerOption{frankenphp.WithWorkerBackground(), frankenphp.WithWorkerEnv(env), frankenphp.WithWorkerTasks(name)} + if server != nil { + opts = append(opts, frankenphp.WithWorkerServerScope(server)) + } + + return frankenphp.WithWorkers(name, "testdata/bgworker/"+file, 1, append(opts, extra...)...) +} + +func TestTaskRoundTrip(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), taskWorker("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")) + // worker names are global, the namespace is what the two share + jobs := func(name, tag string, server *frankenphp.Server) frankenphp.Option { + return frankenphp.WithWorkers(name, "testdata/bgworker/task-worker.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerTasks("jobs"), + frankenphp.WithWorkerEnv(map[string]string{"BG_TAG": tag}), + frankenphp.WithWorkerServerScope(server), + ) + } + initServers(t, + frankenphp.WithServer(server1), + frankenphp.WithServer(server2), + jobs("jobs-one", "one", server1), + jobs("jobs-two", "two", server2), + frankenphp.WithNumThreads(3), + ) + + assert.Contains(t, serverGet(t, server1, "http://example.com/task.php?ns=jobs"), `"tag":"one"`) + assert.Contains(t, serverGet(t, server2, "http://example.com/task.php?ns=jobs"), `"tag":"two"`) + + // a name nobody declares may come from the request: the message shows its start + long := serverGet(t, server1, "http://example.com/task.php?ns="+strings.Repeat("x", 5000)) + assert.Contains(t, long, `the task namespace "`+strings.Repeat("x", 64)+`"...`) + assert.Less(t, len(long), 300) +} + +// between two requests, a worker serving any server still sends its tasks +// to the workers of the one it just served +func TestTaskBetweenTwoRequests(t *testing.T) { + server1, _ := frankenphp.NewServer(testDataDir, frankenphp.WithServerName("one")) + server2, _ := frankenphp.NewServer(testDataDir, frankenphp.WithServerName("two")) + jobs := func(name, tag string, server *frankenphp.Server) frankenphp.Option { + return frankenphp.WithWorkers(name, "testdata/bgworker/task-worker.php", 1, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerTasks("jobs"), + frankenphp.WithWorkerEnv(map[string]string{"BG_TAG": tag}), + frankenphp.WithWorkerServerScope(server), + ) + } + initServers(t, + frankenphp.WithServer(server1), + frankenphp.WithServer(server2), + jobs("jobs-one", "one", server1), + jobs("jobs-two", "two", server2), + frankenphp.WithWorkers("sender", testDataDir+"task-between-requests.php", 1), + frankenphp.WithNumThreads(4), + ) + + url := "http://example.com/task-between-requests.php" + assert.JSONEq(t, `{"request":"one","after":null}`, serverGet(t, server1, url)) + assert.JSONEq(t, `{"request":"two","after":"one"}`, serverGet(t, server2, url)) + assert.JSONEq(t, `{"request":"one","after":"two"}`, serverGet(t, server1, url)) +} + +// a background worker may send tasks too, here while booting +func TestTaskFromBackgroundWorker(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "relay.json") + initServers(t, + taskWorker("relay", "task-relay.php", map[string]string{"BG_TARGET": "echo", "BG_SENTINEL": sentinel}, nil), + taskWorker("echo", "task-worker.php", nil, nil), + frankenphp.WithNumThreads(3), + ) + + assert.Contains(t, requireFileContentEventually(t, sentinel), `"result":"processed:relayed"`) +} + +// a task waits for a thread to pick it up, up to the timeout: its read() +// tells +func TestTaskPickupTimeout(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), taskWorker("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 sent to "echo" 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), taskWorker("echo", "task-worker.php", nil, server), frankenphp.WithNumThreads(2)) + + assert.Contains(t, serverGet(t, server, "http://example.com/task.php?crash=1"), "let go of the task without completing it") + 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), taskWorker("echo", "task-worker.php", map[string]string{"BG_SENTINEL": sentinel}, server), frankenphp.WithNumThreads(2)) + + mark := url.QueryEscape(filepath.Join(t.TempDir(), "picked")) + assert.Equal(t, "closed", serverGet(t, server, "http://example.com/task.php?sleep_ms=200&close_early=1&mark="+mark)) + 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), taskWorker("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.WithWorkerTasks("pool")), + frankenphp.WithNumThreads(3), + ) + + assert.Equal(t, "[\"processed:a\",\"processed:b\"]\ntwo threads", serverGet(t, server, "http://example.com/task-pool.php")) +} + +// a stream outlives its handle closed: the handle took the task with it +func TestTaskStreamKeptPastItsHandle(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), taskWorker("echo", "task-worker.php", nil, server), frankenphp.WithNumThreads(2)) + + assert.Equal(t, "closed\nclosed,closed\n", serverGet(t, server, "http://example.com/task-kept-stream.php")) +} + +// closing the stream of a task leaves the task alone, on both sides: the +// handle hands out a fresh one and the result still comes +func TestTaskStreamClosedByTheScript(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), taskWorker("echo", "task-worker.php", nil, server), frankenphp.WithNumThreads(2)) + + assert.Equal(t, "resource (closed), resource (stream)\n1 ready\nprocessed:x\ntrue\n", serverGet(t, server, "http://example.com/task-closed-stream.php")) +} + +// read() has no timeout, not even the one a script sets on the stream of the +// task: stream_select() and the poll contexts take one +func TestTaskReadIgnoresTheTimeoutOfTheStream(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), taskWorker("echo", "task-worker.php", nil, server), frankenphp.WithNumThreads(2)) + + assert.Equal(t, "processed:x\ndone", serverGet(t, server, "http://example.com/task-read-timeout.php")) +} + +// a sender queues as many tasks as it wants before reading any, and reads +// them in any order, while the worker streams its updates: neither side +// waits on the other, so neither can hold the other +func TestTaskFanOut(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), taskWorker("echo", "task-worker.php", nil, server), frankenphp.WithNumThreads(2)) + + assert.Equal(t, "20 tasks, 420 updates", serverGet(t, server, "http://example.com/task-fanout.php?n=20&steps=20")) + assert.Equal(t, "20 tasks, 420 updates", serverGet(t, server, "http://example.com/task-fanout.php?n=20&steps=20&order=reverse")) +} + +// only complete() completes a task: a worker letting go of it otherwise +// makes the sender's read() throw, as a crash does +func TestTaskDroppedWithoutComplete(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), taskWorker("echo", "task-worker.php", nil, server), frankenphp.WithNumThreads(2)) + + assert.Equal(t, `RuntimeException: FrankenPHP\SentTaskHandle::read(): the background worker let go of the task without completing it`, serverGet(t, server, "http://example.com/task.php?drop=1")) + assert.Contains(t, serverGet(t, server, "http://example.com/task.php?input=next"), `"result":"processed:next"`) + + // abort() says it at once, with the handle still held + assert.Equal(t, `RuntimeException: FrankenPHP\SentTaskHandle::read(): the background worker let go of the task without completing it`, serverGet(t, server, "http://example.com/task.php?abort=1")) + assert.Contains(t, serverGet(t, server, "http://example.com/task.php?input=again"), `"result":"processed:again"`) +} + +// a worker learns from update() that its sender gave up, nothing throws +func TestTaskUpdateAfterTheSenderLeft(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "error") + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), taskWorker("echo", "task-worker.php", map[string]string{"BG_SENTINEL": sentinel}, server), frankenphp.WithNumThreads(2)) + + mark := url.QueryEscape(filepath.Join(t.TempDir(), "picked")) + assert.Equal(t, "closed", serverGet(t, server, "http://example.com/task.php?steps=2&pause_ms=200&close_early=1&mark="+mark)) + assert.Equal(t, "RuntimeException: update() returned false at step 1", requireFileContentEventually(t, sentinel)) +} + +func TestTaskErrors(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), taskWorker("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: no background worker receives the task namespace "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 ($pickupTimeout) must be greater than or equal to 0`) + assert.Contains(t, body, `big: ValueError: FrankenPHP\SentTaskHandle::__construct(): payload values must fit in memory_limit once copied`) + assert.Contains(t, body, `received: Error: Call to private FrankenPHP\ReceivedTaskHandle::__construct()`) + assert.Contains(t, body, `worker: LogicException: 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), + taskWorker("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"), "let go of the task without completing it") + mark := url.QueryEscape(filepath.Join(t.TempDir(), "picked")) + assert.Equal(t, "closed", serverGet(t, server, "http://example.com/task.php?sleep_ms=200&close_early=1&mark="+mark)) + requireFileContentEventually(t, sentinel) + assert.Contains(t, serverGet(t, server, "http://example.com/task-busy.php"), `picked up the task sent to "echo" in time`) + + expected := ` + # HELP frankenphp_worker_task_count Number of tasks sent to this background worker, by outcome: completed, aborted (the worker let go of the task without completing it), abandoned (the sender gave up on the task first), timeout (no thread picked the task up in time) or cancelled (the wait for a thread ended on a restart or the shutdown) + # TYPE frankenphp_worker_task_count counter + frankenphp_worker_task_count{outcome="abandoned",worker="echo"} 1 + frankenphp_worker_task_count{outcome="aborted",worker="echo"} 1 + frankenphp_worker_task_count{outcome="completed",worker="echo"} 2 + frankenphp_worker_task_count{outcome="timeout",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{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{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) +} + +// complete() with a result is one event for the sender: one leaving right +// after it left a completed task, whatever the two threads do next +func TestTaskResultThenLeave(t *testing.T) { + registry := prometheus.NewRegistry() + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, + frankenphp.WithServer(server), + taskWorker("echo", "task-worker.php", nil, server), + frankenphp.WithNumThreads(2), + frankenphp.WithMetrics(frankenphp.NewPrometheusMetrics(registry)), + ) + + assert.Equal(t, "processed:1999", serverGet(t, server, "http://example.com/task-result.php?n=2000")) + + expected := ` + # HELP frankenphp_worker_task_count Number of tasks sent to this background worker, by outcome: completed, aborted (the worker let go of the task without completing it), abandoned (the sender gave up on the task first), timeout (no thread picked the task up in time) or cancelled (the wait for a thread ended on a restart or the shutdown) + # TYPE frankenphp_worker_task_count counter + frankenphp_worker_task_count{outcome="completed",worker="echo"} 2000 + ` + require.EventuallyWithT(t, func(c *assert.CollectT) { + assert.NoError(c, testutil.GatherAndCompare(registry, strings.NewReader(expected), "frankenphp_worker_task_count")) + }, 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), taskWorker("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") + registry := prometheus.NewRegistry() + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, + frankenphp.WithServer(server), + taskWorker("echo", "task-worker.php", nil, server), + frankenphp.WithNumThreads(2), + frankenphp.WithMetrics(frankenphp.NewPrometheusMetrics(registry)), + ) + + 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") + assert.Equal(t, 1.0, taskOutcomes(t, registry)["cancelled"], "the task the restart ended was not counted") + + // 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 +} + +// 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), + taskWorker("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: LogicException: 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, and a stream + // the script never took is not built past the end of the task + assert.Equal(t, `getPayload: {"input":"over"} +update: LogicException: the task is over +complete: false +complete-data: LogicException: the task is over +getStream: LogicException: the task is over`, requireFileContentEventually(t, sentinel)) +} + +// 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") + } + + 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.WithWorkerTasks("pool")), + frankenphp.WithNumThreads(3), + ) + + assert.Equal(t, `["processed:a","processed:b"] 0`, serverGet(t, server, "http://example.com/task-poll.php")) +} + +// the end of the task's stream comes after its updates, not ahead of them +func TestTaskStreamEndsBehindItsUpdates(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), taskWorker("echo", "task-worker.php", nil, server), frankenphp.WithNumThreads(2)) + + assert.JSONEq(t, `{"before":false,"updates":3,"after":true}`, serverGet(t, server, "http://example.com/task-feof.php")) +} + +// a context still watching a task that is over hears nothing of the tasks +// that come next +func TestTaskWatcherLeftBehind(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), taskWorker("echo", "task-worker.php", nil, server), frankenphp.WithNumThreads(2)) + + assert.Equal(t, "[] processed:next", serverGet(t, server, "http://example.com/task-stale-watcher.php")) + // and so does one left on a task abandoned with an update unread, which + // its worker still holds + assert.Equal(t, "1 ready, then 0", serverGet(t, server, "http://example.com/task-stale-update.php")) +} + +// a poller that keeps the descriptor of a task's stream is told of every +// update, not only of the first one +func TestTaskStreamInAPoller(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), taskWorker("echo", "task-worker.php", nil, server), frankenphp.WithNumThreads(2)) + + assert.Equal(t, "done after 5 events", serverGet(t, server, "http://example.com/task-stream-poll.php")) +} + +// one pool receives several task namespaces through one queue, and each task +// tells the namespace it was sent to +func TestTaskNamespaces(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, + frankenphp.WithServer(server), + frankenphp.WithWorkers("images", "testdata/bgworker/task-worker.php", 2, + frankenphp.WithWorkerBackground(), + frankenphp.WithWorkerServerScope(server), + frankenphp.WithWorkerTasks("image.resize", "image.thumbnail"), + ), + frankenphp.WithNumThreads(3), + ) + + resize := serverGet(t, server, "http://example.com/task.php?ns=image.resize&input=a") + assert.Contains(t, resize, `"namespace":"image.resize"`) + assert.Contains(t, resize, `"worker":"images"`) + assert.Contains(t, serverGet(t, server, "http://example.com/task.php?ns=image.thumbnail&input=b"), `"namespace":"image.thumbnail"`) + assert.Contains(t, serverGet(t, server, "http://example.com/task.php?ns=images"), "no background worker receives") +} + +func TestValidateTaskNamespaces(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + receiver := func(name string, extra ...frankenphp.WorkerOption) frankenphp.Option { + return frankenphp.WithWorkers(name, "testdata/bgworker/task-worker.php", 1, append([]frankenphp.WorkerOption{frankenphp.WithWorkerBackground()}, extra...)...) + } + + assert.ErrorContains(t, frankenphp.Validate( + receiver("one", frankenphp.WithWorkerTasks("jobs")), + receiver("two", frankenphp.WithWorkerTasks("mail", "jobs")), + ), `two global workers cannot receive the same task namespace: "jobs"`) + + assert.NoError(t, frankenphp.Validate( + frankenphp.WithServer(server), + receiver("one", frankenphp.WithWorkerTasks("jobs")), + receiver("two", frankenphp.WithWorkerTasks("jobs"), frankenphp.WithWorkerServerScope(server)), + )) + + // vars and tasks are separate addresses, one worker may hold both + assert.NoError(t, frankenphp.Validate( + receiver("one", frankenphp.WithWorkerTasks("jobs"), frankenphp.WithWorkerVars("jobs")), + )) + + assert.ErrorContains(t, frankenphp.Validate( + receiver("one", frankenphp.WithWorkerTasks("jobs", "jobs")), + ), `declares the task namespace "jobs" twice`) + + assert.ErrorContains(t, frankenphp.Validate( + frankenphp.WithWorkers("http", "testdata/worker.php", 1, frankenphp.WithWorkerTasks("jobs")), + ), "only background workers receive them") +} + +// taskOutcomes sums frankenphp_worker_task_count by outcome +func taskOutcomes(t *testing.T, registry *prometheus.Registry) map[string]float64 { + t.Helper() + families, err := registry.Gather() + require.NoError(t, err) + + outcomes := map[string]float64{} + for _, family := range families { + if family.GetName() != "frankenphp_worker_task_count" { + continue + } + for _, metric := range family.GetMetric() { + for _, label := range metric.GetLabel() { + if label.GetName() == "outcome" { + outcomes[label.GetValue()] += metric.GetCounter().GetValue() + } + } + } + } + + return outcomes +} + +// a handle sends one task: constructing it again throws, the first task goes on +func TestTaskHandleIsConstructedOnce(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), taskWorker("echo", "task-worker.php", nil, server), frankenphp.WithNumThreads(2)) + + body := serverGet(t, server, "http://example.com/task-reconstruct.php") + assert.Contains(t, body, `Error: FrankenPHP\SentTaskHandle::__construct() cannot be called twice`) + assert.Contains(t, body, `"result":"processed:first"`) +} + +// a server without a receiver of its own sends to the global one +func TestTaskFallsBackToAGlobalReceiver(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), taskWorker("echo", "task-worker.php", map[string]string{"BG_TAG": "global"}, nil), frankenphp.WithNumThreads(2)) + + assert.Contains(t, serverGet(t, server, "http://example.com/task.php?input=x"), `"tag":"global"`) +} + +// a worker with nothing else to wait on waits for its tasks in tick(): each +// one wakes it there, and so does the drain +func TestTaskTickWaits(t *testing.T) { + sentinel := filepath.Join(t.TempDir(), "drained.txt") + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + t.Cleanup(frankenphp.Shutdown) + require.NoError(t, frankenphp.Init( + frankenphp.WithServer(server), + taskWorker("echo", "task-wait-worker.php", map[string]string{"BG_SENTINEL": sentinel}, server), + frankenphp.WithNumThreads(2), + )) + + assert.Contains(t, serverGet(t, server, "http://example.com/task.php?input=hello"), `"result":"processed:hello"`) + // the thread sleeps between two tasks, and takes the next one + time.Sleep(50 * time.Millisecond) + assert.Contains(t, serverGet(t, server, "http://example.com/task.php?input=again"), `"result":"processed:again"`) + + done := make(chan struct{}) + go func() { + frankenphp.Shutdown() + close(done) + }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("Shutdown did not end the wait in tick() within 10s") + } + // no wait returned but for a task, the drain ended the last one + assert.Equal(t, "drained after 0 waits without a task\n", requireFileContentEventually(t, sentinel)) +} + +// with a timeout, tick() hands the loop back when no task comes +func TestTaskTickTimesOut(t *testing.T) { + const timeout = 20 * time.Millisecond + start := time.Now() + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, + frankenphp.WithServer(server), + taskWorker("echo", "task-wait-worker.php", map[string]string{"BG_TICK_TIMEOUT": "0.02"}, server), + frankenphp.WithNumThreads(2), + ) + + // each task is answered with the number of waits that ended without one + var answer struct { + Result string `json:"result"` + Idle int `json:"idle"` + } + require.Eventually(t, func() bool { + body := serverGet(t, server, "http://example.com/task.php?input=late") + + return json.Unmarshal([]byte(strings.SplitN(body, "\n", 2)[0]), &answer) == nil && answer.Idle >= 3 + }, 10*time.Second, 50*time.Millisecond, "tick() did not time out while no task came") + assert.Equal(t, "processed:late", answer.Result) + // no wait ends before its timeout: one at most per timeout elapsed + assert.LessOrEqual(t, answer.Idle, int(time.Since(start)/timeout)+1, "tick() returned before its timeout") +} + +// the threads of a pool waiting in tick() share the queue like those waiting +// on their streams, each task waking one of them +func TestTaskTickWaitsInAPool(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, + frankenphp.WithServer(server), + frankenphp.WithWorkers("pool", "testdata/bgworker/task-wait-worker.php", 2, frankenphp.WithWorkerBackground(), frankenphp.WithWorkerServerScope(server), frankenphp.WithWorkerTasks("pool")), + frankenphp.WithNumThreads(3), + ) + + assert.Equal(t, "[\"processed:a\",\"processed:b\"]\ntwo threads", serverGet(t, server, "http://example.com/task-pool.php")) + // and again, once both threads went back to sleep + time.Sleep(50 * time.Millisecond) + assert.Equal(t, "[\"processed:a\",\"processed:b\"]\ntwo threads", serverGet(t, server, "http://example.com/task-pool.php")) +} + +// queueRequest runs a scenario of task-queue.php against a worker of one +// thread, which the script keeps busy first +func queueRequest(t *testing.T, scenario string, registry *prometheus.Registry) string { + t.Helper() + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + options := []frankenphp.Option{frankenphp.WithServer(server), taskWorker("echo", "task-worker.php", nil, server), frankenphp.WithNumThreads(2)} + if registry != nil { + options = append(options, frankenphp.WithMetrics(frankenphp.NewPrometheusMetrics(registry))) + } + initServers(t, options...) + + dir := t.TempDir() + query := url.Values{"mark": {filepath.Join(dir, "picked")}, "never": {filepath.Join(dir, "never")}} + + return serverGet(t, server, "http://example.com/task-queue.php?case="+scenario+"&"+query.Encode()) +} + +// a task abandoned before a thread picked it up leaves the queue: the worker +// never sees it +func TestTaskAbandonedWhileQueued(t *testing.T) { + registry := prometheus.NewRegistry() + assert.Equal(t, "processed:after\nnever processed", queueRequest(t, "abandon", registry)) + + outcomes := taskOutcomes(t, registry) + assert.Equal(t, 1.0, outcomes["abandoned"]) + assert.Equal(t, 2.0, outcomes["completed"]) +} + +// the timeout for a pickup ends the wait of a sender on its stream too +func TestTaskPickupTimeoutOnAStream(t *testing.T) { + assert.Equal(t, "1 ready at the timeout\nFrankenPHP\\SentTaskHandle: no thread picked up the task sent to \"echo\" in time", queueRequest(t, "select", nil)) +} + +// a refusal stands: the task left the queue when its time was up, whether its +// sender waited on it or not, so a read() called later throws at once, +// whatever became of the worker meanwhile +func TestTaskRefusalIsFinal(t *testing.T) { + for _, scenario := range []string{"refused", "unwatched"} { + t.Run(scenario, func(t *testing.T) { + registry := prometheus.NewRegistry() + assert.Equal(t, "at once: FrankenPHP\\SentTaskHandle: no thread picked up the task sent to \"echo\" in time\nnever processed", queueRequest(t, scenario, registry)) + + outcomes := taskOutcomes(t, registry) + assert.Equal(t, 1.0, outcomes["timeout"]) + assert.Equal(t, 1.0, outcomes["completed"]) + }) + } +} + +// each task wakes a thread by itself, whatever the shape of the worker's loop: +// waiting on its stream, ticking once woken as the callback of an event loop +// does, which ends each wake-up on a receive() returning null, or waiting in +// tick(). The watcher of the queue wakes every thread for a task that waits, +// but only at its next look: most round trips are over long before +func TestTaskWakesAThreadByItself(t *testing.T) { + loops := map[string]frankenphp.Option{ + "stream": taskWorker("echo", "task-worker.php", nil, nil), + "callback": taskWorker("echo", "task-worker.php", map[string]string{"BG_LOOP": "callback"}, nil), + "tick": taskWorker("echo", "task-wait-worker.php", nil, nil), + } + for loop, worker := range loops { + t.Run(loop, func(t *testing.T) { + server, err := frankenphp.NewServer(testDataDir) + require.NoError(t, err) + initServers(t, frankenphp.WithServer(server), worker, frankenphp.WithNumThreads(2)) + + median, err := strconv.Atoi(serverGet(t, server, "http://example.com/task-latency.php")) + require.NoError(t, err) + assert.Less(t, median, 5000, "the tasks waited for the watcher of the queue to wake the worker") + }) + } +} diff --git a/workervars.go b/workervars.go index ac8437ab7b..a285df624a 100644 --- a/workervars.go +++ b/workervars.go @@ -213,8 +213,11 @@ func go_frankenphp_worker_namespaces(threadIndex C.uintptr_t, namespaces *C.zval worker := phpThreads[threadIndex].handler.(*backgroundWorkerThread).worker phpValueInto(AssociativeArray[any]{ - Map: map[string]any{"vars": namespaceList(worker.varsNamespaces)}, - Order: []string{"vars"}, + Map: map[string]any{ + "vars": namespaceList(worker.varsNamespaces), + "tasks": namespaceList(worker.taskNamespaces), + }, + Order: []string{"vars", "tasks"}, }, namespaces) } diff --git a/workervars_test.go b/workervars_test.go index 9d2cb73168..7a6ec7ab85 100644 --- a/workervars_test.go +++ b/workervars_test.go @@ -51,11 +51,12 @@ func TestVarsNamespaces(t *testing.T) { frankenphp.WithWorkerBackground(), frankenphp.WithWorkerServerScope(server), frankenphp.WithWorkerVars("redis", "feature_flags"), + frankenphp.WithWorkerTasks("jobs"), ), frankenphp.WithNumThreads(2), ) - all := `{"vars":["redis","feature_flags"]}` + all := `{"vars":["redis","feature_flags"],"tasks":["jobs"]}` assert.JSONEq(t, `{"namespace":"redis","all":`+all+`}`, serverGet(t, server, "http://example.com/vars.php?ns=redis")) assert.JSONEq(t, `{"namespace":"feature_flags","all":`+all+`}`, serverGet(t, server, "http://example.com/vars.php?ns=feature_flags")) assert.Contains(t, serverGet(t, server, "http://example.com/vars.php?ns=config"), "no background worker publishes") diff --git a/zval.h b/zval.h index 18a4ba6302..e82a6612d9 100644 --- a/zval.h +++ b/zval.h @@ -2,11 +2,11 @@ * * Provides a small, self-contained toolkit for moving zval trees across * thread boundaries. The supported shape is a whitelist: scalars, arrays, - * and enums. Everything else is rejected by persistent_zval_validate so + * and enums. Everything else is rejected by persistent_zval_invalid so * callers can fail fast before allocating. Nesting is also capped there * (PERSISTENT_ZVAL_MAX_DEPTH): persist/free/to_request below recurse once * per nesting level with no guard of their own, so every caller MUST run - * persistent_zval_validate first, on the full tree, before calling any of + * persistent_zval_invalid first, on the full tree, before calling any of * them - it's the only thing standing between attacker-controlled nesting * depth and a native stack overflow (crashes the whole process, not just * one request). @@ -120,14 +120,8 @@ static const char *persistent_zval_invalid(zval *z) { return persistent_zval_check(z, 0, &left); } -/* inline: the callers that want a reason use persistent_zval_invalid(), and a - * build may leave this one without any */ -static inline bool persistent_zval_validate(zval *z) { - return persistent_zval_invalid(z) == NULL; -} - /* Deep-copy a zval from request memory into persistent (pemalloc) memory. - * Callers must have already passed persistent_zval_validate on src. + * Callers must have already checked src with persistent_zval_invalid. * * Storage convention for enums: dst becomes IS_PTR holding a * persistent_zval_enum_t. This is an internal representation; the caller @@ -153,7 +147,7 @@ static void persistent_zval_persist(zval *dst, zval *src) { break; } case IS_OBJECT: { - /* Must be an enum (validated earlier in persistent_zval_validate). */ + /* Must be an enum (checked earlier by persistent_zval_invalid). */ zend_class_entry *ce = Z_OBJCE_P(src); persistent_zval_enum_t *e = pemalloc(sizeof(*e), 1); e->class_name = zend_string_init(ZSTR_VAL(ce->name), ZSTR_LEN(ce->name), 1); @@ -190,7 +184,7 @@ static void persistent_zval_persist(zval *dst, zval *src) { break; } default: - /* Unreachable: persistent_zval_validate is the gatekeeper. */ + /* Unreachable: persistent_zval_invalid is the gatekeeper. */ ZEND_UNREACHABLE(); } }