Skip to content

Conversation

@AlliBalliBaba
Copy link
Contributor

Should fix #1891.

Changes the PHP* array functions for extensions to return a zend_array instead of a zval.

In most cases it's easier to work with the zend_array and in the current setup the zval container sometimes needs pinning.

This PR also adds some wrapper functions so tests still work and some #cgnocallback and #cgonoescape optimizations

@dunglas
Copy link
Member

dunglas commented Sep 23, 2025

Why doesn't it need pinning? As long as it is heap-allocated, it needs pinning.

@AlliBalliBaba
Copy link
Contributor Author

I think right now it's actually allocated on the stack, which works for go -> c, but doesn't work for c -> go -> c.

We could also explicitly allocate the zval instead, but it might end up being an unnecessary allocation.

@dunglas
Copy link
Member

dunglas commented Sep 23, 2025

Couldn't we just expose the pinner instead?

@AlliBalliBaba
Copy link
Contributor Author

AlliBalliBaba commented Sep 24, 2025

Hmm yeah we could, not sure though if that's a good idea.

I still think the best way is to just return the zend_array since most of the time the flow probably will include some kind of custom PHP function like this:

PHP_FUNCTION(my_function)
{
    HashTable *ht =go_get_an_array()
    
    RETURN_ARR(ht)
}

For tests we can just wrap the array into a zval.

@dunglas
Copy link
Member

dunglas commented Sep 24, 2025

I agree. In general, we should return the specific data structure instead of a zval when possible.

@AlliBalliBaba
Copy link
Contributor Author

Let's stick with the Hashtable then 👍 . Only unfortunate thing is the asymmetry between PHPArray and GoArray since one takes a zval and the other returns a HashTable. But it's probably the most convenient for this type of flow:

php function -> go -> return from php function.

@dunglas
Copy link
Member

dunglas commented Sep 25, 2025

Shouldn't we remove zvals from our public API?

@AlliBalliBaba
Copy link
Contributor Author

I think that makes sense since we have to be strict anyways about what types we are passing. The go functions will now also only accept a HashTable.

Only thing I'm not sure about is where to hint this in the docs or extensions generator, maybe @alexandre-daubois can help out. Basically it now needs an additional call to Z_ARRVAL_P on the C side:

PHP_FUNCTION(my_function)
{
    zval *input;

    ZEND_PARSE_PARAMETERS_START(1, 1)
        Z_PARAM_ARRAY(input)
    ZEND_PARSE_PARAMETERS_END();

    RETURN_ARR(go_process_the_array(Z_ARRVAL_P(input)));
}

@alexandre-daubois
Copy link
Member

I guess this should do the trick?

diff --git a/internal/extgen/templates/extension.c.tpl b/internal/extgen/templates/extension.c.tpl
index 6ae0b34..fdd61a1 100644
--- a/internal/extgen/templates/extension.c.tpl
+++ b/internal/extgen/templates/extension.c.tpl
@@ -113,19 +113,19 @@ PHP_METHOD({{namespacedClassName $.Namespace .ClassName}}, {{.PhpName}}) {
     
     {{- if ne .ReturnType "void"}}
     {{- if eq .ReturnType "string"}}
-    zend_string* result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{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}}{{end}}{{else}}{{.Name}}{{end}}{{end}}{{end}});
+    zend_string* result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{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}} ? Z_ARRVAL_P({{.Name}}) : NULL{{end}}{{else}}{{if eq .PhpType "array"}}Z_ARRVAL_P({{.Name}}){{else}}{{.Name}}{{end}}{{end}}{{end}}{{end}});
     RETURN_STR(result);
     {{- else if eq .ReturnType "int"}}
-    zend_long result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{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}}{{end}}{{else}}{{if eq .PhpType "array"}}{{.Name}}{{else}}(long){{.Name}}{{end}}{{end}}{{end}}{{end}});
+    zend_long result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{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}} ? Z_ARRVAL_P({{.Name}}) : NULL{{end}}{{else}}{{if eq .PhpType "array"}}Z_ARRVAL_P({{.Name}}){{else}}(long){{.Name}}{{end}}{{end}}{{end}}{{end}});
     RETURN_LONG(result);
     {{- else if eq .ReturnType "float"}}
-    double result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{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}}{{end}}{{else}}{{if eq .PhpType "array"}}{{.Name}}{{else}}(double){{.Name}}{{end}}{{end}}{{end}}{{end}});
+    double result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{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}} ? Z_ARRVAL_P({{.Name}}) : NULL{{end}}{{else}}{{if eq .PhpType "array"}}Z_ARRVAL_P({{.Name}}){{else}}(double){{.Name}}{{end}}{{end}}{{end}}{{end}});
     RETURN_DOUBLE(result);
     {{- else if eq .ReturnType "bool"}}
-    int result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{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}}{{end}}{{else}}{{if eq .PhpType "array"}}{{.Name}}{{else}}(int){{.Name}}{{end}}{{end}}{{end}}{{end}});
+    int result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{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}} ? Z_ARRVAL_P({{.Name}}) : NULL{{end}}{{else}}{{if eq .PhpType "array"}}Z_ARRVAL_P({{.Name}}){{else}}(int){{.Name}}{{end}}{{end}}{{end}}{{end}});
     RETURN_BOOL(result);
     {{- else if eq .ReturnType "array"}}
-    void* result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{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}}{{end}}{{else}}{{.Name}}{{end}}{{end}}{{end}});
+    void* result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{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}} ? Z_ARRVAL_P({{.Name}}) : NULL{{end}}{{else}}{{if eq .PhpType "array"}}Z_ARRVAL_P({{.Name}}){{else}}{{.Name}}{{end}}{{end}}{{end}}{{end}});
     if (result != NULL) {
         HashTable *ht = (HashTable*)result;
         RETURN_ARR(ht);
@@ -134,7 +134,7 @@ PHP_METHOD({{namespacedClassName $.Namespace .ClassName}}, {{.PhpName}}) {
     }
     {{- end}}
     {{- else}}
-    {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{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}}{{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}}{{end}}{{end}}{{end}}{{end}});
+    {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{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}} ? Z_ARRVAL_P({{.Name}}) : NULL{{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"}}Z_ARRVAL_P({{.Name}}){{end}}{{end}}{{end}}{{end}});
     {{- end}}
 }
 {{end}}{{end}}

@dunglas
Copy link
Member

dunglas commented Oct 7, 2025

Hey, I would like to make a release soon. Do you think you can include @alexandre-daubois changes @AlliBalliBaba?

@AlliBalliBaba
Copy link
Contributor Author

Hey yeah, I'll have a look 👍

@dunglas
Copy link
Member

dunglas commented Oct 9, 2025

I'm wondering if this change is actually a good idea. The zend_array itself may contain zvals and may require pining. Aren't we just hiding the problem instead of fixing it?

@AlliBalliBaba
Copy link
Contributor Author

AlliBalliBaba commented Oct 11, 2025

The zend_array has its own buckets that are allocated together with the array, so no pinning needed in that case. When converting with phpValue, the zval only shortly lives on the go stack before going into the array bucket.

There's probably still a lot of room for improvement on these conversions, but they should currently be safe to do. The main issue was that our tests only test the go->c->go direction and not the c->go->c direction. I'm thinking about also supporting objects, then we could probably use and test this together with some of the new messaging apis.

@AlliBalliBaba
Copy link
Contributor Author

Alright, it's now zend_array everywhere. I also noticed that empty strings can currently segfault (they are converted to NULL), so I added a __zval_empty_string__ fallback.

Copy link
Member

@dunglas dunglas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM except for these minor comments changes.

@AlliBalliBaba
Copy link
Contributor Author

Fixed the merge conflicts. types.go still has a lot of inefficiencies and potential memory leaks.

PHPValue should probably accept a *zval pointer to avoid stack overflows and memory pinning. I plan to optimize and fix this in a separate PR. I've also been able to add Objects support on a separate branch, which now has too many merge conflicts.

One issue is also that we cannot properly test c->go->c convertions, which requires a built-in messaging api like in #1883

@AlliBalliBaba
Copy link
Contributor Author

AlliBalliBaba commented Nov 1, 2025

@dunglas can we merge this?

The only alternative that I could think of would be passing the zval container directly since that's often how the control-flow in php-src works. Hard to say what would be more convenient for extensions.

func PHPPackedArray[T any](slice []T) unsafe.Pointer {
	// current behaviour: return an unsafe.Pointer zend_array
}
func PHPPackedArray[T any](slice []T, zval unsafe.Pointer) {
	// alternative: put the array directly into the zval instead of returning it
}

@dunglas
Copy link
Member

dunglas commented Nov 1, 2025

LGTM as is

@AlliBalliBaba AlliBalliBaba requested a review from dunglas November 2, 2025 18:09
@AlliBalliBaba
Copy link
Contributor Author

@dunglas merging is still blocked

@myleshyson
Copy link

I think the extension.go.tpl needs to be updated as well. just tried to to use this fork to test the array fixes and noticed my function wasn't getting exported when running extension-init.

looks like there just needs to be a case added for C.zend_array

@AlliBalliBaba
Copy link
Contributor Author

AlliBalliBaba commented Nov 16, 2025

I'll try to add a test 👍

@francislavoie do you know why the testext tests segafult when running go test ./... from the root directory? Is it a build issue?

Hmm also currently when initializing an extension with frankenphp extension-init it will just delete the whole folder including the go file

@AlliBalliBaba
Copy link
Contributor Author

@myleshyson does it work for you now?

@AlliBalliBaba
Copy link
Contributor Author

AlliBalliBaba commented Nov 16, 2025

@alexandre-daubois actually I don't think the extgen command should be removing the BuildDir at all

generator := extgen.Generator{BaseName: baseName, SourceFile: sourceFile, BuildDir: filepath.Dir(sourceFile)}

Has the potential to just nuke a whole project without warning.

Oh nvm, I just saw that this was already fixed on main.

@myleshyson
Copy link

myleshyson commented Nov 17, 2025

maybe its related to this but I'm having a hard time testing. im running this in a docker container. And funny enough this actually did nuke my whole ext project!

Steps to reproduce:

  1. have a volume of your ext project in a docker container.
  2. try to run (using this branch) frankenphp ext-init /path/to/your/project inside the docker container.

I got this error, and noticed my project was deleted. its a git repo so I just restored it.

dont't have this issue with 1.9.1.

root@3834af4d486f:/app# frankenphp extension-init ./sdphp/stringext.go
Error: setup build directory: removing build directory: unlinkat ./sdphp: device or resource busy
root@3834af4d486f:/app# exit
exit

@alexandre-daubois actually I don't think the extgen command should be removing the BuildDir at all

generator := extgen.Generator{BaseName: baseName, SourceFile: sourceFile, BuildDir: filepath.Dir(sourceFile)}

Has the potential to just nuke a whole project without warning.

Oh nvm, I just saw that this was already fixed on main.

@alexandre-daubois
Copy link
Member

@myleshyson Yes there was a bug after the removal of the build dir, it's fixed on main. I stumbled across that too, and it wasn't a Git repository in my case 😅 The fix has been pushed last week, sorry for the inconvenience!

@AlliBalliBaba
Copy link
Contributor Author

I merged main into this branch so you can try again @myleshyson

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Returning arrays in frankenphp extensions seems broken

5 participants