Skip to content

Commit 89dd1ea

Browse files
feat: minimal background worker (WIP, hold for precursors)
1 parent 73ba679 commit 89dd1ea

28 files changed

Lines changed: 1298 additions & 188 deletions

.github/workflows/sanitizers.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ jobs:
108108
- name: Set CGO flags
109109
run: |
110110
{
111-
echo "CGO_CFLAGS=$CFLAGS -I${PWD}/watcher/target/include -DFRANKENPHP_TEST $(php-config --includes)"
111+
echo "CGO_CFLAGS=$CFLAGS -I${PWD}/watcher/target/include $(php-config --includes)"
112112
echo "CGO_LDFLAGS=$LDFLAGS $(php-config --ldflags) $(php-config --libs)"
113113
} >> "$GITHUB_ENV"
114114
- name: Run tests

.github/workflows/tests.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ jobs:
5959
- name: Install e-dant/watcher
6060
uses: ./.github/actions/watcher
6161
- name: Set CGO flags
62-
run: echo "CGO_CFLAGS=-I${PWD}/watcher/target/include -DFRANKENPHP_TEST $(php-config --includes)" >> "${GITHUB_ENV}"
62+
run: echo "CGO_CFLAGS=-I${PWD}/watcher/target/include $(php-config --includes)" >> "${GITHUB_ENV}"
6363
- name: Build
6464
run: go build
6565
- name: Build testcli binary
@@ -135,7 +135,7 @@ jobs:
135135
echo "GEN_STUB_SCRIPT=${PWD}/php-${PHP_VERSION}/build/gen_stub.php" >> "${GITHUB_ENV}"
136136
- name: Set CGO flags
137137
run: |
138-
echo "CGO_CFLAGS=-DFRANKENPHP_TEST $(php-config --includes)" >> "${GITHUB_ENV}"
138+
echo "CGO_CFLAGS=$(php-config --includes)" >> "${GITHUB_ENV}"
139139
echo "CGO_LDFLAGS=$(php-config --ldflags) $(php-config --libs)" >> "${GITHUB_ENV}"
140140
- name: Install gotestsum
141141
run: go install gotest.tools/gotestsum@latest
@@ -172,7 +172,7 @@ jobs:
172172
- name: Set CGO flags
173173
run: |
174174
{
175-
echo "CGO_CFLAGS=-I/opt/homebrew/include/ -DFRANKENPHP_TEST $(php-config --includes)"
175+
echo "CGO_CFLAGS=-I/opt/homebrew/include/ $(php-config --includes)"
176176
echo "CGO_LDFLAGS=-L/opt/homebrew/lib/ $(php-config --ldflags) $(php-config --libs)"
177177
} >> "${GITHUB_ENV}"
178178
- name: Build

background_worker.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package frankenphp
2+
3+
// #include <stdint.h>
4+
// #include "frankenphp.h"
5+
import "C"
6+
import (
7+
"unsafe"
8+
)
9+
10+
// go_frankenphp_set_vars is called from PHP when a background worker publishes
11+
// its shared vars. The caller has already deep-copied the vars into persistent
12+
// memory; here we swap the pointer under the state lock and hand back the old
13+
// pointer so the C side can free it after the call returns.
14+
//
15+
// Returns an error string (malloc'd C string) on misuse, NULL on success.
16+
//
17+
//export go_frankenphp_set_vars
18+
func go_frankenphp_set_vars(threadIndex C.uintptr_t, varsPtr unsafe.Pointer, oldPtr *unsafe.Pointer) *C.char {
19+
thread := phpThreads[threadIndex]
20+
21+
bgHandler, ok := thread.handler.(*backgroundWorkerThread)
22+
if !ok || bgHandler.worker.backgroundWorker == nil {
23+
return C.CString("frankenphp_set_vars() can only be called from a background worker")
24+
}
25+
26+
sk := bgHandler.worker.backgroundWorker
27+
28+
sk.mu.Lock()
29+
*oldPtr = sk.varsPtr
30+
sk.varsPtr = varsPtr
31+
sk.varsVersion.Add(1)
32+
sk.mu.Unlock()
33+
34+
bgHandler.markBackgroundReady()
35+
36+
return nil
37+
}
38+
39+
// go_frankenphp_get_vars looks up the named background worker and copies its
40+
// shared vars into the return value. Pure read: never starts a worker, never
41+
// waits. If the worker is not declared, not running, or has not reached
42+
// ready, an error string is returned.
43+
//
44+
//export go_frankenphp_get_vars
45+
func go_frankenphp_get_vars(name *C.char, nameLen C.size_t, returnValue *C.zval) *C.char {
46+
goName := C.GoStringN(name, C.int(nameLen))
47+
48+
w := workersByName[goName]
49+
if w == nil || !w.isBackgroundWorker {
50+
return C.CString("background worker not found: " + goName)
51+
}
52+
sk := w.backgroundWorker
53+
if sk == nil {
54+
return C.CString("background worker not running: " + goName)
55+
}
56+
57+
select {
58+
case <-sk.ready:
59+
default:
60+
return C.CString("background worker not ready: " + goName + " (no set_vars call yet)")
61+
}
62+
63+
sk.mu.RLock()
64+
C.frankenphp_copy_persistent_vars(returnValue, sk.varsPtr)
65+
sk.mu.RUnlock()
66+
67+
return nil
68+
}

background_worker_coverage_test.go

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
package frankenphp_test
2+
3+
import (
4+
"encoding/hex"
5+
"errors"
6+
"io"
7+
"net/http/httptest"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
"testing"
12+
"time"
13+
14+
"github.com/dunglas/frankenphp"
15+
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
// serveInlinePHP writes an inline PHP script to testDataDir, serves it via
20+
// ServeHTTP, and returns the response body. Mirrors the
21+
// write-tmp-then-GET pattern already used across the bg-worker tests,
22+
// factored out to keep the coverage tests readable.
23+
func serveInlinePHP(t *testing.T, testDataDir, name, php string) string {
24+
t.Helper()
25+
tmp := testDataDir + name
26+
require.NoError(t, os.WriteFile(tmp, []byte(php), 0644))
27+
t.Cleanup(func() { _ = os.Remove(tmp) })
28+
29+
req := httptest.NewRequest("GET", "http://example.com/"+name, nil)
30+
fr, err := frankenphp.NewRequestWithContext(req, frankenphp.WithRequestDocumentRoot(testDataDir, false))
31+
require.NoError(t, err)
32+
w := httptest.NewRecorder()
33+
if err := frankenphp.ServeHTTP(w, fr); err != nil && !errors.As(err, &frankenphp.ErrRejected{}) {
34+
t.Fatalf("serve: %v", err)
35+
}
36+
body, _ := io.ReadAll(w.Result().Body)
37+
return string(body)
38+
}
39+
40+
// TestBackgroundWorkerCrashRestart covers the crash-recovery path: the
41+
// worker publishes count=1, crashes, is auto-restarted, then publishes
42+
// count=2. The reader polls get_vars() (which never blocks) and must
43+
// eventually observe the post-restart snapshot. Along the way get_vars()
44+
// returns the pre-crash snapshot, proving vars are kept in persistent
45+
// memory across worker deaths.
46+
func TestBackgroundWorkerCrashRestart(t *testing.T) {
47+
cwd, _ := os.Getwd()
48+
testDataDir := cwd + "/testdata/"
49+
50+
// Clean any stale marker from prior runs of this PID so the first boot
51+
// attempt is guaranteed to take the crash branch.
52+
matches, _ := filepath.Glob(os.TempDir() + "/bg-worker-crash-*")
53+
for _, m := range matches {
54+
_ = os.Remove(m)
55+
}
56+
57+
require.NoError(t, frankenphp.Init(
58+
frankenphp.WithWorkers("crash-worker", testDataDir+"background-worker-crash.php", 0,
59+
frankenphp.WithWorkerBackground()),
60+
frankenphp.WithNumThreads(3),
61+
))
62+
t.Cleanup(frankenphp.Shutdown)
63+
64+
php := `<?php
65+
frankenphp_ensure_background_worker('crash-worker', 5.0);
66+
$vars = frankenphp_get_vars('crash-worker');
67+
echo 'count=', $vars['count'] ?? 'MISSING', ' phase=', $vars['phase'] ?? 'MISSING';
68+
`
69+
deadline := time.Now().Add(5 * time.Second)
70+
var last string
71+
for time.Now().Before(deadline) {
72+
last = serveInlinePHP(t, testDataDir, "bg-crash-reader.php", php)
73+
if strings.Contains(last, "count=2") && strings.Contains(last, "phase=post-restart") {
74+
return
75+
}
76+
time.Sleep(100 * time.Millisecond)
77+
}
78+
t.Fatalf("did not observe post-restart snapshot within 5s; last=%q", last)
79+
}
80+
81+
// TestBackgroundWorkerTypeValidation drives the set_vars() allow-list from
82+
// inside a bg worker: int values and keys, nested arrays, and enum cases
83+
// are accepted; objects and references are rejected with ValueError.
84+
// The final published RESULTS string carries every branch outcome, and
85+
// the enum case round-trips to the same ::class::$name on the reader.
86+
func TestBackgroundWorkerTypeValidation(t *testing.T) {
87+
cwd, _ := os.Getwd()
88+
testDataDir := cwd + "/testdata/"
89+
90+
require.NoError(t, frankenphp.Init(
91+
frankenphp.WithWorkers("type-worker", testDataDir+"background-worker-types.php", 1,
92+
frankenphp.WithWorkerBackground()),
93+
frankenphp.WithNumThreads(3),
94+
))
95+
t.Cleanup(frankenphp.Shutdown)
96+
97+
php := `<?php
98+
enum BgTestStatus { case Active; case Inactive; }
99+
for ($i = 0; $i < 200; $i++) {
100+
try {
101+
$vars = frankenphp_get_vars('type-worker');
102+
if (isset($vars['RESULTS'])) break;
103+
} catch (\RuntimeException $e) {}
104+
usleep(10000);
105+
}
106+
echo 'RESULTS=', $vars['RESULTS'] ?? 'MISSING', "\n";
107+
echo 'ENUM_ROUNDTRIP=', (isset($vars['status']) && $vars['status'] === BgTestStatus::Active) ? 'match' : 'mismatch', "\n";
108+
`
109+
out := serveInlinePHP(t, testDataDir, "bg-type-reader.php", php)
110+
111+
assert.Contains(t, out, "INT_VAL:allowed")
112+
assert.Contains(t, out, "INT_KEY:allowed")
113+
assert.Contains(t, out, "NESTED:allowed")
114+
assert.Contains(t, out, "OBJECT:blocked")
115+
assert.Contains(t, out, "REFERENCE:blocked")
116+
assert.Contains(t, out, "ENUM_ROUNDTRIP=match", "enum case should restore to the same instance:\n"+out)
117+
}
118+
119+
// TestBackgroundWorkerBinarySafe verifies that values pass through
120+
// set_vars/get_vars byte-for-byte: embedded NUL, multibyte UTF-8 and
121+
// empty string all survive the persistent-memory deep copy without
122+
// truncation or re-encoding.
123+
func TestBackgroundWorkerBinarySafe(t *testing.T) {
124+
cwd, _ := os.Getwd()
125+
testDataDir := cwd + "/testdata/"
126+
127+
require.NoError(t, frankenphp.Init(
128+
frankenphp.WithWorkers("binary-worker", testDataDir+"background-worker-binary.php", 1,
129+
frankenphp.WithWorkerBackground()),
130+
frankenphp.WithNumThreads(3),
131+
))
132+
t.Cleanup(frankenphp.Shutdown)
133+
134+
php := `<?php
135+
for ($i = 0; $i < 200; $i++) {
136+
try {
137+
$vars = frankenphp_get_vars('binary-worker');
138+
if (isset($vars['BINARY'])) break;
139+
} catch (\RuntimeException $e) {}
140+
usleep(10000);
141+
}
142+
echo 'BINARY_HEX=', bin2hex($vars['BINARY'] ?? ''), "\n";
143+
echo 'BINARY_LEN=', strlen($vars['BINARY'] ?? ''), "\n";
144+
echo 'UTF8=', $vars['UTF8'] ?? '', "\n";
145+
echo 'EMPTY_EXISTS=', array_key_exists('EMPTY', $vars) ? '1' : '0', "\n";
146+
echo 'EMPTY_LEN=', strlen($vars['EMPTY'] ?? 'missing'), "\n";
147+
`
148+
out := serveInlinePHP(t, testDataDir, "bg-binary-reader.php", php)
149+
150+
assert.Contains(t, out, "BINARY_HEX="+hex.EncodeToString([]byte("hello\x00world")))
151+
assert.Contains(t, out, "BINARY_LEN=11")
152+
assert.Contains(t, out, "UTF8=héllo wörld 🚀")
153+
assert.Contains(t, out, "EMPTY_EXISTS=1")
154+
assert.Contains(t, out, "EMPTY_LEN=0")
155+
}
156+
157+
// TestBackgroundWorkerEnumMissing guards the generational deserializer:
158+
// when the bg worker publishes an enum whose class is absent from the
159+
// reader's process image, get_vars() must throw a LogicException rather
160+
// than return a corrupt zval. The enum class is only declared in the bg
161+
// worker entrypoint, so the reader's request has no way to reconstruct
162+
// the case.
163+
func TestBackgroundWorkerEnumMissing(t *testing.T) {
164+
cwd, _ := os.Getwd()
165+
testDataDir := cwd + "/testdata/"
166+
167+
require.NoError(t, frankenphp.Init(
168+
frankenphp.WithWorkers("enum-worker", testDataDir+"background-worker-enum-only.php", 1,
169+
frankenphp.WithWorkerBackground()),
170+
frankenphp.WithNumThreads(3),
171+
))
172+
t.Cleanup(frankenphp.Shutdown)
173+
174+
php := `<?php
175+
// WorkerOnlyEnum is intentionally NOT declared here.
176+
for ($i = 0; $i < 200; $i++) {
177+
try {
178+
$vars = frankenphp_get_vars('enum-worker');
179+
echo 'NO_ERROR val_type=', get_debug_type($vars['val'] ?? null);
180+
return;
181+
} catch (\LogicException $e) {
182+
echo 'LogicException: ', $e->getMessage();
183+
return;
184+
} catch (\RuntimeException $e) {
185+
usleep(10000);
186+
}
187+
}
188+
echo 'TIMEOUT';
189+
`
190+
out := serveInlinePHP(t, testDataDir, "bg-enum-missing-reader.php", php)
191+
192+
assert.NotContains(t, out, "NO_ERROR", "enum should not have materialized:\n"+out)
193+
assert.NotContains(t, out, "TIMEOUT", "worker never published:\n"+out)
194+
assert.Contains(t, out, "LogicException")
195+
assert.Contains(t, out, "WorkerOnlyEnum", "missing class name must appear in the error:\n"+out)
196+
}
197+
198+
// TestBackgroundWorkerSignalingStreamResource confirms that the value
199+
// returned by frankenphp_get_worker_handle() is a real PHP stream
200+
// resource. Complements the bounded-wall-clock force-kill test: that
201+
// one proves the pipe closes on shutdown, this one proves the handle
202+
// is a proper resource in the first place (not null, not an int, not
203+
// a user object).
204+
func TestBackgroundWorkerSignalingStreamResource(t *testing.T) {
205+
cwd, _ := os.Getwd()
206+
testDataDir := cwd + "/testdata/"
207+
208+
require.NoError(t, frankenphp.Init(
209+
frankenphp.WithWorkers("stream-worker", testDataDir+"background-worker-stream-probe.php", 1,
210+
frankenphp.WithWorkerBackground()),
211+
frankenphp.WithNumThreads(3),
212+
))
213+
t.Cleanup(frankenphp.Shutdown)
214+
215+
php := `<?php
216+
for ($i = 0; $i < 200; $i++) {
217+
try {
218+
$vars = frankenphp_get_vars('stream-worker');
219+
if (isset($vars['stream_type'])) break;
220+
} catch (\RuntimeException $e) {}
221+
usleep(10000);
222+
}
223+
echo 'stream_type=', $vars['stream_type'] ?? 'MISSING', "\n";
224+
echo 'is_resource=', var_export($vars['is_resource'] ?? 'MISSING', true), "\n";
225+
`
226+
out := serveInlinePHP(t, testDataDir, "bg-stream-reader.php", php)
227+
228+
assert.Contains(t, out, "stream_type=stream", "get_worker_handle() must return a stream resource:\n"+out)
229+
assert.Contains(t, out, "is_resource=true")
230+
}

0 commit comments

Comments
 (0)