Skip to content

Commit 562c6e6

Browse files
feat: allow set_vars/get_vars(null) from HTTP workers
1 parent 1c0e62d commit 562c6e6

12 files changed

+220
-46
lines changed

background_worker.go

Lines changed: 62 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,17 @@ var backgroundLookups map[string]*backgroundWorkerLookup
3333
type backgroundWorkerLookup struct {
3434
byName map[string]*backgroundWorkerRegistry
3535
catchAll *backgroundWorkerRegistry
36+
httpVars backgroundWorkerState // shared vars bucket for HTTP workers in this scope
3637
}
3738

3839
func newBackgroundWorkerLookup() *backgroundWorkerLookup {
39-
return &backgroundWorkerLookup{
40+
l := &backgroundWorkerLookup{
4041
byName: make(map[string]*backgroundWorkerRegistry),
4142
}
43+
// httpVars is always "ready" — get_worker_vars(null) never blocks
44+
l.httpVars.ready = make(chan struct{})
45+
close(l.httpVars.ready)
46+
return l
4247
}
4348

4449
func (l *backgroundWorkerLookup) AddNamed(name string, registry *backgroundWorkerRegistry) {
@@ -244,6 +249,20 @@ func startBackgroundWorkerWithRegistry(registry *backgroundWorkerRegistry, bgWor
244249
return nil
245250
}
246251

252+
var backgroundLookupsMu sync.Mutex
253+
254+
func getOrCreateLookup(scope string) *backgroundWorkerLookup {
255+
if backgroundLookups == nil {
256+
backgroundLookups = make(map[string]*backgroundWorkerLookup)
257+
}
258+
if l, ok := backgroundLookups[scope]; ok {
259+
return l
260+
}
261+
l := newBackgroundWorkerLookup()
262+
backgroundLookups[scope] = l
263+
return l
264+
}
265+
247266
func getLookup(thread *phpThread) *backgroundWorkerLookup {
248267
if handler, ok := thread.handler.(*workerThread); ok && handler.worker.backgroundLookup != nil {
249268
return handler.worker.backgroundLookup
@@ -253,16 +272,16 @@ func getLookup(thread *phpThread) *backgroundWorkerLookup {
253272
}
254273
// Non-worker requests: resolve scope from context
255274
if fc, ok := fromContext(thread.context()); ok && fc.backgroundScope != "" {
256-
if backgroundLookups != nil {
257-
return backgroundLookups[fc.backgroundScope]
258-
}
275+
backgroundLookupsMu.Lock()
276+
l := getOrCreateLookup(fc.backgroundScope)
277+
backgroundLookupsMu.Unlock()
278+
return l
259279
}
260280
// Fall back to global scope
261-
if backgroundLookups != nil {
262-
return backgroundLookups[""]
263-
}
264-
265-
return nil
281+
backgroundLookupsMu.Lock()
282+
l := getOrCreateLookup("")
283+
backgroundLookupsMu.Unlock()
284+
return l
266285
}
267286

268287
// go_frankenphp_get_vars starts background workers if needed, waits for them
@@ -277,7 +296,7 @@ func go_frankenphp_get_vars(threadIndex C.uintptr_t, names **C.char, nameLens *C
277296
thread := phpThreads[threadIndex]
278297
lookup := getLookup(thread)
279298
if lookup == nil {
280-
return C.CString("no background worker configured in this php_server")
299+
return C.CString("no worker configured in this php_server")
281300
}
282301

283302
n := int(nameCount)
@@ -289,6 +308,12 @@ func go_frankenphp_get_vars(threadIndex C.uintptr_t, names **C.char, nameLens *C
289308
for i := 0; i < n; i++ {
290309
goNames[i] = C.GoStringN(nameSlice[i], C.int(nameLenSlice[i]))
291310

311+
if goNames[i] == "" {
312+
// Empty string = HTTP workers' shared scope (ready channel is pre-closed)
313+
sks[i] = &lookup.httpVars
314+
continue
315+
}
316+
292317
// Start background worker if not already running
293318
if err := startBackgroundWorker(thread, goNames[i]); err != nil {
294319
return C.CString(err.Error())
@@ -363,23 +388,36 @@ func go_frankenphp_get_vars(threadIndex C.uintptr_t, names **C.char, nameLens *C
363388
func go_frankenphp_set_vars(threadIndex C.uintptr_t, varsPtr unsafe.Pointer, oldPtr *unsafe.Pointer) *C.char {
364389
thread := phpThreads[threadIndex]
365390

366-
bgHandler, ok := thread.handler.(*backgroundWorkerThread)
367-
if !ok || bgHandler.worker.backgroundWorker == nil {
368-
return C.CString("frankenphp_set_vars() can only be called from a background worker")
369-
}
391+
// Background worker: write to named bucket
392+
if bgHandler, ok := thread.handler.(*backgroundWorkerThread); ok && bgHandler.worker.backgroundWorker != nil {
393+
sk := bgHandler.worker.backgroundWorker
370394

371-
sk := bgHandler.worker.backgroundWorker
395+
sk.mu.Lock()
396+
*oldPtr = sk.varsPtr
397+
sk.varsPtr = varsPtr
398+
sk.varsVersion.Add(1)
399+
sk.mu.Unlock()
372400

373-
sk.mu.Lock()
374-
*oldPtr = sk.varsPtr
375-
sk.varsPtr = varsPtr
376-
sk.varsVersion.Add(1)
377-
sk.mu.Unlock()
401+
sk.readyOnce.Do(func() {
402+
bgHandler.markBackgroundReady()
403+
close(sk.ready)
404+
})
378405

379-
sk.readyOnce.Do(func() {
380-
bgHandler.markBackgroundReady()
381-
close(sk.ready)
382-
})
406+
return nil
407+
}
408+
409+
// HTTP worker: write to scope's shared httpVars bucket
410+
lookup := getLookup(thread)
411+
if lookup == nil {
412+
return C.CString("frankenphp_set_vars() requires a configured php_server block")
413+
}
414+
415+
hv := &lookup.httpVars
416+
hv.mu.Lock()
417+
*oldPtr = hv.varsPtr
418+
hv.varsPtr = varsPtr
419+
hv.varsVersion.Add(1)
420+
hv.mu.Unlock()
383421

384422
return nil
385423
}

background_worker_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,32 @@ func (m *backgroundWorkerTestMetrics) QueuedRequest() {}
4646

4747
func (m *backgroundWorkerTestMetrics) DequeuedRequest() {}
4848

49+
func TestGetOrCreateLookup(t *testing.T) {
50+
oldLookups := backgroundLookups
51+
backgroundLookups = nil
52+
t.Cleanup(func() {
53+
backgroundLookups = oldLookups
54+
})
55+
56+
l1 := getOrCreateLookup("scope-a")
57+
require.NotNil(t, l1)
58+
assert.NotNil(t, l1.byName)
59+
60+
l2 := getOrCreateLookup("scope-a")
61+
assert.Same(t, l1, l2, "same scope should return the same lookup")
62+
63+
l3 := getOrCreateLookup("")
64+
assert.NotSame(t, l1, l3, "different scope should return a different lookup")
65+
66+
// httpVars ready channel should be pre-closed
67+
select {
68+
case <-l1.httpVars.ready:
69+
// expected
70+
default:
71+
t.Fatal("httpVars.ready should be closed")
72+
}
73+
}
74+
4975
func TestStartBackgroundWorkerFailureIsRetryable(t *testing.T) {
5076
lookup := newBackgroundWorkerLookup()
5177
lookup.catchAll = newBackgroundWorkerRegistry(testDataPath + "/background-worker-with-argv.php")

docs/background-workers.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ example.com {
4242

4343
## PHP API
4444

45-
### `frankenphp_get_vars(string|array $name, float $timeout = 30.0): array`
45+
### `frankenphp_get_vars(string|array|null $name, float $timeout = 30.0): array`
4646

4747
Starts a background worker (at-most-once) and returns its published vars.
4848

@@ -52,24 +52,32 @@ $redis = frankenphp_get_vars('redis-watcher');
5252

5353
$all = frankenphp_get_vars(['redis-watcher', 'feature-flags']);
5454
// ['redis-watcher' => [...], 'feature-flags' => [...]]
55+
56+
// Read vars published by HTTP workers in the same scope
57+
$shared = frankenphp_get_vars(null);
5558
```
5659

57-
- First call blocks until the background worker calls `set_vars()` or the timeout expires
60+
- With a string name: blocks until the background worker calls `set_vars()` or the timeout expires
61+
- With `null`: returns the HTTP workers' shared vars immediately (empty array if none published yet)
5862
- Subsequent calls return the latest snapshot immediately
5963
- Within a single HTTP request, repeated calls with the same name return the same cached array - `===` comparisons are O(1)
6064
- Throws `RuntimeException` on timeout, missing entrypoint, or background worker crash
6165
- Works in both worker and non-worker mode
6266

6367
### `frankenphp_set_vars(array $vars): void`
6468

65-
Publishes vars from inside a background worker.
69+
Publishes vars from a worker context.
70+
71+
- From a **background worker**: publishes to the named worker's bucket (readable via `get_worker_vars('name')`)
72+
- From an **HTTP worker**: publishes to the scope's shared bucket (readable via `get_worker_vars(null)`)
73+
6674
Each call **replaces** the entire vars array atomically.
6775
If the new value is identical (`===`) to the previous one, the call is a no-op.
6876

6977
Values can be `null`, scalars (`bool`, `int`, `float`, `string`), nested `array`s, or **enum**s.
7078
Objects, resources, and references are rejected.
7179

72-
- Throws `RuntimeException` if not called from a background worker context
80+
- Throws `RuntimeException` if not called from a worker context
7381
- Throws `ValueError` if values contain objects, resources, or references
7482

7583
### `frankenphp_get_worker_handle(): resource`

frankenphp.c

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -949,9 +949,55 @@ PHP_FUNCTION(frankenphp_get_vars) {
949949
}
950950
int timeout_ms = (int)(timeout * 1000);
951951

952+
/* null: read from the HTTP workers' shared scope via empty string key */
953+
if (Z_TYPE_P(names) == IS_NULL) {
954+
char *empty = "";
955+
size_t empty_len = 0;
956+
uint64_t caller_version = 0;
957+
uint64_t out_version = 0;
958+
bg_worker_vars_cache_entry *cached = NULL;
959+
if (worker_vars_cache) {
960+
zval *entry_zv =
961+
zend_hash_str_find(worker_vars_cache, empty, empty_len);
962+
if (entry_zv) {
963+
cached = Z_PTR_P(entry_zv);
964+
caller_version = cached->version;
965+
}
966+
}
967+
968+
char *error = go_frankenphp_get_vars(
969+
thread_index, &empty, &empty_len, 1, timeout_ms, return_value,
970+
cached ? &caller_version : NULL, &out_version);
971+
if (error) {
972+
zend_throw_exception(spl_ce_RuntimeException, error, 0);
973+
free(error);
974+
RETURN_THROWS();
975+
}
976+
if (EG(exception)) {
977+
RETURN_THROWS();
978+
}
979+
980+
if (cached && out_version == caller_version) {
981+
ZVAL_COPY(return_value, &cached->value);
982+
return;
983+
}
984+
985+
if (!worker_vars_cache) {
986+
worker_vars_cache = malloc(sizeof(HashTable));
987+
zend_hash_init(worker_vars_cache, 4, NULL, bg_worker_vars_cache_dtor, 1);
988+
}
989+
bg_worker_vars_cache_entry *entry = malloc(sizeof(*entry));
990+
entry->version = out_version;
991+
ZVAL_COPY(&entry->value, return_value);
992+
zval entry_zv;
993+
ZVAL_PTR(&entry_zv, entry);
994+
zend_hash_str_update(worker_vars_cache, empty, empty_len, &entry_zv);
995+
return;
996+
}
997+
952998
if (Z_TYPE_P(names) == IS_STRING) {
953999
if (Z_STRLEN_P(names) == 0) {
954-
zend_value_error("Background worker name must not be empty");
1000+
zend_value_error("Worker name must not be empty");
9551001
RETURN_THROWS();
9561002
}
9571003

@@ -1005,9 +1051,9 @@ PHP_FUNCTION(frankenphp_get_vars) {
10051051
}
10061052

10071053
if (Z_TYPE_P(names) != IS_ARRAY) {
1008-
zend_type_error("Argument #1 ($name) must be of type string|array, %s "
1009-
"given",
1010-
zend_zval_type_name(names));
1054+
zend_type_error(
1055+
"Argument #1 ($name) must be of type string|array|null, %s given",
1056+
zend_zval_type_name(names));
10111057
RETURN_THROWS();
10121058
}
10131059

@@ -1016,7 +1062,7 @@ PHP_FUNCTION(frankenphp_get_vars) {
10161062

10171063
ZEND_HASH_FOREACH_VAL(ht, val) {
10181064
if (Z_TYPE_P(val) != IS_STRING || Z_STRLEN_P(val) == 0) {
1019-
zend_value_error("All background worker names must be non-empty strings");
1065+
zend_value_error("All worker names must be non-empty strings");
10201066
RETURN_THROWS();
10211067
}
10221068
}

frankenphp.stub.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ function frankenphp_set_vars(array $vars): void {}
2424
/**
2525
* @return array<string, null|scalar|array<mixed>|\UnitEnum>
2626
*/
27-
function frankenphp_get_vars(string|array $name, float $timeout = 30.0): array {}
27+
function frankenphp_get_vars(string|array|null $name, float $timeout = 30.0): array {}
2828

2929
/** @return resource */
3030
function frankenphp_get_worker_handle() {}

frankenphp_arginfo.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_set_vars, 0, 1, IS_VO
1010
ZEND_END_ARG_INFO()
1111

1212
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_get_vars, 0, 1, IS_ARRAY, 0)
13-
ZEND_ARG_TYPE_MASK(0, name, MAY_BE_STRING|MAY_BE_ARRAY, NULL)
13+
ZEND_ARG_TYPE_MASK(0, name, MAY_BE_STRING|MAY_BE_ARRAY|MAY_BE_NULL, NULL)
1414
ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, timeout, IS_DOUBLE, 0, "30.0")
1515
ZEND_END_ARG_INFO()
1616

frankenphp_test.go

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -876,7 +876,7 @@ func TestBackgroundWorkerNoEntrypoint(t *testing.T) {
876876
func TestBackgroundWorkerSetVarsValidation(t *testing.T) {
877877
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) {
878878
body, _ := testGet("http://example.com/background-worker-set-server-var-validation.php", handler, t)
879-
assert.Contains(t, body, "NON_BACKGROUND:blocked")
879+
assert.Contains(t, body, "HTTP_SET_VARS:allowed")
880880
assert.Contains(t, body, "STREAM_NON_BACKGROUND:blocked")
881881
}, &testOptions{
882882
workerScript: "background-worker-set-server-var-validation.php",
@@ -1120,6 +1120,39 @@ func TestBackgroundWorkerNamedAutoStart(t *testing.T) {
11201120
})
11211121
}
11221122

1123+
func TestHttpWorkerVars(t *testing.T) {
1124+
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) {
1125+
body, _ := testGet("http://example.com/background-worker-http-vars.php", handler, t)
1126+
assert.Equal(t, "http:1", body)
1127+
}, &testOptions{
1128+
workerScript: "background-worker-http-vars.php",
1129+
nbWorkers: 1,
1130+
nbParallelRequests: 1,
1131+
})
1132+
}
1133+
1134+
func TestHttpWorkerVarsEmpty(t *testing.T) {
1135+
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) {
1136+
body, _ := testGet("http://example.com/background-worker-http-vars-empty.php", handler, t)
1137+
assert.Equal(t, "array:0", body)
1138+
}, &testOptions{
1139+
workerScript: "background-worker-http-vars-empty.php",
1140+
nbWorkers: 1,
1141+
nbParallelRequests: 1,
1142+
})
1143+
}
1144+
1145+
func TestHttpWorkerVarsIdentity(t *testing.T) {
1146+
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) {
1147+
body, _ := testGet("http://example.com/background-worker-http-vars-identity.php", handler, t)
1148+
assert.Equal(t, "IDENTICAL", body)
1149+
}, &testOptions{
1150+
workerScript: "background-worker-http-vars-identity.php",
1151+
nbWorkers: 1,
1152+
nbParallelRequests: 1,
1153+
})
1154+
}
1155+
11231156
func ExampleServeHTTP() {
11241157
if err := frankenphp.Init(); err != nil {
11251158
panic(err)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
frankenphp_handle_request(function () {
4+
// get_vars(null) without any prior set_vars should return empty array
5+
$vars = frankenphp_get_vars(null);
6+
echo is_array($vars) ? 'array:' . count($vars) : 'not_array';
7+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
frankenphp_handle_request(function () {
4+
frankenphp_set_vars(['KEY' => 'val']);
5+
6+
// Two calls with null in the same request should return identical arrays
7+
$a = frankenphp_get_vars(null);
8+
$b = frankenphp_get_vars(null);
9+
echo $a === $b ? 'IDENTICAL' : 'DIFFERENT';
10+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
frankenphp_handle_request(function () {
4+
// First call: publish vars from HTTP worker
5+
frankenphp_set_vars(['REQUEST_COUNT' => '1', 'SOURCE' => 'http']);
6+
7+
// Read back via get_vars(null)
8+
$vars = frankenphp_get_vars(null);
9+
echo $vars['SOURCE'] . ':' . $vars['REQUEST_COUNT'];
10+
});

0 commit comments

Comments
 (0)