diff --git a/env.go b/env.go new file mode 100644 index 0000000000..f95c6fd138 --- /dev/null +++ b/env.go @@ -0,0 +1,84 @@ +package frankenphp + +// #include "frankenphp.h" +import "C" +import ( + "os" + "strings" + "unsafe" +) + +//export go_putenv +func go_putenv(str *C.char, length C.int) C.bool { + // Create a byte slice from C string with a specified length + s := C.GoBytes(unsafe.Pointer(str), length) + + // Convert byte slice to string + envString := string(s) + + // Check if '=' is present in the string + if key, val, found := strings.Cut(envString, "="); found { + if os.Setenv(key, val) != nil { + return false // Failure + } + } else { + // No '=', unset the environment variable + if os.Unsetenv(envString) != nil { + return false // Failure + } + } + + return true // Success +} + +//export go_getfullenv +func go_getfullenv(threadIndex C.uintptr_t) (*C.go_string, C.size_t) { + thread := phpThreads[threadIndex] + + env := os.Environ() + goStrings := make([]C.go_string, len(env)*2) + + for i, envVar := range env { + key, val, _ := strings.Cut(envVar, "=") + goStrings[i*2] = C.go_string{C.size_t(len(key)), thread.pinString(key)} + goStrings[i*2+1] = C.go_string{C.size_t(len(val)), thread.pinString(val)} + } + + value := unsafe.SliceData(goStrings) + thread.Pin(value) + + return value, C.size_t(len(env)) +} + +//export go_getenv +func go_getenv(threadIndex C.uintptr_t, name *C.go_string) (C.bool, *C.go_string) { + thread := phpThreads[threadIndex] + + // Create a byte slice from C string with a specified length + envName := C.GoStringN(name.data, C.int(name.len)) + + // Get the environment variable value + envValue, exists := os.LookupEnv(envName) + if !exists { + // Environment variable does not exist + return false, nil // Return 0 to indicate failure + } + + // Convert Go string to C string + value := &C.go_string{C.size_t(len(envValue)), thread.pinString(envValue)} + thread.Pin(value) + + return true, value // Return 1 to indicate success +} + +//export go_sapi_getenv +func go_sapi_getenv(threadIndex C.uintptr_t, name *C.go_string) *C.char { + envName := C.GoStringN(name.data, C.int(name.len)) + + envValue, exists := os.LookupEnv(envName) + if !exists { + return nil + } + + return phpThreads[threadIndex].pinCString(envValue) +} diff --git a/exponential_backoff.go b/exponential_backoff.go new file mode 100644 index 0000000000..359e2bd4fa --- /dev/null +++ b/exponential_backoff.go @@ -0,0 +1,60 @@ +package frankenphp + +import ( + "sync" + "time" +) + +const maxBackoff = 1 * time.Second +const minBackoff = 100 * time.Millisecond +const maxConsecutiveFailures = 6 + +type exponentialBackoff struct { + backoff time.Duration + failureCount int + mu sync.RWMutex + upFunc sync.Once +} + +func newExponentialBackoff() *exponentialBackoff { + return &exponentialBackoff{backoff: minBackoff} +} + +func (e *exponentialBackoff) reset() { + e.mu.Lock() + e.upFunc = sync.Once{} + wait := e.backoff * 2 + e.mu.Unlock() + go func() { + time.Sleep(wait) + e.mu.Lock() + defer e.mu.Unlock() + e.upFunc.Do(func() { + // if we come back to a stable state, reset the failure count + if e.backoff == minBackoff { + e.failureCount = 0 + } + + // earn back the backoff over time + if e.failureCount > 0 { + e.backoff = max(e.backoff/2, minBackoff) + } + }) + }() +} + +func (e *exponentialBackoff) trigger(onMaxFailures func(failureCount int)) { + e.mu.RLock() + e.upFunc.Do(func() { + if e.failureCount >= maxConsecutiveFailures { + onMaxFailures(e.failureCount) + } + e.failureCount += 1 + }) + wait := e.backoff + e.mu.RUnlock() + time.Sleep(wait) + e.mu.Lock() + e.backoff = min(e.backoff*2, maxBackoff) + e.mu.Unlock() +} diff --git a/frankenphp.c b/frankenphp.c index 661e41028e..c033366b60 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -243,7 +243,7 @@ PHP_FUNCTION(frankenphp_finish_request) { /* {{{ */ php_header(); if (ctx->has_active_request) { - go_frankenphp_finish_request(thread_index, false); + go_frankenphp_finish_php_request(thread_index); } ctx->finished = true; @@ -443,7 +443,7 @@ PHP_FUNCTION(frankenphp_handle_request) { frankenphp_worker_request_shutdown(); ctx->has_active_request = false; - go_frankenphp_finish_request(thread_index, true); + go_frankenphp_finish_worker_request(thread_index); RETURN_TRUE; } @@ -808,9 +808,9 @@ static void set_thread_name(char *thread_name) { } static void *php_thread(void *arg) { - char thread_name[16] = {0}; - snprintf(thread_name, 16, "php-%" PRIxPTR, (uintptr_t)arg); thread_index = (uintptr_t)arg; + char thread_name[16] = {0}; + snprintf(thread_name, 16, "php-%" PRIxPTR, thread_index); set_thread_name(thread_name); #ifdef ZTS @@ -820,7 +820,6 @@ static void *php_thread(void *arg) { ZEND_TSRMLS_CACHE_UPDATE(); #endif #endif - local_ctx = malloc(sizeof(frankenphp_server_context)); /* check if a default filter is set in php.ini and only filter if @@ -829,7 +828,22 @@ static void *php_thread(void *arg) { cfg_get_string("filter.default", &default_filter); should_filter_var = default_filter != NULL; - while (go_handle_request(thread_index)) { + go_frankenphp_on_thread_startup(thread_index); + + // perform work until go signals to stop + while (true) { + char *scriptName = go_frankenphp_before_script_execution(thread_index); + + // if the script name is NULL, the thread should exit + if (scriptName == NULL) { + break; + } + + // if the script name is not empty, execute the PHP script + if (strlen(scriptName) != 0) { + int exit_status = frankenphp_execute_script(scriptName); + go_frankenphp_after_script_execution(thread_index, exit_status); + } } go_frankenphp_release_known_variable_keys(thread_index); @@ -838,6 +852,8 @@ static void *php_thread(void *arg) { ts_free_thread(); #endif + go_frankenphp_on_thread_shutdown(thread_index); + return NULL; } @@ -855,13 +871,11 @@ static void *php_main(void *arg) { exit(EXIT_FAILURE); } - intptr_t num_threads = (intptr_t)arg; - set_thread_name("php-main"); #ifdef ZTS #if (PHP_VERSION_ID >= 80300) - php_tsrm_startup_ex(num_threads); + php_tsrm_startup_ex((intptr_t)arg); #else php_tsrm_startup(); #endif @@ -889,28 +903,7 @@ static void *php_main(void *arg) { frankenphp_sapi_module.startup(&frankenphp_sapi_module); - pthread_t *threads = malloc(num_threads * sizeof(pthread_t)); - if (threads == NULL) { - perror("malloc failed"); - exit(EXIT_FAILURE); - } - - for (uintptr_t i = 0; i < num_threads; i++) { - if (pthread_create(&(*(threads + i)), NULL, &php_thread, (void *)i) != 0) { - perror("failed to create PHP thread"); - free(threads); - exit(EXIT_FAILURE); - } - } - - for (int i = 0; i < num_threads; i++) { - if (pthread_join((*(threads + i)), NULL) != 0) { - perror("failed to join PHP thread"); - free(threads); - exit(EXIT_FAILURE); - } - } - free(threads); + go_frankenphp_main_thread_is_ready(); /* channel closed, shutdown gracefully */ frankenphp_sapi_module.shutdown(&frankenphp_sapi_module); @@ -926,25 +919,29 @@ static void *php_main(void *arg) { frankenphp_sapi_module.ini_entries = NULL; } #endif - - go_shutdown(); - + go_frankenphp_shutdown_main_thread(); return NULL; } -int frankenphp_init(int num_threads) { +int frankenphp_new_main_thread(int num_threads) { pthread_t thread; if (pthread_create(&thread, NULL, &php_main, (void *)(intptr_t)num_threads) != 0) { - go_shutdown(); - return -1; } - return pthread_detach(thread); } +bool frankenphp_new_php_thread(uintptr_t thread_index) { + pthread_t thread; + if (pthread_create(&thread, NULL, &php_thread, (void *)thread_index) != 0) { + return false; + } + pthread_detach(thread); + return true; +} + int frankenphp_request_startup() { if (php_request_startup() == SUCCESS) { return SUCCESS; @@ -957,8 +954,6 @@ int frankenphp_request_startup() { int frankenphp_execute_script(char *file_name) { if (frankenphp_request_startup() == FAILURE) { - free(file_name); - file_name = NULL; return FAILURE; } @@ -967,8 +962,6 @@ int frankenphp_execute_script(char *file_name) { zend_file_handle file_handle; zend_stream_init_filename(&file_handle, file_name); - free(file_name); - file_name = NULL; file_handle.primary_script = 1; diff --git a/frankenphp.go b/frankenphp.go index d7e35604cc..7b7f61b6ae 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -64,8 +64,6 @@ var ( ScriptExecutionError = errors.New("error during PHP script execution") requestChan chan *http.Request - done chan struct{} - shutdownWG sync.WaitGroup loggerMu sync.RWMutex logger *zap.Logger @@ -123,7 +121,7 @@ type FrankenPHPContext struct { closed sync.Once responseWriter http.ResponseWriter - exitStatus C.int + exitStatus int done chan interface{} startedAt time.Time @@ -244,7 +242,7 @@ func Config() PHPConfig { // MaxThreads is internally used during tests. It is written to, but never read and may go away in the future. var MaxThreads int -func calculateMaxThreads(opt *opt) error { +func calculateMaxThreads(opt *opt) (int, int, error) { maxProcs := runtime.GOMAXPROCS(0) * 2 var numWorkers int @@ -266,13 +264,13 @@ func calculateMaxThreads(opt *opt) error { opt.numThreads = maxProcs } } else if opt.numThreads <= numWorkers { - return NotEnoughThreads + return opt.numThreads, numWorkers, NotEnoughThreads } metrics.TotalThreads(opt.numThreads) MaxThreads = opt.numThreads - return nil + return opt.numThreads, numWorkers, nil } // Init starts the PHP runtime and the configured workers. @@ -311,7 +309,7 @@ func Init(options ...Option) error { metrics = opt.metrics } - err := calculateMaxThreads(opt) + totalThreadCount, workerThreadCount, err := calculateMaxThreads(opt) if err != nil { return err } @@ -327,29 +325,25 @@ func Init(options ...Option) error { logger.Warn(`Zend Max Execution Timers are not enabled, timeouts (e.g. "max_execution_time") are disabled, recompile PHP with the "--enable-zend-max-execution-timers" configuration option to fix this issue`) } } else { - opt.numThreads = 1 + totalThreadCount = 1 logger.Warn(`ZTS is not enabled, only 1 thread will be available, recompile PHP using the "--enable-zts" configuration option or performance will be degraded`) } - shutdownWG.Add(1) - done = make(chan struct{}) requestChan = make(chan *http.Request, opt.numThreads) - initPHPThreads(opt.numThreads) - - if C.frankenphp_init(C.int(opt.numThreads)) != 0 { - return MainThreadCreationError + if err := initPHPThreads(totalThreadCount); err != nil { + return err } - if err := initWorkers(opt.workers); err != nil { - return err + for i := 0; i < totalThreadCount-workerThreadCount; i++ { + getInactivePHPThread().setActive(nil, handleRequest, afterRequest, nil) } - if err := restartWorkersOnFileChanges(opt.workers); err != nil { + if err := initWorkers(opt.workers); err != nil { return err } if c := logger.Check(zapcore.InfoLevel, "FrankenPHP started 🐘"); c != nil { - c.Write(zap.String("php_version", Version().Version), zap.Int("num_threads", opt.numThreads)) + c.Write(zap.String("php_version", Version().Version), zap.Int("num_threads", totalThreadCount)) } if EmbeddedAppPath != "" { if c := logger.Check(zapcore.InfoLevel, "embedded PHP app 📦"); c != nil { @@ -363,7 +357,7 @@ func Init(options ...Option) error { // Shutdown stops the workers and the PHP runtime. func Shutdown() { drainWorkers() - drainThreads() + drainPHPThreads() metrics.Shutdown() requestChan = nil @@ -375,17 +369,6 @@ func Shutdown() { logger.Debug("FrankenPHP shut down") } -//export go_shutdown -func go_shutdown() { - shutdownWG.Done() -} - -func drainThreads() { - close(done) - shutdownWG.Wait() - phpThreads = nil -} - func getLogger() *zap.Logger { loggerMu.RLock() defer loggerMu.RUnlock() @@ -466,9 +449,6 @@ func ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) error return nil } - shutdownWG.Add(1) - defer shutdownWG.Done() - fc, ok := FromContext(request.Context()) if !ok { return InvalidRequestError @@ -477,151 +457,57 @@ func ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) error fc.responseWriter = responseWriter fc.startedAt = time.Now() - isWorker := fc.responseWriter == nil - // Detect if a worker is available to handle this request - if !isWorker { - if worker, ok := workers[fc.scriptFilename]; ok { - metrics.StartWorkerRequest(fc.scriptFilename) - worker.handleRequest(request) - <-fc.done - metrics.StopWorkerRequest(fc.scriptFilename, time.Since(fc.startedAt)) - return nil - } else { - metrics.StartRequest() - } + if worker, ok := workers[fc.scriptFilename]; ok { + worker.handleRequest(request, fc) + return nil } + metrics.StartRequest() + select { case <-done: case requestChan <- request: <-fc.done } - if !isWorker { - metrics.StopRequest() - } + metrics.StopRequest() return nil } -//export go_putenv -func go_putenv(str *C.char, length C.int) C.bool { - // Create a byte slice from C string with a specified length - s := C.GoBytes(unsafe.Pointer(str), length) - - // Convert byte slice to string - envString := string(s) - - // Check if '=' is present in the string - if key, val, found := strings.Cut(envString, "="); found { - if os.Setenv(key, val) != nil { - return false // Failure - } - } else { - // No '=', unset the environment variable - if os.Unsetenv(envString) != nil { - return false // Failure - } - } - - return true // Success -} - -//export go_getfullenv -func go_getfullenv(threadIndex C.uintptr_t) (*C.go_string, C.size_t) { - thread := phpThreads[threadIndex] - - env := os.Environ() - goStrings := make([]C.go_string, len(env)*2) - - for i, envVar := range env { - key, val, _ := strings.Cut(envVar, "=") - k := unsafe.StringData(key) - v := unsafe.StringData(val) - thread.Pin(k) - thread.Pin(v) - - goStrings[i*2] = C.go_string{C.size_t(len(key)), (*C.char)(unsafe.Pointer(k))} - goStrings[i*2+1] = C.go_string{C.size_t(len(val)), (*C.char)(unsafe.Pointer(v))} - } - - value := unsafe.SliceData(goStrings) - thread.Pin(value) - - return value, C.size_t(len(env)) -} - -//export go_getenv -func go_getenv(threadIndex C.uintptr_t, name *C.go_string) (C.bool, *C.go_string) { - thread := phpThreads[threadIndex] - - // Create a byte slice from C string with a specified length - envName := C.GoStringN(name.data, C.int(name.len)) - - // Get the environment variable value - envValue, exists := os.LookupEnv(envName) - if !exists { - // Environment variable does not exist - return false, nil // Return 0 to indicate failure - } - - // Convert Go string to C string - val := unsafe.StringData(envValue) - thread.Pin(val) - value := &C.go_string{C.size_t(len(envValue)), (*C.char)(unsafe.Pointer(val))} - thread.Pin(value) - - return true, value // Return 1 to indicate success -} - -//export go_sapi_getenv -func go_sapi_getenv(threadIndex C.uintptr_t, name *C.go_string) *C.char { - envName := C.GoStringN(name.data, C.int(name.len)) - - envValue, exists := os.LookupEnv(envName) - if !exists { - return nil - } - - return phpThreads[threadIndex].pinCString(envValue) -} - -//export go_handle_request -func go_handle_request(threadIndex C.uintptr_t) bool { +func handleRequest(thread *phpThread) { select { case <-done: - return false + // no script should be executed if the server is shutting down + thread.scriptName = "" + return case r := <-requestChan: - thread := phpThreads[threadIndex] thread.mainRequest = r - - fc, ok := FromContext(r.Context()) - if !ok { - panic(InvalidRequestError) - } - defer func() { - maybeCloseContext(fc) - thread.mainRequest = nil - thread.Unpin() - }() + fc := r.Context().Value(contextKey).(*FrankenPHPContext) if err := updateServerContext(thread, r, true, false); err != nil { rejectRequest(fc.responseWriter, err.Error()) - return true - } - - // scriptFilename is freed in frankenphp_execute_script() - fc.exitStatus = C.frankenphp_execute_script(C.CString(fc.scriptFilename)) - if fc.exitStatus < 0 { - panic(ScriptExecutionError) + afterRequest(thread, 0) + thread.Unpin() + // no script should be executed if the request was rejected + thread.scriptName = "" + return } - return true + // set the scriptName that should be executed + thread.scriptName = fc.scriptFilename } } +func afterRequest(thread *phpThread, exitStatus int) { + fc := thread.mainRequest.Context().Value(contextKey).(*FrankenPHPContext) + fc.exitStatus = exitStatus + maybeCloseContext(fc) + thread.mainRequest = nil +} + func maybeCloseContext(fc *FrankenPHPContext) { fc.closed.Do(func() { close(fc.done) @@ -859,21 +745,11 @@ func freeArgs(argv []*C.char) { } } -func executePHPFunction(functionName string) { +func executePHPFunction(functionName string) bool { cFunctionName := C.CString(functionName) defer C.free(unsafe.Pointer(cFunctionName)) - success := C.frankenphp_execute_php_function(cFunctionName) - - if success == 1 { - if c := logger.Check(zapcore.DebugLevel, "php function call successful"); c != nil { - c.Write(zap.String("function", functionName)) - } - } else { - if c := logger.Check(zapcore.ErrorLevel, "php function call failed"); c != nil { - c.Write(zap.String("function", functionName)) - } - } + return C.frankenphp_execute_php_function(cFunctionName) == 1 } // Ensure that the request path does not contain null bytes diff --git a/frankenphp.h b/frankenphp.h index 41a5a2124f..2ed926d961 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -40,7 +40,8 @@ typedef struct frankenphp_config { } frankenphp_config; frankenphp_config frankenphp_get_config(); -int frankenphp_init(int num_threads); +int frankenphp_new_main_thread(int num_threads); +bool frankenphp_new_php_thread(uintptr_t thread_index); int frankenphp_update_server_context( bool create, bool has_main_request, bool has_active_request, @@ -52,7 +53,6 @@ int frankenphp_request_startup(); int frankenphp_execute_script(char *file_name); int frankenphp_execute_script_cli(char *script, int argc, char **argv); - int frankenphp_execute_php_function(const char *php_function); void frankenphp_register_variables_from_request_info( diff --git a/frankenphp_test.go b/frankenphp_test.go index 9ca6b1520b..436b96b19e 100644 --- a/frankenphp_test.go +++ b/frankenphp_test.go @@ -592,6 +592,23 @@ func testFiberNoCgo(t *testing.T, opts *testOptions) { }, opts) } +func TestFiberBasic_module(t *testing.T) { testFiberBasic(t, &testOptions{}) } +func TestFiberBasic_worker(t *testing.T) { + testFiberBasic(t, &testOptions{workerScript: "fiber-basic.php"}) +} +func testFiberBasic(t *testing.T, opts *testOptions) { + runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) { + req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/fiber-basic.php?i=%d", i), nil) + w := httptest.NewRecorder() + handler(w, req) + + resp := w.Result() + body, _ := io.ReadAll(resp.Body) + + assert.Equal(t, string(body), fmt.Sprintf("Fiber %d", i)) + }, opts) +} + func TestRequestHeaders_module(t *testing.T) { testRequestHeaders(t, &testOptions{}) } func TestRequestHeaders_worker(t *testing.T) { testRequestHeaders(t, &testOptions{workerScript: "request-headers.php"}) diff --git a/php_thread.go b/php_thread.go index af030600aa..1692c6d3c2 100644 --- a/php_thread.go +++ b/php_thread.go @@ -1,7 +1,6 @@ package frankenphp -// #include -// #include +// #include "frankenphp.h" import "C" import ( "net/http" @@ -9,26 +8,35 @@ import ( "unsafe" ) -var phpThreads []*phpThread - type phpThread struct { runtime.Pinner - mainRequest *http.Request - workerRequest *http.Request - worker *worker - requestChan chan *http.Request - knownVariableKeys map[string]*C.zend_string -} + mainRequest *http.Request + workerRequest *http.Request + requestChan chan *http.Request + worker *worker -func initPHPThreads(numThreads int) { - phpThreads = make([]*phpThread, 0, numThreads) - for i := 0; i < numThreads; i++ { - phpThreads = append(phpThreads, &phpThread{}) - } + // the script name for the current request + scriptName string + // the index in the phpThreads slice + threadIndex int + // right before the first work iteration + onStartup func(*phpThread) + // the actual work iteration (done in a loop) + beforeScriptExecution func(*phpThread) + // after the work iteration is done + afterScriptExecution func(*phpThread, int) + // after the thread is done + onShutdown func(*phpThread) + // exponential backoff for worker failures + backoff *exponentialBackoff + // known $_SERVER key names + knownVariableKeys map[string]*C.zend_string + // the state handler + state *threadStateHandler } -func (thread phpThread) getActiveRequest() *http.Request { +func (thread *phpThread) getActiveRequest() *http.Request { if thread.workerRequest != nil { return thread.workerRequest } @@ -36,6 +44,36 @@ func (thread phpThread) getActiveRequest() *http.Request { return thread.mainRequest } +func (thread *phpThread) setInactive() { + thread.scriptName = "" + // TODO: handle this in a state machine + if !thread.state.is(stateShuttingDown) { + thread.state.set(stateInactive) + } +} + +func (thread *phpThread) setActive( + onStartup func(*phpThread), + beforeScriptExecution func(*phpThread), + afterScriptExecution func(*phpThread, int), + onShutdown func(*phpThread), +) { + // to avoid race conditions, the thread sets its own hooks on startup + thread.onStartup = func(thread *phpThread) { + if thread.onShutdown != nil { + thread.onShutdown(thread) + } + thread.onStartup = onStartup + thread.beforeScriptExecution = beforeScriptExecution + thread.onShutdown = onShutdown + thread.afterScriptExecution = afterScriptExecution + if thread.onStartup != nil { + thread.onStartup(thread) + } + } + thread.state.set(stateActive) +} + // Pin a string that is not null-terminated // PHP's zend_string may contain null-bytes func (thread *phpThread) pinString(s string) *C.char { @@ -46,5 +84,61 @@ func (thread *phpThread) pinString(s string) *C.char { // C strings must be null-terminated func (thread *phpThread) pinCString(s string) *C.char { - return thread.pinString(s+"\x00") + return thread.pinString(s + "\x00") +} + +//export go_frankenphp_on_thread_startup +func go_frankenphp_on_thread_startup(threadIndex C.uintptr_t) { + phpThreads[threadIndex].setInactive() +} + +//export go_frankenphp_before_script_execution +func go_frankenphp_before_script_execution(threadIndex C.uintptr_t) *C.char { + thread := phpThreads[threadIndex] + + // if the state is inactive, wait for it to be active + if thread.state.is(stateInactive) { + thread.state.waitFor(stateActive, stateShuttingDown) + } + + // returning nil signals the thread to stop + if thread.state.is(stateShuttingDown) { + return nil + } + + // if the thread is not ready yet, set it up + if !thread.state.is(stateReady) { + thread.state.set(stateReady) + if thread.onStartup != nil { + thread.onStartup(thread) + } + } + + // execute a hook before the script is executed + thread.beforeScriptExecution(thread) + + // return the name of the PHP script that should be executed + return thread.pinCString(thread.scriptName) +} + +//export go_frankenphp_after_script_execution +func go_frankenphp_after_script_execution(threadIndex C.uintptr_t, exitStatus C.int) { + thread := phpThreads[threadIndex] + if exitStatus < 0 { + panic(ScriptExecutionError) + } + if thread.afterScriptExecution != nil { + thread.afterScriptExecution(thread, int(exitStatus)) + } + thread.Unpin() +} + +//export go_frankenphp_on_thread_shutdown +func go_frankenphp_on_thread_shutdown(threadIndex C.uintptr_t) { + thread := phpThreads[threadIndex] + thread.Unpin() + if thread.onShutdown != nil { + thread.onShutdown(thread) + } + thread.state.set(stateDone) } diff --git a/php_thread_test.go b/php_thread_test.go index 63afe4d89c..eba873d5be 100644 --- a/php_thread_test.go +++ b/php_thread_test.go @@ -7,20 +7,9 @@ import ( "github.com/stretchr/testify/assert" ) -func TestInitializeTwoPhpThreadsWithoutRequests(t *testing.T) { - initPHPThreads(2) - - assert.Len(t, phpThreads, 2) - assert.NotNil(t, phpThreads[0]) - assert.NotNil(t, phpThreads[1]) - assert.Nil(t, phpThreads[0].mainRequest) - assert.Nil(t, phpThreads[0].workerRequest) -} - func TestMainRequestIsActiveRequest(t *testing.T) { mainRequest := &http.Request{} - initPHPThreads(1) - thread := phpThreads[0] + thread := phpThread{} thread.mainRequest = mainRequest @@ -30,8 +19,7 @@ func TestMainRequestIsActiveRequest(t *testing.T) { func TestWorkerRequestIsActiveRequest(t *testing.T) { mainRequest := &http.Request{} workerRequest := &http.Request{} - initPHPThreads(1) - thread := phpThreads[0] + thread := phpThread{} thread.mainRequest = mainRequest thread.workerRequest = workerRequest diff --git a/php_threads.go b/php_threads.go new file mode 100644 index 0000000000..9ef71fde81 --- /dev/null +++ b/php_threads.go @@ -0,0 +1,95 @@ +package frankenphp + +// #include "frankenphp.h" +import "C" +import ( + "fmt" + "sync" +) + +var ( + phpThreads []*phpThread + done chan struct{} + mainThreadState *threadStateHandler +) + +// reserve a fixed number of PHP threads on the go side +func initPHPThreads(numThreads int) error { + done = make(chan struct{}) + phpThreads = make([]*phpThread, numThreads) + for i := 0; i < numThreads; i++ { + phpThreads[i] = &phpThread{ + threadIndex: i, + state: &threadStateHandler{currentState: stateBooting}, + } + } + if err := startMainThread(numThreads); err != nil { + return err + } + + // initialize all threads as inactive + ready := sync.WaitGroup{} + ready.Add(len(phpThreads)) + + for _, thread := range phpThreads { + go func() { + if !C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) { + panic(fmt.Sprintf("unable to create thread %d", thread.threadIndex)) + } + thread.state.waitFor(stateInactive) + ready.Done() + }() + } + + ready.Wait() + + return nil +} + +func drainPHPThreads() { + doneWG := sync.WaitGroup{} + doneWG.Add(len(phpThreads)) + for _, thread := range phpThreads { + thread.state.set(stateShuttingDown) + } + close(done) + for _, thread := range phpThreads { + go func(thread *phpThread) { + thread.state.waitFor(stateDone) + doneWG.Done() + }(thread) + } + doneWG.Wait() + mainThreadState.set(stateShuttingDown) + mainThreadState.waitFor(stateDone) + phpThreads = nil +} + +func startMainThread(numThreads int) error { + mainThreadState = &threadStateHandler{currentState: stateBooting} + if C.frankenphp_new_main_thread(C.int(numThreads)) != 0 { + return MainThreadCreationError + } + mainThreadState.waitFor(stateActive) + return nil +} + +func getInactivePHPThread() *phpThread { + for _, thread := range phpThreads { + if thread.state.is(stateInactive) { + return thread + } + } + panic("not enough threads reserved") +} + +//export go_frankenphp_main_thread_is_ready +func go_frankenphp_main_thread_is_ready() { + mainThreadState.set(stateActive) + mainThreadState.waitFor(stateShuttingDown) +} + +//export go_frankenphp_shutdown_main_thread +func go_frankenphp_shutdown_main_thread() { + mainThreadState.set(stateDone) +} diff --git a/php_threads_test.go b/php_threads_test.go new file mode 100644 index 0000000000..ab85c783fe --- /dev/null +++ b/php_threads_test.go @@ -0,0 +1,175 @@ +package frankenphp + +import ( + "net/http" + "path/filepath" + "sync" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) { + logger = zap.NewNop() // the logger needs to not be nil + assert.NoError(t, initPHPThreads(1)) // reserve 1 thread + + assert.Len(t, phpThreads, 1) + assert.Equal(t, 0, phpThreads[0].threadIndex) + assert.True(t, phpThreads[0].state.is(stateInactive)) + assert.Nil(t, phpThreads[0].worker) + + drainPHPThreads() + assert.Nil(t, phpThreads) +} + +// We'll start 100 threads and check that their hooks work correctly +func TestStartAndStop100PHPThreadsThatDoNothing(t *testing.T) { + logger = zap.NewNop() // the logger needs to not be nil + numThreads := 100 + readyThreads := atomic.Uint64{} + finishedThreads := atomic.Uint64{} + workingThreads := atomic.Uint64{} + workWG := sync.WaitGroup{} + workWG.Add(numThreads) + + assert.NoError(t, initPHPThreads(numThreads)) + + for i := 0; i < numThreads; i++ { + newThread := getInactivePHPThread() + newThread.setActive( + // onStartup => before the thread is ready + func(thread *phpThread) { + if thread.threadIndex == newThread.threadIndex { + readyThreads.Add(1) + } + }, + // beforeScriptExecution => we stop here immediately + func(thread *phpThread) { + if thread.threadIndex == newThread.threadIndex { + workingThreads.Add(1) + } + workWG.Done() + newThread.setInactive() + }, + // afterScriptExecution => no script is executed, we shouldn't reach here + func(thread *phpThread, exitStatus int) { + panic("hook afterScriptExecution should not be called here") + }, + // onShutdown => after the thread is done + func(thread *phpThread) { + if thread.threadIndex == newThread.threadIndex { + finishedThreads.Add(1) + } + }, + ) + } + + workWG.Wait() + drainPHPThreads() + + assert.Equal(t, numThreads, int(readyThreads.Load())) + assert.Equal(t, numThreads, int(workingThreads.Load())) + assert.Equal(t, numThreads, int(finishedThreads.Load())) +} + +// This test calls sleep() 10.000 times for 1ms in 100 PHP threads. +func TestSleep10000TimesIn100Threads(t *testing.T) { + logger, _ = zap.NewDevelopment() // the logger needs to not be nil + numThreads := 100 + maxExecutions := 10000 + executionMutex := sync.Mutex{} + executionCount := 0 + scriptPath, _ := filepath.Abs("./testdata/sleep.php") + workWG := sync.WaitGroup{} + workWG.Add(maxExecutions) + + assert.NoError(t, initPHPThreads(numThreads)) + + for i := 0; i < numThreads; i++ { + getInactivePHPThread().setActive( + // onStartup => fake a request on startup (like a worker would do) + func(thread *phpThread) { + r, _ := http.NewRequest(http.MethodGet, "sleep.php", nil) + r, _ = NewRequestWithContext(r, WithRequestDocumentRoot("/", false)) + assert.NoError(t, updateServerContext(thread, r, true, false)) + thread.mainRequest = r + thread.scriptName = scriptPath + }, + // beforeScriptExecution => execute the sleep.php script until we reach maxExecutions + func(thread *phpThread) { + executionMutex.Lock() + if executionCount >= maxExecutions { + executionMutex.Unlock() + thread.setInactive() + return + } + executionCount++ + workWG.Done() + executionMutex.Unlock() + }, + // afterScriptExecution => check the exit status of the script + func(thread *phpThread, exitStatus int) { + if int(exitStatus) != 0 { + panic("script execution failed: " + scriptPath) + } + }, + // onShutdown => nothing to do here + nil, + ) + } + + workWG.Wait() + drainPHPThreads() + + assert.Equal(t, maxExecutions, executionCount) +} + +// TODO: Make this test more chaotic +func TestStart100ThreadsAndConvertThemToDifferentThreads10Times(t *testing.T) { + logger = zap.NewNop() // the logger needs to not be nil + numThreads := 100 + numConversions := 10 + startUpTypes := make([]atomic.Uint64, numConversions) + workTypes := make([]atomic.Uint64, numConversions) + shutdownTypes := make([]atomic.Uint64, numConversions) + workWG := sync.WaitGroup{} + + assert.NoError(t, initPHPThreads(numThreads)) + + for i := 0; i < numConversions; i++ { + workWG.Add(numThreads) + numberOfConversion := i + for j := 0; j < numThreads; j++ { + getInactivePHPThread().setActive( + // onStartup => before the thread is ready + func(thread *phpThread) { + startUpTypes[numberOfConversion].Add(1) + }, + // beforeScriptExecution => while the thread is running + func(thread *phpThread) { + workTypes[numberOfConversion].Add(1) + thread.setInactive() + workWG.Done() + }, + // afterScriptExecution => we don't execute a script + nil, + // onShutdown => after the thread is done + func(thread *phpThread) { + shutdownTypes[numberOfConversion].Add(1) + }, + ) + } + workWG.Wait() + } + + drainPHPThreads() + + // each type of thread needs to have started, worked and stopped the same amount of times + for i := 0; i < numConversions; i++ { + assert.Equal(t, numThreads, int(startUpTypes[i].Load())) + assert.Equal(t, numThreads, int(workTypes[i].Load())) + assert.Equal(t, numThreads, int(shutdownTypes[i].Load())) + } +} diff --git a/testdata/fiber-basic.php b/testdata/fiber-basic.php new file mode 100644 index 0000000000..bdb52336f6 --- /dev/null +++ b/testdata/fiber-basic.php @@ -0,0 +1,9 @@ +start(); +}; diff --git a/testdata/sleep.php b/testdata/sleep.php new file mode 100644 index 0000000000..d2c78b865d --- /dev/null +++ b/testdata/sleep.php @@ -0,0 +1,4 @@ + // #include "frankenphp.h" import "C" import ( @@ -9,7 +8,6 @@ import ( "net/http" "path/filepath" "sync" - "sync/atomic" "time" "github.com/dunglas/frankenphp/internal/watcher" @@ -26,24 +24,17 @@ type worker struct { threadMutex sync.RWMutex } -const maxWorkerErrorBackoff = 1 * time.Second -const minWorkerErrorBackoff = 100 * time.Millisecond -const maxWorkerConsecutiveFailures = 6 - var ( - watcherIsEnabled bool - workersReadyWG sync.WaitGroup - workerShutdownWG sync.WaitGroup - workersAreReady atomic.Bool - workersAreDone atomic.Bool + workers map[string]*worker workersDone chan interface{} - workers = make(map[string]*worker) + watcherIsEnabled bool ) func initWorkers(opt []workerOpt) error { + workers = make(map[string]*worker, len(opt)) workersDone = make(chan interface{}) - workersAreReady.Store(false) - workersAreDone.Store(false) + directoriesToWatch := getDirectoriesToWatch(opt) + watcherIsEnabled = len(directoriesToWatch) > 0 for _, o := range opt { worker, err := newWorker(o) @@ -51,14 +42,18 @@ func initWorkers(opt []workerOpt) error { if err != nil { return err } - workersReadyWG.Add(worker.num) for i := 0; i < worker.num; i++ { - go worker.startNewWorkerThread() + worker.startNewThread() } } - workersReadyWG.Wait() - workersAreReady.Store(true) + if len(directoriesToWatch) == 0 { + return nil + } + + if err := watcher.InitWatcher(directoriesToWatch, restartWorkers, getLogger()); err != nil { + return err + } return nil } @@ -69,12 +64,6 @@ func newWorker(o workerOpt) (*worker, error) { return nil, fmt.Errorf("worker filename is invalid %q: %w", o.fileName, err) } - // if the worker already exists, return it, - // it's necessary since we don't want to destroy the channels when restarting on file changes - if w, ok := workers[absFileName]; ok { - return w, nil - } - if o.env == nil { o.env = make(PreparedEnv, 1) } @@ -86,198 +75,168 @@ func newWorker(o workerOpt) (*worker, error) { return w, nil } -func (worker *worker) startNewWorkerThread() { - workerShutdownWG.Add(1) - defer workerShutdownWG.Done() - - backoff := minWorkerErrorBackoff - failureCount := 0 - backingOffLock := sync.RWMutex{} - - for { - - // if the worker can stay up longer than backoff*2, it is probably an application error - upFunc := sync.Once{} - go func() { - backingOffLock.RLock() - wait := backoff * 2 - backingOffLock.RUnlock() - time.Sleep(wait) - upFunc.Do(func() { - backingOffLock.Lock() - defer backingOffLock.Unlock() - // if we come back to a stable state, reset the failure count - if backoff == minWorkerErrorBackoff { - failureCount = 0 - } - - // earn back the backoff over time - if failureCount > 0 { - backoff = max(backoff/2, 100*time.Millisecond) - } - }) - }() - - metrics.StartWorker(worker.fileName) - - // Create main dummy request - r, err := http.NewRequest(http.MethodGet, filepath.Base(worker.fileName), nil) - if err != nil { - panic(err) - } +func stopWorkers() { + close(workersDone) +} - r, err = NewRequestWithContext( - r, - WithRequestDocumentRoot(filepath.Dir(worker.fileName), false), - WithRequestPreparedEnv(worker.env), - ) - if err != nil { - panic(err) - } +func drainWorkers() { + watcher.DrainWatcher() + stopWorkers() +} - if c := logger.Check(zapcore.DebugLevel, "starting"); c != nil { - c.Write(zap.String("worker", worker.fileName), zap.Int("num", worker.num)) +func restartWorkers() { + restart := sync.WaitGroup{} + restart.Add(1) + ready := sync.WaitGroup{} + for _, worker := range workers { + worker.threadMutex.RLock() + ready.Add(len(worker.threads)) + for _, thread := range worker.threads { + thread.state.set(stateRestarting) + go func(thread *phpThread) { + thread.state.waitForAndYield(&restart, stateReady) + ready.Done() + }(thread) } + worker.threadMutex.RUnlock() + } + stopWorkers() + ready.Wait() + workersDone = make(chan interface{}) + restart.Done() +} - if err := ServeHTTP(nil, r); err != nil { - panic(err) - } +func getDirectoriesToWatch(workerOpts []workerOpt) []string { + directoriesToWatch := []string{} + for _, w := range workerOpts { + directoriesToWatch = append(directoriesToWatch, w.watch...) + } + return directoriesToWatch +} - fc := r.Context().Value(contextKey).(*FrankenPHPContext) +func (worker *worker) startNewThread() { + getInactivePHPThread().setActive( + // onStartup => right before the thread is ready + func(thread *phpThread) { + thread.worker = worker + thread.scriptName = worker.fileName + thread.requestChan = make(chan *http.Request) + thread.backoff = newExponentialBackoff() + worker.threadMutex.Lock() + worker.threads = append(worker.threads, thread) + worker.threadMutex.Unlock() + metrics.ReadyWorker(worker.fileName) + }, + // beforeScriptExecution => set up the worker with a fake request + func(thread *phpThread) { + worker.beforeScript(thread) + }, + // afterScriptExecution => tear down the worker + func(thread *phpThread, exitStatus int) { + worker.afterScript(thread, exitStatus) + }, + // onShutdown => after the thread is done + func(thread *phpThread) { + thread.worker = nil + thread.backoff = nil + }, + ) +} - // if we are done, exit the loop that restarts the worker script - if workersAreDone.Load() { - break - } +func (worker *worker) beforeScript(thread *phpThread) { + // if we are restarting due to file watching, set the state back to ready + if thread.state.is(stateRestarting) { + thread.state.set(stateReady) + } - // on exit status 0 we just run the worker script again - if fc.exitStatus == 0 { - // TODO: make the max restart configurable - if c := logger.Check(zapcore.InfoLevel, "restarting"); c != nil { - c.Write(zap.String("worker", worker.fileName)) - } - metrics.StopWorker(worker.fileName, StopReasonRestart) - continue - } + thread.backoff.reset() + metrics.StartWorker(worker.fileName) - // on exit status 1 we log the error and apply an exponential backoff when restarting - upFunc.Do(func() { - backingOffLock.Lock() - defer backingOffLock.Unlock() - // if we end up here, the worker has not been up for backoff*2 - // this is probably due to a syntax error or another fatal error - if failureCount >= maxWorkerConsecutiveFailures { - if !watcherIsEnabled { - panic(fmt.Errorf("workers %q: too many consecutive failures", worker.fileName)) - } - logger.Warn("many consecutive worker failures", zap.String("worker", worker.fileName), zap.Int("failures", failureCount)) - } - failureCount += 1 - }) - backingOffLock.RLock() - wait := backoff - backingOffLock.RUnlock() - time.Sleep(wait) - backingOffLock.Lock() - backoff *= 2 - backoff = min(backoff, maxWorkerErrorBackoff) - backingOffLock.Unlock() - metrics.StopWorker(worker.fileName, StopReasonCrash) + // Create a dummy request to set up the worker + r, err := http.NewRequest(http.MethodGet, filepath.Base(worker.fileName), nil) + if err != nil { + panic(err) } - metrics.StopWorker(worker.fileName, StopReasonShutdown) + r, err = NewRequestWithContext( + r, + WithRequestDocumentRoot(filepath.Dir(worker.fileName), false), + WithRequestPreparedEnv(worker.env), + ) + if err != nil { + panic(err) + } - // TODO: check if the termination is expected - if c := logger.Check(zapcore.DebugLevel, "terminated"); c != nil { - c.Write(zap.String("worker", worker.fileName)) + if err := updateServerContext(thread, r, true, false); err != nil { + panic(err) + } + + thread.mainRequest = r + if c := logger.Check(zapcore.DebugLevel, "starting"); c != nil { + c.Write(zap.String("worker", worker.fileName), zap.Int("thread", thread.threadIndex)) } } -func (worker *worker) handleRequest(r *http.Request) { - worker.threadMutex.RLock() +func (worker *worker) afterScript(thread *phpThread, exitStatus int) { + fc := thread.mainRequest.Context().Value(contextKey).(*FrankenPHPContext) + fc.exitStatus = exitStatus + + defer func() { + maybeCloseContext(fc) + thread.mainRequest = nil + }() + + // on exit status 0 we just run the worker script again + if fc.exitStatus == 0 { + // TODO: make the max restart configurable + metrics.StopWorker(worker.fileName, StopReasonRestart) + + if c := logger.Check(zapcore.DebugLevel, "restarting"); c != nil { + c.Write(zap.String("worker", worker.fileName)) + } + return + } + + // on exit status 1 we apply an exponential backoff when restarting + metrics.StopWorker(worker.fileName, StopReasonCrash) + thread.backoff.trigger(func(failureCount int) { + // if we end up here, the worker has not been up for backoff*2 + // this is probably due to a syntax error or another fatal error + if !watcherIsEnabled { + panic(fmt.Errorf("workers %q: too many consecutive failures", worker.fileName)) + } + logger.Warn("many consecutive worker failures", zap.String("worker", worker.fileName), zap.Int("failures", failureCount)) + }) +} + +func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { + metrics.StartWorkerRequest(fc.scriptFilename) + // dispatch requests to all worker threads in order + worker.threadMutex.RLock() for _, thread := range worker.threads { select { case thread.requestChan <- r: worker.threadMutex.RUnlock() + <-fc.done + metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) return default: } } worker.threadMutex.RUnlock() + // if no thread was available, fan the request out to all threads // TODO: theoretically there could be autoscaling of threads here worker.requestChan <- r -} - -func stopWorkers() { - workersAreDone.Store(true) - close(workersDone) -} - -func drainWorkers() { - watcher.DrainWatcher() - watcherIsEnabled = false - stopWorkers() - workerShutdownWG.Wait() - workers = make(map[string]*worker) -} - -func restartWorkersOnFileChanges(workerOpts []workerOpt) error { - var directoriesToWatch []string - for _, w := range workerOpts { - directoriesToWatch = append(directoriesToWatch, w.watch...) - } - watcherIsEnabled = len(directoriesToWatch) > 0 - if !watcherIsEnabled { - return nil - } - restartWorkers := func() { - restartWorkers(workerOpts) - } - if err := watcher.InitWatcher(directoriesToWatch, restartWorkers, getLogger()); err != nil { - return err - } - - return nil -} - -func restartWorkers(workerOpts []workerOpt) { - stopWorkers() - workerShutdownWG.Wait() - if err := initWorkers(workerOpts); err != nil { - logger.Error("failed to restart workers when watching files") - panic(err) - } - logger.Info("workers restarted successfully") -} - -func assignThreadToWorker(thread *phpThread) { - fc := thread.mainRequest.Context().Value(contextKey).(*FrankenPHPContext) - metrics.ReadyWorker(fc.scriptFilename) - worker, ok := workers[fc.scriptFilename] - if !ok { - panic("worker not found for script: " + fc.scriptFilename) - } - thread.worker = worker - if !workersAreReady.Load() { - workersReadyWG.Done() - } - thread.requestChan = make(chan *http.Request) - worker.threadMutex.Lock() - worker.threads = append(worker.threads, thread) - worker.threadMutex.Unlock() + <-fc.done + metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) } //export go_frankenphp_worker_handle_request_start func go_frankenphp_worker_handle_request_start(threadIndex C.uintptr_t) C.bool { thread := phpThreads[threadIndex] - // we assign a worker to the thread if it doesn't have one already - if thread.worker == nil { - assignThreadToWorker(thread) - } - if c := logger.Check(zapcore.DebugLevel, "waiting for request"); c != nil { c.Write(zap.String("worker", thread.worker.fileName)) } @@ -288,12 +247,15 @@ func go_frankenphp_worker_handle_request_start(threadIndex C.uintptr_t) C.bool { if c := logger.Check(zapcore.DebugLevel, "shutting down"); c != nil { c.Write(zap.String("worker", thread.worker.fileName)) } - thread.worker = nil - C.frankenphp_reset_opcache() + + // execute opcache_reset if the restart was triggered by the watcher + if watcherIsEnabled && thread.state.is(stateRestarting) { + C.frankenphp_reset_opcache() + } return C.bool(false) - case r = <-thread.worker.requestChan: case r = <-thread.requestChan: + case r = <-thread.worker.requestChan: } thread.workerRequest = r @@ -318,30 +280,30 @@ func go_frankenphp_worker_handle_request_start(threadIndex C.uintptr_t) C.bool { return C.bool(true) } -//export go_frankenphp_finish_request -func go_frankenphp_finish_request(threadIndex C.uintptr_t, isWorkerRequest bool) { +//export go_frankenphp_finish_worker_request +func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t) { thread := phpThreads[threadIndex] r := thread.getActiveRequest() fc := r.Context().Value(contextKey).(*FrankenPHPContext) - if isWorkerRequest { - thread.workerRequest = nil - } - maybeCloseContext(fc) + thread.workerRequest = nil + thread.Unpin() if c := fc.logger.Check(zapcore.DebugLevel, "request handling finished"); c != nil { - var fields []zap.Field - if isWorkerRequest { - fields = append(fields, zap.String("worker", fc.scriptFilename), zap.String("url", r.RequestURI)) - } else { - fields = append(fields, zap.String("url", r.RequestURI)) - } - - c.Write(fields...) + c.Write(zap.String("worker", fc.scriptFilename), zap.String("url", r.RequestURI)) } +} - if isWorkerRequest { - thread.Unpin() +// when frankenphp_finish_request() is directly called from PHP +// +//export go_frankenphp_finish_php_request +func go_frankenphp_finish_php_request(threadIndex C.uintptr_t) { + r := phpThreads[threadIndex].getActiveRequest() + fc := r.Context().Value(contextKey).(*FrankenPHPContext) + maybeCloseContext(fc) + + if c := fc.logger.Check(zapcore.DebugLevel, "request handling finished"); c != nil { + c.Write(zap.String("url", r.RequestURI)) } }