From 2d5839d5a1d4f4f29b030d4c0bd82ad643ea0da7 Mon Sep 17 00:00:00 2001 From: Johan Janssens Date: Wed, 25 Mar 2026 16:50:07 +0100 Subject: [PATCH 1/5] feat: expose thread API for Go-based PHP extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add APIs that enable Go-based PHP extensions to execute on existing FrankenPHP threads and access per-request state: - Thread(index) — retrieves a PHP thread by its index, returning its *http.Request which carries the request context - PHPThread.Pin() — pins a Go object to prevent GC collection - PHPThread.IsRequestDone() — checks if the request has been closed - frankenphp_thread_index() — C function returning the current thread index so C extension methods can call back into Go This makes it possible to build PHP extensions where PHP calls into C, C resolves the current thread index, and Go handles the logic with full access to the request context. --- frankenphp.c | 4 ++++ frankenphp.go | 32 ++++++++++++++++++++++++++++++++ frankenphp.h | 2 ++ 3 files changed, 38 insertions(+) diff --git a/frankenphp.c b/frankenphp.c index 823ba11c1c..c0a65dbeae 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1761,6 +1761,10 @@ bool frankenphp_new_php_thread(uintptr_t thread_index) { return true; } +uintptr_t frankenphp_thread_index(void) { + return thread_index; +} + /* Use global variables to store CLI arguments to prevent useless allocations */ static char *cli_script; static int cli_argc; diff --git a/frankenphp.go b/frankenphp.go index 319f00a30c..7342691d8c 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -131,6 +131,38 @@ type PHPConfig struct { ZendMaxExecutionTimers bool } +// PHPThread exposes a PHP thread's request context. +type PHPThread struct { + Request *http.Request + thread *phpThread +} + +// IsRequestDone determines whether the request associated with the PHPThread has been closed. +func (p *PHPThread) IsRequestDone() bool { + return p.thread.frankenPHPContext().isDone +} + +// Pin pins a Go object, preventing it from being moved or freed by the garbage +// collector until the Pinner.Unpin method has been called. +func (p *PHPThread) Pin(pointer any) { + p.thread.Pin(pointer) +} + +// Thread retrieves a PHP thread by its index. +// Returns nil and false if the system is not running or no thread exists at the given index. +func Thread(index int) (*PHPThread, bool) { + if !isRunning { + return nil, false + } + + thread := phpThreads[index] + if thread != nil { + return &PHPThread{thread.frankenPHPContext().request, thread}, true + } + + return nil, false +} + // Version returns infos about the PHP version. func Version() PHPVersion { cVersion := C.frankenphp_get_version() diff --git a/frankenphp.h b/frankenphp.h index db32a82fe0..789b5fb71e 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -190,6 +190,8 @@ frankenphp_config frankenphp_get_config(); int frankenphp_new_main_thread(int num_threads); bool frankenphp_new_php_thread(uintptr_t thread_index); +uintptr_t frankenphp_thread_index(void); + bool frankenphp_shutdown_dummy_request(void); void frankenphp_update_local_thread_context(bool is_worker); From ece2f5d98591e1fc149f8415f23283f02f286902 Mon Sep 17 00:00:00 2001 From: Johan Janssens Date: Thu, 26 Mar 2026 22:31:04 +0100 Subject: [PATCH 2/5] test: add thread API tests Test Thread(), PHPThread.Pin(), and request handling. Fix nil pointer dereference in Thread() when no request is active or index is out of bounds. --- frankenphp.go | 16 +++++++-- thread_api_test.go | 88 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 thread_api_test.go diff --git a/frankenphp.go b/frankenphp.go index 7342691d8c..e5d1e3c82b 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -155,12 +155,22 @@ func Thread(index int) (*PHPThread, bool) { return nil, false } + if index >= len(phpThreads) { + return nil, false + } + thread := phpThreads[index] - if thread != nil { - return &PHPThread{thread.frankenPHPContext().request, thread}, true + if thread == nil { + return nil, false + } + + fc := thread.frankenPHPContext() + var request *http.Request + if fc != nil { + request = fc.request } - return nil, false + return &PHPThread{request, thread}, true } // Version returns infos about the PHP version. diff --git a/thread_api_test.go b/thread_api_test.go new file mode 100644 index 0000000000..70d474107b --- /dev/null +++ b/thread_api_test.go @@ -0,0 +1,88 @@ +package frankenphp + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestThreadReturnsNilWhenNotRunning(t *testing.T) { + // Before Init(), Thread() should return nil + thread, ok := Thread(0) + assert.Nil(t, thread) + assert.False(t, ok) +} + +func TestThreadReturnsValidThread(t *testing.T) { + t.Cleanup(Shutdown) + + require.NoError(t, Init( + WithNumThreads(2), + WithMaxThreads(2), + )) + + // Thread 0 should exist after init + thread, ok := Thread(0) + assert.True(t, ok) + assert.NotNil(t, thread) +} + +func TestThreadReturnsNilForInvalidIndex(t *testing.T) { + t.Cleanup(Shutdown) + + require.NoError(t, Init( + WithNumThreads(2), + WithMaxThreads(2), + )) + + // Index beyond thread count should return nil + thread, ok := Thread(999) + assert.Nil(t, thread) + assert.False(t, ok) +} + +func TestThreadExposesRequestDuringExecution(t *testing.T) { + t.Cleanup(Shutdown) + + require.NoError(t, Init( + WithNumThreads(2), + WithMaxThreads(2), + )) + + handler := func(w http.ResponseWriter, r *http.Request) { + req, err := NewRequestWithContext(r, + WithRequestDocumentRoot(testDataPath, false), + ) + assert.NoError(t, err) + + err = ServeHTTP(w, req) + assert.NoError(t, err) + } + + req := httptest.NewRequest("GET", "http://localhost/echo.php", nil) + w := httptest.NewRecorder() + handler(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestPHPThreadPin(t *testing.T) { + t.Cleanup(Shutdown) + + require.NoError(t, Init( + WithNumThreads(2), + WithMaxThreads(2), + )) + + thread, ok := Thread(0) + require.True(t, ok) + + // Pin should not panic + data := "test data" + assert.NotPanics(t, func() { + thread.Pin(&data) + }) +} From 53529e42e3c691abdc2571b78e48b45973c1bbc1 Mon Sep 17 00:00:00 2001 From: Kevin Dunglas Date: Sat, 18 Jul 2026 16:38:36 +0200 Subject: [PATCH 3/5] thread: use uint for Thread() index to match internal C API --- frankenphp.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index e5d1e3c82b..16c5a23a2a 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -150,12 +150,12 @@ func (p *PHPThread) Pin(pointer any) { // Thread retrieves a PHP thread by its index. // Returns nil and false if the system is not running or no thread exists at the given index. -func Thread(index int) (*PHPThread, bool) { +func Thread(index uint) (*PHPThread, bool) { if !isRunning { return nil, false } - if index >= len(phpThreads) { + if index >= uint(len(phpThreads)) { return nil, false } From 52e19326b6182b58218ab0c65a310652b51e35c3 Mon Sep 17 00:00:00 2001 From: Kevin Dunglas Date: Sat, 18 Jul 2026 17:19:01 +0200 Subject: [PATCH 4/5] fix: drop duplicate frankenphp_thread_index, unused and colliding with the existing static helper --- frankenphp.c | 4 ---- frankenphp.h | 2 -- 2 files changed, 6 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index c0a65dbeae..823ba11c1c 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1761,10 +1761,6 @@ bool frankenphp_new_php_thread(uintptr_t thread_index) { return true; } -uintptr_t frankenphp_thread_index(void) { - return thread_index; -} - /* Use global variables to store CLI arguments to prevent useless allocations */ static char *cli_script; static int cli_argc; diff --git a/frankenphp.h b/frankenphp.h index 789b5fb71e..db32a82fe0 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -190,8 +190,6 @@ frankenphp_config frankenphp_get_config(); int frankenphp_new_main_thread(int num_threads); bool frankenphp_new_php_thread(uintptr_t thread_index); -uintptr_t frankenphp_thread_index(void); - bool frankenphp_shutdown_dummy_request(void); void frankenphp_update_local_thread_context(bool is_worker); From 5133cea49f8002ebb2c63fe7d5c66035180c4f95 Mon Sep 17 00:00:00 2001 From: Kevin Dunglas Date: Sat, 18 Jul 2026 17:46:01 +0200 Subject: [PATCH 5/5] docs: mark the new thread API as experimental --- frankenphp.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index 16c5a23a2a..a27679edfa 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -131,24 +131,24 @@ type PHPConfig struct { ZendMaxExecutionTimers bool } -// PHPThread exposes a PHP thread's request context. +// EXPERIMENTAL: PHPThread exposes a PHP thread's request context. type PHPThread struct { Request *http.Request thread *phpThread } -// IsRequestDone determines whether the request associated with the PHPThread has been closed. +// EXPERIMENTAL: IsRequestDone determines whether the request associated with the PHPThread has been closed. func (p *PHPThread) IsRequestDone() bool { return p.thread.frankenPHPContext().isDone } -// Pin pins a Go object, preventing it from being moved or freed by the garbage +// EXPERIMENTAL: Pin pins a Go object, preventing it from being moved or freed by the garbage // collector until the Pinner.Unpin method has been called. func (p *PHPThread) Pin(pointer any) { p.thread.Pin(pointer) } -// Thread retrieves a PHP thread by its index. +// EXPERIMENTAL: Thread retrieves a PHP thread by its index. // Returns nil and false if the system is not running or no thread exists at the given index. func Thread(index uint) (*PHPThread, bool) { if !isRunning {