diff --git a/caddy/app.go b/caddy/app.go index 9242d870c6..99e1bede52 100644 --- a/caddy/app.go +++ b/caddy/app.go @@ -162,6 +162,7 @@ func (f *FrankenPHPApp) Start() error { frankenphp.WithWorkerMaxFailures(w.MaxConsecutiveFailures), frankenphp.WithWorkerMaxThreads(w.MaxThreads), frankenphp.WithWorkerRequestOptions(w.requestOptions...), + frankenphp.WithWorkerSidekickRegistry(w.sidekickRegistry), ) f.opts = append(f.opts, frankenphp.WithWorkers(w.Name, repl.ReplaceKnown(w.FileName, ""), w.Num, w.options...)) diff --git a/caddy/module.go b/caddy/module.go index 2241e216e2..ab2255b61c 100644 --- a/caddy/module.go +++ b/caddy/module.go @@ -45,10 +45,13 @@ type FrankenPHPModule struct { Env map[string]string `json:"env,omitempty"` // Workers configures the worker scripts to start. Workers []workerConfig `json:"workers,omitempty"` + // SidekickEntrypoint is the script used to start sidekicks (e.g., bin/console) + SidekickEntrypoint string `json:"sidekick_entrypoint,omitempty"` resolvedDocumentRoot string preparedEnv frankenphp.PreparedEnv preparedEnvNeedsReplacement bool + sidekickRegistry *frankenphp.SidekickRegistry logger *slog.Logger requestOptions []frankenphp.RequestOption } @@ -78,6 +81,10 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error { f.assignMercureHub(ctx) + if f.SidekickEntrypoint != "" { + f.sidekickRegistry = frankenphp.NewSidekickRegistry(f.SidekickEntrypoint) + } + loggerOpt := frankenphp.WithRequestLogger(f.logger) for i, wc := range f.Workers { // make the file path absolute from the public directory @@ -91,6 +98,7 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error { wc.inheritEnv(f.Env) } + wc.sidekickRegistry = f.sidekickRegistry wc.requestOptions = append(wc.requestOptions, loggerOpt) f.Workers[i] = wc } @@ -241,6 +249,7 @@ func (f *FrankenPHPModule) ServeHTTP(w http.ResponseWriter, r *http.Request, _ c opts, frankenphp.WithOriginalRequest(new(ctx.Value(caddyhttp.OriginalRequestCtxKey).(http.Request))), frankenphp.WithWorkerName(workerName), + frankenphp.WithRequestSidekickRegistry(f.sidekickRegistry), )..., ) @@ -297,6 +306,12 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } f.ResolveRootSymlink = &v + case "sidekick_entrypoint": + if !d.NextArg() { + return d.ArgErr() + } + f.SidekickEntrypoint = d.Val() + case "worker": wc, err := unmarshalWorker(d) if err != nil { @@ -311,7 +326,7 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } default: - return wrongSubDirectiveError("php or php_server", "hot_reload, name, root, split, env, resolve_root_symlink, worker", d.Val()) + return wrongSubDirectiveError("php or php_server", "hot_reload, name, root, split, env, resolve_root_symlink, sidekick_entrypoint, worker", d.Val()) } } } diff --git a/caddy/workerconfig.go b/caddy/workerconfig.go index c50f0d0688..a52501689c 100644 --- a/caddy/workerconfig.go +++ b/caddy/workerconfig.go @@ -41,11 +41,11 @@ 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"` - - options []frankenphp.WorkerOption - requestOptions []frankenphp.RequestOption - absFileName string - matchRelPath string // pre-computed relative URL path for fast matching + sidekickRegistry *frankenphp.SidekickRegistry + options []frankenphp.WorkerOption + requestOptions []frankenphp.RequestOption + absFileName string + matchRelPath string // pre-computed relative URL path for fast matching } func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) { diff --git a/context.go b/context.go index 92f3b7471c..7e13a68fbb 100644 --- a/context.go +++ b/context.go @@ -16,13 +16,14 @@ import ( type frankenPHPContext struct { mercureContext - documentRoot string - splitPath []string - env PreparedEnv - logger *slog.Logger - request *http.Request - originalRequest *http.Request - worker *worker + documentRoot string + splitPath []string + env PreparedEnv + logger *slog.Logger + request *http.Request + originalRequest *http.Request + worker *worker + sidekickRegistry *SidekickRegistry docURI string pathInfo string diff --git a/docs/sidekicks.md b/docs/sidekicks.md new file mode 100644 index 0000000000..94ea011f7b --- /dev/null +++ b/docs/sidekicks.md @@ -0,0 +1,136 @@ +# Application Sidekicks + +Sidekicks are long-running PHP workers that run **outside the HTTP request cycle**. +They observe their environment (Redis Sentinel, secret vaults, feature flag services, etc.) +and publish configuration to HTTP workers in real time — without polling, TTLs, or redeployment. + +## How It Works + +1. A sidekick runs its own event loop (subscribe to Redis, watch files, poll an API, etc.) +2. It calls `frankenphp_sidekick_set_vars()` to publish key-value pairs +3. HTTP workers call `frankenphp_sidekick_get_vars()` to read the latest snapshot +4. The first `get_vars` call **blocks until the sidekick has published** — no startup race condition + +## Configuration + +```caddyfile +example.com { + php_server { + sidekick_entrypoint /app/bin/console + } +} +``` + +Each `php_server` block has its own isolated sidekick scope. + +## PHP API + +### `frankenphp_sidekick_get_vars(string|array $name, float $timeout = 30.0): array` + +Starts a sidekick (at-most-once) and returns its published variables. + +- First call blocks until the sidekick calls `set_vars()` or the timeout expires +- Subsequent calls return the latest snapshot immediately +- When `$name` is an array, all sidekicks start in parallel and vars are returned keyed by name: + +```php +$redis = frankenphp_sidekick_get_vars('redis-watcher'); +// ['MASTER_HOST' => '10.0.0.1', 'MASTER_PORT' => '6379'] + +$all = frankenphp_sidekick_get_vars(['redis-watcher', 'feature-flags']); +// ['redis-watcher' => [...], 'feature-flags' => [...]] +``` + +- `$name` is available as `$_SERVER['FRANKENPHP_SIDEKICK_NAME']` and `$_SERVER['argv'][1]` in the entrypoint script +- Throws `RuntimeException` on timeout, missing entrypoint, or sidekick crash +- Works in both worker and non-worker mode + +### `frankenphp_sidekick_set_vars(array $vars): void` + +Publishes a snapshot of string key-value pairs from inside a sidekick. +Each call **replaces** the entire snapshot atomically. + +- Throws `RuntimeException` if not called from a sidekick context +- Throws `ValueError` if keys or values are not strings + +### `frankenphp_sidekick_should_stop(): bool` + +Returns `true` when FrankenPHP is shutting down. + +- Throws `RuntimeException` if not called from a sidekick context + +## Example + +### Sidekick Entrypoint + +```php + runRedisWatcher(), + default => throw new \RuntimeException("Unknown sidekick: $command"), +}; + +function runRedisWatcher(): void +{ + frankenphp_sidekick_set_vars([ + 'MASTER_HOST' => '10.0.0.1', + 'MASTER_PORT' => '6379', + ]); + + while (!frankenphp_sidekick_should_stop()) { + $master = discoverRedisMaster(); + frankenphp_sidekick_set_vars([ + 'MASTER_HOST' => $master['host'], + 'MASTER_PORT' => (string) $master['port'], + ]); + usleep(100_000); + } +} +``` + +### HTTP Worker + +```php +boot(); + +while (frankenphp_handle_request(function () use ($app) { + $redis = frankenphp_sidekick_get_vars('redis-watcher'); + + $app->handle($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER + $redis); +})) { + gc_collect_cycles(); +} +``` + +### Graceful Degradation + +```php +if (function_exists('frankenphp_sidekick_get_vars')) { + $config = frankenphp_sidekick_get_vars('config-watcher'); +} else { + $config = ['MASTER_HOST' => getenv('REDIS_HOST') ?: '127.0.0.1']; +} +``` + +## Runtime Behavior + +- Sidekicks get their own dedicated thread from the reserved pool; they don't reduce HTTP capacity +- Execution timeout is automatically disabled +- Shebangs (`#!/usr/bin/env php`) are silently skipped +- `SCRIPT_FILENAME` is set to the entrypoint's full path +- `$_SERVER['FRANKENPHP_SIDEKICK_NAME']` and `$_SERVER['argv'][1]` contain the sidekick name +- Crash recovery: automatic restart with exponential backoff +- Graceful shutdown via `frankenphp_sidekick_should_stop()` +- Use `error_log()` or `frankenphp_log()` for logging — avoid `echo` diff --git a/frankenphp.c b/frankenphp.c index 07f9fdf67a..a71cb645b6 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -85,6 +85,7 @@ HashTable *main_thread_env = NULL; __thread uintptr_t thread_index; __thread bool is_worker_thread = false; +__thread char *sidekick_name = NULL; __thread HashTable *sandboxed_env = NULL; void frankenphp_update_local_thread_context(bool is_worker) { @@ -94,6 +95,11 @@ void frankenphp_update_local_thread_context(bool is_worker) { PG(ignore_user_abort) = is_worker ? 1 : original_user_abort_setting; } +void frankenphp_set_sidekick_name(char *name) { + sidekick_name = name; + zend_unset_timeout(); +} + static void frankenphp_update_request_context() { /* the server context is stored on the go side, still SG(server_context) needs * to not be NULL */ @@ -534,11 +540,13 @@ PHP_FUNCTION(frankenphp_handle_request) { Z_PARAM_FUNC(fci, fcc) ZEND_PARSE_PARAMETERS_END(); - if (!is_worker_thread) { - /* not a worker, throw an error */ + if (!is_worker_thread || sidekick_name != NULL) { zend_throw_exception( spl_ce_RuntimeException, - "frankenphp_handle_request() called while not in worker mode", 0); + sidekick_name != NULL + ? "frankenphp_handle_request() cannot be called from a sidekick" + : "frankenphp_handle_request() called while not in worker mode", + 0); RETURN_THROWS(); } @@ -608,6 +616,213 @@ PHP_FUNCTION(frankenphp_handle_request) { RETURN_TRUE; } +PHP_FUNCTION(frankenphp_sidekick_set_vars) { + zval *vars_array = NULL; + + ZEND_PARSE_PARAMETERS_START(1, 1); + Z_PARAM_ARRAY(vars_array); + ZEND_PARSE_PARAMETERS_END(); + + /* Validate all keys and values are strings */ + HashTable *ht = Z_ARRVAL_P(vars_array); + zend_string *key; + zval *val; + ZEND_HASH_FOREACH_STR_KEY_VAL(ht, key, val) { + if (key == NULL) { + zend_value_error("All keys must be strings"); + RETURN_THROWS(); + } + if (Z_TYPE_P(val) != IS_STRING) { + zend_value_error("All values must be strings"); + RETURN_THROWS(); + } + } + ZEND_HASH_FOREACH_END(); + + HashTable *persistent = pemalloc(sizeof(HashTable), 1); + zend_hash_init(persistent, zend_hash_num_elements(ht), NULL, NULL, 1); + + ZEND_HASH_FOREACH_STR_KEY_VAL(ht, key, val) { + zend_string *pkey = zend_string_init(ZSTR_VAL(key), ZSTR_LEN(key), 1); + zend_string *pval_str = + zend_string_init(Z_STRVAL_P(val), Z_STRLEN_P(val), 1); + zval pval; + ZVAL_NEW_STR(&pval, pval_str); + zend_hash_add_new(persistent, pkey, &pval); + zend_string_release(pkey); + } + ZEND_HASH_FOREACH_END(); + + void *old = NULL; + char *error = go_frankenphp_sidekick_set_vars(thread_index, persistent, &old); + if (error) { + zval *ev; + ZEND_HASH_FOREACH_VAL(persistent, ev) { zend_string_free(Z_STR_P(ev)); } + ZEND_HASH_FOREACH_END(); + zend_hash_destroy(persistent); + pefree(persistent, 1); + zend_throw_exception(spl_ce_RuntimeException, error, 0); + free(error); + RETURN_THROWS(); + } + + if (old != NULL) { + HashTable *old_ht = (HashTable *)old; + zval *v; + ZEND_HASH_FOREACH_VAL(old_ht, v) { zend_string_free(Z_STR_P(v)); } + ZEND_HASH_FOREACH_END(); + zend_hash_destroy(old_ht); + pefree(old_ht, 1); + } +} + +PHP_FUNCTION(frankenphp_sidekick_get_vars) { + zval *names = NULL; + double timeout = 30.0; + + ZEND_PARSE_PARAMETERS_START(1, 2); + Z_PARAM_ZVAL(names); + Z_PARAM_OPTIONAL + Z_PARAM_DOUBLE(timeout); + ZEND_PARSE_PARAMETERS_END(); + + int timeout_ms = (int)(timeout * 1000); + + if (Z_TYPE_P(names) == IS_STRING) { + if (Z_STRLEN_P(names) == 0) { + zend_value_error("Sidekick name must not be empty"); + RETURN_THROWS(); + } + + char *error = go_frankenphp_start_sidekick(thread_index, Z_STRVAL_P(names), + Z_STRLEN_P(names)); + if (error) { + zend_throw_exception(spl_ce_RuntimeException, error, 0); + free(error); + RETURN_THROWS(); + } + + char *name_ptr = Z_STRVAL_P(names); + size_t name_len_val = Z_STRLEN_P(names); + void *vars_ptr = NULL; + error = go_frankenphp_sidekick_wait_and_get( + thread_index, &name_ptr, &name_len_val, 1, timeout_ms, &vars_ptr); + if (error) { + zend_throw_exception(spl_ce_RuntimeException, error, 0); + free(error); + RETURN_THROWS(); + } + + array_init(return_value); + if (vars_ptr) { + HashTable *persistent = (HashTable *)vars_ptr; + zend_string *key; + zval *val; + ZEND_HASH_FOREACH_STR_KEY_VAL(persistent, key, val) { + add_assoc_stringl(return_value, ZSTR_VAL(key), Z_STRVAL_P(val), + Z_STRLEN_P(val)); + } + ZEND_HASH_FOREACH_END(); + } + return; + } + + if (Z_TYPE_P(names) != IS_ARRAY) { + zend_type_error("Argument #1 ($name) must be of type string|array, %s " + "given", + zend_zval_type_name(names)); + RETURN_THROWS(); + } + + HashTable *ht = Z_ARRVAL_P(names); + zval *val; + + ZEND_HASH_FOREACH_VAL(ht, val) { + if (Z_TYPE_P(val) != IS_STRING || Z_STRLEN_P(val) == 0) { + zend_value_error("All sidekick names must be non-empty strings"); + RETURN_THROWS(); + } + } + ZEND_HASH_FOREACH_END(); + + int name_count = zend_hash_num_elements(ht); + + /* Start all sidekicks */ + ZEND_HASH_FOREACH_VAL(ht, val) { + char *error = go_frankenphp_start_sidekick(thread_index, Z_STRVAL_P(val), + Z_STRLEN_P(val)); + if (error) { + zend_throw_exception(spl_ce_RuntimeException, error, 0); + free(error); + RETURN_THROWS(); + } + } + ZEND_HASH_FOREACH_END(); + + /* Wait for all + get pointers in one CGo call */ + char **name_ptrs = emalloc(sizeof(char *) * name_count); + size_t *name_lens_arr = emalloc(sizeof(size_t) * name_count); + void **vars_ptrs = emalloc(sizeof(void *) * name_count); + int idx = 0; + ZEND_HASH_FOREACH_VAL(ht, val) { + name_ptrs[idx] = Z_STRVAL_P(val); + name_lens_arr[idx] = Z_STRLEN_P(val); + idx++; + } + ZEND_HASH_FOREACH_END(); + + char *error = go_frankenphp_sidekick_wait_and_get(thread_index, name_ptrs, + name_lens_arr, name_count, + timeout_ms, vars_ptrs); + efree(name_ptrs); + efree(name_lens_arr); + if (error) { + efree(vars_ptrs); + zend_throw_exception(spl_ce_RuntimeException, error, 0); + free(error); + RETURN_THROWS(); + } + + /* Build result keyed by sidekick name */ + array_init(return_value); + idx = 0; + ZEND_HASH_FOREACH_VAL(ht, val) { + zval sidekick_vars; + array_init(&sidekick_vars); + if (vars_ptrs[idx]) { + HashTable *persistent = (HashTable *)vars_ptrs[idx]; + zend_string *k; + zval *v; + ZEND_HASH_FOREACH_STR_KEY_VAL(persistent, k, v) { + add_assoc_stringl(&sidekick_vars, ZSTR_VAL(k), Z_STRVAL_P(v), + Z_STRLEN_P(v)); + } + ZEND_HASH_FOREACH_END(); + } + add_assoc_zval(return_value, Z_STRVAL_P(val), &sidekick_vars); + idx++; + } + ZEND_HASH_FOREACH_END(); + efree(vars_ptrs); +} + +PHP_FUNCTION(frankenphp_sidekick_should_stop) { + ZEND_PARSE_PARAMETERS_NONE(); + + int result = go_frankenphp_sidekick_should_stop(thread_index); + if (result < 0) { + zend_throw_exception( + spl_ce_RuntimeException, + "frankenphp_sidekick_should_stop() can only be called from a sidekick", + 0); + RETURN_THROWS(); + } + if (result > 0) { + RETURN_TRUE; + } + RETURN_FALSE; +} + PHP_FUNCTION(headers_send) { zend_long response_code = 200; @@ -1217,6 +1432,27 @@ int frankenphp_execute_script(char *file_name) { file_handle.primary_script = 1; + if (sidekick_name != NULL) { + CG(skip_shebang) = 1; + + zend_is_auto_global_str("_SERVER", sizeof("_SERVER") - 1); + zval *server = &PG(http_globals)[TRACK_VARS_SERVER]; + if (server && Z_TYPE_P(server) == IS_ARRAY) { + zval argv_array; + array_init(&argv_array); + add_next_index_string(&argv_array, file_name); + add_next_index_string(&argv_array, sidekick_name); + + zval argc_zval; + ZVAL_LONG(&argc_zval, 2); + + zend_hash_str_update(Z_ARRVAL_P(server), "argv", sizeof("argv") - 1, + &argv_array); + zend_hash_str_update(Z_ARRVAL_P(server), "argc", sizeof("argc") - 1, + &argc_zval); + } + } + zend_first_try { EG(exit_status) = 0; php_execute_script(&file_handle); diff --git a/frankenphp.go b/frankenphp.go index d2aaa3c7d9..a669cdefbb 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -48,6 +48,7 @@ var ( ErrMainThreadCreation = errors.New("error creating the main thread") 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") + ErrNotHTTPWorker = errors.New("worker is not an HTTP worker") ErrInvalidRequestPath = ErrRejected{"invalid request path", http.StatusBadRequest} ErrInvalidContentLengthHeader = ErrRejected{"invalid Content-Length header", http.StatusBadRequest} @@ -414,6 +415,9 @@ func ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) error // Detect if a worker is available to handle this request if fc.worker != nil { + if !fc.worker.httpEnabled { + return ErrNotHTTPWorker + } return fc.worker.handleRequest(ch) } diff --git a/frankenphp.h b/frankenphp.h index f25cb85128..912b4727a8 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -171,6 +171,7 @@ bool frankenphp_new_php_thread(uintptr_t thread_index); bool frankenphp_shutdown_dummy_request(void); int frankenphp_execute_script(char *file_name); void frankenphp_update_local_thread_context(bool is_worker); +void frankenphp_set_sidekick_name(char *name); int frankenphp_execute_script_cli(char *script, int argc, char **argv, bool eval); diff --git a/frankenphp.stub.php b/frankenphp.stub.php index d6c85aa05f..a2192cd65b 100644 --- a/frankenphp.stub.php +++ b/frankenphp.stub.php @@ -16,6 +16,12 @@ function frankenphp_handle_request(callable $callback): bool {} +function frankenphp_sidekick_set_vars(array $vars): void {} + +function frankenphp_sidekick_get_vars(string|array $name, float $timeout = 30.0): array {} + +function frankenphp_sidekick_should_stop(): bool {} + function headers_send(int $status = 200): int {} function frankenphp_finish_request(): bool {} diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index 4f2707cbca..ba2e9a8ab6 100644 --- a/frankenphp_arginfo.h +++ b/frankenphp_arginfo.h @@ -5,6 +5,18 @@ 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_sidekick_set_vars, 0, 1, IS_VOID, 0) + ZEND_ARG_TYPE_INFO(0, vars, IS_ARRAY, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_sidekick_get_vars, 0, 1, IS_ARRAY, 0) + ZEND_ARG_TYPE_MASK(0, name, MAY_BE_STRING|MAY_BE_ARRAY, NULL) + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, timeout, IS_DOUBLE, 0, "30.0") +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_sidekick_should_stop, 0, 0, _IS_BOOL, 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() @@ -43,6 +55,9 @@ ZEND_END_ARG_INFO() ZEND_FUNCTION(frankenphp_handle_request); +ZEND_FUNCTION(frankenphp_sidekick_set_vars); +ZEND_FUNCTION(frankenphp_sidekick_get_vars); +ZEND_FUNCTION(frankenphp_sidekick_should_stop); ZEND_FUNCTION(headers_send); ZEND_FUNCTION(frankenphp_finish_request); ZEND_FUNCTION(frankenphp_request_headers); @@ -53,6 +68,9 @@ ZEND_FUNCTION(frankenphp_log); static const zend_function_entry ext_functions[] = { ZEND_FE(frankenphp_handle_request, arginfo_frankenphp_handle_request) + ZEND_FE(frankenphp_sidekick_set_vars, arginfo_frankenphp_sidekick_set_vars) + ZEND_FE(frankenphp_sidekick_get_vars, arginfo_frankenphp_sidekick_get_vars) + ZEND_FE(frankenphp_sidekick_should_stop, arginfo_frankenphp_sidekick_should_stop) 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/frankenphp_test.go b/frankenphp_test.go index f8279d80ad..dd0aa5ebae 100644 --- a/frankenphp_test.go +++ b/frankenphp_test.go @@ -7,6 +7,7 @@ package frankenphp_test import ( "bytes" "context" + "encoding/hex" "errors" "flag" "fmt" @@ -45,6 +46,7 @@ type testOptions struct { realServer bool logger *slog.Logger initOpts []frankenphp.Option + workerOpts []frankenphp.WorkerOption requestOpts []frankenphp.RequestOption phpIni map[string]string } @@ -66,6 +68,7 @@ func runTest(t *testing.T, test func(func(http.ResponseWriter, *http.Request), * frankenphp.WithWorkerEnv(opts.env), frankenphp.WithWorkerWatchMode(opts.watch), } + workerOpts = append(workerOpts, opts.workerOpts...) initOpts = append(initOpts, frankenphp.WithWorkers("workerName", testDataDir+opts.workerScript, opts.nbWorkers, workerOpts...)) } initOpts = append(initOpts, opts.initOpts...) @@ -804,6 +807,125 @@ func testFileUpload(t *testing.T, opts *testOptions) { }, opts) } +func TestSidekickGetVars(t *testing.T) { + cwd, _ := os.Getwd() + entrypoint := cwd + "/testdata/sidekick-with-argv.php" + + runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) { + // get_vars blocks until the sidekick calls set_vars — no polling needed + body, _ := testGet("http://example.com/sidekick-start.php", handler, t) + assert.Equal(t, "test-sidekick", body) + }, &testOptions{ + workerScript: "sidekick-start.php", + nbWorkers: 1, + nbParallelRequests: 1, + initOpts: []frankenphp.Option{frankenphp.WithMaxThreads(50)}, + workerOpts: []frankenphp.WorkerOption{ + frankenphp.WithWorkerSidekickRegistry(frankenphp.NewSidekickRegistry(entrypoint)), + }, + }) +} + +func TestSidekickAtMostOnce(t *testing.T) { + cwd, _ := os.Getwd() + entrypoint := cwd + "/testdata/sidekick-dedup.php" + + runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) { + body, _ := testGet("http://example.com/sidekick-start-twice.php", handler, t) + assert.Equal(t, "dedup-sidekick", body) + }, &testOptions{ + workerScript: "sidekick-start-twice.php", + nbWorkers: 1, + nbParallelRequests: 1, + initOpts: []frankenphp.Option{frankenphp.WithMaxThreads(50)}, + workerOpts: []frankenphp.WorkerOption{ + frankenphp.WithWorkerSidekickRegistry(frankenphp.NewSidekickRegistry(entrypoint)), + }, + }) +} + +func TestSidekickNoEntrypoint(t *testing.T) { + runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) { + body, _ := testGet("http://example.com/sidekick-no-entrypoint.php", handler, t) + assert.Equal(t, "no sidekick_entrypoint configured in this php_server", body) + }, &testOptions{ + workerScript: "sidekick-no-entrypoint.php", + nbWorkers: 1, + nbParallelRequests: 1, + }) +} + +func TestSidekickSetVarsValidation(t *testing.T) { + runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) { + body, _ := testGet("http://example.com/sidekick-set-server-var-validation.php", handler, t) + assert.Contains(t, body, "NON_SIDEKICK:blocked") + assert.Contains(t, body, "NON_STRING_VAL:blocked") + assert.Contains(t, body, "INT_KEY:blocked") + }, &testOptions{ + workerScript: "sidekick-set-server-var-validation.php", + nbWorkers: 1, + nbParallelRequests: 1, + }) +} + +func TestSidekickBinarySafe(t *testing.T) { + cwd, _ := os.Getwd() + entrypoint := cwd + "/testdata/sidekick-binary-entrypoint.php" + + runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) { + body, _ := testGet("http://example.com/sidekick-binary-safe.php", handler, t) + assert.Contains(t, body, "BINARY_LEN:11") + assert.Contains(t, body, "BINARY_CONTENT:"+hex.EncodeToString([]byte("hello\x00world"))) + assert.Contains(t, body, "UTF8:héllo wörld 🚀") + assert.Contains(t, body, "EMPTY_EXISTS:yes") + assert.Contains(t, body, "EMPTY_LEN:0") + }, &testOptions{ + workerScript: "sidekick-binary-safe.php", + nbWorkers: 1, + nbParallelRequests: 1, + initOpts: []frankenphp.Option{frankenphp.WithMaxThreads(50)}, + workerOpts: []frankenphp.WorkerOption{ + frankenphp.WithWorkerSidekickRegistry(frankenphp.NewSidekickRegistry(entrypoint)), + }, + }) +} + +func TestSidekickGetVarsMultiple(t *testing.T) { + cwd, _ := os.Getwd() + entrypoint := cwd + "/testdata/sidekick-multi-entrypoint.php" + + runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) { + body, _ := testGet("http://example.com/sidekick-multi.php", handler, t) + assert.Equal(t, "sidekick-a:NAME_SIDEKICK_A=sidekick-a,sidekick-b:NAME_SIDEKICK_B=sidekick-b", body) + }, &testOptions{ + workerScript: "sidekick-multi.php", + nbWorkers: 1, + nbParallelRequests: 1, + initOpts: []frankenphp.Option{frankenphp.WithMaxThreads(50)}, + workerOpts: []frankenphp.WorkerOption{ + frankenphp.WithWorkerSidekickRegistry(frankenphp.NewSidekickRegistry(entrypoint)), + }, + }) +} +func TestSidekickCrashRestart(t *testing.T) { + cwd, _ := os.Getwd() + entrypoint := cwd + "/testdata/sidekick-crash.php" + + runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) { + // get_vars blocks — sidekick crashes, restarts, then publishes + body, _ := testGet("http://example.com/sidekick-crash-starter.php", handler, t) + assert.Equal(t, "restarted", body) + }, &testOptions{ + workerScript: "sidekick-crash-starter.php", + nbWorkers: 1, + nbParallelRequests: 1, + initOpts: []frankenphp.Option{frankenphp.WithMaxThreads(50)}, + workerOpts: []frankenphp.WorkerOption{ + frankenphp.WithWorkerSidekickRegistry(frankenphp.NewSidekickRegistry(entrypoint)), + }, + }) +} + func ExampleServeHTTP() { if err := frankenphp.Init(); err != nil { panic(err) diff --git a/options.go b/options.go index 9ba1f916f6..f2273beee1 100644 --- a/options.go +++ b/options.go @@ -49,6 +49,7 @@ type workerOpt struct { onThreadShutdown func(int) onServerStartup func() onServerShutdown func() + sidekickRegistry *SidekickRegistry } // WithContext sets the main context to use. @@ -85,6 +86,14 @@ func WithMetrics(m Metrics) Option { } } +func WithWorkerSidekickRegistry(registry *SidekickRegistry) WorkerOption { + return func(w *workerOpt) error { + w.sidekickRegistry = registry + + return nil + } +} + // WithWorkers configures the PHP workers to start func WithWorkers(name, fileName string, num int, options ...WorkerOption) Option { return func(o *opt) error { diff --git a/requestoptions.go b/requestoptions.go index 42cc3cf7c0..f74ac8de47 100644 --- a/requestoptions.go +++ b/requestoptions.go @@ -164,3 +164,11 @@ func WithWorkerName(name string) RequestOption { return nil } } + +func WithRequestSidekickRegistry(registry *SidekickRegistry) RequestOption { + return func(o *frankenPHPContext) error { + o.sidekickRegistry = registry + + return nil + } +} diff --git a/sidekick_test.go b/sidekick_test.go new file mode 100644 index 0000000000..c2c44946e5 --- /dev/null +++ b/sidekick_test.go @@ -0,0 +1,122 @@ +package frankenphp + +import ( + "testing" + "time" + + "github.com/dunglas/frankenphp/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type sidekickTestMetrics struct { + readyCalls int + stopCalls []StopReason +} + +func (m *sidekickTestMetrics) StartWorker(string) {} + +func (m *sidekickTestMetrics) ReadyWorker(string) { + m.readyCalls++ +} + +func (m *sidekickTestMetrics) StopWorker(_ string, reason StopReason) { + m.stopCalls = append(m.stopCalls, reason) +} + +func (m *sidekickTestMetrics) TotalWorkers(string, int) {} + +func (m *sidekickTestMetrics) TotalThreads(int) {} + +func (m *sidekickTestMetrics) StartRequest() {} + +func (m *sidekickTestMetrics) StopRequest() {} + +func (m *sidekickTestMetrics) StopWorkerRequest(string, time.Duration) {} + +func (m *sidekickTestMetrics) StartWorkerRequest(string) {} + +func (m *sidekickTestMetrics) Shutdown() {} + +func (m *sidekickTestMetrics) QueuedWorkerRequest(string) {} + +func (m *sidekickTestMetrics) DequeuedWorkerRequest(string) {} + +func (m *sidekickTestMetrics) QueuedRequest() {} + +func (m *sidekickTestMetrics) DequeuedRequest() {} + +func TestStartSidekickFailureIsRetryable(t *testing.T) { + registry := NewSidekickRegistry(testDataPath + "/sidekick-with-argv.php") + thread := newPHPThread(0) + thread.state.Set(state.Ready) + thread.handler = &workerThread{ + thread: thread, + worker: &worker{sidekickRegistry: registry}, + } + phpThreads = []*phpThread{thread} + t.Cleanup(func() { + phpThreads = nil + }) + + err := startSidekick(thread, "retryable-sidekick") + require.EqualError(t, err, "no available PHP thread for sidekick (increase max_threads)") + assert.Empty(t, registry.sidekicks) + + err = startSidekick(thread, "retryable-sidekick") + require.EqualError(t, err, "no available PHP thread for sidekick (increase max_threads)") + assert.Empty(t, registry.sidekicks) +} + + +func TestSidekickSetVarsMarksWorkerReady(t *testing.T) { + originalMetrics := metrics + testMetrics := &sidekickTestMetrics{} + metrics = testMetrics + t.Cleanup(func() { + metrics = originalMetrics + }) + + handler := &workerThread{ + thread: newPHPThread(0), + worker: &worker{name: "sidekick", fileName: "sidekick.php", maxConsecutiveFailures: -1}, + isBootingScript: true, + } + + handler.markSidekickReady() + handler.markSidekickReady() + + assert.False(t, handler.isBootingScript) + assert.Equal(t, 0, handler.failureCount) + assert.Equal(t, 1, testMetrics.readyCalls) +} + +func TestSidekickBootFailureStaysBootFailureUntilReady(t *testing.T) { + originalMetrics := metrics + testMetrics := &sidekickTestMetrics{} + metrics = testMetrics + t.Cleanup(func() { + metrics = originalMetrics + }) + + handler := &workerThread{ + thread: newPHPThread(0), + worker: &worker{ + name: "sidekick", + fileName: "sidekick.php", + maxConsecutiveFailures: -1, + }, + isBootingScript: true, + } + + tearDownWorkerScript(handler, 1) + require.Len(t, testMetrics.stopCalls, 1) + assert.Equal(t, StopReason(StopReasonBootFailure), testMetrics.stopCalls[0]) + + testMetrics.stopCalls = nil + handler.isBootingScript = true + handler.markSidekickReady() + tearDownWorkerScript(handler, 1) + require.Len(t, testMetrics.stopCalls, 1) + assert.Equal(t, StopReason(StopReasonCrash), testMetrics.stopCalls[0]) +} diff --git a/testdata/sidekick-binary-entrypoint.php b/testdata/sidekick-binary-entrypoint.php new file mode 100644 index 0000000000..44516c3448 --- /dev/null +++ b/testdata/sidekick-binary-entrypoint.php @@ -0,0 +1,11 @@ + "hello\x00world", + 'UTF8_TEST' => "héllo wörld 🚀", + 'EMPTY_VAL' => "", +]); + +while (!frankenphp_sidekick_should_stop()) { + usleep(10_000); +} diff --git a/testdata/sidekick-binary-safe.php b/testdata/sidekick-binary-safe.php new file mode 100644 index 0000000000..220a4e6952 --- /dev/null +++ b/testdata/sidekick-binary-safe.php @@ -0,0 +1,23 @@ +getMessage(); + return; + } + + $results = []; + + $bin = $vars['BINARY_TEST'] ?? 'NOT_SET'; + $results[] = 'BINARY_LEN:' . strlen($bin); + $results[] = 'BINARY_CONTENT:' . bin2hex($bin); + + $results[] = 'UTF8:' . ($vars['UTF8_TEST'] ?? 'NOT_SET'); + + $results[] = 'EMPTY_EXISTS:' . (array_key_exists('EMPTY_VAL', $vars) ? 'yes' : 'no'); + $results[] = 'EMPTY_LEN:' . strlen($vars['EMPTY_VAL'] ?? 'NOT_SET'); + + echo implode("\n", $results); +}); diff --git a/testdata/sidekick-crash-starter.php b/testdata/sidekick-crash-starter.php new file mode 100644 index 0000000000..de3495e2f0 --- /dev/null +++ b/testdata/sidekick-crash-starter.php @@ -0,0 +1,10 @@ +getMessage(); + } +}); diff --git a/testdata/sidekick-crash.php b/testdata/sidekick-crash.php new file mode 100644 index 0000000000..8db00ae37d --- /dev/null +++ b/testdata/sidekick-crash.php @@ -0,0 +1,17 @@ + 'restarted']); + +while (!frankenphp_sidekick_should_stop()) { + usleep(10_000); +} + +@unlink($marker); diff --git a/testdata/sidekick-dedup.php b/testdata/sidekick-dedup.php new file mode 100644 index 0000000000..6a3e1febd0 --- /dev/null +++ b/testdata/sidekick-dedup.php @@ -0,0 +1,9 @@ + $name]); + +while (!frankenphp_sidekick_should_stop()) { + usleep(10_000); +} diff --git a/testdata/sidekick-multi-entrypoint.php b/testdata/sidekick-multi-entrypoint.php new file mode 100644 index 0000000000..413b7aabb0 --- /dev/null +++ b/testdata/sidekick-multi-entrypoint.php @@ -0,0 +1,9 @@ + $name]); + +while (!frankenphp_sidekick_should_stop()) { + usleep(10_000); +} diff --git a/testdata/sidekick-multi.php b/testdata/sidekick-multi.php new file mode 100644 index 0000000000..2eb50f870d --- /dev/null +++ b/testdata/sidekick-multi.php @@ -0,0 +1,17 @@ + $vars) { + foreach ($vars as $k => $v) { + $parts[] = "$name:$k=$v"; + } + } + echo implode(',', $parts); + } catch (\Throwable $e) { + echo 'ERROR:' . $e->getMessage(); + } +}); diff --git a/testdata/sidekick-no-entrypoint.php b/testdata/sidekick-no-entrypoint.php new file mode 100644 index 0000000000..d1114eeb7f --- /dev/null +++ b/testdata/sidekick-no-entrypoint.php @@ -0,0 +1,10 @@ +getMessage(); + } +}); diff --git a/testdata/sidekick-set-server-var-validation.php b/testdata/sidekick-set-server-var-validation.php new file mode 100644 index 0000000000..a2e616bbef --- /dev/null +++ b/testdata/sidekick-set-server-var-validation.php @@ -0,0 +1,28 @@ + 'val']); + $results[] = 'NON_SIDEKICK:no_error'; + } catch (\RuntimeException $e) { + $results[] = 'NON_SIDEKICK:blocked'; + } + + try { + frankenphp_sidekick_set_vars(['KEY' => 123]); + $results[] = 'NON_STRING_VAL:no_error'; + } catch (\ValueError $e) { + $results[] = 'NON_STRING_VAL:blocked'; + } + + try { + frankenphp_sidekick_set_vars([0 => 'val']); + $results[] = 'INT_KEY:no_error'; + } catch (\ValueError $e) { + $results[] = 'INT_KEY:blocked'; + } + + echo implode("\n", $results); +}); diff --git a/testdata/sidekick-start-twice.php b/testdata/sidekick-start-twice.php new file mode 100644 index 0000000000..2ccd6173ec --- /dev/null +++ b/testdata/sidekick-start-twice.php @@ -0,0 +1,10 @@ +getMessage(); + } +}); diff --git a/testdata/sidekick-start.php b/testdata/sidekick-start.php new file mode 100644 index 0000000000..91afe4428f --- /dev/null +++ b/testdata/sidekick-start.php @@ -0,0 +1,10 @@ +getMessage(); + } +}); diff --git a/testdata/sidekick-with-argv.php b/testdata/sidekick-with-argv.php new file mode 100644 index 0000000000..255ce52877 --- /dev/null +++ b/testdata/sidekick-with-argv.php @@ -0,0 +1,11 @@ + $name]); + +while (!frankenphp_sidekick_should_stop()) { + usleep(10_000); +} diff --git a/threadworker.go b/threadworker.go index a0984afab7..e79a3bf322 100644 --- a/threadworker.go +++ b/threadworker.go @@ -101,10 +101,17 @@ func (handler *workerThread) name() string { func setupWorkerScript(handler *workerThread, worker *worker) { metrics.StartWorker(worker.name) - // Create a dummy request to set up the worker + opts := worker.requestOptions + if !worker.httpEnabled { + opts = append(opts, WithRequestPreparedEnv(PreparedEnv{ + "FRANKENPHP_SIDEKICK_NAME\x00": worker.name, + })) + C.frankenphp_set_sidekick_name(handler.thread.pinCString(worker.name)) + } + fc, err := newDummyContext( filepath.Base(worker.fileName), - worker.requestOptions..., + opts..., ) if err != nil { panic(err) @@ -120,6 +127,21 @@ func setupWorkerScript(handler *workerThread, worker *worker) { if globalLogger.Enabled(ctx, slog.LevelDebug) { globalLogger.LogAttrs(ctx, slog.LevelDebug, "starting", slog.String("worker", worker.name), slog.Int("thread", handler.thread.threadIndex)) } + + if !worker.httpEnabled { + handler.thread.state.Set(state.Ready) + fc.scriptFilename = worker.fileName + } +} + +func (handler *workerThread) markSidekickReady() { + if !handler.isBootingScript { + return + } + + handler.failureCount = 0 + handler.isBootingScript = false + metrics.ReadyWorker(handler.worker.name) } func tearDownWorkerScript(handler *workerThread, exitStatus int) { @@ -296,7 +318,7 @@ func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t, retval *C.zval thread.handler.(*workerThread).workerFrankenPHPContext = nil thread.handler.(*workerThread).workerContext = nil - if globalLogger.Enabled(ctx, slog.LevelDebug) { + if globalLogger.Enabled(ctx, slog.LevelDebug) && thread.handler.(*workerThread).worker.httpEnabled { if fc.request == nil { fc.logger.LogAttrs(ctx, slog.LevelDebug, "request handling finished", slog.String("worker", fc.worker.name), slog.Int("thread", thread.threadIndex)) } else { diff --git a/worker.go b/worker.go index c97cc4a3a7..a802a4617e 100644 --- a/worker.go +++ b/worker.go @@ -4,6 +4,7 @@ package frankenphp import "C" import ( "fmt" + "log/slog" "os" "path/filepath" "runtime" @@ -11,6 +12,7 @@ import ( "sync" "sync/atomic" "time" + "unsafe" "github.com/dunglas/frankenphp/internal/fastabs" "github.com/dunglas/frankenphp/internal/state" @@ -33,6 +35,58 @@ type worker struct { onThreadReady func(int) onThreadShutdown func(int) queuedRequests atomic.Int32 + httpEnabled bool + sidekickRegistry *SidekickRegistry + sidekick *sidekickState +} + +type sidekickState struct { + varsPtr unsafe.Pointer // *C.HashTable, persistent, managed by C + mu sync.RWMutex + ready chan struct{} + readyOnce sync.Once + err error +} + +func (s *sidekickState) setError(err error) { + s.err = err + s.readyOnce.Do(func() { close(s.ready) }) +} + +type SidekickRegistry struct { + entrypoint string + mu sync.Mutex + sidekicks map[string]*sidekickState +} + +func NewSidekickRegistry(entrypoint string) *SidekickRegistry { + return &SidekickRegistry{ + entrypoint: entrypoint, + sidekicks: make(map[string]*sidekickState), + } +} + +func (registry *SidekickRegistry) reserve(name string) (*sidekickState, bool) { + registry.mu.Lock() + defer registry.mu.Unlock() + + if sidekick := registry.sidekicks[name]; sidekick != nil { + return sidekick, true + } + + sidekick := &sidekickState{ready: make(chan struct{})} + registry.sidekicks[name] = sidekick + + return sidekick, false +} + +func (registry *SidekickRegistry) remove(name string, sidekick *sidekickState) { + registry.mu.Lock() + defer registry.mu.Unlock() + + if registry.sidekicks[name] == sidekick { + delete(registry.sidekicks, name) + } } var ( @@ -148,6 +202,8 @@ func newWorker(o workerOpt) (*worker, error) { maxConsecutiveFailures: o.maxConsecutiveFailures, onThreadReady: o.onThreadReady, onThreadShutdown: o.onThreadShutdown, + httpEnabled: true, + sidekickRegistry: o.sidekickRegistry, } w.configureMercure(&o) @@ -246,6 +302,59 @@ func (worker *worker) countThreads() int { return l } +func startSidekick(thread *phpThread, sidekickName string) error { + if sidekickName == "" { + return fmt.Errorf("sidekick name must not be empty") + } + + registry := getRegistry(thread) + if registry == nil || registry.entrypoint == "" { + return fmt.Errorf("no sidekick_entrypoint configured in this php_server") + } + + sidekick, exists := registry.reserve(sidekickName) + if exists { + return nil + } + + worker, err := newWorker(workerOpt{ + name: sidekickName, + fileName: registry.entrypoint, + num: 1, + env: PrepareEnv(nil), + watch: []string{}, + maxConsecutiveFailures: -1, + }) + if err != nil { + registry.remove(sidekickName, sidekick) + + return fmt.Errorf("failed to create sidekick worker: %w", err) + } + + worker.httpEnabled = false + worker.sidekick = sidekick + worker.sidekickRegistry = registry + + sidekickThread := getInactivePHPThread() + if sidekickThread == nil { + registry.remove(sidekickName, sidekick) + + return fmt.Errorf("no available PHP thread for sidekick (increase max_threads)") + } + + scalingMu.Lock() + workers = append(workers, worker) + scalingMu.Unlock() + + convertToWorkerThread(sidekickThread, worker) + + if globalLogger.Enabled(globalCtx, slog.LevelInfo) { + globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "sidekick started", slog.String("name", sidekickName)) + } + + return nil +} + // check if max_threads has been reached func (worker *worker) isAtThreadLimit() bool { if worker.maxThreads <= 0 { @@ -314,3 +423,126 @@ func (worker *worker) handleRequest(ch contextHolder) error { } } } + +//export go_frankenphp_start_sidekick +func go_frankenphp_start_sidekick(threadIndex C.uintptr_t, name *C.char, nameLen C.size_t) *C.char { + sidekickName := C.GoStringN(name, C.int(nameLen)) + + if err := startSidekick(phpThreads[threadIndex], sidekickName); err != nil { + return C.CString(err.Error()) + } + + return nil +} + +// go_frankenphp_sidekick_wait_and_get waits for sidekicks to be ready and returns +// their persistent HashTable pointers. outPtrs must point to a C-allocated array +// of nameCount void* slots. +// +//export go_frankenphp_sidekick_wait_and_get +func go_frankenphp_sidekick_wait_and_get(threadIndex C.uintptr_t, names **C.char, nameLens *C.size_t, nameCount C.int, timeoutMs C.int, outPtrs *unsafe.Pointer) *C.char { + registry := getRegistry(phpThreads[threadIndex]) + if registry == nil { + return C.CString("no sidekick_entrypoint configured in this php_server") + } + + n := int(nameCount) + nameSlice := unsafe.Slice(names, n) + nameLenSlice := unsafe.Slice(nameLens, n) + ptrSlice := unsafe.Slice(outPtrs, n) + + sks := make([]*sidekickState, n) + goNames := make([]string, n) + for i := 0; i < n; i++ { + goNames[i] = C.GoStringN(nameSlice[i], C.int(nameLenSlice[i])) + registry.mu.Lock() + sks[i] = registry.sidekicks[goNames[i]] + registry.mu.Unlock() + if sks[i] == nil { + return C.CString("sidekick not found: " + goNames[i]) + } + } + + timeout := time.Duration(timeoutMs) * time.Millisecond + done := make(chan error, 1) + go func() { + timer := time.NewTimer(timeout) + defer timer.Stop() + for i, sk := range sks { + select { + case <-sk.ready: + if sk.err != nil { + done <- fmt.Errorf("sidekick failed: %s: %w", goNames[i], sk.err) + return + } + case <-timer.C: + done <- fmt.Errorf("timeout waiting for sidekick: %s", goNames[i]) + return + } + } + done <- nil + }() + + if err := <-done; err != nil { + return C.CString(err.Error()) + } + + for i, sk := range sks { + sk.mu.RLock() + ptrSlice[i] = sk.varsPtr + sk.mu.RUnlock() + } + + return nil +} + +func getRegistry(thread *phpThread) *SidekickRegistry { + if handler, ok := thread.handler.(*workerThread); ok && handler.worker.sidekickRegistry != nil { + return handler.worker.sidekickRegistry + } + if fc, ok := fromContext(thread.context()); ok { + return fc.sidekickRegistry + } + + return nil +} + +//export go_frankenphp_sidekick_should_stop +func go_frankenphp_sidekick_should_stop(threadIndex C.uintptr_t) C.int { + thread := phpThreads[threadIndex] + + handler, ok := thread.handler.(*workerThread) + if !ok || handler.worker.httpEnabled || handler.worker.sidekick == nil { + return -1 + } + + if thread.state.Is(state.ShuttingDown) || thread.state.Is(state.Done) { + return 1 + } + + return 0 +} + +//export go_frankenphp_sidekick_set_vars +func go_frankenphp_sidekick_set_vars(threadIndex C.uintptr_t, varsPtr unsafe.Pointer, oldPtr *unsafe.Pointer) *C.char { + thread := phpThreads[threadIndex] + + handler, ok := thread.handler.(*workerThread) + if !ok || handler.worker.httpEnabled || handler.worker.sidekick == nil { + return C.CString("frankenphp_sidekick_set_vars() can only be called from a sidekick") + } + + sk := handler.worker.sidekick + + sk.mu.Lock() + *oldPtr = sk.varsPtr + sk.varsPtr = varsPtr + sk.mu.Unlock() + + sk.readyOnce.Do(func() { + handler.markSidekickReady() + close(sk.ready) + }) + + return nil +}