Skip to content

Commit ea6cde9

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 43fe99b commit ea6cde9

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
@@ -380,9 +380,10 @@ func isBootstrapEnsure(thread *phpThread) bool {
380380
return ok && handler.isBootingScript
381381
}
382382

383-
// go_frankenphp_ensure_background_worker declares a dependency on a
384-
// background worker by name. Lazy-starts it if not already running, then
385-
// blocks until it has called set_vars (ready state) or the timeout expires.
383+
// go_frankenphp_ensure_background_worker declares a dependency on one or
384+
// more background workers by name. Each named worker is lazy-started if
385+
// not already running; the call blocks until every one has reached ready
386+
// (set_vars called at least once) or the shared deadline expires.
386387
//
387388
// Bootstrap mode (HTTP worker before frankenphp_handle_request): fail-fast.
388389
// Any boot failure throws immediately with captured details, without
@@ -394,61 +395,78 @@ func isBootstrapEnsure(thread *phpThread) bool {
394395
// cycle recover from transient boot failures.
395396
//
396397
//export go_frankenphp_ensure_background_worker
397-
func go_frankenphp_ensure_background_worker(threadIndex C.uintptr_t, name *C.char, nameLen C.size_t, timeoutMs C.int) *C.char {
398+
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 {
398399
thread := phpThreads[threadIndex]
399400
lookup := getLookup(thread)
400401
if lookup == nil {
401402
return C.CString("no background worker configured")
402403
}
403404

404-
goName := C.GoStringN(name, C.int(nameLen))
405+
n := int(nameCount)
406+
nameSlice := unsafe.Slice(names, n)
407+
nameLenSlice := unsafe.Slice(nameLens, n)
405408
bootstrap := isBootstrapEnsure(thread)
406-
if err := startBackgroundWorker(thread, goName); err != nil {
407-
return C.CString(err.Error())
408-
}
409-
registry := lookup.Resolve(goName)
410-
if registry == nil {
411-
return C.CString("background worker not found: " + goName)
412-
}
413-
registry.mu.Lock()
414-
sk := registry.workers[goName]
415-
registry.mu.Unlock()
416-
if sk == nil {
417-
return C.CString("background worker not found: " + goName)
409+
410+
// Start each named worker first. Reserve their states so a shared
411+
// deadline applies across the whole group (the caller gets one
412+
// timeout value, not one per worker).
413+
sks := make([]*backgroundWorkerState, n)
414+
goNames := make([]string, n)
415+
for i := 0; i < n; i++ {
416+
goNames[i] = C.GoStringN(nameSlice[i], C.int(nameLenSlice[i]))
417+
if err := startBackgroundWorker(thread, goNames[i]); err != nil {
418+
return C.CString(err.Error())
419+
}
420+
registry := lookup.Resolve(goNames[i])
421+
if registry == nil {
422+
return C.CString("background worker not found: " + goNames[i])
423+
}
424+
registry.mu.Lock()
425+
sks[i] = registry.workers[goNames[i]]
426+
registry.mu.Unlock()
427+
if sks[i] == nil {
428+
return C.CString("background worker not found: " + goNames[i])
429+
}
418430
}
419431

420432
deadline := time.After(time.Duration(timeoutMs) * time.Millisecond)
421433
if bootstrap {
422434
ticker := time.NewTicker(50 * time.Millisecond)
423435
defer ticker.Stop()
424-
for {
425-
select {
426-
case <-sk.ready:
427-
return nil
428-
case <-sk.aborted:
429-
return C.CString(sk.abortErr)
430-
case <-deadline:
431-
return C.CString(formatBackgroundWorkerTimeoutError(goName, sk))
432-
case <-globalCtx.Done():
433-
return C.CString("frankenphp is shutting down")
434-
case <-ticker.C:
435-
if sk.bootFailure.Load() != nil {
436-
return C.CString(formatBackgroundWorkerTimeoutError(goName, sk))
436+
for i, sk := range sks {
437+
wait:
438+
for {
439+
select {
440+
case <-sk.ready:
441+
break wait
442+
case <-sk.aborted:
443+
return C.CString(sk.abortErr)
444+
case <-deadline:
445+
return C.CString(formatBackgroundWorkerTimeoutError(goNames[i], sk))
446+
case <-globalCtx.Done():
447+
return C.CString("frankenphp is shutting down")
448+
case <-ticker.C:
449+
if sk.bootFailure.Load() != nil {
450+
return C.CString(formatBackgroundWorkerTimeoutError(goNames[i], sk))
451+
}
437452
}
438453
}
439454
}
455+
return nil
440456
}
441457

442-
select {
443-
case <-sk.ready:
444-
return nil
445-
case <-sk.aborted:
446-
return C.CString(sk.abortErr)
447-
case <-deadline:
448-
return C.CString(formatBackgroundWorkerTimeoutError(goName, sk))
449-
case <-globalCtx.Done():
450-
return C.CString("frankenphp is shutting down")
458+
for i, sk := range sks {
459+
select {
460+
case <-sk.ready:
461+
case <-sk.aborted:
462+
return C.CString(sk.abortErr)
463+
case <-deadline:
464+
return C.CString(formatBackgroundWorkerTimeoutError(goNames[i], sk))
465+
case <-globalCtx.Done():
466+
return C.CString("frankenphp is shutting down")
467+
}
451468
}
469+
return nil
452470
}
453471

454472
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)