Files
frankenphp/phpmainthread_test.go
Marc 1d74b2caa8 feat: define domain specific workers in php_server and php blocks (#1509)
* add module (php_server directive) based workers

* refactor moduleID to uintptr for faster comparisons

* let workers inherit environment variables and root from php_server

* caddy can shift FrankenPHPModules in memory for some godforsaken reason, can't rely on them staying the same

* remove debugging statement

* fix tests

* refactor moduleID to uint64 for faster comparisons

* actually allow multiple workers per script filename

* remove logging

* utility function

* reuse existing worker with same filename and environment when calling newWorker with a filepath that already has a suitable worker, simply add number of threads

* no cleanup happens between tests, so restore old global worker overwriting logic

* add test, use getWorker(ForContext) function in frankenphp.go as well

* bring error on second global worker with the same filename again

* refactor to using name instead of moduleID

* nicer name

* nicer name

* add more tests

* remove test case already covered by previous test

* revert back to single variable, moduleIDs no longer relevant

* update comment

* figure out the worker to use in FrankenPHPModule::ServeHTTP

* add caddy/config_tests, add --retry 5 to download

* add caddy/config_tests

* sum up logic a bit, put worker thread addition into moduleWorkers parsing, before workers are actually created

* implement suggestions as far as possible

* fixup

* remove tags

* feat: download the mostly static binary when possible (#1467)

* feat: download the mostly static binary when possible

* cs

* docs: remove wildcard matcher from root directive (#1513)

* docs: update README with additional documentation links

Add link to classic mode, efficiently serving large static files and monitoring FrankenPHP

Signed-off-by: Romain Bastide <romain.bastide@orange.com>

* ci: combine dependabot updates for one group to 1 pull-request

* feat: compatibility with libphp.dylib on macOS

* feat: upgrade to Caddy 2.10

* feat: upgrade to Caddy 2.10

* chore: run prettier

* fix: build-static.sh consecutive builds (#1496)

* fix consecutive builds

* use minor version in PHP_VERSION

* install jq in centos container

* fix "arm64" download arch for spc binary

* jq is not available as a rpm download

* linter

* specify php 8.4 default

specify 8.4 so we manually switch to 8.5 when we make sure it works
allows to run without jq installed

* Apply suggestions from code review

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

---------

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* chore: update Go and toolchain version (#1526)

* apply suggestions one be one - scriptpath only

* generate unique worker names by filename and number

* support worker config from embedded apps

* rename back to make sure we don't accidentally add FrankenPHPApp workers to the slice

* fix test after changing error message

* use 🧩 for module workers

* use 🌍 for global workers :)

* revert 1c414cebbc

* revert 4cc8893ced

* apply suggestions

* add dynamic config loading test of module worker

* fix test

* minor changes

---------

Signed-off-by: Romain Bastide <romain.bastide@orange.com>
Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>
Co-authored-by: Indra Gunawan <hello@indra.my.id>
Co-authored-by: Romain Bastide <romain.bastide@orange.com>
2025-05-05 16:14:19 +02:00

284 lines
9.1 KiB
Go

package frankenphp
import (
"io"
"log/slog"
"math/rand/v2"
"net/http/httptest"
"path/filepath"
"runtime"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/dunglas/frankenphp/internal/phpheaders"
"github.com/stretchr/testify/assert"
)
var testDataPath, _ = filepath.Abs("./testdata")
func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) {
logger = slog.New(slog.NewTextHandler(io.Discard, nil))
_, 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(stateInactive))
drainPHPThreads()
assert.Nil(t, phpThreads)
}
func TestTransitionRegularThreadToWorkerThread(t *testing.T) {
workers = nil
logger = slog.New(slog.NewTextHandler(io.Discard, nil))
_, 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("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) {
workers = nil
logger = slog.New(slog.NewTextHandler(io.Discard, nil))
_, err := initPHPThreads(1, 1, nil)
assert.NoError(t, err)
firstWorker := getDummyWorker("transition-worker-1.php")
secondWorker := getDummyWorker("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) {
numThreads := 10
numRequestsPerThread := 100
isDone := atomic.Bool{}
wg := sync.WaitGroup{}
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, map[string]string{"ENV1": "foo"}, []string{}),
WithWorkers(worker2Name, worker2Path, 1, map[string]string{"ENV1": "foo"}, []string{}),
WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))),
))
// try all possible permutations of transition, transition every ms
transitions := allPossibleTransitions(worker1Path, worker2Path)
for i := 0; i < numThreads; i++ {
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 := 0; i < numThreads; i++ {
go func(i int) {
for j := 0; j < numRequestsPerThread; j++ {
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)
Shutdown()
}
// Note: this test is here since it would break compilation when put into the phpheaders package
func TestAllCommonHeadersAreCorrect(t *testing.T) {
fakeRequest := httptest.NewRequest("GET", "http://localhost", nil)
for header, phpHeader := range phpheaders.CommonRequestHeaders {
// verify that common and uncommon headers return the same result
expectedPHPHeader := phpheaders.GetUnCommonHeader(header)
assert.Equal(t, phpHeader+"\x00", expectedPHPHeader, "header is not well formed: "+phpHeader)
// net/http will capitalize lowercase headers, verify that headers are capitalized
fakeRequest.Header.Add(header, "foo")
_, ok := fakeRequest.Header[header]
assert.True(t, ok, "header is not correctly capitalized: "+header)
}
}
func TestFinishBootingAWorkerScript(t *testing.T) {
workers = nil
logger = slog.New(slog.NewTextHandler(io.Discard, nil))
_, err := initPHPThreads(1, 1, nil)
assert.NoError(t, err)
// boot the worker
worker := getDummyWorker("transition-worker-1.php")
convertToWorkerThread(phpThreads[0], worker)
phpThreads[0].state.waitFor(stateReady)
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 = make(map[string]*worker)
_, err1 := newWorker(workerOpt{fileName: "filename.php"})
_, err2 := newWorker(workerOpt{fileName: "filename.php"})
assert.NoError(t, err1)
assert.Error(t, err2, "two workers cannot have the same filename")
}
func TestReturnAnErrorIf2ModuleWorkersHaveTheSameName(t *testing.T) {
workers = make(map[string]*worker)
_, err1 := newWorker(workerOpt{fileName: "filename.php", name: "workername"})
_, err2 := newWorker(workerOpt{fileName: "filename2.php", name: "workername"})
assert.NoError(t, err1)
assert.Error(t, err2, "two workers cannot have the same name")
}
func getDummyWorker(fileName string) *worker {
if workers == nil {
workers = make(map[string]*worker)
}
worker, _ := newWorker(workerOpt{
fileName: testDataPath + "/" + fileName,
num: 1,
})
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(stateReserved) {
thread.boot()
}
},
func(thread *phpThread) { convertToWorkerThread(thread, workers[worker1Path]) },
convertToInactiveThread,
func(thread *phpThread) { convertToWorkerThread(thread, workers[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})
// 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})
}
func testThreadCalculation(t *testing.T, expectedNumThreads int, expectedMaxThreads int, o *opt) {
totalThreadCount, _, maxThreadCount, err := calculateMaxThreads(o)
assert.NoError(t, err, "no error should be returned")
assert.Equal(t, expectedNumThreads, totalThreadCount, "num_threads must be correct")
assert.Equal(t, expectedMaxThreads, maxThreadCount, "max_threads must be correct")
}
func testThreadCalculationError(t *testing.T, o *opt) {
_, _, _, err := calculateMaxThreads(o)
assert.Error(t, err, "configuration must error")
}