Skip to content
Merged
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
256 changes: 250 additions & 6 deletions src/Phaseolies/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -212,6 +213,20 @@ class Application extends Container
*/
protected array $terminatingCallbacks = [];

/**
* Active defer scopes, innermost last.
*
* @var array<int, DeferScope>
*/
protected array $deferScopes = [];

/**
* Custom reporter for exceptions thrown by deferred callbacks.
*
* @var callable|null
*/
protected $deferFailureReporter = null;

/**
* Application constructor.
*
Expand Down Expand Up @@ -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.
}
}
Comment thread
abdulmajidcse marked this conversation as resolved.
Resolved
}

Expand Down Expand Up @@ -1284,18 +1512,34 @@ public function dispatch($request): DispatchResult
{
$this->snapshotBootBindings();

$deferScope = $this->beginDeferScope();

try {
$this->instance('request', $request);

$response = $this->handle($request);

$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;
}
}
}
13 changes: 13 additions & 0 deletions src/Phaseolies/Console/Console.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
13 changes: 13 additions & 0 deletions src/Phaseolies/Helpers/helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions src/Phaseolies/Http/DispatchResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Phaseolies\Http;

use Phaseolies\Application;
use Phaseolies\Support\DeferScope;

class DispatchResult
{
Expand All @@ -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
) {}

/**
Expand All @@ -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;
}
Expand Down
Loading
Loading