• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / CodeIgniter4 / 41877220548

04 Nov 2026 05:23PM UTC coverage: 90.275%. First build
41877220548

Pull #10721

github

web-flow
Merge fc8fde034 into 654cd0ccd
Pull Request #10721: fix: prevent exception handlers from restarting FrankenPHP workers

8 of 13 new or added lines in 3 files covered. (61.54%)

23142 of 25635 relevant lines covered (90.28%)

239.74 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

34.09
/system/Debug/Exceptions.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\Debug;
15

16
use CodeIgniter\API\ResponseTrait;
17
use CodeIgniter\Boot;
18
use CodeIgniter\Exceptions\HasExitCodeInterface;
19
use CodeIgniter\Exceptions\HTTPExceptionInterface;
20
use CodeIgniter\Exceptions\PageNotFoundException;
21
use CodeIgniter\HTTP\Exceptions\HTTPException;
22
use CodeIgniter\HTTP\RequestInterface;
23
use CodeIgniter\HTTP\ResponseInterface;
24
use Config\Exceptions as ExceptionsConfig;
25
use Config\Paths;
26
use ErrorException;
27
use Psr\Log\LogLevel;
28
use Throwable;
29

30
/**
31
 * Exceptions manager
32
 */
33
class Exceptions
34
{
35
    use ResponseTrait;
36

37
    /**
38
     * Nesting level of the output buffering mechanism
39
     *
40
     * @var int
41
     *
42
     * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler.
43
     */
44
    public $ob_level;
45

46
    /**
47
     * The path to the directory containing the
48
     * cli and html error view directories.
49
     *
50
     * @var string
51
     *
52
     * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler.
53
     */
54
    protected $viewPath;
55

56
    /**
57
     * Config for debug exceptions.
58
     *
59
     * @var ExceptionsConfig
60
     */
61
    protected $config;
62

63
    /**
64
     * The request.
65
     *
66
     * @var RequestInterface|null
67
     */
68
    protected $request;
69

70
    /**
71
     * The outgoing response.
72
     *
73
     * @var ResponseInterface|null
74
     */
75
    protected $response;
76

77
    private ?Throwable $exceptionCaughtByExceptionHandler = null;
78

79
    public function __construct(ExceptionsConfig $config)
80
    {
81
        // For backward compatibility
82
        $this->ob_level = ob_get_level();
13 ✔
83
        $this->viewPath = rtrim($config->errorViewPath, '\\/ ') . DIRECTORY_SEPARATOR;
13 ✔
84

85
        $this->config = $config;
13 ✔
86
    }
87

88
    /**
89
     * Responsible for registering the error, exception and shutdown
90
     * handling of our application.
91
     *
92
     * @codeCoverageIgnore
93
     *
94
     * @return void
95
     */
96
    public function initialize()
97
    {
98
        set_exception_handler($this->exceptionHandler(...));
99
        set_error_handler($this->errorHandler(...));
100
        register_shutdown_function([$this, 'shutdownHandler']);
101
    }
102

103
    /**
104
     * Catches any uncaught errors and exceptions, including most Fatal errors
105
     * (Yay PHP7!). Will log the error, display it if display_errors is on,
106
     * and fire an event that allows custom actions to be taken at this point.
107
     *
108
     * @return void
109
     */
110
    public function exceptionHandler(Throwable $exception)
111
    {
112
        $this->exceptionCaughtByExceptionHandler = $exception;
×
113

114
        [$statusCode, $exitCode] = $this->determineCodes($exception);
×
115

116
        $this->request = service('request');
×
117

118
        if ($this->config->log === true && ! in_array($statusCode, $this->config->ignoreCodes, true)) {
×
119
            $uri       = $this->request->getPath() === '' ? '/' : $this->request->getPath();
×
120
            $routeInfo = '[Method: ' . $this->request->getMethod() . ', Route: ' . $uri . ']';
×
121

122
            log_message('critical', $exception::class . ": {message}\n{routeInfo}\nin {exFile} on line {exLine}.\n{trace}", [
×
123
                'message'   => $exception->getMessage(),
×
124
                'routeInfo' => $routeInfo,
×
125
                'exFile'    => clean_path($exception->getFile()), // {file} refers to THIS file
×
126
                'exLine'    => $exception->getLine(), // {line} refers to THIS line
×
127
                'trace'     => render_backtrace($exception->getTrace()),
×
128
            ]);
×
129

130
            // Get the first exception.
131
            $last = $exception;
×
132

133
            while ($prevException = $last->getPrevious()) {
×
134
                $last = $prevException;
×
135

136
                log_message('critical', '[Caused by] ' . $prevException::class . ": {message}\nin {exFile} on line {exLine}.\n{trace}", [
×
137
                    'message' => $prevException->getMessage(),
×
138
                    'exFile'  => clean_path($prevException->getFile()), // {file} refers to THIS file
×
139
                    'exLine'  => $prevException->getLine(), // {line} refers to THIS line
×
140
                    'trace'   => render_backtrace($prevException->getTrace()),
×
141
                ]);
×
142
            }
143
        }
144

145
        $this->response = service('response');
×
146

147
        if (method_exists($this->config, 'handler')) {
×
148
            // Use new ExceptionHandler
149
            $handler = $this->config->handler($statusCode, $exception);
×
150
            $handler->handle(
×
151
                $exception,
×
152
                $this->request,
×
153
                $this->response,
×
154
                $statusCode,
×
155
                $exitCode,
×
156
            );
×
157

158
            return;
×
159
        }
160

161
        // For backward compatibility
162
        if (! is_cli()) {
×
163
            try {
164
                $this->response->setStatusCode($statusCode);
×
165
            } catch (HTTPException) {
×
166
                // Workaround for invalid HTTP status code.
167
                $statusCode = 500;
×
168
                $this->response->setStatusCode($statusCode);
×
169
            }
170

171
            if (! headers_sent()) {
×
172
                header(sprintf('HTTP/%s %s %s', $this->request->getProtocolVersion(), $this->response->getStatusCode(), $this->response->getReasonPhrase()), true, $statusCode);
×
173
            }
174

175
            if (! str_contains($this->request->getHeaderLine('accept'), 'text/html')) {
×
176
                $this->respond(ENVIRONMENT === 'development' ? $this->collectVars($exception, $statusCode) : '', $statusCode)->send();
×
177

NEW
178
                if (! Boot::isWorkerMode()) {
×
NEW
179
                    exit($exitCode);
×
180
                }
181

NEW
182
                return;
×
183
            }
184
        }
185

186
        $this->render($exception, $statusCode);
×
187

NEW
188
        if (! Boot::isWorkerMode()) {
×
NEW
189
            exit($exitCode);
×
190
        }
191
    }
192

193
    /**
194
     * Clears the state of the previous request in worker mode.
195
     *
196
     * @internal
197
     */
198
    public function resetForWorkerMode(): void
199
    {
200
        $this->exceptionCaughtByExceptionHandler = null;
2 ✔
201
        $this->request                           = null;
2 ✔
202
        $this->response                          = null;
2 ✔
203
    }
204

205
    /**
206
     * The callback to be registered to `set_error_handler()`.
207
     *
208
     * @return bool
209
     *
210
     * @throws ErrorException
211
     *
212
     * @codeCoverageIgnore
213
     */
214
    public function errorHandler(int $severity, string $message, ?string $file = null, ?int $line = null)
215
    {
216
        if ($this->isDeprecationError($severity)) {
217
            if ($this->isSessionSidDeprecationError($message, $file, $line)) {
218
                return true;
219
            }
220

221
            if (! $this->config->logDeprecations || (bool) env('CODEIGNITER_SCREAM_DEPRECATIONS')) {
222
                throw new ErrorException($message, 0, $severity, $file, $line);
223
            }
224

225
            return $this->handleDeprecationError($message, $file, $line);
226
        }
227

228
        if ((error_reporting() & $severity) !== 0) {
229
            throw new ErrorException($message, 0, $severity, $file, $line);
230
        }
231

232
        return false; // return false to propagate the error to PHP standard error handler
233
    }
234

235
    /**
236
     * Handles session.sid_length and session.sid_bits_per_character deprecations
237
     * in PHP 8.4.
238
     */
239
    private function isSessionSidDeprecationError(string $message, ?string $file = null, ?int $line = null): bool
240
    {
241
        if (
242
            PHP_VERSION_ID >= 80400
2 ✔
243
            && str_contains($message, 'session.sid_')
2 ✔
244
        ) {
245
            log_message(
×
246
                LogLevel::WARNING,
×
247
                '[DEPRECATED] {message} in {errFile} on line {errLine}.',
×
248
                [
×
249
                    'message' => $message,
×
250
                    'errFile' => clean_path($file ?? ''),
×
251
                    'errLine' => $line ?? 0,
×
252
                ],
×
253
            );
×
254

255
            return true;
×
256
        }
257

258
        return false;
2 ✔
259
    }
260

261
    /**
262
     * Checks to see if any errors have happened during shutdown that
263
     * need to be caught and handle them.
264
     *
265
     * @codeCoverageIgnore
266
     *
267
     * @return void
268
     */
269
    public function shutdownHandler()
270
    {
271
        $error = error_get_last();
272

273
        if ($error === null) {
274
            return;
275
        }
276

277
        ['type' => $type, 'message' => $message, 'file' => $file, 'line' => $line] = $error;
278

279
        if ($this->exceptionCaughtByExceptionHandler instanceof Throwable) {
280
            $message .= "\n【Previous Exception】\n"
281
                . $this->exceptionCaughtByExceptionHandler::class . "\n"
282
                . $this->exceptionCaughtByExceptionHandler->getMessage() . "\n"
283
                . $this->exceptionCaughtByExceptionHandler->getTraceAsString();
284
        }
285

286
        if (in_array($type, [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE], true)) {
287
            $this->exceptionHandler(new ErrorException($message, 0, $type, $file, $line));
288
        }
289
    }
290

291
    /**
292
     * Determines the view to display based on the exception thrown,
293
     * whether an HTTP or CLI request, etc.
294
     *
295
     * @return string The path and filename of the view file to use
296
     *
297
     * @deprecated 4.4.0 No longer used. Moved to ExceptionHandler.
298
     */
299
    protected function determineView(Throwable $exception, string $templatePath): string
300
    {
301
        // Production environments should have a custom exception file.
302
        $view         = 'production.php';
303
        $templatePath = rtrim($templatePath, '\\/ ') . DIRECTORY_SEPARATOR;
304

305
        if (
306
            in_array(
307
                strtolower(ini_get('display_errors')),
308
                ['1', 'true', 'on', 'yes'],
309
                true,
310
            )
311
        ) {
312
            $view = 'error_exception.php';
313
        }
314

315
        // 404 Errors
316
        if ($exception instanceof PageNotFoundException) {
317
            return 'error_404.php';
318
        }
319

320
        // Allow for custom views based upon the status code
321
        if (is_file($templatePath . 'error_' . $exception->getCode() . '.php')) {
322
            return 'error_' . $exception->getCode() . '.php';
323
        }
324

325
        return $view;
326
    }
327

328
    /**
329
     * Given an exception and status code will display the error to the client.
330
     *
331
     * @return void
332
     *
333
     * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler.
334
     */
335
    protected function render(Throwable $exception, int $statusCode)
336
    {
337
        // Determine possible directories of error views
338
        $path    = $this->viewPath;
339
        $altPath = rtrim((new Paths())->viewDirectory, '\\/ ') . DIRECTORY_SEPARATOR . 'errors' . DIRECTORY_SEPARATOR;
340

341
        $path    .= (is_cli() ? 'cli' : 'html') . DIRECTORY_SEPARATOR;
342
        $altPath .= (is_cli() ? 'cli' : 'html') . DIRECTORY_SEPARATOR;
343

344
        // Determine the views
345
        $view    = $this->determineView($exception, $path);
346
        $altView = $this->determineView($exception, $altPath);
347

348
        // Check if the view exists
349
        if (is_file($path . $view)) {
350
            $viewFile = $path . $view;
351
        } elseif (is_file($altPath . $altView)) {
352
            $viewFile = $altPath . $altView;
353
        }
354

355
        if (! isset($viewFile)) {
356
            echo 'The error view files were not found. Cannot render exception trace.';
357

358
            exit(1);
359
        }
360

361
        echo (function () use ($exception, $statusCode, $viewFile): string {
362
            $vars = $this->collectVars($exception, $statusCode);
363
            extract($vars, EXTR_SKIP);
364

365
            ob_start();
366
            include $viewFile;
367

368
            return ob_get_clean();
369
        })();
370
    }
371

372
    /**
373
     * Gathers the variables that will be made available to the view.
374
     *
375
     * @return array{title: string, type: string, code: int, message: string, file: string, line: int, trace: list<array<string, mixed>>}
376
     *
377
     * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler.
378
     */
379
    protected function collectVars(Throwable $exception, int $statusCode): array
380
    {
381
        // Get the first exception.
382
        $firstException = $exception;
383

384
        while ($prevException = $firstException->getPrevious()) {
385
            $firstException = $prevException;
386
        }
387

388
        $trace = $firstException->getTrace();
389

390
        if ($this->config->sensitiveDataInTrace !== []) {
391
            $trace = $this->maskSensitiveData($trace, $this->config->sensitiveDataInTrace);
392
        }
393

394
        return [
395
            'title'   => $exception::class,
396
            'type'    => $exception::class,
397
            'code'    => $statusCode,
398
            'message' => $exception->getMessage(),
399
            'file'    => $exception->getFile(),
400
            'line'    => $exception->getLine(),
401
            'trace'   => $trace,
402
        ];
403
    }
404

405
    /**
406
     * Mask sensitive data in the trace.
407
     *
408
     * @param list<array<string, mixed>> $trace
409
     * @param list<string>               $keysToMask
410
     *
411
     * @return list<array<string, mixed>>
412
     *
413
     * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler.
414
     */
415
    protected function maskSensitiveData($trace, array $keysToMask, string $path = '')
416
    {
417
        foreach ($trace as $i => $line) {
418
            $trace[$i]['args'] = $this->maskData($line['args'], $keysToMask);
419
        }
420

421
        return $trace;
422
    }
423

424
    /**
425
     * @param array<array-key, mixed>|object $args
426
     * @param list<string>                   $keysToMask
427
     *
428
     * @return array<array-key, mixed>|object
429
     *
430
     * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler.
431
     */
432
    private function maskData($args, array $keysToMask, string $path = '')
433
    {
434
        foreach ($keysToMask as $keyToMask) {
435
            $explode = explode('/', $keyToMask);
436
            $index   = end($explode);
437

438
            if (str_starts_with(strrev($path . '/' . $index), strrev($keyToMask))) {
439
                if (is_array($args) && array_key_exists($index, $args)) {
440
                    $args[$index] = '******************';
441
                } elseif (
442
                    is_object($args) && property_exists($args, $index)
443
                    && isset($args->{$index}) && is_scalar($args->{$index})
444
                ) {
445
                    $args->{$index} = '******************';
446
                }
447
            }
448
        }
449

450
        if (is_array($args)) {
451
            foreach ($args as $pathKey => $subarray) {
452
                $args[$pathKey] = $this->maskData($subarray, $keysToMask, $path . '/' . $pathKey);
453
            }
454
        } elseif (is_object($args)) {
455
            foreach ($args as $pathKey => $subarray) {
456
                $args->{$pathKey} = $this->maskData($subarray, $keysToMask, $path . '/' . $pathKey);
457
            }
458
        }
459

460
        return $args;
461
    }
462

463
    /**
464
     * Determines the HTTP status code and the exit status code for this request.
465
     *
466
     * @return array{int, int}
467
     */
468
    protected function determineCodes(Throwable $exception): array
469
    {
470
        $statusCode = 500;
1 ✔
471
        $exitStatus = EXIT_ERROR;
1 ✔
472

473
        if ($exception instanceof HTTPExceptionInterface) {
1 ✔
474
            $statusCode = $exception->getCode();
1 ✔
475
        }
476

477
        if ($exception instanceof HasExitCodeInterface) {
1 ✔
478
            $exitStatus = $exception->getExitCode();
1 ✔
479
        }
480

481
        return [$statusCode, $exitStatus];
1 ✔
482
    }
483

484
    private function isDeprecationError(int $error): bool
485
    {
486
        $deprecations = E_DEPRECATED | E_USER_DEPRECATED;
146 ✔
487

488
        return ($error & $deprecations) !== 0;
146 ✔
489
    }
490

491
    /**
492
     * @return true
493
     */
494
    private function handleDeprecationError(string $message, ?string $file = null, ?int $line = null): bool
495
    {
496
        // Remove the trace of the error handler.
497
        $trace = array_slice(debug_backtrace(), 2);
2 ✔
498

499
        log_message(
2 ✔
500
            $this->config->deprecationLogLevel,
2 ✔
501
            "[DEPRECATED] {message} in {errFile} on line {errLine}.\n{trace}",
2 ✔
502
            [
2 ✔
503
                'message' => $message,
2 ✔
504
                'errFile' => clean_path($file ?? ''),
2 ✔
505
                'errLine' => $line ?? 0,
2 ✔
506
                'trace'   => render_backtrace($trace),
2 ✔
507
            ],
2 ✔
508
        );
2 ✔
509

510
        return true;
2 ✔
511
    }
512

513
    // --------------------------------------------------------------------
514
    // Display Methods
515
    // --------------------------------------------------------------------
516

517
    /**
518
     * This makes nicer looking paths for the error output.
519
     *
520
     * @deprecated Use dedicated `clean_path()` function.
521
     */
522
    public static function cleanPath(string $file): string
523
    {
524
        return match (true) {
525
            str_starts_with($file, APPPATH)                             => 'APPPATH' . DIRECTORY_SEPARATOR . substr($file, strlen(APPPATH)),
526
            str_starts_with($file, SYSTEMPATH)                          => 'SYSTEMPATH' . DIRECTORY_SEPARATOR . substr($file, strlen(SYSTEMPATH)),
527
            str_starts_with($file, FCPATH)                              => 'FCPATH' . DIRECTORY_SEPARATOR . substr($file, strlen(FCPATH)),
528
            defined('VENDORPATH') && str_starts_with($file, VENDORPATH) => 'VENDORPATH' . DIRECTORY_SEPARATOR . substr($file, strlen(VENDORPATH)),
529
            default                                                     => $file,
530
        };
531
    }
532

533
    /**
534
     * Describes memory usage in real-world units. Intended for use
535
     * with memory_get_usage, etc.
536
     *
537
     * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler.
538
     */
539
    public static function describeMemory(int $bytes): string
540
    {
541
        if ($bytes < 1024) {
542
            return $bytes . 'B';
543
        }
544

545
        if ($bytes < 1_048_576) {
546
            return round($bytes / 1024, 2) . 'KB';
547
        }
548

549
        return round($bytes / 1_048_576, 2) . 'MB';
550
    }
551

552
    /**
553
     * Creates a syntax-highlighted version of a PHP file.
554
     *
555
     * @return bool|string
556
     *
557
     * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler.
558
     */
559
    public static function highlightFile(string $file, int $lineNumber, int $lines = 15)
560
    {
561
        if ($file === '' || ! is_readable($file)) {
562
            return false;
563
        }
564

565
        // Set our highlight colors:
566
        if (function_exists('ini_set')) {
567
            ini_set('highlight.comment', '#767a7e; font-style: italic');
568
            ini_set('highlight.default', '#c7c7c7');
569
            ini_set('highlight.html', '#06B');
570
            ini_set('highlight.keyword', '#f1ce61;');
571
            ini_set('highlight.string', '#869d6a');
572
        }
573

574
        try {
575
            $source = file_get_contents($file);
576
        } catch (Throwable) {
577
            return false;
578
        }
579

580
        $source = str_replace(["\r\n", "\r"], "\n", $source);
581
        $source = explode("\n", highlight_string($source, true));
582
        $source = str_replace('<br />', "\n", $source[1]);
583
        $source = explode("\n", str_replace("\r\n", "\n", $source));
584

585
        // Get just the part to show
586
        $start = max($lineNumber - (int) round($lines / 2), 0);
587

588
        // Get just the lines we need to display, while keeping line numbers...
589
        $source = array_splice($source, $start, $lines, true);
590

591
        // Used to format the line number in the source
592
        $format = '% ' . strlen((string) ($start + $lines)) . 'd';
593

594
        $out = '';
595
        // Because the highlighting may have an uneven number
596
        // of open and close span tags on one line, we need
597
        // to ensure we can close them all to get the lines
598
        // showing correctly.
599
        $spans = 1;
600

601
        foreach ($source as $n => $row) {
602
            $spans += substr_count($row, '<span') - substr_count($row, '</span');
603
            $row = str_replace(["\r", "\n"], ['', ''], $row);
604

605
            if (($n + $start + 1) === $lineNumber) {
606
                preg_match_all('#<[^>]+>#', $row, $tags);
607

608
                $out .= sprintf(
609
                    "<span class='line highlight'><span class='number'>{$format}</span> %s\n</span>%s",
610
                    $n + $start + 1,
611
                    strip_tags($row),
612
                    implode('', $tags[0]),
613
                );
614
            } else {
615
                $out .= sprintf('<span class="line"><span class="number">' . $format . '</span> %s', $n + $start + 1, $row) . "\n";
616
            }
617
        }
618

619
        if ($spans > 0) {
620
            $out .= str_repeat('</span>', $spans);
621
        }
622

623
        return '<pre><code>' . $out . '</code></pre>';
624
    }
625
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc