Skip to content

Commit 67e80f4

Browse files
feat: batch ensure + FRANKENPHP_WORKER_BACKGROUND server flag
Eighth step on top of #2287's split. User-facing polish on the ensure API plus a small $_SERVER flag, both landing together because they are small and closely related to the worker-handling surface. - frankenphp_ensure_background_worker now accepts string|array. The array form shares one deadline across all names and preserves the same mode semantics (fail-fast in HTTP-worker bootstrap, tolerant everywhere else). Empty arrays and non-string elements raise clear ValueError / TypeError instead of silent no-ops or cryptic failures. - $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] = true in background worker scripts, alongside the existing FRANKENPHP_WORKER_NAME and argv/argc wiring. Gives scripts a single-key branch for "am I a bg worker?" without checking each function independently. ## Tests - TestEnsureBackgroundWorkerBatch: three workers ensured in one call, each publishing its own name, all read back after the batch returns. - TestEnsureBackgroundWorkerBatchEmpty: [] rejected with ValueError. - TestEnsureBackgroundWorkerBatchNonString: ['a', 42] rejected with TypeError before any worker starts. - TestBackgroundWorkerServerFlag: bg worker sees FRANKENPHP_WORKER_BACKGROUND=true in $_SERVER. ## Deferred - CLI-mode function hiding was in the sidekicks draft but turned out to be dead code (the frankenphp PHP module isn't loaded in CLI, so the functions don't exist there either). - C-side per-request get_vars cache: step 9 (needs benchmarks). - Docs: step 10 (will cover the final API including batch ensure).
1 parent 0a90793 commit 67e80f4

7 files changed

Lines changed: 333 additions & 68 deletions

background_worker.go

Lines changed: 57 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -369,9 +369,10 @@ func isBootstrapEnsure(thread *phpThread) bool {
369369
return ok && handler.isBootingScript
370370
}
371371

372-
// go_frankenphp_ensure_background_worker declares a dependency on a
373-
// background worker by name. Lazy-starts it if not already running, then
374-
// blocks until it has called set_vars (ready state) or the timeout expires.
372+
// go_frankenphp_ensure_background_worker declares a dependency on one or
373+
// more background workers by name. Each named worker is lazy-started if
374+
// not already running; the call blocks until every one has reached ready
375+
// (set_vars called at least once) or the shared deadline expires.
375376
//
376377
// Bootstrap mode (HTTP worker before frankenphp_handle_request): fail-fast.
377378
// Any boot failure throws immediately with captured details, without
@@ -383,61 +384,78 @@ func isBootstrapEnsure(thread *phpThread) bool {
383384
// cycle recover from transient boot failures.
384385
//
385386
//export go_frankenphp_ensure_background_worker
386-
func go_frankenphp_ensure_background_worker(threadIndex C.uintptr_t, name *C.char, nameLen C.size_t, timeoutMs C.int) *C.char {
387+
func go_frankenphp_ensure_background_worker(threadIndex C.uintptr_t, names **C.char, nameLens *C.size_t, nameCount C.int, timeoutMs C.int) *C.char {
387388
thread := phpThreads[threadIndex]
388389
lookup := getLookup(thread)
389390
if lookup == nil {
390391
return C.CString("no background worker configured")
391392
}
392393

393-
goName := C.GoStringN(name, C.int(nameLen))
394+
n := int(nameCount)
395+
nameSlice := unsafe.Slice(names, n)
396+
nameLenSlice := unsafe.Slice(nameLens, n)
394397
bootstrap := isBootstrapEnsure(thread)
395-
if err := startBackgroundWorker(thread, goName); err != nil {
396-
return C.CString(err.Error())
397-
}
398-
registry := lookup.Resolve(goName)
399-
if registry == nil {
400-
return C.CString("background worker not found: " + goName)
401-
}
402-
registry.mu.Lock()
403-
sk := registry.workers[goName]
404-
registry.mu.Unlock()
405-
if sk == nil {
406-
return C.CString("background worker not found: " + goName)
398+
399+
// Start each named worker first. Reserve their states so a shared
400+
// deadline applies across the whole group (the caller gets one
401+
// timeout value, not one per worker).
402+
sks := make([]*backgroundWorkerState, n)
403+
goNames := make([]string, n)
404+
for i := 0; i < n; i++ {
405+
goNames[i] = C.GoStringN(nameSlice[i], C.int(nameLenSlice[i]))
406+
if err := startBackgroundWorker(thread, goNames[i]); err != nil {
407+
return C.CString(err.Error())
408+
}
409+
registry := lookup.Resolve(goNames[i])
410+
if registry == nil {
411+
return C.CString("background worker not found: " + goNames[i])
412+
}
413+
registry.mu.Lock()
414+
sks[i] = registry.workers[goNames[i]]
415+
registry.mu.Unlock()
416+
if sks[i] == nil {
417+
return C.CString("background worker not found: " + goNames[i])
418+
}
407419
}
408420

409421
deadline := time.After(time.Duration(timeoutMs) * time.Millisecond)
410422
if bootstrap {
411423
ticker := time.NewTicker(50 * time.Millisecond)
412424
defer ticker.Stop()
413-
for {
414-
select {
415-
case <-sk.ready:
416-
return nil
417-
case <-sk.aborted:
418-
return C.CString(sk.abortErr)
419-
case <-deadline:
420-
return C.CString(formatBackgroundWorkerTimeoutError(goName, sk))
421-
case <-globalCtx.Done():
422-
return C.CString("frankenphp is shutting down")
423-
case <-ticker.C:
424-
if sk.bootFailure.Load() != nil {
425-
return C.CString(formatBackgroundWorkerTimeoutError(goName, sk))
425+
for i, sk := range sks {
426+
wait:
427+
for {
428+
select {
429+
case <-sk.ready:
430+
break wait
431+
case <-sk.aborted:
432+
return C.CString(sk.abortErr)
433+
case <-deadline:
434+
return C.CString(formatBackgroundWorkerTimeoutError(goNames[i], sk))
435+
case <-globalCtx.Done():
436+
return C.CString("frankenphp is shutting down")
437+
case <-ticker.C:
438+
if sk.bootFailure.Load() != nil {
439+
return C.CString(formatBackgroundWorkerTimeoutError(goNames[i], sk))
440+
}
426441
}
427442
}
428443
}
444+
return nil
429445
}
430446

431-
select {
432-
case <-sk.ready:
433-
return nil
434-
case <-sk.aborted:
435-
return C.CString(sk.abortErr)
436-
case <-deadline:
437-
return C.CString(formatBackgroundWorkerTimeoutError(goName, sk))
438-
case <-globalCtx.Done():
439-
return C.CString("frankenphp is shutting down")
447+
for i, sk := range sks {
448+
select {
449+
case <-sk.ready:
450+
case <-sk.aborted:
451+
return C.CString(sk.abortErr)
452+
case <-deadline:
453+
return C.CString(formatBackgroundWorkerTimeoutError(goNames[i], sk))
454+
case <-globalCtx.Done():
455+
return C.CString("frankenphp is shutting down")
456+
}
440457
}
458+
return nil
441459
}
442460

443461
func formatBackgroundWorkerTimeoutError(name string, sk *backgroundWorkerState) string {

background_worker_batch_test.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package frankenphp_test
2+
3+
import (
4+
"errors"
5+
"io"
6+
"net/http/httptest"
7+
"os"
8+
"testing"
9+
10+
"github.com/dunglas/frankenphp"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
// TestEnsureBackgroundWorkerBatch ensures multiple workers in one call,
16+
// each publishing its own identity. Verifies the batch path (array arg)
17+
// shares one deadline across all workers.
18+
func TestEnsureBackgroundWorkerBatch(t *testing.T) {
19+
cwd, _ := os.Getwd()
20+
testDataDir := cwd + "/testdata/"
21+
22+
require.NoError(t, frankenphp.Init(
23+
frankenphp.WithWorkers("worker-a", testDataDir+"background-worker-named.php", 0,
24+
frankenphp.WithWorkerBackground()),
25+
frankenphp.WithWorkers("worker-b", testDataDir+"background-worker-named.php", 0,
26+
frankenphp.WithWorkerBackground()),
27+
frankenphp.WithWorkers("worker-c", testDataDir+"background-worker-named.php", 0,
28+
frankenphp.WithWorkerBackground()),
29+
frankenphp.WithNumThreads(6),
30+
))
31+
t.Cleanup(frankenphp.Shutdown)
32+
33+
req := httptest.NewRequest("GET", "http://example.com/background-worker-batch-ensure.php", nil)
34+
fr, err := frankenphp.NewRequestWithContext(req, frankenphp.WithRequestDocumentRoot(testDataDir, false))
35+
require.NoError(t, err)
36+
37+
w := httptest.NewRecorder()
38+
if err := frankenphp.ServeHTTP(w, fr); err != nil && !errors.As(err, &frankenphp.ErrRejected{}) {
39+
t.Fatalf("serve: %v", err)
40+
}
41+
body, _ := io.ReadAll(w.Result().Body)
42+
out := string(body)
43+
44+
assert.NotContains(t, out, "MISSING", "batch ensure should have started and published all workers:\n"+out)
45+
assert.Contains(t, out, "worker-a=worker-a")
46+
assert.Contains(t, out, "worker-b=worker-b")
47+
assert.Contains(t, out, "worker-c=worker-c")
48+
}
49+
50+
// TestEnsureBackgroundWorkerBatchEmpty verifies that an empty array is
51+
// rejected with a clear error rather than silently succeeding.
52+
func TestEnsureBackgroundWorkerBatchEmpty(t *testing.T) {
53+
cwd, _ := os.Getwd()
54+
testDataDir := cwd + "/testdata/"
55+
56+
require.NoError(t, frankenphp.Init(
57+
frankenphp.WithWorkers("bg", testDataDir+"background-worker-named.php", 0,
58+
frankenphp.WithWorkerBackground()),
59+
frankenphp.WithNumThreads(3),
60+
))
61+
t.Cleanup(frankenphp.Shutdown)
62+
63+
php := `<?php
64+
try {
65+
frankenphp_ensure_background_worker([], 1.0);
66+
echo "FAIL no error";
67+
} catch (ValueError $e) {
68+
echo "OK ", $e->getMessage();
69+
}`
70+
tmp := testDataDir + "bg-batch-empty.php"
71+
require.NoError(t, os.WriteFile(tmp, []byte(php), 0644))
72+
t.Cleanup(func() { _ = os.Remove(tmp) })
73+
74+
req := httptest.NewRequest("GET", "http://example.com/bg-batch-empty.php", nil)
75+
fr, _ := frankenphp.NewRequestWithContext(req, frankenphp.WithRequestDocumentRoot(testDataDir, false))
76+
w := httptest.NewRecorder()
77+
_ = frankenphp.ServeHTTP(w, fr)
78+
body, _ := io.ReadAll(w.Result().Body)
79+
assert.Contains(t, string(body), "OK ")
80+
assert.Contains(t, string(body), "must not be empty")
81+
assert.NotContains(t, string(body), "FAIL")
82+
}
83+
84+
// TestEnsureBackgroundWorkerBatchNonString verifies array-entry type
85+
// validation: non-string elements produce a TypeError.
86+
func TestEnsureBackgroundWorkerBatchNonString(t *testing.T) {
87+
cwd, _ := os.Getwd()
88+
testDataDir := cwd + "/testdata/"
89+
90+
require.NoError(t, frankenphp.Init(
91+
frankenphp.WithWorkers("bg", testDataDir+"background-worker-named.php", 0,
92+
frankenphp.WithWorkerBackground()),
93+
frankenphp.WithNumThreads(3),
94+
))
95+
t.Cleanup(frankenphp.Shutdown)
96+
97+
php := `<?php
98+
try {
99+
frankenphp_ensure_background_worker(['bg', 42], 1.0);
100+
echo "FAIL no error";
101+
} catch (TypeError $e) {
102+
echo "OK ", $e->getMessage();
103+
}`
104+
tmp := testDataDir + "bg-batch-nonstring.php"
105+
require.NoError(t, os.WriteFile(tmp, []byte(php), 0644))
106+
t.Cleanup(func() { _ = os.Remove(tmp) })
107+
108+
req := httptest.NewRequest("GET", "http://example.com/bg-batch-nonstring.php", nil)
109+
fr, _ := frankenphp.NewRequestWithContext(req, frankenphp.WithRequestDocumentRoot(testDataDir, false))
110+
w := httptest.NewRecorder()
111+
_ = frankenphp.ServeHTTP(w, fr)
112+
body, _ := io.ReadAll(w.Result().Body)
113+
assert.Contains(t, string(body), "OK ")
114+
assert.Contains(t, string(body), "must contain only strings")
115+
assert.NotContains(t, string(body), "FAIL")
116+
}
117+
118+
// TestBackgroundWorkerServerFlag confirms that a bg worker sees
119+
// FRANKENPHP_WORKER_BACKGROUND=true alongside FRANKENPHP_WORKER_NAME in
120+
// $_SERVER, so scripts can branch without checking every function
121+
// independently.
122+
func TestBackgroundWorkerServerFlag(t *testing.T) {
123+
cwd, _ := os.Getwd()
124+
testDataDir := cwd + "/testdata/"
125+
126+
require.NoError(t, frankenphp.Init(
127+
frankenphp.WithWorkers("flag-worker", testDataDir+"background-worker-bg-flag.php", 1,
128+
frankenphp.WithWorkerBackground()),
129+
frankenphp.WithNumThreads(3),
130+
))
131+
t.Cleanup(frankenphp.Shutdown)
132+
133+
// ensure() removes the race between Init returning and the eager
134+
// bg-worker thread reaching its first set_vars.
135+
php := `<?php
136+
frankenphp_ensure_background_worker('flag-worker');
137+
$vars = frankenphp_get_vars('flag-worker');
138+
echo 'name=', $vars['name'] ?? 'MISSING', "\n";
139+
echo 'is_background=', var_export($vars['is_background'] ?? 'MISSING', true), "\n";
140+
`
141+
tmp := testDataDir + "bg-flag-reader.php"
142+
require.NoError(t, os.WriteFile(tmp, []byte(php), 0644))
143+
t.Cleanup(func() { _ = os.Remove(tmp) })
144+
145+
req := httptest.NewRequest("GET", "http://example.com/bg-flag-reader.php", nil)
146+
fr, _ := frankenphp.NewRequestWithContext(req, frankenphp.WithRequestDocumentRoot(testDataDir, false))
147+
w := httptest.NewRecorder()
148+
_ = frankenphp.ServeHTTP(w, fr)
149+
body, _ := io.ReadAll(w.Result().Body)
150+
out := string(body)
151+
152+
assert.Contains(t, out, "name=flag-worker")
153+
assert.Contains(t, out, "is_background=true")
154+
}

0 commit comments

Comments
 (0)