Skip to content

Commit a5abd16

Browse files
feat: shared-state APIs (set_vars/get_vars) + readiness wait
Adds the worker-to-HTTP shared-state surface deferred from the config+ensure split (#2393): - frankenphp_set_vars(array $vars): void publishes a snapshot from a background worker. Persistent (pemalloc) memory, RWMutex-protected, cross-thread safe. Skips work when data is identical (=== check). - frankenphp_get_vars(string $name): array reads the latest snapshot. Pure read; throws if the worker is not running or has not published yet. - ensure_background_worker now blocks until the named worker has called set_vars at least once (the readiness signal). The fire-and-forget semantics from the config-only PR become a stronger contract here with no API change visible to callers. - Two-mode ensure: fail-fast in HTTP-worker bootstrap (before frankenphp_handle_request) so a broken dependency surfaces at boot rather than serving degraded traffic; tolerant inside requests so the restart-with-backoff cycle can recover from transient boot failures. - Boot-failure capture: the worker's last PHP error (message, file, line, exit status) is recorded so ensure() can throw a descriptive RuntimeException on timeout. The persistent storage path uses opcache-immutable arrays (zero-copy share), interned strings (no copy), and rich type support: null, scalars, arrays (nested), enums. Tests cover happy-path roundtrips, type coverage, ensure() blocking on first set_vars, fail-fast vs tolerant modes, boot-failure reporting, and the catch-all + scope interactions with vars.
1 parent a8a4d9f commit a5abd16

40 files changed

Lines changed: 1541 additions & 837 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
@@ -62,7 +62,7 @@ jobs:
6262
# TODO: remove this workaround when fixed upstream
6363
run: sudo apt-get install --reinstall -y libbrotli-dev
6464
- name: Set CGO flags
65-
run: echo "CGO_CFLAGS=-I${PWD}/watcher/target/include -DFRANKENPHP_TEST $(php-config --includes)" >> "${GITHUB_ENV}"
65+
run: echo "CGO_CFLAGS=-I${PWD}/watcher/target/include $(php-config --includes)" >> "${GITHUB_ENV}"
6666
- name: Build
6767
run: go build
6868
- name: Build testcli binary
@@ -138,7 +138,7 @@ jobs:
138138
echo "GEN_STUB_SCRIPT=${PWD}/php-${PHP_VERSION}/build/gen_stub.php" >> "${GITHUB_ENV}"
139139
- name: Set CGO flags
140140
run: |
141-
echo "CGO_CFLAGS=-DFRANKENPHP_TEST $(php-config --includes)" >> "${GITHUB_ENV}"
141+
echo "CGO_CFLAGS=$(php-config --includes)" >> "${GITHUB_ENV}"
142142
echo "CGO_LDFLAGS=$(php-config --ldflags) $(php-config --libs)" >> "${GITHUB_ENV}"
143143
- name: Install gotestsum
144144
run: go install gotest.tools/gotestsum@latest
@@ -175,7 +175,7 @@ jobs:
175175
- name: Set CGO flags
176176
run: |
177177
{
178-
echo "CGO_CFLAGS=-I/opt/homebrew/include/ -DFRANKENPHP_TEST $(php-config --includes)"
178+
echo "CGO_CFLAGS=-I/opt/homebrew/include/ $(php-config --includes)"
179179
echo "CGO_LDFLAGS=-L/opt/homebrew/lib/ $(php-config --ldflags) $(php-config --libs)"
180180
} >> "${GITHUB_ENV}"
181181
- name: Build

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)