mirror of
https://github.com/php/frankenphp.git
synced 2026-03-24 00:52:11 +01:00
This patch brings hot reloading capabilities to PHP apps: in development, the browser will automatically refresh the page when any source file changes! It's similar to HMR in JavaScript. It is built on top of [the watcher mechanism](https://frankenphp.dev/docs/config/#watching-for-file-changes) and of the [Mercure](https://frankenphp.dev/docs/mercure/) integration. Each time a watched file is modified, a Mercure update is sent, giving the ability to the client to reload the page, or part of the page (assets, images...). Here is an example implementation: ```caddyfile root ./public mercure { subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} anonymous } php_server { hot_reload } ``` ```php <?php header('Content-Type: text/html'); ?> <!DOCTYPE html> <html lang="en"> <head> <title>Test</title> <script> const es = new EventSource('<?=$_SERVER['FRANKENPHP_HOT_RELOAD']?>'); es.onmessage = () => location.reload(); </script> </head> <body> Hello ``` I plan to create a helper JS library to handle more advanced cases (reloading CSS, JS, etc), similar to [HotWire Spark](https://github.com/hotwired/spark). Be sure to attend my SymfonyCon to learn more! There is still room for improvement: - Provide an option to only trigger the update without reloading the worker for some files (ex, images, JS, CSS...) - Support classic mode (currently, only the worker mode is supported) - Don't reload all workers when only the files used by one change However, this PR is working as-is and can be merged as a first step. This patch heavily refactors the watcher module. Maybe it will be possible to extract it as a standalone library at some point (would be useful to add a similar feature but not tight to PHP as a Caddy module). --------- Signed-off-by: Kévin Dunglas <kevin@dunglas.fr> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
315 lines
9.5 KiB
Go
315 lines
9.5 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/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, ®ularThread{}, 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),
|
|
),
|
|
WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))),
|
|
))
|
|
|
|
// 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")
|
|
}
|