Skip to content

Commit cfe289b

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 e85ccba commit cfe289b

7 files changed

Lines changed: 327 additions & 68 deletions

background_worker.go

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

329-
// go_frankenphp_ensure_background_worker declares a dependency on a
330-
// background worker by name. Lazy-starts it if not already running, then
331-
// blocks until it has called set_vars (ready state) or the timeout expires.
329+
// go_frankenphp_ensure_background_worker declares a dependency on one or
330+
// more background workers by name. Each named worker is lazy-started if
331+
// not already running; the call blocks until every one has reached ready
332+
// (set_vars called at least once) or the shared deadline expires.
332333
//
333334
// Bootstrap mode (HTTP worker before frankenphp_handle_request): fail-fast.
334335
// Any boot failure throws immediately with captured details, without
@@ -340,61 +341,78 @@ func isBootstrapEnsure(thread *phpThread) bool {
340341
// cycle recover from transient boot failures.
341342
//
342343
//export go_frankenphp_ensure_background_worker
343-
func go_frankenphp_ensure_background_worker(threadIndex C.uintptr_t, name *C.char, nameLen C.size_t, timeoutMs C.int) *C.char {
344+
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 {
344345
thread := phpThreads[threadIndex]
345346
lookup := getLookup(thread)
346347
if lookup == nil {
347348
return C.CString("no background worker configured")
348349
}
349350

350-
goName := C.GoStringN(name, C.int(nameLen))
351+
n := int(nameCount)
352+
nameSlice := unsafe.Slice(names, n)
353+
nameLenSlice := unsafe.Slice(nameLens, n)
351354
bootstrap := isBootstrapEnsure(thread)
352-
if err := startBackgroundWorker(thread, goName); err != nil {
353-
return C.CString(err.Error())
354-
}
355-
registry := lookup.Resolve(goName)
356-
if registry == nil {
357-
return C.CString("background worker not found: " + goName)
358-
}
359-
registry.mu.Lock()
360-
sk := registry.workers[goName]
361-
registry.mu.Unlock()
362-
if sk == nil {
363-
return C.CString("background worker not found: " + goName)
355+
356+
// Start each named worker first. Reserve their states so a shared
357+
// deadline applies across the whole group (the caller gets one
358+
// timeout value, not one per worker).
359+
sks := make([]*backgroundWorkerState, n)
360+
goNames := make([]string, n)
361+
for i := 0; i < n; i++ {
362+
goNames[i] = C.GoStringN(nameSlice[i], C.int(nameLenSlice[i]))
363+
if err := startBackgroundWorker(thread, goNames[i]); err != nil {
364+
return C.CString(err.Error())
365+
}
366+
registry := lookup.Resolve(goNames[i])
367+
if registry == nil {
368+
return C.CString("background worker not found: " + goNames[i])
369+
}
370+
registry.mu.Lock()
371+
sks[i] = registry.workers[goNames[i]]
372+
registry.mu.Unlock()
373+
if sks[i] == nil {
374+
return C.CString("background worker not found: " + goNames[i])
375+
}
364376
}
365377

366378
deadline := time.After(time.Duration(timeoutMs) * time.Millisecond)
367379
if bootstrap {
368380
ticker := time.NewTicker(50 * time.Millisecond)
369381
defer ticker.Stop()
370-
for {
371-
select {
372-
case <-sk.ready:
373-
return nil
374-
case <-sk.aborted:
375-
return C.CString(sk.abortErr)
376-
case <-deadline:
377-
return C.CString(formatBackgroundWorkerTimeoutError(goName, sk))
378-
case <-globalCtx.Done():
379-
return C.CString("frankenphp is shutting down")
380-
case <-ticker.C:
381-
if sk.bootFailure.Load() != nil {
382-
return C.CString(formatBackgroundWorkerTimeoutError(goName, sk))
382+
for i, sk := range sks {
383+
wait:
384+
for {
385+
select {
386+
case <-sk.ready:
387+
break wait
388+
case <-sk.aborted:
389+
return C.CString(sk.abortErr)
390+
case <-deadline:
391+
return C.CString(formatBackgroundWorkerTimeoutError(goNames[i], sk))
392+
case <-globalCtx.Done():
393+
return C.CString("frankenphp is shutting down")
394+
case <-ticker.C:
395+
if sk.bootFailure.Load() != nil {
396+
return C.CString(formatBackgroundWorkerTimeoutError(goNames[i], sk))
397+
}
383398
}
384399
}
385400
}
401+
return nil
386402
}
387403

388-
select {
389-
case <-sk.ready:
390-
return nil
391-
case <-sk.aborted:
392-
return C.CString(sk.abortErr)
393-
case <-deadline:
394-
return C.CString(formatBackgroundWorkerTimeoutError(goName, sk))
395-
case <-globalCtx.Done():
396-
return C.CString("frankenphp is shutting down")
404+
for i, sk := range sks {
405+
select {
406+
case <-sk.ready:
407+
case <-sk.aborted:
408+
return C.CString(sk.abortErr)
409+
case <-deadline:
410+
return C.CString(formatBackgroundWorkerTimeoutError(goNames[i], sk))
411+
case <-globalCtx.Done():
412+
return C.CString("frankenphp is shutting down")
413+
}
397414
}
415+
return nil
398416
}
399417

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