Skip to content

Commit 8a56d4c

Browse files
feat: application sidekicks = non-HTTP workers with shared state
1 parent 097563d commit 8a56d4c

26 files changed

+755
-15
lines changed

caddy/app.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ func (f *FrankenPHPApp) Start() error {
162162
frankenphp.WithWorkerMaxFailures(w.MaxConsecutiveFailures),
163163
frankenphp.WithWorkerMaxThreads(w.MaxThreads),
164164
frankenphp.WithWorkerRequestOptions(w.requestOptions...),
165+
frankenphp.WithWorkerSidekickEntrypoint(w.SidekickEntrypoint),
165166
)
166167

167168
f.opts = append(f.opts, frankenphp.WithWorkers(w.Name, repl.ReplaceKnown(w.FileName, ""), w.Num, w.options...))

caddy/module.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ type FrankenPHPModule struct {
4545
Env map[string]string `json:"env,omitempty"`
4646
// Workers configures the worker scripts to start.
4747
Workers []workerConfig `json:"workers,omitempty"`
48+
// SidekickEntrypoint is the script used to start sidekicks (e.g., bin/console)
49+
SidekickEntrypoint string `json:"sidekick_entrypoint,omitempty"`
4850

4951
resolvedDocumentRoot string
5052
preparedEnv frankenphp.PreparedEnv
@@ -91,6 +93,7 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error {
9193
wc.inheritEnv(f.Env)
9294
}
9395

96+
wc.SidekickEntrypoint = f.SidekickEntrypoint
9497
wc.requestOptions = append(wc.requestOptions, loggerOpt)
9598
f.Workers[i] = wc
9699
}
@@ -297,6 +300,12 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
297300
}
298301
f.ResolveRootSymlink = &v
299302

303+
case "sidekick_entrypoint":
304+
if !d.NextArg() {
305+
return d.ArgErr()
306+
}
307+
f.SidekickEntrypoint = d.Val()
308+
300309
case "worker":
301310
wc, err := unmarshalWorker(d)
302311
if err != nil {
@@ -311,7 +320,7 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
311320
}
312321

313322
default:
314-
return wrongSubDirectiveError("php or php_server", "hot_reload, name, root, split, env, resolve_root_symlink, worker", d.Val())
323+
return wrongSubDirectiveError("php or php_server", "hot_reload, name, root, split, env, resolve_root_symlink, sidekick_entrypoint, worker", d.Val())
315324
}
316325
}
317326
}

caddy/workerconfig.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ type workerConfig struct {
4141
MatchPath []string `json:"match_path,omitempty"`
4242
// MaxConsecutiveFailures sets the maximum number of consecutive failures before panicking (defaults to 6, set to -1 to never panick)
4343
MaxConsecutiveFailures int `json:"max_consecutive_failures,omitempty"`
44+
// SidekickEntrypoint is the script used to start sidekicks (inherited from php_server)
45+
SidekickEntrypoint string `json:"sidekick_entrypoint,omitempty"`
4446

4547
options []frankenphp.WorkerOption
4648
requestOptions []frankenphp.RequestOption

cgi.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,9 @@ func go_register_server_variables(threadIndex C.uintptr_t, trackVarsArray *C.zva
194194

195195
// The Prepared Environment is registered last and can overwrite any previous values
196196
addPreparedEnvToServer(fc, trackVarsArray)
197+
198+
// Inject variables set by non-HTTP workers via frankenphp_set_server_var()
199+
addSidekickVarsToServer(trackVarsArray)
197200
}
198201

199202
// splitCgiPath splits the request path into SCRIPT_NAME, SCRIPT_FILENAME, PATH_INFO, DOCUMENT_URI

docs/sidekicks.md

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# Application Sidekicks
2+
3+
Sidekicks are long-running PHP workers that run **outside the HTTP request cycle**.
4+
They observe their environment (Redis Sentinel, secret vaults, feature flag services, etc.)
5+
and publish configuration to HTTP workers in real time — without polling, approximate TTLs, or redeployment.
6+
7+
## How It Works
8+
9+
- A sidekick runs its own infinite loop (subscribe to Redis, watch files, poll an API, etc.)
10+
- It calls `frankenphp_set_server_var()` to publish key-value pairs
11+
- HTTP workers receive those values in `$_SERVER` at each `frankenphp_handle_request()` iteration
12+
- Values are **consistent for the entire request** — no mid-request mutation
13+
14+
## Configuration
15+
16+
Add a `sidekick_entrypoint` to your `php_server` block in the Caddyfile.
17+
This is the script that will be executed for every sidekick started by workers of this server:
18+
19+
```caddyfile
20+
example.com {
21+
php_server {
22+
sidekick_entrypoint /app/bin/console
23+
}
24+
}
25+
```
26+
27+
Sidekicks are then started dynamically from PHP using `frankenphp_sidekick_start()`.
28+
29+
## PHP API
30+
31+
### `frankenphp_sidekick_start(string $name): void`
32+
33+
Starts a sidekick worker. The configured `sidekick_entrypoint` script is executed
34+
in a new PHP thread with `$name` as the first argument in `$_SERVER['argv']`.
35+
36+
- **At-most-once by name**: calling it multiple times with the same `$name` is a no-op (safe with multiple HTTP workers)
37+
- **`$_SERVER['argv']`** in the sidekick will be `[script_path, name]`
38+
- Throws `ValueError` if `$name` is empty
39+
- Throws `RuntimeException` if no `sidekick_entrypoint` is configured or no thread is available
40+
41+
### `frankenphp_set_server_var(string $key, ?string $value): void`
42+
43+
Sets a variable that will be injected into `$_SERVER` of all HTTP workers.
44+
Can be called from any worker (sidekick or HTTP).
45+
46+
- Passing `null` as `$value` removes the key (it will no longer be injected)
47+
- Throws `ValueError` if `$key` is empty or starts with `HTTP_` (reserved for HTTP request headers)
48+
49+
### `frankenphp_sidekick_should_stop(): bool`
50+
51+
Returns `true` when FrankenPHP is shutting down. Sidekick scripts must poll this
52+
in their event loop to exit gracefully:
53+
54+
```php
55+
while (!frankenphp_sidekick_should_stop()) {
56+
// do work
57+
usleep(100_000);
58+
}
59+
```
60+
61+
## Example
62+
63+
### Sidekick Script
64+
65+
```php
66+
<?php
67+
// bin/sidekick.php
68+
69+
require __DIR__.'/../vendor/autoload.php';
70+
71+
$argv = $_SERVER['argv'] ?? [];
72+
array_shift($argv); // skip argv[0] (script name)
73+
74+
// Set initial values
75+
frankenphp_set_server_var('REDIS_MASTER_HOST', '10.0.0.1');
76+
frankenphp_set_server_var('REDIS_MASTER_PORT', '6379');
77+
78+
while (!frankenphp_sidekick_should_stop()) {
79+
// Watch for changes (e.g., Redis Sentinel, file watcher, API poll)
80+
$master = discoverRedisMaster();
81+
frankenphp_set_server_var('REDIS_MASTER_HOST', $master['host']);
82+
frankenphp_set_server_var('REDIS_MASTER_PORT', (string) $master['port']);
83+
84+
usleep(100_000);
85+
}
86+
```
87+
88+
### Starting from the HTTP Worker
89+
90+
```php
91+
<?php
92+
// public/index.php
93+
94+
// Start sidekicks before your application boots
95+
// At-most-once: safe to call from multiple HTTP worker threads
96+
if (function_exists('frankenphp_sidekick_start')) {
97+
frankenphp_sidekick_start('redis-watcher', ['--sentinel-host=10.0.0.1']);
98+
}
99+
100+
// Your application entry point
101+
require __DIR__.'/../vendor/autoload.php';
102+
103+
$app = new App();
104+
$app->boot();
105+
106+
$handler = static function () use ($app) {
107+
// $_SERVER['REDIS_MASTER_HOST'] contains the latest value
108+
// published by the sidekick — consistent for the entire request
109+
echo $app->handle($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER);
110+
};
111+
112+
while (frankenphp_handle_request($handler)) {
113+
$app->terminate();
114+
gc_collect_cycles();
115+
}
116+
117+
$app->shutdown();
118+
```
119+
120+
### Reading Sidekick Variables
121+
122+
In your application code, read the values from `$_SERVER`:
123+
124+
```php
125+
$redisHost = $_SERVER['REDIS_MASTER_HOST'] ?? '127.0.0.1';
126+
$redisPort = $_SERVER['REDIS_MASTER_PORT'] ?? '6379';
127+
128+
$redis = new \Redis();
129+
$redis->connect($redisHost, (int) $redisPort);
130+
```
131+
132+
## Runtime Behavior
133+
134+
- **Execution timeout disabled**: sidekick threads automatically call `zend_unset_timeout()`
135+
- **Shebang support**: entrypoints with `#!/usr/bin/env php` are handled correctly
136+
- **Crash recovery**: if a sidekick script exits, FrankenPHP restarts it automatically (using the existing worker restart logic with exponential backoff)
137+
- **Graceful shutdown**: sidekicks detect shutdown via `frankenphp_sidekick_should_stop()` and exit their loop
138+
- **`SCRIPT_FILENAME`**: set to the entrypoint's full path, so `dirname(__DIR__)` works correctly
139+
- **`$_SERVER['argv']`**: follows CLI conventions — `argv[0]` is the script path, followed by the arguments passed to `frankenphp_sidekick_start()`
140+
141+
## Debugging
142+
143+
- Use `error_log()` or `frankenphp_log()` for structured output — these go through FrankenPHP's logger
144+
- Avoid `echo` in sidekicks — it produces unstructured, unattributed log entries
145+
- Sidekick scripts can be tested standalone: `php bin/sidekick.php`

frankenphp.c

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,27 @@ HashTable *main_thread_env = NULL;
8585

8686
__thread uintptr_t thread_index;
8787
__thread bool is_worker_thread = false;
88+
__thread bool is_http_thread = true;
89+
__thread int sidekick_argc = 0;
90+
__thread char **sidekick_argv = NULL;
8891
__thread HashTable *sandboxed_env = NULL;
8992

90-
void frankenphp_update_local_thread_context(bool is_worker) {
93+
void frankenphp_update_local_thread_context(bool is_worker, bool httpEnabled) {
9194
is_worker_thread = is_worker;
95+
is_http_thread = httpEnabled;
9296

9397
/* workers should keep running if the user aborts the connection */
9498
PG(ignore_user_abort) = is_worker ? 1 : original_user_abort_setting;
99+
100+
/* Sidekick workers are long-running, disable execution timeout */
101+
if (is_worker && !httpEnabled) {
102+
zend_unset_timeout();
103+
}
104+
}
105+
106+
void frankenphp_set_thread_argv(int argc, char **argv) {
107+
sidekick_argc = argc;
108+
sidekick_argv = argv;
95109
}
96110

97111
static void frankenphp_update_request_context() {
@@ -246,6 +260,9 @@ static void frankenphp_reset_session_state(void) {
246260

247261
/* Adapted from php_request_shutdown */
248262
static void frankenphp_worker_request_shutdown() {
263+
if (!is_http_thread) {
264+
return;
265+
}
249266
/* Flush all output buffers */
250267
zend_try { php_output_end_all(); }
251268
zend_end_try();
@@ -296,6 +313,9 @@ void get_full_env(zval *track_vars_array) {
296313
/* Adapted from php_request_startup() */
297314
static int frankenphp_worker_request_startup() {
298315
int retval = SUCCESS;
316+
if (!is_http_thread) {
317+
return retval;
318+
}
299319

300320
frankenphp_update_request_context();
301321

@@ -608,6 +628,65 @@ PHP_FUNCTION(frankenphp_handle_request) {
608628
RETURN_TRUE;
609629
}
610630

631+
PHP_FUNCTION(frankenphp_set_server_var) {
632+
char *key = NULL;
633+
size_t key_len = 0;
634+
char *value = NULL;
635+
size_t value_len = 0;
636+
637+
ZEND_PARSE_PARAMETERS_START(2, 2);
638+
Z_PARAM_STRING(key, key_len);
639+
Z_PARAM_STRING_OR_NULL(value, value_len);
640+
ZEND_PARSE_PARAMETERS_END();
641+
642+
if (key_len == 0) {
643+
zend_value_error("Key must not be empty");
644+
RETURN_THROWS();
645+
}
646+
647+
/* Reject HTTP_* keys; these are set per-request from HTTP headers */
648+
if (key_len >= 5 && memcmp(key, "HTTP_", 5) == 0) {
649+
zend_value_error("Key must not start with \"HTTP_\"");
650+
RETURN_THROWS();
651+
}
652+
653+
if (value == NULL) {
654+
go_frankenphp_unset_server_var(key, key_len);
655+
} else {
656+
go_frankenphp_set_server_var(key, key_len, value, value_len);
657+
}
658+
}
659+
660+
PHP_FUNCTION(frankenphp_sidekick_start) {
661+
char *name = NULL;
662+
size_t name_len = 0;
663+
664+
ZEND_PARSE_PARAMETERS_START(1, 1);
665+
Z_PARAM_STRING(name, name_len);
666+
ZEND_PARSE_PARAMETERS_END();
667+
668+
if (name_len == 0) {
669+
zend_value_error("Sidekick name must not be empty");
670+
RETURN_THROWS();
671+
}
672+
673+
char *error = go_frankenphp_start_sidekick(thread_index, name, name_len);
674+
if (error) {
675+
zend_throw_exception(spl_ce_RuntimeException, error, 0);
676+
free(error);
677+
RETURN_THROWS();
678+
}
679+
}
680+
681+
PHP_FUNCTION(frankenphp_sidekick_should_stop) {
682+
ZEND_PARSE_PARAMETERS_NONE();
683+
684+
if (go_frankenphp_sidekick_should_stop(thread_index)) {
685+
RETURN_TRUE;
686+
}
687+
RETURN_FALSE;
688+
}
689+
611690
PHP_FUNCTION(headers_send) {
612691
zend_long response_code = 200;
613692

@@ -966,6 +1045,34 @@ static void frankenphp_register_variables(zval *track_vars_array) {
9661045

9671046
/* Some variables are already present in SG(request_info) */
9681047
frankenphp_register_variables_from_request_info(track_vars_array);
1048+
1049+
/* For sidekick threads: inject $argv/$argc into $_SERVER
1050+
* argv[0] is the script name (like CLI), followed by the sidekick args */
1051+
if (!is_http_thread && sidekick_argc > 0) {
1052+
zval argv_array;
1053+
array_init(&argv_array);
1054+
1055+
/* Get SCRIPT_FILENAME to use as argv[0] */
1056+
zval *script_filename =
1057+
zend_hash_str_find(Z_ARRVAL_P(track_vars_array), "SCRIPT_FILENAME",
1058+
sizeof("SCRIPT_FILENAME") - 1);
1059+
if (script_filename) {
1060+
add_next_index_zval(&argv_array, script_filename);
1061+
Z_TRY_ADDREF_P(script_filename);
1062+
}
1063+
1064+
for (int i = 0; i < sidekick_argc; i++) {
1065+
add_next_index_string(&argv_array, sidekick_argv[i]);
1066+
}
1067+
1068+
zval argc_zval;
1069+
ZVAL_LONG(&argc_zval, zend_hash_num_elements(Z_ARRVAL(argv_array)));
1070+
1071+
zend_hash_str_update(Z_ARRVAL_P(track_vars_array), "argv",
1072+
sizeof("argv") - 1, &argv_array);
1073+
zend_hash_str_update(Z_ARRVAL_P(track_vars_array), "argc",
1074+
sizeof("argc") - 1, &argc_zval);
1075+
}
9691076
}
9701077

9711078
static void frankenphp_log_message(const char *message, int syslog_type_int) {
@@ -1217,6 +1324,11 @@ int frankenphp_execute_script(char *file_name) {
12171324

12181325
file_handle.primary_script = 1;
12191326

1327+
/* Sidekick entrypoints (e.g. bin/console) may have a shebang line */
1328+
if (!is_http_thread) {
1329+
CG(skip_shebang) = 1;
1330+
}
1331+
12201332
zend_first_try {
12211333
EG(exit_status) = 0;
12221334
php_execute_script(&file_handle);

0 commit comments

Comments
 (0)