Throwing PHP exceptions from Go extensions
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 11.3k
- Forks
- 488
- Avg merge
- 4d 10h
- Merged PRs (30d)
- 11
Description
Is your feature request related to a problem? Please describe.
There is currently no supported way for a Go extension to report a failure to PHP. This shows up in four places:
-
No public API. #2273 asks exactly this. As noted there, the only known workaround is hand-written C with a thread-local slot. Extensions written with the generator have no option at all, so failures get encoded into the return type (
['ok' => false, 'error' => ...]), which is not how PHP reports errors. -
Generated stubs never check
EG(exception).phpfunc.go(generateReturnCode) andtemplates/extension.c.tplgo straight toRETURN_STR/RETURN_ARR/RETURN_LONG. So even an extension that manages to throw from C gets its exception ignored, and a return value is written while an exception is pending — which violates the engine invariant and trips assertions on a debug build. -
CallPHPCallableswallows exceptions. Intypes.go:result := C.__call_user_function__(callback, &retval, C.uint32_t(paramCount), paramStorage) if result != C.SUCCESS { return nil }zend_call_functionreturnsSUCCESSwhen the callable threw; the exception is left pending inEG(exception)andretvalis unset. The helper returnsnil, the Go loop keeps going and calls the callable again (it can no longer run), and the stub finally returns a truncated array with an exception in flight. Themy_array_mapexample indocs/extensions.mdbreaks as soon as the callback throws. -
A Go panic takes down the whole process. An unrecovered panic in an exported function is not recoverable further up: it kills FrankenPHP, every PHP thread, and every in-flight request. Today each extension author has to remember a defensive
defer recover()of their own.
Describe the solution you'd like
A small public API in the frankenphp package, plus generator support built on top of it.
Naming a class — no hardcoded list of exception classes, so nothing to maintain as PHP evolves:
// EXPERIMENTAL: resolved lazily on first throw, then cached.
func ExceptionClass(name string) ExceptionClass
Resolution uses zend_lookup_class_ex(..., ZEND_FETCH_CLASS_NO_AUTOLOAD) and accepts the class only if it is an internal class that is instanceof Throwable. No autoload from inside an extension, and no load-order-dependent behaviour in worker mode. The restriction to internal classes is not arbitrary: internal class entries are persistent and shared across threads, so caching a zend_class_entry* process-wide is sound — userland classes are recompiled per thread and caching theirs would not be. Classes from other extensions (PDOException, JsonException, ReflectionException) work out of the box.
If the name cannot be resolved, an \Error carrying the original message plus the offending class name is thrown and the failure is logged. The business error still reaches PHP; nothing is silently swallowed.
Throwing:
// EXPERIMENTAL: must be called from the PHP thread, during a synchronous call from PHP.
func ThrowException(class ExceptionClass, message string, code int64)
type PHPException struct {
Class ExceptionClass // nil -> RuntimeException
Message string
Code int64
}
func (e *PHPException) Error() string
// Honours *PHPException, otherwise throws RuntimeException with err.Error().
func ThrowError(err error)
ThrowException only marks EG(exception) and returns; the C stub is what performs RETURN_THROWS(). Nothing longjmps through Go code, so defers still run.
Calling it off the PHP thread (from a goroutine) is a logged no-op rather than a crash. The guard is a dedicated thread-local flag set when a PHP thread starts, checked before touching EG() — on a non-PHP thread tsrm_get_ls_cache() returns NULL and reading EG() would segfault, so the check cannot itself go through EG().
Generator support:
-
//export_php:functionaccepts(T, error), orerroralone for avoidreturn. The generated bridge consumes theerroron the Go side and callsThrowError; theerrornever crosses the cgo boundary. -
Every generated stub gains
if (UNEXPECTED(EG(exception))) { ...release...; RETURN_THROWS(); }after the Go call — for all functions and methods, not just those returning anerror, since the primitive can be called from anywhere. Any value already allocated by Go is released before returning, instead of leaking until the end of the request. -
New directive for extension-owned exception classes, with no backing Go struct (an exception carries no Go state, unlike the current opaque classes):
//export_php:exception MyExt\TransformError extends \RuntimeExceptionBeing internal, it is then addressable through the same
ExceptionClass("MyExt\\TransformError")path as everything else.
Panics: the generated bridge gets a defer, and a public RecoverAsException() is available to hand-written extensions. A recovered panic is logged at error level with the Go stack, then thrown as \FrankenPHP\PanicError extends \Error — distinguishable from engine errors, so panics in extensions can be observed and counted. A panic inside a goroutine spawned by the extension is still fatal (no wrapper on its stack); that limit needs to be documented explicitly.
Callables: CallPHPCallable returns (any, error) so a throwing callback propagates. It is marked EXPERIMENTAL in types.go, so the break seems acceptable. Unwind and graceful exits (zend_is_unwind_exit / zend_is_graceful_exit, already handled in frankenphp.c) must pass through untouched rather than being wrapped — exit() inside a callback has to keep meaning exit().
Describe alternatives you've considered
- A fixed set of exception-class constants (
ClassRuntimeException, …) mapped tozend_ce_*/spl_ce_*. Compile-time checked and lookup-free, but it goes stale with every PHP release and ignores throwables owned by other extensions. Lazy lookup keeps the engine as the source of truth; the cost is that a typo surfaces on first throw instead of at compile time. - Lookup by FQCN with autoload, allowing userland classes. Most flexible, but it triggers autoload from inside an extension and makes behaviour depend on load order in worker mode.
- Only the
(T, error)signature. Idiomatic, but useless to hand-written extensions and limited to one throw site per function. - Only the imperative primitive. Closest to the C API, but nothing then forces the function to stop, so a value can be returned alongside a pending exception.
- Splitting panic recovery into its own PR. Same machinery, and an extension that can still crash the whole server undercuts the rest; keeping them together tells one coherent story.
Open questions for maintainers
- Is the
deferfor panic recovery acceptable on every generated call, or is an opt-out directive wanted for hot paths? \FrankenPHP\PanicErrormeans FrankenPHP registers a class of its own — any objection, or would a plain\Errorbe preferred?- Is breaking
CallPHPCallable's signature fine given itsEXPERIMENTALmarker, or should a separate variant be added? - Should the extension-owned exception directive be
//export_php:exception, or anextendsclause on the existing//export_php:class? The latter reuses the directive but the current class machinery assumes a backing Go struct and acreate_objecthandler, which an exception does not need.
Happy to implement this — planned as one PR in reviewable commits (C helpers + primitive → error type, recover, PanicError → EG(exception) guard in generated C → (T, error) signatures → exception directive → CallPHPCallable → docs), with end-to-end tests in internal/testext covering explicit throws, error returns, panics (process must survive), unknown classes, throwing callables, exit() in a callback, and throws attempted from a goroutine — each also run in worker mode.
Refs #2273
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with types.go, phpfunc.go, templates/extension.c.tpl, and frankenphp.c to understand callable errors, generated returns, and existing unwind handling. Review internal/testext and docs/extensions.md before proposing the public exception, panic-recovery, generator, and callable changes. Done means the listed end-to-end cases pass in normal and worker modes, including unknown classes, throwing callbacks, exit(), goroutine attempts, and panic survival.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- c, go, php
- Domain
- api, backend, testing-qa
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 30/100