Files
frankenphp/phpmainthread_test.go
Raphael Coeffic 91c553f3d9 feat: add support for structured logging with the frankenphp_log() PHP function (#1979)
As discussed in https://github.com/php/frankenphp/discussions/1961,
there is no real way to pass a severity/level to any log handler offered
by PHP that would make it to the FrankenPHP layer. This new function
allows applications embedding FrankenPHP to integrate PHP logging into
the application itself, thus offering a more streamlined experience.

---------

Co-authored-by: Quentin Burgess <qutn.burgess@gmail.com>
Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>
2025-12-15 16:10:35 +01:00

313 lines
9.4 KiB
Go

package frankenphp
import (
"io"
"math/rand/v2"
"net/http/httptest"
"path/filepath"
"runtime"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/dunglas/frankenphp/internal/state"
"github.com/stretchr/testify/assert"
)
var testDataPath, _ = filepath.Abs("./testdata")
func setupGlobals(t *testing.T) {
t.Helper()
t.Cleanup(Shutdown)
resetGlobals()
}
func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) {
_, err := initPHPThreads(1, 1, nil) // boot 1 thread
assert.NoError(t, err)
assert.Len(t, phpThreads, 1)
assert.Equal(t, 0, phpThreads[0].threadIndex)
assert.True(t, phpThreads[0].state.Is(state.Inactive))
drainPHPThreads()
assert.Nil(t, phpThreads)
}
func TestTransitionRegularThreadToWorkerThread(t *testing.T) {
setupGlobals(t)
_, err := initPHPThreads(1, 1, nil)
assert.NoError(t, err)
// transition to regular thread
convertToRegularThread(phpThreads[0])
assert.IsType(t, &regularThread{}, phpThreads[0].handler)
// transition to worker thread
worker := getDummyWorker(t, "transition-worker-1.php")
convertToWorkerThread(phpThreads[0], worker)
assert.IsType(t, &workerThread{}, phpThreads[0].handler)
assert.Len(t, worker.threads, 1)
// transition back to inactive thread
convertToInactiveThread(phpThreads[0])
assert.IsType(t, &inactiveThread{}, phpThreads[0].handler)
assert.Len(t, worker.threads, 0)
drainPHPThreads()
assert.Nil(t, phpThreads)
}
func TestTransitionAThreadBetween2DifferentWorkers(t *testing.T) {
setupGlobals(t)
_, err := initPHPThreads(1, 1, nil)
assert.NoError(t, err)
firstWorker := getDummyWorker(t, "transition-worker-1.php")
secondWorker := getDummyWorker(t, "transition-worker-2.php")
// convert to first worker thread
convertToWorkerThread(phpThreads[0], firstWorker)
firstHandler := phpThreads[0].handler.(*workerThread)
assert.Same(t, firstWorker, firstHandler.worker)
assert.Len(t, firstWorker.threads, 1)
assert.Len(t, secondWorker.threads, 0)
// convert to second worker thread
convertToWorkerThread(phpThreads[0], secondWorker)
secondHandler := phpThreads[0].handler.(*workerThread)
assert.Same(t, secondWorker, secondHandler.worker)
assert.Len(t, firstWorker.threads, 0)
assert.Len(t, secondWorker.threads, 1)
drainPHPThreads()
assert.Nil(t, phpThreads)
}
// try all possible handler transitions
// takes around 200ms and is supposed to force race conditions
func TestTransitionThreadsWhileDoingRequests(t *testing.T) {
t.Cleanup(Shutdown)
var (
isDone atomic.Bool
wg sync.WaitGroup
)
numThreads := 10
numRequestsPerThread := 100
worker1Path := testDataPath + "/transition-worker-1.php"
worker1Name := "worker-1"
worker2Path := testDataPath + "/transition-worker-2.php"
worker2Name := "worker-2"
assert.NoError(t, Init(
WithNumThreads(numThreads),
WithWorkers(worker1Name, worker1Path, 1,
WithWorkerEnv(map[string]string{"ENV1": "foo"}),
WithWorkerWatchMode([]string{}),
WithWorkerMaxFailures(0),
),
WithWorkers(worker2Name, worker2Path, 1,
WithWorkerEnv(map[string]string{"ENV1": "foo"}),
WithWorkerWatchMode([]string{}),
WithWorkerMaxFailures(0),
),
))
// try all possible permutations of transition, transition every ms
transitions := allPossibleTransitions(worker1Path, worker2Path)
for i := range numThreads {
go func(thread *phpThread, start int) {
for {
for j := start; j < len(transitions); j++ {
if isDone.Load() {
return
}
transitions[j](thread)
time.Sleep(time.Millisecond)
}
start = 0
}
}(phpThreads[i], i)
}
// randomly do requests to the 3 endpoints
wg.Add(numThreads)
for i := range numThreads {
go func(i int) {
for range numRequestsPerThread {
switch rand.IntN(3) {
case 0:
assertRequestBody(t, "http://localhost/transition-worker-1.php", "Hello from worker 1")
case 1:
assertRequestBody(t, "http://localhost/transition-worker-2.php", "Hello from worker 2")
case 2:
assertRequestBody(t, "http://localhost/transition-regular.php", "Hello from regular thread")
}
}
wg.Done()
}(i)
}
// we are finished as soon as all 1000 requests are done
wg.Wait()
isDone.Store(true)
}
func TestFinishBootingAWorkerScript(t *testing.T) {
setupGlobals(t)
_, err := initPHPThreads(1, 1, nil)
assert.NoError(t, err)
// boot the worker
worker := getDummyWorker(t, "transition-worker-1.php")
convertToWorkerThread(phpThreads[0], worker)
phpThreads[0].state.WaitFor(state.Ready)
assert.NotNil(t, phpThreads[0].handler.(*workerThread).dummyContext)
assert.Nil(t, phpThreads[0].handler.(*workerThread).workerContext)
assert.False(
t,
phpThreads[0].handler.(*workerThread).isBootingScript,
"isBootingScript should be false after the worker thread is ready",
)
drainPHPThreads()
assert.Nil(t, phpThreads)
}
func TestReturnAnErrorIf2WorkersHaveTheSameFileName(t *testing.T) {
workers = []*worker{}
w, err1 := newWorker(workerOpt{fileName: testDataPath + "/index.php"})
workers = append(workers, w)
_, err2 := newWorker(workerOpt{fileName: testDataPath + "/index.php"})
assert.NoError(t, err1)
assert.Error(t, err2, "two workers cannot have the same filename")
}
func TestReturnAnErrorIf2ModuleWorkersHaveTheSameName(t *testing.T) {
workers = []*worker{}
w, err1 := newWorker(workerOpt{fileName: testDataPath + "/index.php", name: "workername"})
workers = append(workers, w)
_, err2 := newWorker(workerOpt{fileName: testDataPath + "/hello.php", name: "workername"})
assert.NoError(t, err1)
assert.Error(t, err2, "two workers cannot have the same name")
}
func getDummyWorker(t *testing.T, fileName string) *worker {
t.Helper()
if workers == nil {
workers = []*worker{}
}
worker, _ := newWorker(workerOpt{
fileName: testDataPath + "/" + fileName,
num: 1,
})
workers = append(workers, worker)
return worker
}
func assertRequestBody(t *testing.T, url string, expected string) {
r := httptest.NewRequest("GET", url, nil)
w := httptest.NewRecorder()
req, err := NewRequestWithContext(r, WithRequestDocumentRoot(testDataPath, false))
assert.NoError(t, err)
err = ServeHTTP(w, req)
assert.NoError(t, err)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, expected, string(body))
}
// create a mix of possible transitions of workers and regular threads
func allPossibleTransitions(worker1Path string, worker2Path string) []func(*phpThread) {
return []func(*phpThread){
convertToRegularThread,
func(thread *phpThread) { thread.shutdown() },
func(thread *phpThread) {
if thread.state.Is(state.Reserved) {
thread.boot()
}
},
func(thread *phpThread) { convertToWorkerThread(thread, getWorkerByPath(worker1Path)) },
convertToInactiveThread,
func(thread *phpThread) { convertToWorkerThread(thread, getWorkerByPath(worker2Path)) },
convertToInactiveThread,
}
}
func TestCorrectThreadCalculation(t *testing.T) {
maxProcs := runtime.GOMAXPROCS(0) * 2
oneWorkerThread := []workerOpt{{num: 1}}
// default values
testThreadCalculation(t, maxProcs, maxProcs, &opt{})
testThreadCalculation(t, maxProcs, maxProcs, &opt{workers: oneWorkerThread})
// num_threads is set
testThreadCalculation(t, 1, 1, &opt{numThreads: 1})
testThreadCalculation(t, 2, 2, &opt{numThreads: 2, workers: oneWorkerThread})
// max_threads is set
testThreadCalculation(t, 1, 10, &opt{maxThreads: 10})
testThreadCalculation(t, 2, 10, &opt{maxThreads: 10, workers: oneWorkerThread})
testThreadCalculation(t, 5, 10, &opt{numThreads: 5, maxThreads: 10, workers: oneWorkerThread})
// automatic max_threads
testThreadCalculation(t, 1, -1, &opt{maxThreads: -1})
testThreadCalculation(t, 2, -1, &opt{maxThreads: -1, workers: oneWorkerThread})
testThreadCalculation(t, 2, -1, &opt{numThreads: 2, maxThreads: -1})
// max_threads should be thread minimum + sum of worker max_threads
testThreadCalculation(t, 2, 6, &opt{workers: []workerOpt{{num: 1, maxThreads: 5}}})
testThreadCalculation(t, 6, 9, &opt{workers: []workerOpt{{num: 1, maxThreads: 4}, {num: 4, maxThreads: 4}}})
testThreadCalculation(t, 10, 14, &opt{numThreads: 10, workers: []workerOpt{{num: 1, maxThreads: 4}, {num: 3, maxThreads: 4}}})
// max_threads should remain equal to overall max_threads
testThreadCalculation(t, 2, 5, &opt{maxThreads: 5, workers: []workerOpt{{num: 1, maxThreads: 3}}})
testThreadCalculation(t, 3, 5, &opt{maxThreads: 5, workers: []workerOpt{{num: 1, maxThreads: 4}, {num: 1, maxThreads: 4}}})
// not enough num threads
testThreadCalculationError(t, &opt{numThreads: 1, workers: oneWorkerThread})
testThreadCalculationError(t, &opt{numThreads: 1, maxThreads: 1, workers: oneWorkerThread})
// not enough max_threads
testThreadCalculationError(t, &opt{numThreads: 2, maxThreads: 1})
testThreadCalculationError(t, &opt{maxThreads: 1, workers: oneWorkerThread})
// worker max_threads is bigger than overall max_threads
testThreadCalculationError(t, &opt{maxThreads: 5, workers: []workerOpt{{num: 1, maxThreads: 10}}})
// worker max_threads is smaller than num_threads
testThreadCalculationError(t, &opt{workers: []workerOpt{{num: 3, maxThreads: 2}}})
}
func testThreadCalculation(t *testing.T, expectedNumThreads int, expectedMaxThreads int, o *opt) {
t.Helper()
_, err := calculateMaxThreads(o)
assert.NoError(t, err, "no error should be returned")
assert.Equal(t, expectedNumThreads, o.numThreads, "num_threads must be correct")
assert.Equal(t, expectedMaxThreads, o.maxThreads, "max_threads must be correct")
}
func testThreadCalculationError(t *testing.T, o *opt) {
t.Helper()
_, err := calculateMaxThreads(o)
assert.Error(t, err, "configuration must error")
}