Skip to content

Commit 05e9702

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

26 files changed

+822
-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: 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 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 `$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). Only the first call determines the argv values
37+
- **`$_SERVER['argv']`** in the sidekick will be `[script_path, name, ...argv]`
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`

0 commit comments

Comments
 (0)