I'm working on a CodeIgniter 4 project (CI: 4.4.3 production mode | PHP: 8.1.32), and I'm running into a strange issue where my finally block never executes if a database error happens during execution — even though I'm catching \Throwable (and doesn't remove the lock file).
But when I uncomment register_shutdown_function(), it works and cleans up the lock file.
Here's a minimal reproduction:
run() method in CronRunnernamespace App\Models;
class CronRunner extends CI_Model {
public function run($data) {
$result = "OK";
$executedAt = date('Y-m-d H:i:s'); // for logs
$executedAtUnix = time(); // for duration calc
$lockFile = WRITEPATH . 'cache/cronRunner.lock';
if (file_exists($lockFile))
return;
file_put_contents($lockFile, ENVIRONMENT);
// This works — when it's enabled, the file gets removed
// register_shutdown_function(function () use ($lockFile) {
// if (file_exists($lockFile)) {
// unlink($lockFile);
// }
// });
try {
$this->notifyLongRunningTask();
} catch (\Throwable $e2) {
$result = $e2->getMessage();
log_message('error', (string) $result);
} finally {
log_message('error', 'FINALLY BLOCK REACHED');
if (file_exists($lockFile)) {
unlink($lockFile);
}
}
return ['result' => $result];
}
}
notifyLongRunningTask()function notifyLongRunningTask() {
$PushSubscriptions = new PushSubscriptions();
$PushSentLog = new PushSentLog();
$db = \Config\Database::connect();
$sql = "
SELECT hs.id as hourly_id, date_started, time_started, hs.organization_id, hs.user_created, c.name Client, p.client_id
FROM HourlySheet hs
JOIN ToDo t ON hs.todo_id = t.id
JOIN Project p ON t.project_id = p.id
JOIN Client c ON p.client_id = c.id
WHERE time_finished IS NULL
";
$query = $db->query($sql);
foreach ($query->getResult() as $row) {
$startedAt = strtotime($row->date_started . ' ' . $row->time_started);
$now = time();
// This line causes the issue
$PushSentLog_res = $PushSentLog->select(" AND user_created = {$row->user_created} AND name = 'notifyLongRunningTask' ORDER BY id DESC LIMIT 1 ");
// ...
}
}
mysqli_sql_exception: Column 'name' in WHERE is ambiguous
And the query is triggered from within the select() method of our CI_Model-based class. That method includes this logic:
if (!$query) {
$error = $db->error()["message"];
$baseClass = get_parent_class($this);
$this->log_it($error, $baseClass . " " . __FUNCTION__ . ' DB Error');
return ['result' => (string) $error];
}
So the SQL error is:
['result' => '...']But still — somehow — this causes PHP/CI4 to terminate execution before finally runs, and the lock file is not removed.
If I uncomment the register_shutdown_function(), then the lock file is removed as expected.
This tells me the script is being shut down unexpectedly, despite \Throwable being caught, and no exit() or fatal visible.
DBDebug is set to false (production mode)select() (custom function)finally block from the run() method is never reachedWhy is the finally block not executed, even though the exception is handled and no uncaught fatal is shown?
Is this a PHP 8.1 + CI4 side effect?
Or are certain internal CI4 behaviors forcing a shutdown?
Any insight from CI4 internals or PHP changes in 8.1+ would be much appreciated.
Why is the finally block not executed, even though the exception is handled and no uncaught fatal is shown?
Even though PHP promises finally blocks are always run, they do not execute if a fatal error or engine-level crash occurs ( out-of-memory, invalid internal op , destructor-triggered errors etc). Such type of errors bypass exception handling , and invoke script shutdown
Is this a PHP 8.1 + CI4 side effect?
This behaviour might be a CI4 database internals, after the exception is caught — particularly due to destructing queries ,invalid calls inside models or lazy connections It might also be from a PHP fatal error being triggered by a __destruct() method
if $query->getResult() was not valid or failed halfway Even after your select() method catches the query error, CI4 might still attempt to perform some additional follow up like result parsing, model hydration, that might lerads to fatal error inside __destruct() .
you can use register_shutdown_function() hook which is executed at script termination , this will run even if finally is skipped due to whatever.
you can add this to your run() method for a precheck
register_shutdown_function(function () use ($lockFile) {
$error = error_get_last();
if ($error !== null) {
log_message('critical', 'fatal error: ' . print_r($error, true));
}
});
Keep this as a fallback —
methods like $PushSentLog->select(...) ... can be placed in their own try catch block to better get the exceptions.
Also please take care you are not breaking CI's internal SQL compiler or query builder .CI4's Model::select() expects field names, not raw WHERE conditions. If you’re doing:
$PushSentLog->where('model_created', $row->model_created)
->where('name', 'notifyLongRunningTask')
->orderBy('id', 'DESC')
->limit(1)
->find();
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With