Skip to content

Commit cb65f46

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

31 files changed

+1153
-17
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.WithWorkerSidekickRegistry(w.sidekickRegistry),
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: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,13 @@ 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
5153
preparedEnvNeedsReplacement bool
54+
sidekickRegistry *frankenphp.SidekickRegistry
5255
logger *slog.Logger
5356
requestOptions []frankenphp.RequestOption
5457
}
@@ -78,6 +81,10 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error {
7881

7982
f.assignMercureHub(ctx)
8083

84+
if f.SidekickEntrypoint != "" {
85+
f.sidekickRegistry = frankenphp.NewSidekickRegistry(f.SidekickEntrypoint)
86+
}
87+
8188
loggerOpt := frankenphp.WithRequestLogger(f.logger)
8289
for i, wc := range f.Workers {
8390
// make the file path absolute from the public directory
@@ -91,6 +98,7 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error {
9198
wc.inheritEnv(f.Env)
9299
}
93100

101+
wc.sidekickRegistry = f.sidekickRegistry
94102
wc.requestOptions = append(wc.requestOptions, loggerOpt)
95103
f.Workers[i] = wc
96104
}
@@ -241,6 +249,7 @@ func (f *FrankenPHPModule) ServeHTTP(w http.ResponseWriter, r *http.Request, _ c
241249
opts,
242250
frankenphp.WithOriginalRequest(new(ctx.Value(caddyhttp.OriginalRequestCtxKey).(http.Request))),
243251
frankenphp.WithWorkerName(workerName),
252+
frankenphp.WithRequestSidekickRegistry(f.sidekickRegistry),
244253
)...,
245254
)
246255

@@ -297,6 +306,12 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
297306
}
298307
f.ResolveRootSymlink = &v
299308

309+
case "sidekick_entrypoint":
310+
if !d.NextArg() {
311+
return d.ArgErr()
312+
}
313+
f.SidekickEntrypoint = d.Val()
314+
300315
case "worker":
301316
wc, err := unmarshalWorker(d)
302317
if err != nil {
@@ -311,7 +326,7 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
311326
}
312327

313328
default:
314-
return wrongSubDirectiveError("php or php_server", "hot_reload, name, root, split, env, resolve_root_symlink, worker", d.Val())
329+
return wrongSubDirectiveError("php or php_server", "hot_reload, name, root, split, env, resolve_root_symlink, sidekick_entrypoint, worker", d.Val())
315330
}
316331
}
317332
}

caddy/workerconfig.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +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-
45-
options []frankenphp.WorkerOption
44+
sidekickRegistry *frankenphp.SidekickRegistry
45+
options []frankenphp.WorkerOption
4646
requestOptions []frankenphp.RequestOption
4747
absFileName string
4848
matchRelPath string // pre-computed relative URL path for fast matching

context.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@ import (
1616
type frankenPHPContext struct {
1717
mercureContext
1818

19-
documentRoot string
20-
splitPath []string
21-
env PreparedEnv
22-
logger *slog.Logger
23-
request *http.Request
24-
originalRequest *http.Request
25-
worker *worker
19+
documentRoot string
20+
splitPath []string
21+
env PreparedEnv
22+
logger *slog.Logger
23+
request *http.Request
24+
originalRequest *http.Request
25+
worker *worker
26+
sidekickRegistry *SidekickRegistry
2627

2728
docURI string
2829
pathInfo string

docs/sidekicks.md

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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 event loop (subscribe to Redis, watch files, poll an API, etc.)
10+
- It calls `frankenphp_sidekick_set_vars()` to publish a snapshot of key-value pairs
11+
- HTTP workers call `frankenphp_sidekick_get_vars()` to read the latest snapshot
12+
- The first call to get vars **blocks until the sidekick has published** its initial state to prevent race conditions on startup
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:
18+
19+
```caddyfile
20+
example.com {
21+
php_server {
22+
sidekick_entrypoint /app/bin/console
23+
}
24+
}
25+
```
26+
27+
## PHP API
28+
29+
### `frankenphp_sidekick_get_vars(string|array $name, float $timeout = 30.0): array`
30+
31+
Returns the latest variables published by sidekick(s).
32+
On the first call for a given name, this function:
33+
34+
1. Starts the sidekick (at-most-once by name — safe with multiple HTTP workers)
35+
2. Blocks until the sidekick calls `frankenphp_sidekick_set_vars()` or the timeout expires
36+
37+
Subsequent calls return the latest snapshot immediately.
38+
39+
**When `$name` is a string**: returns the sidekick's vars as a flat associative array.
40+
41+
**When `$name` is an array**: starts all sidekicks in parallel, waits for all to be ready,
42+
and returns vars keyed by sidekick name:
43+
44+
```php
45+
$all = frankenphp_sidekick_get_vars(['redis-watcher', 'feature-flags']);
46+
// $all = [
47+
// 'redis-watcher' => ['MASTER_HOST' => '10.0.0.1', ...],
48+
// 'feature-flags' => ['DARK_MODE' => '1', ...],
49+
// ]
50+
```
51+
52+
- **`$name`** identifies the sidekick(s) and is passed as `$_SERVER['argv'][1]` to the entrypoint script
53+
- **`$timeout`** in seconds (default: 30.0) — throws `RuntimeException` on timeout
54+
- Throws `RuntimeException` if no `sidekick_entrypoint` is configured, no thread is available, or a sidekick crashes before publishing
55+
- Throws `ValueError` if the array contains non-string or empty values
56+
- Works in both **worker mode** and **non-worker mode** (regular `php_server` without workers)
57+
58+
### `frankenphp_sidekick_set_vars(array $vars): void`
59+
60+
Publishes a snapshot of variables from inside a sidekick script.
61+
All keys and values must be strings. Each call **replaces** the entire snapshot atomically.
62+
63+
- Can **only** be called from a sidekick context — throws `RuntimeException` otherwise
64+
- Throws `ValueError` if keys or values are not strings
65+
66+
### `frankenphp_sidekick_should_stop(): bool`
67+
68+
Returns `true` when FrankenPHP is shutting down. Sidekick scripts must poll this
69+
in their event loop to exit gracefully.
70+
71+
- Can **only** be called from a sidekick context — throws `RuntimeException` otherwise
72+
73+
```php
74+
while (!frankenphp_sidekick_should_stop()) {
75+
// do work
76+
usleep(100_000);
77+
}
78+
```
79+
80+
## Example
81+
82+
### Sidekick Entrypoint
83+
84+
```php
85+
<?php
86+
// bin/console (or any sidekick entrypoint)
87+
88+
require __DIR__.'/../vendor/autoload.php';
89+
90+
$command = $_SERVER['argv'][1] ?? '';
91+
92+
// Dispatch to the right sidekick based on the command name
93+
match ($command) {
94+
'redis-watcher' => runRedisWatcher(),
95+
default => throw new \RuntimeException("Unknown sidekick: $command"),
96+
};
97+
98+
function runRedisWatcher(): void
99+
{
100+
// Publish initial state — this unblocks any waiting get_vars() calls
101+
frankenphp_sidekick_set_vars([
102+
'REDIS_MASTER_HOST' => '10.0.0.1',
103+
'REDIS_MASTER_PORT' => '6379',
104+
]);
105+
106+
while (!frankenphp_sidekick_should_stop()) {
107+
$master = discoverRedisMaster();
108+
frankenphp_sidekick_set_vars([
109+
'REDIS_MASTER_HOST' => $master['host'],
110+
'REDIS_MASTER_PORT' => (string) $master['port'],
111+
]);
112+
usleep(100_000);
113+
}
114+
}
115+
```
116+
117+
### Reading from the HTTP Worker
118+
119+
```php
120+
<?php
121+
// public/index.php
122+
123+
require __DIR__.'/../vendor/autoload.php';
124+
125+
$app = new App();
126+
$app->boot();
127+
128+
$handler = static function () use ($app) {
129+
// Single sidekick: returns its vars directly
130+
$redis = frankenphp_sidekick_get_vars('redis-watcher');
131+
$host = $redis['REDIS_MASTER_HOST'];
132+
133+
// Multiple sidekicks: starts all in parallel, returns vars keyed by name
134+
$all = frankenphp_sidekick_get_vars(['redis-watcher', 'feature-flags']);
135+
$host = $all['redis-watcher']['REDIS_MASTER_HOST'];
136+
$darkMode = $all['feature-flags']['DARK_MODE'] ?? '0';
137+
138+
echo $app->handle($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER);
139+
};
140+
141+
while (frankenphp_handle_request($handler)) {
142+
$app->terminate();
143+
gc_collect_cycles();
144+
}
145+
146+
$app->shutdown();
147+
```
148+
149+
### Graceful Degradation
150+
151+
Applications can work with or without FrankenPHP:
152+
153+
```php
154+
if (function_exists('frankenphp_sidekick_get_vars')) {
155+
$config = frankenphp_sidekick_get_vars('config-watcher');
156+
} else {
157+
// Fallback: read from static config, environment, etc.
158+
$config = ['REDIS_MASTER_HOST' => getenv('REDIS_HOST') ?: '127.0.0.1'];
159+
}
160+
```
161+
162+
## Runtime Behavior
163+
164+
- **Execution timeout disabled**: sidekick threads automatically call `zend_unset_timeout()`
165+
- **Shebang support**: entrypoints with `#!/usr/bin/env php` aren't echoed
166+
- **Crash recovery**: if a sidekick script exits, FrankenPHP restarts it automatically (using the existing worker restart logic with exponential backoff)
167+
- **Graceful shutdown**: sidekicks detect shutdown via `frankenphp_sidekick_should_stop()` and exit their loop
168+
- **`SCRIPT_FILENAME`**: set to the entrypoint's full path, so `dirname(__DIR__)` works correctly
169+
- **`$_SERVER['argv']`**: follows CLI conventions — `argv[0]` is the script path, `argv[1]` is the sidekick name
170+
171+
## Debugging
172+
173+
- Use `error_log()` or `frankenphp_log()` for structured output — these go through FrankenPHP's logger
174+
- Avoid `echo` in sidekicks — it produces unstructured, unattributed log entries
175+
- Sidekick scripts can be tested standalone: `php bin/console redis-watcher`

0 commit comments

Comments
 (0)