From ea03b467d600a8765698f94055660551f0d13c38 Mon Sep 17 00:00:00 2001 From: michalsn Date: Wed, 4 Nov 2026 08:59:43 +0100 Subject: [PATCH 1/2] fix: prevent exception handlers from restarting FrankenPHP workers --- docs/changelogs/v4.7.7.md | 1 + docs/installation/worker_mode.md | 8 ++- system/Boot.php | 17 +++++ system/CodeIgniter.php | 4 ++ system/Debug/ExceptionHandler.php | 16 ++++- system/Debug/Exceptions.php | 25 +++++++- tests/system/CodeIgniterTest.php | 18 ++++++ tests/system/Debug/ExceptionHandlerTest.php | 71 +++++++++++++++++++++ tests/system/Debug/ExceptionsTest.php | 13 ++++ 9 files changed, 167 insertions(+), 6 deletions(-) diff --git a/docs/changelogs/v4.7.7.md b/docs/changelogs/v4.7.7.md index e5fa4c2ba416..cdf4eb2db148 100644 --- a/docs/changelogs/v4.7.7.md +++ b/docs/changelogs/v4.7.7.md @@ -15,5 +15,6 @@ Release Date: Unreleased ## Bugs Fixed - **Security:** Fixed a bug where `Security::__construct()` threw a `TypeError` when the CSRF cookie was an array, causing an HTTP 500 response for affected requests. +- **Worker Mode:** Fixed a bug where every request that ended with an uncaught exception, including a `404 Page Not Found` without a 404 override, called `exit()` in the exception handler. This ended the worker script, so FrankenPHP booted a new one, and the worker loop skipped its cleanup after the request. The exception handler now returns to the worker loop. See the repo's [CHANGELOG.md](https://github.com/codeigniter4/CodeIgniter4/blob/develop/CHANGELOG.md) for a complete list of bugs fixed. diff --git a/docs/installation/worker_mode.md b/docs/installation/worker_mode.md index 58bad275ae99..06d99b4efa38 100644 --- a/docs/installation/worker_mode.md +++ b/docs/installation/worker_mode.md @@ -148,7 +148,7 @@ The `$persistentServices` array controls which services survive between requests |----|----| | `autoloader` | PSR-4 autoloading configuration. Safe to persist as class maps don't change. | | `locator` | File locator for finding framework files. Caches file paths for performance. | -| `exceptions` | Exception handler. Stateless, safe to reuse. | +| `exceptions` | Exception handler. Its request state is cleared before each request. | | `commands` | CLI commands registry. Only used during worker startup. | | `codeigniter` | Main application instance. Orchestrates the request/response cycle. | | `superglobals` | Superglobals wrapper. Properly isolated per request internally. | @@ -249,6 +249,12 @@ Worker Mode can make debugging more challenging since the process persists acros - **Check logs** in **writable/logs/** for connection and state-related issues - **Use logging liberally** to trace request flow across the persistent process +### Error Handling + +When a request ends with an uncaught exception, such as a `PageNotFoundException`, the exception handler displays the error and returns to the worker loop, which cleans up after the request as usual. The worker does not restart. + +If you use a custom exception handler (see [Custom Exception Handlers](../general/errors.md#custom-exception-handlers)), do not call `exit()` in it. In Worker Mode, `exit()` ends the worker script, and FrankenPHP has to boot a new one. + ### Session and Cache Handlers **File-based handlers** may experience file locking contention between workers. For production environments, consider using: diff --git a/system/Boot.php b/system/Boot.php index a1b8c9695004..36ea8a61a415 100644 --- a/system/Boot.php +++ b/system/Boot.php @@ -30,6 +30,11 @@ */ class Boot { + /** + * Whether the application was booted by `bootWorker()`. + */ + private static bool $workerMode = false; + /** * Used by `public/index.php` * @@ -87,6 +92,8 @@ public static function bootWeb(Paths $paths): int */ public static function bootWorker(Paths $paths): CodeIgniter { + self::$workerMode = true; + static::definePathConstants($paths); if (! defined('APP_NAMESPACE')) { static::loadConstants(); @@ -109,6 +116,16 @@ public static function bootWorker(Paths $paths): CodeIgniter return Boot::initializeCodeIgniter(); } + /** + * Whether the application runs in FrankenPHP worker mode. + * + * @internal + */ + public static function isWorkerMode(): bool + { + return self::$workerMode; + } + /** * Used by command line scripts other than * * `spark` diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index a61360804d6a..e794a6f573da 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -208,6 +208,10 @@ public function resetForWorkerMode(): void $this->totalTime = 0; $this->resetKintForWorkerMode(); + + if (Services::has('exceptions')) { + Services::exceptions()->resetForWorkerMode(); + } } /** diff --git a/system/Debug/ExceptionHandler.php b/system/Debug/ExceptionHandler.php index e884052f5a87..d19d4b4fba71 100644 --- a/system/Debug/ExceptionHandler.php +++ b/system/Debug/ExceptionHandler.php @@ -15,6 +15,7 @@ use Closure; use CodeIgniter\API\ResponseTrait; +use CodeIgniter\Boot; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\HTTP\CLIRequest; use CodeIgniter\HTTP\Exceptions\HTTPException; @@ -91,7 +92,7 @@ public function handle( $this->respond($data, $statusCode)->send(); - if (ENVIRONMENT !== 'testing') { + if ($this->shouldExit()) { // @codeCoverageIgnoreStart exit($exitCode); // @codeCoverageIgnoreEnd @@ -122,13 +123,24 @@ public function handle( // Displays the HTML or CLI error code. $this->render($exception, $statusCode, $viewFile); - if (ENVIRONMENT !== 'testing') { + if ($this->shouldExit()) { // @codeCoverageIgnoreStart exit($exitCode); // @codeCoverageIgnoreEnd } } + /** + * Whether to end the script after the error is displayed. + * + * In worker mode, the worker loop must keep running, so the + * handler returns and the worker cleans up after the request. + */ + private function shouldExit(): bool + { + return ENVIRONMENT !== 'testing' && ! Boot::isWorkerMode(); + } + /** * Determines the view to display based on the exception thrown, HTTP status * code, whether an HTTP or CLI request, etc. diff --git a/system/Debug/Exceptions.php b/system/Debug/Exceptions.php index 4fc5feddd035..59f47022236b 100644 --- a/system/Debug/Exceptions.php +++ b/system/Debug/Exceptions.php @@ -14,6 +14,7 @@ namespace CodeIgniter\Debug; use CodeIgniter\API\ResponseTrait; +use CodeIgniter\Boot; use CodeIgniter\Exceptions\HasExitCodeInterface; use CodeIgniter\Exceptions\HTTPExceptionInterface; use CodeIgniter\Exceptions\PageNotFoundException; @@ -69,7 +70,7 @@ class Exceptions /** * The outgoing response. * - * @var ResponseInterface + * @var ResponseInterface|null */ protected $response; @@ -174,13 +175,31 @@ public function exceptionHandler(Throwable $exception) if (! str_contains($this->request->getHeaderLine('accept'), 'text/html')) { $this->respond(ENVIRONMENT === 'development' ? $this->collectVars($exception, $statusCode) : '', $statusCode)->send(); - exit($exitCode); + if (! Boot::isWorkerMode()) { + exit($exitCode); + } + + return; } } $this->render($exception, $statusCode); - exit($exitCode); + if (! Boot::isWorkerMode()) { + exit($exitCode); + } + } + + /** + * Clears the state of the previous request in worker mode. + * + * @internal + */ + public function resetForWorkerMode(): void + { + $this->exceptionCaughtByExceptionHandler = null; + $this->request = null; + $this->response = null; } /** diff --git a/tests/system/CodeIgniterTest.php b/tests/system/CodeIgniterTest.php index da65ffd4a22d..7f87b4c3b87e 100644 --- a/tests/system/CodeIgniterTest.php +++ b/tests/system/CodeIgniterTest.php @@ -18,6 +18,7 @@ use CodeIgniter\Config\Services; use CodeIgniter\Debug\Timer; use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\Exceptions\RuntimeException; use CodeIgniter\HTTP\Method; use CodeIgniter\HTTP\Response; use CodeIgniter\HTTP\ResponseInterface; @@ -1309,6 +1310,23 @@ public function testResetForWorkerMode(): void $this->assertTrue(RichRenderer::$needs_pre_render); } + public function testResetForWorkerModeClearsExceptionsState(): void + { + $this->resetServices(); + + $exceptions = Services::exceptions(); + $this->setPrivateProperty($exceptions, 'exceptionCaughtByExceptionHandler', new RuntimeException('Previous request')); + $this->setPrivateProperty($exceptions, 'request', service('request')); + $this->setPrivateProperty($exceptions, 'response', service('response')); + + $codeigniter = new MockCodeIgniter(new App()); + $codeigniter->resetForWorkerMode(); + + $this->assertNull($this->getPrivateProperty($exceptions, 'exceptionCaughtByExceptionHandler')); + $this->assertNull($this->getPrivateProperty($exceptions, 'request')); + $this->assertNull($this->getPrivateProperty($exceptions, 'response')); + } + public function testGatherOutputCalledOnceWhenControllerReturnsResponse(): void { $this->resetServices(); diff --git a/tests/system/Debug/ExceptionHandlerTest.php b/tests/system/Debug/ExceptionHandlerTest.php index 59eaa8c203d6..915db65ab9e4 100644 --- a/tests/system/Debug/ExceptionHandlerTest.php +++ b/tests/system/Debug/ExceptionHandlerTest.php @@ -22,6 +22,7 @@ use CodeIgniter\Test\StreamFilterTrait; use Config\Exceptions as ExceptionsConfig; use Config\Services; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use stdClass; @@ -168,6 +169,76 @@ public function testHandleCLIPageNotFoundException(): void $this->resetStreamFilterBuffer(); } + /** + * The handler can only exit outside the testing environment, so it + * runs in a separate process with the production environment. + */ + #[DataProvider('provideHandleWebExceptionExitsOutsideWorkerMode')] + public function testHandleWebExceptionExitsOutsideWorkerMode( + string $accept, + bool $workerMode, + string $expectedOutput, + int $expectedStatus, + ): void { + $escapedHomePath = var_export(HOMEPATH, true); + $escapedBootstrap = var_export(SYSTEMPATH . 'util_bootstrap.php', true); + $escapedAccept = var_export($accept, true); + $escapedWorker = var_export($workerMode, true); + + $code = <<setValue(null, {$escapedWorker}); + + \$request = service('incomingrequest', null, false); + \$request->setHeader('accept', {$escapedAccept}); + + (new ExceptionHandler(new ExceptionsConfig()))->handle( + PageNotFoundException::forPageNotFound(), + \$request, + service('response'), + 404, + EXIT_UNKNOWN_FILE, + ); + + echo 'HANDLER RETURNED'; + PHP; + + $cmd = sprintf('%s -r %s 2>&1', escapeshellarg(PHP_BINARY), escapeshellarg($code)); + exec($cmd, $output, $status); + $output = implode(PHP_EOL, $output); + + $this->assertSame($expectedStatus, $status, $output); + $this->assertStringContainsString($expectedOutput, $output); + + if ($workerMode) { + $this->assertStringEndsWith('HANDLER RETURNED', $output); + } else { + $this->assertStringNotContainsString('HANDLER RETURNED', $output); + } + } + + /** + * @return iterable + */ + public static function provideHandleWebExceptionExitsOutsideWorkerMode(): iterable + { + yield 'non-HTML, worker mode' => ['application/json', true, '""', 0]; + + yield 'non-HTML, traditional' => ['application/json', false, '""', EXIT_UNKNOWN_FILE]; + + yield 'HTML, worker mode' => ['text/html', true, '404 - Page Not Found', 0]; + + yield 'HTML, traditional' => ['text/html', false, '404 - Page Not Found', EXIT_UNKNOWN_FILE]; + } + public function testMaskSensitiveData(): void { $maskSensitiveData = self::getPrivateMethodInvoker($this->handler, 'maskSensitiveData'); diff --git a/tests/system/Debug/ExceptionsTest.php b/tests/system/Debug/ExceptionsTest.php index 08f006637bcb..10de625fb25f 100644 --- a/tests/system/Debug/ExceptionsTest.php +++ b/tests/system/Debug/ExceptionsTest.php @@ -205,4 +205,17 @@ public function testMaskSensitiveDataTraceDataKey(): void $this->assertSame('/var/www/CodeIgniter4/app/Controllers/Home.php', $newTrace[0]['file']); } + + public function testResetForWorkerModeClearsPreviousRequestState(): void + { + $this->setPrivateProperty($this->exception, 'exceptionCaughtByExceptionHandler', new RuntimeException('Previous request')); + $this->setPrivateProperty($this->exception, 'request', service('request')); + $this->setPrivateProperty($this->exception, 'response', service('response')); + + $this->exception->resetForWorkerMode(); + + $this->assertNull($this->getPrivateProperty($this->exception, 'exceptionCaughtByExceptionHandler')); + $this->assertNull($this->getPrivateProperty($this->exception, 'request')); + $this->assertNull($this->getPrivateProperty($this->exception, 'response')); + } } From f272a822e1e957b47bb3191b3a2bde1e22c21323 Mon Sep 17 00:00:00 2001 From: michalsn Date: Wed, 4 Nov 2026 18:23:47 +0100 Subject: [PATCH 2/2] apply code suggestion --- tests/system/Debug/ExceptionHandlerTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/system/Debug/ExceptionHandlerTest.php b/tests/system/Debug/ExceptionHandlerTest.php index 915db65ab9e4..41aa21ec1d21 100644 --- a/tests/system/Debug/ExceptionHandlerTest.php +++ b/tests/system/Debug/ExceptionHandlerTest.php @@ -173,8 +173,8 @@ public function testHandleCLIPageNotFoundException(): void * The handler can only exit outside the testing environment, so it * runs in a separate process with the production environment. */ - #[DataProvider('provideHandleWebExceptionExitsOutsideWorkerMode')] - public function testHandleWebExceptionExitsOutsideWorkerMode( + #[DataProvider('provideHandleWebExceptionExitBehavior')] + public function testHandleWebExceptionExitBehavior( string $accept, bool $workerMode, string $expectedOutput, @@ -228,7 +228,7 @@ public function testHandleWebExceptionExitsOutsideWorkerMode( /** * @return iterable */ - public static function provideHandleWebExceptionExitsOutsideWorkerMode(): iterable + public static function provideHandleWebExceptionExitBehavior(): iterable { yield 'non-HTML, worker mode' => ['application/json', true, '""', 0];