Skip to content

Commit bf1a0ff

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

23 files changed

+752
-17
lines changed

caddy/app.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ type FrankenPHPApp struct {
5757
MaxWaitTime time.Duration `json:"max_wait_time,omitempty"`
5858
// The maximum amount of time an autoscaled thread may be idle before being deactivated
5959
MaxIdleTime time.Duration `json:"max_idle_time,omitempty"`
60+
// SidekickEntrypoint is the script used to start sidekicks (e.g., bin/console)
61+
SidekickEntrypoint string `json:"sidekick_entrypoint,omitempty"`
6062

6163
opts []frankenphp.Option
6264
metrics frankenphp.Metrics
@@ -153,6 +155,7 @@ func (f *FrankenPHPApp) Start() error {
153155
frankenphp.WithPhpIni(f.PhpIni),
154156
frankenphp.WithMaxWaitTime(f.MaxWaitTime),
155157
frankenphp.WithMaxIdleTime(f.MaxIdleTime),
158+
frankenphp.WithSidekickEntrypoint(f.SidekickEntrypoint),
156159
)
157160

158161
for _, w := range f.Workers {
@@ -291,6 +294,11 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
291294
}
292295
}
293296

297+
case "sidekick_entrypoint":
298+
if !d.NextArg() {
299+
return d.ArgErr()
300+
}
301+
f.SidekickEntrypoint = d.Val()
294302
case "worker":
295303
wc, err := unmarshalWorker(d)
296304
if err != nil {
@@ -311,7 +319,7 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
311319

312320
f.Workers = append(f.Workers, wc)
313321
default:
314-
return wrongSubDirectiveError("frankenphp", "num_threads, max_threads, php_ini, worker, max_wait_time, max_idle_time", d.Val())
322+
return wrongSubDirectiveError("frankenphp", "num_threads, max_threads, php_ini, worker, max_wait_time, max_idle_time, sidekick_entrypoint", d.Val())
315323
}
316324
}
317325
}

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: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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 the global `frankenphp` block in your Caddyfile.
17+
This is the script that will be executed for every sidekick:
18+
19+
```caddyfile
20+
{
21+
frankenphp {
22+
sidekick_entrypoint /app/bin/sidekick.php
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, array $argv): void`
32+
33+
Starts a sidekick worker. The configured `sidekick_entrypoint` script is executed
34+
in a new PHP thread with the given `$argv` available 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+
- **`$argv`**: must be an array of strings; available as `$_SERVER['argv']` in the sidekick script (with the script path prepended as `argv[0]`, like CLI)
38+
- Throws `ValueError` if `$name` is empty
39+
- Throws `InvalidArgumentException` if `$argv` contains non-string values
40+
- Throws `RuntimeException` if no `sidekick_entrypoint` is configured or no thread is available
41+
42+
### `frankenphp_set_server_var(string $key, ?string $value): void`
43+
44+
Sets a variable that will be injected into `$_SERVER` of all HTTP workers.
45+
Can be called from any worker (sidekick or HTTP).
46+
47+
- Passing `null` as `$value` removes the key (it will no longer be injected)
48+
- Throws `ValueError` if `$key` is empty or starts with `HTTP_` (reserved for HTTP request headers)
49+
50+
### `frankenphp_sidekick_should_stop(): bool`
51+
52+
Returns `true` when FrankenPHP is shutting down. Sidekick scripts must poll this
53+
in their event loop to exit gracefully:
54+
55+
```php
56+
while (!frankenphp_sidekick_should_stop()) {
57+
// do work
58+
usleep(100_000);
59+
}
60+
```
61+
62+
## Example
63+
64+
### Sidekick Script
65+
66+
```php
67+
<?php
68+
// bin/sidekick.php
69+
70+
require __DIR__.'/../vendor/autoload.php';
71+
72+
$argv = $_SERVER['argv'] ?? [];
73+
array_shift($argv); // skip argv[0] (script name)
74+
75+
// Set initial values
76+
frankenphp_set_server_var('REDIS_MASTER_HOST', '10.0.0.1');
77+
frankenphp_set_server_var('REDIS_MASTER_PORT', '6379');
78+
79+
while (!frankenphp_sidekick_should_stop()) {
80+
// Watch for changes (e.g., Redis Sentinel, file watcher, API poll)
81+
$master = discoverRedisMaster();
82+
frankenphp_set_server_var('REDIS_MASTER_HOST', $master['host']);
83+
frankenphp_set_server_var('REDIS_MASTER_PORT', (string) $master['port']);
84+
85+
usleep(100_000);
86+
}
87+
```
88+
89+
### Starting from the HTTP Worker
90+
91+
```php
92+
<?php
93+
// public/index.php
94+
95+
// Start sidekicks before your application boots
96+
// At-most-once: safe to call from multiple HTTP worker threads
97+
if (function_exists('frankenphp_sidekick_start')) {
98+
frankenphp_sidekick_start('redis-watcher', ['--sentinel-host=10.0.0.1']);
99+
}
100+
101+
// Your application entry point
102+
require __DIR__.'/../vendor/autoload.php';
103+
104+
$app = new App();
105+
$app->boot();
106+
107+
$handler = static function () use ($app) {
108+
// $_SERVER['REDIS_MASTER_HOST'] contains the latest value
109+
// published by the sidekick — consistent for the entire request
110+
echo $app->handle($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER);
111+
};
112+
113+
while (frankenphp_handle_request($handler)) {
114+
$app->terminate();
115+
gc_collect_cycles();
116+
}
117+
118+
$app->shutdown();
119+
```
120+
121+
### Reading Sidekick Variables
122+
123+
In your application code, read the values from `$_SERVER`:
124+
125+
```php
126+
$redisHost = $_SERVER['REDIS_MASTER_HOST'] ?? '127.0.0.1';
127+
$redisPort = $_SERVER['REDIS_MASTER_PORT'] ?? '6379';
128+
129+
$redis = new \Redis();
130+
$redis->connect($redisHost, (int) $redisPort);
131+
```
132+
133+
## Runtime Behavior
134+
135+
- **Execution timeout disabled**: sidekick threads automatically call `zend_unset_timeout()`
136+
- **Shebang support**: entrypoints with `#!/usr/bin/env php` are handled correctly
137+
- **Crash recovery**: if a sidekick script exits, FrankenPHP restarts it automatically (using the existing worker restart logic with exponential backoff)
138+
- **Graceful shutdown**: sidekicks detect shutdown via `frankenphp_sidekick_should_stop()` and exit their loop
139+
- **`SCRIPT_FILENAME`**: set to the entrypoint's full path, so `dirname(__DIR__)` works correctly
140+
- **`$_SERVER['argv']`**: follows CLI conventions — `argv[0]` is the script path, followed by the arguments passed to `frankenphp_sidekick_start()`
141+
142+
## Debugging
143+
144+
- Use `error_log()` or `frankenphp_log()` for structured output — these go through FrankenPHP's logger
145+
- Avoid `echo` in sidekicks — it produces unstructured, unattributed log entries
146+
- Sidekick scripts can be tested standalone: `php bin/sidekick.php`

frankenphp.c

Lines changed: 143 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,95 @@ 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+
zval *argv_array = NULL;
664+
665+
ZEND_PARSE_PARAMETERS_START(2, 2);
666+
Z_PARAM_STRING(name, name_len);
667+
Z_PARAM_ARRAY(argv_array);
668+
ZEND_PARSE_PARAMETERS_END();
669+
670+
if (name_len == 0) {
671+
zend_value_error("Sidekick name must not be empty");
672+
RETURN_THROWS();
673+
}
674+
675+
/* Validate all array values are strings */
676+
HashTable *ht = Z_ARRVAL_P(argv_array);
677+
int argc = zend_hash_num_elements(ht);
678+
zval *val;
679+
ZEND_HASH_FOREACH_VAL(ht, val) {
680+
if (Z_TYPE_P(val) != IS_STRING) {
681+
zend_throw_exception(spl_ce_InvalidArgumentException,
682+
"All argv values must be strings", 0);
683+
RETURN_THROWS();
684+
}
685+
}
686+
ZEND_HASH_FOREACH_END();
687+
688+
/* Allocate argv array; kept alive for the sidekick thread's lifetime */
689+
char **argv = pemalloc(sizeof(char *) * (argc + 1), 1);
690+
int i = 0;
691+
ZEND_HASH_FOREACH_VAL(ht, val) {
692+
argv[i] = pestrndup(Z_STRVAL_P(val), Z_STRLEN_P(val), 1);
693+
i++;
694+
}
695+
ZEND_HASH_FOREACH_END();
696+
argv[argc] = NULL;
697+
698+
char *error = go_frankenphp_start_sidekick(name, name_len, argc, argv);
699+
if (error) {
700+
for (int j = 0; j < argc; j++) {
701+
pefree(argv[j], 1);
702+
}
703+
pefree(argv, 1);
704+
705+
zend_throw_exception(spl_ce_RuntimeException, error, 0);
706+
free(error);
707+
RETURN_THROWS();
708+
}
709+
}
710+
711+
PHP_FUNCTION(frankenphp_sidekick_should_stop) {
712+
ZEND_PARSE_PARAMETERS_NONE();
713+
714+
if (go_frankenphp_sidekick_should_stop(thread_index)) {
715+
RETURN_TRUE;
716+
}
717+
RETURN_FALSE;
718+
}
719+
611720
PHP_FUNCTION(headers_send) {
612721
zend_long response_code = 200;
613722

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

9671076
/* Some variables are already present in SG(request_info) */
9681077
frankenphp_register_variables_from_request_info(track_vars_array);
1078+
1079+
/* For sidekick threads: inject $argv/$argc into $_SERVER
1080+
* argv[0] is the script name (like CLI), followed by the sidekick args */
1081+
if (!is_http_thread && sidekick_argc > 0) {
1082+
zval argv_array;
1083+
array_init(&argv_array);
1084+
1085+
/* Get SCRIPT_FILENAME to use as argv[0] */
1086+
zval *script_filename =
1087+
zend_hash_str_find(Z_ARRVAL_P(track_vars_array), "SCRIPT_FILENAME",
1088+
sizeof("SCRIPT_FILENAME") - 1);
1089+
if (script_filename) {
1090+
add_next_index_zval(&argv_array, script_filename);
1091+
Z_TRY_ADDREF_P(script_filename);
1092+
}
1093+
1094+
for (int i = 0; i < sidekick_argc; i++) {
1095+
add_next_index_string(&argv_array, sidekick_argv[i]);
1096+
}
1097+
1098+
zval argc_zval;
1099+
ZVAL_LONG(&argc_zval, zend_hash_num_elements(Z_ARRVAL(argv_array)));
1100+
1101+
zend_hash_str_update(Z_ARRVAL_P(track_vars_array), "argv",
1102+
sizeof("argv") - 1, &argv_array);
1103+
zend_hash_str_update(Z_ARRVAL_P(track_vars_array), "argc",
1104+
sizeof("argc") - 1, &argc_zval);
1105+
}
9691106
}
9701107

9711108
static void frankenphp_log_message(const char *message, int syslog_type_int) {
@@ -1217,6 +1354,11 @@ int frankenphp_execute_script(char *file_name) {
12171354

12181355
file_handle.primary_script = 1;
12191356

1357+
/* Sidekick entrypoints (e.g. bin/console) may have a shebang line */
1358+
if (!is_http_thread) {
1359+
CG(skip_shebang) = 1;
1360+
}
1361+
12201362
zend_first_try {
12211363
EG(exit_status) = 0;
12221364
php_execute_script(&file_handle);

0 commit comments

Comments
 (0)