|
| 1 | +# Internals |
| 2 | + |
| 3 | +This document explains FrankenPHP's internal architecture, focusing on thread management, the state machine, and the CGO boundary between Go and C/PHP. |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +FrankenPHP embeds the PHP interpreter directly into Go via CGO. Each PHP execution runs on a real POSIX thread (not a goroutine) because PHP's ZTS (Zend Thread Safety) model requires it. Go orchestrates these threads through a state machine, while C handles the PHP SAPI lifecycle. |
| 8 | + |
| 9 | +The main layers are: |
| 10 | + |
| 11 | +1. **Go layer** (`frankenphp.go`, `phpthread.go`, `threadworker.go`, `threadregular.go`, `scaling.go`): Thread pool management, request routing, auto-scaling |
| 12 | +2. **C layer** (`frankenphp.c`): PHP SAPI implementation, script execution loop, superglobal management |
| 13 | +3. **State machine** (`internal/state/`): Synchronization between Go goroutines and C threads |
| 14 | + |
| 15 | +## Thread Types |
| 16 | + |
| 17 | +### Main Thread (`phpmainthread.go`) |
| 18 | + |
| 19 | +The main PHP thread (`phpMainThread`) initializes the PHP runtime: |
| 20 | + |
| 21 | +1. Applies `php.ini` overrides |
| 22 | +2. Takes a snapshot of the environment (`main_thread_env`) for sandboxing |
| 23 | +3. Starts the PHP SAPI module |
| 24 | +4. Signals readiness to the Go side |
| 25 | + |
| 26 | +It stays alive for the lifetime of the server. All other threads are started after it signals `Ready`. |
| 27 | + |
| 28 | +### Regular Threads (`threadregular.go`) |
| 29 | + |
| 30 | +Handle classic one-request-per-invocation PHP scripts. Each request: |
| 31 | + |
| 32 | +1. Receives a request via `requestChan` or the shared `regularRequestChan` |
| 33 | +2. Returns the script filename from `beforeScriptExecution()` |
| 34 | +3. The C layer executes the PHP script |
| 35 | +4. `afterScriptExecution()` closes the request context |
| 36 | + |
| 37 | +### Worker Threads (`threadworker.go`) |
| 38 | + |
| 39 | +Keep a PHP script alive across multiple requests. The PHP script calls `frankenphp_handle_request()` in a loop: |
| 40 | + |
| 41 | +1. `beforeScriptExecution()` returns the worker script filename |
| 42 | +2. The C layer starts executing the PHP script |
| 43 | +3. The PHP script calls `frankenphp_handle_request()`, which calls `waitForWorkerRequest()` in Go |
| 44 | +4. Go blocks until a request arrives, then sets up the request context |
| 45 | +5. The PHP callback handles the request |
| 46 | +6. `go_frankenphp_finish_worker_request()` cleans up the request context |
| 47 | +7. The PHP script loops back to step 3 |
| 48 | + |
| 49 | +Worker threads are restarted when the script exits (exit code 0), with exponential backoff on failure. |
| 50 | + |
| 51 | +## Thread State Machine |
| 52 | + |
| 53 | +Each thread has a `ThreadState` (defined in `internal/state/state.go`) that governs its lifecycle. The state machine uses a `sync.RWMutex` for all state transitions and a channel-based subscriber pattern for blocking waits. |
| 54 | + |
| 55 | +### States |
| 56 | + |
| 57 | +``` |
| 58 | +Lifecycle: Reserved → Booting → Inactive → Ready ⇄ (processing) |
| 59 | + ↓ |
| 60 | +Shutdown: ShuttingDown → Done → Reserved |
| 61 | + ↑ |
| 62 | +Restart: Restarting → Yielding → Ready |
| 63 | + ↑ |
| 64 | +Handler transition: TransitionRequested → TransitionInProgress → TransitionComplete |
| 65 | +``` |
| 66 | + |
| 67 | +| State | Description | |
| 68 | +|-------|-------------| |
| 69 | +| `Reserved` | Thread slot allocated but not booted. Can be booted on demand. | |
| 70 | +| `Booting` | Underlying POSIX thread is starting up. | |
| 71 | +| `Inactive` | Thread is alive but has no handler assigned. Minimal memory footprint. | |
| 72 | +| `Ready` | Thread has a handler and is ready to accept work. | |
| 73 | +| `ShuttingDown` | Thread is shutting down. | |
| 74 | +| `Done` | Thread has completely shut down. Transitions back to `Reserved` for potential reuse. | |
| 75 | +| `Restarting` | Worker thread is being restarted (e.g., via admin API or file watcher). | |
| 76 | +| `Yielding` | Worker thread has yielded control and is waiting to be re-activated. | |
| 77 | +| `TransitionRequested` | A handler change has been requested from the Go side. | |
| 78 | +| `TransitionInProgress` | The C thread has acknowledged the transition request. | |
| 79 | +| `TransitionComplete` | The Go side has installed the new handler. | |
| 80 | + |
| 81 | +### Key Operations |
| 82 | + |
| 83 | +**`RequestSafeStateChange(nextState)`**: The primary way external goroutines request state changes. It: |
| 84 | +- Atomically succeeds from `Ready` or `Inactive` (under mutex) |
| 85 | +- Returns `false` immediately from `ShuttingDown`, `Done`, or `Reserved` |
| 86 | +- Blocks and retries from any other state, waiting for `Ready`, `Inactive`, or `ShuttingDown` |
| 87 | + |
| 88 | +This guarantees mutual exclusion: only one of `shutdown()`, `setHandler()`, or `drainWorkerThreads()` can succeed at a time on a given thread. |
| 89 | + |
| 90 | +**`WaitFor(states...)`**: Blocks until the thread reaches one of the specified states. Uses a channel-based subscriber pattern so waiters are efficiently notified. |
| 91 | + |
| 92 | +**`Set(nextState)`**: Unconditional state change. Used by the thread itself (from C callbacks) to signal state transitions. |
| 93 | + |
| 94 | +**`CompareAndSwap(compareTo, swapTo)`**: Atomic compare-and-swap. Used for boot initialization. |
| 95 | + |
| 96 | +### Handler Transition Protocol |
| 97 | + |
| 98 | +When a thread needs to change its handler (e.g., from inactive to worker): |
| 99 | + |
| 100 | +``` |
| 101 | +Go side (setHandler) C side (PHP thread) |
| 102 | +───────────────── ───────────────── |
| 103 | +RequestSafeStateChange( |
| 104 | + TransitionRequested) |
| 105 | +close(drainChan) |
| 106 | + detects drain |
| 107 | + Set(TransitionInProgress) |
| 108 | +WaitFor(TransitionInProgress) |
| 109 | + → unblocked WaitFor(TransitionComplete) |
| 110 | +handler = newHandler |
| 111 | +drainChan = make(chan struct{}) |
| 112 | +Set(TransitionComplete) |
| 113 | + → unblocked |
| 114 | + newHandler.beforeScriptExecution() |
| 115 | +``` |
| 116 | + |
| 117 | +This protocol ensures the handler pointer is never read and written concurrently. |
| 118 | + |
| 119 | +### Worker Restart Protocol |
| 120 | + |
| 121 | +When workers are restarted (e.g., via admin API): |
| 122 | + |
| 123 | +``` |
| 124 | +Go side (RestartWorkers) C side (worker thread) |
| 125 | +───────────────── ───────────────── |
| 126 | +RequestSafeStateChange( |
| 127 | + Restarting) |
| 128 | +close(drainChan) |
| 129 | + detects drain in waitForWorkerRequest() |
| 130 | + returns false → PHP script exits |
| 131 | + beforeScriptExecution(): |
| 132 | + state is Restarting → |
| 133 | + Set(Yielding) |
| 134 | +WaitFor(Yielding) |
| 135 | + → unblocked WaitFor(Ready, ShuttingDown) |
| 136 | +drainChan = make(chan struct{}) |
| 137 | +Set(Ready) |
| 138 | + → unblocked |
| 139 | + beforeScriptExecution() recurse: |
| 140 | + state is Ready → normal execution |
| 141 | +``` |
| 142 | + |
| 143 | +## CGO Boundary |
| 144 | + |
| 145 | +### Exported Go Functions |
| 146 | + |
| 147 | +C code calls Go functions via CGO exports. The main callbacks are: |
| 148 | + |
| 149 | +| Function | Called when | |
| 150 | +|----------|-----------| |
| 151 | +| `go_frankenphp_before_script_execution` | C loop needs the next script to execute | |
| 152 | +| `go_frankenphp_after_script_execution` | PHP script has finished executing | |
| 153 | +| `go_frankenphp_worker_handle_request_start` | Worker's `frankenphp_handle_request()` is called | |
| 154 | +| `go_frankenphp_finish_worker_request` | Worker request handler has returned | |
| 155 | +| `go_ub_write` | PHP produces output (`echo`, etc.) | |
| 156 | +| `go_read_post` | PHP reads POST body (`php://input`) | |
| 157 | +| `go_read_cookies` | PHP reads cookies | |
| 158 | +| `go_write_headers` | PHP sends response headers | |
| 159 | +| `go_sapi_flush` | PHP flushes output | |
| 160 | +| `go_log_attrs` | PHP logs a structured message | |
| 161 | + |
| 162 | +All these functions receive a `threadIndex` parameter identifying the calling thread. This is a thread-local variable in C (`__thread uintptr_t thread_index`) set during thread initialization. |
| 163 | + |
| 164 | +### C Thread Main Loop |
| 165 | + |
| 166 | +Each PHP thread runs `php_thread()` in `frankenphp.c`: |
| 167 | + |
| 168 | +```c |
| 169 | +while ((scriptName = go_frankenphp_before_script_execution(thread_index))) { |
| 170 | + php_request_startup(); |
| 171 | + php_execute_script(&file_handle); |
| 172 | + php_request_shutdown(); |
| 173 | + go_frankenphp_after_script_execution(thread_index, exit_status); |
| 174 | +} |
| 175 | +``` |
| 176 | + |
| 177 | +Bailouts (fatal PHP errors) are caught by `zend_catch`, which marks the thread as unhealthy and forces cleanup. |
| 178 | + |
| 179 | +### Memory Management |
| 180 | + |
| 181 | +- **Go → C strings**: `C.CString()` allocates with `malloc()`. The C side is responsible for freeing (e.g., `frankenphp_free_request_context()` frees cookie data). |
| 182 | +- **Go string pinning**: `thread.Pin()` / `thread.Unpin()` pins Go memory so C can safely reference it during script execution without copying. Unpinned after each script execution. |
| 183 | +- **PHP memory**: Managed by Zend's memory manager (`emalloc`/`efree`). Automatically freed at request shutdown. |
| 184 | + |
| 185 | +## Auto-Scaling |
| 186 | + |
| 187 | +FrankenPHP can automatically scale the number of PHP threads based on demand (`scaling.go`). |
| 188 | + |
| 189 | +### Configuration |
| 190 | + |
| 191 | +- `num_threads`: Initial number of threads started at boot |
| 192 | +- `max_threads`: Maximum number of threads allowed (includes auto-scaled) |
| 193 | + |
| 194 | +### Upscaling |
| 195 | + |
| 196 | +A single goroutine (`startUpscalingThreads`) reads from an unbuffered `scaleChan`: |
| 197 | + |
| 198 | +1. A request handler can't find an available thread |
| 199 | +2. It sends the request context to `scaleChan` |
| 200 | +3. The scaling goroutine checks: |
| 201 | + - Has the request been stalled long enough? (minimum 5ms) |
| 202 | + - Is CPU usage below the threshold? (80%) |
| 203 | + - Is the thread limit reached? |
| 204 | +4. If all checks pass, a new thread is booted and assigned |
| 205 | + |
| 206 | +### Downscaling |
| 207 | + |
| 208 | +A separate goroutine (`startDownScalingThreads`) periodically checks (every 5s) for idle auto-scaled threads. Threads idle longer than `maxIdleTime` (default 5s) are shut down, up to 10 per cycle. |
| 209 | + |
| 210 | +## Environment Sandboxing |
| 211 | + |
| 212 | +FrankenPHP sandboxes environment variables per-thread: |
| 213 | + |
| 214 | +1. At startup, the main thread snapshots `os.Environ()` into `main_thread_env` (a PHP `HashTable`) |
| 215 | +2. Each request copies `main_thread_env` into `$_SERVER` |
| 216 | +3. `frankenphp_putenv()` / `frankenphp_getenv()` use a thread-local `sandboxed_env` copy, preventing race conditions on the global environment |
| 217 | +4. The sandboxed environment is reset between requests via `reset_sandboxed_environment()` |
| 218 | + |
| 219 | +## Request Flow (Regular Mode) |
| 220 | + |
| 221 | +1. HTTP request arrives at Caddy |
| 222 | +2. FrankenPHP's Caddy module resolves the PHP script path |
| 223 | +3. A `frankenPHPContext` is created with the request and script info |
| 224 | +4. The context is sent to an available regular thread via `requestChan` |
| 225 | +5. The thread's `beforeScriptExecution()` returns the script filename |
| 226 | +6. The C layer executes the PHP script |
| 227 | +7. During execution, Go callbacks handle I/O (`go_ub_write`, `go_read_post`, etc.) |
| 228 | +8. After execution, `afterScriptExecution()` signals completion |
| 229 | +9. The response is sent to the client |
| 230 | + |
| 231 | +## Request Flow (Worker Mode) |
| 232 | + |
| 233 | +1. HTTP request arrives at Caddy |
| 234 | +2. FrankenPHP's Caddy module resolves the worker for this request |
| 235 | +3. A `frankenPHPContext` is created |
| 236 | +4. The context is sent to the worker's `requestChan` or a specific thread's `requestChan` |
| 237 | +5. The worker thread's `waitForWorkerRequest()` receives it |
| 238 | +6. PHP's `frankenphp_handle_request()` callback is invoked |
| 239 | +7. After the callback returns, `go_frankenphp_finish_worker_request()` cleans up |
| 240 | +8. The worker loops back to `waitForWorkerRequest()` |
0 commit comments