Skip to content

Commit 27fbd54

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 (php#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 19a5489 commit 27fbd54

40 files changed

Lines changed: 1707 additions & 969 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_batch_test.go

Lines changed: 100 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,165 +1,154 @@
11
package frankenphp_test
22

33
import (
4+
"errors"
45
"io"
56
"net/http/httptest"
67
"os"
7-
"path/filepath"
8-
"strings"
98
"testing"
10-
"time"
119

1210
"github.com/dunglas/frankenphp"
1311
"github.com/stretchr/testify/assert"
1412
"github.com/stretchr/testify/require"
1513
)
1614

17-
// serveTestRequest issues an HTTP request through frankenphp against the
18-
// given script under testdata/ and returns the response body. ErrRejected
19-
// is treated as a non-fatal outcome so worker-mode quirks don't fail tests
20-
// that only care about the script's stdout.
21-
func serveTestRequest(t *testing.T, testDataDir, script, query string) string {
22-
t.Helper()
23-
url := "http://example.com/" + script
24-
if query != "" {
25-
url += "?" + query
26-
}
27-
req := httptest.NewRequest("GET", url, nil)
28-
fr, err := frankenphp.NewRequestWithContext(req, frankenphp.WithRequestDocumentRoot(testDataDir, false))
29-
require.NoError(t, err)
30-
31-
w := httptest.NewRecorder()
32-
err = frankenphp.ServeHTTP(w, fr)
33-
if err != nil {
34-
require.ErrorAs(t, err, &frankenphp.ErrRejected{})
35-
}
36-
body, err := io.ReadAll(w.Result().Body)
37-
require.NoError(t, err)
38-
return string(body)
39-
}
40-
41-
// TestEnsureBackgroundWorkerBatch declares a single catch-all bg worker
42-
// and ensures three distinct names from a single ensure() call. Each
43-
// catch-all instance touches a per-name sentinel; the test asserts that
44-
// all three appear, proving the array form started one worker per name.
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.
4518
func TestEnsureBackgroundWorkerBatch(t *testing.T) {
4619
cwd, _ := os.Getwd()
4720
testDataDir := cwd + "/testdata/"
48-
tmp := t.TempDir()
4921

5022
require.NoError(t, frankenphp.Init(
51-
frankenphp.WithWorkers("", testDataDir+"background-worker-named.php", 0,
52-
frankenphp.WithWorkerBackground(),
53-
frankenphp.WithWorkerMaxThreads(8),
54-
frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL_DIR": tmp}),
55-
),
56-
frankenphp.WithNumThreads(8),
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),
5730
))
5831
t.Cleanup(frankenphp.Shutdown)
5932

60-
body := serveTestRequest(t, testDataDir, "background-worker-batch-ensure.php", "")
61-
assert.Contains(t, body, "ok", "batch ensure script should echo ok, got: %q", body)
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)
6236

63-
for _, name := range []string{"batch-a", "batch-b", "batch-c"} {
64-
path := filepath.Join(tmp, name)
65-
assert.Eventually(t, func() bool {
66-
_, err := os.Stat(path)
67-
return err == nil
68-
}, 5*time.Second, 10*time.Millisecond,
69-
"catch-all instance %q should have written its sentinel", name)
37+
w := httptest.NewRecorder()
38+
if err := frankenphp.ServeHTTP(w, fr); err != nil && !errors.As(err, &frankenphp.ErrRejected{}) {
39+
t.Fatalf("serve: %v", err)
7040
}
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")
7148
}
7249

73-
// TestEnsureBackgroundWorkerBatchEmpty exercises the C-side validation
74-
// that an empty array raises a ValueError before any worker is started.
75-
// The fixture catches the throwable and echoes its class.
50+
// TestEnsureBackgroundWorkerBatchEmpty verifies that an empty array is
51+
// rejected with a clear error rather than silently succeeding.
7652
func TestEnsureBackgroundWorkerBatchEmpty(t *testing.T) {
7753
cwd, _ := os.Getwd()
7854
testDataDir := cwd + "/testdata/"
7955

8056
require.NoError(t, frankenphp.Init(
81-
frankenphp.WithWorkers("", testDataDir+"background-worker-named.php", 0,
82-
frankenphp.WithWorkerBackground(),
83-
),
84-
frankenphp.WithNumThreads(2),
57+
frankenphp.WithWorkers("bg", testDataDir+"background-worker-named.php", 0,
58+
frankenphp.WithWorkerBackground()),
59+
frankenphp.WithNumThreads(3),
8560
))
8661
t.Cleanup(frankenphp.Shutdown)
8762

88-
body := serveTestRequest(t, testDataDir, "background-worker-batch-errors.php", "mode=empty")
89-
assert.True(t, strings.Contains(body, "ValueError"),
90-
"empty array should raise ValueError, got: %q", body)
91-
assert.Contains(t, body, "must not be empty")
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")
9282
}
9383

94-
// TestEnsureBackgroundWorkerBatchNonString verifies a non-string element
95-
// raises a TypeError (PHP's standard for argument-type mismatches inside
96-
// our parsed array).
84+
// TestEnsureBackgroundWorkerBatchNonString verifies array-entry type
85+
// validation: non-string elements produce a TypeError.
9786
func TestEnsureBackgroundWorkerBatchNonString(t *testing.T) {
9887
cwd, _ := os.Getwd()
9988
testDataDir := cwd + "/testdata/"
10089

10190
require.NoError(t, frankenphp.Init(
102-
frankenphp.WithWorkers("", testDataDir+"background-worker-named.php", 0,
103-
frankenphp.WithWorkerBackground(),
104-
),
105-
frankenphp.WithNumThreads(2),
91+
frankenphp.WithWorkers("bg", testDataDir+"background-worker-named.php", 0,
92+
frankenphp.WithWorkerBackground()),
93+
frankenphp.WithNumThreads(3),
10694
))
10795
t.Cleanup(frankenphp.Shutdown)
10896

109-
body := serveTestRequest(t, testDataDir, "background-worker-batch-errors.php", "mode=nonstring")
110-
assert.True(t, strings.Contains(body, "TypeError"),
111-
"non-string element should raise TypeError, got: %q", body)
112-
}
113-
114-
// TestEnsureBackgroundWorkerBatchDuplicate verifies that duplicate names
115-
// in the same batch are rejected as a ValueError, matching the e17577e
116-
// reference behavior (no silent dedup).
117-
func TestEnsureBackgroundWorkerBatchDuplicate(t *testing.T) {
118-
cwd, _ := os.Getwd()
119-
testDataDir := cwd + "/testdata/"
120-
121-
require.NoError(t, frankenphp.Init(
122-
frankenphp.WithWorkers("", testDataDir+"background-worker-named.php", 0,
123-
frankenphp.WithWorkerBackground(),
124-
),
125-
frankenphp.WithNumThreads(2),
126-
))
127-
t.Cleanup(frankenphp.Shutdown)
128-
129-
body := serveTestRequest(t, testDataDir, "background-worker-batch-errors.php", "mode=duplicate")
130-
assert.True(t, strings.Contains(body, "ValueError"),
131-
"duplicate name should raise ValueError, got: %q", body)
132-
assert.Contains(t, body, "duplicate")
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")
133116
}
134117

135-
// TestBackgroundWorkerBgFlag asserts that a bg worker script sees
136-
// $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] === true. The fixture writes
137-
// var_export() of the value to a sentinel so the test can read the exact
138-
// PHP-level representation.
139-
func TestBackgroundWorkerBgFlag(t *testing.T) {
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) {
140123
cwd, _ := os.Getwd()
141124
testDataDir := cwd + "/testdata/"
142125

143-
tmp := t.TempDir()
144-
sentinel := filepath.Join(tmp, "bg-flag.sentinel")
145-
146126
require.NoError(t, frankenphp.Init(
147-
frankenphp.WithWorkers("bg-flag", testDataDir+"background-worker-bg-flag.php", 1,
148-
frankenphp.WithWorkerBackground(),
149-
frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}),
150-
),
151-
frankenphp.WithNumThreads(2),
127+
frankenphp.WithWorkers("flag-worker", testDataDir+"background-worker-bg-flag.php", 1,
128+
frankenphp.WithWorkerBackground()),
129+
frankenphp.WithNumThreads(3),
152130
))
153131
t.Cleanup(frankenphp.Shutdown)
154132

155-
require.Eventually(t, func() bool {
156-
_, err := os.Stat(sentinel)
157-
return err == nil
158-
}, 5*time.Second, 10*time.Millisecond,
159-
"bg worker should have written the FRANKENPHP_WORKER_BACKGROUND sentinel")
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)
160151

161-
contents, err := os.ReadFile(sentinel)
162-
require.NoError(t, err)
163-
assert.Equal(t, "true", string(contents),
164-
"$_SERVER['FRANKENPHP_WORKER_BACKGROUND'] should be the bool true")
152+
assert.Contains(t, out, "name=flag-worker")
153+
assert.Contains(t, out, "is_background=true")
165154
}

0 commit comments

Comments
 (0)