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: diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 3c285d677f..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 @@ -213,11 +218,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 8b0759f7a6..5172b60d22 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: @@ -47,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 @@ -57,6 +60,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 +75,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/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 new file mode 100755 index 0000000000..9dc96be5f3 --- /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/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(" + +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..2f9349a149 --- /dev/null +++ b/caddy/internal/nativetest/main.go @@ -0,0 +1,52 @@ +//go:build linux + +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..f089ca9694 --- /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/docker-bake.hcl b/docker-bake.hcl index a972ca1176..6fde38a7aa 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -118,12 +118,13 @@ target "default" { "linux/arm/v6", "linux/arm/v7", "linux/arm64", - ] : [ + ] : compact([ "linux/amd64", - "linux/386", + # PHP 8.5 Debian images are not published for 386. + substr(php-version, 0, 3) == "8.5" ? "" : "linux/386", "linux/arm/v7", "linux/arm64" - ] + ]) tags = distinct(flatten( [for pv in php_version(php-version) : flatten([ LATEST ? tag("latest", os, pv, tgt) : [], 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..0804ac3b2f --- /dev/null +++ b/internal/nativebuild/main.go @@ -0,0 +1,259 @@ +// 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 flag { + case "-o": + output = value + case "-buildmode": + if value != "pie" && value != "exe" && value != "default" { + return fmt.Errorf("unsupported native build mode: %s", value) + } + case "-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 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 + } + 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" { + switch flag { + case "-s": + strip = append(strip, "-s") + case "-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/internal/watcher/pattern.go b/internal/watcher/pattern.go index 0f0bf3603e..0de69e95cc 100644 --- a/internal/watcher/pattern.go +++ b/internal/watcher/pattern.go @@ -18,6 +18,7 @@ type pattern struct { value string parsedValues []string events chan eventHolder + done <-chan struct{} failureCount int watcher *watcher.Watcher @@ -101,7 +102,11 @@ func (p *pattern) handle(event *watcher.Event) { } if p.allowReload(event) { - p.events <- eventHolder{p.patternGroup, event} + // Callbacks may still arrive while the native watcher is being closed. + select { + case p.events <- eventHolder{p.patternGroup, event}: + case <-p.done: + } } } diff --git a/internal/watcher/pattern_test.go b/internal/watcher/pattern_test.go index 8ee3907c7e..e7b1ef0436 100644 --- a/internal/watcher/pattern_test.go +++ b/internal/watcher/pattern_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/e-dant/watcher/watcher-go" "github.com/stretchr/testify/assert" @@ -355,6 +356,28 @@ func TestAnAssociatedEventTriggersTheWatcher(t *testing.T) { assert.Equal(t, e, (<-w.events).event) } +func TestEventAfterWatcherShutdownDoesNotBlock(t *testing.T) { + t.Parallel() + + w := newPattern(t, "/**/*.php") + w.events = make(chan eventHolder) + stop := make(chan struct{}) + w.done = stop + close(stop) + + returned := make(chan struct{}) + go func() { + defer close(returned) + w.handle(&watcher.Event{PathName: normalizePath(t, "/path/file.php")}) + }() + + select { + case <-returned: + case <-time.After(time.Second): + t.Fatal("watcher callback blocked after shutdown") + } +} + func relativeDir(t *testing.T, relativePath string) string { t.Helper() diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 61b5fb81e3..92d1f0f3de 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -145,6 +145,7 @@ func (g *globalWatcher) startWatching() error { for _, w := range g.watchers { w.events = g.events + w.done = g.stop w.startSession() } diff --git a/native-go.sh b/native-go.sh new file mode 100755 index 0000000000..fecebdb6c4 --- /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}" "$@"