diff --git a/src/Phaseolies/Application.php b/src/Phaseolies/Application.php index 1048f938..86f37a61 100644 --- a/src/Phaseolies/Application.php +++ b/src/Phaseolies/Application.php @@ -14,6 +14,7 @@ use Phaseolies\Error\ErrorHandler; use Phaseolies\DI\Container; use Phaseolies\Config\Config; +use Phaseolies\Support\DeferScope; use Phaseolies\ApplicationBuilder; use Devium\Toml\Toml; use RuntimeException; @@ -212,6 +213,20 @@ class Application extends Container */ protected array $terminatingCallbacks = []; + /** + * Active defer scopes, innermost last. + * + * @var array + */ + protected array $deferScopes = []; + + /** + * Custom reporter for exceptions thrown by deferred callbacks. + * + * @var callable|null + */ + protected $deferFailureReporter = null; + /** * Application constructor. * @@ -1119,14 +1134,227 @@ public function terminating(callable $callback): self * @param \Throwable|null $exception * @return void */ - public function terminate(Request $request, ?Response $response = null, ?\Throwable $exception = null): void - { + public function terminate( + Request $request, + ?Response $response = null, + ?\Throwable $exception = null, + ?DeferScope $deferScope = null + ): void { + if ($deferScope !== null) { + // Make the request scope current again so terminating callbacks + // can still defer cleanup into it. + $this->resumeDeferScope($deferScope); + } + + $terminationFailure = null; + try { foreach ($this->terminatingCallbacks as $callback) { $this->callTerminatingCallback($callback, $request, $response, $exception); } + } catch (\Throwable $e) { + // Remember it so deferred-callback failures are reported against the + // exception that is actually ending the request, then let it propagate. + $terminationFailure = $e; + + throw $e; + } finally { + try { + if ($deferScope !== null) { + $this->endDeferScope($deferScope, $terminationFailure ?? $exception); + } + } finally { + $this->cleanupRequestScopedServices(); + } + } + } + + /** + * Register a callback to run when the current execution ends. + * + * @param callable $callback + * @return self + * @throws \LogicException + */ + public function defer(callable $callback): self + { + $scope = end($this->deferScopes); + + if (!$scope instanceof DeferScope) { + throw new \LogicException( + 'defer() was called outside an active execution scope. ' + . 'Run the code through runInDeferScope(), an HTTP dispatch or a console command.' + ); + } + + $scope->push($callback); + + return $this; + } + + /** + * Determine if a defer scope is currently active. + * + * @return bool + */ + public function hasDeferScope(): bool + { + return $this->deferScopes !== []; + } + + /** + * Start a new, independent defer scope and make it the current one. + * + * @return DeferScope + */ + public function beginDeferScope(): DeferScope + { + return $this->deferScopes[] = new DeferScope(); + } + + /** + * Detach a scope from the active stack without running its callbacks. + * + * + * @param DeferScope $scope + * @return void + */ + public function suspendDeferScope(DeferScope $scope): void + { + $index = array_search($scope, $this->deferScopes, true); + + if ($index === false) { + return; + } + + while (count($this->deferScopes) > $index + 1) { + $this->endDeferScope(end($this->deferScopes)); + } + + array_pop($this->deferScopes); + } + + /** + * Make a previously suspended scope the current scope again. + * + * @param DeferScope $scope + * @return void + */ + public function resumeDeferScope(DeferScope $scope): void + { + if ($scope->isClosed() || in_array($scope, $this->deferScopes, true)) { + return; + } + + $this->deferScopes[] = $scope; + } + + /** + * End a defer scope: run its callbacks and discard it. + * + * @param DeferScope $scope + * @param \Throwable|null $exception + * @return void + */ + public function endDeferScope(DeferScope $scope, ?\Throwable $exception = null): void + { + $index = array_search($scope, $this->deferScopes, true); + + if ($index === false) { + // Not current: either already ended or suspended. Run a + // suspended scope in place; a closed scope is a no-op. + $this->deferScopes[] = $scope; + $index = count($this->deferScopes) - 1; + } + + while (count($this->deferScopes) > $index) { + // Run while still current, so callbacks may defer further cleanup. + $ending = end($this->deferScopes); + $failures = $ending->run(); + + if (end($this->deferScopes) === $ending) { + array_pop($this->deferScopes); + } + + foreach ($failures as $failure) { + $this->reportDeferFailure($failure, $exception); + } + } + } + + /** + * Run a callback inside its own defer scope. + * + * @param callable $callback + * @return mixed + */ + public function runInDeferScope(callable $callback): mixed + { + $scope = $this->beginDeferScope(); + + try { + return $callback(); + } catch (\Throwable $e) { + $this->endDeferScope($scope, $e); + + throw $e; } finally { - $this->cleanupRequestScopedServices(); + $this->endDeferScope($scope); + } + } + + /** + * Set a custom reporter for exceptions thrown by deferred callbacks. + * + * @param callable(\Throwable, ?\Throwable): void|null $reporter + * @return self + */ + public function reportDeferredExceptionsUsing(?callable $reporter): self + { + $this->deferFailureReporter = $reporter; + + return $this; + } + + /** + * Report an exception thrown by a deferred callback. + * + * @param \Throwable $failure + * @param \Throwable|null $original + * @return void + */ + protected function reportDeferFailure(\Throwable $failure, ?\Throwable $original): void + { + $reported = false; + + try { + if ($this->deferFailureReporter !== null) { + ($this->deferFailureReporter)($failure, $original); + $reported = true; + } elseif ($this->has('log')) { + \Phaseolies\Support\Facades\Log::error('Deferred callback failed: ' . $failure->getMessage(), [ + 'exception' => $failure, + ]); + $reported = true; + } + } catch (\Throwable) { + // Reporting must never break cleanup; fall through to the last resort. + } + + if (!$reported) { + // No reporter or logger took it (absent, or it threw). Cleanup failures + // are never propagated, so leave a best-effort trace rather than none. + try { + error_log(sprintf( + 'Deferred callback failed: %s: %s in %s:%d', + get_class($failure), + $failure->getMessage(), + $failure->getFile(), + $failure->getLine() + )); + } catch (\Throwable) { + // Nothing left to try. + } } } @@ -1284,6 +1512,8 @@ public function dispatch($request): DispatchResult { $this->snapshotBootBindings(); + $deferScope = $this->beginDeferScope(); + try { $this->instance('request', $request); @@ -1291,11 +1521,25 @@ public function dispatch($request): DispatchResult $response->prepare($request)->send(); - return new DispatchResult($this, $request, $response); + $this->suspendDeferScope($deferScope); + + return new DispatchResult($this, $request, $response, null, $deferScope); } catch (HttpException $e) { - Response::dispatchHttpException($e); + try { + Response::dispatchHttpException($e); + } catch (\Throwable $renderFailure) { + $this->endDeferScope($deferScope, $renderFailure); + + throw $renderFailure; + } + + $this->suspendDeferScope($deferScope); + + return new DispatchResult($this, $request, null, $e, $deferScope); + } catch (\Throwable $e) { + $this->endDeferScope($deferScope, $e); - return new DispatchResult($this, $request, null, $e); + throw $e; } } } diff --git a/src/Phaseolies/Console/Console.php b/src/Phaseolies/Console/Console.php index 6c0ee11a..ca12903c 100644 --- a/src/Phaseolies/Console/Console.php +++ b/src/Phaseolies/Console/Console.php @@ -86,4 +86,17 @@ protected function bootstrapCommands(): void $command->registerCommands($this); } + + /** + * Run a command inside its own defer scope. + * + * @param Command $command + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + protected function doRunCommand(Command $command, InputInterface $input, OutputInterface $output): int + { + return $this->app->runInDeferScope(fn() => parent::doRunCommand($command, $input, $output)); + } } diff --git a/src/Phaseolies/Helpers/helpers.php b/src/Phaseolies/Helpers/helpers.php index 0a90d45e..4b1bdb73 100644 --- a/src/Phaseolies/Helpers/helpers.php +++ b/src/Phaseolies/Helpers/helpers.php @@ -77,6 +77,19 @@ function app($abstract = null, array $parameters = []) } } +if (!function_exists('defer')) { + /** + * Register a callback to run when the current execution ends (LIFO). + * + * @param callable $callback + * @return \Phaseolies\Application + */ + function defer(callable $callback) + { + return app()->defer($callback); + } +} + if (!function_exists('request')) { /** * Creates a new request instance to handle HTTP requests. diff --git a/src/Phaseolies/Http/DispatchResult.php b/src/Phaseolies/Http/DispatchResult.php index ca4a6839..cebd4864 100644 --- a/src/Phaseolies/Http/DispatchResult.php +++ b/src/Phaseolies/Http/DispatchResult.php @@ -3,6 +3,7 @@ namespace Phaseolies\Http; use Phaseolies\Application; +use Phaseolies\Support\DeferScope; class DispatchResult { @@ -23,7 +24,8 @@ public function __construct( protected Application $app, protected Request $request, protected ?Response $response = null, - protected ?\Throwable $exception = null + protected ?\Throwable $exception = null, + protected ?DeferScope $deferScope = null ) {} /** @@ -38,7 +40,7 @@ public function terminate(): static } $this->terminated = true; - $this->app->terminate($this->request, $this->response, $this->exception); + $this->app->terminate($this->request, $this->response, $this->exception, $this->deferScope); return $this; } diff --git a/src/Phaseolies/Support/DeferScope.php b/src/Phaseolies/Support/DeferScope.php new file mode 100644 index 00000000..a87b1789 --- /dev/null +++ b/src/Phaseolies/Support/DeferScope.php @@ -0,0 +1,78 @@ + + */ + protected array $callbacks = []; + + /** + * Indicates whether the callbacks have already been run. + * + * @var bool + */ + protected bool $closed = false; + + /** + * Indicates whether the callbacks are currently running. + * + * @var bool + */ + protected bool $running = false; + + /** + * Register a callback to run when the scope ends. + * + * @param callable $callback + * @return void + */ + public function push(callable $callback): void + { + $this->callbacks[] = $callback; + } + + /** + * Determine if the scope has already run its callbacks. + * + * @return bool + */ + public function isClosed(): bool + { + return $this->closed; + } + + /** + * Run every callback in reverse registration order. + * + * @return array + */ + public function run(): array + { + if ($this->closed || $this->running) { + return []; + } + + $this->running = true; + $failures = []; + + while ($this->callbacks !== []) { + $callback = array_pop($this->callbacks); + + try { + $callback(); + } catch (\Throwable $e) { + $failures[] = $e; + } + } + + $this->running = false; + $this->closed = true; + + return $failures; + } +} diff --git a/src/Phaseolies/Support/Facades/App.php b/src/Phaseolies/Support/Facades/App.php index f9876f16..ef9be9ff 100644 --- a/src/Phaseolies/Support/Facades/App.php +++ b/src/Phaseolies/Support/Facades/App.php @@ -24,6 +24,9 @@ * @method static void setLocale($locale) * @method static void setFallbackLocale($fallbackLocale) * @method static bool isLocale($locale) + * @method static \Phaseolies\Application defer(callable $callback) + * @method static mixed runInDeferScope(callable $callback) + * @method static bool hasDeferScope() * * @see \Phaseolies\Application */ diff --git a/tests/Application/DeferTest.php b/tests/Application/DeferTest.php new file mode 100644 index 00000000..4be8e3f1 --- /dev/null +++ b/tests/Application/DeferTest.php @@ -0,0 +1,732 @@ + */ + private array $reported = []; + + protected function setUp(): void + { + $this->app = (new ReflectionClass(Application::class))->newInstanceWithoutConstructor(); + $this->reported = []; + $this->app->reportDeferredExceptionsUsing(function (\Throwable $failure, ?\Throwable $original): void { + $this->reported[] = [$failure, $original]; + }); + } + + public function testCallbacksRunInLastInFirstOutOrder(): void + { + $order = []; + + $this->app->runInDeferScope(function () use (&$order) { + $this->app->defer(function () use (&$order) { $order[] = 'first'; }); + $this->app->defer(function () use (&$order) { $order[] = 'second'; }); + $this->app->defer(function () use (&$order) { $order[] = 'third'; }); + }); + + $this->assertSame(['third', 'second', 'first'], $order); + } + + public function testCallbacksRunAfterSuccessAndReturnTheResult(): void + { + $ran = false; + + $result = $this->app->runInDeferScope(function () use (&$ran) { + $this->app->defer(function () use (&$ran) { $ran = true; }); + + return 'done'; + }); + + $this->assertSame('done', $result); + $this->assertTrue($ran); + $this->assertFalse($this->app->hasDeferScope()); + } + + public function testCallbacksRunAfterAnExceptionAndTheOriginalIsRethrown(): void + { + $ran = false; + $original = new RuntimeException('original'); + + try { + $this->app->runInDeferScope(function () use (&$ran, $original) { + $this->app->defer(function () use (&$ran) { $ran = true; }); + + throw $original; + }); + $this->fail('Expected exception was not thrown.'); + } catch (RuntimeException $e) { + $this->assertSame($original, $e); + } + + $this->assertTrue($ran); + $this->assertFalse($this->app->hasDeferScope()); + } + + public function testOneFailingCallbackDoesNotPreventLaterCleanup(): void + { + $order = []; + + $this->app->runInDeferScope(function () use (&$order) { + $this->app->defer(function () use (&$order) { $order[] = 'first'; }); + $this->app->defer(function () { throw new RuntimeException('cleanup failed'); }); + $this->app->defer(function () use (&$order) { $order[] = 'third'; }); + }); + + $this->assertSame(['third', 'first'], $order); + $this->assertCount(1, $this->reported); + $this->assertSame('cleanup failed', $this->reported[0][0]->getMessage()); + $this->assertNull($this->reported[0][1]); + } + + public function testCleanupFailureDoesNotReplaceTheOriginalException(): void + { + $original = new RuntimeException('original'); + + try { + $this->app->runInDeferScope(function () use ($original) { + $this->app->defer(function () { throw new LogicException('cleanup failed'); }); + + throw $original; + }); + $this->fail('Expected exception was not thrown.'); + } catch (\Throwable $e) { + $this->assertSame($original, $e); + } + + $this->assertCount(1, $this->reported); + $this->assertSame($original, $this->reported[0][1]); + } + + public function testDeferOutsideAScopeFailsClearly(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('outside an active execution scope'); + + $this->app->defer(fn() => null); + } + + public function testNestedScopesHaveIndependentStacks(): void + { + $order = []; + + $this->app->runInDeferScope(function () use (&$order) { + $this->app->defer(function () use (&$order) { $order[] = 'outer'; }); + + $this->app->runInDeferScope(function () use (&$order) { + $this->app->defer(function () use (&$order) { $order[] = 'inner'; }); + }); + + $order[] = 'after-inner'; + }); + + $this->assertSame(['inner', 'after-inner', 'outer'], $order); + } + + public function testEndingAScopeEndsAbandonedInnerScopesFirst(): void + { + $order = []; + + $outer = $this->app->beginDeferScope(); + $this->app->defer(function () use (&$order) { $order[] = 'outer'; }); + $this->app->beginDeferScope(); + $this->app->defer(function () use (&$order) { $order[] = 'abandoned'; }); + + $this->app->endDeferScope($outer); + + $this->assertSame(['abandoned', 'outer'], $order); + $this->assertFalse($this->app->hasDeferScope()); + } + + public function testCallbacksRegisteredDuringCleanupAreRun(): void + { + $order = []; + + $this->app->runInDeferScope(function () use (&$order) { + $this->app->defer(function () use (&$order) { + $order[] = 'outer'; + $this->app->defer(function () use (&$order) { $order[] = 'late'; }); + }); + }); + + $this->assertSame(['outer', 'late'], $order); + } + + public function testCallbackEndingItsOwnScopeDoesNotCorruptTheStack(): void + { + $order = []; + + $outer = $this->app->beginDeferScope(); + $inner = $this->app->beginDeferScope(); + $this->app->defer(function () use (&$order, $inner) { + $order[] = 'first'; + $this->app->endDeferScope($inner); + }); + $this->app->defer(function () use (&$order) { $order[] = 'second'; }); + + $this->app->endDeferScope($outer); + + $this->assertSame(['second', 'first'], $order); + $this->assertFalse($this->app->hasDeferScope()); + } + + public function testScopeRunsEachCallbackOnlyOnce(): void + { + $count = 0; + $scope = new DeferScope(); + $scope->push(function () use (&$count) { $count++; }); + + $scope->run(); + $scope->run(); + + $this->assertSame(1, $count); + $this->assertTrue($scope->isClosed()); + } + + public function testPersistentWorkerDoesNotLeakCallbacksBetweenExecutions(): void + { + $runs = []; + + foreach ([1, 2, 3] as $job) { + $captured = "payload-{$job}"; + + $this->app->runInDeferScope(function () use ($job, $captured, &$runs) { + $this->app->defer(function () use ($job, $captured, &$runs) { + $runs[] = [$job, $captured]; + }); + }); + + $this->assertFalse($this->app->hasDeferScope()); + } + + $this->assertSame([[1, 'payload-1'], [2, 'payload-2'], [3, 'payload-3']], $runs); + } + + public function testFailedExecutionDoesNotLeakIntoTheNextOne(): void + { + $runs = []; + + try { + $this->app->runInDeferScope(function () use (&$runs) { + $this->app->defer(function () use (&$runs) { $runs[] = 'failed-job'; }); + + throw new RuntimeException('boom'); + }); + } catch (RuntimeException) { + } + + $this->app->runInDeferScope(function () use (&$runs) { + $this->app->defer(function () use (&$runs) { $runs[] = 'next-job'; }); + }); + + $this->assertSame(['failed-job', 'next-job'], $runs); + } + + public function testHttpDispatchRunsDeferredCallbacksOnTerminate(): void + { + $request = new Request(); + + $response = $this->getMockBuilder(Response::class) + ->disableOriginalConstructor() + ->onlyMethods(['prepare', 'send']) + ->getMock(); + $response->method('prepare')->willReturnSelf(); + $response->method('send')->willReturnSelf(); + + $ran = []; + + $router = $this->createMock(Router::class); + $router->method('resolve')->willReturnCallback(function () use ($response, &$ran) { + $this->app->defer(function () use (&$ran) { $ran[] = 'first'; }); + $this->app->defer(function () use (&$ran) { $ran[] = 'second'; }); + + return $response; + }); + $this->app->router = $router; + + $result = $this->app->dispatch($request); + + $this->assertInstanceOf(DispatchResult::class, $result); + $this->assertSame([], $ran); + // Suspended until termination, so nothing else can leak into it. + $this->assertFalse($this->app->hasDeferScope()); + + $result->terminate(); + + $this->assertSame(['second', 'first'], $ran); + $this->assertFalse($this->app->hasDeferScope()); + } + + public function testHttpDispatchRunsDeferredCallbacksWhenHandlingThrows(): void + { + $ran = false; + + $router = $this->createMock(Router::class); + $router->method('resolve')->willReturnCallback(function () use (&$ran) { + $this->app->defer(function () use (&$ran) { $ran = true; }); + + throw new RuntimeException('boom'); + }); + $this->app->router = $router; + + try { + $this->app->dispatch(new Request()); + $this->fail('Expected exception was not thrown.'); + } catch (RuntimeException) { + } + + $this->assertTrue($ran); + $this->assertFalse($this->app->hasDeferScope()); + } + + public function testConsoleRunExecutesDeferredCallbacksAfterTheCommand(): void + { + $order = []; + $app = $this->app; + + $console = new class($app) extends Console { + protected function bootstrapCommands(): void {} + }; + $console->setCatchExceptions(false); + + $command = new Command('defer:test'); + $command->setCode(function () use ($app, &$order): int { + $app->defer(function () use (&$order) { $order[] = 'cleanup'; }); + $order[] = 'command'; + + return Command::SUCCESS; + }); + $console->addCommand($command); + + $status = $console->run(new ArrayInput(['command' => 'defer:test']), new NullOutput()); + + $this->assertSame(0, $status); + $this->assertSame(['command', 'cleanup'], $order); + $this->assertFalse($app->hasDeferScope()); + } + + private function makeResponse(): Response + { + $response = $this->getMockBuilder(Response::class) + ->disableOriginalConstructor() + ->onlyMethods(['prepare', 'send']) + ->getMock(); + $response->method('prepare')->willReturnSelf(); + $response->method('send')->willReturnSelf(); + + return $response; + } + + public function testOverlappingDispatchesDoNotCleanUpEachOther(): void + { + $ran = []; + $calls = 0; + + $router = $this->createMock(Router::class); + $router->method('resolve')->willReturnCallback(function () use (&$ran, &$calls) { + $name = 'request-' . (++$calls); + $this->app->defer(function () use (&$ran, $name) { $ran[] = $name; }); + + return $this->makeResponse(); + }); + $this->app->router = $router; + + $first = $this->app->dispatch(new Request()); + $second = $this->app->dispatch(new Request()); + + $this->assertSame([], $ran); + $this->assertFalse($this->app->hasDeferScope()); + + $first->terminate(); + $this->assertSame(['request-1'], $ran); + + $second->terminate(); + $this->assertSame(['request-1', 'request-2'], $ran); + } + + public function testTerminatingCallbacksCanDeferIntoTheRequestScope(): void + { + $order = []; + + $router = $this->createMock(Router::class); + $router->method('resolve')->willReturnCallback(fn() => $this->makeResponse()); + $this->app->router = $router; + + $this->app->terminating(function () use (&$order) { + $order[] = 'terminating'; + $this->app->defer(function () use (&$order) { $order[] = 'deferred-in-terminating'; }); + }); + + $this->app->dispatch(new Request())->terminate(); + + $this->assertSame(['terminating', 'deferred-in-terminating'], $order); + $this->assertFalse($this->app->hasDeferScope()); + } + + public function testDeferAfterDispatchButBeforeTerminateFailsClearly(): void + { + $router = $this->createMock(Router::class); + $router->method('resolve')->willReturnCallback(fn() => $this->makeResponse()); + $this->app->router = $router; + + $result = $this->app->dispatch(new Request()); + + try { + $this->expectException(LogicException::class); + $this->app->defer(fn() => null); + } finally { + $result->terminate(); + } + } + + public function testHttpExceptionPathStillRunsCallbacksWhateverRenderingDoes(): void + { + $ran = false; + + $router = $this->createMock(Router::class); + $router->method('resolve')->willReturnCallback(function () use (&$ran) { + $this->app->defer(function () use (&$ran) { $ran = true; }); + + throw new HttpException(418, 'teapot'); + }); + $this->app->router = $router; + + // Rendering an error page can succeed (an error view exists) or throw + // (it does not, or the environment is not bootstrapped). Both must end + // with cleanup run and no scope left behind. + $level = ob_get_level(); + ob_start(); + $result = null; + + try { + $result = $this->app->dispatch(new Request()); + } catch (\Throwable) { + } finally { + while (ob_get_level() > $level) { + ob_end_clean(); + } + } + + $result?->terminate(); + + $this->assertTrue($ran); + $this->assertFalse($this->app->hasDeferScope()); + } + + public function testRenderFailureInHttpExceptionPathEndsTheScope(): void + { + $ran = false; + + $router = $this->createMock(Router::class); + $router->method('resolve')->willReturnCallback(function () use (&$ran) { + $this->app->defer(function () use (&$ran) { $ran = true; }); + + throw new HttpException(599, 'no page for this status'); + }); + $this->app->router = $router; + + // No bootstrapped application: rendering the error page throws. + \Phaseolies\DI\Container::forgetInstance(); + + ob_start(); + + try { + $this->app->dispatch(new Request()); + $this->fail('Expected the rendering failure to propagate.'); + } catch (\Throwable) { + } finally { + ob_end_clean(); + } + + $this->assertTrue($ran); + $this->assertFalse($this->app->hasDeferScope()); + } + + public function testConsoleCallbacksSeeTheExceptionTheCommandThrows(): void + { + $app = $this->app; + $reported = []; + $app->reportDeferredExceptionsUsing(function (\Throwable $failure, ?\Throwable $original) use (&$reported) { + $reported[] = $original; + }); + + $console = new class($app) extends Console { + protected function bootstrapCommands(): void {} + }; + $console->setCatchExceptions(false); + + $boom = new RuntimeException('command failed'); + $command = new Command('defer:fail'); + $command->setCode(function () use ($app, $boom): int { + $app->defer(function () { throw new LogicException('cleanup failed too'); }); + + throw $boom; + }); + $console->addCommand($command); + + try { + $console->run(new ArrayInput(['command' => 'defer:fail']), new NullOutput()); + $this->fail('Expected the command exception to propagate.'); + } catch (RuntimeException $e) { + $this->assertSame($boom, $e); + } + + $this->assertSame([$boom], $reported); + $this->assertFalse($app->hasDeferScope()); + } + + public function testNestedConsoleCommandsHaveIndependentScopes(): void + { + $app = $this->app; + $order = []; + + $console = new class($app) extends Console { + protected function bootstrapCommands(): void {} + }; + + $inner = new Command('defer:inner'); + $inner->setCode(function () use ($app, &$order): int { + $app->defer(function () use (&$order) { $order[] = 'inner-cleanup'; }); + + return Command::SUCCESS; + }); + + $outer = new Command('defer:outer'); + $outer->setCode(function () use ($app, $console, &$order): int { + $app->defer(function () use (&$order) { $order[] = 'outer-cleanup'; }); + $console->doRun(new ArrayInput(['command' => 'defer:inner']), new NullOutput()); + $order[] = 'after-inner'; + + return Command::SUCCESS; + }); + + $console->addCommand($inner); + $console->addCommand($outer); + $console->setAutoExit(false); + $console->doRun(new ArrayInput(['command' => 'defer:outer']), new NullOutput()); + + $this->assertSame(['inner-cleanup', 'after-inner', 'outer-cleanup'], $order); + } + + public function testCleanupFailuresAreWrittenToTheLogService(): void + { + $logged = []; + $logger = new class($logged) { + public function __construct(public array &$logged) {} + + public function error(string $message, array $context = []): void + { + $this->logged[] = [$message, $context]; + } + }; + + $app = (new ReflectionClass(Application::class))->newInstanceWithoutConstructor(); + $app->bind('log', fn() => $logger); + $app->bind('app', fn() => $app); + \Phaseolies\Facade\BaseFacade::setFacadeApplication($app); + + try { + $failure = new RuntimeException('cleanup failed'); + + $app->runInDeferScope(function () use ($app, $failure) { + $app->defer(function () use ($failure) { throw $failure; }); + }); + } finally { + \Phaseolies\Facade\BaseFacade::setFacadeApplication(null); + } + + $this->assertCount(1, $logged); + $this->assertSame('Deferred callback failed: cleanup failed', $logged[0][0]); + $this->assertSame($failure, $logged[0][1]['exception']); + } + + private function dispatchWithDeferredFailure(): DispatchResult + { + $router = $this->createMock(Router::class); + $router->method('resolve')->willReturnCallback(function () { + $this->app->defer(function () { throw new RuntimeException('deferred failed'); }); + + return $this->makeResponse(); + }); + $this->app->router = $router; + + return $this->app->dispatch(new Request()); + } + + public function testTerminatingCallbackFailureIsReportedAsTheOriginalOfDeferredFailures(): void + { + $terminating = new RuntimeException('terminating failed'); + $this->app->terminating(function () use ($terminating) { throw $terminating; }); + + $result = $this->dispatchWithDeferredFailure(); + + try { + $result->terminate(); + $this->fail('The terminating exception should propagate.'); + } catch (RuntimeException $e) { + $this->assertSame($terminating, $e); + } + + $this->assertCount(1, $this->reported); + $this->assertSame('deferred failed', $this->reported[0][0]->getMessage()); + $this->assertSame($terminating, $this->reported[0][1]); + $this->assertFalse($this->app->hasDeferScope()); + } + + public function testTerminatingFailureTakesPrecedenceOverTheDispatchException(): void + { + $terminating = new RuntimeException('terminating failed'); + $dispatchFailure = new HttpException(404, 'not found'); + $this->app->terminating(function () use ($terminating) { throw $terminating; }); + + $scope = $this->app->beginDeferScope(); + $this->app->defer(function () { throw new RuntimeException('deferred failed'); }); + $this->app->suspendDeferScope($scope); + + try { + $this->app->terminate(new Request(), null, $dispatchFailure, $scope); + $this->fail('The terminating exception should propagate.'); + } catch (RuntimeException $e) { + $this->assertSame($terminating, $e); + } + + $this->assertCount(1, $this->reported); + $this->assertSame($terminating, $this->reported[0][1]); + } + + public function testDispatchExceptionIsStillTheOriginalWhenTerminatingCallbacksSucceed(): void + { + $dispatchFailure = new HttpException(404, 'not found'); + + $scope = $this->app->beginDeferScope(); + $this->app->defer(function () { throw new RuntimeException('deferred failed'); }); + $this->app->suspendDeferScope($scope); + + $this->app->terminate(new Request(), null, $dispatchFailure, $scope); + + $this->assertCount(1, $this->reported); + $this->assertSame($dispatchFailure, $this->reported[0][1]); + } + + public function testDeferredFailureStillReportsNullOriginalWhenTerminationSucceeds(): void + { + $this->dispatchWithDeferredFailure()->terminate(); + + $this->assertCount(1, $this->reported); + $this->assertNull($this->reported[0][1]); + } + + /** + * Run a failing deferred callback and return what error_log() received. + */ + private function captureErrorLog(Application $app): string + { + $file = tempnam(sys_get_temp_dir(), 'defer-log'); + $previous = ini_set('error_log', $file); + + try { + $app->runInDeferScope(function () use ($app) { + $app->defer(function () { throw new RuntimeException('cleanup failed'); }); + }); + + return (string) file_get_contents($file); + } finally { + ini_set('error_log', $previous === false ? '' : $previous); + @unlink($file); + } + } + + public function testCleanupFailureFallsBackToErrorLogWhenNoLoggerIsBound(): void + { + $app = (new ReflectionClass(Application::class))->newInstanceWithoutConstructor(); + + $output = $this->captureErrorLog($app); + + $this->assertStringContainsString('Deferred callback failed: RuntimeException: cleanup failed', $output); + } + + public function testCleanupFailureFallsBackToErrorLogWhenTheReporterThrows(): void + { + $app = (new ReflectionClass(Application::class))->newInstanceWithoutConstructor(); + $app->reportDeferredExceptionsUsing(function () { throw new LogicException('reporter broke'); }); + + $output = $this->captureErrorLog($app); + + $this->assertStringContainsString('Deferred callback failed: RuntimeException: cleanup failed', $output); + } + + public function testCleanupFailureFallsBackToErrorLogWhenTheLoggerThrows(): void + { + $logger = new class { + public function error(string $message, array $context = []): void + { + throw new LogicException('logger broke'); + } + }; + + $app = (new ReflectionClass(Application::class))->newInstanceWithoutConstructor(); + $app->bind('log', fn() => $logger); + $app->bind('app', fn() => $app); + \Phaseolies\Facade\BaseFacade::setFacadeApplication($app); + + try { + $output = $this->captureErrorLog($app); + } finally { + \Phaseolies\Facade\BaseFacade::setFacadeApplication(null); + } + + $this->assertStringContainsString('Deferred callback failed: RuntimeException: cleanup failed', $output); + } + + public function testErrorLogFallbackIsSkippedWhenTheReporterHandlesTheFailure(): void + { + $output = $this->captureErrorLog($this->app); + + $this->assertSame('', $output); + $this->assertCount(1, $this->reported); + } + + public function testLaterCleanupStillRunsWhenTheReporterThrows(): void + { + $app = (new ReflectionClass(Application::class))->newInstanceWithoutConstructor(); + $app->reportDeferredExceptionsUsing(function () { throw new LogicException('reporter broke'); }); + $order = []; + + $file = tempnam(sys_get_temp_dir(), 'defer-log'); + $previous = ini_set('error_log', $file); + + try { + $app->runInDeferScope(function () use ($app, &$order) { + $app->defer(function () use (&$order) { $order[] = 'first'; }); + $app->defer(function () { throw new RuntimeException('cleanup failed'); }); + $app->defer(function () use (&$order) { $order[] = 'third'; }); + }); + } finally { + ini_set('error_log', $previous === false ? '' : $previous); + @unlink($file); + } + + $this->assertSame(['third', 'first'], $order); + } +}