Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/changelogs/v4.7.7.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ Release Date: Unreleased

## Bugs Fixed

- **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.
8 changes: 7 additions & 1 deletion docs/installation/worker_mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions system/Boot.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@
*/
class Boot
{
/**
* Whether the application was booted by `bootWorker()`.
*/
private static bool $workerMode = false;

/**
* Used by `public/index.php`
*
Expand Down Expand Up @@ -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();
Expand All @@ -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`
Expand Down
4 changes: 4 additions & 0 deletions system/CodeIgniter.php
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,10 @@ public function resetForWorkerMode(): void
$this->totalTime = 0;

$this->resetKintForWorkerMode();

if (Services::has('exceptions')) {
Services::exceptions()->resetForWorkerMode();
}
}

/**
Expand Down
16 changes: 14 additions & 2 deletions system/Debug/ExceptionHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -91,7 +92,7 @@ public function handle(

$this->respond($data, $statusCode)->send();

if (ENVIRONMENT !== 'testing') {
if ($this->shouldExit()) {
// @codeCoverageIgnoreStart
exit($exitCode);
// @codeCoverageIgnoreEnd
Expand Down Expand Up @@ -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.
Expand Down
25 changes: 22 additions & 3 deletions system/Debug/Exceptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -69,7 +70,7 @@ class Exceptions
/**
* The outgoing response.
*
* @var ResponseInterface
* @var ResponseInterface|null
*/
protected $response;

Expand Down Expand Up @@ -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;
}

/**
Expand Down
18 changes: 18 additions & 0 deletions tests/system/CodeIgniterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
71 changes: 71 additions & 0 deletions tests/system/Debug/ExceptionHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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('provideHandleWebExceptionExitBehavior')]
public function testHandleWebExceptionExitBehavior(
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 = <<<PHP
chdir({$escapedHomePath});
define('ENVIRONMENT', 'production');
require {$escapedBootstrap};

use CodeIgniter\\Boot;
use CodeIgniter\\Debug\\ExceptionHandler;
use CodeIgniter\\Exceptions\\PageNotFoundException;
use Config\\Exceptions as ExceptionsConfig;

(new \\ReflectionProperty(Boot::class, 'workerMode'))->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<string, array{string, bool, string, int}>
*/
public static function provideHandleWebExceptionExitBehavior(): 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, '<title>404 - Page Not Found</title>', 0];
Comment thread
paulbalandan marked this conversation as resolved.
Resolved

yield 'HTML, traditional' => ['text/html', false, '<title>404 - Page Not Found</title>', EXIT_UNKNOWN_FILE];
}

public function testMaskSensitiveData(): void
{
$maskSensitiveData = self::getPrivateMethodInvoker($this->handler, 'maskSensitiveData');
Expand Down
13 changes: 13 additions & 0 deletions tests/system/Debug/ExceptionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}
}
Loading