Skip to content

Commit 5fadf66

Browse files
feat: batch ensure + FRANKENPHP_WORKER_BACKGROUND server flag
Two small, related polish steps on the bg-worker surface, landing together: - frankenphp_ensure_background_worker now accepts string|array. The array form lazy-starts every named worker fire-and-forget, with the same semantics as the single-string call repeated N times. Input is validated up-front: empty arrays raise ValueError, non-string elements raise TypeError, empty-string and duplicate names raise ValueError. Validation happens before any worker is started so a bad input never leaves a half-spawned batch behind. - $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] = true in background worker scripts, alongside the existing FRANKENPHP_WORKER_NAME wiring. Gives scripts a single-key branch for "am I a bg worker?" without having to probe other frankenphp_* helpers. Set unconditionally for bg workers (catch-all instances with no declared name still see the flag, just no name). - TestEnsureBackgroundWorkerBatch: ensure(['a','b','c']) starts three catch-all-resolved instances; assert three per-name sentinels appear. - TestEnsureBackgroundWorkerBatchEmpty: [] raises ValueError. Driven through a PHP fixture that catches the throwable since the validation lives in the Zend parameter-parsing path. - TestEnsureBackgroundWorkerBatchNonString: ['ok-name', 42] raises TypeError, same fixture pattern. - TestEnsureBackgroundWorkerBatchDuplicate: ['dup','dup'] raises ValueError (duplicate names rejected, not silently deduped). - TestBackgroundWorkerBgFlag: bg worker writes var_export() of $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] to a sentinel; assert the exact value is the bool true.
1 parent c3c8e29 commit 5fadf66

8 files changed

Lines changed: 384 additions & 30 deletions

background_worker.go

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"strings"
99
"sync"
1010
"sync/atomic"
11+
"unsafe"
1112
)
1213

1314
// defaultMaxBackgroundWorkers is the default safety cap for catch-all
@@ -314,17 +315,27 @@ func ensureBackgroundWorker(thread *phpThread, bgWorkerName string) error {
314315
return nil
315316
}
316317

317-
// go_frankenphp_ensure_background_worker declares a dependency on a
318-
// background worker by name. Lazy-starts it if not already running and
319-
// returns immediately. Fire-and-forget: there is no readiness signal in
320-
// this step, so callers cannot block on the worker reaching any state.
318+
// go_frankenphp_ensure_background_worker declares a dependency on one or
319+
// more background workers by name. Each named worker is lazy-started if
320+
// not already running. Fire-and-forget: there is no readiness signal in
321+
// this step, so callers cannot block on the workers reaching any state.
322+
// The C side has already validated that names is non-empty and that every
323+
// element is a non-empty unique string.
321324
//
322325
//export go_frankenphp_ensure_background_worker
323-
func go_frankenphp_ensure_background_worker(threadIndex C.uintptr_t, name *C.char, nameLen C.size_t) *C.char {
326+
func go_frankenphp_ensure_background_worker(threadIndex C.uintptr_t, names **C.char, nameLens *C.size_t, nameCount C.int) *C.char {
324327
thread := phpThreads[threadIndex]
325-
goName := C.GoStringN(name, C.int(nameLen))
326-
if err := ensureBackgroundWorker(thread, goName); err != nil {
327-
return C.CString(err.Error())
328+
n := int(nameCount)
329+
if n <= 0 {
330+
return nil
331+
}
332+
nameSlice := unsafe.Slice(names, n)
333+
nameLenSlice := unsafe.Slice(nameLens, n)
334+
for i := 0; i < n; i++ {
335+
goName := C.GoStringN(nameSlice[i], C.int(nameLenSlice[i]))
336+
if err := ensureBackgroundWorker(thread, goName); err != nil {
337+
return C.CString(err.Error())
338+
}
328339
}
329340
return nil
330341
}

background_worker_batch_test.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package frankenphp_test
2+
3+
import (
4+
"errors"
5+
"io"
6+
"net/http/httptest"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
"testing"
11+
"time"
12+
13+
"github.com/dunglas/frankenphp"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
// waitForBatchSentinel polls for a file to exist before the deadline.
19+
func waitForBatchSentinel(t *testing.T, path string, within time.Duration) bool {
20+
t.Helper()
21+
deadline := time.Now().Add(within)
22+
for time.Now().Before(deadline) {
23+
if _, err := os.Stat(path); err == nil {
24+
return true
25+
}
26+
time.Sleep(10 * time.Millisecond)
27+
}
28+
return false
29+
}
30+
31+
// serveTestRequest issues an HTTP request through frankenphp against the
32+
// given script under testdata/ and returns the response body. ErrRejected
33+
// is treated as a non-fatal outcome so worker-mode quirks don't fail tests
34+
// that only care about the script's stdout.
35+
func serveTestRequest(t *testing.T, testDataDir, script, query string) string {
36+
t.Helper()
37+
url := "http://example.com/" + script
38+
if query != "" {
39+
url += "?" + query
40+
}
41+
req := httptest.NewRequest("GET", url, nil)
42+
fr, err := frankenphp.NewRequestWithContext(req, frankenphp.WithRequestDocumentRoot(testDataDir, false))
43+
require.NoError(t, err)
44+
45+
w := httptest.NewRecorder()
46+
if err := frankenphp.ServeHTTP(w, fr); err != nil && !errors.As(err, &frankenphp.ErrRejected{}) {
47+
t.Fatalf("serve %s: %v", script, err)
48+
}
49+
body, _ := io.ReadAll(w.Result().Body)
50+
return string(body)
51+
}
52+
53+
// TestEnsureBackgroundWorkerBatch declares a single catch-all bg worker
54+
// and ensures three distinct names from a single ensure() call. Each
55+
// catch-all instance touches a per-name sentinel; the test asserts that
56+
// all three appear, proving the array form started one worker per name.
57+
func TestEnsureBackgroundWorkerBatch(t *testing.T) {
58+
cwd, _ := os.Getwd()
59+
testDataDir := cwd + "/testdata/"
60+
tmp := t.TempDir()
61+
62+
require.NoError(t, frankenphp.Init(
63+
frankenphp.WithWorkers("", testDataDir+"background-worker-named.php", 0,
64+
frankenphp.WithWorkerBackground(),
65+
frankenphp.WithWorkerMaxThreads(8),
66+
frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL_DIR": tmp}),
67+
),
68+
frankenphp.WithNumThreads(8),
69+
))
70+
t.Cleanup(frankenphp.Shutdown)
71+
72+
body := serveTestRequest(t, testDataDir, "background-worker-batch-ensure.php", "")
73+
assert.Contains(t, body, "ok", "batch ensure script should echo ok, got: %q", body)
74+
75+
for _, name := range []string{"batch-a", "batch-b", "batch-c"} {
76+
assert.True(t,
77+
waitForBatchSentinel(t, filepath.Join(tmp, name), 5*time.Second),
78+
"catch-all instance %q should have written its sentinel", name)
79+
}
80+
}
81+
82+
// TestEnsureBackgroundWorkerBatchEmpty exercises the C-side validation
83+
// that an empty array raises a ValueError before any worker is started.
84+
// The fixture catches the throwable and echoes its class.
85+
func TestEnsureBackgroundWorkerBatchEmpty(t *testing.T) {
86+
cwd, _ := os.Getwd()
87+
testDataDir := cwd + "/testdata/"
88+
89+
require.NoError(t, frankenphp.Init(
90+
frankenphp.WithWorkers("", testDataDir+"background-worker-named.php", 0,
91+
frankenphp.WithWorkerBackground(),
92+
),
93+
frankenphp.WithNumThreads(2),
94+
))
95+
t.Cleanup(frankenphp.Shutdown)
96+
97+
body := serveTestRequest(t, testDataDir, "background-worker-batch-errors.php", "mode=empty")
98+
assert.True(t, strings.Contains(body, "ValueError"),
99+
"empty array should raise ValueError, got: %q", body)
100+
assert.Contains(t, body, "must not be empty")
101+
}
102+
103+
// TestEnsureBackgroundWorkerBatchNonString verifies a non-string element
104+
// raises a TypeError (PHP's standard for argument-type mismatches inside
105+
// our parsed array).
106+
func TestEnsureBackgroundWorkerBatchNonString(t *testing.T) {
107+
cwd, _ := os.Getwd()
108+
testDataDir := cwd + "/testdata/"
109+
110+
require.NoError(t, frankenphp.Init(
111+
frankenphp.WithWorkers("", testDataDir+"background-worker-named.php", 0,
112+
frankenphp.WithWorkerBackground(),
113+
),
114+
frankenphp.WithNumThreads(2),
115+
))
116+
t.Cleanup(frankenphp.Shutdown)
117+
118+
body := serveTestRequest(t, testDataDir, "background-worker-batch-errors.php", "mode=nonstring")
119+
assert.True(t, strings.Contains(body, "TypeError"),
120+
"non-string element should raise TypeError, got: %q", body)
121+
}
122+
123+
// TestEnsureBackgroundWorkerBatchDuplicate verifies that duplicate names
124+
// in the same batch are rejected as a ValueError, matching the e17577e
125+
// reference behavior (no silent dedup).
126+
func TestEnsureBackgroundWorkerBatchDuplicate(t *testing.T) {
127+
cwd, _ := os.Getwd()
128+
testDataDir := cwd + "/testdata/"
129+
130+
require.NoError(t, frankenphp.Init(
131+
frankenphp.WithWorkers("", testDataDir+"background-worker-named.php", 0,
132+
frankenphp.WithWorkerBackground(),
133+
),
134+
frankenphp.WithNumThreads(2),
135+
))
136+
t.Cleanup(frankenphp.Shutdown)
137+
138+
body := serveTestRequest(t, testDataDir, "background-worker-batch-errors.php", "mode=duplicate")
139+
assert.True(t, strings.Contains(body, "ValueError"),
140+
"duplicate name should raise ValueError, got: %q", body)
141+
assert.Contains(t, body, "duplicate")
142+
}
143+
144+
// TestBackgroundWorkerBgFlag asserts that a bg worker script sees
145+
// $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] === true. The fixture writes
146+
// var_export() of the value to a sentinel so the test can read the exact
147+
// PHP-level representation.
148+
func TestBackgroundWorkerBgFlag(t *testing.T) {
149+
cwd, _ := os.Getwd()
150+
testDataDir := cwd + "/testdata/"
151+
152+
tmp := t.TempDir()
153+
sentinel := filepath.Join(tmp, "bg-flag.sentinel")
154+
155+
require.NoError(t, frankenphp.Init(
156+
frankenphp.WithWorkers("bg-flag", testDataDir+"background-worker-bg-flag.php", 1,
157+
frankenphp.WithWorkerBackground(),
158+
frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}),
159+
),
160+
frankenphp.WithNumThreads(2),
161+
))
162+
t.Cleanup(frankenphp.Shutdown)
163+
164+
require.True(t, waitForBatchSentinel(t, sentinel, 5*time.Second),
165+
"bg worker should have written the FRANKENPHP_WORKER_BACKGROUND sentinel")
166+
167+
contents, err := os.ReadFile(sentinel)
168+
require.NoError(t, err)
169+
assert.Equal(t, "true", string(contents),
170+
"$_SERVER['FRANKENPHP_WORKER_BACKGROUND'] should be the bool true")
171+
}

frankenphp.c

Lines changed: 116 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -952,14 +952,112 @@ PHP_FUNCTION(frankenphp_log) {
952952
}
953953

954954
PHP_FUNCTION(frankenphp_ensure_background_worker) {
955-
zend_string *name = NULL;
955+
zval *names_zv;
956956

957957
ZEND_PARSE_PARAMETERS_START(1, 1);
958-
Z_PARAM_STR(name);
958+
Z_PARAM_ZVAL(names_zv);
959959
ZEND_PARSE_PARAMETERS_END();
960960

961-
char *error = go_frankenphp_ensure_background_worker(
962-
thread_index, (char *)ZSTR_VAL(name), ZSTR_LEN(name));
961+
/* Accept either a single string or an array of strings. For a single
962+
* string we avoid the heap allocation by pointing at ZSTR fields
963+
* directly via the on-stack one-element arrays. */
964+
char *single_name_ptr = NULL;
965+
size_t single_name_len = 0;
966+
char **name_ptrs = NULL;
967+
size_t *name_lens = NULL;
968+
int name_count = 0;
969+
bool heap_allocated = false;
970+
971+
if (Z_TYPE_P(names_zv) == IS_STRING) {
972+
if (Z_STRLEN_P(names_zv) == 0) {
973+
zend_value_error("frankenphp_ensure_background_worker(): name "
974+
"must not be empty");
975+
RETURN_THROWS();
976+
}
977+
/* Reject embedded NUL bytes: PHP's zend_string is length-tagged so
978+
* arbitrary bytes are technically legal, but the bg-worker name flows
979+
* through C-string-aware paths (logging, $_SERVER export) where an
980+
* embedded NUL would silently truncate. Fail loudly instead. */
981+
if (memchr(Z_STRVAL_P(names_zv), '\0', Z_STRLEN_P(names_zv)) != NULL) {
982+
zend_value_error("frankenphp_ensure_background_worker(): name "
983+
"must not contain null bytes");
984+
RETURN_THROWS();
985+
}
986+
single_name_ptr = Z_STRVAL_P(names_zv);
987+
single_name_len = Z_STRLEN_P(names_zv);
988+
name_ptrs = &single_name_ptr;
989+
name_lens = &single_name_len;
990+
name_count = 1;
991+
} else if (Z_TYPE_P(names_zv) == IS_ARRAY) {
992+
HashTable *ht = Z_ARRVAL_P(names_zv);
993+
name_count = zend_hash_num_elements(ht);
994+
if (name_count == 0) {
995+
zend_value_error("frankenphp_ensure_background_worker(): names array "
996+
"must not be empty");
997+
RETURN_THROWS();
998+
}
999+
name_ptrs = emalloc(name_count * sizeof(*name_ptrs));
1000+
name_lens = emalloc(name_count * sizeof(*name_lens));
1001+
heap_allocated = true;
1002+
int idx = 0;
1003+
zval *v;
1004+
ZEND_HASH_FOREACH_VAL(ht, v) {
1005+
if (Z_TYPE_P(v) != IS_STRING) {
1006+
efree(name_ptrs);
1007+
efree(name_lens);
1008+
zend_type_error("frankenphp_ensure_background_worker(): names array "
1009+
"must contain only strings");
1010+
RETURN_THROWS();
1011+
}
1012+
if (Z_STRLEN_P(v) == 0) {
1013+
efree(name_ptrs);
1014+
efree(name_lens);
1015+
zend_value_error("frankenphp_ensure_background_worker(): names array "
1016+
"must not contain empty strings");
1017+
RETURN_THROWS();
1018+
}
1019+
/* Reject embedded NUL bytes: see single-string form above for the
1020+
* reasoning. Apply per-element so a mixed array is fully validated
1021+
* before any worker is started. */
1022+
if (memchr(Z_STRVAL_P(v), '\0', Z_STRLEN_P(v)) != NULL) {
1023+
efree(name_ptrs);
1024+
efree(name_lens);
1025+
zend_value_error("frankenphp_ensure_background_worker(): names array "
1026+
"must not contain names with null bytes");
1027+
RETURN_THROWS();
1028+
}
1029+
/* Reject duplicates: O(n^2) is fine for the small batch sizes we
1030+
* expect here. */
1031+
for (int j = 0; j < idx; j++) {
1032+
if (name_lens[j] == (size_t)Z_STRLEN_P(v) &&
1033+
memcmp(name_ptrs[j], Z_STRVAL_P(v), name_lens[j]) == 0) {
1034+
efree(name_ptrs);
1035+
efree(name_lens);
1036+
zend_value_error(
1037+
"frankenphp_ensure_background_worker(): duplicate name %s",
1038+
Z_STRVAL_P(v));
1039+
RETURN_THROWS();
1040+
}
1041+
}
1042+
name_ptrs[idx] = Z_STRVAL_P(v);
1043+
name_lens[idx] = Z_STRLEN_P(v);
1044+
idx++;
1045+
}
1046+
ZEND_HASH_FOREACH_END();
1047+
} else {
1048+
zend_type_error("frankenphp_ensure_background_worker(): name must be a "
1049+
"string or an array of strings");
1050+
RETURN_THROWS();
1051+
}
1052+
1053+
char *error = go_frankenphp_ensure_background_worker(thread_index, name_ptrs,
1054+
name_lens, name_count);
1055+
1056+
if (heap_allocated) {
1057+
efree(name_ptrs);
1058+
efree(name_lens);
1059+
}
1060+
9631061
if (error) {
9641062
zend_throw_exception(spl_ce_RuntimeException, error, 0);
9651063
free(error);
@@ -1459,14 +1557,22 @@ static void *php_thread(void *arg) {
14591557
/* Background workers run indefinitely; disable max_execution_time
14601558
* so PHP's default 30s timer doesn't interrupt their main loop.
14611559
* php_request_startup re-arms the timer from INI, so we have to
1462-
* disarm it after the call. Also surface the worker name in $_SERVER
1463-
* so catch-all instances can tell which name they were started under. */
1560+
* disarm it after the call. Also surface FRANKENPHP_WORKER_BACKGROUND
1561+
* (and the worker name when one is set) in $_SERVER so scripts can
1562+
* tell they are running as a bg worker without inspecting other
1563+
* frankenphp_* helpers. */
14641564
if (is_background_worker) {
14651565
zend_unset_timeout();
1466-
if (worker_name != NULL) {
1467-
zend_is_auto_global_str("_SERVER", sizeof("_SERVER") - 1);
1468-
zval *server = &PG(http_globals)[TRACK_VARS_SERVER];
1469-
if (server && Z_TYPE_P(server) == IS_ARRAY) {
1566+
zend_is_auto_global_str("_SERVER", sizeof("_SERVER") - 1);
1567+
zval *server = &PG(http_globals)[TRACK_VARS_SERVER];
1568+
if (server && Z_TYPE_P(server) == IS_ARRAY) {
1569+
zval bg_zval;
1570+
ZVAL_TRUE(&bg_zval);
1571+
zend_hash_str_update(
1572+
Z_ARRVAL_P(server), "FRANKENPHP_WORKER_BACKGROUND",
1573+
sizeof("FRANKENPHP_WORKER_BACKGROUND") - 1, &bg_zval);
1574+
1575+
if (worker_name != NULL) {
14701576
zval name_zval;
14711577
ZVAL_STRING(&name_zval, worker_name);
14721578
zend_hash_str_update(Z_ARRVAL_P(server), "FRANKENPHP_WORKER_NAME",

frankenphp.stub.php

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,19 @@ function mercure_publish(string|array $topics, string $data = '', bool $private
5656
function frankenphp_log(string $message, int $level = 0, array $context = []): void {}
5757

5858
/**
59-
* Declare a dependency on the named background worker. Lazy-starts it if
60-
* it is not already running and returns immediately. Fire-and-forget:
61-
* there is no readiness wait in this build, so the caller cannot block on
62-
* the worker reaching any particular state. Throws RuntimeException if
63-
* no background worker is configured for the given name.
59+
* Declare a dependency on one or more background workers. Lazy-starts each
60+
* worker that isn't already running and returns immediately. Fire-and-
61+
* forget: there is no readiness wait in this build, so the caller cannot
62+
* block on any worker reaching a particular state. Throws RuntimeException
63+
* if no background worker is configured for any given name.
64+
*
65+
* The array form rejects empty arrays (ValueError), non-string elements
66+
* (TypeError), empty strings, and duplicate names (ValueError) before
67+
* any worker is started.
68+
*
69+
* @param string|string[] $name
6470
*/
65-
function frankenphp_ensure_background_worker(string $name): void {}
71+
function frankenphp_ensure_background_worker(string|array $name): void {}
6672

6773
/**
6874
* Return the stop-signal stream for the current background worker. The

0 commit comments

Comments
 (0)