Skip to content

Commit abb9f6b

Browse files
feat: ensure_background_worker with lazy-start + catch-all
Fifth step of the split suggested in #2287. Builds on the minimal background worker from step 4: - PHP function frankenphp_ensure_background_worker(string $name, float $timeout = 30.0): void. Lazy-starts the named worker if not already running, waits for it to publish its first vars, returns void on success and throws on timeout / boot failure. - Two-mode semantics: - Bootstrap (called from an HTTP worker's boot phase, before frankenphp_handle_request): fail-fast. Watches sk.bootFailure on a 50ms ticker alongside ready/aborted/deadline so a broken dependency visibly fails the HTTP worker instead of serving degraded traffic. - Runtime (inside frankenphp_handle_request, classic request path): tolerant. Waits up to the timeout, letting the restart-with- backoff cycle recover from transient boot failures. - Registry + lookup layer: - backgroundWorkerRegistry tracks the template options (env, watch, maxConsecutiveFailures, requestOptions) from one declaration plus its live instances. Catch-all registries have a maxWorkers cap. - backgroundWorkerLookup holds a name map + a single catch-all slot. - reserve() atomic insert-or-return-existing; abortStart() wakes ensure waiters via a new aborted channel so a reserve/abandon race can't hang them until deadline. - Catch-all worker: a name-less bg declaration matches any ensure() name at runtime, subject to max_threads (default 16). Caddyfile support: `worker { background; file ... }` without `name`. - Named lazy path: a num=0 named declaration defers thread attach until ensure() asks for it; the worker struct created at init is reused rather than duplicated. - Boot-failure enrichment: bootFailureInfo now carries the captured PG(last_error_*) ("<msg> in <file> on line <n>"), grabbed on the C side before php_request_shutdown clears it. Ensure's timeout error surfaces it. - $_SERVER['FRANKENPHP_WORKER_NAME'] and $argv[1] are now populated for background workers so catch-all instances can tell which instance they are. - calculateMaxThreads reserves per-bg-worker thread budget separately from the HTTP worker count, scaling with max_threads on catch-alls, so lazy starts have room to schedule. - TestEnsureBackgroundWorkerNamedLazy: num=0 named declaration, ensure() from a non-worker request starts it + reads its vars. - TestEnsureBackgroundWorkerCatchAll: two ensures with distinct names against a single catch-all declaration; each publishes its own identity via $_SERVER. - TestEnsureBackgroundWorkerCatchAllCap: max_threads=2 on the catch- all; third distinct name hits the cap error. - TestEnsureBackgroundWorkerUndeclared: ensure() on a name that is neither named nor covered by a catch-all returns the config error. - Step-4 tests (TestBackgroundWorker, TestBackgroundWorkerErrorPaths, TestBackgroundWorkerRestartForceKillsStuckThread) still pass. - Batch name support on ensure (string[] argument): follow-up. - Per-php_server scoping (BackgroundScope): step 6. - Pools (num > 1, named-worker max_threads > 1) and multi-entrypoint: step 7.
1 parent 2ead529 commit abb9f6b

14 files changed

Lines changed: 807 additions & 43 deletions

background_worker.go

Lines changed: 364 additions & 15 deletions
Large diffs are not rendered by default.

background_worker_ensure_test.go

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
package frankenphp_test
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"io"
7+
"net/http/httptest"
8+
"os"
9+
"strings"
10+
"testing"
11+
12+
"github.com/dunglas/frankenphp"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
// TestEnsureBackgroundWorkerNamedLazy drives ensure() against a declared
18+
// named worker with num=0 (lazy). First request lazy-starts it; set_vars
19+
// publishes; get_vars reads the published vars.
20+
func TestEnsureBackgroundWorkerNamedLazy(t *testing.T) {
21+
cwd, _ := os.Getwd()
22+
testDataDir := cwd + "/testdata/"
23+
24+
require.NoError(t, frankenphp.Init(
25+
frankenphp.WithWorkers("bg-lazy", testDataDir+"background-worker-named.php", 0,
26+
frankenphp.WithWorkerBackground()),
27+
frankenphp.WithNumThreads(3),
28+
))
29+
t.Cleanup(frankenphp.Shutdown)
30+
31+
req := httptest.NewRequest("GET", "http://example.com/background-worker-ensure-from-handler.php", nil)
32+
fr, err := frankenphp.NewRequestWithContext(req, frankenphp.WithRequestDocumentRoot(testDataDir, false))
33+
require.NoError(t, err)
34+
35+
w := httptest.NewRecorder()
36+
if err := frankenphp.ServeHTTP(w, fr); err != nil && !errors.As(err, &frankenphp.ErrRejected{}) {
37+
t.Fatalf("serve: %v", err)
38+
}
39+
40+
body, _ := io.ReadAll(w.Result().Body)
41+
out := string(body)
42+
43+
assert.NotContains(t, out, "MISSING", "ensure() should have lazy-started the worker and published vars:\n"+out)
44+
assert.Contains(t, out, "ensured-name=bg-lazy")
45+
}
46+
47+
// TestEnsureBackgroundWorkerCatchAll declares a single catch-all (no name)
48+
// and invokes ensure() twice with distinct names. Each name should start
49+
// its own instance from the same entrypoint and publish its own vars.
50+
func TestEnsureBackgroundWorkerCatchAll(t *testing.T) {
51+
cwd, _ := os.Getwd()
52+
testDataDir := cwd + "/testdata/"
53+
54+
require.NoError(t, frankenphp.Init(
55+
// Name-less bg worker = catch-all. max_threads on a catch-all is
56+
// the cap on lazy-started instances; it also drives the thread
57+
// budget that calculateMaxThreads reserves for the catch-all.
58+
frankenphp.WithWorkers("", testDataDir+"background-worker-named.php", 0,
59+
frankenphp.WithWorkerBackground(),
60+
frankenphp.WithWorkerMaxThreads(4)),
61+
frankenphp.WithNumThreads(5),
62+
))
63+
t.Cleanup(frankenphp.Shutdown)
64+
65+
for _, name := range []string{"job-a", "job-b"} {
66+
req := httptest.NewRequest("GET", "http://example.com/background-worker-ensure-reader.php?name="+name, nil)
67+
fr, err := frankenphp.NewRequestWithContext(req, frankenphp.WithRequestDocumentRoot(testDataDir, false))
68+
require.NoError(t, err)
69+
70+
w := httptest.NewRecorder()
71+
if err := frankenphp.ServeHTTP(w, fr); err != nil && !errors.As(err, &frankenphp.ErrRejected{}) {
72+
t.Fatalf("serve: %v", err)
73+
}
74+
body, _ := io.ReadAll(w.Result().Body)
75+
out := string(body)
76+
assert.Contains(t, out, "name="+name, "catch-all instance %s did not publish its name:\n%s", name, out)
77+
}
78+
}
79+
80+
// TestEnsureBackgroundWorkerCatchAllCap sets max_threads on a catch-all so
81+
// the third distinct name ensure() hits the cap error.
82+
func TestEnsureBackgroundWorkerCatchAllCap(t *testing.T) {
83+
cwd, _ := os.Getwd()
84+
testDataDir := cwd + "/testdata/"
85+
86+
require.NoError(t, frankenphp.Init(
87+
frankenphp.WithWorkers("", testDataDir+"background-worker-named.php", 0,
88+
frankenphp.WithWorkerBackground(),
89+
frankenphp.WithWorkerMaxThreads(2)),
90+
frankenphp.WithNumThreads(5),
91+
))
92+
t.Cleanup(frankenphp.Shutdown)
93+
94+
for _, name := range []string{"cap-a", "cap-b"} {
95+
req := httptest.NewRequest("GET", "http://example.com/background-worker-ensure-reader.php?name="+name, nil)
96+
fr, _ := frankenphp.NewRequestWithContext(req, frankenphp.WithRequestDocumentRoot(testDataDir, false))
97+
w := httptest.NewRecorder()
98+
_ = frankenphp.ServeHTTP(w, fr)
99+
body, _ := io.ReadAll(w.Result().Body)
100+
require.NotContains(t, string(body), "limit of", "first two ensures should succeed, got:\n"+string(body))
101+
}
102+
103+
// Third should fail with a cap error.
104+
req := httptest.NewRequest("GET", "http://example.com/background-worker-ensure-reader.php?name=cap-c", nil)
105+
fr, _ := frankenphp.NewRequestWithContext(req, frankenphp.WithRequestDocumentRoot(testDataDir, false))
106+
w := httptest.NewRecorder()
107+
_ = frankenphp.ServeHTTP(w, fr)
108+
body, _ := io.ReadAll(w.Result().Body)
109+
assert.Contains(t, string(body), "limit of 2 reached", "third ensure must hit the catch-all cap:\n"+string(body))
110+
}
111+
112+
// TestEnsureBackgroundWorkerUndeclared checks that ensure() on a name that
113+
// is neither declared nor covered by a catch-all returns an error.
114+
func TestEnsureBackgroundWorkerUndeclared(t *testing.T) {
115+
cwd, _ := os.Getwd()
116+
testDataDir := cwd + "/testdata/"
117+
118+
require.NoError(t, frankenphp.Init(
119+
frankenphp.WithWorkers("bg-lazy", testDataDir+"background-worker-named.php", 0,
120+
frankenphp.WithWorkerBackground()),
121+
frankenphp.WithNumThreads(2),
122+
))
123+
t.Cleanup(frankenphp.Shutdown)
124+
125+
// Script tries to ensure('other-name') which is neither named nor catch-all.
126+
php := `<?php
127+
try {
128+
frankenphp_ensure_background_worker('other-name', 2.0);
129+
echo "FAIL no error";
130+
} catch (RuntimeException $e) {
131+
echo "OK ", $e->getMessage();
132+
}`
133+
tmp := testDataDir + "bg-ensure-undeclared.php"
134+
require.NoError(t, os.WriteFile(tmp, []byte(php), 0644))
135+
t.Cleanup(func() { _ = os.Remove(tmp) })
136+
137+
req := httptest.NewRequest("GET", "http://example.com/bg-ensure-undeclared.php", nil)
138+
fr, _ := frankenphp.NewRequestWithContext(req, frankenphp.WithRequestDocumentRoot(testDataDir, false))
139+
w := httptest.NewRecorder()
140+
_ = frankenphp.ServeHTTP(w, fr)
141+
body, _ := io.ReadAll(w.Result().Body)
142+
assert.Contains(t, string(body), "OK no background worker configured for name", "ensure of undeclared name should error:\n"+string(body))
143+
assert.NotContains(t, string(body), "FAIL")
144+
_ = fmt.Sprintf // keep fmt imported for potential future asserts
145+
146+
_ = strings.TrimSpace // keep strings imported
147+
}
148+
149+
// TestBackgroundWorkerBootFailureError confirms that an entrypoint which
150+
// throws during boot surfaces the captured details through ensure()'s
151+
// timeout error message: entrypoint path, attempt count, and the PHP
152+
// RuntimeException message. Runs as a non-worker request so ensure uses
153+
// the tolerant lazy-start path (no fail-fast).
154+
func TestBackgroundWorkerBootFailureError(t *testing.T) {
155+
cwd, _ := os.Getwd()
156+
testDataDir := cwd + "/testdata/"
157+
158+
require.NoError(t, frankenphp.Init(
159+
frankenphp.WithWorkers("boot-fail-worker", testDataDir+"background-worker-boot-fail.php", 0,
160+
frankenphp.WithWorkerBackground()),
161+
frankenphp.WithNumThreads(3),
162+
))
163+
t.Cleanup(frankenphp.Shutdown)
164+
165+
php := `<?php
166+
try {
167+
frankenphp_ensure_background_worker('boot-fail-worker', 1.0);
168+
echo "FAIL no error";
169+
} catch (\RuntimeException $e) {
170+
echo $e->getMessage();
171+
}`
172+
tmp := testDataDir + "bg-boot-fail.php"
173+
require.NoError(t, os.WriteFile(tmp, []byte(php), 0644))
174+
t.Cleanup(func() { _ = os.Remove(tmp) })
175+
176+
req := httptest.NewRequest("GET", "http://example.com/bg-boot-fail.php", nil)
177+
fr, _ := frankenphp.NewRequestWithContext(req, frankenphp.WithRequestDocumentRoot(testDataDir, false))
178+
w := httptest.NewRecorder()
179+
_ = frankenphp.ServeHTTP(w, fr)
180+
body, _ := io.ReadAll(w.Result().Body)
181+
out := string(body)
182+
183+
assert.NotContains(t, out, "FAIL", "ensure should have thrown:\n"+out)
184+
assert.Contains(t, out, `"boot-fail-worker"`)
185+
assert.Contains(t, out, "background-worker-boot-fail.php", "entrypoint path must appear in the error:\n"+out)
186+
assert.Contains(t, out, "attempt", "attempt count must appear:\n"+out)
187+
assert.Contains(t, out, "intentional boot failure for test", "PHP exception message must be captured:\n"+out)
188+
}

caddy/workerconfig.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -164,14 +164,14 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) {
164164
}
165165

166166
if wc.Background {
167-
if wc.Name == "" {
168-
return wc, d.Err(`background workers must have an explicit "name"`)
169-
}
170167
if wc.Num > 1 {
171168
return wc, d.Err(`"num" > 1 is not yet supported for background workers`)
172169
}
173-
if wc.MaxThreads > 1 {
174-
return wc, d.Err(`"max_threads" > 1 is not yet supported for background workers`)
170+
// For named bg workers, max_threads is threads-per-worker (>1 not
171+
// yet supported). For the catch-all (no name), it's the cap on
172+
// lazy-started instance count, which is a legitimate user knob.
173+
if wc.Name != "" && wc.MaxThreads > 1 {
174+
return wc, d.Err(`"max_threads" > 1 is not yet supported for named background workers`)
175175
}
176176
if len(wc.MatchPath) != 0 {
177177
return wc, d.Err(`"match" is not supported for background workers`)

frankenphp.c

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ __thread bool is_background_worker = false;
9191
__thread char *worker_name = NULL;
9292
__thread int worker_stop_fds[2] = {-1, -1};
9393
__thread php_stream *worker_signaling_stream = NULL;
94+
__thread char *captured_last_php_error = NULL;
9495
__thread HashTable *sandboxed_env = NULL;
9596

9697
#ifndef PHP_WIN32
@@ -296,6 +297,39 @@ void frankenphp_copy_persistent_vars(zval *dst, void *persistent_ht) {
296297
persistent_zval_to_request(dst, &src);
297298
}
298299

300+
/* Capture PG(last_error_*) into the thread-local captured_last_php_error.
301+
* Called before php_request_shutdown, which clears PG(last_error_*).
302+
* Format: "<message> in <file> on line <line>". */
303+
static void frankenphp_capture_last_php_error(void) {
304+
if (captured_last_php_error != NULL) {
305+
free(captured_last_php_error);
306+
captured_last_php_error = NULL;
307+
}
308+
if (PG(last_error_message) == NULL) {
309+
return;
310+
}
311+
const char *msg = ZSTR_VAL(PG(last_error_message));
312+
size_t msg_len = ZSTR_LEN(PG(last_error_message));
313+
const char *file =
314+
PG(last_error_file) ? ZSTR_VAL(PG(last_error_file)) : "unknown";
315+
size_t file_len = PG(last_error_file) ? ZSTR_LEN(PG(last_error_file)) : 7;
316+
int line = PG(last_error_lineno);
317+
size_t buf_len = msg_len + file_len + 32;
318+
captured_last_php_error = malloc(buf_len);
319+
if (captured_last_php_error != NULL) {
320+
snprintf(captured_last_php_error, buf_len, "%.*s in %.*s on line %d",
321+
(int)msg_len, msg, (int)file_len, file, line);
322+
}
323+
}
324+
325+
/* Return and take ownership of the captured error; caller frees with
326+
* C.free. NULL if nothing was captured. */
327+
char *frankenphp_get_last_php_error(void) {
328+
char *s = captured_last_php_error;
329+
captured_last_php_error = NULL;
330+
return s;
331+
}
332+
299333
static void frankenphp_update_request_context() {
300334
/* the server context is stored on the go side, still SG(server_context) needs
301335
* to not be NULL */
@@ -960,6 +994,32 @@ PHP_FUNCTION(frankenphp_get_vars) {
960994
}
961995
}
962996

997+
PHP_FUNCTION(frankenphp_ensure_background_worker) {
998+
zend_string *name = NULL;
999+
double timeout = 30.0;
1000+
1001+
ZEND_PARSE_PARAMETERS_START(1, 2);
1002+
Z_PARAM_STR(name);
1003+
Z_PARAM_OPTIONAL;
1004+
Z_PARAM_DOUBLE(timeout);
1005+
ZEND_PARSE_PARAMETERS_END();
1006+
1007+
if (timeout < 0) {
1008+
zend_value_error("frankenphp_ensure_background_worker(): timeout must be "
1009+
"non-negative");
1010+
RETURN_THROWS();
1011+
}
1012+
int timeout_ms = (int)(timeout * 1000.0);
1013+
1014+
char *error = go_frankenphp_ensure_background_worker(
1015+
thread_index, (char *)ZSTR_VAL(name), ZSTR_LEN(name), timeout_ms);
1016+
if (error) {
1017+
zend_throw_exception(spl_ce_RuntimeException, error, 0);
1018+
free(error);
1019+
RETURN_THROWS();
1020+
}
1021+
}
1022+
9631023
PHP_FUNCTION(frankenphp_get_worker_handle) {
9641024
ZEND_PARSE_PARAMETERS_NONE();
9651025

@@ -1410,9 +1470,32 @@ static void *php_thread(void *arg) {
14101470
/* Background workers run indefinitely; disable max_execution_time
14111471
* so PHP's default 30s timer doesn't interrupt their main loop.
14121472
* php_request_startup re-arms the timer from INI, so we have to
1413-
* disarm it after the call. */
1473+
* disarm it after the call. Also surface the worker name in $_SERVER
1474+
* and $argv so catch-all workers can tell which instance they are. */
14141475
if (is_background_worker) {
14151476
zend_unset_timeout();
1477+
zend_is_auto_global_str("_SERVER", sizeof("_SERVER") - 1);
1478+
zval *server = &PG(http_globals)[TRACK_VARS_SERVER];
1479+
if (server && Z_TYPE_P(server) == IS_ARRAY && worker_name != NULL) {
1480+
zval name_zval;
1481+
ZVAL_STRING(&name_zval, worker_name);
1482+
zend_hash_str_update(Z_ARRVAL_P(server), "FRANKENPHP_WORKER_NAME",
1483+
sizeof("FRANKENPHP_WORKER_NAME") - 1,
1484+
&name_zval);
1485+
1486+
zval argv_array;
1487+
array_init(&argv_array);
1488+
add_next_index_string(&argv_array, scriptName);
1489+
add_next_index_string(&argv_array, worker_name);
1490+
1491+
zval argc_zval;
1492+
ZVAL_LONG(&argc_zval, 2);
1493+
1494+
zend_hash_str_update(Z_ARRVAL_P(server), "argv", sizeof("argv") - 1,
1495+
&argv_array);
1496+
zend_hash_str_update(Z_ARRVAL_P(server), "argc", sizeof("argc") - 1,
1497+
&argc_zval);
1498+
}
14161499
}
14171500

14181501
zend_file_handle file_handle;
@@ -1437,6 +1520,10 @@ static void *php_thread(void *arg) {
14371520

14381521
has_attempted_shutdown = true;
14391522

1523+
/* Capture the last PHP error before php_request_shutdown clears it,
1524+
* so background-worker boot failures can surface the cause. */
1525+
frankenphp_capture_last_php_error();
1526+
14401527
/* shutdown the request, potential bailout to zend_catch */
14411528
php_request_shutdown((void *)0);
14421529
frankenphp_free_request_context();

0 commit comments

Comments
 (0)