diff --git a/.github/actions/setup-php/action.yaml b/.github/actions/setup-php/action.yaml index 03af29d743..e84e9a198e 100644 --- a/.github/actions/setup-php/action.yaml +++ b/.github/actions/setup-php/action.yaml @@ -14,6 +14,10 @@ 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. + # 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 env: diff --git a/.github/workflows/sanitizers.yaml b/.github/workflows/sanitizers.yaml index 0f6e2e2c7c..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 @@ -56,7 +60,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: | 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/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