From 784137f021689a9cd276659bc42ad93367cc816c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Fri, 16 Oct 2026 14:02:26 +0200 Subject: [PATCH 1/2] feat: make frankenphp_log() available in php-cli --- caddy/go.mod | 2 +- caddy/php-cli.go | 6 +++++- cli.go | 39 +++++++++++++++++++++++++++++++++++++-- cli_test.go | 24 +++++++++++++++++++++++- docs/logging.md | 3 +++ frankenphp.c | 39 +++++++++++++++++++++++++++++++-------- frankenphp.go | 6 ++++++ frankenphp.h | 3 +++ 8 files changed, 109 insertions(+), 13 deletions(-) diff --git a/caddy/go.mod b/caddy/go.mod index 74ead77b02..d5d2ec594a 100644 --- a/caddy/go.mod +++ b/caddy/go.mod @@ -17,6 +17,7 @@ require ( github.com/prometheus/client_golang v1.25.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.12.1 + go.uber.org/zap/exp v0.3.0 ) require ( @@ -184,7 +185,6 @@ require ( go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.28.0 // indirect - go.uber.org/zap/exp v0.3.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/crypto v0.58.0 // indirect golang.org/x/crypto/x509roots/fallback v0.0.0-20261009200856-99e4382b128e // indirect diff --git a/caddy/php-cli.go b/caddy/php-cli.go index 53b754a59f..ff90cc9cff 100644 --- a/caddy/php-cli.go +++ b/caddy/php-cli.go @@ -1,12 +1,15 @@ package caddy import ( + "log/slog" "os" "path/filepath" "strings" + "github.com/caddyserver/caddy/v2" caddycmd "github.com/caddyserver/caddy/v2/cmd" "github.com/dunglas/frankenphp" + "go.uber.org/zap/exp/zapslog" "github.com/spf13/cobra" ) @@ -35,7 +38,8 @@ func cmdPHPCLI(fs caddycmd.Flags) (int, error) { } } - status := frankenphp.ExecuteScriptCLI(args[0], args) + logger := slog.New(zapslog.NewHandler(caddy.Log().Core(), zapslog.WithName("frankenphp"))) + status := frankenphp.ExecuteScriptCLI(args[0], args, frankenphp.WithLogger(logger)) os.Exit(status) diff --git a/cli.go b/cli.go index a96153a14a..75a52b3173 100644 --- a/cli.go +++ b/cli.go @@ -2,11 +2,46 @@ package frankenphp // #include "frankenphp.h" import "C" -import "unsafe" +import ( + "context" + "fmt" + "log/slog" + "os" + "sync/atomic" + "unsafe" +) + +type cliLogger struct { + logger *slog.Logger + ctx context.Context +} + +// separate from globalLogger so a server running in the same process keeps its own +var cliLog atomic.Pointer[cliLogger] // ExecuteScriptCLI executes the PHP script passed as parameter. // It returns the exit status code of the script. -func ExecuteScriptCLI(script string, args []string) int { +// +// Only the WithLogger and WithContext options are supported, they configure frankenphp_log(). +func ExecuteScriptCLI(script string, args []string, opts ...Option) int { + o := &opt{} + for _, f := range opts { + if err := f(o); err != nil { + fmt.Fprintln(os.Stderr, err) + + return 1 + } + } + + l := &cliLogger{logger: o.logger, ctx: o.ctx} + if l.logger == nil { + l.logger = globalLogger + } + if l.ctx == nil { + l.ctx = globalCtx + } + cliLog.Store(l) + // Ensure extensions are registered before CLI execution registerExtensions() diff --git a/cli_test.go b/cli_test.go index 75acc22efe..9f2376b8d8 100644 --- a/cli_test.go +++ b/cli_test.go @@ -241,7 +241,6 @@ func TestExecuteCLIHTTPFunctionsUnavailable(t *testing.T) { {"frankenphp_handle_request", "static function () {}"}, {"headers_send", "103"}, {"mercure_publish", "'https://example.com/topic', 'test'"}, - {"frankenphp_log", "'CLI feature detection'"}, } { t.Run(tt.name, func(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) @@ -266,6 +265,29 @@ var_export(function_exists($function)); } } +func TestExecuteCLILog(t *testing.T) { + if _, err := os.Stat("internal/testcli/testcli"); err != nil { + t.Skip("internal/testcli/testcli has not been compiled, run `cd internal/testcli/ && go build`") + } + + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "internal/testcli/testcli", "-r", ` +frankenphp_log('hello', FRANKENPHP_LOG_LEVEL_WARN, ['k' => 'v']); +try { + frankenphp_log('invalid context', FRANKENPHP_LOG_LEVEL_ERROR, ['k' => new stdClass()]); +} catch (RuntimeException $e) { + echo 'caught'; +} +`) + cmd.WaitDelay = time.Second + output, err := cmd.CombinedOutput() + require.NoError(t, ctx.Err(), "output: %s", output) + require.NoError(t, err, "output: %s", output) + assert.Contains(t, string(output), "WARN hello k=v") + assert.Contains(t, string(output), "caught") +} + func TestExecuteCLINativeHTTPFunctions(t *testing.T) { if _, err := os.Stat("internal/testcli/testcli"); err != nil { t.Skip("internal/testcli/testcli has not been compiled, run `cd internal/testcli/ && go build`") diff --git a/docs/logging.md b/docs/logging.md index e8df467aa6..76e4947ba7 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -59,6 +59,9 @@ When viewing the logs (e.g., via `docker compose logs`), the output will appear {"level":"warn","ts":1704067200,"logger":"frankenphp","msg":"Memory usage high","current_usage":10485760,"peak_usage":12582912} ``` +`frankenphp_log()` is also available in `frankenphp php-cli`, where it writes to the standard error output. +`php-cli` doesn't load the `Caddyfile`, so its `log` directives don't apply there. + ## `error_log()` FrankenPHP also allows logging using the standard `error_log()` function. If the `$message_type` parameter is `4` (SAPI), diff --git a/frankenphp.c b/frankenphp.c index ea72e5ecca..4a61a10bb2 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1022,7 +1022,8 @@ PHP_FUNCTION(mercure_publish) { } } -PHP_FUNCTION(frankenphp_log) { +static void frankenphp_log_to(INTERNAL_FUNCTION_PARAMETERS, + uintptr_t thread_idx) { zend_string *message = NULL; zend_long level = 0; zval *context = NULL; @@ -1035,7 +1036,7 @@ PHP_FUNCTION(frankenphp_log) { ZEND_PARSE_PARAMETERS_END(); char *ret = NULL; - ret = go_log_attrs(frankenphp_thread_index(), message, level, context); + ret = go_log_attrs(thread_idx, message, level, context); if (ret != NULL) { zend_throw_exception(spl_ce_RuntimeException, ret, 0); free(ret); @@ -1043,6 +1044,18 @@ PHP_FUNCTION(frankenphp_log) { } } +PHP_FUNCTION(frankenphp_log) { + frankenphp_log_to(INTERNAL_FUNCTION_PARAM_PASSTHRU, + frankenphp_thread_index()); +} + +/* explicit index: in CLI, the TLS index and SG(server_context) may point to a + * server thread of the same process */ +static PHP_FUNCTION(frankenphp_cli_log) { + frankenphp_log_to(INTERNAL_FUNCTION_PARAM_PASSTHRU, + FRANKENPHP_CLI_THREAD_INDEX); +} + #if FRANKENPHP_OPCACHE_RESTART_HOOK static void frankenphp_opcache_restart_hook(int reason) { go_opcache_restart_scheduled(reason); @@ -1222,16 +1235,26 @@ static zend_module_entry frankenphp_module = { TOSTRING(FRANKENPHP_VERSION), STANDARD_MODULE_PROPERTIES}; +static const zend_function_entry frankenphp_cli_functions[] = { + ZEND_NAMED_FE(frankenphp_log, ZEND_FN(frankenphp_cli_log), + arginfo_frankenphp_log) ZEND_FE_END}; + +static PHP_MINIT_FUNCTION(frankenphp_cli) { + register_frankenphp_symbols(module_number); + + return SUCCESS; +} + /* same phpinfo section in CLI, without the server functions and hooks */ static zend_module_entry frankenphp_cli_module = { STANDARD_MODULE_HEADER, "frankenphp-cli", - NULL, /* function table */ - NULL, /* initialization */ - NULL, /* shutdown */ - NULL, /* request initialization */ - NULL, /* request shutdown */ - PHP_MINFO(frankenphp), /* information */ + frankenphp_cli_functions, /* function table */ + PHP_MINIT(frankenphp_cli), /* initialization */ + NULL, /* shutdown */ + NULL, /* request initialization */ + NULL, /* request shutdown */ + PHP_MINFO(frankenphp), /* information */ TOSTRING(FRANKENPHP_VERSION), STANDARD_MODULE_PROPERTIES}; diff --git a/frankenphp.go b/frankenphp.go index 229eb6b2a0..8fe9a39bd0 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -805,6 +805,12 @@ func go_read_cookies(threadIndex C.uintptr_t) *C.char { // getLogger returns the logger and context safely even if phpThreads have not been created yet func getLogger(threadIndex C.uintptr_t) (*slog.Logger, context.Context) { + if threadIndex == C.FRANKENPHP_CLI_THREAD_INDEX { + if l := cliLog.Load(); l != nil { + return l.logger, l.ctx + } + } + if threadIndex >= C.uintptr_t(len(phpThreads)) { return globalLogger, globalCtx } diff --git a/frankenphp.h b/frankenphp.h index fe38b13c76..e38613ec90 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -219,6 +219,9 @@ typedef enum { FRANKENPHP_MERCURE_UNSUPPORTED = 4, } frankenphp_mercure_status; +/* thread index of the php-cli thread, which has no Go phpThread */ +#define FRANKENPHP_CLI_THREAD_INDEX UINTPTR_MAX + void register_extensions(zend_module_entry **m, int len); #endif From 34eed1c6a5725d574172834f75abcab3a2aaae42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Fri, 16 Oct 2026 14:12:16 +0200 Subject: [PATCH 2/2] refactor: drop the dedicated CLI logger, php-cli never shares its process with a server --- cli.go | 21 ++++----------------- frankenphp.c | 20 +++----------------- frankenphp.go | 6 ------ frankenphp.h | 3 --- 4 files changed, 7 insertions(+), 43 deletions(-) diff --git a/cli.go b/cli.go index 75a52b3173..23d66b5db3 100644 --- a/cli.go +++ b/cli.go @@ -3,22 +3,11 @@ package frankenphp // #include "frankenphp.h" import "C" import ( - "context" "fmt" - "log/slog" "os" - "sync/atomic" "unsafe" ) -type cliLogger struct { - logger *slog.Logger - ctx context.Context -} - -// separate from globalLogger so a server running in the same process keeps its own -var cliLog atomic.Pointer[cliLogger] - // ExecuteScriptCLI executes the PHP script passed as parameter. // It returns the exit status code of the script. // @@ -33,14 +22,12 @@ func ExecuteScriptCLI(script string, args []string, opts ...Option) int { } } - l := &cliLogger{logger: o.logger, ctx: o.ctx} - if l.logger == nil { - l.logger = globalLogger + if o.ctx != nil { + globalCtx = o.ctx } - if l.ctx == nil { - l.ctx = globalCtx + if o.logger != nil { + globalLogger = o.logger } - cliLog.Store(l) // Ensure extensions are registered before CLI execution registerExtensions() diff --git a/frankenphp.c b/frankenphp.c index 4a61a10bb2..a5d2ae9c89 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1022,8 +1022,7 @@ PHP_FUNCTION(mercure_publish) { } } -static void frankenphp_log_to(INTERNAL_FUNCTION_PARAMETERS, - uintptr_t thread_idx) { +PHP_FUNCTION(frankenphp_log) { zend_string *message = NULL; zend_long level = 0; zval *context = NULL; @@ -1036,7 +1035,7 @@ static void frankenphp_log_to(INTERNAL_FUNCTION_PARAMETERS, ZEND_PARSE_PARAMETERS_END(); char *ret = NULL; - ret = go_log_attrs(thread_idx, message, level, context); + ret = go_log_attrs(frankenphp_thread_index(), message, level, context); if (ret != NULL) { zend_throw_exception(spl_ce_RuntimeException, ret, 0); free(ret); @@ -1044,18 +1043,6 @@ static void frankenphp_log_to(INTERNAL_FUNCTION_PARAMETERS, } } -PHP_FUNCTION(frankenphp_log) { - frankenphp_log_to(INTERNAL_FUNCTION_PARAM_PASSTHRU, - frankenphp_thread_index()); -} - -/* explicit index: in CLI, the TLS index and SG(server_context) may point to a - * server thread of the same process */ -static PHP_FUNCTION(frankenphp_cli_log) { - frankenphp_log_to(INTERNAL_FUNCTION_PARAM_PASSTHRU, - FRANKENPHP_CLI_THREAD_INDEX); -} - #if FRANKENPHP_OPCACHE_RESTART_HOOK static void frankenphp_opcache_restart_hook(int reason) { go_opcache_restart_scheduled(reason); @@ -1236,8 +1223,7 @@ static zend_module_entry frankenphp_module = { STANDARD_MODULE_PROPERTIES}; static const zend_function_entry frankenphp_cli_functions[] = { - ZEND_NAMED_FE(frankenphp_log, ZEND_FN(frankenphp_cli_log), - arginfo_frankenphp_log) ZEND_FE_END}; + ZEND_FE(frankenphp_log, arginfo_frankenphp_log) ZEND_FE_END}; static PHP_MINIT_FUNCTION(frankenphp_cli) { register_frankenphp_symbols(module_number); diff --git a/frankenphp.go b/frankenphp.go index 8fe9a39bd0..229eb6b2a0 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -805,12 +805,6 @@ func go_read_cookies(threadIndex C.uintptr_t) *C.char { // getLogger returns the logger and context safely even if phpThreads have not been created yet func getLogger(threadIndex C.uintptr_t) (*slog.Logger, context.Context) { - if threadIndex == C.FRANKENPHP_CLI_THREAD_INDEX { - if l := cliLog.Load(); l != nil { - return l.logger, l.ctx - } - } - if threadIndex >= C.uintptr_t(len(phpThreads)) { return globalLogger, globalCtx } diff --git a/frankenphp.h b/frankenphp.h index e38613ec90..fe38b13c76 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -219,9 +219,6 @@ typedef enum { FRANKENPHP_MERCURE_UNSUPPORTED = 4, } frankenphp_mercure_status; -/* thread index of the php-cli thread, which has no Go phpThread */ -#define FRANKENPHP_CLI_THREAD_INDEX UINTPTR_MAX - void register_extensions(zend_module_entry **m, int len); #endif