From 5a3e0e0f2796e1fa91bcbafb8235a7a00b7f1a46 Mon Sep 17 00:00:00 2001 From: henderkes Date: Sun, 13 Sep 2026 20:38:47 +0200 Subject: [PATCH 1/4] Run Linux PHP CLI on a native thread with Go signals blocked Build FrankenPHP as a Go archive hosted by C. Invoke Go's initializer with the original arguments for musl compatibility, retain Go extension setup, and execute PHP CLI on the original thread while Go threads block CLI signals. Preserve the Caddy server entry point in the same executable. Wire the native launcher into Linux Docker, static and xcaddy builds. Cover external signals, Go callbacks, CLI arguments and exit codes, embedded scripts, and graceful server shutdown in regression tests. --- .github/workflows/tests.yaml | 6 +- .gitignore | 1 + CONTRIBUTING.md | 12 +- Dockerfile | 4 +- build-native.sh | 5 + build-static.sh | 3 + caddy/internal/nativetest/extension.c | 25 +++ caddy/internal/nativetest/extension.h | 3 + caddy/internal/nativetest/main.go | 50 +++++ caddy/native.go | 11 + caddy/native_cli_test.go | 263 ++++++++++++++++++++++++ cli_native.go | 30 +++ docs/compile.md | 42 +++- frankenphp.c | 8 + frankenphp.h | 2 + internal/extgen/integration_test.go | 4 + internal/nativebuild/entrypoint/init.ld | 14 ++ internal/nativebuild/entrypoint/main.c | 78 +++++++ internal/nativebuild/main.go | 258 +++++++++++++++++++++++ internal/nativebuild/main_test.go | 48 +++++ native-go.sh | 10 + 21 files changed, 867 insertions(+), 10 deletions(-) create mode 100755 build-native.sh create mode 100644 caddy/internal/nativetest/extension.c create mode 100644 caddy/internal/nativetest/extension.h create mode 100644 caddy/internal/nativetest/main.go create mode 100644 caddy/native.go create mode 100644 caddy/native_cli_test.go create mode 100644 cli_native.go create mode 100644 internal/nativebuild/entrypoint/init.ld create mode 100644 internal/nativebuild/entrypoint/main.c create mode 100644 internal/nativebuild/main.go create mode 100644 internal/nativebuild/main_test.go create mode 100755 native-go.sh diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 8b0759f7a6..d93720e1f6 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -37,6 +37,7 @@ jobs: GOMAXPROCS: 10 LIBRARY_PATH: ${{ github.workspace }}/watcher/target/lib GOFLAGS: "-tags=nobadger,nomysql,nopgx" + FRANKENPHP_NATIVE_TEST_BINARY: ${{ github.workspace }}/caddy/internal/nativetest/nativetest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -57,6 +58,9 @@ jobs: - name: Build testcli binary working-directory: internal/testcli/ run: go build + - name: Build native CLI with a Go extension + working-directory: caddy/internal/nativetest/ + run: ../../../build-native.sh -o nativetest - name: Install gotestsum run: go install gotest.tools/gotestsum@latest - name: Run library tests @@ -69,7 +73,7 @@ jobs: run: go test -fuzz FuzzRequest -fuzztime 20s - name: Build the server working-directory: caddy/frankenphp/ - run: go build + run: ../../build-native.sh - name: Start the server working-directory: testdata/ run: sudo ../caddy/frankenphp/frankenphp start diff --git a/.gitignore b/.gitignore index 0a849e1f21..ab786da498 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /profiles/worker.pgo /caddy/frankenphp/frankenphp.exe /caddy/frankenphp/public +/caddy/internal/nativetest/nativetest /dist /github_conf /internal/testserver/testserver diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 555333767a..a8790404ac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,10 +45,20 @@ Build Caddy with the FrankenPHP Caddy module: ```console cd caddy/frankenphp/ -go build -tags nobadger,nomysql,nopgx +../../build-native.sh cd ../../ ``` +Use `go build -tags nobadger,nomysql,nopgx` instead on macOS and FreeBSD. +To exercise native CLI signal handling and Go extension callbacks on Linux: + +```console +cd caddy +../build-native.sh -o internal/nativetest/nativetest ./internal/nativetest +FRANKENPHP_NATIVE_TEST_BINARY="$PWD/internal/nativetest/nativetest" ../go.sh test -race -run '^TestNative' . +cd .. +``` + Run the Caddy with the FrankenPHP Caddy module: ```console diff --git a/Dockerfile b/Dockerfile index 6697209d38..553c7204a3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,6 +61,7 @@ COPY --from=golang-base /usr/local/go /usr/local/go ENV PATH=/usr/local/go/bin:$PATH ENV GOTOOLCHAIN=local +ENV XCADDY_WHICH_GO=/go/src/app/native-go.sh # This is required to link the FrankenPHP binary to the PHP binary RUN apt-get update && \ @@ -121,8 +122,7 @@ ENV CGO_CPPFLAGS=$PHP_CPPFLAGS ENV CGO_LDFLAGS="-L/usr/local/lib -lssl -lcrypto -lreadline -largon2 -lcurl -lonig -lz $PHP_LDFLAGS" WORKDIR /go/src/app/caddy/frankenphp -RUN GOBIN=/usr/local/bin \ - ../../go.sh install -ldflags "-w -s -X 'github.com/caddyserver/caddy/v2.CustomVersion=FrankenPHP $FRANKENPHP_VERSION PHP $PHP_VERSION Caddy' -X 'github.com/caddyserver/caddy/v2.CustomBinaryName=frankenphp' -X 'github.com/caddyserver/caddy/v2/modules/caddyhttp.ServerHeader=FrankenPHP Caddy'" -buildvcs=true && \ +RUN ../../build-native.sh -o /usr/local/bin/frankenphp -ldflags "-w -s -X 'github.com/caddyserver/caddy/v2.CustomVersion=FrankenPHP $FRANKENPHP_VERSION PHP $PHP_VERSION Caddy' -X 'github.com/caddyserver/caddy/v2.CustomBinaryName=frankenphp' -X 'github.com/caddyserver/caddy/v2/modules/caddyhttp.ServerHeader=FrankenPHP Caddy'" -buildvcs=true && \ setcap cap_net_bind_service=+ep /usr/local/bin/frankenphp && \ cp Caddyfile /etc/frankenphp/Caddyfile && \ frankenphp version && \ diff --git a/build-native.sh b/build-native.sh new file mode 100755 index 0000000000..cbe44d928a --- /dev/null +++ b/build-native.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec "$script_dir/go.sh" run "$script_dir/internal/nativebuild/main.go" "$@" diff --git a/build-static.sh b/build-static.sh index 798ff19fb2..7a06820d7f 100755 --- a/build-static.sh +++ b/build-static.sh @@ -217,6 +217,9 @@ done # shellcheck disable=SC2086 ${spcCommand} download --with-php="${PHP_VERSION}" --for-extensions="${PHP_EXTENSIONS}" --for-libs="${PHP_EXTENSION_LIBS}" ${SPC_OPT_DOWNLOAD_ARGS} export FRANKENPHP_SOURCE_PATH="${CURRENT_DIR}" +if [ "${os}" = "linux" ]; then + export XCADDY_WHICH_GO="${CURRENT_DIR}/native-go.sh" +fi # shellcheck disable=SC2086,SC2090 ${spcCommand} build --enable-zts --build-embed --build-frankenphp ${SPC_OPT_BUILD_ARGS} "${PHP_EXTENSIONS}" --with-libs="${PHP_EXTENSION_LIBS}" diff --git a/caddy/internal/nativetest/extension.c b/caddy/internal/nativetest/extension.c new file mode 100644 index 0000000000..6a284216bb --- /dev/null +++ b/caddy/internal/nativetest/extension.c @@ -0,0 +1,25 @@ +#include "extension.h" +#include "_cgo_export.h" + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_native_cli_test, 0, 0, IS_LONG, + 0) +ZEND_END_ARG_INFO() + +PHP_FUNCTION(frankenphp_native_test) { + ZEND_PARSE_PARAMETERS_NONE(); + RETURN_LONG(go_frankenphp_native_test()); +} + +static const zend_function_entry native_cli_test_functions[] = { + PHP_FE(frankenphp_native_test, arginfo_native_cli_test) PHP_FE_END}; + +zend_module_entry native_cli_test_module = {STANDARD_MODULE_HEADER, + "native_cli_test", + native_cli_test_functions, + NULL, + NULL, + NULL, + NULL, + NULL, + "1.0.0", + STANDARD_MODULE_PROPERTIES}; diff --git a/caddy/internal/nativetest/extension.h b/caddy/internal/nativetest/extension.h new file mode 100644 index 0000000000..6545f337c3 --- /dev/null +++ b/caddy/internal/nativetest/extension.h @@ -0,0 +1,3 @@ +#include + +extern zend_module_entry native_cli_test_module; diff --git a/caddy/internal/nativetest/main.go b/caddy/internal/nativetest/main.go new file mode 100644 index 0000000000..efab105fce --- /dev/null +++ b/caddy/internal/nativetest/main.go @@ -0,0 +1,50 @@ +package main + +// #cgo linux CFLAGS: -D_GNU_SOURCE +// #include "extension.h" +import "C" +import ( + "os" + "runtime" + "sync" + "unsafe" + + caddycmd "github.com/caddyserver/caddy/v2/cmd" + _ "github.com/caddyserver/caddy/v2/modules/standard" + "github.com/dunglas/frankenphp" + _ "github.com/dunglas/frankenphp/caddy" +) + +func init() { + frankenphp.RegisterExtension(unsafe.Pointer(&C.native_cli_test_module)) + if path := os.Getenv("FRANKENPHP_TEST_EMBEDDED_PATH"); path != "" { + frankenphp.EmbeddedAppPath = path + } +} + +//export go_frankenphp_native_test +func go_frankenphp_native_test() C.int { + // Exercise Go initialization, callbacks, and creation of additional OS + // threads, which must inherit the CLI signal mask too. + var ready, done sync.WaitGroup + ready.Add(8) + done.Add(8) + release := make(chan struct{}) + for range 8 { + go func() { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + defer done.Done() + ready.Done() + <-release + }() + } + ready.Wait() + close(release) + done.Wait() + return 42 +} + +func main() { + caddycmd.Main() +} diff --git a/caddy/native.go b/caddy/native.go new file mode 100644 index 0000000000..6b15d5986a --- /dev/null +++ b/caddy/native.go @@ -0,0 +1,11 @@ +//go:build linux + +package caddy + +import "C" +import caddycmd "github.com/caddyserver/caddy/v2/cmd" + +//export go_frankenphp_caddy_main +func go_frankenphp_caddy_main() { + caddycmd.Main() +} diff --git a/caddy/native_cli_test.go b/caddy/native_cli_test.go new file mode 100644 index 0000000000..5870e17ac0 --- /dev/null +++ b/caddy/native_cli_test.go @@ -0,0 +1,263 @@ +//go:build linux + +package caddy + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func nativeBinary(t *testing.T) string { + t.Helper() + path := os.Getenv("FRANKENPHP_NATIVE_TEST_BINARY") + if path == "" { + t.Skip("build internal/nativetest with build-native.sh and set FRANKENPHP_NATIVE_TEST_BINARY") + } + return path +} + +func nativeCommand(t *testing.T, args ...string) *exec.Cmd { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + t.Cleanup(cancel) + return exec.CommandContext(ctx, nativeBinary(t), args...) +} + +func TestNativeCLI(t *testing.T) { + t.Run("GoExtensionAndArguments", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "script with spaces.php") + require.NoError(t, os.WriteFile(path, []byte(` PHP_VERSION_ID, 'argv' => $argv]), "\n"; +exit(7); +`), 0o600)) + cmd := nativeCommand(t, "php-cli", path, "two words", "--literal") + out, err := cmd.CombinedOutput() + var exit *exec.ExitError + require.ErrorAs(t, err, &exit, "%s", out) + require.Equal(t, 7, exit.ExitCode(), "%s", out) + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + require.Len(t, lines, 2) + require.Equal(t, "42", lines[0]) + var result struct { + Version int `json:"version"` + Args []string `json:"argv"` + } + require.NoError(t, json.Unmarshal([]byte(lines[1]), &result)) + want := []string{path, "two words", "--literal"} + if result.Version < 80600 { + // The older CLI emulation includes the executable in $argv. + want = append([]string{cmd.Args[0]}, want...) + } + require.Equal(t, want, result.Args) + }) + + t.Run("Eval", func(t *testing.T) { + out, err := nativeCommand(t, "php-cli", "-r", `echo frankenphp_native_test();`).CombinedOutput() + require.NoError(t, err, "%s", out) + require.Equal(t, "42", string(out)) + }) + + t.Run("EmbeddedScript", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "embedded.php"), []byte(` +import "C" +import ( + "os" + "path/filepath" + "strings" +) + +// go_frankenphp_cli_init is called by the native launcher after Go's package +// initializers have registered extensions, and before PHP starts on the C thread. +// It returns an optional replacement for the script path, owned by the caller. +// +//export go_frankenphp_cli_init +func go_frankenphp_cli_init() *C.char { + registerExtensions() + + if EmbeddedAppPath != "" && len(os.Args) > 2 { + script := os.Args[2] + if !strings.HasPrefix(script, "-") && strings.HasSuffix(script, ".php") { + if _, err := os.Stat(script); err != nil { + return C.CString(filepath.Join(EmbeddedAppPath, script)) + } + } + } + return nil +} diff --git a/docs/compile.md b/docs/compile.md index 01b999756c..cb648b572c 100644 --- a/docs/compile.md +++ b/docs/compile.md @@ -98,11 +98,25 @@ Alternatively, these features can be disabled by passing build tags to the Go co You can now build the final binary. +On Linux, use the native launcher when running PHP CLI programs with `pcntl`. +It starts Go as a C archive with CLI signals blocked on Go threads, then runs +`php-cli` on the original C thread. Go extensions are initialized and remain +callable. Caddy's server commands use the same executable. + ### Using xcaddy The recommended way is to use [xcaddy](https://github.com/caddyserver/xcaddy) to compile FrankenPHP. `xcaddy` also makes it easy to add [custom Caddy modules](https://caddyserver.com/docs/modules/) and FrankenPHP extensions: +For the native launcher on Linux, first download the FrankenPHP sources and +select the Go wrapper. On macOS and FreeBSD, omit the `export` command: + +```console +curl -L https://github.com/php/frankenphp/archive/refs/heads/main.tar.gz | tar xz +cd frankenphp-main +export XCADDY_WHICH_GO="$PWD/native-go.sh" +``` + ```console CGO_ENABLED=1 \ XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \ @@ -110,14 +124,12 @@ CGO_CFLAGS=$(php-config --includes) \ CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \ xcaddy build \ --output frankenphp \ - --with github.com/dunglas/frankenphp/caddy \ + --with github.com/dunglas/frankenphp="$PWD" \ + --with github.com/dunglas/frankenphp/caddy="$PWD/caddy" \ --with github.com/dunglas/mercure/caddy \ --with github.com/dunglas/vulcain/caddy \ --with github.com/dunglas/caddy-cbrotli # Add extra Caddy modules and FrankenPHP extensions here - # optionally, if you would like to compile from your frankenphp sources: - # --with github.com/dunglas/frankenphp=$(pwd) \ - # --with github.com/dunglas/frankenphp/caddy=$(pwd)/caddy ``` @@ -133,10 +145,28 @@ xcaddy build \ ### Without xcaddy -Alternatively, it's possible to compile FrankenPHP without `xcaddy` by using the `go` command directly: +Alternatively, build the native launcher on Linux without `xcaddy`: ```console curl -L https://github.com/php/frankenphp/archive/refs/heads/main.tar.gz | tar xz cd frankenphp-main/caddy/frankenphp -CGO_CFLAGS=$(php-config --includes) CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" go build -tags=nobadger,nomysql,nopgx +../../build-native.sh -o frankenphp ``` + +The script accepts Go build flags, including `-tags`, `-ldflags` and `-o`. +Set `PHP_CONFIG` if `php-config` is not in your path. For xcaddy, use the +`CGO_CFLAGS` and `CGO_LDFLAGS` variables shown above. Linux Docker and static +builds select the native launcher automatically. + +The native build requires an ELF linker supporting GNU linker scripts (GNU ld +or LLVM lld). Its linker script lets C `main()` supply Go's initializer with +the original arguments on both glibc and musl. + +On macOS and FreeBSD, continue to use +`CGO_CFLAGS=$(php-config --includes) CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" go build -tags=nobadger,nomysql,nopgx`. + +The native launcher reserves SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGUSR1, SIGUSR2 +and SIGALRM for PHP CLI. Go extensions must not call `signal.Notify` for these +signals in CLI mode, because that enables delivery on Go threads again. +Go's runtime signals retain their normal behavior. This does not make +`pcntl_fork()` followed by Go extension calls safe: the Go runtime is still running. diff --git a/frankenphp.c b/frankenphp.c index 2378ac8ff6..5aa5c81b2c 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1773,6 +1773,14 @@ static void *execute_script_cli(void *arg) { #endif } +/* The native launcher calls this after returning from Go initialization, so PHP + * runs on a C-owned thread with the caller's original signal mask. */ +int frankenphp_execute_script_cli_native(int argc, char **argv) { + cli_exec_args_t args = { + .script = argv[0], .argc = argc, .argv = argv, .eval = false}; + return (intptr_t)execute_script_cli(&args); +} + int frankenphp_execute_script_cli(char *script, int argc, char **argv, bool eval) { pthread_t thread; diff --git a/frankenphp.h b/frankenphp.h index 99ac0ab7ec..a5f5ac84e3 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -166,6 +166,8 @@ bool frankenphp_new_php_thread(uintptr_t thread_index); bool frankenphp_shutdown_dummy_request(void); void frankenphp_update_local_thread_context(bool is_worker); +int frankenphp_execute_script_cli_native(int argc, char **argv); + int frankenphp_execute_script_cli(char *script, int argc, char **argv, bool eval); diff --git a/internal/extgen/integration_test.go b/internal/extgen/integration_test.go index 2180214498..2a3bc84463 100644 --- a/internal/extgen/integration_test.go +++ b/internal/extgen/integration_test.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" @@ -186,6 +187,9 @@ func (s *IntegrationTestSuite) compileFrankenPHP(moduleDir string) (string, erro "CGO_LDFLAGS="+cgoLdflags, fmt.Sprintf("XCADDY_GO_BUILD_FLAGS=-ldflags='-w -s' -tags=nobadger,nomysql,nopgx,nowatcher"), ) + if runtime.GOOS == "linux" { + cmd.Env = append(cmd.Env, "XCADDY_WHICH_GO="+filepath.Join(projectRoot, "native-go.sh")) + } cmd.Dir = s.tempDir diff --git a/internal/nativebuild/entrypoint/init.ld b/internal/nativebuild/entrypoint/init.ld new file mode 100644 index 0000000000..f399a43111 --- /dev/null +++ b/internal/nativebuild/entrypoint/init.ld @@ -0,0 +1,14 @@ +/* Go's library initializer requires argc/argv, which musl's constructor loop + * does not supply (https://github.com/golang/go/issues/13492). Save only the Go + * object's initializer for main() to call; leave C/C++ constructors alone. */ +SECTIONS +{ + .frankenphp_go_init : + { + __frankenphp_go_init_start = .; + KEEP(*frankenphp.a:go.o(.init_array)) + __frankenphp_go_init_end = .; + } +} +INSERT BEFORE .init_array; +ASSERT(SIZEOF(.frankenphp_go_init) > 0, "Go archive initializer not found") diff --git a/internal/nativebuild/entrypoint/main.c b/internal/nativebuild/entrypoint/main.c new file mode 100644 index 0000000000..914ab07696 --- /dev/null +++ b/internal/nativebuild/entrypoint/main.c @@ -0,0 +1,78 @@ +#include +#include +#include +#include +#include + +extern char *go_frankenphp_cli_init(void); +extern void go_frankenphp_caddy_main(void); +extern int frankenphp_execute_script_cli_native(int argc, char **argv); + +typedef void (*go_initializer)(int, char **, char **); +extern go_initializer __frankenphp_go_init_start[]; +extern go_initializer __frankenphp_go_init_end[]; + +static sigset_t original_mask; + +/* Run before the Go c-archive constructor. In library mode Go honors these + * inherited masks on its own threads, including SIGTERM and SIGINT. */ +__attribute__((constructor(101))) static void prepare_signals(void) { + sigset_t signals; + sigemptyset(&signals); + sigaddset(&signals, SIGHUP); + sigaddset(&signals, SIGINT); + sigaddset(&signals, SIGQUIT); + sigaddset(&signals, SIGTERM); + sigaddset(&signals, SIGUSR1); + sigaddset(&signals, SIGUSR2); + sigaddset(&signals, SIGALRM); + if (pthread_sigmask(SIG_BLOCK, &signals, &original_mask) != 0) { + static const char message[] = "frankenphp: cannot initialize signal mask\n"; + (void)write(STDERR_FILENO, message, sizeof(message) - 1); + _exit(1); + } +} + +int main(int argc, char **argv, char **envp) { + /* musl does not pass argc/argv to ELF constructors, but Go requires them. + * The linker keeps Go's initializer out of the automatic constructor list. + * All other C/C++ constructors have already run when we enter main. */ + for (go_initializer *init = __frankenphp_go_init_start; + init < __frankenphp_go_init_end; init++) { + (*init)(argc, argv, envp); + } + + if (argc > 1 && strcmp(argv[1], "php-cli") == 0) { + /* This cgo call waits for all Go package initializers, preserving extension + * registration and embedded-app extraction without entering Caddy's CLI. */ + char *script = go_frankenphp_cli_init(); + char **php_argv = malloc((size_t)argc * sizeof(*php_argv)); + if (php_argv == NULL) { + free(script); + return 1; + } + php_argv[0] = argv[0]; + for (int i = 2; i < argc; i++) { + php_argv[i - 1] = argv[i]; + } + php_argv[argc - 1] = NULL; + if (script != NULL) { + php_argv[1] = script; + } + if (pthread_sigmask(SIG_SETMASK, &original_mask, NULL) != 0) { + free(php_argv); + free(script); + return 1; + } + int status = frankenphp_execute_script_cli_native(argc - 1, php_argv); + free(php_argv); + free(script); + return status; + } + + if (pthread_sigmask(SIG_SETMASK, &original_mask, NULL) != 0) { + return 1; + } + go_frankenphp_caddy_main(); + return 0; +} diff --git a/internal/nativebuild/main.go b/internal/nativebuild/main.go new file mode 100644 index 0000000000..d16c2c3ac4 --- /dev/null +++ b/internal/nativebuild/main.go @@ -0,0 +1,258 @@ +// nativebuild builds a Caddy main package as a Go archive and links the native +// FrankenPHP entry point. Arguments are the usual go build flags and package. +package main + +import ( + "bytes" + _ "embed" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" +) + +//go:embed entrypoint/main.c +var entrypoint []byte + +//go:embed entrypoint/init.ld +var initScript []byte + +func main() { + if err := build(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "nativebuild:", err) + os.Exit(1) + } +} + +func build(args []string) error { + output := "frankenphp" + var buildArgs, externalFlags []string + for i := 0; i < len(args); i++ { + flag, value, hasValue := strings.Cut(args[i], "=") + if flag == "-o" || flag == "-buildmode" || flag == "-ldflags" { + if !hasValue { + i++ + if i == len(args) { + return fmt.Errorf("%s requires a value", flag) + } + value = args[i] + } + } + switch { + case flag == "-o": + output = value + case flag == "-buildmode": + if value != "pie" && value != "exe" && value != "default" { + return fmt.Errorf("unsupported native build mode: %s", value) + } + case flag == "-ldflags": + flags, err := linkerFlags(value) + if err != nil { + return err + } + externalFlags = flags + buildArgs = append(buildArgs, "-ldflags", value) + default: + buildArgs = append(buildArgs, args[i]) + } + } + + goCommand := os.Getenv("FRANKENPHP_GO") + if goCommand == "" { + goCommand = "go" + } + envJSON, err := exec.Command(goCommand, "env", "-json", "CC", "GOGCCFLAGS", "CGO_CFLAGS", "CGO_CPPFLAGS", "CGO_LDFLAGS", "GOOS", "PKG_CONFIG").Output() + if err != nil { + return err + } + var env map[string]string + if err := json.Unmarshal(envJSON, &env); err != nil { + return err + } + if env["GOOS"] != "linux" { + return errors.New("the native launcher currently requires Linux") + } + cc, err := splitFlags(env["CC"]) + if err != nil || len(cc) == 0 { + return fmt.Errorf("invalid CC: %q", env["CC"]) + } + ccFlags, err := compilerFlags(cc, env["GOGCCFLAGS"]) + if err != nil { + return err + } + for _, name := range []string{"CGO_CPPFLAGS", "CGO_CFLAGS"} { + flags, err := splitFlags(env[name]) + if err != nil { + return err + } + ccFlags = append(ccFlags, flags...) + } + envLDFlags, err := splitFlags(env["CGO_LDFLAGS"]) + if err != nil { + return err + } + + list := exec.Command(goCommand, append([]string{"list", "-deps", "-json"}, buildArgs...)...) + list.Stderr = os.Stderr + packages, err := list.Output() + if err != nil { + return err + } + var ldflags, pkgConfigs []string + decoder := json.NewDecoder(bytes.NewReader(packages)) + for { + var pkg struct { + CgoLDFLAGS []string + CgoPkgConfig []string + } + if err := decoder.Decode(&pkg); err == io.EOF { + break + } else if err != nil { + return err + } + ldflags = append(ldflags, pkg.CgoLDFLAGS...) + pkgConfigs = append(pkgConfigs, pkg.CgoPkgConfig...) + } + if len(pkgConfigs) > 0 { + flags, err := exec.Command(env["PKG_CONFIG"], append([]string{"--libs"}, pkgConfigs...)...).Output() + if err != nil { + return err + } + parsed, err := splitFlags(string(flags)) + if err != nil { + return err + } + ldflags = append(ldflags, parsed...) + } + ldflags = append(ldflags, envLDFlags...) + + dir, err := os.MkdirTemp("", "frankenphp-native-build-") + if err != nil { + return err + } + defer os.RemoveAll(dir) + archive := filepath.Join(dir, "frankenphp.a") + if err := run(goCommand, append([]string{"build", "-buildmode=c-archive", "-o", archive}, buildArgs...)...); err != nil { + return err + } + source := filepath.Join(dir, "main.c") + if err := os.WriteFile(source, entrypoint, 0o600); err != nil { + return err + } + script := filepath.Join(dir, "init.ld") + if err := os.WriteFile(script, initScript, 0o600); err != nil { + return err + } + linkArgs := append(cc[1:], ccFlags...) + // Go's archive is position independent. Keep the final executable PIE, as + // in the existing static build; -static-pie in the linker flags overrides it. + linkArgs = append(linkArgs, "-pie", "-Wl,-T,"+script, "-o", output, source, archive) + linkArgs = append(linkArgs, ldflags...) + linkArgs = append(linkArgs, externalFlags...) + return run(cc[0], linkArgs...) +} + +func compilerFlags(cc []string, value string) ([]string, error) { + flags, err := splitFlags(value) + if err != nil { + return nil, err + } + // go env removes three arguments from its "CC -I ." probe. With a + // multiword CC, its remaining arguments can leak into GOGCCFLAGS. + probe := append(slices.Clone(cc), "-I", ".")[3:] + if len(probe) > 0 && len(flags) >= len(probe) && slices.Equal(flags[:len(probe)], probe) { + flags = flags[len(probe):] + } + return flags, nil +} + +// Go does not invoke the external linker in c-archive mode. Forward its flags +// ourselves, preserving static PHP symbol exports, stack size and link options. +func linkerFlags(value string) ([]string, error) { + flags, err := splitFlags(strings.TrimPrefix(value, "all=")) + if err != nil { + return nil, err + } + var external, strip []string + for i := 0; i < len(flags); i++ { + flag, value, hasValue := strings.Cut(flags[i], "=") + if !hasValue || value == "true" { + if flag == "-s" { + strip = append(strip, "-s") + } else if flag == "-w" { + strip = append(strip, "-Wl,--strip-debug") + } + } + if flag != "-extldflags" { + continue + } + if !hasValue { + i++ + if i == len(flags) { + return nil, errors.New("-extldflags requires a value") + } + value = flags[i] + } + external, err = splitFlags(value) + if err != nil { + return nil, err + } + } + return append(external, strip...), nil +} + +func run(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s: %w", cmd.String(), err) + } + return nil +} + +// splitFlags accepts the quoting used by CC, CGO_LDFLAGS and pkg-config without +// evaluating shell expansions or running a shell. +func splitFlags(s string) ([]string, error) { + var args []string + var arg strings.Builder + var quote rune + escaped, started := false, false + for _, r := range s { + switch { + case escaped: + arg.WriteRune(r) + escaped = false + case r == '\\' && quote != '\'': + escaped, started = true, true + case quote != 0: + if r == quote { + quote = 0 + } else { + arg.WriteRune(r) + } + case r == '\'' || r == '"': + quote, started = r, true + case r == ' ' || r == '\t' || r == '\n': + if started { + args = append(args, arg.String()) + arg.Reset() + started = false + } + default: + arg.WriteRune(r) + started = true + } + } + if quote != 0 || escaped { + return nil, fmt.Errorf("unterminated quoting in flags: %q", s) + } + if started { + args = append(args, arg.String()) + } + return args, nil +} diff --git a/internal/nativebuild/main_test.go b/internal/nativebuild/main_test.go new file mode 100644 index 0000000000..9d49ef4e17 --- /dev/null +++ b/internal/nativebuild/main_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestCompilerFlags(t *testing.T) { + for _, test := range []struct { + cc []string + flags string + }{ + {[]string{"cc"}, "-fPIC -m64"}, + {[]string{"cc", "-D_GNU_SOURCE"}, ". -fPIC -m64"}, + {[]string{"zig", "cc", "-target", "x86_64-linux-musl"}, "x86_64-linux-musl -I . -fPIC -m64"}, + {[]string{"cc", "-D_GNU_SOURCE"}, "-fPIC -m64"}, + } { + got, err := compilerFlags(test.cc, test.flags) + if err != nil || !reflect.DeepEqual(got, []string{"-fPIC", "-m64"}) { + t.Errorf("compilerFlags(%q, %q) = %q, %v", test.cc, test.flags, got, err) + } + } +} + +func TestLinkerFlags(t *testing.T) { + for _, test := range []struct { + name, flags string + want []string + }{ + {"dynamic", `-w -s -X 'main.Version=FrankenPHP dev'`, []string{"-Wl,--strip-debug", "-s"}}, + {"static", `-linkmode=external -extldflags '-static-pie -Wl,-z,stack-size=0x80000 -Wl,--export-dynamic-symbol=php_printf' -s`, []string{"-static-pie", "-Wl,-z,stack-size=0x80000", "-Wl,--export-dynamic-symbol=php_printf", "-s"}}, + {"quoted path", `-extldflags="-L'/path with spaces' -Wl,-rpath,/lib/php"`, []string{"-L/path with spaces", "-Wl,-rpath,/lib/php"}}, + {"all packages", `all=-extldflags=-pie`, []string{"-pie"}}, + {"debug", `-s=false -w=false`, nil}, + } { + t.Run(test.name, func(t *testing.T) { + got, err := linkerFlags(test.flags) + if err != nil || !reflect.DeepEqual(got, test.want) { + t.Fatalf("linkerFlags(%q) = %q, %v; want %q", test.flags, got, err, test.want) + } + }) + } + for _, invalid := range []string{`-extldflags`, `-extldflags 'unterminated`, `-extldflags="'unterminated"`} { + if _, err := linkerFlags(invalid); err == nil { + t.Errorf("linkerFlags(%q) should reject invalid flags", invalid) + } + } +} diff --git a/native-go.sh b/native-go.sh new file mode 100755 index 0000000000..002e8cc6d6 --- /dev/null +++ b/native-go.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# Set XCADDY_WHICH_GO to this script to build with the native Linux entry point. +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +if [ "${1:-}" = build ]; then + shift + exec "${FRANKENPHP_GO:-go}" run "$script_dir/internal/nativebuild/main.go" "$@" +fi +exec "${FRANKENPHP_GO:-go}" "$@" From 456aaa8c6c2e255498413e917fef5c97b1dad2ae Mon Sep 17 00:00:00 2001 From: henderkes Date: Sun, 13 Sep 2026 21:50:19 +0200 Subject: [PATCH 2/4] Fix native launcher lint and platform builds --- .github/workflows/docker.yaml | 3 ++- .github/workflows/tests.yaml | 2 ++ alpine.Dockerfile | 4 ++-- build-native.sh | 2 +- caddy/internal/nativetest/main.go | 2 ++ caddy/native_cli_test.go | 2 +- internal/nativebuild/main.go | 15 ++++++++------- native-go.sh | 2 +- 8 files changed, 19 insertions(+), 13 deletions(-) diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 3c285d677f..90a0dc48d4 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -213,11 +213,12 @@ jobs: - name: Run tests if: ${{ !fromJson(needs.prepare.outputs.push) }} run: | + # PHP's PIE flags require the external linker for pure Go race tests too. # TODO: remove "containerimage.config.digest" fallback once all runners use buildx v0.18+ # which replaced it with "containerimage.digest" and "containerimage.descriptor" docker run --platform="${PLATFORM}" --rm \ "$(jq -r ".\"builder-${VARIANT}\" | .\"containerimage.config.digest\" // .\"containerimage.digest\"" <<< "${METADATA}")" \ - sh -c "./go.sh test ${RACE} -v $(./go.sh list ./... | grep -v github.com/dunglas/frankenphp/internal/testext | grep -v github.com/dunglas/frankenphp/internal/extgen | tr '\n' ' ') && cd caddy && ../go.sh test ${RACE} -v ./..." + sh -c "./go.sh test ${RACE} -ldflags=-linkmode=external -v $(./go.sh list ./... | grep -v github.com/dunglas/frankenphp/internal/testext | grep -v github.com/dunglas/frankenphp/internal/extgen | tr '\n' ' ') && cd caddy && ../go.sh test ${RACE} -ldflags=-linkmode=external -v ./..." env: METADATA: ${{ steps.build.outputs.metadata }} PLATFORM: ${{ matrix.platform }} diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index d93720e1f6..5172b60d22 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -48,6 +48,8 @@ jobs: php-version: ${{ matrix.php-versions }} - name: Install e-dant/watcher uses: ./.github/actions/watcher + - name: Install PHP development libraries + run: sudo apt-get update && sudo apt-get install -y libkrb5-dev libsodium-dev libargon2-dev - name: Reinstall libbrotli-dev # TODO: remove this workaround when fixed upstream run: sudo apt-get install --reinstall -y libbrotli-dev diff --git a/alpine.Dockerfile b/alpine.Dockerfile index e9e0fb3176..9e1af5f0d9 100644 --- a/alpine.Dockerfile +++ b/alpine.Dockerfile @@ -61,6 +61,7 @@ COPY --link --from=golang-base /usr/local/go /usr/local/go ENV PATH=/usr/local/go/bin:$PATH ENV GOTOOLCHAIN=local +ENV XCADDY_WHICH_GO=/go/src/app/native-go.sh # hadolint ignore=SC2086 RUN apk add --no-cache --virtual .build-deps \ @@ -125,8 +126,7 @@ ENV CGO_CPPFLAGS=$PHP_CPPFLAGS ENV CGO_LDFLAGS="-lssl -lcrypto -lreadline -largon2 -lcurl -lonig -lz $PHP_LDFLAGS" WORKDIR /go/src/app/caddy/frankenphp -RUN GOBIN=/usr/local/bin \ - ../../go.sh install -ldflags "-w -s -extldflags '-Wl,-z,stack-size=0x80000' -X 'github.com/caddyserver/caddy/v2.CustomVersion=FrankenPHP $FRANKENPHP_VERSION PHP $PHP_VERSION Caddy' -X 'github.com/caddyserver/caddy/v2.CustomBinaryName=frankenphp' -X 'github.com/caddyserver/caddy/v2/modules/caddyhttp.ServerHeader=FrankenPHP Caddy'" -buildvcs=true && \ +RUN ../../build-native.sh -o /usr/local/bin/frankenphp -ldflags "-w -s -extldflags '-Wl,-z,stack-size=0x80000' -X 'github.com/caddyserver/caddy/v2.CustomVersion=FrankenPHP $FRANKENPHP_VERSION PHP $PHP_VERSION Caddy' -X 'github.com/caddyserver/caddy/v2.CustomBinaryName=frankenphp' -X 'github.com/caddyserver/caddy/v2/modules/caddyhttp.ServerHeader=FrankenPHP Caddy'" -buildvcs=true && \ setcap cap_net_bind_service=+ep /usr/local/bin/frankenphp && \ ([ -n "${COMPRESS}" ] && upx --best /usr/local/bin/frankenphp || true) && \ frankenphp version && \ diff --git a/build-native.sh b/build-native.sh index cbe44d928a..9dc96be5f3 100755 --- a/build-native.sh +++ b/build-native.sh @@ -1,5 +1,5 @@ #!/bin/sh set -eu -script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) exec "$script_dir/go.sh" run "$script_dir/internal/nativebuild/main.go" "$@" diff --git a/caddy/internal/nativetest/main.go b/caddy/internal/nativetest/main.go index efab105fce..2f9349a149 100644 --- a/caddy/internal/nativetest/main.go +++ b/caddy/internal/nativetest/main.go @@ -1,3 +1,5 @@ +//go:build linux + package main // #cgo linux CFLAGS: -D_GNU_SOURCE diff --git a/caddy/native_cli_test.go b/caddy/native_cli_test.go index 5870e17ac0..f089ca9694 100644 --- a/caddy/native_cli_test.go +++ b/caddy/native_cli_test.go @@ -252,7 +252,7 @@ http://%s { if err != nil { return false } - defer response.Body.Close() + defer func() { require.NoError(t, response.Body.Close()) }() var body bytes.Buffer _, err = body.ReadFrom(response.Body) return err == nil && response.StatusCode == http.StatusOK && body.String() == "42" diff --git a/internal/nativebuild/main.go b/internal/nativebuild/main.go index d16c2c3ac4..0804ac3b2f 100644 --- a/internal/nativebuild/main.go +++ b/internal/nativebuild/main.go @@ -43,14 +43,14 @@ func build(args []string) error { value = args[i] } } - switch { - case flag == "-o": + switch flag { + case "-o": output = value - case flag == "-buildmode": + case "-buildmode": if value != "pie" && value != "exe" && value != "default" { return fmt.Errorf("unsupported native build mode: %s", value) } - case flag == "-ldflags": + case "-ldflags": flags, err := linkerFlags(value) if err != nil { return err @@ -135,7 +135,7 @@ func build(args []string) error { if err != nil { return err } - defer os.RemoveAll(dir) + defer func() { _ = os.RemoveAll(dir) }() archive := filepath.Join(dir, "frankenphp.a") if err := run(goCommand, append([]string{"build", "-buildmode=c-archive", "-o", archive}, buildArgs...)...); err != nil { return err @@ -182,9 +182,10 @@ func linkerFlags(value string) ([]string, error) { for i := 0; i < len(flags); i++ { flag, value, hasValue := strings.Cut(flags[i], "=") if !hasValue || value == "true" { - if flag == "-s" { + switch flag { + case "-s": strip = append(strip, "-s") - } else if flag == "-w" { + case "-w": strip = append(strip, "-Wl,--strip-debug") } } diff --git a/native-go.sh b/native-go.sh index 002e8cc6d6..fecebdb6c4 100755 --- a/native-go.sh +++ b/native-go.sh @@ -2,7 +2,7 @@ # Set XCADDY_WHICH_GO to this script to build with the native Linux entry point. set -eu -script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) if [ "${1:-}" = build ]; then shift exec "${FRANKENPHP_GO:-go}" run "$script_dir/internal/nativebuild/main.go" "$@" From 743c5ebee7ada335cb365d565991d0fb92baa49d Mon Sep 17 00:00:00 2001 From: henderkes Date: Mon, 14 Sep 2026 08:14:01 +0200 Subject: [PATCH 3/4] Enable PCNTL functions in CI --- .github/actions/setup-php/action.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/setup-php/action.yaml b/.github/actions/setup-php/action.yaml index 9bc8c49208..d1cf8cbfb4 100644 --- a/.github/actions/setup-php/action.yaml +++ b/.github/actions/setup-php/action.yaml @@ -12,6 +12,7 @@ runs: with: php-version: ${{ inputs.php-version }} ini-file: development + ini-values: disable_functions= coverage: none tools: none env: From 6e955c9cf2009c7dd2ac763a0345222e0b662355 Mon Sep 17 00:00:00 2001 From: henderkes Date: Mon, 14 Sep 2026 08:30:58 +0200 Subject: [PATCH 4/4] Fix Docker CI platforms and watcher races --- .github/workflows/docker.yaml | 5 +++++ caddy/hotreload_test.go | 22 +++++++++++++++++----- docker-bake.hcl | 7 ++++--- internal/watcher/pattern.go | 7 ++++++- internal/watcher/pattern_test.go | 23 +++++++++++++++++++++++ internal/watcher/watcher.go | 1 + 6 files changed, 56 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 90a0dc48d4..ef0a549fbd 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -128,6 +128,11 @@ jobs: platform: linux/arm/v6 - variant: php-${{ needs.prepare.outputs.php85_version }}-bookworm platform: linux/arm/v6 + # PHP 8.5 Debian images are not published for 386. + - variant: php-${{ needs.prepare.outputs.php85_version }}-trixie + platform: linux/386 + - variant: php-${{ needs.prepare.outputs.php85_version }}-bookworm + platform: linux/386 steps: - name: Prepare id: prepare diff --git a/caddy/hotreload_test.go b/caddy/hotreload_test.go index 9eed620453..3220b4b178 100644 --- a/caddy/hotreload_test.go +++ b/caddy/hotreload_test.go @@ -53,11 +53,14 @@ func TestHotReload(t *testing.T) { } `, "caddyfile") - var connected, received sync.WaitGroup + var connected sync.WaitGroup + received := make(chan struct{}) connected.Add(1) - received.Go(func() { + go func() { + defer close(received) cx, cancel := context.WithCancel(t.Context()) + defer cancel() req, _ := http.NewRequest(http.MethodGet, "http://localhost:"+testPort+u, nil) req = req.WithContext(cx) resp := tester.AssertResponseCode(req, http.StatusOK) @@ -89,13 +92,22 @@ func TestHotReload(t *testing.T) { } require.NoError(t, resp.Body.Close()) - }) + }() connected.Wait() - require.NoError(t, os.WriteFile(indexFile, []byte("