From 2f3d3c92133fedccffd331282e324c930d6edfdd Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 16 Oct 2025 00:01:39 +0200 Subject: [PATCH 1/8] Cleaner request apis. --- frankenphp.go | 2 +- options.go | 27 ++++++ testdata/message-worker.php | 8 ++ threadworker.go | 20 ++-- worker.go | 12 +-- workerextension.go | 179 ++++++++++-------------------------- workerextension_test.go | 90 +++++++++--------- 7 files changed, 145 insertions(+), 193 deletions(-) create mode 100644 testdata/message-worker.php diff --git a/frankenphp.go b/frankenphp.go index c801e7a95f..9d71da03e6 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -216,7 +216,7 @@ func Init(options ...Option) error { // add registered external workers for _, ew := range extensionWorkers { - options = append(options, WithWorkers(ew.Name(), ew.FileName(), ew.MinThreads(), WithWorkerEnv(ew.Env()))) + options = append(options, WithWorkers(ew.Name, ew.FileName, ew.Num, ew.options...)) } opt := &opt{} diff --git a/options.go b/options.go index 18c5ba20fd..d6a159665c 100644 --- a/options.go +++ b/options.go @@ -35,6 +35,9 @@ type workerOpt struct { env PreparedEnv watch []string maxConsecutiveFailures int + onReady func(int) + onShutdown func(int) + onServerShutdown func(int) } // WithNumThreads configures the number of PHP threads to start. @@ -116,6 +119,30 @@ func WithWorkerMaxFailures(maxFailures int) WorkerOption { } } +func WithWorkerOnReady(f func(int)) WorkerOption { + return func(w *workerOpt) error { + w.onReady = f + + return nil + } +} + +func WithWorkerOnShutdown(f func(int)) WorkerOption { + return func(w *workerOpt) error { + w.onShutdown = f + + return nil + } +} + +func WithWorkerOnServerShutdown(f func(int)) WorkerOption { + return func(w *workerOpt) error { + w.onServerShutdown = f + + return nil + } +} + // WithLogger configures the global logger to use. func WithLogger(l *slog.Logger) Option { return func(o *opt) error { diff --git a/testdata/message-worker.php b/testdata/message-worker.php new file mode 100644 index 0000000000..a73f9fa64c --- /dev/null +++ b/testdata/message-worker.php @@ -0,0 +1,8 @@ + Date: Thu, 16 Oct 2025 00:08:06 +0200 Subject: [PATCH 2/8] Allows response writers for messages. --- workerextension.go | 3 ++- workerextension_test.go | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/workerextension.go b/workerextension.go index a332d07954..08b63e3f7d 100644 --- a/workerextension.go +++ b/workerextension.go @@ -66,7 +66,7 @@ func (w Worker) SendRequest(rw http.ResponseWriter, r *http.Request) error { } // EXPERIMENTAL: SendMessage sends a message to the worker and waits for a response. -func (w Worker) SendMessage(message any) (any, error) { +func (w Worker) SendMessage(message any, rw http.ResponseWriter) (any, error) { internalWorker := getWorkerByName(w.Name) if internalWorker == nil { @@ -76,6 +76,7 @@ func (w Worker) SendMessage(message any) (any, error) { fc := newFrankenPHPContext() fc.logger = logger fc.worker = internalWorker + fc.responseWriter = rw fc.handlerParameters = message internalWorker.handleRequest(fc) diff --git a/workerextension_test.go b/workerextension_test.go index 8f48240e53..dc400599da 100644 --- a/workerextension_test.go +++ b/workerextension_test.go @@ -70,7 +70,7 @@ func TestWorkerExtensionSendMessage(t *testing.T) { require.NoError(t, err) defer Shutdown() - result, err := externalWorker.SendMessage("Hello Worker") + result, err := externalWorker.SendMessage("Hello Worker", nil) assert.NoError(t, err, "Sending request should not produce an error") switch v := result.(type) { From f720a9e08525005cd63059289e34197ce4bfb88b Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 16 Oct 2025 00:10:25 +0200 Subject: [PATCH 3/8] naming. --- workerextension_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/workerextension_test.go b/workerextension_test.go index dc400599da..ce54d3df67 100644 --- a/workerextension_test.go +++ b/workerextension_test.go @@ -71,12 +71,12 @@ func TestWorkerExtensionSendMessage(t *testing.T) { defer Shutdown() result, err := externalWorker.SendMessage("Hello Worker", nil) - assert.NoError(t, err, "Sending request should not produce an error") + assert.NoError(t, err, "Sending message should not produce an error") switch v := result.(type) { case string: assert.Equal(t, "received message: Hello Worker", v) default: - t.Fatalf("Expected result to be string or []byte, got %T", v) + t.Fatalf("Expected result to be string, got %T", v) } } From 2c3f3f2545330b28a3f6d4877809d44c10326df3 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 17 Oct 2025 00:11:44 +0200 Subject: [PATCH 4/8] Unexports vars. --- frankenphp.go | 2 +- workerextension.go | 24 ++++++++++++------------ workerextension_test.go | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index 9d71da03e6..ff4aa0ab40 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -216,7 +216,7 @@ func Init(options ...Option) error { // add registered external workers for _, ew := range extensionWorkers { - options = append(options, WithWorkers(ew.Name, ew.FileName, ew.Num, ew.options...)) + options = append(options, WithWorkers(ew.name, ew.fileName, ew.num, ew.options...)) } opt := &opt{} diff --git a/workerextension.go b/workerextension.go index 08b63e3f7d..acc0631895 100644 --- a/workerextension.go +++ b/workerextension.go @@ -25,9 +25,9 @@ import ( // allocated, then FrankenPHP will panic and provide this information to the user (who will need to allocate more // total threads). Don't be greedy. type Worker struct { - Name string - FileName string - Num int + name string + fileName string + num int options []WorkerOption } @@ -35,21 +35,21 @@ var extensionWorkers = make(map[string]Worker) // EXPERIMENTAL: RegisterWorker registers a custom worker script. func RegisterWorker(worker Worker) { - extensionWorkers[worker.Name] = worker + extensionWorkers[worker.name] = worker } // EXPERIMENTAL: SendRequest sends an HTTP request to the worker and writes the response to the provided ResponseWriter. func (w Worker) SendRequest(rw http.ResponseWriter, r *http.Request) error { - worker := getWorkerByName(w.Name) + worker := getWorkerByName(w.name) if worker == nil { - return errors.New("worker not found: " + w.Name) + return errors.New("worker not found: " + w.name) } fr, err := NewRequestWithContext( r, WithOriginalRequest(r), - WithWorkerName(w.Name), + WithWorkerName(w.name), ) if err != nil { @@ -67,10 +67,10 @@ func (w Worker) SendRequest(rw http.ResponseWriter, r *http.Request) error { // EXPERIMENTAL: SendMessage sends a message to the worker and waits for a response. func (w Worker) SendMessage(message any, rw http.ResponseWriter) (any, error) { - internalWorker := getWorkerByName(w.Name) + internalWorker := getWorkerByName(w.name) if internalWorker == nil { - return nil, errors.New("worker not found: " + w.Name) + return nil, errors.New("worker not found: " + w.name) } fc := newFrankenPHPContext() @@ -88,9 +88,9 @@ func (w Worker) SendMessage(message any, rw http.ResponseWriter) (any, error) { // The returned instance may be sufficient on its own for simple use cases. func NewWorker(name string, fileName string, num int, options ...WorkerOption) Worker { return Worker{ - Name: name, - FileName: fileName, - Num: num, + name: name, + fileName: fileName, + num: num, options: options, } } diff --git a/workerextension_test.go b/workerextension_test.go index ce54d3df67..96753830a6 100644 --- a/workerextension_test.go +++ b/workerextension_test.go @@ -28,7 +28,7 @@ func TestWorkerExtension(t *testing.T) { // Clean up external workers after test to avoid interfering with other tests defer func() { - delete(extensionWorkers, externalWorker.Name) + delete(extensionWorkers, externalWorker.name) }() err := Init() @@ -63,7 +63,7 @@ func TestWorkerExtensionSendMessage(t *testing.T) { // Clean up external workers after test to avoid interfering with other tests defer func() { - delete(extensionWorkers, externalWorker.Name) + delete(extensionWorkers, externalWorker.name) }() err := Init() From c80a0b4499ef7a35dc006e59aadedf04fa387ab3 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 26 Oct 2025 14:59:55 +0100 Subject: [PATCH 5/8] Suggestions by @withinboredom + better startup and shutdown hooks. --- frankenphp.go | 19 ++++++++++++++++++- options.go | 23 +++++++++++++++++------ threadworker.go | 16 ++++++++-------- worker.go | 10 ++++------ workerextension.go | 28 ++++++++++++++++++++++------ workerextension_test.go | 23 +++++++++++++++-------- 6 files changed, 84 insertions(+), 35 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index ff4aa0ab40..a5fbfc0ca3 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -52,7 +52,8 @@ var ( ErrScriptExecution = errors.New("error during PHP script execution") ErrNotRunning = errors.New("FrankenPHP is not running. For proper configuration visit: https://frankenphp.dev/docs/config/#caddyfile-config") - isRunning bool + isRunning bool + onServerShutdown []func() loggerMu sync.RWMutex logger *slog.Logger @@ -291,6 +292,17 @@ func Init(options ...Option) error { logger.LogAttrs(ctx, slog.LevelInfo, "embedded PHP app 📦", slog.String("path", EmbeddedAppPath)) } + // register the startup/shutdown hooks (mainly useful for extensions) + onServerShutdown = nil + for _, w := range opt.workers { + if w.onServerStartup != nil { + w.onServerStartup() + } + if w.onServerShutdown != nil { + onServerShutdown = append(onServerShutdown, w.onServerShutdown) + } + } + return nil } @@ -300,6 +312,11 @@ func Shutdown() { return } + // call the shutdown hooks (mainly useful for extensions) + for _, fn := range onServerShutdown { + fn() + } + drainWatcher() drainAutoScaling() drainPHPThreads() diff --git a/options.go b/options.go index d6a159665c..65fd5a374a 100644 --- a/options.go +++ b/options.go @@ -35,9 +35,10 @@ type workerOpt struct { env PreparedEnv watch []string maxConsecutiveFailures int - onReady func(int) - onShutdown func(int) - onServerShutdown func(int) + onThreadReady func(int) + onThreadShutdown func(int) + onServerStartup func() + onServerShutdown func() } // WithNumThreads configures the number of PHP threads to start. @@ -121,7 +122,7 @@ func WithWorkerMaxFailures(maxFailures int) WorkerOption { func WithWorkerOnReady(f func(int)) WorkerOption { return func(w *workerOpt) error { - w.onReady = f + w.onThreadReady = f return nil } @@ -129,13 +130,23 @@ func WithWorkerOnReady(f func(int)) WorkerOption { func WithWorkerOnShutdown(f func(int)) WorkerOption { return func(w *workerOpt) error { - w.onShutdown = f + w.onThreadShutdown = f return nil } } -func WithWorkerOnServerShutdown(f func(int)) WorkerOption { +// WithOnServerStartup adds a function to be called right after server startup. Useful for extensions. +func WithOnServerStartup(f func()) WorkerOption { + return func(w *workerOpt) error { + w.onServerStartup = f + + return nil + } +} + +// WithOnServerShutdown adds a function to be called right before server shutdown. Useful for extensions. +func WithOnServerShutdown(f func()) WorkerOption { return func(w *workerOpt) error { w.onServerShutdown = f diff --git a/threadworker.go b/threadworker.go index ba17da234b..17abb9073f 100644 --- a/threadworker.go +++ b/threadworker.go @@ -41,27 +41,27 @@ func convertToWorkerThread(thread *phpThread, worker *worker) { func (handler *workerThread) beforeScriptExecution() string { switch handler.state.get() { case stateTransitionRequested: - if handler.worker.onShutdown != nil { - handler.worker.onShutdown(handler.thread.threadIndex) + if handler.worker.onThreadShutdown != nil { + handler.worker.onThreadShutdown(handler.thread.threadIndex) } handler.worker.detachThread(handler.thread) return handler.thread.transitionToNewHandler() case stateRestarting: - if handler.worker.onShutdown != nil { - handler.worker.onShutdown(handler.thread.threadIndex) + if handler.worker.onThreadShutdown != nil { + handler.worker.onThreadShutdown(handler.thread.threadIndex) } handler.state.set(stateYielding) handler.state.waitFor(stateReady, stateShuttingDown) return handler.beforeScriptExecution() case stateReady, stateTransitionComplete: - if handler.worker.onReady != nil { - handler.worker.onReady(handler.thread.threadIndex) + if handler.worker.onThreadReady != nil { + handler.worker.onThreadReady(handler.thread.threadIndex) } setupWorkerScript(handler, handler.worker) return handler.worker.fileName case stateShuttingDown: - if handler.worker.onServerShutdown != nil { - handler.worker.onServerShutdown(handler.thread.threadIndex) + if handler.worker.onThreadShutdown != nil { + handler.worker.onThreadShutdown(handler.thread.threadIndex) } handler.worker.detachThread(handler.thread) // signal to stop diff --git a/worker.go b/worker.go index f639b01ef8..ee468bd6cd 100644 --- a/worker.go +++ b/worker.go @@ -23,9 +23,8 @@ type worker struct { threadMutex sync.RWMutex allowPathMatching bool maxConsecutiveFailures int - onReady func(int) - onShutdown func(int) - onServerShutdown func(int) + onThreadReady func(int) + onThreadShutdown func(int) } var ( @@ -128,9 +127,8 @@ func newWorker(o workerOpt) (*worker, error) { threads: make([]*phpThread, 0, o.num), allowPathMatching: allowPathMatching, maxConsecutiveFailures: o.maxConsecutiveFailures, - onReady: o.onReady, - onShutdown: o.onShutdown, - onServerShutdown: o.onServerShutdown, + onThreadReady: o.onThreadReady, + onThreadShutdown: o.onThreadShutdown, } return w, nil diff --git a/workerextension.go b/workerextension.go index acc0631895..f8290583a7 100644 --- a/workerextension.go +++ b/workerextension.go @@ -14,8 +14,8 @@ import ( // After the execution of frankenphp_handle_request(), the return value WorkerRequest.AfterFunc will be called, // with the optional return value of the callback passed as parameter. // -// A worker script with the provided Name and FileName will be registered, along with the provided -// configuration. You can also provide any environment variables that you want through Env. +// A worker script with the provided name, fileName and thread count will be registered, along with additional +// configuration through WorkerOptions. // // Name() and FileName() are only called once at startup, so register them in an init() function. // @@ -33,9 +33,16 @@ type Worker struct { var extensionWorkers = make(map[string]Worker) -// EXPERIMENTAL: RegisterWorker registers a custom worker script. -func RegisterWorker(worker Worker) { +// EXPERIMENTAL: RegisterWorker registers an external worker. +// external workers are booted together with regular workers on server startup. +func RegisterWorker(worker Worker) error { + if _, exists := extensionWorkers[worker.name]; exists { + return errors.New("worker with this name is already registered: " + worker.name) + } + extensionWorkers[worker.name] = worker + + return nil } // EXPERIMENTAL: SendRequest sends an HTTP request to the worker and writes the response to the provided ResponseWriter. @@ -65,6 +72,16 @@ func (w Worker) SendRequest(rw http.ResponseWriter, r *http.Request) error { return nil } +func (w Worker) NumThreads() int { + worker := getWorkerByName(w.name) + + if worker == nil { + return 0 + } + + return worker.countThreads() +} + // EXPERIMENTAL: SendMessage sends a message to the worker and waits for a response. func (w Worker) SendMessage(message any, rw http.ResponseWriter) (any, error) { internalWorker := getWorkerByName(w.name) @@ -84,8 +101,7 @@ func (w Worker) SendMessage(message any, rw http.ResponseWriter) (any, error) { return fc.handlerReturn, nil } -// EXPERIMENTAL: NewWorker creates a Worker instance to embed in a custom struct implementing the Worker interface. -// The returned instance may be sufficient on its own for simple use cases. +// EXPERIMENTAL: NewWorker registers an external worker with the given options func NewWorker(name string, fileName string, num int, options ...WorkerOption) Worker { return Worker{ name: name, diff --git a/workerextension_test.go b/workerextension_test.go index 96753830a6..6637dcd00c 100644 --- a/workerextension_test.go +++ b/workerextension_test.go @@ -11,6 +11,8 @@ import ( func TestWorkerExtension(t *testing.T) { readyWorkers := 0 + shutdownWorkers := 0 + serverStarts := 0 serverShutDowns := 0 externalWorker := NewWorker( @@ -20,25 +22,30 @@ func TestWorkerExtension(t *testing.T) { WithWorkerOnReady(func(id int) { readyWorkers++ }), - WithWorkerOnServerShutdown(func(id int) { + WithWorkerOnShutdown(func(id int) { serverShutDowns++ }), + WithOnServerStartup(func() { + serverStarts++ + }), + WithOnServerShutdown(func() { + shutdownWorkers++ + }), ) RegisterWorker(externalWorker) - // Clean up external workers after test to avoid interfering with other tests + require.NoError(t, Init()) defer func() { + // Clean up external workers after test to avoid interfering with other tests delete(extensionWorkers, externalWorker.name) - }() - - err := Init() - require.NoError(t, err) - defer func() { Shutdown() + assert.Equal(t, 1, shutdownWorkers, "Worker shutdown hook should have been called") assert.Equal(t, 1, serverShutDowns, "Server shutdown hook should have been called") }() assert.Equal(t, readyWorkers, 1, "Worker thread should have called onReady()") + assert.Equal(t, serverStarts, 1, "Server start hook should have been called") + assert.Equal(t, externalWorker.NumThreads(), 1, "NumThreads() should report 1 thread") // Create a test request req := httptest.NewRequest("GET", "https://example.com/test/?foo=bar", nil) @@ -46,7 +53,7 @@ func TestWorkerExtension(t *testing.T) { w := httptest.NewRecorder() // Inject the request into the worker through the extension - err = externalWorker.SendRequest(w, req) + err := externalWorker.SendRequest(w, req) assert.NoError(t, err, "Sending request should not produce an error") resp := w.Result() From a467962b498d499c7ab9e8e165ac2eb28d8e4730 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 26 Oct 2025 15:05:10 +0100 Subject: [PATCH 6/8] Adds registration error test. --- workerextension_test.go | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/workerextension_test.go b/workerextension_test.go index 6637dcd00c..131ef7bc92 100644 --- a/workerextension_test.go +++ b/workerextension_test.go @@ -32,7 +32,8 @@ func TestWorkerExtension(t *testing.T) { shutdownWorkers++ }), ) - RegisterWorker(externalWorker) + + assert.NoError(t, RegisterWorker(externalWorker)) require.NoError(t, Init()) defer func() { @@ -66,7 +67,7 @@ func TestWorkerExtension(t *testing.T) { func TestWorkerExtensionSendMessage(t *testing.T) { externalWorker := NewWorker("externalWorker", "testdata/message-worker.php", 1) - RegisterWorker(externalWorker) + assert.NoError(t, RegisterWorker(externalWorker)) // Clean up external workers after test to avoid interfering with other tests defer func() { @@ -87,3 +88,16 @@ func TestWorkerExtensionSendMessage(t *testing.T) { t.Fatalf("Expected result to be string, got %T", v) } } + +func TestErrorIf2WorkersHaveSameName(t *testing.T) { + w := NewWorker("duplicateWorker", "testdata/worker.php", 1) + w2 := NewWorker("duplicateWorker", "testdata/worker2.php", 1) + + err := RegisterWorker(w) + require.NoError(t, err, "First registration should succeed") + + err = RegisterWorker(w2) + require.Error(t, err, "Second registration with duplicate name should fail") + // Clean up external workers after test to avoid interfering with other tests + extensionWorkers = make(map[string]Worker) +} From 7247c1c2013e1e434f383f68ef10d4b2a5083da5 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 26 Oct 2025 15:55:25 +0100 Subject: [PATCH 7/8] Small adjustments. --- options.go | 8 ++++---- workerextension_test.go | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/options.go b/options.go index 65fd5a374a..befe3a7fb8 100644 --- a/options.go +++ b/options.go @@ -136,8 +136,8 @@ func WithWorkerOnShutdown(f func(int)) WorkerOption { } } -// WithOnServerStartup adds a function to be called right after server startup. Useful for extensions. -func WithOnServerStartup(f func()) WorkerOption { +// WithWorkerOnServerStartup adds a function to be called right after server startup. Useful for extensions. +func WithWorkerOnServerStartup(f func()) WorkerOption { return func(w *workerOpt) error { w.onServerStartup = f @@ -145,8 +145,8 @@ func WithOnServerStartup(f func()) WorkerOption { } } -// WithOnServerShutdown adds a function to be called right before server shutdown. Useful for extensions. -func WithOnServerShutdown(f func()) WorkerOption { +// WithWorkerOnServerShutdown adds a function to be called right before server shutdown. Useful for extensions. +func WithWorkerOnServerShutdown(f func()) WorkerOption { return func(w *workerOpt) error { w.onServerShutdown = f diff --git a/workerextension_test.go b/workerextension_test.go index 131ef7bc92..cddbb854ea 100644 --- a/workerextension_test.go +++ b/workerextension_test.go @@ -25,10 +25,10 @@ func TestWorkerExtension(t *testing.T) { WithWorkerOnShutdown(func(id int) { serverShutDowns++ }), - WithOnServerStartup(func() { + WithWorkerOnServerStartup(func() { serverStarts++ }), - WithOnServerShutdown(func() { + WithWorkerOnServerShutdown(func() { shutdownWorkers++ }), ) @@ -63,6 +63,7 @@ func TestWorkerExtension(t *testing.T) { // The worker.php script should output information about the request // We're just checking that we got a response, not the specific content assert.NotEmpty(t, body, "Response body should not be empty") + assert.Contains(t, string(body), "Requests handled: 0", "Response body should contain request information") } func TestWorkerExtensionSendMessage(t *testing.T) { From 7e40fa822d3ffdcc2aabdcf111fd6499f99f8a36 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 26 Oct 2025 16:00:18 +0100 Subject: [PATCH 8/8] Small adjustments. --- workerextension.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/workerextension.go b/workerextension.go index f8290583a7..743cc7f1d8 100644 --- a/workerextension.go +++ b/workerextension.go @@ -17,8 +17,6 @@ import ( // A worker script with the provided name, fileName and thread count will be registered, along with additional // configuration through WorkerOptions. // -// Name() and FileName() are only called once at startup, so register them in an init() function. -// // Workers are designed to run indefinitely and will be gracefully shut down when FrankenPHP shuts down. // // Extension workers receive the lowest priority when determining thread allocations. If MinThreads cannot be