Compare commits

...

55 Commits

Author SHA1 Message Date
Alliballibaba
ff8f864a3d Removes special empty array. 2025-12-23 22:48:12 +01:00
Alliballibaba
fcc9f81986 Merge branch 'main' into perf/optimize-types 2025-12-23 22:17:51 +01:00
Alliballibaba
69320d2ee7 Adds logs. 2025-12-23 22:09:40 +01:00
Alliballibaba
7ceb485dae Prevents refcounting issues. 2025-12-23 22:03:32 +01:00
Marc
19d00a08e2 fix relative paths not being resolved correctly by spc (#2093)
closes #2092
closes #2064
2025-12-23 10:59:06 +01:00
Alliballibaba
f5a9bc3d7a Cleanup. 2025-12-22 23:31:59 +01:00
Alliballibaba
2306152fde Fixes test. 2025-12-22 22:55:03 +01:00
Alliballibaba
a86533a6ac Adds echos for debugging. 2025-12-22 21:34:52 +01:00
Alliballibaba
8bdb3de552 Simplifies zvals. 2025-12-22 21:33:35 +01:00
Alliballibaba
360d15c2aa Merge branch 'main' into perf/optimize-types 2025-12-20 22:29:27 +01:00
Alliballibaba
549cca304a Returns to old implementation. 2025-12-20 11:18:18 +01:00
Alliballibaba
c8c7d046ef pointer arithmetic fix. 2025-12-20 10:56:29 +01:00
Alliballibaba
ecf3f0e792 Formatting and allocation fixes. 2025-12-20 10:47:33 +01:00
Kévin Dunglas
57c58faf1c chore: prepare release 1.11.1 2025-12-20 09:16:23 +01:00
Loric Brevet
25d9cb9600 fix: crash when using the logger outside of the a request context 2025-12-20 09:15:29 +01:00
Alliballibaba
ae97abb897 Simplifies strings. 2025-12-19 23:17:05 +01:00
Alliballibaba
a222fd51cb Properly frees zvals in tests. 2025-12-19 23:01:58 +01:00
Alliballibaba
e2976abbeb Fixes conflicts. 2025-12-19 22:54:47 +01:00
Alliballibaba
a209d227ef Merge branch 'main' into perf/optimize-types
# Conflicts:
#	types.c
#	types.go
#	types.h
#	types_test.go
2025-12-19 22:31:52 +01:00
Kévin Dunglas
4092ecb5b5 fix: frankenphp_log() level parameter must be optional 2025-12-19 16:25:32 +01:00
Kévin Dunglas
75ccccf1b2 fix(caddy): use default patterns when hot_reload is alone 2025-12-19 09:38:05 +01:00
Alliballibaba
5e139519a3 Removes benchmarks. 2025-12-13 20:42:42 +01:00
Alliballibaba
bcee843017 Removes benchmarks. 2025-12-13 20:39:40 +01:00
Alliballibaba
703d037ef7 Merge branch 'main' into perf/optimize-types
# Conflicts:
#	types.c
#	types.go
#	types.h
#	types_test.go
2025-12-13 20:34:05 +01:00
Alliballibaba
af328a3166 Merge branch 'main' into perf/optimize-types
# Conflicts:
#	internal/extgen/templates/extension.c.tpl
2025-12-12 14:29:41 +01:00
Alliballibaba
c749e2bab0 Merge branch 'main' into perf/optimize-types 2025-11-21 22:39:19 +01:00
Alliballibaba
dfb018cdd4 Fixes logger. 2025-11-19 19:39:16 +01:00
Alliballibaba
19c09050c6 Merge branch 'main' into perf/optimize-types 2025-11-19 19:27:45 +01:00
Alliballibaba
ae391c4ba9 Merge branch 'main' into perf/optimize-types 2025-11-17 19:05:57 +01:00
Alliballibaba
e3994afb78 Merge branch 'perf/optimize-types-bulk-insert' into perf/optimize-types 2025-11-04 22:51:00 +01:00
Alliballibaba
04bca7b847 Index fix. 2025-11-04 00:43:05 +01:00
Alliballibaba
68a9771e65 Adjusts -if- order. 2025-11-04 00:38:02 +01:00
Alliballibaba
9501c37921 fmt 2025-11-04 00:32:37 +01:00
Alliballibaba
360fdfdfd8 Makes associative arrays faster. 2025-11-04 00:09:31 +01:00
Alliballibaba
115d53561f Make slices another 50% faster. 2025-11-03 23:37:58 +01:00
Alliballibaba
8018018bc5 fmt 2025-11-03 00:18:59 +01:00
Alliballibaba
c4bce5c782 bulk insertions 2025-11-03 00:18:35 +01:00
Alliballibaba
8e09ffe89c Removes unnecessary funcs. 2025-11-01 17:57:32 +01:00
Alliballibaba
c3e588b528 Adds benchmarks. 2025-11-01 12:48:49 +01:00
Alliballibaba
066f9060c7 Removes unnecessary checks. 2025-11-01 12:24:52 +01:00
Alliballibaba
1d23b41c38 Removes unnecessary cast. 2025-11-01 12:21:16 +01:00
Alliballibaba
2519a2fbda More cleanup. 2025-10-31 23:23:07 +01:00
Alliballibaba
3770f09df9 More cleanup. 2025-10-31 23:21:24 +01:00
Alliballibaba
15cdac8690 not various optimizations. 2025-10-31 23:07:13 +01:00
Alliballibaba
bf8529335a Merge branch 'fix/return-hasmaps-directly' into perf/optimize-types 2025-10-31 22:00:30 +01:00
Alliballibaba
a5125f5aa8 Fixes toZval case. 2025-10-24 11:09:22 +02:00
Alliballibaba
e9d2294649 Fixes merge conflicts. 2025-10-24 11:03:23 +02:00
Alliballibaba
5f1bd59a53 Suggestions by @dunglas. 2025-10-13 22:38:25 +02:00
Alliballibaba
bb32911564 Changes naming to zend_array. 2025-10-11 22:29:35 +02:00
Alliballibaba
0bc8de4175 Applies git diff by @alexandre-daubois. 2025-10-07 19:33:10 +02:00
Alliballibaba
acfbe5160a Adjusts types. 2025-09-29 17:00:37 +02:00
Alliballibaba
cc6eae4aca Makes go functions also return a hashtable. 2025-09-29 16:53:06 +02:00
Alliballibaba
fb9acec3fa linting. 2025-09-22 23:15:36 +02:00
Alliballibaba
c87e4c9351 Adds cgo optimizations. 2025-09-22 21:43:37 +02:00
Alliballibaba
3744bf0c1d Returns a zend_array to PHP. 2025-09-22 21:37:57 +02:00
17 changed files with 526 additions and 276 deletions

View File

@@ -178,7 +178,11 @@ fi
# Embed PHP app, if any
if [ -n "${EMBED}" ] && [ -d "${EMBED}" ]; then
SPC_OPT_BUILD_ARGS="${SPC_OPT_BUILD_ARGS} --with-frankenphp-app=${EMBED}"
if [[ "${EMBED}" != /* ]]; then
EMBED="${CURRENT_DIR}/${EMBED}"
fi
# shellcheck disable=SC2089
SPC_OPT_BUILD_ARGS="${SPC_OPT_BUILD_ARGS} --with-frankenphp-app='${EMBED}'"
fi
SPC_OPT_INSTALL_ARGS="go-xcaddy"
@@ -204,7 +208,7 @@ done
# shellcheck disable=SC2086
${spcCommand} download --with-php="${PHP_VERSION}" --for-extensions="${PHP_EXTENSIONS}" --for-libs="${PHP_EXTENSION_LIBS}" ${SPC_OPT_DOWNLOAD_ARGS}
export FRANKENPHP_SOURCE_PATH="${CURRENT_DIR}"
# shellcheck disable=SC2086
# shellcheck disable=SC2086,SC2090
${spcCommand} build --enable-zts --build-embed --build-frankenphp ${SPC_OPT_BUILD_ARGS} "${PHP_EXTENSIONS}" --with-libs="${PHP_EXTENSION_LIBS}"
if [ -n "$CI" ]; then

View File

@@ -1472,3 +1472,31 @@ func TestDd(t *testing.T) {
"dump123",
)
}
func TestLog(t *testing.T) {
tester := caddytest.NewTester(t)
tester.InitServer(`
{
skip_install_trust
admin localhost:2999
}
http://localhost:`+testPort+` {
log {
output stdout
format json
}
root ../testdata
php_server {
worker ../testdata/log-frankenphp_log.php
}
}
`, "caddyfile")
tester.AssertGetResponse(
"http://localhost:"+testPort+"/log-frankenphp_log.php?i=0",
http.StatusOK,
"",
)
}

View File

@@ -10,7 +10,7 @@ require (
github.com/caddyserver/caddy/v2 v2.10.2
github.com/caddyserver/certmagic v0.25.0
github.com/dunglas/caddy-cbrotli v1.0.1
github.com/dunglas/frankenphp v1.11.0
github.com/dunglas/frankenphp v1.11.1
github.com/dunglas/mercure v0.21.4
github.com/dunglas/mercure/caddy v0.21.4
github.com/dunglas/vulcain/caddy v1.2.1

View File

@@ -55,11 +55,8 @@ func (f *FrankenPHPModule) configureHotReload(app *FrankenPHPApp) error {
}
func (f *FrankenPHPModule) unmarshalHotReload(d *caddyfile.Dispenser) error {
patterns := d.RemainingArgs()
if len(patterns) > 0 {
f.HotReload = &hotReloadConfig{
Watch: patterns,
}
f.HotReload = &hotReloadConfig{
Watch: d.RemainingArgs(),
}
for d.NextBlock(1) {
@@ -81,10 +78,6 @@ func (f *FrankenPHPModule) unmarshalHotReload(d *caddyfile.Dispenser) error {
return d.ArgErr()
}
if f.HotReload == nil {
f.HotReload = &hotReloadConfig{}
}
f.HotReload.Watch = append(f.HotReload.Watch, patterns...)
default:

View File

@@ -554,10 +554,10 @@ PHP_FUNCTION(frankenphp_log) {
zend_long level = 0;
zval *context = NULL;
ZEND_PARSE_PARAMETERS_START(2, 3)
ZEND_PARSE_PARAMETERS_START(1, 3)
Z_PARAM_STR(message)
Z_PARAM_LONG(level)
Z_PARAM_OPTIONAL
Z_PARAM_LONG(level)
Z_PARAM_ARRAY(context)
ZEND_PARSE_PARAMETERS_END();

View File

@@ -660,10 +660,28 @@ func go_read_cookies(threadIndex C.uintptr_t) *C.char {
return C.CString(cookie)
}
func getLogger(threadIndex C.uintptr_t) (*slog.Logger, context.Context) {
ctxHolder := phpThreads[threadIndex]
if ctxHolder == nil {
return globalLogger, globalCtx
}
ctx := ctxHolder.context()
if ctxHolder.handler == nil {
return globalLogger, ctx
}
fCtx := ctxHolder.frankenPHPContext()
if fCtx == nil || fCtx.logger == nil {
return globalLogger, ctx
}
return fCtx.logger, ctx
}
//export go_log
func go_log(threadIndex C.uintptr_t, message *C.char, level C.int) {
ctx := phpThreads[threadIndex].context()
logger := phpThreads[threadIndex].frankenPHPContext().logger
logger, ctx := getLogger(threadIndex)
m := C.GoString(message)
le := syslogLevelInfo
@@ -697,8 +715,7 @@ func go_log(threadIndex C.uintptr_t, message *C.char, level C.int) {
//export go_log_attrs
func go_log_attrs(threadIndex C.uintptr_t, message *C.zend_string, cLevel C.zend_long, cAttrs *C.zval) *C.char {
ctx := phpThreads[threadIndex].context()
logger := phpThreads[threadIndex].frankenPHPContext().logger
logger, ctx := getLogger(threadIndex)
level := slog.Level(cLevel)

View File

@@ -447,6 +447,7 @@ func testLog_frankenphp_log(t *testing.T, opts *testOptions) {
logs := buf.String()
for _, message := range []string{
`level=INFO msg="default level message"`,
fmt.Sprintf(`level=DEBUG msg="some debug message %d" "key int"=1`, i),
fmt.Sprintf(`level=INFO msg="some info message %d" "key string"=string`, i),
fmt.Sprintf(`level=WARN msg="some warn message %d"`, i),

View File

@@ -738,36 +738,42 @@ func TestCallable(t *testing.T) {
err = suite.verifyFunctionBehavior(`<?php
echo "Testing my_array_map([1, 2, 3])\n";
$result = my_array_map([1, 2, 3], function($x) { return $x * 2; });
if ($result !== [2, 4, 6]) {
echo "FAIL: my_array_map with closure expected [2, 4, 6], got " . json_encode($result);
exit(1);
}
echo "Testing my_array_map(['hello', 'world'])\n";
$result = my_array_map(['hello', 'world'], 'strtoupper');
if ($result !== ['HELLO', 'WORLD']) {
echo "FAIL: my_array_map with function name expected ['HELLO', 'WORLD'], got " . json_encode($result);
exit(1);
}
echo "Testing my_array_map with empty array\n";
$result = my_array_map([], function($x) { return $x; });
if ($result !== []) {
echo "FAIL: my_array_map with empty array expected [], got " . json_encode($result);
exit(1);
}
echo "Testing my_filter([1, 2, 3, 4, 5, 6])\n";
$result = my_filter([1, 2, 3, 4, 5, 6], function($x) { return $x % 2 === 0; });
if ($result !== [2, 4, 6]) {
echo "FAIL: my_filter expected [2, 4, 6], got " . json_encode($result);
exit(1);
}
echo "Testing my_filter with null callback\n";
$result = my_filter([1, 2, 3, 4], null);
if ($result !== [1, 2, 3, 4]) {
echo "FAIL: my_filter with null callback expected [1, 2, 3, 4], got " . json_encode($result);
exit(1);
}
echo "Testing Processor::transform\n";
$processor = new Processor();
$result = $processor->transform('hello', function($s) { return strtoupper($s); });
if ($result !== 'HELLO') {
@@ -775,12 +781,14 @@ if ($result !== 'HELLO') {
exit(1);
}
echo "Testing Processor::transform with function name\n";
$result = $processor->transform('world', 'strtoupper');
if ($result !== 'WORLD') {
echo "FAIL: Processor::transform with function name expected 'WORLD', got '$result'";
exit(1);
}
echo "Testing Processor::transform with trim function\n";
$result = $processor->transform(' test ', 'trim');
if ($result !== 'test') {
echo "FAIL: Processor::transform with trim expected 'test', got '$result'";

View File

@@ -96,7 +96,7 @@ func TestTransitionThreadsWhileDoingRequests(t *testing.T) {
var (
isDone atomic.Bool
wg sync.WaitGroup
wg sync.WaitGroup
)
numThreads := 10

View File

@@ -32,7 +32,8 @@ func my_filter(arr *C.zend_array, callback *C.zval) unsafe.Pointer {
}
if callback == nil {
return unsafe.Pointer(arr)
//return unsafe.Pointer(arr) // returning the original array requires GC_ADDREF
return frankenphp.PHPPackedArray[any](goArray)
}
result := make([]any, 0)
@@ -57,7 +58,7 @@ func (p *Processor) Transform(input *C.zend_string, callback *C.zval) unsafe.Poi
resultStr, ok := callResult.(string)
if !ok {
return unsafe.Pointer(input)
return frankenphp.PHPString(goInput, false)
}
return frankenphp.PHPString(resultStr, false)

View File

@@ -2,6 +2,8 @@
require_once __DIR__.'/_executor.php';
frankenphp_log("default level message");
return function () {
frankenphp_log("some debug message {$_GET['i']}", FRANKENPHP_LOG_LEVEL_DEBUG, [
"key int" => 1,

View File

@@ -255,23 +255,18 @@ func (handler *workerThread) waitForWorkerRequest() (bool, any) {
// go_frankenphp_worker_handle_request_start is called at the start of every php request served.
//
//export go_frankenphp_worker_handle_request_start
func go_frankenphp_worker_handle_request_start(threadIndex C.uintptr_t) (C.bool, unsafe.Pointer) {
func go_frankenphp_worker_handle_request_start(threadIndex C.uintptr_t) (C.bool, *C.zval) {
handler := phpThreads[threadIndex].handler.(*workerThread)
hasRequest, parameters := handler.waitForWorkerRequest()
if parameters != nil {
var ptr unsafe.Pointer
switch p := parameters.(type) {
case unsafe.Pointer:
ptr = p
return C.bool(hasRequest), (*C.zval)(p)
default:
ptr = PHPValue(p)
return C.bool(hasRequest), (*C.zval)(PHPValue(p))
}
handler.thread.Pin(ptr)
return C.bool(hasRequest), ptr
}
return C.bool(hasRequest), nil

73
types.c
View File

@@ -2,14 +2,14 @@
zval *get_ht_packed_data(HashTable *ht, uint32_t index) {
if (ht->u.flags & HASH_FLAG_PACKED) {
return &ht->arPacked[index];
return ht->arPacked;
}
return NULL;
}
Bucket *get_ht_bucket_data(HashTable *ht, uint32_t index) {
Bucket *get_ht_bucket(HashTable *ht) {
if (!(ht->u.flags & HASH_FLAG_PACKED)) {
return &ht->arData[index];
return ht->arData;
}
return NULL;
}
@@ -23,22 +23,61 @@ void __zend_hash_init__(HashTable *ht, uint32_t nSize, dtor_func_t pDestructor,
zend_hash_init(ht, nSize, NULL, pDestructor, persistent);
}
void __zval_null__(zval *zv) { ZVAL_NULL(zv); }
void __zval_bool__(zval *zv, bool val) { ZVAL_BOOL(zv, val); }
void __zval_long__(zval *zv, zend_long val) { ZVAL_LONG(zv, val); }
void __zval_double__(zval *zv, double val) { ZVAL_DOUBLE(zv, val); }
void __zval_string__(zval *zv, zend_string *str) { ZVAL_STR(zv, str); }
void __zval_empty_string__(zval *zv) { ZVAL_EMPTY_STRING(zv); }
void __zval_arr__(zval *zv, zend_array *arr) { ZVAL_ARR(zv, arr); }
zend_array *__zend_new_array__(uint32_t size) { return zend_new_array(size); }
zend_array *zend_hash_bulk_insert(zend_array *arr, size_t num_entries,
size_t bulk_size, char *key1, char *key2,
char *key3, char *key4, size_t key_len1,
size_t key_len2, size_t key_len3,
size_t key_len4, zval *val1, zval *val2,
zval *val3, zval *val4) {
if (!arr) {
arr = zend_new_array(num_entries);
}
zend_hash_str_update(arr, key1, key_len1, val1);
if (bulk_size < 1) {
return arr;
}
zend_hash_str_update(arr, key2, key_len2, val2);
if (bulk_size < 2) {
return arr;
}
zend_hash_str_update(arr, key3, key_len3, val3);
if (bulk_size < 3) {
return arr;
}
zend_hash_str_update(arr, key4, key_len4, val4);
return arr;
}
zend_array *zend_hash_bulk_next_index_insert(zend_array *arr,
size_t num_entries,
size_t bulk_size, zval *val1,
zval *val2, zval *val3,
zval *val4) {
if (!arr) {
arr = zend_new_array(num_entries);
}
zend_hash_next_index_insert(arr, val1);
if (bulk_size < 1) {
return arr;
}
zend_hash_next_index_insert(arr, val2);
if (bulk_size < 2) {
return arr;
}
zend_hash_next_index_insert(arr, val3);
if (bulk_size < 3) {
return arr;
}
zend_hash_next_index_insert(arr, val4);
return arr;
}
int __zend_is_callable__(zval *cb) { return zend_is_callable(cb, 0, NULL); }
int __call_user_function__(zval *function_name, zval *retval,

467
types.go
View File

@@ -1,25 +1,14 @@
package frankenphp
/*
#cgo nocallback __zend_new_array__
#cgo nocallback __zval_null__
#cgo nocallback __zval_bool__
#cgo nocallback __zval_long__
#cgo nocallback __zval_double__
#cgo nocallback __zval_string__
#cgo nocallback __zval_arr__
#cgo noescape __zend_new_array__
#cgo noescape __zval_null__
#cgo noescape __zval_bool__
#cgo noescape __zval_long__
#cgo noescape __zval_double__
#cgo noescape __zval_string__
#cgo noescape __zval_arr__
#include "types.h"
*/
//#cgo noescape __zend_new_array__
//#cgo noescape zend_hash_bulk_insert
//#cgo noescape zend_hash_bulk_next_index_insert
//#cgo noescape get_ht_bucket
//#cgo noescape get_ht_packed_data
//#include "zend_API.h"
//#include "types.h"
import "C"
import (
"errors"
"fmt"
"reflect"
"strconv"
@@ -36,26 +25,30 @@ func GoString(s unsafe.Pointer) string {
return ""
}
zendStr := (*C.zend_string)(s)
return goString((*C.zend_string)(s))
}
func goString(zendStr *C.zend_string) string {
return C.GoStringN((*C.char)(unsafe.Pointer(&zendStr.val)), C.int(zendStr.len))
}
// EXPERIMENTAL: PHPString converts a Go string to a zend_string with copy. The string can be
// non-persistent (automatically freed after the request by the ZMM) or persistent. If you choose
// the second mode, it is your repsonsability to free the allocated memory.
// the second mode, it is your repsonsibility to free the allocated memory.
func PHPString(s string, persistent bool) unsafe.Pointer {
return unsafe.Pointer(phpString(s, persistent))
}
func phpString(s string, persistent bool) *C.zend_string {
if s == "" {
return nil
return C.zend_empty_string
}
zendStr := C.zend_string_init(
return C.zend_string_init(
(*C.char)(unsafe.Pointer(unsafe.StringData(s))),
C.size_t(len(s)),
C._Bool(persistent),
)
return unsafe.Pointer(zendStr)
}
// AssociativeArray represents a PHP array with ordered key-value pairs
@@ -65,35 +58,34 @@ type AssociativeArray[T any] struct {
}
func (a AssociativeArray[T]) toZval(zval *C.zval) {
C.__zval_arr__(zval, (*C.zend_array)(PHPAssociativeArray[T](a)))
*(*uint32)(unsafe.Pointer(&zval.u1)) = C.IS_ARRAY_EX
*(**C.zend_array)(unsafe.Pointer(&zval.value)) = phpArray[T](a.Map, a.Order)
}
// EXPERIMENTAL: GoAssociativeArray converts a zend_array to a Go AssociativeArray
func GoAssociativeArray[T any](arr unsafe.Pointer) (AssociativeArray[T], error) {
entries, order, err := goArray[T](arr, true)
entries, order, err := goArray[T]((*C.zend_array)(arr), true)
return AssociativeArray[T]{entries, order}, err
}
// EXPERIMENTAL: GoMap converts a zend_array to an unordered Go map
func GoMap[T any](arr unsafe.Pointer) (map[string]T, error) {
entries, _, err := goArray[T](arr, false)
entries, _, err := goArray[T]((*C.zend_array)(arr), false)
return entries, err
}
func goArray[T any](arr unsafe.Pointer, ordered bool) (map[string]T, []string, error) {
if arr == nil {
return nil, nil, errors.New("received a nil pointer on array conversion")
}
array := (*C.zend_array)(arr)
func goArray[T any](array *C.zend_array, ordered bool) (map[string]T, []string, error) {
if array == nil {
return nil, nil, fmt.Errorf("received a *zval that wasn't a HashTable on array conversion")
return nil, nil, fmt.Errorf("received a nil pointer on array conversion")
}
nNumUsed := array.nNumUsed
if nNumUsed == 0 {
return make(map[string]T), nil, nil
}
entries := make(map[string]T, nNumUsed)
var order []string
if ordered {
@@ -104,45 +96,36 @@ func goArray[T any](arr unsafe.Pointer, ordered bool) (map[string]T, []string, e
// if the array is packed, convert all integer keys to strings
// this is probably a bug by the dev using this function
// still, we'll (inefficiently) convert to an associative array
zvals := unsafe.Slice(C.get_ht_packed_data(array, 0), nNumUsed)
for i := C.uint32_t(0); i < nNumUsed; i++ {
v := C.get_ht_packed_data(array, i)
if v != nil && C.zval_get_type(v) != C.IS_UNDEF {
strIndex := strconv.Itoa(int(i))
e, err := goValue[T](v)
if err != nil {
return nil, nil, err
}
entries[strIndex] = e
if ordered {
order = append(order, strIndex)
}
v := &zvals[i]
strIndex := strconv.Itoa(int(i))
e, err := goValue[T](v)
if err != nil {
return nil, nil, err
}
entries[strIndex] = e
if ordered {
order = append(order, strIndex)
}
}
return entries, order, nil
}
var zeroVal T
buckets := unsafe.Slice(C.get_ht_bucket(array), nNumUsed)
for i := C.uint32_t(0); i < nNumUsed; i++ {
bucket := C.get_ht_bucket_data(array, i)
if bucket == nil || C.zval_get_type(&bucket.val) == C.IS_UNDEF {
continue
}
v, err := goValue[any](&bucket.val)
bucket := &buckets[i]
v, err := goValue[T](&bucket.val)
if err != nil {
return nil, nil, err
}
if bucket.key != nil {
keyStr := GoString(unsafe.Pointer(bucket.key))
if v == nil {
entries[keyStr] = zeroVal
} else {
entries[keyStr] = v.(T)
}
keyStr := goString(bucket.key)
entries[keyStr] = v
if ordered {
order = append(order, keyStr)
@@ -153,7 +136,7 @@ func goArray[T any](arr unsafe.Pointer, ordered bool) (map[string]T, []string, e
// as fallback convert the bucket index to a string key
strIndex := strconv.Itoa(int(bucket.h))
entries[strIndex] = v.(T)
entries[strIndex] = v
if ordered {
order = append(order, strIndex)
}
@@ -164,46 +147,42 @@ func goArray[T any](arr unsafe.Pointer, ordered bool) (map[string]T, []string, e
// EXPERIMENTAL: GoPackedArray converts a zend_array to a Go slice
func GoPackedArray[T any](arr unsafe.Pointer) ([]T, error) {
if arr == nil {
return nil, errors.New("GoPackedArray received a nil value")
}
array := (*C.zend_array)(arr)
return goPackedArray[T]((*C.zend_array)(arr))
}
func goPackedArray[T any](array *C.zend_array) ([]T, error) {
if array == nil {
return nil, fmt.Errorf("GoPackedArray received *zval that wasn't a HashTable")
return nil, fmt.Errorf("GoPackedArray received nil pointer")
}
nNumUsed := array.nNumUsed
result := make([]T, 0, nNumUsed)
if htIsPacked(array) {
zvals := unsafe.Slice(C.get_ht_packed_data(array, 0), nNumUsed)
for i := C.uint32_t(0); i < nNumUsed; i++ {
v := C.get_ht_packed_data(array, i)
if v != nil && C.zval_get_type(v) != C.IS_UNDEF {
v, err := goValue[T](v)
if err != nil {
return nil, err
}
result = append(result, v)
v := &zvals[i]
goVal, err := goValue[T](v)
if err != nil {
return nil, err
}
result = append(result, goVal)
}
return result, nil
}
// fallback if ht isn't packed - equivalent to array_values()
buckets := unsafe.Slice(C.get_ht_bucket(array), nNumUsed)
for i := C.uint32_t(0); i < nNumUsed; i++ {
bucket := C.get_ht_bucket_data(array, i)
if bucket != nil && C.zval_get_type(&bucket.val) != C.IS_UNDEF {
v, err := goValue[T](&bucket.val)
if err != nil {
return nil, err
}
result = append(result, v)
bucket := &buckets[i]
v, err := goValue[T](&bucket.val)
if err != nil {
return nil, err
}
result = append(result, v)
}
return result, nil
@@ -211,47 +190,142 @@ func GoPackedArray[T any](arr unsafe.Pointer) ([]T, error) {
// EXPERIMENTAL: PHPMap converts an unordered Go map to a zend_array
func PHPMap[T any](arr map[string]T) unsafe.Pointer {
return phpArray[T](arr, nil)
return unsafe.Pointer(phpArray[T](arr, nil))
}
// EXPERIMENTAL: PHPAssociativeArray converts a Go AssociativeArray to a zend_array
func PHPAssociativeArray[T any](arr AssociativeArray[T]) unsafe.Pointer {
return phpArray[T](arr.Map, arr.Order)
return unsafe.Pointer(phpArray[T](arr.Map, arr.Order))
}
func phpArray[T any](entries map[string]T, order []string) unsafe.Pointer {
var zendArray *C.zend_array
func phpArray[T any](entries map[string]T, order []string) *C.zend_array {
lenEntries := len(entries)
lenOrder := len(order)
if lenEntries == 0 && lenOrder == 0 {
return createNewArray(0)
}
if len(order) != 0 {
zendArray = createNewArray((uint32)(len(order)))
// bulk insert zvals 4 by 4
// this is currently the most efficient way to avoid cgo overhead
var zendArray *C.zend_array
var key1 *C.char
var keyLen1 C.size_t
var zval1 C.zval
var key2 *C.char
var keyLen2 C.size_t
var zval2 C.zval
var key3 *C.char
var keyLen3 C.size_t
var zval3 C.zval
var key4 *C.char
var keyLen4 C.size_t
var zval4 C.zval
i := 0
if lenOrder != 0 {
for _, key := range order {
val := entries[key]
zval := phpValue(val)
C.zend_hash_str_update(zendArray, toUnsafeChar(key), C.size_t(len(key)), zval)
C.__efree__(unsafe.Pointer(zval))
mod := i % 4
switch mod {
case 0:
key1 = toUnsafeChar(key)
keyLen1 = C.size_t(len(key))
phpValue(&zval1, val)
case 1:
key2 = toUnsafeChar(key)
keyLen2 = C.size_t(len(key))
phpValue(&zval2, val)
case 2:
key3 = toUnsafeChar(key)
keyLen3 = C.size_t(len(key))
phpValue(&zval3, val)
case 3:
key4 = toUnsafeChar(key)
keyLen4 = C.size_t(len(key))
phpValue(&zval4, val)
}
if mod == 3 || i == lenOrder-1 {
zendArray = C.zend_hash_bulk_insert(
zendArray, C.size_t(lenOrder), C.size_t(mod),
key1, key2, key3, key4,
keyLen1, keyLen2, keyLen3, keyLen4,
&zval1, &zval2, &zval3, &zval4,
)
}
i++
}
} else {
zendArray = createNewArray((uint32)(len(entries)))
for key, val := range entries {
zval := phpValue(val)
C.zend_hash_str_update(zendArray, toUnsafeChar(key), C.size_t(len(key)), zval)
C.__efree__(unsafe.Pointer(zval))
mod := i % 4
switch mod {
case 0:
key1 = toUnsafeChar(key)
keyLen1 = C.size_t(len(key))
phpValue(&zval1, val)
case 1:
key2 = toUnsafeChar(key)
keyLen2 = C.size_t(len(key))
phpValue(&zval2, val)
case 2:
key3 = toUnsafeChar(key)
keyLen3 = C.size_t(len(key))
phpValue(&zval3, val)
case 3:
key4 = toUnsafeChar(key)
keyLen4 = C.size_t(len(key))
phpValue(&zval4, val)
}
if mod == 3 || i == lenEntries-1 {
zendArray = C.zend_hash_bulk_insert(
zendArray, C.size_t(lenEntries), C.size_t(mod),
key1, key2, key3, key4,
keyLen1, keyLen2, keyLen3, keyLen4,
&zval1, &zval2, &zval3, &zval4,
)
}
i++
}
}
return unsafe.Pointer(zendArray)
return zendArray
}
// EXPERIMENTAL: PHPPackedArray converts a Go slice to a PHP zval with a zend_array value.
// EXPERIMENTAL: PHPPackedArray converts a Go slice to a PHP zend_array.
func PHPPackedArray[T any](slice []T) unsafe.Pointer {
zendArray := createNewArray((uint32)(len(slice)))
for _, val := range slice {
zval := phpValue(val)
C.zend_hash_next_index_insert(zendArray, zval)
C.__efree__(unsafe.Pointer(zval))
}
return unsafe.Pointer(phpPackedArray[T](slice))
}
return unsafe.Pointer(zendArray)
func phpPackedArray[T any](slice []T) *C.zend_array {
sliceLen := len(slice)
if sliceLen == 0 {
return createNewArray(0)
}
var zendArray *C.zend_array
var zval1 C.zval
var zval2 C.zval
var zval3 C.zval
var zval4 C.zval
for i, val := range slice {
mod := i % 4
switch mod {
case 0:
phpValue(&zval1, val)
case 1:
phpValue(&zval2, val)
case 2:
phpValue(&zval3, val)
case 3:
phpValue(&zval4, val)
}
if mod == 3 || i == sliceLen-1 {
zendArray = C.zend_hash_bulk_next_index_insert(
zendArray, C.size_t(sliceLen), C.size_t(mod),
&zval1, &zval2, &zval3, &zval4,
)
}
}
return zendArray
}
// EXPERIMENTAL: GoValue converts a PHP zval to a Go value
@@ -269,70 +343,34 @@ func goValue[T any](zval *C.zval) (res T, err error) {
resAny any
resZero T
)
t := C.zval_get_type(zval)
switch t {
switch zvalGetType(zval) {
case C.IS_NULL:
resAny = any(nil)
resAny = nil
case C.IS_FALSE:
resAny = any(false)
resAny = false
case C.IS_TRUE:
resAny = any(true)
resAny = true
case C.IS_LONG:
v, err := extractZvalValue(zval, C.IS_LONG)
if err != nil {
return resZero, err
}
if v != nil {
resAny = any(int64(*(*C.zend_long)(v)))
break
}
resAny = any(int64(0))
v := (*C.zend_long)(unsafe.Pointer(&zval.value[0]))
resAny = int64(*v)
case C.IS_DOUBLE:
v, err := extractZvalValue(zval, C.IS_DOUBLE)
if err != nil {
return resZero, err
}
if v != nil {
resAny = any(float64(*(*C.double)(v)))
break
}
resAny = any(float64(0))
v := (*C.double)(unsafe.Pointer(&zval.value[0]))
resAny = float64(*v)
case C.IS_STRING:
v, err := extractZvalValue(zval, C.IS_STRING)
if err != nil {
return resZero, err
}
if v == nil {
resAny = any("")
break
}
resAny = any(GoString(v))
v := *(**C.zend_string)(unsafe.Pointer(&zval.value[0]))
resAny = goString(v)
case C.IS_ARRAY:
v, err := extractZvalValue(zval, C.IS_ARRAY)
if err != nil {
return resZero, err
}
array := (*C.zend_array)(v)
if array != nil && htIsPacked(array) {
array := *(**C.zend_array)(unsafe.Pointer(&zval.value[0]))
if htIsPacked(array) {
typ := reflect.TypeOf(res)
if typ == nil || typ.Kind() == reflect.Interface && typ.NumMethod() == 0 {
r, e := GoPackedArray[any](unsafe.Pointer(array))
r, e := goPackedArray[any](array)
if e != nil {
return resZero, e
}
resAny = any(r)
resAny = r
break
}
@@ -340,14 +378,14 @@ func goValue[T any](zval *C.zval) (res T, err error) {
return resZero, fmt.Errorf("cannot convert packed array to non-any Go type %s", typ.String())
}
a, err := GoAssociativeArray[T](unsafe.Pointer(array))
goMap, order, err := goArray[T](array, true)
if err != nil {
return resZero, err
}
resAny = any(a)
resAny = AssociativeArray[T]{Map: goMap, Order: order}
default:
return resZero, fmt.Errorf("unsupported zval type %d", t)
return resZero, fmt.Errorf("unsupported zval type %d", zvalGetType(zval))
}
if resAny == nil {
@@ -367,53 +405,70 @@ func goValue[T any](zval *C.zval) (res T, err error) {
// Any other type will cause a panic.
// More types may be supported in the future.
func PHPValue(value any) unsafe.Pointer {
return unsafe.Pointer(phpValue(value))
zval := (*C.zval)(C.__emalloc__(C.size_t(unsafe.Sizeof(C.zval{}))))
phpValue(zval, value)
return unsafe.Pointer(zval)
}
func phpValue(value any) *C.zval {
zval := (*C.zval)(C.__emalloc__(C.size_t(unsafe.Sizeof(C.zval{}))))
func phpValue(zval *C.zval, value any) {
if toZvalObj, ok := value.(toZval); ok {
toZvalObj.toZval(zval)
return zval
return
}
switch v := value.(type) {
case nil:
C.__zval_null__(zval)
// equvalent of ZVAL_NULL
*(*uint32)(unsafe.Pointer(&zval.u1)) = C.IS_NULL
case bool:
C.__zval_bool__(zval, C._Bool(v))
// equvalent of ZVAL_BOOL
if v {
*(*uint32)(unsafe.Pointer(&zval.u1)) = C.IS_TRUE
} else {
*(*uint32)(unsafe.Pointer(&zval.u1)) = C.IS_FALSE
}
case int:
C.__zval_long__(zval, C.zend_long(v))
// equvalent of ZVAL_LONG
*(*uint32)(unsafe.Pointer(&zval.u1)) = C.IS_LONG
*(*C.zend_long)(unsafe.Pointer(&zval.value)) = C.zend_long(v)
case int64:
C.__zval_long__(zval, C.zend_long(v))
// equvalent of ZVAL_LONG
*(*uint32)(unsafe.Pointer(&zval.u1)) = C.IS_LONG
*(*C.zend_long)(unsafe.Pointer(&zval.value)) = C.zend_long(v)
case float64:
C.__zval_double__(zval, C.double(v))
// equvalent of ZVAL_DOUBLE
*(*uint32)(unsafe.Pointer(&zval.u1)) = C.IS_DOUBLE
*(*C.double)(unsafe.Pointer(&zval.value)) = C.double(v)
case string:
if v == "" {
C.__zval_empty_string__(zval)
// equivalent ZVAL_EMPTY_STRING
*(*uint32)(unsafe.Pointer(&zval.u1)) = C.IS_INTERNED_STRING_EX
*(**C.zend_string)(unsafe.Pointer(&zval.value)) = C.zend_empty_string
break
}
str := (*C.zend_string)(PHPString(v, false))
C.__zval_string__(zval, str)
// equvalent of ZVAL_STRING
*(*uint32)(unsafe.Pointer(&zval.u1)) = C.IS_STRING_EX
*(**C.zend_string)(unsafe.Pointer(&zval.value)) = phpString(v, false)
case AssociativeArray[any]:
C.__zval_arr__(zval, (*C.zend_array)(PHPAssociativeArray[any](v)))
// equvalent of ZVAL_ARR
*(*uint32)(unsafe.Pointer(&zval.u1)) = C.IS_ARRAY_EX
*(**C.zend_array)(unsafe.Pointer(&zval.value)) = phpArray[any](v.Map, v.Order)
case map[string]any:
C.__zval_arr__(zval, (*C.zend_array)(PHPMap[any](v)))
// equvalent of ZVAL_ARR
*(*uint32)(unsafe.Pointer(&zval.u1)) = C.IS_ARRAY_EX
*(**C.zend_array)(unsafe.Pointer(&zval.value)) = phpArray[any](v, nil)
case []any:
C.__zval_arr__(zval, (*C.zend_array)(PHPPackedArray[any](v)))
// equvalent of ZVAL_ARR
*(*uint32)(unsafe.Pointer(&zval.u1)) = C.IS_ARRAY_EX
*(**C.zend_array)(unsafe.Pointer(&zval.value)) = phpPackedArray[any](v)
default:
C.__efree__(unsafe.Pointer(zval))
panic(fmt.Sprintf("unsupported Go type %T", v))
}
return zval
}
// createNewArray creates a new zend_array with the specified size.
func createNewArray(size uint32) *C.zend_array {
arr := C.__zend_new_array__(C.uint32_t(size))
return (*C.zend_array)(unsafe.Pointer(arr))
func createNewArray(size int) *C.zend_array {
return C.__zend_new_array__(C.uint32_t(size))
}
// IsPacked determines if the given zend_array is a packed array (list).
@@ -433,42 +488,26 @@ func htIsPacked(ht *C.zend_array) bool {
return (flags & C.HASH_FLAG_PACKED) != 0
}
// extractZvalValue returns a pointer to the zval value cast to the expected type
func extractZvalValue(zval *C.zval, expectedType C.uint8_t) (unsafe.Pointer, error) {
if zval == nil {
if expectedType == C.IS_NULL {
return nil, nil
}
return nil, fmt.Errorf("zval type mismatch: expected %d, got nil", expectedType)
}
if zType := C.zval_get_type(zval); zType != expectedType {
return nil, fmt.Errorf("zval type mismatch: expected %d, got %d", expectedType, zType)
}
v := unsafe.Pointer(&zval.value[0])
switch expectedType {
case C.IS_LONG, C.IS_DOUBLE:
return v, nil
case C.IS_STRING:
return unsafe.Pointer(*(**C.zend_string)(v)), nil
case C.IS_ARRAY:
return unsafe.Pointer(*(**C.zend_array)(v)), nil
}
return nil, fmt.Errorf("unsupported zval type %d", expectedType)
// equivalent of Z_TYPE_P
// interpret z->u1 as a 32-bit integer, then take lowest byte
func zvalGetType(z *C.zval) C.uint8_t {
typeInfo := *(*uint32)(unsafe.Pointer(&z.u1))
return C.uint8_t(typeInfo & 0xFF)
}
// used in tests for cleanup
func zendStringRelease(p unsafe.Pointer) {
zs := (*C.zend_string)(p)
C.zend_string_release(zs)
C.zend_string_release((*C.zend_string)(p))
}
func zendHashDestroy(p unsafe.Pointer) {
ht := (*C.zend_array)(p)
C.zend_hash_destroy(ht)
// used in tests for cleanup
func zendArrayRelease(p unsafe.Pointer) {
C.zend_array_release((*C.zend_array)(p))
}
// used in tests for cleanup
func efree(p unsafe.Pointer) {
C.__efree__(p)
}
// EXPERIMENTAL: CallPHPCallable executes a PHP callable with the given parameters.
@@ -501,9 +540,7 @@ func CallPHPCallable(cb unsafe.Pointer, params []interface{}) interface{} {
for i, param := range params {
targetZval := (*C.zval)(unsafe.Pointer(uintptr(unsafe.Pointer(paramStorage)) + uintptr(i)*unsafe.Sizeof(C.zval{})))
sourceZval := phpValue(param)
*targetZval = *sourceZval
C.__efree__(unsafe.Pointer(sourceZval))
phpValue(targetZval, param)
}
}

22
types.h
View File

@@ -8,7 +8,7 @@
#include <Zend/zend_types.h>
zval *get_ht_packed_data(HashTable *, uint32_t index);
Bucket *get_ht_bucket_data(HashTable *, uint32_t index);
Bucket *get_ht_bucket(HashTable *);
void *__emalloc__(size_t size);
void __efree__(void *ptr);
@@ -19,13 +19,19 @@ int __zend_is_callable__(zval *cb);
int __call_user_function__(zval *function_name, zval *retval,
uint32_t param_count, zval params[]);
void __zval_null__(zval *zv);
void __zval_bool__(zval *zv, bool val);
void __zval_long__(zval *zv, zend_long val);
void __zval_double__(zval *zv, double val);
void __zval_string__(zval *zv, zend_string *str);
void __zval_empty_string__(zval *zv);
void __zval_arr__(zval *zv, zend_array *arr);
zend_array *__zend_new_array__(uint32_t size);
zend_array *zend_hash_bulk_insert(zend_array *arr, size_t num_entries,
size_t bulk_size, char *key1, char *key2,
char *key3, char *key4, size_t key_len1,
size_t key_len2, size_t key_len3,
size_t key_len4, zval *val1, zval *val2,
zval *val3, zval *val4);
zend_array *zend_hash_bulk_next_index_insert(zend_array *arr,
size_t num_entries,
size_t bulk_size, zval *val1,
zval *val2, zval *val3,
zval *val4);
#endif

View File

@@ -1,6 +1,8 @@
package frankenphp
import (
"fmt"
"io"
"log/slog"
"testing"
@@ -44,7 +46,7 @@ func TestPHPMap(t *testing.T) {
}
phpArray := PHPMap(originalMap)
defer zendHashDestroy(phpArray)
defer zendArrayRelease(phpArray)
convertedMap, err := GoMap[string](phpArray)
require.NoError(t, err)
@@ -63,7 +65,7 @@ func TestOrderedPHPAssociativeArray(t *testing.T) {
}
phpArray := PHPAssociativeArray(originalArray)
defer zendHashDestroy(phpArray)
defer zendArrayRelease(phpArray)
convertedArray, err := GoAssociativeArray[string](phpArray)
require.NoError(t, err)
@@ -76,7 +78,7 @@ func TestPHPPackedArray(t *testing.T) {
originalSlice := []string{"bar1", "bar2"}
phpArray := PHPPackedArray(originalSlice)
defer zendHashDestroy(phpArray)
defer zendArrayRelease(phpArray)
convertedSlice, err := GoPackedArray[string](phpArray)
require.NoError(t, err)
@@ -93,7 +95,7 @@ func TestPHPPackedArrayToGoMap(t *testing.T) {
}
phpArray := PHPPackedArray(originalSlice)
defer zendHashDestroy(phpArray)
defer zendArrayRelease(phpArray)
convertedMap, err := GoMap[string](phpArray)
require.NoError(t, err)
@@ -113,7 +115,7 @@ func TestPHPAssociativeArrayToPacked(t *testing.T) {
expectedSlice := []string{"bar1", "bar2"}
phpArray := PHPAssociativeArray(originalArray)
defer zendHashDestroy(phpArray)
defer zendArrayRelease(phpArray)
convertedSlice, err := GoPackedArray[string](phpArray)
require.NoError(t, err)
@@ -138,10 +140,127 @@ func TestNestedMixedArray(t *testing.T) {
}
phpArray := PHPMap(originalArray)
defer zendHashDestroy(phpArray)
defer zendArrayRelease(phpArray)
convertedArray, err := GoMap[any](phpArray)
require.NoError(t, err)
assert.Equal(t, originalArray, convertedArray, "nested mixed array should be equal after conversion")
})
}
func benchOnPHPThread(b *testing.B, count int, cb func()) {
globalLogger = slog.New(slog.NewTextHandler(io.Discard, nil))
_, err := initPHPThreads(1, 1, nil) // boot 1 thread
assert.NoError(b, err)
handler := convertToTaskThread(phpThreads[0])
task := newTask(func() {
for i := 0; i < count; i++ {
cb()
}
})
handler.execute(task)
task.waitForCompletion()
drainPHPThreads()
}
func BenchmarkBool(b *testing.B) {
benchOnPHPThread(b, b.N, func() {
phpBool := PHPValue(true)
_, _ = GoValue[bool](phpBool)
efree(phpBool)
})
}
func BenchmarkInt(b *testing.B) {
benchOnPHPThread(b, b.N, func() {
phpInt := PHPValue(int64(42))
_, _ = GoValue[int64](phpInt)
efree(phpInt)
})
}
func BenchmarkFloat(b *testing.B) {
benchOnPHPThread(b, b.N, func() {
phpFloat := PHPValue(3.14)
_, _ = GoValue[float64](phpFloat)
efree(phpFloat)
})
}
func BenchmarkString(b *testing.B) {
message := "Hello, World!"
benchOnPHPThread(b, b.N, func() {
phpString := PHPString(message, false)
_ = GoString(phpString)
zendStringRelease(phpString)
})
}
func BenchmarkEmptyMap(b *testing.B) {
originalMap := map[string]any{}
benchOnPHPThread(b, b.N, func() {
phpArray := PHPMap(originalMap)
_, _ = GoMap[any](phpArray)
zendArrayRelease(phpArray)
})
}
func BenchmarkMap5Entries(b *testing.B) {
originalMap := map[string]any{
"foo1": "bar1",
"foo2": int64(2),
"foo3": true,
"foo4": 3.14,
"foo5": nil,
}
benchOnPHPThread(b, b.N, func() {
phpArray := PHPMap(originalMap)
_, _ = GoMap[any](phpArray)
zendArrayRelease(phpArray)
})
}
func BenchmarkAssociativeArray5Entries(b *testing.B) {
originalArray := AssociativeArray[any]{
Map: map[string]any{
"foo1": "bar1",
"foo2": int64(2),
"foo3": true,
"foo4": 3.14,
"foo5": nil,
},
Order: []string{"foo3", "foo1", "foo4", "foo2", "foo5"},
}
benchOnPHPThread(b, b.N, func() {
phpArray := PHPAssociativeArray(originalArray)
_, _ = GoAssociativeArray[any](phpArray)
zendArrayRelease(phpArray)
})
}
func BenchmarkSlice5Entries(b *testing.B) {
originalSlice := []any{"bar1", int64(2), true, 3.14, nil}
benchOnPHPThread(b, b.N, func() {
phpArray := PHPPackedArray(originalSlice)
_, _ = GoPackedArray[any](phpArray)
zendArrayRelease(phpArray)
})
}
func BenchmarkMap50Entries(b *testing.B) {
originalMap := map[string]any{}
for i := 0; i < 10; i++ {
originalMap[fmt.Sprintf("foo%d", i*5)] = fmt.Sprintf("val%d", i)
originalMap[fmt.Sprintf("foo%d", i*5+1)] = "Error" // interned string
originalMap[fmt.Sprintf("foo%d", i*5+2)] = true
originalMap[fmt.Sprintf("foo%d", i*5+3)] = 3.12
originalMap[fmt.Sprintf("foo%d", i*5+4)] = nil
}
benchOnPHPThread(b, b.N, func() {
phpArray := PHPMap(originalMap)
_, _ = GoMap[any](phpArray)
zendArrayRelease(phpArray)
})
}

View File

@@ -10,7 +10,7 @@ import (
)
type hotReloadOpt struct {
hotReload []*watcher.PatternGroup
hotReload []*watcher.PatternGroup
}
var restartWorkers atomic.Bool