Skip to content

Commit ad71bfe

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

26 files changed

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

0 commit comments

Comments
 (0)