From d852f1cd4b17add9b1d0b85aff196b81a6fb378f Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 9 Sep 2025 22:26:47 +0200 Subject: [PATCH 01/45] test123 --- threadtasks.go | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 threadtasks.go diff --git a/threadtasks.go b/threadtasks.go new file mode 100644 index 000000000..d81c55535 --- /dev/null +++ b/threadtasks.go @@ -0,0 +1,90 @@ +package frankenphp + +import ( + "sync" +) + +// representation of a thread that handles tasks directly assigned by go +// implements the threadHandler interface +type taskThread struct { + thread *phpThread + execChan chan *task +} + +// task callbacks will be executed directly on the PHP thread +// therefore having full access to the PHP runtime +type task struct { + callback func() + done sync.Mutex +} + +func newTask(cb func()) *task { + t := &task{callback: cb} + t.done.Lock() + + return t +} + +func (t *task) waitForCompletion() { + t.done.Lock() +} + +func convertToTaskThread(thread *phpThread) *taskThread { + handler := &taskThread{ + thread: thread, + execChan: make(chan *task), + } + thread.setHandler(handler) + return handler +} + +func (handler *taskThread) beforeScriptExecution() string { + thread := handler.thread + + switch thread.state.get() { + case stateTransitionRequested: + return thread.transitionToNewHandler() + case stateBooting, stateTransitionComplete: + thread.state.set(stateReady) + handler.waitForTasks() + + return handler.beforeScriptExecution() + case stateReady: + handler.waitForTasks() + + return handler.beforeScriptExecution() + case stateShuttingDown: + // signal to stop + return "" + } + panic("unexpected state: " + thread.state.name()) +} + +func (handler *taskThread) afterScriptExecution(int) { + panic("task threads should not execute scripts") +} + +func (handler *taskThread) getRequestContext() *frankenPHPContext { + return nil +} + +func (handler *taskThread) name() string { + return "Task PHP Thread" +} + +func (handler *taskThread) waitForTasks() { + for { + select { + case task := <-handler.execChan: + task.callback() + task.done.Unlock() // unlock the task to signal completion + case <-handler.thread.drainChan: + // thread is shutting down, do not execute the function + return + } + } +} + +func (handler *taskThread) execute(t *task) { + handler.execChan <- t +} From abdb279bd385ab41ccef8e5c095f4b86c18e85e6 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 14 Sep 2025 23:45:40 +0200 Subject: [PATCH 02/45] Initial testing. --- frankenphp.c | 64 ++++++++++++++ frankenphp.go | 2 + frankenphp.stub.php | 4 + frankenphp_arginfo.h | 14 ++++ testdata/task_worker.php | 13 +++ testdata/worker-with-counter.php | 5 ++ threadtasks.go | 90 -------------------- threadtaskworker.go | 138 +++++++++++++++++++++++++++++++ 8 files changed, 240 insertions(+), 90 deletions(-) create mode 100644 testdata/task_worker.php delete mode 100644 threadtasks.go create mode 100644 threadtaskworker.go diff --git a/frankenphp.c b/frankenphp.c index 3e516ffd4..346ba59e7 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -472,6 +472,70 @@ PHP_FUNCTION(frankenphp_handle_request) { RETURN_TRUE; } +PHP_FUNCTION(frankenphp_handle_task) { + zend_fcall_info fci; + zend_fcall_info_cache fcc; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_FUNC(fci, fcc) + ZEND_PARSE_PARAMETERS_END(); + + /*if (!is_worker_thread) { + zend_throw_exception( + spl_ce_RuntimeException, + "frankenphp_handle_task() called while not in worker mode", 0); + RETURN_THROWS(); + }*/ + +#ifdef ZEND_MAX_EXECUTION_TIMERS + /* Disable timeouts while waiting for a task to handle */ + zend_unset_timeout(); +#endif + + char *task = go_frankenphp_worker_handle_task(thread_index); + if (task == NULL) { + RETURN_FALSE; + } + + /* Call the PHP func passed to frankenphp_handle_request() */ + zval retval = {0}; + fci.size = sizeof fci; + fci.retval = &retval; + zval taskzv; + ZVAL_STRINGL(&taskzv, task, strlen(task)); + fci.params = &taskzv; + fci.param_count = 1; + if (zend_call_function(&fci, &fcc) == SUCCESS) { + zval_ptr_dtor(&retval); + } + + /* + * If an exception occurred, print the message to the client before + * closing the connection and bailout. + */ + if (EG(exception) && !zend_is_unwind_exit(EG(exception)) && + !zend_is_graceful_exit(EG(exception))) { + zend_exception_error(EG(exception), E_ERROR); + zend_bailout(); + } + + RETURN_TRUE; +} + + +PHP_FUNCTION(frankenphp_dispatch_task) { + char *taskString; + size_t task_len; + + ZEND_PARSE_PARAMETERS_START(1, 1); + Z_PARAM_STRING(taskString, task_len); + ZEND_PARSE_PARAMETERS_END(); + + go_frankenphp_worker_dispatch_task(0, taskString, task_len); + + RETURN_TRUE; +} + PHP_FUNCTION(headers_send) { zend_long response_code = 200; diff --git a/frankenphp.go b/frankenphp.go index 58cd95b89..c63773af1 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -278,6 +278,8 @@ func Init(options ...Option) error { return err } + initTaskWorkers() + initAutoScaling(mainThread) ctx := context.Background() diff --git a/frankenphp.stub.php b/frankenphp.stub.php index 6c5a71cb5..bc35240fa 100644 --- a/frankenphp.stub.php +++ b/frankenphp.stub.php @@ -4,6 +4,10 @@ function frankenphp_handle_request(callable $callback): bool {} +function frankenphp_handle_task(callable $callback): bool {} + +function frankenphp_dispatch_task(string $task): bool {} + function headers_send(int $status = 200): int {} function frankenphp_finish_request(): bool {} diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index c1bd7b550..a590d3d1c 100644 --- a/frankenphp_arginfo.h +++ b/frankenphp_arginfo.h @@ -6,6 +6,16 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_handle_request, 0, 1, ZEND_ARG_TYPE_INFO(0, callback, IS_CALLABLE, 0) ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_handle_task, 0, 1, + _IS_BOOL, 0) +ZEND_ARG_TYPE_INFO(0, callback, IS_CALLABLE, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_dispatch_task, 0, 1, + _IS_BOOL, 0) +ZEND_ARG_TYPE_INFO(0, task, IS_STRING, 0) +ZEND_END_ARG_INFO() + ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_headers_send, 0, 0, IS_LONG, 0) ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, status, IS_LONG, 0, "200") ZEND_END_ARG_INFO() @@ -31,6 +41,8 @@ ZEND_END_ARG_INFO() #define arginfo_apache_response_headers arginfo_frankenphp_response_headers ZEND_FUNCTION(frankenphp_handle_request); +ZEND_FUNCTION(frankenphp_handle_task); +ZEND_FUNCTION(frankenphp_dispatch_task); ZEND_FUNCTION(headers_send); ZEND_FUNCTION(frankenphp_finish_request); ZEND_FUNCTION(frankenphp_request_headers); @@ -39,6 +51,8 @@ ZEND_FUNCTION(frankenphp_response_headers); // clang-format off static const zend_function_entry ext_functions[] = { ZEND_FE(frankenphp_handle_request, arginfo_frankenphp_handle_request) + ZEND_FE(frankenphp_handle_task, arginfo_frankenphp_handle_task) + ZEND_FE(frankenphp_dispatch_task, arginfo_frankenphp_dispatch_task) ZEND_FE(headers_send, arginfo_headers_send) ZEND_FE(frankenphp_finish_request, arginfo_frankenphp_finish_request) ZEND_FALIAS(fastcgi_finish_request, frankenphp_finish_request, arginfo_fastcgi_finish_request) diff --git a/testdata/task_worker.php b/testdata/task_worker.php new file mode 100644 index 000000000..64d8ac233 --- /dev/null +++ b/testdata/task_worker.php @@ -0,0 +1,13 @@ + Date: Sun, 14 Sep 2025 23:46:53 +0200 Subject: [PATCH 03/45] FOrmatting. --- frankenphp.c | 1 - threadtaskworker.go | 42 +++++++++++++++++++++--------------------- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 346ba59e7..e51aac115 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -522,7 +522,6 @@ PHP_FUNCTION(frankenphp_handle_task) { RETURN_TRUE; } - PHP_FUNCTION(frankenphp_dispatch_task) { char *taskString; size_t task_len; diff --git a/threadtaskworker.go b/threadtaskworker.go index b4b78f69f..fd520fc35 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -3,23 +3,23 @@ package frankenphp // #include "frankenphp.h" import "C" import ( - "sync" "path/filepath" + "sync" ) -type taskWorker struct{ - threads []*phpThread - mu sync.Mutex +type taskWorker struct { + threads []*phpThread + mu sync.Mutex filename string taskChan chan string - name string + name string } // representation of a thread that handles tasks directly assigned by go // implements the threadHandler interface type taskWorkerThread struct { - thread *phpThread - taskWorker *taskWorker + thread *phpThread + taskWorker *taskWorker dummyContext *frankenPHPContext } @@ -28,11 +28,11 @@ var taskWorkers []*taskWorker func initTaskWorkers() { taskWorkers = []*taskWorker{} tw := &taskWorker{ - threads: []*phpThread{}, - filename: "/go/src/app/testdata/task_worker.php", - taskChan: make(chan string), - name: "Default Task Worker", - } + threads: []*phpThread{}, + filename: "/go/src/app/testdata/task_worker.php", + taskChan: make(chan string), + name: "Default Task Worker", + } taskWorkers = append(taskWorkers, tw) @@ -41,7 +41,7 @@ func initTaskWorkers() { func convertToTaskWorkerThread(thread *phpThread, tw *taskWorker) *taskWorkerThread { handler := &taskWorkerThread{ - thread: thread, + thread: thread, taskWorker: tw, } thread.setHandler(handler) @@ -76,7 +76,7 @@ func (handler *taskWorkerThread) beforeScriptExecution() string { } func (handler *taskWorkerThread) setupWorkerScript() string { - fc, err := newDummyContext( filepath.Base(handler.taskWorker.filename) ) + fc, err := newDummyContext(filepath.Base(handler.taskWorker.filename)) if err != nil { panic(err) @@ -120,17 +120,17 @@ func go_frankenphp_worker_handle_task(threadIndex C.uintptr_t) *C.char { } select { - case taskString := <-handler.taskWorker.taskChan: - return thread.pinCString(taskString) - case <-handler.thread.drainChan: - // thread is shutting down, do not execute the function - return nil - } + case taskString := <-handler.taskWorker.taskChan: + return thread.pinCString(taskString) + case <-handler.thread.drainChan: + // thread is shutting down, do not execute the function + return nil + } } //export go_frankenphp_worker_dispatch_task func go_frankenphp_worker_dispatch_task(taskWorkerIndex C.uintptr_t, taskString *C.char, taskLen C.size_t) C.bool { - go func(){ + go func() { taskWorkers[taskWorkerIndex].taskChan <- C.GoStringN(taskString, C.int(taskLen)) }() From 65e11372c1d03d7143f1a6d2a34712c800aa80ee Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 14 Sep 2025 23:50:48 +0200 Subject: [PATCH 04/45] test --- testdata/task_dispatching.php | 6 ++++++ testdata/worker-with-counter.php | 5 ----- 2 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 testdata/task_dispatching.php diff --git a/testdata/task_dispatching.php b/testdata/task_dispatching.php new file mode 100644 index 000000000..244a04707 --- /dev/null +++ b/testdata/task_dispatching.php @@ -0,0 +1,6 @@ + Date: Mon, 15 Sep 2025 00:21:58 +0200 Subject: [PATCH 05/45] test --- testdata/task_worker.php | 1 - threadtaskworker.go | 20 ++++++-------------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/testdata/task_worker.php b/testdata/task_worker.php index 64d8ac233..3a130b2c2 100644 --- a/testdata/task_worker.php +++ b/testdata/task_worker.php @@ -1,7 +1,6 @@ = 2 + num_threads + convertToTaskWorkerThread(getInactivePHPThread(), tw) convertToTaskWorkerThread(getInactivePHPThread(), tw) } @@ -61,11 +63,9 @@ func (handler *taskWorkerThread) beforeScriptExecution() string { return thread.transitionToNewHandler() case stateBooting, stateTransitionComplete: thread.state.set(stateReady) - handler.waitForTasks() return handler.setupWorkerScript() case stateReady: - handler.waitForTasks() return handler.setupWorkerScript() case stateShuttingDown: @@ -99,18 +99,6 @@ func (handler *taskWorkerThread) name() string { return "Task PHP Thread" } -func (handler *taskWorkerThread) waitForTasks() *C.char { - for { - select { - case taskString := <-handler.taskWorker.taskChan: - return handler.thread.pinCString(taskString) - case <-handler.thread.drainChan: - // thread is shutting down, do not execute the function - return nil - } - } -} - //export go_frankenphp_worker_handle_task func go_frankenphp_worker_handle_task(threadIndex C.uintptr_t) *C.char { thread := phpThreads[threadIndex] @@ -119,6 +107,10 @@ func go_frankenphp_worker_handle_task(threadIndex C.uintptr_t) *C.char { panic("thread is not a task thread") } + if !thread.state.is(stateReady) { + thread.state.set(stateReady) + } + select { case taskString := <-handler.taskWorker.taskChan: return thread.pinCString(taskString) From a102da8171bfaa9105b18fb3a835d1d7cc277280 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 16 Sep 2025 18:24:33 +0200 Subject: [PATCH 06/45] Adds tests and optimizations. --- caddy/app.go | 17 ++++ caddy/taskworkerconfig.go | 89 ++++++++++++++++++++ frankenphp.c | 41 ++++++--- frankenphp.go | 8 +- frankenphp.stub.php | 2 +- frankenphp_arginfo.h | 1 + options.go | 15 +++- testdata/task_dispatching.php | 6 -- testdata/task_worker.php | 12 --- testdata/tasks/task-dispatcher.php | 9 ++ testdata/tasks/task-worker.php | 12 +++ threadtaskworker.go | 129 ++++++++++++++++++++++++----- threadtaskworker_test.go | 46 ++++++++++ 13 files changed, 336 insertions(+), 51 deletions(-) create mode 100644 caddy/taskworkerconfig.go delete mode 100644 testdata/task_dispatching.php delete mode 100644 testdata/task_worker.php create mode 100644 testdata/tasks/task-dispatcher.php create mode 100644 testdata/tasks/task-worker.php create mode 100644 threadtaskworker_test.go diff --git a/caddy/app.go b/caddy/app.go index 01831c533..0315b4b09 100644 --- a/caddy/app.go +++ b/caddy/app.go @@ -33,6 +33,8 @@ type FrankenPHPApp struct { MaxThreads int `json:"max_threads,omitempty"` // Workers configures the worker scripts to start. Workers []workerConfig `json:"workers,omitempty"` + // TaskWorkers configures the task worker scripts to start. + TaskWorkers []taskWorkerConfig `json:"task_workers,omitempty"` // Overwrites the default php ini configuration PhpIni map[string]string `json:"php_ini,omitempty"` // The maximum amount of time a request may be stalled waiting for a thread @@ -127,6 +129,14 @@ func (f *FrankenPHPApp) Start() error { opts = append(opts, frankenphp.WithWorkers(w.Name, repl.ReplaceKnown(w.FileName, ""), w.Num, workerOpts...)) } + for _, tw := range f.TaskWorkers { + workerOpts := []frankenphp.WorkerOption{ + frankenphp.WithWorkerEnv(tw.Env), + frankenphp.WithTaskWorkerMode(true), + } + + opts = append(opts, frankenphp.WithWorkers(tw.Name, repl.ReplaceKnown(tw.FileName, ""), tw.Num, workerOpts...)) + } frankenphp.Shutdown() if err := frankenphp.Init(opts...); err != nil { @@ -234,6 +244,13 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } } + case "task_worker": + twc, err := parseTaskWorkerConfig(d) + if err != nil { + return err + } + f.TaskWorkers = append(f.TaskWorkers, twc) + case "worker": wc, err := parseWorkerConfig(d) if err != nil { diff --git a/caddy/taskworkerconfig.go b/caddy/taskworkerconfig.go new file mode 100644 index 000000000..fdef6da8c --- /dev/null +++ b/caddy/taskworkerconfig.go @@ -0,0 +1,89 @@ +package caddy + +import ( + "errors" + "path/filepath" + "strconv" + + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" + "github.com/dunglas/frankenphp" +) + +// taskWorkerConfig represents the "task_worker" directive in the Caddyfile +// +// frankenphp { +// task_worker { +// name "my-worker" +// file "my-worker.php" +// } +// } +type taskWorkerConfig struct { + // Name for the worker. Default: the filename for FrankenPHPApp workers, always prefixed with "m#" for FrankenPHPModule workers. + Name string `json:"name,omitempty"` + // FileName sets the path to the worker script. + FileName string `json:"file_name,omitempty"` + // Num sets the number of workers to start. + Num int `json:"num,omitempty"` + // Env sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables. + Env map[string]string `json:"env,omitempty"` +} + +func parseTaskWorkerConfig(d *caddyfile.Dispenser) (taskWorkerConfig, error) { + wc := taskWorkerConfig{} + if d.NextArg() { + wc.FileName = d.Val() + } + + if d.NextArg() { + return wc, errors.New(`FrankenPHP: too many "task_worker" arguments: ` + d.Val()) + } + + for d.NextBlock(1) { + v := d.Val() + switch v { + case "name": + if !d.NextArg() { + return wc, d.ArgErr() + } + wc.Name = d.Val() + case "file": + if !d.NextArg() { + return wc, d.ArgErr() + } + wc.FileName = d.Val() + case "num": + if !d.NextArg() { + return wc, d.ArgErr() + } + + v, err := strconv.ParseUint(d.Val(), 10, 32) + if err != nil { + return wc, err + } + + wc.Num = int(v) + case "env": + args := d.RemainingArgs() + if len(args) != 2 { + return wc, d.ArgErr() + } + if wc.Env == nil { + wc.Env = make(map[string]string) + } + wc.Env[args[0]] = args[1] + default: + allowedDirectives := "name, file, num, env" + return wc, wrongSubDirectiveError("worker", allowedDirectives, v) + } + } + + if wc.FileName == "" { + return wc, errors.New(`the "file" argument for "task_worker" must be specified`) + } + + if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) { + wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName) + } + + return wc, nil +} diff --git a/frankenphp.c b/frankenphp.c index e51aac115..5027f51c6 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -480,29 +480,31 @@ PHP_FUNCTION(frankenphp_handle_task) { Z_PARAM_FUNC(fci, fcc) ZEND_PARSE_PARAMETERS_END(); - /*if (!is_worker_thread) { + if (!go_is_task_worker_thread(thread_index)) { zend_throw_exception( spl_ce_RuntimeException, "frankenphp_handle_task() called while not in worker mode", 0); RETURN_THROWS(); - }*/ + } #ifdef ZEND_MAX_EXECUTION_TIMERS /* Disable timeouts while waiting for a task to handle */ zend_unset_timeout(); #endif - char *task = go_frankenphp_worker_handle_task(thread_index); - if (task == NULL) { + go_string task = go_frankenphp_worker_handle_task(thread_index); + if (task.data == NULL) { RETURN_FALSE; } - /* Call the PHP func passed to frankenphp_handle_request() */ + /* Call the PHP func passed to frankenphp_handle_task() */ zval retval = {0}; fci.size = sizeof fci; fci.retval = &retval; + + /* ZVAL_STRINGL_FAST will consume the string without c */ zval taskzv; - ZVAL_STRINGL(&taskzv, task, strlen(task)); + ZVAL_STRINGL_FAST(&taskzv, task.data, task.len); fci.params = &taskzv; fci.param_count = 1; if (zend_call_function(&fci, &fcc) == SUCCESS) { @@ -511,7 +513,7 @@ PHP_FUNCTION(frankenphp_handle_task) { /* * If an exception occurred, print the message to the client before - * closing the connection and bailout. + * exiting */ if (EG(exception) && !zend_is_unwind_exit(EG(exception)) && !zend_is_graceful_exit(EG(exception))) { @@ -519,18 +521,35 @@ PHP_FUNCTION(frankenphp_handle_task) { zend_bailout(); } + zend_try { php_output_end_all(); } + zend_end_try(); + + go_frankenphp_finish_task(thread_index); + + /* free the task string allocated in frankenphp_dispatch_task() */ + pefree(task.data, 1); + zval_ptr_dtor(&taskzv); + RETURN_TRUE; } PHP_FUNCTION(frankenphp_dispatch_task) { - char *taskString; + char *task_string; size_t task_len; + char *worker_name = NULL; + size_t worker_name_len = 0; - ZEND_PARSE_PARAMETERS_START(1, 1); - Z_PARAM_STRING(taskString, task_len); + ZEND_PARSE_PARAMETERS_START(1, 2); + Z_PARAM_STRING(task_string, task_len); + Z_PARAM_OPTIONAL + Z_PARAM_STRING(worker_name, worker_name_len); ZEND_PARSE_PARAMETERS_END(); - go_frankenphp_worker_dispatch_task(0, taskString, task_len); + /* copy the task string so other threads can use it */ + char *task_copy = pemalloc(task_len, 1); /* free in frankenphp_handle_task() */ + memcpy(task_copy, task_string, task_len); + + go_frankenphp_worker_dispatch_task(0, task_copy, task_len, worker_name, worker_name_len); RETURN_TRUE; } diff --git a/frankenphp.go b/frankenphp.go index c63773af1..b8475b7f0 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -151,6 +151,10 @@ func calculateMaxThreads(opt *opt) (int, int, int, error) { numWorkers += opt.workers[i].num } + for _, tw := range opt.taskWorkers { + numWorkers += tw.num + } + numThreadsIsSet := opt.numThreads > 0 maxThreadsIsSet := opt.maxThreads != 0 maxThreadsIsAuto := opt.maxThreads < 0 // maxthreads < 0 signifies auto mode (see phpmaintread.go) @@ -278,7 +282,9 @@ func Init(options ...Option) error { return err } - initTaskWorkers() + if err := initTaskWorkers(opt.taskWorkers); err != nil { + return err + } initAutoScaling(mainThread) diff --git a/frankenphp.stub.php b/frankenphp.stub.php index bc35240fa..f5da552bd 100644 --- a/frankenphp.stub.php +++ b/frankenphp.stub.php @@ -6,7 +6,7 @@ function frankenphp_handle_request(callable $callback): bool {} function frankenphp_handle_task(callable $callback): bool {} -function frankenphp_dispatch_task(string $task): bool {} +function frankenphp_dispatch_task(string $task, ?string $workerName = null): bool {} function headers_send(int $status = 200): int {} diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index a590d3d1c..6c2e056e9 100644 --- a/frankenphp_arginfo.h +++ b/frankenphp_arginfo.h @@ -14,6 +14,7 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_dispatch_task, 0, 1, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, task, IS_STRING, 0) +ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, worker_name, IS_STRING, 0, "null") ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_headers_send, 0, 0, IS_LONG, 0) diff --git a/options.go b/options.go index 18c5ba20f..c3d0583ac 100644 --- a/options.go +++ b/options.go @@ -22,6 +22,7 @@ type opt struct { numThreads int maxThreads int workers []workerOpt + taskWorkers []workerOpt logger *slog.Logger metrics Metrics phpIni map[string]string @@ -35,6 +36,7 @@ type workerOpt struct { env PreparedEnv watch []string maxConsecutiveFailures int + isTaskWorker bool } // WithNumThreads configures the number of PHP threads to start. @@ -80,7 +82,11 @@ func WithWorkers(name string, fileName string, num int, options ...WorkerOption) } } - o.workers = append(o.workers, worker) + if worker.isTaskWorker { + o.taskWorkers = append(o.taskWorkers, worker) + } else { + o.workers = append(o.workers, worker) + } return nil } @@ -141,3 +147,10 @@ func WithMaxWaitTime(maxWaitTime time.Duration) Option { return nil } } + +func WithTaskWorkerMode(isTaskWorker bool) WorkerOption { + return func(w *workerOpt) error { + w.isTaskWorker = isTaskWorker + return nil + } +} diff --git a/testdata/task_dispatching.php b/testdata/task_dispatching.php deleted file mode 100644 index 244a04707..000000000 --- a/testdata/task_dispatching.php +++ /dev/null @@ -1,6 +0,0 @@ -= 2 + num_threads - convertToTaskWorkerThread(getInactivePHPThread(), tw) - convertToTaskWorkerThread(getInactivePHPThread(), tw) + return nil } func convertToTaskWorkerThread(thread *phpThread, tw *taskWorker) *taskWorkerThread { @@ -100,7 +136,7 @@ func (handler *taskWorkerThread) name() string { } //export go_frankenphp_worker_handle_task -func go_frankenphp_worker_handle_task(threadIndex C.uintptr_t) *C.char { +func go_frankenphp_worker_handle_task(threadIndex C.uintptr_t) C.go_string { thread := phpThreads[threadIndex] handler, ok := thread.handler.(*taskWorkerThread) if !ok { @@ -111,20 +147,75 @@ func go_frankenphp_worker_handle_task(threadIndex C.uintptr_t) *C.char { thread.state.set(stateReady) } + thread.state.markAsWaiting(true) + select { - case taskString := <-handler.taskWorker.taskChan: - return thread.pinCString(taskString) + case task := <-handler.taskWorker.taskChan: + handler.currentTask = task + thread.state.markAsWaiting(false) + return C.go_string{len: task.len, data: task.char} case <-handler.thread.drainChan: - // thread is shutting down, do not execute the function - return nil + thread.state.markAsWaiting(false) + // send an empty task to drain the thread + return C.go_string{len: 0, data: nil} } } +//export go_frankenphp_finish_task +func go_frankenphp_finish_task(threadIndex C.uintptr_t) { + thread := phpThreads[threadIndex] + handler, ok := thread.handler.(*taskWorkerThread) + if !ok { + panic("thread is not a task thread") + } + + handler.currentTask.done.Unlock() + handler.currentTask = nil +} + //export go_frankenphp_worker_dispatch_task -func go_frankenphp_worker_dispatch_task(taskWorkerIndex C.uintptr_t, taskString *C.char, taskLen C.size_t) C.bool { +func go_frankenphp_worker_dispatch_task(taskWorkerIndex C.uintptr_t, taskChar *C.char, taskLen C.size_t, name *C.char, nameLen C.size_t) C.bool { + var worker *taskWorker + if name != nil { + name := C.GoStringN(name, C.int(nameLen)) + for _, w := range taskWorkers { + if w.name == name { + worker = w + break + } + } + } else { + worker = taskWorkers[taskWorkerIndex] + } + + if worker == nil { + logger.Error("task worker does not exist", "name", C.GoStringN(name, C.int(nameLen))) + return C.bool(false) + } + + // create a new task and lock it until the task is done + task := &dispatchedTask{char: taskChar, len: taskLen} + task.done.Lock() + + // dispatch immediately if available (best performance) + select { + case taskWorkers[taskWorkerIndex].taskChan <- task: + return C.bool(false) + default: + } + + // otherwise queue up in a non-blocking way go func() { - taskWorkers[taskWorkerIndex].taskChan <- C.GoStringN(taskString, C.int(taskLen)) + taskWorkers[taskWorkerIndex].taskChan <- task }() return C.bool(true) } + +//export go_is_task_worker_thread +func go_is_task_worker_thread(threadIndex C.uintptr_t) C.bool { + thread := phpThreads[threadIndex] + _, ok := thread.handler.(*taskWorkerThread) + + return C.bool(ok) +} diff --git a/threadtaskworker_test.go b/threadtaskworker_test.go new file mode 100644 index 000000000..ecb4b59b6 --- /dev/null +++ b/threadtaskworker_test.go @@ -0,0 +1,46 @@ +package frankenphp + +import ( + "bytes" + "log/slog" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func assertGetRequest(t *testing.T, url string, expectedBodyContains string, opts ...RequestOption) { + t.Helper() + r := httptest.NewRequest("GET", url, nil) + w := httptest.NewRecorder() + req, err := NewRequestWithContext(r, opts...) + assert.NoError(t, err) + assert.NoError(t, ServeHTTP(w, req)) + assert.Contains(t, w.Body.String(), expectedBodyContains) +} + +func TestDispatchWorkToTaskWorker(t *testing.T) { + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + logger := slog.New(handler) + + assert.NoError(t, Init( + WithWorkers("worker", "testdata/tasks/task-worker.php", 1, WithTaskWorkerMode(true)), + WithNumThreads(3), + WithLogger(logger), + )) + defer Shutdown() + + assert.Len(t, taskWorkers, 1) + + assertGetRequest(t, "http://example.com/testdata/tasks/task-dispatcher.php?count=4", "dispatched 4 tasks") + + time.Sleep(time.Millisecond * 200) // wait a bit for tasks to complete + + logOutput := buf.String() + assert.Contains(t, logOutput, "task0") + assert.Contains(t, logOutput, "task1") + assert.Contains(t, logOutput, "task2") + assert.Contains(t, logOutput, "task3") +} From 6e79380ddca07bf1639679a7a34a7562f67d1cfe Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 16 Sep 2025 18:25:07 +0200 Subject: [PATCH 07/45] formatting --- frankenphp.c | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 5027f51c6..a6ace621b 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -546,10 +546,12 @@ PHP_FUNCTION(frankenphp_dispatch_task) { ZEND_PARSE_PARAMETERS_END(); /* copy the task string so other threads can use it */ - char *task_copy = pemalloc(task_len, 1); /* free in frankenphp_handle_task() */ + char *task_copy = + pemalloc(task_len, 1); /* free in frankenphp_handle_task() */ memcpy(task_copy, task_string, task_len); - go_frankenphp_worker_dispatch_task(0, task_copy, task_len, worker_name, worker_name_len); + go_frankenphp_worker_dispatch_task(0, task_copy, task_len, worker_name, + worker_name_len); RETURN_TRUE; } From f43c8bb1bff6a947bb0684c6417f18d8b56725c4 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 16 Sep 2025 20:50:51 +0200 Subject: [PATCH 08/45] Removes log. --- threadtaskworker.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/threadtaskworker.go b/threadtaskworker.go index dd6281a9b..4a3019db5 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -55,8 +55,6 @@ func initTaskWorkers(opts []workerOpt) error { num: opt.num, }, ) - - logger.Info("Initialized task worker", "name", opt.name, "file", filename, "num", opt.num) } ready := sync.WaitGroup{} From 7f52e2d116c1ac701e9145f64ae4dac990b99309 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 17 Sep 2025 21:49:10 +0200 Subject: [PATCH 09/45] Allows watching with threads. --- caddy/app.go | 7 +-- caddy/taskworkerconfig.go | 89 --------------------------------------- frankenphp.go | 23 +++++++++- options.go | 3 +- threadtaskworker.go | 21 +++++---- worker.go | 63 ++++++++++----------------- 6 files changed, 64 insertions(+), 142 deletions(-) delete mode 100644 caddy/taskworkerconfig.go diff --git a/caddy/app.go b/caddy/app.go index 0315b4b09..7ed71fd5b 100644 --- a/caddy/app.go +++ b/caddy/app.go @@ -34,7 +34,7 @@ type FrankenPHPApp struct { // Workers configures the worker scripts to start. Workers []workerConfig `json:"workers,omitempty"` // TaskWorkers configures the task worker scripts to start. - TaskWorkers []taskWorkerConfig `json:"task_workers,omitempty"` + TaskWorkers []workerConfig `json:"task_workers,omitempty"` // Overwrites the default php ini configuration PhpIni map[string]string `json:"php_ini,omitempty"` // The maximum amount of time a request may be stalled waiting for a thread @@ -132,7 +132,8 @@ func (f *FrankenPHPApp) Start() error { for _, tw := range f.TaskWorkers { workerOpts := []frankenphp.WorkerOption{ frankenphp.WithWorkerEnv(tw.Env), - frankenphp.WithTaskWorkerMode(true), + frankenphp.WithWorkerWatchMode(tw.Watch), + frankenphp.WithTaskWorker(true), } opts = append(opts, frankenphp.WithWorkers(tw.Name, repl.ReplaceKnown(tw.FileName, ""), tw.Num, workerOpts...)) @@ -245,7 +246,7 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } case "task_worker": - twc, err := parseTaskWorkerConfig(d) + twc, err := parseWorkerConfig(d) if err != nil { return err } diff --git a/caddy/taskworkerconfig.go b/caddy/taskworkerconfig.go deleted file mode 100644 index fdef6da8c..000000000 --- a/caddy/taskworkerconfig.go +++ /dev/null @@ -1,89 +0,0 @@ -package caddy - -import ( - "errors" - "path/filepath" - "strconv" - - "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" - "github.com/dunglas/frankenphp" -) - -// taskWorkerConfig represents the "task_worker" directive in the Caddyfile -// -// frankenphp { -// task_worker { -// name "my-worker" -// file "my-worker.php" -// } -// } -type taskWorkerConfig struct { - // Name for the worker. Default: the filename for FrankenPHPApp workers, always prefixed with "m#" for FrankenPHPModule workers. - Name string `json:"name,omitempty"` - // FileName sets the path to the worker script. - FileName string `json:"file_name,omitempty"` - // Num sets the number of workers to start. - Num int `json:"num,omitempty"` - // Env sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables. - Env map[string]string `json:"env,omitempty"` -} - -func parseTaskWorkerConfig(d *caddyfile.Dispenser) (taskWorkerConfig, error) { - wc := taskWorkerConfig{} - if d.NextArg() { - wc.FileName = d.Val() - } - - if d.NextArg() { - return wc, errors.New(`FrankenPHP: too many "task_worker" arguments: ` + d.Val()) - } - - for d.NextBlock(1) { - v := d.Val() - switch v { - case "name": - if !d.NextArg() { - return wc, d.ArgErr() - } - wc.Name = d.Val() - case "file": - if !d.NextArg() { - return wc, d.ArgErr() - } - wc.FileName = d.Val() - case "num": - if !d.NextArg() { - return wc, d.ArgErr() - } - - v, err := strconv.ParseUint(d.Val(), 10, 32) - if err != nil { - return wc, err - } - - wc.Num = int(v) - case "env": - args := d.RemainingArgs() - if len(args) != 2 { - return wc, d.ArgErr() - } - if wc.Env == nil { - wc.Env = make(map[string]string) - } - wc.Env[args[0]] = args[1] - default: - allowedDirectives := "name, file, num, env" - return wc, wrongSubDirectiveError("worker", allowedDirectives, v) - } - } - - if wc.FileName == "" { - return wc, errors.New(`the "file" argument for "task_worker" must be specified`) - } - - if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) { - wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName) - } - - return wc, nil -} diff --git a/frankenphp.go b/frankenphp.go index b8475b7f0..42713033f 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -37,6 +37,8 @@ import ( "unsafe" // debug on Linux //_ "github.com/ianlancetaylor/cgosymbolizer" + + "github.com/dunglas/frankenphp/internal/watcher" ) type contextKeyStruct struct{} @@ -278,6 +280,9 @@ func Init(options ...Option) error { convertToRegularThread(getInactivePHPThread()) } + directoriesToWatch := getDirectoriesToWatch(append(opt.workers, opt.taskWorkers...)) + watcherIsEnabled = len(directoriesToWatch) > 0 // watcherIsEnabled is important for initWorkers() + if err := initWorkers(opt.workers); err != nil { return err } @@ -286,6 +291,12 @@ func Init(options ...Option) error { return err } + if watcherIsEnabled { + if err := watcher.InitWatcher(directoriesToWatch, RestartWorkers, logger); err != nil { + return err + } + } + initAutoScaling(mainThread) ctx := context.Background() @@ -303,7 +314,9 @@ func Shutdown() { return } - drainWatcher() + if watcherIsEnabled { + watcher.DrainWatcher() + } drainAutoScaling() drainPHPThreads() @@ -619,3 +632,11 @@ func timeoutChan(timeout time.Duration) <-chan time.Time { return time.After(timeout) } + +func getDirectoriesToWatch(workerOpts []workerOpt) []string { + directoriesToWatch := []string{} + for _, w := range workerOpts { + directoriesToWatch = append(directoriesToWatch, w.watch...) + } + return directoriesToWatch +} diff --git a/options.go b/options.go index c3d0583ac..3540428da 100644 --- a/options.go +++ b/options.go @@ -148,7 +148,8 @@ func WithMaxWaitTime(maxWaitTime time.Duration) Option { } } -func WithTaskWorkerMode(isTaskWorker bool) WorkerOption { +// EXPERMIENTAL: WithTaskWorker configures the worker as a task worker instead. +func WithTaskWorker(isTaskWorker bool) WorkerOption { return func(w *workerOpt) error { w.isTaskWorker = isTaskWorker return nil diff --git a/threadtaskworker.go b/threadtaskworker.go index 4a3019db5..ef8e1d87c 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -9,12 +9,12 @@ import ( ) type taskWorker struct { - threads []*phpThread - mu sync.Mutex - filename string - taskChan chan *dispatchedTask - name string - num int + threads []*phpThread + threadMutex sync.RWMutex + filename string + taskChan chan *dispatchedTask + name string + num int } // representation of a thread that handles tasks directly assigned by go @@ -82,9 +82,9 @@ func convertToTaskWorkerThread(thread *phpThread, tw *taskWorker) *taskWorkerThr } thread.setHandler(handler) - tw.mu.Lock() + tw.threadMutex.Lock() tw.threads = append(tw.threads, thread) - tw.mu.Unlock() + tw.threadMutex.Unlock() return handler } @@ -102,6 +102,11 @@ func (handler *taskWorkerThread) beforeScriptExecution() string { case stateReady: return handler.setupWorkerScript() + case stateRestarting: + thread.state.set(stateYielding) + thread.state.waitFor(stateReady, stateShuttingDown) + + return handler.beforeScriptExecution() case stateShuttingDown: // signal to stop return "" diff --git a/worker.go b/worker.go index 04772fa4a..72e843060 100644 --- a/worker.go +++ b/worker.go @@ -9,7 +9,6 @@ import ( "time" "github.com/dunglas/frankenphp/internal/fastabs" - "github.com/dunglas/frankenphp/internal/watcher" ) // represents a worker script and can have many threads assigned to it @@ -33,8 +32,6 @@ var ( func initWorkers(opt []workerOpt) error { workers = make([]*worker, 0, len(opt)) workersReady := sync.WaitGroup{} - directoriesToWatch := getDirectoriesToWatch(opt) - watcherIsEnabled = len(directoriesToWatch) > 0 for _, o := range opt { w, err := newWorker(o) @@ -58,15 +55,6 @@ func initWorkers(opt []workerOpt) error { workersReady.Wait() - if !watcherIsEnabled { - return nil - } - - watcherIsEnabled = true - if err := watcher.InitWatcher(directoriesToWatch, RestartWorkers, logger); err != nil { - return err - } - return nil } @@ -138,33 +126,36 @@ func DrainWorkers() { func drainWorkerThreads() []*phpThread { ready := sync.WaitGroup{} drainedThreads := make([]*phpThread, 0) + threadsToDrain := make([]*phpThread, 0) for _, worker := range workers { worker.threadMutex.RLock() - ready.Add(len(worker.threads)) - for _, thread := range worker.threads { - if !thread.state.requestSafeStateChange(stateRestarting) { - // no state change allowed == thread is shutting down - // we'll proceed to restart all other threads anyways - continue - } - close(thread.drainChan) - drainedThreads = append(drainedThreads, thread) - go func(thread *phpThread) { - thread.state.waitFor(stateYielding) - ready.Done() - }(thread) - } + threadsToDrain = append(threadsToDrain, worker.threads...) worker.threadMutex.RUnlock() } - ready.Wait() - return drainedThreads -} + for _, taskWorker := range taskWorkers { + taskWorker.threadMutex.RLock() + threadsToDrain = append(threadsToDrain, taskWorker.threads...) + taskWorker.threadMutex.RUnlock() + } -func drainWatcher() { - if watcherIsEnabled { - watcher.DrainWatcher() + for _, thread := range threadsToDrain { + if !thread.state.requestSafeStateChange(stateRestarting) { + // no state change allowed == thread is shutting down + // we'll proceed to restart all other threads anyways + continue + } + ready.Add(1) + close(thread.drainChan) + drainedThreads = append(drainedThreads, thread) + go func(thread *phpThread) { + thread.state.waitFor(stateYielding) + ready.Done() + }(thread) } + ready.Wait() + + return drainedThreads } // RestartWorkers attempts to restart all workers gracefully @@ -181,14 +172,6 @@ func RestartWorkers() { } } -func getDirectoriesToWatch(workerOpts []workerOpt) []string { - directoriesToWatch := []string{} - for _, w := range workerOpts { - directoriesToWatch = append(directoriesToWatch, w.watch...) - } - return directoriesToWatch -} - func (worker *worker) attachThread(thread *phpThread) { worker.threadMutex.Lock() worker.threads = append(worker.threads, thread) From 9c4cf7e2d863f337348c8b3ac5b6d3341206cb13 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 17 Sep 2025 22:10:39 +0200 Subject: [PATCH 10/45] Throws on task handling failure. --- caddy/app.go | 2 +- frankenphp.c | 13 +++++++++---- frankenphp.go | 3 ++- frankenphp.stub.php | 2 +- frankenphp_arginfo.h | 2 +- options.go | 4 ++-- threadtaskworker.go | 16 ++++------------ threadtaskworker_test.go | 2 +- worker.go | 3 +-- 9 files changed, 22 insertions(+), 25 deletions(-) diff --git a/caddy/app.go b/caddy/app.go index 7ed71fd5b..7118080eb 100644 --- a/caddy/app.go +++ b/caddy/app.go @@ -133,7 +133,7 @@ func (f *FrankenPHPApp) Start() error { workerOpts := []frankenphp.WorkerOption{ frankenphp.WithWorkerEnv(tw.Env), frankenphp.WithWorkerWatchMode(tw.Watch), - frankenphp.WithTaskWorker(true), + frankenphp.AsTaskWorker(true), } opts = append(opts, frankenphp.WithWorkers(tw.Name, repl.ReplaceKnown(tw.FileName, ""), tw.Num, workerOpts...)) diff --git a/frankenphp.c b/frankenphp.c index a6ace621b..7328e4d47 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -547,13 +547,18 @@ PHP_FUNCTION(frankenphp_dispatch_task) { /* copy the task string so other threads can use it */ char *task_copy = - pemalloc(task_len, 1); /* free in frankenphp_handle_task() */ + pemalloc(task_len, 1); /* freed in frankenphp_handle_task() */ memcpy(task_copy, task_string, task_len); - go_frankenphp_worker_dispatch_task(0, task_copy, task_len, worker_name, + bool success = go_frankenphp_worker_dispatch_task(0, task_copy, task_len, worker_name, worker_name_len); - - RETURN_TRUE; + if (!success) { + pefree(task_copy, 1); + // throw + zend_throw_exception(spl_ce_RuntimeException, + "No worker found to handle the task", 0); + RETURN_THROWS(); + } } PHP_FUNCTION(headers_send) { diff --git a/frankenphp.go b/frankenphp.go index 42713033f..6c11eaf0f 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -54,7 +54,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 + watcherIsEnabled bool loggerMu sync.RWMutex logger *slog.Logger diff --git a/frankenphp.stub.php b/frankenphp.stub.php index f5da552bd..b75b18d2e 100644 --- a/frankenphp.stub.php +++ b/frankenphp.stub.php @@ -6,7 +6,7 @@ function frankenphp_handle_request(callable $callback): bool {} function frankenphp_handle_task(callable $callback): bool {} -function frankenphp_dispatch_task(string $task, ?string $workerName = null): bool {} +function frankenphp_dispatch_task(string $task, ?string $workerName = null) {} function headers_send(int $status = 200): int {} diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index 6c2e056e9..5e867e89e 100644 --- a/frankenphp_arginfo.h +++ b/frankenphp_arginfo.h @@ -12,7 +12,7 @@ ZEND_ARG_TYPE_INFO(0, callback, IS_CALLABLE, 0) ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_dispatch_task, 0, 1, - _IS_BOOL, 0) + IS_VOID, 0) ZEND_ARG_TYPE_INFO(0, task, IS_STRING, 0) ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, worker_name, IS_STRING, 0, "null") ZEND_END_ARG_INFO() diff --git a/options.go b/options.go index 3540428da..2370776e1 100644 --- a/options.go +++ b/options.go @@ -148,8 +148,8 @@ func WithMaxWaitTime(maxWaitTime time.Duration) Option { } } -// EXPERMIENTAL: WithTaskWorker configures the worker as a task worker instead. -func WithTaskWorker(isTaskWorker bool) WorkerOption { +// EXPERMIENTAL: AsTaskWorker configures the worker as a task worker instead. +func AsTaskWorker(isTaskWorker bool) WorkerOption { return func(w *workerOpt) error { w.isTaskWorker = isTaskWorker return nil diff --git a/threadtaskworker.go b/threadtaskworker.go index ef8e1d87c..e90526ef7 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -141,15 +141,7 @@ func (handler *taskWorkerThread) name() string { //export go_frankenphp_worker_handle_task func go_frankenphp_worker_handle_task(threadIndex C.uintptr_t) C.go_string { thread := phpThreads[threadIndex] - handler, ok := thread.handler.(*taskWorkerThread) - if !ok { - panic("thread is not a task thread") - } - - if !thread.state.is(stateReady) { - thread.state.set(stateReady) - } - + handler, _ := thread.handler.(*taskWorkerThread) thread.state.markAsWaiting(true) select { @@ -180,7 +172,7 @@ func go_frankenphp_finish_task(threadIndex C.uintptr_t) { func go_frankenphp_worker_dispatch_task(taskWorkerIndex C.uintptr_t, taskChar *C.char, taskLen C.size_t, name *C.char, nameLen C.size_t) C.bool { var worker *taskWorker if name != nil { - name := C.GoStringN(name, C.int(nameLen)) + name := C.GoStringN(name, C.int(nameLen)) // TODO: avoid copy for _, w := range taskWorkers { if w.name == name { worker = w @@ -192,7 +184,7 @@ func go_frankenphp_worker_dispatch_task(taskWorkerIndex C.uintptr_t, taskChar *C } if worker == nil { - logger.Error("task worker does not exist", "name", C.GoStringN(name, C.int(nameLen))) + logger.Error("no task worker found to handle this task", "name", C.GoStringN(name, C.int(nameLen))) return C.bool(false) } @@ -203,7 +195,7 @@ func go_frankenphp_worker_dispatch_task(taskWorkerIndex C.uintptr_t, taskChar *C // dispatch immediately if available (best performance) select { case taskWorkers[taskWorkerIndex].taskChan <- task: - return C.bool(false) + return C.bool(true) default: } diff --git a/threadtaskworker_test.go b/threadtaskworker_test.go index ecb4b59b6..bacffb584 100644 --- a/threadtaskworker_test.go +++ b/threadtaskworker_test.go @@ -26,7 +26,7 @@ func TestDispatchWorkToTaskWorker(t *testing.T) { logger := slog.New(handler) assert.NoError(t, Init( - WithWorkers("worker", "testdata/tasks/task-worker.php", 1, WithTaskWorkerMode(true)), + WithWorkers("worker", "./testdata/tasks/task-worker.php", 1, AsTaskWorker(true)), WithNumThreads(3), WithLogger(logger), )) diff --git a/worker.go b/worker.go index 72e843060..26d56335c 100644 --- a/worker.go +++ b/worker.go @@ -25,8 +25,7 @@ type worker struct { } var ( - workers []*worker - watcherIsEnabled bool + workers []*worker ) func initWorkers(opt []workerOpt) error { From 7438edd67678a81c6a48cf4656d5809957d430e9 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 17 Sep 2025 22:39:59 +0200 Subject: [PATCH 11/45] Adds direct dispatching test. --- frankenphp.c | 22 ++++-------- testdata/tasks/task-dispatcher.php | 14 ++++---- threadtaskworker.go | 55 +++++++++++++++++++++--------- threadtaskworker_test.go | 25 ++++++++++++-- 4 files changed, 76 insertions(+), 40 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 7328e4d47..536e973ff 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -504,7 +504,7 @@ PHP_FUNCTION(frankenphp_handle_task) { /* ZVAL_STRINGL_FAST will consume the string without c */ zval taskzv; - ZVAL_STRINGL_FAST(&taskzv, task.data, task.len); + ZVAL_STRINGL(&taskzv, task.data, task.len); fci.params = &taskzv; fci.param_count = 1; if (zend_call_function(&fci, &fcc) == SUCCESS) { @@ -526,8 +526,7 @@ PHP_FUNCTION(frankenphp_handle_task) { go_frankenphp_finish_task(thread_index); - /* free the task string allocated in frankenphp_dispatch_task() */ - pefree(task.data, 1); + /* free the task zval */ zval_ptr_dtor(&taskzv); RETURN_TRUE; @@ -545,19 +544,12 @@ PHP_FUNCTION(frankenphp_dispatch_task) { Z_PARAM_STRING(worker_name, worker_name_len); ZEND_PARSE_PARAMETERS_END(); - /* copy the task string so other threads can use it */ - char *task_copy = - pemalloc(task_len, 1); /* freed in frankenphp_handle_task() */ - memcpy(task_copy, task_string, task_len); - - bool success = go_frankenphp_worker_dispatch_task(0, task_copy, task_len, worker_name, - worker_name_len); + bool success = go_frankenphp_worker_dispatch_task( + 0, task_string, task_len, worker_name, worker_name_len); if (!success) { - pefree(task_copy, 1); - // throw - zend_throw_exception(spl_ce_RuntimeException, - "No worker found to handle the task", 0); - RETURN_THROWS(); + zend_throw_exception(spl_ce_RuntimeException, + "No worker found to handle the task", 0); + RETURN_THROWS(); } } diff --git a/testdata/tasks/task-dispatcher.php b/testdata/tasks/task-dispatcher.php index b47a4b907..9c1ac8aac 100644 --- a/testdata/tasks/task-dispatcher.php +++ b/testdata/tasks/task-dispatcher.php @@ -1,9 +1,11 @@ Date: Wed, 17 Sep 2025 22:53:03 +0200 Subject: [PATCH 12/45] Allows prepared env. --- testdata/tasks/task-worker.php | 1 + threadtaskworker.go | 7 ++++++- threadtaskworker_test.go | 11 +++++++++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/testdata/tasks/task-worker.php b/testdata/tasks/task-worker.php index 619c087c1..4b06a90d6 100644 --- a/testdata/tasks/task-worker.php +++ b/testdata/tasks/task-worker.php @@ -2,6 +2,7 @@ $handleFunc = function ($task) { echo "$task"; + echo $_SERVER['CUSTOM_VAR'] ?? 'no custom var'; }; $maxRequests = 1000; diff --git a/threadtaskworker.go b/threadtaskworker.go index c4782fab6..75e8e5629 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -16,6 +16,7 @@ type taskWorker struct { taskChan chan *PendingTask name string num int + env PreparedEnv } // representation of a thread that handles tasks directly assigned by go @@ -69,6 +70,7 @@ func initTaskWorkers(opts []workerOpt) error { taskChan: make(chan *PendingTask), name: opt.name, num: opt.num, + env: opt.env, }, ) } @@ -131,7 +133,10 @@ func (handler *taskWorkerThread) beforeScriptExecution() string { } func (handler *taskWorkerThread) setupWorkerScript() string { - fc, err := newDummyContext(filepath.Base(handler.taskWorker.filename)) + fc, err := newDummyContext( + filepath.Base(handler.taskWorker.filename), + WithRequestPreparedEnv(handler.taskWorker.env), + ) if err != nil { panic(err) diff --git a/threadtaskworker_test.go b/threadtaskworker_test.go index bc200e7e6..03784ab8d 100644 --- a/threadtaskworker_test.go +++ b/threadtaskworker_test.go @@ -26,7 +26,13 @@ func TestDispatchToTaskWorker(t *testing.T) { logger := slog.New(handler) assert.NoError(t, Init( - WithWorkers("worker", "./testdata/tasks/task-worker.php", 1, AsTaskWorker(true)), + WithWorkers( + "worker", + "./testdata/tasks/task-worker.php", + 1, + AsTaskWorker(true), + WithWorkerEnv(PreparedEnv{"CUSTOM_VAR": "custom var"}), + ), WithNumThreads(3), WithLogger(logger), )) @@ -37,7 +43,8 @@ func TestDispatchToTaskWorker(t *testing.T) { pendingTask.WaitForCompletion() logOutput := buf.String() - assert.Contains(t, logOutput, "go task") + assert.Contains(t, logOutput, "go task", "should see the dispatched task in the logs") + assert.Contains(t, logOutput, "custom var", "should see the prepared env of the task worker") } func TestDispatchToTaskWorkerFromWorker(t *testing.T) { From 6c3e1d6af677de911af188ee4c238b959386d162 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 17 Sep 2025 23:04:37 +0200 Subject: [PATCH 13/45] Fixes race. --- threadtaskworker_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/threadtaskworker_test.go b/threadtaskworker_test.go index 03784ab8d..7f7af456e 100644 --- a/threadtaskworker_test.go +++ b/threadtaskworker_test.go @@ -5,7 +5,6 @@ import ( "log/slog" "net/http/httptest" "testing" - "time" "github.com/stretchr/testify/assert" ) @@ -64,7 +63,9 @@ func TestDispatchToTaskWorkerFromWorker(t *testing.T) { assertGetRequest(t, "http://example.com/testdata/tasks/task-dispatcher.php?count=4", "dispatched 4 tasks") - time.Sleep(time.Millisecond * 100) // ensure all tasks are finished + // dispatch another task to make sure the previous ones are done + pr, _ := DispatchTask("go task", "worker") + pr.WaitForCompletion() logOutput := buf.String() assert.Contains(t, logOutput, "task0") From 7982b3af597ce90fa4455e82165e1a6bf3774d8a Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 18 Sep 2025 21:50:01 +0200 Subject: [PATCH 14/45] Adds max queue len and more tests. --- frankenphp.c | 11 +++++-- frankenphp.go | 1 + testdata/tasks/task-dispatcher.php | 9 +++--- threadtaskworker.go | 46 +++++++++++++++++++++++------- threadtaskworker_test.go | 31 +++++++++++++++----- 5 files changed, 74 insertions(+), 24 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 536e973ff..73258df37 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -526,7 +526,8 @@ PHP_FUNCTION(frankenphp_handle_task) { go_frankenphp_finish_task(thread_index); - /* free the task zval */ + /* free the task zval (right now always a string) */ + free(task.data); zval_ptr_dtor(&taskzv); RETURN_TRUE; @@ -544,9 +545,15 @@ PHP_FUNCTION(frankenphp_dispatch_task) { Z_PARAM_STRING(worker_name, worker_name_len); ZEND_PARSE_PARAMETERS_END(); + // copy the zval to be used in the other thread safely + // right now we only support strings + char *task_copy = malloc(task_len); + memcpy(task_copy, task_string, task_len); + bool success = go_frankenphp_worker_dispatch_task( - 0, task_string, task_len, worker_name, worker_name_len); + task_copy, task_len, worker_name, worker_name_len); if (!success) { + free(task_copy); zend_throw_exception(spl_ce_RuntimeException, "No worker found to handle the task", 0); RETURN_THROWS(); diff --git a/frankenphp.go b/frankenphp.go index 6c11eaf0f..f300de59a 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -319,6 +319,7 @@ func Shutdown() { watcher.DrainWatcher() } drainAutoScaling() + drainTaskWorkers() drainPHPThreads() metrics.Shutdown() diff --git a/testdata/tasks/task-dispatcher.php b/testdata/tasks/task-dispatcher.php index 9c1ac8aac..1896a1a99 100644 --- a/testdata/tasks/task-dispatcher.php +++ b/testdata/tasks/task-dispatcher.php @@ -3,9 +3,10 @@ require_once __DIR__.'/../_executor.php'; return function () { - $count = $_GET['count'] ?? 0; - for ($i = 0; $i < $count; $i++) { - frankenphp_dispatch_task("task$i"); + $taskCount = $_GET['count'] ?? 0; + $workerName = $_GET['worker'] ?? null; + for ($i = 0; $i < $taskCount; $i++) { + frankenphp_dispatch_task("task$i", $workerName); } - echo "dispatched $count tasks\n"; + echo "dispatched $taskCount tasks\n"; }; diff --git a/threadtaskworker.go b/threadtaskworker.go index 75e8e5629..8993a5c08 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -7,6 +7,7 @@ import ( "github.com/dunglas/frankenphp/internal/fastabs" "path/filepath" "sync" + "sync/atomic" ) type taskWorker struct { @@ -17,6 +18,7 @@ type taskWorker struct { name string num int env PreparedEnv + queueLen atomic.Int32 } // representation of a thread that handles tasks directly assigned by go @@ -28,11 +30,13 @@ type taskWorkerThread struct { currentTask *PendingTask } +const maxQueueLen = 1000 // TODO: configurable? var taskWorkers []*taskWorker // EXPERIMENTAL: a task dispatched to a task worker type PendingTask struct { - str string + str *C.char + len C.size_t done sync.RWMutex } @@ -47,7 +51,7 @@ func DispatchTask(task string, workerName string) (*PendingTask, error) { return nil, errors.New("no task worker found with name " + workerName) } - pt := &PendingTask{str: task} + pt := &PendingTask{str: C.CString(task), len: C.size_t(len(task))} pt.done.Lock() tw.taskChan <- pt @@ -93,6 +97,17 @@ func initTaskWorkers(opts []workerOpt) error { return nil } +func drainTaskWorkers() { + for _, tw := range taskWorkers { + select { + // make sure all tasks are done by re-queuing them until the channel is empty + case pt := <-tw.taskChan: + tw.taskChan <- pt + default: + } + } +} + func convertToTaskWorkerThread(thread *phpThread, tw *taskWorker) *taskWorkerThread { handler := &taskWorkerThread{ thread: thread, @@ -143,12 +158,13 @@ func (handler *taskWorkerThread) setupWorkerScript() string { } handler.dummyContext = fc + clearSandboxedEnv(handler.thread) return handler.taskWorker.filename } func (handler *taskWorkerThread) afterScriptExecution(int) { - // shutdown? + // potential place for cleanup after task execution } func (handler *taskWorkerThread) getRequestContext() *frankenPHPContext { @@ -180,7 +196,7 @@ func go_frankenphp_worker_handle_task(threadIndex C.uintptr_t) C.go_string { case task := <-handler.taskWorker.taskChan: handler.currentTask = task thread.state.markAsWaiting(false) - return C.go_string{len: C.size_t(len(task.str)), data: thread.pinString(task.str)} + return C.go_string{len: task.len, data: task.str} case <-handler.thread.drainChan: thread.state.markAsWaiting(false) // send an empty task to drain the thread @@ -201,12 +217,12 @@ func go_frankenphp_finish_task(threadIndex C.uintptr_t) { } //export go_frankenphp_worker_dispatch_task -func go_frankenphp_worker_dispatch_task(taskWorkerIndex C.uintptr_t, taskChar *C.char, taskLen C.size_t, name *C.char, nameLen C.size_t) C.bool { +func go_frankenphp_worker_dispatch_task(taskChar *C.char, taskLen C.size_t, name *C.char, nameLen C.size_t) C.bool { var worker *taskWorker - if name != nil { + if name != nil && nameLen != 0 { worker = getTaskWorkerByName(C.GoStringN(name, C.int(nameLen))) - } else { - worker = taskWorkers[taskWorkerIndex] + } else if len(taskWorkers) != 0 { + worker = taskWorkers[0] } if worker == nil { @@ -215,19 +231,27 @@ func go_frankenphp_worker_dispatch_task(taskWorkerIndex C.uintptr_t, taskChar *C } // create a new task and lock it until the task is done - task := &PendingTask{str: C.GoStringN(taskChar, C.int(taskLen))} + task := &PendingTask{str: taskChar, len: taskLen} task.done.Lock() // dispatch immediately if available (best performance) select { - case taskWorkers[taskWorkerIndex].taskChan <- task: + case worker.taskChan <- task: return C.bool(true) default: } + // make sure the queue is not too full + if worker.queueLen.Load() >= maxQueueLen { + logger.Error("task worker queue is full, dropping task", "name", worker.name) + return C.bool(false) + } + // otherwise queue up in a non-blocking way go func() { - taskWorkers[taskWorkerIndex].taskChan <- task + worker.queueLen.Add(1) + worker.taskChan <- task + worker.queueLen.Add(-1) }() return C.bool(true) diff --git a/threadtaskworker_test.go b/threadtaskworker_test.go index 7f7af456e..f10ad2b75 100644 --- a/threadtaskworker_test.go +++ b/threadtaskworker_test.go @@ -36,6 +36,7 @@ func TestDispatchToTaskWorker(t *testing.T) { WithLogger(logger), )) defer Shutdown() + assert.Len(t, taskWorkers, 1) pendingTask, err := DispatchTask("go task", "worker") assert.NoError(t, err) @@ -54,22 +55,38 @@ func TestDispatchToTaskWorkerFromWorker(t *testing.T) { assert.NoError(t, Init( WithWorkers("worker", "./testdata/tasks/task-worker.php", 1, AsTaskWorker(true)), WithWorkers("worker", "./testdata/tasks/task-dispatcher.php", 1), - WithNumThreads(4), + WithNumThreads(3), WithLogger(logger), )) - defer Shutdown() - - assert.Len(t, taskWorkers, 1) assertGetRequest(t, "http://example.com/testdata/tasks/task-dispatcher.php?count=4", "dispatched 4 tasks") - // dispatch another task to make sure the previous ones are done - pr, _ := DispatchTask("go task", "worker") - pr.WaitForCompletion() + // shutdown to ensure all logs are flushed + Shutdown() + // task output appears in logs at info level logOutput := buf.String() assert.Contains(t, logOutput, "task0") assert.Contains(t, logOutput, "task1") assert.Contains(t, logOutput, "task2") assert.Contains(t, logOutput, "task3") } + +func TestDispatchToMultipleWorkers(t *testing.T) { + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + logger := slog.New(handler) + + assert.NoError(t, Init( + WithWorkers("worker1", "./testdata/tasks/task-worker.php", 1, AsTaskWorker(true)), + WithWorkers("worker2", "./testdata/tasks/task-worker.php", 1, AsTaskWorker(true)), + WithNumThreads(4), + WithLogger(logger), + )) + defer Shutdown() + + script := "http://example.com/testdata/tasks/task-dispatcher.php" + assertGetRequest(t, script+"?count=1&worker=worker1", "dispatched 1 tasks") + assertGetRequest(t, script+"?count=1&worker=worker2", "dispatched 1 tasks") + assertGetRequest(t, script+"?count=1&worker=worker3", "No worker found to handle the task") // fail +} From 229761655265a4299bcf98d4488157b83e90128d Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 18 Sep 2025 21:53:14 +0200 Subject: [PATCH 15/45] Adjusts queue len. --- threadtaskworker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/threadtaskworker.go b/threadtaskworker.go index 8993a5c08..8bf69d822 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -30,7 +30,7 @@ type taskWorkerThread struct { currentTask *PendingTask } -const maxQueueLen = 1000 // TODO: configurable? +const maxQueueLen = 1500 // TODO: configurable or via memory limit or doesn't matter? var taskWorkers []*taskWorker // EXPERIMENTAL: a task dispatched to a task worker From 7a2bb89c9bbf29add869b73a6541ffd51e280bb2 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 18 Sep 2025 22:09:18 +0200 Subject: [PATCH 16/45] Waits briefly to ensure logs are flushed --- threadtaskworker_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/threadtaskworker_test.go b/threadtaskworker_test.go index f10ad2b75..73507cd4d 100644 --- a/threadtaskworker_test.go +++ b/threadtaskworker_test.go @@ -5,6 +5,7 @@ import ( "log/slog" "net/http/httptest" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -61,7 +62,8 @@ func TestDispatchToTaskWorkerFromWorker(t *testing.T) { assertGetRequest(t, "http://example.com/testdata/tasks/task-dispatcher.php?count=4", "dispatched 4 tasks") - // shutdown to ensure all logs are flushed + // wait and shutdown to ensure all logs are flushed + time.Sleep(10 * time.Millisecond) Shutdown() // task output appears in logs at info level From f5e6a045b9e64f6cbce2de77616e7f8bd6defe6e Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 18 Sep 2025 22:28:54 +0200 Subject: [PATCH 17/45] Fixes small issues. --- frankenphp.c | 13 ++++++++----- frankenphp.stub.php | 2 +- frankenphp_arginfo.h | 2 +- options.go | 2 +- testdata/tasks/task-dispatcher.php | 2 +- threadtaskworker.go | 14 ++++++++++++++ 6 files changed, 26 insertions(+), 9 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 73258df37..b2b0b5435 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -488,7 +488,10 @@ PHP_FUNCTION(frankenphp_handle_task) { } #ifdef ZEND_MAX_EXECUTION_TIMERS - /* Disable timeouts while waiting for a task to handle */ + /* + * Disable timeouts while waiting for a task to handle + * TODO: should running forever be the default? + */ zend_unset_timeout(); #endif @@ -502,9 +505,9 @@ PHP_FUNCTION(frankenphp_handle_task) { fci.size = sizeof fci; fci.retval = &retval; - /* ZVAL_STRINGL_FAST will consume the string without c */ + /* ZVAL_STRINGL_FAST will consume the malloced char without copying it */ zval taskzv; - ZVAL_STRINGL(&taskzv, task.data, task.len); + ZVAL_STRINGL_FAST(&taskzv, task.data, task.len); fci.params = &taskzv; fci.param_count = 1; if (zend_call_function(&fci, &fcc) == SUCCESS) { @@ -545,8 +548,8 @@ PHP_FUNCTION(frankenphp_dispatch_task) { Z_PARAM_STRING(worker_name, worker_name_len); ZEND_PARSE_PARAMETERS_END(); - // copy the zval to be used in the other thread safely - // right now we only support strings + /* copy the zval to be used in the other thread safely, right now only strings + * are supported */ char *task_copy = malloc(task_len); memcpy(task_copy, task_string, task_len); diff --git a/frankenphp.stub.php b/frankenphp.stub.php index b75b18d2e..8ed837802 100644 --- a/frankenphp.stub.php +++ b/frankenphp.stub.php @@ -6,7 +6,7 @@ function frankenphp_handle_request(callable $callback): bool {} function frankenphp_handle_task(callable $callback): bool {} -function frankenphp_dispatch_task(string $task, ?string $workerName = null) {} +function frankenphp_dispatch_task(string $task, string $workerName = ''): void {} function headers_send(int $status = 200): int {} diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index 5e867e89e..0964ab607 100644 --- a/frankenphp_arginfo.h +++ b/frankenphp_arginfo.h @@ -14,7 +14,7 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_dispatch_task, 0, 1, IS_VOID, 0) ZEND_ARG_TYPE_INFO(0, task, IS_STRING, 0) -ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, worker_name, IS_STRING, 0, "null") +ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, worker_name, IS_STRING, 0, "\"\"") ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_headers_send, 0, 0, IS_LONG, 0) diff --git a/options.go b/options.go index 2370776e1..ff395b3c6 100644 --- a/options.go +++ b/options.go @@ -148,7 +148,7 @@ func WithMaxWaitTime(maxWaitTime time.Duration) Option { } } -// EXPERMIENTAL: AsTaskWorker configures the worker as a task worker instead. +// EXPERIMENTAL: AsTaskWorker configures the worker as a task worker instead. func AsTaskWorker(isTaskWorker bool) WorkerOption { return func(w *workerOpt) error { w.isTaskWorker = isTaskWorker diff --git a/testdata/tasks/task-dispatcher.php b/testdata/tasks/task-dispatcher.php index 1896a1a99..ac7766978 100644 --- a/testdata/tasks/task-dispatcher.php +++ b/testdata/tasks/task-dispatcher.php @@ -4,7 +4,7 @@ return function () { $taskCount = $_GET['count'] ?? 0; - $workerName = $_GET['worker'] ?? null; + $workerName = $_GET['worker'] ?? ''; for ($i = 0; $i < $taskCount; $i++) { frankenphp_dispatch_task("task$i", $workerName); } diff --git a/threadtaskworker.go b/threadtaskworker.go index 8bf69d822..4e0fd617f 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -127,6 +127,8 @@ func (handler *taskWorkerThread) beforeScriptExecution() string { switch thread.state.get() { case stateTransitionRequested: + handler.taskWorker.detach(thread) + return thread.transitionToNewHandler() case stateBooting, stateTransitionComplete: thread.state.set(stateReady) @@ -141,6 +143,7 @@ func (handler *taskWorkerThread) beforeScriptExecution() string { return handler.beforeScriptExecution() case stateShuttingDown: + handler.taskWorker.detach(thread) // signal to stop return "" } @@ -175,6 +178,17 @@ func (handler *taskWorkerThread) name() string { return "Task PHP Thread" } +func (tw *taskWorker) detach(thread *phpThread) { + tw.threadMutex.Lock() + for i, t := range tw.threads { + if t == thread { + tw.threads = append(tw.threads[:i], tw.threads[i+1:]...) + return + } + } + tw.threadMutex.Unlock() +} + func getTaskWorkerByName(name string) *taskWorker { for _, w := range taskWorkers { if w.name == name { From eb2b575cbc070986f56ba0af8c775e5e5aacbb58 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 18 Sep 2025 22:35:08 +0200 Subject: [PATCH 18/45] Fixes thread attaching. --- threadtaskworker.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/threadtaskworker.go b/threadtaskworker.go index 4e0fd617f..edc3b6967 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -85,10 +85,10 @@ func initTaskWorkers(opts []workerOpt) error { for i := 0; i < tw.num; i++ { thread := getInactivePHPThread() convertToTaskWorkerThread(thread, tw) - go func() { + go func(thread *phpThread) { thread.state.waitFor(stateReady) ready.Done() - }() + }(thread) } } @@ -115,10 +115,6 @@ func convertToTaskWorkerThread(thread *phpThread, tw *taskWorker) *taskWorkerThr } thread.setHandler(handler) - tw.threadMutex.Lock() - tw.threads = append(tw.threads, thread) - tw.threadMutex.Unlock() - return handler } @@ -131,6 +127,10 @@ func (handler *taskWorkerThread) beforeScriptExecution() string { return thread.transitionToNewHandler() case stateBooting, stateTransitionComplete: + tw := handler.taskWorker + tw.threadMutex.Lock() + tw.threads = append(tw.threads, thread) + tw.threadMutex.Unlock() thread.state.set(stateReady) return handler.setupWorkerScript() From 0d43efff35ce8444d6a8c0456dfdfe75427b37bf Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 18 Sep 2025 22:35:57 +0200 Subject: [PATCH 19/45] Adjusts name. --- threadtaskworker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/threadtaskworker.go b/threadtaskworker.go index edc3b6967..dba03edbd 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -175,7 +175,7 @@ func (handler *taskWorkerThread) getRequestContext() *frankenPHPContext { } func (handler *taskWorkerThread) name() string { - return "Task PHP Thread" + return "Task Worker PHP Thread" } func (tw *taskWorker) detach(thread *phpThread) { From c16665ae7836aaf2f28e360ab7f10e0317071a43 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 18 Sep 2025 23:35:26 +0200 Subject: [PATCH 20/45] Allows direct execution on tasks and correctly frees in types_test. --- threadtasks_test.go | 90 --------------------------------------------- threadtaskworker.go | 35 ++++++++++++++++-- types.go | 10 +++++ types_test.go | 58 +++++++++++++++++------------ 4 files changed, 75 insertions(+), 118 deletions(-) delete mode 100644 threadtasks_test.go diff --git a/threadtasks_test.go b/threadtasks_test.go deleted file mode 100644 index d81c55535..000000000 --- a/threadtasks_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package frankenphp - -import ( - "sync" -) - -// representation of a thread that handles tasks directly assigned by go -// implements the threadHandler interface -type taskThread struct { - thread *phpThread - execChan chan *task -} - -// task callbacks will be executed directly on the PHP thread -// therefore having full access to the PHP runtime -type task struct { - callback func() - done sync.Mutex -} - -func newTask(cb func()) *task { - t := &task{callback: cb} - t.done.Lock() - - return t -} - -func (t *task) waitForCompletion() { - t.done.Lock() -} - -func convertToTaskThread(thread *phpThread) *taskThread { - handler := &taskThread{ - thread: thread, - execChan: make(chan *task), - } - thread.setHandler(handler) - return handler -} - -func (handler *taskThread) beforeScriptExecution() string { - thread := handler.thread - - switch thread.state.get() { - case stateTransitionRequested: - return thread.transitionToNewHandler() - case stateBooting, stateTransitionComplete: - thread.state.set(stateReady) - handler.waitForTasks() - - return handler.beforeScriptExecution() - case stateReady: - handler.waitForTasks() - - return handler.beforeScriptExecution() - case stateShuttingDown: - // signal to stop - return "" - } - panic("unexpected state: " + thread.state.name()) -} - -func (handler *taskThread) afterScriptExecution(int) { - panic("task threads should not execute scripts") -} - -func (handler *taskThread) getRequestContext() *frankenPHPContext { - return nil -} - -func (handler *taskThread) name() string { - return "Task PHP Thread" -} - -func (handler *taskThread) waitForTasks() { - for { - select { - case task := <-handler.execChan: - task.callback() - task.done.Unlock() // unlock the task to signal completion - case <-handler.thread.drainChan: - // thread is shutting down, do not execute the function - return - } - } -} - -func (handler *taskThread) execute(t *task) { - handler.execChan <- t -} diff --git a/threadtaskworker.go b/threadtaskworker.go index dba03edbd..27d00a208 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -35,9 +35,10 @@ var taskWorkers []*taskWorker // EXPERIMENTAL: a task dispatched to a task worker type PendingTask struct { - str *C.char - len C.size_t - done sync.RWMutex + str *C.char + len C.size_t + done sync.RWMutex + callback func() } func (t *PendingTask) WaitForCompletion() { @@ -59,6 +60,21 @@ func DispatchTask(task string, workerName string) (*PendingTask, error) { return pt, nil } +// EXPERIMENTAL: ExecuteTask executes the callback func() directly on a task worker thread +func ExecuteTask(callback func(), workerName string) (*PendingTask, error) { + tw := getTaskWorkerByName(workerName) + if tw == nil { + return nil, errors.New("no task worker found with name " + workerName) + } + + pt := &PendingTask{callback: callback} + pt.done.Lock() + + tw.taskChan <- pt + + return pt, nil +} + func initTaskWorkers(opts []workerOpt) error { taskWorkers = make([]*taskWorker, 0, len(opts)) for _, opt := range opts { @@ -167,7 +183,7 @@ func (handler *taskWorkerThread) setupWorkerScript() string { } func (handler *taskWorkerThread) afterScriptExecution(int) { - // potential place for cleanup after task execution + // restart the script } func (handler *taskWorkerThread) getRequestContext() *frankenPHPContext { @@ -210,6 +226,17 @@ func go_frankenphp_worker_handle_task(threadIndex C.uintptr_t) C.go_string { case task := <-handler.taskWorker.taskChan: handler.currentTask = task thread.state.markAsWaiting(false) + + // if the task has a callback, handle it directly + // callbacks may call into C (C -> GO -> C) + if task.callback != nil { + task.callback() + go_frankenphp_finish_task(threadIndex) + + return go_frankenphp_worker_handle_task(threadIndex) + } + + // if the task has no callback, forward it to PHP return C.go_string{len: task.len, data: task.str} case <-handler.thread.drainChan: thread.state.markAsWaiting(false) diff --git a/types.go b/types.go index 4fe2352a4..097bf0211 100644 --- a/types.go +++ b/types.go @@ -313,3 +313,13 @@ func castZval(zval *C.zval, expectedType C.uint8_t) unsafe.Pointer { return nil } } + +func zvalPtrDtor(p unsafe.Pointer) { + zv := (*C.zval)(p) + C.zval_ptr_dtor(zv) +} + +func zendStringRelease(p unsafe.Pointer) { + zs := (*C.zend_string)(p) + C.zend_string_release(zs) +} diff --git a/types_test.go b/types_test.go index 9499301f6..21ef79804 100644 --- a/types_test.go +++ b/types_test.go @@ -1,36 +1,40 @@ package frankenphp import ( - "io" "log/slog" "testing" "github.com/stretchr/testify/assert" + "go.uber.org/zap/exp/zapslog" + "go.uber.org/zap/zaptest" ) // execute the function on a PHP thread directly // this is necessary if tests make use of PHP's internal allocation -func testOnDummyPHPThread(t *testing.T, test func()) { +func testOnDummyPHPThread(t *testing.T, cb func()) { t.Helper() - logger = slog.New(slog.NewTextHandler(io.Discard, nil)) - _, err := initPHPThreads(1, 1, nil) // boot 1 thread + logger = slog.New(zapslog.NewHandler(zaptest.NewLogger(t).Core())) + assert.NoError(t, Init( + WithWorkers("tw", "./testdata/tasks/task-worker.php", 1, AsTaskWorker(true)), + WithNumThreads(2), + WithLogger(logger), + )) + defer Shutdown() + + task, err := ExecuteTask(cb, "tw") assert.NoError(t, err) - handler := convertToTaskThread(phpThreads[0]) - task := newTask(test) - handler.execute(task) - task.waitForCompletion() - - drainPHPThreads() + task.WaitForCompletion() } func TestGoString(t *testing.T) { testOnDummyPHPThread(t, func() { originalString := "Hello, World!" - convertedString := GoString(PHPString(originalString, false)) + phpString := PHPString(originalString, false) + defer zendStringRelease(phpString) - assert.Equal(t, originalString, convertedString, "string -> zend_string -> string should yield an equal string") + assert.Equal(t, originalString, GoString(phpString), "string -> zend_string -> string should yield an equal string") }) } @@ -41,9 +45,10 @@ func TestPHPMap(t *testing.T) { "foo2": "bar2", } - convertedMap := GoMap(PHPMap(originalMap)) + phpArray := PHPMap(originalMap) + defer zvalPtrDtor(phpArray) - assert.Equal(t, originalMap, convertedMap, "associative array should be equal after conversion") + assert.Equal(t, originalMap, GoMap(phpArray), "associative array should be equal after conversion") }) } @@ -57,9 +62,10 @@ func TestOrderedPHPAssociativeArray(t *testing.T) { Order: []string{"foo2", "foo1"}, } - convertedArray := GoAssociativeArray(PHPAssociativeArray(originalArray)) + phpArray := PHPAssociativeArray(originalArray) + defer zvalPtrDtor(phpArray) - assert.Equal(t, originalArray, convertedArray, "associative array should be equal after conversion") + assert.Equal(t, originalArray, GoAssociativeArray(phpArray), "associative array should be equal after conversion") }) } @@ -67,9 +73,10 @@ func TestPHPPackedArray(t *testing.T) { testOnDummyPHPThread(t, func() { originalSlice := []any{"bar1", "bar2"} - convertedSlice := GoPackedArray(PHPPackedArray(originalSlice)) + phpArray := PHPPackedArray(originalSlice) + defer zvalPtrDtor(phpArray) - assert.Equal(t, originalSlice, convertedSlice, "slice should be equal after conversion") + assert.Equal(t, originalSlice, GoPackedArray(phpArray), "slice should be equal after conversion") }) } @@ -81,9 +88,10 @@ func TestPHPPackedArrayToGoMap(t *testing.T) { "1": "bar2", } - convertedMap := GoMap(PHPPackedArray(originalSlice)) + phpArray := PHPPackedArray(originalSlice) + defer zvalPtrDtor(phpArray) - assert.Equal(t, expectedMap, convertedMap, "convert a packed to an associative array") + assert.Equal(t, expectedMap, GoMap(phpArray), "convert a packed to an associative array") }) } @@ -98,9 +106,10 @@ func TestPHPAssociativeArrayToPacked(t *testing.T) { } expectedSlice := []any{"bar1", "bar2"} - convertedSlice := GoPackedArray(PHPAssociativeArray(originalArray)) + phpArray := PHPAssociativeArray(originalArray) + defer zvalPtrDtor(phpArray) - assert.Equal(t, expectedSlice, convertedSlice, "convert an associative array to a slice") + assert.Equal(t, expectedSlice, GoPackedArray(phpArray), "convert an associative array to a slice") }) } @@ -120,8 +129,9 @@ func TestNestedMixedArray(t *testing.T) { }, } - convertedArray := GoMap(PHPMap(originalArray)) + phpArray := PHPMap(originalArray) + defer zvalPtrDtor(phpArray) - assert.Equal(t, originalArray, convertedArray, "nested mixed array should be equal after conversion") + assert.Equal(t, originalArray, GoMap(phpArray), "nested mixed array should be equal after conversion") }) } From 83c7a88ec7f5ba7f4809e26c8b93dbc685f7b8f6 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 20 Sep 2025 22:27:30 +0200 Subject: [PATCH 21/45] Cleanup. --- threadtaskworker.go | 26 ++++++++++++-------------- threadtaskworker_test.go | 5 ++++- worker.go | 8 -------- 3 files changed, 16 insertions(+), 23 deletions(-) diff --git a/threadtaskworker.go b/threadtaskworker.go index 27d00a208..d64010022 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -61,6 +61,7 @@ func DispatchTask(task string, workerName string) (*PendingTask, error) { } // EXPERIMENTAL: ExecuteTask executes the callback func() directly on a task worker thread +// this gives the callback access to PHP's memory management func ExecuteTask(callback func(), workerName string) (*PendingTask, error) { tw := getTaskWorkerByName(workerName) if tw == nil { @@ -77,26 +78,24 @@ func ExecuteTask(callback func(), workerName string) (*PendingTask, error) { func initTaskWorkers(opts []workerOpt) error { taskWorkers = make([]*taskWorker, 0, len(opts)) + ready := sync.WaitGroup{} for _, opt := range opts { filename, err := fastabs.FastAbs(opt.fileName) if err != nil { return err } - taskWorkers = append(taskWorkers, - &taskWorker{ - threads: make([]*phpThread, 0, opt.num), - filename: filename, - taskChan: make(chan *PendingTask), - name: opt.name, - num: opt.num, - env: opt.env, - }, - ) - } + tw := &taskWorker{ + threads: make([]*phpThread, 0, opt.num), + filename: filename, + taskChan: make(chan *PendingTask), + name: opt.name, + num: opt.num, + env: opt.env, + } + taskWorkers = append(taskWorkers, tw) - ready := sync.WaitGroup{} - for _, tw := range taskWorkers { + // start the actual PHP threads ready.Add(tw.num) for i := 0; i < tw.num; i++ { thread := getInactivePHPThread() @@ -107,7 +106,6 @@ func initTaskWorkers(opts []workerOpt) error { }(thread) } } - ready.Wait() return nil diff --git a/threadtaskworker_test.go b/threadtaskworker_test.go index 73507cd4d..d16fefac5 100644 --- a/threadtaskworker_test.go +++ b/threadtaskworker_test.go @@ -36,8 +36,11 @@ func TestDispatchToTaskWorker(t *testing.T) { WithNumThreads(3), WithLogger(logger), )) - defer Shutdown() assert.Len(t, taskWorkers, 1) + defer func() { + Shutdown() + assert.Len(t, taskWorkers[0].threads, 0, "no task-worker threads should remain after shutdown") + }() pendingTask, err := DispatchTask("go task", "worker") assert.NoError(t, err) diff --git a/worker.go b/worker.go index 26d56335c..5624e82b6 100644 --- a/worker.go +++ b/worker.go @@ -188,14 +188,6 @@ func (worker *worker) detachThread(thread *phpThread) { worker.threadMutex.Unlock() } -func (worker *worker) countThreads() int { - worker.threadMutex.RLock() - l := len(worker.threads) - worker.threadMutex.RUnlock() - - return l -} - func (worker *worker) handleRequest(fc *frankenPHPContext) { metrics.StartWorkerRequest(worker.name) From 99bb21f6461c975bab3f0240f27aa2f9474aa92e Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 5 Oct 2025 11:33:19 +0200 Subject: [PATCH 22/45] Allows setting args with task-workers. --- caddy/app.go | 1 + caddy/workerconfig.go | 4 ++++ cgi.go | 4 +--- frankenphp.c | 16 ++++++++++++++-- frankenphp.h | 2 ++ options.go | 9 +++++++++ phpthread.go | 4 ++++ testdata/tasks/task-worker.php | 1 + threadregular.go | 1 + threadtaskworker.go | 23 +++++++++++++++++++---- threadtaskworker_test.go | 3 +++ threadworker.go | 1 + 12 files changed, 60 insertions(+), 9 deletions(-) diff --git a/caddy/app.go b/caddy/app.go index 7118080eb..c16e3d484 100644 --- a/caddy/app.go +++ b/caddy/app.go @@ -134,6 +134,7 @@ func (f *FrankenPHPApp) Start() error { frankenphp.WithWorkerEnv(tw.Env), frankenphp.WithWorkerWatchMode(tw.Watch), frankenphp.AsTaskWorker(true), + frankenphp.WithWorkerArgs(tw.Args), } opts = append(opts, frankenphp.WithWorkers(tw.Name, repl.ReplaceKnown(tw.FileName, ""), tw.Num, workerOpts...)) diff --git a/caddy/workerconfig.go b/caddy/workerconfig.go index 60a2c6fd3..ac04df39b 100644 --- a/caddy/workerconfig.go +++ b/caddy/workerconfig.go @@ -37,6 +37,8 @@ type workerConfig struct { MatchPath []string `json:"match_path,omitempty"` // MaxConsecutiveFailures sets the maximum number of consecutive failures before panicking (defaults to 6, set to -1 to never panick) MaxConsecutiveFailures int `json:"max_consecutive_failures,omitempty"` + // Args sets the command line arguments to pass to the worker script + Args []string `json:"args,omitempty"` } func parseWorkerConfig(d *caddyfile.Dispenser) (workerConfig, error) { @@ -122,6 +124,8 @@ func parseWorkerConfig(d *caddyfile.Dispenser) (workerConfig, error) { } wc.MaxConsecutiveFailures = int(v) + case "args": + wc.Args = d.RemainingArgs() default: allowedDirectives := "name, file, num, env, watch, match, max_consecutive_failures" return wc, wrongSubDirectiveError("worker", allowedDirectives, v) diff --git a/cgi.go b/cgi.go index f9aae8690..8d98f75e5 100644 --- a/cgi.go +++ b/cgi.go @@ -275,7 +275,7 @@ func splitPos(path string, splitPath []string) int { // See: https://github.com/php/php-src/blob/345e04b619c3bc11ea17ee02cdecad6ae8ce5891/main/SAPI.h#L72 // //export go_update_request_info -func go_update_request_info(threadIndex C.uintptr_t, info *C.sapi_request_info) C.bool { +func go_update_request_info(threadIndex C.uintptr_t, info *C.sapi_request_info) { thread := phpThreads[threadIndex] fc := thread.getRequestContext() request := fc.request @@ -305,8 +305,6 @@ func go_update_request_info(threadIndex C.uintptr_t, info *C.sapi_request_info) info.request_uri = thread.pinCString(request.URL.RequestURI()) info.proto_num = C.int(request.ProtoMajor*1000 + request.ProtoMinor) - - return C.bool(fc.worker != nil) } // SanitizedPathJoin performs filepath.Join(root, reqPath) that diff --git a/frankenphp.c b/frankenphp.c index bb4794efc..059d824cc 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -73,6 +73,7 @@ frankenphp_config frankenphp_get_config() { bool should_filter_var = 0; __thread uintptr_t thread_index; __thread bool is_worker_thread = false; +__thread bool is_task_worker_thread = false; __thread zval *os_environment = NULL; static void frankenphp_update_request_context() { @@ -82,7 +83,12 @@ static void frankenphp_update_request_context() { /* status It is not reset by zend engine, set it to 200. */ SG(sapi_headers).http_response_code = 200; - is_worker_thread = go_update_request_info(thread_index, &SG(request_info)); + go_update_request_info(thread_index, &SG(request_info)); +} + +void frankenphp_update_thread_context(bool is_worker, bool is_task_worker) { + is_worker_thread = is_worker; + is_task_worker_thread = is_task_worker; } static void frankenphp_free_request_context() { @@ -492,7 +498,7 @@ PHP_FUNCTION(frankenphp_handle_task) { Z_PARAM_FUNC(fci, fcc) ZEND_PARSE_PARAMETERS_END(); - if (!go_is_task_worker_thread(thread_index)) { + if (!is_task_worker_thread) { zend_throw_exception( spl_ce_RuntimeException, "frankenphp_handle_task() called while not in worker mode", 0); @@ -862,6 +868,12 @@ static void frankenphp_register_variables(zval *track_vars_array) { * variables. */ + /* task workers may have argv and argc configured via config */ + if(is_task_worker_thread) { + go_register_args(thread_index, &SG(request_info)); + php_build_argv(NULL, track_vars_array); + } + /* in non-worker mode we import the os environment regularly */ if (!is_worker_thread) { get_full_env(track_vars_array); diff --git a/frankenphp.h b/frankenphp.h index c17df6061..cab6efb90 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -86,4 +86,6 @@ void frankenphp_register_bulk( void register_extensions(zend_module_entry *m, int len); +void frankenphp_update_thread_context(bool is_worker, bool is_task_worker); + #endif diff --git a/options.go b/options.go index ff395b3c6..8054acf29 100644 --- a/options.go +++ b/options.go @@ -37,6 +37,7 @@ type workerOpt struct { watch []string maxConsecutiveFailures int isTaskWorker bool + args []string } // WithNumThreads configures the number of PHP threads to start. @@ -155,3 +156,11 @@ func AsTaskWorker(isTaskWorker bool) WorkerOption { return nil } } + +// EXPERIMENTAL: WithWorkerArgs configures argv and argc. +func WithWorkerArgs(args []string) WorkerOption { + return func(w *workerOpt) error { + w.args = args + return nil + } +} diff --git a/phpthread.go b/phpthread.go index a60aa8f0a..2f88e31fb 100644 --- a/phpthread.go +++ b/phpthread.go @@ -132,6 +132,10 @@ func (thread *phpThread) pinCString(s string) *C.char { return thread.pinString(s + "\x00") } +func (thread *phpThread) updateContext(isWorker bool, isTaskWorker bool) { + C.frankenphp_update_thread_context(C.bool(isWorker), C.bool(isTaskWorker)) +} + //export go_frankenphp_before_script_execution func go_frankenphp_before_script_execution(threadIndex C.uintptr_t) *C.char { thread := phpThreads[threadIndex] diff --git a/testdata/tasks/task-worker.php b/testdata/tasks/task-worker.php index 4b06a90d6..bf2a1684b 100644 --- a/testdata/tasks/task-worker.php +++ b/testdata/tasks/task-worker.php @@ -3,6 +3,7 @@ $handleFunc = function ($task) { echo "$task"; echo $_SERVER['CUSTOM_VAR'] ?? 'no custom var'; + echo join(' ', $_SERVER['argv']); }; $maxRequests = 1000; diff --git a/threadregular.go b/threadregular.go index 88cef7e79..570487470 100644 --- a/threadregular.go +++ b/threadregular.go @@ -35,6 +35,7 @@ func (handler *regularThread) beforeScriptExecution() string { return handler.thread.transitionToNewHandler() case stateTransitionComplete: handler.state.set(stateReady) + handler.thread.updateContext(false, false) return handler.waitForRequest() case stateReady: return handler.waitForRequest() diff --git a/threadtaskworker.go b/threadtaskworker.go index d64010022..7d8e08526 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -1,6 +1,7 @@ package frankenphp // #include "frankenphp.h" +// #include import "C" import ( "errors" @@ -8,6 +9,7 @@ import ( "path/filepath" "sync" "sync/atomic" + "unsafe" ) type taskWorker struct { @@ -19,6 +21,8 @@ type taskWorker struct { num int env PreparedEnv queueLen atomic.Int32 + argv **C.char + argc C.int } // representation of a thread that handles tasks directly assigned by go @@ -85,6 +89,9 @@ func initTaskWorkers(opts []workerOpt) error { return err } + fullArgs := append([]string{filename}, opt.args...) + argc, argv := convertArgs(fullArgs) + tw := &taskWorker{ threads: make([]*phpThread, 0, opt.num), filename: filename, @@ -92,6 +99,8 @@ func initTaskWorkers(opts []workerOpt) error { name: opt.name, num: opt.num, env: opt.env, + argv: (**C.char)(unsafe.Pointer(&argv[0])), + argc: argc, } taskWorkers = append(taskWorkers, tw) @@ -146,6 +155,7 @@ func (handler *taskWorkerThread) beforeScriptExecution() string { tw.threads = append(tw.threads, thread) tw.threadMutex.Unlock() thread.state.set(stateReady) + thread.updateContext(false, true) return handler.setupWorkerScript() case stateReady: @@ -296,10 +306,15 @@ func go_frankenphp_worker_dispatch_task(taskChar *C.char, taskLen C.size_t, name return C.bool(true) } -//export go_is_task_worker_thread -func go_is_task_worker_thread(threadIndex C.uintptr_t) C.bool { +//export go_register_args +func go_register_args(threadIndex C.uintptr_t, info *C.sapi_request_info) { thread := phpThreads[threadIndex] - _, ok := thread.handler.(*taskWorkerThread) + handler, ok := thread.handler.(*taskWorkerThread) + + if !ok { + panic("thread is not a task thread") + } - return C.bool(ok) + info.argc = handler.taskWorker.argc + info.argv = handler.taskWorker.argv } diff --git a/threadtaskworker_test.go b/threadtaskworker_test.go index d16fefac5..4858a5555 100644 --- a/threadtaskworker_test.go +++ b/threadtaskworker_test.go @@ -32,6 +32,7 @@ func TestDispatchToTaskWorker(t *testing.T) { 1, AsTaskWorker(true), WithWorkerEnv(PreparedEnv{"CUSTOM_VAR": "custom var"}), + WithWorkerArgs([]string{"arg1", "arg2"}), ), WithNumThreads(3), WithLogger(logger), @@ -49,6 +50,8 @@ func TestDispatchToTaskWorker(t *testing.T) { logOutput := buf.String() assert.Contains(t, logOutput, "go task", "should see the dispatched task in the logs") assert.Contains(t, logOutput, "custom var", "should see the prepared env of the task worker") + assert.Contains(t, logOutput, "arg1", "should see args passed to the task worker") + assert.Contains(t, logOutput, "arg2", "should see args passed to the task worker") } func TestDispatchToTaskWorkerFromWorker(t *testing.T) { diff --git a/threadworker.go b/threadworker.go index 745b8f882..b0346680e 100644 --- a/threadworker.go +++ b/threadworker.go @@ -61,6 +61,7 @@ func (handler *workerThread) beforeScriptExecution() string { if handler.externalWorker != nil { handler.externalWorker.ThreadActivatedNotification(handler.thread.threadIndex) } + handler.thread.updateContext(true, false) setupWorkerScript(handler, handler.worker) return handler.worker.fileName case stateShuttingDown: From 0c0a0cb19bbc84695ca99018f8bdf325a8aa8e36 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 7 Oct 2025 20:39:54 +0200 Subject: [PATCH 23/45] Adds more tests. --- caddy/caddy_test.go | 42 +++++++++++++++++++++++++++++++++ testdata/tasks/task-worker2.php | 9 +++++++ threadtaskworker.go | 14 +++++------ threadtaskworker_test.go | 2 +- 4 files changed, 59 insertions(+), 8 deletions(-) create mode 100644 testdata/tasks/task-worker2.php diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index efd9e30aa..557e69b43 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -1423,3 +1423,45 @@ func TestWorkerMatchDirectiveWithoutFileServer(t *testing.T) { // the request should completely fall through the php_server module tester.AssertGetResponse("http://localhost:"+testPort+"/static.txt", http.StatusNotFound, "Request falls through") } + +func TestServerWithTaskWorker(t *testing.T) { + tester := caddytest.NewTester(t) + taskWorker1, err := fastabs.FastAbs("../testdata/tasks/task-worker.php") + require.NoError(t, err) + taskWorker2, err := fastabs.FastAbs("../testdata/tasks/task-worker2.php") + require.NoError(t, err) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + + frankenphp { + num_threads 3 + task_worker `+taskWorker1+` { + args foo bar + num 1 + } + task_worker `+taskWorker2+` { + args foo bar + num 1 + } + } + } + `, "caddyfile") + + debugState := getDebugState(t, tester) + require.Len(t, debugState.ThreadDebugStates, 3, "there should be 3 threads") + require.Equal( + t, + debugState.ThreadDebugStates[1].Name, + "Task Worker PHP Thread - "+taskWorker1, + "the second spawned thread should be the task worker", + ) + + require.Equal( + t, + debugState.ThreadDebugStates[2].Name, + "Task Worker PHP Thread - "+taskWorker2, + "the third spawned thread should belong to the second task worker", + ) +} diff --git a/testdata/tasks/task-worker2.php b/testdata/tasks/task-worker2.php new file mode 100644 index 000000000..389132e03 --- /dev/null +++ b/testdata/tasks/task-worker2.php @@ -0,0 +1,9 @@ + Date: Tue, 7 Oct 2025 21:04:32 +0200 Subject: [PATCH 24/45] Adjusts naming. --- frankenphp.c | 21 ++++++++++----------- threadtaskworker.go | 8 ++++---- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 059d824cc..55d6a4328 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -523,9 +523,11 @@ PHP_FUNCTION(frankenphp_handle_task) { fci.size = sizeof fci; fci.retval = &retval; - /* ZVAL_STRINGL_FAST will consume the malloced char without copying it */ + /* copy the string to thread-local memory */ zval taskzv; ZVAL_STRINGL_FAST(&taskzv, task.data, task.len); + pefree(task.data, 1); + fci.params = &taskzv; fci.param_count = 1; if (zend_call_function(&fci, &fcc) == SUCCESS) { @@ -547,8 +549,6 @@ PHP_FUNCTION(frankenphp_handle_task) { go_frankenphp_finish_task(thread_index); - /* free the task zval (right now always a string) */ - free(task.data); zval_ptr_dtor(&taskzv); RETURN_TRUE; @@ -566,15 +566,14 @@ PHP_FUNCTION(frankenphp_dispatch_task) { Z_PARAM_STRING(worker_name, worker_name_len); ZEND_PARSE_PARAMETERS_END(); - /* copy the zval to be used in the other thread safely, right now only strings - * are supported */ - char *task_copy = malloc(task_len); - memcpy(task_copy, task_string, task_len); + /* copy the zval to be used in the other thread safely */ + char *task_str = pemalloc(task_len, 1); + memcpy(task_str, task_string, task_len); bool success = go_frankenphp_worker_dispatch_task( - task_copy, task_len, worker_name, worker_name_len); + task_str, task_len, worker_name, worker_name_len); if (!success) { - free(task_copy); + pefree(task_str, 1); zend_throw_exception(spl_ce_RuntimeException, "No worker found to handle the task", 0); RETURN_THROWS(); @@ -869,8 +868,8 @@ static void frankenphp_register_variables(zval *track_vars_array) { */ /* task workers may have argv and argc configured via config */ - if(is_task_worker_thread) { - go_register_args(thread_index, &SG(request_info)); + if (is_task_worker_thread) { + go_register_task_worker_args(thread_index, &SG(request_info)); php_build_argv(NULL, track_vars_array); } diff --git a/threadtaskworker.go b/threadtaskworker.go index 9dbfa98bb..b392c14dd 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -266,7 +266,7 @@ func go_frankenphp_finish_task(threadIndex C.uintptr_t) { } //export go_frankenphp_worker_dispatch_task -func go_frankenphp_worker_dispatch_task(taskChar *C.char, taskLen C.size_t, name *C.char, nameLen C.size_t) C.bool { +func go_frankenphp_worker_dispatch_task(taskStr *C.char, taskLen C.size_t, name *C.char, nameLen C.size_t) C.bool { var worker *taskWorker if name != nil && nameLen != 0 { worker = getTaskWorkerByName(C.GoStringN(name, C.int(nameLen))) @@ -280,7 +280,7 @@ func go_frankenphp_worker_dispatch_task(taskChar *C.char, taskLen C.size_t, name } // create a new task and lock it until the task is done - task := &PendingTask{str: taskChar, len: taskLen} + task := &PendingTask{str: taskStr, len: taskLen} task.done.Lock() // dispatch immediately if available (best performance) @@ -306,8 +306,8 @@ func go_frankenphp_worker_dispatch_task(taskChar *C.char, taskLen C.size_t, name return C.bool(true) } -//export go_register_args -func go_register_args(threadIndex C.uintptr_t, info *C.sapi_request_info) { +//export go_register_task_worker_args +func go_register_task_worker_args(threadIndex C.uintptr_t, info *C.sapi_request_info) { thread := phpThreads[threadIndex] handler, ok := thread.handler.(*taskWorkerThread) From df7e77d3a6e9f977c632722f043695df9aeb2c7e Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 7 Oct 2025 21:08:16 +0200 Subject: [PATCH 25/45] Adjusts naming. --- threadtaskworker.go | 18 +++++++++--------- types_test.go | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/threadtaskworker.go b/threadtaskworker.go index b392c14dd..f7b5e35e3 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -50,10 +50,10 @@ func (t *PendingTask) WaitForCompletion() { } // EXPERIMENTAL: DispatchTask dispatches a task to a named task worker -func DispatchTask(task string, workerName string) (*PendingTask, error) { - tw := getTaskWorkerByName(workerName) +func DispatchTask(task string, taskWorkerName string) (*PendingTask, error) { + tw := getTaskWorkerByName(taskWorkerName) if tw == nil { - return nil, errors.New("no task worker found with name " + workerName) + return nil, errors.New("no task worker found with name " + taskWorkerName) } pt := &PendingTask{str: C.CString(task), len: C.size_t(len(task))} @@ -64,12 +64,12 @@ func DispatchTask(task string, workerName string) (*PendingTask, error) { return pt, nil } -// EXPERIMENTAL: ExecuteTask executes the callback func() directly on a task worker thread +// EXPERIMENTAL: ExecuteCallbackOnTaskWorker executes the callback func() directly on a task worker thread // this gives the callback access to PHP's memory management -func ExecuteTask(callback func(), workerName string) (*PendingTask, error) { - tw := getTaskWorkerByName(workerName) +func ExecuteCallbackOnTaskWorker(callback func(), taskWorkerName string) (*PendingTask, error) { + tw := getTaskWorkerByName(taskWorkerName) if tw == nil { - return nil, errors.New("no task worker found with name " + workerName) + return nil, errors.New("no task worker found with name " + taskWorkerName) } pt := &PendingTask{callback: callback} @@ -283,20 +283,20 @@ func go_frankenphp_worker_dispatch_task(taskStr *C.char, taskLen C.size_t, name task := &PendingTask{str: taskStr, len: taskLen} task.done.Lock() - // dispatch immediately if available (best performance) + // dispatch task immediately if a thread available (best performance) select { case worker.taskChan <- task: return C.bool(true) default: } + // otherwise queue up in a non-blocking way // make sure the queue is not too full if worker.queueLen.Load() >= maxQueueLen { logger.Error("task worker queue is full, dropping task", "name", worker.name) return C.bool(false) } - // otherwise queue up in a non-blocking way go func() { worker.queueLen.Add(1) worker.taskChan <- task diff --git a/types_test.go b/types_test.go index 21ef79804..72ce14c2c 100644 --- a/types_test.go +++ b/types_test.go @@ -21,7 +21,7 @@ func testOnDummyPHPThread(t *testing.T, cb func()) { )) defer Shutdown() - task, err := ExecuteTask(cb, "tw") + task, err := ExecuteCallbackOnTaskWorker(cb, "tw") assert.NoError(t, err) task.WaitForCompletion() From 77fec2b4a72db1f75ecebeffdbbc987ab77c8db8 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 7 Oct 2025 21:23:40 +0200 Subject: [PATCH 26/45] Adds docs. --- docs/worker.md | 46 ++++++++++++++++++++++++++++++++++ testdata/tasks/task-worker.php | 8 +++--- threadtaskworker.go | 3 ++- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/docs/worker.md b/docs/worker.md index 81c18266a..64aed5c1d 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -179,3 +179,49 @@ $handler = static function () use ($workerServer) { // ... ``` + +## Task workers + +`workers` are used to handle HTTP requests, but FrankenPHP also supports `task workers` that can handle +asynchronous tasks in the background. Task workers can also be used to just execute a script in a loop. + +```caddyfile +frankenphp { + task_worker { + name my-task-worker # name of the task worker + file /path/to/task-worker.php # path to the script to run + num 4 # number of task workers to start + args arg1 arg2 # args to pass to the script (to simulate $argv in cli mode) + } +} +``` + +Tasks workers may call `frankenphp_handle_task()` to wait for a task to be assigned to them. Tasks +may be dispatched from anywhere in the process using `frankenphp_dispatch_task()`. + +```php + Date: Tue, 7 Oct 2025 21:29:29 +0200 Subject: [PATCH 27/45] Fixes pinning. --- threadtaskworker.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/threadtaskworker.go b/threadtaskworker.go index d3bb184d5..7dd701fe2 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -21,7 +21,7 @@ type taskWorker struct { num int env PreparedEnv queueLen atomic.Int32 - argv **C.char + argv []*C.char argc C.int } @@ -100,7 +100,7 @@ func initTaskWorkers(opts []workerOpt) error { name: opt.name, num: opt.num, env: opt.env, - argv: (**C.char)(unsafe.Pointer(&argv[0])), + argv: argv, argc: argc, } taskWorkers = append(taskWorkers, tw) @@ -317,5 +317,5 @@ func go_register_task_worker_args(threadIndex C.uintptr_t, info *C.sapi_request_ } info.argc = handler.taskWorker.argc - info.argv = handler.taskWorker.argv + info.argv = (**C.char)(unsafe.Pointer(&handler.taskWorker.argv[0])) } From b23f3f833e71324c70293d9fb712a44d504a1f5e Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 7 Oct 2025 22:20:27 +0200 Subject: [PATCH 28/45] Foxes pinning. --- threadtaskworker.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/threadtaskworker.go b/threadtaskworker.go index 7dd701fe2..7ff903f32 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -316,6 +316,8 @@ func go_register_task_worker_args(threadIndex C.uintptr_t, info *C.sapi_request_ panic("thread is not a task thread") } + ptr := unsafe.Pointer(&handler.taskWorker.argv[0]) + thread.Pin(ptr) + info.argv = (**C.char)(ptr) info.argc = handler.taskWorker.argc - info.argv = (**C.char)(unsafe.Pointer(&handler.taskWorker.argv[0])) } From 58d1761fe8eb224a4edeb7c1e55ee8a7ae1a6fa2 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 11 Oct 2025 21:11:14 +0200 Subject: [PATCH 29/45] Simplifies by removing args. --- caddy/app.go | 1 - frankenphp.c | 6 ------ options.go | 9 --------- threadtaskworker.go | 37 ++----------------------------------- threadtaskworker_test.go | 3 --- 5 files changed, 2 insertions(+), 54 deletions(-) diff --git a/caddy/app.go b/caddy/app.go index c16e3d484..7118080eb 100644 --- a/caddy/app.go +++ b/caddy/app.go @@ -134,7 +134,6 @@ func (f *FrankenPHPApp) Start() error { frankenphp.WithWorkerEnv(tw.Env), frankenphp.WithWorkerWatchMode(tw.Watch), frankenphp.AsTaskWorker(true), - frankenphp.WithWorkerArgs(tw.Args), } opts = append(opts, frankenphp.WithWorkers(tw.Name, repl.ReplaceKnown(tw.FileName, ""), tw.Num, workerOpts...)) diff --git a/frankenphp.c b/frankenphp.c index 55d6a4328..e7295fcb9 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -867,12 +867,6 @@ static void frankenphp_register_variables(zval *track_vars_array) { * variables. */ - /* task workers may have argv and argc configured via config */ - if (is_task_worker_thread) { - go_register_task_worker_args(thread_index, &SG(request_info)); - php_build_argv(NULL, track_vars_array); - } - /* in non-worker mode we import the os environment regularly */ if (!is_worker_thread) { get_full_env(track_vars_array); diff --git a/options.go b/options.go index 8054acf29..ff395b3c6 100644 --- a/options.go +++ b/options.go @@ -37,7 +37,6 @@ type workerOpt struct { watch []string maxConsecutiveFailures int isTaskWorker bool - args []string } // WithNumThreads configures the number of PHP threads to start. @@ -156,11 +155,3 @@ func AsTaskWorker(isTaskWorker bool) WorkerOption { return nil } } - -// EXPERIMENTAL: WithWorkerArgs configures argv and argc. -func WithWorkerArgs(args []string) WorkerOption { - return func(w *workerOpt) error { - w.args = args - return nil - } -} diff --git a/threadtaskworker.go b/threadtaskworker.go index 7ff903f32..9cbda62f6 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -9,7 +9,6 @@ import ( "path/filepath" "sync" "sync/atomic" - "unsafe" ) type taskWorker struct { @@ -20,9 +19,6 @@ type taskWorker struct { name string num int env PreparedEnv - queueLen atomic.Int32 - argv []*C.char - argc C.int } // representation of a thread that handles tasks directly assigned by go or via frankenphp_dispatch_task() @@ -35,7 +31,7 @@ type taskWorkerThread struct { currentTask *PendingTask } -const maxQueueLen = 1500 // TODO: configurable or via memory limit or doesn't matter? +const maxQueueLen = 1500 // TODO: make configurable somehow var taskWorkers []*taskWorker // EXPERIMENTAL: a task dispatched to a task worker @@ -90,18 +86,13 @@ func initTaskWorkers(opts []workerOpt) error { return err } - fullArgs := append([]string{fileName}, opt.args...) - argc, argv := convertArgs(fullArgs) - tw := &taskWorker{ threads: make([]*phpThread, 0, opt.num), fileName: fileName, - taskChan: make(chan *PendingTask), + taskChan: make(chan *PendingTask, maxQueueLen), name: opt.name, num: opt.num, env: opt.env, - argv: argv, - argc: argc, } taskWorkers = append(taskWorkers, tw) @@ -291,33 +282,9 @@ func go_frankenphp_worker_dispatch_task(taskStr *C.char, taskLen C.size_t, name default: } - // otherwise queue up in a non-blocking way - // make sure the queue is not too full - if worker.queueLen.Load() >= maxQueueLen { - logger.Error("task worker queue is full, dropping task", "name", worker.name) - return C.bool(false) - } - go func() { - worker.queueLen.Add(1) worker.taskChan <- task - worker.queueLen.Add(-1) }() return C.bool(true) } - -//export go_register_task_worker_args -func go_register_task_worker_args(threadIndex C.uintptr_t, info *C.sapi_request_info) { - thread := phpThreads[threadIndex] - handler, ok := thread.handler.(*taskWorkerThread) - - if !ok { - panic("thread is not a task thread") - } - - ptr := unsafe.Pointer(&handler.taskWorker.argv[0]) - thread.Pin(ptr) - info.argv = (**C.char)(ptr) - info.argc = handler.taskWorker.argc -} diff --git a/threadtaskworker_test.go b/threadtaskworker_test.go index fda6d9312..9f9e4b864 100644 --- a/threadtaskworker_test.go +++ b/threadtaskworker_test.go @@ -32,7 +32,6 @@ func TestDispatchToTaskWorker(t *testing.T) { 1, AsTaskWorker(true), WithWorkerEnv(PreparedEnv{"CUSTOM_VAR": "custom var"}), - WithWorkerArgs([]string{"arg1", "arg2"}), ), WithNumThreads(3), WithLogger(logger), @@ -50,8 +49,6 @@ func TestDispatchToTaskWorker(t *testing.T) { logOutput := buf.String() assert.Contains(t, logOutput, "go task", "should see the dispatched task in the logs") assert.Contains(t, logOutput, "custom var", "should see the prepared env of the task worker") - assert.Contains(t, logOutput, "arg1", "should see args passed to the task worker") - assert.Contains(t, logOutput, "arg2", "should see args passed to the task worker") } func TestDispatchToTaskWorkerFromWorker(t *testing.T) { From 05bf065a1b2b9fd8cab945a4339f47c05ed38864 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 11 Oct 2025 22:13:03 +0200 Subject: [PATCH 30/45] Uses goValue and phpValue for task dispatching. --- frankenphp.c | 36 ++++++++---------- frankenphp.stub.php | 2 +- frankenphp_arginfo.h | 2 +- testdata/tasks/task-dispatcher-array.php | 16 ++++++++ ...patcher.php => task-dispatcher-string.php} | 0 testdata/tasks/task-worker.php | 5 +-- threadtaskworker.go | 37 ++++++++++++------- threadtaskworker_test.go | 33 ++++++++++++++--- types.c | 2 + types.go | 5 ++- types.h | 1 + 11 files changed, 94 insertions(+), 45 deletions(-) create mode 100644 testdata/tasks/task-dispatcher-array.php rename testdata/tasks/{task-dispatcher.php => task-dispatcher-string.php} (100%) diff --git a/frankenphp.c b/frankenphp.c index e7295fcb9..8a79ddab6 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -513,8 +513,8 @@ PHP_FUNCTION(frankenphp_handle_task) { zend_unset_timeout(); #endif - go_string task = go_frankenphp_worker_handle_task(thread_index); - if (task.data == NULL) { + zval *arg = go_frankenphp_worker_handle_task(thread_index); + if (arg == NULL) { RETURN_FALSE; } @@ -524,14 +524,16 @@ PHP_FUNCTION(frankenphp_handle_task) { fci.retval = &retval; /* copy the string to thread-local memory */ - zval taskzv; - ZVAL_STRINGL_FAST(&taskzv, task.data, task.len); - pefree(task.data, 1); - fci.params = &taskzv; + fci.params = arg; fci.param_count = 1; - if (zend_call_function(&fci, &fcc) == SUCCESS) { - zval_ptr_dtor(&retval); + zend_bool status = zend_call_function(&fci, &fcc) == SUCCESS; + + if (status == SUCCESS) { + go_frankenphp_finish_task(thread_index, &retval); + zval_ptr_dtor(&retval); + } else { + go_frankenphp_finish_task(thread_index, NULL); } /* @@ -547,33 +549,25 @@ PHP_FUNCTION(frankenphp_handle_task) { zend_try { php_output_end_all(); } zend_end_try(); - go_frankenphp_finish_task(thread_index); - - zval_ptr_dtor(&taskzv); + zval_ptr_dtor(arg); RETURN_TRUE; } PHP_FUNCTION(frankenphp_dispatch_task) { - char *task_string; - size_t task_len; + go_log("frankenphp_dispatch_task called", 1); + zval *zv; char *worker_name = NULL; size_t worker_name_len = 0; ZEND_PARSE_PARAMETERS_START(1, 2); - Z_PARAM_STRING(task_string, task_len); + Z_PARAM_ZVAL(zv); Z_PARAM_OPTIONAL Z_PARAM_STRING(worker_name, worker_name_len); ZEND_PARSE_PARAMETERS_END(); - /* copy the zval to be used in the other thread safely */ - char *task_str = pemalloc(task_len, 1); - memcpy(task_str, task_string, task_len); - - bool success = go_frankenphp_worker_dispatch_task( - task_str, task_len, worker_name, worker_name_len); + bool success = go_frankenphp_dispatch_task(zv, worker_name, worker_name_len); if (!success) { - pefree(task_str, 1); zend_throw_exception(spl_ce_RuntimeException, "No worker found to handle the task", 0); RETURN_THROWS(); diff --git a/frankenphp.stub.php b/frankenphp.stub.php index 8ed837802..10c6e90f3 100644 --- a/frankenphp.stub.php +++ b/frankenphp.stub.php @@ -6,7 +6,7 @@ function frankenphp_handle_request(callable $callback): bool {} function frankenphp_handle_task(callable $callback): bool {} -function frankenphp_dispatch_task(string $task, string $workerName = ''): void {} +function frankenphp_dispatch_task(mixed $task, string $workerName = ''): void {} function headers_send(int $status = 200): int {} diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index 0964ab607..5eb9183cc 100644 --- a/frankenphp_arginfo.h +++ b/frankenphp_arginfo.h @@ -13,7 +13,7 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_dispatch_task, 0, 1, IS_VOID, 0) -ZEND_ARG_TYPE_INFO(0, task, IS_STRING, 0) +ZEND_ARG_TYPE_INFO(0, task, IS_MIXED, 0) ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, worker_name, IS_STRING, 0, "\"\"") ZEND_END_ARG_INFO() diff --git a/testdata/tasks/task-dispatcher-array.php b/testdata/tasks/task-dispatcher-array.php new file mode 100644 index 000000000..8eaf3fe11 --- /dev/null +++ b/testdata/tasks/task-dispatcher-array.php @@ -0,0 +1,16 @@ + "array task$i", + 'worker' => $workerName, + 'index' => $i, + ]); + } + echo "dispatched $taskCount tasks\n"; +}; diff --git a/testdata/tasks/task-dispatcher.php b/testdata/tasks/task-dispatcher-string.php similarity index 100% rename from testdata/tasks/task-dispatcher.php rename to testdata/tasks/task-dispatcher-string.php diff --git a/testdata/tasks/task-worker.php b/testdata/tasks/task-worker.php index c2c283f26..0e4fc9a94 100644 --- a/testdata/tasks/task-worker.php +++ b/testdata/tasks/task-worker.php @@ -1,9 +1,8 @@ GO -> C) if task.callback != nil { task.callback() - go_frankenphp_finish_task(threadIndex) + go_frankenphp_finish_task(threadIndex, nil) return go_frankenphp_worker_handle_task(threadIndex) } // if the task has no callback, forward it to PHP - return C.go_string{len: task.len, data: task.str} + zval := phpValue(task.arg) + thread.Pin(unsafe.Pointer(zval)) + + return zval case <-handler.thread.drainChan: thread.state.markAsWaiting(false) // send an empty task to drain the thread - return C.go_string{len: 0, data: nil} + return nil } } //export go_frankenphp_finish_task -func go_frankenphp_finish_task(threadIndex C.uintptr_t) { +func go_frankenphp_finish_task(threadIndex C.uintptr_t, zv *C.zval) { thread := phpThreads[threadIndex] handler, ok := thread.handler.(*taskWorkerThread) if !ok { panic("thread is not a task thread") } + if zv != nil { + handler.currentTask.Result = goValue(zv) + } handler.currentTask.done.Unlock() handler.currentTask = nil } -//export go_frankenphp_worker_dispatch_task -func go_frankenphp_worker_dispatch_task(taskStr *C.char, taskLen C.size_t, name *C.char, nameLen C.size_t) C.bool { +//export go_frankenphp_dispatch_task +func go_frankenphp_dispatch_task(zv *C.zval, name *C.char, nameLen C.size_t) C.bool { + if zv == nil { + logger.Error("no task argument provided") + return C.bool(false) + } + var worker *taskWorker if name != nil && nameLen != 0 { worker = getTaskWorkerByName(C.GoStringN(name, C.int(nameLen))) @@ -272,7 +283,7 @@ func go_frankenphp_worker_dispatch_task(taskStr *C.char, taskLen C.size_t, name } // create a new task and lock it until the task is done - task := &PendingTask{str: taskStr, len: taskLen} + task := &PendingTask{arg: goValue(zv)} task.done.Lock() // dispatch task immediately if a thread available (best performance) diff --git a/threadtaskworker_test.go b/threadtaskworker_test.go index 9f9e4b864..3ec16793d 100644 --- a/threadtaskworker_test.go +++ b/threadtaskworker_test.go @@ -57,13 +57,13 @@ func TestDispatchToTaskWorkerFromWorker(t *testing.T) { logger := slog.New(handler) assert.NoError(t, Init( - WithWorkers("worker", "./testdata/tasks/task-worker.php", 1, AsTaskWorker(true)), - WithWorkers("worker", "./testdata/tasks/task-dispatcher.php", 1), - WithNumThreads(3), + WithWorkers("taskworker", "./testdata/tasks/task-worker.php", 1, AsTaskWorker(true)), + WithWorkers("worker1", "./testdata/tasks/task-dispatcher-string.php", 1), + WithNumThreads(3), // regular thread, task worker thread, dispatcher threads WithLogger(logger), )) - assertGetRequest(t, "http://example.com/testdata/tasks/task-dispatcher.php?count=4", "dispatched 4 tasks") + assertGetRequest(t, "http://example.com/testdata/tasks/task-dispatcher-string.php?count=4", "dispatched 4 tasks") // wait and shutdown to ensure all logs are flushed time.Sleep(10 * time.Millisecond) @@ -77,6 +77,29 @@ func TestDispatchToTaskWorkerFromWorker(t *testing.T) { assert.Contains(t, logOutput, "task3") } +func TestDispatchArrayToTaskWorker(t *testing.T) { + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + logger := slog.New(handler) + + assert.NoError(t, Init( + WithWorkers("taskworker", "./testdata/tasks/task-worker.php", 1, AsTaskWorker(true)), + WithWorkers("worker2", "./testdata/tasks/task-dispatcher-array.php", 1), + WithNumThreads(3), // regular thread, task worker thread, dispatcher thread + WithLogger(logger), + )) + + assertGetRequest(t, "http://example.com/testdata/tasks/task-dispatcher-array.php?count=1", "dispatched 1 tasks") + + // wait and shutdown to ensure all logs are flushed + time.Sleep(10 * time.Millisecond) + Shutdown() + + // task output appears in logs at info level + logOutput := buf.String() + assert.Contains(t, logOutput, "array task0") +} + func TestDispatchToMultipleWorkers(t *testing.T) { var buf bytes.Buffer handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) @@ -90,7 +113,7 @@ func TestDispatchToMultipleWorkers(t *testing.T) { )) defer Shutdown() - script := "http://example.com/testdata/tasks/task-dispatcher.php" + script := "http://example.com/testdata/tasks/task-dispatcher-string.php" assertGetRequest(t, script+"?count=1&worker=worker1", "dispatched 1 tasks") assertGetRequest(t, script+"?count=1&worker=worker2", "dispatched 1 tasks") assertGetRequest(t, script+"?count=1&worker=worker3", "No worker found to handle the task") // fail diff --git a/types.c b/types.c index 9c4887b23..ce3835fb3 100644 --- a/types.c +++ b/types.c @@ -31,6 +31,8 @@ void __zval_double__(zval *zv, double val) { ZVAL_DOUBLE(zv, val); } void __zval_string__(zval *zv, zend_string *str) { ZVAL_STR(zv, str); } +void __zval_empty_string__(zval *zv) { ZVAL_EMPTY_STRING(zv); } + void __zval_arr__(zval *zv, zend_array *arr) { ZVAL_ARR(zv, arr); } zend_array *__zend_new_array__(uint32_t size) { return zend_new_array(size); } diff --git a/types.go b/types.go index afa838913..ce2d904b1 100644 --- a/types.go +++ b/types.go @@ -214,7 +214,6 @@ func GoValue(zval unsafe.Pointer) any { func goValue(zval *C.zval) any { t := C.zval_get_type(zval) - switch t { case C.IS_NULL: return nil @@ -275,6 +274,10 @@ func phpValue(value any) *C.zval { case float64: C.__zval_double__(&zval, C.double(v)) case string: + if (v == "") { + C.__zval_empty_string__(&zval) + break + } str := (*C.zend_string)(PHPString(v, false)) C.__zval_string__(&zval, str) case AssociativeArray: diff --git a/types.h b/types.h index 853d23ca8..e667a6f7e 100644 --- a/types.h +++ b/types.h @@ -19,6 +19,7 @@ void __zval_bool__(zval *zv, bool val); void __zval_long__(zval *zv, zend_long val); void __zval_double__(zval *zv, double val); void __zval_string__(zval *zv, zend_string *str); +void __zval_empty_string__(zval *zv); void __zval_arr__(zval *zv, zend_array *arr); zend_array *__zend_new_array__(uint32_t size); From 7565628516b7f00514486a4e6c5b26f528090e90 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 11 Oct 2025 22:21:02 +0200 Subject: [PATCH 31/45] Allows setting queue len. --- caddy/app.go | 2 +- options.go | 4 +++- threadtaskworker.go | 20 +++++++++++++------- threadtaskworker_test.go | 10 +++++----- types.go | 2 +- types_test.go | 2 +- 6 files changed, 24 insertions(+), 16 deletions(-) diff --git a/caddy/app.go b/caddy/app.go index 7118080eb..1530edcff 100644 --- a/caddy/app.go +++ b/caddy/app.go @@ -133,7 +133,7 @@ func (f *FrankenPHPApp) Start() error { workerOpts := []frankenphp.WorkerOption{ frankenphp.WithWorkerEnv(tw.Env), frankenphp.WithWorkerWatchMode(tw.Watch), - frankenphp.AsTaskWorker(true), + frankenphp.AsTaskWorker(true, 0), // TODO: maxQueueLen configurable here? } opts = append(opts, frankenphp.WithWorkers(tw.Name, repl.ReplaceKnown(tw.FileName, ""), tw.Num, workerOpts...)) diff --git a/options.go b/options.go index ff395b3c6..9cb4ecf8d 100644 --- a/options.go +++ b/options.go @@ -37,6 +37,7 @@ type workerOpt struct { watch []string maxConsecutiveFailures int isTaskWorker bool + maxQueueLen int } // WithNumThreads configures the number of PHP threads to start. @@ -149,9 +150,10 @@ func WithMaxWaitTime(maxWaitTime time.Duration) Option { } // EXPERIMENTAL: AsTaskWorker configures the worker as a task worker instead. -func AsTaskWorker(isTaskWorker bool) WorkerOption { +func AsTaskWorker(isTaskWorker bool, maxQueueLen int) WorkerOption { return func(w *workerOpt) error { w.isTaskWorker = isTaskWorker + w.maxQueueLen = maxQueueLen return nil } } diff --git a/threadtaskworker.go b/threadtaskworker.go index c4adf2b73..b54d2fad4 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -31,7 +31,6 @@ type taskWorkerThread struct { currentTask *PendingTask } -const maxQueueLen = 1500 // TODO: make configurable somehow var taskWorkers []*taskWorker // EXPERIMENTAL: a task dispatched to a task worker @@ -86,10 +85,14 @@ func initTaskWorkers(opts []workerOpt) error { return err } + if opt.maxQueueLen <= 0 { + opt.maxQueueLen = 10000 // default queue len, TODO: unlimited? + } + tw := &taskWorker{ threads: make([]*phpThread, 0, opt.num), fileName: fileName, - taskChan: make(chan *PendingTask, maxQueueLen), + taskChan: make(chan *PendingTask, opt.maxQueueLen), name: opt.name, num: opt.num, env: opt.env, @@ -266,9 +269,9 @@ func go_frankenphp_finish_task(threadIndex C.uintptr_t, zv *C.zval) { //export go_frankenphp_dispatch_task func go_frankenphp_dispatch_task(zv *C.zval, name *C.char, nameLen C.size_t) C.bool { if zv == nil { - logger.Error("no task argument provided") - return C.bool(false) - } + logger.Error("no task argument provided") + return C.bool(false) + } var worker *taskWorker if name != nil && nameLen != 0 { @@ -283,14 +286,17 @@ func go_frankenphp_dispatch_task(zv *C.zval, name *C.char, nameLen C.size_t) C.b } // create a new task and lock it until the task is done - task := &PendingTask{arg: goValue(zv)} + goArg := goValue(zv) + task := &PendingTask{arg: goArg} task.done.Lock() - // dispatch task immediately if a thread available (best performance) + // dispatch to the queue select { case worker.taskChan <- task: return C.bool(true) default: + logger.Error("task worker queue is full, cannot dispatch task", "name", worker.name, "arg", goArg) + return C.bool(false) } go func() { diff --git a/threadtaskworker_test.go b/threadtaskworker_test.go index 3ec16793d..08dde4fd9 100644 --- a/threadtaskworker_test.go +++ b/threadtaskworker_test.go @@ -30,7 +30,7 @@ func TestDispatchToTaskWorker(t *testing.T) { "worker", "./testdata/tasks/task-worker.php", 1, - AsTaskWorker(true), + AsTaskWorker(true, 0), WithWorkerEnv(PreparedEnv{"CUSTOM_VAR": "custom var"}), ), WithNumThreads(3), @@ -57,7 +57,7 @@ func TestDispatchToTaskWorkerFromWorker(t *testing.T) { logger := slog.New(handler) assert.NoError(t, Init( - WithWorkers("taskworker", "./testdata/tasks/task-worker.php", 1, AsTaskWorker(true)), + WithWorkers("taskworker", "./testdata/tasks/task-worker.php", 1, AsTaskWorker(true, 0)), WithWorkers("worker1", "./testdata/tasks/task-dispatcher-string.php", 1), WithNumThreads(3), // regular thread, task worker thread, dispatcher threads WithLogger(logger), @@ -83,7 +83,7 @@ func TestDispatchArrayToTaskWorker(t *testing.T) { logger := slog.New(handler) assert.NoError(t, Init( - WithWorkers("taskworker", "./testdata/tasks/task-worker.php", 1, AsTaskWorker(true)), + WithWorkers("taskworker", "./testdata/tasks/task-worker.php", 1, AsTaskWorker(true, 0)), WithWorkers("worker2", "./testdata/tasks/task-dispatcher-array.php", 1), WithNumThreads(3), // regular thread, task worker thread, dispatcher thread WithLogger(logger), @@ -106,8 +106,8 @@ func TestDispatchToMultipleWorkers(t *testing.T) { logger := slog.New(handler) assert.NoError(t, Init( - WithWorkers("worker1", "./testdata/tasks/task-worker.php", 1, AsTaskWorker(true)), - WithWorkers("worker2", "./testdata/tasks/task-worker2.php", 1, AsTaskWorker(true)), + WithWorkers("worker1", "./testdata/tasks/task-worker.php", 1, AsTaskWorker(true, 0)), + WithWorkers("worker2", "./testdata/tasks/task-worker2.php", 1, AsTaskWorker(true, 0)), WithNumThreads(4), WithLogger(logger), )) diff --git a/types.go b/types.go index ce2d904b1..d02f627ed 100644 --- a/types.go +++ b/types.go @@ -274,7 +274,7 @@ func phpValue(value any) *C.zval { case float64: C.__zval_double__(&zval, C.double(v)) case string: - if (v == "") { + if v == "" { C.__zval_empty_string__(&zval) break } diff --git a/types_test.go b/types_test.go index 72ce14c2c..bc9fdd987 100644 --- a/types_test.go +++ b/types_test.go @@ -15,7 +15,7 @@ func testOnDummyPHPThread(t *testing.T, cb func()) { t.Helper() logger = slog.New(zapslog.NewHandler(zaptest.NewLogger(t).Core())) assert.NoError(t, Init( - WithWorkers("tw", "./testdata/tasks/task-worker.php", 1, AsTaskWorker(true)), + WithWorkers("tw", "./testdata/tasks/task-worker.php", 1, AsTaskWorker(true, 0)), WithNumThreads(2), WithLogger(logger), )) From 02a3b3f56d59e0e68aa3b53b773362c17986801b Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 11 Oct 2025 22:38:51 +0200 Subject: [PATCH 32/45] Fixes build error. --- cgi.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cgi.go b/cgi.go index 128c9bc97..6c36cf698 100644 --- a/cgi.go +++ b/cgi.go @@ -283,7 +283,7 @@ func go_update_request_info(threadIndex C.uintptr_t, info *C.sapi_request_info) request := fc.request if request == nil { - return C.bool(fc.worker != nil) + return } authUser, authPassword, ok := request.BasicAuth() From 6b9c236d9aa1eab9224f935112561f2174d643d9 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 11 Oct 2025 22:41:15 +0200 Subject: [PATCH 33/45] Removes docs (still experimental) --- docs/worker.md | 46 ---------------------------------------------- 1 file changed, 46 deletions(-) diff --git a/docs/worker.md b/docs/worker.md index 169e0a63a..e055436d3 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -185,49 +185,3 @@ $handler = static function () use ($workerServer) { // ... ``` - -## Task workers - -`workers` are used to handle HTTP requests, but FrankenPHP also supports `task workers` that can handle -asynchronous tasks in the background. Task workers can also be used to just execute a script in a loop. - -```caddyfile -frankenphp { - task_worker { - name my-task-worker # name of the task worker - file /path/to/task-worker.php # path to the script to run - num 4 # number of task workers to start - args arg1 arg2 # args to pass to the script (to simulate $argv in cli mode) - } -} -``` - -Tasks workers may call `frankenphp_handle_task()` to wait for a task to be assigned to them. Tasks -may be dispatched from anywhere in the process using `frankenphp_dispatch_task()`. - -```php - Date: Sat, 11 Oct 2025 22:53:44 +0200 Subject: [PATCH 34/45] Returns error messages directly to PHP. --- frankenphp.c | 8 +++----- threadtaskworker.go | 19 +++++-------------- threadtaskworker_test.go | 2 +- 3 files changed, 9 insertions(+), 20 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 75876fd64..b26e28f54 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -555,7 +555,6 @@ PHP_FUNCTION(frankenphp_handle_task) { } PHP_FUNCTION(frankenphp_dispatch_task) { - go_log("frankenphp_dispatch_task called", 1); zval *zv; char *worker_name = NULL; size_t worker_name_len = 0; @@ -566,10 +565,9 @@ PHP_FUNCTION(frankenphp_dispatch_task) { Z_PARAM_STRING(worker_name, worker_name_len); ZEND_PARSE_PARAMETERS_END(); - bool success = go_frankenphp_dispatch_task(zv, worker_name, worker_name_len); - if (!success) { - zend_throw_exception(spl_ce_RuntimeException, - "No worker found to handle the task", 0); + char *error = go_frankenphp_dispatch_task(thread_index, zv, worker_name, worker_name_len); + if (error) { + zend_throw_exception(spl_ce_RuntimeException, error, 0); RETURN_THROWS(); } } diff --git a/threadtaskworker.go b/threadtaskworker.go index b54d2fad4..0d3c9f258 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -267,10 +267,9 @@ func go_frankenphp_finish_task(threadIndex C.uintptr_t, zv *C.zval) { } //export go_frankenphp_dispatch_task -func go_frankenphp_dispatch_task(zv *C.zval, name *C.char, nameLen C.size_t) C.bool { +func go_frankenphp_dispatch_task(threadIndex C.uintptr_t, zv *C.zval, name *C.char, nameLen C.size_t) *C.char { if zv == nil { - logger.Error("no task argument provided") - return C.bool(false) + return phpThreads[threadIndex].pinCString("Task argument cannot be null") } var worker *taskWorker @@ -281,8 +280,7 @@ func go_frankenphp_dispatch_task(zv *C.zval, name *C.char, nameLen C.size_t) C.b } if worker == nil { - logger.Error("no task worker found to handle this task", "name", C.GoStringN(name, C.int(nameLen))) - return C.bool(false) + return phpThreads[threadIndex].pinCString("No worker found to handle this task: " + C.GoStringN(name, C.int(nameLen))) } // create a new task and lock it until the task is done @@ -293,15 +291,8 @@ func go_frankenphp_dispatch_task(zv *C.zval, name *C.char, nameLen C.size_t) C.b // dispatch to the queue select { case worker.taskChan <- task: - return C.bool(true) + return nil default: - logger.Error("task worker queue is full, cannot dispatch task", "name", worker.name, "arg", goArg) - return C.bool(false) + return phpThreads[threadIndex].pinCString("Task worker queue is full, cannot dispatch task to worker: " + worker.name) } - - go func() { - worker.taskChan <- task - }() - - return C.bool(true) } diff --git a/threadtaskworker_test.go b/threadtaskworker_test.go index 08dde4fd9..a39609346 100644 --- a/threadtaskworker_test.go +++ b/threadtaskworker_test.go @@ -116,5 +116,5 @@ func TestDispatchToMultipleWorkers(t *testing.T) { script := "http://example.com/testdata/tasks/task-dispatcher-string.php" assertGetRequest(t, script+"?count=1&worker=worker1", "dispatched 1 tasks") assertGetRequest(t, script+"?count=1&worker=worker2", "dispatched 1 tasks") - assertGetRequest(t, script+"?count=1&worker=worker3", "No worker found to handle the task") // fail + assertGetRequest(t, script+"?count=1&worker=worker3", "No worker found to handle this task") // fail } From 8144a06ebb674c59c0c282203bd418febeaf0d0b Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 11 Oct 2025 22:55:31 +0200 Subject: [PATCH 35/45] clang-format. --- frankenphp.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index b26e28f54..d6d6f63fb 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -531,7 +531,7 @@ PHP_FUNCTION(frankenphp_handle_task) { if (status == SUCCESS) { go_frankenphp_finish_task(thread_index, &retval); - zval_ptr_dtor(&retval); + zval_ptr_dtor(&retval); } else { go_frankenphp_finish_task(thread_index, NULL); } @@ -565,7 +565,8 @@ PHP_FUNCTION(frankenphp_dispatch_task) { Z_PARAM_STRING(worker_name, worker_name_len); ZEND_PARSE_PARAMETERS_END(); - char *error = go_frankenphp_dispatch_task(thread_index, zv, worker_name, worker_name_len); + char *error = go_frankenphp_dispatch_task(thread_index, zv, worker_name, + worker_name_len); if (error) { zend_throw_exception(spl_ce_RuntimeException, error, 0); RETURN_THROWS(); From 03d886d32ee7fdaaf8e7cfd28665dbc8d35a77ef Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 11 Oct 2025 23:23:58 +0200 Subject: [PATCH 36/45] Removes more code. --- caddy/caddy_test.go | 2 +- testdata/tasks/task-worker2.php | 9 ----- threadtaskworker.go | 69 ++++++++++++++++----------------- threadtaskworker_test.go | 5 +-- types_test.go | 17 +++++++- 5 files changed, 52 insertions(+), 50 deletions(-) delete mode 100644 testdata/tasks/task-worker2.php diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index 557e69b43..29398b1a6 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -1428,7 +1428,7 @@ func TestServerWithTaskWorker(t *testing.T) { tester := caddytest.NewTester(t) taskWorker1, err := fastabs.FastAbs("../testdata/tasks/task-worker.php") require.NoError(t, err) - taskWorker2, err := fastabs.FastAbs("../testdata/tasks/task-worker2.php") + taskWorker2, err := fastabs.FastAbs("../testdata/tasks/task-worker.php") require.NoError(t, err) tester.InitServer(` { diff --git a/testdata/tasks/task-worker2.php b/testdata/tasks/task-worker2.php deleted file mode 100644 index 389132e03..000000000 --- a/testdata/tasks/task-worker2.php +++ /dev/null @@ -1,9 +0,0 @@ - GO -> C) + // currently only here for tests, TODO: is this useful for extensions? if task.callback != nil { task.callback() go_frankenphp_finish_task(threadIndex, nil) @@ -273,7 +274,7 @@ func go_frankenphp_dispatch_task(threadIndex C.uintptr_t, zv *C.zval, name *C.ch } var worker *taskWorker - if name != nil && nameLen != 0 { + if nameLen != 0 { worker = getTaskWorkerByName(C.GoStringN(name, C.int(nameLen))) } else if len(taskWorkers) != 0 { worker = taskWorkers[0] @@ -286,13 +287,11 @@ func go_frankenphp_dispatch_task(threadIndex C.uintptr_t, zv *C.zval, name *C.ch // create a new task and lock it until the task is done goArg := goValue(zv) task := &PendingTask{arg: goArg} - task.done.Lock() + err := task.dispatch(worker) - // dispatch to the queue - select { - case worker.taskChan <- task: - return nil - default: - return phpThreads[threadIndex].pinCString("Task worker queue is full, cannot dispatch task to worker: " + worker.name) + if err != nil { + return phpThreads[threadIndex].pinCString(err.Error()) } + + return nil } diff --git a/threadtaskworker_test.go b/threadtaskworker_test.go index a39609346..16d424f3b 100644 --- a/threadtaskworker_test.go +++ b/threadtaskworker_test.go @@ -5,7 +5,6 @@ import ( "log/slog" "net/http/httptest" "testing" - "time" "github.com/stretchr/testify/assert" ) @@ -66,7 +65,6 @@ func TestDispatchToTaskWorkerFromWorker(t *testing.T) { assertGetRequest(t, "http://example.com/testdata/tasks/task-dispatcher-string.php?count=4", "dispatched 4 tasks") // wait and shutdown to ensure all logs are flushed - time.Sleep(10 * time.Millisecond) Shutdown() // task output appears in logs at info level @@ -92,7 +90,6 @@ func TestDispatchArrayToTaskWorker(t *testing.T) { assertGetRequest(t, "http://example.com/testdata/tasks/task-dispatcher-array.php?count=1", "dispatched 1 tasks") // wait and shutdown to ensure all logs are flushed - time.Sleep(10 * time.Millisecond) Shutdown() // task output appears in logs at info level @@ -107,7 +104,7 @@ func TestDispatchToMultipleWorkers(t *testing.T) { assert.NoError(t, Init( WithWorkers("worker1", "./testdata/tasks/task-worker.php", 1, AsTaskWorker(true, 0)), - WithWorkers("worker2", "./testdata/tasks/task-worker2.php", 1, AsTaskWorker(true, 0)), + WithWorkers("worker2", "./testdata/tasks/task-worker.php", 1, AsTaskWorker(true, 0)), WithNumThreads(4), WithLogger(logger), )) diff --git a/types_test.go b/types_test.go index bc9fdd987..9972c7f92 100644 --- a/types_test.go +++ b/types_test.go @@ -1,6 +1,7 @@ package frankenphp import ( + "errors" "log/slog" "testing" @@ -21,12 +22,26 @@ func testOnDummyPHPThread(t *testing.T, cb func()) { )) defer Shutdown() - task, err := ExecuteCallbackOnTaskWorker(cb, "tw") + task, err := executeOnPHPThread(cb, "tw") assert.NoError(t, err) task.WaitForCompletion() } +// executeOnPHPThread executes the callback func() directly on a task worker thread +// Currently only used in tests +func executeOnPHPThread(callback func(), taskWorkerName string) (*PendingTask, error) { + tw := getTaskWorkerByName(taskWorkerName) + if tw == nil { + return nil, errors.New("no task worker found with name " + taskWorkerName) + } + + pt := &PendingTask{callback: callback} + err := pt.dispatch(tw) + + return pt, err +} + func TestGoString(t *testing.T) { testOnDummyPHPThread(t, func() { originalString := "Hello, World!" From a5a93510207897ad7a87b7f7e919e523a65978dd Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 11 Oct 2025 23:29:13 +0200 Subject: [PATCH 37/45] Adds sleep back in. --- threadtaskworker_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/threadtaskworker_test.go b/threadtaskworker_test.go index 16d424f3b..408b86d1f 100644 --- a/threadtaskworker_test.go +++ b/threadtaskworker_test.go @@ -5,6 +5,7 @@ import ( "log/slog" "net/http/httptest" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -65,6 +66,7 @@ func TestDispatchToTaskWorkerFromWorker(t *testing.T) { assertGetRequest(t, "http://example.com/testdata/tasks/task-dispatcher-string.php?count=4", "dispatched 4 tasks") // wait and shutdown to ensure all logs are flushed + time.Sleep(10 * time.Millisecond) Shutdown() // task output appears in logs at info level @@ -90,6 +92,7 @@ func TestDispatchArrayToTaskWorker(t *testing.T) { assertGetRequest(t, "http://example.com/testdata/tasks/task-dispatcher-array.php?count=1", "dispatched 1 tasks") // wait and shutdown to ensure all logs are flushed + time.Sleep(10 * time.Millisecond) Shutdown() // task output appears in logs at info level From 12b6aaeac6c0bad485ef288581433a62186e8efc Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 11 Oct 2025 23:36:48 +0200 Subject: [PATCH 38/45] Prevents test race condition. --- threadtaskworker_test.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/threadtaskworker_test.go b/threadtaskworker_test.go index 408b86d1f..098dfd4a5 100644 --- a/threadtaskworker_test.go +++ b/threadtaskworker_test.go @@ -37,15 +37,14 @@ func TestDispatchToTaskWorker(t *testing.T) { WithLogger(logger), )) assert.Len(t, taskWorkers, 1) - defer func() { - Shutdown() - assert.Len(t, taskWorkers[0].threads, 0, "no task-worker threads should remain after shutdown") - }() pendingTask, err := DispatchTask("go task", "worker") assert.NoError(t, err) pendingTask.WaitForCompletion() + Shutdown() + assert.Len(t, taskWorkers[0].threads, 0, "no task-worker threads should remain after shutdown") + logOutput := buf.String() assert.Contains(t, logOutput, "go task", "should see the dispatched task in the logs") assert.Contains(t, logOutput, "custom var", "should see the prepared env of the task worker") From e801a49f3f7e0b27dcfb89d7e00c751fca5fa5f1 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 11 Oct 2025 23:36:57 +0200 Subject: [PATCH 39/45] Prevents test race condition. --- threadtaskworker_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/threadtaskworker_test.go b/threadtaskworker_test.go index 098dfd4a5..e36003a66 100644 --- a/threadtaskworker_test.go +++ b/threadtaskworker_test.go @@ -43,7 +43,7 @@ func TestDispatchToTaskWorker(t *testing.T) { pendingTask.WaitForCompletion() Shutdown() - assert.Len(t, taskWorkers[0].threads, 0, "no task-worker threads should remain after shutdown") + assert.Len(t, taskWorkers[0].threads, 0, "no task-worker threads should remain after shutdown") logOutput := buf.String() assert.Contains(t, logOutput, "go task", "should see the dispatched task in the logs") From 3f63a4d137d359f70820da07c6111021c08d5771 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 26 Oct 2025 20:21:19 +0100 Subject: [PATCH 40/45] Combines frankenphp_handle_task() and frankenphp_handle_request(). --- caddy/workerconfig.go | 2 - frankenphp.c | 117 ++++++++++------------ frankenphp.go | 3 +- frankenphp.stub.php | 4 +- frankenphp_arginfo.h | 13 +-- testdata/tasks/task-dispatcher-array.php | 2 +- testdata/tasks/task-dispatcher-string.php | 2 +- testdata/tasks/task-worker.php | 3 +- threadtaskworker.go | 56 +++++------ threadtaskworker_test.go | 4 +- types_test.go | 10 +- 11 files changed, 93 insertions(+), 123 deletions(-) diff --git a/caddy/workerconfig.go b/caddy/workerconfig.go index ac04df39b..c49aab579 100644 --- a/caddy/workerconfig.go +++ b/caddy/workerconfig.go @@ -37,8 +37,6 @@ type workerConfig struct { MatchPath []string `json:"match_path,omitempty"` // MaxConsecutiveFailures sets the maximum number of consecutive failures before panicking (defaults to 6, set to -1 to never panick) MaxConsecutiveFailures int `json:"max_consecutive_failures,omitempty"` - // Args sets the command line arguments to pass to the worker script - Args []string `json:"args,omitempty"` } func parseWorkerConfig(d *caddyfile.Dispenser) (workerConfig, error) { diff --git a/frankenphp.c b/frankenphp.c index d6d6f63fb..e8d0ad250 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -417,6 +417,48 @@ PHP_FUNCTION(frankenphp_response_headers) /* {{{ */ } /* }}} */ +bool frankenphp_handle_message(zend_fcall_info fci, + zend_fcall_info_cache fcc) { + zval *arg = go_frankenphp_worker_handle_task(thread_index); + if (arg == NULL) { + return false; + } + + /* Call the PHP func passed to frankenphp_handle_request() */ + zval retval = {0}; + fci.size = sizeof fci; + fci.retval = &retval; + fci.params = arg; + fci.param_count = 1; + zend_bool status = zend_call_function(&fci, &fcc) == SUCCESS; + + if (!status || Z_TYPE(retval) == IS_UNDEF) { + go_frankenphp_finish_task(thread_index, NULL); + zval_ptr_dtor(arg); + } else { + go_frankenphp_finish_task(thread_index, &retval); + } + + zval_ptr_dtor(&retval); + + /* + * If an exception occurred, print the message to the client before + * exiting + */ + if (EG(exception) && !zend_is_unwind_exit(EG(exception)) && + !zend_is_graceful_exit(EG(exception))) { + zend_exception_error(EG(exception), E_ERROR); + zend_bailout(); + } + + zend_try { php_output_end_all(); } + zend_end_try(); + + zval_ptr_dtor(arg); + + return true; +} + PHP_FUNCTION(frankenphp_handle_request) { zend_fcall_info fci; zend_fcall_info_cache fcc; @@ -426,6 +468,13 @@ PHP_FUNCTION(frankenphp_handle_request) { ZEND_PARSE_PARAMETERS_END(); if (!is_worker_thread) { + + /* thread is a task worker + * handle the message and do not reset globals */ + if (is_task_worker_thread) { + bool keep_running = frankenphp_handle_message(fci, fcc); + RETURN_BOOL(keep_running); + } /* not a worker, throw an error */ zend_throw_exception( spl_ce_RuntimeException, @@ -490,71 +539,7 @@ PHP_FUNCTION(frankenphp_handle_request) { RETURN_TRUE; } -PHP_FUNCTION(frankenphp_handle_task) { - zend_fcall_info fci; - zend_fcall_info_cache fcc; - - ZEND_PARSE_PARAMETERS_START(1, 1) - Z_PARAM_FUNC(fci, fcc) - ZEND_PARSE_PARAMETERS_END(); - - if (!is_task_worker_thread) { - zend_throw_exception( - spl_ce_RuntimeException, - "frankenphp_handle_task() called while not in worker mode", 0); - RETURN_THROWS(); - } - -#ifdef ZEND_MAX_EXECUTION_TIMERS - /* - * Disable timeouts while waiting for a task to handle - * TODO: should running forever be the default? - */ - zend_unset_timeout(); -#endif - - zval *arg = go_frankenphp_worker_handle_task(thread_index); - if (arg == NULL) { - RETURN_FALSE; - } - - /* Call the PHP func passed to frankenphp_handle_task() */ - zval retval = {0}; - fci.size = sizeof fci; - fci.retval = &retval; - - /* copy the string to thread-local memory */ - - fci.params = arg; - fci.param_count = 1; - zend_bool status = zend_call_function(&fci, &fcc) == SUCCESS; - - if (status == SUCCESS) { - go_frankenphp_finish_task(thread_index, &retval); - zval_ptr_dtor(&retval); - } else { - go_frankenphp_finish_task(thread_index, NULL); - } - - /* - * If an exception occurred, print the message to the client before - * exiting - */ - if (EG(exception) && !zend_is_unwind_exit(EG(exception)) && - !zend_is_graceful_exit(EG(exception))) { - zend_exception_error(EG(exception), E_ERROR); - zend_bailout(); - } - - zend_try { php_output_end_all(); } - zend_end_try(); - - zval_ptr_dtor(arg); - - RETURN_TRUE; -} - -PHP_FUNCTION(frankenphp_dispatch_task) { +PHP_FUNCTION(frankenphp_dispatch_request) { zval *zv; char *worker_name = NULL; size_t worker_name_len = 0; @@ -565,7 +550,7 @@ PHP_FUNCTION(frankenphp_dispatch_task) { Z_PARAM_STRING(worker_name, worker_name_len); ZEND_PARSE_PARAMETERS_END(); - char *error = go_frankenphp_dispatch_task(thread_index, zv, worker_name, + char *error = go_frankenphp_dispatch_request(thread_index, zv, worker_name, worker_name_len); if (error) { zend_throw_exception(spl_ce_RuntimeException, error, 0); diff --git a/frankenphp.go b/frankenphp.go index 3d77acc54..6e80b4df9 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -287,7 +287,7 @@ func Init(options ...Option) error { } directoriesToWatch := getDirectoriesToWatch(append(opt.workers, opt.taskWorkers...)) - watcherIsEnabled = len(directoriesToWatch) > 0 // watcherIsEnabled is important for initWorkers() + watcherIsEnabled = len(directoriesToWatch) > 0 // watcherIsEnabled needs to be set before initWorkers() if err := initWorkers(opt.workers); err != nil { return err @@ -649,5 +649,6 @@ func getDirectoriesToWatch(workerOpts []workerOpt) []string { for _, w := range workerOpts { directoriesToWatch = append(directoriesToWatch, w.watch...) } + return directoriesToWatch } diff --git a/frankenphp.stub.php b/frankenphp.stub.php index 10c6e90f3..71a95c8b2 100644 --- a/frankenphp.stub.php +++ b/frankenphp.stub.php @@ -4,9 +4,7 @@ function frankenphp_handle_request(callable $callback): bool {} -function frankenphp_handle_task(callable $callback): bool {} - -function frankenphp_dispatch_task(mixed $task, string $workerName = ''): void {} +function frankenphp_dispatch_request(mixed $task, string $workerName = ''): void {} function headers_send(int $status = 200): int {} diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index 5eb9183cc..52eeee069 100644 --- a/frankenphp_arginfo.h +++ b/frankenphp_arginfo.h @@ -6,12 +6,7 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_handle_request, 0, 1, ZEND_ARG_TYPE_INFO(0, callback, IS_CALLABLE, 0) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_handle_task, 0, 1, - _IS_BOOL, 0) -ZEND_ARG_TYPE_INFO(0, callback, IS_CALLABLE, 0) -ZEND_END_ARG_INFO() - -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_dispatch_task, 0, 1, +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_dispatch_request, 0, 1, IS_VOID, 0) ZEND_ARG_TYPE_INFO(0, task, IS_MIXED, 0) ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, worker_name, IS_STRING, 0, "\"\"") @@ -42,8 +37,7 @@ ZEND_END_ARG_INFO() #define arginfo_apache_response_headers arginfo_frankenphp_response_headers ZEND_FUNCTION(frankenphp_handle_request); -ZEND_FUNCTION(frankenphp_handle_task); -ZEND_FUNCTION(frankenphp_dispatch_task); +ZEND_FUNCTION(frankenphp_dispatch_request); ZEND_FUNCTION(headers_send); ZEND_FUNCTION(frankenphp_finish_request); ZEND_FUNCTION(frankenphp_request_headers); @@ -52,8 +46,7 @@ ZEND_FUNCTION(frankenphp_response_headers); // clang-format off static const zend_function_entry ext_functions[] = { ZEND_FE(frankenphp_handle_request, arginfo_frankenphp_handle_request) - ZEND_FE(frankenphp_handle_task, arginfo_frankenphp_handle_task) - ZEND_FE(frankenphp_dispatch_task, arginfo_frankenphp_dispatch_task) + ZEND_FE(frankenphp_dispatch_request, arginfo_frankenphp_dispatch_request) ZEND_FE(headers_send, arginfo_headers_send) ZEND_FE(frankenphp_finish_request, arginfo_frankenphp_finish_request) ZEND_FALIAS(fastcgi_finish_request, frankenphp_finish_request, arginfo_fastcgi_finish_request) diff --git a/testdata/tasks/task-dispatcher-array.php b/testdata/tasks/task-dispatcher-array.php index 8eaf3fe11..05634cfb2 100644 --- a/testdata/tasks/task-dispatcher-array.php +++ b/testdata/tasks/task-dispatcher-array.php @@ -6,7 +6,7 @@ $taskCount = $_GET['count'] ?? 0; $workerName = $_GET['worker'] ?? ''; for ($i = 0; $i < $taskCount; $i++) { - frankenphp_dispatch_task([ + frankenphp_dispatch_request([ 'task' => "array task$i", 'worker' => $workerName, 'index' => $i, diff --git a/testdata/tasks/task-dispatcher-string.php b/testdata/tasks/task-dispatcher-string.php index ac7766978..d987e33c7 100644 --- a/testdata/tasks/task-dispatcher-string.php +++ b/testdata/tasks/task-dispatcher-string.php @@ -6,7 +6,7 @@ $taskCount = $_GET['count'] ?? 0; $workerName = $_GET['worker'] ?? ''; for ($i = 0; $i < $taskCount; $i++) { - frankenphp_dispatch_task("task$i", $workerName); + frankenphp_dispatch_request("task$i", $workerName); } echo "dispatched $taskCount tasks\n"; }; diff --git a/testdata/tasks/task-worker.php b/testdata/tasks/task-worker.php index 0e4fc9a94..7ebc754ff 100644 --- a/testdata/tasks/task-worker.php +++ b/testdata/tasks/task-worker.php @@ -3,11 +3,12 @@ $handleFunc = function ($task) { var_dump($task); echo $_SERVER['CUSTOM_VAR'] ?? 'no custom var'; + return "task completed: ".$task; }; $maxTasksBeforeRestarting = 1000; $currentTask = 0; -while(frankenphp_handle_task($handleFunc) && $currentTask++ < $maxTasksBeforeRestarting) { +while(frankenphp_handle_request($handleFunc) && $currentTask++ < $maxTasksBeforeRestarting) { // Keep handling tasks until there are no more tasks or the max limit is reached } \ No newline at end of file diff --git a/threadtaskworker.go b/threadtaskworker.go index 9858acbde..9760eb6bd 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -15,37 +15,33 @@ type taskWorker struct { threads []*phpThread threadMutex sync.RWMutex fileName string - taskChan chan *PendingTask + taskChan chan *pendingTask name string num int env PreparedEnv } -// representation of a thread that handles tasks directly assigned by go or via frankenphp_dispatch_task() +// representation of a thread that handles tasks directly assigned by go or via frankenphp_dispatch_request() // can also just execute a script in a loop // implements the threadHandler interface type taskWorkerThread struct { thread *phpThread taskWorker *taskWorker dummyContext *frankenPHPContext - currentTask *PendingTask + currentTask *pendingTask } var taskWorkers []*taskWorker // EXPERIMENTAL: a task dispatched to a task worker -type PendingTask struct { +type pendingTask struct { arg any done sync.RWMutex callback func() - Result any + result any } -func (t *PendingTask) WaitForCompletion() { - t.done.RLock() -} - -func (t *PendingTask) dispatch(tw *taskWorker) error { +func (t *pendingTask) dispatch(tw *taskWorker) error { t.done.Lock() select { @@ -57,16 +53,18 @@ func (t *PendingTask) dispatch(tw *taskWorker) error { } // EXPERIMENTAL: DispatchTask dispatches a task to a named task worker -func DispatchTask(arg any, taskWorkerName string) (*PendingTask, error) { - tw := getTaskWorkerByName(taskWorkerName) +// will block until completion and return the result or an error +func DispatchTask(arg any, workerName string) (any, error) { + tw := getTaskWorkerByName(workerName) if tw == nil { - return nil, errors.New("no task worker found with name " + taskWorkerName) + return nil, errors.New("no task worker found with name " + workerName) } - pt := &PendingTask{arg: arg} + pt := &pendingTask{arg: arg} err := pt.dispatch(tw) + pt.done.RLock() // wait for completion - return pt, err + return pt.result, err } func initTaskWorkers(opts []workerOpt) error { @@ -85,7 +83,7 @@ func initTaskWorkers(opts []workerOpt) error { tw := &taskWorker{ threads: make([]*phpThread, 0, opt.num), fileName: fileName, - taskChan: make(chan *PendingTask, opt.maxQueueLen), + taskChan: make(chan *pendingTask, opt.maxQueueLen), name: opt.name, num: opt.num, env: opt.env, @@ -202,7 +200,7 @@ func (tw *taskWorker) drainQueue() { select { case pt := <-tw.taskChan: tw.taskChan <- pt - pt.WaitForCompletion() + pt.done.RLock() // wait for completion default: return } @@ -231,8 +229,7 @@ func go_frankenphp_worker_handle_task(threadIndex C.uintptr_t) *C.zval { handler.currentTask = task thread.state.markAsWaiting(false) - // if the task has a callback, handle it directly - // currently only here for tests, TODO: is this useful for extensions? + // if the task has a callback, execute it (see types_test.go) if task.callback != nil { task.callback() go_frankenphp_finish_task(threadIndex, nil) @@ -240,9 +237,8 @@ func go_frankenphp_worker_handle_task(threadIndex C.uintptr_t) *C.zval { return go_frankenphp_worker_handle_task(threadIndex) } - // if the task has no callback, forward it to PHP zval := phpValue(task.arg) - thread.Pin(unsafe.Pointer(zval)) + thread.Pin(unsafe.Pointer(zval)) // TODO: refactor types.go so no pinning is required return zval case <-handler.thread.drainChan: @@ -261,33 +257,33 @@ func go_frankenphp_finish_task(threadIndex C.uintptr_t, zv *C.zval) { } if zv != nil { - handler.currentTask.Result = goValue(zv) + handler.currentTask.result = goValue(zv) } handler.currentTask.done.Unlock() handler.currentTask = nil } -//export go_frankenphp_dispatch_task -func go_frankenphp_dispatch_task(threadIndex C.uintptr_t, zv *C.zval, name *C.char, nameLen C.size_t) *C.char { +//export go_frankenphp_dispatch_request +func go_frankenphp_dispatch_request(threadIndex C.uintptr_t, zv *C.zval, name *C.char, nameLen C.size_t) *C.char { if zv == nil { return phpThreads[threadIndex].pinCString("Task argument cannot be null") } - var worker *taskWorker + var tw *taskWorker if nameLen != 0 { - worker = getTaskWorkerByName(C.GoStringN(name, C.int(nameLen))) + tw = getTaskWorkerByName(C.GoStringN(name, C.int(nameLen))) } else if len(taskWorkers) != 0 { - worker = taskWorkers[0] + tw = taskWorkers[0] } - if worker == nil { + if tw == nil { return phpThreads[threadIndex].pinCString("No worker found to handle this task: " + C.GoStringN(name, C.int(nameLen))) } // create a new task and lock it until the task is done goArg := goValue(zv) - task := &PendingTask{arg: goArg} - err := task.dispatch(worker) + task := &pendingTask{arg: goArg} + err := task.dispatch(tw) if err != nil { return phpThreads[threadIndex].pinCString(err.Error()) diff --git a/threadtaskworker_test.go b/threadtaskworker_test.go index e36003a66..48d0f138d 100644 --- a/threadtaskworker_test.go +++ b/threadtaskworker_test.go @@ -38,9 +38,8 @@ func TestDispatchToTaskWorker(t *testing.T) { )) assert.Len(t, taskWorkers, 1) - pendingTask, err := DispatchTask("go task", "worker") + result, err := DispatchTask("go task", "worker") assert.NoError(t, err) - pendingTask.WaitForCompletion() Shutdown() assert.Len(t, taskWorkers[0].threads, 0, "no task-worker threads should remain after shutdown") @@ -48,6 +47,7 @@ func TestDispatchToTaskWorker(t *testing.T) { logOutput := buf.String() assert.Contains(t, logOutput, "go task", "should see the dispatched task in the logs") assert.Contains(t, logOutput, "custom var", "should see the prepared env of the task worker") + assert.Equal(t, "task completed: go task", result) } func TestDispatchToTaskWorkerFromWorker(t *testing.T) { diff --git a/types_test.go b/types_test.go index 9972c7f92..d95fd189b 100644 --- a/types_test.go +++ b/types_test.go @@ -22,21 +22,19 @@ func testOnDummyPHPThread(t *testing.T, cb func()) { )) defer Shutdown() - task, err := executeOnPHPThread(cb, "tw") + _, err := executeOnPHPThread(cb, "tw") assert.NoError(t, err) - - task.WaitForCompletion() } // executeOnPHPThread executes the callback func() directly on a task worker thread -// Currently only used in tests -func executeOnPHPThread(callback func(), taskWorkerName string) (*PendingTask, error) { +// useful for testing purposes when dealing with PHP allocations +func executeOnPHPThread(callback func(), taskWorkerName string) (*pendingTask, error) { tw := getTaskWorkerByName(taskWorkerName) if tw == nil { return nil, errors.New("no task worker found with name " + taskWorkerName) } - pt := &PendingTask{callback: callback} + pt := &pendingTask{callback: callback} err := pt.dispatch(tw) return pt, err From 694b6188c071de802948e39927218e90c03a8c49 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 26 Oct 2025 20:39:31 +0100 Subject: [PATCH 41/45] Formatting. --- frankenphp.c | 9 ++++----- threadtaskworker.go | 36 ++++++++++++++---------------------- threadtaskworker_test.go | 30 ------------------------------ types_test.go | 20 ++++++++++---------- 4 files changed, 28 insertions(+), 67 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index e8d0ad250..bf10f706f 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -417,8 +417,7 @@ PHP_FUNCTION(frankenphp_response_headers) /* {{{ */ } /* }}} */ -bool frankenphp_handle_message(zend_fcall_info fci, - zend_fcall_info_cache fcc) { +bool frankenphp_handle_message(zend_fcall_info fci, zend_fcall_info_cache fcc) { zval *arg = go_frankenphp_worker_handle_task(thread_index); if (arg == NULL) { return false; @@ -433,8 +432,8 @@ bool frankenphp_handle_message(zend_fcall_info fci, zend_bool status = zend_call_function(&fci, &fcc) == SUCCESS; if (!status || Z_TYPE(retval) == IS_UNDEF) { - go_frankenphp_finish_task(thread_index, NULL); - zval_ptr_dtor(arg); + go_frankenphp_finish_task(thread_index, NULL); + zval_ptr_dtor(arg); } else { go_frankenphp_finish_task(thread_index, &retval); } @@ -551,7 +550,7 @@ PHP_FUNCTION(frankenphp_dispatch_request) { ZEND_PARSE_PARAMETERS_END(); char *error = go_frankenphp_dispatch_request(thread_index, zv, worker_name, - worker_name_len); + worker_name_len); if (error) { zend_throw_exception(spl_ce_RuntimeException, error, 0); RETURN_THROWS(); diff --git a/threadtaskworker.go b/threadtaskworker.go index 9760eb6bd..31b3ef357 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -35,15 +35,14 @@ var taskWorkers []*taskWorker // EXPERIMENTAL: a task dispatched to a task worker type pendingTask struct { - arg any + arg any // the argument passed to frankenphp_send_request() + result any // the return value of frankenphp_handle_request() done sync.RWMutex - callback func() - result any + callback func() // optional callback for direct execution (tests) } func (t *pendingTask) dispatch(tw *taskWorker) error { t.done.Lock() - select { case tw.taskChan <- t: return nil @@ -52,21 +51,6 @@ func (t *pendingTask) dispatch(tw *taskWorker) error { } } -// EXPERIMENTAL: DispatchTask dispatches a task to a named task worker -// will block until completion and return the result or an error -func DispatchTask(arg any, workerName string) (any, error) { - tw := getTaskWorkerByName(workerName) - if tw == nil { - return nil, errors.New("no task worker found with name " + workerName) - } - - pt := &pendingTask{arg: arg} - err := pt.dispatch(tw) - pt.done.RLock() // wait for completion - - return pt.result, err -} - func initTaskWorkers(opts []workerOpt) error { taskWorkers = make([]*taskWorker, 0, len(opts)) ready := sync.WaitGroup{} @@ -257,7 +241,11 @@ func go_frankenphp_finish_task(threadIndex C.uintptr_t, zv *C.zval) { } if zv != nil { - handler.currentTask.result = goValue(zv) + result, err := goValue[any](zv) + if err != nil { + panic("failed to convert go_frankenphp_finish_task() return value: " + err.Error()) + } + handler.currentTask.result = result } handler.currentTask.done.Unlock() handler.currentTask = nil @@ -281,9 +269,13 @@ func go_frankenphp_dispatch_request(threadIndex C.uintptr_t, zv *C.zval, name *C } // create a new task and lock it until the task is done - goArg := goValue(zv) + goArg, err := goValue[any](zv) + if err != nil { + return phpThreads[threadIndex].pinCString("Failed to convert go_frankenphp_dispatch_request() argument: " + err.Error()) + } + task := &pendingTask{arg: goArg} - err := task.dispatch(tw) + err = task.dispatch(tw) if err != nil { return phpThreads[threadIndex].pinCString(err.Error()) diff --git a/threadtaskworker_test.go b/threadtaskworker_test.go index 48d0f138d..c233e894e 100644 --- a/threadtaskworker_test.go +++ b/threadtaskworker_test.go @@ -20,36 +20,6 @@ func assertGetRequest(t *testing.T, url string, expectedBodyContains string, opt assert.Contains(t, w.Body.String(), expectedBodyContains) } -func TestDispatchToTaskWorker(t *testing.T) { - var buf bytes.Buffer - handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) - logger := slog.New(handler) - - assert.NoError(t, Init( - WithWorkers( - "worker", - "./testdata/tasks/task-worker.php", - 1, - AsTaskWorker(true, 0), - WithWorkerEnv(PreparedEnv{"CUSTOM_VAR": "custom var"}), - ), - WithNumThreads(3), - WithLogger(logger), - )) - assert.Len(t, taskWorkers, 1) - - result, err := DispatchTask("go task", "worker") - assert.NoError(t, err) - - Shutdown() - assert.Len(t, taskWorkers[0].threads, 0, "no task-worker threads should remain after shutdown") - - logOutput := buf.String() - assert.Contains(t, logOutput, "go task", "should see the dispatched task in the logs") - assert.Contains(t, logOutput, "custom var", "should see the prepared env of the task worker") - assert.Equal(t, "task completed: go task", result) -} - func TestDispatchToTaskWorkerFromWorker(t *testing.T) { var buf bytes.Buffer handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) diff --git a/types_test.go b/types_test.go index 7e99d9941..318967ba8 100644 --- a/types_test.go +++ b/types_test.go @@ -60,11 +60,11 @@ func TestPHPMap(t *testing.T) { } phpArray := PHPMap(originalMap) - defer zvalPtrDtor(phpArray) + defer zvalPtrDtor(phpArray) convertedMap, err := GoMap[string](phpArray) require.NoError(t, err) - assert.Equal(t, originalMap, GoMap(phpArray), "associative array should be equal after conversion") + assert.Equal(t, originalMap, convertedMap, "associative array should be equal after conversion") }) } @@ -79,11 +79,11 @@ func TestOrderedPHPAssociativeArray(t *testing.T) { } phpArray := PHPAssociativeArray(originalArray) - defer zvalPtrDtor(phpArray) + defer zvalPtrDtor(phpArray) convertedArray, err := GoAssociativeArray[string](phpArray) require.NoError(t, err) - assert.Equal(t, originalArray, GoAssociativeArray(phpArray), "associative array should be equal after conversion") + assert.Equal(t, originalArray, convertedArray, "associative array should be equal after conversion") }) } @@ -91,12 +91,12 @@ func TestPHPPackedArray(t *testing.T) { testOnDummyPHPThread(t, func() { originalSlice := []string{"bar1", "bar2"} -phpArray := PHPPackedArray(originalSlice) + phpArray := PHPPackedArray(originalSlice) defer zvalPtrDtor(phpArray) convertedSlice, err := GoPackedArray[string](phpArray) require.NoError(t, err) - assert.Equal(t, originalSlice, GoPackedArray(phpArray), "slice should be equal after conversion") + assert.Equal(t, originalSlice, convertedSlice, "slice should be equal after conversion") }) } @@ -108,12 +108,12 @@ func TestPHPPackedArrayToGoMap(t *testing.T) { "1": "bar2", } -phpArray := PHPPackedArray(originalSlice) + phpArray := PHPPackedArray(originalSlice) defer zvalPtrDtor(phpArray) convertedMap, err := GoMap[string](phpArray) require.NoError(t, err) - assert.Equal(t, expectedMap, GoMap(phpArray), "convert a packed to an associative array") + assert.Equal(t, expectedMap, convertedMap, "convert a packed to an associative array") }) } @@ -133,7 +133,7 @@ func TestPHPAssociativeArrayToPacked(t *testing.T) { convertedSlice, err := GoPackedArray[string](phpArray) require.NoError(t, err) - assert.Equal(t, expectedSlice, GoPackedArray(phpArray), "convert an associative array to a slice") + assert.Equal(t, expectedSlice, convertedSlice, "convert an associative array to a slice") }) } @@ -158,6 +158,6 @@ func TestNestedMixedArray(t *testing.T) { convertedArray, err := GoMap[any](phpArray) require.NoError(t, err) - assert.Equal(t, originalArray, GoMap(phpArray), "nested mixed array should be equal after conversion") + assert.Equal(t, originalArray, convertedArray, "nested mixed array should be equal after conversion") }) } From d54f736db72bfdddab9d87958b613526156411ee Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 26 Oct 2025 20:52:19 +0100 Subject: [PATCH 42/45] simplifications --- frankenphp.c | 5 +-- frankenphp.stub.php | 2 +- frankenphp_arginfo.h | 6 ++-- testdata/tasks/task-dispatcher-array.php | 2 +- testdata/tasks/task-dispatcher-string.php | 2 +- threadtaskworker.go | 41 ++++++++++------------- types_test.go | 12 +++---- 7 files changed, 31 insertions(+), 39 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index bf10f706f..fec63ff65 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -417,6 +417,7 @@ PHP_FUNCTION(frankenphp_response_headers) /* {{{ */ } /* }}} */ +/* Handle a message in task worker mode */ bool frankenphp_handle_message(zend_fcall_info fci, zend_fcall_info_cache fcc) { zval *arg = go_frankenphp_worker_handle_task(thread_index); if (arg == NULL) { @@ -538,7 +539,7 @@ PHP_FUNCTION(frankenphp_handle_request) { RETURN_TRUE; } -PHP_FUNCTION(frankenphp_dispatch_request) { +PHP_FUNCTION(frankenphp_send_request) { zval *zv; char *worker_name = NULL; size_t worker_name_len = 0; @@ -549,7 +550,7 @@ PHP_FUNCTION(frankenphp_dispatch_request) { Z_PARAM_STRING(worker_name, worker_name_len); ZEND_PARSE_PARAMETERS_END(); - char *error = go_frankenphp_dispatch_request(thread_index, zv, worker_name, + char *error = go_frankenphp_send_request(thread_index, zv, worker_name, worker_name_len); if (error) { zend_throw_exception(spl_ce_RuntimeException, error, 0); diff --git a/frankenphp.stub.php b/frankenphp.stub.php index 71a95c8b2..7f239b994 100644 --- a/frankenphp.stub.php +++ b/frankenphp.stub.php @@ -4,7 +4,7 @@ function frankenphp_handle_request(callable $callback): bool {} -function frankenphp_dispatch_request(mixed $task, string $workerName = ''): void {} +function frankenphp_send_request(mixed $task, string $workerName = ''): void {} function headers_send(int $status = 200): int {} diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index 52eeee069..fdcb08fa5 100644 --- a/frankenphp_arginfo.h +++ b/frankenphp_arginfo.h @@ -6,7 +6,7 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_handle_request, 0, 1, ZEND_ARG_TYPE_INFO(0, callback, IS_CALLABLE, 0) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_dispatch_request, 0, 1, +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_send_request, 0, 1, IS_VOID, 0) ZEND_ARG_TYPE_INFO(0, task, IS_MIXED, 0) ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, worker_name, IS_STRING, 0, "\"\"") @@ -37,7 +37,7 @@ ZEND_END_ARG_INFO() #define arginfo_apache_response_headers arginfo_frankenphp_response_headers ZEND_FUNCTION(frankenphp_handle_request); -ZEND_FUNCTION(frankenphp_dispatch_request); +ZEND_FUNCTION(frankenphp_send_request); ZEND_FUNCTION(headers_send); ZEND_FUNCTION(frankenphp_finish_request); ZEND_FUNCTION(frankenphp_request_headers); @@ -46,7 +46,7 @@ ZEND_FUNCTION(frankenphp_response_headers); // clang-format off static const zend_function_entry ext_functions[] = { ZEND_FE(frankenphp_handle_request, arginfo_frankenphp_handle_request) - ZEND_FE(frankenphp_dispatch_request, arginfo_frankenphp_dispatch_request) + ZEND_FE(frankenphp_send_request, arginfo_frankenphp_send_request) ZEND_FE(headers_send, arginfo_headers_send) ZEND_FE(frankenphp_finish_request, arginfo_frankenphp_finish_request) ZEND_FALIAS(fastcgi_finish_request, frankenphp_finish_request, arginfo_fastcgi_finish_request) diff --git a/testdata/tasks/task-dispatcher-array.php b/testdata/tasks/task-dispatcher-array.php index 05634cfb2..48938c78a 100644 --- a/testdata/tasks/task-dispatcher-array.php +++ b/testdata/tasks/task-dispatcher-array.php @@ -6,7 +6,7 @@ $taskCount = $_GET['count'] ?? 0; $workerName = $_GET['worker'] ?? ''; for ($i = 0; $i < $taskCount; $i++) { - frankenphp_dispatch_request([ + frankenphp_send_request([ 'task' => "array task$i", 'worker' => $workerName, 'index' => $i, diff --git a/testdata/tasks/task-dispatcher-string.php b/testdata/tasks/task-dispatcher-string.php index d987e33c7..49df56ec3 100644 --- a/testdata/tasks/task-dispatcher-string.php +++ b/testdata/tasks/task-dispatcher-string.php @@ -6,7 +6,7 @@ $taskCount = $_GET['count'] ?? 0; $workerName = $_GET['worker'] ?? ''; for ($i = 0; $i < $taskCount; $i++) { - frankenphp_dispatch_request("task$i", $workerName); + frankenphp_send_request("task$i", $workerName); } echo "dispatched $taskCount tasks\n"; }; diff --git a/threadtaskworker.go b/threadtaskworker.go index 31b3ef357..b21a85022 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -6,7 +6,6 @@ import "C" import ( "errors" "github.com/dunglas/frankenphp/internal/fastabs" - "path/filepath" "sync" "unsafe" ) @@ -21,7 +20,7 @@ type taskWorker struct { env PreparedEnv } -// representation of a thread that handles tasks directly assigned by go or via frankenphp_dispatch_request() +// representation of a thread that handles tasks directly assigned by go or via frankenphp_send_request() // can also just execute a script in a loop // implements the threadHandler interface type taskWorkerThread struct { @@ -41,16 +40,6 @@ type pendingTask struct { callback func() // optional callback for direct execution (tests) } -func (t *pendingTask) dispatch(tw *taskWorker) error { - t.done.Lock() - select { - case tw.taskChan <- t: - return nil - default: - return errors.New("Task worker queue is full, cannot dispatch task: " + tw.name) - } -} - func initTaskWorkers(opts []workerOpt) error { taskWorkers = make([]*taskWorker, 0, len(opts)) ready := sync.WaitGroup{} @@ -140,10 +129,7 @@ func (handler *taskWorkerThread) beforeScriptExecution() string { } func (handler *taskWorkerThread) setupWorkerScript() string { - fc, err := newDummyContext( - filepath.Base(handler.taskWorker.fileName), - WithRequestPreparedEnv(handler.taskWorker.env), - ) + fc, err := newDummyContext(handler.taskWorker.fileName, WithRequestPreparedEnv(handler.taskWorker.env)) if err != nil { panic(err) @@ -191,6 +177,16 @@ func (tw *taskWorker) drainQueue() { } } +func (tw *taskWorker) dispatch(t *pendingTask) error { + t.done.Lock() + select { + case tw.taskChan <- t: + return nil + default: + return errors.New("Task worker queue is full, cannot dispatch task: " + tw.name) + } +} + func getTaskWorkerByName(name string) *taskWorker { for _, w := range taskWorkers { if w.name == name { @@ -237,7 +233,7 @@ func go_frankenphp_finish_task(threadIndex C.uintptr_t, zv *C.zval) { thread := phpThreads[threadIndex] handler, ok := thread.handler.(*taskWorkerThread) if !ok { - panic("thread is not a task thread") + panic("thread is not a task thread: " + thread.handler.name()) } if zv != nil { @@ -251,8 +247,8 @@ func go_frankenphp_finish_task(threadIndex C.uintptr_t, zv *C.zval) { handler.currentTask = nil } -//export go_frankenphp_dispatch_request -func go_frankenphp_dispatch_request(threadIndex C.uintptr_t, zv *C.zval, name *C.char, nameLen C.size_t) *C.char { +//export go_frankenphp_send_request +func go_frankenphp_send_request(threadIndex C.uintptr_t, zv *C.zval, name *C.char, nameLen C.size_t) *C.char { if zv == nil { return phpThreads[threadIndex].pinCString("Task argument cannot be null") } @@ -268,14 +264,13 @@ func go_frankenphp_dispatch_request(threadIndex C.uintptr_t, zv *C.zval, name *C return phpThreads[threadIndex].pinCString("No worker found to handle this task: " + C.GoStringN(name, C.int(nameLen))) } - // create a new task and lock it until the task is done + // convert the argument of frankenphp_send_request() to a Go value goArg, err := goValue[any](zv) if err != nil { - return phpThreads[threadIndex].pinCString("Failed to convert go_frankenphp_dispatch_request() argument: " + err.Error()) + return phpThreads[threadIndex].pinCString("Failed to convert frankenphp_send_request() argument: " + err.Error()) } - task := &pendingTask{arg: goArg} - err = task.dispatch(tw) + err = tw.dispatch(&pendingTask{arg: goArg}) if err != nil { return phpThreads[threadIndex].pinCString(err.Error()) diff --git a/types_test.go b/types_test.go index 318967ba8..033dc2eab 100644 --- a/types_test.go +++ b/types_test.go @@ -23,22 +23,18 @@ func testOnDummyPHPThread(t *testing.T, cb func()) { )) defer Shutdown() - _, err := executeOnPHPThread(cb, "tw") - assert.NoError(t, err) + assert.NoError(t, executeOnPHPThread(cb, "tw")) } // executeOnPHPThread executes the callback func() directly on a task worker thread // useful for testing purposes when dealing with PHP allocations -func executeOnPHPThread(callback func(), taskWorkerName string) (*pendingTask, error) { +func executeOnPHPThread(callback func(), taskWorkerName string) error { tw := getTaskWorkerByName(taskWorkerName) if tw == nil { - return nil, errors.New("no task worker found with name " + taskWorkerName) + return errors.New("no task worker found with name " + taskWorkerName) } - pt := &pendingTask{callback: callback} - err := pt.dispatch(tw) - - return pt, err + return tw.dispatch(&pendingTask{callback: callback}) } func TestGoString(t *testing.T) { From 02c27fc2a9747dfa8292b37871a3b0c5f991dc91 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 26 Oct 2025 20:56:08 +0100 Subject: [PATCH 43/45] Fixes library tests. --- caddy/caddy_test.go | 24 +++++------------------- caddy/workerconfig.go | 2 -- 2 files changed, 5 insertions(+), 21 deletions(-) diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index 29398b1a6..ae7ad7bbb 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -1426,9 +1426,7 @@ func TestWorkerMatchDirectiveWithoutFileServer(t *testing.T) { func TestServerWithTaskWorker(t *testing.T) { tester := caddytest.NewTester(t) - taskWorker1, err := fastabs.FastAbs("../testdata/tasks/task-worker.php") - require.NoError(t, err) - taskWorker2, err := fastabs.FastAbs("../testdata/tasks/task-worker.php") + taskWorker, err := fastabs.FastAbs("../testdata/tasks/task-worker.php") require.NoError(t, err) tester.InitServer(` { @@ -1436,32 +1434,20 @@ func TestServerWithTaskWorker(t *testing.T) { admin localhost:2999 frankenphp { - num_threads 3 - task_worker `+taskWorker1+` { - args foo bar + num_threads 2 + task_worker `+taskWorker+` { num 1 } - task_worker `+taskWorker2+` { - args foo bar - num 1 - } } } `, "caddyfile") debugState := getDebugState(t, tester) - require.Len(t, debugState.ThreadDebugStates, 3, "there should be 3 threads") + require.Len(t, debugState.ThreadDebugStates, 2, "there should be 3 threads") require.Equal( t, debugState.ThreadDebugStates[1].Name, - "Task Worker PHP Thread - "+taskWorker1, + "Task Worker PHP Thread - "+taskWorker, "the second spawned thread should be the task worker", ) - - require.Equal( - t, - debugState.ThreadDebugStates[2].Name, - "Task Worker PHP Thread - "+taskWorker2, - "the third spawned thread should belong to the second task worker", - ) } diff --git a/caddy/workerconfig.go b/caddy/workerconfig.go index c019a8f61..9ccbbb30c 100644 --- a/caddy/workerconfig.go +++ b/caddy/workerconfig.go @@ -122,8 +122,6 @@ func parseWorkerConfig(d *caddyfile.Dispenser) (workerConfig, error) { } wc.MaxConsecutiveFailures = int(v) - case "args": - wc.Args = d.RemainingArgs() default: allowedDirectives := "name, file, num, env, watch, match, max_consecutive_failures" return wc, wrongSubDirectiveError("worker", allowedDirectives, v) From acf423f9f0382a25236934089314e51b9cc7691c Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 26 Oct 2025 20:57:00 +0100 Subject: [PATCH 44/45] adds comments. --- types.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/types.go b/types.go index ab2b0cc0c..a98313e66 100644 --- a/types.go +++ b/types.go @@ -439,12 +439,12 @@ func extractZvalValue(zval *C.zval, expectedType C.uint8_t) (unsafe.Pointer, err return nil, fmt.Errorf("unsupported zval type %d", expectedType) } +// used for cleanup in tests func zvalPtrDtor(p unsafe.Pointer) { - zv := (*C.zval)(p) - C.zval_ptr_dtor(zv) + C.zval_ptr_dtor((*C.zval)(p)) } +// used for cleanup in tests func zendStringRelease(p unsafe.Pointer) { - zs := (*C.zend_string)(p) - C.zend_string_release(zs) + C.zend_string_release((*C.zend_string)(p)) } From fd40e62cb8325797e7ffd79a4c5b5750e0efe9df Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 26 Oct 2025 21:25:19 +0100 Subject: [PATCH 45/45] cleanup. --- frankenphp.c | 5 +++-- options.go | 4 +++- threadtaskworker.go | 12 ++++++------ 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index fec63ff65..999318c71 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -418,7 +418,8 @@ PHP_FUNCTION(frankenphp_response_headers) /* {{{ */ /* }}} */ /* Handle a message in task worker mode */ -bool frankenphp_handle_message(zend_fcall_info fci, zend_fcall_info_cache fcc) { +static bool frankenphp_handle_message(zend_fcall_info fci, + zend_fcall_info_cache fcc) { zval *arg = go_frankenphp_worker_handle_task(thread_index); if (arg == NULL) { return false; @@ -551,7 +552,7 @@ PHP_FUNCTION(frankenphp_send_request) { ZEND_PARSE_PARAMETERS_END(); char *error = go_frankenphp_send_request(thread_index, zv, worker_name, - worker_name_len); + worker_name_len); if (error) { zend_throw_exception(spl_ce_RuntimeException, error, 0); RETURN_THROWS(); diff --git a/options.go b/options.go index 9cb4ecf8d..a891059d3 100644 --- a/options.go +++ b/options.go @@ -149,7 +149,9 @@ func WithMaxWaitTime(maxWaitTime time.Duration) Option { } } -// EXPERIMENTAL: AsTaskWorker configures the worker as a task worker instead. +// EXPERIMENTAL: AsTaskWorker configures the worker as a task worker. +// no http requests will be handled. +// no globals resetting will be performed between tasks. func AsTaskWorker(isTaskWorker bool, maxQueueLen int) WorkerOption { return func(w *workerOpt) error { w.isTaskWorker = isTaskWorker diff --git a/threadtaskworker.go b/threadtaskworker.go index b21a85022..1109317c5 100644 --- a/threadtaskworker.go +++ b/threadtaskworker.go @@ -34,8 +34,7 @@ var taskWorkers []*taskWorker // EXPERIMENTAL: a task dispatched to a task worker type pendingTask struct { - arg any // the argument passed to frankenphp_send_request() - result any // the return value of frankenphp_handle_request() + message any // the argument passed to frankenphp_send_request() or the return value of frankenphp_handle_request() done sync.RWMutex callback func() // optional callback for direct execution (tests) } @@ -155,13 +154,13 @@ func (handler *taskWorkerThread) name() string { func (tw *taskWorker) detach(thread *phpThread) { tw.threadMutex.Lock() + defer tw.threadMutex.Unlock() for i, t := range tw.threads { if t == thread { tw.threads = append(tw.threads[:i], tw.threads[i+1:]...) return } } - tw.threadMutex.Unlock() } // make sure all tasks are done by re-queuing them until the channel is empty @@ -217,7 +216,8 @@ func go_frankenphp_worker_handle_task(threadIndex C.uintptr_t) *C.zval { return go_frankenphp_worker_handle_task(threadIndex) } - zval := phpValue(task.arg) + zval := phpValue(task.message) + task.message = nil // free memory thread.Pin(unsafe.Pointer(zval)) // TODO: refactor types.go so no pinning is required return zval @@ -241,7 +241,7 @@ func go_frankenphp_finish_task(threadIndex C.uintptr_t, zv *C.zval) { if err != nil { panic("failed to convert go_frankenphp_finish_task() return value: " + err.Error()) } - handler.currentTask.result = result + handler.currentTask.message = result } handler.currentTask.done.Unlock() handler.currentTask = nil @@ -270,7 +270,7 @@ func go_frankenphp_send_request(threadIndex C.uintptr_t, zv *C.zval, name *C.cha return phpThreads[threadIndex].pinCString("Failed to convert frankenphp_send_request() argument: " + err.Error()) } - err = tw.dispatch(&pendingTask{arg: goArg}) + err = tw.dispatch(&pendingTask{message: goArg}) if err != nil { return phpThreads[threadIndex].pinCString(err.Error())