Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ jobs:
- name: Install gotestsum
run: go install gotest.tools/gotestsum@latest
- name: Run library tests
env:
FRANKENPHP_TEST_PID_NAMESPACE_WITH_SUDO: "1"
run: gotestsum -- -race ./...
- name: Run Caddy module tests
working-directory: caddy/
Expand Down
110 changes: 110 additions & 0 deletions cli_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package frankenphp_test

import (
"context"
"errors"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"time"

"github.com/dunglas/frankenphp"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -67,6 +70,113 @@ func TestExecuteScriptCLISignals(t *testing.T) {
assert.Contains(t, string(stdoutStderr), "ok")
}

// Regression test for https://github.com/php/frankenphp/issues/2558. When a
// C-created thread segfaults while FrankenPHP is PID 1, the process must
// exit instead of looping forever in Go's runtime.raisebadsignal.
func TestCThreadSegfaultAsPID1(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("PID namespaces are only available on Linux")
}
useSudo := os.Getenv("FRANKENPHP_TEST_PID_NAMESPACE_WITH_SUDO") == "1"
testCLIPath, err := filepath.Abs("internal/testcli/testcli")
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(testCLIPath); err != nil {
if useSudo {
t.Fatalf("internal/testcli/testcli must be compiled for the PID namespace test: %v", err)
}
t.Skip("internal/testcli/testcli has not been compiled, run `cd internal/testcli/ && go build`")
}
unsharePath, err := exec.LookPath("unshare")
if err != nil {
if useSudo {
t.Fatal("unshare is required for the PID namespace test")
}
t.Skip("unshare is not available")
}
truePath, err := exec.LookPath("true")
if err != nil {
t.Fatal("true is required for the PID namespace test probe")
}

command := unsharePath
args := []string{
"--user",
"--map-root-user",
"--pid",
"--fork",
"--kill-child=KILL",
testCLIPath,
"--segfault",
}
rootlessOutput, rootlessErr := runPIDNamespaceProbe(
unsharePath,
"--user",
"--map-root-user",
"--pid",
"--fork",
"--kill-child=KILL",
truePath,
)
if rootlessErr != nil {
if !useSudo {
t.Skipf("unprivileged PID namespaces are not available: %v: %s", rootlessErr, rootlessOutput)
}

sudoPath, sudoErr := exec.LookPath("sudo")
if sudoErr != nil {
t.Fatal("sudo was requested for the PID namespace test but was not found")
}
sudoOutput, sudoErr := runPIDNamespaceProbe(
sudoPath,
"-n",
unsharePath,
"--pid",
"--fork",
"--kill-child=KILL",
truePath,
)
if sudoErr != nil {
t.Fatalf("failed to create a PID namespace with sudo: %v: %s", sudoErr, sudoOutput)
}
command = sudoPath
args = []string{
"-n",
unsharePath,
"--pid",
"--fork",
"--kill-child=KILL",
testCLIPath,
"--segfault",
}
}

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, command, args...)
output, err := cmd.CombinedOutput()
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
t.Fatalf("FrankenPHP did not exit after the PHP thread segfaulted: %s", output)
}

var exitError *exec.ExitError
if !errors.As(err, &exitError) {
t.Fatalf("expected FrankenPHP to exit with an error, got %v: %s", err, output)
}
assert.Equal(t, 2, exitError.ExitCode(), "output: %s", output)
}

func runPIDNamespaceProbe(command string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
output, err := exec.CommandContext(ctx, command, args...).CombinedOutput()
if ctx.Err() != nil {
return output, ctx.Err()
}
return output, err
}

func ExampleExecuteScriptCLI() {
if len(os.Args) <= 1 {
log.Println("Usage: my-program script.php")
Expand Down
39 changes: 39 additions & 0 deletions frankenphp.c
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,46 @@ static void frankenphp_fill_cli_signal_set(sigset_t *s) {
#endif
}

#if defined(__linux__)
/* Linux ignores default-action signals re-raised by PID 1 from inside its
* namespace. Go's runtime preserves handlers installed before it starts and
* forwards synchronous faults from C-created threads to them, so this turns a
* PHP-thread SIGSEGV into a process exit instead of the raisebadsignal loop.
* See https://github.com/php/frankenphp/issues/2558 and
* https://github.com/golang/go/issues/59569. */
static void frankenphp_pid1_sigsegv_handler(int sig, siginfo_t *info,
void *context) {
(void)sig;
(void)info;
(void)context;
_exit(2);
}

static void frankenphp_install_pid1_sigsegv_handler(void) {
if (getpid() != 1) {
return;
}

struct sigaction previous;
if (sigaction(SIGSEGV, NULL, &previous) != 0 ||
previous.sa_handler != SIG_DFL) {
return;
}

struct sigaction action;
memset(&action, 0, sizeof(action));
action.sa_sigaction = frankenphp_pid1_sigsegv_handler;
sigemptyset(&action.sa_mask);
action.sa_flags = SA_SIGINFO | SA_ONSTACK;
sigaction(SIGSEGV, &action, NULL);
}
#endif

__attribute__((constructor)) static void frankenphp_libpreinit(void) {
#if defined(__linux__)
frankenphp_install_pid1_sigsegv_handler();
#endif

sigset_t set;
frankenphp_fill_cli_signal_set(&set);
/* Single-threaded at this point (constructors run before Go's runtime),
Expand Down
5 changes: 5 additions & 0 deletions internal/testcli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import (
)

func main() {
if len(os.Args) == 2 && os.Args[1] == "--segfault" {
triggerSIGSEGVOnCThread()
os.Exit(3)
}

if len(os.Args) <= 1 {
log.Println("Usage: testcli script.php")
os.Exit(1)
Expand Down
27 changes: 27 additions & 0 deletions internal/testcli/segfault_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//go:build linux

package main

/*
#include <pthread.h>
#include <unistd.h>

static void *segfault(void *unused) {
(void)unused;
*(volatile int *)0 = 1;
return NULL;
}

static void trigger_sigsegv_on_c_thread(void) {
pthread_t thread;
if (pthread_create(&thread, NULL, segfault, NULL) != 0) {
_exit(3);
}
pthread_join(thread, NULL);
}
*/
import "C"

func triggerSIGSEGVOnCThread() {
C.trigger_sigsegv_on_c_thread()
}
5 changes: 5 additions & 0 deletions internal/testcli/segfault_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
//go:build !linux

package main

func triggerSIGSEGVOnCThread() {}
Loading