Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[9.x] Return non-zero exit code for uncaught exceptions #46541

Merged
merged 12 commits into from
Apr 6, 2023
8 changes: 7 additions & 1 deletion src/Illuminate/Foundation/Bootstrap/HandleExceptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -194,17 +194,23 @@ public function handleException(Throwable $e)
{
self::$reservedMemory = null;

$uncaught = false;

try {
$this->getExceptionHandler()->report($e);
} catch (Exception $e) {
//
$uncaught = true;
}

if (static::$app->runningInConsole()) {
$this->renderForConsole($e);
} else {
$this->renderHttpResponse($e);
}

if ($uncaught) {
exit(1);
Copy link
Member

@crynobone crynobone Mar 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't this cause Octane to exit the worker on every uncaught exception, which is not desired?

laravel/octane#654

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good question. That could be the case yes.

Anyhow, there is no way to say to php "exit with exitCode=1 on your own accord (when its time to exit)". AFAIK you must call exit(1) yourself which will then still call any remaining __destruct() listeners and shutdown handlers.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved the exit(1) call a few lines up now (inside the $app->runningInConsole() since this is a command line related issue)

Copy link
Member

@crynobone crynobone Mar 30, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Octane workers still will have $app->runningInConsole() === true since it is an artisan command.

Octane worker emits APP_RUNNING_IN_CONSOLE=false so should be okay.

$this->isRunningInConsole = Env::get('APP_RUNNING_IN_CONSOLE') ?? (\PHP_SAPI === 'cli' || \PHP_SAPI === 'phpdbg');

}
}

/**
Expand Down
134 changes: 134 additions & 0 deletions src/Illuminate/Foundation/Testing/Concerns/MakesArtisanScript.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?php

namespace Illuminate\Foundation\Testing\Concerns;

use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Symfony\Component\Process\PhpExecutableFinder;

trait MakesArtisanScript
{
/**
* The file system.
*
* @var Filesystem|null
*/
protected $fileSystem;

/**
* The contents of the original Artisan file if it exists.
*
* @var string|null
*/
protected $originalArtisanFile;

/**
* Create an Artisan script in the Laravel testbench core for external testing.
*
* @param array<string, callable>|callable $slots
* @return string
*/
public function setUpArtisanScript($slots = []): string
{
$this->fileSystem = new Filesystem;
$path = base_path('artisan');

$uuid = Str::random(32);

// Save existing artisan script if there is one.
if ($this->fileSystem->exists($path)) {
$this->originalArtisanFile = $this->fileSystem->get($path);
}

$this->fileSystem->put(
base_path('artisan'),
$this->buildArtisanScript($uuid, $slots)
);

return $uuid;
}

/**
* Delete an Artisan script and revert it to the cached original.
*
* @return void
*/
public function tearDownArtisanScript(): void
{
$this->fileSystem->delete(base_path('artisan'));
if (! is_null($this->originalArtisanFile)) {
$this->fileSystem->put(base_path('artisan'), $this->originalArtisanFile);
}
}

/**
* Execute the Artisan script in a separate process and return the output and exit code.
*
* @param string $command
* @return array
*/
public function artisanScript($command): array
{
$output = $exitCode = null;
exec((new PhpExecutableFinder)->find().' '.base_path('artisan').' '.$command, $output, $exitCode);

return [$output, $exitCode];
}

/**
* Build a custom Artisan script containing specific scripts.
*
* @param string $uuid
* @param array<string, callable>|callable $slots
* @return string
*/
protected function buildArtisanScript($uuid, $slots = []): string
{
// If no array is passed, the default "preHandle" slot is assumed.
$slots = ! is_array($slots) ? ['preHandle' => $slots] : $slots;

$thisFile = __FILE__;

$slots = array_merge([
'preBootstrap' => '', 'preKernel' => '', 'preHandle' => '', 'preTerminate' => '', 'preExit' => '',
], Arr::map($slots, fn ($part) => value($part, $uuid)));

return <<<PHP
#!/usr/bin/env php
<?php

define('LARAVEL_START', microtime(true));

// This is a custom artisan testing script made specifically for:
// File: {$thisFile}
// Uuid: {$uuid}

require __DIR__.'/../../../autoload.php';

{$slots['preBootstrap']}

\$app = require_once __DIR__.'/bootstrap/app.php';

{$slots['preKernel']}

\$kernel = \$app->make(Illuminate\Contracts\Console\Kernel::class);

{$slots['preHandle']}

\$status = \$kernel->handle(
\$input = new Symfony\Component\Console\Input\ArgvInput,
new Symfony\Component\Console\Output\ConsoleOutput
);

{$slots['preTerminate']}

\$kernel->terminate(\$input, \$status);

{$slots['preExit']}

exit(\$status);

PHP;
}
}
66 changes: 66 additions & 0 deletions tests/Integration/Foundation/ExceptionHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@

use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\Access\Response;
use Illuminate\Foundation\Testing\Concerns\MakesArtisanScript;
use Illuminate\Support\Facades\Route;
use Orchestra\Testbench\TestCase;

class ExceptionHandlerTest extends TestCase
{
use MakesArtisanScript;

public function testItRendersAuthorizationExceptions()
{
Route::get('test-route', fn () => Response::deny('expected message', 321)->authorize());
Expand Down Expand Up @@ -107,4 +110,67 @@ public function testItHasFallbackErrorMessageForUnknownStatusCodes()
'message' => 'Whoops, looks like something went wrong.',
]);
}

/**
* @dataProvider exitCodesProvider
*/
public function testItReturnsNonZeroExitCodesForUncaughtExceptions($shouldThrow)
{
$this->setUpArtisanScript(function () use ($shouldThrow) {
return <<<PHP
class ThrowExceptionCommand extends \Illuminate\Console\Command
{
protected \$signature = 'throw-exception-command';

public function handle()
{
throw new \Exception('Thrown inside ThrowExceptionCommand');
}
}

class ThrowExceptionLogHandler extends \Monolog\Handler\AbstractProcessingHandler
{
protected function write(array \$record): void
{
if ({$shouldThrow}) {
throw new \Exception('Thrown inside ThrowExceptionLogHandler');
}
}
}

\$config = \$app['config'];

\$config->set('logging.channels.stack', [
'driver' => 'stack',
'path' => storage_path('logs/stacklog.log'),
'channels' => ['throw_exception'],
'ignore_exceptions' => false,
]);

\$config->set('logging.channels.throw_exception', [
'driver' => 'monolog',
'handler' => ThrowExceptionLogHandler::class,
]);

Illuminate\Console\Application::starting(function (\$artisan) {
\$artisan->add(new ThrowExceptionCommand);
});

PHP;
});

[, $exitCode] = $this->artisanScript('throw-exception-command');

$this->assertEquals($shouldThrow, $exitCode);

$this->tearDownArtisanScript();
}

public static function exitCodesProvider()
{
return [
['Throw exception' => 1],
['Do not throw exception' => 0],
];
}
}