Skip to content

Commit 0a767dc

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

28 files changed

+1117
-19
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: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,11 @@ 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
46-
requestOptions []frankenphp.RequestOption
47-
absFileName string
48-
matchRelPath string // pre-computed relative URL path for fast matching
44+
sidekickRegistry *frankenphp.SidekickRegistry
45+
options []frankenphp.WorkerOption
46+
requestOptions []frankenphp.RequestOption
47+
absFileName string
48+
matchRelPath string // pre-computed relative URL path for fast matching
4949
}
5050

5151
func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) {

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: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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, TTLs, or redeployment.
6+
7+
## How It Works
8+
9+
1. A sidekick runs its own event loop (subscribe to Redis, watch files, poll an API, etc.)
10+
2. It calls `frankenphp_sidekick_set_vars()` to publish key-value pairs
11+
3. HTTP workers call `frankenphp_sidekick_get_vars()` to read the latest snapshot
12+
4. The first `get_vars` call **blocks until the sidekick has published** — no startup race condition
13+
14+
## Configuration
15+
16+
```caddyfile
17+
example.com {
18+
php_server {
19+
sidekick_entrypoint /app/bin/console
20+
}
21+
}
22+
```
23+
24+
Each `php_server` block has its own isolated sidekick scope.
25+
26+
## PHP API
27+
28+
### `frankenphp_sidekick_get_vars(string|array $name, float $timeout = 30.0): array`
29+
30+
Starts a sidekick (at-most-once) and returns its published variables.
31+
32+
- First call blocks until the sidekick calls `set_vars()` or the timeout expires
33+
- Subsequent calls return the latest snapshot immediately
34+
- When `$name` is an array, all sidekicks start in parallel and vars are returned keyed by name:
35+
36+
```php
37+
$redis = frankenphp_sidekick_get_vars('redis-watcher');
38+
// ['MASTER_HOST' => '10.0.0.1', 'MASTER_PORT' => '6379']
39+
40+
$all = frankenphp_sidekick_get_vars(['redis-watcher', 'feature-flags']);
41+
// ['redis-watcher' => [...], 'feature-flags' => [...]]
42+
```
43+
44+
- `$name` is available as `$_SERVER['FRANKENPHP_SIDEKICK_NAME']` and `$_SERVER['argv'][1]` in the entrypoint script
45+
- Throws `RuntimeException` on timeout, missing entrypoint, or sidekick crash
46+
- Works in both worker and non-worker mode
47+
48+
### `frankenphp_sidekick_set_vars(array $vars): void`
49+
50+
Publishes a snapshot of string key-value pairs from inside a sidekick.
51+
Each call **replaces** the entire snapshot atomically.
52+
53+
- Throws `RuntimeException` if not called from a sidekick context
54+
- Throws `ValueError` if keys or values are not strings
55+
56+
### `frankenphp_sidekick_should_stop(): bool`
57+
58+
Returns `true` when FrankenPHP is shutting down.
59+
60+
- Throws `RuntimeException` if not called from a sidekick context
61+
62+
## Example
63+
64+
### Sidekick Entrypoint
65+
66+
```php
67+
<?php
68+
// bin/console
69+
70+
require __DIR__.'/../vendor/autoload.php';
71+
72+
$command = $_SERVER['FRANKENPHP_SIDEKICK_NAME'] ?? '';
73+
74+
match ($command) {
75+
'redis-watcher' => runRedisWatcher(),
76+
default => throw new \RuntimeException("Unknown sidekick: $command"),
77+
};
78+
79+
function runRedisWatcher(): void
80+
{
81+
frankenphp_sidekick_set_vars([
82+
'MASTER_HOST' => '10.0.0.1',
83+
'MASTER_PORT' => '6379',
84+
]);
85+
86+
while (!frankenphp_sidekick_should_stop()) {
87+
$master = discoverRedisMaster();
88+
frankenphp_sidekick_set_vars([
89+
'MASTER_HOST' => $master['host'],
90+
'MASTER_PORT' => (string) $master['port'],
91+
]);
92+
usleep(100_000);
93+
}
94+
}
95+
```
96+
97+
### HTTP Worker
98+
99+
```php
100+
<?php
101+
// public/index.php
102+
103+
require __DIR__.'/../vendor/autoload.php';
104+
105+
$app = new App();
106+
$app->boot();
107+
108+
while (frankenphp_handle_request(function () use ($app) {
109+
$redis = frankenphp_sidekick_get_vars('redis-watcher');
110+
111+
$app->handle($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER + $redis);
112+
})) {
113+
gc_collect_cycles();
114+
}
115+
```
116+
117+
### Graceful Degradation
118+
119+
```php
120+
if (function_exists('frankenphp_sidekick_get_vars')) {
121+
$config = frankenphp_sidekick_get_vars('config-watcher');
122+
} else {
123+
$config = ['MASTER_HOST' => getenv('REDIS_HOST') ?: '127.0.0.1'];
124+
}
125+
```
126+
127+
## Runtime Behavior
128+
129+
- Sidekicks get their own dedicated thread from the reserved pool; they don't reduce HTTP capacity
130+
- Execution timeout is automatically disabled
131+
- Shebangs (`#!/usr/bin/env php`) are silently skipped
132+
- `SCRIPT_FILENAME` is set to the entrypoint's full path
133+
- `$_SERVER['FRANKENPHP_SIDEKICK_NAME']` and `$_SERVER['argv'][1]` contain the sidekick name
134+
- Crash recovery: automatic restart with exponential backoff
135+
- Graceful shutdown via `frankenphp_sidekick_should_stop()`
136+
- Use `error_log()` or `frankenphp_log()` for logging — avoid `echo`

0 commit comments

Comments
 (0)