From eb8ce6444f345adc634fa8a184118a2b7ff1ba9a Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Thu, 12 Nov 2026 09:56:33 +0100 Subject: [PATCH 1/3] fix: build and run on Ubuntu 26.04 ubuntu-latest now resolves to Ubuntu 26.04, where three things break: - PHP unloads the shared extensions when it shuts down, and libenchant 2.8 can't be loaded twice in a process: GObject still knows its types, so the next use of enchant aborts. That's the second phpinfo() of the test suite, and on a server the first use of enchant after a worker restart, opcache_reset() or a config reload. ZEND_DONT_UNLOAD_MODULES is now set from the library constructor, so extensions stay loaded as they do in a static build. - GCC 14+ rejects the extensions the generator writes: memrchr() is not declared because cgo includes before php.h, and nullable int and bool arguments are passed as pointers to other types than the ones cgo declares. The generated files now define _GNU_SOURCE, like cgo.go, and point to values of the right types. The manual example of the docs gets the define too. - MemorySanitizer in clang 21 takes the index of _mm512_permutex2var_epi8() for uninitialized memory, which clang 22 fixes. The msan job builds PHP without the AVX-512 VBMI variant of base64_decode(), the one place calling it, and the key of the PHP cache follows the workflow file so that a change of the configure line rebuilds PHP. --- .github/workflows/sanitizers.yaml | 7 ++-- docs/extensions.md | 3 ++ frankenphp.c | 8 +++++ internal/extgen/cfile_phpmethod_test.go | 13 ++++++++ internal/extgen/gofile_test.go | 1 + internal/extgen/integration_test.go | 39 +++++++++++++++++++++- internal/extgen/paramparser.go | 6 ++-- internal/extgen/paramparser_test.go | 4 +-- internal/extgen/templates/extension.c.tpl | 2 +- internal/extgen/templates/extension.go.tpl | 1 + testdata/integration/basic_function.go | 17 ++++++++++ testdata/integration/class_methods.go | 7 ++++ 12 files changed, 100 insertions(+), 8 deletions(-) diff --git a/.github/workflows/sanitizers.yaml b/.github/workflows/sanitizers.yaml index 0f6e2e2c7c..70f13b1bea 100644 --- a/.github/workflows/sanitizers.yaml +++ b/.github/workflows/sanitizers.yaml @@ -56,7 +56,7 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: php/target - key: php-sanitizers-${{ matrix.sanitizer }}-${{ runner.arch }}-${{ steps.determine-php-version.outputs.version }} + key: php-sanitizers-${{ matrix.sanitizer }}-${{ runner.arch }}-${{ steps.determine-php-version.outputs.version }}-${{ hashFiles('.github/workflows/sanitizers.yaml') }} - if: steps.cache-php.outputs.cache-hit != 'true' name: Compile PHP run: | @@ -64,6 +64,9 @@ jobs: MIRROR_URL=${URL/https:\/\/www.php.net/https:\/\/phpmirror.static-php.dev} (curl -fsSL "${URL}" || curl -fsSL "${MIRROR_URL}") | tar -Jx -C php --strip-components=1 cd php/ + # MemorySanitizer in clang 21 takes the index of an AVX-512 permutation for uninitialized memory + # (https://github.com/llvm/llvm-project/pull/148785, fixed in clang 22): + # skip the AVX-512 VBMI variant of base64_decode(), the one place that has such a call ./configure \ CFLAGS="$CFLAGS" \ LDFLAGS="$LDFLAGS" \ @@ -88,7 +91,7 @@ jobs: --without-pear \ --disable-mbregex \ --enable-werror \ - ${{ matrix.sanitizer == 'msan' && '--enable-memory-sanitizer' || '' }} \ + ${{ matrix.sanitizer == 'msan' && '--enable-memory-sanitizer php_cv_have_avx512vbmi=no' || '' }} \ --prefix="$(pwd)/target/" make -j"$(getconf _NPROCESSORS_ONLN)" make install diff --git a/docs/extensions.md b/docs/extensions.md index fdb9a5ff45..748561cae0 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -671,6 +671,7 @@ In your module, you need to define a new native function that will be called fro // extension.go package example +// #cgo linux CFLAGS: -D_GNU_SOURCE // #include "extension.h" import "C" import ( @@ -694,6 +695,8 @@ func go_print_something() { The `frankenphp.RegisterExtension()` function simplifies the extension registration process by handling the internal PHP registration logic. The `go_print_something` function uses the `//export` directive to indicate that it will be accessible in the C code we will write, thanks to CGO. +The `#cgo` line is needed on Linux: CGO includes system headers before `php.h`, and without `_GNU_SOURCE` they don't declare all the functions the PHP headers call, which recent compilers refuse. + In this example, our new function will trigger a goroutine that logs a message in Caddy's logs. #### Define the PHP function diff --git a/frankenphp.c b/frankenphp.c index ca7ec27b14..75a21667da 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -255,6 +255,14 @@ __attribute__((constructor)) static void frankenphp_libpreinit(void) { /* Single-threaded at this point (constructors run before Go's runtime), * so sigprocmask is sufficient and portable. */ sigprocmask(SIG_BLOCK, &set, NULL); + + /* PHP unloads the shared extensions when it shuts down, and FrankenPHP + * starts it again in the same process: to restart the workers, after + * opcache_reset(), for a new configuration. Some libraries don't survive + * being loaded twice (libenchant aborts, GObject still knows its types), + * so the extensions stay loaded, as they do in a static build. + * Set here because setenv() isn't safe once other threads run. */ + setenv("ZEND_DONT_UNLOAD_MODULES", "1", 0); } #endif diff --git a/internal/extgen/cfile_phpmethod_test.go b/internal/extgen/cfile_phpmethod_test.go index 98e98b3812..345cbee091 100644 --- a/internal/extgen/cfile_phpmethod_test.go +++ b/internal/extgen/cfile_phpmethod_test.go @@ -302,6 +302,19 @@ func TestCFile_ClassMethodParamCastsByParamType(t *testing.T) { "(long)name", }, }, + { + name: "int return with nullable params points to values of the types cgo declares", + returnType: phpInt, + params: []phpParameter{ + {Name: "name", PhpType: phpString, IsNullable: true}, + {Name: "count", PhpType: phpInt, IsNullable: true}, + {Name: "ratio", PhpType: phpFloat, IsNullable: true}, + {Name: "enabled", PhpType: phpBool, IsNullable: true}, + }, + wantContain: []string{ + "getVarInt_wrapper(intern->go_handle, name_is_null ? NULL : name, count_is_null ? NULL : &(GoInt64){count}, ratio_is_null ? NULL : &ratio, enabled_is_null ? NULL : &(GoUint8){enabled});", + }, + }, } for _, tt := range tests { diff --git a/internal/extgen/gofile_test.go b/internal/extgen/gofile_test.go index 196ca4214f..dbf358b2bb 100644 --- a/internal/extgen/gofile_test.go +++ b/internal/extgen/gofile_test.go @@ -1219,6 +1219,7 @@ func (cs *CallableStruct) ProcessOptionalCallback(callback *C.zval) string { func testGeneratedFileBasicStructure(t *testing.T, content, expectedPackage, baseName string) { requiredElements := []string{ "package " + expectedPackage, + "// #cgo linux CFLAGS: -D_GNU_SOURCE", "// #include ", `// #include "` + baseName + `.h"`, `import "C"`, diff --git a/internal/extgen/integration_test.go b/internal/extgen/integration_test.go index a90ab15bea..4ce6fc4daf 100644 --- a/internal/extgen/integration_test.go +++ b/internal/extgen/integration_test.go @@ -303,7 +303,7 @@ func TestBasicFunction(t *testing.T) { require.NoError(t, err, "FrankenPHP compilation should succeed") err = suite.verifyPHPSymbols( - []string{"test_uppercase", "test_add_numbers", "test_multiply", "test_is_enabled"}, + []string{"test_uppercase", "test_add_numbers", "test_multiply", "test_is_enabled", "test_nullables"}, []string{}, []string{}, ) @@ -340,6 +340,24 @@ if ($result !== true) { exit(1); } +$result = test_nullables(null, null, null); +if ($result !== "null null null") { + echo "FAIL: test_nullables(null, null, null) expected 'null null null', got '$result'"; + exit(1); +} + +$result = test_nullables(-42, 1.5, true); +if ($result !== "-42 1.5 true") { + echo "FAIL: test_nullables(-42, 1.5, true) expected '-42 1.5 true', got '$result'"; + exit(1); +} + +$result = test_nullables(0, 0.0, false); +if ($result !== "0 0 false") { + echo "FAIL: test_nullables(0, 0.0, false) expected '0 0 false', got '$result'"; + exit(1); +} + echo "OK"; `, "OK") require.NoError(t, err, "all function calls should work correctly") @@ -416,6 +434,25 @@ if ($counter->getValue() !== 50) { exit(1); } +$counter->resetIf(null); +if ($counter->getValue() !== 50) { + echo "FAIL: Counter after resetIf(null) expected 50 (unchanged), got " . $counter->getValue(); + exit(1); +} + +$counter->resetIf(false); +if ($counter->getValue() !== 50) { + echo "FAIL: Counter after resetIf(false) expected 50 (unchanged), got " . $counter->getValue(); + exit(1); +} + +$counter->resetIf(true); +if ($counter->getValue() !== 0) { + echo "FAIL: Counter after resetIf(true) expected 0, got " . $counter->getValue(); + exit(1); +} + +$counter->setValue(50); $counter->reset(); if ($counter->getValue() !== 0) { echo "FAIL: Counter after reset expected 0, got " . $counter->getValue(); diff --git a/internal/extgen/paramparser.go b/internal/extgen/paramparser.go index a9f40c3054..66f6d51249 100644 --- a/internal/extgen/paramparser.go +++ b/internal/extgen/paramparser.go @@ -168,12 +168,14 @@ func (pp *ParameterParser) generateSingleGoCallParam(param phpParameter) string switch param.PhpType { case phpString: return fmt.Sprintf("%s_is_null ? NULL : %s", param.Name, param.Name) + // cgo declares *int64 and *bool as GoInt64 * and GoUint8 *, which GCC 14+ + // refuses to mix with zend_long * and zend_bool *: point to a copy of the right type case phpInt: - return fmt.Sprintf("%s_is_null ? NULL : &%s", param.Name, param.Name) + return fmt.Sprintf("%s_is_null ? NULL : &(GoInt64){%s}", param.Name, param.Name) case phpFloat: return fmt.Sprintf("%s_is_null ? NULL : &%s", param.Name, param.Name) case phpBool: - return fmt.Sprintf("%s_is_null ? NULL : &%s", param.Name, param.Name) + return fmt.Sprintf("%s_is_null ? NULL : &(GoUint8){%s}", param.Name, param.Name) case phpCallable: return fmt.Sprintf("%s_callback", param.Name) default: diff --git a/internal/extgen/paramparser_test.go b/internal/extgen/paramparser_test.go index c53a55874d..c59e77d023 100644 --- a/internal/extgen/paramparser_test.go +++ b/internal/extgen/paramparser_test.go @@ -503,7 +503,7 @@ func TestParameterParser_GenerateSingleGoCallParam(t *testing.T) { { name: "nullable int parameter", param: phpParameter{Name: "count", PhpType: phpInt, IsNullable: true}, - expected: "count_is_null ? NULL : &count", + expected: "count_is_null ? NULL : &(GoInt64){count}", }, { name: "float parameter", @@ -523,7 +523,7 @@ func TestParameterParser_GenerateSingleGoCallParam(t *testing.T) { { name: "nullable bool parameter", param: phpParameter{Name: "enabled", PhpType: phpBool, IsNullable: true}, - expected: "enabled_is_null ? NULL : &enabled", + expected: "enabled_is_null ? NULL : &(GoUint8){enabled}", }, { name: "array parameter", diff --git a/internal/extgen/templates/extension.c.tpl b/internal/extgen/templates/extension.c.tpl index 9a03bace2e..545948e5c9 100644 --- a/internal/extgen/templates/extension.c.tpl +++ b/internal/extgen/templates/extension.c.tpl @@ -9,7 +9,7 @@ {{define "methodCallArg" -}} {{- if .IsNullable -}} -{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "mixed"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}} +{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &(GoInt64){ {{- .Name -}} }{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &(GoUint8){ {{- .Name -}} }{{else if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "mixed"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}} {{- else -}} {{if eq .PhpType "string"}}{{.Name}}{{else if eq .PhpType "int"}}(long){{.Name}}{{else if eq .PhpType "float"}}(double){{.Name}}{{else if eq .PhpType "bool"}}(int){{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "mixed"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}} {{- end -}} diff --git a/internal/extgen/templates/extension.go.tpl b/internal/extgen/templates/extension.go.tpl index d5d8889fc8..9088315a88 100644 --- a/internal/extgen/templates/extension.go.tpl +++ b/internal/extgen/templates/extension.go.tpl @@ -9,6 +9,7 @@ package {{.PackageName}} // You may edit the file and remove this comment if you plan to manually maintain // this file going forward. +// #cgo linux CFLAGS: -D_GNU_SOURCE // #include // #include "{{.BaseName}}.h" import "C" diff --git a/testdata/integration/basic_function.go b/testdata/integration/basic_function.go index 5004764602..39669d45e6 100644 --- a/testdata/integration/basic_function.go +++ b/testdata/integration/basic_function.go @@ -3,6 +3,7 @@ package testintegration // #include import "C" import ( + "strconv" "strings" "unsafe" @@ -30,3 +31,19 @@ func test_multiply(a float64, b float64) float64 { func test_is_enabled(flag bool) bool { return !flag } + +// export_php:function test_nullables(?int $count, ?float $ratio, ?bool $flag): string +func test_nullables(count *int64, ratio *float64, flag *bool) unsafe.Pointer { + values := []string{"null", "null", "null"} + if count != nil { + values[0] = strconv.FormatInt(*count, 10) + } + if ratio != nil { + values[1] = strconv.FormatFloat(*ratio, 'f', -1, 64) + } + if flag != nil { + values[2] = strconv.FormatBool(*flag) + } + + return frankenphp.PHPString(strings.Join(values, " "), false) +} diff --git a/testdata/integration/class_methods.go b/testdata/integration/class_methods.go index e84d346a15..7169799871 100644 --- a/testdata/integration/class_methods.go +++ b/testdata/integration/class_methods.go @@ -51,6 +51,13 @@ func (c *CounterStruct) UpdateWithNullable(newValue *int64) { } } +// export_php:method Counter::resetIf(?bool $condition): void +func (c *CounterStruct) ResetIf(condition *bool) { + if condition != nil && *condition { + c.Value = 0 + } +} + // export_php:class StringHolder type StringHolderStruct struct { Data string From 2560911d6312a9b0965baebf8f9e63830072d70b Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Thu, 12 Nov 2026 10:16:05 +0100 Subject: [PATCH 2/3] Don't load enchant on CI instead of keeping extensions loaded With ZEND_DONT_UNLOAD_MODULES, net-snmp is initialized again in the same image, which it doesn't expect: it floods the log at each start of PHP and the suite segfaults on the runner. Back to PHP's default: the PHP of the CI jobs no longer loads enchant. --- .github/actions/setup-php/action.yaml | 3 +++ frankenphp.c | 8 -------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/.github/actions/setup-php/action.yaml b/.github/actions/setup-php/action.yaml index 03af29d743..dd0021daa0 100644 --- a/.github/actions/setup-php/action.yaml +++ b/.github/actions/setup-php/action.yaml @@ -14,6 +14,9 @@ runs: ini-file: development # JIT's fixed-address mappings can race Go thread creation during startup. ini-values: opcache.jit=disable + # libenchant 2.8 can't be loaded again once PHP unloaded it, which each new start of PHP does: + # GObject still knows the types of the first load, and aborts when they come back + extensions: :enchant coverage: none tools: none env: diff --git a/frankenphp.c b/frankenphp.c index 75a21667da..ca7ec27b14 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -255,14 +255,6 @@ __attribute__((constructor)) static void frankenphp_libpreinit(void) { /* Single-threaded at this point (constructors run before Go's runtime), * so sigprocmask is sufficient and portable. */ sigprocmask(SIG_BLOCK, &set, NULL); - - /* PHP unloads the shared extensions when it shuts down, and FrankenPHP - * starts it again in the same process: to restart the workers, after - * opcache_reset(), for a new configuration. Some libraries don't survive - * being loaded twice (libenchant aborts, GObject still knows its types), - * so the extensions stay loaded, as they do in a static build. - * Set here because setenv() isn't safe once other threads run. */ - setenv("ZEND_DONT_UNLOAD_MODULES", "1", 0); } #endif From 4b69a1507eb1dd5e975332ee7f1d05da11bad1bb Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Thu, 12 Nov 2026 11:44:45 +0100 Subject: [PATCH 3/3] Run the sanitizers with clang 22 instead of skipping the AVX-512 code Ubuntu 26.04 has clang 22 in its archive, where MemorySanitizer no longer takes the index of an AVX-512 permutation for uninitialized memory: PHP is built as before. A note next to the enchant line says when to drop it. --- .github/actions/setup-php/action.yaml | 3 ++- .github/workflows/sanitizers.yaml | 13 +++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/actions/setup-php/action.yaml b/.github/actions/setup-php/action.yaml index dd0021daa0..e84e9a198e 100644 --- a/.github/actions/setup-php/action.yaml +++ b/.github/actions/setup-php/action.yaml @@ -15,7 +15,8 @@ runs: # JIT's fixed-address mappings can race Go thread creation during startup. ini-values: opcache.jit=disable # libenchant 2.8 can't be loaded again once PHP unloaded it, which each new start of PHP does: - # GObject still knows the types of the first load, and aborts when they come back + # GObject still knows the types of the first load, and aborts when they come back. + # To remove once https://github.com/php/php-src/issues/25412 is fixed in the PHP versions tested here extensions: :enchant coverage: none tools: none diff --git a/.github/workflows/sanitizers.yaml b/.github/workflows/sanitizers.yaml index 70f13b1bea..26cc7034e2 100644 --- a/.github/workflows/sanitizers.yaml +++ b/.github/workflows/sanitizers.yaml @@ -31,8 +31,10 @@ jobs: env: CFLAGS: -g -O0 -fsanitize=${{ matrix.sanitizer == 'asan' && 'address' || 'memory' }} -DZEND_TRACK_ARENA_ALLOC LDFLAGS: -fsanitize=${{ matrix.sanitizer == 'asan' && 'address' || 'memory' }} - CC: clang - CXX: clang++ + # MemorySanitizer in clang 21, the default of Ubuntu 26.04, takes the index of an AVX-512 permutation + # for uninitialized memory: https://github.com/llvm/llvm-project/pull/148785 + CC: clang-22 + CXX: clang++-22 USE_ZEND_ALLOC: 0 LIBRARY_PATH: ${{ github.workspace }}/php/target/lib:${{ github.workspace }}/watcher/target/lib LD_LIBRARY_PATH: ${{ github.workspace }}/php/target/lib @@ -41,6 +43,8 @@ jobs: steps: - name: Remove local PHP run: sudo apt-get remove --purge --autoremove 'php*' 'libmemcached*' + - name: Install clang + run: sudo apt-get update && sudo apt-get install -y "${CC}" - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -64,9 +68,6 @@ jobs: MIRROR_URL=${URL/https:\/\/www.php.net/https:\/\/phpmirror.static-php.dev} (curl -fsSL "${URL}" || curl -fsSL "${MIRROR_URL}") | tar -Jx -C php --strip-components=1 cd php/ - # MemorySanitizer in clang 21 takes the index of an AVX-512 permutation for uninitialized memory - # (https://github.com/llvm/llvm-project/pull/148785, fixed in clang 22): - # skip the AVX-512 VBMI variant of base64_decode(), the one place that has such a call ./configure \ CFLAGS="$CFLAGS" \ LDFLAGS="$LDFLAGS" \ @@ -91,7 +92,7 @@ jobs: --without-pear \ --disable-mbregex \ --enable-werror \ - ${{ matrix.sanitizer == 'msan' && '--enable-memory-sanitizer php_cv_have_avx512vbmi=no' || '' }} \ + ${{ matrix.sanitizer == 'msan' && '--enable-memory-sanitizer' || '' }} \ --prefix="$(pwd)/target/" make -j"$(getconf _NPROCESSORS_ONLN)" make install