Skip to content

Commit 04ef4fe

Browse files
feat: ShutdownException for background workers
Adds FrankenPHP\ShutdownException to gracefully interrupt background workers blocked in C-level calls (sleep, Redis subscribe, etc.). On POSIX, pthread_kill(SIGUSR1) interrupts the blocked syscall with EINTR. The signal handler sets EG(vm_interrupt), causing the Zend VM to throw ShutdownException at the next opcode boundary. On Windows, CancelSynchronousIo() interrupts blocked I/O and the shutdown flag triggers the same exception path. PHP code catches it with try/catch - no new API, no callback: try { $redis->subscribe([...], function ($msg) { ... }); } catch (\FrankenPHP\ShutdownException) { return Command::SUCCESS; }
1 parent f62ce8e commit 04ef4fe

30 files changed

Lines changed: 307 additions & 325 deletions

background_worker.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ type backgroundWorkerState struct {
2020
readyOnce sync.Once
2121
}
2222

23+
// reset prepares the state for a worker restart: new ready channel so
24+
// get_vars blocks until the restarted worker calls set_vars again.
25+
func (s *backgroundWorkerState) reset() {
26+
s.ready = make(chan struct{})
27+
s.readyOnce = sync.Once{}
28+
}
29+
2330
type BackgroundWorkerRegistry struct {
2431
entrypoint string
2532
num int // threads per background worker (0 = lazy-start with 1 thread)

caddy/module.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ 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+
4849
resolvedDocumentRoot string
4950
preparedEnv frankenphp.PreparedEnv
5051
preparedEnvNeedsReplacement bool

caddy/workerconfig.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import (
1313
"github.com/dunglas/frankenphp/internal/fastabs"
1414
)
1515

16-
1716
// workerConfig represents the "worker" directive in the Caddyfile
1817
// it can appear in the "frankenphp", "php_server" and "php" directives
1918
//
@@ -42,13 +41,14 @@ type workerConfig struct {
4241
MatchPath []string `json:"match_path,omitempty"`
4342
// MaxConsecutiveFailures sets the maximum number of consecutive failures before panicking (defaults to 6, set to -1 to never panick)
4443
MaxConsecutiveFailures int `json:"max_consecutive_failures,omitempty"`
44+
4545
// Background marks this worker as a background worker (non-HTTP)
4646
Background bool `json:"background,omitempty"`
4747
backgroundRegistry *frankenphp.BackgroundWorkerRegistry
48-
options []frankenphp.WorkerOption
49-
requestOptions []frankenphp.RequestOption
50-
absFileName string
51-
matchRelPath string // pre-computed relative URL path for fast matching
48+
options []frankenphp.WorkerOption
49+
requestOptions []frankenphp.RequestOption
50+
absFileName string
51+
matchRelPath string // pre-computed relative URL path for fast matching
5252
}
5353

5454
func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) {
@@ -136,6 +136,7 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) {
136136
if err := caddyMatchPath.Provision(caddy.Context{}); err != nil {
137137
return wc, d.WrapErr(err)
138138
}
139+
139140
wc.MatchPath = caddyMatchPath
140141
case "max_consecutive_failures":
141142
if !d.NextArg() {

docs/background-workers.md

Lines changed: 38 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,15 @@ example.com {
3636

3737
- `num` and `max_threads` are accepted but capped at 1 for now (pooling is a future feature). Values > 1 are rejected with a clear error.
3838
- `max_threads` on catch-all workers sets a safety cap for lazy-started instances (defaults to 16).
39-
- `max_consecutive_failures` defaults to -1 (never panic on boot failures).
39+
- `max_consecutive_failures` defaults to 6 (same as HTTP workers).
4040
- `env` and `watch` work the same as HTTP workers.
4141

4242
### Thread reservation
4343

4444
Background workers get dedicated thread slots outside the global `max_threads` budget.
45-
They don't compete with HTTP auto-scaling. For catch-all workers, `max_threads` determines
46-
the reservation (default 16). Named workers with `num 0` (default) are lazy-started and
47-
don't reserve threads.
45+
They don't compete with HTTP auto-scaling. Named workers reserve 1 thread each
46+
(`max_threads` defaults to `max(num, 1)`). For catch-all workers, `max_threads` determines
47+
the reservation (default 16).
4848

4949
Each `php_server` block has its own isolated background worker scope.
5050

@@ -83,39 +83,28 @@ Objects, resources, and references are rejected.
8383
- Throws `RuntimeException` if not called from a background worker context
8484
- Throws `ValueError` if values contain objects, resources, or references
8585

86-
### `frankenphp_worker_get_signaling_stream(): resource`
86+
### Shutdown: `FrankenPHP\ShutdownException`
8787

88-
Returns a readable stream used for receiving signals from FrankenPHP.
89-
Signals are newline-terminated strings: `"stop\n"` signals shutdown or restart.
90-
Read with `fgets()` to identify the signal type.
88+
When FrankenPHP needs to stop a background worker (server shutdown, worker restart, file watcher trigger), it throws a `FrankenPHP\ShutdownException`. This exception interrupts any blocking PHP call - `sleep()`, `stream_select()`, Redis `subscribe()`, etc.
9189

92-
Use `stream_select()` instead of `sleep()` or `usleep()` to wait between iterations:
90+
Use `try/catch` to handle shutdown gracefully:
9391

9492
```php
95-
function background_worker_should_stop(float $timeout = 0): bool
96-
{
97-
static $signalingStream;
98-
$signalingStream ??= frankenphp_worker_get_signaling_stream();
99-
$s = (int) $timeout;
100-
101-
return match (@stream_select(...[[$signalingStream], [], [], $s, (int) (($timeout - $s) * 1e6)])) {
102-
0 => false, // timeout
103-
false => true, // error (pipe closed) = stop
104-
default => "stop\n" === fgets($signalingStream),
105-
};
93+
try {
94+
$redis->subscribe(['+switch-master'], function ($msg) {
95+
frankenphp_worker_set_vars([...]);
96+
});
97+
} catch (\FrankenPHP\ShutdownException) {
98+
// cleanup, close connections, etc.
10699
}
107-
108-
do {
109-
// ... do work, call set_vars() ...
110-
} while (!background_worker_should_stop(5));
111100
```
112101

113-
> [!WARNING]
114-
> Avoid using `sleep()` or `usleep()` in background workers. They block at the C level and cannot be interrupted.
115-
> A background worker using `sleep(60)` would delay shutdown or worker restart by up to 60 seconds.
116-
> Use `stream_select()` with the signaling stream instead - it wakes up immediately when FrankenPHP needs the thread to stop.
102+
If the exception is not caught, the script ends and the worker restarts normally.
117103

118-
- Throws `RuntimeException` if not called from a background worker context
104+
> [!NOTE]
105+
> On POSIX, shutdown is implemented via `pthread_kill(SIGUSR1)` which interrupts blocking syscalls with `EINTR`.
106+
> On Windows, `CancelSynchronousIo` + `QueueUserAPC` interrupts blocking I/O and alertable waits (`SleepEx`).
107+
> In both cases, the Zend VM throws `ShutdownException` at the next opcode boundary.
119108
120109
## Example
121110

@@ -134,27 +123,30 @@ match ($command) {
134123
default => throw new \RuntimeException("Unknown background worker: $command"),
135124
};
136125

137-
function background_worker_should_stop(float $timeout = 0): bool
138-
{
139-
static $signalingStream;
140-
$signalingStream ??= frankenphp_worker_get_signaling_stream();
141-
$s = (int) $timeout;
142-
143-
return 0 !== stream_select(...[[$signalingStream], [], [], $s, (int) (($timeout - $s) * 1e6)]);
144-
};
145-
146126
function run_redis_watcher(): void
147127
{
148128
$sentinel = new \Redis();
149129
$sentinel->pconnect('sentinel-host', 26379);
150130

151-
do {
152-
$master = $sentinel->rawCommand('SENTINEL', 'get-master-addr-by-name', 'mymaster');
153-
frankenphp_worker_set_vars([
154-
'MASTER_HOST' => $master[0],
155-
'MASTER_PORT' => $master[1],
156-
]);
157-
} while (!background_worker_should_stop(5.0)); // check every 5s
131+
// Publish initial state
132+
$master = $sentinel->rawCommand('SENTINEL', 'get-master-addr-by-name', 'mymaster');
133+
frankenphp_worker_set_vars([
134+
'MASTER_HOST' => $master[0],
135+
'MASTER_PORT' => $master[1],
136+
]);
137+
138+
// subscribe() blocks until interrupted by ShutdownException
139+
try {
140+
$sentinel->subscribe(['+switch-master'], function ($r, $ch, $msg) {
141+
[$name, $oldIp, $oldPort, $newIp, $newPort] = explode(' ', $msg);
142+
frankenphp_worker_set_vars([
143+
'MASTER_HOST' => $newIp,
144+
'MASTER_PORT' => $newPort,
145+
]);
146+
});
147+
} catch (\FrankenPHP\ShutdownException) {
148+
// graceful shutdown
149+
}
158150
}
159151
```
160152

@@ -198,13 +190,10 @@ if (function_exists('frankenphp_worker_get_vars')) {
198190
- `$_SERVER['FRANKENPHP_WORKER_BACKGROUND']` is `true` for background workers, `false` for HTTP workers
199191
- Background workers also get `$_SERVER['argv']` = `[entrypoint, name]` for CLI compatibility
200192
- Crash recovery: automatic restart with exponential backoff
201-
- Graceful shutdown via `frankenphp_worker_get_signaling_stream()` and `stream_select()`
193+
- Graceful shutdown via `FrankenPHP\ShutdownException` - interrupts any blocking call
202194
- Worker restarts stop running background workers; the next `get_vars()` call starts them again
203195
- Use `error_log()` or `frankenphp_log()` for logging - avoid `echo`
204196

205-
For advanced use cases (amphp, ReactPHP), the signaling stream can be registered directly
206-
in the event loop - see `frankenphp_worker_get_signaling_stream()`.
207-
208197
## Performance
209198

210199
`get_vars` is designed to be called on every HTTP request with minimal overhead:

0 commit comments

Comments
 (0)