Skip to content

Commit 2bd6f64

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). ## Tests - 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 36b8d68 commit 2bd6f64

8 files changed

Lines changed: 359 additions & 24 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
@@ -291,17 +292,27 @@ func ensureBackgroundWorker(thread *phpThread, bgWorkerName string) error {
291292
return nil
292293
}
293294

294-
// go_frankenphp_ensure_background_worker declares a dependency on a
295-
// background worker by name. Lazy-starts it if not already running and
296-
// returns immediately. Fire-and-forget: there is no readiness signal in
297-
// this step, so callers cannot block on the worker reaching any state.
295+
// go_frankenphp_ensure_background_worker declares a dependency on one or
296+
// more background workers by name. Each named worker is lazy-started if
297+
// not already running. Fire-and-forget: there is no readiness signal in
298+
// this step, so callers cannot block on the workers reaching any state.
299+
// The C side has already validated that names is non-empty and that every
300+
// element is a non-empty unique string.
298301
//
299302
//export go_frankenphp_ensure_background_worker
300-
func go_frankenphp_ensure_background_worker(threadIndex C.uintptr_t, name *C.char, nameLen C.size_t) *C.char {
303+
func go_frankenphp_ensure_background_worker(threadIndex C.uintptr_t, names **C.char, nameLens *C.size_t, nameCount C.int) *C.char {
301304
thread := phpThreads[threadIndex]
302-
goName := C.GoStringN(name, C.int(nameLen))
303-
if err := ensureBackgroundWorker(thread, goName); err != nil {
304-
return C.CString(err.Error())
305+
n := int(nameCount)
306+
if n <= 0 {
307+
return nil
308+
}
309+
nameSlice := unsafe.Slice(names, n)
310+
nameLenSlice := unsafe.Slice(nameLens, n)
311+
for i := 0; i < n; i++ {
312+
goName := C.GoStringN(nameSlice[i], C.int(nameLenSlice[i]))
313+
if err := ensureBackgroundWorker(thread, goName); err != nil {
314+
return C.CString(err.Error())
315+
}
305316
}
306317
return nil
307318
}

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: 96 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -913,14 +913,93 @@ PHP_FUNCTION(frankenphp_log) {
913913
}
914914

915915
PHP_FUNCTION(frankenphp_ensure_background_worker) {
916-
zend_string *name = NULL;
916+
zval *names_zv;
917917

918918
ZEND_PARSE_PARAMETERS_START(1, 1);
919-
Z_PARAM_STR(name);
919+
Z_PARAM_ZVAL(names_zv);
920920
ZEND_PARSE_PARAMETERS_END();
921921

922+
/* Accept either a single string or an array of strings. For a single
923+
* string we avoid the heap allocation by pointing at ZSTR fields
924+
* directly via the on-stack one-element arrays. */
925+
char *single_name_ptr = NULL;
926+
size_t single_name_len = 0;
927+
char **name_ptrs = NULL;
928+
size_t *name_lens = NULL;
929+
int name_count = 0;
930+
bool heap_allocated = false;
931+
932+
if (Z_TYPE_P(names_zv) == IS_STRING) {
933+
if (Z_STRLEN_P(names_zv) == 0) {
934+
zend_value_error("frankenphp_ensure_background_worker(): name "
935+
"must not be empty");
936+
RETURN_THROWS();
937+
}
938+
single_name_ptr = Z_STRVAL_P(names_zv);
939+
single_name_len = Z_STRLEN_P(names_zv);
940+
name_ptrs = &single_name_ptr;
941+
name_lens = &single_name_len;
942+
name_count = 1;
943+
} else if (Z_TYPE_P(names_zv) == IS_ARRAY) {
944+
HashTable *ht = Z_ARRVAL_P(names_zv);
945+
name_count = zend_hash_num_elements(ht);
946+
if (name_count == 0) {
947+
zend_value_error("frankenphp_ensure_background_worker(): names array "
948+
"must not be empty");
949+
RETURN_THROWS();
950+
}
951+
name_ptrs = emalloc(name_count * sizeof(*name_ptrs));
952+
name_lens = emalloc(name_count * sizeof(*name_lens));
953+
heap_allocated = true;
954+
int idx = 0;
955+
zval *v;
956+
ZEND_HASH_FOREACH_VAL(ht, v) {
957+
if (Z_TYPE_P(v) != IS_STRING) {
958+
efree(name_ptrs);
959+
efree(name_lens);
960+
zend_type_error("frankenphp_ensure_background_worker(): names array "
961+
"must contain only strings");
962+
RETURN_THROWS();
963+
}
964+
if (Z_STRLEN_P(v) == 0) {
965+
efree(name_ptrs);
966+
efree(name_lens);
967+
zend_value_error("frankenphp_ensure_background_worker(): names array "
968+
"must not contain empty strings");
969+
RETURN_THROWS();
970+
}
971+
/* Reject duplicates: O(n^2) is fine for the small batch sizes we
972+
* expect here. */
973+
for (int j = 0; j < idx; j++) {
974+
if (name_lens[j] == (size_t)Z_STRLEN_P(v) &&
975+
memcmp(name_ptrs[j], Z_STRVAL_P(v), name_lens[j]) == 0) {
976+
efree(name_ptrs);
977+
efree(name_lens);
978+
zend_value_error(
979+
"frankenphp_ensure_background_worker(): duplicate name %s",
980+
Z_STRVAL_P(v));
981+
RETURN_THROWS();
982+
}
983+
}
984+
name_ptrs[idx] = Z_STRVAL_P(v);
985+
name_lens[idx] = Z_STRLEN_P(v);
986+
idx++;
987+
}
988+
ZEND_HASH_FOREACH_END();
989+
} else {
990+
zend_type_error("frankenphp_ensure_background_worker(): name must be a "
991+
"string or an array of strings");
992+
RETURN_THROWS();
993+
}
994+
922995
char *error = go_frankenphp_ensure_background_worker(
923-
thread_index, (char *)ZSTR_VAL(name), ZSTR_LEN(name));
996+
thread_index, name_ptrs, name_lens, name_count);
997+
998+
if (heap_allocated) {
999+
efree(name_ptrs);
1000+
efree(name_lens);
1001+
}
1002+
9241003
if (error) {
9251004
zend_throw_exception(spl_ce_RuntimeException, error, 0);
9261005
free(error);
@@ -1420,14 +1499,22 @@ static void *php_thread(void *arg) {
14201499
/* Background workers run indefinitely; disable max_execution_time
14211500
* so PHP's default 30s timer doesn't interrupt their main loop.
14221501
* php_request_startup re-arms the timer from INI, so we have to
1423-
* disarm it after the call. Also surface the worker name in $_SERVER
1424-
* so catch-all instances can tell which name they were started under. */
1502+
* disarm it after the call. Also surface FRANKENPHP_WORKER_BACKGROUND
1503+
* (and the worker name when one is set) in $_SERVER so scripts can
1504+
* tell they are running as a bg worker without inspecting other
1505+
* frankenphp_* helpers. */
14251506
if (is_background_worker) {
14261507
zend_unset_timeout();
1427-
if (worker_name != NULL) {
1428-
zend_is_auto_global_str("_SERVER", sizeof("_SERVER") - 1);
1429-
zval *server = &PG(http_globals)[TRACK_VARS_SERVER];
1430-
if (server && Z_TYPE_P(server) == IS_ARRAY) {
1508+
zend_is_auto_global_str("_SERVER", sizeof("_SERVER") - 1);
1509+
zval *server = &PG(http_globals)[TRACK_VARS_SERVER];
1510+
if (server && Z_TYPE_P(server) == IS_ARRAY) {
1511+
zval bg_zval;
1512+
ZVAL_TRUE(&bg_zval);
1513+
zend_hash_str_update(
1514+
Z_ARRVAL_P(server), "FRANKENPHP_WORKER_BACKGROUND",
1515+
sizeof("FRANKENPHP_WORKER_BACKGROUND") - 1, &bg_zval);
1516+
1517+
if (worker_name != NULL) {
14311518
zval name_zval;
14321519
ZVAL_STRING(&name_zval, worker_name);
14331520
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

frankenphp_arginfo.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_log, 0, 1, IS_VOID, 0
4242
ZEND_END_ARG_INFO()
4343

4444
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_ensure_background_worker, 0, 1, IS_VOID, 0)
45-
ZEND_ARG_TYPE_INFO(0, name, IS_STRING, 0)
45+
ZEND_ARG_TYPE_MASK(0, name, MAY_BE_STRING|MAY_BE_ARRAY, NULL)
4646
ZEND_END_ARG_INFO()
4747

4848
ZEND_BEGIN_ARG_INFO_EX(arginfo_frankenphp_get_worker_handle, 0, 0, 0)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
// HTTP fixture: ensure three workers in a single batch call. Each
4+
// catch-all instance writes its own per-name sentinel under
5+
// $_SERVER['BG_SENTINEL_DIR'] (set via WithWorkerEnv at the bg worker
6+
// declaration). The HTTP response just confirms the call did not throw.
7+
frankenphp_ensure_background_worker(['batch-a', 'batch-b', 'batch-c']);
8+
echo "ok\n";

0 commit comments

Comments
 (0)