refactor(kernel): unify HTTP and CLI application lifecycle

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-07-27 00:48:11 -04:00
parent 98826143a8
commit 65c1b5fb75
67 changed files with 2737 additions and 1070 deletions
+140 -124
View File
@@ -9,13 +9,15 @@
namespace KTXC;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use KTXC\Http\Middleware\MiddlewarePipeline;
use KTXC\Http\Middleware\TenantMiddleware;
use KTXC\Http\Middleware\FirewallMiddleware;
use KTXC\Http\Middleware\AuthenticationMiddleware;
use KTXC\Http\Middleware\RouterMiddleware;
use KTXC\Application\KernelOptions;
use KTXC\Application\Execution\ExecutionDescriptor;
use KTXC\Application\Execution\ExecutionOutcome;
use KTXC\Application\Execution\ExecutionScope;
use KTXC\Application\Execution\TerminationReport;
use KTXC\Context\IdentityContext;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContext;
use KTXC\Context\TenantContextInterface;
use KTXC\Injection\Builder;
use KTXC\Injection\Container;
use Psr\Container\ContainerInterface;
@@ -23,7 +25,10 @@ use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
use KTXC\Logger\LoggerFactory;
use KTXC\Logger\TenantAwareLogger;
use KTXF\Event\EventBus;
use KTXF\Event\DeferredEventProcessorInterface;
use KTXF\Event\EventDispatcher;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\EventListenerRegistry;
use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Cache\PersistentCacheInterface;
use KTXF\Cache\BlobCacheInterface;
@@ -31,7 +36,7 @@ use KTXF\Cache\Store\FileEphemeralCache;
use KTXF\Cache\Store\FilePersistentCache;
use KTXF\Cache\Store\FileBlobCache;
class Kernel
class Kernel implements KernelInterface
{
public const VERSION = '1.0.0';
public const VERSION_ID = 10000;
@@ -45,26 +50,16 @@ class Kernel
protected ?float $startTime = null;
protected ?ContainerInterface $container = null;
protected ?LoggerInterface $logger = null;
protected ?MiddlewarePipeline $pipeline = null;
private bool $errorHandlerInstalled = false;
private ?ExecutionScope $activeScope = null;
private string $projectDir;
private array $config;
public function __construct(
protected string $environment = 'prod',
protected bool $debug = false,
private readonly KernelOptions $options,
array $config = [],
?string $projectDir = null,
) {
if (!$environment) {
throw new \InvalidArgumentException(\sprintf('Invalid environment provided to "%s": the environment cannot be empty.', get_debug_type($this)));
}
$this->config = $config;
if ($projectDir !== null) {
$this->projectDir = $projectDir;
}
}
public function __clone()
@@ -77,10 +72,10 @@ class Kernel
private function initialize(): void
{
if ($this->debug) {
if ($this->debug()) {
$this->startTime = microtime(true);
}
if ($this->debug && !isset($_ENV['SHELL_VERBOSITY']) && !isset($_SERVER['SHELL_VERBOSITY'])) {
if ($this->debug() && !isset($_ENV['SHELL_VERBOSITY']) && !isset($_SERVER['SHELL_VERBOSITY'])) {
if (\function_exists('putenv')) {
putenv('SHELL_VERBOSITY=3');
}
@@ -129,23 +124,7 @@ class Kernel
return true;
});
// Handle uncaught exceptions
set_exception_handler(function (\Throwable $exception) {
$this->logger->error('Exception caught: ' . $exception->getMessage(), [
'exception' => $exception,
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $exception->getTraceAsString(),
]);
if ($this->debug) {
echo '<pre>Uncaught Exception: ' . $exception . '</pre>';
} else {
echo 'An unexpected error occurred. Please try again later.';
}
exit(1);
});
$this->errorHandlerInstalled = true;
// Handle fatal errors
register_shutdown_function(function () {
@@ -161,11 +140,6 @@ class Kernel
$this->logger->error($message, $error);
if ($this->debug) {
echo '<pre>' . $message . '</pre>';
} else {
echo 'A fatal error occurred. Please try again later.';
}
}
});
}
@@ -180,9 +154,9 @@ class Kernel
/** @var ModuleManager $moduleManager */
$moduleManager = $this->container->get(ModuleManager::class);
$moduleManager->modulesBoot();
// Build middleware pipeline
$this->pipeline = $this->buildMiddlewarePipeline();
$this->container
->get(EventListenerRegistry::class)
->freeze($this->container);
$this->booted = true;
}
@@ -199,52 +173,108 @@ class Kernel
if (false === $this->initialized) {
return;
}
if ($this->activeScope !== null && !$this->activeScope->terminated()) {
throw new \LogicException('Cannot shut down the kernel while an execution scope is active.');
}
$this->initialized = false;
$this->booted = false;
$this->container = null;
if ($this->errorHandlerInstalled) {
restore_error_handler();
$this->errorHandlerInstalled = false;
}
}
public function handle(Request $request): Response
public function beginExecution(ExecutionDescriptor $descriptor): ExecutionScope
{
if (!$this->booted) {
$this->boot();
}
if ($this->activeScope !== null) {
throw new \LogicException('The kernel already has an active execution scope.');
}
// Use middleware pipeline to handle the request
return $this->pipeline->handle($request);
$scope = new ExecutionScope(
$descriptor,
\KTXC\Application\Execution\ExecutionContext::fromDescriptor($descriptor),
$this->container->get(TenantContext::class),
$this->container->get(IdentityContext::class),
);
$this->container
->get(DeferredEventProcessorInterface::class)
->beginExecution($descriptor->executionId);
$this->activeScope = $scope;
return $scope;
}
/**
* Build the middleware pipeline
*/
protected function buildMiddlewarePipeline(): MiddlewarePipeline
public function terminateExecution(
ExecutionScope $scope,
ExecutionOutcome $outcome,
): TerminationReport
{
$pipeline = new MiddlewarePipeline($this->container);
// Register middleware in execution order
$pipeline->pipe(TenantMiddleware::class);
$pipeline->pipe(FirewallMiddleware::class);
$pipeline->pipe(AuthenticationMiddleware::class);
$pipeline->pipe(RouterMiddleware::class);
return $pipeline;
}
if ($scope->terminated()) {
return new TerminationReport();
}
if ($this->activeScope !== $scope) {
throw new \LogicException('Cannot terminate a scope that is not active.');
}
$processed = 0;
$remaining = 0;
$deadlineExceeded = false;
$limitExceeded = false;
$failures = [];
/**
* Process deferred events at the end of the request
*/
public function processEvents(): void
{
try {
if ($this->container && $this->container->has(EventBus::class)) {
/** @var EventBus $eventBus */
$eventBus = $this->container->get(EventBus::class);
$eventBus->processDeferred();
if ($this->container && $this->container->has(DeferredEventProcessorInterface::class)) {
$result = $this->container
->get(DeferredEventProcessorInterface::class)
->processDeferred($scope->descriptor->executionId);
$processed = $result->processed;
$remaining = $result->remaining;
$deadlineExceeded = $result->deadlineExceeded;
$limitExceeded = $result->limitExceeded;
}
} catch (\Throwable $e) {
error_log('Event processing error: ' . $e->getMessage());
$failures[] = $e;
try {
$this->container
?->get(DeferredEventProcessorInterface::class)
->discardDeferred($scope->descriptor->executionId);
} catch (\Throwable $discardError) {
$failures[] = $discardError;
}
$this->logger?->error('Deferred event processing failed.', [
'exception' => $e,
'execution_id' => $scope->descriptor->executionId,
'runtime' => $scope->descriptor->runtime->value,
]);
} finally {
if ($this->container !== null) {
foreach ([
EphemeralCacheInterface::class,
PersistentCacheInterface::class,
BlobCacheInterface::class,
] as $cacheType) {
$cache = $this->container->get($cacheType);
$cache->setUserContext(null);
$cache->setTenantContext(null);
}
}
$scope->dispose();
$scope->markTerminated();
$this->activeScope = null;
}
return new TerminationReport(
deferredProcessed: $processed,
deferredRemaining: $remaining,
failures: $failures,
deadlineExceeded: $deadlineExceeded,
limitExceeded: $limitExceeded,
);
}
/**
@@ -256,13 +286,13 @@ class Kernel
{
return [
'kernel.project_dir' => realpath($this->folderRoot()) ?: $this->folderRoot(),
'kernel.environment' => $this->environment,
'kernel.environment' => $this->environment(),
'kernel.runtime_environment' => '%env(default:kernel.environment:APP_RUNTIME_ENV)%',
'kernel.runtime_mode' => '%env(query_string:default:container.runtime_mode:APP_RUNTIME_MODE)%',
'kernel.runtime_mode.web' => '%env(bool:default::key:web:default:kernel.runtime_mode:)%',
'kernel.runtime_mode.cli' => '%env(not:default:kernel.runtime_mode.web:)%',
'kernel.runtime_mode.worker' => '%env(bool:default::key:worker:default:kernel.runtime_mode:)%',
'kernel.debug' => $this->debug,
'kernel.debug' => $this->debug(),
'kernel.build_dir' => realpath($this->getBuildDir()) ?: $this->getBuildDir(),
'kernel.cache_dir' => realpath($this->getCacheDir()) ?: $this->getCacheDir(),
'kernel.logs_dir' => realpath($this->getLogDir()) ?: $this->getLogDir(),
@@ -272,12 +302,12 @@ class Kernel
public function environment(): string
{
return $this->environment;
return $this->options->environment;
}
public function debug(): bool
{
return $this->debug;
return $this->options->debug;
}
public function container(): ContainerInterface
@@ -291,7 +321,7 @@ class Kernel
public function getStartTime(): float
{
return $this->debug && null !== $this->startTime ? $this->startTime : -\INF;
return $this->debug() && null !== $this->startTime ? $this->startTime : -\INF;
}
/**
@@ -299,24 +329,7 @@ class Kernel
*/
public function folderRoot(): string
{
if (!isset($this->projectDir)) {
$r = new \ReflectionObject($this);
if (!is_file($dir = $r->getFileName())) {
throw new \LogicException(\sprintf('Cannot auto-detect project dir for kernel of class "%s".', $r->name));
}
$dir = $rootDir = \dirname($dir);
while (!is_file($dir.'/composer.json')) {
if ($dir === \dirname($dir)) {
return $this->projectDir = $rootDir;
}
$dir = \dirname($dir);
}
$this->projectDir = $dir;
}
return $this->projectDir;
return $this->options->paths->project;
}
@@ -325,12 +338,12 @@ class Kernel
*/
private function getConfigDir(): string
{
return $this->folderRoot().'/config';
return $this->options->paths->configuration();
}
public function getCacheDir(): string
{
return $this->folderRoot().'/var/cache/'.$this->environment;
return $this->options->paths->cache($this->environment());
}
public function getBuildDir(): string
@@ -340,7 +353,7 @@ class Kernel
public function getLogDir(): string
{
return $this->folderRoot().'/var/log';
return $this->options->paths->logs();
}
public function getCharset(): string
@@ -382,7 +395,7 @@ class Kernel
// Service definitions
$projectDir = $this->folderRoot();
$moduleDir = $projectDir . '/modules';
$environment = $this->environment;
$environment = $this->environment();
$builder->addDefinitions([
@@ -395,6 +408,9 @@ class Kernel
// Without this alias, PHP-DI will happily autowire a new empty Container when asked
Container::class => \DI\get(ContainerInterface::class),
TenantContextInterface::class => \DI\get(TenantContext::class),
IdentityContextInterface::class => \DI\get(IdentityContext::class),
LoggerInterface::class => function (ContainerInterface $c) use ($projectDir) {
$logConfig = $this->config['log'] ?? [];
@@ -405,7 +421,7 @@ class Kernel
return new TenantAwareLogger(
$this->logger,
$c->get(SessionTenant::class),
$c->get(TenantContextInterface::class),
$logDir,
$channel,
$level,
@@ -413,8 +429,8 @@ class Kernel
);
},
// EventBus as singleton for consistent event handling
EventBus::class => \DI\create(EventBus::class),
EventDispatcherInterface::class => \DI\get(EventDispatcher::class),
DeferredEventProcessorInterface::class => \DI\get(EventDispatcher::class),
// Ephemeral Cache - for short-lived data (sessions, rate limits, challenges)
EphemeralCacheInterface::class => function(ContainerInterface $c) use ($projectDir) {
$storeType = $c->has('cache.ephemeral') ? $c->get('cache.ephemeral') : 'file';
@@ -433,13 +449,13 @@ class Kernel
$cache = new $storeClass($projectDir);
// Set tenant/user context if available
if ($c->has(SessionTenant::class)) {
$tenant = $c->get(SessionTenant::class);
$cache->setTenantContext($tenant->identifier());
if ($c->has(TenantContextInterface::class)) {
$tenantContext = $c->get(TenantContextInterface::class);
$cache->setTenantContext($tenantContext->identifier());
}
if ($c->has(SessionIdentity::class)) {
$identity = $c->get(SessionIdentity::class);
$cache->setUserContext($identity->identifier());
if ($c->has(IdentityContextInterface::class)) {
$identityContext = $c->get(IdentityContextInterface::class);
$cache->setUserContext($identityContext->identifier());
}
return $cache;
@@ -462,13 +478,13 @@ class Kernel
$cache = new $storeClass($projectDir);
// Set tenant/user context if available
if ($c->has(SessionTenant::class)) {
$tenant = $c->get(SessionTenant::class);
$cache->setTenantContext($tenant->identifier());
if ($c->has(TenantContextInterface::class)) {
$tenantContext = $c->get(TenantContextInterface::class);
$cache->setTenantContext($tenantContext->identifier());
}
if ($c->has(SessionIdentity::class)) {
$identity = $c->get(SessionIdentity::class);
$cache->setUserContext($identity->identifier());
if ($c->has(IdentityContextInterface::class)) {
$identityContext = $c->get(IdentityContextInterface::class);
$cache->setUserContext($identityContext->identifier());
}
return $cache;
@@ -491,13 +507,13 @@ class Kernel
$cache = new $storeClass($projectDir);
// Set tenant/user context if available
if ($c->has(SessionTenant::class)) {
$tenant = $c->get(SessionTenant::class);
$cache->setTenantContext($tenant->identifier());
if ($c->has(TenantContextInterface::class)) {
$tenantContext = $c->get(TenantContextInterface::class);
$cache->setTenantContext($tenantContext->identifier());
}
if ($c->has(SessionIdentity::class)) {
$identity = $c->get(SessionIdentity::class);
$cache->setUserContext($identity->identifier());
if ($c->has(IdentityContextInterface::class)) {
$identityContext = $c->get(IdentityContextInterface::class);
$cache->setUserContext($identityContext->identifier());
}
return $cache;