From 35b03793c4f43d81b223f93ae05df25f680b8115 Mon Sep 17 00:00:00 2001 From: flytachi Date: Wed, 22 Jul 2026 02:53:03 +0500 Subject: [PATCH 01/71] beta test --- console/Command/Complete.php | 6 +- console/Command/Di.php | 345 ++++++++++++++++-- dev/main/MainController.php | 22 ++ docs/architecture/04-request/08-validation.md | 12 +- src/BaseBoot.php | 20 +- src/Dev/Async/Async.php | 74 ++++ src/Dev/Async/AsyncCollector.php | 170 +++++++++ src/Dev/Async/AsyncException.php | 25 ++ src/Dev/Async/AsyncSupport.php | 53 +++ src/Dev/Async/Proxy/BypassScanner.php | 317 ++++++++++++++++ src/Dev/Async/Proxy/ProxyFactory.php | 197 ++++++++++ src/Dev/Async/Proxy/ProxyGenerator.php | 294 +++++++++++++++ src/Dev/Async/Proxy/SignatureWriter.php | 298 +++++++++++++++ src/Dev/Concurrent/CancellationException.php | 12 + src/Dev/Concurrent/CompletableFuture.php | 330 +++++++++++++++++ src/Dev/Concurrent/ExecutionException.php | 22 ++ .../Executor/CoroutineExecutorService.php | 237 ++++++++++++ .../Executor/DeferredExecutorService.php | 236 ++++++++++++ src/Dev/Concurrent/ExecutorService.php | 92 +++++ src/Dev/Concurrent/Executors.php | 112 ++++++ src/Dev/Concurrent/Future.php | 59 +++ .../Concurrent/RejectedExecutionException.php | 16 + src/Dev/Concurrent/TimeoutException.php | 14 + src/Http/Request/K1ValidationTrait.php | 2 +- src/Http/Request/RequestObject.php | 180 --------- src/Process/Core/WinterRunner.php | 4 +- src/Unit/DataTableNet/DTNWrapper.php | 114 ------ .../DataTableNet/DataTableNetException.php | 15 - src/Unit/DataTableNet/DataTableNetRequest.php | 191 ---------- .../DataTableNet/DataTableNetResponse.php | 27 -- src/Unit/DataTableNet/Entity/DTNetColumn.php | 31 -- src/Unit/DataTableNet/Entity/DTNetColumns.php | 20 - src/Unit/DataTableNet/Entity/DTNetOrder.php | 14 - src/Unit/DataTableNet/Entity/DTNetSearch.php | 14 - 34 files changed, 2920 insertions(+), 655 deletions(-) create mode 100644 src/Dev/Async/Async.php create mode 100644 src/Dev/Async/AsyncCollector.php create mode 100644 src/Dev/Async/AsyncException.php create mode 100644 src/Dev/Async/AsyncSupport.php create mode 100644 src/Dev/Async/Proxy/BypassScanner.php create mode 100644 src/Dev/Async/Proxy/ProxyFactory.php create mode 100644 src/Dev/Async/Proxy/ProxyGenerator.php create mode 100644 src/Dev/Async/Proxy/SignatureWriter.php create mode 100644 src/Dev/Concurrent/CancellationException.php create mode 100644 src/Dev/Concurrent/CompletableFuture.php create mode 100644 src/Dev/Concurrent/ExecutionException.php create mode 100644 src/Dev/Concurrent/Executor/CoroutineExecutorService.php create mode 100644 src/Dev/Concurrent/Executor/DeferredExecutorService.php create mode 100644 src/Dev/Concurrent/ExecutorService.php create mode 100644 src/Dev/Concurrent/Executors.php create mode 100644 src/Dev/Concurrent/Future.php create mode 100644 src/Dev/Concurrent/RejectedExecutionException.php create mode 100644 src/Dev/Concurrent/TimeoutException.php delete mode 100644 src/Http/Request/RequestObject.php delete mode 100644 src/Unit/DataTableNet/DTNWrapper.php delete mode 100644 src/Unit/DataTableNet/DataTableNetException.php delete mode 100644 src/Unit/DataTableNet/DataTableNetRequest.php delete mode 100644 src/Unit/DataTableNet/DataTableNetResponse.php delete mode 100644 src/Unit/DataTableNet/Entity/DTNetColumn.php delete mode 100644 src/Unit/DataTableNet/Entity/DTNetColumns.php delete mode 100644 src/Unit/DataTableNet/Entity/DTNetOrder.php delete mode 100644 src/Unit/DataTableNet/Entity/DTNetSearch.php diff --git a/console/Command/Complete.php b/console/Command/Complete.php index 9fd46f2..d3183ed 100644 --- a/console/Command/Complete.php +++ b/console/Command/Complete.php @@ -81,11 +81,13 @@ class Complete extends Cmd // --- di --- 'di' => [ - 'build:scan project and write the DI cache file', - 'clean:delete the DI cache file', + 'build:scan project once — write the DI cache and generate #[Async] proxies', + 'clean:delete the DI cache and every generated proxy', 'show:list classes in the DI cache (or a live scan if absent)', + 'async:list #[Async] methods and whether their proxy is built', ], 'di show' => [], // takes optional FQCN substring as argument + 'di async' => [], // takes optional FQCN substring as argument // --- storage --- 'storage' => [ diff --git a/console/Command/Di.php b/console/Command/Di.php index dece375..3be2d34 100644 --- a/console/Command/Di.php +++ b/console/Command/Di.php @@ -9,28 +9,44 @@ use Flytachi\Winter\DI\Collector\DICollector; use Flytachi\Winter\DI\Contract\CollectorInterface; use Flytachi\Winter\DI\Scanner; +use Flytachi\Winter\K2\Dev\Async\AsyncCollector; +use Flytachi\Winter\K2\Dev\Async\Proxy\BypassScanner; +use Flytachi\Winter\K2\Dev\Async\Proxy\ProxyFactory; +use Flytachi\Winter\K2\Dev\Async\Proxy\ProxyGenerator; use Flytachi\Winter\K2\Kernel; use ReflectionClass; +use ReflectionMethod; class Di extends Cmd { - public static string $title = "manage and inspect DI scanner cache (build, clean, show)"; + public static string $title = "manage and inspect DI scanner cache (build, clean, show, async)"; + + /** Maximum bypass warnings printed before the rest is summarised. */ + private const int BYPASS_REPORT_LIMIT = 20; public function handle(): void { self::printTitle("Di", 34); $sub = $this->args['arguments'][1] ?? ''; + $ok = true; match ($sub) { - 'build' => $this->buildArg(), + 'build' => $ok = $this->buildArg(), 'clean' => $this->cleanArg(), 'show' => $this->showArg($this->args['arguments'][2] ?? ''), + 'async' => $this->asyncArg($this->args['arguments'][2] ?? ''), '' => self::help(), default => $this->showArg($sub), }; self::printTitle("Di", 34); + + // A failed build must be visible to CI — the console layer otherwise + // always exits 0. + if (!$ok) { + exit(1); + } } /** @@ -41,6 +57,31 @@ private static function cachePath(): string return Kernel::$pathStorageVolatile . '/di.php'; } + /** + * List of classes carrying #[Async] — kept in sync with BaseBoot::boot(). + */ + private static function asyncCachePath(): string + { + return Kernel::$pathStorageVolatile . '/async.php'; + } + + /** + * Removes a cache file and drops it from the opcode cache. + */ + private static function forget(string $file): bool + { + if (!file_exists($file)) { + return false; + } + + @unlink($file); + if (function_exists('opcache_invalidate')) { + opcache_invalidate($file, true); + } + + return true; + } + private function showArg(string $pattern): void { try { @@ -88,63 +129,283 @@ private function showArg(string $pattern): void } } - private function buildArg(): void + /** + * Builds every artefact the container needs, in a single filesystem pass. + * + * The class list and the #[Async] proxies come from the same scan on + * purpose — two commands would leave a window where one is stale. + * + * Note that this step can now fail on application code: an #[Async] method + * breaking its contract stops proxy generation. That is the point — the + * error belongs to CI, not to the first request in production. + * + * @return bool False when an artefact could not be produced. + */ + private function buildArg(): bool { - try { - $cachePath = self::cachePath(); + $cachePath = self::cachePath(); + $factory = ProxyFactory::forKernel(refresh: true); - // Force a rebuild: Scanner short-circuits when the file already exists. - if (file_exists($cachePath)) { - @unlink($cachePath); - if (function_exists('opcache_invalidate')) { - opcache_invalidate($cachePath, true); - } - } + // Force a rebuild: both caches short-circuit when their file exists. + self::forget($cachePath); + self::forget(self::asyncCachePath()); + + $async = new AsyncCollector(Container::init(), $factory, self::asyncCachePath()); + + try { + // Drop proxies of services that no longer exist or lost the attribute. + $factory->clear(); // Same call BaseBoot::boot() makes — populates a fresh Container and // writes the FQCN list to $cachePath as a side effect. Scanner::run(rootDir: Kernel::$pathRoot, cache: $cachePath) - ->collect(new DICollector(Container::init())) + ->collect(new DICollector(Container::getInstance())) + ->collect($async) ->execute(); - if (!is_file($cachePath)) { - self::printWarning("Cache file was not produced at $cachePath"); + $async->flush(); + } catch (\Throwable $e) { + // The class list is written before collectors run, so report what did survive. + $this->reportCache($cachePath); + self::printBadge("async proxies", 'FAILED', 34, 31); + self::printWarning($e->getMessage()); + if (env('DEBUG', false)) { + self::printSplit($e->getTraceAsString(), 31); + } + + return false; + } + + if (!$this->reportCache($cachePath)) { + return false; + } + + $proxied = $async->proxied(); + if ($proxied === []) { + self::printBadge("async proxies", 'NONE', 34, 33); + + return true; + } + + self::printBadge( + "async proxies", + sprintf('BUILT (%d classes, %d methods)', count($proxied), $this->countAsyncMethods($proxied)), + 34, + 32 + ); + self::printInfo($factory->directory()); + + $this->reportBypasses(array_keys($proxied)); + + return true; + } + + /** + * Warns about services built with `new` instead of resolved from the container. + * + * Never fails the build — the scan is textual and cannot see dynamic + * construction, so a clean report is not proof of correctness. + * + * @param list $asyncClasses Classes that must come from the container. + */ + private function reportBypasses(array $asyncClasses): void + { + $found = new BypassScanner($asyncClasses, self::bypassExcludes())->scan(Kernel::$pathRoot); + + if ($found === []) { + self::printBadge("async bypass", 'NONE', 34, 32); + return; + } + + self::printBadge("async bypass", count($found) . ' FOUND', 34, 33); + + $shown = array_slice($found, 0, self::BYPASS_REPORT_LIMIT); + foreach ($shown as $hit) { + self::printWarning(sprintf( + '%s:%d — new %s() bypasses the proxy and runs synchronously; inject it instead', + self::relativePath($hit['file']), + $hit['line'], + $hit['class'] + )); + } + + $hidden = count($found) - count($shown); + if ($hidden > 0) { + self::printWarning("… and $hidden more (run `call di async` for the full service list)"); + } + } + + /** + * Directories the bypass scan skips: generated and non-application code. + * + * Test directories are excluded on purpose — constructing a service directly + * is usually what a test wants. + * + * @return list + */ + private static function bypassExcludes(): array + { + return [ + Kernel::$pathStorage, + Kernel::$pathStorageVolatile, + Kernel::$pathRoot . '/tests', + Kernel::$pathRoot . '/test', + ]; + } + + /** + * @param string $file Absolute path. + */ + private static function relativePath(string $file): string + { + $root = rtrim(Kernel::$pathRoot, '/\\') . DIRECTORY_SEPARATOR; + + return str_starts_with($file, $root) ? substr($file, strlen($root)) : $file; + } + + private function cleanArg(): void + { + try { + $cleaned = self::forget(self::cachePath()); + self::forget(self::asyncCachePath()); + self::printBadge("di cache", $cleaned ? 'CLEANED' : 'NOT FOUND', 34, $cleaned ? 32 : 33); + + $removed = ProxyFactory::forKernel()->clear(); + self::printBadge( + "async proxies", + $removed > 0 ? "CLEANED ($removed files)" : 'NOT FOUND', + 34, + $removed > 0 ? 32 : 33 + ); + } catch (\Throwable $e) { + self::printWarning("Clean failed: " . $e->getMessage()); + } + } + + /** + * Lists every #[Async] method found in the project and whether its proxy exists. + * + * @param string $pattern Case-insensitive FQCN substring filter. + */ + private function asyncArg(string $pattern): void + { + try { + $found = $this->scanAsync(); + $pattern = trim($pattern); + + if ($pattern !== '') { + $found = array_filter( + $found, + static fn(string $fqcn): bool => stripos($fqcn, $pattern) !== false, + ARRAY_FILTER_USE_KEY + ); + } + + if ($found === []) { + self::printWarning($pattern === '' + ? 'No #[Async] methods found.' + : "No #[Async] methods in classes matching '$pattern'."); return; } - if (function_exists('opcache_invalidate')) { - opcache_invalidate($cachePath, true); + ksort($found); + $factory = ProxyFactory::forKernel(); + $label = 'Async methods (' . count($found) . ' classes)'; + + self::printLabel($label, 34); + foreach ($found as $fqcn => $methods) { + $built = is_file($factory->fileFor($fqcn)); + self::printBadge($fqcn, $built ? 'BUILT' : 'PENDING', 36, $built ? 32 : 33); + foreach ($methods as [$name, $returns]) { + self::print(" {$name}() → {$returns}", 36); + } } + self::printLabel($label, 34); - $count = count((array) (require $cachePath)); - self::printBadge("di cache", "BUILT ($count classes)", 34, 32); - self::printInfo($cachePath); + self::printDivider(34); + self::printInfo($factory->directory()); } catch (\Throwable $e) { - self::printWarning("Build failed: " . $e->getMessage()); + self::printWarning("Async scan failed: " . $e->getMessage()); if (env('DEBUG', false)) { - self::printTitle($e->getMessage(), 31); self::printSplit($e->getTraceAsString(), 31); - self::printTitle($e->getMessage(), 31); } } } - private function cleanArg(): void + /** + * Prints the state of the class-list cache. + * + * @param string $cachePath Absolute path of the cache file. + * @return bool False when the file was not produced. + */ + private function reportCache(string $cachePath): bool { - try { - $cachePath = self::cachePath(); - if (file_exists($cachePath)) { - unlink($cachePath); - if (function_exists('opcache_invalidate')) { - opcache_invalidate($cachePath, true); + if (!is_file($cachePath)) { + self::printWarning("Cache file was not produced at $cachePath"); + return false; + } + + if (function_exists('opcache_invalidate')) { + opcache_invalidate($cachePath, true); + } + + $count = count((array) (require $cachePath)); + self::printBadge("di cache", "BUILT ($count classes)", 34, 32); + self::printInfo($cachePath); + + return true; + } + + /** + * @param array $proxied Original class mapped to its proxy. + */ + private function countAsyncMethods(array $proxied): int + { + $total = 0; + foreach (array_keys($proxied) as $class) { + $total += count(ProxyGenerator::asyncMethods(new ReflectionClass($class))); + } + + return $total; + } + + /** + * Walks the project and collects #[Async] methods without generating anything. + * + * @return array> Class mapped to method name and return type. + */ + private function scanAsync(): array + { + $sink = new class implements CollectorInterface { + /** @var array> */ + public array $found = []; + + public function collect(string $class, ReflectionClass $ref): void + { + if (str_starts_with($class, ProxyGenerator::PROXY_NAMESPACE . '\\')) { + return; } - self::printBadge("di cache", 'CLEANED', 34, 32); - } else { - self::printBadge("di cache", 'NOT FOUND', 34, 33); + + $methods = ProxyGenerator::asyncMethods($ref); + if ($methods === []) { + return; + } + + $this->found[$class] = array_map( + static fn(ReflectionMethod $m): array => [ + $m->getName(), + (string) ($m->getReturnType() ?? 'mixed'), + ], + $methods + ); } - } catch (\Throwable $e) { - self::printWarning("Clean failed: " . $e->getMessage()); - } + }; + + Scanner::run(rootDir: Kernel::$pathRoot) + ->collect($sink) + ->execute(); + + return $sink->found; } /** @@ -182,10 +443,12 @@ public static function help(): void self::printLabel("Usage", $cl); self::printLabel("Commands", $cl); - self::printBadge('build', 'scan project and write the DI cache file (deletes the existing one)', $cl, 36); - self::printBadge('clean', 'delete the DI cache file', $cl, 36); + self::printBadge('build', 'scan project once: DI cache, #[Async] proxies, bypass check', $cl, 36); + self::printBadge('clean', 'delete the DI cache and every generated proxy', $cl, 36); self::printBadge('show', 'list every class in the DI cache', $cl, 36); self::printBadge('show ', 'filter cached classes by FQCN substring (case-insensitive)', $cl, 36); + self::printBadge('async', 'list #[Async] methods and whether their proxy is built', $cl, 36); + self::printBadge('async ', 'filter by FQCN substring (case-insensitive)', $cl, 36); self::printLabel("Commands", $cl); self::printDivider($cl); @@ -195,11 +458,17 @@ public static function help(): void self::printInfo("call di clean"); self::printInfo("call di show"); self::printInfo("call di show App\\Service"); + self::printInfo("call di async"); self::printLabel("Examples", $cl); self::printDivider($cl); self::printInfo("Cache file: " . Kernel::$pathStorageVolatile . '/di.php'); + self::printInfo("Proxy dir: " . Kernel::$pathStorageVolatile . '/' . ProxyFactory::DIRECTORY); self::printInfo("DEBUG=true disables the cache entirely (always live scan)."); + self::printInfo("build doubles as a contract check — an invalid #[Async] method fails here, not in prod."); + self::printInfo("A failed build exits with code 1."); + self::printInfo("It also warns when an #[Async] service is built with new (skips vendor/ and tests/)."); + self::printInfo("That check is textual: it cannot see 'new \$class' or factories, so it never fails a build."); self::printTitle("Di Help", $cl); } diff --git a/dev/main/MainController.php b/dev/main/MainController.php index 673298e..043b8b5 100644 --- a/dev/main/MainController.php +++ b/dev/main/MainController.php @@ -3,15 +3,18 @@ namespace Main; use Flytachi\Winter\DI\Attribute\Inject; +use Flytachi\Winter\K2\Dev\Concurrent\Executors; use Flytachi\Winter\K2\Http\Request\Annotation\PathVariable; use Flytachi\Winter\K2\Http\Request\Annotation\RequestBody; use Flytachi\Winter\K2\Http\Request\Annotation\RequestParam; use Flytachi\Winter\K2\Http\Request\Validation\Positive; use Flytachi\Winter\K2\Http\Request\Validation\Valid; use Flytachi\Winter\K2\Http\Response\ResponseEntity; +use Flytachi\Winter\K2\Route\Annotation\GetMapping; use Flytachi\Winter\K2\Route\Annotation\PostMapping; use Flytachi\Winter\K2\Route\Annotation\RequestMapping; use Flytachi\Winter\K2\Stereotype\Controller; +use Flytachi\Winter\Logger\Log; use Main\Services\SendInterface; use Main\Services\SmsSendService; @@ -21,6 +24,25 @@ class MainController extends Controller #[Inject(SmsSendService::class)] private SendInterface $service; + #[GetMapping] + public function test(): mixed + { + Executors::common()->execute(function () { + sleep(1); + Log::info("agu agu"); + $this->service->send(); + }); + + $t= Executors::common()->submit(function () { + sleep(2); + Log::info("submit"); + return $this->service->list(); + }); + dd( + $t->get() + ); + } + #[AuthMiddleware] #[PostMapping('test')] public function hello( diff --git a/docs/architecture/04-request/08-validation.md b/docs/architecture/04-request/08-validation.md index ae0476f..b820119 100644 --- a/docs/architecture/04-request/08-validation.md +++ b/docs/architecture/04-request/08-validation.md @@ -339,7 +339,9 @@ Return `null` to pass, return a string to fail with that message. ## Legacy: `K1ValidationTrait` -`Flytachi\Winter\K2\Http\Request\K1ValidationTrait` is the older, string-rule API used by `RequestObject` (now `#[\Deprecated]`). It is kept for backwards compatibility — **prefer the attribute-based system above for any new code**. +`Flytachi\Winter\K2\Http\Request\K1ValidationTrait` is the older, string-rule API. It is kept for backwards compatibility — **prefer the attribute-based system above for any new code**. + +Use it on any DTO of your own; there is no base class to extend. Key differences from the attribute system: @@ -376,10 +378,12 @@ Key differences from the attribute system: ### Usage ```php -use Flytachi\Winter\K2\Http\Request\RequestObject; +use Flytachi\Winter\K2\Http\Request\K1ValidationTrait; -final class CreateUserRequest extends RequestObject +final class CreateUserRequest { + use K1ValidationTrait; + public function __construct( public readonly ?string $name = null, public readonly ?int $age = null, @@ -413,4 +417,4 @@ Rules apply **per existing element**, and failures report the resolved path element-level checks run — validate the parent itself with a separate `$this->validate('staffs', ['array'])` when its presence is required. -> **Migration tip:** when you move a `RequestObject` to a plain readonly DTO with `#[Constraint]` attributes, you also gain (a) all-errors-at-once reporting, (b) per-rule `message:` overrides, and (c) richer i18n placeholders (`:max`, `:min`, …). +> **Migration tip:** when you move a `K1ValidationTrait` DTO to `#[Constraint]` attributes, you also gain (a) all-errors-at-once reporting, (b) per-rule `message:` overrides, and (c) richer i18n placeholders (`:max`, `:min`, …). diff --git a/src/BaseBoot.php b/src/BaseBoot.php index 610fd5c..0eff0de 100644 --- a/src/BaseBoot.php +++ b/src/BaseBoot.php @@ -10,6 +10,8 @@ use Flytachi\Winter\DI\Collector\DICollector; use Flytachi\Winter\DI\Container; use Flytachi\Winter\DI\Scanner; +use Flytachi\Winter\K2\Dev\Async\AsyncCollector; +use Flytachi\Winter\K2\Dev\Async\Proxy\ProxyFactory; use Flytachi\Winter\K2\Http\Adapter\FpmRequest; use Flytachi\Winter\K2\Http\Adapter\FpmResponse; use Flytachi\Winter\K2\Http\Adapter\SwooleRequest; @@ -522,19 +524,33 @@ private static function boot(): void static::configure(); $c = Container::init(); + $debug = (bool) env('DEBUG', false); + + // Swaps classes carrying #[Async] for their generated proxies. Shares the + // scan with DICollector and must run after it — that collector rebinds a + // class to itself, which would undo the substitution. + $async = new AsyncCollector( + $c, + ProxyFactory::forKernel($debug), + $debug ? null : Kernel::$pathStorageVolatile . '/async.php', + ); Scanner::run( rootDir: Kernel::$pathRoot, - cache: env('DEBUG', false) ? null + cache: $debug ? null : Kernel::$pathStorageVolatile . '/di.php', ) ->collect(new DICollector($c)) + ->collect($async) ->execute(); + $async->flush(); + // Default contextual logger: #[Autowired] LoggerInterface $logger resolves to a // logger named after the class it is injected into. Override in providers() by // re-registering contextual(LoggerInterface::class, …). - $c->contextual(LoggerInterface::class, + $c->contextual( + LoggerInterface::class, static fn(Container $c, ?string $consumer) => LoggerFactory::getLogger($consumer ?? 'app'), ); diff --git a/src/Dev/Async/Async.php b/src/Dev/Async/Async.php new file mode 100644 index 0000000..56670ce --- /dev/null +++ b/src/Dev/Async/Async.php @@ -0,0 +1,74 @@ +mixpanel->push($userId, $event); + * } + * + * #[Async] + * public function send(int $userId): Future + * { + * $this->mailer->send($userId); + * + * return CompletableFuture::completedFuture(true); + * } + * } + * ``` + * + * --- + * ### Caveats + * + * Only instances obtained from the DI container are proxied. A service built + * with `new` runs synchronously. + * + * Under Swoole a body that never suspends — no I/O, no sleep — finishes before + * the call even returns, because a new coroutine is entered immediately. The + * result is still correct; only the "runs later" intuition does not hold for + * purely computational bodies. + * + * @see \Flytachi\Winter\K2\Dev\Concurrent\Future + */ +#[\Attribute(\Attribute::TARGET_METHOD)] +final class Async +{ + /** + * @param string|null $executor Container id of the executor to run on; null uses the shared one. + */ + public function __construct( + public readonly ?string $executor = null + ) { + } +} diff --git a/src/Dev/Async/AsyncCollector.php b/src/Dev/Async/AsyncCollector.php new file mode 100644 index 0000000..6e007e7 --- /dev/null +++ b/src/Dev/Async/AsyncCollector.php @@ -0,0 +1,170 @@ +collect(new DICollector($container)) + * ->collect(new AsyncCollector($container, ProxyFactory::forKernel())) + * ->execute(); + * ``` + * + * @see Async + * @see ProxyFactory + */ +final class AsyncCollector implements CollectorInterface +{ + /** @var array Original class mapped to its proxy. */ + private array $proxied = []; + + /** + * Names of the classes known to be annotated, or null while discovering. + * + * Finding them means reflecting every method of every class in the project — + * roughly three times the cost of {@see \Flytachi\Winter\DI\Collector\DICollector} + * itself, paid on every boot, which under FPM means every request. The answer + * does not change between boots, so it is written next to the DI cache and + * replayed as a plain lookup. + * + * @var array|null + */ + private ?array $known = null; + + /** + * @param Container $container Container whose bindings are rewritten. + * @param ProxyFactory $factory Source of the generated proxies. + * @param string|null $cacheFile Where the discovered list is stored; null keeps discovery on every boot. + */ + public function __construct( + private readonly Container $container, + private readonly ProxyFactory $factory, + private readonly ?string $cacheFile = null + ) { + if ($cacheFile !== null && is_file($cacheFile)) { + $cached = require $cacheFile; + $this->known = is_array($cached) ? $cached : null; + } + } + + public function collect(string $class, \ReflectionClass $ref): void + { + // Generated proxies live under the project root as well and would + // otherwise be scanned as ordinary classes. + if (str_starts_with($class, ProxyGenerator::PROXY_NAMESPACE . '\\')) { + return; + } + + if ($this->known !== null) { + if (isset($this->known[$class])) { + $this->bind($class, $ref); + } + + return; + } + + if (ProxyGenerator::asyncMethods($ref) === []) { + return; + } + + $this->bind($class, $ref); + } + + /** + * Persists the discovered list so later boots skip the reflection pass. + * + * Call once, after the scan has finished. Writing an empty list matters as + * much as writing a full one: a project without any `#[Async]` service must + * not rediscover that fact on every boot. + */ + public function flush(): void + { + if ($this->cacheFile === null || $this->known !== null) { + return; + } + + $known = array_fill_keys(array_keys($this->proxied), true); + $export = var_export($known, true); + $temporary = $this->cacheFile . '.' . getmypid() . '.tmp'; + + Kernel::ensureDirectory(dirname($this->cacheFile)); + + if (file_put_contents($temporary, "cacheFile); + } else { + @unlink($temporary); + } + } + + /** + * Returns every substitution made during the scan. + * + * @return array Original class mapped to its proxy. + */ + public function proxied(): array + { + return $this->proxied; + } + + /** + * Generates the proxy if needed and points the container at it. + * + * @param class-string $class Original class. + * @param \ReflectionClass $ref Reflection of the original. + */ + private function bind(string $class, \ReflectionClass $ref): void + { + $proxy = $this->factory->proxyFor($ref); + $this->rebind($class, $proxy, $ref); + $this->proxied[$class] = $proxy; + } + + /** + * Points the container at the proxy while keeping the original lifetime. + * + * @param class-string $class Original class. + * @param class-string $proxy Generated subclass. + * @param \ReflectionClass $ref Reflection of the original. + */ + private function rebind(string $class, string $proxy, \ReflectionClass $ref): void + { + match (true) { + $ref->getAttributes(Singleton::class) !== [] => $this->container->singleton($class, $proxy), + $ref->getAttributes(Request::class) !== [] => $this->container->request($class, $proxy), + $ref->getAttributes(Transient::class) !== [] => $this->container->transient($class, $proxy), + default => $this->container->bind($class, $proxy), + }; + } +} diff --git a/src/Dev/Async/AsyncException.php b/src/Dev/Async/AsyncException.php new file mode 100644 index 0000000..34939d0 --- /dev/null +++ b/src/Dev/Async/AsyncException.php @@ -0,0 +1,25 @@ +submit(static function () use ($body): mixed { + $result = $body(); + + return $result instanceof Future ? $result->get() : $result; + }); + } + + /** + * Runs a void method body asynchronously, discarding its outcome. + * + * @param ExecutorService $executor Executor to run on. + * @param \Closure $body Bound call to the original method. + */ + public static function execute(ExecutorService $executor, \Closure $body): void + { + $executor->execute($body); + } +} diff --git a/src/Dev/Async/Proxy/BypassScanner.php b/src/Dev/Async/Proxy/BypassScanner.php new file mode 100644 index 0000000..9b7385f --- /dev/null +++ b/src/Dev/Async/Proxy/BypassScanner.php @@ -0,0 +1,317 @@ + Fully qualified names of classes carrying #[Async]. */ + private array $targets; + + /** + * @param iterable $asyncClasses Classes whose instances must come from the container. + * @param list $exclude Absolute directory prefixes to skip. + */ + public function __construct(iterable $asyncClasses, private readonly array $exclude = []) + { + $this->targets = []; + foreach ($asyncClasses as $class) { + $this->targets[ltrim($class, '\\')] = true; + } + } + + /** + * Walks a project tree and reports every direct instantiation found. + * + * @param string $rootDir Directory to scan. + * @return list Findings in file order. + */ + public function scan(string $rootDir): array + { + if ($this->targets === []) { + return []; + } + + $findings = []; + + foreach ($this->files($rootDir) as $file) { + foreach ($this->scanFile($file) as $finding) { + $findings[] = $finding; + } + } + + return $findings; + } + + /** + * Reports direct instantiations inside a single file. + * + * @param string $file Absolute path of a PHP file. + * @return list + */ + public function scanFile(string $file): array + { + $source = @file_get_contents($file); + if ($source === false) { + return []; + } + + // Cheap pre-filter: no `new` at all means no work to do. + if (!str_contains($source, 'new')) { + return []; + } + + $tokens = \PhpToken::tokenize($source); + $namespace = ''; + $aliases = []; + $depth = 0; + $findings = []; + + for ($i = 0, $count = count($tokens); $i < $count; $i++) { + $token = $tokens[$i]; + + if ($token->is('{')) { + $depth++; + continue; + } + if ($token->is('}')) { + $depth--; + continue; + } + + if ($token->is(T_NAMESPACE)) { + $namespace = $this->readName($tokens, $i); + continue; + } + + // Only top-level `use` imports classes; inside a body it is a trait + // import or a closure binding. + if ($token->is(T_USE) && $depth === 0) { + $this->readUse($tokens, $i, $aliases); + continue; + } + + if (!$token->is(T_NEW)) { + continue; + } + + $name = $this->readName($tokens, $i); + if ($name === '' || in_array(strtolower($name), self::SELF_REFERENCES, true)) { + continue; + } + + $resolved = $this->resolve($name, $namespace, $aliases); + if (isset($this->targets[$resolved])) { + $findings[] = ['file' => $file, 'line' => $token->line, 'class' => $resolved]; + } + } + + return $findings; + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** + * Yields every PHP file under the root that is not excluded. + * + * @param string $rootDir Directory to walk. + * @return \Generator + */ + private function files(string $rootDir): \Generator + { + $root = rtrim($rootDir, '/\\'); + $skip = array_map(static fn(string $dir): string => rtrim($dir, '/\\'), $this->exclude); + $skip[] = $root . DIRECTORY_SEPARATOR . 'vendor'; + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($root, \RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + /** @var \SplFileInfo $file */ + if ($file->getExtension() !== 'php') { + continue; + } + + $path = $file->getRealPath(); + if ($path === false) { + continue; + } + + foreach ($skip as $prefix) { + if (str_starts_with($path, $prefix)) { + continue 2; + } + } + + yield $path; + } + } + + /** + * Reads the name that follows the token at $index, advancing past it. + * + * @param list<\PhpToken> $tokens Token stream. + * @param int $index Cursor, moved to the last consumed token. + */ + private function readName(array $tokens, int &$index): string + { + for ($i = $index + 1, $count = count($tokens); $i < $count; $i++) { + $token = $tokens[$i]; + + if ($token->is([T_WHITESPACE, T_COMMENT, T_DOC_COMMENT])) { + continue; + } + + if ($token->is([T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED, T_NAME_RELATIVE])) { + $index = $i; + + return $token->text; + } + + // Anything else — `new $var`, `new (expr)`, `new class {…}` — is not a name. + return ''; + } + + return ''; + } + + /** + * Records the imports of a top-level `use` statement. + * + * Handles plain, aliased and grouped forms; skips `use function` / `use const` + * and closure bindings. + * + * @param list<\PhpToken> $tokens Token stream. + * @param int $index Cursor, moved to the end of the statement. + * @param array $aliases Alias map to fill, short name to FQCN. + */ + private function readUse(array $tokens, int &$index, array &$aliases): void + { + $prefix = ''; + $current = ''; + $alias = null; + $expectAlias = false; + + for ($i = $index + 1, $count = count($tokens); $i < $count; $i++) { + $token = $tokens[$i]; + + if ($token->is([T_WHITESPACE, T_COMMENT, T_DOC_COMMENT])) { + continue; + } + + // `use function foo;`, `use const BAR;` and closure `use (...)`. + if ($token->is([T_FUNCTION, T_CONST, '('])) { + $index = $i; + + return; + } + + if ($token->is(T_AS)) { + $expectAlias = true; + continue; + } + + if ($token->is([T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED, T_NAME_RELATIVE])) { + if ($expectAlias) { + $alias = $token->text; + } else { + $current = $token->text; + } + continue; + } + + if ($token->is('{')) { + $prefix = rtrim($current, '\\') . '\\'; + $current = ''; + continue; + } + + if ($token->is(',') || $token->is('}') || $token->is(';')) { + if ($current !== '') { + $fqcn = ltrim($prefix . $current, '\\'); + $aliases[$alias ?? $this->shortName($current)] = $fqcn; + } + + $current = ''; + $alias = null; + $expectAlias = false; + + if ($token->is(';')) { + $index = $i; + + return; + } + if ($token->is('}')) { + $prefix = ''; + } + } + } + + $index = $count - 1; + } + + /** + * Turns a name as written into a fully qualified one. + * + * @param string $name Name as it appears after `new`. + * @param string $namespace Namespace of the file. + * @param array $aliases Import map of the file. + */ + private function resolve(string $name, string $namespace, array $aliases): string + { + if (str_starts_with($name, '\\')) { + return ltrim($name, '\\'); + } + + $segments = explode('\\', $name); + $first = array_shift($segments); + + if (isset($aliases[$first])) { + return $segments === [] + ? $aliases[$first] + : $aliases[$first] . '\\' . implode('\\', $segments); + } + + return $namespace === '' ? $name : $namespace . '\\' . $name; + } + + /** + * @param string $name Possibly qualified name. + */ + private function shortName(string $name): string + { + $position = strrpos($name, '\\'); + + return $position === false ? $name : substr($name, $position + 1); + } +} diff --git a/src/Dev/Async/Proxy/ProxyFactory.php b/src/Dev/Async/Proxy/ProxyFactory.php new file mode 100644 index 0000000..05bf3a0 --- /dev/null +++ b/src/Dev/Async/Proxy/ProxyFactory.php @@ -0,0 +1,197 @@ +directory; + } + + /** + * Returns the loaded proxy class for the given class, generating it if needed. + * + * @param \ReflectionClass $class Class to proxy. + * @return class-string Name of the generated subclass. + * @throws AsyncException If generation or loading fails. + */ + public function proxyFor(\ReflectionClass $class): string + { + /** @var class-string $proxyClass */ + $proxyClass = ProxyGenerator::proxyClass($class->getName()); + + if (class_exists($proxyClass, false)) { + return $proxyClass; + } + + $file = $this->fileFor($class->getName()); + + if ($this->isStale($file, $class)) { + $this->write($file, ProxyGenerator::generate($class)); + } + + require_once $file; + + if (!class_exists($proxyClass, false)) { + throw AsyncException::of( + $class->getName(), + 'the generated proxy ' . $proxyClass . ' was not declared by ' . $file, + 'Delete the file and let it be regenerated.' + ); + } + + return $proxyClass; + } + + /** + * Generates proxies for every given class without loading them. + * + * Used by the build step so a production image ships ready-made files. + * + * @param iterable<\ReflectionClass> $classes Classes to pre-build. + * @return array Original class name mapped to the written file. + */ + public function warm(iterable $classes): array + { + $written = []; + + foreach ($classes as $class) { + $file = $this->fileFor($class->getName()); + $this->write($file, ProxyGenerator::generate($class)); + $written[$class->getName()] = $file; + } + + return $written; + } + + /** + * Removes every generated proxy file. + * + * @return int Number of files deleted. + */ + public function clear(): int + { + $deleted = 0; + + foreach (glob($this->directory . DIRECTORY_SEPARATOR . '*.php') ?: [] as $file) { + if (unlink($file)) { + $deleted++; + } + } + + return $deleted; + } + + /** + * Returns the file a class's proxy is written to. + * + * @param string $class Fully qualified name of the original class. + */ + public function fileFor(string $class): string + { + return $this->directory + . DIRECTORY_SEPARATOR + . str_replace('\\', '_', ltrim($class, '\\')) + . '.php'; + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** + * @param string $file Proxy file to check. + * @param \ReflectionClass $class Class the proxy was generated from. + */ + private function isStale(string $file, \ReflectionClass $class): bool + { + if (!is_file($file)) { + return true; + } + + if (!$this->refresh) { + return false; + } + + $source = $class->getFileName(); + + return $source !== false && filemtime($source) > filemtime($file); + } + + /** + * Writes the proxy atomically, so a concurrent worker never sees half a file. + * + * @param string $file Destination path. + * @param string $source Generated PHP source. + */ + private function write(string $file, string $source): void + { + Kernel::ensureDirectory($this->directory); + + $temporary = $file . '.' . getmypid() . '.tmp'; + + if (file_put_contents($temporary, $source, LOCK_EX) === false || !rename($temporary, $file)) { + @unlink($temporary); + + throw AsyncException::of( + $file, + 'the generated proxy could not be written', + 'Check that ' . $this->directory . ' exists and is writable.' + ); + } + } +} diff --git a/src/Dev/Async/Proxy/ProxyGenerator.php b/src/Dev/Async/Proxy/ProxyGenerator.php new file mode 100644 index 0000000..ded27af --- /dev/null +++ b/src/Dev/Async/Proxy/ProxyGenerator.php @@ -0,0 +1,294 @@ + Methods carrying the attribute. + */ + public static function asyncMethods(\ReflectionClass $class): array + { + $methods = []; + foreach ($class->getMethods() as $method) { + if ($method->getAttributes(Async::class) !== []) { + $methods[] = $method; + } + } + + return $methods; + } + + /** + * Renders the proxy of a class as PHP source. + * + * @param \ReflectionClass $class Class to proxy. + * @return string Complete PHP file, opening tag included. + * @throws AsyncException If the class or any of its methods breaks the contract. + */ + public static function generate(\ReflectionClass $class): string + { + $methods = self::asyncMethods($class); + if ($methods === []) { + throw AsyncException::of( + $class->getName(), + 'no method is marked with #[Async]', + 'Nothing to proxy.' + ); + } + + self::assertProxyable($class); + + $body = ''; + foreach ($methods as $method) { + $body .= self::renderMethod($method, $class); + } + + $short = substr(self::proxyClass($class->getName()), strlen(self::PROXY_NAMESPACE) + 1); + $modifiers = $class->isReadOnly() ? 'final readonly class' : 'final class'; + $namespace = self::PROXY_NAMESPACE; + $contract = self::PROXY_CONTRACT; + $origin = '\\' . $class->getName(); + $body = rtrim($body) . "\n"; + + return <<isFinal()) { + throw AsyncException::of( + $class->getName(), + 'the class is final and cannot be extended', + 'Drop "final" from the class, or move the #[Async] method to a non-final service.' + ); + } + + if ($class->isAbstract() || $class->isInterface() || $class->isEnum()) { + throw AsyncException::of( + $class->getName(), + 'only instantiable classes can be proxied', + 'Put #[Async] on the concrete service the container resolves.' + ); + } + + if ($class->isInternal()) { + throw AsyncException::of( + $class->getName(), + 'internal classes cannot be proxied', + 'Wrap the call in a service of your own.' + ); + } + } + + /** + * @param \ReflectionMethod $method Method to override. + * @param \ReflectionClass $class Class being proxied. + */ + private static function renderMethod(\ReflectionMethod $method, \ReflectionClass $class): string + { + $subject = $class->getName() . '::' . $method->getName() . '()'; + self::assertOverridable($method, $subject); + + $isVoid = self::returnsVoid($method, $subject); + $attribute = $method->getAttributes(Async::class)[0]->newInstance(); + $executor = self::renderExecutor($attribute); + + $signature = sprintf( + '%s function %s(%s)', + $method->isProtected() ? 'protected' : 'public', + $method->getName(), + SignatureWriter::parameters($method) + ); + + $returnType = SignatureWriter::type($method->getReturnType(), $method->getDeclaringClass()); + $call = sprintf('parent::%s(%s)', $method->getName(), SignatureWriter::arguments($method)); + + $line = $isVoid + ? sprintf('%s::execute(%s, fn() => %s);', self::SUPPORT, $executor, $call) + : sprintf('return %s::submit(%s, fn() => %s);', self::SUPPORT, $executor, $call); + + return <<isFinal() => ['the method is final', 'Drop "final" so the proxy can override it.'], + $method->isStatic() => [ + 'the method is static', + 'Asynchrony is applied per instance; make the method non-static.', + ], + $method->isAbstract() => [ + 'the method is abstract', + 'Put #[Async] on the implementation instead.', + ], + $method->isPrivate() => [ + 'the method is private', + 'A private method is resolved statically inside its own class, so no subclass can ' + . 'intercept it. Make it protected — self-calls still go through the proxy.', + ], + default => null, + }; + + if ($problem !== null) { + throw AsyncException::of($subject, $problem[0], $problem[1]); + } + } + + /** + * @param \ReflectionMethod $method Method to validate. + * @param string $subject Method name used in error messages. + * @return bool True when the method returns void. + * @throws AsyncException If the return type is not void or Future. + */ + private static function returnsVoid(\ReflectionMethod $method, string $subject): bool + { + $type = $method->getReturnType(); + $remedy = sprintf( + 'Declare "void" for fire-and-forget, or "\\%s" and return CompletableFuture::completedFuture($value).', + Future::class + ); + + if (!$type instanceof \ReflectionNamedType) { + throw AsyncException::of( + $subject, + $type === null ? 'the method has no return type' : 'the return type is not a single named type', + $remedy + ); + } + + $name = strtolower($type->getName()); + + if ($name === 'void') { + return true; + } + + if (!$type->allowsNull() && $type->getName() === Future::class) { + return false; + } + + throw AsyncException::of( + $subject, + 'the return type is "' . $type . '", which the proxy cannot produce', + $remedy + ); + } + + /** + * @param Async $attribute Attribute instance carrying the executor id. + */ + private static function renderExecutor(Async $attribute): string + { + if ($attribute->executor === null) { + return self::EXECUTORS . '::common()'; + } + + return sprintf( + '%s::getInstance()->get(%s)', + self::CONTAINER, + var_export($attribute->executor, true) + ); + } +} diff --git a/src/Dev/Async/Proxy/SignatureWriter.php b/src/Dev/Async/Proxy/SignatureWriter.php new file mode 100644 index 0000000..a90e9d2 --- /dev/null +++ b/src/Dev/Async/Proxy/SignatureWriter.php @@ -0,0 +1,298 @@ +getParameters() as $parameter) { + $rendered[] = self::parameter($parameter, $method); + } + + return implode(', ', $rendered); + } + + /** + * Renders the argument list forwarding the call to the parent method. + * + * @param \ReflectionMethod $method Method being overridden. + * @return string Rendered arguments, comma separated. + */ + public static function arguments(\ReflectionMethod $method): string + { + $rendered = []; + foreach ($method->getParameters() as $parameter) { + $rendered[] = ($parameter->isVariadic() ? '...$' : '$') . $parameter->getName(); + } + + return implode(', ', $rendered); + } + + /** + * Renders a type as it must appear in the overriding declaration. + * + * @param \ReflectionType|null $type Type to render, or null when untyped. + * @param \ReflectionClass $scope Class the original declaration lives in. + * @return string Rendered type, or an empty string when there is none. + */ + public static function type(?\ReflectionType $type, \ReflectionClass $scope): string + { + if ($type === null) { + return ''; + } + + if ($type instanceof \ReflectionUnionType) { + $members = []; + foreach ($type->getTypes() as $member) { + $members[] = $member instanceof \ReflectionIntersectionType + ? '(' . self::intersection($member, $scope) . ')' + : self::named($member, $scope, false); + } + + return implode('|', $members); + } + + if ($type instanceof \ReflectionIntersectionType) { + return self::intersection($type, $scope); + } + + if ($type instanceof \ReflectionNamedType) { + return self::named($type, $scope, true); + } + + throw AsyncException::of( + $scope->getName(), + 'type ' . $type . ' is not supported', + 'Use a named, union or intersection type.' + ); + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** + * @param \ReflectionParameter $parameter Parameter to render. + * @param \ReflectionMethod $method Method the parameter belongs to. + */ + private static function parameter(\ReflectionParameter $parameter, \ReflectionMethod $method): string + { + $out = self::type($parameter->getType(), $method->getDeclaringClass()); + if ($out !== '') { + $out .= ' '; + } + + if ($parameter->isPassedByReference()) { + throw AsyncException::of( + $method->getDeclaringClass()->getName() . '::' . $method->getName() . '()', + 'parameter $' . $parameter->getName() . ' is passed by reference', + 'An asynchronous call returns before the body runs, so writes to it could never be observed. ' + . 'Return the value instead.' + ); + } + + if ($parameter->isVariadic()) { + return $out . '...$' . $parameter->getName(); + } + + $out .= '$' . $parameter->getName(); + + if ($parameter->isDefaultValueAvailable()) { + $out .= ' = ' . self::defaultValue($parameter, $method); + } + + return $out; + } + + /** + * @param \ReflectionNamedType $type Type to render. + * @param \ReflectionClass $scope Class the original declaration lives in. + * @param bool $shorthand Whether the `?T` shorthand may be used. + */ + private static function named(\ReflectionNamedType $type, \ReflectionClass $scope, bool $shorthand): string + { + $name = $type->getName(); + $lowered = strtolower($name); + + $resolved = match ($lowered) { + // Relative to where it is written, so it must be spelled out. + 'self' => '\\' . $scope->getName(), + 'parent' => '\\' . self::parentOf($scope)->getName(), + default => $type->isBuiltin() ? $lowered : '\\' . $name, + }; + + if ($shorthand && $type->allowsNull() && $lowered !== 'mixed' && $lowered !== 'null') { + return '?' . $resolved; + } + + return $resolved; + } + + /** + * @param \ReflectionIntersectionType $type Type to render. + * @param \ReflectionClass $scope Class the original declaration lives in. + */ + private static function intersection(\ReflectionIntersectionType $type, \ReflectionClass $scope): string + { + $members = []; + foreach ($type->getTypes() as $member) { + $members[] = $member instanceof \ReflectionNamedType + ? self::named($member, $scope, false) + : self::type($member, $scope); + } + + return implode('&', $members); + } + + /** + * @param \ReflectionClass $scope Class whose parent is needed. + */ + private static function parentOf(\ReflectionClass $scope): \ReflectionClass + { + $parent = $scope->getParentClass(); + if ($parent === false) { + throw AsyncException::of( + $scope->getName(), + 'the signature refers to "parent" but the class has none', + 'Name the type explicitly.' + ); + } + + return $parent; + } + + /** + * Renders a parameter default. + * + * PHP only requires an overriding parameter to *stay* optional — the value + * itself is never compared — but reproducing it faithfully keeps reflection + * and IDE hints on the proxy truthful. + * + * @param \ReflectionParameter $parameter Parameter carrying the default. + * @param \ReflectionMethod $method Method the parameter belongs to. + */ + private static function defaultValue(\ReflectionParameter $parameter, \ReflectionMethod $method): string + { + if ($parameter->isDefaultValueConstant()) { + $reference = self::constantReference( + (string) $parameter->getDefaultValueConstantName(), + $method->getDeclaringClass() + ); + + // A private constant is invisible from the subclass, so the literal + // value is inlined instead of the reference. + if ($reference !== null) { + return $reference; + } + } + + return self::export( + $parameter->getDefaultValue(), + $method->getDeclaringClass()->getName() . '::' . $method->getName() . '()', + '$' . $parameter->getName() + ); + } + + /** + * Resolves a constant default into source, or null when it is unreachable + * from the generated subclass. + * + * @param string $name Constant name as reported by reflection. + * @param \ReflectionClass $scope Class the original declaration lives in. + */ + private static function constantReference(string $name, \ReflectionClass $scope): ?string + { + if (!str_contains($name, '::')) { + return '\\' . ltrim($name, '\\'); + } + + [$owner, $constant] = explode('::', $name, 2); + $owner = match (strtolower($owner)) { + 'self', 'static' => $scope->getName(), + 'parent' => self::parentOf($scope)->getName(), + default => ltrim($owner, '\\'), + }; + + try { + if ((new \ReflectionClassConstant($owner, $constant))->isPrivate()) { + return null; + } + } catch (\ReflectionException) { + return null; + } + + return '\\' . $owner . '::' . $constant; + } + + /** + * Exports a runtime value as a PHP literal. + * + * @param mixed $value Value to export. + * @param string $subject Method the value belongs to, used for error messages. + * @param string $where Parameter name, used for error messages. + */ + private static function export(mixed $value, string $subject, string $where): string + { + if ($value === null) { + return 'null'; + } + + if ($value instanceof \UnitEnum) { + return '\\' . $value::class . '::' . $value->name; + } + + if (is_array($value)) { + $parts = []; + $list = array_is_list($value); + foreach ($value as $key => $item) { + $parts[] = ($list ? '' : self::export($key, $subject, $where) . ' => ') + . self::export($item, $subject, $where); + } + + return '[' . implode(', ', $parts) . ']'; + } + + if (is_object($value)) { + throw AsyncException::of( + $subject, + 'default value of ' . $where . ' is an object of ' . $value::class + . ', which cannot be written back as source', + 'Default to null and build the object inside the method.' + ); + } + + return var_export($value, true); + } +} diff --git a/src/Dev/Concurrent/CancellationException.php b/src/Dev/Concurrent/CancellationException.php new file mode 100644 index 0000000..41c8044 --- /dev/null +++ b/src/Dev/Concurrent/CancellationException.php @@ -0,0 +1,12 @@ +api->call($id); + * return CompletableFuture::completedFuture($result); + * } + * ``` + * + * @see Future + * @see ExecutorService + */ +final class CompletableFuture implements Future +{ + private const int STATE_PENDING = 0; + private const int STATE_COMPLETED = 1; + private const int STATE_FAILED = 2; + private const int STATE_CANCELLED = 3; + + private int $state = self::STATE_PENDING; + private mixed $value = null; + private ?\Throwable $throwable = null; + + /** + * Backend-specific blocking strategy, invoked by {@see get()} while the + * future is still pending. Receives the remaining timeout in seconds. + * + * @var (\Closure(float|null): void)|null + */ + private ?\Closure $awaiter = null; + + /** + * Backend-specific interruption strategy, invoked by {@see cancel()}. + * + * @var (\Closure(): void)|null + */ + private ?\Closure $canceller = null; + + /** @var list<\Closure(mixed, \Throwable|null): void> */ + private array $listeners = []; + + // ------------------------------------------------------------------------- + // Factories + // ------------------------------------------------------------------------- + + /** + * Returns a future that is already completed with the given value. + */ + public static function completedFuture(mixed $value): self + { + $future = new self(); + $future->complete($value); + + return $future; + } + + /** + * Returns a future that has already failed with the given throwable. + */ + public static function failedFuture(\Throwable $throwable): self + { + $future = new self(); + $future->completeExceptionally($throwable); + + return $future; + } + + /** + * Submits a value-returning task to an executor. + * + * @param callable $supplier Task producing the result. + * @param ExecutorService|null $executor Executor to run on; defaults to {@see Executors::common()}. + */ + public static function supplyAsync(callable $supplier, ?ExecutorService $executor = null): Future + { + return ($executor ?? Executors::common())->submit($supplier); + } + + /** + * Submits a task whose result is discarded. + * + * @param callable $runnable Task to run. + * @param ExecutorService|null $executor Executor to run on; defaults to {@see Executors::common()}. + */ + public static function runAsync(callable $runnable, ?ExecutorService $executor = null): Future + { + return ($executor ?? Executors::common())->submit(static function () use ($runnable): null { + $runnable(); + + return null; + }); + } + + /** + * Returns a future completing when every given future has completed. + * + * The result is always null. If any input fails or is cancelled, the + * returned future fails with that same throwable. + * + * @param Future ...$futures Futures to await. + */ + public static function allOf(Future ...$futures): Future + { + $all = new self(); + $all->setAwaiter(static function (?float $timeout) use ($futures, $all): void { + $deadline = $timeout === null ? null : microtime(true) + $timeout; + + foreach ($futures as $future) { + try { + $future->get($deadline === null ? null : max(0.0, $deadline - microtime(true))); + } catch (\Throwable $throwable) { + $all->completeExceptionally($throwable); + + return; + } + } + + $all->complete(null); + }); + + return $all; + } + + // ------------------------------------------------------------------------- + // Producer side + // ------------------------------------------------------------------------- + + /** + * Completes the future with a value. + * + * @return bool False if the future was already settled. + */ + public function complete(mixed $value): bool + { + if ($this->state !== self::STATE_PENDING) { + return false; + } + + $this->value = $value; + $this->state = self::STATE_COMPLETED; + $this->settle(); + + return true; + } + + /** + * Completes the future with a throwable. + * + * @return bool False if the future was already settled. + */ + public function completeExceptionally(\Throwable $throwable): bool + { + if ($this->state !== self::STATE_PENDING) { + return false; + } + + $this->throwable = $throwable; + $this->state = self::STATE_FAILED; + $this->settle(); + + return true; + } + + /** + * Registers the blocking strategy used while the future is pending. + * + * Called by the executor that created the future. The closure must return + * once the future is settled or the timeout has elapsed. + * + * @internal + * @param \Closure(float|null): void $awaiter Blocking strategy. + */ + public function setAwaiter(\Closure $awaiter): void + { + $this->awaiter = $awaiter; + } + + /** + * Registers the interruption strategy used by {@see cancel()}. + * + * @internal + * @param \Closure(): void $canceller Interruption strategy. + */ + public function setCanceller(\Closure $canceller): void + { + $this->canceller = $canceller; + } + + /** + * Registers a callback fired once the future settles. + * + * The callback receives the value and the throwable; exactly one of them is + * null. If the future has already settled, the callback runs immediately. + * + * Unlike Java this returns the same instance rather than a new stage — the + * composition pipeline (`thenApply` and friends) is intentionally omitted. + * + * @param \Closure(mixed, \Throwable|null): void $action Completion callback. + */ + public function whenComplete(\Closure $action): self + { + if ($this->state === self::STATE_PENDING) { + $this->listeners[] = $action; + + return $this; + } + + $action($this->value, $this->failure()); + + return $this; + } + + // ------------------------------------------------------------------------- + // Consumer side + // ------------------------------------------------------------------------- + + public function get(?float $timeout = null): mixed + { + if ($this->state === self::STATE_PENDING && $this->awaiter !== null) { + ($this->awaiter)($timeout); + } + + return match ($this->state) { + self::STATE_COMPLETED => $this->value, + self::STATE_FAILED => throw new ExecutionException($this->throwable), + self::STATE_CANCELLED => throw new CancellationException('Task was cancelled'), + default => throw new TimeoutException( + $timeout === null + ? 'Future is still pending and no awaiter is attached' + : sprintf('Future did not complete within %.3f second(s)', $timeout) + ), + }; + } + + /** + * Waits indefinitely and returns the result. + * + * Convenience alias of `get(null)`. + */ + public function join(): mixed + { + return $this->get(); + } + + public function isDone(): bool + { + return $this->state !== self::STATE_PENDING; + } + + public function isCancelled(): bool + { + return $this->state === self::STATE_CANCELLED; + } + + /** + * Returns true when the task finished by throwing. + */ + public function isCompletedExceptionally(): bool + { + return $this->state === self::STATE_FAILED; + } + + /** + * Returns the throwable the task failed with, or null. + */ + public function failure(): ?\Throwable + { + return $this->throwable; + } + + public function cancel(bool $mayInterruptIfRunning = false): bool + { + if ($this->state !== self::STATE_PENDING) { + return false; + } + + $this->state = self::STATE_CANCELLED; + + if ($mayInterruptIfRunning && $this->canceller !== null) { + ($this->canceller)(); + } + + $this->settle(); + + return true; + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** + * Releases waiters and fires completion callbacks exactly once. + */ + private function settle(): void + { + $listeners = $this->listeners; + $this->listeners = []; + $this->awaiter = null; + $this->canceller = null; + + foreach ($listeners as $listener) { + $listener($this->value, $this->throwable); + } + } +} diff --git a/src/Dev/Concurrent/ExecutionException.php b/src/Dev/Concurrent/ExecutionException.php new file mode 100644 index 0000000..919b548 --- /dev/null +++ b/src/Dev/Concurrent/ExecutionException.php @@ -0,0 +1,22 @@ +getMessage(), + (int) $cause->getCode(), + $cause + ); + } +} diff --git a/src/Dev/Concurrent/Executor/CoroutineExecutorService.php b/src/Dev/Concurrent/Executor/CoroutineExecutorService.php new file mode 100644 index 0000000..39a6d23 --- /dev/null +++ b/src/Dev/Concurrent/Executor/CoroutineExecutorService.php @@ -0,0 +1,237 @@ +ensureAccepting(); + + $future = new CompletableFuture(); + $this->attachAwaiter($future); + $this->spawn($task, $args, $future, false); + + return $future; + } + + public function execute(callable $task, mixed ...$args): void + { + $this->ensureAccepting(); + + $this->spawn($task, $args, new CompletableFuture(), true); + } + + public function invokeAll(iterable $tasks, ?float $timeout = null): array + { + $futures = []; + foreach ($tasks as $task) { + $futures[] = $this->submit($task); + } + + $deadline = $timeout === null ? null : microtime(true) + $timeout; + foreach ($futures as $future) { + try { + $future->get($deadline === null ? null : max(0.0, $deadline - microtime(true))); + } catch (\Throwable) { + // The outcome stays on the future; the caller inspects it there. + } + } + + return $futures; + } + + public function shutdown(): void + { + $this->shutdown = true; + } + + public function isShutdown(): bool + { + return $this->shutdown; + } + + /** + * {@inheritDoc} + * + * Only a coroutine can wait here: outside one the scheduler is not running, + * so busy-waiting would never let the pending tasks advance. In that case + * the current state is reported without waiting. + */ + public function awaitTermination(?float $timeout = null): bool + { + if (!Runtime::isSwooleCoroutine()) { + return $this->running === 0; + } + + $deadline = $timeout === null ? null : microtime(true) + $timeout; + + while ($this->running > 0) { + if ($deadline !== null && microtime(true) >= $deadline) { + return false; + } + \Swoole\Coroutine::sleep(self::DRAIN_TICK); + } + + return true; + } + + /** + * Returns the number of tasks that have not settled yet. + */ + public function runningCount(): int + { + return $this->running; + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** + * Starts the task in its own coroutine. + * + * Swoole switches into the new coroutine immediately, so a task that never + * suspends is already settled by the time this method returns. + * + * @param callable $task Task to run. + * @param list $args Arguments passed to the task. + * @param CompletableFuture $future Future carrying the outcome. + * @param bool $logFailure Whether a throwable must be logged instead of only stored. + */ + private function spawn(callable $task, array $args, CompletableFuture $future, bool $logFailure): void + { + $this->running++; + + $cid = \Swoole\Coroutine::create(function () use ($task, $args, $future, $logFailure): void { + $throwable = null; + $value = null; + + try { + $value = $task(...$args); + } catch (\Throwable $caught) { + $throwable = $caught; + } finally { + // Settling resumes the waiters, so the counter must already be + // accurate by then — otherwise they observe a stale value. + $this->running--; + } + + if ($throwable === null) { + $future->complete($value); + + return; + } + + $future->completeExceptionally($throwable); + if ($logFailure) { + $this->logFailure($throwable); + } + }); + + if ($cid === false) { + $this->running--; + $future->completeExceptionally( + new RejectedExecutionException('Swoole refused to create a coroutine for the task') + ); + + throw new RejectedExecutionException('Swoole refused to create a coroutine for the task'); + } + + if (!$future->isDone()) { + $future->setCanceller(static fn() => \Swoole\Coroutine::cancel($cid)); + } + } + + /** + * Wires the future to a channel so that waiting suspends the calling coroutine. + * + * The channel holds a single token. Whoever is woken pushes it back, so any + * number of coroutines may wait on the same future. + * + * @param CompletableFuture $future Future to attach the strategy to. + */ + private function attachAwaiter(CompletableFuture $future): void + { + $channel = new \Swoole\Coroutine\Channel(1); + + $future->whenComplete(static function () use ($channel): void { + $channel->push(true); + }); + + $future->setAwaiter(static function (?float $timeout) use ($channel, $future): void { + if ($future->isDone()) { + return; + } + + $token = $channel->pop($timeout ?? -1); + if ($token !== false) { + $channel->push($token); + } + }); + } + + /** + * Rejects the task when the executor cannot run it. + * + * @throws RejectedExecutionException If shut down or called outside a coroutine. + */ + private function ensureAccepting(): void + { + if ($this->shutdown) { + throw new RejectedExecutionException('Executor has been shut down'); + } + + if (!Runtime::isSwooleCoroutine()) { + throw new RejectedExecutionException( + 'CoroutineExecutorService requires an active Swoole coroutine. ' + . 'Use Executors::common() to pick the backend matching the current runtime.' + ); + } + } + + /** + * Reports a failure nobody is able to observe through a future. + */ + private function logFailure(\Throwable $throwable): void + { + LoggerFactory::getLogger(self::class)->error( + $throwable->getMessage() + . (env('DEBUG', false) ? "\n" . $throwable->getTraceAsString() : '') + ); + } +} diff --git a/src/Dev/Concurrent/Executor/DeferredExecutorService.php b/src/Dev/Concurrent/Executor/DeferredExecutorService.php new file mode 100644 index 0000000..af83806 --- /dev/null +++ b/src/Dev/Concurrent/Executor/DeferredExecutorService.php @@ -0,0 +1,236 @@ + */ + private array $queue = []; + + private int $sequence = 0; + private bool $shutdown = false; + private bool $draining = false; + private bool $hooked = false; + + public function submit(callable $task, mixed ...$args): Future + { + $this->ensureAccepting(); + + $future = new CompletableFuture(); + $run = $this->runner($task, $args, $future); + $id = $this->enqueue(static fn() => $run(true)); + + // Awaiting runs the task right here, so its queue slot is no longer needed. + $future->setAwaiter(function (?float $timeout) use ($run, $id): void { + unset($this->queue[$id]); + $run(false); + }); + + return $future; + } + + public function execute(callable $task, mixed ...$args): void + { + $this->ensureAccepting(); + + $run = $this->runner($task, $args, new CompletableFuture()); + $this->enqueue(static fn() => $run(true)); + } + + public function invokeAll(iterable $tasks, ?float $timeout = null): array + { + $futures = []; + foreach ($tasks as $task) { + $future = $this->submit($task); + + try { + $future->get(); + } catch (\Throwable) { + // The outcome stays on the future; the caller inspects it there. + } + + $futures[] = $future; + } + + return $futures; + } + + public function shutdown(): void + { + $this->shutdown = true; + $this->drain(); + } + + public function isShutdown(): bool + { + return $this->shutdown; + } + + public function awaitTermination(?float $timeout = null): bool + { + $this->drain(); + + return true; + } + + /** + * Returns the number of tasks still waiting to be drained. + */ + public function pendingCount(): int + { + return count($this->queue); + } + + /** + * Flushes the response and runs every queued task. + * + * Registered as a shutdown function on the first enqueue, and safe to call + * manually at any point. + */ + public function drain(): void + { + if ($this->draining || $this->queue === []) { + return; + } + + $this->draining = true; + $this->releaseClient(); + + try { + while ($this->queue !== []) { + $id = array_key_first($this->queue); + $task = $this->queue[$id]; + unset($this->queue[$id]); + $task(); + } + } finally { + $this->draining = false; + } + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** + * Builds the single runner shared by the lazy path and the drain path. + * + * The flag tells the runner whether nobody is holding the future any more, + * in which case a failure would otherwise vanish and must be logged. + * + * @param callable $task Task to run. + * @param list $args Arguments passed to the task. + * @param CompletableFuture $future Future carrying the outcome. + * @return \Closure(bool): void + */ + private function runner(callable $task, array $args, CompletableFuture $future): \Closure + { + return function (bool $unobserved) use ($task, $args, $future): void { + if ($future->isDone()) { + return; + } + + try { + $future->complete($task(...$args)); + } catch (\Throwable $throwable) { + $future->completeExceptionally($throwable); + if ($unobserved) { + $this->logFailure($throwable); + } + } + }; + } + + /** + * Queues a task and makes sure the drain will happen at shutdown. + * + * @param \Closure(): void $task Task to queue. + * @return int Slot identifier, used to drop the task once it has run early. + */ + private function enqueue(\Closure $task): int + { + $id = $this->sequence++; + $this->queue[$id] = $task; + + if (!$this->hooked) { + $this->hooked = true; + register_shutdown_function($this->drain(...)); + } + + return $id; + } + + /** + * Sends the response before the queue runs, when the SAPI allows it. + * + * Only PHP-FPM can do this. Under CLI and the built-in server the client + * simply receives the response once the script ends. + */ + private function releaseClient(): void + { + ignore_user_abort(true); + + if (function_exists('fastcgi_finish_request')) { + fastcgi_finish_request(); + + return; + } + + if (function_exists('litespeed_finish_request')) { + litespeed_finish_request(); + } + } + + /** + * @throws RejectedExecutionException If the executor has been shut down. + */ + private function ensureAccepting(): void + { + if ($this->shutdown) { + throw new RejectedExecutionException('Executor has been shut down'); + } + } + + /** + * Reports a failure nobody is able to observe through a future. + */ + private function logFailure(\Throwable $throwable): void + { + LoggerFactory::getLogger(self::class)->error( + $throwable->getMessage() + . (env('DEBUG', false) ? "\n" . $throwable->getTraceAsString() : '') + ); + } +} diff --git a/src/Dev/Concurrent/ExecutorService.php b/src/Dev/Concurrent/ExecutorService.php new file mode 100644 index 0000000..a2d141c --- /dev/null +++ b/src/Dev/Concurrent/ExecutorService.php @@ -0,0 +1,92 @@ +execute(fn() => $mixpanel->track($userId, $event)); + * + * $future = $executor->submit($api->fetch(...), $id); + * $value = $future->get(); + * ``` + * + * @see Executors + * @see Future + */ +interface ExecutorService +{ + /** + * Submits a task and returns a handle to its future result. + * + * The task is guaranteed to run even if the returned future is never + * awaited. + * + * @param callable $task Task to run. + * @param mixed ...$args Arguments passed to the task. + * @throws RejectedExecutionException If the executor cannot accept the task. + */ + public function submit(callable $task, mixed ...$args): Future; + + /** + * Submits a task whose result and return value are discarded. + * + * Failures are logged rather than propagated — nobody is holding a handle + * to observe them. + * + * @param callable $task Task to run. + * @param mixed ...$args Arguments passed to the task. + * @throws RejectedExecutionException If the executor cannot accept the task. + */ + public function execute(callable $task, mixed ...$args): void; + + /** + * Submits every task and blocks until all of them have settled. + * + * @param iterable $tasks Tasks to run. + * @param float|null $timeout Seconds to wait for the whole batch, or null for no limit. + * @return list Futures in the order the tasks were given. + * @throws RejectedExecutionException If the executor cannot accept the tasks. + */ + public function invokeAll(iterable $tasks, ?float $timeout = null): array; + + /** + * Stops accepting new tasks; already submitted ones still run to completion. + */ + public function shutdown(): void; + + /** + * Returns true once {@see shutdown()} has been called. + */ + public function isShutdown(): bool; + + /** + * Blocks until every submitted task has settled. + * + * @param float|null $timeout Seconds to wait, or null for no limit. + * @return bool True if the executor drained, false if the timeout elapsed first. + */ + public function awaitTermination(?float $timeout = null): bool; +} diff --git a/src/Dev/Concurrent/Executors.php b/src/Dev/Concurrent/Executors.php new file mode 100644 index 0000000..25c8984 --- /dev/null +++ b/src/Dev/Concurrent/Executors.php @@ -0,0 +1,112 @@ +execute(fn() => $mixpanel->track($userId, $event)); + * + * // Force a specific backend. + * $executor = Executors::newDeferredExecutor(); + * ``` + * + * @see ExecutorService + */ +final class Executors +{ + private static ?CoroutineExecutorService $coroutine = null; + private static ?DeferredExecutorService $deferred = null; + + private function __construct() + { + } + + /** + * Returns the shared executor matching the current runtime. + * + * Inside a Swoole coroutine this is the coroutine executor; everywhere else + * it is the deferred executor. The decision is made per call, never cached, + * so the same process may legitimately use both — for example a Swoole + * worker booting outside of a coroutine and then serving requests inside + * one. + */ + public static function common(): ExecutorService + { + return Runtime::isSwooleCoroutine() + ? self::coroutine() + : self::deferred(); + } + + /** + * Returns a fresh coroutine-backed executor. + * + * Requires an active Swoole coroutine at submit time. + */ + public static function newCoroutineExecutor(): ExecutorService + { + return new CoroutineExecutorService(); + } + + /** + * Returns a fresh executor that defers tasks until the response is flushed. + */ + public static function newDeferredExecutor(): ExecutorService + { + return new DeferredExecutorService(); + } + + /** + * Drains the shared executors and stops them accepting new tasks. + * + * Intended for graceful shutdown — worker recycling under Swoole, or an + * explicit flush at the end of a console command. + * + * @param float|null $timeout Seconds to wait for running tasks, or null for no limit. + * @return bool True if everything settled within the timeout. + */ + public static function shutdownCommon(?float $timeout = null): bool + { + $drained = true; + + foreach ([self::$coroutine, self::$deferred] as $executor) { + if ($executor === null) { + continue; + } + + $executor->shutdown(); + $drained = $executor->awaitTermination($timeout) && $drained; + } + + return $drained; + } + + private static function coroutine(): CoroutineExecutorService + { + return self::$coroutine ??= new CoroutineExecutorService(); + } + + private static function deferred(): DeferredExecutorService + { + return self::$deferred ??= new DeferredExecutorService(); + } +} diff --git a/src/Dev/Concurrent/Future.php b/src/Dev/Concurrent/Future.php new file mode 100644 index 0000000..e2ad2c0 --- /dev/null +++ b/src/Dev/Concurrent/Future.php @@ -0,0 +1,59 @@ +submit(fn() => $api->fetch($id)); + * $value = $future->get(); + * ``` + * + * @see CompletableFuture + * @see ExecutorService + */ +interface Future +{ + /** + * Waits for the computation to finish and returns its result. + * + * @param float|null $timeout Seconds to wait, or null to wait indefinitely. + * @return mixed The value produced by the task. + * @throws ExecutionException If the task threw; the original throwable is the previous exception. + * @throws TimeoutException If $timeout elapsed before completion. + * @throws CancellationException If the task was cancelled. + */ + public function get(?float $timeout = null): mixed; + + /** + * Returns true once the task has completed, failed or been cancelled. + */ + public function isDone(): bool; + + /** + * Returns true if the task was cancelled before it completed. + */ + public function isCancelled(): bool; + + /** + * Attempts to cancel the task. + * + * @param bool $mayInterruptIfRunning Whether an already running task may be interrupted. + * @return bool True if the task was cancelled by this call. + */ + public function cancel(bool $mayInterruptIfRunning = false): bool; +} diff --git a/src/Dev/Concurrent/RejectedExecutionException.php b/src/Dev/Concurrent/RejectedExecutionException.php new file mode 100644 index 0000000..f2d1e47 --- /dev/null +++ b/src/Dev/Concurrent/RejectedExecutionException.php @@ -0,0 +1,16 @@ +getRawBody(); - if ($required && (!$raw || !json_validate($raw))) { - RequestException::throw('Missing or invalid JSON body'); - } - $data = $raw ? (json_decode($raw, true) ?? []) : []; - return self::make($data); - } - - /** Build from XML body (Content-Type: application/xml | text/xml). */ - final public static function xml(HttpRequest $request, bool $required = true): static - { - $raw = $request->getRawBody(); - if ($required && !$raw) { - RequestException::throw('Missing XML body'); - } - $xml = $raw ? @simplexml_load_string($raw) : false; - if ($required && $xml === false) { - RequestException::throw('Invalid XML body'); - } - $data = $xml ? (json_decode(json_encode($xml), true) ?: []) : []; - return self::make($data); - } - - /** Build from URL-encoded / multipart form data. */ - final public static function form(HttpRequest $request, bool $required = true): static - { - $data = $request->getParsedBody(); - if ($required && empty($data)) { - RequestException::throw('Missing required form data'); - } - return self::make($data); - } - - /** Build directly from an associative array (e.g. one item from a JSON array). */ - final public static function fromArray(array $data, bool $required = true): static - { - if ($required && empty($data)) { - RequestException::throw('Empty data array'); - } - return self::make($data); - } - - /** Build from query string parameters. */ - final public static function query(HttpRequest $request, bool $required = true): static - { - $data = $request->getQueryParams(); - if ($required && empty($data)) { - RequestException::throw('Missing required query parameters'); - } - return self::make($data); - } - - /** - * Auto-select source by Content-Type header. - * Falls back to JSON for unknown content types. - */ - final public static function fromRequest(HttpRequest $request, bool $required = true): static - { - $ct = strtolower($request->getHeader('content-type') ?? ''); - - if (str_contains($ct, 'application/json')) { - return static::json($request, $required); - } - if (str_contains($ct, 'multipart/form-data') || str_contains($ct, 'application/x-www-form-urlencoded')) { - return static::form($request, $required); - } - if (str_contains($ct, 'application/xml') || str_contains($ct, 'text/xml')) { - return static::xml($request, $required); - } - - return static::json($request, $required); - } - - /** Override to declare validation rules — called automatically after construction. */ - public function rules(): void - { - } - - // ── Internals ───────────────────────────────────────────────────────────── - - private static function make(array $data): static - { - $normalized = []; - foreach ($data as $key => $value) { - $normalized[self::dashToCamel($key)] = $value; - } - - if (!empty($normalized)) { - $normalized = self::castToConstructorTypes($normalized); - } - - try { - $instance = empty($normalized) ? new static() : new static(...$normalized); - $instance->rules(); - return $instance; - } catch (\ArgumentCountError $e) { - $msg = preg_replace( - '/.*Argument #\d+ \(\$(\w+)\) not passed.*/', - "Required field '\$1' not found", - $e->getMessage() - ) ?: 'Missing required data'; - RequestException::throw($msg, previous: $e); - } catch (\TypeError $e) { - $msg = preg_replace( - '/.*Argument #\d+ \(\$(\w+)\) must be of type (\S+), (\S+) given.*/', - "Invalid type for '\$1' (expected: '\$2', got: '\$3')", - $e->getMessage() - ) ?: $e->getMessage(); - RequestException::throw($msg, previous: $e); - } catch (\Error $e) { - $msg = preg_replace( - '/Unknown named parameter \$(\w+)/', - "Unknown field '\$1'", - $e->getMessage() - ) ?: $e->getMessage(); - RequestException::throw($msg, previous: $e); - } - } - - private static function castToConstructorTypes(array $data): array - { - $constructor = (new \ReflectionClass(static::class))->getConstructor(); - if ($constructor === null) { - return $data; - } - - foreach ($constructor->getParameters() as $param) { - $name = $param->getName(); - if (!array_key_exists($name, $data)) { - continue; - } - $type = $param->getType(); - if (!$type instanceof \ReflectionNamedType || !$type->isBuiltin()) { - continue; - } - $value = $data[$name]; - if ($value === null) { - continue; - } - $data[$name] = match ($type->getName()) { - 'int' => (int) $value, - 'float' => (float) $value, - 'bool' => filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? (bool) $value, - 'string' => (string) $value, - default => $value, - }; - } - - return $data; - } - - private static function dashToCamel(string $key): string - { - return lcfirst(str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $key)))); - } -} diff --git a/src/Process/Core/WinterRunner.php b/src/Process/Core/WinterRunner.php index e5b09c5..bc496df 100644 --- a/src/Process/Core/WinterRunner.php +++ b/src/Process/Core/WinterRunner.php @@ -72,10 +72,10 @@ public function execute(array $options): int return 0; } catch (\Throwable $e) { $logger->critical('Uncaught exception in background process: ' . $e->getMessage()); - if (env('DEBUG' , false)) { + if (env('DEBUG', false)) { $logger->critical($e->getTraceAsString()); } return 1; } } -} \ No newline at end of file +} diff --git a/src/Unit/DataTableNet/DTNWrapper.php b/src/Unit/DataTableNet/DTNWrapper.php deleted file mode 100644 index 16ae1b2..0000000 --- a/src/Unit/DataTableNet/DTNWrapper.php +++ /dev/null @@ -1,114 +0,0 @@ -getSql('option') !== null) { - throw new DataTableNetException( - 'Repository already has a SELECT clause defined. ' . - 'The paginator requires the SELECT clause to be empty.' - ); - } - if ($repo->getSql('where') !== null) { - throw new DataTableNetException( - 'Repository already has a WHERE clause defined. ' . - 'The paginator builds its own filter and expects no predefined WHERE conditions.' - ); - } - if ($repo->getSql('order') !== null) { - throw new DataTableNetException( - 'Repository already has an ORDER BY clause defined. ' . - 'The paginator applies its own ordering logic and requires ORDER BY to be unset.' - ); - } - - try { - $repo->select($request->selection()); - $repo->where($headQueryBuilder); - $repo->orderBy($request->order()); - $repo->limit($request->length, $request->start); - - if ($accurateCounts) { - $recordsTotal = self::countRecords($repo); - } - - $repo->cleanCache('where'); - $repo->cleanCache('binds'); - $repo->where(Qb::and( - $headQueryBuilder ?: Qb::empty(), - $request->filter() - )); - - $recordsFiltered = self::countRecords($repo); - - return new DataTableNetResponse( - $request->draw, - $recordsTotal ?? $recordsFiltered, - $recordsFiltered, - $repo->findAll() - ); - } catch (\Throwable $throwable) { - if ((int) $throwable->getCode() === 42703) { - $message = $throwable->getMessage(); - - if (preg_match('/column "(.*?)" does not exist/i', $message, $matches)) { - $invalidColumn = $matches[1]; - $userMessage = "Invalid column reference: `{$invalidColumn}` does not exist in the table"; - } else { - $userMessage = "Invalid column name used in request. Please check your field mappings"; - } - - throw new DataTableNetException($userMessage, previous: $throwable); - } - throw $throwable; - } - } - - private static function countRecords(RepositoryInterface $repo): int - { - $sql = self::prepareCountSql($repo->buildSql()); - $stmt = new CDOStatement($repo->db()->prepare($sql)); - - if ($repo->getSql('binds')) { - $method = method_exists($stmt, 'bindTypedValue') ? 'bindTypedValue' : 'bindValue'; - foreach ($repo->getSql('binds') as $bind) { - $stmt->{$method}($bind->getName(), $bind->getValue()); - } - } - - $stmt->getStmt()->execute(); - return (int) $stmt->getStmt()->fetchColumn(); - } - - private static function prepareCountSql(string $sql): string - { - $sql = preg_replace('/\s+LIMIT\s+\d+/i', '', $sql); - $sql = preg_replace('/\s+OFFSET\s+\d+/i', '', $sql); - $sql = preg_replace('/\s+FOR\s+UPDATE/i', '', $sql); - return 'SELECT COUNT(*) FROM (' . $sql . ') AS tmp'; - } -} diff --git a/src/Unit/DataTableNet/DataTableNetException.php b/src/Unit/DataTableNet/DataTableNetException.php deleted file mode 100644 index 6a44234..0000000 --- a/src/Unit/DataTableNet/DataTableNetException.php +++ /dev/null @@ -1,15 +0,0 @@ -search = match (true) { - $search === null => new DTNetSearch(), - default => new DTNetSearch($search['value'] ?? '', $search['regex'] ?? false), - }; - - if (!empty($order)) { - foreach ($order as $orderItem) { - $this->order[] = new DTNetOrder($orderItem['column'], $orderItem['dir']); - } - } - - $this->columns = new DTNetColumns($columns ?? []); - } - - public function rules(): void - { - $this->validate('draw', ['positive']); - $this->validate('start', ['numeric']); - $this->validate('length', ['positive']); - } - - /** - * Validates that all requested columns are explicitly allowed. - * - * @param string[] $allowed List of permitted column `data` keys. - * @throws RequestException If any column is not in the allowed list. - */ - public function allowColumns(array $allowed): void - { - foreach ($this->columns->items as $column) { - if (!in_array($column->data, $allowed, true)) { - throw new RequestException("Column '{$column->data}' is not allowed"); - } - } - } - - /** - * Overrides column identifiers for use in the SQL SELECT clause. - * - * @param array $resetNames Associative array of `data => name`. - */ - final public function overrideSelection(array $resetNames = []): void - { - foreach ($this->columns->items as $item) { - if (isset($resetNames[$item->data])) { - $item->name = $resetNames[$item->data]; - } - } - } - - /** - * Sets a custom filtering callback for global and per-column search. - * - * @param callable|null $callback function(DTNetColumn $column, string $value): ?Qb - */ - final public function overrideFilter(?callable $callback): void - { - $this->filterCallback = $callback; - } - - /** - * Sets a fallback ORDER BY string used when no sorting is specified. - * - * @param string|null $defaultContext Example: "id DESC, created_at ASC" - */ - final public function overrideOrder(?string $defaultContext = null): void - { - $this->defaultOrder = $defaultContext; - } - - /** - * Generates a comma-separated list of column names for a SQL SELECT clause. - */ - final public function selection(): string - { - $naming = array_map( - function (DTNetColumn $item): string { - if (!empty($item->name) && $item->name !== $item->data) { - return "{$item->name} AS {$item->data}"; - } - return $item->name ?: $item->data; - }, - $this->columns->items - ); - - return implode(', ', $naming); - } - - /** - * Builds the SQL WHERE clause based on global and column-specific filters. - */ - final public function filter(): Qb - { - $andConditions = []; - - $callback = $this->filterCallback ?? function (DTNetColumn $column, string $value): ?Qb { - $field = $column->name ?: $column->data; - return Qb::like($field, "%{$value}%"); - }; - - foreach ($this->columns->items as $column) { - $value = trim($column->search->value ?? ''); - if ($value !== '' && $column->searchable) { - $cond = $callback($column, $value); - if ($cond !== null) { - $andConditions[] = $cond; - } - } - } - - $global = trim($this->search->value ?? ''); - if ($global !== '') { - $orConditions = []; - - foreach ($this->columns->items as $column) { - if ($column->searchable) { - $cond = $callback($column, $global); - if ($cond !== null) { - $orConditions[] = $cond; - } - } - } - - if (!empty($orConditions)) { - $andConditions[] = Qb::clip(Qb::or(...$orConditions)); - } - } - - return empty($andConditions) ? Qb::empty() : Qb::and(...$andConditions); - } - - /** - * Builds a SQL ORDER BY expression from the current ordering configuration. - */ - final public function order(): string - { - $orderClauses = []; - - foreach ($this->order as $orderItem) { - $column = $this->columns->items[$orderItem->column] ?? null; - if (!$column || !$column->orderable) { - continue; - } - - $field = $column->name ?: $column->data; - $direction = strtolower($orderItem->dir) === 'desc' ? 'DESC' : 'ASC'; - $orderClauses[] = "{$field} {$direction}"; - } - - if (empty($orderClauses) && $this->defaultOrder !== null) { - return $this->defaultOrder; - } - - return implode(', ', $orderClauses); - } -} diff --git a/src/Unit/DataTableNet/DataTableNetResponse.php b/src/Unit/DataTableNet/DataTableNetResponse.php deleted file mode 100644 index be1181d..0000000 --- a/src/Unit/DataTableNet/DataTableNetResponse.php +++ /dev/null @@ -1,27 +0,0 @@ - */ - public function toArray(): array - { - return [ - 'draw' => $this->draw, - 'recordsTotal' => $this->recordsTotal, - 'recordsFiltered' => $this->recordsFiltered, - 'data' => $this->data, - ]; - } -} diff --git a/src/Unit/DataTableNet/Entity/DTNetColumn.php b/src/Unit/DataTableNet/Entity/DTNetColumn.php deleted file mode 100644 index 646cfad..0000000 --- a/src/Unit/DataTableNet/Entity/DTNetColumn.php +++ /dev/null @@ -1,31 +0,0 @@ -items[] = DTNetColumn::fromArray($column); - } - } - } -} diff --git a/src/Unit/DataTableNet/Entity/DTNetOrder.php b/src/Unit/DataTableNet/Entity/DTNetOrder.php deleted file mode 100644 index 7b43f33..0000000 --- a/src/Unit/DataTableNet/Entity/DTNetOrder.php +++ /dev/null @@ -1,14 +0,0 @@ - Date: Wed, 22 Jul 2026 03:43:08 +0500 Subject: [PATCH 02/71] file moving, delete crashed functional and base docs --- console/Command/Di.php | 8 +- dev/main/MainController.php | 2 +- docs/concurrent/00-overview.md | 150 ++++++++++ docs/concurrent/01-executors.md | 209 ++++++++++++++ docs/concurrent/02-future.md | 208 ++++++++++++++ docs/concurrent/03-async.md | 258 ++++++++++++++++++ docs/concurrent/04-build.md | 181 ++++++++++++ docs/operation/00-overview.md | 190 ------------- src/BaseBoot.php | 4 +- src/{Dev => Concurrent}/Async/Async.php | 8 +- .../Async/AsyncCollector.php | 6 +- .../Async/AsyncException.php | 2 +- .../Async/AsyncSupport.php | 6 +- .../Async/Proxy/BypassScanner.php | 4 +- .../Async/Proxy/ProxyFactory.php | 17 +- .../Async/Proxy/ProxyGenerator.php | 14 +- .../Async/Proxy/SignatureWriter.php | 4 +- .../Concurrent/CancellationException.php | 2 +- .../Concurrent/CompletableFuture.php | 2 +- .../Concurrent/ExecutionException.php | 2 +- .../Executor/CoroutineExecutorService.php | 14 +- .../Executor/DeferredExecutorService.php | 12 +- src/{Dev => }/Concurrent/ExecutorService.php | 2 +- src/{Dev => }/Concurrent/Executors.php | 6 +- src/{Dev => }/Concurrent/Future.php | 2 +- .../Concurrent/RejectedExecutionException.php | 2 +- src/{Dev => }/Concurrent/TimeoutException.php | 2 +- src/Unit/Operation/Future.php | 119 -------- src/Unit/Operation/OpResult.php | 56 ---- src/Unit/Operation/Operation.php | 70 ----- src/Unit/Operation/OperationRunnable.php | 93 ------- 31 files changed, 1068 insertions(+), 587 deletions(-) create mode 100644 docs/concurrent/00-overview.md create mode 100644 docs/concurrent/01-executors.md create mode 100644 docs/concurrent/02-future.md create mode 100644 docs/concurrent/03-async.md create mode 100644 docs/concurrent/04-build.md delete mode 100644 docs/operation/00-overview.md rename src/{Dev => Concurrent}/Async/Async.php (89%) rename src/{Dev => Concurrent}/Async/AsyncCollector.php (97%) rename src/{Dev => Concurrent}/Async/AsyncException.php (94%) rename src/{Dev => Concurrent}/Async/AsyncSupport.php (91%) rename src/{Dev => Concurrent}/Async/Proxy/BypassScanner.php (98%) rename src/{Dev => Concurrent}/Async/Proxy/ProxyFactory.php (93%) rename src/{Dev => Concurrent}/Async/Proxy/ProxyGenerator.php (95%) rename src/{Dev => Concurrent}/Async/Proxy/SignatureWriter.php (98%) rename src/{Dev => }/Concurrent/CancellationException.php (80%) rename src/{Dev => }/Concurrent/CompletableFuture.php (99%) rename src/{Dev => }/Concurrent/ExecutionException.php (91%) rename src/{Dev => }/Concurrent/Executor/CoroutineExecutorService.php (94%) rename src/{Dev => }/Concurrent/Executor/DeferredExecutorService.php (95%) rename src/{Dev => }/Concurrent/ExecutorService.php (98%) rename src/{Dev => }/Concurrent/Executors.php (94%) rename src/{Dev => }/Concurrent/Future.php (97%) rename src/{Dev => }/Concurrent/RejectedExecutionException.php (88%) rename src/{Dev => }/Concurrent/TimeoutException.php (85%) delete mode 100644 src/Unit/Operation/Future.php delete mode 100644 src/Unit/Operation/OpResult.php delete mode 100644 src/Unit/Operation/Operation.php delete mode 100644 src/Unit/Operation/OperationRunnable.php diff --git a/console/Command/Di.php b/console/Command/Di.php index 3be2d34..3fd2f92 100644 --- a/console/Command/Di.php +++ b/console/Command/Di.php @@ -9,10 +9,10 @@ use Flytachi\Winter\DI\Collector\DICollector; use Flytachi\Winter\DI\Contract\CollectorInterface; use Flytachi\Winter\DI\Scanner; -use Flytachi\Winter\K2\Dev\Async\AsyncCollector; -use Flytachi\Winter\K2\Dev\Async\Proxy\BypassScanner; -use Flytachi\Winter\K2\Dev\Async\Proxy\ProxyFactory; -use Flytachi\Winter\K2\Dev\Async\Proxy\ProxyGenerator; +use Flytachi\Winter\K2\Concurrent\Async\AsyncCollector; +use Flytachi\Winter\K2\Concurrent\Async\Proxy\BypassScanner; +use Flytachi\Winter\K2\Concurrent\Async\Proxy\ProxyFactory; +use Flytachi\Winter\K2\Concurrent\Async\Proxy\ProxyGenerator; use Flytachi\Winter\K2\Kernel; use ReflectionClass; use ReflectionMethod; diff --git a/dev/main/MainController.php b/dev/main/MainController.php index 043b8b5..e9d2baf 100644 --- a/dev/main/MainController.php +++ b/dev/main/MainController.php @@ -3,7 +3,7 @@ namespace Main; use Flytachi\Winter\DI\Attribute\Inject; -use Flytachi\Winter\K2\Dev\Concurrent\Executors; +use Flytachi\Winter\K2\Concurrent\Executors; use Flytachi\Winter\K2\Http\Request\Annotation\PathVariable; use Flytachi\Winter\K2\Http\Request\Annotation\RequestBody; use Flytachi\Winter\K2\Http\Request\Annotation\RequestParam; diff --git a/docs/concurrent/00-overview.md b/docs/concurrent/00-overview.md new file mode 100644 index 0000000..9a099df --- /dev/null +++ b/docs/concurrent/00-overview.md @@ -0,0 +1,150 @@ +# Winter Concurrent — Overview + +Some work does not belong in the response. Pushing an event to Mixpanel, +sending a welcome e-mail, calling an SMS gateway — the user does not need to +wait 300 ms for any of it, and the request should return as soon as the useful +work is done. + +The **Concurrent** unit is how the kernel runs that work: submit a task, get a +handle back, keep serving the request. + +```php +Executors::common()->execute(fn() => $mixpanel->track($userId, 'signup')); + +return ResponseEntity::ok($user); // client is not waiting for Mixpanel +``` + +The API is modelled on `java.util.concurrent` — `Future`, `ExecutorService`, +`Executors`, `CompletableFuture` mean here what they mean there. On top of it +sits `#[Async]`, the equivalent of Spring's `@Async`, which moves the decision +from the call site into the service declaration. + +--- + +## Why an abstraction at all + +PHP has no threads. Historically the only way to run something in the +background was to spawn a whole new process: fork or `proc_open`, boot the +framework again, open a fresh database connection, and only then do the work. +That costs about **112 ms and tens of megabytes for a task whose body is +empty** — usually far more than the work itself. + +Under Swoole the answer is different: a coroutine costs roughly **12 KB** and a +few microseconds, and a task blocked on a network call simply yields so the +worker keeps serving other requests. Under PHP-FPM there are still no +coroutines, but a task can at least be deferred until *after* the response has +been flushed. + +Two runtimes, two mechanisms, one contract. Application code says *what* should +happen in the background; the kernel decides *how*, based on where it is +running. + +--- + +## How the pieces fit together + +``` +Executors ← entry point, picks the backend for the runtime + │ + ├── ExecutorService ← the contract: submit / execute / invokeAll + │ ↑ + │ ├── CoroutineExecutorService (Swoole) go() + Channel + │ └── DeferredExecutorService (FPM/CLI) lazy + fastcgi_finish_request + │ + └── Future ← handle on a result + ↑ + CompletableFuture + +#[Async] ← declaration-site sugar, generates a proxy that + calls the executor for you +``` + +`Future` and `CompletableFuture` know nothing about Swoole. The executor hands +the future a waiting strategy when it creates it, which is why the same object +works unchanged in both runtimes. + +--- + +## The two runtimes + +| | Swoole | PHP-FPM / CLI | +|---|---|---| +| Backend | `CoroutineExecutorService` | `DeferredExecutorService` | +| A task is | a coroutine | a deferred callback | +| Concurrency | real — tasks interleave on I/O | none — tasks run one after another | +| Waiting on a `Future` | suspends the calling coroutine | runs the task right there | +| Fire-and-forget | runs alongside the request | runs after the response is flushed | +| Cost per task | ~12 KB | ~0 | + +The contract is identical in both: **the result is always correct, and +fire-and-forget never makes the client wait.** What differs is parallelism, +which FPM cannot provide at all. See [01-executors.md](01-executors.md) for the +exact FPM semantics. + +--- + +## Two ways to use it + +Concurrent offers a primitive and, on top of it, an attribute. They are not +alternatives to choose between once — they answer different questions. + +**`Executors` — asynchrony is a decision of the caller.** + +```php +Executors::common()->execute(fn() => $mixpanel->track($userId, 'signup')); +``` + +Visible at the call site, works everywhere: with `new`, with `final` classes, +with private methods, with plain closures. This is the right default for +one-off background work. + +**`#[Async]` — asynchrony is a property of the service.** + +```php +#[Async] +public function track(int $userId, string $event): void { … } +``` + +Declared once, and every one of the twenty call sites gets it without knowing. +Use it when "this service always works in the background" is part of its +contract. It requires the object to come from the DI container — see +[03-async.md](03-async.md) for the full rule set. + +| Situation | Reach for | +|---|---| +| One-off background work in a controller | `Executors::common()` | +| A service that is asynchronous by nature | `#[Async]` | +| Static method, `final` class, manual `new` | `Executors::common()` | +| Fan-out over several external calls | `invokeAll()` | + +--- + +## What it costs + +Measured on PHP 8.5.8, a single background task with an empty body, start to +result: + +| | process per task (the old way) | coroutine | +|---|---|---| +| Full cycle | 112 ms | **1.0 ms** | +| Memory | 3.5 MB (minimal app; a real one is far larger) | **12.3 KB** | + +500 concurrent coroutines occupy about 6 MB in one worker. + +--- + +## Pages + +| # | File | Contents | +|---|------|----------| +| 00 | this page | What the unit is, the two runtimes, when to use what | +| 01 | [01-executors.md](01-executors.md) | `Executors`, `ExecutorService`, backend behaviour | +| 02 | [02-future.md](02-future.md) | `Future`, `CompletableFuture`, results and failures | +| 03 | [03-async.md](03-async.md) | `#[Async]` — contract, proxying, pitfalls | +| 04 | [04-build.md](04-build.md) | Caches, `call di build`, deployment | + +## See also + +- [`configuration/07-di.md`](../configuration/07-di.md) — the container the proxies are registered in +- [`console/10-di.md`](../console/10-di.md) — the `call di` command +- [`threads/00-overview.md`](../threads/00-overview.md) — jobs and daemons that own their own process diff --git a/docs/concurrent/01-executors.md b/docs/concurrent/01-executors.md new file mode 100644 index 0000000..8ee8a14 --- /dev/null +++ b/docs/concurrent/01-executors.md @@ -0,0 +1,209 @@ +# Executors + +An **executor** is the thing that actually runs a background task. Application +code never says "spawn a coroutine" or "defer until the response is flushed" — +it hands the task to an `ExecutorService` and the executor decides, based on the +runtime it is living in. + +That indirection is the whole point: the same three lines of application code +behave correctly under Swoole and under PHP-FPM, and there is no +`if (Runtime::isSwoole())` anywhere in your project. + +```php +use Flytachi\Winter\K2\Concurrent\Executors; + +Executors::common()->execute(fn() => $mixpanel->track($userId, 'signup')); +``` + +--- + +## `Executors` + +```php +use Flytachi\Winter\K2\Concurrent\Executors; +``` + +### `common(): ExecutorService` + +Returns the shared executor matching the current runtime — the coroutine +executor inside a Swoole coroutine, the deferred one everywhere else. + +The decision is made **on every call**, never cached, because one process can +legitimately be in both states: a Swoole worker booting outside a coroutine and +then serving requests inside one. + +This is what application code should use. + +### `newCoroutineExecutor(): ExecutorService` + +A fresh coroutine-backed executor. Useful when you want an isolated +`shutdown()` / `awaitTermination()` scope — for example a batch job that must +drain its own tasks without waiting for everything else in the worker. + +Requires an active coroutine at submit time; otherwise every call throws +`RejectedExecutionException`. + +### `newDeferredExecutor(): ExecutorService` + +A fresh deferred executor. Chiefly useful in tests, and when you deliberately +want synchronous behaviour inside a Swoole process. + +### `shutdownCommon(?float $timeout = null): bool` + +Stops the shared executors accepting new work and waits for what is already +running. Returns `false` if the timeout elapsed with tasks still in flight. + +Intended for graceful shutdown — worker recycling under Swoole, or an explicit +flush at the end of a console command: + +```php +Executors::common()->execute(fn() => $reporter->send($stats)); + +Executors::shutdownCommon(timeout: 5.0); // let it finish before we exit +``` + +--- + +## `ExecutorService` + +The contract every backend implements. + +### `submit(callable $task, mixed ...$args): Future` + +Runs the task and returns a handle on its result. + +```php +$future = Executors::common()->submit($api->fetch(...), $id); +$value = $future->get(); +``` + +Extra arguments are passed to the task, which pairs nicely with PHP's +first-class callable syntax (`$api->fetch(...)`) — no closure needed. + +The task is **guaranteed to run** even if the returned future is never awaited. + +### `execute(callable $task, mixed ...$args): void` + +Fire-and-forget: no handle, no result. + +Because nobody can observe a failure through a future, **a throwable is logged** +instead of being silently dropped. `submit()` does not log — its owner is +expected to see the failure through `get()`. + +### `invokeAll(iterable $tasks, ?float $timeout = null): array` + +Submits every task and blocks until all of them have settled. Returns the +futures in the order the tasks were given; inspect each one individually. + +```php +$results = Executors::common()->invokeAll([ + fn() => $billing->fetch($id), + fn() => $crm->fetch($id), + fn() => $support->fetch($id), +]); + +foreach ($results as $future) { + if ($future->isCompletedExceptionally()) { + $logger->warning($future->failure()->getMessage()); + continue; + } + $data[] = $future->get(); +} +``` + +Under Swoole the three calls run **concurrently**: total time is the slowest +one, not the sum. Under FPM they run in sequence — the results are identical, +the wall clock is not. + +`$timeout` bounds the whole batch. A task that outlives it keeps running; only +the waiting stops. + +### `shutdown(): void` / `isShutdown(): bool` + +Stops accepting new tasks. Already submitted ones still run to completion. +Submitting afterwards throws `RejectedExecutionException`. + +### `awaitTermination(?float $timeout = null): bool` + +Blocks until every submitted task has settled. Returns `false` on timeout. + +> Under Swoole this only works from **inside a coroutine**. Outside one the +> scheduler is not running, so busy-waiting could never let the pending tasks +> advance; the method reports the current state instead of hanging. + +--- + +## Backends + +### `CoroutineExecutorService` — Swoole + +Each task becomes a coroutine in the current worker. A task blocked on I/O +yields, and the worker keeps serving other work — this is the only backend that +offers real concurrency. + +Waiting on a future suspends the calling coroutine through a `Channel` rather +than blocking the process, and any number of coroutines may wait on the same +future. + +Two consequences worth knowing: + +**A task gets its own coroutine context.** That means its own connection +borrowed from the PPA pool, returned automatically when the task ends. It also +means request-scoped state — headers, locale, repository query state — is +**not** inherited. Everything a task needs must arrive through its arguments. + +**A task that never suspends finishes eagerly.** Swoole enters the new coroutine +immediately, so a purely computational body completes before `submit()` even +returns. The result is still correct; only the "runs later" intuition does not +hold. + +`runningCount(): int` reports how many tasks have not settled yet. + +### `DeferredExecutorService` — PHP-FPM, CLI, built-in server + +There is no concurrency to be had in a synchronous SAPI, so the contract is +preserved by moving work in *time* rather than running it in parallel: + +- **`submit()`** runs the task lazily, at the moment its future is awaited. A + future that is never awaited still runs — during the drain below. +- **`execute()`** always defers. The queue is drained after + `fastcgi_finish_request()` has flushed the response, so the client never + waits. +- **`invokeAll()`** runs everything immediately, in order. + +Deferred tasks execute **sequentially in the same worker**: four tasks of 200 ms +occupy the worker for 800 ms after the response was sent. That is the deliberate +trade — no process spawning, no closure serialization, no extra database +connection. It also means the worker is unavailable for new requests during that +time, which is the real cost to watch under load. + +Two limits keep counting during the drain: `max_execution_time` and FPM's +`request_terminate_timeout`. A long deferred task can still be killed. + +`pendingCount(): int` reports the queue depth; `drain(): void` runs it early. + +> `fastcgi_finish_request()` exists only under FPM (LiteSpeed's equivalent is +> used when present). Under CLI and the built-in dev server the response is +> simply sent when the script ends. + +--- + +## Errors + +| Thrown by | When | +|---|---| +| `RejectedExecutionException` | executor is shut down, or a coroutine executor is used outside a coroutine | +| `ExecutionException` | the task threw — original available via `getPrevious()` | +| `TimeoutException` | the wait elapsed; the task itself keeps running | +| `CancellationException` | the task was cancelled before producing a result | + +The first is raised at submit time, the rest by `Future::get()` — see +[02-future.md](02-future.md). + +--- + +## See also + +- [02-future.md](02-future.md) — the handle `submit()` returns +- [03-async.md](03-async.md) — declaring asynchrony on the method instead +- [`ppa/`](../ppa/00-overview.md) — connection pool a task borrows from diff --git a/docs/concurrent/02-future.md b/docs/concurrent/02-future.md new file mode 100644 index 0000000..42d5936 --- /dev/null +++ b/docs/concurrent/02-future.md @@ -0,0 +1,208 @@ +# Future and CompletableFuture + +A background task produces a value at some point after the call that started it +has already returned. A **`Future`** is the handle on that value: you hold it, +and when you finally need the result you ask for it. + +```php +$future = Executors::common()->submit($api->fetch(...), $id); + +// …other work, which is the entire point… + +$value = $future->get(); +``` + +`Future` is the read side, `CompletableFuture` the implementation that also +exposes the write side. Application code normally only sees the interface; +`CompletableFuture` becomes relevant when writing an `#[Async]` method body or +composing results by hand. + +Both are runtime-agnostic. The executor that created a future hands it a waiting +strategy, so `get()` suspends a coroutine under Swoole and runs the task inline +under FPM — without the future knowing which happened. + +--- + +## `Future` + +```php +use Flytachi\Winter\K2\Concurrent\Future; +``` + +### `get(?float $timeout = null): mixed` + +Waits for the task and returns its value. + +```php +$value = $future->get(); // wait as long as it takes +$value = $future->get(2.5); // …but no longer than 2.5 s +``` + +| Outcome | Result | +|---|---| +| Task returned | the value | +| Task threw | throws `ExecutionException`, original in `getPrevious()` | +| Task was cancelled | throws `CancellationException` | +| Timeout elapsed | throws `TimeoutException` | + +**A timeout bounds the wait, not the task.** The work keeps going, and a later +`get()` on the same future still returns the result: + +```php +try { + $value = $future->get(0.05); +} catch (TimeoutException) { + $logger->info('slow, moving on'); +} + +$value = $future->get(); // still works, still correct +``` + +### `isDone(): bool` + +True once the task has completed, failed or been cancelled — the three are not +distinguished here. Use `isCompletedExceptionally()` or `isCancelled()` when you +need to tell them apart. + +### `isCancelled(): bool` + +True if the task was cancelled before it produced a result. + +### `cancel(bool $mayInterruptIfRunning = false): bool` + +Attempts to cancel; returns `true` only if this call is what cancelled it. With +`$mayInterruptIfRunning` the backend also tries to interrupt work already in +progress — under Swoole that means cancelling the coroutine. + +--- + +## `CompletableFuture` + +```php +use Flytachi\Winter\K2\Concurrent\CompletableFuture; +``` + +Everything `Future` has, plus the ability to settle it yourself. + +### Factories + +```php +CompletableFuture::completedFuture($value); // already successful +CompletableFuture::failedFuture($throwable); // already failed +``` + +`completedFuture()` is what the body of a `Future`-returning `#[Async]` method +returns — see [03-async.md](03-async.md). + +```php +CompletableFuture::supplyAsync(fn() => $api->fetch($id)); // submit() +CompletableFuture::runAsync(fn() => $mailer->send($to)); // result discarded +``` + +Both accept an optional executor and default to `Executors::common()`. + +### `allOf(Future ...$futures): Future` + +Completes when every given future has completed. The value is always `null`; +you read the individual futures for results. + +```php +$a = Executors::common()->submit(fn() => $billing->fetch($id)); +$b = Executors::common()->submit(fn() => $crm->fetch($id)); + +CompletableFuture::allOf($a, $b)->get(); + +$data = ['billing' => $a->get(), 'crm' => $b->get()]; +``` + +If any input fails or is cancelled, the returned future fails with that same +throwable. + +> For a batch you are building anyway, `ExecutorService::invokeAll()` is more +> direct — it submits and waits in one call. `allOf()` is for futures you +> already hold, possibly from different sources. + +### Settling manually + +```php +$future = new CompletableFuture(); + +$future->complete($value); // → true, or false if already settled +$future->completeExceptionally($throwable); // → same +``` + +Both return `false` when the future was already settled, which makes them safe +to call from a race without checking first. + +### `whenComplete(\Closure $action): self` + +Registers a callback fired once the future settles, receiving `(mixed $value, +?Throwable $error)` — exactly one of them is non-null. If the future has already +settled, the callback runs immediately. + +```php +$future->whenComplete(function (mixed $value, ?\Throwable $e) use ($logger): void { + $e === null + ? $logger->info('done') + : $logger->error($e->getMessage()); +}); +``` + +> Unlike Java this returns the **same** instance rather than a new stage. The +> composition pipeline (`thenApply`, `thenCompose`, …) is deliberately not +> implemented — it buys little in a language without a fluent async ecosystem, +> and it would double the surface to maintain. + +### Inspecting the outcome + +```php +$future->isCompletedExceptionally(); // failed? +$future->failure(); // the throwable, or null +$future->join(); // get() with no timeout +``` + +`failure()` is the way to look at an error **without** it being thrown at you — +useful when processing a batch where some failures are expected: + +```php +foreach ($results as $future) { + if ($error = $future->failure()) { + $logger->warning('partial failure: ' . $error->getMessage()); + continue; + } + $ok[] = $future->get(); +} +``` + +--- + +## Errors in background work + +Where a failure surfaces depends on how the task was started: + +| Started with | A throwable | +|---|---| +| `submit()` | is stored on the future, re-thrown wrapped in `ExecutionException` by `get()` | +| `execute()` | is **logged** — nobody holds a handle, so silence would lose it | +| `invokeAll()` | is stored on the individual future; the batch itself does not throw | + +`ExecutionException` always wraps the original: + +```php +try { + $future->get(); +} catch (ExecutionException $e) { + $original = $e->getPrevious(); // the real exception from the task body +} +``` + +A task that is never awaited and never logged would be a black hole, so the +deferred backend logs failures of futures it had to run during the drain — that +is, ones nobody ever asked for. + +--- + +## See also + +- [01-executors.md](01-executors.md) — where futures come from +- [03-async.md](03-async.md) — `#[Async]` methods return a `Future` diff --git a/docs/concurrent/03-async.md b/docs/concurrent/03-async.md new file mode 100644 index 0000000..7a9720b --- /dev/null +++ b/docs/concurrent/03-async.md @@ -0,0 +1,258 @@ +# `#[Async]` + +With `Executors` the decision to go asynchronous is made at the **call site** — +every place that calls the service has to remember to wrap it. When a service is +asynchronous *by nature*, that repetition is noise, and one forgotten wrap is a +silent regression. + +`#[Async]` moves the decision into the declaration: + +```php +#[Singleton] +class NotificationService +{ + #[Async] + public function track(int $userId, string $event): void + { + $this->mixpanel->push($userId, $event); + } +} +``` + +```php +class AuthController extends Controller +{ + #[Autowired] + protected NotificationService $notifications; + + #[PostMapping('/register')] + public function register(): ResponseEntity + { + $user = $this->users->create(…); + $this->notifications->track($user->id, 'signup'); // returns immediately + + return ResponseEntity::ok($user); + } +} +``` + +The controller knows nothing. Nothing is registered by hand. This is Spring's +`@Async`, and — as there — it comes with rules, because PHP has no way to make a +method asynchronous without generating code around it. + +--- + +## How it works + +At scan time the kernel finds every class with `#[Async]` methods and generates +a **subclass** that overrides them: + +```php +final class NotificationService__Async extends NotificationService implements ProxyInterface +{ + public static function proxyTarget(): string + { + return NotificationService::class; + } + + public function track(int $userId, string $event): void + { + AsyncSupport::execute(Executors::common(), fn() => parent::track($userId, $event)); + } +} +``` + +The container binding is then swapped — `NotificationService` resolves to the +generated class — while the DI lifetime is preserved: a `#[Singleton]` service +stays a singleton, only its concrete class changes. + +Generated files live in volatile storage and are produced on first boot or by +`call di build`; see [04-build.md](04-build.md). + +Because the proxy **extends** the original, `instanceof NotificationService` +still holds and every type hint keeps working. + +--- + +## The contract + +A method carrying `#[Async]` must satisfy five rules. All of them are checked +when proxies are generated — a violation fails the build, never a request. + +### 1. The class is not `final`, the method is not `final` or `static` + +The proxy is a subclass. A `final` class cannot be extended, a `final` method +cannot be overridden, and a `static` call names the class at the call site so no +subclass ever participates: + +```php +NotificationService::track($id, 'signup'); // resolved by name → original class +``` + +`protected` methods **are** allowed — PHP dispatches them virtually, so an +internal asynchronous helper works and a self-call reaches the override. + +`private` methods are not: PHP resolves them statically inside their own class, +so no subclass can intercept. Make the method `protected`. + +### 2. The return type is `Future` or `void` + +An overriding method must be compatible with the declared type, so the proxy can +only return what the signature already promises. This is the same rule Spring +has, for the same reason. + +### 3. A `Future`-returning body returns a completed future + +```php +#[Async] +public function send(int $userId): Future +{ + $result = $this->mailer->send($userId); + + return CompletableFuture::completedFuture($result); +} +``` + +The proxy runs this body in the background and immediately returns a *pending* +future; when the body finishes, the value inside its completed future is +unwrapped into the outer one. The caller sees a plain `Future` carrying +`$result`. + +### 4. No by-reference parameters + +An asynchronous call returns before the body runs, so writes to a `&$param` +could never be observed. Return the value instead. + +### 5. The object comes from the DI container + +This is the rule with teeth — see below. + +--- + +## The `new` trap + +`#[Async]` is metadata; on its own it does nothing. Only the container knows +about the substitution, and `new` goes straight past it: + +```php +// ❌ runs synchronously — the request waits +$service = new NotificationService(); +$service->track($user->id, 'signup'); + +// ✅ container hands out the proxy +#[Autowired] +protected NotificationService $notifications; +``` + +Measured on the same class and the same call: + +``` +from the container: returned in 0.4 ms, result after 102 ms +via new: returned in 101 ms ← blocked +``` + +Nothing distinguishes the two in code — same declared type, `instanceof Future` +true in both, `get()` returns the same value. The only tell is that the `new` +version's future is already `isDone()`, which nobody checks. + +`void` methods are worse still: with no return value there is nothing to +inspect at all. + +Two mitigations: + +- **`call di build` warns about it.** The build scans your sources for `new X()` + where `X` has `#[Async]` methods and reports file and line. It is textual and + therefore not exhaustive — see [04-build.md](04-build.md). +- **Prefer returning `Future` over `void`**, even when the result is not needed. + `$future = $svc->track(...)` at least reads as asynchronous. + +--- + +## Self-invocation works + +In Spring, one method of a bean calling another annotated method of the same +bean bypasses the proxy — the famous `@Async` gotcha. The cause is that Spring's +proxy and the target bean are two different objects, and `this` inside the body +is the target. + +Here there is only one object: the container creates the proxy itself, so `this` +in an inherited method **is** the proxy, and PHP dispatches virtually. + +```php +class NotificationService +{ + #[Async] + public function track(int $userId, string $event): Future { … } + + public function trackBatch(array $userIds): void + { + foreach ($userIds as $id) { + $this->track($id, 'batch'); // goes through the override + } + } +} +``` + +There is no recursion risk: the override calls `parent::track()`, which is +non-virtual by definition. + +--- + +## What a task does and does not inherit + +An `#[Async]` method runs in a fresh execution context. Under Swoole that is +literally a new coroutine context. + +**Inherited:** nothing beyond its arguments and the object's own state. + +**Not inherited:** request headers, locale, repository query state, logging +correlation fields. A repository configured in the caller will arrive empty — +build it inside the method. + +Pass everything the task needs explicitly: + +```php +#[Async] +public function sendWelcome(int $userId, string $locale): Future // ← locale is an argument +``` + +The one thing that *is* handled for you is the database connection: the task +borrows its own from the PPA pool and returns it automatically when it ends. + +--- + +## Choosing an executor + +```php +#[Async(executor: 'reports')] +public function build(int $month): Future { … } +``` + +The argument is a container id resolved at call time. Omitted, the method uses +`Executors::common()`. + +--- + +## When not to use it + +`#[Async]` needs the container. Where there is no container there is no +substitution, and the primitive is the honest answer: + +| Situation | Use | +|---|---| +| `final` class or `final` method | `Executors::common()` | +| `static` method | `Executors::common()` inside the method body | +| object built with `new` | `Executors::common()` | +| `private` method | make it `protected`, or call the executor directly | +| one-off background work | `Executors::common()` | + +Nothing is lost by doing so — `#[Async]` is sugar over exactly that call. + +--- + +## See also + +- [01-executors.md](01-executors.md) — the primitive underneath +- [02-future.md](02-future.md) — `CompletableFuture::completedFuture()` +- [04-build.md](04-build.md) — generation, caches, the bypass warning +- [`configuration/07-di.md`](../configuration/07-di.md) — container lifetimes the proxy preserves diff --git a/docs/concurrent/04-build.md b/docs/concurrent/04-build.md new file mode 100644 index 0000000..e285882 --- /dev/null +++ b/docs/concurrent/04-build.md @@ -0,0 +1,181 @@ +# Build and caches + +`#[Async]` needs generated code — a proxy class per annotated service. This page +covers where that code comes from, where it lives, and what the build step is +actually for. + +Nothing here applies if you only use `Executors` directly: the primitive +generates nothing and caches nothing. + +--- + +## What gets produced + +Two artefacts, both derived from the same project scan and both living in +volatile storage next to the DI cache: + +| Path | Contents | +|---|---| +| `/async.php` | list of classes that carry `#[Async]` | +| `/async/*.php` | the generated proxy classes | + +`` is `Kernel::$pathStorageVolatile`. By default that is +`sys_get_temp_dir() . '/flytachi.winter.volatile.'`; with +`Kernel::init(isTmpVolatile: false)` it becomes `storage/volatile`. The proxies +follow that setting automatically — they are the same kind of artefact as +`di.php`, so they obey the same durability policy. + +Both are **regenerable**. If the directory is wiped, the next boot rebuilds what +it needs; writes are atomic, so several Swoole workers rebuilding at once cannot +corrupt a file. + +--- + +## When generation happens + +``` +DEBUG=true → no caches at all; a proxy is regenerated whenever its source file is newer +DEBUG=false → generated on first boot if missing, then only checked for existence +``` + +Editing a service and reloading is enough in development. In production nothing +is regenerated after the first boot unless the files are gone. + +The class list is cached separately for a reason: **finding** `#[Async]` methods +means reflecting every method of every class in the project, which costs about +three times as much as the whole DI collector and would be paid on every boot — +under FPM, on every request. With the list cached, later boots do a plain array +lookup. + +| Per boot, 300-class project | | +|---|---| +| DI collector alone | 0.26 ms | +| + async, list cached | 0.30 ms | +| + async, discovering | 0.59 ms | + +Memory overhead of the layer at boot: about 2 KB. + +--- + +## `call di build` + +One scan produces everything the container needs: + +``` +$ php call di build + + | di cache ........................ [BUILT (247 classes)] + | async proxies ................... [BUILT (6 classes, 14 methods)] + | async bypass .................... [NONE] +``` + +The DI class list and the proxies come from the same filesystem walk on purpose +— two separate commands would leave a window where one is stale. + +**The build is primarily a contract check.** With volatile storage in `/tmp` the +generated files do not survive into a container image anyway, so the value is +not a warmer start: it is that an invalid `#[Async]` method fails here rather +than on the first request in production. + +``` + | di cache ........................ [BUILT (248 classes)] + | async proxies ................... [FAILED] + | [!] App\Services\BrokenService: the class is final and cannot be extended. + Drop "final" from the class, or move the #[Async] method to a non-final service. +``` + +**A failed build exits with code 1** — the only command in the console that +returns a non-zero status, added precisely so CI notices. Note that the DI cache +still reports `BUILT`: the class list is written before collectors run, and the +output says so honestly rather than pretending the whole step failed. + +--- + +## `call di clean` + +Removes `di.php`, `async.php` and every generated proxy: + +``` + | di cache ........................ [CLEANED] + | async proxies ................... [CLEANED (6 files)] +``` + +Worth running after deleting or renaming a service — a stale proxy for a class +that no longer exists is harmless but confusing. `build` clears them itself +before regenerating. + +--- + +## `call di async` + +Lists every `#[Async]` method in the project and whether its proxy exists: + +``` +$ php call di async + + | [ Async methods (2 classes) ] + | App\Services\NotificationService ......... [BUILT] + | track() → void + | send() → Flytachi\Winter\K2\Concurrent\Future + | App\Services\ReportService ............... [PENDING] + | build() → Flytachi\Winter\K2\Concurrent\Future +``` + +`PENDING` means the proxy has not been generated yet; it will be on first use. +Accepts an optional case-insensitive FQCN filter: `call di async Notification`. + +--- + +## The bypass warning + +Every build scans your sources for services built with `new` instead of resolved +from the container — the trap described in [03-async.md](03-async.md#the-new-trap): + +``` + | async bypass .................... [2 FOUND] + | [!] main/Http/AuthController.php:16 — new App\Services\NotificationService() + bypasses the proxy and runs synchronously; inject it instead + | [!] main/Http/OrderController.php:13 — new App\Services\ReportService() + bypasses the proxy and runs synchronously; inject it instead +``` + +It resolves names through each file's `namespace` and `use` block, so imported, +aliased, grouped and fully qualified forms are all recognised. + +**It is a warning, never a build failure**, because the scan reads source text +and cannot be exhaustive: + +- dynamic construction — `new $class`, factories, names from configuration — is + invisible; +- `vendor/` and test directories are skipped, since constructing a service + directly is usually what a test wants; +- `new self()` / `new static()` inside the service are not bypasses. + +A clean report is therefore not proof of correctness — but what it does find is +almost always a real mistake. Output is capped at 20 entries, with the remainder +summarised. + +--- + +## Deployment + +```bash +php call mapping build # route cache +php call di build # DI cache + async proxies + contract check +``` + +Run both in CI so a broken `#[Async]` contract fails the pipeline. If your +volatile storage is project-local (`isTmpVolatile: false`) the artefacts also +ship with the image and the first request skips generation entirely. + +Nothing breaks if you skip the build: the first boot generates what it needs, +provided the runtime user can write to volatile storage — the same requirement +`di.php` and `mapping.php` already have. + +--- + +## See also + +- [03-async.md](03-async.md) — the contract this step verifies +- [`console/10-di.md`](../console/10-di.md) — the rest of the `call di` command +- [`configuration/01-kernel.md`](../configuration/01-kernel.md) — `isTmpVolatile` and storage paths diff --git a/docs/operation/00-overview.md b/docs/operation/00-overview.md deleted file mode 100644 index 88060b5..0000000 --- a/docs/operation/00-overview.md +++ /dev/null @@ -1,190 +0,0 @@ -# Operation — async results via `Future` - -`Operation::async()` wraps any callable, runs it in a **child process** -(a `Thread`), and hands back a `Future`. From there you either **await** -the result or **drop** the handle for fire-and-forget execution. - -Namespace: `Flytachi\Winter\K2\Unit\Operation`. The result is marshaled -back to the parent through a volatile store (`Kernel::volatile('operations')`), -so this is real OS-level process isolation, not coroutines or threads-in-memory. - -```php -use Flytachi\Winter\K2\Unit\Operation\Operation; - -// Await the result -$value = Operation::async(fn() => heavyJob())->return(); - -// Fire-and-forget — don't keep the Future -Operation::async(fn() => sendWelcomeEmail($userId)); -``` - ---- - -## The three classes - -| Class | Role | -|------------|----------------------------------------------------------------------| -| `Operation`| Static entry point. `async(callable): Future` dispatches the task. | -| `Future` | Handle to the running task. Await with `return()` / `get()`. | -| `OpResult` | Immutable outcome — either a return value or a caught `Throwable`. | - -`OperationRunnable` is the internal `Runnable` that wraps your callable; -application code never touches it directly. - ---- - -## `Operation` - -```php -public static function async(callable $callback): Future -public static function store(): FileStorage -``` - -`$callback` is any PHP callable — closure, named function, or invokable -object. The child process is spawned immediately (inside `Future`'s -constructor), so the work starts the moment `async()` returns. - -`store()` exposes the shared `FileStorage` used to pass results between -parent and child. You rarely need it directly. - ---- - -## `Future` - -```php -public function return(bool $isThrow = true): mixed // raw value -public function get(): OpResult // full outcome -public function join(): void // wait, no result -``` - -| Method | Blocks? | Returns | Notes | -|-------------------|---------|------------------------|-------| -| `return()` | yes | the callback's value | Re-throws the child's exception when `$isThrow = true` (default). | -| `return(false)` | yes | the callback's value | Swallows the exception; returns `null` if the task threw. | -| `get()` | yes | `OpResult` | Inspect success/failure yourself via `getThrowable()`. | -| `join()` | yes | `void` | Waits for the process to finish without reading a result. | - -`get()` waits for the child, then reads the stored result with up to 3 -retries (1 ms apart). If nothing is found it throws -`Error('Operation result not found')`. - ---- - -## `OpResult` - -```php -public function getResult(): mixed // the returned value (null on failure) -public function getThrowable(): ?Throwable // the caught exception, or null -``` - -```php -$opResult = $future->get(); - -if ($opResult->getThrowable() !== null) { - log_error($opResult->getThrowable()->getMessage()); -} else { - $value = $opResult->getResult(); -} -``` - ---- - -## Await vs fire-and-forget - -The mode is decided by **whether you keep the `Future`**, not by a flag. - -```php -// AWAIT — block until the background task returns -$report = Operation::async(fn() => buildMonthlyReport($month))->return(); - -// FIRE-AND-FORGET — let the Future go out of scope -public function store(Request $request): Response -{ - Operation::async(fn() => sendReceipt($request->user)); - return new Response('queued'); // Future destructed here → result discarded -} -``` - -When a `Future` is destroyed without `get()` / `return()`, its destructor -deletes the "pending" marker in the store. The child sees the marker is -gone and **does not write its result back** — no orphaned data accumulates. - ---- - -## How the result crosses the process boundary - -``` -Operation::async($cb) - ↓ -new OperationRunnable($cb) ← id = "op_" + 16 random chars - ↓ -new Future(id, Thread) - ├── store->write(id, 'pending') ← parent marks the slot - └── Thread::start() ← fork - ↓ (inside the child) - $result = $cb() ← YOUR CODE, isolated process - ↓ - if store->read(id) === 'pending' - store->write(id, OpResult) ← only if parent still waiting - ↓ (back in the parent) -$future->get() → store->read(id) → store->del(id) -``` - ---- - -## Caveats — it's a separate process - -- The callback runs in a **forked child**. Mutations to objects, static - state, or in-memory caches inside the callback are **not** visible to - the parent — only the returned value comes back. -- The return value must survive storage in the volatile store. Return - plain serializable data; don't return live resources (open DB handles, - sockets, stream resources). -- Closures capture by value at fork time. A captured DB connection may not - be usable in the child — open what you need **inside** the callback. -- `return()` / `get()` are **blocking**. For genuinely parallel work, - dispatch several operations first, then await them: - -```php -$a = Operation::async(fn() => fetchA()); -$b = Operation::async(fn() => fetchB()); - -// both already running in parallel -[$ra, $rb] = [$a->return(), $b->return()]; -``` - ---- - -## Error handling - -If the callback throws, the exception is caught in the child and carried -back inside the `OpResult`: - -- `return()` (default) re-throws it in the parent — handle it with a - normal `try/catch` around the await. -- `return(false)` returns `null` instead of throwing. -- `get()` never throws the child's exception — inspect `getThrowable()`. - -```php -try { - $value = Operation::async(fn() => riskyJob())->return(); -} catch (\Throwable $e) { - // the exception thrown inside the child surfaces here -} -``` - ---- - -## Source - -- `src/Unit/Operation/Operation.php` — `async()`, `store()` -- `src/Unit/Operation/Future.php` — `return()`, `get()`, `join()`, destructor -- `src/Unit/Operation/OpResult.php` — `getResult()`, `getThrowable()` -- `src/Unit/Operation/OperationRunnable.php` — internal `Runnable` wrapper - -## See also - -- [`../threads/00-overview.md`](../threads/00-overview.md) — the `Thread` / - `Dispatch` model `Operation` is built on -- [`../threads/01-job.md`](../threads/01-job.md) — stereotype-based - background tasks when you want a named, DI-managed class instead of a closure diff --git a/src/BaseBoot.php b/src/BaseBoot.php index 0eff0de..78b99e4 100644 --- a/src/BaseBoot.php +++ b/src/BaseBoot.php @@ -10,8 +10,8 @@ use Flytachi\Winter\DI\Collector\DICollector; use Flytachi\Winter\DI\Container; use Flytachi\Winter\DI\Scanner; -use Flytachi\Winter\K2\Dev\Async\AsyncCollector; -use Flytachi\Winter\K2\Dev\Async\Proxy\ProxyFactory; +use Flytachi\Winter\K2\Concurrent\Async\AsyncCollector; +use Flytachi\Winter\K2\Concurrent\Async\Proxy\ProxyFactory; use Flytachi\Winter\K2\Http\Adapter\FpmRequest; use Flytachi\Winter\K2\Http\Adapter\FpmResponse; use Flytachi\Winter\K2\Http\Adapter\SwooleRequest; diff --git a/src/Dev/Async/Async.php b/src/Concurrent/Async/Async.php similarity index 89% rename from src/Dev/Async/Async.php rename to src/Concurrent/Async/Async.php index 56670ce..cf14662 100644 --- a/src/Dev/Async/Async.php +++ b/src/Concurrent/Async/Async.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Async; +namespace Flytachi\Winter\K2\Concurrent\Async; /** * Marks a method to be executed asynchronously. * * Mirrors Spring's `Async`. The call returns immediately; the body runs on an - * {@see \Flytachi\Winter\K2\Dev\Concurrent\ExecutorService} — a coroutine under + * {@see \Flytachi\Winter\K2\Concurrent\ExecutorService} — a coroutine under * Swoole, a deferred task under FPM. * * The framework replaces the container binding of the declaring class with a @@ -21,7 +21,7 @@ * - the method is `public`, not `static` and not `final`; * - the declaring class is not `final`; * - the return type is `Future` or `void`; - * - a `Future`-returning body returns {@see \Flytachi\Winter\K2\Dev\Concurrent\CompletableFuture::completedFuture()}; + * - a `Future`-returning body returns {@see \Flytachi\Winter\K2\Concurrent\CompletableFuture::completedFuture()}; * - parameters are not passed by reference — a background task cannot write back. * * Violations are reported when proxies are generated, not at runtime. @@ -59,7 +59,7 @@ * result is still correct; only the "runs later" intuition does not hold for * purely computational bodies. * - * @see \Flytachi\Winter\K2\Dev\Concurrent\Future + * @see \Flytachi\Winter\K2\Concurrent\Future */ #[\Attribute(\Attribute::TARGET_METHOD)] final class Async diff --git a/src/Dev/Async/AsyncCollector.php b/src/Concurrent/Async/AsyncCollector.php similarity index 97% rename from src/Dev/Async/AsyncCollector.php rename to src/Concurrent/Async/AsyncCollector.php index 6e007e7..8623eb6 100644 --- a/src/Dev/Async/AsyncCollector.php +++ b/src/Concurrent/Async/AsyncCollector.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Async; +namespace Flytachi\Winter\K2\Concurrent\Async; use Flytachi\Winter\DI\Attribute\Request; use Flytachi\Winter\DI\Attribute\Singleton; use Flytachi\Winter\DI\Attribute\Transient; use Flytachi\Winter\DI\Container; use Flytachi\Winter\DI\Contract\CollectorInterface; -use Flytachi\Winter\K2\Dev\Async\Proxy\ProxyFactory; -use Flytachi\Winter\K2\Dev\Async\Proxy\ProxyGenerator; +use Flytachi\Winter\K2\Concurrent\Async\Proxy\ProxyFactory; +use Flytachi\Winter\K2\Concurrent\Async\Proxy\ProxyGenerator; use Flytachi\Winter\K2\Kernel; /** diff --git a/src/Dev/Async/AsyncException.php b/src/Concurrent/Async/AsyncException.php similarity index 94% rename from src/Dev/Async/AsyncException.php rename to src/Concurrent/Async/AsyncException.php index 34939d0..241ede2 100644 --- a/src/Dev/Async/AsyncException.php +++ b/src/Concurrent/Async/AsyncException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Async; +namespace Flytachi\Winter\K2\Concurrent\Async; /** * Thrown when an {@see Async} method cannot be proxied. diff --git a/src/Dev/Async/AsyncSupport.php b/src/Concurrent/Async/AsyncSupport.php similarity index 91% rename from src/Dev/Async/AsyncSupport.php rename to src/Concurrent/Async/AsyncSupport.php index e2cb837..db3071c 100644 --- a/src/Dev/Async/AsyncSupport.php +++ b/src/Concurrent/Async/AsyncSupport.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Async; +namespace Flytachi\Winter\K2\Concurrent\Async; -use Flytachi\Winter\K2\Dev\Concurrent\ExecutorService; -use Flytachi\Winter\K2\Dev\Concurrent\Future; +use Flytachi\Winter\K2\Concurrent\ExecutorService; +use Flytachi\Winter\K2\Concurrent\Future; /** * Runtime helper called by generated proxies. diff --git a/src/Dev/Async/Proxy/BypassScanner.php b/src/Concurrent/Async/Proxy/BypassScanner.php similarity index 98% rename from src/Dev/Async/Proxy/BypassScanner.php rename to src/Concurrent/Async/Proxy/BypassScanner.php index 9b7385f..6e45d9e 100644 --- a/src/Dev/Async/Proxy/BypassScanner.php +++ b/src/Concurrent/Async/Proxy/BypassScanner.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Async\Proxy; +namespace Flytachi\Winter\K2\Concurrent\Async\Proxy; /** - * Finds places where an {@see \Flytachi\Winter\K2\Dev\Async\Async} service is + * Finds places where an {@see \Flytachi\Winter\K2\Concurrent\Async\Async} service is * built with `new` instead of being taken from the container. * * Such a call gets the original class, not the proxy, so the annotated method diff --git a/src/Dev/Async/Proxy/ProxyFactory.php b/src/Concurrent/Async/Proxy/ProxyFactory.php similarity index 93% rename from src/Dev/Async/Proxy/ProxyFactory.php rename to src/Concurrent/Async/Proxy/ProxyFactory.php index 05bf3a0..8b0fb97 100644 --- a/src/Dev/Async/Proxy/ProxyFactory.php +++ b/src/Concurrent/Async/Proxy/ProxyFactory.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Async\Proxy; +namespace Flytachi\Winter\K2\Concurrent\Async\Proxy; -use Flytachi\Winter\K2\Dev\Async\AsyncException; +use Flytachi\Winter\K2\Concurrent\Async\AsyncException; use Flytachi\Winter\K2\Kernel; /** @@ -73,17 +73,20 @@ public function proxyFor(\ReflectionClass $class): string { /** @var class-string $proxyClass */ $proxyClass = ProxyGenerator::proxyClass($class->getName()); - - if (class_exists($proxyClass, false)) { - return $proxyClass; - } - $file = $this->fileFor($class->getName()); + // The file is reconciled before the class is, because the two can + // disagree: `call di build` clears the directory in a process whose + // boot has already loaded the proxies, and skipping the write here + // would leave the build reporting success over an empty directory. if ($this->isStale($file, $class)) { $this->write($file, ProxyGenerator::generate($class)); } + if (class_exists($proxyClass, false)) { + return $proxyClass; + } + require_once $file; if (!class_exists($proxyClass, false)) { diff --git a/src/Dev/Async/Proxy/ProxyGenerator.php b/src/Concurrent/Async/Proxy/ProxyGenerator.php similarity index 95% rename from src/Dev/Async/Proxy/ProxyGenerator.php rename to src/Concurrent/Async/Proxy/ProxyGenerator.php index ded27af..a42e2c2 100644 --- a/src/Dev/Async/Proxy/ProxyGenerator.php +++ b/src/Concurrent/Async/Proxy/ProxyGenerator.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Async\Proxy; +namespace Flytachi\Winter\K2\Concurrent\Async\Proxy; -use Flytachi\Winter\K2\Dev\Async\Async; -use Flytachi\Winter\K2\Dev\Async\AsyncException; -use Flytachi\Winter\K2\Dev\Concurrent\Future; +use Flytachi\Winter\K2\Concurrent\Async\Async; +use Flytachi\Winter\K2\Concurrent\Async\AsyncException; +use Flytachi\Winter\K2\Concurrent\Future; /** * Turns a class carrying {@see Async} methods into the source of a subclass @@ -32,11 +32,11 @@ final class ProxyGenerator { /** Namespace generated classes are placed in. */ - public const string PROXY_NAMESPACE = 'Flytachi\\Winter\\K2\\Dev\\Async\\Proxy\\Generated'; + public const string PROXY_NAMESPACE = 'Flytachi\\Winter\\K2\\Concurrent\\Async\\Proxy\\Generated'; private const string PROXY_SUFFIX = '__Async'; - private const string SUPPORT = '\\Flytachi\\Winter\\K2\\Dev\\Async\\AsyncSupport'; - private const string EXECUTORS = '\\Flytachi\\Winter\\K2\\Dev\\Concurrent\\Executors'; + private const string SUPPORT = '\\Flytachi\\Winter\\K2\\Concurrent\\Async\\AsyncSupport'; + private const string EXECUTORS = '\\Flytachi\\Winter\\K2\\Concurrent\\Executors'; private const string CONTAINER = '\\Flytachi\\Winter\\DI\\Container'; private const string PROXY_CONTRACT = '\\Flytachi\\Winter\\DI\\Contract\\ProxyInterface'; diff --git a/src/Dev/Async/Proxy/SignatureWriter.php b/src/Concurrent/Async/Proxy/SignatureWriter.php similarity index 98% rename from src/Dev/Async/Proxy/SignatureWriter.php rename to src/Concurrent/Async/Proxy/SignatureWriter.php index a90e9d2..f1f21d8 100644 --- a/src/Dev/Async/Proxy/SignatureWriter.php +++ b/src/Concurrent/Async/Proxy/SignatureWriter.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Async\Proxy; +namespace Flytachi\Winter\K2\Concurrent\Async\Proxy; -use Flytachi\Winter\K2\Dev\Async\AsyncException; +use Flytachi\Winter\K2\Concurrent\Async\AsyncException; /** * Renders reflected method signatures back into PHP source. diff --git a/src/Dev/Concurrent/CancellationException.php b/src/Concurrent/CancellationException.php similarity index 80% rename from src/Dev/Concurrent/CancellationException.php rename to src/Concurrent/CancellationException.php index 41c8044..53e463f 100644 --- a/src/Dev/Concurrent/CancellationException.php +++ b/src/Concurrent/CancellationException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Concurrent; +namespace Flytachi\Winter\K2\Concurrent; /** * Thrown by {@see Future::get()} when the task was cancelled before it produced a result. diff --git a/src/Dev/Concurrent/CompletableFuture.php b/src/Concurrent/CompletableFuture.php similarity index 99% rename from src/Dev/Concurrent/CompletableFuture.php rename to src/Concurrent/CompletableFuture.php index df65474..7505c33 100644 --- a/src/Dev/Concurrent/CompletableFuture.php +++ b/src/Concurrent/CompletableFuture.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Concurrent; +namespace Flytachi\Winter\K2\Concurrent; /** * A {@see Future} whose completion can be driven explicitly. diff --git a/src/Dev/Concurrent/ExecutionException.php b/src/Concurrent/ExecutionException.php similarity index 91% rename from src/Dev/Concurrent/ExecutionException.php rename to src/Concurrent/ExecutionException.php index 919b548..41ef427 100644 --- a/src/Dev/Concurrent/ExecutionException.php +++ b/src/Concurrent/ExecutionException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Concurrent; +namespace Flytachi\Winter\K2\Concurrent; /** * Thrown by {@see Future::get()} when the task terminated with a throwable. diff --git a/src/Dev/Concurrent/Executor/CoroutineExecutorService.php b/src/Concurrent/Executor/CoroutineExecutorService.php similarity index 94% rename from src/Dev/Concurrent/Executor/CoroutineExecutorService.php rename to src/Concurrent/Executor/CoroutineExecutorService.php index 39a6d23..c0c86d2 100644 --- a/src/Dev/Concurrent/Executor/CoroutineExecutorService.php +++ b/src/Concurrent/Executor/CoroutineExecutorService.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Concurrent\Executor; +namespace Flytachi\Winter\K2\Concurrent\Executor; use Flytachi\Winter\Base\Runtime; -use Flytachi\Winter\K2\Dev\Concurrent\CompletableFuture; -use Flytachi\Winter\K2\Dev\Concurrent\ExecutorService; -use Flytachi\Winter\K2\Dev\Concurrent\Future; -use Flytachi\Winter\K2\Dev\Concurrent\RejectedExecutionException; +use Flytachi\Winter\K2\Concurrent\CompletableFuture; +use Flytachi\Winter\K2\Concurrent\ExecutorService; +use Flytachi\Winter\K2\Concurrent\Future; +use Flytachi\Winter\K2\Concurrent\RejectedExecutionException; use Flytachi\Winter\Logger\LoggerFactory; /** @@ -25,10 +25,10 @@ * repository query state) is deliberately **not** inherited: everything a task * needs must be passed through its arguments. * - * Requires an active coroutine; use {@see \Flytachi\Winter\K2\Dev\Concurrent\Executors::common()} + * Requires an active coroutine; use {@see \Flytachi\Winter\K2\Concurrent\Executors::common()} * to get the right backend for the current runtime. * - * @see \Flytachi\Winter\K2\Dev\Concurrent\Executors + * @see \Flytachi\Winter\K2\Concurrent\Executors */ final class CoroutineExecutorService implements ExecutorService { diff --git a/src/Dev/Concurrent/Executor/DeferredExecutorService.php b/src/Concurrent/Executor/DeferredExecutorService.php similarity index 95% rename from src/Dev/Concurrent/Executor/DeferredExecutorService.php rename to src/Concurrent/Executor/DeferredExecutorService.php index af83806..073ede1 100644 --- a/src/Dev/Concurrent/Executor/DeferredExecutorService.php +++ b/src/Concurrent/Executor/DeferredExecutorService.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Concurrent\Executor; +namespace Flytachi\Winter\K2\Concurrent\Executor; -use Flytachi\Winter\K2\Dev\Concurrent\CompletableFuture; -use Flytachi\Winter\K2\Dev\Concurrent\ExecutorService; -use Flytachi\Winter\K2\Dev\Concurrent\Future; -use Flytachi\Winter\K2\Dev\Concurrent\RejectedExecutionException; +use Flytachi\Winter\K2\Concurrent\CompletableFuture; +use Flytachi\Winter\K2\Concurrent\ExecutorService; +use Flytachi\Winter\K2\Concurrent\Future; +use Flytachi\Winter\K2\Concurrent\RejectedExecutionException; use Flytachi\Winter\Logger\LoggerFactory; /** @@ -32,7 +32,7 @@ * Note that `max_execution_time` and FPM's `request_terminate_timeout` keep * counting during the drain: a long deferred task can still be killed. * - * @see \Flytachi\Winter\K2\Dev\Concurrent\Executors + * @see \Flytachi\Winter\K2\Concurrent\Executors */ final class DeferredExecutorService implements ExecutorService { diff --git a/src/Dev/Concurrent/ExecutorService.php b/src/Concurrent/ExecutorService.php similarity index 98% rename from src/Dev/Concurrent/ExecutorService.php rename to src/Concurrent/ExecutorService.php index a2d141c..7be50e2 100644 --- a/src/Dev/Concurrent/ExecutorService.php +++ b/src/Concurrent/ExecutorService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Concurrent; +namespace Flytachi\Winter\K2\Concurrent; /** * Runs tasks asynchronously and hands back {@see Future} handles. diff --git a/src/Dev/Concurrent/Executors.php b/src/Concurrent/Executors.php similarity index 94% rename from src/Dev/Concurrent/Executors.php rename to src/Concurrent/Executors.php index 25c8984..09da12f 100644 --- a/src/Dev/Concurrent/Executors.php +++ b/src/Concurrent/Executors.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Concurrent; +namespace Flytachi\Winter\K2\Concurrent; use Flytachi\Winter\Base\Runtime; -use Flytachi\Winter\K2\Dev\Concurrent\Executor\CoroutineExecutorService; -use Flytachi\Winter\K2\Dev\Concurrent\Executor\DeferredExecutorService; +use Flytachi\Winter\K2\Concurrent\Executor\CoroutineExecutorService; +use Flytachi\Winter\K2\Concurrent\Executor\DeferredExecutorService; /** * Factory for {@see ExecutorService} instances. diff --git a/src/Dev/Concurrent/Future.php b/src/Concurrent/Future.php similarity index 97% rename from src/Dev/Concurrent/Future.php rename to src/Concurrent/Future.php index e2ad2c0..f54e011 100644 --- a/src/Dev/Concurrent/Future.php +++ b/src/Concurrent/Future.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Concurrent; +namespace Flytachi\Winter\K2\Concurrent; /** * Handle to the result of an asynchronous computation. diff --git a/src/Dev/Concurrent/RejectedExecutionException.php b/src/Concurrent/RejectedExecutionException.php similarity index 88% rename from src/Dev/Concurrent/RejectedExecutionException.php rename to src/Concurrent/RejectedExecutionException.php index f2d1e47..a51e93e 100644 --- a/src/Dev/Concurrent/RejectedExecutionException.php +++ b/src/Concurrent/RejectedExecutionException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Concurrent; +namespace Flytachi\Winter\K2\Concurrent; /** * Thrown when a task cannot be accepted for execution. diff --git a/src/Dev/Concurrent/TimeoutException.php b/src/Concurrent/TimeoutException.php similarity index 85% rename from src/Dev/Concurrent/TimeoutException.php rename to src/Concurrent/TimeoutException.php index 2a6758f..8f56173 100644 --- a/src/Dev/Concurrent/TimeoutException.php +++ b/src/Concurrent/TimeoutException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Concurrent; +namespace Flytachi\Winter\K2\Concurrent; /** * Thrown by {@see Future::get()} when the given timeout elapsed before the task completed. diff --git a/src/Unit/Operation/Future.php b/src/Unit/Operation/Future.php deleted file mode 100644 index 5dd541c..0000000 --- a/src/Unit/Operation/Future.php +++ /dev/null @@ -1,119 +0,0 @@ -return(); // blocks until the task finishes - * ``` - * - * --- - * ### Example 2: Fire-and-forget - * - * ``` - * $future = Operation::async(function () { - * sendWelcomeEmail($userId); - * }); - * - * return new Response('OK'); // $future goes out of scope → result discarded - * ``` - * @template TResult - * - * @see Operation::async() - * @see OpResult - */ -readonly class Future -{ - private OpResult $result; - - /** - * Creates a new Future and immediately starts the background thread. - * - * @param string $id The unique operation ID generated by {@see OperationRunnable}. - * @param Thread $thread The thread that will execute the task. - */ - public function __construct( - private string $id, - private Thread $thread - ) { - Operation::store()->write($this->id, 'pending'); - $thread->start(); - } - - public function join(): void - { - $this->thread->join(); - usleep(1_000); - } - - /** - * Blocks until the operation completes and returns its result. - * - * @return OpResult - * @throws Error If the result is not available after 3 retries. - */ - public function get(): OpResult - { - if (!isset($this->result)) { - $this->join(); - - for ($i = 0; $i < 3; $i++) { - $opResult = Operation::store()->read($this->id); - if ($opResult) { - $this->result = $opResult; - Operation::store()->del($this->id); - break; - } - if ($i == 2) { - Error::throw('Operation result not found'); - } - usleep(1_000); - } - } - - return $this->result; - } - - /** - * Awaits the operation and returns the raw result value. - * - * @param bool $isThrow Whether to re-throw any exception caught in the child process. - * @return TResult - * @throws \Throwable If $isThrow is true and the background task threw an exception. - */ - public function return(bool $isThrow = true): mixed - { - $opResult = $this->get(); - if ($isThrow && $opResult->getThrowable()) { - throw $opResult->getThrowable(); - } - return $opResult->getResult(); - } - - public function __destruct() - { - Operation::store()->del($this->id); - } -} diff --git a/src/Unit/Operation/OpResult.php b/src/Unit/Operation/OpResult.php deleted file mode 100644 index e473235..0000000 --- a/src/Unit/Operation/OpResult.php +++ /dev/null @@ -1,56 +0,0 @@ -get(); - * - * if ($opResult->getThrowable() !== null) { - * // task failed - * echo $opResult->getThrowable()->getMessage(); - * } else { - * $value = $opResult->getResult(); - * } - * ``` - * @template TResult - * - * @see Future::get() - * @see OperationRunnable::run() - */ -readonly class OpResult -{ - /** - * @param TResult $result The value returned by the callback. - * @param \Throwable|null $throwable The exception thrown by the callback, or null on success. - */ - public function __construct( - private mixed $result, - private ?\Throwable $throwable - ) { - } - - /** @return TResult */ - public function getResult(): mixed - { - return $this->result; - } - - public function getThrowable(): ?\Throwable - { - return $this->throwable; - } -} diff --git a/src/Unit/Operation/Operation.php b/src/Unit/Operation/Operation.php deleted file mode 100644 index d288f1e..0000000 --- a/src/Unit/Operation/Operation.php +++ /dev/null @@ -1,70 +0,0 @@ - heavyJob())->return(); - * - * // Fire-and-forget - * Operation::async(fn() => sendEmail($to, $subject, $body)); - * ``` - * - * @see Future - * @see OperationRunnable - */ -final class Operation -{ - private function __construct() - { - } - - /** - * Returns the shared volatile store used for inter-process result passing. - * - * @return FileStorage The shared operation store instance. - */ - public static function store(): FileStorage - { - return Kernel::volatile('operations'); - } - - /** - * Dispatches a callable to run asynchronously in a background process. - * - * @template TResult - * - * @param callable(): TResult $callback Any PHP callable: closure, named function, or invokable object. - * @return Future A handle to the running background operation. - */ - public static function async(callable $callback): Future - { - $runnable = new OperationRunnable($callback); - - $thread = new Thread( - $runnable, - 'operation', - $runnable->getName() - ); - - return new Future($runnable->getId(), $thread); - } -} diff --git a/src/Unit/Operation/OperationRunnable.php b/src/Unit/Operation/OperationRunnable.php deleted file mode 100644 index 4e524af..0000000 --- a/src/Unit/Operation/OperationRunnable.php +++ /dev/null @@ -1,93 +0,0 @@ -id = 'op_' . Algorithm::random(16); - $this->callback = \Closure::fromCallable($callback); - $this->name = $this->callableName(); - } - - public function getId(): string - { - return $this->id; - } - - public function getName(): string - { - return $this->name; - } - - public function run(array $args): void - { - try { - $result = ($this->callback)(); - } catch (\Throwable $throwable) { - } finally { - $pending = Operation::store()->read($this->id); - if ($pending === 'pending') { - Operation::store()->write( - $this->id, - new OpResult( - $result ?? null, - $throwable ?? null - ) - ); - } - } - } - - private function callableName(): string - { - $reflection = new \ReflectionFunction($this->callback); - - if ($reflection->isClosure()) { - $class = $reflection->getClosureScopeClass(); - if ($class) { - return '[closure] in ' . $class->getName(); - } - return '[closure]'; - } - - $name = $reflection->getName(); - if (str_contains($name, '{closure}')) { - return '[closure]'; - } - - return '[function] ' . $name; - } -} From 081be25bef346e29ad50cbbad9c5aea7f8f97c8c Mon Sep 17 00:00:00 2001 From: flytachi Date: Wed, 22 Jul 2026 18:32:03 +0500 Subject: [PATCH 03/71] New Engine Process (betta test) --- console/Command/Process.php | 258 ++++++++++++++++++++++ dev/main/Process/CrashDaemon.php | 28 +++ dev/main/Process/DemoProcess.php | 34 +++ dev/main/Process/LongDemo.php | 27 +++ dev/main/Process/StableDaemon.php | 30 +++ src/Dev/Process/Daemon.php | 126 +++++++++++ src/Dev/Process/Engine/Engines.php | 32 +++ src/Dev/Process/Engine/ProcessEngine.php | 61 +++++ src/Dev/Process/Engine/SwooleEngine.php | 105 +++++++++ src/Dev/Process/Engine/SyncEngine.php | 129 +++++++++++ src/Dev/Process/Process.php | 216 ++++++++++++++++++ src/Dev/Process/ProcessRunnable.php | 31 +++ src/Dev/Process/ProcessState.php | 28 +++ src/Dev/Process/ProcessStatus.php | 37 ++++ src/Dev/Process/ProcessStore.php | 30 +++ src/Dev/Process/RestartPolicy.php | 33 +++ src/Dev/Process/Supervisor/Supervisor.php | 162 ++++++++++++++ 17 files changed, 1367 insertions(+) create mode 100644 console/Command/Process.php create mode 100644 dev/main/Process/CrashDaemon.php create mode 100644 dev/main/Process/DemoProcess.php create mode 100644 dev/main/Process/LongDemo.php create mode 100644 dev/main/Process/StableDaemon.php create mode 100644 src/Dev/Process/Daemon.php create mode 100644 src/Dev/Process/Engine/Engines.php create mode 100644 src/Dev/Process/Engine/ProcessEngine.php create mode 100644 src/Dev/Process/Engine/SwooleEngine.php create mode 100644 src/Dev/Process/Engine/SyncEngine.php create mode 100644 src/Dev/Process/Process.php create mode 100644 src/Dev/Process/ProcessRunnable.php create mode 100644 src/Dev/Process/ProcessState.php create mode 100644 src/Dev/Process/ProcessStatus.php create mode 100644 src/Dev/Process/ProcessStore.php create mode 100644 src/Dev/Process/RestartPolicy.php create mode 100644 src/Dev/Process/Supervisor/Supervisor.php diff --git a/console/Command/Process.php b/console/Command/Process.php new file mode 100644 index 0000000..1d162da --- /dev/null +++ b/console/Command/Process.php @@ -0,0 +1,258 @@ +args['arguments']) > 1) { + $this->resolution(); + } else { + self::help(); + } + + self::printTitle("Process", 34); + } + + private function resolution(): void + { + $input = $this->args['arguments'][1]; + if ($input === 'list') { + $this->listArg(); + return; + } + + $class = $this->resolveClass($input); + $name = basename(str_replace('\\', '/', $class)); + + if (!class_exists($class)) { + self::printWarning("Class '$name' not found."); + self::printInfo("Resolved: $class"); + self::printInfo("Run 'call process list' to see available processes."); + return; + } + if (!is_subclass_of($class, ProcessUnit::class)) { + self::printWarning("Class '$name' does not extend Process."); + self::printInfo("Resolved: $class"); + return; + } + + match (strtolower($this->args['arguments'][2] ?? '')) { + 'start' => $this->startArg($class), + 'stop' => $this->stopArg($class), + 'status' => $this->statusArg($class, in_array('v', $this->args['flags'])), + '' => $this->startArg($class), + default => self::printWarning("Unknown action (use start|stop|status)."), + }; + } + + /** + * @param class-string $class + */ + private function startArg(string $class): void + { + $info = $class::status(); + if ($info) { + self::printWarning("Already running [PID:{$info->pid}] ({$info->getStartedAt()})."); + return; + } + + if (in_array('d', $this->args['flags'])) { + $pid = $class::dispatch(); + // The detached double-fork reports the launcher PID; the real process + // registers its own PID in the store. Poll briefly for it. + $info = null; + for ($i = 0; $i < 20 && $info === null; $i++) { + usleep(50_000); + $info = $class::status(); + } + self::printSuccess("Dispatched (background): $class"); + self::printKeyValue("PID", (string) ($info->pid ?? $pid), 12, 34, 32); + return; + } + + self::printInfo("Starting: $class"); + $class::start(); + self::printSuccess("Finished: $class"); + } + + /** + * @param class-string $class + */ + private function stopArg(string $class): void + { + $info = $class::status(); + if (!$info) { + self::printWarning("Process is not running."); + return; + } + if ($class::stop()) { + self::printSuccess("Stop signal sent: $class"); + self::printKeyValue("PID", (string) $info->pid, 12, 34, 32); + } else { + self::printWarning("Failed to signal process."); + } + } + + /** + * @param class-string $class + */ + private function statusArg(string $class, bool $detailed): void + { + $dot = str_replace('\\', '.', $class); + $info = $class::status($detailed); + + self::printLabel("Process Status", 34); + + if (!$info) { + self::printBadge($dot, '○ STOPPED', 34, 31); + self::printInfo("The process is not running."); + self::printLabel("Process Status", 34); + return; + } + + self::printBadge($dot, '● ' . $info->state->name, 34, 32); + self::printDivider(); + self::printKeyValue("PID", (string) $info->pid, 12, 34, 36); + self::printKeyValue("State", $info->state->name, 12, 34, 36); + self::printKeyValue("Started", $info->getStartedAt(), 12, 34, 36); + self::printKeyValue("Uptime", $this->formatDuration(time() - $info->startedAt), 12, 34, 36); + if ($info->concurrency > 0) { + self::printKeyValue("Concurrency", (string) $info->concurrency, 12, 34, 36); + } + if (is_subclass_of($class, DaemonUnit::class)) { + self::printKeyValue("Workers", (string) count($info->workers), 12, 34, 36); + self::printKeyValue("Restarts", (string) $info->restarts, 12, 34, 36); + } + + if ($detailed && $info->stats) { + $st = $info->stats; + self::printDivider(); + self::printLabel("Resources", 34); + self::printKeyValue("User", $st->user, 12, 34, 35); + self::printKeyValue("PPID", (string) $st->ppid, 12, 34, 35); + self::printKeyValue("CPU", $st->cpu . ' %', 12, 34, 35); + self::printKeyValue( + "Memory", + $st->mem . ' % (' . round($st->rssMb(), 1) . ' MB)', + 12, + 34, + 35 + ); + self::printKeyValue("Elapsed", $st->etime, 12, 34, 35); + } + + if ($detailed && $info->workers !== []) { + self::printDivider(); + self::printLabel("Workers (" . count($info->workers) . ")", 34); + foreach ($info->workers as $wpid) { + $ws = TStats::ofPid($wpid); + $line = $ws + ? sprintf("#%-7d cpu %s%% rss %s MB", $wpid, $ws->cpu, round($ws->rssMb(), 1)) + : sprintf("#%-7d (gone)", $wpid); + self::print($line, 36); + } + } + + self::printLabel("Process Status", 34); + } + + private function listArg(): void + { + $collector = new SubclassCollector(ProcessUnit::class); + ClassScanner::scan($collector); + $processes = $collector->getResult(); + + self::printLabel("Available Processes", 34); + if (empty($processes)) { + self::printWarning("No Process classes found."); + self::printInfo("Create one that extends Process."); + } else { + foreach ($processes as $ref) { + $class = $ref->getName(); + $dot = str_replace('\\', '.', $class); + $type = $ref->isSubclassOf(DaemonUnit::class) ? 'Daemon' : 'Process'; + $info = $class::status(); + $badge = $info ? '● ' . $info->state->name : '○ STOPPED'; + self::printBadge($dot, "[$type] $badge", 34, $info ? 32 : 31); + } + } + self::printLabel("Available Processes", 34); + } + + /** + * Dot/dashed notation → FQCN, e.g. `main.process.Backup` → `Main\Process\Backup`. + */ + private function resolveClass(string $input): string + { + return str_replace( + '/', + '\\', + implode('/', array_map( + fn($word) => ucfirst($word), + explode('/', str_replace('.', '/', $input)) + )) + ); + } + + /** + * Human-readable duration, e.g. 90061 → "1d 1h". + */ + private function formatDuration(int $seconds): string + { + $seconds = max(0, $seconds); + $units = ['d' => 86400, 'h' => 3600, 'm' => 60, 's' => 1]; + + $parts = []; + foreach ($units as $suffix => $size) { + $value = intdiv($seconds, $size); + $seconds %= $size; + if ($value > 0) { + $parts[] = $value . $suffix; + } + } + + return $parts === [] ? '0s' : implode(' ', array_slice($parts, 0, 2)); + } + + public static function help(): void + { + $cl = 34; + self::printTitle("Process Help", $cl); + + self::printLabel("Usage", $cl); + self::print("call process [action] -[flags]", $cl); + self::printLabel("Usage", $cl); + + self::printLabel("Commands", $cl); + self::printBadge('list', 'list all Process classes with live state', $cl, 36); + self::printBadge('', 'start in foreground (default)', $cl, 36); + self::printBadge(' start', 'start in foreground', $cl, 36); + self::printBadge(' start -d', 'start detached in background', $cl, 36); + self::printBadge(' stop', 'send graceful stop signal', $cl, 36); + self::printBadge(' status', 'show status', $cl, 36); + self::printBadge(' status -v', 'detailed: resources', $cl, 36); + self::printLabel("Commands", $cl); + + self::printLabel("Flags", $cl); + self::printKeyValue("-d", "start detached in background", 10, $cl, 36); + self::printKeyValue("-v", "verbose status (resource stats)", 10, $cl, 36); + self::printLabel("Flags", $cl); + + self::printTitle("Process Help", $cl); + } +} diff --git a/dev/main/Process/CrashDaemon.php b/dev/main/Process/CrashDaemon.php new file mode 100644 index 0000000..620933c --- /dev/null +++ b/dev/main/Process/CrashDaemon.php @@ -0,0 +1,28 @@ +logger->info('CrashDaemon worker START pid=' . $this->pid); + $this->sleep(0.6); + $this->logger->warning('CrashDaemon worker about to crash pid=' . $this->pid); + throw new \RuntimeException('boom'); + } +} diff --git a/dev/main/Process/DemoProcess.php b/dev/main/Process/DemoProcess.php new file mode 100644 index 0000000..8be7938 --- /dev/null +++ b/dev/main/Process/DemoProcess.php @@ -0,0 +1,34 @@ +logger->info('DemoProcess START (concurrency=' . $this->concurrency . ')'); + + for ($i = 1; $i <= 6; $i++) { + $this->spawn(fn() => $this->task($i)); + } + + $this->logger->info('DemoProcess loop done, draining...'); + } + + private function task(int $n): void + { + $this->logger->info(" task #$n start (inFlight)"); + $this->sleep(0.3); + $this->logger->info(" task #$n done"); + } +} diff --git a/dev/main/Process/LongDemo.php b/dev/main/Process/LongDemo.php new file mode 100644 index 0000000..46a7906 --- /dev/null +++ b/dev/main/Process/LongDemo.php @@ -0,0 +1,27 @@ +logger->info('LongDemo START pid=' . $this->pid); + + $tick = 0; + while ($this->running()) { + $this->logger->info('LongDemo tick ' . (++$tick)); + $this->sleep(1.0); + } + + $this->logger->info('LongDemo graceful exit after ' . $tick . ' ticks'); + } +} diff --git a/dev/main/Process/StableDaemon.php b/dev/main/Process/StableDaemon.php new file mode 100644 index 0000000..80f175a --- /dev/null +++ b/dev/main/Process/StableDaemon.php @@ -0,0 +1,30 @@ +logger->info('StableDaemon worker START pid=' . $this->pid); + $tick = 0; + while ($this->running()) { + $this->logger->info('StableDaemon worker ' . $this->pid . ' tick ' . (++$tick)); + $this->sleep(1.0); + } + $this->logger->info('StableDaemon worker ' . $this->pid . ' graceful exit'); + } +} diff --git a/src/Dev/Process/Daemon.php b/src/Dev/Process/Daemon.php new file mode 100644 index 0000000..dcd513d --- /dev/null +++ b/src/Dev/Process/Daemon.php @@ -0,0 +1,126 @@ +rabbit->connect(); + * while ($this->running()) { + * $msg = $ch->get(); + * if ($msg === null) { $this->sleep(0.2); continue; } + * $this->spawn(fn() => $this->handle($msg)); + * } + * $ch->close(); + * } + * } + * ``` + */ +abstract class Daemon extends Process +{ + /** Number of identical workers to keep running. */ + protected int $replicas = 1; + /** When to restart a worker after it exits. */ + protected RestartPolicy $restart = RestartPolicy::ON_FAILURE; + /** Give up after this many restarts (0 = unlimited). */ + protected int $maxRestarts = 0; + /** Base seconds for exponential back-off between restarts. */ + protected float $backoff = 1.0; + + public function replicas(): int + { + return max(1, $this->replicas); + } + + public function restartPolicy(): RestartPolicy + { + return $this->restart; + } + + public function maxRestarts(): int + { + return $this->maxRestarts; + } + + public function backoffBase(): float + { + return $this->backoff; + } + + /** + * Launches the supervisor in the foreground, registering it in the store so + * {@see status()} / {@see stop()} reach it from another terminal. + */ + final public static function start(): void + { + /** @var static $self */ + $self = Container::getInstance()->make(static::class); + $self->supervise(); + } + + private function supervise(): void + { + $this->pid = getmypid(); + $this->logger = LoggerFactory::getLogger(static::class); + + $store = static::store(); + $key = static::key(); + $startedAt = time(); + + $write = function (ProcessState $state, int $restarts, array $workers) use ($store, $key, $startedAt): void { + $store->write($key, new ProcessStatus( + pid: $this->pid, + className: static::class, + state: $state, + startedAt: $startedAt, + concurrency: $this->concurrency, + restarts: $restarts, + workers: $workers, + )); + }; + + $write(ProcessState::RUNNING, 0, []); + + try { + $final = (new Supervisor())->run( + $this, + fn() => $this->runWorker(), + fn(int $restarts, array $workers) => $write(ProcessState::RUNNING, $restarts, $workers), + ); + + if ($final === ProcessState::FAILED) { + $this->logger->critical('Daemon reached maxRestarts; giving up.'); + } + } catch (\Throwable $e) { + $this->logger->critical( + $e->getMessage() + . (env('DEBUG', false) ? "\n" . $e->getTraceAsString() : '') + ); + } finally { + $store->del($key); + } + } +} diff --git a/src/Dev/Process/Engine/Engines.php b/src/Dev/Process/Engine/Engines.php new file mode 100644 index 0000000..510e64c --- /dev/null +++ b/src/Dev/Process/Engine/Engines.php @@ -0,0 +1,32 @@ +concurrency > 0) { + $this->semaphore = new \Swoole\Coroutine\Channel($this->concurrency); + for ($i = 0; $i < $this->concurrency; $i++) { + $this->semaphore->push(true); + } + } + + \Swoole\Process::signal(SIGTERM, fn() => $this->requestStop()); + \Swoole\Process::signal(SIGINT, fn() => $this->requestStop()); + + try { + $body(); + } catch (\Throwable $e) { + // Swoole may swallow a throwable escaping the top coroutine; capture + // it and rethrow outside so a supervisor sees a non-zero exit. + $error = $e; + return; + } + + // Let tasks already in flight finish before the process exits. + while ($this->inFlight > 0) { + \Swoole\Coroutine::sleep(0.01); + } + }); + + if ($error !== null) { + throw $error; + } + } + + public function spawn(callable $task): Future + { + // Acquire a slot; suspends the caller when the cap is reached. + $this->semaphore?->pop(); + $this->inFlight++; + + $wrapped = function () use ($task): mixed { + try { + return $task(); + } finally { + $this->inFlight--; + $this->semaphore?->push(true); + } + }; + + return Executors::common()->submit($wrapped); + } + + public function sleep(float $seconds): void + { + \Swoole\Coroutine::sleep($seconds); + } + + public function running(): bool + { + return !$this->stop; + } + + public function requestStop(): void + { + $this->stop = true; + } + + public function inFlight(): int + { + return $this->inFlight; + } +} diff --git a/src/Dev/Process/Engine/SyncEngine.php b/src/Dev/Process/Engine/SyncEngine.php new file mode 100644 index 0000000..423f73d --- /dev/null +++ b/src/Dev/Process/Engine/SyncEngine.php @@ -0,0 +1,129 @@ + Live child PIDs. */ + private array $children = []; + + /** + * @param int $concurrency Maximum simultaneous children; 0 means unlimited. + */ + public function __construct(private readonly int $concurrency) + { + $this->hasPcntl = extension_loaded('pcntl'); + } + + public function enter(callable $body): void + { + if ($this->hasPcntl) { + pcntl_async_signals(true); + pcntl_signal(SIGTERM, fn() => $this->requestStop()); + pcntl_signal(SIGINT, fn() => $this->requestStop()); + } + + $body(); + $this->waitAll(); + } + + public function spawn(callable $task): Future + { + if (!$this->hasPcntl) { + return CompletableFuture::completedFuture($task()); + } + + $this->reap(); + // Back-pressure: block until a slot frees up. + while ($this->concurrency > 0 && count($this->children) >= $this->concurrency) { + $pid = pcntl_wait($status); + if ($pid > 0) { + unset($this->children[$pid]); + } + } + + $pid = pcntl_fork(); + if ($pid === 0) { + try { + $task(); + } catch (\Throwable) { + // Isolated in the child; nothing to propagate to the parent. + } finally { + exit(0); + } + } + + if ($pid > 0) { + $this->children[$pid] = true; + } + + return CompletableFuture::completedFuture(null); + } + + public function sleep(float $seconds): void + { + usleep((int) ($seconds * 1_000_000)); + if ($this->hasPcntl) { + pcntl_signal_dispatch(); + } + } + + public function running(): bool + { + return !$this->stop; + } + + public function requestStop(): void + { + $this->stop = true; + } + + public function inFlight(): int + { + $this->reap(); + return count($this->children); + } + + /** + * Reaps finished children without blocking. + */ + private function reap(): void + { + if (!$this->hasPcntl) { + return; + } + while (($pid = pcntl_waitpid(-1, $status, WNOHANG)) > 0) { + unset($this->children[$pid]); + } + } + + /** + * Blocks until every child has exited. + */ + private function waitAll(): void + { + if (!$this->hasPcntl) { + return; + } + foreach (array_keys($this->children) as $pid) { + pcntl_waitpid($pid, $status); + unset($this->children[$pid]); + } + } +} diff --git a/src/Dev/Process/Process.php b/src/Dev/Process/Process.php new file mode 100644 index 0000000..589de86 --- /dev/null +++ b/src/Dev/Process/Process.php @@ -0,0 +1,216 @@ +rabbit->connect(); + * while ($this->running()) { + * $msg = $ch->get(); + * if ($msg === null) { $this->sleep(0.2); continue; } + * $this->spawn(fn() => $this->handle($msg)); + * } + * $ch->close(); + * } + * } + * ``` + */ +abstract class Process +{ + /** Maximum simultaneous {@see spawn()} tasks; 0 means unlimited. */ + protected int $concurrency = 0; + + protected LoggerInterface $logger; + protected int $pid; + private ProcessEngine $engine; + + final public function __construct() + { + } + + /** + * The process body. Runs inside the chosen runtime. + */ + abstract public function run(): void; + + // ------------------------------------------------------------------------- + // Primitives available to the body + // ------------------------------------------------------------------------- + + /** + * Dispatches a task concurrently (coroutine under Swoole, fork otherwise), + * capped by {@see $concurrency}. + */ + final protected function spawn(callable $task): Future + { + return $this->engine->spawn($task); + } + + /** + * Pauses the body — non-blocking under Swoole. + */ + final protected function sleep(float $seconds): void + { + $this->engine->sleep($seconds); + } + + /** + * False once a stop signal has arrived; drive loops with it. + */ + final protected function running(): bool + { + return $this->engine->running(); + } + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + /** + * Runs the process in the foreground, registering it in the store so + * {@see status()} and {@see stop()} can reach it from another terminal. + */ + public static function start(): void + { + /** @var static $self */ + $self = Container::getInstance()->make(static::class); + $self->boot(); + } + + /** + * Launches the process detached in the background and returns its PID. The + * child registers itself in the store, so {@see status()} / {@see stop()} + * reach it exactly as with a foreground start. + * + * @param string|null $output '/dev/null' (default) or a file path for the child's stdio. + */ + final public static function dispatch(?string $output = '/dev/null'): int + { + return new Thread( + new ProcessRunnable(static::class), + 'process', + new \ReflectionClass(static::class)->getShortName(), + )->start(outputTarget: $output, detached: true); + } + + /** + * Current status, or null when the process is not running. + * + * @param bool $stats Attach live resource stats (CPU/memory via `ps`). + */ + final public static function status(bool $stats = false): ?ProcessStatus + { + try { + $store = static::store(); + $key = static::key(); + /** @var ?ProcessStatus $status */ + $status = $store->read($key); + if (!$status) { + return null; + } + if (!posix_getpgid($status->pid)) { + $store->del($key); + return null; + } + if ($stats) { + $status->stats = TStats::ofPid($status->pid); + } + return $status; + } catch (\Throwable) { + return null; + } + } + + /** + * Sends a graceful stop signal. Returns false when nothing is running. + */ + final public static function stop(): bool + { + $status = static::status(); + if (!$status) { + return false; + } + return posix_kill($status->pid, SIGTERM); + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + private function boot(): void + { + $store = static::store(); + $key = static::key(); + $store->write($key, new ProcessStatus( + pid: getmypid(), + className: static::class, + state: ProcessState::RUNNING, + startedAt: time(), + concurrency: $this->concurrency, + )); + + try { + $this->runWorker(); + } catch (\Throwable $e) { + $this->logger->critical( + $e->getMessage() + . (env('DEBUG', false) ? "\n" . $e->getTraceAsString() : '') + ); + } finally { + $store->del($key); + } + } + + /** + * Sets up the runtime and runs the body. No store bookkeeping — this is the + * unit a {@see Daemon} supervisor forks per worker. A throwable propagates so + * the supervisor can observe a failed exit. + */ + protected function runWorker(): void + { + $this->pid = getmypid(); + $this->logger = LoggerFactory::getLogger(static::class); + $this->engine = Engines::common($this->concurrency); + + $this->engine->enter(fn() => $this->run()); + } + + final protected static function key(): string + { + return hash('xxh64', static::class); + } + + final protected static function store(): FileStorage + { + return (new ProcessStore(static::class))->main(); + } +} diff --git a/src/Dev/Process/ProcessRunnable.php b/src/Dev/Process/ProcessRunnable.php new file mode 100644 index 0000000..6aa89b8 --- /dev/null +++ b/src/Dev/Process/ProcessRunnable.php @@ -0,0 +1,31 @@ + $class + */ + public function __construct(private string $class) + { + } + + public function run(array $args): void + { + ($this->class)::start(); + } +} diff --git a/src/Dev/Process/ProcessState.php b/src/Dev/Process/ProcessState.php new file mode 100644 index 0000000..e0b9bd1 --- /dev/null +++ b/src/Dev/Process/ProcessState.php @@ -0,0 +1,28 @@ + $workers Worker PIDs supervised by a daemon (empty for a bare process). + */ + public function __construct( + public int $pid, + public string $className, + public ProcessState $state, + public int $startedAt, + public int $concurrency = 0, + public int $restarts = 0, + public array $workers = [], + public ?TStats $stats = null, + ) { + } + + public function getStartedAt(): string + { + return date('Y-m-d H:i:s P', $this->startedAt); + } +} diff --git a/src/Dev/Process/ProcessStore.php b/src/Dev/Process/ProcessStore.php new file mode 100644 index 0000000..439337d --- /dev/null +++ b/src/Dev/Process/ProcessStore.php @@ -0,0 +1,30 @@ +key = str_replace('\\', '.', $className); + } + + public function main(): FileStorage + { + return Kernel::runnable($this->key); + } +} diff --git a/src/Dev/Process/RestartPolicy.php b/src/Dev/Process/RestartPolicy.php new file mode 100644 index 0000000..3c06c8b --- /dev/null +++ b/src/Dev/Process/RestartPolicy.php @@ -0,0 +1,33 @@ + true, + self::ON_FAILURE => $crashed, + self::NEVER => false, + }; + } +} diff --git a/src/Dev/Process/Supervisor/Supervisor.php b/src/Dev/Process/Supervisor/Supervisor.php new file mode 100644 index 0000000..939cf30 --- /dev/null +++ b/src/Dev/Process/Supervisor/Supervisor.php @@ -0,0 +1,162 @@ + $this->stop = true); + pcntl_signal(SIGINT, fn() => $this->stop = true); + + $replicas = $daemon->replicas(); + $policy = $daemon->restartPolicy(); + $maxRestarts = $daemon->maxRestarts(); + $base = max(0.0, $daemon->backoffBase()); + + /** @var array $workers Live worker PIDs. */ + $workers = []; + for ($i = 0; $i < $replicas; $i++) { + $workers[$this->spawn($worker)] = true; + } + + $restarts = 0; + $failures = 0; + $onChange($restarts, array_keys($workers)); + + while (!$this->stop) { + $pid = pcntl_waitpid(-1, $status, WNOHANG); + if ($pid <= 0) { + usleep(100_000); + pcntl_signal_dispatch(); + continue; + } + + unset($workers[$pid]); + $crashed = !pcntl_wifexited($status) || pcntl_wexitstatus($status) !== 0; + + if ($this->stop) { + break; + } + + if (!$policy->shouldRestart($crashed)) { + if ($workers === []) { + return ProcessState::TERMINATED; + } + $onChange($restarts, array_keys($workers)); + continue; + } + + $restarts++; + $failures = $crashed ? $failures + 1 : 0; + + if ($maxRestarts > 0 && $restarts >= $maxRestarts) { + $this->stopAll($workers); + return ProcessState::FAILED; + } + + $this->interruptibleSleep($this->backoff($base, $failures)); + if ($this->stop) { + break; + } + + $workers[$this->spawn($worker)] = true; + $onChange($restarts, array_keys($workers)); + } + + $this->stopAll($workers); + return ProcessState::TERMINATED; + } + + /** + * Forks a worker. In the child, inherited signal handlers are reset so the + * worker's own runtime installs its own — then the body runs and its outcome + * maps to the exit code. + */ + private function spawn(callable $worker): int + { + $pid = pcntl_fork(); + if ($pid === 0) { + pcntl_signal(SIGTERM, SIG_DFL); + pcntl_signal(SIGINT, SIG_DFL); + try { + $worker(); + exit(0); + } catch (\Throwable) { + // The worker logs its own failure; the non-zero code is the signal. + exit(1); + } + } + return $pid; + } + + /** + * Exponential back-off, capped. + */ + private function backoff(float $base, int $failures): float + { + if ($base <= 0.0 || $failures <= 0) { + return 0.0; + } + return min($base * (2 ** ($failures - 1)), self::BACKOFF_CAP); + } + + /** + * Sleeps while staying responsive to a stop signal. + */ + private function interruptibleSleep(float $seconds): void + { + $remaining = $seconds; + while ($remaining > 0 && !$this->stop) { + usleep((int) (min($remaining, 0.1) * 1_000_000)); + pcntl_signal_dispatch(); + $remaining -= 0.1; + } + } + + /** + * Signals every worker to stop and waits for them to exit. + * + * @param array $workers + */ + private function stopAll(array $workers): void + { + foreach (array_keys($workers) as $pid) { + posix_kill($pid, SIGTERM); + } + foreach (array_keys($workers) as $pid) { + pcntl_waitpid($pid, $status); + } + } +} From 4497ba3867ca65f62d5a99c7ef4f24acb5cc4152 Mon Sep 17 00:00:00 2001 From: flytachi Date: Thu, 23 Jul 2026 00:03:53 +0500 Subject: [PATCH 04/71] New Engine Process (betta test) --- dev/main/Process/SignalDemo.php | 41 +++++++++++++++++++ src/Dev/Process/Engine/ProcessEngine.php | 7 ++-- src/Dev/Process/Engine/SwooleEngine.php | 9 +++-- src/Dev/Process/Engine/SyncEngine.php | 7 ++-- src/Dev/Process/Process.php | 46 ++++++++++++++++++++- src/Dev/Process/Supervisor/Supervisor.php | 49 +++++++++++++++-------- 6 files changed, 132 insertions(+), 27 deletions(-) create mode 100644 dev/main/Process/SignalDemo.php diff --git a/dev/main/Process/SignalDemo.php b/dev/main/Process/SignalDemo.php new file mode 100644 index 0000000..2670c2f --- /dev/null +++ b/dev/main/Process/SignalDemo.php @@ -0,0 +1,41 @@ +logger->info('SignalDemo START pid=' . $this->pid); + while ($this->running()) { + $this->sleep(0.5); + } + $this->logger->info('SignalDemo loop exit (running=false)'); + } + + protected function onTerminate(): void + { + $this->logger->warning('HOOK onTerminate (SIGTERM) -> stopping'); + $this->requestStop(); + } + + protected function onInterrupt(): void + { + $this->logger->warning('HOOK onInterrupt (SIGINT) -> stopping'); + $this->requestStop(); + } + + protected function onClose(): void + { + // Reload semantics: react but keep running (no requestStop()). + $this->logger->warning('HOOK onClose (SIGHUP) -> reload, NOT stopping'); + } +} diff --git a/src/Dev/Process/Engine/ProcessEngine.php b/src/Dev/Process/Engine/ProcessEngine.php index 194c37f..a864d84 100644 --- a/src/Dev/Process/Engine/ProcessEngine.php +++ b/src/Dev/Process/Engine/ProcessEngine.php @@ -20,12 +20,13 @@ interface ProcessEngine { /** - * Establishes the runtime context, runs the body, then drains outstanding - * tasks before returning. + * Establishes the runtime context, installs the signal handlers, runs the + * body, then drains outstanding tasks before returning. * * @param callable $body The process body (its `run()` method). + * @param array $signals Map of signal number to handler, installed with the runtime-appropriate mechanism. */ - public function enter(callable $body): void; + public function enter(callable $body, array $signals = []): void; /** * Dispatches a task concurrently, capped by the configured concurrency. diff --git a/src/Dev/Process/Engine/SwooleEngine.php b/src/Dev/Process/Engine/SwooleEngine.php index 1ee92b5..af6bf18 100644 --- a/src/Dev/Process/Engine/SwooleEngine.php +++ b/src/Dev/Process/Engine/SwooleEngine.php @@ -30,11 +30,11 @@ public function __construct(private readonly int $concurrency) { } - public function enter(callable $body): void + public function enter(callable $body, array $signals = []): void { $error = null; - \Swoole\Coroutine\run(function () use ($body, &$error): void { + \Swoole\Coroutine\run(function () use ($body, $signals, &$error): void { if ($this->concurrency > 0) { $this->semaphore = new \Swoole\Coroutine\Channel($this->concurrency); for ($i = 0; $i < $this->concurrency; $i++) { @@ -42,8 +42,9 @@ public function enter(callable $body): void } } - \Swoole\Process::signal(SIGTERM, fn() => $this->requestStop()); - \Swoole\Process::signal(SIGINT, fn() => $this->requestStop()); + foreach ($signals as $signo => $handler) { + \Swoole\Process::signal($signo, $handler); + } try { $body(); diff --git a/src/Dev/Process/Engine/SyncEngine.php b/src/Dev/Process/Engine/SyncEngine.php index 423f73d..eab235d 100644 --- a/src/Dev/Process/Engine/SyncEngine.php +++ b/src/Dev/Process/Engine/SyncEngine.php @@ -31,12 +31,13 @@ public function __construct(private readonly int $concurrency) $this->hasPcntl = extension_loaded('pcntl'); } - public function enter(callable $body): void + public function enter(callable $body, array $signals = []): void { if ($this->hasPcntl) { pcntl_async_signals(true); - pcntl_signal(SIGTERM, fn() => $this->requestStop()); - pcntl_signal(SIGINT, fn() => $this->requestStop()); + foreach ($signals as $signo => $handler) { + pcntl_signal($signo, $handler); + } } $body(); diff --git a/src/Dev/Process/Process.php b/src/Dev/Process/Process.php index 589de86..71c64dd 100644 --- a/src/Dev/Process/Process.php +++ b/src/Dev/Process/Process.php @@ -91,6 +91,46 @@ final protected function running(): bool return $this->engine->running(); } + /** + * Requests a graceful stop from inside the body or a signal hook — flips + * {@see running()} to false so the loop exits on its next check. + */ + final protected function requestStop(): void + { + $this->engine->requestStop(); + } + + // ------------------------------------------------------------------------- + // Signal hooks — override to react to a specific signal + // ------------------------------------------------------------------------- + + /** + * SIGTERM — the standard "please stop" signal (what {@see stop()} sends). + * Default: graceful stop. Override to add cleanup before the loop exits. + */ + protected function onTerminate(): void + { + $this->requestStop(); + } + + /** + * SIGINT — interrupt (Ctrl-C). Default: graceful stop. + */ + protected function onInterrupt(): void + { + $this->requestStop(); + } + + /** + * SIGHUP — the connection/terminal closed, conventionally "reload". + * Default: graceful stop. Override to reload config without stopping + * (simply do not call {@see requestStop()}). + */ + protected function onClose(): void + { + $this->requestStop(); + } + // ------------------------------------------------------------------------- // Lifecycle // ------------------------------------------------------------------------- @@ -201,7 +241,11 @@ protected function runWorker(): void $this->logger = LoggerFactory::getLogger(static::class); $this->engine = Engines::common($this->concurrency); - $this->engine->enter(fn() => $this->run()); + $this->engine->enter(fn() => $this->run(), [ + SIGTERM => fn() => $this->onTerminate(), + SIGINT => fn() => $this->onInterrupt(), + SIGHUP => fn() => $this->onClose(), + ]); } final protected static function key(): string diff --git a/src/Dev/Process/Supervisor/Supervisor.php b/src/Dev/Process/Supervisor/Supervisor.php index 939cf30..bd57534 100644 --- a/src/Dev/Process/Supervisor/Supervisor.php +++ b/src/Dev/Process/Supervisor/Supervisor.php @@ -25,11 +25,16 @@ final class Supervisor private const float BACKOFF_CAP = 30.0; private bool $stop = false; + /** @var array Live worker PIDs. */ + private array $workers = []; /** * Runs the supervision loop until every worker is done or a stop signal * arrives. Returns the terminal state. * + * SIGTERM/SIGINT stop the daemon (workers are stopped, no restart). SIGHUP is + * forwarded to the workers — a "reload" that does not stop the supervisor. + * * @param Daemon $daemon Daemon supplying policy (replicas, restart, limits). * @param callable $worker Worker body run in each forked child. * @param callable $onChange Called with (int $restarts, array $workerPids) whenever the set changes. @@ -39,21 +44,21 @@ public function run(Daemon $daemon, callable $worker, callable $onChange): Proce pcntl_async_signals(true); pcntl_signal(SIGTERM, fn() => $this->stop = true); pcntl_signal(SIGINT, fn() => $this->stop = true); + pcntl_signal(SIGHUP, fn() => $this->reloadWorkers()); $replicas = $daemon->replicas(); $policy = $daemon->restartPolicy(); $maxRestarts = $daemon->maxRestarts(); $base = max(0.0, $daemon->backoffBase()); - /** @var array $workers Live worker PIDs. */ - $workers = []; + $this->workers = []; for ($i = 0; $i < $replicas; $i++) { - $workers[$this->spawn($worker)] = true; + $this->workers[$this->spawn($worker)] = true; } $restarts = 0; $failures = 0; - $onChange($restarts, array_keys($workers)); + $onChange($restarts, array_keys($this->workers)); while (!$this->stop) { $pid = pcntl_waitpid(-1, $status, WNOHANG); @@ -63,7 +68,7 @@ public function run(Daemon $daemon, callable $worker, callable $onChange): Proce continue; } - unset($workers[$pid]); + unset($this->workers[$pid]); $crashed = !pcntl_wifexited($status) || pcntl_wexitstatus($status) !== 0; if ($this->stop) { @@ -71,10 +76,10 @@ public function run(Daemon $daemon, callable $worker, callable $onChange): Proce } if (!$policy->shouldRestart($crashed)) { - if ($workers === []) { + if ($this->workers === []) { return ProcessState::TERMINATED; } - $onChange($restarts, array_keys($workers)); + $onChange($restarts, array_keys($this->workers)); continue; } @@ -82,7 +87,7 @@ public function run(Daemon $daemon, callable $worker, callable $onChange): Proce $failures = $crashed ? $failures + 1 : 0; if ($maxRestarts > 0 && $restarts >= $maxRestarts) { - $this->stopAll($workers); + $this->stopAll(); return ProcessState::FAILED; } @@ -91,14 +96,26 @@ public function run(Daemon $daemon, callable $worker, callable $onChange): Proce break; } - $workers[$this->spawn($worker)] = true; - $onChange($restarts, array_keys($workers)); + $this->workers[$this->spawn($worker)] = true; + $onChange($restarts, array_keys($this->workers)); } - $this->stopAll($workers); + $this->stopAll(); return ProcessState::TERMINATED; } + /** + * Forwards SIGHUP to every worker — a reload that leaves the supervisor + * running. Each worker's {@see \Flytachi\Winter\K2\Dev\Process\Process::onClose()} + * decides what reload means. + */ + private function reloadWorkers(): void + { + foreach (array_keys($this->workers) as $pid) { + posix_kill($pid, SIGHUP); + } + } + /** * Forks a worker. In the child, inherited signal handlers are reset so the * worker's own runtime installs its own — then the body runs and its outcome @@ -110,6 +127,7 @@ private function spawn(callable $worker): int if ($pid === 0) { pcntl_signal(SIGTERM, SIG_DFL); pcntl_signal(SIGINT, SIG_DFL); + pcntl_signal(SIGHUP, SIG_DFL); try { $worker(); exit(0); @@ -147,16 +165,15 @@ private function interruptibleSleep(float $seconds): void /** * Signals every worker to stop and waits for them to exit. - * - * @param array $workers */ - private function stopAll(array $workers): void + private function stopAll(): void { - foreach (array_keys($workers) as $pid) { + foreach (array_keys($this->workers) as $pid) { posix_kill($pid, SIGTERM); } - foreach (array_keys($workers) as $pid) { + foreach (array_keys($this->workers) as $pid) { pcntl_waitpid($pid, $status); } + $this->workers = []; } } From 214b5da45f251cd1acf94a7c9697ca61d3feba9b Mon Sep 17 00:00:00 2001 From: flytachi Date: Thu, 23 Jul 2026 00:15:27 +0500 Subject: [PATCH 05/71] New Engine Process (betta test) --- src/Dev/Process/Process.php | 20 +++++++++++-- src/Dev/Process/Supervisor/Supervisor.php | 34 +++++++++++++++++++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/Dev/Process/Process.php b/src/Dev/Process/Process.php index 71c64dd..7cff566 100644 --- a/src/Dev/Process/Process.php +++ b/src/Dev/Process/Process.php @@ -52,6 +52,7 @@ abstract class Process protected LoggerInterface $logger; protected int $pid; private ProcessEngine $engine; + private bool $stopping = false; final public function __construct() { @@ -242,12 +243,27 @@ protected function runWorker(): void $this->engine = Engines::common($this->concurrency); $this->engine->enter(fn() => $this->run(), [ - SIGTERM => fn() => $this->onTerminate(), - SIGINT => fn() => $this->onInterrupt(), + SIGTERM => fn() => $this->onStopSignal(fn() => $this->onTerminate()), + SIGINT => fn() => $this->onStopSignal(fn() => $this->onInterrupt()), SIGHUP => fn() => $this->onClose(), ]); } + /** + * Guards the stop signals: the first one runs the hook (graceful — the body + * may finish its loop and drain in-flight tasks); a repeated one forces the + * process down, so a blocked or one-shot body can always be interrupted. + */ + private function onStopSignal(callable $hook): void + { + if ($this->stopping) { + $this->logger->warning('Forced exit on repeated stop signal.'); + exit(1); + } + $this->stopping = true; + $hook(); + } + final protected static function key(): string { return hash('xxh64', static::class); diff --git a/src/Dev/Process/Supervisor/Supervisor.php b/src/Dev/Process/Supervisor/Supervisor.php index bd57534..092dad2 100644 --- a/src/Dev/Process/Supervisor/Supervisor.php +++ b/src/Dev/Process/Supervisor/Supervisor.php @@ -23,6 +23,8 @@ final class Supervisor { /** Upper bound on exponential back-off between restarts, in seconds. */ private const float BACKOFF_CAP = 30.0; + /** How long to wait for workers to exit on SIGTERM before SIGKILL, in seconds. */ + private const float STOP_GRACE = 5.0; private bool $stop = false; /** @var array Live worker PIDs. */ @@ -164,16 +166,44 @@ private function interruptibleSleep(float $seconds): void } /** - * Signals every worker to stop and waits for them to exit. + * Signals every worker to stop gracefully, then SIGKILLs any that outlast the + * grace window — so a blocked worker can never hang the supervisor. */ private function stopAll(): void { + if ($this->workers === []) { + return; + } + foreach (array_keys($this->workers) as $pid) { posix_kill($pid, SIGTERM); } + + $deadline = microtime(true) + self::STOP_GRACE; + while ($this->workers !== [] && microtime(true) < $deadline) { + $this->reap(); + if ($this->workers !== []) { + usleep(100_000); + pcntl_signal_dispatch(); + } + } + foreach (array_keys($this->workers) as $pid) { + posix_kill($pid, SIGKILL); pcntl_waitpid($pid, $status); + unset($this->workers[$pid]); + } + } + + /** + * Reaps any workers that have already exited, without blocking. + */ + private function reap(): void + { + foreach (array_keys($this->workers) as $pid) { + if (pcntl_waitpid($pid, $status, WNOHANG) !== 0) { + unset($this->workers[$pid]); + } } - $this->workers = []; } } From a4ec7e838737c30442857e85f17fe19ca4830101 Mon Sep 17 00:00:00 2001 From: flytachi Date: Thu, 23 Jul 2026 03:42:59 +0500 Subject: [PATCH 06/71] New Engine Process (betta test) --- console/Command/Complete.php | 43 +++- console/Command/Process.php | 109 ++++++-- console/Core.php | 1 + dev/main/Process/ConsumerDemo.php | 41 +++ dev/main/Process/LongDemo.php | 18 +- dev/main/Process/SignalDemo.php | 60 ++++- dev/main/Process/StableDaemon.php | 2 +- src/Dev/Process/Activity.php | 20 ++ src/Dev/Process/Daemon.php | 6 +- src/Dev/Process/Engine/Engines.php | 7 +- src/Dev/Process/Engine/ProcessEngine.php | 21 +- src/Dev/Process/Engine/SwooleEngine.php | 107 ++++++-- src/Dev/Process/Engine/SyncEngine.php | 108 ++++++-- src/Dev/Process/InterruptedException.php | 22 ++ src/Dev/Process/Process.php | 298 ++++++++++++++++------ src/Dev/Process/ProcessStatus.php | 9 +- src/Dev/Process/ResourceUsage.php | 67 +++++ src/Dev/Process/Supervisor/Supervisor.php | 17 +- 18 files changed, 771 insertions(+), 185 deletions(-) create mode 100644 dev/main/Process/ConsumerDemo.php create mode 100644 src/Dev/Process/Activity.php create mode 100644 src/Dev/Process/InterruptedException.php create mode 100644 src/Dev/Process/ResourceUsage.php diff --git a/console/Command/Complete.php b/console/Command/Complete.php index d3183ed..0b043a3 100644 --- a/console/Command/Complete.php +++ b/console/Command/Complete.php @@ -10,6 +10,7 @@ use Flytachi\Winter\K2\Collector\ImplementorCollector; use Flytachi\Winter\K2\Collector\SubclassCollector; use Flytachi\Winter\K2\Core\ClassScanner; +use Flytachi\Winter\K2\Dev\Process\Process as ProcessUnit; use Flytachi\Winter\K2\Process\Core\Dispatchable; use Flytachi\Winter\K2\Process\ThreadDaemon; @@ -103,6 +104,11 @@ class Complete extends Cmd 'daemons:list daemons with live status', ], + // --- process / proc --- + 'process' => [ + 'list:list all processes with live state', + ], + // --- db --- 'db' => [ 'ping:check DB connection and latency', @@ -201,6 +207,27 @@ private function suggest(?string $cmd, ?string $sub, ?string $act, string $curre $base = array_merge($this->getDispatchableClasses(), $base); } + // process: list + classes at top level; once a class is selected, + // suggest lifecycle actions, then flags per action. + if ($resolved === 'process' && $sub !== null && $sub !== 'list') { + if ($act === null) { + $base = [ + 'start:start (foreground; -d for background)', + 'stop:send graceful stop (SIGTERM)', + 'status:show status (-v for detail)', + '-d:start detached in background', + ]; + } elseif ($act === 'status') { + $base = ['-v:detailed status (resources + workers)']; + } elseif ($act === 'start') { + $base = ['-d:start detached in background']; + } else { + $base = []; + } + } elseif ($resolved === 'process' && $sub === null) { + $base = array_merge($this->getProcessClasses(), $base); + } + // help: suggest command names if ($resolved === 'help' && $sub === null) { $base = $this->getCommandNames(); @@ -214,9 +241,10 @@ private function filter(array $items, string $current): array if ($current === '') { return $items; } - return array_values(array_filter($items, function (string $s) use ($current): bool { + $needle = strtolower($current); + return array_values(array_filter($items, function (string $s) use ($needle): bool { $word = strstr($s, ':', true) ?: $s; - return str_starts_with($word, $current); + return str_starts_with(strtolower($word), $needle); })); } @@ -271,6 +299,17 @@ private function getDaemonClasses(): array ); } + private function getProcessClasses(): array + { + $collector = new SubclassCollector(ProcessUnit::class); + ClassScanner::scan($collector); + + return array_map( + fn(\ReflectionClass $ref) => str_replace('\\', '.', $ref->getName()), + $collector->getResult() + ); + } + public static function help(): void { } diff --git a/console/Command/Process.php b/console/Command/Process.php index 1d162da..43da66f 100644 --- a/console/Command/Process.php +++ b/console/Command/Process.php @@ -7,9 +7,10 @@ use Flytachi\Winter\Console\Inc\Cmd; use Flytachi\Winter\K2\Collector\SubclassCollector; use Flytachi\Winter\K2\Core\ClassScanner; +use Flytachi\Winter\K2\Dev\Process\Activity; use Flytachi\Winter\K2\Dev\Process\Daemon as DaemonUnit; use Flytachi\Winter\K2\Dev\Process\Process as ProcessUnit; -use Flytachi\Winter\K2\Process\Entity\TStats; +use Flytachi\Winter\K2\Dev\Process\ResourceUsage; class Process extends Cmd { @@ -125,42 +126,50 @@ private function statusArg(string $class, bool $detailed): void return; } - self::printBadge($dot, '● ' . $info->state->name, 34, 32); + $isDaemon = is_subclass_of($class, DaemonUnit::class); + self::printBadge($dot, ($isDaemon ? 'Daemon ' : 'Process ') . '● ' . $info->state->name, 34, 32); self::printDivider(); self::printKeyValue("PID", (string) $info->pid, 12, 34, 36); self::printKeyValue("State", $info->state->name, 12, 34, 36); + self::printKeyValue( + "Activity", + $info->activity->name, + 12, + 34, + $info->activity === Activity::BUSY ? 33 : 90 + ); self::printKeyValue("Started", $info->getStartedAt(), 12, 34, 36); self::printKeyValue("Uptime", $this->formatDuration(time() - $info->startedAt), 12, 34, 36); if ($info->concurrency > 0) { self::printKeyValue("Concurrency", (string) $info->concurrency, 12, 34, 36); } - if (is_subclass_of($class, DaemonUnit::class)) { + if ($isDaemon) { self::printKeyValue("Workers", (string) count($info->workers), 12, 34, 36); self::printKeyValue("Restarts", (string) $info->restarts, 12, 34, 36); } - if ($detailed && $info->stats) { - $st = $info->stats; + if ($detailed && $info->usage) { + $u = $info->usage; self::printDivider(); self::printLabel("Resources", 34); - self::printKeyValue("User", $st->user, 12, 34, 35); - self::printKeyValue("PPID", (string) $st->ppid, 12, 34, 35); - self::printKeyValue("CPU", $st->cpu . ' %', 12, 34, 35); + self::printKeyValue("User", $u->user, 12, 34, 35); + self::printKeyValue("PPID", (string) $u->ppid, 12, 34, 35); + self::printKeyValue("CPU", $u->cpu . ' %', 12, 34, 35); self::printKeyValue( "Memory", - $st->mem . ' % (' . round($st->rssMb(), 1) . ' MB)', + $u->memory . ' % (' . round($u->rssMb(), 1) . ' MB)', 12, 34, 35 ); - self::printKeyValue("Elapsed", $st->etime, 12, 34, 35); + self::printKeyValue("Elapsed", $u->elapsed, 12, 34, 35); } if ($detailed && $info->workers !== []) { self::printDivider(); self::printLabel("Workers (" . count($info->workers) . ")", 34); foreach ($info->workers as $wpid) { - $ws = TStats::ofPid($wpid); + $ws = ResourceUsage::ofPid($wpid); $line = $ws ? sprintf("#%-7d cpu %s%% rss %s MB", $wpid, $ws->cpu, round($ws->rssMb(), 1)) : sprintf("#%-7d (gone)", $wpid); @@ -181,19 +190,60 @@ private function listArg(): void if (empty($processes)) { self::printWarning("No Process classes found."); self::printInfo("Create one that extends Process."); - } else { - foreach ($processes as $ref) { - $class = $ref->getName(); - $dot = str_replace('\\', '.', $class); - $type = $ref->isSubclassOf(DaemonUnit::class) ? 'Daemon' : 'Process'; - $info = $class::status(); - $badge = $info ? '● ' . $info->state->name : '○ STOPPED'; - self::printBadge($dot, "[$type] $badge", 34, $info ? 32 : 31); + self::printLabel("Available Processes", 34); + return; + } + + $running = 0; + foreach ($processes as $ref) { + $class = $ref->getName(); + $isDaemon = $ref->isSubclassOf(DaemonUnit::class); + if ($this->printRow($class, $isDaemon)) { + $running++; } } + + self::printDivider(); + self::printInfo(count($processes) . " defined, {$running} running."); self::printLabel("Available Processes", 34); } + /** + * Renders one process as a padded, colour-coded row. Returns whether it runs. + * + * @param class-string $class + */ + private function printRow(string $class, bool $isDaemon): bool + { + $dot = str_replace('\\', '.', $class); + $tag = $isDaemon ? 'D' : 'P'; + $info = $class::status(); + + echo "\033[34m" . str_pad(" |\t [{$tag}] {$dot} ", 72, '.') . " "; + if (!$info) { + echo "\033[31m[○ STOPPED]\033[0m\n"; + return false; + } + + $uptime = $this->formatDuration(time() - $info->startedAt); + echo "\033[32m[● {$info->state->name}]" + . $this->activityTag($info->activity) + . ($isDaemon ? "\033[36m [w:" . count($info->workers) . "]" : '') + . "\033[90m {$uptime}\033[0m\n"; + + return true; + } + + /** + * Colour-coded activity label: BUSY is highlighted, IDLE is dim. + */ + private function activityTag(Activity $activity): string + { + return $activity === Activity::BUSY + ? "\033[33m [BUSY]" + : "\033[90m [idle]"; + } + /** * Dot/dashed notation → FQCN, e.g. `main.process.Backup` → `Main\Process\Backup`. */ @@ -236,23 +286,36 @@ public static function help(): void self::printLabel("Usage", $cl); self::print("call process [action] -[flags]", $cl); + self::print("call proc [action] -[flags] (alias)", $cl); self::printLabel("Usage", $cl); self::printLabel("Commands", $cl); - self::printBadge('list', 'list all Process classes with live state', $cl, 36); + self::printBadge('list', 'list all processes with live state', $cl, 36); self::printBadge('', 'start in foreground (default)', $cl, 36); self::printBadge(' start', 'start in foreground', $cl, 36); self::printBadge(' start -d', 'start detached in background', $cl, 36); - self::printBadge(' stop', 'send graceful stop signal', $cl, 36); + self::printBadge(' stop', 'send graceful stop signal (SIGTERM)', $cl, 36); self::printBadge(' status', 'show status', $cl, 36); - self::printBadge(' status -v', 'detailed: resources', $cl, 36); + self::printBadge(' status -v', 'detailed: resources + workers', $cl, 36); self::printLabel("Commands", $cl); self::printLabel("Flags", $cl); self::printKeyValue("-d", "start detached in background", 10, $cl, 36); - self::printKeyValue("-v", "verbose status (resource stats)", 10, $cl, 36); + self::printKeyValue("-v", "verbose status (resource usage)", 10, $cl, 36); self::printLabel("Flags", $cl); + self::printDivider($cl); + + self::printLabel("Examples", $cl); + self::printInfo("call process list"); + self::printInfo("call process main.process.Consumer -d"); + self::printInfo("call process main.process.Consumer status -v"); + self::printInfo("call proc main.process.Consumer stop"); + self::printLabel("Examples", $cl); + + self::printDivider($cl); + self::printInfo("Row tags: [P] process, [D] daemon | ● running, ○ stopped | [BUSY]/[idle]"); + self::printTitle("Process Help", $cl); } } diff --git a/console/Core.php b/console/Core.php index 6507a86..534581e 100644 --- a/console/Core.php +++ b/console/Core.php @@ -12,6 +12,7 @@ class Core extends CoreHandle protected static array $aliases = [ 'sc' => 'Script', 'th' => 'Thread', + 'proc' => 'Process', ]; public function __construct($args) diff --git a/dev/main/Process/ConsumerDemo.php b/dev/main/Process/ConsumerDemo.php new file mode 100644 index 0000000..b8fc5ef --- /dev/null +++ b/dev/main/Process/ConsumerDemo.php @@ -0,0 +1,41 @@ +logger->info('Consumer started'); + $n = 0; + + try { + while ($this->isRunning()) { + $this->sleep(0.5); // IDLE wait (interruptible) + + $n++; + $this->markBusy(); + $this->logger->info("unit #{$n}: BUSY start"); + $this->sleep(3.0); // long processing — must finish on stop + $this->logger->info("unit #{$n}: BUSY done"); + $this->markIdle(); + } + } catch (InterruptedException) { + $this->logger->info('Woken from IDLE wait by stop'); + } + + $this->logger->info('Consumer stopped'); + } +} diff --git a/dev/main/Process/LongDemo.php b/dev/main/Process/LongDemo.php index 46a7906..bd91307 100644 --- a/dev/main/Process/LongDemo.php +++ b/dev/main/Process/LongDemo.php @@ -17,11 +17,27 @@ public function run(): void $this->logger->info('LongDemo START pid=' . $this->pid); $tick = 0; - while ($this->running()) { + while ($this->isRunning()) { $this->logger->info('LongDemo tick ' . (++$tick)); $this->sleep(1.0); + $this->spawn(function () { + $this->logger->notice('spawner'); + $i = 10; + while ($this->isRunning()) { + $i--; + if ($i == 0) { + break; + } + $this->sleep(1); + } + }); } $this->logger->info('LongDemo graceful exit after ' . $tick . ' ticks'); } + + protected function onShutdown(): void + { + $this->logger->alert('LongDemo shutdown'); + } } diff --git a/dev/main/Process/SignalDemo.php b/dev/main/Process/SignalDemo.php index 2670c2f..866d071 100644 --- a/dev/main/Process/SignalDemo.php +++ b/dev/main/Process/SignalDemo.php @@ -4,38 +4,72 @@ namespace Main\Process; +use Flytachi\Winter\K2\Dev\Process\InterruptedException; use Flytachi\Winter\K2\Dev\Process\Process; /** - * Proves per-signal hook dispatch: each of the 3 signals invokes its own - * overridable handler. onClose (SIGHUP) deliberately does NOT stop — a reload. + * Reference of the signal contract with canonical PSR-3 log levels. + * + * The body sits in a long sleep to show interruptibility: one SIGTERM/SIGINT + * wakes it instantly through InterruptedException — no waiting the sleep out. */ class SignalDemo extends Process { + private string $verbosity = 'info'; + public function run(): void { - $this->logger->info('SignalDemo START pid=' . $this->pid); - while ($this->running()) { - $this->sleep(0.5); + $this->logger->info('Process started'); + + try { + while ($this->isRunning()) { + $this->logger->debug('Working'); + $this->sleep(30); + } + } catch (InterruptedException) { + // Stop arrived mid-sleep — handle partial work (requeue, roll back) here. + $this->logger->info('Interrupted during sleep, shutting down'); + } finally { + $this->logger->debug('Releasing local resources'); } - $this->logger->info('SignalDemo loop exit (running=false)'); + + $this->logger->info('Process stopped'); } + // --- stop signals: shutdown is guaranteed; the hook is only your reaction --- + protected function onTerminate(): void { - $this->logger->warning('HOOK onTerminate (SIGTERM) -> stopping'); - $this->requestStop(); + $this->logger->info('SIGTERM received, shutting down'); } protected function onInterrupt(): void { - $this->logger->warning('HOOK onInterrupt (SIGINT) -> stopping'); - $this->requestStop(); + $this->logger->info('SIGINT received, shutting down'); + } + + // --- control signals: the process keeps running --- + + protected function onReload(): void + { + $this->logger->notice('SIGHUP received, reloading configuration'); } - protected function onClose(): void + protected function onUser1(): void + { + $this->logger->notice('SIGUSR1 received, reopening log files'); + } + + protected function onUser2(): void + { + $this->verbosity = $this->verbosity === 'info' ? 'debug' : 'info'; + $this->logger->notice("SIGUSR2 received, verbosity set to {$this->verbosity}"); + } + + // --- guaranteed teardown on every exit path (graceful, forced, fatal) --- + + protected function onShutdown(): void { - // Reload semantics: react but keep running (no requestStop()). - $this->logger->warning('HOOK onClose (SIGHUP) -> reload, NOT stopping'); + $this->logger->info('Shutdown hook: final teardown'); } } diff --git a/dev/main/Process/StableDaemon.php b/dev/main/Process/StableDaemon.php index 80f175a..e2ce19c 100644 --- a/dev/main/Process/StableDaemon.php +++ b/dev/main/Process/StableDaemon.php @@ -21,7 +21,7 @@ public function run(): void { $this->logger->info('StableDaemon worker START pid=' . $this->pid); $tick = 0; - while ($this->running()) { + while ($this->isRunning()) { $this->logger->info('StableDaemon worker ' . $this->pid . ' tick ' . (++$tick)); $this->sleep(1.0); } diff --git a/src/Dev/Process/Activity.php b/src/Dev/Process/Activity.php new file mode 100644 index 0000000..9232f12 --- /dev/null +++ b/src/Dev/Process/Activity.php @@ -0,0 +1,20 @@ +rabbit->connect(); - * while ($this->running()) { + * while ($this->isRunning()) { * $msg = $ch->get(); * if ($msg === null) { $this->sleep(0.2); continue; } * $this->spawn(fn() => $this->handle($msg)); @@ -95,6 +95,7 @@ private function supervise(): void pid: $this->pid, className: static::class, state: $state, + activity: $workers === [] ? Activity::IDLE : Activity::BUSY, startedAt: $startedAt, concurrency: $this->concurrency, restarts: $restarts, @@ -104,6 +105,9 @@ className: static::class, $write(ProcessState::RUNNING, 0, []); + // Backstop the finally below against a forced/fatal exit that skips it. + register_shutdown_function(static fn() => $store->del($key)); + try { $final = (new Supervisor())->run( $this, diff --git a/src/Dev/Process/Engine/Engines.php b/src/Dev/Process/Engine/Engines.php index 510e64c..eeb3f85 100644 --- a/src/Dev/Process/Engine/Engines.php +++ b/src/Dev/Process/Engine/Engines.php @@ -22,11 +22,12 @@ private function __construct() /** * @param int $concurrency Maximum simultaneous tasks; 0 means unlimited. + * @param float $grace Seconds to wait after a stop request before forcing exit. */ - public static function common(int $concurrency): ProcessEngine + public static function common(int $concurrency, float $grace): ProcessEngine { return extension_loaded('swoole') - ? new SwooleEngine($concurrency) - : new SyncEngine($concurrency); + ? new SwooleEngine($concurrency, $grace) + : new SyncEngine($concurrency, $grace); } } diff --git a/src/Dev/Process/Engine/ProcessEngine.php b/src/Dev/Process/Engine/ProcessEngine.php index a864d84..d99aef6 100644 --- a/src/Dev/Process/Engine/ProcessEngine.php +++ b/src/Dev/Process/Engine/ProcessEngine.php @@ -25,8 +25,15 @@ interface ProcessEngine * * @param callable $body The process body (its `run()` method). * @param array $signals Map of signal number to handler, installed with the runtime-appropriate mechanism. + * @param callable|null $onForceExit Run just before the grace timer forces the process down. + * @param callable|null $onHeartbeat Run about once a second while the body runs (e.g. to flush status). */ - public function enter(callable $body, array $signals = []): void; + public function enter( + callable $body, + array $signals = [], + ?callable $onForceExit = null, + ?callable $onHeartbeat = null, + ): void; /** * Dispatches a task concurrently, capped by the configured concurrency. @@ -39,21 +46,27 @@ public function enter(callable $body, array $signals = []): void; public function spawn(callable $task): Future; /** - * Pauses the body without blocking sibling tasks under Swoole. + * Pauses the body without blocking sibling tasks under Swoole. Throws + * {@see \Flytachi\Winter\K2\Dev\Process\InterruptedException} once if the body + * was interrupted (an IDLE wait woken by a stop request). * * @param float $seconds Seconds to pause. */ public function sleep(float $seconds): void; /** - * Returns false once a stop signal (SIGTERM/SIGINT) has been received. + * Returns false once a stop has been requested. */ public function running(): bool; /** * Requests a graceful stop, flipping {@see running()} to false. + * + * @param bool $interrupt Wake a blocked (IDLE) body so it unwinds at once. + * Pass false while an inline unit is BUSY so it is not + * aborted mid-work. */ - public function requestStop(): void; + public function requestStop(bool $interrupt): void; /** * Number of dispatched tasks that have not settled yet. diff --git a/src/Dev/Process/Engine/SwooleEngine.php b/src/Dev/Process/Engine/SwooleEngine.php index af6bf18..0b1d937 100644 --- a/src/Dev/Process/Engine/SwooleEngine.php +++ b/src/Dev/Process/Engine/SwooleEngine.php @@ -6,35 +6,51 @@ use Flytachi\Winter\K2\Concurrent\Executors; use Flytachi\Winter\K2\Concurrent\Future; +use Flytachi\Winter\K2\Dev\Process\InterruptedException; /** * Coroutine backend. * * The body runs inside {@see \Swoole\Coroutine\run()}, so `spawn()` yields real - * concurrency, `sleep()` never blocks the process, and every task borrows its - * own connection from the shared PPA pool. Concurrency is capped by a counting - * semaphore built on a {@see \Swoole\Coroutine\Channel}: acquiring suspends the - * caller when the cap is reached, which is exactly the back-pressure a producer - * loop needs. + * concurrency and `sleep()` never blocks the process. Cancellation is Java-like: + * a stop request cancels the body coroutine, so a blocked `sleep()` wakes at once + * and throws {@see InterruptedException} instead of running to completion. A grace + * timer force-exits if the body ignores the request and keeps yielding. */ final class SwooleEngine implements ProcessEngine { private bool $stop = false; private int $inFlight = 0; private ?\Swoole\Coroutine\Channel $semaphore = null; + private ?int $bodyCid = null; + private ?int $graceTimerId = null; + private ?int $heartbeatTimerId = null; + private bool $interruptDelivered = false; + /** @var callable|null */ + private $onForceExit = null; /** * @param int $concurrency Maximum simultaneous tasks; 0 means unlimited. + * @param float $grace Seconds to wait after a stop request before forcing exit; 0 disables. */ - public function __construct(private readonly int $concurrency) - { + public function __construct( + private readonly int $concurrency, + private readonly float $grace, + ) { } - public function enter(callable $body, array $signals = []): void - { + public function enter( + callable $body, + array $signals = [], + ?callable $onForceExit = null, + ?callable $onHeartbeat = null, + ): void { $error = null; + $this->onForceExit = $onForceExit; + + \Swoole\Coroutine\run(function () use ($body, $signals, $onHeartbeat, &$error): void { + $this->bodyCid = \Swoole\Coroutine::getCid(); - \Swoole\Coroutine\run(function () use ($body, $signals, &$error): void { if ($this->concurrency > 0) { $this->semaphore = new \Swoole\Coroutine\Channel($this->concurrency); for ($i = 0; $i < $this->concurrency; $i++) { @@ -42,22 +58,35 @@ public function enter(callable $body, array $signals = []): void } } + // A broken pipe must not kill a long-lived process; handle EPIPE in code. + if (function_exists('pcntl_signal')) { + pcntl_signal(SIGPIPE, SIG_IGN); + } foreach ($signals as $signo => $handler) { \Swoole\Process::signal($signo, $handler); } + if ($onHeartbeat !== null) { + $this->heartbeatTimerId = \Swoole\Timer::tick(1000, static fn() => $onHeartbeat()); + } try { $body(); + // Drain in-flight tasks on the normal path; skip when cancelled + // (Coroutine\run drains the rest) to avoid busy-waiting. + while ($this->inFlight > 0 && !\Swoole\Coroutine::isCanceled()) { + \Swoole\Coroutine::sleep(0.01); + } + } catch (InterruptedException) { + // Stop requested mid-block: unwind cleanly. } catch (\Throwable $e) { - // Swoole may swallow a throwable escaping the top coroutine; capture - // it and rethrow outside so a supervisor sees a non-zero exit. $error = $e; - return; - } - - // Let tasks already in flight finish before the process exits. - while ($this->inFlight > 0) { - \Swoole\Coroutine::sleep(0.01); + } finally { + // Drop timers so a graceful exit is not held back once the body is done. + $this->disarmGrace(); + if ($this->heartbeatTimerId !== null) { + \Swoole\Timer::clear($this->heartbeatTimerId); + $this->heartbeatTimerId = null; + } } }); @@ -68,7 +97,6 @@ public function enter(callable $body, array $signals = []): void public function spawn(callable $task): Future { - // Acquire a slot; suspends the caller when the cap is reached. $this->semaphore?->pop(); $this->inFlight++; @@ -87,6 +115,12 @@ public function spawn(callable $task): Future public function sleep(float $seconds): void { \Swoole\Coroutine::sleep($seconds); + // Throw only if this coroutine was cancelled (an IDLE wait woken by a stop). + // A BUSY unit is not cancelled, so its sleeps return normally. + if (\Swoole\Coroutine::isCanceled() && !$this->interruptDelivered) { + $this->interruptDelivered = true; + throw new InterruptedException(); + } } public function running(): bool @@ -94,13 +128,46 @@ public function running(): bool return !$this->stop; } - public function requestStop(): void + public function requestStop(bool $interrupt): void { + if ($this->stop) { + return; + } $this->stop = true; + + // Wake an IDLE body blocked in an interruptible point; leave a BUSY unit + // to finish (do not cancel it). + if ($interrupt && $this->bodyCid !== null) { + \Swoole\Coroutine::cancel($this->bodyCid); + } + + // Backstop: force exit if the body keeps running past the grace window. + if ($this->graceTimerId === null && $this->grace > 0) { + $this->graceTimerId = \Swoole\Timer::after( + (int) ($this->grace * 1000), + function (): void { + if ($this->onForceExit !== null) { + ($this->onForceExit)(); + } + exit(1); + } + ); + } } public function inFlight(): int { return $this->inFlight; } + + /** + * Cancels the grace timer, if armed, so a finished process exits at once. + */ + private function disarmGrace(): void + { + if ($this->graceTimerId !== null) { + \Swoole\Timer::clear($this->graceTimerId); + $this->graceTimerId = null; + } + } } diff --git a/src/Dev/Process/Engine/SyncEngine.php b/src/Dev/Process/Engine/SyncEngine.php index eab235d..f142b22 100644 --- a/src/Dev/Process/Engine/SyncEngine.php +++ b/src/Dev/Process/Engine/SyncEngine.php @@ -6,41 +6,68 @@ use Flytachi\Winter\K2\Concurrent\CompletableFuture; use Flytachi\Winter\K2\Concurrent\Future; +use Flytachi\Winter\K2\Dev\Process\InterruptedException; /** * Fork backend for runtimes without Swoole. * - * Each `spawn()` forks a child process, matching the existing daemon model. - * Because a child cannot write a return value back to the parent, the future is - * a settled placeholder — fork tasks are fire-and-forget. When `pcntl` is - * unavailable the task simply runs inline. Concurrency is capped by blocking on - * `pcntl_wait()` once the cap is reached. + * Each `spawn()` forks a child process (fire-and-forget — a child cannot return a + * value to the parent). `sleep()` is interruptible: it wakes in small steps and + * throws {@see InterruptedException} once a stop is requested, matching the + * coroutine backend. The grace deadline is enforced with `SIGALRM` via + * `pcntl_alarm()`, which interrupts even a native blocking call. */ final class SyncEngine implements ProcessEngine { + /** Step size for interruptible sleep, in seconds. */ + private const float SLEEP_STEP = 0.1; + private bool $stop = false; + private bool $interruptRequested = false; + private bool $interruptDelivered = false; private readonly bool $hasPcntl; + /** @var callable|null */ + private $onForceExit = null; + /** @var callable|null */ + private $onHeartbeat = null; + private float $lastHeartbeat = 0.0; /** @var array Live child PIDs. */ private array $children = []; /** * @param int $concurrency Maximum simultaneous children; 0 means unlimited. + * @param float $grace Seconds to wait after a stop request before forcing exit; 0 disables. */ - public function __construct(private readonly int $concurrency) - { + public function __construct( + private readonly int $concurrency, + private readonly float $grace, + ) { $this->hasPcntl = extension_loaded('pcntl'); } - public function enter(callable $body, array $signals = []): void - { + public function enter( + callable $body, + array $signals = [], + ?callable $onForceExit = null, + ?callable $onHeartbeat = null, + ): void { + $this->onForceExit = $onForceExit; + $this->onHeartbeat = $onHeartbeat; + if ($this->hasPcntl) { pcntl_async_signals(true); + pcntl_signal(SIGPIPE, SIG_IGN); foreach ($signals as $signo => $handler) { pcntl_signal($signo, $handler); } } - $body(); + try { + $body(); + } catch (InterruptedException) { + // Stop requested mid-block: unwind cleanly. + } + $this->waitAll(); } @@ -51,7 +78,6 @@ public function spawn(callable $task): Future } $this->reap(); - // Back-pressure: block until a slot frees up. while ($this->concurrency > 0 && count($this->children) >= $this->concurrency) { $pid = pcntl_wait($status); if ($pid > 0) { @@ -79,9 +105,37 @@ public function spawn(callable $task): Future public function sleep(float $seconds): void { - usleep((int) ($seconds * 1_000_000)); - if ($this->hasPcntl) { - pcntl_signal_dispatch(); + $remaining = $seconds; + while ($remaining > 0) { + $step = min($remaining, self::SLEEP_STEP); + usleep((int) ($step * 1_000_000)); + if ($this->hasPcntl) { + pcntl_signal_dispatch(); + } + // Throw only when an interrupt was requested (an IDLE wait); a BUSY unit + // is stopped cooperatively and its sleeps return normally. + if ($this->interruptRequested && !$this->interruptDelivered) { + $this->interruptDelivered = true; + throw new InterruptedException(); + } + $this->heartbeat(); + $remaining -= $step; + } + } + + /** + * Fires the heartbeat callback at most once a second (the sync backend has no + * event loop, so this is the periodic hook available). + */ + private function heartbeat(): void + { + if ($this->onHeartbeat === null) { + return; + } + $now = microtime(true); + if (($now - $this->lastHeartbeat) >= 1.0) { + $this->lastHeartbeat = $now; + ($this->onHeartbeat)(); } } @@ -90,9 +144,27 @@ public function running(): bool return !$this->stop; } - public function requestStop(): void + public function requestStop(bool $interrupt): void { + if ($this->stop) { + return; + } $this->stop = true; + if ($interrupt) { + $this->interruptRequested = true; + } + + // Backstop: SIGALRM force-exits after the grace window, interrupting even a + // native blocking call. + if ($this->hasPcntl && $this->grace > 0) { + pcntl_signal(SIGALRM, function (): void { + if ($this->onForceExit !== null) { + ($this->onForceExit)(); + } + exit(1); + }); + pcntl_alarm((int) ceil($this->grace)); + } } public function inFlight(): int @@ -101,9 +173,6 @@ public function inFlight(): int return count($this->children); } - /** - * Reaps finished children without blocking. - */ private function reap(): void { if (!$this->hasPcntl) { @@ -114,9 +183,6 @@ private function reap(): void } } - /** - * Blocks until every child has exited. - */ private function waitAll(): void { if (!$this->hasPcntl) { diff --git a/src/Dev/Process/InterruptedException.php b/src/Dev/Process/InterruptedException.php new file mode 100644 index 0000000..cfd4709 --- /dev/null +++ b/src/Dev/Process/InterruptedException.php @@ -0,0 +1,22 @@ +rabbit->connect(); - * while ($this->running()) { - * $msg = $ch->get(); - * if ($msg === null) { $this->sleep(0.2); continue; } - * $this->spawn(fn() => $this->handle($msg)); + * while ($this->isRunning()) { + * $msg = $ch->get(timeout: 1.0); + * if ($msg === null) { continue; } + * $this->markBusy(); + * $this->handle($msg); + * $ch->ack($msg); + * $this->markIdle(); * } * $ch->close(); * } @@ -48,11 +46,23 @@ abstract class Process { /** Maximum simultaneous {@see spawn()} tasks; 0 means unlimited. */ protected int $concurrency = 0; + /** Seconds to wait for a BUSY unit / in-flight spawns to drain on stop before forcing. 0 = wait forever. */ + protected float $grace = 0.0; + /** Title shown in `ps` / status; defaults to the short class name. */ + protected ?string $processTitle = null; protected LoggerInterface $logger; protected int $pid; private ProcessEngine $engine; private bool $stopping = false; + private bool $shutdownDone = false; + private bool $inlineBusy = false; + + // Status store bookkeeping (a bare process owns its record; a daemon worker does not). + private bool $ownsRecord = false; + private int $startedAt = 0; + private ProcessState $state = ProcessState::NEW; + private ?Activity $writtenActivity = null; final public function __construct() { @@ -68,16 +78,16 @@ abstract public function run(): void; // ------------------------------------------------------------------------- /** - * Dispatches a task concurrently (coroutine under Swoole, fork otherwise), - * capped by {@see $concurrency}. + * Keep looping? False once a stop has been requested (signal or {@see requestStop()}). */ - final protected function spawn(callable $task): Future + final protected function isRunning(): bool { - return $this->engine->spawn($task); + return $this->engine->running(); } /** - * Pauses the body — non-blocking under Swoole. + * Interruptible pause — non-blocking under Swoole. Throws + * {@see InterruptedException} if an IDLE wait is woken by a stop. */ final protected function sleep(float $seconds): void { @@ -85,60 +95,94 @@ final protected function sleep(float $seconds): void } /** - * False once a stop signal has arrived; drive loops with it. + * Dispatches a task concurrently (coroutine under Swoole, fork otherwise), + * capped by {@see $concurrency}. The process always waits for its spawns to + * finish before exiting; while any is in flight the process is BUSY. */ - final protected function running(): bool + final protected function spawn(callable $task): Future { - return $this->engine->running(); + return $this->engine->spawn($task); } /** - * Requests a graceful stop from inside the body or a signal hook — flips - * {@see running()} to false so the loop exits on its next check. + * Requests a graceful stop of this process from inside the body — the + * cooperative equivalent of receiving SIGTERM. */ final protected function requestStop(): void { - $this->engine->requestStop(); + // Do not abort an inline BUSY unit; wake only an IDLE wait. + $this->engine->requestStop(!$this->inlineBusy); } - // ------------------------------------------------------------------------- - // Signal hooks — override to react to a specific signal - // ------------------------------------------------------------------------- - /** - * SIGTERM — the standard "please stop" signal (what {@see stop()} sends). - * Default: graceful stop. Override to add cleanup before the loop exits. + * Marks the start of an inline unit of work (no {@see spawn()}). Keeps the + * process BUSY so it is not interrupted mid-unit and not scaled down. */ - protected function onTerminate(): void + final protected function markBusy(): void { - $this->requestStop(); + $this->inlineBusy = true; } /** - * SIGINT — interrupt (Ctrl-C). Default: graceful stop. + * Marks the end of an inline unit of work. */ - protected function onInterrupt(): void + final protected function markIdle(): void { - $this->requestStop(); + $this->inlineBusy = false; } /** - * SIGHUP — the connection/terminal closed, conventionally "reload". - * Default: graceful stop. Override to reload config without stopping - * (simply do not call {@see requestStop()}). + * Current activity: BUSY while an inline unit is marked or any spawn is in + * flight, IDLE otherwise. */ - protected function onClose(): void + final protected function activity(): Activity { - $this->requestStop(); + return $this->inlineBusy || $this->engine->inFlight() > 0 + ? Activity::BUSY + : Activity::IDLE; } // ------------------------------------------------------------------------- - // Lifecycle + // Signal hooks — override to react to a specific signal + // ------------------------------------------------------------------------- + + /** SIGTERM — stop is guaranteed; override only to react. */ + protected function onTerminate(): void + { + } + + /** SIGINT — Ctrl-C; stop is guaranteed. */ + protected function onInterrupt(): void + { + } + + /** SIGHUP — reload configuration. Does NOT stop by default. */ + protected function onReload(): void + { + } + + /** SIGUSR1 — a user-defined action (e.g. reopen log files). */ + protected function onUser1(): void + { + } + + /** SIGUSR2 — a user-defined action (e.g. dump stats, toggle debug). */ + protected function onUser2(): void + { + } + + /** Guaranteed teardown — runs on every exit path (graceful, forced, fatal). */ + protected function onShutdown(): void + { + } + + // ------------------------------------------------------------------------- + // Control surface (CLI / web) // ------------------------------------------------------------------------- /** * Runs the process in the foreground, registering it in the store so - * {@see status()} and {@see stop()} can reach it from another terminal. + * {@see status()} / {@see stop()} reach it from another terminal. */ public static function start(): void { @@ -148,9 +192,7 @@ public static function start(): void } /** - * Launches the process detached in the background and returns its PID. The - * child registers itself in the store, so {@see status()} / {@see stop()} - * reach it exactly as with a foreground start. + * Launches the process detached in the background and returns its PID. * * @param string|null $output '/dev/null' (default) or a file path for the child's stdio. */ @@ -166,9 +208,9 @@ final public static function dispatch(?string $output = '/dev/null'): int /** * Current status, or null when the process is not running. * - * @param bool $stats Attach live resource stats (CPU/memory via `ps`). + * @param bool $usage Attach live resource usage (CPU/memory via `ps`). */ - final public static function status(bool $stats = false): ?ProcessStatus + final public static function status(bool $usage = false): ?ProcessStatus { try { $store = static::store(); @@ -178,12 +220,13 @@ final public static function status(bool $stats = false): ?ProcessStatus if (!$status) { return null; } - if (!posix_getpgid($status->pid)) { + // Drop a stale record whose process is gone (e.g. a forced exit). + if (!posix_kill($status->pid, 0)) { $store->del($key); return null; } - if ($stats) { - $status->stats = TStats::ofPid($status->pid); + if ($usage) { + $status->usage = ResourceUsage::ofPid($status->pid); } return $status; } catch (\Throwable) { @@ -192,7 +235,7 @@ final public static function status(bool $stats = false): ?ProcessStatus } /** - * Sends a graceful stop signal. Returns false when nothing is running. + * Sends a graceful stop signal (SIGTERM). Returns false when nothing is running. */ final public static function stop(): bool { @@ -209,61 +252,146 @@ final public static function stop(): bool private function boot(): void { - $store = static::store(); - $key = static::key(); - $store->write($key, new ProcessStatus( - pid: getmypid(), - className: static::class, - state: ProcessState::RUNNING, - startedAt: time(), - concurrency: $this->concurrency, - )); + $this->prepareWorker(); + $this->ownsRecord = true; + $this->writeStatus(); try { - $this->runWorker(); - } catch (\Throwable $e) { - $this->logger->critical( - $e->getMessage() - . (env('DEBUG', false) ? "\n" . $e->getTraceAsString() : '') - ); + $this->runBody(); } finally { - $store->del($key); + static::store()->del(static::key()); } } /** - * Sets up the runtime and runs the body. No store bookkeeping — this is the - * unit a {@see Daemon} supervisor forks per worker. A throwable propagates so - * the supervisor can observe a failed exit. + * Worker entry point used by a {@see Daemon} supervisor: sets up the runtime + * and runs the body, without owning the store record (the supervisor owns it). + * + * @internal Not for application code — calling it re-enters the engine. */ protected function runWorker(): void + { + $this->prepareWorker(); + $this->runBody(); + } + + private function prepareWorker(): void { $this->pid = getmypid(); $this->logger = LoggerFactory::getLogger(static::class); - $this->engine = Engines::common($this->concurrency); + $this->startedAt = time(); + $this->state = ProcessState::RUNNING; + $this->applyProcessTitle(); + + // Fatal backstop for onShutdown; the explicit calls below cover normal paths. + register_shutdown_function(fn() => $this->invokeShutdown()); - $this->engine->enter(fn() => $this->run(), [ - SIGTERM => fn() => $this->onStopSignal(fn() => $this->onTerminate()), - SIGINT => fn() => $this->onStopSignal(fn() => $this->onInterrupt()), - SIGHUP => fn() => $this->onClose(), - ]); + $this->engine = Engines::common($this->concurrency, $this->grace); + } + + private function runBody(): void + { + try { + $this->engine->enter( + fn() => $this->run(), + [ + SIGTERM => fn() => $this->onStopSignal(fn() => $this->onTerminate()), + SIGINT => fn() => $this->onStopSignal(fn() => $this->onInterrupt()), + SIGHUP => fn() => $this->onReload(), + SIGUSR1 => fn() => $this->onUser1(), + SIGUSR2 => fn() => $this->onUser2(), + ], + fn() => $this->invokeShutdown(), + fn() => $this->flushStatus(), + ); + } finally { + $this->invokeShutdown(); + } } /** - * Guards the stop signals: the first one runs the hook (graceful — the body - * may finish its loop and drain in-flight tasks); a repeated one forces the - * process down, so a blocked or one-shot body can always be interrupted. + * First stop signal begins a graceful stop and runs the hook; further stop + * signals are ignored (force is the grace timer or an external SIGKILL). */ private function onStopSignal(callable $hook): void { if ($this->stopping) { - $this->logger->warning('Forced exit on repeated stop signal.'); - exit(1); + return; } $this->stopping = true; + $this->state = ProcessState::STOPPING; + $this->requestStop(); + $this->writeStatus(); $hook(); } + /** + * Runs {@see onShutdown()} exactly once, on whichever exit path reaches it. + */ + private function invokeShutdown(): void + { + if ($this->shutdownDone) { + return; + } + $this->shutdownDone = true; + try { + $this->onShutdown(); + } catch (\Throwable $e) { + $this->logger->error('onShutdown() failed: ' . $e->getMessage()); + } + } + + private function applyProcessTitle(): void + { + if (!function_exists('cli_set_process_title')) { + return; + } + $title = $this->processTitle ?? new \ReflectionClass(static::class)->getShortName(); + @cli_set_process_title('winter-process: ' . $title); + } + + // ------------------------------------------------------------------------- + // Status store — in-memory authoritative, throttled + deduped write + // ------------------------------------------------------------------------- + + /** + * Called on the heartbeat (~1s): persists the record only when the activity + * actually changed, so a per-message BUSY/IDLE flip never storms the disk. A + * no-op for a daemon worker (the supervisor owns the record). + */ + private function flushStatus(): void + { + if (!$this->ownsRecord) { + return; + } + if ($this->activity() === $this->writtenActivity) { + return; + } + $this->writeStatus(); + } + + private function writeStatus(): void + { + if (!$this->ownsRecord) { + return; + } + $activity = $this->activity(); + try { + static::store()->write(static::key(), new ProcessStatus( + pid: $this->pid, + className: static::class, + state: $this->state, + activity: $activity, + startedAt: $this->startedAt, + concurrency: $this->concurrency, + )); + } catch (\Throwable $e) { + $this->logger->warning('Status write failed: ' . $e->getMessage()); + return; + } + $this->writtenActivity = $activity; + } + final protected static function key(): string { return hash('xxh64', static::class); diff --git a/src/Dev/Process/ProcessStatus.php b/src/Dev/Process/ProcessStatus.php index da34781..d4cba3c 100644 --- a/src/Dev/Process/ProcessStatus.php +++ b/src/Dev/Process/ProcessStatus.php @@ -4,14 +4,12 @@ namespace Flytachi\Winter\K2\Dev\Process; -use Flytachi\Winter\K2\Process\Entity\TStats; - /** * Persisted status record of a {@see Process}. * * Written to the runnable store while the process lives, read back by the CLI - * and (later) the web layer. Resource {@see TStats} are live and never - * persisted — they are attached on read via {@see Process::status()}. + * and the web layer. {@see ResourceUsage} is live and never persisted — it is + * attached on read via {@see Process::status()}. */ final class ProcessStatus { @@ -22,11 +20,12 @@ public function __construct( public int $pid, public string $className, public ProcessState $state, + public Activity $activity, public int $startedAt, public int $concurrency = 0, public int $restarts = 0, public array $workers = [], - public ?TStats $stats = null, + public ?ResourceUsage $usage = null, ) { } diff --git a/src/Dev/Process/ResourceUsage.php b/src/Dev/Process/ResourceUsage.php new file mode 100644 index 0000000..dc583cf --- /dev/null +++ b/src/Dev/Process/ResourceUsage.php @@ -0,0 +1,67 @@ +rssKb / 1024; + } +} diff --git a/src/Dev/Process/Supervisor/Supervisor.php b/src/Dev/Process/Supervisor/Supervisor.php index 092dad2..afd7963 100644 --- a/src/Dev/Process/Supervisor/Supervisor.php +++ b/src/Dev/Process/Supervisor/Supervisor.php @@ -46,7 +46,10 @@ public function run(Daemon $daemon, callable $worker, callable $onChange): Proce pcntl_async_signals(true); pcntl_signal(SIGTERM, fn() => $this->stop = true); pcntl_signal(SIGINT, fn() => $this->stop = true); - pcntl_signal(SIGHUP, fn() => $this->reloadWorkers()); + // Control signals are forwarded to the workers, leaving the supervisor up. + pcntl_signal(SIGHUP, fn() => $this->forwardToWorkers(SIGHUP)); + pcntl_signal(SIGUSR1, fn() => $this->forwardToWorkers(SIGUSR1)); + pcntl_signal(SIGUSR2, fn() => $this->forwardToWorkers(SIGUSR2)); $replicas = $daemon->replicas(); $policy = $daemon->restartPolicy(); @@ -107,14 +110,13 @@ public function run(Daemon $daemon, callable $worker, callable $onChange): Proce } /** - * Forwards SIGHUP to every worker — a reload that leaves the supervisor - * running. Each worker's {@see \Flytachi\Winter\K2\Dev\Process\Process::onClose()} - * decides what reload means. + * Forwards a control signal (HUP/USR1/USR2) to every worker, leaving the + * supervisor running. Each worker's hook decides what it means. */ - private function reloadWorkers(): void + private function forwardToWorkers(int $signo): void { foreach (array_keys($this->workers) as $pid) { - posix_kill($pid, SIGHUP); + posix_kill($pid, $signo); } } @@ -127,9 +129,12 @@ private function spawn(callable $worker): int { $pid = pcntl_fork(); if ($pid === 0) { + // Drop inherited handlers; the worker's engine installs its own. pcntl_signal(SIGTERM, SIG_DFL); pcntl_signal(SIGINT, SIG_DFL); pcntl_signal(SIGHUP, SIG_DFL); + pcntl_signal(SIGUSR1, SIG_DFL); + pcntl_signal(SIGUSR2, SIG_DFL); try { $worker(); exit(0); From 0b885a2fec1f3767a27153df566b8e3520a5738e Mon Sep 17 00:00:00 2001 From: flytachi Date: Thu, 23 Jul 2026 16:47:17 +0500 Subject: [PATCH 07/71] New Engine Process (betta test) --- src/Dev/Process/Engine/SwooleEngine.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Dev/Process/Engine/SwooleEngine.php b/src/Dev/Process/Engine/SwooleEngine.php index 0b1d937..ae432cc 100644 --- a/src/Dev/Process/Engine/SwooleEngine.php +++ b/src/Dev/Process/Engine/SwooleEngine.php @@ -69,6 +69,8 @@ public function enter( $this->heartbeatTimerId = \Swoole\Timer::tick(1000, static fn() => $onHeartbeat()); } + $signos = array_keys($signals); + try { $body(); // Drain in-flight tasks on the normal path; skip when cancelled @@ -77,16 +79,21 @@ public function enter( \Swoole\Coroutine::sleep(0.01); } } catch (InterruptedException) { - // Stop requested mid-block: unwind cleanly. } catch (\Throwable $e) { $error = $e; } finally { - // Drop timers so a graceful exit is not held back once the body is done. + // Drop timers and signal listeners so the reactor can go idle and + // Coroutine\run returns. Registered signal handlers keep the event + // loop alive on their own — without this the process hangs after the + // body is done, only escaping via the grace timer (or never, grace=0). $this->disarmGrace(); if ($this->heartbeatTimerId !== null) { \Swoole\Timer::clear($this->heartbeatTimerId); $this->heartbeatTimerId = null; } + foreach ($signos as $signo) { + \Swoole\Process::signal($signo, null); + } } }); From b49831ebcae812bedcf025dd79cc792487eb9f3d Mon Sep 17 00:00:00 2001 From: flytachi Date: Thu, 23 Jul 2026 18:17:48 +0500 Subject: [PATCH 08/71] New Engine Process (betta test) --- docs/process/00-overview.md | 176 +++++++++ docs/process/01-lifecycle.md | 360 ++++++++++++++++++ docs/process/02-concurrency.md | 305 +++++++++++++++ docs/process/03-control.md | 243 ++++++++++++ src/Dev/Process/Daemon.php | 10 + src/Dev/Process/Process.php | 67 ++++ .../ProcessAlreadyRunningException.php | 17 + 7 files changed, 1178 insertions(+) create mode 100644 docs/process/00-overview.md create mode 100644 docs/process/01-lifecycle.md create mode 100644 docs/process/02-concurrency.md create mode 100644 docs/process/03-control.md create mode 100644 src/Dev/Process/ProcessAlreadyRunningException.php diff --git a/docs/process/00-overview.md b/docs/process/00-overview.md new file mode 100644 index 0000000..180258e --- /dev/null +++ b/docs/process/00-overview.md @@ -0,0 +1,176 @@ +# Winter Process — Overview + +Most of what a web framework does is shaped like a request: something comes in, +you compute an answer, you send it back, and the worker is free again in +milliseconds. A large class of real work does not fit that shape at all. + +An outbound-email worker has to sit on a queue for days, sending as jobs arrive. +A media service has to turn uploaded videos into thumbnails without blocking the +upload. A billing integration has to hold a persistent connection to a payment +gateway open around the clock. A reporting job has to walk millions of rows at +3 a.m. and assemble a file. None of these is a request. Each one has to be +*started*, *observed while it runs*, and *stopped cleanly* — and each has to keep +running long after the shell that launched it has closed. + +**Process** is the unit for that work. You write the logic as an ordinary class; +the framework wraps it in a managed lifecycle — start, stop and status from the +CLI and the web — and supplies the machinery underneath: a runtime that is either +Swoole coroutines or forked processes, a bounded concurrency model, cooperative +cancellation so a running worker can be interrupted safely, and cleanup that is +guaranteed to run however the process ends. + +Here is a complete outbound-email worker. It is the example this documentation +returns to, because a queue consumer exercises nearly everything a process does: + +```php +final class EmailDispatchWorker extends Process +{ + #[Autowired] private MailQueue $queue; + #[Autowired] private Mailer $mailer; + + public function run(): void + { + while ($this->isRunning()) { + $job = $this->queue->reserve(timeout: 1.0); // next pending email, or null + if ($job === null) { + continue; // queue empty — poll again + } + + try { + $this->mailer->send($job->to, $job->subject, $job->body); + $this->queue->markSent($job->id); + } catch (DeliveryException $e) { + $this->queue->release($job->id); // return it for a retry + $this->logger->warning("delivery failed for {$job->to}: {$e->getMessage()}"); + } + } + } +} +``` + +Nothing in that class knows whether it is running on top of a coroutine scheduler +or a forking process. It reserves a job, sends it, acknowledges it, and loops. +Operating it is three commands: + +``` +call process main.EmailDispatchWorker start -d # launch, detached from the shell +call process main.EmailDispatchWorker status -v # is it alive? busy? how much memory? +call process main.EmailDispatchWorker stop # stop it gracefully +``` + +--- + +## The central idea: you own the body, the framework owns everything else + +The method you write, `run()`, is plain application code. It never opens an event +loop, never forks, never installs a signal handler, never writes a PID file. It +reads as if it were the only thing running. + +Everything the body *needs* from its environment arrives through a small set of +protected primitives — `isRunning()` to drive its loop, `sleep()` for +interruptible pauses, `spawn()` to run work concurrently. Everything the body +should *react to* — a stop request, a reload signal, a fatal exit — arrives +through overridable hooks such as `onTerminate()`, `onReload()` and +`onShutdown()`. The two sets are small enough to hold in your head, and they are +the entire contract. + +The payoff is that the same class is correct on two very different runtimes. When +Swoole is present the body runs inside a coroutine scheduler and `spawn()` yields +lightweight coroutines that interleave on I/O. When it is not, the body runs in a +plain process and `spawn()` forks children. The framework chooses; the body does +not change. This is the design goal stated once and enforced everywhere: +**one body, two runtimes, identical observable behaviour.** + +--- + +## A process is whatever its body is + +A process is not a specific structure like "a worker pool." The body dictates the +shape, and three shapes cover almost everything. + +**A one-shot task** runs to completion and exits. There is no loop; `run()` does +its work and returns. + +```php +final class ExpiredSessionCleanup extends Process +{ + #[Autowired] private SessionRepository $sessions; + + public function run(): void + { + $removed = $this->sessions->deleteOlderThan(days: 30); + $this->logger->info("purged {$removed} expired sessions"); + // run() returns → the process is finished and exits + } +} +``` + +**A long-lived consumer** loops until it is told to stop. This is the +`EmailDispatchWorker` above, and the most common shape by far. + +**A concurrent worker** is a consumer whose loop hands each unit to `spawn()`, so +the units run in parallel instead of one at a time — the right shape when the work +is I/O-bound and the units are independent. That is the subject of +[02-concurrency.md](02-concurrency.md). + +Concurrency is opt-in. A process that never calls `spawn()` is an ordinary +sequential program, and that is a perfectly good thing to be. + +--- + +## The two runtimes + +The framework selects the backend from the loaded extension: if the Swoole +extension is available the body runs on coroutines, otherwise on forks. The +selection is invisible to your code, but the mechanics differ enough that it is +worth seeing side by side. + +``` + run() (your body) + │ + ┌───────────────┴────────────────┐ + Swoole extension present no Swoole (PHP-FPM / CLI) + │ │ + Swoole\Coroutine\run() a plain OS process + spawn() → a coroutine spawn() → pcntl_fork() + sleep() → yields, non-blocking sleep() → a real pause + tasks share the body's memory each task is an isolated child +``` + +| | Swoole | PHP-FPM / CLI | +|---|---|---| +| The body runs inside | `Swoole\Coroutine\run()` | the process itself | +| `spawn()` produces | a coroutine (~12 KB, µs to start) | a forked child process (MBs, ms to start) | +| `sleep()` | yields — other coroutines keep running | pauses this process | +| Concurrent tasks share memory | yes — they observe `isRunning()` directly | no — a child has its own copy of everything | +| Parallelism | many coroutines overlapping on I/O, one core | one running task per forked child | + +Everything a process *is* — its lifecycle, the way it stops, the concurrency cap +and back-pressure — is identical on both backends. What differs is the physics +underneath: coroutines are cheap and share memory, forks are heavier and +isolated. The following pages describe each mechanism once and then, where the +difference is observable, spell out how the FPM backend is adapted to match. + +**One boundary is worth stating up front.** Coroutines give you *concurrency* — +many tasks making progress by overlapping their I/O — but not *parallelism* +across CPU cores, because a coroutine scheduler runs on a single thread. When you +need genuine parallelism, fault isolation, or workers that scale up and down +independently, you run several separate *processes* under a supervisor. That +supervisor is a `Daemon` (a supervised Process), and `spawn()` is the concurrency +layer *inside* a single worker — the two compose rather than compete. + +--- + +## Pages + +| # | File | Contents | +|---|------|----------| +| 00 | this page | What a process is, the two runtimes, the shapes it takes | +| 01 | [01-lifecycle.md](01-lifecycle.md) | Writing the body, cooperative stop, signals, guaranteed cleanup | +| 02 | [02-concurrency.md](02-concurrency.md) | `spawn`, the concurrency cap, the semaphore and back-pressure | +| 03 | [03-control.md](03-control.md) | Starting, stopping and inspecting a process; the CLI | + +## See also + +- [`concurrent/00-overview.md`](../concurrent/00-overview.md) — `Executors` and `Future`, the primitive `spawn()` is built on +- [`ppa/00-overview.md`](../ppa/00-overview.md) — the database connection pool a process shares across its coroutines diff --git a/docs/process/01-lifecycle.md b/docs/process/01-lifecycle.md new file mode 100644 index 0000000..79fe896 --- /dev/null +++ b/docs/process/01-lifecycle.md @@ -0,0 +1,360 @@ +# Lifecycle, stop and signals + +The hardest part of a long-running process is not starting it — it is stopping it +correctly. A deploy, a scale-down, a `systemctl restart`, an operator pressing +Ctrl-C: all of them ask the process to end, and the process has to end *well*. It +must not be cut off in the middle of sending an email or halfway through a +database transaction. It must release the connections and locks it holds. And it +has to be stoppable even at the moment it is blocked waiting on a socket that may +stay quiet for minutes. + +Getting this right is a solved problem, and the solution is the same one Java, +Go, and Kubernetes all arrived at independently: **cooperative cancellation with a +forced deadline behind it.** The process is *asked* to stop; the body itself +decides the safe point at which to actually do so; and if the body never reaches +such a point, a deadline forces the process down anyway. Winter's Process +implements exactly this model, and this page is the small surface you use to +participate in it. + +--- + +## `isRunning()` — the cooperative signal + +The backbone of the whole mechanism is a single boolean the body polls. It is +`true` while the process should keep working and becomes `false` the moment a stop +is requested — whether that request came from `SIGTERM`, from the CLI `stop` +command, or from the body calling `requestStop()` on itself. + +A loop driven by `isRunning()` therefore stops **between** units of work, never in +the middle of one: + +```php +final class EmailDispatchWorker extends Process +{ + #[Autowired] private MailQueue $queue; + #[Autowired] private Mailer $mailer; + + public function run(): void + { + while ($this->isRunning()) { // re-checked before every job + $job = $this->queue->reserve(timeout: 1.0); + if ($job === null) { + continue; + } + $this->mailer->send($job->to, $job->subject, $job->body); + $this->queue->markSent($job->id); + // if a stop was requested during send(), the loop exits cleanly here, + // *after* this email is fully sent and acknowledged + } + } +} +``` + +This is the direct analogue of Java's `while (!Thread.currentThread().isInterrupted())`: +a flag you check at the points where stopping is safe. You are never obliged to +call anything to make a process stop. If `run()` returns on its own — a one-shot +task, or a loop that reached its natural end — the process is finished. If a stop +is requested from outside, the framework flips the flag for you; your only +responsibility is to test it. + +--- + +## Interruptible waits — `sleep()` and `InterruptedException` + +A polled flag has a blind spot. In the worker above, `reserve(timeout: 1.0)` +returns at least once a second, so the loop re-checks `isRunning()` promptly. But +consider a process that legitimately blocks for a long time — waiting thirty +seconds between health checks, or parked on a socket read. A flag alone would let +it sit out the entire wait before noticing the stop. + +To close that gap, the framework's own blocking primitive is **interruptible**. +When a stop arrives while the body is parked in `sleep()`, the wait is abandoned +immediately and throws `InterruptedException` — it does not run out the clock: + +```php +final class HealthReporter extends Process +{ + #[Autowired] private HealthCheck $check; + #[Autowired] private StatusdClient $statusd; + + public function run(): void + { + while ($this->isRunning()) { + $this->statusd->report($this->check->run()); + $this->sleep(30); // a 30-second wait, but interruptible + } + } +} +``` + +If this process is stopped nineteen seconds into a `sleep(30)`, it does not wait +the remaining eleven — the `sleep()` throws at once and the body unwinds. This is +precisely how Java behaves: `Thread.sleep()`, `Object.wait()`, +`BlockingQueue.take()` throw `InterruptedException` when the thread is interrupted +mid-block, so a blocked thread wakes instantly rather than after its timeout. + +Because it is an exception, you decide how much ceremony to give it. The common +case is none — let it propagate: + +```php +public function run(): void +{ + while ($this->isRunning()) { + $this->statusd->report($this->check->run()); + $this->sleep(30); // may throw; nothing to clean up, so we let it + } + // an interrupt during sleep() simply unwinds out of run() — a graceful stop +} +``` + +The other case is when you have work in flight that must be settled before you +stop — a reserved job that should go back on the queue rather than be lost: + +```php +public function run(): void +{ + $job = null; + try { + while ($this->isRunning()) { + $job = $this->queue->reserve(timeout: 30.0); // interruptible block + if ($job === null) { + continue; + } + $this->mailer->send($job->to, $job->subject, $job->body); + $this->queue->markSent($job->id); + $job = null; + } + } catch (InterruptedException) { + // stopped while parked in reserve() — no email was half-sent, nothing to undo + } finally { + if ($job !== null) { + $this->queue->release($job->id); // hand a reserved-but-unsent job back + } + } +} +``` + +Two details distinguish this from Java and remove its sharpest edge. First, +`finally` blocks always run on the way out, so cleanup is guaranteed even when the +interrupt propagates. Second, catching `InterruptedException` does **not** clear +any flag — `isRunning()` is a separate, sticky signal — so there is no equivalent +of Java's notorious "swallowed interrupt" bug, where catching the exception hides +the fact that a stop was ever requested. + +> Interruptibility is real coroutine cancellation under Swoole. A native PHP +> `sleep()`, or a blocking call in a client that is not coroutine-aware, cannot be +> interrupted this way — it will run to completion regardless. Use `$this->sleep()` +> and coroutine-aware clients for any wait that should be interruptible. Under +> FPM, `$this->sleep()` is implemented as a series of short slices, so an +> interrupt is noticed within one slice rather than instantly, but the effect is +> the same. + +--- + +## The stop sequence, end to end + +With those two primitives in place, a `stop` (which sends `SIGTERM`) drives the +following sequence. Read it as the definition of "stopping well": + +``` +1. isRunning() flips to false the body stops accepting new work +2. onTerminate() runs your reaction — log it, flush a metric +3. the body reaches a safe point: + · parked in an interruptible wait → the wait throws, the body unwinds now + · in the middle of a unit of work → the unit finishes; it is never cut off +4. in-flight spawn()ed tasks drain the process waits for its own children +5. the body returns → onShutdown() runs guaranteed teardown, on every path + └─ if draining outlasts $grace → the process is forced down (see below) +``` + +Steps 3 and 4 are what make a stop *graceful* rather than merely fast. A unit of +work already in progress is allowed to complete — the framework distinguishes a +process that is *waiting* for work (safe to interrupt at once) from one that is +*doing* work (finished first). That distinction is the process's **activity**, +`IDLE` versus `BUSY`, and how it is tracked — automatically for `spawn()`, or +explicitly with `markBusy()` — is covered in [02-concurrency.md](02-concurrency.md). + +Underpinning step 4 is a firm guarantee: **a process always waits for the tasks it +spawned to finish before it exits.** It never abandons its children. This is +structured concurrency, and it is why a graceful stop drains outstanding work +instead of dropping it. + +--- + +## `grace` — the drain deadline + +Draining is bounded by `grace`: it is *how long the process will wait for +in-flight work to finish before it stops waiting and forces itself down.* It is a +drain deadline — deliberately **not** a blunt "kill this process after N seconds" +timer, which would cut off legitimate long-running work. + +```php +final class VideoTranscodeWorker extends Process +{ + // a single transcode can take minutes; do not let a deploy kill one mid-flight + protected float $grace = 0.0; // wait for the current job to finish, however long +} +``` + +| `$grace` | Meaning | +|---|---| +| `0.0` (default) | wait for in-flight work to drain **for as long as it takes** — never force by a timer | +| `> 0` | wait up to *N* seconds for the drain, then force the process down | + +The choice is a policy decision about your work. If dropping an in-progress unit +is worse than a slow shutdown — a video transcode, a large export — keep `grace` +at `0` and let an external `SIGKILL` (or, for queue work, the broker's redelivery) +be the ultimate stop. If you need a hard ceiling — a container orchestrator that +will `SIGKILL` after thirty seconds anyway — set `grace` to just under that so the +process controls its own forced exit. A repeated `stop`, or `kill -9`, always +forces immediately, regardless of `grace`. + +--- + +## Signals + +A process traps the conventional daemon signal set and routes each signal to an +overridable hook. It is important to understand the division of responsibility: +for the stop signals, **the framework guarantees the stop** — the hook is your +chance to *react*, not a veto. You cannot accidentally ignore a `SIGTERM` by +forgetting to act on it; the process will stop regardless. + +| Signal | Hook | Default behaviour | +|---|---|---| +| `SIGTERM` | `onTerminate()` | graceful stop — this is what `stop` and orchestrators send | +| `SIGINT` | `onInterrupt()` | graceful stop — an interactive Ctrl-C | +| `SIGHUP` | `onReload()` | **reload configuration — the process keeps running** | +| `SIGUSR1` | `onUser1()` | a user-defined action — no-op by default | +| `SIGUSR2` | `onUser2()` | a user-defined action — no-op by default | +| `SIGPIPE` | — | ignored, so a write to a closed socket never kills the process | + +`SIGHUP` follows the long-standing daemon convention that a hang-up means *reload*, +not *stop* — the same signal nginx and Apache use to re-read their configuration +without dropping a connection. It is the one place to make a running process pick +up new settings: + +```php +final class ApiPoller extends Process +{ + private RateLimit $limit; + + #[Autowired] private ConfigStore $config; + #[Autowired] private UpstreamApi $api; + + public function run(): void + { + $this->limit = $this->config->rateLimit(); // initial config + while ($this->isRunning()) { + $this->api->poll(); + $this->sleep($this->limit->intervalSeconds()); + } + } + + protected function onReload(): void + { + // operator ran `kill -HUP` after changing the config file; + // pick up the new rate without a restart, keep running + $this->limit = $this->config->rateLimit(); + $this->logger->notice("reloaded: interval now {$this->limit->intervalSeconds()}s"); + } +} +``` + +`SIGUSR1` and `SIGUSR2` are the two signals POSIX leaves for the application to +define — the standard way to trigger a one-off action on a running process without +restarting it (nginx, for example, uses them to reopen log files and to begin a +binary upgrade). A common pair is "show me what you are doing" and "change how +loudly you report it," neither of which should stop the worker: + +```php +private bool $verbose = false; + +protected function onUser1(): void +{ + // operator wants a live snapshot of internal counters, without stopping the worker + $this->logger->info('metrics', $this->metrics->snapshot()); +} + +protected function onUser2(): void +{ + // flip verbose logging on a running process to debug an incident, then flip it back + $this->verbose = !$this->verbose; + $this->logger->notice($this->verbose ? 'verbose logging enabled' : 'verbose logging disabled'); +} +``` + +--- + +## `onShutdown()` — cleanup that always runs + +A `finally` block in `run()` covers the graceful path, but not a forced exit past +the `grace` deadline, and not a fatal error. For teardown that must happen on +**every** exit path — releasing a distributed lock, deregistering from a service +registry, flushing a buffer — override `onShutdown()`. The framework calls it +exactly once, whether the process ended gracefully, was forced down, or crashed: + +```php +final class LeaderElectedWorker extends Process +{ + #[Autowired] private DistributedLock $lock; + + public function run(): void + { + $this->lock->acquire('report-generator'); // only one instance may run + while ($this->isRunning()) { + $this->generateNextReport(); + } + } + + protected function onShutdown(): void + { + // must run even on a forced or fatal exit, or the lock would be held + // until its TTL expires and no other instance could take over + $this->lock->release('report-generator'); + } +} +``` + +The two levels are deliberate and complementary. The `finally` inside `run()` is +your local cleanup for the cooperative path — closing what this particular loop +opened. `onShutdown()` is the guaranteed hook for critical, externally-visible +teardown that has to run no matter how the process dies. + +--- + +## `requestStop()` — stopping from the inside + +Everything so far is about a stop arriving from *outside*. Sometimes the body +itself decides it is done — it consumed a poison-pill message, hit a +non-recoverable error, or finished the batch it was created for. `requestStop()` +is how it stops itself: it is the cooperative equivalent of the process sending +itself a `SIGTERM`. `isRunning()` flips to false everywhere — in the top-level +loop, in any nested loop, and in any `spawn()`ed task that checks it: + +```php +public function run(): void +{ + while ($this->isRunning()) { + $job = $this->queue->reserve(timeout: 1.0); + if ($job === null) { + continue; + } + if ($job->isShutdownSignal()) { + $this->logger->notice('received shutdown job, draining and exiting'); + $this->requestStop(); // stop the whole process, not just this loop + break; + } + $this->process($job); + } +} +``` + +For a plain top-level loop, `break` or `return` alone is enough to end the body. +Reach for `requestStop()` when the intent to stop has to be visible beyond the +current stack — to a nested loop, or to concurrent tasks that are checking +`isRunning()` to wind themselves down. + +--- + +Next: [02-concurrency.md](02-concurrency.md) — running many units at once, capping +how many, and the semaphore that turns the cap into back-pressure. diff --git a/docs/process/02-concurrency.md b/docs/process/02-concurrency.md new file mode 100644 index 0000000..c655c89 --- /dev/null +++ b/docs/process/02-concurrency.md @@ -0,0 +1,305 @@ +# Concurrency, the semaphore and back-pressure + +The `EmailDispatchWorker` from the previous pages has a ceiling that has nothing +to do with the framework: it sends one email at a time. Each `send()` spends most +of its time waiting on the SMTP server — connecting, handshaking, waiting for a +`250 OK` — and while it waits, the worker does nothing else. If the queue holds +ten thousand emails and each send takes 200 ms of mostly-idle waiting, the worker +needs over half an hour, almost all of it spent blocked on the network. + +The fix is to have several sends in flight at once, overlapping their waits. That +is what `spawn()` is for. And the moment you have several things in flight, you +need a way to say *how many* — otherwise a fast producer will launch work faster +than it completes and exhaust the machine. That limit, and the well-behaved way +it is enforced, is the substance of this page. + +--- + +## `spawn()` — run a unit concurrently + +`spawn()` hands a task to the runtime to run alongside the body, and returns a +`Future` — a handle you can use to wait for its result or discover its failure, +though for fire-and-forget work you usually ignore it. + +Here is the email worker rewritten to send concurrently. The body's job is no +longer to *send* — it is to *dispatch*: pull the next email and hand it off, as +fast as the concurrency limit allows. + +```php +final class EmailDispatchWorker extends Process +{ + protected int $concurrency = 25; // up to 25 sends in flight at once + + #[Autowired] private MailQueue $queue; + #[Autowired] private Mailer $mailer; + + public function run(): void + { + while ($this->isRunning()) { + $job = $this->queue->reserve(timeout: 1.0); + if ($job === null) { + continue; + } + + // hand this email to the runtime and immediately go get the next one + $this->spawn(function () use ($job): void { + try { + $this->mailer->send($job->to, $job->subject, $job->body); + $this->queue->markSent($job->id); + } catch (DeliveryException) { + $this->queue->release($job->id); + } + }); + } + } +} +``` + +Under Swoole each `spawn()` becomes a coroutine. When `send()` blocks on the +network the coroutine yields, the scheduler runs another coroutine, and the body +keeps reserving jobs — so up to twenty-five sends make progress at the same time +on a single thread. The half hour becomes roughly a minute and a half. + +`spawn()` is built directly on the Concurrent unit +([`concurrent/00-overview.md`](../concurrent/00-overview.md)) — a spawned task is +an `Executors::common()->submit()` under the hood, which means it runs in its own +coroutine context and borrows its own connection from the shared PPA pool, so the +twenty-five concurrent sends do not fight over one database handle. + +One guarantee underlies all of this and is worth stating plainly: **a process +always waits for every task it spawned to finish before it exits.** A parent never +outlives its children. This is structured concurrency, and it is the reason a +graceful stop drains the sends that are already in flight instead of dropping +them on the floor. + +--- + +## `$concurrency` — the bound, and why it is not optional + +The `protected int $concurrency` field is the maximum number of spawned tasks +allowed to run at once. + +```php +protected int $concurrency = 25; +``` + +| Value | Behaviour | +|---|---| +| `0` (default) | **unlimited** — every `spawn()` starts immediately | +| `N` | at most `N` tasks run concurrently; further `spawn()`s wait for a slot | + +It is tempting to read `0` as "as fast as possible" and reach for it, but it is a +trap, and it is worth understanding exactly why. With no limit, the dispatch loop +reserves a job and spawns a send, reserves another and spawns another, and never +pauses — because nothing makes it pause. If the SMTP server slows down, sends +start taking longer, but the loop keeps launching new ones at full speed. The +number of in-flight coroutines climbs without bound; each one holds a job, a +buffer, a connection. Memory grows until the worker is killed by the OOM killer, +and the slow SMTP server — the actual bottleneck — is now also drowning in +connections. An unbounded producer does not go faster; it goes down. + +Setting `$concurrency` to a real number prevents this, but the interesting part is +*how* it prevents it. + +--- + +## The mechanism: a counting semaphore + +The limit is implemented as a **counting semaphore** — a pool of `N` permits. To +start a task, `spawn()` must first take a permit; when the task finishes, its +permit goes back into the pool. While all `N` permits are checked out, the next +`spawn()` cannot get one, so it **waits until a running task returns its permit.** + +``` +$concurrency = N creates a permit pool of size N + +spawn(): + take a permit ← if none are available, WAIT here + start the task + ── task finishes ──► return the permit (which wakes one waiting spawn) +``` + +Under Swoole the permit pool is a `Swoole\Coroutine\Channel(N)` pre-loaded with +`N` tokens. Taking a permit is a `pop()`; when the channel is empty the calling +coroutine **suspends** — it does not spin, it does not burn CPU, and critically it +does not block the worker thread, so all the other coroutines keep running while +this one is parked. When a finishing task pushes its token back, the scheduler +resumes exactly one waiting `spawn()`. That suspension is the whole trick. + +This is not a Winter invention; it is the canonical way to bound concurrent work, +and you will recognise it from other ecosystems: + +- **Java** — the pattern in Goetz's *Java Concurrency in Practice* is literally a + `Semaphore` wrapped around a thread pool; `acquire()` blocks the submitter when + the pool is saturated. +- **Go** — the idiomatic bounded worker pool is a buffered channel used as a + semaphore: `sem := make(chan struct{}, N)`, and sending to a full channel blocks + the goroutine. +- **Kotlin / Reactive Streams** — a coroutine `Semaphore`, or first-class + back-pressure where the consumer signals the producer to slow down. + +The principle they share is the one that matters here: **when the workers are +saturated, slow down the producer.** Blocking the thing that submits work is not a +deficiency to be engineered away — it is the correct behaviour, and the unbounded +alternative is the classic mistake it exists to prevent. + +--- + +## Back-pressure, in practice + +The practical consequence of the semaphore is precise and worth internalising: +**`spawn()` is a point at which the body can pause.** When every permit is out, the +body stops *at the `spawn()` call* and does not proceed until a slot frees. Any +code that follows `spawn()` in the loop waits along with it. + +You can see this most clearly by pushing the limit to its extreme, `concurrency = +1`, which serialises the tasks completely — task #2 cannot even begin until task +#1 has finished. Instrumenting a body that logs on either side of `spawn()` makes +the pause visible: + +```php +protected int $concurrency = 1; + +public function run(): void +{ + while ($this->isRunning()) { + $job = $this->queue->reserve(timeout: 1.0); + if ($job === null) { + continue; + } + $this->logger->debug("about to dispatch #{$job->id}"); + $this->spawn(fn() => $this->handle($job)); // waits here while a task holds the permit + $this->logger->debug("dispatched #{$job->id}"); + } +} +``` + +``` +about to dispatch #41 12:00:00.000 +dispatched #41 12:00:00.001 ← permit was free, taken instantly +about to dispatch #42 12:00:00.100 +dispatched #42 12:00:03.100 ← +3s: waited for #41's task to return the permit +``` + +Nothing is lost in this waiting — every `spawn()` you call still runs; the body is +simply *paced* to the rate at which tasks complete, which is exactly what stops it +from overwhelming the workers or the memory. Where you place a log line relative to +`spawn()` determines whether it fires on every iteration (put it before) or only +once a permit is actually acquired (put it after). + +> Back-pressure paces the *producer*; it is not a durable queue. In-flight tasks +> live in memory, so a `SIGKILL`, a crash, or a `grace` timeout drops whatever was +> running. When the work itself must survive a crash — a payment, a queued message +> — durability has to come from the source: reserve-and-acknowledge against the +> broker or database, so an un-acknowledged job is redelivered. `spawn()` gives you +> concurrency and back-pressure, not persistence; the two are separate concerns and +> should stay that way. + +--- + +## Activity — `IDLE` and `BUSY` + +Separately from its lifecycle state, a process reports whether it is *doing work +right now*. This is its **activity**, and it takes one of two values, `BUSY` or +`IDLE`. Activity drives three things: the drain-to-idle behaviour of a graceful +stop (a `BUSY` process is never interrupted mid-work), the live status shown by +the CLI and web, and — for a supervised `Daemon` — the decision of which workers +are safe to stop when scaling down, since you only ever want to remove an idle one. + +A process is `BUSY` when **either** an inline unit is marked in progress **or** any +`spawn()`ed task is still running, and `IDLE` otherwise. The common case needs no +work from you at all: because `spawn()` tracks its own in-flight count, a worker +that dispatches through `spawn()` reports `BUSY`/`IDLE` automatically. + +You only mark activity by hand when you process a unit **inline** — directly in +the body, without `spawn()` — because then there is no in-flight count for the +framework to observe: + +```php +final class ReportBuilder extends Process +{ + #[Autowired] private ReportQueue $queue; + + public function run(): void + { + while ($this->isRunning()) { + $request = $this->queue->reserve(timeout: 1.0); + if ($request === null) { + continue; // IDLE — parked waiting for a request + } + + $this->markBusy(); // a report build starts — protect it from mid-work interruption + $this->buildReport($request); // minutes of CPU-bound work, run inline + $this->markIdle(); // done — safe to interrupt or scale down again + } + } +} +``` + +`markBusy()` and `markIdle()` are pure in-memory flags — they cost nothing and are +safe to call on every iteration. They combine with the spawn count by OR, so a +process that both marks itself busy and has spawns in flight stays `BUSY` until +both are clear; it can never report a false `IDLE` while work is genuinely +outstanding. The value is persisted to the status record on a roughly one-second +heartbeat, and only when it actually changes, so a worker flipping between busy +and idle on every message never touches the disk for it. + +--- + +## How the FPM backend reproduces this + +When Swoole is absent there are no coroutines, so the backend runs each `spawn()` +as a **forked child process**. Every behaviour above is preserved — the cap, the +back-pressure, the serialisation at `concurrency = 1` — but the machinery is +different, and one difference is observable and worth knowing. + +| | Swoole | PHP-FPM / CLI | +|---|---|---| +| A `spawn()` becomes | a coroutine | a `pcntl_fork()` child process | +| The permit pool is | a `Coroutine\Channel(N)`; taking a permit **suspends** the coroutine | a count of live children; a full pool **blocks** the parent on `pcntl_wait()` | +| Waiting for a permit | non-blocking — other coroutines keep running | a genuine block — the parent has nothing else to do meanwhile | +| The task's memory | shared with the body | an isolated copy taken at fork time | +| The `Future` result | the task's real return value | a settled placeholder — a child cannot return a value across the process boundary | + +The back-pressure has the same shape on both. At `concurrency = 1` under FPM, the +parent reaches the second `spawn()`, sees the one permit is taken, and blocks in +`pcntl_wait()` until the running child exits — then forks the next. The tasks run +strictly one after another, and a trace shows the identical pause you saw under +Swoole. + +The one behaviour that genuinely differs comes from process isolation, and you +should know it before you rely on cooperative cancellation. A Swoole `spawn()` is +a coroutine sharing the body's memory, so it observes `isRunning()` flip to false +the instant a stop is requested and can break out of its own loop early. An FPM +`spawn()` is a separate process holding its own copy of that flag as it stood at +fork time: a child that was already running when the stop arrived does **not** see +the later `requestStop()`, and runs its task to completion. The parent waits for +it in `pcntl_wait()` and then exits. This is ordinary, correct fork semantics, but +it means that under FPM an already-running spawned task cannot be cancelled +mid-flight the way a coroutine can — it finishes what it started. + +--- + +## Where `spawn()` fits, and where it does not + +``` +Daemon ──► N separate Process workers isolation · independent scaling · CPU parallelism + │ + └──► spawn() inside each I/O concurrency within one worker +``` + +`spawn()` gives you concurrency *inside a single process*: many units overlapping +their I/O on one runtime. It does not give you fault isolation — an uncaught fatal +error in one coroutine takes down every coroutine in that process — nor +parallelism across CPU cores, because the coroutine scheduler is single-threaded. +When you need those, you run several separate *processes*, each isolated and +individually restartable, under a supervisor. That supervisor is a `Daemon`, and +the two layers compose cleanly: a supervised worker still uses `spawn()` internally +to overlap its own I/O. Choosing between them is a question of what you are +protecting against — the machine running out of memory (bound with `$concurrency`) +versus one bad job taking down the whole fleet (isolate with separate processes). + +--- + +Next: [03-control.md](03-control.md) — launching, stopping and inspecting a process +from the CLI and the web. diff --git a/docs/process/03-control.md b/docs/process/03-control.md new file mode 100644 index 0000000..4a8c2f6 --- /dev/null +++ b/docs/process/03-control.md @@ -0,0 +1,243 @@ +# Control — start, stop, status + +A process exists to be operated. Someone — a deploy script, an operator, a +supervisor, an admin dashboard — needs to launch it into the background, ask +whether it is alive and what it is doing, and stop it cleanly when the time comes. +That operational surface is four static methods on every process. The +`call process` command is a thin, ergonomic wrapper over those four; the web layer +is whatever you build on the same four. Nothing else is privileged — the CLI has +no special access the rest of your application lacks. + +--- + +## The four methods + +```php +EmailDispatchWorker::start(); // run in this process, in the foreground +$pid = EmailDispatchWorker::dispatch(); // launch detached in the background, return its PID +$status = EmailDispatchWorker::status(true); // its status (with live resource usage), or null +EmailDispatchWorker::stop(); // ask it to stop gracefully +``` + +**`start()`** runs the body in the current process and blocks the caller until it +returns. While it runs it registers itself in the store, so another terminal can +still find it with `status()` and `stop()`. This is what you use in development and +for one-shot tasks you want to watch. + +**`dispatch()`** launches the process detached — double-forked and `setsid`-ed so +it has no controlling terminal — and returns immediately with its PID. A detached +process survives the shell that started it and is what you use for a real service. + +**`status()`** reads the process's record from the store: its PID, lifecycle +state, activity, uptime and concurrency. Pass `true` to also attach a live +`ResourceUsage` snapshot. It returns `null` when the process is not running, and it +is safe to call from anywhere — a controller, a health check, a scheduled audit. + +**`stop()`** sends `SIGTERM`, beginning the graceful stop sequence from +[01-lifecycle.md](01-lifecycle.md). `SIGTERM` is the deliberate choice here: it is +the universal "terminate, and you may clean up" signal that `kill`, systemd, Docker +and Kubernetes all send by default. `SIGINT` is left to mean an interactive Ctrl-C. + +Because these are ordinary static methods, the web layer is not a separate feature +— it is a few lines that call them. A minimal operations endpoint looks like this: + +```php +class WorkersController extends Controller +{ + #[GetMapping('/ops/workers/email')] + public function emailStatus(): ResponseEntity + { + $status = EmailDispatchWorker::status(usage: true); + + if ($status === null) { + return ResponseEntity::ok(['running' => false]); + } + + return ResponseEntity::ok([ + 'running' => true, + 'pid' => $status->pid, + 'activity' => $status->activity->name, // BUSY | IDLE + 'uptime' => time() - $status->startedAt, + 'memory_mb' => round($status->usage?->rssMb() ?? 0, 1), + ]); + } + + #[PostMapping('/ops/workers/email/stop')] + public function stopEmail(): ResponseEntity + { + return ResponseEntity::ok(['stopped' => EmailDispatchWorker::stop()]); + } +} +``` + +--- + +## One instance per class + +A process is a **singleton per class**: one class means one running instance. The +whole control model assumes it — the status record is keyed by the class name, and +`status()` and `stop()` address a process *by its class*, which only has an +unambiguous answer when there is exactly one. Starting a second instance of the +same class would have both write to the same record, so `status()` would see one +while the other ran orphaned and unreachable. + +`start()` and `dispatch()` therefore refuse a second instance and throw +`ProcessAlreadyRunningException`: + +```php +try { + EmailDispatchWorker::dispatch(); +} catch (ProcessAlreadyRunningException $e) { + // one is already running — $e carries its PID and start time +} +``` + +The guard has two layers, so it holds even under a race. `start()` / `dispatch()` +first check the store and refuse immediately if a live instance is recorded. The +launched worker then takes an exclusive `flock` keyed by its class before it does +anything; if two launches slip past the first check at the same instant, only one +wins the lock and the other exits. Because a `flock` is released by the operating +system the moment the process dies, it is never left stale — a crashed or +`kill -9`-ed process frees it instantly, and the next launch succeeds with no +manual cleanup, which a PID file could not guarantee. + +To run **several workers of the same logic**, this is not the mechanism — launching +the same class repeatedly is precisely what is forbidden. Use a `Daemon`, which +supervises a configurable number of identical worker processes, or define distinct +classes for distinct roles. Multiplicity lives in the supervisor; a bare process +stays a single, unambiguous, named instance. + +--- + +## The command line: `call process` + +`call process` (aliased to `call proc`) drives the four methods and renders live +state, with tab-completion wired for command names, discovered process classes, +and flags. + +``` +call process list # every process, with live state +call process main.EmailDispatchWorker # start in the foreground (blocks the shell) +call process main.EmailDispatchWorker start -d # start detached in the background +call process main.EmailDispatchWorker status # the status card +call process main.EmailDispatchWorker status -v # + resource usage (and, for a daemon, its workers) +call process main.EmailDispatchWorker stop # graceful stop +``` + +`list` gives you the fleet at a glance. Each row shows a `[P]` process or `[D]` +daemon tag, its running/stopped state, its live `[BUSY]`/`[idle]` activity, and its +uptime, followed by a one-line summary: + +``` + [P] Main.EmailDispatchWorker ......... [● RUNNING] [BUSY] 4h 12m + [P] Main.ExpiredSessionCleanup ....... [○ STOPPED] + [D] Main.SmsGatewayBridge ............ [● RUNNING] [idle] [w:4] 2d 1h + 3 defined, 2 running. +``` + +`status -v` prints the full card, including CPU and resident memory read live from +`ps` at the moment you ask: + +``` + Main.EmailDispatchWorker [Process ● RUNNING] + ───────────────────────────────────────────── + PID 58753 + State RUNNING + Activity BUSY + Started 2026-07-23 07:41:02 +00:00 + Uptime 4h 12m + Concurrency 25 + ─── Resources ─── + CPU 3.1 % + Memory 0.4 % (48.2 MB) +``` + +--- + +## The status record + +While a process runs it owns one small record in the runnable store, keyed by its +class name, holding the PID, lifecycle state, activity, start time and concurrency. +This record is the single source of truth that `status()`, the CLI and any web +endpoint all read; because it is a plain file store, they read it with no locking +or coordination between them. + +Two properties keep the record both trustworthy and cheap. + +It is **self-healing**. Every `status()` call verifies that the recorded PID still +exists before trusting the record; if the process died without cleaning up — a +forced exit, a crash — the stale record is dropped on that read and the process +correctly reports as stopped. You never see a ghost. + +Its writes are **throttled**. The frequently-changing part of the record is the +activity, and a busy worker can flip between `BUSY` and `IDLE` many times a second. +Writing the file on each flip would be pointless disk churn, so activity is +persisted on a roughly one-second heartbeat and only when it has actually changed. +The in-memory value the process acts on is always current; the persisted value the +outside world reads lags by at most about a second, which is exactly the precision +a human or a scale-down decision needs. Live resource usage is never stored at all +— it is read from `ps` on demand. + +--- + +## Resource usage + +Passing `usage: true` to `status()` (or `-v` on the CLI) attaches a `ResourceUsage` +snapshot: PID, parent PID, user, CPU percentage, memory percentage, resident set +size and elapsed time, all read from the operating system's `ps`. + +```php +$usage = ResourceUsage::ofPid($pid); +$megabytes = $usage?->rssMb(); // resident memory, or null if the process is gone +``` + +It is designed for **occasional, on-demand** observation — a status card, a +dashboard poll, an alert threshold — and never for a hot path, because each read +forks the `ps` binary. Note the direction: this is a process being *observed from +the outside*, not a process measuring itself. It answers "how much memory is that +worker using right now?" from another process, which is exactly what an operator or +a monitor asks. + +--- + +## Foreground versus detached + +The difference between `start()` and `dispatch()` (or `start` and `start -d` on the +CLI) is where the process lives and how long it survives. + +| | Foreground — `start` | Detached — `start -d` / `dispatch()` | +|---|---|---| +| Blocks the caller | yes, until the body returns | no — returns a PID immediately | +| Survives the launching shell | no — dies with the terminal | yes — re-parented to init | +| stdout and logs | the terminal | `/dev/null` by default; logging goes to files | +| Typical use | development, one-shot tasks you watch | real long-running services | + +A detached launch re-executes through the framework's runner, which boots the +application in the child process so the detached worker has the very same +container, configuration and logger as any other entry point — it is not a +stripped-down environment. One consequence of the double-fork is worth knowing: the +PID the launcher first hands back is an intermediate one, and the real worker +registers its own, final PID in the store as it starts. The CLI resolves this for +you by reading the store, so the PID it prints and the one `status`/`stop` act on +are always the real process; you never target the wrong thing. + +--- + +## Stopping cleanly + +`stop()` sends `SIGTERM`, and from there the process runs the sequence detailed in +[01-lifecycle.md](01-lifecycle.md): it stops taking new work, lets the current unit +finish, drains its in-flight `spawn()`ed tasks, runs `onShutdown()`, and exits. How +long that takes is bounded by the worker's `$grace` — `0` means it waits for the +drain for as long as the drain needs. If you cannot wait, a second `stop`, or an +external `kill -9`, forces the process down at once, skipping the drain. + +Operating a *fleet* of workers — starting and stopping individual instances by +their activity so that a `BUSY` one is never the one you remove, and scaling the +count up and down with load — is a level above a single process. That is the job of +a `Daemon`, a supervisor that manages a set of Process workers, and it is +documented separately. + +--- + +Back to [00-overview.md](00-overview.md). diff --git a/src/Dev/Process/Daemon.php b/src/Dev/Process/Daemon.php index f3150e2..1c1ff9b 100644 --- a/src/Dev/Process/Daemon.php +++ b/src/Dev/Process/Daemon.php @@ -76,6 +76,8 @@ public function backoffBase(): float */ final public static function start(): void { + static::ensureNotRunning(); + /** @var static $self */ $self = Container::getInstance()->make(static::class); $self->supervise(); @@ -83,6 +85,13 @@ final public static function start(): void private function supervise(): void { + if (!$this->acquireLock()) { + LoggerFactory::getLogger(static::class)->notice( + static::class . ' is already running; not starting a second supervisor.' + ); + return; + } + $this->pid = getmypid(); $this->logger = LoggerFactory::getLogger(static::class); @@ -125,6 +134,7 @@ className: static::class, ); } finally { $store->del($key); + $this->releaseLock(); } } } diff --git a/src/Dev/Process/Process.php b/src/Dev/Process/Process.php index b6b8acf..970aeb0 100644 --- a/src/Dev/Process/Process.php +++ b/src/Dev/Process/Process.php @@ -9,6 +9,7 @@ use Flytachi\Winter\K2\Concurrent\Future; use Flytachi\Winter\K2\Dev\Process\Engine\Engines; use Flytachi\Winter\K2\Dev\Process\Engine\ProcessEngine; +use Flytachi\Winter\K2\Kernel; use Flytachi\Winter\Logger\LoggerFactory; use Flytachi\Winter\Thread\Thread; use Psr\Log\LoggerInterface; @@ -57,6 +58,8 @@ abstract class Process private bool $stopping = false; private bool $shutdownDone = false; private bool $inlineBusy = false; + /** @var resource|null Held for the process lifetime; the flock is the singleton guard. */ + private $lockHandle = null; // Status store bookkeeping (a bare process owns its record; a daemon worker does not). private bool $ownsRecord = false; @@ -186,6 +189,8 @@ protected function onShutdown(): void */ public static function start(): void { + static::ensureNotRunning(); + /** @var static $self */ $self = Container::getInstance()->make(static::class); $self->boot(); @@ -198,6 +203,8 @@ public static function start(): void */ final public static function dispatch(?string $output = '/dev/null'): int { + static::ensureNotRunning(); + return new Thread( new ProcessRunnable(static::class), 'process', @@ -205,6 +212,21 @@ final public static function dispatch(?string $output = '/dev/null'): int )->start(outputTarget: $output, detached: true); } + /** + * Refuses to launch a second instance of the same class. + * + * @throws ProcessAlreadyRunningException If one is already running. + */ + protected static function ensureNotRunning(): void + { + $info = static::status(); + if ($info !== null) { + throw new ProcessAlreadyRunningException( + static::class . " is already running [PID {$info->pid}] (since {$info->getStartedAt()})." + ); + } + } + /** * Current status, or null when the process is not running. * @@ -252,6 +274,17 @@ final public static function stop(): bool private function boot(): void { + // The status check in start()/dispatch() catches the common case; this + // exclusive lock closes the race between two near-simultaneous launches. + // A flock is released automatically when the process dies, so it is never + // left stale — unlike a PID file. + if (!$this->acquireLock()) { + LoggerFactory::getLogger(static::class)->notice( + static::class . ' is already running; not starting a second instance.' + ); + return; + } + $this->prepareWorker(); $this->ownsRecord = true; $this->writeStatus(); @@ -260,9 +293,43 @@ private function boot(): void $this->runBody(); } finally { static::store()->del(static::key()); + $this->releaseLock(); } } + /** + * Takes the per-class singleton lock. Returns false when another instance + * already holds it; true when acquired (or when a lock file cannot be created, + * in which case it proceeds best-effort rather than block on a filesystem issue). + */ + protected function acquireLock(): bool + { + $handle = @fopen($this->lockPath(), 'c'); + if ($handle === false) { + return true; + } + if (!flock($handle, LOCK_EX | LOCK_NB)) { + fclose($handle); + return false; + } + $this->lockHandle = $handle; + return true; + } + + protected function releaseLock(): void + { + if ($this->lockHandle !== null) { + flock($this->lockHandle, LOCK_UN); + fclose($this->lockHandle); + $this->lockHandle = null; + } + } + + protected function lockPath(): string + { + return Kernel::$pathStorageRunnable . '/' . str_replace('\\', '.', static::class) . '.lock'; + } + /** * Worker entry point used by a {@see Daemon} supervisor: sets up the runtime * and runs the body, without owning the store record (the supervisor owns it). diff --git a/src/Dev/Process/ProcessAlreadyRunningException.php b/src/Dev/Process/ProcessAlreadyRunningException.php new file mode 100644 index 0000000..a0235d1 --- /dev/null +++ b/src/Dev/Process/ProcessAlreadyRunningException.php @@ -0,0 +1,17 @@ + Date: Thu, 23 Jul 2026 18:46:48 +0500 Subject: [PATCH 09/71] New Engine Process (betta test) --- src/Dev/Process/Activity.php | 8 +++++--- src/Dev/Process/Process.php | 11 ++++++++--- src/Dev/Process/ProcessStatus.php | 23 ++++++++++++++++++++++- src/Dev/Process/ResourceUsage.php | 18 +++++++++++++++++- 4 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/Dev/Process/Activity.php b/src/Dev/Process/Activity.php index 9232f12..e5bb082 100644 --- a/src/Dev/Process/Activity.php +++ b/src/Dev/Process/Activity.php @@ -12,9 +12,11 @@ * {@see Process::spawn()} task is in flight; IDLE otherwise. It drives * drain-to-idle on stop, the status view, and (later) a daemon's scale-down * decision — never stop a BUSY worker. + * + * Backed by a string so it serialises cleanly to JSON and logs. */ -enum Activity +enum Activity: string { - case IDLE; - case BUSY; + case IDLE = 'idle'; + case BUSY = 'busy'; } diff --git a/src/Dev/Process/Process.php b/src/Dev/Process/Process.php index 970aeb0..45a68d4 100644 --- a/src/Dev/Process/Process.php +++ b/src/Dev/Process/Process.php @@ -242,9 +242,14 @@ final public static function status(bool $usage = false): ?ProcessStatus if (!$status) { return null; } - // Drop a stale record whose process is gone (e.g. a forced exit). - if (!posix_kill($status->pid, 0)) { - $store->del($key); + // Liveness via getpgid, not kill(pid, 0): getpgid needs no permission, + // so a status check from another user (web as `winter`, process as + // root) cannot mistake a live process for a dead one. And status() is a + // pure read — it never deletes: a read must have no side effect, or any + // caller who can query could evict a running process's record. A stale + // record left by a crash is harmless (this returns null) and is + // overwritten by the next start(). + if (posix_getpgid($status->pid) === false) { return null; } if ($usage) { diff --git a/src/Dev/Process/ProcessStatus.php b/src/Dev/Process/ProcessStatus.php index d4cba3c..4ce2fe4 100644 --- a/src/Dev/Process/ProcessStatus.php +++ b/src/Dev/Process/ProcessStatus.php @@ -10,8 +10,10 @@ * Written to the runnable store while the process lives, read back by the CLI * and the web layer. {@see ResourceUsage} is live and never persisted — it is * attached on read via {@see Process::status()}. + * + * Serialises to a stable JSON shape so a controller can return it directly. */ -final class ProcessStatus +final class ProcessStatus implements \JsonSerializable { /** * @param array $workers Worker PIDs supervised by a daemon (empty for a bare process). @@ -33,4 +35,23 @@ public function getStartedAt(): string { return date('Y-m-d H:i:s P', $this->startedAt); } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'pid' => $this->pid, + 'class' => $this->className, + 'state' => $this->state->name, // NEW | RUNNING | STOPPING | … + 'activity' => $this->activity->value, // idle | busy + 'started_at' => $this->startedAt, + 'uptime' => time() - $this->startedAt, + 'concurrency' => $this->concurrency, + 'restarts' => $this->restarts, + 'workers' => $this->workers, + 'usage' => $this->usage, // ResourceUsage|null (also JsonSerializable) + ]; + } } diff --git a/src/Dev/Process/ResourceUsage.php b/src/Dev/Process/ResourceUsage.php index dc583cf..6a31a8d 100644 --- a/src/Dev/Process/ResourceUsage.php +++ b/src/Dev/Process/ResourceUsage.php @@ -11,7 +11,7 @@ * web layer — never a hot path: each read forks the `ps` binary. Use it to look * at a process from the outside; a process does not measure itself with it. */ -final class ResourceUsage +final class ResourceUsage implements \JsonSerializable { public function __construct( public int $pid, @@ -64,4 +64,20 @@ public function rssMb(): float { return $this->rssKb / 1024; } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'pid' => $this->pid, + 'ppid' => $this->ppid, + 'user' => $this->user, + 'cpu' => $this->cpu, + 'memory' => $this->memory, + 'rss_mb' => round($this->rssMb(), 1), + 'elapsed' => $this->elapsed, + ]; + } } From d1d89dbab3e6efd9eb1907e58c9b0c95141d415a Mon Sep 17 00:00:00 2001 From: flytachi Date: Thu, 23 Jul 2026 18:56:28 +0500 Subject: [PATCH 10/71] New Engine Process (betta test) --- src/Dev/Process/ResourceUsage.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Dev/Process/ResourceUsage.php b/src/Dev/Process/ResourceUsage.php index 6a31a8d..cdbf113 100644 --- a/src/Dev/Process/ResourceUsage.php +++ b/src/Dev/Process/ResourceUsage.php @@ -78,6 +78,7 @@ public function jsonSerialize(): array 'memory' => $this->memory, 'rss_mb' => round($this->rssMb(), 1), 'elapsed' => $this->elapsed, + 'command' => $this->command, ]; } } From 9cea9161d899b353c2a213809281a02ea7596d5a Mon Sep 17 00:00:00 2001 From: flytachi Date: Thu, 23 Jul 2026 19:08:40 +0500 Subject: [PATCH 11/71] New Engine Process (betta test) --- console/Command/Process.php | 7 ++--- src/Dev/Process/Daemon.php | 2 +- src/Dev/Process/DaemonStatus.php | 44 +++++++++++++++++++++++++++++++ src/Dev/Process/ProcessStatus.php | 12 +++------ 4 files changed, 52 insertions(+), 13 deletions(-) create mode 100644 src/Dev/Process/DaemonStatus.php diff --git a/console/Command/Process.php b/console/Command/Process.php index 43da66f..0a65d27 100644 --- a/console/Command/Process.php +++ b/console/Command/Process.php @@ -9,6 +9,7 @@ use Flytachi\Winter\K2\Core\ClassScanner; use Flytachi\Winter\K2\Dev\Process\Activity; use Flytachi\Winter\K2\Dev\Process\Daemon as DaemonUnit; +use Flytachi\Winter\K2\Dev\Process\DaemonStatus; use Flytachi\Winter\K2\Dev\Process\Process as ProcessUnit; use Flytachi\Winter\K2\Dev\Process\ResourceUsage; @@ -143,7 +144,7 @@ private function statusArg(string $class, bool $detailed): void if ($info->concurrency > 0) { self::printKeyValue("Concurrency", (string) $info->concurrency, 12, 34, 36); } - if ($isDaemon) { + if ($info instanceof DaemonStatus) { self::printKeyValue("Workers", (string) count($info->workers), 12, 34, 36); self::printKeyValue("Restarts", (string) $info->restarts, 12, 34, 36); } @@ -165,7 +166,7 @@ private function statusArg(string $class, bool $detailed): void self::printKeyValue("Elapsed", $u->elapsed, 12, 34, 35); } - if ($detailed && $info->workers !== []) { + if ($detailed && $info instanceof DaemonStatus && $info->workers !== []) { self::printDivider(); self::printLabel("Workers (" . count($info->workers) . ")", 34); foreach ($info->workers as $wpid) { @@ -228,7 +229,7 @@ private function printRow(string $class, bool $isDaemon): bool $uptime = $this->formatDuration(time() - $info->startedAt); echo "\033[32m[● {$info->state->name}]" . $this->activityTag($info->activity) - . ($isDaemon ? "\033[36m [w:" . count($info->workers) . "]" : '') + . ($info instanceof DaemonStatus ? "\033[36m [w:" . count($info->workers) . "]" : '') . "\033[90m {$uptime}\033[0m\n"; return true; diff --git a/src/Dev/Process/Daemon.php b/src/Dev/Process/Daemon.php index 1c1ff9b..b0bdd84 100644 --- a/src/Dev/Process/Daemon.php +++ b/src/Dev/Process/Daemon.php @@ -100,7 +100,7 @@ private function supervise(): void $startedAt = time(); $write = function (ProcessState $state, int $restarts, array $workers) use ($store, $key, $startedAt): void { - $store->write($key, new ProcessStatus( + $store->write($key, new DaemonStatus( pid: $this->pid, className: static::class, state: $state, diff --git a/src/Dev/Process/DaemonStatus.php b/src/Dev/Process/DaemonStatus.php new file mode 100644 index 0000000..90fcec6 --- /dev/null +++ b/src/Dev/Process/DaemonStatus.php @@ -0,0 +1,44 @@ + $workers Live worker PIDs under the supervisor. + */ + public function __construct( + int $pid, + string $className, + ProcessState $state, + Activity $activity, + int $startedAt, + int $concurrency, + public int $restarts, + public array $workers, + ?ResourceUsage $usage = null, + ) { + parent::__construct($pid, $className, $state, $activity, $startedAt, $concurrency, $usage); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return array_merge(parent::jsonSerialize(), [ + 'restarts' => $this->restarts, + 'workers' => $this->workers, + ]); + } +} diff --git a/src/Dev/Process/ProcessStatus.php b/src/Dev/Process/ProcessStatus.php index 4ce2fe4..a415d50 100644 --- a/src/Dev/Process/ProcessStatus.php +++ b/src/Dev/Process/ProcessStatus.php @@ -11,13 +11,11 @@ * and the web layer. {@see ResourceUsage} is live and never persisted — it is * attached on read via {@see Process::status()}. * - * Serialises to a stable JSON shape so a controller can return it directly. + * Serialises to a stable JSON shape so a controller can return it directly. A + * supervised {@see Daemon} records the richer {@see DaemonStatus} subclass. */ -final class ProcessStatus implements \JsonSerializable +class ProcessStatus implements \JsonSerializable { - /** - * @param array $workers Worker PIDs supervised by a daemon (empty for a bare process). - */ public function __construct( public int $pid, public string $className, @@ -25,8 +23,6 @@ public function __construct( public Activity $activity, public int $startedAt, public int $concurrency = 0, - public int $restarts = 0, - public array $workers = [], public ?ResourceUsage $usage = null, ) { } @@ -49,8 +45,6 @@ public function jsonSerialize(): array 'started_at' => $this->startedAt, 'uptime' => time() - $this->startedAt, 'concurrency' => $this->concurrency, - 'restarts' => $this->restarts, - 'workers' => $this->workers, 'usage' => $this->usage, // ResourceUsage|null (also JsonSerializable) ]; } From 0a81ef874e3592d3fb71c18ce3300b40a4349b28 Mon Sep 17 00:00:00 2001 From: flytachi Date: Thu, 23 Jul 2026 20:24:48 +0500 Subject: [PATCH 12/71] Process (betta test) --- src/Kernel.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Kernel.php b/src/Kernel.php index 8a3a6da..f01d30c 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -6,7 +6,7 @@ use Flytachi\Winter\Base\Runtime; use Flytachi\Winter\K2\Core\KernelStore; -use Flytachi\Winter\Thread\Launch\CliLauncher; +use Flytachi\Winter\Thread\Launch\AdaptiveLauncher; use Flytachi\Winter\Thread\Thread; use Flytachi\Winter\Logger\Context\ProcessContext; use Flytachi\Winter\Logger\LoggerFactory; @@ -62,8 +62,9 @@ public static function init( self::bootLogger(); - // thread - Thread::bindLauncher(CliLauncher::adaptive( + // thread — route each launch by runtime: Swoole\Process inside a coroutine + // (proc_open corrupts the reactor's fds there), proc_open everywhere else. + Thread::bindLauncher(AdaptiveLauncher::adaptive( secret: env('WINTER_KEY', ''), runnerPath: self::threadRunnerPath(), )); From df6516bb32d9429e0a1544aa319ffa3ad18a7ac9 Mon Sep 17 00:00:00 2001 From: flytachi Date: Fri, 24 Jul 2026 01:25:32 +0500 Subject: [PATCH 13/71] Process fix --- src/Dev/Process/Process.php | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/Dev/Process/Process.php b/src/Dev/Process/Process.php index 45a68d4..e984c7e 100644 --- a/src/Dev/Process/Process.php +++ b/src/Dev/Process/Process.php @@ -413,13 +413,30 @@ private function invokeShutdown(): void } } - private function applyProcessTitle(): void + protected function applyProcessTitle(): void { if (!function_exists('cli_set_process_title')) { return; } - $title = $this->processTitle ?? new \ReflectionClass(static::class)->getShortName(); - @cli_set_process_title('winter-process: ' . $title); + @cli_set_process_title($this->buildProcessTitle()); + } + + /** + * The display name for this process, without the runtime prefix: the explicit + * {@see $processTitle} when set, otherwise the class short name. + */ + protected function titleName(): string + { + return $this->processTitle ?? new \ReflectionClass(static::class)->getShortName(); + } + + /** + * The full OS process title. Overridable so a {@see Daemon} can label its + * master and numbered workers distinctly (e.g. `winter-daemon: X worker#2`). + */ + protected function buildProcessTitle(): string + { + return 'winter-process: ' . $this->titleName(); } // ------------------------------------------------------------------------- From 0d11747818435a518f607d22cab6236a634e3fc0 Mon Sep 17 00:00:00 2001 From: flytachi Date: Fri, 24 Jul 2026 23:41:48 +0500 Subject: [PATCH 14/71] =?UTF-8?q?=D0=BA=D1=80=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- console/Command/Complete.php | 49 +- console/Command/Daemon.php | 323 ++++++++ console/Command/Process.php | 75 +- console/Command/Thread.php | 10 +- console/Core.php | 1 + dev/main/Process/AutoscaleDaemon.php | 58 ++ dev/main/Process/ConsumerDemo.php | 4 +- dev/main/Process/CrashDaemon.php | 18 +- dev/main/Process/DemoProcess.php | 2 +- dev/main/Process/FleetDaemon.php | 30 + dev/main/Process/HungDaemon.php | 35 + dev/main/Process/LongDemo.php | 2 +- dev/main/Process/NeverDaemon.php | 30 + dev/main/Process/SendProc.php | 33 + dev/main/Process/SignalDemo.php | 4 +- dev/main/Process/StableDaemon.php | 10 +- dev/main/Te1.php | 11 + docs/process/01-lifecycle.md | 8 +- docs/process/03-control.md | 7 +- docs/process/daemon/00-overview.md | 107 +++ docs/process/daemon/01-workers.md | 107 +++ docs/process/daemon/02-autoscaling.md | 148 ++++ docs/process/daemon/03-control.md | 128 ++++ phpunit.xml | 4 + src/BaseBoot.php | 2 +- src/Dev/Process/Daemon.php | 140 ---- src/Dev/Process/Supervisor/Supervisor.php | 214 ------ src/Kernel.php | 6 + src/{ => Old}/Process/Core/DaemonStore.php | 2 +- src/{ => Old}/Process/Core/Dispatch.php | 2 +- src/{ => Old}/Process/Core/DispatchStore.php | 2 +- src/{ => Old}/Process/Core/Dispatchable.php | 2 +- src/{ => Old}/Process/Core/WinterRunner.php | 2 +- src/{ => Old}/Process/DaemonException.php | 2 +- src/{ => Old}/Process/Entity/TCondition.php | 2 +- src/{ => Old}/Process/Entity/TDInfo.php | 2 +- src/{ => Old}/Process/Entity/TDStatus.php | 2 +- src/{ => Old}/Process/Entity/TInfo.php | 2 +- src/{ => Old}/Process/Entity/TStats.php | 2 +- src/{ => Old}/Process/Entity/TStatus.php | 2 +- .../Process/Socket/Web/PDU/DecodedFrame.php | 2 +- src/{ => Old}/Process/Socket/Web/PDU/Msg.php | 2 +- .../Process/Socket/Web/PDU/WSResource.php | 2 +- .../Socket/Web/SocketWebServerHandler.php | 2 +- .../Process/Socket/Web/ThreadWebSocket.php | 10 +- .../Process/Socket/Web/WebSocketProtocol.php | 6 +- src/{ => Old}/Process/ThreadDaemon.php | 22 +- src/{ => Old}/Process/ThreadJob.php | 8 +- src/{ => Old}/Process/ThreadProcess.php | 10 +- .../Process/Traits/ThreadDaemonFork.php | 2 +- .../Process/Traits/ThreadDaemonHandler.php | 2 +- .../Process/Traits/ThreadDaemonStatement.php | 12 +- src/{ => Old}/Process/Traits/ThreadFork.php | 2 +- .../Process/Traits/ThreadJobHandler.php | 2 +- .../Process/Traits/ThreadProcessHandler.php | 2 +- .../Process/Traits/ThreadSignalHandler.php | 2 +- src/Ppa/Pool/PpaConnectionPool.php | 22 + src/{Dev => }/Process/Activity.php | 2 +- src/Process/Daemon/Daemon.php | 417 +++++++++++ src/Process/Daemon/DaemonConfigException.php | 14 + .../Daemon}/DaemonStatus.php | 17 +- .../Daemon/RestartMode.php} | 9 +- src/Process/Daemon/RestartPolicy.php | 52 ++ src/Process/Daemon/ScalingPolicy.php | 48 ++ src/Process/Daemon/Slot.php | 43 ++ src/Process/Daemon/SlotState.php | 68 ++ src/Process/Daemon/SupervisesFleet.php | 693 ++++++++++++++++++ src/Process/Daemon/WorkerStatus.php | 44 ++ src/{Dev => }/Process/Engine/Engines.php | 3 +- .../Process/Engine/ProcessEngine.php | 6 +- src/{Dev => }/Process/Engine/SwooleEngine.php | 39 +- src/{Dev => }/Process/Engine/SyncEngine.php | 29 +- src/Process/ForkReset.php | 61 ++ src/Process/Internal/SingletonLock.php | 63 ++ .../Process/InterruptedException.php | 2 +- src/{Dev => }/Process/Process.php | 213 ++++-- .../ProcessAlreadyRunningException.php | 2 +- src/{Dev => }/Process/ProcessRunnable.php | 8 +- src/{Dev => }/Process/ProcessState.php | 2 +- src/{Dev => }/Process/ProcessStatus.php | 7 +- src/{Dev => }/Process/ProcessStore.php | 11 +- src/{Dev => }/Process/ResourceUsage.php | 4 +- src/Stereotype/Daemon.php | 2 +- src/Stereotype/Job.php | 2 +- src/Stereotype/Process.php | 2 +- src/Stereotype/WebSocket.php | 2 +- tests/Process/ActivityTest.php | 33 + tests/Process/Daemon/DaemonStatusTest.php | 72 ++ tests/Process/Daemon/DaemonTest.php | 163 ++++ tests/Process/Daemon/RestartModeTest.php | 43 ++ tests/Process/Daemon/RestartPolicyTest.php | 56 ++ tests/Process/Daemon/ScalingPolicyTest.php | 56 ++ tests/Process/Daemon/SlotStateTest.php | 67 ++ tests/Process/Daemon/SlotTest.php | 49 ++ tests/Process/Daemon/SupervisesFleetTest.php | 224 ++++++ tests/Process/Daemon/WorkerStatusTest.php | 61 ++ tests/Process/ExceptionsTest.php | 42 ++ .../Process/Fixtures/AutoscaleLoopDaemon.php | 52 ++ tests/Process/Fixtures/BlankDaemon.php | 16 + tests/Process/Fixtures/BusyIdleDaemon.php | 26 + tests/Process/Fixtures/ClampDaemon.php | 27 + tests/Process/Fixtures/CrashCapDaemon.php | 29 + tests/Process/Fixtures/CrashLoopDaemon.php | 29 + tests/Process/Fixtures/DefaultDaemon.php | 18 + tests/Process/Fixtures/ExternalDaemon.php | 16 + tests/Process/Fixtures/HungLoopDaemon.php | 33 + tests/Process/Fixtures/InlineDaemon.php | 76 ++ tests/Process/Fixtures/LoopDaemon.php | 31 + tests/Process/Fixtures/LoopWorker.php | 22 + tests/Process/Fixtures/NeverCrashDaemon.php | 29 + tests/Process/Fixtures/SampleProcess.php | 17 + tests/Process/Fixtures/SignalProcess.php | 57 ++ tests/Process/Fixtures/StubDaemon.php | 25 + tests/Process/Fixtures/StuckStopDaemon.php | 25 + tests/Process/Fixtures/TitledProcess.php | 19 + tests/Process/Fixtures/WorkerClassDaemon.php | 18 + tests/Process/ForkResetTest.php | 73 ++ .../Integration/DaemonIntegrationTest.php | 233 ++++++ tests/Process/Integration/IntegrationCase.php | 144 ++++ .../ProcessSignalIntegrationTest.php | 114 +++ tests/Process/ProcessStateTest.php | 37 + tests/Process/ProcessStatusTest.php | 68 ++ tests/Process/ProcessTest.php | 70 ++ tests/Process/ResourceUsageTest.php | 58 ++ 124 files changed, 5210 insertions(+), 583 deletions(-) create mode 100644 console/Command/Daemon.php create mode 100644 dev/main/Process/AutoscaleDaemon.php create mode 100644 dev/main/Process/FleetDaemon.php create mode 100644 dev/main/Process/HungDaemon.php create mode 100644 dev/main/Process/NeverDaemon.php create mode 100644 dev/main/Process/SendProc.php create mode 100644 dev/main/Te1.php create mode 100644 docs/process/daemon/00-overview.md create mode 100644 docs/process/daemon/01-workers.md create mode 100644 docs/process/daemon/02-autoscaling.md create mode 100644 docs/process/daemon/03-control.md delete mode 100644 src/Dev/Process/Daemon.php delete mode 100644 src/Dev/Process/Supervisor/Supervisor.php rename src/{ => Old}/Process/Core/DaemonStore.php (91%) rename src/{ => Old}/Process/Core/Dispatch.php (97%) rename src/{ => Old}/Process/Core/DispatchStore.php (91%) rename src/{ => Old}/Process/Core/Dispatchable.php (86%) rename src/{ => Old}/Process/Core/WinterRunner.php (97%) rename src/{ => Old}/Process/DaemonException.php (85%) rename src/{ => Old}/Process/Entity/TCondition.php (79%) rename src/{ => Old}/Process/Entity/TDInfo.php (78%) rename src/{ => Old}/Process/Entity/TDStatus.php (89%) rename src/{ => Old}/Process/Entity/TInfo.php (77%) rename src/{ => Old}/Process/Entity/TStats.php (96%) rename src/{ => Old}/Process/Entity/TStatus.php (87%) rename src/{ => Old}/Process/Socket/Web/PDU/DecodedFrame.php (74%) rename src/{ => Old}/Process/Socket/Web/PDU/Msg.php (88%) rename src/{ => Old}/Process/Socket/Web/PDU/WSResource.php (94%) rename src/{ => Old}/Process/Socket/Web/SocketWebServerHandler.php (93%) rename src/{ => Old}/Process/Socket/Web/ThreadWebSocket.php (96%) rename src/{ => Old}/Process/Socket/Web/WebSocketProtocol.php (97%) rename src/{ => Old}/Process/ThreadDaemon.php (84%) rename src/{ => Old}/Process/ThreadJob.php (70%) rename src/{ => Old}/Process/ThreadProcess.php (66%) rename src/{ => Old}/Process/Traits/ThreadDaemonFork.php (99%) rename src/{ => Old}/Process/Traits/ThreadDaemonHandler.php (97%) rename src/{ => Old}/Process/Traits/ThreadDaemonStatement.php (92%) rename src/{ => Old}/Process/Traits/ThreadFork.php (99%) rename src/{ => Old}/Process/Traits/ThreadJobHandler.php (93%) rename src/{ => Old}/Process/Traits/ThreadProcessHandler.php (97%) rename src/{ => Old}/Process/Traits/ThreadSignalHandler.php (92%) rename src/{Dev => }/Process/Activity.php (93%) create mode 100644 src/Process/Daemon/Daemon.php create mode 100644 src/Process/Daemon/DaemonConfigException.php rename src/{Dev/Process => Process/Daemon}/DaemonStatus.php (61%) rename src/{Dev/Process/RestartPolicy.php => Process/Daemon/RestartMode.php} (77%) create mode 100644 src/Process/Daemon/RestartPolicy.php create mode 100644 src/Process/Daemon/ScalingPolicy.php create mode 100644 src/Process/Daemon/Slot.php create mode 100644 src/Process/Daemon/SlotState.php create mode 100644 src/Process/Daemon/SupervisesFleet.php create mode 100644 src/Process/Daemon/WorkerStatus.php rename src/{Dev => }/Process/Engine/Engines.php (91%) rename src/{Dev => }/Process/Engine/ProcessEngine.php (91%) rename src/{Dev => }/Process/Engine/SwooleEngine.php (90%) rename src/{Dev => }/Process/Engine/SyncEngine.php (90%) create mode 100644 src/Process/ForkReset.php create mode 100644 src/Process/Internal/SingletonLock.php rename src/{Dev => }/Process/InterruptedException.php (94%) rename src/{Dev => }/Process/Process.php (65%) rename src/{Dev => }/Process/ProcessAlreadyRunningException.php (90%) rename src/{Dev => }/Process/ProcessRunnable.php (72%) rename src/{Dev => }/Process/ProcessState.php (94%) rename src/{Dev => }/Process/ProcessStatus.php (85%) rename src/{Dev => }/Process/ProcessStore.php (59%) rename src/{Dev => }/Process/ResourceUsage.php (97%) create mode 100644 tests/Process/ActivityTest.php create mode 100644 tests/Process/Daemon/DaemonStatusTest.php create mode 100644 tests/Process/Daemon/DaemonTest.php create mode 100644 tests/Process/Daemon/RestartModeTest.php create mode 100644 tests/Process/Daemon/RestartPolicyTest.php create mode 100644 tests/Process/Daemon/ScalingPolicyTest.php create mode 100644 tests/Process/Daemon/SlotStateTest.php create mode 100644 tests/Process/Daemon/SlotTest.php create mode 100644 tests/Process/Daemon/SupervisesFleetTest.php create mode 100644 tests/Process/Daemon/WorkerStatusTest.php create mode 100644 tests/Process/ExceptionsTest.php create mode 100644 tests/Process/Fixtures/AutoscaleLoopDaemon.php create mode 100644 tests/Process/Fixtures/BlankDaemon.php create mode 100644 tests/Process/Fixtures/BusyIdleDaemon.php create mode 100644 tests/Process/Fixtures/ClampDaemon.php create mode 100644 tests/Process/Fixtures/CrashCapDaemon.php create mode 100644 tests/Process/Fixtures/CrashLoopDaemon.php create mode 100644 tests/Process/Fixtures/DefaultDaemon.php create mode 100644 tests/Process/Fixtures/ExternalDaemon.php create mode 100644 tests/Process/Fixtures/HungLoopDaemon.php create mode 100644 tests/Process/Fixtures/InlineDaemon.php create mode 100644 tests/Process/Fixtures/LoopDaemon.php create mode 100644 tests/Process/Fixtures/LoopWorker.php create mode 100644 tests/Process/Fixtures/NeverCrashDaemon.php create mode 100644 tests/Process/Fixtures/SampleProcess.php create mode 100644 tests/Process/Fixtures/SignalProcess.php create mode 100644 tests/Process/Fixtures/StubDaemon.php create mode 100644 tests/Process/Fixtures/StuckStopDaemon.php create mode 100644 tests/Process/Fixtures/TitledProcess.php create mode 100644 tests/Process/Fixtures/WorkerClassDaemon.php create mode 100644 tests/Process/ForkResetTest.php create mode 100644 tests/Process/Integration/DaemonIntegrationTest.php create mode 100644 tests/Process/Integration/IntegrationCase.php create mode 100644 tests/Process/Integration/ProcessSignalIntegrationTest.php create mode 100644 tests/Process/ProcessStateTest.php create mode 100644 tests/Process/ProcessStatusTest.php create mode 100644 tests/Process/ProcessTest.php create mode 100644 tests/Process/ResourceUsageTest.php diff --git a/console/Command/Complete.php b/console/Command/Complete.php index 0b043a3..23db885 100644 --- a/console/Command/Complete.php +++ b/console/Command/Complete.php @@ -10,9 +10,10 @@ use Flytachi\Winter\K2\Collector\ImplementorCollector; use Flytachi\Winter\K2\Collector\SubclassCollector; use Flytachi\Winter\K2\Core\ClassScanner; -use Flytachi\Winter\K2\Dev\Process\Process as ProcessUnit; -use Flytachi\Winter\K2\Process\Core\Dispatchable; -use Flytachi\Winter\K2\Process\ThreadDaemon; +use Flytachi\Winter\K2\Process\Daemon\Daemon as DaemonUnit; +use Flytachi\Winter\K2\Process\Process as ProcessUnit; +use Flytachi\Winter\K2\Old\Process\Core\Dispatchable; +use Flytachi\Winter\K2\Old\Process\ThreadDaemon; class Complete extends Cmd { @@ -109,6 +110,11 @@ class Complete extends Cmd 'list:list all processes with live state', ], + // --- daemon / dmn --- + 'daemon' => [ + 'list:list all daemons with live state', + ], + // --- db --- 'db' => [ 'ping:check DB connection and latency', @@ -228,6 +234,27 @@ private function suggest(?string $cmd, ?string $sub, ?string $act, string $curre $base = array_merge($this->getProcessClasses(), $base); } + // daemon: list + classes at top level; once a class is selected, + // suggest lifecycle actions, then flags per action. + if ($resolved === 'daemon' && $sub !== null && $sub !== 'list') { + if ($act === null) { + $base = [ + 'start:supervise (foreground; -d for background)', + 'stop:graceful stop (drains the fleet)', + 'status:show status + worker fleet', + '-d:supervise detached in background', + ]; + } elseif ($act === 'status') { + $base = ['-v:also master resource usage']; + } elseif ($act === 'start') { + $base = ['-d:supervise detached in background']; + } else { + $base = []; + } + } elseif ($resolved === 'daemon' && $sub === null) { + $base = array_merge($this->getDaemonUnitClasses(), $base); + } + // help: suggest command names if ($resolved === 'help' && $sub === null) { $base = $this->getCommandNames(); @@ -304,6 +331,22 @@ private function getProcessClasses(): array $collector = new SubclassCollector(ProcessUnit::class); ClassScanner::scan($collector); + $bare = array_filter( + $collector->getResult(), + static fn(\ReflectionClass $ref) => !$ref->isSubclassOf(DaemonUnit::class) + ); + + return array_map( + fn(\ReflectionClass $ref) => str_replace('\\', '.', $ref->getName()), + $bare + ); + } + + private function getDaemonUnitClasses(): array + { + $collector = new SubclassCollector(DaemonUnit::class); + ClassScanner::scan($collector); + return array_map( fn(\ReflectionClass $ref) => str_replace('\\', '.', $ref->getName()), $collector->getResult() diff --git a/console/Command/Daemon.php b/console/Command/Daemon.php new file mode 100644 index 0000000..74c1880 --- /dev/null +++ b/console/Command/Daemon.php @@ -0,0 +1,323 @@ +args['arguments']) > 1) { + $this->resolution(); + } else { + self::help(); + } + + self::printTitle("Daemon", 35); + } + + private function resolution(): void + { + $input = $this->args['arguments'][1]; + if ($input === 'list') { + $this->listArg(); + return; + } + + $class = $this->resolveClass($input); + $name = basename(str_replace('\\', '/', $class)); + + if (!class_exists($class)) { + self::printWarning("Class '$name' not found."); + self::printInfo("Resolved: $class"); + self::printInfo("Run 'call daemon list' to see available daemons."); + return; + } + if (!is_subclass_of($class, DaemonUnit::class)) { + self::printWarning("Class '$name' is not a Daemon."); + self::printInfo("Resolved: $class"); + self::printInfo("Bare processes: 'call process " . str_replace('\\', '.', $class) . " ...'."); + return; + } + + match (strtolower($this->args['arguments'][2] ?? '')) { + 'start' => $this->startArg($class), + 'stop' => $this->stopArg($class), + 'status' => $this->statusArg($class, in_array('v', $this->args['flags'])), + '' => $this->startArg($class), + default => self::printWarning("Unknown action (use start|stop|status)."), + }; + } + + /** + * @param class-string $class + */ + private function startArg(string $class): void + { + $info = $class::status(); + if ($info) { + self::printWarning("Already running [PID:{$info->pid}] ({$info->getStartedAt()})."); + return; + } + + if (in_array('d', $this->args['flags'])) { + $pid = $class::dispatch(); + $info = null; + for ($i = 0; $i < 20 && $info === null; $i++) { + usleep(50_000); + $info = $class::status(); + } + self::printSuccess("Dispatched (background): $class"); + self::printKeyValue("PID", (string) ($info->pid ?? $pid), 12, 35, 32); + return; + } + + self::printInfo("Supervising: $class"); + $class::start(); + self::printSuccess("Finished: $class"); + } + + /** + * @param class-string $class + */ + private function stopArg(string $class): void + { + $info = $class::status(); + if (!$info) { + self::printWarning("Daemon is not running."); + return; + } + if ($class::stop()) { + self::printSuccess("Stop signal sent: $class"); + self::printKeyValue("PID", (string) $info->pid, 12, 35, 32); + } else { + self::printWarning("Failed to signal daemon."); + } + } + + /** + * @param class-string $class + */ + private function statusArg(string $class, bool $detailed): void + { + $dot = str_replace('\\', '.', $class); + $info = $class::status($detailed); + + self::printLabel("Daemon Status", 35); + + if (!$info) { + self::printBadge($dot, '○ STOPPED', 35, 31); + self::printInfo("The daemon is not running."); + self::printLabel("Daemon Status", 35); + return; + } + + self::printBadge($dot, 'Daemon ● ' . $info->state->name, 35, 32); + self::printDivider(); + self::printKeyValue("PID", (string) $info->pid, 12, 35, 36); + self::printKeyValue("State", $info->state->name, 12, 35, 36); + self::printKeyValue( + "Activity", + $info->activity->name, + 12, + 35, + $info->activity === Activity::BUSY ? 33 : 90 + ); + self::printKeyValue("Started", $info->getStartedAt(), 12, 35, 36); + self::printKeyValue("Uptime", $this->formatDuration(time() - $info->startedAt), 12, 35, 36); + if ($info->concurrency > 0) { + self::printKeyValue("Concurrency", (string) $info->concurrency, 12, 35, 36); + } + if ($info instanceof DaemonStatus) { + self::printKeyValue("Workers", (string) count($info->workers), 12, 35, 36); + self::printKeyValue("Restarts", (string) $info->restarts, 12, 35, 36); + } + + if ($info instanceof DaemonStatus && $info->workers !== []) { + $this->printWorkers($info->workers); + } + + self::printLabel("Daemon Status", 35); + } + + /** + * @param array $workers + */ + private function printWorkers(array $workers): void + { + self::printDivider(); + self::printLabel("Workers (" . count($workers) . ")", 35); + self::print( + sprintf(" %-6s %-8s %-11s %-6s %-9s %s", 'SLOT', 'PID', 'STATE', 'ACT', 'UPTIME', 'RESTARTS'), + 90 + ); + foreach ($workers as $w) { + $pid = $w->pid > 0 ? (string) $w->pid : '—'; + $uptime = $w->startedAt > 0 ? $this->formatDuration(time() - $w->startedAt) : '—'; + $line = sprintf( + " #%-5d %-8s %-11s %-6s %-9s %d", + $w->slot, + $pid, + $w->state->value, + $w->activity->value, + $uptime, + $w->restarts, + ); + self::print($line, $this->stateColor($w->state)); + } + } + + private function stateColor(SlotState $state): int + { + return match ($state) { + SlotState::RUNNING => 32, // green + SlotState::STARTING => 36, // cyan + SlotState::RETIRING => 33, // yellow + SlotState::KILLING => 31, // red + SlotState::RESTARTING => 35, // magenta + SlotState::RETIRED => 90, // dim + SlotState::EMPTY => 90, // dim + }; + } + + private function listArg(): void + { + $collector = new SubclassCollector(DaemonUnit::class); + ClassScanner::scan($collector); + $daemons = $collector->getResult(); + + self::printLabel("Available Daemons", 35); + if (empty($daemons)) { + self::printWarning("No Daemon classes found."); + self::printInfo("Create one that extends Daemon."); + self::printLabel("Available Daemons", 35); + return; + } + + $running = 0; + foreach ($daemons as $ref) { + if ($this->printRow($ref->getName())) { + $running++; + } + } + + self::printDivider(); + self::printInfo(count($daemons) . " defined, {$running} running."); + self::printLabel("Available Daemons", 35); + } + + /** + * Renders one daemon as a padded, colour-coded row. Returns whether it runs. + * + * @param class-string $class + */ + private function printRow(string $class): bool + { + $dot = str_replace('\\', '.', $class); + $info = $class::status(); + + echo "\033[35m" . str_pad(" |\t [D] {$dot} ", 72, '.') . " "; + if (!$info) { + echo "\033[31m[○ STOPPED]\033[0m\n"; + return false; + } + + $uptime = $this->formatDuration(time() - $info->startedAt); + $workers = $info instanceof DaemonStatus ? "\033[36m [w:" . count($info->workers) . "]" : ''; + echo "\033[32m[● {$info->state->name}]" + . $this->activityTag($info->activity) + . $workers + . "\033[90m {$uptime}\033[0m\n"; + + return true; + } + + /** + * Colour-coded activity label: BUSY is highlighted, IDLE is dim. + */ + private function activityTag(Activity $activity): string + { + return $activity === Activity::BUSY + ? "\033[33m [BUSY]" + : "\033[90m [idle]"; + } + + /** + * Dot/dashed notation → FQCN, e.g. `main.daemon.Emails` → `Main\Daemon\Emails`. + */ + private function resolveClass(string $input): string + { + return str_replace( + '/', + '\\', + implode('/', array_map( + fn($word) => ucfirst($word), + explode('/', str_replace('.', '/', $input)) + )) + ); + } + + /** + * Human-readable duration, e.g. 90061 → "1d 1h". + */ + private function formatDuration(int $seconds): string + { + $seconds = max(0, $seconds); + $units = ['d' => 86400, 'h' => 3600, 'm' => 60, 's' => 1]; + + $parts = []; + foreach ($units as $suffix => $size) { + $value = intdiv($seconds, $size); + $seconds %= $size; + if ($value > 0) { + $parts[] = $value . $suffix; + } + } + + return $parts === [] ? '0s' : implode(' ', array_slice($parts, 0, 2)); + } + + public static function help(): void + { + $cl = 35; + self::printTitle("Daemon Help", $cl); + + self::printLabel("Usage", $cl); + self::print("call daemon [action] -[flags]", $cl); + self::print("call dmn [action] -[flags] (alias)", $cl); + self::printLabel("Usage", $cl); + + self::printLabel("Commands", $cl); + self::printBadge('list', 'list all daemons with live state + worker count', $cl, 36); + self::printBadge('', 'supervise in foreground (default)', $cl, 36); + self::printBadge(' start -d', 'supervise detached in background', $cl, 36); + self::printBadge(' stop', 'graceful stop (drains the whole fleet)', $cl, 36); + self::printBadge(' status', 'status + per-worker fleet table', $cl, 36); + self::printBadge(' status -v', 'also master resource usage', $cl, 36); + self::printLabel("Commands", $cl); + + self::printDivider($cl); + self::printInfo("Worker states: running / starting / retiring / killing / restarting"); + self::printInfo("Bare processes are managed by 'call process'."); + + self::printTitle("Daemon Help", $cl); + } +} diff --git a/console/Command/Process.php b/console/Command/Process.php index 0a65d27..fde077c 100644 --- a/console/Command/Process.php +++ b/console/Command/Process.php @@ -7,12 +7,13 @@ use Flytachi\Winter\Console\Inc\Cmd; use Flytachi\Winter\K2\Collector\SubclassCollector; use Flytachi\Winter\K2\Core\ClassScanner; -use Flytachi\Winter\K2\Dev\Process\Activity; -use Flytachi\Winter\K2\Dev\Process\Daemon as DaemonUnit; -use Flytachi\Winter\K2\Dev\Process\DaemonStatus; -use Flytachi\Winter\K2\Dev\Process\Process as ProcessUnit; -use Flytachi\Winter\K2\Dev\Process\ResourceUsage; +use Flytachi\Winter\K2\Process\Activity; +use Flytachi\Winter\K2\Process\Daemon\Daemon as DaemonUnit; +use Flytachi\Winter\K2\Process\Process as ProcessUnit; +/** + * Manages bare {@see ProcessUnit} units. Daemons are managed by `call daemon`. + */ class Process extends Cmd { public static string $title = "manage Process units (start/stop/status)"; @@ -52,6 +53,11 @@ private function resolution(): void self::printInfo("Resolved: $class"); return; } + if (is_subclass_of($class, DaemonUnit::class)) { + self::printWarning("Class '$name' is a Daemon, not a bare Process."); + self::printInfo("Use 'call daemon " . str_replace('\\', '.', $class) . " ...' instead."); + return; + } match (strtolower($this->args['arguments'][2] ?? '')) { 'start' => $this->startArg($class), @@ -127,8 +133,7 @@ private function statusArg(string $class, bool $detailed): void return; } - $isDaemon = is_subclass_of($class, DaemonUnit::class); - self::printBadge($dot, ($isDaemon ? 'Daemon ' : 'Process ') . '● ' . $info->state->name, 34, 32); + self::printBadge($dot, 'Process ● ' . $info->state->name, 34, 32); self::printDivider(); self::printKeyValue("PID", (string) $info->pid, 12, 34, 36); self::printKeyValue("State", $info->state->name, 12, 34, 36); @@ -144,10 +149,6 @@ private function statusArg(string $class, bool $detailed): void if ($info->concurrency > 0) { self::printKeyValue("Concurrency", (string) $info->concurrency, 12, 34, 36); } - if ($info instanceof DaemonStatus) { - self::printKeyValue("Workers", (string) count($info->workers), 12, 34, 36); - self::printKeyValue("Restarts", (string) $info->restarts, 12, 34, 36); - } if ($detailed && $info->usage) { $u = $info->usage; @@ -166,18 +167,6 @@ private function statusArg(string $class, bool $detailed): void self::printKeyValue("Elapsed", $u->elapsed, 12, 34, 35); } - if ($detailed && $info instanceof DaemonStatus && $info->workers !== []) { - self::printDivider(); - self::printLabel("Workers (" . count($info->workers) . ")", 34); - foreach ($info->workers as $wpid) { - $ws = ResourceUsage::ofPid($wpid); - $line = $ws - ? sprintf("#%-7d cpu %s%% rss %s MB", $wpid, $ws->cpu, round($ws->rssMb(), 1)) - : sprintf("#%-7d (gone)", $wpid); - self::print($line, 36); - } - } - self::printLabel("Process Status", 34); } @@ -185,21 +174,22 @@ private function listArg(): void { $collector = new SubclassCollector(ProcessUnit::class); ClassScanner::scan($collector); - $processes = $collector->getResult(); + $processes = array_filter( + $collector->getResult(), + static fn($ref) => !$ref->isSubclassOf(DaemonUnit::class) + ); self::printLabel("Available Processes", 34); if (empty($processes)) { self::printWarning("No Process classes found."); - self::printInfo("Create one that extends Process."); + self::printInfo("Create one that extends Process. (Daemons: 'call daemon list'.)"); self::printLabel("Available Processes", 34); return; } $running = 0; foreach ($processes as $ref) { - $class = $ref->getName(); - $isDaemon = $ref->isSubclassOf(DaemonUnit::class); - if ($this->printRow($class, $isDaemon)) { + if ($this->printRow($ref->getName())) { $running++; } } @@ -214,13 +204,12 @@ private function listArg(): void * * @param class-string $class */ - private function printRow(string $class, bool $isDaemon): bool + private function printRow(string $class): bool { $dot = str_replace('\\', '.', $class); - $tag = $isDaemon ? 'D' : 'P'; $info = $class::status(); - echo "\033[34m" . str_pad(" |\t [{$tag}] {$dot} ", 72, '.') . " "; + echo "\033[34m" . str_pad(" |\t [P] {$dot} ", 72, '.') . " "; if (!$info) { echo "\033[31m[○ STOPPED]\033[0m\n"; return false; @@ -229,7 +218,6 @@ private function printRow(string $class, bool $isDaemon): bool $uptime = $this->formatDuration(time() - $info->startedAt); echo "\033[32m[● {$info->state->name}]" . $this->activityTag($info->activity) - . ($info instanceof DaemonStatus ? "\033[36m [w:" . count($info->workers) . "]" : '') . "\033[90m {$uptime}\033[0m\n"; return true; @@ -291,31 +279,16 @@ public static function help(): void self::printLabel("Usage", $cl); self::printLabel("Commands", $cl); - self::printBadge('list', 'list all processes with live state', $cl, 36); + self::printBadge('list', 'list all bare processes with live state', $cl, 36); self::printBadge('', 'start in foreground (default)', $cl, 36); - self::printBadge(' start', 'start in foreground', $cl, 36); self::printBadge(' start -d', 'start detached in background', $cl, 36); self::printBadge(' stop', 'send graceful stop signal (SIGTERM)', $cl, 36); - self::printBadge(' status', 'show status', $cl, 36); - self::printBadge(' status -v', 'detailed: resources + workers', $cl, 36); + self::printBadge(' status -v', 'detailed: resource usage', $cl, 36); self::printLabel("Commands", $cl); - self::printLabel("Flags", $cl); - self::printKeyValue("-d", "start detached in background", 10, $cl, 36); - self::printKeyValue("-v", "verbose status (resource usage)", 10, $cl, 36); - self::printLabel("Flags", $cl); - - self::printDivider($cl); - - self::printLabel("Examples", $cl); - self::printInfo("call process list"); - self::printInfo("call process main.process.Consumer -d"); - self::printInfo("call process main.process.Consumer status -v"); - self::printInfo("call proc main.process.Consumer stop"); - self::printLabel("Examples", $cl); - self::printDivider($cl); - self::printInfo("Row tags: [P] process, [D] daemon | ● running, ○ stopped | [BUSY]/[idle]"); + self::printInfo("Daemons (supervised fleets) are managed by 'call daemon'."); + self::printInfo("Row tags: [P] process | ● running, ○ stopped | [BUSY]/[idle]"); self::printTitle("Process Help", $cl); } diff --git a/console/Command/Thread.php b/console/Command/Thread.php index 1c6a8d3..cb890e6 100644 --- a/console/Command/Thread.php +++ b/console/Command/Thread.php @@ -8,11 +8,11 @@ use Flytachi\Winter\K2\Collector\ImplementorCollector; use Flytachi\Winter\K2\Collector\SubclassCollector; use Flytachi\Winter\K2\Core\ClassScanner; -use Flytachi\Winter\K2\Process\Core\Dispatchable; -use Flytachi\Winter\K2\Process\DaemonException; -use Flytachi\Winter\K2\Process\ThreadDaemon; -use Flytachi\Winter\K2\Process\ThreadJob; -use Flytachi\Winter\K2\Process\ThreadProcess; +use Flytachi\Winter\K2\Old\Process\Core\Dispatchable; +use Flytachi\Winter\K2\Old\Process\DaemonException; +use Flytachi\Winter\K2\Old\Process\ThreadDaemon; +use Flytachi\Winter\K2\Old\Process\ThreadJob; +use Flytachi\Winter\K2\Old\Process\ThreadProcess; class Thread extends Cmd { diff --git a/console/Core.php b/console/Core.php index 534581e..87b1974 100644 --- a/console/Core.php +++ b/console/Core.php @@ -13,6 +13,7 @@ class Core extends CoreHandle 'sc' => 'Script', 'th' => 'Thread', 'proc' => 'Process', + 'dmn' => 'Daemon', ]; public function __construct($args) diff --git a/dev/main/Process/AutoscaleDaemon.php b/dev/main/Process/AutoscaleDaemon.php new file mode 100644 index 0000000..bf5c493 --- /dev/null +++ b/dev/main/Process/AutoscaleDaemon.php @@ -0,0 +1,58 @@ +bootAt === 0.0) { + $this->bootAt = microtime(true); + } + $elapsed = microtime(true) - $this->bootAt; + + if ($elapsed < 3.0) { + return 1; + } + if ($elapsed < 8.0) { + return 5; + } + return 2; + } + + protected function onScale(int $from, int $to): void + { + $this->logger->info("AutoscaleDaemon scaled {$from} → {$to}"); + } + + protected function workerRun(): void + { + while ($this->isRunning()) { + $this->sleep(0.5); + } + } +} diff --git a/dev/main/Process/ConsumerDemo.php b/dev/main/Process/ConsumerDemo.php index b8fc5ef..e2f8818 100644 --- a/dev/main/Process/ConsumerDemo.php +++ b/dev/main/Process/ConsumerDemo.php @@ -4,8 +4,8 @@ namespace Main\Process; -use Flytachi\Winter\K2\Dev\Process\InterruptedException; -use Flytachi\Winter\K2\Dev\Process\Process; +use Flytachi\Winter\K2\Process\InterruptedException; +use Flytachi\Winter\K2\Process\Process; /** * Consumer-style demo: IDLE wait, then a BUSY unit. Proves drain-to-idle — a diff --git a/dev/main/Process/CrashDaemon.php b/dev/main/Process/CrashDaemon.php index 620933c..8942ec1 100644 --- a/dev/main/Process/CrashDaemon.php +++ b/dev/main/Process/CrashDaemon.php @@ -4,21 +4,25 @@ namespace Main\Process; -use Flytachi\Winter\K2\Dev\Process\Daemon; -use Flytachi\Winter\K2\Dev\Process\RestartPolicy; +use Flytachi\Winter\K2\Process\Daemon\Daemon; +use Flytachi\Winter\K2\Process\Daemon\RestartMode; +use Flytachi\Winter\K2\Process\Daemon\RestartPolicy; /** * Worker crashes after a couple of ticks. Exercises ON_FAILURE restart with - * exponential back-off and the maxRestarts ceiling → FAILED. + * exponential back-off (slot reused, worker#{n} stable) and the maxRestarts + * ceiling → FAILED. */ class CrashDaemon extends Daemon { protected int $replicas = 1; - protected RestartPolicy $restart = RestartPolicy::ON_FAILURE; - protected int $maxRestarts = 3; - protected float $backoff = 0.5; - public function run(): void + protected function restart(): RestartPolicy + { + return new RestartPolicy(mode: RestartMode::ON_FAILURE, maxRestarts: 3, backoff: 0.5); + } + + protected function workerRun(): void { $this->logger->info('CrashDaemon worker START pid=' . $this->pid); $this->sleep(0.6); diff --git a/dev/main/Process/DemoProcess.php b/dev/main/Process/DemoProcess.php index 8be7938..78c7124 100644 --- a/dev/main/Process/DemoProcess.php +++ b/dev/main/Process/DemoProcess.php @@ -4,7 +4,7 @@ namespace Main\Process; -use Flytachi\Winter\K2\Dev\Process\Process; +use Flytachi\Winter\K2\Process\Process; /** * One-shot demo: dispatch 6 tasks with a concurrency cap of 2 and exit. diff --git a/dev/main/Process/FleetDaemon.php b/dev/main/Process/FleetDaemon.php new file mode 100644 index 0000000..e76e94f --- /dev/null +++ b/dev/main/Process/FleetDaemon.php @@ -0,0 +1,30 @@ +logger->info('FleetDaemon worker#' . ($slot + 1) . " start pid={$pid}"); + } + + protected function onWorkerExit(int $slot, int $pid, bool $crashed): void + { + $this->logger->info( + 'FleetDaemon worker#' . ($slot + 1) . " exit pid={$pid} crashed=" . ($crashed ? '1' : '0') + ); + } +} diff --git a/dev/main/Process/HungDaemon.php b/dev/main/Process/HungDaemon.php new file mode 100644 index 0000000..ffd2a62 --- /dev/null +++ b/dev/main/Process/HungDaemon.php @@ -0,0 +1,35 @@ +logger->info('HungDaemon worker START pid=' . $this->pid); + $this->sleep(1.0); + $this->logger->warning('HungDaemon worker WEDGING now pid=' . $this->pid); + while (true) { + // Deadlock simulation: blocks the reactor, so no heartbeat lands. + } + } +} diff --git a/dev/main/Process/LongDemo.php b/dev/main/Process/LongDemo.php index bd91307..64888db 100644 --- a/dev/main/Process/LongDemo.php +++ b/dev/main/Process/LongDemo.php @@ -4,7 +4,7 @@ namespace Main\Process; -use Flytachi\Winter\K2\Dev\Process\Process; +use Flytachi\Winter\K2\Process\Process; /** * Long-lived demo: ticks until stopped. Exercises running()/sleep() and the diff --git a/dev/main/Process/NeverDaemon.php b/dev/main/Process/NeverDaemon.php new file mode 100644 index 0000000..046740c --- /dev/null +++ b/dev/main/Process/NeverDaemon.php @@ -0,0 +1,30 @@ +logger->info('NeverDaemon worker START pid=' . $this->pid); + $this->sleep(0.5); + throw new \RuntimeException('boom (never restarts)'); + } +} diff --git a/dev/main/Process/SendProc.php b/dev/main/Process/SendProc.php new file mode 100644 index 0000000..0ddd3bd --- /dev/null +++ b/dev/main/Process/SendProc.php @@ -0,0 +1,33 @@ +info('SendProc afterFork pid=' . getmypid()); + } + + public function run(): void + { + $this->logger->info('SendProc worker run pid=' . $this->pid); + while ($this->isRunning()) { + $this->markBusy(); + $this->sleep(0.4); + $this->markIdle(); + $this->sleep(0.4); + } + $this->logger->info('SendProc worker exit pid=' . $this->pid); + } +} diff --git a/dev/main/Process/SignalDemo.php b/dev/main/Process/SignalDemo.php index 866d071..6856215 100644 --- a/dev/main/Process/SignalDemo.php +++ b/dev/main/Process/SignalDemo.php @@ -4,8 +4,8 @@ namespace Main\Process; -use Flytachi\Winter\K2\Dev\Process\InterruptedException; -use Flytachi\Winter\K2\Dev\Process\Process; +use Flytachi\Winter\K2\Process\InterruptedException; +use Flytachi\Winter\K2\Process\Process; /** * Reference of the signal contract with canonical PSR-3 log levels. diff --git a/dev/main/Process/StableDaemon.php b/dev/main/Process/StableDaemon.php index e2ce19c..fb78604 100644 --- a/dev/main/Process/StableDaemon.php +++ b/dev/main/Process/StableDaemon.php @@ -4,20 +4,18 @@ namespace Main\Process; -use Flytachi\Winter\K2\Dev\Process\Daemon; -use Flytachi\Winter\K2\Dev\Process\RestartPolicy; +use Flytachi\Winter\K2\Process\Daemon\Daemon; /** * Long-lived worker that loops until stopped. Exercises the graceful stop of a - * supervised daemon: SIGTERM to the supervisor → workers signalled → clean exit, - * no restart. + * supervised daemon: SIGTERM to the supervisor → whole fleet drained → clean + * exit, no restart. Inline body via workerRun(). */ class StableDaemon extends Daemon { protected int $replicas = 2; - protected RestartPolicy $restart = RestartPolicy::ON_FAILURE; - public function run(): void + protected function workerRun(): void { $this->logger->info('StableDaemon worker START pid=' . $this->pid); $tick = 0; diff --git a/dev/main/Te1.php b/dev/main/Te1.php new file mode 100644 index 0000000..531a8ff --- /dev/null +++ b/dev/main/Te1.php @@ -0,0 +1,11 @@ + 0`) +or an external `kill -9`. (The "second signal forces now" behaviour belongs to a +[Daemon](daemon/03-control.md#stopping-the-fleet), whose supervisor collapses the +drain deadline on a repeat signal.) --- diff --git a/docs/process/03-control.md b/docs/process/03-control.md index 4a8c2f6..c21a49a 100644 --- a/docs/process/03-control.md +++ b/docs/process/03-control.md @@ -120,7 +120,7 @@ call process list # every process, with live state call process main.EmailDispatchWorker # start in the foreground (blocks the shell) call process main.EmailDispatchWorker start -d # start detached in the background call process main.EmailDispatchWorker status # the status card -call process main.EmailDispatchWorker status -v # + resource usage (and, for a daemon, its workers) +call process main.EmailDispatchWorker status -v # + live resource usage (CPU/memory) call process main.EmailDispatchWorker stop # graceful stop ``` @@ -229,8 +229,9 @@ are always the real process; you never target the wrong thing. [01-lifecycle.md](01-lifecycle.md): it stops taking new work, lets the current unit finish, drains its in-flight `spawn()`ed tasks, runs `onShutdown()`, and exits. How long that takes is bounded by the worker's `$grace` — `0` means it waits for the -drain for as long as the drain needs. If you cannot wait, a second `stop`, or an -external `kill -9`, forces the process down at once, skipping the drain. +drain for as long as the drain needs. A second `stop` on a bare process is ignored; +if you cannot wait, an external `kill -9` forces it down at once, and a `grace > 0` +gives it its own hard ceiling. Operating a *fleet* of workers — starting and stopping individual instances by their activity so that a `BUSY` one is never the one you remove, and scaling the diff --git a/docs/process/daemon/00-overview.md b/docs/process/daemon/00-overview.md new file mode 100644 index 0000000..2e9f63e --- /dev/null +++ b/docs/process/daemon/00-overview.md @@ -0,0 +1,107 @@ +# Winter Daemon — Overview + +A [Process](../00-overview.md) is **one** managed worker: one body, one PID, alive +until you stop it. That is enough for a single consumer or a single scheduled job. +It is not enough when one worker cannot keep up, when a crash must not take the +work down with it, or when the load rises and falls through the day and you want +the number of workers to follow it. + +**Daemon** is the answer to all three. A daemon is the same `Process` body, but run +as a **fleet of identical worker replicas** kept alive by a **master supervisor**. +The master does no work itself — it forks workers, watches them, restarts the ones +that die, and grows or shrinks the fleet to match demand. The mental model is +exactly `nginx` or `php-fpm`: a `master process` over a pool of `worker process`es; +or a Kubernetes Deployment whose controller keeps a set of pods at the size you +declared. + +``` +winter-daemon: EmailDispatch master ← supervisor (does NOT run the body) +├── winter-daemon: EmailDispatch worker#1 ← a Process worker +├── winter-daemon: EmailDispatch worker#2 +└── winter-daemon: EmailDispatch worker#3 +``` + +You write config plus a body; the daemon supplies everything hard about running a +fleet: the two-tier process tree, forking and reaping, restart with back-off, a +graceful stop that drains the whole fleet, autoscaling with damping, and a +crash-safe singleton lock. As with `Process`, the same code runs whether the +workers are Swoole coroutine processes or plain forks. + +## Two ways to give the daemon its body + +The manager and the unit of work are separate concerns (like an executor and its +task), so the worker body is supplied in one of two ways. The daemon picks the +first that applies: + +**1. Inline — `workerRun()`.** Define this method and the daemon *is* the worker; +its body runs in every replica. Simplest, one file: + +```php +final class EmailDispatch extends Daemon +{ + #[Autowired] private MailQueue $queue; + + protected int $replicas = 3; + + protected function workerRun(): void + { + while ($this->isRunning()) { + $job = $this->queue->pop(timeout: 1.0); + if ($job === null) { + continue; + } + $this->markBusy(); + $this->send($job); + $this->markIdle(); + } + } +} +``` + +**2. External — `$workerClass`.** Point at a standalone [`Process`](../00-overview.md) +class. The worker is then reusable on its own (run it solo with `SendProcess::start()`) +*and* supervisable under the daemon: + +```php +final class EmailDispatch extends Daemon +{ + protected int $replicas = 3; + protected ?string $workerClass = SendProcess::class; +} +``` + +If neither is provided the master still starts, but each worker fails on fork with +a `DaemonConfigException` (there is no body to run). The priority is fixed: +**`workerRun()` first, `$workerClass` second.** See +[Workers](01-workers.md) for how a worker inherits DI across the fork and the +`afterFork()` seam that keeps its connections safe. + +## What the master does for you + +Everything below the body is the supervisor's job — you never write it: + +| Concern | Handled by the master | +|---|---| +| Keep N workers alive | forks replicas, reaps exits | +| A worker crashed | restart per [`RestartPolicy`](02-autoscaling.md#restartpolicy) with exponential back-off, into the same slot | +| Load changed | drive the fleet to [`desiredReplicas()`](02-autoscaling.md), damped by [`ScalingPolicy`](02-autoscaling.md#scalingpolicy) | +| Stop requested | drain the whole fleet gracefully, then exit ([Control](03-control.md#stopping-the-fleet)) | +| One instance per class | crash-safe `flock` singleton | +| Observe the fleet | per-worker status (`call daemon … status`) | + +## When a daemon, when a bare process + +- **Bare [`Process`](../00-overview.md)** — a single coordinator, a leader-elected + singleton, one long loop. Not a fleet. +- **`Daemon`** — several identical workers that must stay alive, scale, or survive + crashes independently. A queue consumer pool, an SMPP bind pool, a fan-out of + pollers. + +## Where to go next + +- **[Workers](01-workers.md)** — the two body forms, DI and fork-safety + (`afterFork()` / `ForkReset`), `spawn()` inside a worker. +- **[Autoscaling & restart](02-autoscaling.md)** — `desiredReplicas()`, the + `ScalingPolicy` damping model, `RestartPolicy`, and the master hooks. +- **[Control](03-control.md)** — `start` / `dispatch` / `status` / `stop`, the CLI, + the stop sequence, and the per-worker fleet view. diff --git a/docs/process/daemon/01-workers.md b/docs/process/daemon/01-workers.md new file mode 100644 index 0000000..e752a60 --- /dev/null +++ b/docs/process/daemon/01-workers.md @@ -0,0 +1,107 @@ +# Daemon Workers + +A daemon worker is an ordinary [`Process`](../00-overview.md) body — it has the +same primitives (`isRunning()`, `sleep()`, `spawn()`, `markBusy()` / `markIdle()`) +and the same signal hooks. This page covers what is specific to running that body +*under a supervisor*: how the body is chosen, how it is created, what it inherits +across the fork, and the one seam you need when it holds a connection. + +## Two body forms, fixed priority + +The daemon resolves its worker body in this order: + +1. **`workerRun(): void`** — if you define it, the daemon itself is the worker and + this method is the body. Its primitives come from `Process` by inheritance. +2. **`$workerClass`** — otherwise, if set, the daemon supervises instances of that + standalone `Process` class. +3. Neither → **`DaemonConfigException`** at start. + +Use `workerRun()` for a self-contained daemon (one file); use `$workerClass` when +the worker is worth having on its own — it stays independently testable and +runnable (`SendProcess::start()`), and the same class can be supervised by more +than one daemon. + +Both forms produce the same process title, `winter-daemon: worker#{n}`, +where `n` is one-based (`worker#1` is slot #0) and stable across restarts — the +per-worker fleet table shows the underlying zero-based `SLOT`. + +## How a worker is created: fork, not Thread + +The supervisor is a plain `pcntl` loop with **no event loop running**, so it forks +each worker with `pcntl_fork()`. Forking before any reactor starts is safe — the +child then boots its own clean Swoole coroutine runtime (or a plain fork runtime +without Swoole). Fork is the right tool here, not the [Thread](../../../vendor/flytachi/winter-thread/docs/README.md) +launcher, because supervision needs the direct parent↔child relationship: exact +`waitpid` exit codes, reaping, and per-slot signalling. (Thread's detached launch +re-parents to init and *loses* that relationship; it is used one level up, to send +the **whole daemon** to the background — see [Control](03-control.md#background-dispatch).) + +## Dependency injection across the fork + +A worker inherits the master's memory copy-on-write, so it already has the same DI +container, the same singletons, the same configuration — `#[Autowired]` is resolved +and `Container::make()` works. For `$workerClass`, the worker is resolved with +`Container::make()` *in the child* (after the fork); for `workerRun()`, the daemon +instance itself carries the body. + +### The one caveat: fork-unsafe resources + +A fork copies file descriptors. A DB connection, a pool, a socket held in the +container becomes a **shared descriptor** across the master and every worker — +using it from more than one corrupts the protocol. The rule is simple: + +> **Open connections in the child, after the fork.** + +Two mechanisms make that automatic: + +- **`ForkReset`** — framework packages register a reset at bootstrap (a pool + registers a *reconnect*). The runtime runs every registered reset in the child + before the body. You write nothing; workers get fresh connections. + + ```php + // inside a package's bootstrap + ForkReset::register(fn() => Db::pool()->reconnect()); + ``` + +- **`afterFork()`** — override it to reset your *own* resources the framework does + not know about. Call the parent first: + + ```php + protected function afterFork(): void + { + parent::afterFork(); // runs the registered ForkReset handlers + $this->grpc = null; // drop the inherited channel; reopen lazily in the body + } + ``` + +A reset must **reconnect in place** (close the old fd, open a new one on the same +object) rather than replace the object — otherwise an already-injected reference +keeps pointing at the stale instance. A lazily-opened resource needs no handler at +all: there is nothing to reset until the child first uses it. + +`afterFork()` runs only in a forked worker; a bare foreground `Process` is never +forked and never runs it. + +## `spawn()` inside a worker + +A worker can still use `spawn()` for I/O concurrency *within itself*, exactly as a +bare process does — bounded by `$concurrency`. The mechanism follows the runtime: + +| Runtime | `spawn()` in a worker | +|---|---| +| **Swoole** | real coroutines sharing the worker's memory (and its freshly-reset pool) | +| **FPM / no Swoole** | a `pcntl_fork` per task — isolated child processes, fire-and-forget | + +So a Swoole worker fans out coroutines that share its connections; a fork-runtime +worker fans out isolated child processes (its own grandchildren, which it reaps). +See [Process — Concurrency](../02-concurrency.md) for the full model. + +## Worker state and scale-down + +A worker reports its activity — `IDLE` or `BUSY` — through `markBusy()` / +`markIdle()` (inline units) and in-flight `spawn()` count. The supervisor reads +that heartbeat to build the [fleet view](03-control.md#the-per-worker-view) and, +crucially, to **retire IDLE workers first** when scaling down — so a scale-down +never interrupts work in progress. A BUSY worker chosen for retirement still +drains gracefully; the ordering only decides who goes first. You do not manage any +of this; you only mark your units of work. diff --git a/docs/process/daemon/02-autoscaling.md b/docs/process/daemon/02-autoscaling.md new file mode 100644 index 0000000..09b3998 --- /dev/null +++ b/docs/process/daemon/02-autoscaling.md @@ -0,0 +1,148 @@ +# Autoscaling & Restart + +The master's job is to keep the fleet at the right size and to heal it. Two +policies shape that — one for *how big* the fleet should be, one for *what to do +when a worker dies* — plus a set of hooks for reacting to fleet events. All are +optional: the defaults run a fixed fleet that restarts on failure. + +## Fixed size — `$replicas` + +The simplest daemon just declares a size: + +```php +final class Emails extends Daemon +{ + protected int $replicas = 3; // keep three workers alive +} +``` + +The supervisor forks three workers, restarts any that crash, and keeps the count +at three. Nothing else is needed. + +## Dynamic size — `desiredReplicas()` + +Override `desiredReplicas()` to make the fleet follow load. It returns *how many +workers should run right now*; the supervisor drives the fleet toward it. This is +a declarative target (like a Kubernetes replica count), not an imperative command — +you say the number, the controller reconciles reality to it. + +```php +protected function desiredReplicas(): int +{ + // one worker per 100 queued messages, capped at 16 + return min(16, max(1, intdiv($this->queue->depth(), 100))); +} +``` + +`desiredReplicas()` runs on the master about once per `scaleInterval`. It is a +plain method — read a queue depth, a metric, a config value. The default returns +`$replicas`. + +## Stability over speed — `ScalingPolicy` + +`desiredReplicas()` is a **signal, not a command**. If it flickers — a naive or +noisy implementation — reacting to every reading would thrash the fleet. The +supervisor damps it, and the damping is asymmetric, the way mature autoscalers do +it (Kubernetes HPA, cloud auto-scaling groups): **scale up quickly, scale down only +when low demand is sustained.** + +Three knobs, on three different axes, tuned by a `ScalingPolicy`: + +| Knob | Default | Axis — what it controls | +|---|---|---| +| `scaleDownStabilization` | `60.0` s | *whether* to shrink — low demand must hold this long (shrink only to the high-water demand over the window) | +| `scaleStep` | `0` (∞) | *how many* workers change per action — a gentle ramp, not a cliff | +| `cooldown` | `3.0` s | *how often* an action may happen | +| `scaleUpDelay` | `0.0` s | a rise must be sustained this long before scaling up (0 = at once) | +| `scaleInterval` | `1.0` s | how often `desiredReplicas()` / `tick()` are polled | + +**Why `scaleDownStabilization` matters.** Say the fleet is at 10 and demand dips to +4 for a moment, then jumps back to 9. Without the window you would kill 6 workers, +then scramble to spawn them again — a saw-tooth. With it, scale-down uses the +**maximum** demand over the window, so a transient dip sheds nothing; the fleet +shrinks only after low demand has held for the whole window. + +**Why `scaleStep` matters.** Going from 10 to 2 all at once drops capacity off a +cliff (and going 2 → 40 forks a thundering herd). `scaleStep: 2` moves at most two +workers per action, ramping gradually in either direction. + +The defaults are tuned for a daemon — most never touch them. To tune, override +`scaling()`; the object is immutable and non-`final`, so you can also define reusable +named profiles: + +```php +// one-off +protected function scaling(): ScalingPolicy +{ + return new ScalingPolicy(scaleDownStabilization: 120.0, scaleStep: 2); +} + +// reusable profile +final class ConservativeScaling extends ScalingPolicy +{ + public function __construct() + { + parent::__construct(scaleDownStabilization: 300.0, cooldown: 30.0, scaleStep: 1); + } +} +``` + +Scale-down is always **graceful**: chosen workers are sent SIGTERM and drain to +exit (IDLE ones first, so work in progress is never cut off). See +[Control — Stopping the fleet](03-control.md#stopping-the-fleet) for the drain +deadline that bounds it. + +## Restart — `RestartPolicy` + +When a worker dies *unexpectedly* (not a scale-down, not a stop), the restart +policy decides what happens. It groups three knobs: + +```php +protected function restart(): RestartPolicy +{ + return new RestartPolicy( + mode: RestartMode::ALWAYS, // ALWAYS | ON_FAILURE | NEVER + maxRestarts: 0, // give up after N restarts across the fleet (0 = unlimited) + backoff: 1.0, // base seconds, exponential + ); +} +``` + +- **`RestartMode`** — `ALWAYS` (keep the worker alive on any exit), `ON_FAILURE` + (restart only on a crash / non-zero exit; a clean exit is final — the default), + `NEVER`. +- **`maxRestarts`** — a ceiling on total restarts across the fleet; exceeding it + puts the daemon in the `FAILED` state and stops it. +- **`backoff`** — exponential back-off between restarts (`base × 2^(n-1)`, capped), + so a crash-looping worker does not spin. + +A restart re-forks into the **same slot**, so `worker#{n}` stays stable across a +worker's lifetime. Critically, an intentionally retired worker (scale-down or stop) +is **never** restarted — the supervisor tracks each slot's intent, so the restart +policy and the autoscaler never fight over it. + +## Master hooks + +Optional callbacks on the master for reacting to fleet events and for imperative +periodic work. All run on the supervisor, not in a worker: + +| Hook | Fires | +|---|---| +| `tick()` | about once per `scaleInterval` — poll metrics, drive custom logic | +| `onWorkerStart(int $slot, int $pid)` | a worker was forked | +| `onWorkerExit(int $slot, int $pid, bool $crashed)` | a worker exited (`$crashed` = abnormal exit of a live worker) | +| `onScale(int $from, int $to)` | the fleet size changed | +| `onReload()` | SIGHUP reached the master | + +`tick()` and `desiredReplicas()` are the two periodic seams, and they differ by +intent: `desiredReplicas()` is **declarative** (return a number, the supervisor +reconciles), `tick()` is **imperative** (do whatever — poll an API, refresh a +config). `onScale()` is the *notification* that a change happened; it is not where +the decision is made. + +```php +protected function onScale(int $from, int $to): void +{ + $this->logger->info("scaled {$from} → {$to}"); +} +``` diff --git a/docs/process/daemon/03-control.md b/docs/process/daemon/03-control.md new file mode 100644 index 0000000..4c3c2cd --- /dev/null +++ b/docs/process/daemon/03-control.md @@ -0,0 +1,128 @@ +# Controlling a Daemon + +A daemon is started, observed and stopped exactly like a [`Process`](../03-control.md) — +the same four verbs — but everything reports the *fleet*: the master plus its +workers. This page covers the control surface, the CLI, the stop sequence, and the +per-worker view. + +## The control surface + +```php +Emails::start(); // supervise in the foreground (blocks) +$pid = Emails::dispatch(); // supervise detached in the background → master PID +$info = Emails::status(); // ?DaemonStatus (null when not running) +Emails::stop(); // graceful stop — drains the whole fleet +``` + +`start()` and `dispatch()` are refused if the daemon is already running: a daemon +is a **singleton per class**, guarded by a crash-safe `flock` the master holds for +its lifetime. `status()` returns a `DaemonStatus` — a `ProcessStatus` plus the +fleet: `restarts` and a `workers[]` snapshot. It is a pure read that never mutates +the record, and it works across users (liveness via `posix_getpgid`). + +## The CLI — `call daemon` + +Daemons have their own command, separate from `call process` (bare processes): + +``` +call daemon list # every daemon, with live state + worker count +call daemon main.daemon.Emails # supervise in the foreground +call daemon main.daemon.Emails start -d # supervise detached in the background +call daemon main.daemon.Emails status # status + the per-worker fleet table +call daemon main.daemon.Emails status -v # also the master's resource usage +call daemon main.daemon.Emails stop # graceful stop +call dmn main.daemon.Emails stop # 'dmn' is the alias +``` + +`call daemon list` shows only daemons; bare processes live under `call process`. + +### The per-worker view + +`status` renders the fleet the supervisor sees — one row per slot, so you can tell +at a glance why the fleet is the size it is: + +``` +[ Workers (3) ] + SLOT PID STATE ACT UPTIME RESTARTS + #0 41201 running busy 12m03s 0 + #1 41202 running idle 12m03s 0 + #2 41230 retiring busy 45s 1 +``` + +`STATE` is the slot's authoritative lifecycle (`starting` / `running` / `retiring` +/ `killing` / `restarting` / `retired`); `ACT` is the worker's own `IDLE` / `BUSY` +heartbeat, about a second behind. A `retiring` row explains why the live process +count can exceed the active fleet — that worker is draining on its way out; a +`retired` row is a death the restart policy declined to replace (a `NEVER` worker, +or a clean exit). + +## Background dispatch + +`dispatch()` (CLI `-d`) sends the **whole daemon** to the background via the Thread +launcher: the master detaches, re-parents to init, and keeps running after the +shell closes. The call returns the master PID, and — because the detached +double-fork reports an intermediate PID — the CLI briefly polls the store for the +real master PID before printing it. Once the master is up, it forks its own workers +with `pcntl` (see [Workers](01-workers.md#how-a-worker-is-created-fork-not-thread)). + +``` +CLI: Emails::dispatch() ──Thread──► master (PPID=1) in the background + │ pcntl_fork ×N + ├──► worker#1 + ├──► worker#2 + └──► worker#3 +``` + +## Stopping the fleet + +`stop()` sends SIGTERM to the master, and the master runs a single, ordered stop +sequence — a full stop is simply "retire the whole fleet at once", reusing the same +graceful drain the autoscaler uses: + +1. **Freeze the autoscaler first.** The daemon enters `STOPPING`; reconcile no + longer spawns or restarts anything. This is what stops the restart policy from + resurrecting workers as they exit. +2. **Drain every worker in parallel.** SIGTERM goes to all workers at once; each + drains to idle on its own body (finishing its current unit, refusing new work), + then exits. +3. **Force stragglers.** A worker that outlives its drain deadline is SIGKILLed. + The deadline is the daemon's `$grace` (`0` = wait forever). A **second** stop + signal collapses every deadline to *now* — the operator's "stop now", like a + second `Ctrl+C`. +4. **Tear down.** Once the fleet is empty the master runs `onShutdown()`, removes + its store record, releases the `flock`, and exits `TERMINATED`. + +No worker is orphaned (the master waits for the fleet) and none is restarted (the +autoscaler is frozen). Set `$grace > 0` for a bounded shutdown; leave it `0` to +wait indefinitely for a clean drain. + +## Process titles + +Every process in the tree shares the `winter-daemon:` prefix, so the whole family +is visible — and killable — together: + +``` +winter-daemon: Emails master +winter-daemon: Emails worker#1 +winter-daemon: Emails worker#2 +``` + +```bash +pkill -f 'winter-daemon: Emails' # the entire fleet +``` + +The name is `$processTitle` if set, otherwise the daemon's short class name. + +## Lifecycle states + +The master's `DaemonStatus.state` is one of: + +| State | Meaning | +|---|---| +| `RUNNING` | supervising the fleet | +| `STOPPING` | draining the fleet on a stop request | +| `TERMINATED` | the fleet drained and the master exited | +| `FAILED` | `maxRestarts` was exceeded; the daemon gave up and stopped | + +See [Autoscaling & restart](02-autoscaling.md) for what drives `FAILED`, and +[Workers](01-workers.md) for the body and its fork-safety. diff --git a/phpunit.xml b/phpunit.xml index 5331cb6..acd4746 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -26,6 +26,10 @@ tests/Integration tests/Integration/Fixtures + + tests/Process + tests/Process/Fixtures + diff --git a/src/BaseBoot.php b/src/BaseBoot.php index 78b99e4..6c2d0d3 100644 --- a/src/BaseBoot.php +++ b/src/BaseBoot.php @@ -18,7 +18,7 @@ use Flytachi\Winter\K2\Http\Adapter\SwooleResponse; use Flytachi\Winter\K2\Http\Contracts\HttpResponse; use Flytachi\Winter\K2\Http\Response\ExceptionWrapper; -use Flytachi\Winter\K2\Process\Core\WinterRunner; +use Flytachi\Winter\K2\Old\Process\Core\WinterRunner; use Flytachi\Winter\K2\Route\MemoryWatcher; use Flytachi\Winter\K2\Route\Router; use Flytachi\Winter\Logger\LoggerFactory; diff --git a/src/Dev/Process/Daemon.php b/src/Dev/Process/Daemon.php deleted file mode 100644 index b0bdd84..0000000 --- a/src/Dev/Process/Daemon.php +++ /dev/null @@ -1,140 +0,0 @@ -rabbit->connect(); - * while ($this->isRunning()) { - * $msg = $ch->get(); - * if ($msg === null) { $this->sleep(0.2); continue; } - * $this->spawn(fn() => $this->handle($msg)); - * } - * $ch->close(); - * } - * } - * ``` - */ -abstract class Daemon extends Process -{ - /** Number of identical workers to keep running. */ - protected int $replicas = 1; - /** When to restart a worker after it exits. */ - protected RestartPolicy $restart = RestartPolicy::ON_FAILURE; - /** Give up after this many restarts (0 = unlimited). */ - protected int $maxRestarts = 0; - /** Base seconds for exponential back-off between restarts. */ - protected float $backoff = 1.0; - - public function replicas(): int - { - return max(1, $this->replicas); - } - - public function restartPolicy(): RestartPolicy - { - return $this->restart; - } - - public function maxRestarts(): int - { - return $this->maxRestarts; - } - - public function backoffBase(): float - { - return $this->backoff; - } - - /** - * Launches the supervisor in the foreground, registering it in the store so - * {@see status()} / {@see stop()} reach it from another terminal. - */ - final public static function start(): void - { - static::ensureNotRunning(); - - /** @var static $self */ - $self = Container::getInstance()->make(static::class); - $self->supervise(); - } - - private function supervise(): void - { - if (!$this->acquireLock()) { - LoggerFactory::getLogger(static::class)->notice( - static::class . ' is already running; not starting a second supervisor.' - ); - return; - } - - $this->pid = getmypid(); - $this->logger = LoggerFactory::getLogger(static::class); - - $store = static::store(); - $key = static::key(); - $startedAt = time(); - - $write = function (ProcessState $state, int $restarts, array $workers) use ($store, $key, $startedAt): void { - $store->write($key, new DaemonStatus( - pid: $this->pid, - className: static::class, - state: $state, - activity: $workers === [] ? Activity::IDLE : Activity::BUSY, - startedAt: $startedAt, - concurrency: $this->concurrency, - restarts: $restarts, - workers: $workers, - )); - }; - - $write(ProcessState::RUNNING, 0, []); - - // Backstop the finally below against a forced/fatal exit that skips it. - register_shutdown_function(static fn() => $store->del($key)); - - try { - $final = (new Supervisor())->run( - $this, - fn() => $this->runWorker(), - fn(int $restarts, array $workers) => $write(ProcessState::RUNNING, $restarts, $workers), - ); - - if ($final === ProcessState::FAILED) { - $this->logger->critical('Daemon reached maxRestarts; giving up.'); - } - } catch (\Throwable $e) { - $this->logger->critical( - $e->getMessage() - . (env('DEBUG', false) ? "\n" . $e->getTraceAsString() : '') - ); - } finally { - $store->del($key); - $this->releaseLock(); - } - } -} diff --git a/src/Dev/Process/Supervisor/Supervisor.php b/src/Dev/Process/Supervisor/Supervisor.php deleted file mode 100644 index afd7963..0000000 --- a/src/Dev/Process/Supervisor/Supervisor.php +++ /dev/null @@ -1,214 +0,0 @@ - Live worker PIDs. */ - private array $workers = []; - - /** - * Runs the supervision loop until every worker is done or a stop signal - * arrives. Returns the terminal state. - * - * SIGTERM/SIGINT stop the daemon (workers are stopped, no restart). SIGHUP is - * forwarded to the workers — a "reload" that does not stop the supervisor. - * - * @param Daemon $daemon Daemon supplying policy (replicas, restart, limits). - * @param callable $worker Worker body run in each forked child. - * @param callable $onChange Called with (int $restarts, array $workerPids) whenever the set changes. - */ - public function run(Daemon $daemon, callable $worker, callable $onChange): ProcessState - { - pcntl_async_signals(true); - pcntl_signal(SIGTERM, fn() => $this->stop = true); - pcntl_signal(SIGINT, fn() => $this->stop = true); - // Control signals are forwarded to the workers, leaving the supervisor up. - pcntl_signal(SIGHUP, fn() => $this->forwardToWorkers(SIGHUP)); - pcntl_signal(SIGUSR1, fn() => $this->forwardToWorkers(SIGUSR1)); - pcntl_signal(SIGUSR2, fn() => $this->forwardToWorkers(SIGUSR2)); - - $replicas = $daemon->replicas(); - $policy = $daemon->restartPolicy(); - $maxRestarts = $daemon->maxRestarts(); - $base = max(0.0, $daemon->backoffBase()); - - $this->workers = []; - for ($i = 0; $i < $replicas; $i++) { - $this->workers[$this->spawn($worker)] = true; - } - - $restarts = 0; - $failures = 0; - $onChange($restarts, array_keys($this->workers)); - - while (!$this->stop) { - $pid = pcntl_waitpid(-1, $status, WNOHANG); - if ($pid <= 0) { - usleep(100_000); - pcntl_signal_dispatch(); - continue; - } - - unset($this->workers[$pid]); - $crashed = !pcntl_wifexited($status) || pcntl_wexitstatus($status) !== 0; - - if ($this->stop) { - break; - } - - if (!$policy->shouldRestart($crashed)) { - if ($this->workers === []) { - return ProcessState::TERMINATED; - } - $onChange($restarts, array_keys($this->workers)); - continue; - } - - $restarts++; - $failures = $crashed ? $failures + 1 : 0; - - if ($maxRestarts > 0 && $restarts >= $maxRestarts) { - $this->stopAll(); - return ProcessState::FAILED; - } - - $this->interruptibleSleep($this->backoff($base, $failures)); - if ($this->stop) { - break; - } - - $this->workers[$this->spawn($worker)] = true; - $onChange($restarts, array_keys($this->workers)); - } - - $this->stopAll(); - return ProcessState::TERMINATED; - } - - /** - * Forwards a control signal (HUP/USR1/USR2) to every worker, leaving the - * supervisor running. Each worker's hook decides what it means. - */ - private function forwardToWorkers(int $signo): void - { - foreach (array_keys($this->workers) as $pid) { - posix_kill($pid, $signo); - } - } - - /** - * Forks a worker. In the child, inherited signal handlers are reset so the - * worker's own runtime installs its own — then the body runs and its outcome - * maps to the exit code. - */ - private function spawn(callable $worker): int - { - $pid = pcntl_fork(); - if ($pid === 0) { - // Drop inherited handlers; the worker's engine installs its own. - pcntl_signal(SIGTERM, SIG_DFL); - pcntl_signal(SIGINT, SIG_DFL); - pcntl_signal(SIGHUP, SIG_DFL); - pcntl_signal(SIGUSR1, SIG_DFL); - pcntl_signal(SIGUSR2, SIG_DFL); - try { - $worker(); - exit(0); - } catch (\Throwable) { - // The worker logs its own failure; the non-zero code is the signal. - exit(1); - } - } - return $pid; - } - - /** - * Exponential back-off, capped. - */ - private function backoff(float $base, int $failures): float - { - if ($base <= 0.0 || $failures <= 0) { - return 0.0; - } - return min($base * (2 ** ($failures - 1)), self::BACKOFF_CAP); - } - - /** - * Sleeps while staying responsive to a stop signal. - */ - private function interruptibleSleep(float $seconds): void - { - $remaining = $seconds; - while ($remaining > 0 && !$this->stop) { - usleep((int) (min($remaining, 0.1) * 1_000_000)); - pcntl_signal_dispatch(); - $remaining -= 0.1; - } - } - - /** - * Signals every worker to stop gracefully, then SIGKILLs any that outlast the - * grace window — so a blocked worker can never hang the supervisor. - */ - private function stopAll(): void - { - if ($this->workers === []) { - return; - } - - foreach (array_keys($this->workers) as $pid) { - posix_kill($pid, SIGTERM); - } - - $deadline = microtime(true) + self::STOP_GRACE; - while ($this->workers !== [] && microtime(true) < $deadline) { - $this->reap(); - if ($this->workers !== []) { - usleep(100_000); - pcntl_signal_dispatch(); - } - } - - foreach (array_keys($this->workers) as $pid) { - posix_kill($pid, SIGKILL); - pcntl_waitpid($pid, $status); - unset($this->workers[$pid]); - } - } - - /** - * Reaps any workers that have already exited, without blocking. - */ - private function reap(): void - { - foreach (array_keys($this->workers) as $pid) { - if (pcntl_waitpid($pid, $status, WNOHANG) !== 0) { - unset($this->workers[$pid]); - } - } - } -} diff --git a/src/Kernel.php b/src/Kernel.php index f01d30c..8e7fbd9 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -6,6 +6,8 @@ use Flytachi\Winter\Base\Runtime; use Flytachi\Winter\K2\Core\KernelStore; +use Flytachi\Winter\K2\Process\ForkReset; +use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; use Flytachi\Winter\Thread\Launch\AdaptiveLauncher; use Flytachi\Winter\Thread\Thread; use Flytachi\Winter\Logger\Context\ProcessContext; @@ -68,6 +70,10 @@ public static function init( secret: env('WINTER_KEY', ''), runnerPath: self::threadRunnerPath(), )); + + // fork-safety — a forked daemon worker inherits the parent's DB sockets; + // reset the pool in the child (Process::afterFork) so it reconnects fresh. + ForkReset::register(static fn() => PpaConnectionPool::reset()); } private static function bootLogger(): void diff --git a/src/Process/Core/DaemonStore.php b/src/Old/Process/Core/DaemonStore.php similarity index 91% rename from src/Process/Core/DaemonStore.php rename to src/Old/Process/Core/DaemonStore.php index 129a6a0..25a92d3 100644 --- a/src/Process/Core/DaemonStore.php +++ b/src/Old/Process/Core/DaemonStore.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Core; +namespace Flytachi\Winter\K2\Old\Process\Core; use Flytachi\FileStore\FileStorage; use Flytachi\Winter\K2\Kernel; diff --git a/src/Process/Core/Dispatch.php b/src/Old/Process/Core/Dispatch.php similarity index 97% rename from src/Process/Core/Dispatch.php rename to src/Old/Process/Core/Dispatch.php index 72ee977..62dc641 100644 --- a/src/Process/Core/Dispatch.php +++ b/src/Old/Process/Core/Dispatch.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Core; +namespace Flytachi\Winter\K2\Old\Process\Core; use Flytachi\Winter\DI\Container; use Flytachi\Winter\Logger\LoggerFactory; diff --git a/src/Process/Core/DispatchStore.php b/src/Old/Process/Core/DispatchStore.php similarity index 91% rename from src/Process/Core/DispatchStore.php rename to src/Old/Process/Core/DispatchStore.php index f128218..d71ca5a 100644 --- a/src/Process/Core/DispatchStore.php +++ b/src/Old/Process/Core/DispatchStore.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Core; +namespace Flytachi\Winter\K2\Old\Process\Core; use Flytachi\Winter\K2\Kernel; diff --git a/src/Process/Core/Dispatchable.php b/src/Old/Process/Core/Dispatchable.php similarity index 86% rename from src/Process/Core/Dispatchable.php rename to src/Old/Process/Core/Dispatchable.php index 35663d4..25d0ffa 100644 --- a/src/Process/Core/Dispatchable.php +++ b/src/Old/Process/Core/Dispatchable.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Core; +namespace Flytachi\Winter\K2\Old\Process\Core; use Flytachi\Winter\Thread\Runnable; diff --git a/src/Process/Core/WinterRunner.php b/src/Old/Process/Core/WinterRunner.php similarity index 97% rename from src/Process/Core/WinterRunner.php rename to src/Old/Process/Core/WinterRunner.php index bc496df..9527790 100644 --- a/src/Process/Core/WinterRunner.php +++ b/src/Old/Process/Core/WinterRunner.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Core; +namespace Flytachi\Winter\K2\Old\Process\Core; use Flytachi\Winter\Logger\LoggerFactory; use Flytachi\Winter\Thread\Runnable; diff --git a/src/Process/DaemonException.php b/src/Old/Process/DaemonException.php similarity index 85% rename from src/Process/DaemonException.php rename to src/Old/Process/DaemonException.php index eea7b9d..938ee79 100644 --- a/src/Process/DaemonException.php +++ b/src/Old/Process/DaemonException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process; +namespace Flytachi\Winter\K2\Old\Process; use Flytachi\Winter\Base\Exception\ExceptionTrait; use Psr\Log\LogLevel; diff --git a/src/Process/Entity/TCondition.php b/src/Old/Process/Entity/TCondition.php similarity index 79% rename from src/Process/Entity/TCondition.php rename to src/Old/Process/Entity/TCondition.php index 88c04ee..89381b0 100644 --- a/src/Process/Entity/TCondition.php +++ b/src/Old/Process/Entity/TCondition.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Entity; +namespace Flytachi\Winter\K2\Old\Process\Entity; enum TCondition: int { diff --git a/src/Process/Entity/TDInfo.php b/src/Old/Process/Entity/TDInfo.php similarity index 78% rename from src/Process/Entity/TDInfo.php rename to src/Old/Process/Entity/TDInfo.php index fae3190..acbf10b 100644 --- a/src/Process/Entity/TDInfo.php +++ b/src/Old/Process/Entity/TDInfo.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Entity; +namespace Flytachi\Winter\K2\Old\Process\Entity; final class TDInfo { diff --git a/src/Process/Entity/TDStatus.php b/src/Old/Process/Entity/TDStatus.php similarity index 89% rename from src/Process/Entity/TDStatus.php rename to src/Old/Process/Entity/TDStatus.php index f8c080f..689f1ee 100644 --- a/src/Process/Entity/TDStatus.php +++ b/src/Old/Process/Entity/TDStatus.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Entity; +namespace Flytachi\Winter\K2\Old\Process\Entity; final class TDStatus { diff --git a/src/Process/Entity/TInfo.php b/src/Old/Process/Entity/TInfo.php similarity index 77% rename from src/Process/Entity/TInfo.php rename to src/Old/Process/Entity/TInfo.php index 722ff0e..6408c97 100644 --- a/src/Process/Entity/TInfo.php +++ b/src/Old/Process/Entity/TInfo.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Entity; +namespace Flytachi\Winter\K2\Old\Process\Entity; final class TInfo { diff --git a/src/Process/Entity/TStats.php b/src/Old/Process/Entity/TStats.php similarity index 96% rename from src/Process/Entity/TStats.php rename to src/Old/Process/Entity/TStats.php index e53a6a6..bd09b1c 100644 --- a/src/Process/Entity/TStats.php +++ b/src/Old/Process/Entity/TStats.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Entity; +namespace Flytachi\Winter\K2\Old\Process\Entity; final class TStats { diff --git a/src/Process/Entity/TStatus.php b/src/Old/Process/Entity/TStatus.php similarity index 87% rename from src/Process/Entity/TStatus.php rename to src/Old/Process/Entity/TStatus.php index 2b53a46..d7a6050 100644 --- a/src/Process/Entity/TStatus.php +++ b/src/Old/Process/Entity/TStatus.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Entity; +namespace Flytachi\Winter\K2\Old\Process\Entity; final class TStatus { diff --git a/src/Process/Socket/Web/PDU/DecodedFrame.php b/src/Old/Process/Socket/Web/PDU/DecodedFrame.php similarity index 74% rename from src/Process/Socket/Web/PDU/DecodedFrame.php rename to src/Old/Process/Socket/Web/PDU/DecodedFrame.php index d440248..892dc0e 100644 --- a/src/Process/Socket/Web/PDU/DecodedFrame.php +++ b/src/Old/Process/Socket/Web/PDU/DecodedFrame.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Socket\Web\PDU; +namespace Flytachi\Winter\K2\Old\Process\Socket\Web\PDU; readonly class DecodedFrame { diff --git a/src/Process/Socket/Web/PDU/Msg.php b/src/Old/Process/Socket/Web/PDU/Msg.php similarity index 88% rename from src/Process/Socket/Web/PDU/Msg.php rename to src/Old/Process/Socket/Web/PDU/Msg.php index 16a908b..520c8e0 100644 --- a/src/Process/Socket/Web/PDU/Msg.php +++ b/src/Old/Process/Socket/Web/PDU/Msg.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Socket\Web\PDU; +namespace Flytachi\Winter\K2\Old\Process\Socket\Web\PDU; readonly class Msg { diff --git a/src/Process/Socket/Web/PDU/WSResource.php b/src/Old/Process/Socket/Web/PDU/WSResource.php similarity index 94% rename from src/Process/Socket/Web/PDU/WSResource.php rename to src/Old/Process/Socket/Web/PDU/WSResource.php index 2d97219..e5ce673 100644 --- a/src/Process/Socket/Web/PDU/WSResource.php +++ b/src/Old/Process/Socket/Web/PDU/WSResource.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Socket\Web\PDU; +namespace Flytachi\Winter\K2\Old\Process\Socket\Web\PDU; class WSResource { diff --git a/src/Process/Socket/Web/SocketWebServerHandler.php b/src/Old/Process/Socket/Web/SocketWebServerHandler.php similarity index 93% rename from src/Process/Socket/Web/SocketWebServerHandler.php rename to src/Old/Process/Socket/Web/SocketWebServerHandler.php index 8eeb411..232a648 100644 --- a/src/Process/Socket/Web/SocketWebServerHandler.php +++ b/src/Old/Process/Socket/Web/SocketWebServerHandler.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Socket\Web; +namespace Flytachi\Winter\K2\Old\Process\Socket\Web; trait SocketWebServerHandler { diff --git a/src/Process/Socket/Web/ThreadWebSocket.php b/src/Old/Process/Socket/Web/ThreadWebSocket.php similarity index 96% rename from src/Process/Socket/Web/ThreadWebSocket.php rename to src/Old/Process/Socket/Web/ThreadWebSocket.php index 0a9fa3c..ad292f0 100644 --- a/src/Process/Socket/Web/ThreadWebSocket.php +++ b/src/Old/Process/Socket/Web/ThreadWebSocket.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Socket\Web; +namespace Flytachi\Winter\K2\Old\Process\Socket\Web; -use Flytachi\Winter\K2\Process\Core\Dispatch; -use Flytachi\Winter\K2\Process\Socket\Web\PDU\Msg; -use Flytachi\Winter\K2\Process\Socket\Web\PDU\WSResource; -use Flytachi\Winter\K2\Process\Traits\ThreadSignalHandler; +use Flytachi\Winter\K2\Old\Process\Core\Dispatch; +use Flytachi\Winter\K2\Old\Process\Socket\Web\PDU\Msg; +use Flytachi\Winter\K2\Old\Process\Socket\Web\PDU\WSResource; +use Flytachi\Winter\K2\Old\Process\Traits\ThreadSignalHandler; use Flytachi\Winter\Thread\ThreadException; abstract class ThreadWebSocket extends Dispatch diff --git a/src/Process/Socket/Web/WebSocketProtocol.php b/src/Old/Process/Socket/Web/WebSocketProtocol.php similarity index 97% rename from src/Process/Socket/Web/WebSocketProtocol.php rename to src/Old/Process/Socket/Web/WebSocketProtocol.php index 359be5d..7636666 100644 --- a/src/Process/Socket/Web/WebSocketProtocol.php +++ b/src/Old/Process/Socket/Web/WebSocketProtocol.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Socket\Web; +namespace Flytachi\Winter\K2\Old\Process\Socket\Web; -use Flytachi\Winter\K2\Process\Socket\Web\PDU\DecodedFrame; -use Flytachi\Winter\K2\Process\Socket\Web\PDU\Msg; +use Flytachi\Winter\K2\Old\Process\Socket\Web\PDU\DecodedFrame; +use Flytachi\Winter\K2\Old\Process\Socket\Web\PDU\Msg; final class WebSocketProtocol { diff --git a/src/Process/ThreadDaemon.php b/src/Old/Process/ThreadDaemon.php similarity index 84% rename from src/Process/ThreadDaemon.php rename to src/Old/Process/ThreadDaemon.php index 49f6caa..f905605 100644 --- a/src/Process/ThreadDaemon.php +++ b/src/Old/Process/ThreadDaemon.php @@ -2,20 +2,20 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process; +namespace Flytachi\Winter\K2\Old\Process; use Flytachi\FileStore\FileStorageException; use Flytachi\Winter\Base\HttpCode; -use Flytachi\Winter\K2\Process\Core\DaemonStore; -use Flytachi\Winter\K2\Process\Core\Dispatch; -use Flytachi\Winter\K2\Process\Entity\TCondition; -use Flytachi\Winter\K2\Process\Entity\TDInfo; -use Flytachi\Winter\K2\Process\Entity\TDStatus; -use Flytachi\Winter\K2\Process\Entity\TStats; -use Flytachi\Winter\K2\Process\Traits\ThreadDaemonFork; -use Flytachi\Winter\K2\Process\Traits\ThreadDaemonHandler; -use Flytachi\Winter\K2\Process\Traits\ThreadDaemonStatement; -use Flytachi\Winter\K2\Process\Traits\ThreadSignalHandler; +use Flytachi\Winter\K2\Old\Process\Core\DaemonStore; +use Flytachi\Winter\K2\Old\Process\Core\Dispatch; +use Flytachi\Winter\K2\Old\Process\Entity\TCondition; +use Flytachi\Winter\K2\Old\Process\Entity\TDInfo; +use Flytachi\Winter\K2\Old\Process\Entity\TDStatus; +use Flytachi\Winter\K2\Old\Process\Entity\TStats; +use Flytachi\Winter\K2\Old\Process\Traits\ThreadDaemonFork; +use Flytachi\Winter\K2\Old\Process\Traits\ThreadDaemonHandler; +use Flytachi\Winter\K2\Old\Process\Traits\ThreadDaemonStatement; +use Flytachi\Winter\K2\Old\Process\Traits\ThreadSignalHandler; use Flytachi\Winter\Thread\Signal; abstract class ThreadDaemon extends Dispatch diff --git a/src/Process/ThreadJob.php b/src/Old/Process/ThreadJob.php similarity index 70% rename from src/Process/ThreadJob.php rename to src/Old/Process/ThreadJob.php index 36cc73b..f88911d 100644 --- a/src/Process/ThreadJob.php +++ b/src/Old/Process/ThreadJob.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process; +namespace Flytachi\Winter\K2\Old\Process; -use Flytachi\Winter\K2\Process\Core\Dispatch; -use Flytachi\Winter\K2\Process\Traits\ThreadJobHandler; -use Flytachi\Winter\K2\Process\Traits\ThreadSignalHandler; +use Flytachi\Winter\K2\Old\Process\Core\Dispatch; +use Flytachi\Winter\K2\Old\Process\Traits\ThreadJobHandler; +use Flytachi\Winter\K2\Old\Process\Traits\ThreadSignalHandler; abstract class ThreadJob extends Dispatch { diff --git a/src/Process/ThreadProcess.php b/src/Old/Process/ThreadProcess.php similarity index 66% rename from src/Process/ThreadProcess.php rename to src/Old/Process/ThreadProcess.php index e8c5403..5354667 100644 --- a/src/Process/ThreadProcess.php +++ b/src/Old/Process/ThreadProcess.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process; +namespace Flytachi\Winter\K2\Old\Process; -use Flytachi\Winter\K2\Process\Core\Dispatch; -use Flytachi\Winter\K2\Process\Traits\ThreadFork; -use Flytachi\Winter\K2\Process\Traits\ThreadProcessHandler; -use Flytachi\Winter\K2\Process\Traits\ThreadSignalHandler; +use Flytachi\Winter\K2\Old\Process\Core\Dispatch; +use Flytachi\Winter\K2\Old\Process\Traits\ThreadFork; +use Flytachi\Winter\K2\Old\Process\Traits\ThreadProcessHandler; +use Flytachi\Winter\K2\Old\Process\Traits\ThreadSignalHandler; abstract class ThreadProcess extends Dispatch { diff --git a/src/Process/Traits/ThreadDaemonFork.php b/src/Old/Process/Traits/ThreadDaemonFork.php similarity index 99% rename from src/Process/Traits/ThreadDaemonFork.php rename to src/Old/Process/Traits/ThreadDaemonFork.php index 17e0862..932e8a5 100644 --- a/src/Process/Traits/ThreadDaemonFork.php +++ b/src/Old/Process/Traits/ThreadDaemonFork.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Traits; +namespace Flytachi\Winter\K2\Old\Process\Traits; use Flytachi\Winter\Logger\LoggerFactory; use RuntimeException; diff --git a/src/Process/Traits/ThreadDaemonHandler.php b/src/Old/Process/Traits/ThreadDaemonHandler.php similarity index 97% rename from src/Process/Traits/ThreadDaemonHandler.php rename to src/Old/Process/Traits/ThreadDaemonHandler.php index 9f00692..d015dd6 100644 --- a/src/Process/Traits/ThreadDaemonHandler.php +++ b/src/Old/Process/Traits/ThreadDaemonHandler.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Traits; +namespace Flytachi\Winter\K2\Old\Process\Traits; trait ThreadDaemonHandler { diff --git a/src/Process/Traits/ThreadDaemonStatement.php b/src/Old/Process/Traits/ThreadDaemonStatement.php similarity index 92% rename from src/Process/Traits/ThreadDaemonStatement.php rename to src/Old/Process/Traits/ThreadDaemonStatement.php index f0d7854..b15e226 100644 --- a/src/Process/Traits/ThreadDaemonStatement.php +++ b/src/Old/Process/Traits/ThreadDaemonStatement.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Traits; +namespace Flytachi\Winter\K2\Old\Process\Traits; use Flytachi\FileStore\FileStorageException; -use Flytachi\Winter\K2\Process\Entity\TCondition; -use Flytachi\Winter\K2\Process\Entity\TDStatus; -use Flytachi\Winter\K2\Process\Entity\TInfo; -use Flytachi\Winter\K2\Process\Entity\TStats; -use Flytachi\Winter\K2\Process\Entity\TStatus; +use Flytachi\Winter\K2\Old\Process\Entity\TCondition; +use Flytachi\Winter\K2\Old\Process\Entity\TDStatus; +use Flytachi\Winter\K2\Old\Process\Entity\TInfo; +use Flytachi\Winter\K2\Old\Process\Entity\TStats; +use Flytachi\Winter\K2\Old\Process\Entity\TStatus; trait ThreadDaemonStatement { diff --git a/src/Process/Traits/ThreadFork.php b/src/Old/Process/Traits/ThreadFork.php similarity index 99% rename from src/Process/Traits/ThreadFork.php rename to src/Old/Process/Traits/ThreadFork.php index 79307fd..8331abf 100644 --- a/src/Process/Traits/ThreadFork.php +++ b/src/Old/Process/Traits/ThreadFork.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Traits; +namespace Flytachi\Winter\K2\Old\Process\Traits; use Flytachi\Winter\Logger\LoggerFactory; use RuntimeException; diff --git a/src/Process/Traits/ThreadJobHandler.php b/src/Old/Process/Traits/ThreadJobHandler.php similarity index 93% rename from src/Process/Traits/ThreadJobHandler.php rename to src/Old/Process/Traits/ThreadJobHandler.php index 60a2cee..04b5b24 100644 --- a/src/Process/Traits/ThreadJobHandler.php +++ b/src/Old/Process/Traits/ThreadJobHandler.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Traits; +namespace Flytachi\Winter\K2\Old\Process\Traits; trait ThreadJobHandler { diff --git a/src/Process/Traits/ThreadProcessHandler.php b/src/Old/Process/Traits/ThreadProcessHandler.php similarity index 97% rename from src/Process/Traits/ThreadProcessHandler.php rename to src/Old/Process/Traits/ThreadProcessHandler.php index 90ada7b..4bfdd51 100644 --- a/src/Process/Traits/ThreadProcessHandler.php +++ b/src/Old/Process/Traits/ThreadProcessHandler.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Traits; +namespace Flytachi\Winter\K2\Old\Process\Traits; trait ThreadProcessHandler { diff --git a/src/Process/Traits/ThreadSignalHandler.php b/src/Old/Process/Traits/ThreadSignalHandler.php similarity index 92% rename from src/Process/Traits/ThreadSignalHandler.php rename to src/Old/Process/Traits/ThreadSignalHandler.php index a00cacf..bc431e2 100644 --- a/src/Process/Traits/ThreadSignalHandler.php +++ b/src/Old/Process/Traits/ThreadSignalHandler.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Traits; +namespace Flytachi\Winter\K2\Old\Process\Traits; trait ThreadSignalHandler { diff --git a/src/Ppa/Pool/PpaConnectionPool.php b/src/Ppa/Pool/PpaConnectionPool.php index beb7d88..103a4d1 100644 --- a/src/Ppa/Pool/PpaConnectionPool.php +++ b/src/Ppa/Pool/PpaConnectionPool.php @@ -129,6 +129,28 @@ public static function showDbConfigs(): array return self::$configs; } + /** + * Drops every cached connection, pool and config so the next `db()` opens + * fresh sockets — the fork-safety reset. + * + * A fork copies file descriptors, so any connection cached before the fork + * would be shared with the parent and corrupt the wire protocol. A forked + * daemon worker runs this via {@see \Flytachi\Winter\K2\Process\ForkReset} + * (registered in {@see \Flytachi\Winter\K2\Kernel::init()}), then re-opens + * lazily in the child. Because access is static — repositories call + * `PpaConnectionPool::db()`, never an injected instance — clearing the caches + * is a complete "reconnect": nothing holds a stale reference. + * + * Keep connections lazy (do not query from a supervisor before it forks + * workers) so this stays a cheap no-op in the common case. + */ + public static function reset(): void + { + self::$pools = []; + self::$static = []; + self::$configs = []; + } + // ------------------------------------------------------------------------- // Internals // ------------------------------------------------------------------------- diff --git a/src/Dev/Process/Activity.php b/src/Process/Activity.php similarity index 93% rename from src/Dev/Process/Activity.php rename to src/Process/Activity.php index e5bb082..ede3dfb 100644 --- a/src/Dev/Process/Activity.php +++ b/src/Process/Activity.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Process; +namespace Flytachi\Winter\K2\Process; /** * Whether the process is doing work right now. diff --git a/src/Process/Daemon/Daemon.php b/src/Process/Daemon/Daemon.php new file mode 100644 index 0000000..eb1bdf8 --- /dev/null +++ b/src/Process/Daemon/Daemon.php @@ -0,0 +1,417 @@ +isRunning()) { + * $this->markBusy(); + * // ... work ... + * $this->markIdle(); + * } + * } + * } + * + * // external — supervise a reusable Process class + * class Emails extends Daemon { + * protected int $replicas = 3; + * protected ?string $workerClass = SendProcess::class; + * } + * ``` + * + * The whole process tree, forking/reaping, restart, drain and singleton lock are + * handled by the fleet supervision ({@see SupervisesFleet}); you write only + * config + body (+ optional autoscaling and hooks). + */ +abstract class Daemon extends Process +{ + use SingletonLock; + use SupervisesFleet; + + /** Baseline number of workers to keep running (the default scale target). */ + protected int $replicas = 1; + /** Worker Process class to supervise when {@see workerRun()} is not defined. */ + protected ?string $workerClass = null; + /** + * How long the master waits for a worker to drain on stop / scale-down before + * SIGKILL, in seconds. Overrides {@see Process::$grace} (0) with a bounded, + * deploy-safe default: a stuck worker can never hang the whole fleet's + * shutdown — mirroring Kubernetes' 30s terminationGracePeriodSeconds. Set to + * 0 to wait forever (never cut off in-flight work). + */ + protected float $grace = 30.0; + /** + * Watchdog: kill and restart a worker whose heartbeat has been silent this + * long, in seconds. Catches a wedged worker (deadlock, hung I/O, a boot that + * never finishes) that a plain liveness check misses. 0 disables it (the + * default). Under the fork runtime, set it longer than your longest + * non-yielding unit, or call {@see touch()} inside such a unit, to avoid + * killing a healthy-but-busy worker. + */ + protected float $livenessTimeout = 0.0; + + // ------------------------------------------------------------------------- + // Overridable policy (all optional — sane defaults apply) + // ------------------------------------------------------------------------- + + /** + * How many workers should be running right now. Override for autoscaling + * (e.g. from queue depth); the supervisor damps the value per {@see scaling()}. + */ + protected function desiredReplicas(): int + { + return $this->replicas(); + } + + /** + * Scaling damping policy (stability over speed). Override to tune. + */ + protected function scaling(): ScalingPolicy + { + return ScalingPolicy::default(); + } + + /** + * Restart policy for an unexpectedly dead worker. Override to tune. + */ + protected function restart(): RestartPolicy + { + return RestartPolicy::default(); + } + + /** + * Inline worker body. Define it to run the daemon itself as the worker; if it + * is not defined, {@see $workerClass} is supervised instead. + */ + protected function workerRun(): void + { + throw new DaemonConfigException( + static::class . ': workerRun() is not defined and $workerClass is not set.' + ); + } + + // ------------------------------------------------------------------------- + // Master lifecycle hooks (all optional) + // ------------------------------------------------------------------------- + + /** A worker was forked into a slot. */ + protected function onWorkerStart(int $slot, int $pid): void + { + } + + /** A worker exited ($crashed = a non-zero/abnormal exit of a live worker). */ + protected function onWorkerExit(int $slot, int $pid, bool $crashed): void + { + } + + /** The fleet size changed. */ + protected function onScale(int $from, int $to): void + { + } + + /** Periodic master callback (about once per scaleInterval) — poll metrics here. */ + protected function tick(): void + { + } + + // ------------------------------------------------------------------------- + // Worker body wiring — the engine calls run(), which delegates to workerRun() + // ------------------------------------------------------------------------- + + /** + * Satisfies the {@see Process} body contract by delegating to + * {@see workerRun()}. Final — a daemon defines its inline body in + * `workerRun()` (or supervises {@see $workerClass}), never by overriding run(). + */ + final public function run(): void + { + $this->workerRun(); + } + + // ------------------------------------------------------------------------- + // Control surface + // ------------------------------------------------------------------------- + + /** + * Launches the supervisor in the foreground, registering it in the store so + * {@see status()} / {@see stop()} reach it from another terminal. + */ + final public static function start(): void + { + static::ensureNotRunning(); + + /** @var static $self */ + $self = Container::getInstance()->make(static::class); + $self->supervise(); + } + + // ------------------------------------------------------------------------- + // Internal surface consumed by the SupervisesFleet trait (all private) + // ------------------------------------------------------------------------- + + /** @internal */ + private function replicas(): int + { + return max(1, $this->replicas); + } + + /** @internal */ + private function computeDesired(): int + { + return max(0, $this->desiredReplicas()); + } + + /** @internal */ + private function scalingPolicy(): ScalingPolicy + { + return $this->scaling(); + } + + /** @internal */ + private function restartPolicy(): RestartPolicy + { + return $this->restart(); + } + + /** @internal Master's drain-deadline budget for a stopping/retiring worker. */ + private function graceSeconds(): float + { + return max(0.0, $this->grace); + } + + /** @internal Watchdog silence threshold (0 = disabled). */ + private function livenessTimeout(): float + { + return max(0.0, $this->livenessTimeout); + } + + /** + * Child side: resolves the worker body and runs it in the forked worker. + * + * @internal Called by the supervisor in the fork child. + */ + private function bootWorker(int $slot): void + { + $title = $this->workerTitle($slot); + + if ($this->definesWorkerRun()) { + $this->runWorker($slot, $title, static::class); + return; + } + + if ($this->workerClass !== null) { + $worker = Container::getInstance()->make($this->workerClass); + if (!$worker instanceof Process) { + throw new DaemonConfigException( + static::class . ": \$workerClass {$this->workerClass} must extend Process." + ); + } + $worker->runWorker($slot, $title, static::class); + return; + } + + throw new DaemonConfigException( + static::class . ': no worker body — define workerRun() or set $workerClass.' + ); + } + + /** @internal Best-effort read of a worker's last heartbeat record. */ + private function workerRecord(int $slot): ?ProcessStatus + { + try { + $record = static::store()->read($this->workerRecordKey($slot)); + return $record instanceof ProcessStatus ? $record : null; + } catch (\Throwable) { + return null; + } + } + + /** @internal Removes a worker's per-slot heartbeat record. */ + private function clearWorkerRecord(int $slot): void + { + try { + static::store()->del($this->workerRecordKey($slot)); + } catch (\Throwable) { + // best-effort cleanup + } + } + + /** @internal */ + private function fireWorkerStart(int $slot, int $pid): void + { + $this->guard(fn() => $this->onWorkerStart($slot, $pid)); + } + + /** @internal */ + private function fireWorkerExit(int $slot, int $pid, bool $crashed): void + { + $this->guard(fn() => $this->onWorkerExit($slot, $pid, $crashed)); + } + + /** @internal */ + private function fireScale(int $from, int $to): void + { + $this->guard(fn() => $this->onScale($from, $to)); + } + + /** @internal */ + private function fireTick(): void + { + $this->guard(fn() => $this->tick()); + } + + /** @internal */ + private function fireReload(): void + { + $this->guard(fn() => $this->onReload()); + } + + // ------------------------------------------------------------------------- + // Title + // ------------------------------------------------------------------------- + + /** + * The master's `ps` title, e.g. `winter-daemon: Emails master`. + */ + protected function buildProcessTitle(): string + { + return 'winter-daemon: ' . $this->titleName() . ' master'; + } + + /** + * A worker's `ps` title, e.g. `winter-daemon: Emails worker#2`. The number is + * one-based (slot 0 → worker#1), so the whole tree shares a `winter-daemon:` + * prefix and can be found — or killed — together. + */ + private function workerTitle(int $slot): string + { + return 'winter-daemon: ' . $this->titleName() . ' worker#' . ($slot + 1); + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** + * The master loop: takes the singleton lock, publishes the {@see DaemonStatus} + * record, runs the fleet loop ({@see superviseFleet()}), and on exit runs {@see onShutdown()}, + * removes the record and releases the lock. + */ + private function supervise(): void + { + if (!$this->acquireLock()) { + LoggerFactory::getLogger(static::class)->notice( + static::class . ' is already running; not starting a second supervisor.' + ); + return; + } + + $this->pid = getmypid(); + $this->logger = LoggerFactory::getLogger(static::class); + if (function_exists('cli_set_process_title')) { + @cli_set_process_title($this->buildProcessTitle()); // master title + } + + $store = static::store(); + $key = static::key(); + $startedAt = time(); + + $write = function () use ($store, $key, $startedAt): void { + $workers = $this->snapshot(); + $busy = false; + foreach ($workers as $worker) { + if ($worker->activity === Activity::BUSY) { + $busy = true; + break; + } + } + try { + $store->write($key, new DaemonStatus( + pid: $this->pid, + className: static::class, + state: $this->isStopping() ? ProcessState::STOPPING : ProcessState::RUNNING, + activity: $busy ? Activity::BUSY : Activity::IDLE, + startedAt: $startedAt, + concurrency: $this->concurrency, + restarts: $this->restartsTotal(), + workers: $workers, + )); + } catch (\Throwable $e) { + $this->logger->warning('Daemon status write failed: ' . $e->getMessage()); + } + }; + + $write(); + // Backstop the finally below against a forced/fatal exit that skips it. + register_shutdown_function(static fn() => $store->del($key)); + + try { + $final = $this->superviseFleet($write); + if ($final === ProcessState::FAILED) { + $this->logger->critical('Daemon reached maxRestarts; giving up.'); + } + } catch (\Throwable $e) { + $this->logger->critical( + $e->getMessage() + . (env('DEBUG', false) ? "\n" . $e->getTraceAsString() : '') + ); + } finally { + $this->guard(fn() => $this->onShutdown()); + $store->del($key); + $this->releaseLock(); + } + } + + /** + * Runs a user hook, swallowing (and logging) any exception so a faulty hook + * can never take the supervisor down. + */ + private function guard(callable $hook): void + { + try { + $hook(); + } catch (\Throwable $e) { + $this->logger->error('Daemon hook failed: ' . $e->getMessage()); + } + } + + /** + * Whether a subclass overrode {@see workerRun()} — the signal to run the + * daemon itself as the worker rather than supervising {@see $workerClass}. + */ + private function definesWorkerRun(): bool + { + return (new \ReflectionMethod($this, 'workerRun'))->getDeclaringClass()->getName() !== self::class; + } + + /** + * Store key of a worker's per-slot heartbeat record (this daemon's class + slot). + */ + private function workerRecordKey(int $slot): string + { + return hash('xxh64', static::class . '#' . $slot); + } +} diff --git a/src/Process/Daemon/DaemonConfigException.php b/src/Process/Daemon/DaemonConfigException.php new file mode 100644 index 0000000..6b05ec5 --- /dev/null +++ b/src/Process/Daemon/DaemonConfigException.php @@ -0,0 +1,14 @@ + $workers Live worker PIDs under the supervisor. + * @param array $workers Live fleet snapshot, one entry per non-empty slot. */ public function __construct( int $pid, diff --git a/src/Dev/Process/RestartPolicy.php b/src/Process/Daemon/RestartMode.php similarity index 77% rename from src/Dev/Process/RestartPolicy.php rename to src/Process/Daemon/RestartMode.php index 3c06c8b..d567dd8 100644 --- a/src/Dev/Process/RestartPolicy.php +++ b/src/Process/Daemon/RestartMode.php @@ -2,15 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Process; +namespace Flytachi\Winter\K2\Process\Daemon; /** - * When a supervised {@see Daemon} worker should be restarted after it exits. + * When a supervised worker should be restarted after it exits. * * The naming follows the common convention (Kubernetes / systemd): a clean exit - * means "the work is done", a non-zero exit or crash means "failure". + * means "the work is done", a non-zero exit or crash means "failure". Carried + * inside a {@see RestartPolicy} together with the restart limit and back-off. */ -enum RestartPolicy +enum RestartMode { /** Restart on any exit — the worker must stay alive until stopped. */ case ALWAYS; diff --git a/src/Process/Daemon/RestartPolicy.php b/src/Process/Daemon/RestartPolicy.php new file mode 100644 index 0000000..797b2d1 --- /dev/null +++ b/src/Process/Daemon/RestartPolicy.php @@ -0,0 +1,52 @@ +mode->shouldRestart($crashed); + } +} diff --git a/src/Process/Daemon/ScalingPolicy.php b/src/Process/Daemon/ScalingPolicy.php new file mode 100644 index 0000000..3fb672e --- /dev/null +++ b/src/Process/Daemon/ScalingPolicy.php @@ -0,0 +1,48 @@ + Slots keyed by their stable index. */ + private array $slots = []; + private bool $stop = false; + private int $totalRestarts = 0; + private ProcessState $finalState = ProcessState::TERMINATED; + + /** @var list Ring of [microtime, rawDesired] for scaling damping. */ + private array $desiredHistory = []; + private float $lastScaleAt = 0.0; + private float $lastReconcileAt = 0.0; + private float $lastStatusAt = 0.0; + + // ------------------------------------------------------------------------- + // Main loop + // ------------------------------------------------------------------------- + + /** + * Runs the supervision loop until every worker is done or a stop signal + * arrives. Returns the terminal state. + * + * @param callable $onChange Persist the DaemonStatus; called whenever the fleet changes. + */ + private function superviseFleet(callable $onChange): ProcessState + { + pcntl_async_signals(true); + pcntl_signal(SIGTERM, fn() => $this->requestFleetStop()); + pcntl_signal(SIGINT, fn() => $this->requestFleetStop()); + pcntl_signal(SIGHUP, fn() => $this->reload()); + pcntl_signal(SIGUSR1, fn() => $this->forward(SIGUSR1)); + pcntl_signal(SIGUSR2, fn() => $this->forward(SIGUSR2)); + + $replicas = $this->replicas(); + for ($i = 0; $i < $replicas; $i++) { + $this->slots[$i] = new Slot($i); + } + + while (true) { + $changed = $this->refreshWorkers(); + if (!$this->stop) { + $this->watchdog(); + } + $this->reapAndEnforce($onChange); + + if ($this->stop) { + if ($this->aliveCount() === 0) { + break; + } + } else { + $now = microtime(true); + $interval = max(0.05, $this->scalingPolicy()->scaleInterval); + if (($now - $this->lastReconcileAt) >= $interval) { + $this->lastReconcileAt = $now; + $this->fireTick(); + $this->reconcile($onChange); + } + } + + // Heartbeat the status so activity and STARTING → RUNNING promotions + // reach the store even when the fleet size does not change. + $now = microtime(true); + if ($changed || ($now - $this->lastStatusAt) >= self::STATUS_INTERVAL) { + $this->lastStatusAt = $now; + $onChange(); + } + + usleep((int) (self::TICK * 1_000_000)); + pcntl_signal_dispatch(); + } + + return $this->finalState; + } + + // ------------------------------------------------------------------------- + // Reaping and deadline enforcement (runs every tick) + // ------------------------------------------------------------------------- + + /** + * One reaping pass: harvests exited workers (routing each to {@see handleExit()}), + * SIGKILLs a RETIRING worker past its drain deadline, and re-forks a RESTARTING + * slot once its back-off has elapsed. + */ + private function reapAndEnforce(callable $onChange): void + { + $now = microtime(true); + foreach ($this->slots as $slot) { + if ($slot->state->isAlive()) { + $res = pcntl_waitpid($slot->pid, $status, WNOHANG); + if ($res === $slot->pid) { + $crashed = !(pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0); + $this->handleExit($slot, $crashed, $onChange); + } elseif ($slot->state === SlotState::RETIRING && $now > $slot->deadline) { + // Outlived its drain deadline (or a force-stop collapsed it) — SIGKILL. + @posix_kill($slot->pid, SIGKILL); + $slot->state = SlotState::KILLING; + } + } elseif ($slot->state === SlotState::RESTARTING && !$this->stop) { + if ($now >= $slot->restartAt) { + $this->forkInto($slot, $onChange); + } + } + } + } + + /** + * Reacts to a worker exit by slot intent: an intentionally retired worker is + * freed (never refilled); an unexpected death is restarted per policy. + */ + private function handleExit(Slot $slot, bool $crashed, callable $onChange): void + { + $idx = $slot->index; + $pid = $slot->pid; + $intentional = $this->stop + || $slot->state === SlotState::RETIRING + || $slot->state === SlotState::KILLING; + + $this->fireWorkerExit($idx, $pid, $intentional ? false : $crashed); + + if ($intentional) { + $this->free($slot); + $onChange(); + return; + } + + $restart = $this->restartPolicy(); + if ($restart->shouldRestart($crashed)) { + $this->totalRestarts++; + $slot->restarts++; + if ($restart->maxRestarts > 0 && $this->totalRestarts >= $restart->maxRestarts) { + $this->finalState = ProcessState::FAILED; + // This worker is already dead and reaped — free its slot here, then + // stop the rest. Leaving it for beginStop() would mark an already + // reaped slot RETIRING, and it would never drain (aliveCount stuck). + $this->free($slot); + $this->beginStop(); + $onChange(); + return; + } + // Restart into the SAME slot so worker#{n} stays stable; back off first. + $this->clearWorkerRecord($idx); + $slot->state = SlotState::RESTARTING; + $slot->pid = 0; + $slot->startedAt = 0; + $slot->restartAt = microtime(true) + $this->backoff($restart->backoff, $slot->restarts); + } else { + // Policy declined to replace this death (NEVER, or a clean exit under + // ON_FAILURE): retire the slot terminally. It counts as committed, so + // reconcile does NOT immediately refill it — which would bypass the + // back-off and the policy and could crash-loop. + $this->retirePermanently($slot); + } + $onChange(); + } + + // ------------------------------------------------------------------------- + // Reconcile — drive the committed fleet to the (damped) desired size + // ------------------------------------------------------------------------- + + /** + * Drives the committed fleet toward the damped desired size: scales up or down + * by at most `scaleStep`, gated by `cooldown`. Skipped entirely while stopping. + */ + private function reconcile(callable $onChange): void + { + $policy = $this->scalingPolicy(); + $now = microtime(true); + if (($now - $this->lastScaleAt) < max(0.0, $policy->cooldown)) { + return; // cooldown gate between actions + } + + $committed = $this->committedCount(); + $desired = $this->effectiveDesired($committed, $policy); + if ($desired === $committed) { + return; + } + + $step = $policy->scaleStep; + $from = $committed; + + if ($desired > $committed) { + $need = $desired - $committed; + if ($step > 0) { + $need = min($need, $step); + } + $added = $this->scaleUp($need, $onChange); + if ($added > 0) { + $this->lastScaleAt = $now; + $this->fireScale($from, $from + $added); + $onChange(); + } + } else { + $excess = $committed - $desired; + if ($step > 0) { + $excess = min($excess, $step); + } + $removed = $this->scaleDown($excess); + if ($removed > 0) { + $this->lastScaleAt = $now; + $this->fireScale($from, $from - $removed); + $onChange(); + } + } + } + + /** + * Resolves the target size with damping: react to a rise quickly (once it is + * sustained over scaleUpDelay), shrink only to the high-water demand over the + * stabilization window — so a transient dip never sheds workers. + */ + private function effectiveDesired(int $committed, ScalingPolicy $policy): int + { + $now = microtime(true); + $raw = $this->computeDesired(); + $this->desiredHistory[] = [$now, $raw]; + + $window = max($policy->scaleUpDelay, $policy->scaleDownStabilization); + $cutoff = $now - $window; + $this->desiredHistory = array_values(array_filter( + $this->desiredHistory, + static fn(array $e): bool => $e[0] >= $cutoff + )); + + if ($raw > $committed) { + // Scale up to the sustained floor of demand; never below current here. + $floor = $this->windowExtreme($now - max(0.0, $policy->scaleUpDelay), false); + return max($committed, $floor); + } + if ($raw < $committed) { + // Scale down only to the high-water demand over the window; never above current. + $ceil = $this->windowExtreme($now - max(0.0, $policy->scaleDownStabilization), true); + return min($committed, $ceil); + } + return $committed; + } + + /** + * Max ($max=true) or min of the recorded raw desired values within [$since, now]. + */ + private function windowExtreme(float $since, bool $max): int + { + $result = null; + foreach ($this->desiredHistory as [$t, $v]) { + if ($t < $since) { + continue; + } + if ($result === null) { + $result = $v; + } else { + $result = $max ? max($result, $v) : min($result, $v); + } + } + return $result ?? $this->computeDesired(); + } + + /** + * Grows the fleet by up to $need: reclaim still-draining workers first + * (anti-flap), then spawn on free slots, allocating new ones if short. + */ + private function scaleUp(int $need, callable $onChange): int + { + $added = 0; + foreach ($this->slots as $slot) { + if ($added >= $need) { + break; + } + if ($slot->state === SlotState::RETIRING) { + $slot->state = SlotState::RUNNING; + $slot->deadline = INF; + $added++; + } + } + foreach ($this->slots as $slot) { + if ($added >= $need) { + break; + } + if ($slot->state === SlotState::EMPTY) { + $this->forkInto($slot, $onChange); + $added++; + } + } + while ($added < $need) { + $idx = $this->nextFreeIndex(); + $this->slots[$idx] = new Slot($idx); + $this->forkInto($this->slots[$idx], $onChange); + $added++; + } + return $added; + } + + /** + * Shrinks the fleet by up to $excess: cancel back-off restarts first (no live + * process), then retire live workers gracefully, IDLE ones first. + */ + private function scaleDown(int $excess): int + { + $removed = 0; + // 0) shed already-dead RETIRED slots first — reclaiming them is free. + foreach ($this->slots as $slot) { + if ($removed >= $excess) { + break; + } + if ($slot->state === SlotState::RETIRED) { + $this->free($slot); + $removed++; + } + } + // 1) cancel pending back-off restarts (no live process yet). + foreach ($this->slots as $slot) { + if ($removed >= $excess) { + break; + } + if ($slot->state === SlotState::RESTARTING) { + $this->free($slot); + $removed++; + } + } + $grace = $this->graceSeconds(); + foreach ($this->pickVictims($excess - $removed) as $slot) { + $this->retire($slot, $grace); + $removed++; + } + return $removed; + } + + /** + * Picks up to $k live workers to retire — IDLE first (reclaimed at once, no + * work lost), then BUSY by highest slot (keeping low slots stable). A BUSY + * victim still drains gracefully; this only orders which go first. + * + * @return list + */ + private function pickVictims(int $k): array + { + if ($k <= 0) { + return []; + } + $candidates = []; + foreach ($this->slots as $slot) { + if ($slot->state === SlotState::RUNNING || $slot->state === SlotState::STARTING) { + $candidates[] = $slot; + } + } + usort($candidates, static function (Slot $a, Slot $b): int { + $ai = $a->activity === Activity::IDLE ? 0 : 1; + $bi = $b->activity === Activity::IDLE ? 0 : 1; + return ($ai <=> $bi) ?: ($b->index <=> $a->index); + }); + return array_slice($candidates, 0, $k); + } + + // ------------------------------------------------------------------------- + // Stop sequence + // ------------------------------------------------------------------------- + + /** + * First stop signal drains the whole fleet; a second one forces it down now. + */ + private function requestFleetStop(): void + { + if (!$this->stop) { + $this->beginStop(); + } else { + $this->forceStop(); + } + } + + /** + * Freezes reconcile and retires the whole fleet at once (a full stop is + * "retire every slot"), reusing the graceful drain + deadline machinery. + */ + private function beginStop(): void + { + $this->stop = true; + $grace = $this->graceSeconds(); + foreach ($this->slots as $slot) { + if ($slot->state === SlotState::STARTING || $slot->state === SlotState::RUNNING) { + $this->retire($slot, $grace); + } elseif ($slot->state === SlotState::RESTARTING) { + $this->free($slot); // cancel a pending restart + } + } + } + + /** + * Collapses every drain deadline to now, so the next enforcement pass SIGKILLs + * any worker still alive — the operator's "stop now" on a repeated signal. + */ + private function forceStop(): void + { + $now = microtime(true); + foreach ($this->slots as $slot) { + if ($slot->state->isAlive()) { + $slot->deadline = $now; + } + } + } + + /** + * SIGHUP handling: runs the master's {@see Daemon::onReload()} hook and + * forwards the signal to every worker. Reload, not stop. + */ + private function reload(): void + { + $this->fireReload(); + $this->forward(SIGHUP); + } + + /** + * Forwards a control signal to every live worker; the supervisor stays up. + */ + private function forward(int $signo): void + { + foreach ($this->slots as $slot) { + if ($slot->pid > 0 && $slot->state->isAlive()) { + @posix_kill($slot->pid, $signo); + } + } + } + + // ------------------------------------------------------------------------- + // Slot transitions + // ------------------------------------------------------------------------- + + /** + * Forks a worker into the slot. In the child, inherited signal handlers are + * reset so the worker's own runtime installs its own — then the body runs and + * its outcome maps to the exit code. + */ + private function forkInto(Slot $slot, callable $onChange): void + { + $slot->startedAt = time(); + $pid = pcntl_fork(); + if ($pid === 0) { + pcntl_signal(SIGTERM, SIG_DFL); + pcntl_signal(SIGINT, SIG_DFL); + pcntl_signal(SIGHUP, SIG_DFL); + pcntl_signal(SIGUSR1, SIG_DFL); + pcntl_signal(SIGUSR2, SIG_DFL); + try { + $this->bootWorker($slot->index); + exit(0); + } catch (\Throwable) { + // The worker logs its own failure; the non-zero code is the signal. + exit(1); + } + } + $slot->pid = $pid; + $slot->state = SlotState::STARTING; + $slot->deadline = INF; + $slot->activity = Activity::IDLE; + $slot->heartbeatAt = 0; + $slot->killed = false; + $this->fireWorkerStart($slot->index, $pid); + $onChange(); + } + + /** + * Retires a worker: SIGTERM to begin a graceful drain, and stamp the deadline + * the enforcement pass uses to SIGKILL a straggler (INF = wait forever). + */ + private function retire(Slot $slot, float $grace): void + { + $slot->state = SlotState::RETIRING; + @posix_kill($slot->pid, SIGTERM); + $slot->deadline = $grace > 0 ? microtime(true) + $grace : INF; + } + + /** + * Returns a slot to EMPTY (reusable), clearing its heartbeat record. + */ + private function free(Slot $slot): void + { + $this->clearWorkerRecord($slot->index); + $slot->state = SlotState::EMPTY; + $slot->pid = 0; + $slot->deadline = INF; + $slot->restartAt = 0.0; + $slot->startedAt = 0; + $slot->activity = Activity::IDLE; + $slot->heartbeatAt = 0; + $slot->killed = false; + } + + /** + * Terminally retires a slot whose worker died and the policy declined to + * replace. It counts as committed (so reconcile leaves it alone) until a + * scale-down reclaims it. + */ + private function retirePermanently(Slot $slot): void + { + $this->clearWorkerRecord($slot->index); + $slot->state = SlotState::RETIRED; + $slot->pid = 0; + $slot->deadline = INF; + $slot->restartAt = 0.0; + $slot->startedAt = 0; + $slot->activity = Activity::IDLE; + $slot->heartbeatAt = 0; + $slot->killed = false; + } + + // ------------------------------------------------------------------------- + // Worker observation + // ------------------------------------------------------------------------- + + /** + * Refreshes each live slot's activity from the worker heartbeat, and promotes + * STARTING → RUNNING on the first heartbeat seen. Returns whether any slot's + * state or activity changed (so the caller can persist the fleet view). + */ + private function refreshWorkers(): bool + { + $changed = false; + foreach ($this->slots as $slot) { + if ( + $slot->state === SlotState::STARTING + || $slot->state === SlotState::RUNNING + || $slot->state === SlotState::RETIRING + ) { + $record = $this->workerRecord($slot->index); + if ($record !== null) { + if ($slot->activity !== $record->activity) { + $slot->activity = $record->activity; + $changed = true; + } + $slot->heartbeatAt = $record->heartbeatAt; + if ($slot->state === SlotState::STARTING) { + $slot->state = SlotState::RUNNING; + $changed = true; + } + } + } + } + return $changed; + } + + /** + * Kills a worker whose heartbeat has gone silent past the liveness timeout — + * a wedged worker (deadlock, hung I/O, a boot that never finishes) that a + * plain pid check misses. The reap then restarts it through the crash path + * (back-off + policy). Disabled when the timeout is 0. + */ + private function watchdog(): void + { + $timeout = $this->livenessTimeout(); + if ($timeout <= 0.0) { + return; + } + $now = time(); + foreach ($this->slots as $slot) { + if ($slot->killed) { + continue; + } + if ($slot->state !== SlotState::RUNNING && $slot->state !== SlotState::STARTING) { + continue; + } + // A running worker is judged by its last heartbeat; a STARTING worker + // that has not beat yet is judged from when it was forked. + $last = $slot->heartbeatAt > 0 ? $slot->heartbeatAt : $slot->startedAt; + if ($last > 0 && ($now - $last) > $timeout) { + $this->logger->warning( + 'worker#' . ($slot->index + 1) . " [PID {$slot->pid}] hung — no heartbeat for " + . ($now - $last) . "s (> {$timeout}s); killing." + ); + @posix_kill($slot->pid, SIGKILL); + $slot->killed = true; + } + } + } + + // ------------------------------------------------------------------------- + // Introspection (consumed by the Daemon status writer) + // ------------------------------------------------------------------------- + + /** + * @return list One entry per non-empty slot. + */ + private function snapshot(): array + { + $out = []; + foreach ($this->slots as $slot) { + if ($slot->state === SlotState::EMPTY) { + continue; + } + $out[] = new WorkerStatus( + slot: $slot->index, + pid: $slot->pid, + state: $slot->state, + activity: $slot->activity, + startedAt: $slot->startedAt, + restarts: $slot->restarts, + ); + } + return $out; + } + + /** Total worker restarts across the fleet's lifetime (for {@see DaemonStatus}). */ + private function restartsTotal(): int + { + return $this->totalRestarts; + } + + /** Whether a stop is in progress (reconcile frozen, fleet draining). */ + private function isStopping(): bool + { + return $this->stop; + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** Slots that are or will be running — the size reconcile drives to desired. */ + private function committedCount(): int + { + $n = 0; + foreach ($this->slots as $slot) { + if ($slot->state->isCommitted()) { + $n++; + } + } + return $n; + } + + /** Slots with a live OS process attached. */ + private function aliveCount(): int + { + $n = 0; + foreach ($this->slots as $slot) { + if ($slot->state->isAlive()) { + $n++; + } + } + return $n; + } + + /** Lowest free slot index (reuses an EMPTY slot, else appends a new one). */ + private function nextFreeIndex(): int + { + $i = 0; + while (isset($this->slots[$i]) && $this->slots[$i]->state !== SlotState::EMPTY) { + $i++; + } + return $i; + } + + /** + * Exponential back-off `base × 2^(failures-1)`, capped at {@see BACKOFF_CAP}; + * 0 for no failures or no base. + */ + private function backoff(float $base, int $failures): float + { + if ($base <= 0.0 || $failures <= 0) { + return 0.0; + } + return min($base * (2 ** ($failures - 1)), self::BACKOFF_CAP); + } +} diff --git a/src/Process/Daemon/WorkerStatus.php b/src/Process/Daemon/WorkerStatus.php new file mode 100644 index 0000000..a40aa47 --- /dev/null +++ b/src/Process/Daemon/WorkerStatus.php @@ -0,0 +1,44 @@ + + */ + public function jsonSerialize(): array + { + return [ + 'slot' => $this->slot, + 'pid' => $this->pid, + 'state' => $this->state->value, + 'activity' => $this->activity->value, + 'started_at' => $this->startedAt, + 'uptime' => $this->startedAt > 0 ? time() - $this->startedAt : 0, + 'restarts' => $this->restarts, + ]; + } +} diff --git a/src/Dev/Process/Engine/Engines.php b/src/Process/Engine/Engines.php similarity index 91% rename from src/Dev/Process/Engine/Engines.php rename to src/Process/Engine/Engines.php index eeb3f85..a55fa48 100644 --- a/src/Dev/Process/Engine/Engines.php +++ b/src/Process/Engine/Engines.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Process\Engine; +namespace Flytachi\Winter\K2\Process\Engine; /** * Picks the {@see ProcessEngine} matching the current runtime. @@ -16,6 +16,7 @@ */ final class Engines { + /** Static factory — not instantiable. */ private function __construct() { } diff --git a/src/Dev/Process/Engine/ProcessEngine.php b/src/Process/Engine/ProcessEngine.php similarity index 91% rename from src/Dev/Process/Engine/ProcessEngine.php rename to src/Process/Engine/ProcessEngine.php index d99aef6..3024cb4 100644 --- a/src/Dev/Process/Engine/ProcessEngine.php +++ b/src/Process/Engine/ProcessEngine.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Process\Engine; +namespace Flytachi\Winter\K2\Process\Engine; use Flytachi\Winter\K2\Concurrent\Future; /** - * Runtime backend that carries a {@see \Flytachi\Winter\K2\Dev\Process\Process} + * Runtime backend that carries a {@see \Flytachi\Winter\K2\Process\Process} * body. * * The engine hides the difference between runtimes so the process body is @@ -47,7 +47,7 @@ public function spawn(callable $task): Future; /** * Pauses the body without blocking sibling tasks under Swoole. Throws - * {@see \Flytachi\Winter\K2\Dev\Process\InterruptedException} once if the body + * {@see \Flytachi\Winter\K2\Process\InterruptedException} once if the body * was interrupted (an IDLE wait woken by a stop request). * * @param float $seconds Seconds to pause. diff --git a/src/Dev/Process/Engine/SwooleEngine.php b/src/Process/Engine/SwooleEngine.php similarity index 90% rename from src/Dev/Process/Engine/SwooleEngine.php rename to src/Process/Engine/SwooleEngine.php index ae432cc..502e36f 100644 --- a/src/Dev/Process/Engine/SwooleEngine.php +++ b/src/Process/Engine/SwooleEngine.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Process\Engine; +namespace Flytachi\Winter\K2\Process\Engine; use Flytachi\Winter\K2\Concurrent\Executors; use Flytachi\Winter\K2\Concurrent\Future; -use Flytachi\Winter\K2\Dev\Process\InterruptedException; +use Flytachi\Winter\K2\Process\InterruptedException; /** * Coroutine backend. @@ -39,6 +39,9 @@ public function __construct( ) { } + /** + * {@inheritDoc} + */ public function enter( callable $body, array $signals = [], @@ -102,6 +105,9 @@ public function enter( } } + /** + * {@inheritDoc} + */ public function spawn(callable $task): Future { $this->semaphore?->pop(); @@ -119,6 +125,9 @@ public function spawn(callable $task): Future return Executors::common()->submit($wrapped); } + /** + * {@inheritDoc} + */ public function sleep(float $seconds): void { \Swoole\Coroutine::sleep($seconds); @@ -130,11 +139,17 @@ public function sleep(float $seconds): void } } + /** + * {@inheritDoc} + */ public function running(): bool { return !$this->stop; } + /** + * {@inheritDoc} + */ public function requestStop(bool $interrupt): void { if ($this->stop) { @@ -142,13 +157,10 @@ public function requestStop(bool $interrupt): void } $this->stop = true; - // Wake an IDLE body blocked in an interruptible point; leave a BUSY unit - // to finish (do not cancel it). - if ($interrupt && $this->bodyCid !== null) { - \Swoole\Coroutine::cancel($this->bodyCid); - } - - // Backstop: force exit if the body keeps running past the grace window. + // Arm the grace backstop BEFORE the cancel. Arming a timer in the same + // handler *after* Coroutine::cancel() swallows the body's pending resume, + // freezing it until the timer fires — so a fast IDLE drain would instead + // always wait the full grace. Order matters here. if ($this->graceTimerId === null && $this->grace > 0) { $this->graceTimerId = \Swoole\Timer::after( (int) ($this->grace * 1000), @@ -160,8 +172,17 @@ function (): void { } ); } + + // Wake an IDLE body blocked in an interruptible point; leave a BUSY unit + // to finish (do not cancel it). + if ($interrupt && $this->bodyCid !== null) { + \Swoole\Coroutine::cancel($this->bodyCid); + } } + /** + * {@inheritDoc} + */ public function inFlight(): int { return $this->inFlight; diff --git a/src/Dev/Process/Engine/SyncEngine.php b/src/Process/Engine/SyncEngine.php similarity index 90% rename from src/Dev/Process/Engine/SyncEngine.php rename to src/Process/Engine/SyncEngine.php index f142b22..78d2ec1 100644 --- a/src/Dev/Process/Engine/SyncEngine.php +++ b/src/Process/Engine/SyncEngine.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Process\Engine; +namespace Flytachi\Winter\K2\Process\Engine; use Flytachi\Winter\K2\Concurrent\CompletableFuture; use Flytachi\Winter\K2\Concurrent\Future; -use Flytachi\Winter\K2\Dev\Process\InterruptedException; +use Flytachi\Winter\K2\Process\InterruptedException; /** * Fork backend for runtimes without Swoole. @@ -45,6 +45,9 @@ public function __construct( $this->hasPcntl = extension_loaded('pcntl'); } + /** + * {@inheritDoc} + */ public function enter( callable $body, array $signals = [], @@ -71,6 +74,9 @@ public function enter( $this->waitAll(); } + /** + * {@inheritDoc} + */ public function spawn(callable $task): Future { if (!$this->hasPcntl) { @@ -103,6 +109,9 @@ public function spawn(callable $task): Future return CompletableFuture::completedFuture(null); } + /** + * {@inheritDoc} + */ public function sleep(float $seconds): void { $remaining = $seconds; @@ -139,11 +148,17 @@ private function heartbeat(): void } } + /** + * {@inheritDoc} + */ public function running(): bool { return !$this->stop; } + /** + * {@inheritDoc} + */ public function requestStop(bool $interrupt): void { if ($this->stop) { @@ -167,12 +182,18 @@ public function requestStop(bool $interrupt): void } } + /** + * {@inheritDoc} + */ public function inFlight(): int { $this->reap(); return count($this->children); } + /** + * Harvests any finished spawn children without blocking (avoids zombies). + */ private function reap(): void { if (!$this->hasPcntl) { @@ -183,6 +204,10 @@ private function reap(): void } } + /** + * Blocks until every spawn child has finished — the structured-concurrency + * drain that runs after the body returns. + */ private function waitAll(): void { if (!$this->hasPcntl) { diff --git a/src/Process/ForkReset.php b/src/Process/ForkReset.php new file mode 100644 index 0000000..761612b --- /dev/null +++ b/src/Process/ForkReset.php @@ -0,0 +1,61 @@ + */ + private static array $handlers = []; + + /** Static-only registry — not instantiable. */ + private function __construct() + { + } + + /** + * Registers a reset to run in every forked worker. Call once at bootstrap. + */ + public static function register(callable $handler): void + { + self::$handlers[] = $handler; + } + + /** + * Runs every registered reset. A throwing handler is swallowed so one bad + * reset never aborts worker boot or blocks the others. + */ + public static function runAll(): void + { + foreach (self::$handlers as $handler) { + try { + $handler(); + } catch (\Throwable) { + // best-effort — a failing reset must not abort worker boot + } + } + } + + /** + * Clears all handlers (mainly for tests). + */ + public static function clear(): void + { + self::$handlers = []; + } +} diff --git a/src/Process/Internal/SingletonLock.php b/src/Process/Internal/SingletonLock.php new file mode 100644 index 0000000..ccedd5c --- /dev/null +++ b/src/Process/Internal/SingletonLock.php @@ -0,0 +1,63 @@ +lockPath(), 'c'); + if ($handle === false) { + return true; + } + if (!flock($handle, LOCK_EX | LOCK_NB)) { + fclose($handle); + return false; + } + $this->lockHandle = $handle; + return true; + } + + /** + * Releases the lock, if held. The OS also releases it when the process dies. + */ + private function releaseLock(): void + { + if ($this->lockHandle !== null) { + flock($this->lockHandle, LOCK_UN); + fclose($this->lockHandle); + $this->lockHandle = null; + } + } + + /** + * Path of this class's lock file, under the runnable storage directory. + */ + private function lockPath(): string + { + return Kernel::$pathStorageRunnable . '/' . str_replace('\\', '.', static::class) . '.lock'; + } +} diff --git a/src/Dev/Process/InterruptedException.php b/src/Process/InterruptedException.php similarity index 94% rename from src/Dev/Process/InterruptedException.php rename to src/Process/InterruptedException.php index cfd4709..a06b209 100644 --- a/src/Dev/Process/InterruptedException.php +++ b/src/Process/InterruptedException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Process; +namespace Flytachi\Winter\K2\Process; /** * Thrown from an interruptible blocking point (e.g. {@see Process::sleep()}) when diff --git a/src/Dev/Process/Process.php b/src/Process/Process.php similarity index 65% rename from src/Dev/Process/Process.php rename to src/Process/Process.php index e984c7e..b29e4bf 100644 --- a/src/Dev/Process/Process.php +++ b/src/Process/Process.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Process; +namespace Flytachi\Winter\K2\Process; use Flytachi\FileStore\FileStorage; use Flytachi\Winter\DI\Container; use Flytachi\Winter\K2\Concurrent\Future; -use Flytachi\Winter\K2\Dev\Process\Engine\Engines; -use Flytachi\Winter\K2\Dev\Process\Engine\ProcessEngine; -use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\K2\Process\Engine\Engines; +use Flytachi\Winter\K2\Process\Engine\ProcessEngine; +use Flytachi\Winter\K2\Process\Internal\SingletonLock; use Flytachi\Winter\Logger\LoggerFactory; use Flytachi\Winter\Thread\Thread; use Psr\Log\LoggerInterface; @@ -34,7 +34,7 @@ * $msg = $ch->get(timeout: 1.0); * if ($msg === null) { continue; } * $this->markBusy(); - * $this->handle($msg); + * // ... process $msg ... * $ch->ack($msg); * $this->markIdle(); * } @@ -45,6 +45,8 @@ */ abstract class Process { + use SingletonLock; + /** Maximum simultaneous {@see spawn()} tasks; 0 means unlimited. */ protected int $concurrency = 0; /** Seconds to wait for a BUSY unit / in-flight spawns to drain on stop before forcing. 0 = wait forever. */ @@ -58,21 +60,46 @@ abstract class Process private bool $stopping = false; private bool $shutdownDone = false; private bool $inlineBusy = false; - /** @var resource|null Held for the process lifetime; the flock is the singleton guard. */ - private $lockHandle = null; - // Status store bookkeeping (a bare process owns its record; a daemon worker does not). + // Status store bookkeeping (a bare process owns its record; a daemon worker + // instead writes a per-slot heartbeat record under its owner daemon's store). private bool $ownsRecord = false; private int $startedAt = 0; private ProcessState $state = ProcessState::NEW; private ?Activity $writtenActivity = null; + // Daemon-worker context, injected by the supervisor before runWorker(). + private ?int $workerSlot = null; + private ?string $ownerClass = null; + private ?string $titleOverride = null; + /** Microtime of the last worker heartbeat write; throttles {@see touch()}. */ + private float $lastHeartbeatAt = 0.0; + /** Minimum seconds between heartbeat writes (throttle for a tight touch() loop). */ + private const float HEARTBEAT_THROTTLE = 1.0; + + /** + * Constructed by the framework via the DI container (so `#[Autowired]` + * dependencies resolve) — never `new`ed by application code. Use the static + * control surface ({@see start()} / {@see dispatch()}) instead. + */ final public function __construct() { } /** - * The process body. Runs inside the chosen runtime. + * The process body — your long-running work. Runs inside the chosen runtime + * (Swoole coroutines, or a plain process). Loop on {@see isRunning()} so a + * stop signal can end it cleanly. + * + * ``` + * public function run(): void + * { + * while ($this->isRunning()) { + * // ... do one unit of work ... + * $this->sleep(0.5); + * } + * } + * ``` */ abstract public function run(): void; @@ -145,6 +172,25 @@ final protected function activity(): Activity : Activity::IDLE; } + /** + * Explicit liveness beat for a daemon watchdog. The engine already beats + * about once a second, which covers a worker that yields (sleep / coroutine + * I/O). Call {@see touch()} from inside a long, non-yielding unit of work so a + * heartbeat still lands and the supervisor does not mistake progress for a + * hang — mainly needed under the fork runtime, where nothing beats while a + * native blocking call runs. Throttled, so a tight loop is safe; a no-op for + * a bare process (the watchdog is a daemon concern). + */ + final protected function touch(): void + { + if ($this->workerSlot === null) { + return; + } + if ((microtime(true) - $this->lastHeartbeatAt) >= self::HEARTBEAT_THROTTLE) { + $this->writeWorkerRecord(); + } + } + // ------------------------------------------------------------------------- // Signal hooks — override to react to a specific signal // ------------------------------------------------------------------------- @@ -179,6 +225,18 @@ protected function onShutdown(): void { } + /** + * Runs in a freshly forked daemon worker, before {@see run()}. Resets + * inherited fork-unsafe resources — every framework reset registered with + * {@see ForkReset} runs here (e.g. a DB pool reconnect). Override to reset + * your own resources (call `parent::afterFork()` first). A bare foreground + * process is not forked and never runs this. + */ + protected function afterFork(): void + { + ForkReset::runAll(); + } + // ------------------------------------------------------------------------- // Control surface (CLI / web) // ------------------------------------------------------------------------- @@ -277,6 +335,11 @@ final public static function stop(): bool // Internals // ------------------------------------------------------------------------- + /** + * Foreground entry point: takes the singleton lock, registers the status + * record, runs the body, and guarantees the record and lock are released on + * every exit path (graceful, forced or fatal). + */ private function boot(): void { // The status check in start()/dispatch() catches the common case; this @@ -303,50 +366,34 @@ private function boot(): void } /** - * Takes the per-class singleton lock. Returns false when another instance - * already holds it; true when acquired (or when a lock file cannot be created, - * in which case it proceeds best-effort rather than block on a filesystem issue). - */ - protected function acquireLock(): bool - { - $handle = @fopen($this->lockPath(), 'c'); - if ($handle === false) { - return true; - } - if (!flock($handle, LOCK_EX | LOCK_NB)) { - fclose($handle); - return false; - } - $this->lockHandle = $handle; - return true; - } - - protected function releaseLock(): void - { - if ($this->lockHandle !== null) { - flock($this->lockHandle, LOCK_UN); - fclose($this->lockHandle); - $this->lockHandle = null; - } - } - - protected function lockPath(): string - { - return Kernel::$pathStorageRunnable . '/' . str_replace('\\', '.', static::class) . '.lock'; - } - - /** - * Worker entry point used by a {@see Daemon} supervisor: sets up the runtime - * and runs the body, without owning the store record (the supervisor owns it). + * Worker entry point used by a Daemon supervisor: resets inherited resources, + * sets up the runtime and runs the body. It does not own the main store record + * (the supervisor does); instead it writes a per-slot heartbeat the supervisor + * reads for the fleet view. * + * @param int|null $slot Stable fleet slot, or null for a bare worker. + * @param string|null $title OS process title to apply (the daemon's worker title). + * @param string|null $ownerClass Owning daemon class whose store holds the per-slot record. * @internal Not for application code — calling it re-enters the engine. + * Protected (not public) so a daemon can boot an external + * {@see \Flytachi\Winter\K2\Process\Daemon\Daemon::$workerClass} + * worker (a sibling Process) without exposing it to the outside. */ - protected function runWorker(): void + protected function runWorker(?int $slot = null, ?string $title = null, ?string $ownerClass = null): void { + $this->workerSlot = $slot; + $this->titleOverride = $title; + $this->ownerClass = $ownerClass; + $this->afterFork(); $this->prepareWorker(); $this->runBody(); } + /** + * Wires up the running process — PID, logger, title, the runtime engine and a + * fatal backstop for {@see onShutdown()} — then writes an initial heartbeat + * for a daemon worker. Shared by the foreground path and a supervised worker. + */ private function prepareWorker(): void { $this->pid = getmypid(); @@ -359,8 +406,19 @@ private function prepareWorker(): void register_shutdown_function(fn() => $this->invokeShutdown()); $this->engine = Engines::common($this->concurrency, $this->grace); + + // A daemon worker writes its initial heartbeat at once, so the supervisor + // sees it promote from STARTING to RUNNING without waiting a full tick. + if ($this->workerSlot !== null) { + $this->writeStatus(); + } } + /** + * Runs the body inside the runtime engine with the signal map wired to the + * hooks (SIGTERM/SIGINT → stop, SIGHUP → reload, SIGUSR1/2 → user), and + * guarantees {@see onShutdown()} runs once however the body ends. + */ private function runBody(): void { try { @@ -413,12 +471,19 @@ private function invokeShutdown(): void } } - protected function applyProcessTitle(): void + /** + * Sets the OS process title shown in `ps` / `htop`, so the process is easy to + * find and signal. No-op where `cli_set_process_title` is unavailable. Internal + * — override {@see buildProcessTitle()} / {@see titleName()} to customise it. + */ + private function applyProcessTitle(): void { if (!function_exists('cli_set_process_title')) { return; } - @cli_set_process_title($this->buildProcessTitle()); + // A daemon injects the worker title (e.g. `winter-daemon: X worker#2`); + // a bare process builds its own. + @cli_set_process_title($this->titleOverride ?? $this->buildProcessTitle()); } /** @@ -445,12 +510,16 @@ protected function buildProcessTitle(): string /** * Called on the heartbeat (~1s): persists the record only when the activity - * actually changed, so a per-message BUSY/IDLE flip never storms the disk. A - * no-op for a daemon worker (the supervisor owns the record). + * actually changed, so a per-message BUSY/IDLE flip never storms the disk. + * Routes to the bare-process record or the daemon-worker per-slot record. */ private function flushStatus(): void { - if (!$this->ownsRecord) { + // A daemon worker beats every tick (monotonic liveness for the watchdog), + // regardless of whether its activity changed. A bare process dedups by + // activity to avoid disk storms — it has no watchdog. + if ($this->workerSlot !== null) { + $this->writeWorkerRecord(); return; } if ($this->activity() === $this->writtenActivity) { @@ -459,8 +528,18 @@ private function flushStatus(): void $this->writeStatus(); } + /** + * Persists the status record now: a daemon worker writes its per-slot + * heartbeat, a bare foreground process writes (and owns) its own record. + */ private function writeStatus(): void { + // A daemon worker reports to its per-slot heartbeat record in the owner + // daemon's store; a bare foreground process owns its own record. + if ($this->workerSlot !== null) { + $this->writeWorkerRecord(); + return; + } if (!$this->ownsRecord) { return; } @@ -481,11 +560,45 @@ className: static::class, $this->writtenActivity = $activity; } + /** + * Writes the per-slot heartbeat a daemon worker reports to the supervisor. + * Keyed by the owner daemon's class + slot, so the supervisor aggregates all + * workers into its {@see \Flytachi\Winter\K2\Process\Daemon\DaemonStatus}. + */ + private function writeWorkerRecord(): void + { + $activity = $this->activity(); + $this->lastHeartbeatAt = microtime(true); + try { + $key = hash('xxh64', $this->ownerClass . '#' . $this->workerSlot); + (new ProcessStore($this->ownerClass))->main()->write($key, new ProcessStatus( + pid: $this->pid, + className: static::class, + state: $this->state, + activity: $activity, + startedAt: $this->startedAt, + concurrency: $this->concurrency, + heartbeatAt: time(), + )); + } catch (\Throwable $e) { + $this->logger->warning('Worker status write failed: ' . $e->getMessage()); + return; + } + $this->writtenActivity = $activity; + } + + /** + * Stable store key for this process class (its status record's address). + */ final protected static function key(): string { return hash('xxh64', static::class); } + /** + * The runnable store — one status record per class — shared by the CLI and + * the web layer so both read the same source of truth. + */ final protected static function store(): FileStorage { return (new ProcessStore(static::class))->main(); diff --git a/src/Dev/Process/ProcessAlreadyRunningException.php b/src/Process/ProcessAlreadyRunningException.php similarity index 90% rename from src/Dev/Process/ProcessAlreadyRunningException.php rename to src/Process/ProcessAlreadyRunningException.php index a0235d1..c95b709 100644 --- a/src/Dev/Process/ProcessAlreadyRunningException.php +++ b/src/Process/ProcessAlreadyRunningException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Process; +namespace Flytachi\Winter\K2\Process; /** * Thrown by {@see Process::start()} / {@see Process::dispatch()} when an instance diff --git a/src/Dev/Process/ProcessRunnable.php b/src/Process/ProcessRunnable.php similarity index 72% rename from src/Dev/Process/ProcessRunnable.php rename to src/Process/ProcessRunnable.php index 6aa89b8..d35bac9 100644 --- a/src/Dev/Process/ProcessRunnable.php +++ b/src/Process/ProcessRunnable.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Process; +namespace Flytachi\Winter\K2\Process; use Flytachi\Winter\Thread\Runnable; @@ -24,6 +24,12 @@ public function __construct(private string $class) { } + /** + * Runs in the detached child: starts the process in the foreground, so the + * child becomes the process. {@inheritDoc} + * + * @param array $args Launch arguments (unused — a process is self-configuring). + */ public function run(array $args): void { ($this->class)::start(); diff --git a/src/Dev/Process/ProcessState.php b/src/Process/ProcessState.php similarity index 94% rename from src/Dev/Process/ProcessState.php rename to src/Process/ProcessState.php index e0b9bd1..5092e05 100644 --- a/src/Dev/Process/ProcessState.php +++ b/src/Process/ProcessState.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Process; +namespace Flytachi\Winter\K2\Process; /** * Lifecycle state of a {@see Process}. diff --git a/src/Dev/Process/ProcessStatus.php b/src/Process/ProcessStatus.php similarity index 85% rename from src/Dev/Process/ProcessStatus.php rename to src/Process/ProcessStatus.php index a415d50..d2e17f3 100644 --- a/src/Dev/Process/ProcessStatus.php +++ b/src/Process/ProcessStatus.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Process; +namespace Flytachi\Winter\K2\Process; /** * Persisted status record of a {@see Process}. @@ -24,9 +24,13 @@ public function __construct( public int $startedAt, public int $concurrency = 0, public ?ResourceUsage $usage = null, + public int $heartbeatAt = 0, ) { } + /** + * Start time as a human-readable timestamp with timezone (for the CLI/web view). + */ public function getStartedAt(): string { return date('Y-m-d H:i:s P', $this->startedAt); @@ -45,6 +49,7 @@ public function jsonSerialize(): array 'started_at' => $this->startedAt, 'uptime' => time() - $this->startedAt, 'concurrency' => $this->concurrency, + 'heartbeat_at' => $this->heartbeatAt, // last liveness beat (0 = none) 'usage' => $this->usage, // ResourceUsage|null (also JsonSerializable) ]; } diff --git a/src/Dev/Process/ProcessStore.php b/src/Process/ProcessStore.php similarity index 59% rename from src/Dev/Process/ProcessStore.php rename to src/Process/ProcessStore.php index 439337d..0c0c6b3 100644 --- a/src/Dev/Process/ProcessStore.php +++ b/src/Process/ProcessStore.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Process; +namespace Flytachi\Winter\K2\Process; use Flytachi\FileStore\FileStorage; use Flytachi\Winter\K2\Kernel; @@ -11,18 +11,25 @@ * Locates the runnable store for a process class. * * One record per class, keyed by the dotted class name — the same convention - * as {@see \Flytachi\Winter\K2\Process\Core\DaemonStore}, so CLI and web read + * as {@see \Flytachi\Winter\K2\Old\Process\Core\DaemonStore}, so CLI and web read * from a single place. */ final class ProcessStore { private string $key; + /** + * @param string $className Process class whose record this store addresses; + * the dotted form is the on-disk key. + */ public function __construct(string $className) { $this->key = str_replace('\\', '.', $className); } + /** + * The backing {@see FileStorage} for this class's runnable records. + */ public function main(): FileStorage { return Kernel::runnable($this->key); diff --git a/src/Dev/Process/ResourceUsage.php b/src/Process/ResourceUsage.php similarity index 97% rename from src/Dev/Process/ResourceUsage.php rename to src/Process/ResourceUsage.php index cdbf113..9437f4b 100644 --- a/src/Dev/Process/ResourceUsage.php +++ b/src/Process/ResourceUsage.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Dev\Process; +namespace Flytachi\Winter\K2\Process; /** * A resource snapshot of a running process, taken from `ps`. @@ -31,7 +31,7 @@ public function __construct( public static function ofPid(int $pid): ?self { $command = sprintf( - 'ps -p %d -o pid=,ppid=,user=,%%cpu=,%%mem=,rss=,etime=,command=', + 'ps -p %d -o pid=,ppid=,user=,%%cpu=,%%mem=,rss=,etime=,command= 2>/dev/null', $pid ); exec($command, $output, $exitCode); diff --git a/src/Stereotype/Daemon.php b/src/Stereotype/Daemon.php index 8445273..c4881c3 100644 --- a/src/Stereotype/Daemon.php +++ b/src/Stereotype/Daemon.php @@ -4,7 +4,7 @@ namespace Flytachi\Winter\K2\Stereotype; -use Flytachi\Winter\K2\Process\ThreadDaemon; +use Flytachi\Winter\K2\Old\Process\ThreadDaemon; abstract class Daemon extends ThreadDaemon { diff --git a/src/Stereotype/Job.php b/src/Stereotype/Job.php index 61bbb34..2a32149 100644 --- a/src/Stereotype/Job.php +++ b/src/Stereotype/Job.php @@ -4,7 +4,7 @@ namespace Flytachi\Winter\K2\Stereotype; -use Flytachi\Winter\K2\Process\ThreadJob; +use Flytachi\Winter\K2\Old\Process\ThreadJob; abstract class Job extends ThreadJob { diff --git a/src/Stereotype/Process.php b/src/Stereotype/Process.php index 655dcd0..6ee3c78 100644 --- a/src/Stereotype/Process.php +++ b/src/Stereotype/Process.php @@ -4,7 +4,7 @@ namespace Flytachi\Winter\K2\Stereotype; -use Flytachi\Winter\K2\Process\ThreadProcess; +use Flytachi\Winter\K2\Old\Process\ThreadProcess; abstract class Process extends ThreadProcess { diff --git a/src/Stereotype/WebSocket.php b/src/Stereotype/WebSocket.php index 2661e3a..2bacf04 100644 --- a/src/Stereotype/WebSocket.php +++ b/src/Stereotype/WebSocket.php @@ -4,7 +4,7 @@ namespace Flytachi\Winter\K2\Stereotype; -use Flytachi\Winter\K2\Process\Socket\Web\ThreadWebSocket; +use Flytachi\Winter\K2\Old\Process\Socket\Web\ThreadWebSocket; abstract class WebSocket extends ThreadWebSocket { diff --git a/tests/Process/ActivityTest.php b/tests/Process/ActivityTest.php new file mode 100644 index 0000000..4a26e77 --- /dev/null +++ b/tests/Process/ActivityTest.php @@ -0,0 +1,33 @@ +getBackingType()?->getName()); + } + + public function test_cases_and_values(): void + { + self::assertSame('idle', Activity::IDLE->value); + self::assertSame('busy', Activity::BUSY->value); + } + + public function test_exactly_two_cases(): void + { + self::assertCount(2, Activity::cases()); + } + + public function test_from_value_round_trips(): void + { + self::assertSame(Activity::BUSY, Activity::from('busy')); + self::assertSame(Activity::IDLE, Activity::from('idle')); + } +} diff --git a/tests/Process/Daemon/DaemonStatusTest.php b/tests/Process/Daemon/DaemonStatusTest.php new file mode 100644 index 0000000..1beb983 --- /dev/null +++ b/tests/Process/Daemon/DaemonStatusTest.php @@ -0,0 +1,72 @@ +make()); + } + + public function test_daemon_fields(): void + { + $workers = [new WorkerStatus(0, 11, SlotState::RUNNING, Activity::BUSY, time(), 0)]; + $status = $this->make($workers, 7); + + self::assertSame(7, $status->restarts); + self::assertSame($workers, $status->workers); + } + + public function test_json_merges_base_and_daemon_fields(): void + { + $workers = [ + new WorkerStatus(0, 11, SlotState::RUNNING, Activity::BUSY, time(), 0), + new WorkerStatus(1, 12, SlotState::RETIRING, Activity::IDLE, time(), 1), + ]; + $json = $this->make($workers, 3)->jsonSerialize(); + + // base keys still present + self::assertArrayHasKey('pid', $json); + self::assertArrayHasKey('state', $json); + self::assertArrayHasKey('heartbeat_at', $json); + // daemon-only keys added + self::assertSame(3, $json['restarts']); + self::assertCount(2, $json['workers']); + self::assertSame($workers, $json['workers']); + } + + public function test_encodes_to_json_string_with_nested_workers(): void + { + $workers = [new WorkerStatus(0, 11, SlotState::RUNNING, Activity::BUSY, time(), 0)]; + $decoded = json_decode((string) json_encode($this->make($workers, 1)), true); + + self::assertSame(1, $decoded['restarts']); + self::assertSame('running', $decoded['workers'][0]['state']); + self::assertSame(11, $decoded['workers'][0]['pid']); + } +} diff --git a/tests/Process/Daemon/DaemonTest.php b/tests/Process/Daemon/DaemonTest.php new file mode 100644 index 0000000..c4c5b8d --- /dev/null +++ b/tests/Process/Daemon/DaemonTest.php @@ -0,0 +1,163 @@ +invoke($o, ...$args); + } + + // --- introspection accessors + clamping ------------------------------- + + // These accessors are internal (consumed by the SupervisesFleet trait), so + // they are reflected — clamping behaviour is still asserted directly. + + public function test_replicas_clamps_to_at_least_one(): void + { + self::assertSame(1, $this->priv(new DefaultDaemon(), 'replicas')); + self::assertSame(3, $this->priv(new InlineDaemon(), 'replicas')); + self::assertSame(1, $this->priv(new ClampDaemon(), 'replicas')); // configured 0 → 1 + } + + public function test_compute_desired_clamps_to_at_least_zero(): void + { + self::assertSame(1, $this->priv(new DefaultDaemon(), 'computeDesired')); // = replicas() + self::assertSame(4, $this->priv(new InlineDaemon(), 'computeDesired')); + self::assertSame(0, $this->priv(new ClampDaemon(), 'computeDesired')); // configured -3 → 0 + } + + public function test_grace_seconds_clamps_and_defaults_to_thirty(): void + { + self::assertSame(30.0, $this->priv(new DefaultDaemon(), 'graceSeconds')); // k8s-parity default + self::assertSame(5.0, $this->priv(new InlineDaemon(), 'graceSeconds')); + self::assertSame(0.0, $this->priv(new ClampDaemon(), 'graceSeconds')); // configured -5 → 0 + } + + public function test_liveness_timeout_clamps_and_defaults_to_off(): void + { + self::assertSame(0.0, $this->priv(new DefaultDaemon(), 'livenessTimeout')); // watchdog off + self::assertSame(7.0, $this->priv(new InlineDaemon(), 'livenessTimeout')); + self::assertSame(0.0, $this->priv(new ClampDaemon(), 'livenessTimeout')); // configured -1 → 0 + } + + // --- policies ---------------------------------------------------------- + + public function test_default_scaling_policy(): void + { + $p = $this->priv(new DefaultDaemon(), 'scalingPolicy'); + self::assertInstanceOf(ScalingPolicy::class, $p); + self::assertEquals(ScalingPolicy::default(), $p); + } + + public function test_overridden_scaling_policy(): void + { + $p = $this->priv(new InlineDaemon(), 'scalingPolicy'); + self::assertSame(2.0, $p->scaleInterval); + self::assertSame(2, $p->scaleStep); + } + + public function test_default_restart_policy(): void + { + $p = $this->priv(new DefaultDaemon(), 'restartPolicy'); + self::assertInstanceOf(RestartPolicy::class, $p); + self::assertSame(RestartMode::ON_FAILURE, $p->mode); + self::assertSame(0, $p->maxRestarts); + } + + public function test_overridden_restart_policy(): void + { + $p = $this->priv(new InlineDaemon(), 'restartPolicy'); + self::assertSame(RestartMode::ALWAYS, $p->mode); + self::assertSame(4, $p->maxRestarts); + self::assertSame(0.25, $p->backoff); + } + + // --- worker body resolution ------------------------------------------- + + public function test_defines_worker_run_true_when_overridden(): void + { + self::assertTrue($this->priv(new InlineDaemon(), 'definesWorkerRun')); + self::assertTrue($this->priv(new DefaultDaemon(), 'definesWorkerRun')); + } + + public function test_defines_worker_run_false_when_using_worker_class(): void + { + self::assertFalse($this->priv(new ExternalDaemon(), 'definesWorkerRun')); + } + + public function test_boot_worker_throws_when_no_body_configured(): void + { + $this->expectException(DaemonConfigException::class); + $this->priv(new BlankDaemon(), 'bootWorker', 0); + } + + public function test_run_delegates_to_worker_run_and_throws_when_undefined(): void + { + // BlankDaemon has no workerRun → the base default throws. + $this->expectException(DaemonConfigException::class); + (new BlankDaemon())->run(); + } + + public function test_run_delegates_to_defined_worker_run_without_throwing(): void + { + // InlineDaemon defines an empty workerRun → run() is a clean no-op. + (new InlineDaemon())->run(); + $this->addToAssertionCount(1); + } + + public function test_run_is_final(): void + { + self::assertTrue((new \ReflectionMethod(Daemon::class, 'run'))->isFinal()); + } + + // --- titles ------------------------------------------------------------ + + public function test_master_title(): void + { + self::assertSame('winter-daemon: DefaultDaemon master', $this->priv(new DefaultDaemon(), 'buildProcessTitle')); + } + + public function test_worker_title_is_one_based(): void + { + $d = new DefaultDaemon(); + self::assertSame('winter-daemon: DefaultDaemon worker#1', $this->priv($d, 'workerTitle', 0)); + self::assertSame('winter-daemon: DefaultDaemon worker#3', $this->priv($d, 'workerTitle', 2)); + } + + // --- hook fan-out ------------------------------------------------------ + + public function test_fire_hooks_invoke_the_protected_callbacks(): void + { + $d = new InlineDaemon(); + + $this->priv($d, 'fireWorkerStart', 0, 111); + $this->priv($d, 'fireWorkerStart', 1, 222); + $this->priv($d, 'fireWorkerExit', 1, 222, true); + $this->priv($d, 'fireScale', 2, 5); + $this->priv($d, 'fireTick'); + $this->priv($d, 'fireTick'); + $this->priv($d, 'fireReload'); + + self::assertSame([[0, 111], [1, 222]], $d->started); + self::assertSame([[1, 222, true]], $d->exited); + self::assertSame([[2, 5]], $d->scaled); + self::assertSame(2, $d->ticks); + self::assertSame(1, $d->reloads); + } +} diff --git a/tests/Process/Daemon/RestartModeTest.php b/tests/Process/Daemon/RestartModeTest.php new file mode 100644 index 0000000..498f9c8 --- /dev/null +++ b/tests/Process/Daemon/RestartModeTest.php @@ -0,0 +1,43 @@ +getBackingType()); + } + + public function test_exactly_three_cases(): void + { + self::assertCount(3, RestartMode::cases()); + self::assertSame(['ALWAYS', 'ON_FAILURE', 'NEVER'], array_map( + static fn(RestartMode $m) => $m->name, + RestartMode::cases() + )); + } + + public function test_always_restarts_on_any_exit(): void + { + self::assertTrue(RestartMode::ALWAYS->shouldRestart(true)); + self::assertTrue(RestartMode::ALWAYS->shouldRestart(false)); + } + + public function test_on_failure_restarts_only_on_crash(): void + { + self::assertTrue(RestartMode::ON_FAILURE->shouldRestart(true)); + self::assertFalse(RestartMode::ON_FAILURE->shouldRestart(false)); + } + + public function test_never_restarts(): void + { + self::assertFalse(RestartMode::NEVER->shouldRestart(true)); + self::assertFalse(RestartMode::NEVER->shouldRestart(false)); + } +} diff --git a/tests/Process/Daemon/RestartPolicyTest.php b/tests/Process/Daemon/RestartPolicyTest.php new file mode 100644 index 0000000..90eb126 --- /dev/null +++ b/tests/Process/Daemon/RestartPolicyTest.php @@ -0,0 +1,56 @@ +mode); + self::assertSame(0, $p->maxRestarts); + self::assertSame(1.0, $p->backoff); + } + + public function test_constructor_defaults_match_default_factory(): void + { + $p = new RestartPolicy(); + self::assertSame(RestartMode::ON_FAILURE, $p->mode); + self::assertSame(0, $p->maxRestarts); + self::assertSame(1.0, $p->backoff); + } + + public function test_named_construction_and_field_types(): void + { + $p = new RestartPolicy(mode: RestartMode::ALWAYS, maxRestarts: 5, backoff: 2.5); + self::assertSame(RestartMode::ALWAYS, $p->mode); + self::assertSame(5, $p->maxRestarts); + self::assertSame(2.5, $p->backoff); + self::assertIsInt($p->maxRestarts); + self::assertIsFloat($p->backoff); + } + + public function test_should_restart_delegates_to_mode(): void + { + self::assertTrue((new RestartPolicy(mode: RestartMode::ALWAYS))->shouldRestart(false)); + self::assertTrue((new RestartPolicy(mode: RestartMode::ON_FAILURE))->shouldRestart(true)); + self::assertFalse((new RestartPolicy(mode: RestartMode::ON_FAILURE))->shouldRestart(false)); + self::assertFalse((new RestartPolicy(mode: RestartMode::NEVER))->shouldRestart(true)); + } + + public function test_is_readonly(): void + { + self::assertTrue((new \ReflectionClass(RestartPolicy::class))->isReadOnly()); + } + + public function test_is_not_final_so_named_profiles_can_subclass(): void + { + self::assertFalse((new \ReflectionClass(RestartPolicy::class))->isFinal()); + } +} diff --git a/tests/Process/Daemon/ScalingPolicyTest.php b/tests/Process/Daemon/ScalingPolicyTest.php new file mode 100644 index 0000000..46c3a89 --- /dev/null +++ b/tests/Process/Daemon/ScalingPolicyTest.php @@ -0,0 +1,56 @@ +scaleInterval); + self::assertSame(0.0, $p->scaleUpDelay); + self::assertSame(60.0, $p->scaleDownStabilization); + self::assertSame(3.0, $p->cooldown); + self::assertSame(0, $p->scaleStep); + } + + public function test_constructor_defaults_match_default_factory(): void + { + $a = new ScalingPolicy(); + $b = ScalingPolicy::default(); + self::assertEquals($a, $b); + } + + public function test_named_construction_and_types(): void + { + $p = new ScalingPolicy( + scaleInterval: 0.5, + scaleUpDelay: 2.0, + scaleDownStabilization: 120.0, + cooldown: 10.0, + scaleStep: 4, + ); + self::assertSame(0.5, $p->scaleInterval); + self::assertSame(2.0, $p->scaleUpDelay); + self::assertSame(120.0, $p->scaleDownStabilization); + self::assertSame(10.0, $p->cooldown); + self::assertSame(4, $p->scaleStep); + self::assertIsInt($p->scaleStep); + self::assertIsFloat($p->scaleDownStabilization); + } + + public function test_is_readonly(): void + { + self::assertTrue((new \ReflectionClass(ScalingPolicy::class))->isReadOnly()); + } + + public function test_is_not_final_so_named_profiles_can_subclass(): void + { + self::assertFalse((new \ReflectionClass(ScalingPolicy::class))->isFinal()); + } +} diff --git a/tests/Process/Daemon/SlotStateTest.php b/tests/Process/Daemon/SlotStateTest.php new file mode 100644 index 0000000..5cc3b91 --- /dev/null +++ b/tests/Process/Daemon/SlotStateTest.php @@ -0,0 +1,67 @@ +getBackingType()?->getName()); + } + + public function test_all_cases_present(): void + { + $values = array_map(static fn(SlotState $s) => $s->value, SlotState::cases()); + self::assertSame( + ['empty', 'starting', 'running', 'retiring', 'killing', 'restarting', 'retired'], + $values + ); + } + + /** + * isCommitted = counts toward the fleet size reconcile drives to desired. + */ + public function test_is_committed_matrix(): void + { + self::assertTrue(SlotState::STARTING->isCommitted()); + self::assertTrue(SlotState::RUNNING->isCommitted()); + self::assertTrue(SlotState::RESTARTING->isCommitted()); + self::assertTrue(SlotState::RETIRED->isCommitted()); + + self::assertFalse(SlotState::EMPTY->isCommitted()); + self::assertFalse(SlotState::RETIRING->isCommitted()); + self::assertFalse(SlotState::KILLING->isCommitted()); + } + + /** + * isAlive = has a live OS process attached. + */ + public function test_is_alive_matrix(): void + { + self::assertTrue(SlotState::STARTING->isAlive()); + self::assertTrue(SlotState::RUNNING->isAlive()); + self::assertTrue(SlotState::RETIRING->isAlive()); + self::assertTrue(SlotState::KILLING->isAlive()); + + self::assertFalse(SlotState::EMPTY->isAlive()); + self::assertFalse(SlotState::RESTARTING->isAlive()); + self::assertFalse(SlotState::RETIRED->isAlive()); + } + + /** + * A committed-but-not-alive state (RESTARTING/RETIRED) is exactly what keeps + * reconcile from refilling a slot the restart path already owns. + */ + public function test_restarting_and_retired_are_committed_but_not_alive(): void + { + foreach ([SlotState::RESTARTING, SlotState::RETIRED] as $state) { + self::assertTrue($state->isCommitted(), $state->value); + self::assertFalse($state->isAlive(), $state->value); + } + } +} diff --git a/tests/Process/Daemon/SlotTest.php b/tests/Process/Daemon/SlotTest.php new file mode 100644 index 0000000..2e2610d --- /dev/null +++ b/tests/Process/Daemon/SlotTest.php @@ -0,0 +1,49 @@ +index); + self::assertSame(SlotState::EMPTY, $slot->state); + self::assertSame(0, $slot->pid); + self::assertTrue(is_infinite($slot->deadline)); + self::assertSame(0.0, $slot->restartAt); + self::assertSame(0, $slot->restarts); + self::assertSame(0, $slot->startedAt); + self::assertSame(Activity::IDLE, $slot->activity); + self::assertSame(0, $slot->heartbeatAt); + self::assertFalse($slot->killed); + } + + public function test_index_is_readonly(): void + { + $prop = new \ReflectionProperty(Slot::class, 'index'); + self::assertTrue($prop->isReadOnly()); + } + + public function test_state_is_mutable_in_place(): void + { + $slot = new Slot(0); + $slot->state = SlotState::RUNNING; + $slot->pid = 4242; + $slot->activity = Activity::BUSY; + $slot->killed = true; + + self::assertSame(SlotState::RUNNING, $slot->state); + self::assertSame(4242, $slot->pid); + self::assertSame(Activity::BUSY, $slot->activity); + self::assertTrue($slot->killed); + } +} diff --git a/tests/Process/Daemon/SupervisesFleetTest.php b/tests/Process/Daemon/SupervisesFleetTest.php new file mode 100644 index 0000000..43ebce0 --- /dev/null +++ b/tests/Process/Daemon/SupervisesFleetTest.php @@ -0,0 +1,224 @@ +daemon = new StubDaemon(); + } + + private function call(string $method, mixed ...$args): mixed + { + return (new \ReflectionMethod($this->daemon, $method))->invoke($this->daemon, ...$args); + } + + /** @param array $slots */ + private function setSlots(array $slots): void + { + // The property is declared by the SupervisesFleet trait on Daemon, so it + // must be reflected via the declaring class, not the StubDaemon subclass. + (new \ReflectionProperty(Daemon::class, 'slots'))->setValue($this->daemon, $slots); + } + + /** @param list $history */ + private function setHistory(array $history): void + { + (new \ReflectionProperty(Daemon::class, 'desiredHistory'))->setValue($this->daemon, $history); + } + + private function slot(int $index, SlotState $state, Activity $activity = Activity::IDLE): Slot + { + $s = new Slot($index); + $s->state = $state; + $s->activity = $activity; + return $s; + } + + // --- back-off ---------------------------------------------------------- + + public function test_backoff_is_exponential(): void + { + self::assertSame(1.0, $this->call('backoff', 1.0, 1)); + self::assertSame(2.0, $this->call('backoff', 1.0, 2)); + self::assertSame(4.0, $this->call('backoff', 1.0, 3)); + self::assertSame(8.0, $this->call('backoff', 1.0, 4)); + self::assertSame(1.0, $this->call('backoff', 0.5, 2)); // 0.5 * 2^1 + } + + public function test_backoff_is_capped_at_thirty_seconds(): void + { + self::assertSame(30.0, $this->call('backoff', 1.0, 10)); // 512 → capped + } + + public function test_backoff_zero_for_no_failures_or_no_base(): void + { + self::assertSame(0.0, $this->call('backoff', 1.0, 0)); + self::assertSame(0.0, $this->call('backoff', 0.0, 3)); + } + + // --- windowExtreme (the stabilization core) --------------------------- + + public function test_window_extreme_max_over_window_excludes_aged_entries(): void + { + $now = microtime(true); + // An old high reading (100s ago) plus recent low readings. + $this->setHistory([[$now - 100.0, 8], [$now - 1.0, 4], [$now, 4]]); + + // 60s window: the old 8 is aged out → high-water is 4. + self::assertSame(4, $this->call('windowExtreme', $now - 60.0, true)); + // 200s window: the 8 is still in → high-water is 8 (no shrink yet). + self::assertSame(8, $this->call('windowExtreme', $now - 200.0, true)); + } + + public function test_window_extreme_min_over_window(): void + { + $now = microtime(true); + $this->setHistory([[$now - 5.0, 9], [$now - 1.0, 3], [$now, 6]]); + self::assertSame(3, $this->call('windowExtreme', $now - 10.0, false)); + } + + // --- effectiveDesired (asymmetric damping) ---------------------------- + + public function test_scale_up_reacts_to_the_raw_target(): void + { + $this->daemon->desired = 8; + // committed = 1, raw = 8 → grow to 8. + self::assertSame(8, $this->call('effectiveDesired', 1, ScalingPolicy::default())); + } + + public function test_stable_when_raw_equals_committed(): void + { + $this->daemon->desired = 5; + self::assertSame(5, $this->call('effectiveDesired', 5, ScalingPolicy::default())); + } + + public function test_scale_down_when_low_demand_is_the_only_reading(): void + { + $this->daemon->desired = 4; + // committed = 8, the only reading is 4 → shrink to 4. + self::assertSame(4, $this->call('effectiveDesired', 8, ScalingPolicy::default())); + } + + public function test_scale_down_is_held_off_while_a_recent_high_reading_stands(): void + { + $policy = new ScalingPolicy(scaleDownStabilization: 60.0); + + $this->daemon->desired = 8; + $this->call('effectiveDesired', 8, $policy); // records a high reading (8) + + // Demand drops to 4, but the high-water over the stabilization window is + // still 8, so the fleet does NOT shrink yet. + $this->daemon->desired = 4; + self::assertSame(8, $this->call('effectiveDesired', 8, $policy)); + } + + // --- victim selection (IDLE-first, then highest slot) ------------------ + + public function test_pick_victims_prefers_idle_then_highest_index(): void + { + $this->setSlots([ + $this->slot(0, SlotState::RUNNING, Activity::BUSY), + $this->slot(1, SlotState::RUNNING, Activity::IDLE), + $this->slot(2, SlotState::RUNNING, Activity::IDLE), + $this->slot(3, SlotState::RUNNING, Activity::BUSY), + $this->slot(4, SlotState::RETIRING, Activity::IDLE), // not a candidate + $this->slot(5, SlotState::EMPTY), // not a candidate + ]); + + $indexes = array_map(static fn(Slot $s) => $s->index, $this->call('pickVictims', 2)); + // Two IDLE workers, highest index first. + self::assertSame([2, 1], $indexes); + } + + public function test_pick_victims_falls_back_to_busy_by_highest_index(): void + { + $this->setSlots([ + $this->slot(0, SlotState::RUNNING, Activity::BUSY), + $this->slot(1, SlotState::RUNNING, Activity::IDLE), + $this->slot(2, SlotState::RUNNING, Activity::BUSY), + ]); + + $indexes = array_map(static fn(Slot $s) => $s->index, $this->call('pickVictims', 3)); + // IDLE #1 first, then BUSY by highest index (#2 then #0). + self::assertSame([1, 2, 0], $indexes); + } + + public function test_pick_victims_zero_or_no_candidates_returns_empty(): void + { + $this->setSlots([$this->slot(0, SlotState::RETIRING), $this->slot(1, SlotState::EMPTY)]); + self::assertSame([], $this->call('pickVictims', 2)); + + $this->setSlots([$this->slot(0, SlotState::RUNNING)]); + self::assertSame([], $this->call('pickVictims', 0)); + } + + // --- slot accounting --------------------------------------------------- + + public function test_committed_count_includes_starting_running_restarting_retired(): void + { + $this->setSlots([ + $this->slot(0, SlotState::STARTING), + $this->slot(1, SlotState::RUNNING), + $this->slot(2, SlotState::RESTARTING), + $this->slot(3, SlotState::RETIRED), + $this->slot(4, SlotState::RETIRING), // not committed + $this->slot(5, SlotState::KILLING), // not committed + $this->slot(6, SlotState::EMPTY), // not committed + ]); + + self::assertSame(4, $this->call('committedCount')); + } + + public function test_alive_count_includes_states_with_a_live_process(): void + { + $this->setSlots([ + $this->slot(0, SlotState::STARTING), + $this->slot(1, SlotState::RUNNING), + $this->slot(2, SlotState::RETIRING), + $this->slot(3, SlotState::KILLING), + $this->slot(4, SlotState::RESTARTING), // not alive + $this->slot(5, SlotState::RETIRED), // not alive + $this->slot(6, SlotState::EMPTY), // not alive + ]); + + self::assertSame(4, $this->call('aliveCount')); + } + + public function test_next_free_index_returns_lowest_empty_slot(): void + { + $this->setSlots([ + $this->slot(0, SlotState::RUNNING), + $this->slot(1, SlotState::EMPTY), + $this->slot(2, SlotState::RUNNING), + ]); + self::assertSame(1, $this->call('nextFreeIndex')); + } + + public function test_next_free_index_appends_when_all_occupied(): void + { + $this->setSlots([ + $this->slot(0, SlotState::RUNNING), + $this->slot(1, SlotState::RUNNING), + ]); + self::assertSame(2, $this->call('nextFreeIndex')); + } +} diff --git a/tests/Process/Daemon/WorkerStatusTest.php b/tests/Process/Daemon/WorkerStatusTest.php new file mode 100644 index 0000000..004cef6 --- /dev/null +++ b/tests/Process/Daemon/WorkerStatusTest.php @@ -0,0 +1,61 @@ +slot); + self::assertSame(5555, $w->pid); + self::assertSame(SlotState::RUNNING, $w->state); + self::assertSame(Activity::BUSY, $w->activity); + self::assertSame(2, $w->restarts); + } + + public function test_json_shape_and_types(): void + { + $started = time() - 30; + $json = (new WorkerStatus(3, 4321, SlotState::RETIRING, Activity::IDLE, $started, 1))->jsonSerialize(); + + self::assertSame( + ['slot', 'pid', 'state', 'activity', 'started_at', 'uptime', 'restarts'], + array_keys($json) + ); + self::assertSame(3, $json['slot']); + self::assertSame(4321, $json['pid']); + self::assertSame('retiring', $json['state']); // SlotState->value + self::assertSame('idle', $json['activity']); // Activity->value + self::assertSame($started, $json['started_at']); + self::assertGreaterThanOrEqual(30, $json['uptime']); + self::assertSame(1, $json['restarts']); + } + + public function test_uptime_zero_when_not_started(): void + { + $json = (new WorkerStatus(0, 0, SlotState::RETIRED, Activity::IDLE, 0, 0))->jsonSerialize(); + self::assertSame(0, $json['uptime']); + } + + public function test_encodes_to_json_string(): void + { + $w = new WorkerStatus(0, 10, SlotState::STARTING, Activity::IDLE, time(), 0); + self::assertIsString(json_encode($w)); + } +} diff --git a/tests/Process/ExceptionsTest.php b/tests/Process/ExceptionsTest.php new file mode 100644 index 0000000..8c1eb89 --- /dev/null +++ b/tests/Process/ExceptionsTest.php @@ -0,0 +1,42 @@ +getMessage()); + } + + public function test_interrupted_is_a_runtime_exception(): void + { + self::assertInstanceOf(\RuntimeException::class, new InterruptedException()); + } + + public function test_daemon_config_is_a_runtime_exception(): void + { + self::assertInstanceOf(\RuntimeException::class, new DaemonConfigException('bad')); + } + + public function test_are_throwable_and_catchable(): void + { + try { + throw new DaemonConfigException('no body'); + } catch (DaemonConfigException $e) { + self::assertSame('no body', $e->getMessage()); + return; + } + + self::fail('exception was not caught'); + } +} diff --git a/tests/Process/Fixtures/AutoscaleLoopDaemon.php b/tests/Process/Fixtures/AutoscaleLoopDaemon.php new file mode 100644 index 0000000..284f437 --- /dev/null +++ b/tests/Process/Fixtures/AutoscaleLoopDaemon.php @@ -0,0 +1,52 @@ +bootAt === 0.0) { + $this->bootAt = microtime(true); + } + $elapsed = microtime(true) - $this->bootAt; + + if ($elapsed < 2.0) { + return 1; + } + if ($elapsed < 5.0) { + return 4; + } + return 2; + } + + protected function workerRun(): void + { + while ($this->isRunning()) { + $this->sleep(0.2); + } + } +} diff --git a/tests/Process/Fixtures/BlankDaemon.php b/tests/Process/Fixtures/BlankDaemon.php new file mode 100644 index 0000000..7124d4f --- /dev/null +++ b/tests/Process/Fixtures/BlankDaemon.php @@ -0,0 +1,16 @@ +isRunning()) { + $this->markBusy(); + $this->sleep(0.15); + $this->markIdle(); + $this->sleep(0.15); + } + } +} diff --git a/tests/Process/Fixtures/ClampDaemon.php b/tests/Process/Fixtures/ClampDaemon.php new file mode 100644 index 0000000..d65807b --- /dev/null +++ b/tests/Process/Fixtures/ClampDaemon.php @@ -0,0 +1,27 @@ += 1, desired >= 0, grace/liveness >= 0). + */ +final class ClampDaemon extends Daemon +{ + protected int $replicas = 0; + protected float $grace = -5.0; + protected float $livenessTimeout = -1.0; + + protected function desiredReplicas(): int + { + return -3; + } + + protected function workerRun(): void + { + } +} diff --git a/tests/Process/Fixtures/CrashCapDaemon.php b/tests/Process/Fixtures/CrashCapDaemon.php new file mode 100644 index 0000000..7a63d34 --- /dev/null +++ b/tests/Process/Fixtures/CrashCapDaemon.php @@ -0,0 +1,29 @@ +sleep(0.4); + while (true) { + // wedged — blocks the reactor, no heartbeat lands + } + } +} diff --git a/tests/Process/Fixtures/InlineDaemon.php b/tests/Process/Fixtures/InlineDaemon.php new file mode 100644 index 0000000..4e18da8 --- /dev/null +++ b/tests/Process/Fixtures/InlineDaemon.php @@ -0,0 +1,76 @@ + */ + public array $started = []; + /** @var list */ + public array $exited = []; + /** @var list */ + public array $scaled = []; + public int $ticks = 0; + public int $reloads = 0; + + protected function desiredReplicas(): int + { + return $this->desired; + } + + protected function scaling(): ScalingPolicy + { + return new ScalingPolicy(scaleInterval: 2.0, scaleDownStabilization: 30.0, scaleStep: 2); + } + + protected function restart(): RestartPolicy + { + return new RestartPolicy(mode: RestartMode::ALWAYS, maxRestarts: 4, backoff: 0.25); + } + + protected function workerRun(): void + { + } + + protected function onWorkerStart(int $slot, int $pid): void + { + $this->started[] = [$slot, $pid]; + } + + protected function onWorkerExit(int $slot, int $pid, bool $crashed): void + { + $this->exited[] = [$slot, $pid, $crashed]; + } + + protected function onScale(int $from, int $to): void + { + $this->scaled[] = [$from, $to]; + } + + protected function tick(): void + { + $this->ticks++; + } + + protected function onReload(): void + { + $this->reloads++; + } +} diff --git a/tests/Process/Fixtures/LoopDaemon.php b/tests/Process/Fixtures/LoopDaemon.php new file mode 100644 index 0000000..0fcfd3b --- /dev/null +++ b/tests/Process/Fixtures/LoopDaemon.php @@ -0,0 +1,31 @@ +isRunning()) { + $this->sleep(0.2); + } + } +} diff --git a/tests/Process/Fixtures/LoopWorker.php b/tests/Process/Fixtures/LoopWorker.php new file mode 100644 index 0000000..7d00035 --- /dev/null +++ b/tests/Process/Fixtures/LoopWorker.php @@ -0,0 +1,22 @@ +isRunning()) { + $this->sleep(0.2); + } + } +} diff --git a/tests/Process/Fixtures/NeverCrashDaemon.php b/tests/Process/Fixtures/NeverCrashDaemon.php new file mode 100644 index 0000000..cec8f32 --- /dev/null +++ b/tests/Process/Fixtures/NeverCrashDaemon.php @@ -0,0 +1,29 @@ +mark('start'); + while ($this->isRunning()) { + $this->sleep(0.1); + } + } + + protected function onTerminate(): void + { + $this->mark('terminate'); + } + + protected function onInterrupt(): void + { + $this->mark('interrupt'); + } + + protected function onReload(): void + { + $this->mark('reload'); + } + + protected function onUser1(): void + { + $this->mark('user1'); + } + + protected function onUser2(): void + { + $this->mark('user2'); + } +} diff --git a/tests/Process/Fixtures/StubDaemon.php b/tests/Process/Fixtures/StubDaemon.php new file mode 100644 index 0000000..a1abbb2 --- /dev/null +++ b/tests/Process/Fixtures/StubDaemon.php @@ -0,0 +1,25 @@ +desired; + } + + protected function workerRun(): void + { + } +} diff --git a/tests/Process/Fixtures/StuckStopDaemon.php b/tests/Process/Fixtures/StuckStopDaemon.php new file mode 100644 index 0000000..6ec8315 --- /dev/null +++ b/tests/Process/Fixtures/StuckStopDaemon.php @@ -0,0 +1,25 @@ +addToAssertionCount(1); + } + + public function test_registered_handlers_run_in_order(): void + { + $order = []; + ForkReset::register(static function () use (&$order): void { + $order[] = 'a'; + }); + ForkReset::register(static function () use (&$order): void { + $order[] = 'b'; + }); + + ForkReset::runAll(); + + self::assertSame(['a', 'b'], $order); + } + + public function test_a_throwing_handler_does_not_block_the_others(): void + { + $ran = []; + ForkReset::register(static function () use (&$ran): void { + $ran[] = 'before'; + }); + ForkReset::register(static function (): void { + throw new \RuntimeException('boom'); + }); + ForkReset::register(static function () use (&$ran): void { + $ran[] = 'after'; + }); + + ForkReset::runAll(); + + self::assertSame(['before', 'after'], $ran); + } + + public function test_clear_removes_all_handlers(): void + { + $calls = 0; + ForkReset::register(static function () use (&$calls): void { + $calls++; + }); + + ForkReset::clear(); + ForkReset::runAll(); + + self::assertSame(0, $calls); + } +} diff --git a/tests/Process/Integration/DaemonIntegrationTest.php b/tests/Process/Integration/DaemonIntegrationTest.php new file mode 100644 index 0000000..cd7ed84 --- /dev/null +++ b/tests/Process/Integration/DaemonIntegrationTest.php @@ -0,0 +1,233 @@ + $class */ + private function daemonStatus(string $class): ?DaemonStatus + { + $s = $class::status(); + return $s instanceof DaemonStatus ? $s : null; + } + + public function test_forks_the_configured_replicas(): void + { + $sup = $this->fork(static fn() => LoopDaemon::start()); + + $ready = $this->pollUntil(function (): bool { + $st = $this->daemonStatus(LoopDaemon::class); + return $st !== null + && count($st->workers) === 2 + && count(array_filter($st->workers, fn($w) => $w->state === SlotState::RUNNING)) === 2; + }); + self::assertTrue($ready, 'two workers should reach RUNNING'); + + $st = $this->daemonStatus(LoopDaemon::class); + self::assertNotNull($st); + self::assertSame(0, $st->restarts); + $pids = array_map(static fn($w) => $w->pid, $st->workers); + self::assertCount(2, array_unique($pids), 'workers have distinct PIDs'); + self::assertSame($sup, $st->pid, 'status PID is the supervisor'); + } + + public function test_supervises_an_external_worker_class(): void + { + // $workerClass path: the daemon forks a sibling Process and boots it via + // the protected, cross-instance runWorker() — regression guard for that. + $sup = $this->fork(static fn() => WorkerClassDaemon::start()); + + $ready = $this->pollUntil(function (): bool { + $st = $this->daemonStatus(WorkerClassDaemon::class); + return $st !== null + && count(array_filter($st->workers, fn($w) => $w->state === SlotState::RUNNING)) === 2; + }); + self::assertTrue($ready, 'the external worker class is supervised into a running fleet'); + + $workerPids = array_map(static fn($w) => $w->pid, $this->daemonStatus(WorkerClassDaemon::class)->workers); + posix_kill($sup, SIGTERM); + + self::assertTrue($this->waitExit($sup), 'the fleet stops gracefully'); + foreach ($workerPids as $pid) { + self::assertFalse($this->isAlive($pid), "external worker {$pid} must not be orphaned"); + } + } + + public function test_graceful_stop_drains_the_whole_fleet_without_orphans(): void + { + $sup = $this->fork(static fn() => LoopDaemon::start()); + self::assertTrue($this->pollUntil( + fn() => ($st = $this->daemonStatus(LoopDaemon::class)) !== null && count($st->workers) === 2 + )); + + $workerPids = array_map(static fn($w) => $w->pid, $this->daemonStatus(LoopDaemon::class)->workers); + + posix_kill($sup, SIGTERM); + + self::assertTrue($this->waitExit($sup), 'supervisor exits gracefully'); + self::assertNull(LoopDaemon::status(), 'the store record is removed'); + foreach ($workerPids as $pid) { + self::assertFalse($this->isAlive($pid), "worker {$pid} must not be orphaned"); + } + } + + public function test_restarts_a_crashed_worker_in_the_same_slot(): void + { + $this->fork(static fn() => CrashLoopDaemon::start()); + + // Restarts accumulate while the same slot (#0) is reused. + $seen = $this->pollUntil(function (): bool { + $st = $this->daemonStatus(CrashLoopDaemon::class); + return $st !== null && $st->restarts >= 2; + }, timeout: 10.0); + self::assertTrue($seen, 'the crashed worker is restarted repeatedly'); + + $st = $this->daemonStatus(CrashLoopDaemon::class); + self::assertNotNull($st); + foreach ($st->workers as $w) { + self::assertSame(0, $w->slot, 'restart reuses slot #0'); + } + } + + public function test_gives_up_after_max_restarts_and_self_terminates(): void + { + $sup = $this->fork(static fn() => CrashCapDaemon::start()); + + // maxRestarts = 2 → the supervisor stops itself with no signal from us. + self::assertTrue($this->waitExit($sup, 10.0), 'daemon self-terminates on FAILED'); + self::assertNull(CrashCapDaemon::status()); + } + + public function test_never_policy_retires_crashed_workers_without_restarting(): void + { + $this->fork(static fn() => NeverCrashDaemon::start()); + + $retired = $this->pollUntil(function (): bool { + $st = $this->daemonStatus(NeverCrashDaemon::class); + return $st !== null + && count($st->workers) === 2 + && count(array_filter($st->workers, fn($w) => $w->state === SlotState::RETIRED)) === 2; + }, timeout: 8.0); + + self::assertTrue($retired, 'both crashed workers are retired terminally'); + $st = $this->daemonStatus(NeverCrashDaemon::class); + self::assertNotNull($st, 'the daemon keeps running (no self-terminate under NEVER)'); + self::assertSame(0, $st->restarts, 'NEVER never restarts — no storm'); + } + + public function test_watchdog_kills_and_restarts_a_hung_worker(): void + { + $started = microtime(true); + $sup = $this->fork(static fn() => HungLoopDaemon::start()); + + // The worker is alive-but-wedged; only the watchdog can end it. With + // livenessTimeout=2 and maxRestarts=2 it kills, restarts, then gives up — + // so it must take at least one liveness window, not die instantly. + self::assertTrue($this->waitExit($sup, 15.0), 'watchdog drives it to FAILED'); + self::assertGreaterThan(2.0, microtime(true) - $started, 'it waited for the liveness timeout'); + } + + public function test_autoscaler_scales_up_then_down(): void + { + $this->fork(static fn() => AutoscaleLoopDaemon::start()); + + $scaledUp = $this->pollUntil(function (): bool { + $st = $this->daemonStatus(AutoscaleLoopDaemon::class); + $running = $st ? array_filter($st->workers, fn($w) => $w->state === SlotState::RUNNING) : []; + return count($running) >= 4; + }, timeout: 8.0); + self::assertTrue($scaledUp, 'fleet grows to the ramped-up target'); + + $scaledDown = $this->pollUntil(function (): bool { + $st = $this->daemonStatus(AutoscaleLoopDaemon::class); + $running = $st ? array_filter($st->workers, fn($w) => $w->state === SlotState::RUNNING) : []; + return count($running) <= 2 && count($running) >= 1; + }, timeout: 8.0); + self::assertTrue($scaledDown, 'fleet shrinks after the stabilization window'); + } + + public function test_singleton_refuses_a_second_supervisor(): void + { + $a = $this->fork(static fn() => LoopDaemon::start()); + self::assertTrue($this->pollUntil( + fn() => ($st = $this->daemonStatus(LoopDaemon::class)) !== null && count($st->workers) === 2 + )); + + $b = $this->fork(static fn() => LoopDaemon::start()); + + // The second start is refused (already running) and exits promptly, while + // the first keeps supervising unchanged. + self::assertTrue($this->waitExit($b, 4.0), 'the second supervisor bails out'); + self::assertTrue($this->isAlive($a), 'the first supervisor is unaffected'); + $st = $this->daemonStatus(LoopDaemon::class); + self::assertNotNull($st); + self::assertSame($a, $st->pid); + self::assertCount(2, $st->workers); + } + + public function test_second_stop_signal_forces_a_stuck_fleet_down(): void + { + $sup = $this->fork(static fn() => StuckStopDaemon::start()); + self::assertTrue($this->pollUntil( + fn() => ($st = $this->daemonStatus(StuckStopDaemon::class)) !== null && $st->workers !== [] + )); + + // First signal begins a drain the wedged worker will never honour. + posix_kill($sup, SIGTERM); + usleep(1_500_000); + self::assertTrue($this->isAlive($sup), 'still draining (grace is 8s, worker is stuck)'); + + // Second signal forces the fleet down at once — well before grace. + $forcedAt = microtime(true); + posix_kill($sup, SIGTERM); + self::assertTrue($this->waitExit($sup, 4.0), 'the second signal forces it down'); + self::assertLessThan(6.0, microtime(true) - $forcedAt, 'forced, not waiting out the 8s grace'); + } + + public function test_per_worker_status_reports_busy_activity(): void + { + $this->fork(static fn() => BusyIdleDaemon::start()); + + $sawBusy = $this->pollUntil(function (): bool { + $st = $this->daemonStatus(BusyIdleDaemon::class); + return $st !== null + && $st->workers !== [] + && $st->workers[0]->activity === Activity::BUSY; + }, timeout: 8.0); + + self::assertTrue($sawBusy, 'the worker heartbeat surfaces BUSY activity'); + } + + public function test_sighup_reloads_without_stopping(): void + { + $sup = $this->fork(static fn() => LoopDaemon::start()); + self::assertTrue($this->pollUntil( + fn() => ($st = $this->daemonStatus(LoopDaemon::class)) !== null && count($st->workers) === 2 + )); + + posix_kill($sup, SIGHUP); + usleep(700_000); + + self::assertTrue($this->isAlive($sup), 'SIGHUP is reload, not stop'); + $st = $this->daemonStatus(LoopDaemon::class); + self::assertNotNull($st); + self::assertCount(2, $st->workers, 'the fleet is untouched by reload'); + } +} diff --git a/tests/Process/Integration/IntegrationCase.php b/tests/Process/Integration/IntegrationCase.php new file mode 100644 index 0000000..662ef12 --- /dev/null +++ b/tests/Process/Integration/IntegrationCase.php @@ -0,0 +1,144 @@ + Forked child PIDs to clean up. */ + private array $children = []; + + protected function setUp(): void + { + if (!extension_loaded('pcntl') || !extension_loaded('posix')) { + self::markTestSkipped('pcntl and posix are required for the integration tests.'); + } + + $this->storage = sys_get_temp_dir() . '/wk_it_' . getmypid() . '_' . bin2hex(random_bytes(4)); + @mkdir($this->storage . '/runnable', 0777, true); + + Kernel::init(pathRoot: $this->storage, pathStorageRunnable: $this->storage . '/runnable'); + Container::init(); + + // Kernel caches FileStorage by name against the path it was first built + // with. Each test uses a fresh temp dir, so drop the cache or a reused + // fixture class would read a previous test's (deleted) directory. + foreach (['runnable', 'storages', 'volatiles'] as $cache) { + (new \ReflectionProperty(KernelStore::class, $cache))->setValue(null, []); + } + } + + protected function tearDown(): void + { + foreach ($this->children as $pid) { + if ($this->isAlive($pid)) { + @posix_kill($pid, SIGTERM); + } + } + $deadline = microtime(true) + 4.0; + foreach ($this->children as $pid) { + while ($this->isAlive($pid) && microtime(true) < $deadline) { + if (pcntl_waitpid($pid, $s, WNOHANG) === $pid) { + break; + } + usleep(50_000); + } + @posix_kill($pid, SIGKILL); + @pcntl_waitpid($pid, $s, WNOHANG); + } + while (pcntl_waitpid(-1, $s, WNOHANG) > 0) { + // drain any remaining direct children + } + $this->children = []; + $this->rrmdir($this->storage); + } + + /** + * Runs $body in a forked child and returns its PID. The child resets inherited + * signal handlers and, when $body returns, vanishes via SIGKILL so the test + * runner's shutdown never runs (and never pollutes output) in the child. + */ + protected function fork(callable $body): int + { + $pid = pcntl_fork(); + if ($pid === 0) { + foreach ([SIGTERM, SIGINT, SIGHUP, SIGUSR1, SIGUSR2] as $signo) { + pcntl_signal($signo, SIG_DFL); + } + try { + $body(); + } catch (\Throwable) { + // observed via the store / markers, not the child exit + } + posix_kill(getmypid(), SIGKILL); + } + $this->children[] = $pid; + return $pid; + } + + /** + * Polls $cond until it is truthy or the timeout elapses. + */ + protected function pollUntil(callable $cond, float $timeout = 8.0, float $step = 0.1): bool + { + $deadline = microtime(true) + $timeout; + do { + if ($cond()) { + return true; + } + usleep((int) ($step * 1_000_000)); + } while (microtime(true) < $deadline); + + return (bool) $cond(); + } + + protected function isAlive(int $pid): bool + { + return $pid > 0 && posix_getpgid($pid) !== false; + } + + /** + * Waits for a forked child to exit (reaping it). Returns true once gone. + */ + protected function waitExit(int $pid, float $timeout = 8.0): bool + { + $deadline = microtime(true) + $timeout; + do { + $r = pcntl_waitpid($pid, $s, WNOHANG); + if ($r === $pid || $r === -1) { + return true; + } + usleep(100_000); + } while (microtime(true) < $deadline); + + return false; + } + + private function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + is_dir($path) ? $this->rrmdir($path) : @unlink($path); + } + @rmdir($dir); + } +} diff --git a/tests/Process/Integration/ProcessSignalIntegrationTest.php b/tests/Process/Integration/ProcessSignalIntegrationTest.php new file mode 100644 index 0000000..573fa35 --- /dev/null +++ b/tests/Process/Integration/ProcessSignalIntegrationTest.php @@ -0,0 +1,114 @@ +marker = $this->storage . '/marker'; + putenv('WK_MARKER=' . $this->marker); + } + + protected function tearDown(): void + { + putenv('WK_MARKER'); + parent::tearDown(); + } + + /** @return list */ + private function markers(): array + { + if (!is_file($this->marker)) { + return []; + } + return array_values(array_filter(explode("\n", (string) file_get_contents($this->marker)))); + } + + private function hasMarker(string $event): bool + { + return in_array($event, $this->markers(), true); + } + + private function startAndWaitReady(): int + { + $pid = $this->fork(static fn() => SignalProcess::start()); + self::assertTrue( + $this->pollUntil(fn() => $this->hasMarker('start') && SignalProcess::status() !== null), + 'the process should reach its run loop' + ); + return $pid; + } + + public function test_sigterm_stops_and_fires_on_terminate(): void + { + $pid = $this->startAndWaitReady(); + + posix_kill($pid, SIGTERM); + + self::assertTrue($this->waitExit($pid), 'SIGTERM stops the process'); + self::assertTrue($this->hasMarker('terminate'), 'onTerminate() fired'); + self::assertNull(SignalProcess::status(), 'the record is removed on exit'); + } + + public function test_sigint_stops_and_fires_on_interrupt(): void + { + $pid = $this->startAndWaitReady(); + + posix_kill($pid, SIGINT); + + self::assertTrue($this->waitExit($pid), 'SIGINT stops the process'); + self::assertTrue($this->hasMarker('interrupt'), 'onInterrupt() fired'); + } + + public function test_sighup_fires_reload_without_stopping(): void + { + $pid = $this->startAndWaitReady(); + + posix_kill($pid, SIGHUP); + + self::assertTrue($this->pollUntil(fn() => $this->hasMarker('reload')), 'onReload() fired'); + self::assertTrue($this->isAlive($pid), 'SIGHUP is reload, not stop'); + + posix_kill($pid, SIGTERM); + self::assertTrue($this->waitExit($pid)); + } + + public function test_sigusr1_and_sigusr2_fire_without_stopping(): void + { + $pid = $this->startAndWaitReady(); + + posix_kill($pid, SIGUSR1); + self::assertTrue($this->pollUntil(fn() => $this->hasMarker('user1')), 'onUser1() fired'); + + posix_kill($pid, SIGUSR2); + self::assertTrue($this->pollUntil(fn() => $this->hasMarker('user2')), 'onUser2() fired'); + + self::assertTrue($this->isAlive($pid), 'user signals do not stop the process'); + + posix_kill($pid, SIGTERM); + self::assertTrue($this->waitExit($pid)); + } + + public function test_singleton_refuses_a_second_bare_process(): void + { + $a = $this->startAndWaitReady(); + + $b = $this->fork(static fn() => SignalProcess::start()); + + self::assertTrue($this->waitExit($b, 4.0), 'the second instance bails out'); + self::assertTrue($this->isAlive($a), 'the first instance is unaffected'); + $st = SignalProcess::status(); + self::assertNotNull($st); + self::assertSame($a, $st->pid); + } +} diff --git a/tests/Process/ProcessStateTest.php b/tests/Process/ProcessStateTest.php new file mode 100644 index 0000000..5b9f3f0 --- /dev/null +++ b/tests/Process/ProcessStateTest.php @@ -0,0 +1,37 @@ +getBackingType()?->getName()); + } + + public function test_cases_and_ordinal_values(): void + { + self::assertSame(0, ProcessState::NEW->value); + self::assertSame(1, ProcessState::RUNNING->value); + self::assertSame(2, ProcessState::STOPPING->value); + self::assertSame(3, ProcessState::TERMINATED->value); + self::assertSame(4, ProcessState::FAILED->value); + self::assertSame(5, ProcessState::RESTARTING->value); + } + + public function test_exactly_six_cases(): void + { + self::assertCount(6, ProcessState::cases()); + } + + public function test_name_is_stable_label(): void + { + self::assertSame('RUNNING', ProcessState::RUNNING->name); + self::assertSame('FAILED', ProcessState::FAILED->name); + } +} diff --git a/tests/Process/ProcessStatusTest.php b/tests/Process/ProcessStatusTest.php new file mode 100644 index 0000000..7a94d26 --- /dev/null +++ b/tests/Process/ProcessStatusTest.php @@ -0,0 +1,68 @@ +concurrency); + self::assertNull($s->usage); + self::assertSame(0, $s->heartbeatAt); + } + + public function test_get_started_at_is_formatted(): void + { + $s = new ProcessStatus(1, 'Foo', ProcessState::RUNNING, Activity::IDLE, 1_600_000_000); + self::assertSame(date('Y-m-d H:i:s P', 1_600_000_000), $s->getStartedAt()); + } + + public function test_json_shape_and_value_encoding(): void + { + $started = time() - 5; + $json = (new ProcessStatus( + pid: 200, + className: 'App\\Worker', + state: ProcessState::STOPPING, + activity: Activity::BUSY, + startedAt: $started, + concurrency: 8, + usage: null, + heartbeatAt: $started + 3, + ))->jsonSerialize(); + + self::assertSame( + ['pid', 'class', 'state', 'activity', 'started_at', 'uptime', 'concurrency', 'heartbeat_at', 'usage'], + array_keys($json) + ); + self::assertSame(200, $json['pid']); + self::assertSame('App\\Worker', $json['class']); + self::assertSame('STOPPING', $json['state']); // ProcessState->name + self::assertSame('busy', $json['activity']); // Activity->value + self::assertSame($started, $json['started_at']); + self::assertGreaterThanOrEqual(5, $json['uptime']); + self::assertSame(8, $json['concurrency']); + self::assertSame($started + 3, $json['heartbeat_at']); + self::assertNull($json['usage']); + } + + /** + * Regression: a backed Activity + JsonSerializable must encode cleanly — the + * pure-enum activity previously made json_encode return false. + */ + public function test_encodes_to_json_string(): void + { + $s = new ProcessStatus(1, 'Foo', ProcessState::RUNNING, Activity::BUSY, time()); + $encoded = json_encode($s); + self::assertIsString($encoded); + self::assertArrayHasKey('activity', json_decode($encoded, true)); + } +} diff --git a/tests/Process/ProcessTest.php b/tests/Process/ProcessTest.php new file mode 100644 index 0000000..3726270 --- /dev/null +++ b/tests/Process/ProcessTest.php @@ -0,0 +1,70 @@ +invoke($p); + } + + public function test_title_name_defaults_to_short_class_name(): void + { + self::assertSame('SampleProcess', $this->invoke(new SampleProcess(), 'titleName')); + } + + public function test_title_name_prefers_explicit_process_title(): void + { + self::assertSame('custom-title', $this->invoke(new TitledProcess(), 'titleName')); + } + + public function test_build_process_title_uses_the_winter_process_prefix(): void + { + self::assertSame('winter-process: SampleProcess', $this->invoke(new SampleProcess(), 'buildProcessTitle')); + self::assertSame('winter-process: custom-title', $this->invoke(new TitledProcess(), 'buildProcessTitle')); + } + + public function test_after_fork_runs_the_fork_reset_handlers(): void + { + $ran = false; + ForkReset::register(static function () use (&$ran): void { + $ran = true; + }); + + $this->invoke(new SampleProcess(), 'afterFork'); + + self::assertTrue($ran); + } + + public function test_touch_is_a_noop_for_a_bare_process(): void + { + // No worker slot → no heartbeat write, no error. + $this->invoke(new SampleProcess(), 'touch'); + $this->addToAssertionCount(1); + } + + public function test_run_is_abstract_on_the_base(): void + { + self::assertTrue((new \ReflectionMethod(Process::class, 'run'))->isAbstract()); + } +} diff --git a/tests/Process/ResourceUsageTest.php b/tests/Process/ResourceUsageTest.php new file mode 100644 index 0000000..cfb8802 --- /dev/null +++ b/tests/Process/ResourceUsageTest.php @@ -0,0 +1,58 @@ +pid); + self::assertIsInt($usage->ppid); + self::assertIsString($usage->user); + self::assertIsFloat($usage->cpu); + self::assertIsFloat($usage->memory); + self::assertIsInt($usage->rssKb); + self::assertIsString($usage->elapsed); + self::assertIsString($usage->command); + } + + public function test_of_pid_returns_null_for_a_dead_pid(): void + { + // A PID far above any live process on a normal system. + self::assertNull(ResourceUsage::ofPid(4_194_303)); + } + + public function test_rss_mb_converts_from_kb(): void + { + $usage = new ResourceUsage(1, 0, 'me', 0.0, 0.0, 2048, '00:01', 'php'); + self::assertSame(2.0, $usage->rssMb()); + } + + public function test_json_shape_and_types(): void + { + $json = (new ResourceUsage(7, 1, 'root', 1.5, 0.3, 1536, '01:23', 'php worker'))->jsonSerialize(); + + self::assertSame( + ['pid', 'ppid', 'user', 'cpu', 'memory', 'rss_mb', 'elapsed', 'command'], + array_keys($json) + ); + self::assertSame(7, $json['pid']); + self::assertSame('root', $json['user']); + self::assertSame(1.5, $json['cpu']); + self::assertSame(1.5, $json['rss_mb']); // 1536 KB rounded to 1 decimal + self::assertSame('php worker', $json['command']); + } + + public function test_encodes_to_json_string(): void + { + self::assertIsString(json_encode(new ResourceUsage(1, 0, 'me', 0.0, 0.0, 0, '0', 'x'))); + } +} From 4a0308ce723a809861860f3c6402d7050bdfb40a Mon Sep 17 00:00:00 2001 From: Jason Khan Date: Sat, 25 Jul 2026 20:46:53 +0500 Subject: [PATCH 15/71] Schedule beta test --- console/Command/Complete.php | 9 + console/Command/Schedule.php | 190 +++++++++++++++ console/Core.php | 1 + dev/main/Schedule/DemoTasks.php | 56 +++++ docs/schedule/00-overview.md | 98 ++++++++ docs/schedule/01-usage.md | 200 ++++++++++++++++ phpunit.xml | 4 + src/Schedule/ScheduleConfigException.php | 14 ++ src/Schedule/Scheduled.php | 68 ++++++ src/Schedule/ScheduledCollector.php | 140 +++++++++++ src/Schedule/ScheduledTask.php | 51 ++++ src/Schedule/Scheduler.php | 188 +++++++++++++++ src/Schedule/Trigger/CronTrigger.php | 217 ++++++++++++++++++ src/Schedule/Trigger/FixedDelayTrigger.php | 44 ++++ src/Schedule/Trigger/FixedRateTrigger.php | 50 ++++ src/Schedule/Trigger/Trigger.php | 44 ++++ tests/Schedule/Fixtures/AbstractScheduled.php | 16 ++ tests/Schedule/Fixtures/ArgScheduled.php | 16 ++ tests/Schedule/Fixtures/BadCronScheduled.php | 16 ++ .../Fixtures/CronInitialDelayScheduled.php | 16 ++ tests/Schedule/Fixtures/CronScheduled.php | 16 ++ tests/Schedule/Fixtures/MarkerScheduler.php | 28 +++ tests/Schedule/Fixtures/MarkerTask.php | 20 ++ .../Schedule/Fixtures/NoTriggerScheduled.php | 16 ++ .../Fixtures/NonPositiveScheduled.php | 16 ++ tests/Schedule/Fixtures/SampleScheduled.php | 33 +++ tests/Schedule/Fixtures/StaticScheduled.php | 16 ++ .../Schedule/Fixtures/TwoTriggerScheduled.php | 16 ++ .../Integration/SchedulerIntegrationTest.php | 59 +++++ tests/Schedule/ScheduledCollectorTest.php | 123 ++++++++++ tests/Schedule/ScheduledTaskTest.php | 25 ++ tests/Schedule/ScheduledTest.php | 42 ++++ tests/Schedule/SchedulerTest.php | 136 +++++++++++ tests/Schedule/Trigger/CronTriggerTest.php | 109 +++++++++ .../Trigger/FixedDelayTriggerTest.php | 37 +++ .../Schedule/Trigger/FixedRateTriggerTest.php | 43 ++++ 36 files changed, 2173 insertions(+) create mode 100644 console/Command/Schedule.php create mode 100644 dev/main/Schedule/DemoTasks.php create mode 100644 docs/schedule/00-overview.md create mode 100644 docs/schedule/01-usage.md create mode 100644 src/Schedule/ScheduleConfigException.php create mode 100644 src/Schedule/Scheduled.php create mode 100644 src/Schedule/ScheduledCollector.php create mode 100644 src/Schedule/ScheduledTask.php create mode 100644 src/Schedule/Scheduler.php create mode 100644 src/Schedule/Trigger/CronTrigger.php create mode 100644 src/Schedule/Trigger/FixedDelayTrigger.php create mode 100644 src/Schedule/Trigger/FixedRateTrigger.php create mode 100644 src/Schedule/Trigger/Trigger.php create mode 100644 tests/Schedule/Fixtures/AbstractScheduled.php create mode 100644 tests/Schedule/Fixtures/ArgScheduled.php create mode 100644 tests/Schedule/Fixtures/BadCronScheduled.php create mode 100644 tests/Schedule/Fixtures/CronInitialDelayScheduled.php create mode 100644 tests/Schedule/Fixtures/CronScheduled.php create mode 100644 tests/Schedule/Fixtures/MarkerScheduler.php create mode 100644 tests/Schedule/Fixtures/MarkerTask.php create mode 100644 tests/Schedule/Fixtures/NoTriggerScheduled.php create mode 100644 tests/Schedule/Fixtures/NonPositiveScheduled.php create mode 100644 tests/Schedule/Fixtures/SampleScheduled.php create mode 100644 tests/Schedule/Fixtures/StaticScheduled.php create mode 100644 tests/Schedule/Fixtures/TwoTriggerScheduled.php create mode 100644 tests/Schedule/Integration/SchedulerIntegrationTest.php create mode 100644 tests/Schedule/ScheduledCollectorTest.php create mode 100644 tests/Schedule/ScheduledTaskTest.php create mode 100644 tests/Schedule/ScheduledTest.php create mode 100644 tests/Schedule/SchedulerTest.php create mode 100644 tests/Schedule/Trigger/CronTriggerTest.php create mode 100644 tests/Schedule/Trigger/FixedDelayTriggerTest.php create mode 100644 tests/Schedule/Trigger/FixedRateTriggerTest.php diff --git a/console/Command/Complete.php b/console/Command/Complete.php index 23db885..564941b 100644 --- a/console/Command/Complete.php +++ b/console/Command/Complete.php @@ -115,6 +115,15 @@ class Complete extends Cmd 'list:list all daemons with live state', ], + // --- schedule / sch --- + 'schedule' => [ + 'list:list all #[Scheduled] tasks and cadence', + 'start:run the scheduler (foreground; -d for background)', + 'stop:send graceful stop (SIGTERM)', + 'status:scheduler run state + task count', + ], + 'schedule start' => ['-d:run detached in background'], + // --- db --- 'db' => [ 'ping:check DB connection and latency', diff --git a/console/Command/Schedule.php b/console/Command/Schedule.php new file mode 100644 index 0000000..2f845c5 --- /dev/null +++ b/console/Command/Schedule.php @@ -0,0 +1,190 @@ +args['arguments'][1] ?? '')) { + 'list' => $this->listArg(), + 'start' => $this->startArg(), + 'stop' => $this->stopArg(), + 'status' => $this->statusArg(in_array('v', $this->args['flags'])), + '' => $this->listArg(), + default => self::printWarning("Unknown action (use list|start|stop|status)."), + }; + + self::printTitle("Schedule", self::CL); + } + + /** + * Lists the discovered tasks and their cadence — a static scan, no running + * scheduler required. + */ + private function listArg(): void + { + $tasks = $this->discover(); + + self::printLabel("Scheduled Tasks", self::CL); + if ($tasks === []) { + self::printWarning("No #[Scheduled] methods found."); + self::printInfo("Annotate a public, no-argument method with #[Scheduled]."); + self::printLabel("Scheduled Tasks", self::CL); + return; + } + + self::print(sprintf(" %-46s %s", 'TASK', 'TRIGGER'), 90); + foreach ($tasks as $task) { + self::print(sprintf(" %-46s %s", $task->id(), $task->trigger->describe()), 32); + } + + self::printDivider(); + self::printInfo(count($tasks) . " task(s) defined."); + self::printLabel("Scheduled Tasks", self::CL); + } + + private function startArg(): void + { + $info = Scheduler::status(); + if ($info) { + self::printWarning("Scheduler already running [PID:{$info->pid}] ({$info->getStartedAt()})."); + return; + } + + if (in_array('d', $this->args['flags'])) { + $pid = Scheduler::dispatch(); + $info = null; + for ($i = 0; $i < 20 && $info === null; $i++) { + usleep(50_000); + $info = Scheduler::status(); + } + self::printSuccess("Scheduler dispatched (background)."); + self::printKeyValue("PID", (string) ($info->pid ?? $pid), 12, self::CL, 32); + return; + } + + self::printInfo("Starting scheduler …"); + Scheduler::start(); + self::printSuccess("Scheduler finished."); + } + + private function stopArg(): void + { + $info = Scheduler::status(); + if (!$info) { + self::printWarning("Scheduler is not running."); + return; + } + if (Scheduler::stop()) { + self::printSuccess("Stop signal sent."); + self::printKeyValue("PID", (string) $info->pid, 12, self::CL, 32); + } else { + self::printWarning("Failed to signal scheduler."); + } + } + + private function statusArg(bool $detailed): void + { + $info = Scheduler::status($detailed); + + self::printLabel("Scheduler Status", self::CL); + if (!$info) { + self::printBadge('Scheduler', '○ STOPPED', self::CL, 31); + self::printInfo("The scheduler is not running."); + self::printLabel("Scheduler Status", self::CL); + return; + } + + self::printBadge('Scheduler', 'Scheduler ● ' . $info->state->name, self::CL, 32); + self::printDivider(); + self::printKeyValue("PID", (string) $info->pid, 12, self::CL, 36); + self::printKeyValue("State", $info->state->name, 12, self::CL, 36); + self::printKeyValue( + "Activity", + $info->activity->name, + 12, + self::CL, + $info->activity === Activity::BUSY ? 33 : 90 + ); + self::printKeyValue("Started", $info->getStartedAt(), 12, self::CL, 36); + self::printKeyValue("Uptime", $this->formatDuration(time() - $info->startedAt), 12, self::CL, 36); + self::printKeyValue("Tasks", (string) count($this->discover()), 12, self::CL, 36); + + self::printLabel("Scheduler Status", self::CL); + } + + /** + * @return ScheduledTask[] + */ + private function discover(): array + { + $collector = new ScheduledCollector(); + ClassScanner::scan($collector); + return $collector->getResult(); + } + + /** + * Human-readable duration, e.g. 90061 → "1d 1h". + */ + private function formatDuration(int $seconds): string + { + $seconds = max(0, $seconds); + $units = ['d' => 86400, 'h' => 3600, 'm' => 60, 's' => 1]; + + $parts = []; + foreach ($units as $suffix => $size) { + $value = intdiv($seconds, $size); + $seconds %= $size; + if ($value > 0) { + $parts[] = $value . $suffix; + } + } + + return $parts === [] ? '0s' : implode(' ', array_slice($parts, 0, 2)); + } + + public static function help(): void + { + $cl = self::CL; + self::printTitle("Schedule Help", $cl); + + self::printLabel("Usage", $cl); + self::print("call schedule -[flags]", $cl); + self::printLabel("Usage", $cl); + + self::printLabel("Commands", $cl); + self::printBadge('list', 'list all #[Scheduled] tasks and their cadence (default)', $cl, 36); + self::printBadge('start', 'run the scheduler in the foreground', $cl, 36); + self::printBadge('start -d', 'run the scheduler detached in the background', $cl, 36); + self::printBadge('stop', 'send a graceful stop signal (SIGTERM)', $cl, 36); + self::printBadge('status', 'scheduler run state + task count', $cl, 36); + self::printLabel("Commands", $cl); + + self::printDivider($cl); + self::printInfo("The scheduler fires one process per host (singleton lock)."); + + self::printTitle("Schedule Help", $cl); + } +} diff --git a/console/Core.php b/console/Core.php index 87b1974..f04ccf1 100644 --- a/console/Core.php +++ b/console/Core.php @@ -14,6 +14,7 @@ class Core extends CoreHandle 'th' => 'Thread', 'proc' => 'Process', 'dmn' => 'Daemon', + 'sch' => 'Schedule', ]; public function __construct($args) diff --git a/dev/main/Schedule/DemoTasks.php b/dev/main/Schedule/DemoTasks.php new file mode 100644 index 0000000..e00c96b --- /dev/null +++ b/dev/main/Schedule/DemoTasks.php @@ -0,0 +1,56 @@ +info('DemoTasks::heartbeat (fixedRate 2s)'); + } + + /** + * fixedDelay with an initial delay: first run after 3s, then 5s after each + * run finishes. The 1s of work here shows the delay is measured from the end. + */ + #[Scheduled(fixedDelay: 5.0, initialDelay: 3.0)] + public function report(): void + { + $log = LoggerFactory::getLogger(self::class); + $log->info('DemoTasks::report START (fixedDelay 5s, initialDelay 3s)'); + sleep(1); + $log->info('DemoTasks::report DONE'); + } + + /** + * cron: every night at 02:00, clock-aligned. + */ + #[Scheduled(cron: '0 2 * * *')] + public function nightly(): void + { + LoggerFactory::getLogger(self::class)->info('DemoTasks::nightly (cron 0 2 * * *)'); + } + + /** + * cron: every morning at 08:00 on weekdays. + */ + #[Scheduled(cron: '0 8 * * 1-5')] + public function morning(): void + { + LoggerFactory::getLogger(self::class)->info('DemoTasks::morning (cron 0 8 * * 1-5)'); + } +} diff --git a/docs/schedule/00-overview.md b/docs/schedule/00-overview.md new file mode 100644 index 0000000..5437a72 --- /dev/null +++ b/docs/schedule/00-overview.md @@ -0,0 +1,98 @@ +# Winter Schedule — Overview + +Some work is neither a request nor a long-lived consumer: it is a small job that +has to run *again and again on a clock*. Flush a cache every thirty seconds. +Poll a payment gateway for settled transactions every two minutes. Roll yesterday's +metrics into a report five seconds after the last roll finished. There is nothing +to consume from a queue and nothing to keep a connection open for — there is only +a method and a cadence. + +**Schedule** is the layer for that. You annotate an ordinary method with +`#[Scheduled]`, name a cadence, and a single system process — the **Scheduler** — +discovers every such method across the project and fires it on time. It is the +declarative model Spring popularised as `@Scheduled`, expressed with a PHP +attribute and built directly on the [Process](../process/00-overview.md) layer. + +```php +final class ReportService +{ + #[Autowired] private MetricStore $metrics; + + // 5 seconds after each run finishes, run again + #[Scheduled(fixedDelay: 5.0)] + public function roll(): void + { + // ... aggregate and persist ... + } + + // start every 2 seconds, first run 10 seconds after boot + #[Scheduled(fixedRate: 2.0, initialDelay: 10.0)] + public function poll(): void + { + // ... check the gateway ... + } +} +``` + +You never wire the two together. The method carries the *policy* (when to run); +the Scheduler supplies the *mechanism* (discovery, timing, dispatch, isolation). + +## What the Scheduler gives you + +- **Discovery.** On boot it scans the project (and plugins) for every + `#[Scheduled]` method — the same scan the router uses for its mappings. +- **Timing in seconds.** Cadences are floats, in seconds, like the rest of the + kernel (`sleep()`, `grace`) — `fixedDelay: 5.0`, not milliseconds. +- **A concurrency pool.** Each due task is dispatched with the Process + `spawn()` primitive — a coroutine under Swoole — so a slow task never delays + another's clock. A task **never overlaps itself**: an in-flight run holds the + next fire until it finishes. +- **Failure isolation.** A task body is the developer's code and may be wrong — + the Scheduler is built so one bad task cannot take down the schedule. A task + that **throws** is caught and logged; the next fire still happens on time. A + **slow** task runs in its own `spawn`, so it never delays another task's clock, + and it **never overlaps itself** — a run that outlasts its period simply holds + its own next fire, it does not pile up. See + [Usage — robustness](01-usage.md#robustness-against-a-buggy-task-body) for the + one caveat (a CPU-bound task that never yields under the Swoole runtime). +- **One per host.** The Scheduler inherits the Process singleton lock (a + crash-safe `flock`), so a second start refuses — a task is never fired twice by + two schedulers on the same host. +- **Live reload.** `SIGHUP` re-scans the annotated methods without a restart. + +## Triggers + +A `#[Scheduled]` method declares **exactly one** trigger: + +| Trigger | Meaning | +|---|---| +| `fixedDelay: N` | Wait N seconds between the **end** of one run and the **start** of the next. The gap is immune to a run's own duration. | +| `fixedRate: N` | Start every N seconds, measured from the previous **start**. If a run outlasts the period, the next fires once as soon as it frees — missed ticks are dropped, never replayed as a burst. | +| `cron: '…'` | A clock-aligned five-field cron expression (or a macro), for time-of-day work: `0 2 * * *` every night at 02:00, `0 8 * * 1-5` at 08:00 on weekdays, `* * * * *` every minute on the minute, `0 * * * *` hourly on the hour. See [Usage](01-usage.md#cron--clock-aligned). | +| `initialDelay: N` | Seconds to wait before the very first run — with a period trigger only (a cron is already clock-aligned). | + +The period triggers (`fixedDelay` / `fixedRate`) answer "how long between runs"; +`cron` answers "at which wall-clock times". A single project freely mixes both — +a `fixedRate: 5.0` health poll and a `cron: '0 3 * * *'` nightly cleanup live side +by side, each on its own schedule. + +Anything invalid — no trigger, more than one, a non-positive period, a malformed +cron, `initialDelay` with cron, a static method, a method that needs arguments, or +a non-instantiable class — is rejected at discovery with a `ScheduleConfigException`, +so a misconfiguration fails loudly at start rather than silently never firing. + +## Running it + +The Scheduler is one fixed runtime, not a class you write, so it is driven by its +own command rather than by naming a class: + +``` +call schedule list # every #[Scheduled] task and its cadence (no run needed) +call schedule start # run in the foreground +call schedule start -d # run detached in the background +call schedule status # run state + task count +call schedule stop # graceful SIGTERM +``` + +See [Usage](01-usage.md) for the attribute in depth, the execution model, and the +fork-safety note under the non-Swoole runtime. diff --git a/docs/schedule/01-usage.md b/docs/schedule/01-usage.md new file mode 100644 index 0000000..2eb50c7 --- /dev/null +++ b/docs/schedule/01-usage.md @@ -0,0 +1,200 @@ +# Winter Schedule — Usage + +## The attribute + +`#[Scheduled]` marks a method to be run on a cadence. It is repeatable, so one +method may carry several triggers. + +```php +use Flytachi\Winter\K2\Schedule\Scheduled; + +#[Scheduled(fixedDelay: 5.0)] +public function flush(): void { /* ... */ } +``` + +The method must be: + +- **public**, **non-static**, and take **no required arguments** — the Scheduler + resolves the declaring class from the container on each fire and calls the + method with no arguments; +- declared on an **instantiable** class (an interface or an abstract class cannot + be resolved). + +Its class needs no marker or base type: any DI-resolvable class works, and its +`#[Autowired]` dependencies are injected as usual, because the instance comes from +the container. + +## Choosing a trigger + +Declare **exactly one** of `fixedDelay`, `fixedRate`, or `cron`. `initialDelay` is +independent and may accompany either period. + +### fixedDelay — steady gap between runs + +```php +#[Scheduled(fixedDelay: 5.0)] +public function roll(): void { /* ... */ } +``` + +The next start is measured from the moment the previous run **finished**. A run +that takes eight seconds is still followed by a full five-second gap. Two runs of +the same task therefore can never overlap. Use it when what matters is breathing +room between runs — most maintenance jobs. + +### fixedRate — steady cadence + +```php +#[Scheduled(fixedRate: 2.0)] +public function poll(): void { /* ... */ } +``` + +The next start is measured from the previous **start**, so the cadence is +independent of how long a run takes. If a run outlasts the period, the Scheduler +holds the next fire until the run finishes (a task never overlaps itself) and then +fires it once immediately — the missed ticks are dropped, not replayed as a burst. +Use it when you want a run "about every N seconds" regardless of duration. + +### cron — clock-aligned + +```php +#[Scheduled(cron: '0 2 * * *')] +public function nightlyCleanup(): void { /* ... */ } +``` + +Use cron when a run must land at specific wall-clock times rather than at an +interval. The expression has five fields — `minute hour day-of-month month +day-of-week` — each accepting a star, a number, an `a-b` range, a `/step`, and +comma lists. Day-of-week is `0-6` with Sunday `0` (or `7`); when day-of-month and +day-of-week are both restricted the day matches their **union** (the standard +cron rule). Common macros are accepted: `@yearly`, `@monthly`, `@weekly`, +`@daily` (= `@midnight`), `@hourly`. + +| You want | Expression | +|---|---| +| Every night at 02:00 | `0 2 * * *` | +| Every morning at 08:00 on weekdays | `0 8 * * 1-5` | +| Every minute, on the minute | `* * * * *` | +| Every hour, on the hour | `0 * * * *` | +| Every 15 minutes | `*/15 * * * *` | +| Twice a day, 06:30 and 18:30 | `30 6,18 * * *` | +| First of every month, midnight | `@monthly` | + +Cron fires on the local timezone and is clock-aligned, so it ignores +`initialDelay` (setting both is a configuration error). A malformed expression is +rejected at discovery, not silently ignored. + +### initialDelay — delay the first run + +```php +#[Scheduled(fixedRate: 2.0, initialDelay: 10.0)] +public function warmThenPoll(): void { /* ... */ } +``` + +Seconds to wait after boot before the first run — handy for letting dependencies +warm up, or staggering tasks that would otherwise all fire at boot. + +## The execution model + +The Scheduler loops: on each pass it fires every task that is due and not already +in flight, then sleeps until the next task is due (bounded, so it stays responsive +to a stop). A fired task is dispatched with the Process `spawn()` primitive: + +- **Under Swoole**, `spawn()` is a real coroutine — tasks run concurrently in one + process, sharing memory, and a slow task never delays another's clock. +- **Without Swoole**, `spawn()` forks a child per run. + +Concurrency across *different* tasks is bounded by the Scheduler's `spawn` pool; +the same task never runs concurrently with itself, matching a single-threaded +scheduler's default behaviour. + +A task that throws is caught and logged; the schedule is unaffected and the next +fire happens on time. + +## Robustness against a buggy task body + +A task body is application code and will sometimes be wrong. The Scheduler is +designed so a single bad task cannot derail the schedule or the other tasks: + +- **A task that throws** is caught and logged, and the next fire happens on time. + The exception never reaches the loop. +- **A slow task** runs in its own `spawn`, so it does not delay when any other + task fires. Its own next fire is held until it finishes (a task never overlaps + itself), so a run that drifts over its period does not stack up a backlog. +- **A task stuck in a fire-and-forget fork** (the non-Swoole runtime) is isolated + in its own child process; the Scheduler is unaffected. + +The one case the runtime cannot defend against is inherent to cooperative +coroutines: under Swoole, a task that runs a **long CPU-bound loop and never +yields** (no I/O, no `sleep`) holds the single reactor thread, freezing the +Scheduler loop and every other task until it returns. This is not specific to +scheduling — it is how coroutines work. If a task must do heavy, non-yielding CPU +work, either break it into chunks that yield (`\Swoole\Coroutine::sleep(0)` / +periodic I/O), or run the Scheduler under the fork runtime so each run is a +separate process. Blocking *I/O* is not a problem: Swoole's runtime hooks turn it +into a yield. + +## Fork-safety under the non-Swoole runtime + +Under Swoole — the intended runtime — a scheduled task runs in a coroutine and +shares the process's resources safely. Under the fork runtime, `spawn()` forks a +child per run and, unlike a daemon worker, does **not** run the `ForkReset` hooks +(a scheduler is not a daemon). A task that uses a fork-unsafe resource (a shared DB +connection, a pool) directly from a forked run can therefore corrupt it. If you +must run the Scheduler without Swoole, keep scheduled tasks fork-safe — open and +close their own resources inside the run. + +## Discovering and running + +List every task without starting anything — a static scan: + +``` +$ call schedule list + TASK TRIGGER + App\ReportService::roll fixedDelay 5s + App\ReportService::poll fixedRate 2s +``` + +Run it in the foreground (blocks) or detached: + +``` +call schedule start +call schedule start -d +``` + +Inspect and stop it from any terminal: + +``` +call schedule status +call schedule stop # graceful SIGTERM +``` + +`SIGHUP` (or restarting) re-scans the annotated methods, so newly added or removed +tasks take effect without a full restart: + +``` +kill -HUP +``` + +## Advanced: sourcing tasks yourself + +Discovery is an override point. Subclass `Scheduler` and override `discover()` to +supply `ScheduledTask`s from another source — for example a table of cron rows — +instead of, or in addition to, annotation scanning: + +```php +final class DbScheduler extends Scheduler +{ + protected function discover(): array + { + $tasks = parent::discover(); // keep annotated methods + foreach ($this->rows() as $row) { + $tasks[] = new ScheduledTask( + $row->class, + $row->method, + new FixedRateTrigger($row->seconds), + ); + } + return $tasks; + } +} +``` diff --git a/phpunit.xml b/phpunit.xml index acd4746..d5bdc65 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -30,6 +30,10 @@ tests/Process tests/Process/Fixtures + + tests/Schedule + tests/Schedule/Fixtures + diff --git a/src/Schedule/ScheduleConfigException.php b/src/Schedule/ScheduleConfigException.php new file mode 100644 index 0000000..37c2122 --- /dev/null +++ b/src/Schedule/ScheduleConfigException.php @@ -0,0 +1,14 @@ +getResult(); // ScheduledTask[] + */ +final class ScheduledCollector implements CollectorInterface +{ + /** @var ScheduledTask[] */ + private array $tasks = []; + + public function collect(string $class, ReflectionClass $ref): void + { + foreach ($ref->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + $attrs = $method->getAttributes(Scheduled::class); + if ($attrs === []) { + continue; + } + $this->assertCallable($ref, $method); + foreach ($attrs as $attr) { + /** @var Scheduled $scheduled */ + $scheduled = $attr->newInstance(); + $this->tasks[] = new ScheduledTask( + className: $class, + methodName: $method->getName(), + trigger: $this->trigger($scheduled, $class, $method->getName()), + initialDelay: max(0.0, $scheduled->initialDelay), + ); + } + } + } + + /** @return ScheduledTask[] */ + public function getResult(): array + { + return $this->tasks; + } + + /** + * Rejects a method the scheduler could never invoke: an abstract or + * non-instantiable class, a static method, or one that needs arguments. + */ + private function assertCallable(ReflectionClass $ref, ReflectionMethod $method): void + { + $where = $ref->getName() . '::' . $method->getName() . '()'; + if ($method->isStatic()) { + throw new ScheduleConfigException("#[Scheduled] {$where} must be a non-static method."); + } + if ($method->getNumberOfRequiredParameters() > 0) { + throw new ScheduleConfigException("#[Scheduled] {$where} must take no required arguments."); + } + if ($ref->isAbstract() || $ref->isInterface() || !$ref->isInstantiable()) { + throw new ScheduleConfigException( + "#[Scheduled] {$where} is on a non-instantiable class; it cannot be resolved." + ); + } + } + + /** + * Resolves the single trigger declared by the attribute, rejecting zero or + * more than one. + */ + private function trigger(Scheduled $scheduled, string $class, string $method): Trigger + { + $modes = []; + if ($scheduled->fixedDelay !== null) { + $modes[] = 'fixedDelay'; + } + if ($scheduled->fixedRate !== null) { + $modes[] = 'fixedRate'; + } + if ($scheduled->cron !== null) { + $modes[] = 'cron'; + } + + $where = $class . '::' . $method . '()'; + if ($modes === []) { + throw new ScheduleConfigException( + "#[Scheduled] {$where} sets no trigger; use fixedDelay, fixedRate or cron." + ); + } + if (count($modes) > 1) { + throw new ScheduleConfigException( + "#[Scheduled] {$where} sets more than one trigger (" . implode(', ', $modes) . '); use exactly one.' + ); + } + + if ($scheduled->fixedDelay !== null) { + $this->assertPositive($scheduled->fixedDelay, 'fixedDelay', $where); + return new FixedDelayTrigger($scheduled->fixedDelay); + } + if ($scheduled->fixedRate !== null) { + $this->assertPositive($scheduled->fixedRate, 'fixedRate', $where); + return new FixedRateTrigger($scheduled->fixedRate); + } + + if ($scheduled->initialDelay > 0.0) { + throw new ScheduleConfigException("#[Scheduled] {$where} initialDelay is not supported with cron."); + } + try { + return new CronTrigger((string) $scheduled->cron); + } catch (InvalidArgumentException $e) { + throw new ScheduleConfigException("#[Scheduled] {$where} " . $e->getMessage()); + } + } + + /** + * A period must be strictly positive — 0 or negative would busy-loop the task. + */ + private function assertPositive(float $value, string $name, string $where): void + { + if ($value <= 0.0) { + throw new ScheduleConfigException("#[Scheduled] {$where} {$name} must be greater than 0."); + } + } +} diff --git a/src/Schedule/ScheduledTask.php b/src/Schedule/ScheduledTask.php new file mode 100644 index 0000000..7f0338a --- /dev/null +++ b/src/Schedule/ScheduledTask.php @@ -0,0 +1,51 @@ +className . '::' . $this->methodName; + } +} diff --git a/src/Schedule/Scheduler.php b/src/Schedule/Scheduler.php new file mode 100644 index 0000000..dd8d9b4 --- /dev/null +++ b/src/Schedule/Scheduler.php @@ -0,0 +1,188 @@ + In-flight run per task index; reaped when the future settles. */ + private array $running = []; + + /** + * Discovers the annotated methods and drives their triggers until stopped. + */ + public function run(): void + { + $this->tasks = $this->discover(); + $this->seed(microtime(true)); + + if ($this->tasks === []) { + $this->logger->warning('Scheduler: no #[Scheduled] methods found; idling.'); + } else { + $this->logger->info('Scheduler: ' . count($this->tasks) . ' scheduled task(s) registered.'); + } + + while ($this->isRunning()) { + $this->reap(); + $now = microtime(true); + foreach ($this->tasks as $index => $task) { + if (!$task->inFlight && $task->nextFireAt <= $now) { + $this->fire($index, $task); + } + } + $this->sleep($this->untilNext(microtime(true))); + } + } + + /** + * SIGHUP: re-scan the annotated methods so newly added or removed tasks take + * effect without a restart. Any in-flight runs are detached (they finish on + * their own and harmlessly); the fresh registry seeds from now. + */ + protected function onReload(): void + { + $this->tasks = $this->discover(); + $this->running = []; + $this->seed(microtime(true)); + $this->logger->info('Scheduler: reloaded, ' . count($this->tasks) . ' scheduled task(s).'); + } + + /** + * Dispatches one task: mark it in flight and spawn the run — resolve its class + * from the container and invoke the method, logging any failure so it is never + * fatal. The run's completion is picked up later by {@see reap()}; the spawned + * closure deliberately does not touch task state, because under the fork + * runtime it executes in a separate process. + */ + private function fire(int $index, ScheduledTask $task): void + { + $task->inFlight = true; + $task->lastStartAt = microtime(true); + + $this->running[$index] = $this->spawn(function () use ($task): void { + try { + $bean = Container::getInstance()->make($task->className); + $bean->{$task->methodName}(); + } catch (\Throwable $e) { + $this->logger->error('Scheduled ' . $task->id() . ' failed: ' . $e->getMessage()); + } + }); + } + + /** + * Finalises every run whose future has settled: record its end, advance the + * next fire from the trigger, and release the in-flight hold. Reading + * completion from the parent (not the run) keeps the state correct under the + * fork runtime, where the run executes in a child process. + */ + private function reap(): void + { + foreach ($this->running as $index => $future) { + if (!$future->isDone()) { + continue; + } + $task = $this->tasks[$index] ?? null; + if ($task !== null) { + $task->lastEndAt = microtime(true); + $task->runs++; + $task->nextFireAt = $task->trigger->nextFireTime( + microtime(true), + $task->lastStartAt, + $task->lastEndAt, + ); + $task->inFlight = false; + } + unset($this->running[$index]); + } + } + + /** + * Scans the project and plugins for {@see Scheduled} methods. + * + * Override to source tasks another way — e.g. from a database of cron rows — + * instead of (or in addition to) annotation scanning. + * + * @return ScheduledTask[] + */ + protected function discover(): array + { + $collector = new ScheduledCollector(); + ClassScanner::scan($collector); + return $collector->getResult(); + } + + /** + * Seeds each task's first fire time from its trigger — boot plus the initial + * delay for a period trigger, the next matching instant for a cron trigger. + */ + private function seed(float $now): void + { + foreach ($this->tasks as $task) { + $task->nextFireAt = $task->trigger->firstFireTime($now, $task->initialDelay); + } + } + + /** + * How long to pause before the next pass: until the soonest not-in-flight + * task is due, clamped to [{@see MIN_SLEEP}, {@see MAX_SLEEP}]. When every task + * is in flight (or there are none) it idles a full {@see MAX_SLEEP} — but while + * any run is in flight the wait is capped to {@see REAP_POLL} so its completion, + * and the task's next fire, are picked up promptly rather than a whole second later. + */ + private function untilNext(float $now): float + { + $soonest = null; + foreach ($this->tasks as $task) { + if ($task->inFlight) { + continue; + } + $soonest = $soonest === null ? $task->nextFireAt : min($soonest, $task->nextFireAt); + } + $wait = $soonest === null + ? self::MAX_SLEEP + : max(self::MIN_SLEEP, min($soonest - $now, self::MAX_SLEEP)); + + if ($this->running !== []) { + $wait = min($wait, self::REAP_POLL); + } + return $wait; + } +} diff --git a/src/Schedule/Trigger/CronTrigger.php b/src/Schedule/Trigger/CronTrigger.php new file mode 100644 index 0000000..5119c09 --- /dev/null +++ b/src/Schedule/Trigger/CronTrigger.php @@ -0,0 +1,217 @@ + '0 0 1 1 *', + '@annually' => '0 0 1 1 *', + '@monthly' => '0 0 1 * *', + '@weekly' => '0 0 * * 0', + '@daily' => '0 0 * * *', + '@midnight' => '0 0 * * *', + '@hourly' => '0 * * * *', + ]; + + /** @var array */ + private array $minutes; + /** @var array */ + private array $hours; + /** @var array */ + private array $daysOfMonth; + /** @var array */ + private array $months; + /** @var array */ + private array $daysOfWeek; + private bool $domRestricted; + private bool $dowRestricted; + + /** + * @param string $expression A five-field cron expression or a supported macro. + * @throws InvalidArgumentException On a malformed expression. + */ + public function __construct(private readonly string $expression) + { + $normalized = self::MACROS[strtolower(trim($expression))] ?? trim($expression); + $fields = preg_split('/\s+/', $normalized) ?: []; + if (count($fields) !== 5) { + throw new InvalidArgumentException( + "cron '{$expression}' must have 5 fields (minute hour day month weekday) or be a macro." + ); + } + + [$min, $hour, $dom, $mon, $dow] = $fields; + $this->minutes = $this->parseField($min, 0, 59, $expression); + $this->hours = $this->parseField($hour, 0, 23, $expression); + $this->daysOfMonth = $this->parseField($dom, 1, 31, $expression); + $this->months = $this->parseField($mon, 1, 12, $expression); + $this->daysOfWeek = $this->normalizeWeekdays($this->parseField($dow, 0, 7, $expression)); + $this->domRestricted = trim($dom) !== '*'; + $this->dowRestricted = trim($dow) !== '*'; + } + + /** + * {@inheritDoc} + */ + public function firstFireTime(float $now, float $initialDelay): float + { + // Cron is clock-aligned; the initial delay does not apply. + return $this->nextFireTime($now, null, null); + } + + /** + * {@inheritDoc} + */ + public function nextFireTime(float $now, ?float $lastStartAt, ?float $lastEndAt): float + { + // Start at the next whole minute strictly after now, then walk the calendar + // jumping over whole non-matching months/days/hours so even a rare rule + // (e.g. Feb 29) resolves in a handful of steps. + $t = new DateTimeImmutable()->setTimestamp((int) floor($now)); + $t = $t->setTime((int) $t->format('G'), (int) $t->format('i'), 0)->modify('+1 minute'); + + for ($step = 0; $step < self::MAX_STEPS; $step++) { + // Jump over a whole non-matching month or day; step minute by minute + // inside a matching day (at most 1440 steps, so hour need not jump). + if (!isset($this->months[(int) $t->format('n')])) { + $t = $t->modify('first day of next month')->setTime(0, 0, 0); + continue; + } + if (!$this->dayMatches($t)) { + $t = $t->modify('+1 day')->setTime(0, 0, 0); + continue; + } + if (isset($this->hours[(int) $t->format('G')]) && isset($this->minutes[(int) $t->format('i')])) { + return (float) $t->getTimestamp(); + } + $t = $t->modify('+1 minute'); + } + + throw new InvalidArgumentException("cron '{$this->expression}' has no upcoming match."); + } + + /** + * {@inheritDoc} + */ + public function describe(): string + { + return 'cron ' . $this->expression; + } + + /** + * Whether the date's day satisfies the day-of-month / day-of-week rule: their + * union when both are restricted, otherwise whichever one constrains. + */ + private function dayMatches(DateTimeImmutable $t): bool + { + $domOk = isset($this->daysOfMonth[(int) $t->format('j')]); + $dowOk = isset($this->daysOfWeek[(int) $t->format('w')]); + + if ($this->domRestricted && $this->dowRestricted) { + return $domOk || $dowOk; + } + if ($this->domRestricted) { + return $domOk; + } + if ($this->dowRestricted) { + return $dowOk; + } + return true; + } + + /** + * Parses one cron field into the set of values it allows. + * + * @return array + */ + private function parseField(string $field, int $min, int $max, string $expression): array + { + $allowed = []; + foreach (explode(',', trim($field)) as $part) { + $step = 1; + $range = $part; + if (str_contains($part, '/')) { + [$range, $stepStr] = explode('/', $part, 2); + if (!ctype_digit($stepStr) || (int) $stepStr < 1) { + throw new InvalidArgumentException("cron '{$expression}' has an invalid step in '{$part}'."); + } + $step = (int) $stepStr; + } + + if ($range === '*') { + $lo = $min; + $hi = $max; + } elseif (str_contains($range, '-')) { + [$a, $b] = explode('-', $range, 2); + $lo = $this->intOrFail($a, $part, $expression); + $hi = $this->intOrFail($b, $part, $expression); + } else { + $lo = $hi = $this->intOrFail($range, $part, $expression); + } + + if ($lo < $min || $hi > $max || $lo > $hi) { + throw new InvalidArgumentException( + "cron '{$expression}' field '{$part}' is out of range [{$min}-{$max}]." + ); + } + for ($v = $lo; $v <= $hi; $v += $step) { + $allowed[$v] = true; + } + } + return $allowed; + } + + /** + * Folds weekday 7 (an accepted alias for Sunday) onto 0. + * + * @param array $days + * @return array + */ + private function normalizeWeekdays(array $days): array + { + if (isset($days[7])) { + unset($days[7]); + $days[0] = true; + } + return $days; + } + + private function intOrFail(string $value, string $part, string $expression): int + { + if (!ctype_digit($value)) { + throw new InvalidArgumentException("cron '{$expression}' field '{$part}' is not numeric."); + } + return (int) $value; + } +} diff --git a/src/Schedule/Trigger/FixedDelayTrigger.php b/src/Schedule/Trigger/FixedDelayTrigger.php new file mode 100644 index 0000000..73b1ab5 --- /dev/null +++ b/src/Schedule/Trigger/FixedDelayTrigger.php @@ -0,0 +1,44 @@ +delay; + } + + /** + * {@inheritDoc} + */ + public function describe(): string + { + return 'fixedDelay ' . $this->delay . 's'; + } +} diff --git a/src/Schedule/Trigger/FixedRateTrigger.php b/src/Schedule/Trigger/FixedRateTrigger.php new file mode 100644 index 0000000..1e08c5a --- /dev/null +++ b/src/Schedule/Trigger/FixedRateTrigger.php @@ -0,0 +1,50 @@ +rate); + } + + /** + * {@inheritDoc} + */ + public function describe(): string + { + return 'fixedRate ' . $this->rate . 's'; + } +} diff --git a/src/Schedule/Trigger/Trigger.php b/src/Schedule/Trigger/Trigger.php new file mode 100644 index 0000000..d49d3f1 --- /dev/null +++ b/src/Schedule/Trigger/Trigger.php @@ -0,0 +1,44 @@ +delayRuns++; + } + + #[Scheduled(fixedRate: 2.0, initialDelay: 1.5)] + public function onRate(): void + { + $this->rateRuns++; + } + + public function notScheduled(): void + { + } +} diff --git a/tests/Schedule/Fixtures/StaticScheduled.php b/tests/Schedule/Fixtures/StaticScheduled.php new file mode 100644 index 0000000..e4e631c --- /dev/null +++ b/tests/Schedule/Fixtures/StaticScheduled.php @@ -0,0 +1,16 @@ +marker = $this->storage . '/marker'; + putenv('WK_MARKER=' . $this->marker); + } + + protected function tearDown(): void + { + putenv('WK_MARKER'); + parent::tearDown(); + } + + private function tickCount(): int + { + if (!is_file($this->marker)) { + return 0; + } + return count(array_filter(explode("\n", (string) file_get_contents($this->marker)))); + } + + public function test_fires_repeatedly_then_stops_gracefully(): void + { + $pid = $this->fork(static fn() => MarkerScheduler::start()); + + // The task (fixedRate 0.2s) should fire several times, proving the loop + // dispatches through the engine and the bean is resolved and invoked. + self::assertTrue( + $this->pollUntil(fn() => $this->tickCount() >= 3 && MarkerScheduler::status() !== null), + 'the scheduler should fire its task repeatedly' + ); + + posix_kill($pid, SIGTERM); + + self::assertTrue($this->waitExit($pid), 'SIGTERM stops the scheduler'); + self::assertNull(MarkerScheduler::status(), 'the record is removed on exit'); + } +} diff --git a/tests/Schedule/ScheduledCollectorTest.php b/tests/Schedule/ScheduledCollectorTest.php new file mode 100644 index 0000000..f331d90 --- /dev/null +++ b/tests/Schedule/ScheduledCollectorTest.php @@ -0,0 +1,123 @@ +collect($class, new ReflectionClass($class)); + return $collector->getResult(); + } + + public function test_collects_annotated_methods_and_ignores_plain_ones(): void + { + $tasks = $this->collect(SampleScheduled::class); + + self::assertCount(2, $tasks); + $byMethod = []; + foreach ($tasks as $task) { + $byMethod[$task->methodName] = $task; + } + + self::assertArrayHasKey('onDelay', $byMethod); + self::assertArrayHasKey('onRate', $byMethod); + self::assertArrayNotHasKey('notScheduled', $byMethod); + + self::assertSame(SampleScheduled::class, $byMethod['onDelay']->className); + self::assertInstanceOf(FixedDelayTrigger::class, $byMethod['onDelay']->trigger); + self::assertSame(0.0, $byMethod['onDelay']->initialDelay); + + self::assertInstanceOf(FixedRateTrigger::class, $byMethod['onRate']->trigger); + self::assertSame(1.5, $byMethod['onRate']->initialDelay); + } + + public function test_no_trigger_is_rejected(): void + { + $this->expectException(ScheduleConfigException::class); + $this->expectExceptionMessageMatches('/no trigger/'); + $this->collect(NoTriggerScheduled::class); + } + + public function test_two_triggers_are_rejected(): void + { + $this->expectException(ScheduleConfigException::class); + $this->expectExceptionMessageMatches('/more than one trigger/'); + $this->collect(TwoTriggerScheduled::class); + } + + public function test_non_positive_period_is_rejected(): void + { + $this->expectException(ScheduleConfigException::class); + $this->expectExceptionMessageMatches('/greater than 0/'); + $this->collect(NonPositiveScheduled::class); + } + + public function test_static_method_is_rejected(): void + { + $this->expectException(ScheduleConfigException::class); + $this->expectExceptionMessageMatches('/non-static/'); + $this->collect(StaticScheduled::class); + } + + public function test_method_with_required_argument_is_rejected(): void + { + $this->expectException(ScheduleConfigException::class); + $this->expectExceptionMessageMatches('/no required arguments/'); + $this->collect(ArgScheduled::class); + } + + public function test_abstract_class_is_rejected(): void + { + $this->expectException(ScheduleConfigException::class); + $this->expectExceptionMessageMatches('/non-instantiable/'); + $this->collect(AbstractScheduled::class); + } + + public function test_cron_is_accepted(): void + { + $tasks = $this->collect(CronScheduled::class); + + self::assertCount(1, $tasks); + self::assertInstanceOf(CronTrigger::class, $tasks[0]->trigger); + self::assertSame('cron 0 2 * * *', $tasks[0]->trigger->describe()); + } + + public function test_malformed_cron_is_rejected(): void + { + $this->expectException(ScheduleConfigException::class); + $this->expectExceptionMessageMatches('/cron/'); + $this->collect(BadCronScheduled::class); + } + + public function test_initial_delay_with_cron_is_rejected(): void + { + $this->expectException(ScheduleConfigException::class); + $this->expectExceptionMessageMatches('/initialDelay is not supported with cron/'); + $this->collect(CronInitialDelayScheduled::class); + } +} diff --git a/tests/Schedule/ScheduledTaskTest.php b/tests/Schedule/ScheduledTaskTest.php new file mode 100644 index 0000000..7a1386d --- /dev/null +++ b/tests/Schedule/ScheduledTaskTest.php @@ -0,0 +1,25 @@ +id()); + self::assertSame(1.5, $task->initialDelay); + self::assertFalse($task->inFlight); + self::assertSame(0, $task->runs); + self::assertNull($task->lastStartAt); + self::assertNull($task->lastEndAt); + self::assertSame(0.0, $task->nextFireAt); + } +} diff --git a/tests/Schedule/ScheduledTest.php b/tests/Schedule/ScheduledTest.php new file mode 100644 index 0000000..ba7f4f4 --- /dev/null +++ b/tests/Schedule/ScheduledTest.php @@ -0,0 +1,42 @@ +fixedDelay); + self::assertNull($s->fixedRate); + self::assertNull($s->cron); + self::assertSame(0.0, $s->initialDelay); + } + + public function test_named_arguments(): void + { + $s = new Scheduled(fixedRate: 2.0, initialDelay: 10.0); + self::assertSame(2.0, $s->fixedRate); + self::assertSame(10.0, $s->initialDelay); + self::assertNull($s->fixedDelay); + } + + public function test_attribute_is_readable_from_a_method(): void + { + $method = new ReflectionMethod(SampleScheduled::class, 'onRate'); + $attrs = $method->getAttributes(Scheduled::class); + self::assertCount(1, $attrs); + + /** @var Scheduled $inst */ + $inst = $attrs[0]->newInstance(); + self::assertSame(2.0, $inst->fixedRate); + self::assertSame(1.5, $inst->initialDelay); + } +} diff --git a/tests/Schedule/SchedulerTest.php b/tests/Schedule/SchedulerTest.php new file mode 100644 index 0000000..74a55e9 --- /dev/null +++ b/tests/Schedule/SchedulerTest.php @@ -0,0 +1,136 @@ +setValue($scheduler, $tasks); + return $scheduler; + } + + private function untilNext(Scheduler $scheduler, float $now): float + { + return new ReflectionMethod(Scheduler::class, 'untilNext')->invoke($scheduler, $now); + } + + public function test_seed_sets_first_fire_from_now_plus_initial_delay(): void + { + $a = $this->task(0.0); + $b = $this->task(2.5); + $scheduler = $this->withTasks([$a, $b]); + + new ReflectionMethod(Scheduler::class, 'seed')->invoke($scheduler, 100.0); + + self::assertSame(100.0, $a->nextFireAt); + self::assertSame(102.5, $b->nextFireAt); + } + + public function test_until_next_idles_max_when_no_tasks(): void + { + // MAX_SLEEP = 1.0 + self::assertSame(1.0, $this->untilNext($this->withTasks([]), 100.0)); + } + + public function test_until_next_idles_max_when_all_in_flight(): void + { + $t = $this->task(); + $t->nextFireAt = 100.0; // due, but in flight → excluded + $t->inFlight = true; + self::assertSame(1.0, $this->untilNext($this->withTasks([$t]), 100.0)); + } + + public function test_until_next_floors_a_past_due_task(): void + { + // MIN_SLEEP = 0.01 + $t = $this->task(); + $t->nextFireAt = 90.0; // overdue + self::assertSame(0.01, $this->untilNext($this->withTasks([$t]), 100.0)); + } + + public function test_until_next_caps_a_far_future_task(): void + { + $t = $this->task(); + $t->nextFireAt = 200.0; // far away → capped at MAX_SLEEP + self::assertSame(1.0, $this->untilNext($this->withTasks([$t]), 100.0)); + } + + public function test_until_next_returns_soonest_within_the_window(): void + { + $a = $this->task(); + $a->nextFireAt = 100.7; + $b = $this->task(); + $b->nextFireAt = 100.3; // sooner + self::assertEqualsWithDelta(0.3, $this->untilNext($this->withTasks([$a, $b]), 100.0), 1e-9); + } + + public function test_until_next_polls_soon_while_a_run_is_in_flight(): void + { + // Nothing is due for a while, but a run is in flight — the loop must poll + // soon (REAP_POLL = 0.05) to reap it, not idle a whole MAX_SLEEP. + $t = $this->task(); + $t->nextFireAt = 200.0; + $scheduler = $this->withTasks([$t]); + new ReflectionProperty(Scheduler::class, 'running')->setValue($scheduler, [ + 0 => CompletableFuture::completedFuture(null), + ]); + self::assertSame(0.05, $this->untilNext($scheduler, 100.0)); + } + + public function test_reap_finalizes_only_settled_runs(): void + { + // Task 0's run has completed; task 1's is still in flight. + $done = $this->task(); + $done->inFlight = true; + $done->lastStartAt = 100.0; + $pending = $this->task(); + $pending->inFlight = true; + $pending->lastStartAt = 100.0; + + $scheduler = $this->withTasks([$done, $pending]); + new ReflectionProperty(Scheduler::class, 'running')->setValue($scheduler, [ + 0 => CompletableFuture::completedFuture(null), + 1 => new CompletableFuture(), + ]); + + new ReflectionMethod(Scheduler::class, 'reap')->invoke($scheduler); + + // The settled run is finalized: released, counted, its next fire advanced. + self::assertFalse($done->inFlight); + self::assertSame(1, $done->runs); + self::assertNotNull($done->lastEndAt); + self::assertGreaterThan(0.0, $done->nextFireAt); + + // The pending run is untouched and still tracked. + self::assertTrue($pending->inFlight); + self::assertSame(0, $pending->runs); + + $running = new ReflectionProperty(Scheduler::class, 'running')->getValue($scheduler); + self::assertArrayNotHasKey(0, $running); + self::assertArrayHasKey(1, $running); + } +} diff --git a/tests/Schedule/Trigger/CronTriggerTest.php b/tests/Schedule/Trigger/CronTriggerTest.php new file mode 100644 index 0000000..6cd6763 --- /dev/null +++ b/tests/Schedule/Trigger/CronTriggerTest.php @@ -0,0 +1,109 @@ +tz = date_default_timezone_get(); + date_default_timezone_set('UTC'); + } + + protected function tearDown(): void + { + date_default_timezone_set($this->tz); + } + + private function next(string $expr, int $fromTs): int + { + return (int) new CronTrigger($expr)->nextFireTime((float) $fromTs, null, null); + } + + public function test_daily_at_night(): void + { + // Mon 2026-06-15 10:30:15 → next 02:00 is the following day. + $now = mktime(10, 30, 15, 6, 15, 2026); + self::assertSame(mktime(2, 0, 0, 6, 16, 2026), $this->next('0 2 * * *', $now)); + } + + public function test_every_minute_and_hour(): void + { + $now = mktime(10, 30, 15, 6, 15, 2026); + self::assertSame(mktime(10, 31, 0, 6, 15, 2026), $this->next('* * * * *', $now)); + self::assertSame(mktime(11, 0, 0, 6, 15, 2026), $this->next('0 * * * *', $now)); + } + + public function test_step_and_list(): void + { + $now = mktime(10, 30, 15, 6, 15, 2026); + self::assertSame(mktime(10, 45, 0, 6, 15, 2026), $this->next('*/15 * * * *', $now)); + self::assertSame(mktime(18, 30, 0, 6, 15, 2026), $this->next('30 6,18 * * *', $now)); + } + + public function test_weekday_range_skips_the_weekend(): void + { + // Sat 2026-06-13 09:00 → 08:00 on weekdays lands on Mon 2026-06-15. + $sat = mktime(9, 0, 0, 6, 13, 2026); + self::assertSame(mktime(8, 0, 0, 6, 15, 2026), $this->next('0 8 * * 1-5', $sat)); + } + + public function test_leap_day_resolves_across_years(): void + { + $now = mktime(10, 30, 0, 6, 15, 2026); + self::assertSame(mktime(0, 0, 0, 2, 29, 2028), $this->next('0 0 29 2 *', $now)); + } + + public function test_macro_matches_expanded_form(): void + { + $now = mktime(10, 30, 15, 6, 15, 2026); + self::assertSame($this->next('0 * * * *', $now), $this->next('@hourly', $now)); + self::assertSame($this->next('0 0 * * *', $now), $this->next('@daily', $now)); + } + + public function test_sunday_is_both_zero_and_seven(): void + { + $now = mktime(10, 30, 15, 6, 15, 2026); // Monday + self::assertSame($this->next('0 0 * * 0', $now), $this->next('0 0 * * 7', $now)); + } + + public function test_first_fire_ignores_initial_delay(): void + { + $now = mktime(10, 30, 15, 6, 15, 2026); + $trigger = new CronTrigger('0 2 * * *'); + self::assertSame( + $trigger->nextFireTime((float) $now, null, null), + $trigger->firstFireTime((float) $now, 9999.0) + ); + } + + public function test_describe(): void + { + self::assertSame('cron 0 2 * * *', new CronTrigger('0 2 * * *')->describe()); + } + + /** + * @return list + */ + public static function malformed(): array + { + return [[''], ['1 2 3'], ['60 * * * *'], ['* * * * 9'], ['a b c d e'], ['*/0 * * * *'], ['5-1 * * * *']]; + } + + #[DataProvider('malformed')] + public function test_malformed_expressions_throw(string $expr): void + { + $this->expectException(InvalidArgumentException::class); + new CronTrigger($expr); + } +} diff --git a/tests/Schedule/Trigger/FixedDelayTriggerTest.php b/tests/Schedule/Trigger/FixedDelayTriggerTest.php new file mode 100644 index 0000000..0a1b1fe --- /dev/null +++ b/tests/Schedule/Trigger/FixedDelayTriggerTest.php @@ -0,0 +1,37 @@ +nextFireTime(100.0, null, null)); + } + + public function test_next_fire_is_measured_from_last_end_not_start(): void + { + $t = new FixedDelayTrigger(5.0); + // Started at 100, finished at 108 (an 8s run): the next fire is 108 + 5. + self::assertSame(113.0, $t->nextFireTime(108.0, 100.0, 108.0)); + } + + public function test_first_fire_is_boot_plus_initial_delay(): void + { + $t = new FixedDelayTrigger(5.0); + self::assertSame(110.0, $t->firstFireTime(100.0, 10.0)); + self::assertSame(100.0, $t->firstFireTime(100.0, 0.0)); + } + + public function test_describe(): void + { + self::assertSame('fixedDelay 5s', new FixedDelayTrigger(5.0)->describe()); + } +} diff --git a/tests/Schedule/Trigger/FixedRateTriggerTest.php b/tests/Schedule/Trigger/FixedRateTriggerTest.php new file mode 100644 index 0000000..7ec88c2 --- /dev/null +++ b/tests/Schedule/Trigger/FixedRateTriggerTest.php @@ -0,0 +1,43 @@ +nextFireTime(100.0, null, null)); + } + + public function test_next_fire_is_measured_from_last_start(): void + { + $t = new FixedRateTrigger(2.0); + // Started at 100, finished at 100.5 (a short run): next fire is 100 + 2. + self::assertSame(102.0, $t->nextFireTime(100.5, 100.0, 100.5)); + } + + public function test_overrun_fires_once_now_without_burst(): void + { + $t = new FixedRateTrigger(2.0); + // Started at 100, still finishing at 105 (a 5s run > 2s rate): the + // next fire is already overdue, so it fires now — not once per missed tick. + self::assertSame(105.0, $t->nextFireTime(105.0, 100.0, 105.0)); + } + + public function test_first_fire_is_boot_plus_initial_delay(): void + { + $t = new FixedRateTrigger(2.0); + self::assertSame(110.0, $t->firstFireTime(100.0, 10.0)); + } + + public function test_describe(): void + { + self::assertSame('fixedRate 2s', new FixedRateTrigger(2.0)->describe()); + } +} From ea62313ee2f2c70008247ccce77605db61428255 Mon Sep 17 00:00:00 2001 From: Jason Khan Date: Sun, 26 Jul 2026 02:17:07 +0500 Subject: [PATCH 16/71] Schedule beta test --- docs/concurrent/00-overview.md | 5 +- docs/concurrent/01-executors.md | 25 ++ docs/concurrent/03-async.md | 18 ++ docs/concurrent/05-pools.md | 219 +++++++++++++++ docs/console/00-overview.md | 19 +- docs/console/12-schedule.md | 124 +++++++++ docs/process/00-overview.md | 8 + docs/process/daemon/00-overview.md | 6 + docs/schedule/00-overview.md | 13 + docs/schedule/01-usage.md | 141 ++++++++++ phpunit.xml | 3 + src/Concurrent/BoundedExecutorService.php | 31 +++ .../Executor/FixedExecutorService.php | 260 ++++++++++++++++++ src/Concurrent/Executors.php | 26 ++ src/Concurrent/RejectPolicy.php | 24 ++ src/Schedule/Scheduler.php | 20 +- .../Executor/FixedExecutorConcurrencyTest.php | 113 ++++++++ .../Executor/FixedExecutorServiceTest.php | 104 +++++++ tests/Concurrent/RejectPolicyTest.php | 20 ++ 19 files changed, 1169 insertions(+), 10 deletions(-) create mode 100644 docs/concurrent/05-pools.md create mode 100644 docs/console/12-schedule.md create mode 100644 src/Concurrent/BoundedExecutorService.php create mode 100644 src/Concurrent/Executor/FixedExecutorService.php create mode 100644 src/Concurrent/RejectPolicy.php create mode 100644 tests/Concurrent/Executor/FixedExecutorConcurrencyTest.php create mode 100644 tests/Concurrent/Executor/FixedExecutorServiceTest.php create mode 100644 tests/Concurrent/RejectPolicyTest.php diff --git a/docs/concurrent/00-overview.md b/docs/concurrent/00-overview.md index 9a099df..8adf22d 100644 --- a/docs/concurrent/00-overview.md +++ b/docs/concurrent/00-overview.md @@ -49,7 +49,8 @@ Executors ← entry point, picks the backend for the runtime ├── ExecutorService ← the contract: submit / execute / invokeAll │ ↑ │ ├── CoroutineExecutorService (Swoole) go() + Channel - │ └── DeferredExecutorService (FPM/CLI) lazy + fastcgi_finish_request + │ ├── DeferredExecutorService (FPM/CLI) lazy + fastcgi_finish_request + │ └── FixedExecutorService (pool) N-slot semaphore over the above │ └── Future ← handle on a result ↑ @@ -116,6 +117,7 @@ contract. It requires the object to come from the DI container — see | A service that is asynchronous by nature | `#[Async]` | | Static method, `final` class, manual `new` | `Executors::common()` | | Fan-out over several external calls | `invokeAll()` | +| Cap the parallelism of a workstream | `Executors::newFixedExecutor(n)` — [05-pools.md](05-pools.md) | --- @@ -142,6 +144,7 @@ result: | 02 | [02-future.md](02-future.md) | `Future`, `CompletableFuture`, results and failures | | 03 | [03-async.md](03-async.md) | `#[Async]` — contract, proxying, pitfalls | | 04 | [04-build.md](04-build.md) | Caches, `call di build`, deployment | +| 05 | [05-pools.md](05-pools.md) | Fixed-size pools — sizing, reject policies, gauges | ## See also diff --git a/docs/concurrent/01-executors.md b/docs/concurrent/01-executors.md index 8ee8a14..30ca91b 100644 --- a/docs/concurrent/01-executors.md +++ b/docs/concurrent/01-executors.md @@ -48,6 +48,21 @@ Requires an active coroutine at submit time; otherwise every call throws A fresh deferred executor. Chiefly useful in tests, and when you deliberately want synchronous behaviour inside a Swoole process. +### `newFixedExecutor(int $concurrency, int $queue = 0, RejectPolicy $onReject = RejectPolicy::ABORT): BoundedExecutorService` + +A fresh **fixed-size pool**: at most `$concurrency` tasks run at once, the rest +wait for a slot. `Executors.newFixedThreadPool(n)` for coroutines. Register it in +the container to give an `#[Async('id')]` method a dedicated, capped pool. + +```php +$pool = Executors::newFixedExecutor(5); // cap at 5 concurrent +$pool->submit(fn() => $gateway->send($msg)); +``` + +The cap is enforced under Swoole; without coroutines the pool runs tasks +sequentially and the size is a no-op. Full treatment — the reject policy, the +occupancy gauges, the per-process caveat — is in [05-pools.md](05-pools.md). + ### `shutdownCommon(?float $timeout = null): bool` Stops the shared executors accepting new work and waits for what is already @@ -186,6 +201,15 @@ Two limits keep counting during the drain: `max_execution_time` and FPM's > used when present). Under CLI and the built-in dev server the response is > simply sent when the script ends. +### `FixedExecutorService` — a bounded pool + +A decorator over the two backends above: under Swoole it gates each task with a +`Channel` semaphore of N tokens (so no more than N coroutines run the body at +once); without coroutines it delegates to the deferred backend, where the bound +is moot. It adds occupancy gauges (`activeCount()`, `queuedCount()`, +`remainingCapacity()`) and a reject policy for a bounded queue. Created via +`newFixedExecutor()` — see [05-pools.md](05-pools.md). + --- ## Errors @@ -206,4 +230,5 @@ The first is raised at submit time, the rest by `Future::get()` — see - [02-future.md](02-future.md) — the handle `submit()` returns - [03-async.md](03-async.md) — declaring asynchrony on the method instead +- [05-pools.md](05-pools.md) — fixed-size pools, reject policies, occupancy gauges - [`ppa/`](../ppa/00-overview.md) — connection pool a task borrows from diff --git a/docs/concurrent/03-async.md b/docs/concurrent/03-async.md index 7a9720b..234edb8 100644 --- a/docs/concurrent/03-async.md +++ b/docs/concurrent/03-async.md @@ -231,6 +231,24 @@ public function build(int $month): Future { … } The argument is a container id resolved at call time. Omitted, the method uses `Executors::common()`. +To back a method with a **bounded pool** — cap its parallelism, or share one +budget across several methods — register an executor under that id as a singleton +and name it: + +```php +// Boot::providers() +$c->singleton('reports', fn() => Executors::newFixedExecutor(3)); // at most 3 at once + +// the method +#[Async(executor: 'reports')] +public function build(int $month): Future { … } +``` + +Now every `build()` call runs on the `reports` pool, three concurrently, the rest +queued. Any `ExecutorService` registered under the id works; a fixed pool is the +usual choice. See [05-pools.md](05-pools.md) for pool sizing, reject policies and +the per-process caveat. + --- ## When not to use it diff --git a/docs/concurrent/05-pools.md b/docs/concurrent/05-pools.md new file mode 100644 index 0000000..7916f00 --- /dev/null +++ b/docs/concurrent/05-pools.md @@ -0,0 +1,219 @@ +# Fixed-size pools + +`Executors::common()` never says no. Every `execute()` starts a coroutine at +once, so a burst of a thousand tasks becomes a thousand coroutines fighting over +the same database pool and the same CPU. Usually that is fine — coroutines are +cheap and I/O-bound work interleaves. Sometimes it is not: an external API that +allows five connections, a report that each eats 200 MB, a mailer you must not +hammer. + +A **fixed-size pool** puts a ceiling on it: at most *N* tasks run at once, the +rest wait for a slot. It is `Executors.newFixedThreadPool(n)` from Java, adapted +to coroutines. + +```php +use Flytachi\Winter\K2\Concurrent\Executors; + +$pool = Executors::newFixedExecutor(5); // at most 5 running at a time + +for ($i = 0; $i < 100; $i++) { + $pool->submit(fn() => $gateway->send($messages[$i])); // 5 in flight, 95 waiting +} +``` + +The 100 submits return immediately; only five bodies run concurrently, the other +ninety-five park until a slot frees. Nothing is lost, nothing blocks the caller. + +--- + +## Creating one + +```php +Executors::newFixedExecutor( + int $concurrency, // max tasks running at once (>= 1) + int $queue = 0, // waiting slots; 0 = unbounded + RejectPolicy $onReject = RejectPolicy::ABORT, +): BoundedExecutorService +``` + +- **`concurrency`** — the ceiling. Five means five bodies run at the same time. +- **`queue`** — how many tasks may *wait* for a slot. `0` (the default) is an + unbounded wait queue: submitting always succeeds, matching Java's + `newFixedThreadPool`. A positive value caps the backlog and turns on the reject + policy. +- **`onReject`** — what happens to a task that arrives when the pool is full and + the queue is at capacity. Only reached when `queue > 0`. See + [Overflow](#overflow). + +`newFixedExecutor(5)` is the common case: cap the parallelism, let everything +else queue. + +--- + +## Giving a service its own pool + +A pool is most useful behind [`#[Async]`](03-async.md): register the pool once, +name it on the method, and every call to that method runs on it — bounded, +without a line of executor code at the call site. + +**Register it as a singleton** in your Boot's `providers()`: + +```php +use Flytachi\Winter\K2\Concurrent\Executors; + +protected static function providers(Container $c): void +{ + $c->singleton('gatewayPool', fn() => Executors::newFixedExecutor(5)); +} +``` + +**Name it on the method:** + +```php +class SmsService +{ + #[Async(executor: 'gatewayPool')] + public function send(string $to, string $body): void + { + $this->gateway->send($to, $body); + } +} +``` + +**Call it normally** — the calls funnel through `gatewayPool`, five at a time: + +```php +foreach ($recipients as $to) { + $this->sms->send($to, $body); // returns immediately, bounded to 5 in flight +} +``` + +> **It must be a `singleton`.** The cap lives in the pool object's state, so every +> `#[Async('gatewayPool')]` call has to resolve the *same* instance. A `bind()` or +> transient registration hands out a fresh pool each call, and the ceiling never +> holds. + +--- + +## The bound is a Swoole property + +A pool caps *concurrency*, and concurrency only exists under Swoole. The backend +switches on the runtime, per call: + +| Runtime | What a pool does | +|---|---| +| **Swoole** (coroutine) | Real ceiling: N coroutines run, the rest park on a `Channel` semaphore. | +| **PHP-FPM** | Delegates to the deferred backend: tasks run **sequentially after the response is flushed**. There is no parallelism to bound, so `concurrency` is a no-op. | +| **Plain CLI** | Deferred / sequential likewise. | + +This mirrors `Executors::common()` — see [01-executors.md](01-executors.md#backends). +The pool never breaks in a non-Swoole runtime; it degrades to running tasks one +at a time, which is all a synchronous SAPI can do. Design for Swoole; treat the +FPM/CLI behaviour as a correct-but-serial fallback. + +--- + +## Overflow + +With the default unbounded queue (`queue = 0`) a pool never rejects — a slow +drain just grows the backlog of parked coroutines, which costs memory. When you +need a hard limit, set `queue` and pick a `RejectPolicy` for the moment both the +slots and the queue are full: + +```php +use Flytachi\Winter\K2\Concurrent\RejectPolicy; + +Executors::newFixedExecutor( + concurrency: 5, + queue: 50, + onReject: RejectPolicy::ABORT, +); +``` + +| Policy | On overflow | The caller sees | +|---|---|---| +| `ABORT` (default) | Throw `RejectedExecutionException` at submit | An exception — fail fast, shed load loudly | +| `CALLER_RUNS` | Run the task inline, right here | Back-pressure: the submitter does the work and slows down | +| `DISCARD` | Drop the task | A **cancelled** future (`isCancelled()` true); for `execute()`, nothing | + +Which one you feel depends on how you called it: + +**Fire-and-forget (`void` / `execute`)** — you hold no handle, so the *pool* +reacts: `ABORT` surfaces the exception to whoever triggered the call (a scheduler +tick, a request), `CALLER_RUNS` makes that caller do the work, `DISCARD` drops it +silently. + +**With a result (`: Future` / `submit`)** — you hold the handle and decide: + +```php +$future = $this->reports->build($month); // #[Async('reportPool')] : Future +try { + $report = $future->get(timeout: 30.0); +} catch (RejectedExecutionException $e) { + // pool saturated → degrade (503, retry later, …) +} +``` + +Rule of thumb: unbounded queue for work that must not be lost; bounded + `ABORT` +or `DISCARD` for work where "too much at once" should shed rather than pile up; +`CALLER_RUNS` when you want the producer to naturally slow to the pool's pace. + +--- + +## Reading the gauges + +`newFixedExecutor` returns a `BoundedExecutorService`, which reports its live +occupancy — the equivalent of `ThreadPoolExecutor.getActiveCount()` / +`getQueue().size()`: + +```php +$pool->concurrency(); // the ceiling N +$pool->activeCount(); // running right now (0..N) +$pool->queuedCount(); // accepted, waiting for a slot +$pool->remainingCapacity(); // room before the reject policy applies (PHP_INT_MAX if unbounded) +``` + +Surface them where the owning process is already observed — a `status` command, +an actuator endpoint: *"gateway: 5/5 busy, 12 queued."* The gauges are live only +under Swoole; in a sequential runtime they stay at rest. + +> These are counters, not a task registry. "How many are waiting" is cheap; "which +> tasks are waiting" is not tracked — if you need a named, inspectable, durable +> queue, that is a job-queue built on [Process/Daemon](../process/00-overview.md), +> not this in-memory pool. + +--- + +## Two things to keep in mind + +**The pool is per-process.** It lives in the memory of whichever process holds +it. Under a Swoole HTTP server every worker has its own instance, so a pool of 5 +registered as a singleton caps at 5 *per worker* — with 4 workers, up to 20 run +across the server. The scheduler process has its own, separate. A single ceiling +shared across processes or hosts is a different tool (an external broker), not an +in-memory pool. + +**A pool is not durable.** The queue is RAM. If the process dies, the parked +tasks die with it. That is the trade for being instant and free — no Redis, no +serialization, no network. When you need tasks to survive a restart, reach for a +persistent queue, again on Process/Daemon. + +--- + +## When to use what + +| Need | Reach for | +|---|---| +| One-off background work, no limit | `Executors::common()` | +| Cap the parallelism of a workstream | `Executors::newFixedExecutor(n)` | +| A service always async, on its own bounded pool | `#[Async('id')]` + a registered `newFixedExecutor` | +| One global cap across processes / hosts, durable | a job queue on [Process/Daemon](../process/00-overview.md) | + +--- + +## See also + +- [01-executors.md](01-executors.md) — `Executors`, the primitive backends +- [03-async.md](03-async.md#choosing-an-executor) — `#[Async(executor: 'id')]` +- [02-future.md](02-future.md) — the handle `submit()` returns +- [`schedule/01-usage.md`](../schedule/01-usage.md#bounding-concurrency-with-a-named-pool) — sharing a pool between the scheduler and the API diff --git a/docs/console/00-overview.md b/docs/console/00-overview.md index 1487f6f..c271e23 100644 --- a/docs/console/00-overview.md +++ b/docs/console/00-overview.md @@ -28,12 +28,15 @@ three buckets: | `options` | `--key` or `--key=v`| `--port=8000`, `--mvc` | The first positional argument is the command name; if omitted, `Help` runs. -Two short aliases are wired in `console/Core.php`: +Short aliases are wired in `console/Core.php`: -| Alias | Resolves to | -|-------|-------------| -| `sc` | `Script` | -| `th` | `Thread` | +| Alias | Resolves to | +|--------|-------------| +| `sc` | `Script` | +| `th` | `Thread` | +| `proc` | `Process` | +| `dmn` | `Daemon` | +| `sch` | `Schedule` | A command name is mapped to `Flytachi\Winter\Console\Command\` via `ucwords()`, and `::script($parsed)` is called on it. @@ -182,6 +185,12 @@ it directly. See [11-complete.md](11-complete.md). | [09](09-thread.md) | `thread` | Run `Dispatchable` tasks (alias `th`) | | [10](10-di.md) | `di` | Build / clean / show DI scanner cache | | [11](11-complete.md) | `complete`| Shell-completion endpoint (internal) | +| [12](12-schedule.md) | `schedule`| Run the scheduler; list `#[Scheduled]` tasks (alias `sch`) | + +The **`process`** and **`daemon`** commands (aliases `proc` / `dmn`) manage +long-lived worker processes and supervised fleets; they are documented with the +runtime itself in [`process/03-control.md`](../process/03-control.md) and +[`process/daemon/03-control.md`](../process/daemon/03-control.md). --- diff --git a/docs/console/12-schedule.md b/docs/console/12-schedule.md new file mode 100644 index 0000000..1b4ae74 --- /dev/null +++ b/docs/console/12-schedule.md @@ -0,0 +1,124 @@ +# `call schedule` — run the scheduler and list `#[Scheduled]` tasks + +Drives the **Scheduler** — the single process that fires every method annotated +with `#[Scheduled]` on its trigger. Unlike `call process` / `call daemon` there is +no class to name: the scheduler is one fixed runtime, and its "schedule" is the set +of annotated methods it discovers across the project. + +Alias: **`sch`**. + +--- + +## Synopsis + +``` +call schedule list # list every #[Scheduled] task and its cadence (default) +call schedule start # run the scheduler in the foreground +call schedule start -d # run it detached in the background +call schedule status # run state + task count +call schedule status -v # also the master's resource usage +call schedule stop # graceful stop (SIGTERM) +``` + +Running `call schedule` with no action is the same as `call schedule list`. + +--- + +## Actions + +### `list` + +Runs the discovery scan (`ClassScanner` + `ScheduledCollector`) and prints every +task and its trigger. It is a **static scan** — nothing has to be running, and it +is the fastest way to confirm an annotation was picked up (or why it was rejected: +a misconfigured `#[Scheduled]` fails the scan with a `ScheduleConfigException`). + +``` + | [ Scheduled Tasks ] + TASK TRIGGER + App\ReportService::nightly cron 0 2 * * * + App\PollService::poll fixedRate 30s + | [i] 2 task(s) defined. +``` + +### `start` + +Launches the scheduler. Foreground by default (blocks the terminal; `Ctrl-C` +stops it). With `-d` it is dispatched **detached** into the background and the +command returns after briefly polling for the started PID. + +| Flag | Effect | +|------|--------| +| `-d` | Start detached in the background instead of the foreground | + +A second `start` while one is already running is refused — the scheduler holds a +per-class singleton lock (a crash-safe `flock`), so there is never more than one +per host. + +### `stop` + +Sends a graceful `SIGTERM` to the running scheduler. In-flight task runs drain, +then the process exits and removes its status record. Prints a warning if nothing +is running. + +### `status` + +Reports the scheduler's run state, PID, activity, uptime and the number of +discovered tasks. `-v` additionally attaches the master process's live resource +usage (CPU / memory via `ps`). + +| Flag | Effect | +|------|--------| +| `-v` | Attach the master's resource usage | + +``` + | [ Scheduler Status ] + PID 48213 + State RUNNING + Activity IDLE + Started 2026-07-26 02:00:00 +00:00 + Uptime 6h 12m + Tasks 2 +``` + +When nothing is running, `status` prints `○ STOPPED`. + +--- + +## Runtime notes + +- **One per host.** The singleton lock means `start` / `dispatch` refuse a second + instance; a task is never fired twice by two schedulers on the same box. +- **`list` needs no running process** — it boots the container and scans, exactly + as the scheduler does on start, so it surfaces configuration errors early. +- **Under FPM there is no long-lived scheduler.** Like `call daemon`, this is a + CLI-driven long-running process; in production run it under a supervisor + (systemd / supervisor / a Kubernetes deployment) with `start` in the foreground, + or `start -d` for a quick detached run. + +--- + +## Examples + +```bash +call schedule list # what got discovered? +call sch list # same, via the alias +call schedule start # run in the foreground (Ctrl-C to stop) +call schedule start -d # run detached; note the PID it prints +call schedule status -v # is it alive, and how much is it using? +call schedule stop # ask it to stop gracefully +``` + +--- + +## Source + +- `console/Command/Schedule.php` — the command +- `src/Schedule/Scheduler.php` — the runtime it starts +- `src/Schedule/ScheduledCollector.php` — the discovery `list` runs + +## See also + +- [`../schedule/00-overview.md`](../schedule/00-overview.md) — what the Scheduler is and how triggers work +- [`../schedule/01-usage.md`](../schedule/01-usage.md) — writing `#[Scheduled]` methods, recipes, production +- [`../process/03-control.md`](../process/03-control.md) — the `start` / `stop` / `status` model the scheduler inherits from Process diff --git a/docs/process/00-overview.md b/docs/process/00-overview.md index 180258e..46306ff 100644 --- a/docs/process/00-overview.md +++ b/docs/process/00-overview.md @@ -116,6 +116,12 @@ is I/O-bound and the units are independent. That is the subject of Concurrency is opt-in. A process that never calls `spawn()` is an ordinary sequential program, and that is a perfectly good thing to be. +**Triggered by a clock, not a loop?** When the question is *when* to run rather +than *how* to loop — every night at 02:00, every five minutes — you usually do +not write a Process at all: annotate a plain method with `#[Scheduled]` and the +**Scheduler** runs it on time. The Scheduler is itself a Process, built on +everything this page describes; see [`schedule/`](../schedule/00-overview.md). + --- ## The two runtimes @@ -173,4 +179,6 @@ layer *inside* a single worker — the two compose rather than compete. ## See also - [`concurrent/00-overview.md`](../concurrent/00-overview.md) — `Executors` and `Future`, the primitive `spawn()` is built on +- [`schedule/00-overview.md`](../schedule/00-overview.md) — the Scheduler, a Process that runs `#[Scheduled]` methods on a clock +- [`process/daemon/00-overview.md`](daemon/00-overview.md) — a supervised fleet of identical processes - [`ppa/00-overview.md`](../ppa/00-overview.md) — the database connection pool a process shares across its coroutines diff --git a/docs/process/daemon/00-overview.md b/docs/process/daemon/00-overview.md index 2e9f63e..13291b4 100644 --- a/docs/process/daemon/00-overview.md +++ b/docs/process/daemon/00-overview.md @@ -105,3 +105,9 @@ Everything below the body is the supervisor's job — you never write it: `ScalingPolicy` damping model, `RestartPolicy`, and the master hooks. - **[Control](03-control.md)** — `start` / `dispatch` / `status` / `stop`, the CLI, the stop sequence, and the per-worker fleet view. + +## See also + +- [`process/00-overview.md`](../00-overview.md) — the bare Process a Daemon supervises and extends +- [`concurrent/05-pools.md`](../../concurrent/05-pools.md) — an in-process bounded pool; a Daemon is the tool when a workstream needs its *own processes* instead +- [`schedule/00-overview.md`](../../schedule/00-overview.md) — the Scheduler, for time-triggered rather than always-on work diff --git a/docs/schedule/00-overview.md b/docs/schedule/00-overview.md index 5437a72..347d484 100644 --- a/docs/schedule/00-overview.md +++ b/docs/schedule/00-overview.md @@ -96,3 +96,16 @@ call schedule stop # graceful SIGTERM See [Usage](01-usage.md) for the attribute in depth, the execution model, and the fork-safety note under the non-Swoole runtime. + +## Pages + +| # | File | Contents | +|---|------|----------| +| 00 | this page | What the layer is, triggers, when it runs | +| 01 | [01-usage.md](01-usage.md) | Quick start, recipes, triggers, execution, running in production | + +## See also + +- [`concurrent/05-pools.md`](../concurrent/05-pools.md) — bound a scheduled task to a named pool it shares with the API +- [`concurrent/03-async.md`](../concurrent/03-async.md) — `#[Async]` on a scheduled method +- [`process/00-overview.md`](../process/00-overview.md) — the Process layer the Scheduler runs on diff --git a/docs/schedule/01-usage.md b/docs/schedule/01-usage.md index 2eb50c7..8497b0c 100644 --- a/docs/schedule/01-usage.md +++ b/docs/schedule/01-usage.md @@ -1,5 +1,46 @@ # Winter Schedule — Usage +## Quick start + +Three steps, no wiring. + +**1. Annotate a method** on any class the container can build: + +```php +use Flytachi\Winter\K2\Schedule\Scheduled; +use Psr\Log\LoggerInterface; + +class ReportService +{ + #[Autowired] private LoggerInterface $logger; + + #[Scheduled(cron: '0 2 * * *')] // every night at 02:00 + public function nightly(): void + { + $this->logger->info('rolling yesterday’s metrics'); + // ... work ... + } +} +``` + +**2. See what was found** (a static scan — nothing has to be running): + +``` +$ call schedule list + TASK TRIGGER + App\ReportService::nightly cron 0 2 * * * +``` + +**3. Run the scheduler:** + +``` +call schedule start # foreground (Ctrl-C to stop) +call schedule start -d # background; stop with: call schedule stop +``` + +That is the whole loop. Everything below is detail on the triggers, the execution +model, and running it in production. + ## The attribute `#[Scheduled]` marks a method to be run on a cadence. It is repeatable, so one @@ -93,6 +134,45 @@ public function warmThenPoll(): void { /* ... */ } Seconds to wait after boot before the first run — handy for letting dependencies warm up, or staggering tasks that would otherwise all fire at boot. +## Recipes + +Copy-paste starting points for the common cadences. + +```php +// every 30 seconds, measured from the start of each run +#[Scheduled(fixedRate: 30.0)] +public function poll(): void { /* ... */ } + +// 5 seconds after each run finishes (gap immune to run duration) +#[Scheduled(fixedDelay: 5.0)] +public function drainQueue(): void { /* ... */ } + +// warm up, then poll every 10s starting 60s after boot +#[Scheduled(fixedRate: 10.0, initialDelay: 60.0)] +public function refreshCache(): void { /* ... */ } + +// every night at 02:00 +#[Scheduled(cron: '0 2 * * *')] +public function nightlyCleanup(): void { /* ... */ } + +// 08:00 on weekdays +#[Scheduled(cron: '0 8 * * 1-5')] +public function weekdayReport(): void { /* ... */ } + +// top of every hour +#[Scheduled(cron: '0 * * * *')] +public function hourlyRollup(): void { /* ... */ } + +// every 15 minutes, on the quarter +#[Scheduled(cron: '*/15 * * * *')] +public function everyQuarterHour(): void { /* ... */ } + +// two triggers on one method — both fire it +#[Scheduled(cron: '0 9 * * *')] // 09:00 daily +#[Scheduled(cron: '0 17 * * *')] // and 17:00 daily +public function twiceADay(): void { /* ... */ } +``` + ## The execution model The Scheduler loops: on each pass it fires every task that is due and not already @@ -133,6 +213,67 @@ periodic I/O), or run the Scheduler under the fork runtime so each run is a separate process. Blocking *I/O* is not a problem: Swoole's runtime hooks turn it into a yield. +## Bounding concurrency with a named pool + +A scheduled method may also be an `#[Async]` method — the two compose. When the +scheduler fires it, the `#[Async]` proxy routes the body onto its executor, exactly +as it would for an API call. Point both at the same **named pool** and scheduled +runs and API-triggered runs share one bounded set of workers. + +Register the pool once (a fixed-size executor) in your Boot's `providers()`: + +```php +use Flytachi\Winter\K2\Concurrent\Executors; + +protected static function providers(Container $c): void +{ + // at most 5 running at once; unbounded wait queue (the default) + $c->singleton('mailPool', fn() => Executors::newFixedExecutor(5)); +} +``` + +Then reference it by id from `#[Async]`: + +```php +class MailService +{ + #[Scheduled(cron: '* * * * *')] // the scheduler fires it every minute + #[Async('mailPool')] // …onto mailPool + public function drain(): void { /* ... */ } +} + +// an API endpoint can trigger the same work onto the same pool +$this->mail->drain(); // returns immediately, queued on mailPool +``` + +Both paths now funnel through `mailPool`: at most 5 concurrent runs across the +scheduler and the web workers **of one process**. Two things to keep in mind: + +- **Register it as a `singleton`.** The cap lives in the pool instance's state, so + every `#[Async('mailPool')]` call must resolve the *same* instance. A `bind()` / + transient registration would hand out a fresh pool per call and the cap would + never hold. +- **The pool is per-process.** The scheduler process and each web worker hold + their own `mailPool` instance, so the cap is 5 *per process*, not 5 globally. + A single shared cap across processes/hosts needs an external broker, not this + in-memory pool. +- **With `#[Async]`, the scheduler's own no-overlap guard steps aside** (the async + hand-off returns immediately), so overlap is governed by the pool. That is what + the pool is for — bound it (`Executors::newFixedExecutor(5)`), and a slow run + simply queues instead of piling up unbounded coroutines. + +The pool enforces its cap only under Swoole (coroutines). Without coroutines +(FPM, plain CLI) there is no parallelism to bound, so tasks run sequentially and +the size is a no-op. For cost control set a bounded queue and a reject policy: + +```php +use Flytachi\Winter\K2\Concurrent\RejectPolicy; + +$c->singleton('mailPool', fn() => Executors::newFixedExecutor( + concurrency: 5, queue: 50, onReject: RejectPolicy::DISCARD, +)); +``` + ## Fork-safety under the non-Swoole runtime Under Swoole — the intended runtime — a scheduled task runs in a coroutine and diff --git a/phpunit.xml b/phpunit.xml index d5bdc65..04e9d15 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -34,6 +34,9 @@ tests/Schedule tests/Schedule/Fixtures + + tests/Concurrent + diff --git a/src/Concurrent/BoundedExecutorService.php b/src/Concurrent/BoundedExecutorService.php new file mode 100644 index 0000000..6bcc213 --- /dev/null +++ b/src/Concurrent/BoundedExecutorService.php @@ -0,0 +1,31 @@ + 0`) is full, {@see RejectPolicy} decides the + * outcome. An unbounded pool (`queue = 0`, the default) never rejects. + * + * Obtain one through {@see \Flytachi\Winter\K2\Concurrent\Executors::newFixedExecutor()}; + * register it in the container to give an `#[Async('id')]` method a dedicated pool. + */ +final class FixedExecutorService implements BoundedExecutorService +{ + private readonly CoroutineExecutorService $coroutine; + private readonly DeferredExecutorService $deferred; + /** Semaphore of N tokens; lazily created on first use inside a coroutine. */ + private ?\Swoole\Coroutine\Channel $slots = null; + private int $active = 0; + private int $queued = 0; + private bool $shutdown = false; + + /** + * @param int $concurrency Maximum tasks running at once (>= 1). + * @param int $queue Waiting slots before the reject policy applies; 0 = unbounded. + * @param RejectPolicy $onReject What to do with a task when the queue is full. + */ + public function __construct( + private readonly int $concurrency, + private readonly int $queue = 0, + private readonly RejectPolicy $onReject = RejectPolicy::ABORT, + ) { + if ($concurrency < 1) { + throw new \InvalidArgumentException('Fixed executor concurrency must be >= 1.'); + } + if ($queue < 0) { + throw new \InvalidArgumentException('Fixed executor queue capacity must be >= 0.'); + } + $this->coroutine = new CoroutineExecutorService(); + $this->deferred = new DeferredExecutorService(); + } + + public function submit(callable $task, mixed ...$args): Future + { + $this->ensureAccepting(); + + if (!Runtime::isSwooleCoroutine()) { + return $this->deferred->submit($task, ...$args); + } + if ($this->isSaturated()) { + return $this->reject($task, $args); + } + $this->queued++; + + return $this->coroutine->submit($this->gate($task, $args)); + } + + public function execute(callable $task, mixed ...$args): void + { + $this->ensureAccepting(); + + if (!Runtime::isSwooleCoroutine()) { + $this->deferred->execute($task, ...$args); + + return; + } + if ($this->isSaturated()) { + $this->reject($task, $args); + + return; + } + $this->queued++; + $this->coroutine->execute($this->gate($task, $args)); + } + + public function invokeAll(iterable $tasks, ?float $timeout = null): array + { + $futures = []; + foreach ($tasks as $task) { + $futures[] = $this->submit($task); + } + + $deadline = $timeout === null ? null : microtime(true) + $timeout; + foreach ($futures as $future) { + try { + $future->get($deadline === null ? null : max(0.0, $deadline - microtime(true))); + } catch (\Throwable) { + // The outcome stays on the future; the caller inspects it there. + } + } + + return $futures; + } + + public function shutdown(): void + { + $this->shutdown = true; + $this->coroutine->shutdown(); + $this->deferred->shutdown(); + } + + public function isShutdown(): bool + { + return $this->shutdown; + } + + public function awaitTermination(?float $timeout = null): bool + { + $coroutine = $this->coroutine->awaitTermination($timeout); + $deferred = $this->deferred->awaitTermination($timeout); + + return $coroutine && $deferred; + } + + // ------------------------------------------------------------------------- + // Introspection + // ------------------------------------------------------------------------- + + public function concurrency(): int + { + return $this->concurrency; + } + + public function activeCount(): int + { + return $this->active; + } + + public function queuedCount(): int + { + return $this->queued; + } + + public function remainingCapacity(): int + { + if ($this->queue === 0) { + return PHP_INT_MAX; // unbounded — never rejects + } + + return max(0, $this->concurrency + $this->queue - ($this->active + $this->queued)); + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** + * Whether the pool is at its hard limit (only a bounded queue can saturate). + */ + private function isSaturated(): bool + { + return $this->queue > 0 && ($this->active + $this->queued) >= $this->concurrency + $this->queue; + } + + /** + * Wraps a task so it acquires a slot before running and releases it after — + * the coroutine parks on the semaphore while the pool is full, then advances + * the active/queued gauges around the body. + * + * @param list $args + */ + private function gate(callable $task, array $args): \Closure + { + return function () use ($task, $args): mixed { + $this->slots()->pop(); + $this->queued--; + $this->active++; + try { + return $task(...$args); + } finally { + $this->active--; + $this->slots()->push(true); + } + }; + } + + /** + * Applies the reject policy to a task the full queue cannot accept. + * + * @param list $args + */ + private function reject(callable $task, array $args): Future + { + return match ($this->onReject) { + RejectPolicy::ABORT => throw new RejectedExecutionException( + "Fixed executor saturated (concurrency={$this->concurrency}, queue={$this->queue})." + ), + RejectPolicy::CALLER_RUNS => $this->runInline($task, $args), + RejectPolicy::DISCARD => $this->discarded(), + }; + } + + /** + * Runs the task right here (back-pressure), reporting the outcome as a future. + * + * @param list $args + */ + private function runInline(callable $task, array $args): Future + { + try { + return CompletableFuture::completedFuture($task(...$args)); + } catch (\Throwable $throwable) { + return CompletableFuture::failedFuture($throwable); + } + } + + /** + * A future for a dropped task: cancelled, so a caller can tell it never ran. + */ + private function discarded(): Future + { + $future = new CompletableFuture(); + $future->cancel(); + + return $future; + } + + /** + * The semaphore, filled with N tokens on first use (inside a coroutine). + */ + private function slots(): \Swoole\Coroutine\Channel + { + if ($this->slots === null) { + $this->slots = new \Swoole\Coroutine\Channel($this->concurrency); + for ($i = 0; $i < $this->concurrency; $i++) { + $this->slots->push(true); + } + } + + return $this->slots; + } + + /** + * @throws RejectedExecutionException If the executor has been shut down. + */ + private function ensureAccepting(): void + { + if ($this->shutdown) { + throw new RejectedExecutionException('Executor has been shut down'); + } + } +} diff --git a/src/Concurrent/Executors.php b/src/Concurrent/Executors.php index 09da12f..0fe457e 100644 --- a/src/Concurrent/Executors.php +++ b/src/Concurrent/Executors.php @@ -7,6 +7,7 @@ use Flytachi\Winter\Base\Runtime; use Flytachi\Winter\K2\Concurrent\Executor\CoroutineExecutorService; use Flytachi\Winter\K2\Concurrent\Executor\DeferredExecutorService; +use Flytachi\Winter\K2\Concurrent\Executor\FixedExecutorService; /** * Factory for {@see ExecutorService} instances. @@ -75,6 +76,31 @@ public static function newDeferredExecutor(): ExecutorService return new DeferredExecutorService(); } + /** + * Returns a fresh fixed-size pool: at most $concurrency tasks run at once. + * + * Mirrors `Executors.newFixedThreadPool(n)`. The bound is enforced under Swoole + * (coroutine semaphore); without coroutines the pool runs tasks sequentially + * (deferred), where the bound is a no-op. Register the result in the container + * to back an `#[Async('id')]` method with a dedicated, capped pool. + * + * ``` + * $c->singleton('mailPool', fn() => Executors::newFixedExecutor(5)); + * // then: #[Async('mailPool')] public function send(): void { ... } + * ``` + * + * @param int $concurrency Maximum simultaneous tasks (>= 1). + * @param int $queue Waiting slots before the reject policy applies; 0 = unbounded (never rejects). + * @param RejectPolicy $onReject What to do with a task when a bounded queue is full. + */ + public static function newFixedExecutor( + int $concurrency, + int $queue = 0, + RejectPolicy $onReject = RejectPolicy::ABORT, + ): BoundedExecutorService { + return new FixedExecutorService($concurrency, $queue, $onReject); + } + /** * Drains the shared executors and stops them accepting new tasks. * diff --git a/src/Concurrent/RejectPolicy.php b/src/Concurrent/RejectPolicy.php new file mode 100644 index 0000000..e6c2c03 --- /dev/null +++ b/src/Concurrent/RejectPolicy.php @@ -0,0 +1,24 @@ + 0`) is actually full — an unbounded + * pool never rejects. + */ +enum RejectPolicy +{ + /** Throw {@see RejectedExecutionException} — the caller learns the pool is saturated. */ + case ABORT; + /** Run the task synchronously in the calling context — natural back-pressure. */ + case CALLER_RUNS; + /** Silently drop the task; a submitted future comes back cancelled. */ + case DISCARD; +} diff --git a/src/Schedule/Scheduler.php b/src/Schedule/Scheduler.php index dd8d9b4..e6224af 100644 --- a/src/Schedule/Scheduler.php +++ b/src/Schedule/Scheduler.php @@ -88,9 +88,11 @@ protected function onReload(): void /** * Dispatches one task: mark it in flight and spawn the run — resolve its class * from the container and invoke the method, logging any failure so it is never - * fatal. The run's completion is picked up later by {@see reap()}; the spawned - * closure deliberately does not touch task state, because under the fork - * runtime it executes in a separate process. + * fatal. Bean resolution and the method call are reported separately, so a + * class that cannot be autowired is distinguished from a method that threw. The + * run's completion is picked up later by {@see reap()}; the spawned closure + * deliberately does not touch task state, because under the fork runtime it + * executes in a separate process. */ private function fire(int $index, ScheduledTask $task): void { @@ -100,9 +102,19 @@ private function fire(int $index, ScheduledTask $task): void $this->running[$index] = $this->spawn(function () use ($task): void { try { $bean = Container::getInstance()->make($task->className); + } catch (\Throwable $e) { + $this->logger->error( + 'Scheduled ' . $task->id() . ': cannot resolve ' . $task->className + . ' from the container — check its constructor and #[Autowired] dependencies (' + . $e->getMessage() . ').' + ); + return; + } + + try { $bean->{$task->methodName}(); } catch (\Throwable $e) { - $this->logger->error('Scheduled ' . $task->id() . ' failed: ' . $e->getMessage()); + $this->logger->error('Scheduled ' . $task->id() . ' threw: ' . $e->getMessage()); } }); } diff --git a/tests/Concurrent/Executor/FixedExecutorConcurrencyTest.php b/tests/Concurrent/Executor/FixedExecutorConcurrencyTest.php new file mode 100644 index 0000000..5835157 --- /dev/null +++ b/tests/Concurrent/Executor/FixedExecutorConcurrencyTest.php @@ -0,0 +1,113 @@ +submit(function () use ($ex, &$peak): bool { + $peak = max($peak, $ex->activeCount()); + \Swoole\Coroutine::sleep(0.02); + return true; + }); + } + foreach ($futures as $future) { + $future->get(); + } + }); + + self::assertGreaterThan(0, $peak); + self::assertLessThanOrEqual(2, $peak); + } + + public function test_abort_policy_throws_when_saturated(): void + { + $thrown = false; + \Swoole\Coroutine\run(function () use (&$thrown): void { + // N=1, queue=1 → capacity for 2 in flight; the 3rd is rejected. + $ex = new FixedExecutorService(1, 1, RejectPolicy::ABORT); + $gate = new \Swoole\Coroutine\Channel(1); + $block = static function () use ($gate): void { + $gate->pop(); // hold the slot until released + }; + $ex->submit($block); // occupies the single slot + $ex->submit($block); // occupies the single queue slot + try { + $ex->submit($block); // saturated + } catch (RejectedExecutionException) { + $thrown = true; + } + $gate->push(true); + $gate->push(true); + }); + + self::assertTrue($thrown, 'a saturated ABORT pool must reject'); + } + + public function test_discard_policy_returns_a_cancelled_future(): void + { + $cancelled = false; + \Swoole\Coroutine\run(function () use (&$cancelled): void { + $ex = new FixedExecutorService(1, 1, RejectPolicy::DISCARD); + $gate = new \Swoole\Coroutine\Channel(1); + $block = static function () use ($gate): void { + $gate->pop(); + }; + $ex->submit($block); + $ex->submit($block); + $dropped = $ex->submit($block); // saturated → discarded + $cancelled = $dropped->isCancelled(); + $gate->push(true); + $gate->push(true); + }); + + self::assertTrue($cancelled, 'a discarded task must come back cancelled'); + } + + public function test_caller_runs_policy_executes_inline(): void + { + $ran = false; + \Swoole\Coroutine\run(function () use (&$ran): void { + $ex = new FixedExecutorService(1, 1, RejectPolicy::CALLER_RUNS); + $gate = new \Swoole\Coroutine\Channel(1); + $block = static function () use ($gate): void { + $gate->pop(); + }; + $ex->submit($block); + $ex->submit($block); + $ex->submit(static function () use (&$ran): void { + $ran = true; // runs inline, right here + }); + $gate->push(true); + $gate->push(true); + }); + + self::assertTrue($ran, 'CALLER_RUNS must execute the task inline'); + } +} diff --git a/tests/Concurrent/Executor/FixedExecutorServiceTest.php b/tests/Concurrent/Executor/FixedExecutorServiceTest.php new file mode 100644 index 0000000..5c1e711 --- /dev/null +++ b/tests/Concurrent/Executor/FixedExecutorServiceTest.php @@ -0,0 +1,104 @@ +concurrency()); + } + + public function test_constructor_validates_arguments(): void + { + $this->expectException(InvalidArgumentException::class); + new FixedExecutorService(0); + } + + public function test_negative_queue_is_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + new FixedExecutorService(1, -1); + } + + public function test_remaining_capacity_is_unbounded_by_default(): void + { + self::assertSame(PHP_INT_MAX, new FixedExecutorService(3)->remainingCapacity()); + } + + public function test_remaining_capacity_reflects_a_bounded_queue(): void + { + // N + Q available when idle. + self::assertSame(3 + 5, new FixedExecutorService(3, 5)->remainingCapacity()); + } + + public function test_idle_gauges_are_zero(): void + { + $ex = new FixedExecutorService(4); + self::assertSame(0, $ex->activeCount()); + self::assertSame(0, $ex->queuedCount()); + } + + public function test_submit_runs_the_task_and_returns_its_value(): void + { + $ex = new FixedExecutorService(2); + self::assertSame(42, $ex->submit(static fn(): int => 42)->get()); + } + + public function test_submit_forwards_arguments(): void + { + $ex = new FixedExecutorService(2); + self::assertSame(42, $ex->submit(static fn(int $x): int => $x + 1, 41)->get()); + } + + public function test_execute_runs_on_drain(): void + { + $ex = new FixedExecutorService(2); + $box = new ArrayObject(['n' => 0]); + + $ex->execute(static function () use ($box): void { + $box['n']++; + }); + $ex->awaitTermination(); + + self::assertSame(1, $box['n']); + } + + public function test_shutdown_refuses_new_tasks(): void + { + $ex = new FixedExecutorService(2); + $ex->shutdown(); + + self::assertTrue($ex->isShutdown()); + $this->expectException(RejectedExecutionException::class); + $ex->submit(static fn(): int => 1); + } + + public function test_reject_policy_does_not_reject_when_queue_is_unbounded(): void + { + // Sanity: with the default unbounded queue, nothing is ever rejected even + // under the sequential backend — the value comes straight back. + $ex = new FixedExecutorService(1, 0, RejectPolicy::ABORT); + self::assertSame(7, $ex->submit(static fn(): int => 7)->get()); + } +} diff --git a/tests/Concurrent/RejectPolicyTest.php b/tests/Concurrent/RejectPolicyTest.php new file mode 100644 index 0000000..9c0d07b --- /dev/null +++ b/tests/Concurrent/RejectPolicyTest.php @@ -0,0 +1,20 @@ + $p->name, RejectPolicy::cases()), + ); + } +} From 48fa942ede1571119ac2246248a2083304c93319 Mon Sep 17 00:00:00 2001 From: Jason Khan Date: Sun, 26 Jul 2026 20:22:58 +0500 Subject: [PATCH 17/71] Schedule beta test --- docs/starter/00-quickstart.md | 415 +++++++++++++++++++++++++ src/App/ApplicationConfigException.php | 14 + src/App/Component.php | 93 ++++++ src/App/ComponentKind.php | 18 ++ src/Application.php | 203 ++++++++++++ src/BaseBoot.php | 7 +- 6 files changed, 749 insertions(+), 1 deletion(-) create mode 100644 docs/starter/00-quickstart.md create mode 100644 src/App/ApplicationConfigException.php create mode 100644 src/App/Component.php create mode 100644 src/App/ComponentKind.php create mode 100644 src/Application.php diff --git a/docs/starter/00-quickstart.md b/docs/starter/00-quickstart.md new file mode 100644 index 0000000..77a1f3f --- /dev/null +++ b/docs/starter/00-quickstart.md @@ -0,0 +1,415 @@ +# Winter — Quickstart (from zero to running) + +This is the shortest honest path from an empty folder to a running Winter +application. You just did: + +```bash +mkdir my-app && cd my-app +composer require flytachi/winter-kernel +``` + +Now you have `vendor/` and nothing else. This page adds the handful of files a +project needs, explains the **one entry point** (`App::run()`), and shows every +way to start the app. + +If you would rather not type these files by hand, skip to +[Using the starter template](#using-the-starter-template) — `composer +create-project flytachi/winter` writes all of them for you. + +--- + +## The mental model in one picture + +You write **one** application class that declares **what your app contains** — +its *components*. A component is any long-lived thing: the web server, a +WebSocket endpoint, a background `Process`, a supervised `Daemon`, the +`Scheduler`. One entry point (`App::run($argv)`) turns that declaration into a +running program. + +``` + App::run($argv) ← the one entry (your java main()) + / \ + argv = "start" argv = anything else + │ │ + server mode console mode + (all components in ONE (make / run / daemon / + Swoole process) schedule / your command) +``` + +- **Swoole** hosts *everything in one process* — HTTP + Process + Daemon + + Scheduler together, like a JVM. This is `php call start`. +- **FPM** hosts *only the web tier*, one request at a time — because php-fpm is + not your process. Anything long-lived runs as its own `call` process next to + it. (See [Deployment shapes](#deployment-shapes).) + +You do not choose a "runtime" per app. You list components; the substrate decides +how many share a process. + +--- + +## Step 1 — `composer.json` autoload + +`composer require` created a `composer.json`. Add one PSR-4 root so the kernel's +scanner and router can discover your classes: + +```json +{ + "type": "project", + "autoload": { + "psr-4": { "Main\\": "main/" } + }, + "require": { + "php": ">=8.3", + "flytachi/winter-kernel": "^3.0" + } +} +``` + +Then: + +```bash +composer dump-autoload +``` + +Every class under `main/` is now namespace `Main\`. Add more PSR-4 roots as the +project grows — the scanner follows all of them. + +--- + +## Step 2 — `bootstrap.php` (your application class) + +This is the heart of the project — the equivalent of a Spring +`@SpringBootApplication`. It configures the kernel **and declares your +components**. + +```php += 80300) { + chdir(__DIR__); + require './bootstrap.php'; + App::run($argv); // ← the single entry point +} else { + echo "Please use PHP 8.3 or higher.\n"; +} +``` + +Make it executable: + +```bash +chmod +x call +``` + +### `public/index.php` — the FPM web adapter + +FPM is not a persistent process, so it cannot go through `App::run()`. It gets +its own two-line front controller that runs the web tier per request: + +```php +` | Console mode — `make`, `mapping`, `di`, your commands | +| nginx → `public/index.php` | Web only, under PHP-FPM | + +With only `Component::http()` declared, `php call start` is a web server. Open +`http://0.0.0.0:8000` → `Hello from Winter`. On the console you will see: + +``` +Application up: http://0.0.0.0:8000 +``` + +> `call start` needs ext-swoole (`pecl install swoole`). Without it, use +> `call run dev` for local web, and run background components individually +> (below). + +--- + +## Step 7 — add a background component + +Say you want a worker that runs forever alongside the web server. Write it as a +`Process`: + +```php +isRunning()) { + $this->sleep(3); + // ... periodic work ... + } + } +} +``` + +Add one line to `components()`: + +```php +protected static function components(): array +{ + return [ + Component::http(port: 8000), + Component::process(\Main\KernelSys::class), // ← new + ]; +} +``` + +Now `php call start` runs **both** in one Swoole process: + +``` +Application up: http://0.0.0.0:8000 + [KernelSys] +``` + +`Ctrl-C` (SIGTERM) stops the server *and* `KernelSys` together — the Swoole +master supervises the companion and terminates it with the server. + +The same works for a `Daemon` (`Component::daemon(...)`) and the scheduler +(`Component::scheduler()`). Each companion behaves exactly as if you had launched +it standalone — see below. + +--- + +## Deployment shapes + +The same code runs two ways; only the process layout differs. + +### Swoole — all in one (the JVM shape) + +``` +php call start ── ONE process + ├─ HTTP :8000 + ├─ KernelSys (addProcess, supervised) + ├─ Emails daemon (addProcess, supervised) + └─ Scheduler (addProcess, supervised) +``` + +One command, one process, co-terminating. This is the recommended shape when the +app has any long-lived component. + +### FPM — web on fpm, everything else standalone + +FPM only serves the web tier. Long-lived components run as their own processes +(systemd units, separate containers, `-d` detached): + +``` +nginx → php-fpm → public/index.php → App::web() # HTTP, per request ++ php call process main.KernelSys start -d # separate process ++ php call daemon main.Emails start -d # separate process ++ php call schedule start -d # separate process +``` + +`components()` does not change — under FPM the non-web entries are simply not +hosted by the request process; you start them yourself. One Docker image, and the +container's `command:` picks the role: + +```yaml +web: command: php call start # or php-fpm for the FPM shape +worker: command: php call daemon main.Emails start +scheduler: command: php call schedule start +``` + +> **Why FPM is the odd one out:** php-fpm master is not your process — it invokes +> your code per request and recycles the worker. There is no persistent loop for a +> daemon or scheduler to live in, so those always need their own process. If your +> app has WebSocket / daemon / scheduler, you already need a persistent process — +> at that point Swoole (`call start`) is usually the simpler choice. + +--- + +## Cheat sheet + +```bash +# scaffold checklist (fresh clone) +composer install +mkdir -p storage/{cache,logs} && chmod -R 777 storage +chmod +x call +php call cfg key -g + +# run +php call start # Swoole: all components in one process +php call run dev # local web, no swoole +php call run # Swoole web only +php call mapping show # list routes + +# run a single component standalone (split / FPM deploy) +php call process main.KernelSys start [-d] +php call daemon main.Emails start [-d] +php call schedule start [-d] + +# production caches +php call mapping build +php call di build +``` + +--- + +## Using the starter template + +Everything above is generated for you by the starter repository: + +```bash +composer create-project flytachi/winter my-app +cd my-app +php call start +``` + +See [`../starter.md`](../starter.md) for the file-by-file breakdown of the +generated project. + +--- + +## See also + +- [`../configuration/01-kernel.md`](../configuration/01-kernel.md) — every `configure()` / hook option +- [`../process/00-overview.md`](../process/00-overview.md) — writing a `Process` +- [`../process/daemon/00-overview.md`](../process/daemon/00-overview.md) — writing a `Daemon` +- [`../schedule/00-overview.md`](../schedule/00-overview.md) — `#[Scheduled]` tasks +- [`../console/00-overview.md`](../console/00-overview.md) — the `call` CLI diff --git a/src/App/ApplicationConfigException.php b/src/App/ApplicationConfigException.php new file mode 100644 index 0000000..dd191c9 --- /dev/null +++ b/src/App/ApplicationConfigException.php @@ -0,0 +1,14 @@ + $class + */ + public static function process(string $class): self + { + return new self(ComponentKind::Process, class: $class); + } + + /** + * A supervised {@see \Flytachi\Winter\K2\Process\Daemon\Daemon} fleet. + * + * @param class-string<\Flytachi\Winter\K2\Process\Daemon\Daemon> $class + */ + public static function daemon(string $class): self + { + return new self(ComponentKind::Daemon, class: $class); + } + + /** + * The scheduler that runs #[Scheduled] tasks. Defaults to the built-in + * {@see Scheduler}; pass a subclass to override discovery. + * + * @param class-string $class + */ + public static function scheduler(string $class = Scheduler::class): self + { + return new self(ComponentKind::Scheduler, class: $class); + } +} diff --git a/src/App/ComponentKind.php b/src/App/ComponentKind.php new file mode 100644 index 0000000..df660c7 --- /dev/null +++ b/src/App/ComponentKind.php @@ -0,0 +1,18 @@ + + */ + protected static function components(): array + { + return []; + } + + /** + * The single entry point. Pass raw $argv; the first argument selects the mode: + * - `start` → {@see serve()} (all components in one Swoole process); + * - otherwise → {@see cli()} (console command, then exit). + * + * @param array $argv Raw $argv (script name in [0]). + */ + final public static function run(array $argv = []): never + { + if (($argv[1] ?? null) === 'start') { + static::serve(); + } + + static::cli($argv); + } + + /** + * Server mode: boot once, then host every declared component in a single + * Swoole process and block until shutdown. + * + * The one {@see Component::http()} entry becomes the HTTP server. Each + * Process / Daemon / Scheduler entry is attached with `addProcess`, so the + * Swoole master supervises it and terminates it together with the server. + * Every companion runs exactly as if launched by a standalone + * `call daemon|process|schedule`: runtime coroutine hooks are reset inside + * the child and {@see ForkReset} gives it fresh connections, so a Daemon's + * pcntl master and a Process's own `Coroutine\run` behave identically to solo. + */ + final public static function serve(): never + { + if (!extension_loaded('swoole')) { + fwrite( + STDERR, + "[winter] 'call start' needs ext-swoole. Install it, or launch components " + . "individually: `call run`, `call daemon`, `call schedule`.\n" + ); + exit(1); + } + + static::boot(); + LoggerFactory::setContextStorage(new CoroutineContext()); + LoggerFactory::setDefaultChannel('http'); + + /** @var ?Component $http */ + $http = null; + /** @var list $sockets */ + $sockets = []; + /** @var list $companions */ + $companions = []; + + foreach (static::components() as $component) { + if (!$component instanceof Component) { + throw new ApplicationConfigException( + 'components() must return ' . Component::class + . ' instances; got ' . get_debug_type($component) . '.' + ); + } + match ($component->kind) { + ComponentKind::Http => $http = $component, + ComponentKind::WebSocket => $sockets[] = $component, + default => $companions[] = $component, + }; + } + + if ($sockets !== []) { + throw new ApplicationConfigException( + 'WebSocket components are not hosted by the bundled runtime yet — ' + . 'the port from the legacy engine is pending.' + ); + } + + if ($http === null) { + throw new ApplicationConfigException( + 'serve() needs one Component::http() to host the bundle. To run a single ' + . 'background component, launch it directly: `call daemon|process|schedule ... start`.' + ); + } + + $logger = LoggerFactory::getLogger(static::class); + + $router = Router::fromScan(Kernel::$pathRoot); + $router->static(Kernel::$pathPublic); + + \Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL ^ SWOOLE_HOOK_PROC); + Runtime::boot(RuntimeMode::Swoole); + + $server = new \Swoole\Http\Server($http->host, $http->port); + $server->set(static::swooleConfig()); + + // Companions are attached BEFORE start() — one supervised process each. + foreach ($companions as $companion) { + /** @var class-string<\Flytachi\Winter\K2\Process\Process> $class */ + $class = (string) $companion->class; + $server->addProcess(new \Swoole\Process( + static function () use ($class): void { + // The bundle process turned runtime hooks on for the HTTP + // reactor; reset them so this child is a clean plain process + // and each component boots its own runtime inside start(). + \Swoole\Runtime::enableCoroutine(false); + // The fork copies the parent's open fds; drop them so the + // component reconnects in place (see Process::afterFork()). + ForkReset::runAll(); + $class::start(); + } + )); + } + + $watcher = new MemoryWatcher(); + $watcher->attach($server); + $server->on('request', $watcher->wrap( + static function (\Swoole\Http\Request $req, \Swoole\Http\Response $res) use ($router): void { + $request = new SwooleRequest($req); + $isHead = strtoupper($request->getMethod()) === 'HEAD'; + $router->handle($request, new SwooleResponse($res, $isHead)); + } + )); + + $names = array_map( + static fn(Component $c): string => (new \ReflectionClass((string) $c->class))->getShortName(), + $companions + ); + $logger->info(sprintf( + 'Application up: http://%s:%d%s', + $http->host, + $http->port, + $names === [] ? '' : ' + [' . implode(', ', $names) . ']' + )); + + $server->start(); + exit(0); + } +} diff --git a/src/BaseBoot.php b/src/BaseBoot.php index 6c2d0d3..8039077 100644 --- a/src/BaseBoot.php +++ b/src/BaseBoot.php @@ -518,7 +518,12 @@ final public static function executor(array $argv = []): never // ── Internal ────────────────────────────────────────────────────────────── - private static function boot(): void + /** + * Runs the boot sequence once (kernel init, DI scan, providers, channels, + * plugins, CORS, health). Every entry point calls this first. Protected so a + * subclass entry point (e.g. {@see Application::serve()}) can reuse it. + */ + protected static function boot(): void { self::$bootClass = static::class; static::configure(); From 1a1720f5e14bea070db986e2683054f9539aaebe Mon Sep 17 00:00:00 2001 From: Jason Khan Date: Mon, 27 Jul 2026 00:13:34 +0500 Subject: [PATCH 18/71] Schedule beta test --- console/Command/Run.php | 221 +++++----------------------------- dev/bootstrap.php | 22 +++- dev/call | 2 +- docs/starter/00-quickstart.md | 102 ++++++++++------ src/Application.php | 209 ++++++++++++++++++++++---------- 5 files changed, 262 insertions(+), 294 deletions(-) diff --git a/console/Command/Run.php b/console/Command/Run.php index 537ba2a..bdb2b6e 100644 --- a/console/Command/Run.php +++ b/console/Command/Run.php @@ -4,226 +4,69 @@ namespace Flytachi\Winter\Console\Command; -use Flytachi\Winter\Base\Runtime; -use Flytachi\Winter\Base\RuntimeMode; use Flytachi\Winter\Console\Inc\Cmd; +use Flytachi\Winter\K2\Application; use Flytachi\Winter\K2\BaseBoot; -use Flytachi\Winter\K2\Http\Adapter\SwooleRequest; -use Flytachi\Winter\K2\Http\Adapter\SwooleResponse; -use Flytachi\Winter\K2\Route\MemoryWatcher; -use Flytachi\Winter\K2\Route\Router; -use Flytachi\Winter\K2\Kernel; -use Flytachi\Winter\Logger\Context\CoroutineContext; -use Flytachi\Winter\Logger\LoggerFactory; class Run extends Cmd { - public static string $title = "start HTTP server: Swoole (default) or PHP built-in (dev)"; - - public final const string HOST = '0.0.0.0'; - public final const int PORT = 8000; + public static string $title = "run the application: web + declared components (Swoole)"; public function handle(): void { self::printTitle("Run", 34); - $sub = $this->args['arguments'][1] ?? null; - match ($sub) { - 'dev' => $this->runDev(), - default => $this->runSwoole(), - }; - - self::printTitle("Run", 34); - } - - // ── Swoole server ───────────────────────────────────────────────────────── - - private function runSwoole(): void - { - if (!extension_loaded('swoole')) { - self::printWarning("Swoole extension is not loaded."); - self::printInfo("Install swoole: pecl install swoole"); - return; - } + $sub = $this->args['arguments'][1] ?? null; + $watch = $sub === 'dev' + || in_array('w', $this->args['flags'] ?? [], true) + || isset($this->args['options']['watcher']); - $host = $this->args['options']['host'] ?? self::HOST; - $port = (int) ($this->args['options']['port'] ?? self::PORT); - $workerNum = $this->args['options']['workers'] ?? null; - $taskWorkers = $this->args['options']['tasks'] ?? null; - $maxRequest = $this->args['options']['max_request'] ?? null; - $maxRequestGrace = $this->args['options']['max_request_grace'] ?? null; - $watcher = in_array('w', $this->args['flags'] ?? []) - || isset($this->args['options']['watcher']); - - if ($this->isPortInUse($host, $port)) { - self::printWarning("Address 'http://$host:$port' is already in use."); - return; - } - - self::printSuccess("Swoole server starting at http://$host:$port"); - self::printKeyValue('Root', Kernel::$pathRoot, 6, 34, 90); - self::printKeyValue('Workers', $workerNum ?? 'auto', 6, 34, 36); - self::printKeyValue('Task-workers', $taskWorkers ?? 'off', 6, 34, 36); - self::printKeyValue('Max-request', $maxRequest ?? 'off', 6, 34, 36); - self::printKeyValue('Max-request-grace', $maxRequestGrace ?? 'off', 6, 34, 36); - self::printKeyValue('Watcher', $watcher ? 'on' : 'off', 6, 34, 36); - - $router = Router::fromScan(Kernel::$pathRoot); - - \Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL ^ SWOOLE_HOOK_PROC); - Runtime::boot(RuntimeMode::Swoole); - - // Log — activate Swoole context and HTTP channel - // ------------------------------------------------ - // Kernel::init() registers channels (sys / http / cli) with ProcessContext - // and sets 'sys' as default. Here, before the server starts: - // 1. CoroutineContext — replaces ProcessContext so each coroutine (request) - // gets its own isolated storage. All log fields (request_id, user_id …) - // set via contextStorage()->set() are scoped to the current coroutine - // and never leak across concurrent requests. - // 2. setDefaultChannel('http') — switches the active channel so every - // LoggerFactory::getLogger() and Log::* call writes to 'http'. - // Both calls happen once before server start; worker processes inherit - // the state via fork and do not need to call them again. - LoggerFactory::setContextStorage(new CoroutineContext()); - LoggerFactory::setDefaultChannel('http'); - $router->static(Kernel::$pathPublic); - - $server = new \Swoole\Http\Server($host, $port); - - // Base config from Boot::swooleConfig(), CLI args override $bootClass = BaseBoot::getBootClass(); - $config = $bootClass !== '' ? $bootClass::swooleConfig() : []; - if ($workerNum !== null) { - $config['worker_num'] = (int) $workerNum; - } - if ($taskWorkers !== null) { - $config['task_worker_num'] = (int) $taskWorkers; - } - if ($maxRequest !== null) { - $config['max_request'] = (int) $maxRequest; - } - if ($maxRequestGrace !== null) { - $config['max_request_grace'] = (int) $maxRequestGrace; - } - if (!empty($config)) { - $server->set($config); - } - - cli_set_process_title( - "Winter swoole -> server" - . ($watcher ? '@watch' : '') - . " [W=" . ($workerNum ?: 'auto') . "]" - . " [MX_R=" . ($maxRequest ?: 'off') . "]" - . " [MX_RG=" . ($maxRequestGrace ?: 'off') . "]" - ); - - $workerHandler = static function (\Swoole\Http\Server $server, int $workerId): void { - cli_set_process_title("Winter swoole -> worker@$workerId"); - }; - $requestHandler = static function (\Swoole\Http\Request $req, \Swoole\Http\Response $res) use ($router): void { - $router->handle(new SwooleRequest($req), new SwooleResponse($res)); - }; - - if ($watcher) { - $memWatcher = new MemoryWatcher(); - $memWatcher->attach($server, $workerHandler); - $server->on('request', $memWatcher->wrap($requestHandler)); - } else { - $server->on('workerStart', $workerHandler); - $server->on('request', $requestHandler); + if ($bootClass === '' || !is_subclass_of($bootClass, Application::class)) { + self::printWarning("`call run` requires your Boot class to extend Application."); + self::printInfo("Change `extends BaseBoot` to `extends Application` and declare components()."); + self::printInfo("Docs: docs/starter/00-quickstart.md"); + return; } - $server->start(); - } + self::printSuccess($watch ? "Starting application (dev / watch)" : "Starting application"); - private function isPortInUse(string $host, int $port): bool - { - $sock = @socket_create(AF_INET, SOCK_STREAM, SOL_TCP); - if ($sock === false) { - return false; - } - $inUse = @socket_connect($sock, $host, $port); - socket_close($sock); - return $inUse; + // serve() blocks until shutdown and exits the process itself. + $bootClass::serve($watch); } - // ── PHP built-in dev server ─────────────────────────────────────────────── - - private function runDev(): void - { - $isManual = isset($this->args['options']['host']) || isset($this->args['options']['port']); - $host = $this->args['options']['host'] ?? self::HOST; - $port = isset($this->args['options']['port']) - ? (int) $this->args['options']['port'] - : self::PORT; - - if ($isManual) { - $connection = @fsockopen($host, $port); - if (is_resource($connection)) { - fclose($connection); - self::printWarning("Address 'http://$host:$port' is already in use."); - return; - } - } else { - $basePort = $port; - for ($i = 0; $i < 10; $i++) { - $port = $basePort + $i; - $connection = @fsockopen($host, $port); - if (is_resource($connection)) { - fclose($connection); - self::printWarning("Port $port is busy, trying next..."); - if ($i === 9) { - self::printWarning("No free port found in range {$basePort}–" . ($basePort + 9) . "."); - return; - } - } else { - break; - } - } - } - - self::printSuccess("Dev server started at http://$host:$port"); - self::printKeyValue('Root', Kernel::$pathPublic, 6, 34, 90); - passthru('php -S ' . escapeshellarg("$host:$port") - . ' -t ' . escapeshellarg(Kernel::$pathPublic)); - } - - // ── Help ────────────────────────────────────────────────────────────────── - public static function help(): void { $cl = 34; self::printTitle("Run Help", $cl); self::printLabel("Usage", $cl); - self::print("call run - start Swoole HTTP server", $cl); - self::print("call run dev - start PHP built-in dev server", $cl); + self::print("call run - run the application (production; MemoryWatcher off)", $cl); + self::print("call run dev - run the application (development; MemoryWatcher on)", $cl); self::printLabel("Usage", $cl); - self::printLabel("Swoole options", $cl); - self::print("--host= bind host (default: " . self::HOST . ")", $cl); - self::print("--port= bind port (default: " . self::PORT . ")", $cl); - self::print("--workers= number of workers (default: auto)", $cl); - self::print("--tasks= number of task workers (default: off)", $cl); - self::print("--max_request= max requests/worker (default: off)", $cl); - self::print("--max_request_grace= graceful drain count (default: off)", $cl); - self::print("-w / --watcher enable MemoryWatcher (default: off)", $cl); - self::printLabel("Swoole options", $cl); - - self::printLabel("Dev options", $cl); - self::print("--host= bind host (default: " . self::HOST . ")", $cl); - self::print("--port= bind port (default: " . self::PORT . ", auto-scan if omitted)", $cl); - self::printLabel("Dev options", $cl); + self::printDivider($cl); + + self::printLabel("What runs", $cl); + self::print("Everything declared in your App::components():", $cl); + self::print(" Component::http() -> the Swoole HTTP server (main)", $cl); + self::print(" Component::process() -> a managed Process, attached via addProcess", $cl); + self::print(" Component::daemon() -> a supervised Daemon fleet", $cl); + self::print(" Component::scheduler() -> the #[Scheduled] scheduler", $cl); + self::print("With no Component::http() the app runs headless (background only).", $cl); + self::printLabel("What runs", $cl); + + self::printDivider($cl); + + self::printLabel("Options", $cl); + self::print("-w / --watcher force the MemoryWatcher on (same as `run dev`)", $cl); + self::printLabel("Options", $cl); self::printDivider($cl); self::printLabel("Examples", $cl); self::printInfo("call run"); - self::printInfo("call run --port=8000 --workers=4 -w"); - self::printInfo("call run --max_request=5000 --max_request_grace=500"); self::printInfo("call run dev"); - self::printInfo("call run dev --port=9000"); self::printLabel("Examples", $cl); self::printDivider($cl); diff --git a/dev/bootstrap.php b/dev/bootstrap.php index da0acfe..e36cee0 100644 --- a/dev/bootstrap.php +++ b/dev/bootstrap.php @@ -3,7 +3,8 @@ declare(strict_types=1); use Flytachi\Winter\DI\Container; -use Flytachi\Winter\K2\BaseBoot; +use Flytachi\Winter\K2\App\Component; +use Flytachi\Winter\K2\Application; use Flytachi\Winter\K2\Http\Cors; use Flytachi\Winter\K2\Http\Health\Health; use Flytachi\Winter\K2\Kernel; @@ -32,8 +33,25 @@ * Boot::cli($argv) call — CLI console * Boot::executor($argv) wKernelExecutor — thread / job runner */ -class Boot extends BaseBoot +class Boot extends Application { + /** + * Components — what this application is made of. + * + * `call run` / `call run dev` bring these up: the Http one becomes the Swoole + * server, the rest run beside it (addProcess). Remove Http to run headless. + * + * @return list + */ + protected static function components(): array + { + return [ + Component::http(port: 8000), + // Component::process(\Main\KernelSys::class), + // Component::scheduler(), + ]; + } + /** * Kernel — paths, .env, logging, timezone. * diff --git a/dev/call b/dev/call index 1b87abd..0a067b5 100755 --- a/dev/call +++ b/dev/call @@ -27,7 +27,7 @@ if (PHP_VERSION_ID >= 80300) { */ require './bootstrap.php'; - Boot::cli($argv); + Boot::run($argv); } else { echo "\033[33m"." Please use PHP version 8.3 or higher.\n"; diff --git a/docs/starter/00-quickstart.md b/docs/starter/00-quickstart.md index 77a1f3f..3aa7f25 100644 --- a/docs/starter/00-quickstart.md +++ b/docs/starter/00-quickstart.md @@ -9,8 +9,8 @@ composer require flytachi/winter-kernel ``` Now you have `vendor/` and nothing else. This page adds the handful of files a -project needs, explains the **one entry point** (`App::run()`), and shows every -way to start the app. +project needs, explains the **one entry point** (`App::run()`), and shows how to +run the app. If you would rather not type these files by hand, skip to [Using the starter template](#using-the-starter-template) — `composer @@ -22,22 +22,25 @@ create-project flytachi/winter` writes all of them for you. You write **one** application class that declares **what your app contains** — its *components*. A component is any long-lived thing: the web server, a -WebSocket endpoint, a background `Process`, a supervised `Daemon`, the -`Scheduler`. One entry point (`App::run($argv)`) turns that declaration into a -running program. +background `Process`, a supervised `Daemon`, the `Scheduler`. One command brings +them all up in a single process. ``` - App::run($argv) ← the one entry (your java main()) - / \ - argv = "start" argv = anything else - │ │ - server mode console mode - (all components in ONE (make / run / daemon / - Swoole process) schedule / your command) + App::components() = [ http, process, daemon, scheduler ] + │ + php call run ← run the whole app (prod) + php call run dev ← same + MemoryWatcher (dev) + │ + ONE Swoole process + ┌─────────────┬──────────┬───────────┐ + HTTP :8000 Process Daemon Scheduler + (addProcess, supervised, co-terminating) ``` -- **Swoole** hosts *everything in one process* — HTTP + Process + Daemon + - Scheduler together, like a JVM. This is `php call start`. +- The web tier is **just a component** (`Component::http()`), not a hard + requirement. Declare it and `call run` serves HTTP; omit it and the app runs + **headless** (background components only). +- **Swoole** hosts everything in one process, like a JVM. - **FPM** hosts *only the web tier*, one request at a time — because php-fpm is not your process. Anything long-lived runs as its own `call` process next to it. (See [Deployment shapes](#deployment-shapes).) @@ -107,7 +110,7 @@ final class App extends Application protected static function components(): array { return [ - Component::http(port: 8000), // the web server (main) + Component::http(port: 8000), // web server (optional) // Component::process(\Main\KernelSys::class), // Component::daemon(\Main\Emails::class), // Component::scheduler(), @@ -136,13 +139,14 @@ This is your `java -jar`. Every runtime flows through it. if (PHP_VERSION_ID >= 80300) { chdir(__DIR__); require './bootstrap.php'; - App::run($argv); // ← the single entry point + App::run($argv); // ← the single entry point (the app's main()) } else { echo "Please use PHP 8.3 or higher.\n"; } ``` -Make it executable: +`App::run($argv)` boots once and dispatches the console command — `run`, +`run dev`, `make`, `daemon`, `schedule`, or your own. Make it executable: ```bash chmod +x call @@ -150,8 +154,8 @@ chmod +x call ### `public/index.php` — the FPM web adapter -FPM is not a persistent process, so it cannot go through `App::run()`. It gets -its own two-line front controller that runs the web tier per request: +FPM is not a persistent process, so it cannot go through `call run`. It gets its +own two-line front controller that runs the web tier per request: ```php ` | Console mode — `make`, `mapping`, `di`, your commands | +| `php call run` | **Production** — every component in one Swoole process, MemoryWatcher off | +| `php call run dev` | **Development** — same, MemoryWatcher on | +| `php call ` | Console — `make`, `mapping`, `di`, your commands | | nginx → `public/index.php` | Web only, under PHP-FPM | -With only `Component::http()` declared, `php call start` is a web server. Open +With only `Component::http()` declared, `php call run` is a web server. Open `http://0.0.0.0:8000` → `Hello from Winter`. On the console you will see: ``` Application up: http://0.0.0.0:8000 ``` -> `call start` needs ext-swoole (`pecl install swoole`). Without it, use -> `call run dev` for local web, and run background components individually -> (below). +> `call run` with a web tier needs ext-swoole (`pecl install swoole`). Without a +> web tier the app runs headless and works without swoole (each component picks +> its own engine). --- @@ -302,7 +305,7 @@ protected static function components(): array } ``` -Now `php call start` runs **both** in one Swoole process: +Now `php call run` runs **both** in one Swoole process: ``` Application up: http://0.0.0.0:8000 + [KernelSys] @@ -313,7 +316,27 @@ master supervises the companion and terminates it with the server. The same works for a `Daemon` (`Component::daemon(...)`) and the scheduler (`Component::scheduler()`). Each companion behaves exactly as if you had launched -it standalone — see below. +it standalone (`call daemon|process|schedule`). + +### Headless (no web) + +Drop `Component::http()` and `call run` runs only the background components — one +in the foreground, several under a small supervisor. Useful for a worker-only or +scheduler-only deployment: + +```php +protected static function components(): array +{ + return [ + Component::daemon(\Main\Emails::class), + Component::scheduler(), + ]; +} +``` + +``` +Application up (headless): [Emails, Scheduler] +``` --- @@ -324,7 +347,7 @@ The same code runs two ways; only the process layout differs. ### Swoole — all in one (the JVM shape) ``` -php call start ── ONE process +php call run ── ONE process ├─ HTTP :8000 ├─ KernelSys (addProcess, supervised) ├─ Emails daemon (addProcess, supervised) @@ -351,7 +374,7 @@ hosted by the request process; you start them yourself. One Docker image, and th container's `command:` picks the role: ```yaml -web: command: php call start # or php-fpm for the FPM shape +web: command: php call run # or php-fpm for the FPM shape worker: command: php call daemon main.Emails start scheduler: command: php call schedule start ``` @@ -359,8 +382,8 @@ scheduler: command: php call schedule start > **Why FPM is the odd one out:** php-fpm master is not your process — it invokes > your code per request and recycles the worker. There is no persistent loop for a > daemon or scheduler to live in, so those always need their own process. If your -> app has WebSocket / daemon / scheduler, you already need a persistent process — -> at that point Swoole (`call start`) is usually the simpler choice. +> app has a daemon or scheduler, you already need a persistent process — at that +> point Swoole (`call run`) is usually the simpler choice. --- @@ -374,9 +397,8 @@ chmod +x call php call cfg key -g # run -php call start # Swoole: all components in one process -php call run dev # local web, no swoole -php call run # Swoole web only +php call run # production: all components, one process +php call run dev # development: + MemoryWatcher php call mapping show # list routes # run a single component standalone (split / FPM deploy) @@ -398,7 +420,7 @@ Everything above is generated for you by the starter repository: ```bash composer create-project flytachi/winter my-app cd my-app -php call start +php call run ``` See [`../starter.md`](../starter.md) for the file-by-file breakdown of the diff --git a/src/Application.php b/src/Application.php index e7882bb..0a0d7e7 100644 --- a/src/Application.php +++ b/src/Application.php @@ -16,12 +16,13 @@ use Flytachi\Winter\K2\Route\Router; use Flytachi\Winter\Logger\Context\CoroutineContext; use Flytachi\Winter\Logger\LoggerFactory; +use Psr\Log\LoggerInterface; /** * Single application entry point — the framework's answer to a Java `main()`. * * Extend it once, declare what the application contains via {@see components()}, - * then route every runtime through {@see run()} from a single file: + * then route the CLI through {@see run()} from a single file: * ``` * final class App extends Application * { @@ -33,7 +34,7 @@ * protected static function components(): array * { * return [ - * Component::http(port: 8000), // web server (main) + * Component::http(port: 8000), // web server (optional) * Component::process(KernelSys::class), * Component::daemon(Emails::class), * Component::scheduler(), @@ -45,14 +46,18 @@ * App::run($argv); * ``` * - * {@see run()} dispatches on the first argument: - * - `start` → server mode: every component in ONE Swoole process (see {@see serve()}); - * - anything else → console mode: the CLI (make / run / daemon / schedule / …). + * {@see run()} is the CLI front door: it just dispatches the console (`run`, + * `run dev`, `make`, `daemon`, `schedule`, …). The application itself is brought + * up by `call run` / `call run dev`, which call {@see serve()}: + * - `call run` → production: every component, MemoryWatcher OFF; + * - `call run dev` → development: every component, MemoryWatcher ON. * - * Server mode is a Swoole-only concept — one process, many concerns, like a JVM. - * FPM cannot host it (it is not a persistent process): under FPM the web tier is - * still served by {@see web()} per request, and the other components run as - * standalone `call daemon|process|schedule` processes. + * Server mode is Swoole's strength — one process, many concerns, like a JVM. The + * one {@see Component::http()} (if declared) becomes the HTTP server; the rest run + * beside it. With no Http component the app runs headless (background components + * only). FPM cannot host this bundle: under FPM the web tier is served by + * {@see web()} per request and the other components run as standalone + * `call daemon|process|schedule` processes. */ abstract class Application extends BaseBoot { @@ -70,48 +75,32 @@ protected static function components(): array } /** - * The single entry point. Pass raw $argv; the first argument selects the mode: - * - `start` → {@see serve()} (all components in one Swoole process); - * - otherwise → {@see cli()} (console command, then exit). + * The CLI front door (the app's `main()`). Boots once and dispatches the + * console command in $argv, then exits. `call run` / `call run dev` reach + * {@see serve()} from here. * * @param array $argv Raw $argv (script name in [0]). */ final public static function run(array $argv = []): never { - if (($argv[1] ?? null) === 'start') { - static::serve(); - } - static::cli($argv); } /** - * Server mode: boot once, then host every declared component in a single - * Swoole process and block until shutdown. + * Brings the application up and blocks until shutdown. Called by the `run` + * console command (so the boot sequence has already run — this does not + * re-boot). + * + * With a {@see Component::http()} declared: builds one Swoole HTTP server and + * attaches every other component as a supervised `addProcess`, co-terminating + * with the server. MemoryWatcher is attached only when $watch is true + * (`call run dev`). With no Http component: runs headless — a single component + * in the foreground, or several under a small pcntl supervisor. * - * The one {@see Component::http()} entry becomes the HTTP server. Each - * Process / Daemon / Scheduler entry is attached with `addProcess`, so the - * Swoole master supervises it and terminates it together with the server. - * Every companion runs exactly as if launched by a standalone - * `call daemon|process|schedule`: runtime coroutine hooks are reset inside - * the child and {@see ForkReset} gives it fresh connections, so a Daemon's - * pcntl master and a Process's own `Coroutine\run` behave identically to solo. + * @param bool $watch Attach the MemoryWatcher (development). */ - final public static function serve(): never + final public static function serve(bool $watch = false): never { - if (!extension_loaded('swoole')) { - fwrite( - STDERR, - "[winter] 'call start' needs ext-swoole. Install it, or launch components " - . "individually: `call run`, `call daemon`, `call schedule`.\n" - ); - exit(1); - } - - static::boot(); - LoggerFactory::setContextStorage(new CoroutineContext()); - LoggerFactory::setDefaultChannel('http'); - /** @var ?Component $http */ $http = null; /** @var list $sockets */ @@ -140,14 +129,33 @@ final public static function serve(): never ); } - if ($http === null) { - throw new ApplicationConfigException( - 'serve() needs one Component::http() to host the bundle. To run a single ' - . 'background component, launch it directly: `call daemon|process|schedule ... start`.' - ); + $logger = LoggerFactory::getLogger(static::class); + + if ($http !== null) { + static::serveHttp($http, $companions, $watch, $logger); } - $logger = LoggerFactory::getLogger(static::class); + static::serveHeadless($companions, $logger); + } + + // ── Internal ────────────────────────────────────────────────────────────── + + /** + * Web bundle: the Http component becomes the Swoole server; every other + * component is attached with addProcess so the master supervises it and + * stops it together with the server. + * + * @param list $companions + */ + private static function serveHttp(Component $http, array $companions, bool $watch, LoggerInterface $logger): never + { + if (!extension_loaded('swoole')) { + fwrite(STDERR, "[winter] `call run` with a web tier needs ext-swoole (pecl install swoole).\n"); + exit(1); + } + + LoggerFactory::setContextStorage(new CoroutineContext()); + LoggerFactory::setDefaultChannel('http'); $router = Router::fromScan(Kernel::$pathRoot); $router->static(Kernel::$pathPublic); @@ -158,10 +166,10 @@ final public static function serve(): never $server = new \Swoole\Http\Server($http->host, $http->port); $server->set(static::swooleConfig()); - // Companions are attached BEFORE start() — one supervised process each. + $names = []; foreach ($companions as $companion) { - /** @var class-string<\Flytachi\Winter\K2\Process\Process> $class */ $class = (string) $companion->class; + $names[] = self::shortName($class); $server->addProcess(new \Swoole\Process( static function () use ($class): void { // The bundle process turned runtime hooks on for the HTTP @@ -176,28 +184,105 @@ static function () use ($class): void { )); } - $watcher = new MemoryWatcher(); - $watcher->attach($server); - $server->on('request', $watcher->wrap( - static function (\Swoole\Http\Request $req, \Swoole\Http\Response $res) use ($router): void { - $request = new SwooleRequest($req); - $isHead = strtoupper($request->getMethod()) === 'HEAD'; - $router->handle($request, new SwooleResponse($res, $isHead)); - } - )); + $handler = static function (\Swoole\Http\Request $req, \Swoole\Http\Response $res) use ($router): void { + $request = new SwooleRequest($req); + $isHead = strtoupper($request->getMethod()) === 'HEAD'; + $router->handle($request, new SwooleResponse($res, $isHead)); + }; + + if ($watch) { + $memory = new MemoryWatcher(); + $memory->attach($server); + $server->on('request', $memory->wrap($handler)); + } else { + $server->on('request', $handler); + } - $names = array_map( - static fn(Component $c): string => (new \ReflectionClass((string) $c->class))->getShortName(), - $companions - ); $logger->info(sprintf( - 'Application up: http://%s:%d%s', + 'Application up: http://%s:%d%s%s', $http->host, $http->port, - $names === [] ? '' : ' + [' . implode(', ', $names) . ']' + $names === [] ? '' : ' + [' . implode(', ', $names) . ']', + $watch ? ' (dev/watch)' : '' )); $server->start(); exit(0); } + + /** + * No web tier: run the background components directly. One component runs in + * the foreground (fully managed on its own); several are forked and reaped by + * a small supervisor that forwards a stop signal to the whole group. Works + * with or without ext-swoole (each component picks its own engine). + * + * @param list $companions + */ + private static function serveHeadless(array $companions, LoggerInterface $logger): never + { + if ($companions === []) { + fwrite( + STDERR, + "[winter] Nothing to run: components() is empty. Declare at least one " + . "Component::http()/process()/daemon()/scheduler().\n" + ); + exit(1); + } + + if (count($companions) === 1) { + $class = (string) $companions[0]->class; + $logger->info('Application up (headless): ' . self::shortName($class)); + $class::start(); + exit(0); + } + + if (!function_exists('pcntl_fork')) { + fwrite(STDERR, "[winter] Running several headless components needs ext-pcntl.\n"); + exit(1); + } + + $children = []; + foreach ($companions as $companion) { + $class = (string) $companion->class; + $pid = pcntl_fork(); + if ($pid === -1) { + fwrite(STDERR, "[winter] fork failed for {$class}.\n"); + exit(1); + } + if ($pid === 0) { + if (extension_loaded('swoole')) { + \Swoole\Runtime::enableCoroutine(false); + } + ForkReset::runAll(); + $class::start(); + exit(0); + } + $children[$pid] = self::shortName($class); + } + + $forward = static function (int $signo) use (&$children): void { + foreach (array_keys($children) as $pid) { + @posix_kill($pid, $signo); + } + }; + pcntl_async_signals(true); + pcntl_signal(SIGTERM, $forward); + pcntl_signal(SIGINT, $forward); + + $logger->info('Application up (headless): [' . implode(', ', $children) . ']'); + + while ($children !== []) { + $pid = pcntl_waitpid(-1, $status); + if ($pid > 0) { + unset($children[$pid]); + } + } + + exit(0); + } + + private static function shortName(string $class): string + { + return new \ReflectionClass($class)->getShortName(); + } } From 1df66e1575763ad728d7ea31f24b95c1dee5bdef Mon Sep 17 00:00:00 2001 From: Jason Khan Date: Mon, 27 Jul 2026 00:27:39 +0500 Subject: [PATCH 19/71] Schedule beta test --- src/Application.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Application.php b/src/Application.php index 0a0d7e7..156c2ff 100644 --- a/src/Application.php +++ b/src/Application.php @@ -160,7 +160,7 @@ private static function serveHttp(Component $http, array $companions, bool $watc $router = Router::fromScan(Kernel::$pathRoot); $router->static(Kernel::$pathPublic); - \Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL ^ SWOOLE_HOOK_PROC); + \Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL); Runtime::boot(RuntimeMode::Swoole); $server = new \Swoole\Http\Server($http->host, $http->port); @@ -173,9 +173,10 @@ private static function serveHttp(Component $http, array $companions, bool $watc $server->addProcess(new \Swoole\Process( static function () use ($class): void { // The bundle process turned runtime hooks on for the HTTP - // reactor; reset them so this child is a clean plain process - // and each component boots its own runtime inside start(). - \Swoole\Runtime::enableCoroutine(false); + // reactor; clear them (flags = 0) so this child is a clean + // plain process and each component boots its own runtime + // inside start(), exactly as a standalone launch would. + \Swoole\Runtime::enableCoroutine(0); // The fork copies the parent's open fds; drop them so the // component reconnects in place (see Process::afterFork()). ForkReset::runAll(); @@ -251,7 +252,7 @@ private static function serveHeadless(array $companions, LoggerInterface $logger } if ($pid === 0) { if (extension_loaded('swoole')) { - \Swoole\Runtime::enableCoroutine(false); + \Swoole\Runtime::enableCoroutine(0); } ForkReset::runAll(); $class::start(); From 0a8204c0617eeabfd6b0fe9d228ab493df75bc5f Mon Sep 17 00:00:00 2001 From: Jason Khan Date: Mon, 27 Jul 2026 01:45:03 +0500 Subject: [PATCH 20/71] rebuild beta test --- dev/bootstrap.php | 7 ++- docs/configuration/02-logging.md | 33 +++++------- docs/configuration/04-health.md | 3 +- docs/starter.md | 6 +-- src/Application.php | 45 ++++++++++------ src/BaseBoot.php | 84 ++++------------------------- src/Http/Health/HealthIndicator.php | 2 +- src/Kernel.php | 4 +- tests/bootstrap.php | 6 ++- 9 files changed, 68 insertions(+), 122 deletions(-) diff --git a/dev/bootstrap.php b/dev/bootstrap.php index e36cee0..e7f1e67 100644 --- a/dev/bootstrap.php +++ b/dev/bootstrap.php @@ -27,10 +27,9 @@ * 6. httpCors() — global CORS policy * 7. health() — /actuator endpoints * - * Entry points (call one from each runtime file): - * Boot::web() public/index.php — FPM - * Boot::swoole() server.php — Swoole HTTP server - * Boot::cli($argv) call — CLI console + * Entry points: + * Boot::run($argv) call — CLI + `call run` (Swoole, all components) + * Boot::web() public/index.php — FPM (web tier, per request) * Boot::executor($argv) wKernelExecutor — thread / job runner */ class Boot extends Application diff --git a/docs/configuration/02-logging.md b/docs/configuration/02-logging.md index c145816..e8502d2 100644 --- a/docs/configuration/02-logging.md +++ b/docs/configuration/02-logging.md @@ -11,27 +11,27 @@ over [Monolog](https://github.com/Seldaek/monolog) designed for multi-runtime PH ``` Kernel::init() └── LoggerManager ← built once, holds all channel configs - ├── channel 'sys' ← system / kernel-level events - ├── channel 'http' ← HTTP request lifecycle - └── channel 'cli' ← console commands and jobs + ├── channel 'http' ← HTTP request lifecycle (coroutine-isolated) + └── channel 'sys' ← everything else: kernel, CLI, background components Entry point (index.php / call / run) - └── LoggerFactory::setDefaultChannel('http' | 'cli') + └── LoggerFactory::setDefaultChannel('http' | 'sys') Application code └── LoggerFactory::getLogger(MyClass::class) └── Logger (wraps Monolog, merges class FQCN into every record) ``` -**Key principle — entry-point driven.** The kernel registers all channels and sets `sys` as -the default. Entry points switch to the channel that matches the runtime: +**Key principle — entry-point driven.** The kernel registers both channels and sets `sys` as +the default. Only the HTTP request path switches to `http`; everything else stays on `sys`: | Entry point | Channel | Context storage | |-------------|---------|-----------------| -| `public/index.php` | `http` | `ProcessContext` (per FPM worker) | -| `call run` (Swoole) | `http` | `CoroutineContext` (per coroutine) | -| `call` (CLI) | `cli` | `ProcessContext` (per process) | -| `wKernelExecutor` (threads/jobs) | `cli` | `ProcessContext` | +| `public/index.php` (FPM) | `http` | `ProcessContext` (per FPM worker) | +| `call run` — request workers | `http` | `CoroutineContext` (per coroutine) | +| `call run` — master + components (Process/Daemon/Scheduler) | `sys` | `ProcessContext` (per process) | +| `call` (CLI commands) | `sys` | `ProcessContext` (per process) | +| `wKernelExecutor` (threads/jobs) | `sys` | `ProcessContext` | --- @@ -127,10 +127,6 @@ LOG_HTTP_FILE_MAX=14 LOG_SYS_OUTPUT=syslog LOG_SYS_SYSLOG_IDENT=myapp-sys -# cli channel — debug level, stderr -LOG_CLI_LEVEL=debug -LOG_CLI_OUTPUT=stderr - # custom 'job' channel (registered via Kernel::channel('job')) LOG_JOB_LEVEL=debug LOG_JOB_OUTPUT=file @@ -142,7 +138,7 @@ LOG_JOB_FILE_MAX=7 ## Custom channels -Built-in channels (`sys`, `http`, `cli`) are registered automatically by `Kernel::init()`. +Built-in channels (`http`, `sys`) are registered automatically by `Kernel::init()`. Add extra channels in `bootstrap.php` using `Kernel::channel()`: ```php @@ -191,7 +187,7 @@ Raw `channel()` calls omit it. [2024-01-01 12:00:00] [DEBUG] -http- [4821] (UserService): db query {"class":"App\\Service\\UserService","request_id":"abc-123"} [2024-01-01 12:00:00] [WARN ] -http- [4821] (PaymentService): retry {"class":"App\\PaymentService","attempt":3} [2024-01-01 12:00:00] [ERROR] -http- [4821] (OrderController): checkout failed {"class":"App\\OrderController","order_id":99} -[2024-01-01 12:00:00] [DEBUG] -cli- [5012] (MyJob): step done {"class":"App\\Job\\MyJob","job_id":"xyz"} +[2024-01-01 12:00:00] [DEBUG] -sys- [5012] (MyJob): step done {"class":"App\\Job\\MyJob","job_id":"xyz"} [2024-01-01 12:00:00] [INFO ] -sys- [4821]: kernel booted ``` @@ -295,7 +291,7 @@ Equivalent to `LoggerFactory::logger()->{level}(...)` — always writes to the c ```php LoggerFactory::channel('http')->warning('rate limit hit'); -LoggerFactory::channel('cli')->debug('job started'); +LoggerFactory::channel('sys')->debug('job started'); ``` No `(ClassName)` in output. Throws `InvalidArgumentException` if the channel is not registered. @@ -421,9 +417,6 @@ LOG_SYSLOG_IDENT=winter # syslog program tag # LOG_SYS_OUTPUT=syslog # LOG_SYS_SYSLOG_IDENT=myapp -# LOG_CLI_OUTPUT=stderr -# LOG_CLI_LEVEL=debug - # Custom channels (registered via Kernel::channel('job') in bootstrap.php) # LOG_JOB_LEVEL=debug # LOG_JOB_OUTPUT=file diff --git a/docs/configuration/04-health.md b/docs/configuration/04-health.md index 6713d1b..e33ded5 100644 --- a/docs/configuration/04-health.md +++ b/docs/configuration/04-health.md @@ -245,9 +245,8 @@ See [`../architecture/02-middleware.md`](../architecture/02-middleware.md) for m ```json { - "sys": {"level": "INFO", "output": "syslog", "format": "line"}, "http": {"level": "WARN", "output": "file", "format": "json", "file": {"path": "storage/logs/http.log", "max_files": 14}}, - "cli": {"level": "DEBUG", "output": "stderr", "format": "line"} + "sys": {"level": "INFO", "output": "syslog", "format": "line"} } ``` diff --git a/docs/starter.md b/docs/starter.md index b8b100f..dd0eeb0 100644 --- a/docs/starter.md +++ b/docs/starter.md @@ -110,7 +110,7 @@ called in a fixed order from every entry point. 1. configure() ← Kernel::init() — paths, .env, logging, timezone 2. DI scan ← auto-discovers #[Singleton] / #[Request] / #[Transient] 3. providers($c) ← manual bindings, factories, scalar values -4. channels() ← extra log channels beyond sys / http / cli +4. channels() ← extra log channels beyond http / sys 5. plugins() ← route-prefixed sub-applications 6. httpCors() ← global CORS policy 7. health() ← /actuator endpoints @@ -180,7 +180,7 @@ class Boot extends BaseBoot } /** - * Logging — extra channels beyond sys / http / cli. + * Logging — extra channels beyond http / sys. * Each channel reads LOG_{NAME}_* env with the same fallback chain. */ protected static function channels(): void @@ -323,7 +323,7 @@ Variables: | `LOG_FILE_MAX` | Number of daily-rotating files to keep | | `LOG_SYSLOG_IDENT` | Program identity tag in syslog (`journalctl -t winter`) | -For per-channel overrides (`LOG_HTTP_*`, `LOG_CLI_*`, custom channels) +For per-channel overrides (`LOG_HTTP_*`, `LOG_SYS_*`, custom channels) see [`configuration/02-logging.md`](configuration/02-logging.md). **Regenerate the key** any time: diff --git a/src/Application.php b/src/Application.php index 156c2ff..ccd73ce 100644 --- a/src/Application.php +++ b/src/Application.php @@ -15,6 +15,7 @@ use Flytachi\Winter\K2\Route\MemoryWatcher; use Flytachi\Winter\K2\Route\Router; use Flytachi\Winter\Logger\Context\CoroutineContext; +use Flytachi\Winter\Logger\Context\ProcessContext; use Flytachi\Winter\Logger\LoggerFactory; use Psr\Log\LoggerInterface; @@ -150,13 +151,15 @@ final public static function serve(bool $watch = false): never private static function serveHttp(Component $http, array $companions, bool $watch, LoggerInterface $logger): never { if (!extension_loaded('swoole')) { - fwrite(STDERR, "[winter] `call run` with a web tier needs ext-swoole (pecl install swoole).\n"); - exit(1); + throw new ApplicationConfigException( + '`call run` with a web tier needs ext-swoole (pecl install swoole).' + ); } - LoggerFactory::setContextStorage(new CoroutineContext()); - LoggerFactory::setDefaultChannel('http'); - + // The 'http' channel + coroutine context belong to request workers only + // (set in workerStart below). The master and the addProcess companions + // stay on 'sys' with process context — a background component must not + // log as if it were an HTTP request. $router = Router::fromScan(Kernel::$pathRoot); $router->static(Kernel::$pathPublic); @@ -177,6 +180,10 @@ static function () use ($class): void { // plain process and each component boots its own runtime // inside start(), exactly as a standalone launch would. \Swoole\Runtime::enableCoroutine(0); + // Background component: log on the system channel with process + // context, exactly like a standalone launch. + LoggerFactory::setContextStorage(new ProcessContext()); + LoggerFactory::setDefaultChannel('sys'); // The fork copies the parent's open fds; drop them so the // component reconnects in place (see Process::afterFork()). ForkReset::runAll(); @@ -191,11 +198,19 @@ static function () use ($class): void { $router->handle($request, new SwooleResponse($res, $isHead)); }; + // Request workers log on 'http' with per-request coroutine isolation. + // addProcess companions never receive workerStart, so they keep 'sys'. + $workerStart = static function (\Swoole\Http\Server $server, int $workerId): void { + LoggerFactory::setContextStorage(new CoroutineContext()); + LoggerFactory::setDefaultChannel('http'); + }; + if ($watch) { $memory = new MemoryWatcher(); - $memory->attach($server); + $memory->attach($server, $workerStart); $server->on('request', $memory->wrap($handler)); } else { + $server->on('workerStart', $workerStart); $server->on('request', $handler); } @@ -222,12 +237,10 @@ static function () use ($class): void { private static function serveHeadless(array $companions, LoggerInterface $logger): never { if ($companions === []) { - fwrite( - STDERR, - "[winter] Nothing to run: components() is empty. Declare at least one " - . "Component::http()/process()/daemon()/scheduler().\n" + throw new ApplicationConfigException( + 'Nothing to run: components() is empty. Declare at least one ' + . 'Component::http()/process()/daemon()/scheduler().' ); - exit(1); } if (count($companions) === 1) { @@ -238,8 +251,9 @@ private static function serveHeadless(array $companions, LoggerInterface $logger } if (!function_exists('pcntl_fork')) { - fwrite(STDERR, "[winter] Running several headless components needs ext-pcntl.\n"); - exit(1); + throw new ApplicationConfigException( + 'Running several headless components needs ext-pcntl.' + ); } $children = []; @@ -247,13 +261,14 @@ private static function serveHeadless(array $companions, LoggerInterface $logger $class = (string) $companion->class; $pid = pcntl_fork(); if ($pid === -1) { - fwrite(STDERR, "[winter] fork failed for {$class}.\n"); - exit(1); + throw new \RuntimeException("Application: fork failed for {$class}."); } if ($pid === 0) { if (extension_loaded('swoole')) { \Swoole\Runtime::enableCoroutine(0); } + LoggerFactory::setContextStorage(new ProcessContext()); + LoggerFactory::setDefaultChannel('sys'); ForkReset::runAll(); $class::start(); exit(0); diff --git a/src/BaseBoot.php b/src/BaseBoot.php index 8039077..2bdd1c2 100644 --- a/src/BaseBoot.php +++ b/src/BaseBoot.php @@ -4,8 +4,6 @@ namespace Flytachi\Winter\K2; -use Flytachi\Winter\Base\Runtime; -use Flytachi\Winter\Base\RuntimeMode; use Flytachi\Winter\Console\Core; use Flytachi\Winter\DI\Collector\DICollector; use Flytachi\Winter\DI\Container; @@ -14,12 +12,9 @@ use Flytachi\Winter\K2\Concurrent\Async\Proxy\ProxyFactory; use Flytachi\Winter\K2\Http\Adapter\FpmRequest; use Flytachi\Winter\K2\Http\Adapter\FpmResponse; -use Flytachi\Winter\K2\Http\Adapter\SwooleRequest; -use Flytachi\Winter\K2\Http\Adapter\SwooleResponse; use Flytachi\Winter\K2\Http\Contracts\HttpResponse; use Flytachi\Winter\K2\Http\Response\ExceptionWrapper; use Flytachi\Winter\K2\Old\Process\Core\WinterRunner; -use Flytachi\Winter\K2\Route\MemoryWatcher; use Flytachi\Winter\K2\Route\Router; use Flytachi\Winter\Logger\LoggerFactory; use Psr\Log\LoggerInterface; @@ -44,11 +39,13 @@ * Entry points: * ``` * Boot::web(); // public/index.php — FPM - * Boot::swoole(); // server.php — Swoole HTTP server * Boot::cli($argv); // call — CLI console * Boot::executor($argv); // wKernelExecutor — thread / job runner * ``` * + * The Swoole HTTP server is no longer a BaseBoot entry point — it is built by + * {@see Application::serve()} from the declared components (`call run`). + * * Boot-time error handler: * handleBootError($e, $response) is invoked from web() if anything throws * above Router::handle() — DI scan failure, ambiguous route mapping, .env @@ -87,10 +84,10 @@ public static function getBootClass(): string * ``` * * Logging is driven entirely by .env — no code changes needed for basic setup. - * Three built-in channels are always registered: sys, http, cli. + * Two built-in channels are always registered: http, sys. * Entry points switch the active channel automatically: - * - web() / swoole() → 'http' - * - cli() / executor() → 'cli' + * - web() / swoole() → 'http' (per-request, coroutine context) + * - cli() / executor() → 'sys' (everything else) * * Global .env variables: * LOG_LEVEL=info Minimum severity (DEBUG|INFO|NOTICE|WARNING|ERROR|...) @@ -139,7 +136,7 @@ protected static function providers(Container $c): void /** * Register additional log channels via Kernel::channel(). * - * Built-in channels (sys, http, cli) are registered automatically by Kernel::init(). + * Built-in channels (http, sys) are registered automatically by Kernel::init(). * Call this hook to add custom channels. Each channel reads LOG_{NAME}_* env vars * with the same fallback chain as the built-in channels. * @@ -388,67 +385,6 @@ final public static function web(): never exit(0); } - /** - * Swoole coroutine HTTP server entry point. - * - * Performs a single filesystem scan on startup — routes stay in memory for - * the entire server lifetime. All requests share the same Router instance - * via the on('request') callback; coroutine isolation keeps per-request - * state separate. - * - * Requires ext-swoole. SWOOLE_HOOK_ALL is enabled automatically so that - * all blocking I/O (PDO, cURL, file, sleep, …) is coroutine-friendly. - * - * Server configuration is supplied via swooleConfig() — override it in Boot: - * ``` - * protected static function swooleConfig(): array - * { - * return ['worker_num' => swoole_cpu_num() * 2, 'max_request' => 5000]; - * } - * ``` - * - * Request pipeline (per coroutine): - * 1. Header::init() — snapshot request headers into coroutine ctx - * 2. Locale::initFromRequest() — detect Accept-Language / locale cookie - * 3. Swoole ctx stamp — record start time, method, URI - * 4. Static file check — serve files from Kernel::$pathPublic - * 5. Global CORS headers — applied before dispatch - * 6. OPTIONS preflight — returns 204 before handler invocation - * 7. Route dispatch — same pipeline as web() - * 8–12. identical to web() - * - * @param string $host Listen address (default: 0.0.0.0) - * @param int $port Listen port (default: 9501) - */ - final public static function swoole(string $host = '0.0.0.0', int $port = 9501): never - { - self::boot(); - LoggerFactory::setDefaultChannel('http'); - - $router = Router::fromScan(Kernel::$pathRoot); - $router->static(Kernel::$pathPublic); - - \Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL); - Runtime::boot(RuntimeMode::Swoole); - - $server = new \Swoole\Http\Server($host, $port); - $server->set(static::swooleConfig()); - - $watcher = new MemoryWatcher(); - $watcher->attach($server); - - $server->on('request', $watcher->wrap( - static function (\Swoole\Http\Request $req, \Swoole\Http\Response $res) use ($router): void { - $request = new SwooleRequest($req); - $isHead = strtoupper($request->getMethod()) === 'HEAD'; - $router->handle($request, new SwooleResponse($res, $isHead)); - } - )); - - $server->start(); - exit(0); - } - /** * CLI console entry point. * @@ -466,7 +402,7 @@ static function (\Swoole\Http\Request $req, \Swoole\Http\Response $res) use ($ro * ./call help * ``` * - * The 'cli' log channel is activated so all log writes go to the CLI output. + * The 'sys' log channel is activated so all log writes go to the system output. * To inject per-session fields into every log line: * LoggerFactory::contextStorage()->set('job', 'import'); * @@ -475,7 +411,7 @@ static function (\Swoole\Http\Request $req, \Swoole\Http\Response $res) use ($ro final public static function cli(array $argv = []): never { self::boot(); - LoggerFactory::setDefaultChannel('cli'); + LoggerFactory::setDefaultChannel('sys'); new Core($argv)->run(); @@ -511,7 +447,7 @@ final public static function cli(array $argv = []): never final public static function executor(array $argv = []): never { self::boot(); - LoggerFactory::setDefaultChannel('cli'); + LoggerFactory::setDefaultChannel('sys'); $options = getopt('', ['namespace::', 'name::', 'tag::', 'debug', 'detach', 'shmkey::']); exit(WinterRunner::adaptive()->execute($options)); } diff --git a/src/Http/Health/HealthIndicator.php b/src/Http/Health/HealthIndicator.php index 87a00e9..9e2b787 100644 --- a/src/Http/Health/HealthIndicator.php +++ b/src/Http/Health/HealthIndicator.php @@ -110,7 +110,7 @@ public function loggers(): array $globalFormat = env('LOG_FORMAT', 'line'); $channels = []; - foreach (['sys', 'http', 'cli'] as $name) { + foreach (['sys', 'http'] as $name) { $prefix = 'LOG_' . strtoupper($name) . '_'; $level = env($prefix . 'LEVEL') ?? $globalLevel; $output = env($prefix . 'OUTPUT') ?? $globalOutput; diff --git a/src/Kernel.php b/src/Kernel.php index 8e7fbd9..a80bb62 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -92,8 +92,9 @@ private static function bootLogger(): void if (empty($levelStr)) { LoggerFactory::setManager(new LoggerManager( contextStorage: new ProcessContext(), - channels: ['sys' => $null, 'http' => $null, 'cli' => $null], + channels: ['sys' => $null, 'http' => $null], )); + LoggerFactory::setDefaultChannel('sys'); return; } @@ -102,7 +103,6 @@ private static function bootLogger(): void channels: [ 'sys' => self::buildChannelConfig('sys'), 'http' => self::buildChannelConfig('http'), - 'cli' => self::buildChannelConfig('cli'), ], )); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e5a6f6e..f981f33 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -29,5 +29,9 @@ LoggerFactory::setManager(new LoggerManager( contextStorage: new ProcessContext(), - channels: ['sys' => $null, 'http' => $null, 'cli' => $null], + channels: ['sys' => $null, 'http' => $null], )); + +// LoggerFactory's built-in default channel is 'cli'; pin it to 'sys' to match +// Kernel::init() now that 'cli' is no longer a registered channel. +LoggerFactory::setDefaultChannel('sys'); From 000e8804b4d2af16f71c37d8ac239a5c926f7c32 Mon Sep 17 00:00:00 2001 From: Jason Khan Date: Mon, 27 Jul 2026 01:53:42 +0500 Subject: [PATCH 21/71] rebuild - hot relod --- console/Command/Complete.php | 2 +- console/Command/Run.php | 6 +- src/Application.php | 32 ++++-- src/Route/DevWatcher.php | 208 +++++++++++++++++++++++++++++++++++ src/Route/MemoryWatcher.php | 97 ---------------- 5 files changed, 233 insertions(+), 112 deletions(-) create mode 100644 src/Route/DevWatcher.php delete mode 100644 src/Route/MemoryWatcher.php diff --git a/console/Command/Complete.php b/console/Command/Complete.php index 564941b..5a2eeb2 100644 --- a/console/Command/Complete.php +++ b/console/Command/Complete.php @@ -66,7 +66,7 @@ class Complete extends Cmd '--tasks=:number of Swoole task workers', '--max_request=:max requests per worker', '--max_request_grace=:graceful drain count', - '-w:enable MemoryWatcher', + '-w:enable DevWatcher (memory + hot-reload)', ], 'run dev' => [ '--host=:bind host (default: 0.0.0.0)', diff --git a/console/Command/Run.php b/console/Command/Run.php index bdb2b6e..02f5d67 100644 --- a/console/Command/Run.php +++ b/console/Command/Run.php @@ -41,8 +41,8 @@ public static function help(): void self::printTitle("Run Help", $cl); self::printLabel("Usage", $cl); - self::print("call run - run the application (production; MemoryWatcher off)", $cl); - self::print("call run dev - run the application (development; MemoryWatcher on)", $cl); + self::print("call run - run the application (production; DevWatcher off)", $cl); + self::print("call run dev - run the application (development; DevWatcher: memory + hot-reload)", $cl); self::printLabel("Usage", $cl); self::printDivider($cl); @@ -59,7 +59,7 @@ public static function help(): void self::printDivider($cl); self::printLabel("Options", $cl); - self::print("-w / --watcher force the MemoryWatcher on (same as `run dev`)", $cl); + self::print("-w / --watcher force the DevWatcher on (same as `run dev`)", $cl); self::printLabel("Options", $cl); self::printDivider($cl); diff --git a/src/Application.php b/src/Application.php index ccd73ce..6fdad33 100644 --- a/src/Application.php +++ b/src/Application.php @@ -12,7 +12,7 @@ use Flytachi\Winter\K2\Http\Adapter\SwooleRequest; use Flytachi\Winter\K2\Http\Adapter\SwooleResponse; use Flytachi\Winter\K2\Process\ForkReset; -use Flytachi\Winter\K2\Route\MemoryWatcher; +use Flytachi\Winter\K2\Route\DevWatcher; use Flytachi\Winter\K2\Route\Router; use Flytachi\Winter\Logger\Context\CoroutineContext; use Flytachi\Winter\Logger\Context\ProcessContext; @@ -50,8 +50,8 @@ * {@see run()} is the CLI front door: it just dispatches the console (`run`, * `run dev`, `make`, `daemon`, `schedule`, …). The application itself is brought * up by `call run` / `call run dev`, which call {@see serve()}: - * - `call run` → production: every component, MemoryWatcher OFF; - * - `call run dev` → development: every component, MemoryWatcher ON. + * - `call run` → production: every component, DevWatcher OFF; + * - `call run dev` → development: every component, DevWatcher ON (memory + hot-reload). * * Server mode is Swoole's strength — one process, many concerns, like a JVM. The * one {@see Component::http()} (if declared) becomes the HTTP server; the rest run @@ -94,11 +94,12 @@ final public static function run(array $argv = []): never * * With a {@see Component::http()} declared: builds one Swoole HTTP server and * attaches every other component as a supervised `addProcess`, co-terminating - * with the server. MemoryWatcher is attached only when $watch is true - * (`call run dev`). With no Http component: runs headless — a single component - * in the foreground, or several under a small pcntl supervisor. + * with the server. The {@see DevWatcher} (memory reporting + code hot-reload) + * is attached only when $watch is true (`call run dev`). With no Http component: + * runs headless — a single component in the foreground, or several under a small + * pcntl supervisor. * - * @param bool $watch Attach the MemoryWatcher (development). + * @param bool $watch Attach the DevWatcher — memory + hot-reload (development). */ final public static function serve(bool $watch = false): never { @@ -205,10 +206,12 @@ static function () use ($class): void { LoggerFactory::setDefaultChannel('http'); }; - if ($watch) { - $memory = new MemoryWatcher(); - $memory->attach($server, $workerStart); - $server->on('request', $memory->wrap($handler)); + // Dev mode: the DevWatcher reports memory and hot-reloads on code changes + // by restarting the whole process (see reexec() after start()). + $dev = $watch ? new DevWatcher([Kernel::$pathRoot]) : null; + if ($dev !== null) { + $dev->attach($server, $workerStart); + $server->on('request', $dev->wrap($handler)); } else { $server->on('workerStart', $workerStart); $server->on('request', $handler); @@ -223,6 +226,13 @@ static function () use ($class): void { )); $server->start(); + + // start() returns when a dev code change stopped the server — re-exec into + // a fresh `call run dev` so the change is fully picked up. + if ($dev !== null && $dev->reloadRequested()) { + $dev->reexec(); + } + exit(0); } diff --git a/src/Route/DevWatcher.php b/src/Route/DevWatcher.php new file mode 100644 index 0000000..b1d73ec --- /dev/null +++ b/src/Route/DevWatcher.php @@ -0,0 +1,208 @@ +reload()`) is deliberate: `boot()` and the router + * scan run in the master before workers fork, so a plain worker reload would keep the + * old controller/service classes cached in the master. Re-exec'ing the process image + * is the only reliable way to reflect changes to master-loaded code. + * + * Usage (from Application::serveHttp, dev path): + * $dev = new DevWatcher([Kernel::$pathRoot]); + * $dev->attach($server, $onWorkerStart); + * $server->on('request', $dev->wrap($handler)); + * $server->start(); + * if ($dev->reloadRequested()) { $dev->reexec(); } + */ +final class DevWatcher +{ + private int $workerId = 0; + private int $baseline = 0; + + /** @var array Watched file path => last mtime. */ + private array $snapshot = []; + private bool $reloadRequested = false; + private ?int $timerId = null; + + /** + * @param list $watchPaths Directories scanned for `.php` changes. + * @param float $interval Poll interval in seconds. + * @param list $exclude Directory names skipped during the scan. + */ + public function __construct( + private readonly array $watchPaths, + private readonly float $interval = 1.0, + private readonly array $exclude = ['vendor', 'storage', '.git', 'node_modules'], + ) { + } + + /** + * Registers the workerStart memory baseline (composing an optional extra + * callback) and the master-side file-watch timer. + * + * @param callable|null $onWorkerStart function(Server $server, int $workerId): void + */ + public function attach(Server $server, ?callable $onWorkerStart = null): void + { + $server->on('workerStart', function (Server $server, int $workerId) use ($onWorkerStart): void { + $this->workerId = $workerId; + $this->baseline = memory_get_usage(false); + echo sprintf( + "[Worker %d] START | Baseline: %s\n", + $this->workerId, + $this->format($this->baseline) + ); + if ($onWorkerStart !== null) { + $onWorkerStart($server, $workerId); + } + }); + + // The file watcher lives in the master reactor. On a change it stops the + // server; serve() then re-exec's the process (see reexec()). + $server->on('start', function (Server $server): void { + $this->snapshot = $this->scan(); + $this->timerId = Timer::tick((int) ($this->interval * 1000), function () use ($server): void { + $current = $this->scan(); + if ($current === $this->snapshot) { + return; + } + $changed = $this->firstChange($this->snapshot, $current); + echo sprintf( + "\n[dev] change detected%s — restarting server...\n", + $changed !== null ? " ({$changed})" : '' + ); + $this->reloadRequested = true; + if ($this->timerId !== null) { + Timer::clear($this->timerId); + $this->timerId = null; + } + $server->shutdown(); + }); + }); + } + + public function wrap(callable $handler): callable + { + return function (Request $request, Response $response) use ($handler): void { + $before = memory_get_usage(false); + + $handler($request, $response); + + $after = memory_get_usage(false); + + echo sprintf( + "[Worker %d] REQUEST => (before: %s, after: %s, delta: %s, growth: %s, peak: %s)\n", + $this->workerId, + $this->format($before), + $this->format($after), + $this->formatDelta($after - $before), + $this->formatDelta($after - $this->baseline), + $this->format(memory_get_peak_usage(false)) + ); + }; + } + + /** True when a watched file changed and the server was stopped for a restart. */ + public function reloadRequested(): bool + { + return $this->reloadRequested; + } + + /** + * Replaces the current process image with a fresh `php run dev`. + * Call only after `$server->start()` has returned (workers already stopped). + */ + public function reexec(): never + { + $argv = $_SERVER['argv'] ?? []; + if (function_exists('pcntl_exec') && $argv !== []) { + pcntl_exec(PHP_BINARY, $argv); + } + // pcntl_exec only returns on failure (or is unavailable): fall back to a + // non-zero exit so an external supervisor can restart the process. + echo "[dev] cannot re-exec (ext-pcntl unavailable) — exiting for a supervisor restart.\n"; + exit(1); + } + + /** @return array path => mtime for every watched `.php` file. */ + private function scan(): array + { + $files = []; + foreach ($this->watchPaths as $path) { + if (!is_dir($path)) { + continue; + } + $iterator = new \RecursiveIteratorIterator( + new \RecursiveCallbackFilterIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + function (\SplFileInfo $file): bool { + if ($file->isDir()) { + return !in_array($file->getFilename(), $this->exclude, true); + } + return $file->getExtension() === 'php'; + } + ) + ); + foreach ($iterator as $file) { + /** @var \SplFileInfo $file */ + $files[$file->getPathname()] = (int) $file->getMTime(); + } + } + return $files; + } + + /** Name of the first added/changed/removed file, for the console notice. */ + private function firstChange(array $old, array $new): ?string + { + foreach ($new as $path => $mtime) { + if (!isset($old[$path]) || $old[$path] !== $mtime) { + return basename($path); + } + } + foreach ($old as $path => $mtime) { + if (!isset($new[$path])) { + return basename($path) . ' (removed)'; + } + } + return null; + } + + private function format(int $bytes): string + { + if ($bytes >= 1024 * 1024) { + return round($bytes / 1024 / 1024, 2) . ' MB'; + } + if ($bytes >= 1024) { + return round($bytes / 1024, 2) . ' KB'; + } + return $bytes . ' B'; + } + + private function formatDelta(int $bytes): string + { + $sign = $bytes >= 0 ? '+' : '-'; + $abs = abs($bytes); + if ($abs >= 1024 * 1024) { + return $sign . round($abs / 1024 / 1024, 2) . ' MB'; + } + if ($abs >= 1024) { + return $sign . round($abs / 1024, 2) . ' KB'; + } + return $sign . $abs . ' B'; + } +} diff --git a/src/Route/MemoryWatcher.php b/src/Route/MemoryWatcher.php deleted file mode 100644 index 35346de..0000000 --- a/src/Route/MemoryWatcher.php +++ /dev/null @@ -1,97 +0,0 @@ -attach($server); - * $server->on('request', $watcher->wrap($handler)); - * - * Usage (with custom workerStart logic): - * $watcher->attach($server, function (Server $server, int $workerId): void { - * // your onWorkerStart code here - * }); - */ -class MemoryWatcher -{ - private int $workerId = 0; - private int $baseline = 0; - - /** - * Registers the workerStart handler. - * - * @param Server $server Swoole HTTP server - * @param callable|null $onWorkerStart Optional extra callback — called after MemoryWatcher - * initialises, inside the same workerStart event. - * Signature: function(Server $server, int $workerId): void - */ - public function attach(Server $server, ?callable $onWorkerStart = null): void - { - $server->on('workerStart', function (Server $server, int $workerId) use ($onWorkerStart): void { - $this->workerId = $workerId; - $this->baseline = memory_get_usage(false); - echo sprintf( - "[Worker %d] START | Baseline: %s\n", - $this->workerId, - $this->format($this->baseline) - ); - if ($onWorkerStart !== null) { - $onWorkerStart($server, $workerId); - } - }); - } - - public function wrap(callable $handler): callable - { - return function (Request $request, Response $response) use ($handler): void { - $before = memory_get_usage(false); - - $handler($request, $response); - - $after = memory_get_usage(false); - - echo sprintf( - "[Worker %d] REQUEST => (before: %s, after: %s, delta: %s, growth: %s, peak: %s)\n", - $this->workerId, - $this->format($before), - $this->format($after), - $this->formatDelta($after - $before), - $this->formatDelta($after - $this->baseline), - $this->format(memory_get_peak_usage(false)) - ); - }; - } - - private function format(int $bytes): string - { - if ($bytes >= 1024 * 1024) { - return round($bytes / 1024 / 1024, 2) . ' MB'; - } - if ($bytes >= 1024) { - return round($bytes / 1024, 2) . ' KB'; - } - return $bytes . ' B'; - } - - private function formatDelta(int $bytes): string - { - $sign = $bytes >= 0 ? '+' : '-'; - $abs = abs($bytes); - if ($abs >= 1024 * 1024) { - return $sign . round($abs / 1024 / 1024, 2) . ' MB'; - } - if ($abs >= 1024) { - return $sign . round($abs / 1024, 2) . ' KB'; - } - return $sign . $abs . ' B'; - } -} From d8a35144f2d10ceb189ab84dcac9c2208e97c6a7 Mon Sep 17 00:00:00 2001 From: flytachi Date: Mon, 27 Jul 2026 20:36:01 +0500 Subject: [PATCH 22/71] =?UTF-8?q?=D0=BA=D1=80=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- console/Template/Docker/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/console/Template/Docker/Dockerfile b/console/Template/Docker/Dockerfile index 1014545..f2197c5 100644 --- a/console/Template/Docker/Dockerfile +++ b/console/Template/Docker/Dockerfile @@ -92,6 +92,8 @@ RUN apk add --no-cache su-exec procps \ && rm -rf /var/cache/apk/* \ && adduser -D -H -s /sbin/nologin winter +RUN docker-php-ext-install -j"$(nproc)" pcntl + # Opcache (swoole-flavored: enable_cli=1). Toggle off for dev via DISABLE_OPCACHE=true. ARG DISABLE_OPCACHE=false COPY docker/swoole/php-opcache.ini /tmp/php-opcache.ini From 082c297425888ae167b353e664819dc28af0f872 Mon Sep 17 00:00:00 2001 From: Jason Khan Date: Tue, 28 Jul 2026 02:09:14 +0500 Subject: [PATCH 23/71] test - new dep --- CLAUDE.md | 400 ++++++++++++++++ doc/actuator-plan.md | 124 +++++ doc/redes-check.md | 349 ++++++++++++++ doc/winter-application-flow.md | 255 ++++++++++ docs/winter-application-redesign.md | 572 +++++++++++++++++++++++ phpunit.xml | 3 + src/App/ApplicationArguments.php | 112 +++++ src/App/Attribute/Bean.php | 44 ++ src/App/Attribute/Configuration.php | 29 ++ src/App/Attribute/Import.php | 36 ++ src/App/Attribute/Value.php | 34 ++ src/App/Config/ChannelRegistry.php | 38 ++ src/App/Config/CorsRegistry.php | 97 ++++ src/App/Config/LoggingConfigurer.php | 25 + src/App/Config/ServerSettings.php | 80 ++++ src/App/Config/WebConfigurer.php | 24 + src/App/Config/WebConfigurerAdapter.php | 31 ++ src/App/Scope.php | 23 + src/Collector/ConfigurationCollector.php | 151 ++++++ src/WinterApplication.php | 483 +++++++++++++++++++ tests/App/ApplicationArgumentsTest.php | 57 +++ tests/App/ConfigurationCollectorTest.php | 127 +++++ 22 files changed, 3094 insertions(+) create mode 100644 CLAUDE.md create mode 100644 doc/actuator-plan.md create mode 100644 doc/redes-check.md create mode 100644 doc/winter-application-flow.md create mode 100644 docs/winter-application-redesign.md create mode 100644 src/App/ApplicationArguments.php create mode 100644 src/App/Attribute/Bean.php create mode 100644 src/App/Attribute/Configuration.php create mode 100644 src/App/Attribute/Import.php create mode 100644 src/App/Attribute/Value.php create mode 100644 src/App/Config/ChannelRegistry.php create mode 100644 src/App/Config/CorsRegistry.php create mode 100644 src/App/Config/LoggingConfigurer.php create mode 100644 src/App/Config/ServerSettings.php create mode 100644 src/App/Config/WebConfigurer.php create mode 100644 src/App/Config/WebConfigurerAdapter.php create mode 100644 src/App/Scope.php create mode 100644 src/Collector/ConfigurationCollector.php create mode 100644 src/WinterApplication.php create mode 100644 tests/App/ApplicationArgumentsTest.php create mode 100644 tests/App/ConfigurationCollectorTest.php diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8ae404a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,400 @@ +# CLAUDE.md — winter-kernel: Process/Daemon layer handoff + +This file orients you (Claude) to the work done on the **Process/Daemon** layer of +`winter-kernel`. Read it fully before touching that layer. It describes what was +built, how it works, why, and the rules to keep. + +> **winter-kernel** is a PHP 8.4+ framework kernel (a library, not an app). It runs +> under two runtimes: **Swoole** (coroutines) and **FPM/CLI** (plain processes). +> Namespace root: `Flytachi\Winter\K2\` → `src/`. Tests: `Flytachi\Winter\K2\Tests\` → `tests/`. + +--- + +## 0. Standing rules (do not violate) + +- **NEVER touch git.** The user commits/pushes. Plans contain code only — no commit steps. +- **Comments & PHPDoc: English only.** (Discussion with the user can be Russian.) +- **PHPDoc style:** one space between tag/type/name (no column alignment). In code + examples, use **only real methods** — never invent a method like `$this->handle()`; + use a comment placeholder (`// ... work ...`) for domain logic. +- **Framework philosophy: mechanism, not policy.** The kernel gives pipeline/hooks; + the coder decides policy (masking, sanitization, etc.). +- **Encapsulation matters to the user.** Internal machinery must not leak into the + application-facing API (see §7). This was a repeated, explicit concern. +- Sibling packages (`winter-thread`, `winter-logger`, `winter-di`, `winter-cdo`) are + **separate repos** pulled via composer. Don't edit them from here unless asked. +- The **dev playground** is `dev/` — a runnable app. Demos: `php dev/call daemon|process ...`. + +--- + +## 1. What this layer is + +A **runtime-agnostic managed-process abstraction**, Java-canonical in feel, mirroring +the existing `src/Concurrent` layer. Two levels: + +- **`Process`** — one managed worker: one body, one PID, alive until stopped. For a + single queue consumer, a single scheduled loop, a leader-elected singleton. +- **`Daemon extends Process`** — a **supervised fleet** of identical worker replicas + kept alive by a master supervisor (like `nginx`/`php-fpm` master+workers, or a k8s + Deployment). For a consumer pool, autoscaled workers, crash-isolated fan-out. + +The developer writes config + a body; the framework supplies the runtime (coroutines +or forks), concurrency, cooperative cancellation, a signal contract, guaranteed +teardown, a crash-safe singleton, and (for Daemon) forking/reaping, restart with +back-off, graceful drain, autoscaling with damping, and a liveness watchdog. + +### Location & namespaces + +| Path | Namespace | What | +|---|---|---| +| `src/Process/` | `Flytachi\Winter\K2\Process` | **the canonical layer (this work)** | +| `src/Process/Daemon/` | `…\Process\Daemon` | Daemon + supervision + policies + slot model | +| `src/Process/Engine/` | `…\Process\Engine` | runtime backends (Swoole/Sync) | +| `src/Process/Internal/` | `…\Process\Internal` | private-method traits (encapsulation) | +| `src/Old/Process/` | `…\Old\Process` | **OLD ThreadDaemon system, archived; the user will delete it.** Do not build on it. | + +> History: this layer was developed in `src/Dev/Process/` (`…\Dev\Process`), then +> promoted to canonical `src/Process/`; the old `src/Process/` moved to `src/Old/Process/`. +> If you see `Dev\Process` anywhere it is stale — it should be `Process`. + +--- + +## 2. `Process` — developer API + +```php +final class EmailConsumer extends Process +{ + #[Autowired] private MailQueue $queue; + + protected int $concurrency = 50; // cap on spawn() (0 = unlimited) + protected float $grace = 0.0; // drain deadline on stop (0 = wait forever) + protected ?string $processTitle = null; // ps title (default = short class name) + + public function run(): void // the body — loop on isRunning() + { + while ($this->isRunning()) { + $job = $this->queue->pop(timeout: 1.0); + if ($job === null) { continue; } + $this->markBusy(); + // ... process $job ... + $this->markIdle(); + } + } +} +``` + +**Primitives** (use inside `run()`; `final protected`): `isRunning(): bool`, +`sleep(float)` (interruptible — throws `InterruptedException` if an IDLE wait is +woken by a stop), `spawn(callable): Future` (bounded by `$concurrency`; +structured-concurrency drain on exit), `requestStop()`, `markBusy()/markIdle()`, +`activity(): Activity`, `touch()` (explicit liveness beat — for a daemon watchdog). + +**Hooks** (`protected`, override to react; the framework calls them): +`onTerminate` (SIGTERM), `onInterrupt` (SIGINT), `onReload` (SIGHUP — does NOT stop), +`onUser1`/`onUser2` (SIGUSR1/2), `onShutdown` (guaranteed teardown, runs once), +`afterFork` (reset fork-unsafe resources in a forked worker — see §5), +`buildProcessTitle`/`titleName` (override to customise the `ps` title). + +**Control** (`final public static`; usually from CLI): `start(): void` (foreground, +blocks), `dispatch(?string $output = '/dev/null'): int` (detached background, returns +PID), `status(bool $usage = false): ?ProcessStatus`, `stop(): bool` (sends SIGTERM). + +**Singleton per class** — one running instance per class, guarded by a crash-safe +`flock` (never a PID file). A second `start()`/`dispatch()` throws +`ProcessAlreadyRunningException`. Liveness via `posix_getpgid` (needs no permission → +cross-user safe). `status()` is a **pure read** — it never deletes the record. + +**Stop semantics:** SIGTERM/SIGINT → `isRunning()` flips false (cooperative). An IDLE +`sleep()` is interrupted at once (coroutine cancel / flag). A BUSY unit drains +(finishes its current unit). Past `grace` (>0) → force exit. `grace = 0` = wait +forever. A **repeated** stop on a bare process is **ignored** (only `grace` timer or +external SIGKILL forces) — the "second signal forces" behaviour belongs to Daemon. + +--- + +## 3. `Daemon` — developer API + +```php +final class Emails extends Daemon +{ + protected int $replicas = 3; // baseline fleet size + protected float $grace = 30.0; // master drain deadline (k8s parity; 0 = forever) + protected float $livenessTimeout = 0.0; // watchdog off by default + + // Body — ONE of two (priority: workerRun ▸ $workerClass): + protected function workerRun(): void // inline: the daemon IS the worker + { + while ($this->isRunning()) { $this->markBusy(); /* ... */ $this->markIdle(); } + } + // protected ?string $workerClass = SendProcess::class; // OR supervise an external Process class + + // Optional policy (all have sane defaults): + protected function desiredReplicas(): int { return min(16, intdiv($this->queue->depth(), 100)); } + protected function scaling(): ScalingPolicy { return ScalingPolicy::default(); } + protected function restart(): RestartPolicy { return RestartPolicy::default(); } + + // Optional master hooks: + protected function onWorkerStart(int $slot, int $pid): void {} + protected function onWorkerExit(int $slot, int $pid, bool $crashed): void {} + protected function onScale(int $from, int $to): void {} + protected function tick(): void {} // periodic on master (~scaleInterval) +} +``` + +The **manager and the unit of work are separate concerns** (like an executor and its +task). `workerRun()` = self-typed (daemon is also the worker). `$workerClass` = a +standalone `Process` you can also run solo (`SendProcess::start()`) and supervise. If +neither is set, each worker fails on fork with `DaemonConfigException`. + +**Control** = same 4 verbs as Process (`start`/`dispatch`/`status`/`stop`); `status()` +returns a **`DaemonStatus`** (a `ProcessStatus` + `restarts` + `workers[]`, one +`WorkerStatus` per non-empty slot). + +`ScalingPolicy` (readonly, non-final, `::default()`): `scaleInterval=1.0`, +`scaleUpDelay=0.0`, `scaleDownStabilization=60.0`, `cooldown=3.0`, `scaleStep=0`. +`RestartPolicy` (readonly, non-final, `::default()`): `mode` (`RestartMode` enum: +`ALWAYS`/`ON_FAILURE`(default)/`NEVER`), `maxRestarts=0` (0=∞), `backoff=1.0`. + +--- + +## 4. How the Daemon works internally (the hard part) + +The master is a **plain `pcntl` process with no event loop of its own** — it forks +workers and reaps them with `pcntl_waitpid`. Forking before any reactor starts is +safe; each worker child then boots its own clean `Coroutine\run` (Swoole) or plain +runtime. Workers are created with **`pcntl_fork`, not the Thread launcher** — +supervision needs the direct parent↔child `waitpid` relationship. (The Thread +launcher is used one level up, in `dispatch()`, to send the whole master to the +background.) + +### Slot state machine (`SlotState` enum, `Slot` mutable model) + +``` + EMPTY ─fork─► STARTING ─heartbeat─► RUNNING ─exit─► RESTARTING ─backoff─► (fork) + │ │ ▲ │ + retire │ retire │ │ un-retire │ give up + ▼ ▼ │ ▼ + RETIRING ◄───────┘ │ RETIRED / EMPTY + │ deadline/force + ▼ + KILLING ─reaped─► EMPTY +``` + +- `isCommitted()` = STARTING/RUNNING/RESTARTING/**RETIRED** (the size reconcile drives + to desired). `isAlive()` = STARTING/RUNNING/RETIRING/KILLING (has a live PID). +- **The slot state is the intent marker** that keeps the restart policy and the + autoscaler from fighting: a worker retired on purpose (scale-down/stop) is `RETIRING` + → never restarted; one that died on its own is handled by the restart policy. + +### The reconcile loop (single authority over fleet size) + +Each `scaleInterval`: `tick()` → compute damped `desiredReplicas()` → drive the +committed fleet toward it (up: fork; down: retire) by at most `scaleStep`, gated by +`cooldown`. + +- **Restart:** an unexpected death (`RUNNING`→exit) → `RestartPolicy`. If it restarts, + the slot goes `RESTARTING` (back-off `base·2^(n-1)`, cap 30s) then re-forks into the + **same slot** (so `worker#{n}` is stable). `maxRestarts` exceeded → daemon `FAILED`, + stops. If policy declines (`NEVER`, or a clean exit under `ON_FAILURE`) → **`RETIRED` + terminal state** (NOT freed — else reconcile would refill it instantly, bypassing + back-off → crash-loop). This RETIRED behaviour is a deliberate fix; do not "simplify" + it back to `free()`. +- **Scale-down:** mark victims `RETIRING` (graceful SIGTERM drain), **IDLE-first** + (best-effort from the heartbeat; a BUSY victim still drains). RETIRED/RESTARTING + slots are shed first (free). Anti-flap: a scale-up first un-retires still-draining + workers before forking new ones. +- **Scaling damping (`ScalingPolicy`):** up = react to the sustained floor over + `scaleUpDelay`; down = shrink only to the **high-water demand over + `scaleDownStabilization`** (a transient dip sheds nothing). `scaleStep` caps the + magnitude per action; `cooldown` the frequency. Signal, not command. + +### Stop sequence + +SIGTERM to master → **freeze reconcile** (state STOPPING) FIRST → retire the whole +fleet (parallel SIGTERM) → each drains by its deadline (`grace`; 0=∞) → SIGKILL +stragglers past deadline → after the fleet is empty run `onShutdown()`, delete the +record, release the flock, exit `TERMINATED`. A **second** stop signal collapses all +deadlines → immediate SIGKILL (operator's "stop now"). + +### Watchdog (liveness) + +Every worker writes a **monotonic heartbeat** (`ProcessStatus.heartbeatAt`) each engine +tick (~1s) to a per-slot store record keyed `#`. The master reads it for +(a) the fleet view and (b) IDLE-first victim selection. If `livenessTimeout > 0` and a +RUNNING/STARTING worker's heartbeat goes silent past it → SIGKILL → reap → restart +(crash path). Catches a wedged worker (deadlock, hung I/O) that a plain PID check +misses. Under FPM, a long BUSY unit that never yields won't heartbeat → either raise +the timeout or call `touch()` inside it. + +### Titles + +`winter-process: ` · `winter-daemon: master` · `winter-daemon: +worker#{n}` where **n is one-based** (`worker#1` = slot #0). Shared `winter-daemon:` +prefix → `pkill -f 'winter-daemon: '` kills the whole family. + +--- + +## 5. Fork-safety (`afterFork` / `ForkReset`) + +A fork copies the parent's memory **including open fds** (DB connections, pools, +sockets). Shared fds corrupt the wire protocol. Rule: **open connections in the child**. + +- `Process::afterFork()` runs in a forked worker before `run()`. Default: + `ForkReset::runAll()` — runs every framework-registered reset. Override to reset your + own resources (call `parent::afterFork()` first). +- Framework packages register a reset at bootstrap. **`Kernel::init()` registers** + `ForkReset::register(fn() => PpaConnectionPool::reset())` — so daemon workers get + fresh DB connections automatically. A reset must **reconnect in place** (not replace + the object — an injected reference would go stale) or the pool must be lazy. + +--- + +## 6. Runtimes (engines) + +`Engines::common($concurrency, $grace)` picks by `extension_loaded('swoole')`: + +- **`SwooleEngine`** — body runs in `Coroutine\run`; `spawn()` = real coroutines + (shared memory, coroutine semaphore); `sleep()` non-blocking; stop cancels the body + coroutine → interruptible. +- **`SyncEngine`** — FPM/plain; `spawn()` = `pcntl_fork` per task (isolated, + fire-and-forget, `Future` = settled placeholder); `sleep()` interruptible in chunks; + `SIGALRM` force-exit at grace. + +Both implement `ProcessEngine`. **Two bugs fixed here — do not reintroduce:** +1. **Reactor hang:** `SwooleEngine::enter()` MUST unregister signals + (`\Swoole\Process::signal($signo, null)`) + clear timers in its `finally`, else + `Coroutine\run` never returns after the body ends (hangs forever with `grace=0`). +2. **grace>0 drain freeze:** in `SwooleEngine::requestStop()`, arm the grace + `Timer::after` **BEFORE** `Coroutine::cancel($bodyCid)`. Arming the timer after the + cancel swallows the pending resume → the body freezes until the timer fires (drain + would always wait the full grace). Order matters. + +--- + +## 7. Encapsulation design (important — the user cares deeply) + +Internal machinery must not appear in the application-facing API of a subclass. PHP has +no package-private, so: + +- **`Internal\SingletonLock` trait** (private `acquireLock`/`releaseLock`/`lockPath` + + `$lockHandle`) — `use`d by BOTH `Process` and `Daemon`. Private trait methods are + invisible to app subclasses (`class Foo extends Process` can't call them) yet each + framework class gets its own copy. +- **`Daemon\SupervisesFleet` trait** — the ENTIRE supervision loop (reconcile, + reap, watchdog, scaling, stop, slot transitions, `superviseFleet()`, `snapshot()`, + `backoff()`, …), all **`private`**, `use`d by `Daemon`. There is **no `Supervisor` + class** — it was merged into this trait so nothing leaks. The daemon IS the supervisor + in the master process. +- Daemon's internal accessors (`replicas`, `computeDesired`, `scalingPolicy`, + `restartPolicy`, `graceSeconds`, `livenessTimeout`, `bootWorker`, `workerRecord`, + `clearWorkerRecord`, `fire*`) are all **`private`** — the trait calls them via `$this`. +- `applyProcessTitle` and `Daemon::workerTitle` are **`private`**. +- `Process::runWorker` is **`protected`** (not public): a daemon boots an external + `$workerClass` worker (a sibling Process) via cross-instance protected access + (allowed in PHP because it's declared in the common base `Process`). +- **Kept `protected @internal` by necessity** (not leaks): `key()`, `store()`, + `ensureNotRunning()` — required protected because `status()`/`stop()` reach them via + late-static-binding (`static::key()`) and Daemon reuses them; they are benign. +- `run()` is public — it is the contract method the developer implements (like + `Runnable::run`), not internal machinery. + +**Override points that stay `protected` (intended API):** `run`, all `on*` hooks, +`afterFork`, `buildProcessTitle`, `titleName` (Process); `workerRun`, `desiredReplicas`, +`scaling`, `restart`, `onWorkerStart/Exit`, `onScale`, `tick` (Daemon). + +If you add anything the supervision trait needs from Daemon, make it **private** and +call it via `$this->` from the trait. + +--- + +## 8. CLI + +- `call process [start [-d] | stop | status [-v]]` (alias `proc`). Lists + only bare processes. +- `call daemon [start [-d] | stop | status [-v]]` (alias `dmn`). Lists only + daemons; `status` shows the per-worker fleet table (SLOT/PID/STATE/ACT/UPTIME/RESTARTS). + +Dot notation → FQCN: `main.process.Emails` → `Main\Process\Emails`. Tab-completion is +in `console/Command/Complete.php`. Commands: `console/Command/{Process,Daemon}.php`. + +--- + +## 9. Tests + +- **Unit** (`tests/Process/`, ~96 tests): enums, value objects, JSON serialization, + config resolution/clamping, `ForkReset`, titles, and the supervision **decision + algorithms** (`SupervisesFleetTest` — backoff, damping/`windowExtreme`, `pickVictims` + IDLE-first, slot counts) via reflection, deterministic, no forks. +- **Integration** (`tests/Process/Integration/`, 17 tests, `#[Group('integration')]` → + excluded from the default run): real fork/swoole/signals. `IntegrationCase` boots a + temp-storage kernel, forks a real Process/Daemon child, observes via the shared store + + a `WK_MARKER` file, sends real signals. Covers: fork N replicas, graceful stop (no + orphans), restart-in-slot, maxRestarts→FAILED, NEVER→retired, watchdog, autoscale + up→down, singleton, 2nd-signal force, per-worker activity, SIGHUP=reload, external + `$workerClass` path, and all Process signal hooks. + +**Run:** `vendor/bin/phpunit` (full suite, **1346 tests** — integration excluded). +Integration: `vendor/bin/phpunit --group integration tests/Process/Integration` (needs +`pcntl`+`posix`; runs under Swoole here; ~20s). + +**Test conventions:** +- Method names are `test_snake_case` (project convention; PSR-12 flags camelCase — ignore). +- Reflection needs no `setAccessible` (PHP 8.1+). For a **private property declared in a + trait**, reflect via the **declaring class** (`new ReflectionProperty(Daemon::class, + 'slots')`), NOT the subclass (private parent props aren't visible via a child class). + Private methods reflect fine via the instance. +- Forked children in tests end with `posix_kill(getmypid(), SIGKILL)` to skip PHPUnit's + shutdown (avoids polluting output). +- `IntegrationCase::setUp` resets `KernelStore`'s `$runnable`/`$storages`/`$volatiles` + caches (via reflection) — `Kernel::runnable()` caches `FileStorage` by name against + the path it was first built with, so a reused fixture class would otherwise read a + previous test's deleted temp dir. + +Dev demos (runnable): `dev/main/Process/*.php` (StableDaemon, CrashDaemon, FleetDaemon +[`$workerClass`], AutoscaleDaemon, NeverDaemon, HungDaemon, SendProc, …). + +--- + +## 10. Docs + +`docs/process/` (`00-overview`, `01-lifecycle`, `02-concurrency`, `03-control`) and +`docs/process/daemon/` (`00-overview`, `01-workers`, `02-autoscaling`, `03-control`). +Mature, behaviour-focused, English, verified against the code. Keep them accurate if you +change the API. + +--- + +## 11. Status + +**Done & verified (live under Swoole + full test suite):** Process (lifecycle, dual +runtime, signals, drain, grace/force, spawn/concurrency, singleton, JSON/security), +Daemon (worker-typed + inline, fork+DI+`afterFork`, reconcile+slot-state, retire/ +scale-down IDLE-first, scaling damping, restart+backoff+maxRestarts→FAILED, full stop +sequence, watchdog, per-worker status, hooks, titles), full encapsulation (traits), +PHPDoc, docs, unit + integration tests. `dispatch()` works from inside a Swoole +coroutine (fixed in the `winter-thread` package: `AdaptiveLauncher`/`SwooleLauncher`). + +**Not done (future phases):** phase 3 — templates (worker-pool, SMPP, WebSocket); +phase 4 — web status (a controller reading the same store; `DaemonStatus` is already +JSON-serializable). `src/Old/Process` is to be **deleted by the user**. + +**Known minor limitations:** `maxRestarts` is cumulative (not "consecutive"); a +STARTING worker that hangs before its first heartbeat relies on the watchdog with a +timeout; SIGKILL of an FPM worker can orphan its `spawn` grandchildren (only on the +past-grace force path). + +--- + +## 12. Gotchas — things already solved, don't reintroduce + +- Force-exit must run cleanup: a `register_shutdown_function` backstop deletes the store + record even on `exit()` (finally alone is skipped on force-exit). +- `status()` uses `posix_getpgid` (not `posix_kill($pid,0)` — which needs permission and + fails cross-user) and is a **pure read** (no delete) — else a cross-user status check + could evict a live process's record. +- Activity is a **backed** enum (`Activity: string`) and status objects are + `JsonSerializable` — a pure enum made `json_encode` return `false`. +- The two SwooleEngine bugs in §6. +- The maxRestarts→FAILED slot-free ordering and the NEVER→RETIRED terminal state in §4. +- Daemon status must heartbeat-persist (~1s) in the loop, not only on fleet-size changes, + or activity/STARTING→RUNNING never reach the store. diff --git a/doc/actuator-plan.md b/doc/actuator-plan.md new file mode 100644 index 0000000..0a78167 --- /dev/null +++ b/doc/actuator-plan.md @@ -0,0 +1,124 @@ +# Actuator / Health — план (handoff, продолжить отсюда) + +> Что хотим сделать с actuator в рамках редизайна `WinterApplication`. Часть решена, +> часть требует решения ПЕРЕД кодом. Контекст обсуждения: `doc/redes-check.md` §3. +> Health в новом `WinterApplication` ПОКА НЕ подключён — это следующий шаг. + +--- + +## 0. Цель одной фразой + +Дать диагностику приложения (`/actuator/*`: health/info/metrics/env/loggers/mappings) +так, чтобы она работала **и с web-сервером, и в headless** (приложение из только +Daemon+Schedule), и убрать хук `Health::configure()` с «бог-класса» → в атрибут. + +--- + +## 1. Ключевое разделение (не путать) + +- **Health-проверка (логика)** — транспорт-независима, работает всегда, в т.ч. без web. +- **`/actuator/*`** — это HTTP-**роуты** → без слушателя по HTTP их не прочитать. +- **НЕ путать** с Process/Daemon-статусом: `DaemonStatus`/`WorkerStatus` (`call daemon X + status`) = process-level (жив ли воркер, рестарты). Actuator = app-level (БД, память, + диск). Разные вещи, не сливать. + +--- + +## 2. ✅ Решено — способ отдачи = ОБА + +1. **`call health` (CLI)** — новая консольная команда. Зовёт индикатор, печатает JSON, + код возврата 0/1. Работает всегда (headless тоже). Для k8s `exec`-проб / cron. + ```yaml + livenessProbe: { exec: { command: ["php","call","health"] } } + ``` +2. **`#[EnableActuator(port: 9000)]`** — отдельный management-компонент (крошечный + сервер), поднимается **даже без web-компонента**. Калька Spring `management.server.port`. + ```yaml + livenessProbe: { httpGet: { path: /actuator/health, port: 9000 } } + ``` +3. **Если есть `Component::http()`** → `/actuator/*` на основном сервере (как сейчас). +4. **`#[EnableActuator(...)]` заменяет `Health::configure()`** — параметры (`port`, + `middleware`, `indicator`) переезжают в атрибут на App-классе. + +Пример: +```php +#[EnableActuator(port: 9000, middleware: InternalOnlyMiddleware::class)] +final class App extends WinterApplication { /* ... */ } +``` + +--- + +## 3. 🟡 РЕШИТЬ ПЕРЕД КОДОМ — форма индикатора + +Текущая модель: **ОДИН** `HealthIndicator` с 6 методами (см. §5), кастом через +наследование + `Health::configure(indicator:)`. Это НЕ спринговский «много маленьких». + +- **B1** — оставить один индикатор; только перенести конфиг в `#[EnableActuator]`. + Минимум изменений. Кодер наследует весь индикатор и переопределяет метод. +- **B2 (тяготеем сюда)** — разбить на много маленьких `HealthContributor` (каждый + чекает одно: БД, redis, диск); фреймворк сам находит их через `ImplementorCollector` + и агрегирует в `/actuator/health`. Системные секции (info/metrics/env/loggers/ + mappings) остаются встроенными. Drop-in, как понравилось в §2 redes-check. + ```php + final class RedisHealth implements HealthContributor { + public function check(): Health { return $redis->ping() ? Health::up() : Health::down(); } + } + ``` + Минус: переделка интерфейса Health + агрегатор. + +> **Решение оставлено на завтра. Пользователь склоняется к B2.** Начать с этого выбора. + +--- + +## 4. Куда встроить в WinterApplication (точки интеграции) + +1. **Чтение атрибута** — в `WinterApplication::bootstrap()`, рядом с `applyImports()`: + прочитать `#[EnableActuator]` на `static::class` → сохранить конфиг (port/middleware/ + indicator). (См. как сделан `applyImports()` — тот же приём с рефлексией атрибутов.) +2. **Web-путь** — если есть `Component::http()` и actuator включён → зарегистрировать + `/actuator/*` роуты (эквивалент нынешнего `Health::configure`), чтобы `Router::fromScan` + в `serveHttp()` их подхватил. Проверить, КАК сейчас `/actuator` попадает в роутер + (`src/Http/Health/Health.php` + где Router читает `Health::getConfig()`). +3. **Management-порт** — если задан `port` → поднять отдельный маленький сервер только + на `/actuator/*`. Варианты: отдельный `addProcess` в `serveHttp`, либо отдельный + companion в headless. Дизайн уточнить (Swoole `Http\Server` на своём порту, только + actuator-роуты). +4. **CLI** — новая команда `console/Command/Health.php`: boot → собрать секции индикатора + (health/info/metrics/...) → `echo json` → `exit(up?0:1)`. Не зависит от web. + Учесть: `WinterApplication::run()` уже отдаёт неизвестные глаголы в `Core`, значит + `call health` дойдёт до команды после boot автоматически. + +--- + +## 5. Текущий код Health (карта — что есть сейчас) + +- `src/Http/Health/HealthIndicatorInterface.php` — интерфейс, **6 методов**: + `health(): array`, `info(): array`, `metrics(): array`, `env(): array`, + `loggers(): array`, `mappings(): array`. +- `src/Http/Health/HealthIndicator.php` — дефолтная реализация (класс implements + interface); есть helper `dbHealth(string $rootDir)`, системные метрики. +- `src/Http/Health/Health.php` — статический реестр: + `configure(indicator = HealthIndicator::class, middleware = null)` → пишет `self::$config`; + `getConfig()`, `setMappings()/getMappings()`, `setRootDir()/getRootDir()`, + статические хелперы (`cpu()` и др.). Регистрирует `/actuator/*` эндпоинты. +- Порог статуса: degraded ≥80% ресурсов, down ≥90% или отказ соединения. + +--- + +## 6. Resume-чеклист (с чего начать завтра) + +1. [ ] Выбрать **B1 или B2** (форма индикатора). Пользователь → скорее B2. +2. [ ] Если B2: спроектировать `HealthContributor` (интерфейс `check(): Health`) + + агрегатор + `Health` value-объект (up/down/withDetail). Системные секции — + оставить во встроенном индикаторе. +3. [ ] Атрибут `#[EnableActuator(port?, middleware?, indicator?)]` + + `src/App/...` + чтение в `bootstrap()`. +4. [ ] Web-путь: регистрация `/actuator/*` при наличии `Component::http()`. +5. [ ] Management-порт: отдельный сервер при заданном `port` (в т.ч. headless). +6. [ ] CLI `console/Command/Health.php` (`call health`, JSON + exit-код). +7. [ ] Тесты в `tests/App` (агрегация contributors, exit-код CLI, attribute-config). +8. [ ] Обновить `doc/redes-check.md` §3 (🟡 → ✅) и `doc/winter-application-flow.md`. + +> Не трогать `Boot`/`Application` (правило редизайна). `WinterApplication` — +> отдельный вход. Полный контекст редизайна: `doc/redes-check.md`, +> `doc/winter-application-flow.md`. Память: `winter-application-redesign`. diff --git a/doc/redes-check.md b/doc/redes-check.md new file mode 100644 index 0000000..5a1e87c --- /dev/null +++ b/doc/redes-check.md @@ -0,0 +1,349 @@ +# Redesign speak — живой лог решений (WinterApplication) + +> Рабочий журнал обсуждения редизайна загрузчика. Сюда фиксируем **согласованные** +> куски по мере разбора. Статусы: ✅ принято · 🟡 обсуждается · ⬜ ещё не трогали. +> +> Общая цель: убрать «бог-класс» `Boot` (где один класс держит `components()`, +> `configure()`, `providers()`, `channels()`, `httpCors()`, `health()`, `plugins()`, +> `swooleConfig()`) → перейти к Spring-модели: **тонкий entry-класс + +> конфигурация, разъехавшаяся по классам, которые находит сканер.** + +--- + +## ✅ 1. Beans — `#[Configuration]` + `#[Bean]` (замена `providers()`) + +**Принято.** Нравится, потенциально закрывает много кейсов конфигурации. + +### Суть +`#[Bean]` — это **сахар над существующим контейнером**, не отдельная система. +Коллектор под капотом дёргает ровно `$c->singleton()/->transient()/->request()`. + +### Как работает +- Класс с `#[Configuration]` — контейнер бинов. +- Метод с `#[Bean]` — фабрика. **Ключ бина = тип возврата метода.** +- Тело метода = та же фабрика-замыкание, что раньше писали в `providers()`. +- Аргументы `#[Bean]`-метода автоинжектятся (в т.ч. `#[Value('ENV_KEY')]` из `.env`). + +### Scope (важно!) +`#[Bean]` по умолчанию = **singleton** (метод вызывается 1 раз, объект кэшируется и +переиспользуется везде) — как `@Bean` в Spring. + +| Запись | Под капотом | +|---|---| +| `#[Bean]` | `$c->singleton(ReturnType, factory)` — 1 объект на процесс | +| `#[Bean(scope: Scope::Transient)]` | `$c->transient(...)` — новый каждый раз | +| `#[Bean(scope: Scope::Request)]` | `$c->request(...)` — один на запрос/корутину | + +> Асимметрия, держать в голове: `#[Bean]` по умолчанию singleton, а обычный +> сканируемый класс **без атрибута** — transient. (Тоже как в Spring.) + +### Пример (в нашем стиле — `#[Autowired]`, без конструктора) + +```php +#[Configuration] +final class AppConfig +{ + #[Bean] // = $c->singleton(CacheInterface::class, ...) + public function cache(): CacheInterface + { + return new RedisCache(env('REDIS_URL')); + } + + #[Bean] // аргументы автоинжектятся + public function mailer(#[Value('MAIL_HOST')] string $host): MailerInterface + { + return new SmtpMailer($host); + } +} +``` + +Потребитель — как обычно: + +```php +class ReportService extends Service +{ + #[Autowired] + private CacheInterface $cache; // придёт бин из cache(), тот же объект везде +} +``` + +### Про `#[Service]` — НЕ добавляем +- У нас `Service` — это **базовый класс** (`Stereotype\Service`), его наследуют; + зависимости через `#[Autowired]`-свойства. Атрибута `#[Service]` нет и не нужен. +- `extends Service` сам по себе в DI ничего не регистрирует — за scope отвечает + атрибут `#[Singleton]/#[Request]/#[Transient]` (см. `DICollector`). Без атрибута + класс автоварится по рефлексии как transient. +- Spring `@Service` = просто спец-`@Component` (метка + семантика). **Прироста + скорости нет** — атрибут vs базовый класс в рантайме равны. Единственный + реальный плюс атрибута — освобождает слот наследования PHP; нам сейчас не жмёт. +- Итог: простые сервисы — `extends Service` + `#[Autowired]` (+ `#[Singleton]` при + нужде кэша). Сложную сборку (интерфейс→реализация, фабрика, скаляр из env) берёт + `#[Bean]`. + +--- + +## ✅ 2. Конфигураторы — интерфейс + авто-discovery (на примере CORS `WebConfigurer`) + +**Принято.** Ключевой вывод: **этим паттерном можно добавлять сколько угодно +конфигов** — универсальный «drop-in» механизм. + +### Суть +Кодер пишет класс, `implements` нужный интерфейс — и **приложение само его +находит** на скане. Никакой регистрации, никаких ссылок из App-класса. + +### Как «оно само находит» +Механизм не новый — у нас **уже есть** `ImplementorCollector` +(`src/Collector/ImplementorCollector.php`): на скане собирает все не-абстрактные +классы, реализующие заданный интерфейс (так сейчас находятся `DbConfigInterface`). + +```php +// framework side — в тот же скан, что уже идёт в boot(): +$web = new ImplementorCollector(WebConfigurer::class); + +Scanner::run(rootDir: Kernel::$pathRoot, cache: ...) + ->collect(new DICollector($c)) + ->collect($async) + ->collect($web) // ← одна строка = «ищи реализации WebConfigurer» + ->execute(); + +// после скана — вызвать найденное: +$registry = new CorsRegistry(); +foreach ($web->getResult() as $ref) { + $configurer = $c->make($ref->getName()); // через контейнер → #[Autowired] внутри тоже работает + $configurer->configureCors($registry); +} +Cors::configure(...$registry->build()); // применяем в существующий Cors::configure() +``` + +### Что делает кодер — только создать файл + +```php +namespace Main\Config; + +use Flytachi\Winter\K2\Http\Cors\WebConfigurer; +use Flytachi\Winter\K2\Http\Cors\CorsRegistry; + +final class WebConfig implements WebConfigurer // ← достаточно `implements` +{ + public function configureCors(CorsRegistry $cors): void + { + $cors->allowedOrigins('https://app.example.com') + ->allowCredentials(true); + } +} +``` + +Положил класс → сканер увидел `implements` → фреймворк вызвал. Удалил → дефолт. + +### Нюансы +- Конфигураторов может быть **несколько** — вызовутся все (обычно хватает одного). +- Создаётся через `$c->make(...)` → внутри можно `#[Autowired]`-зависимости. + +### Обобщение +Тот же паттерн (`implements интерфейс` → сканер находит → фреймворк вызывает) +переиспользуется для всех «конфигураторов» редизайна: + +| Интерфейс | Заменяет | Что делает | +|---|---|---| +| `WebConfigurer` | `httpCors()` | настраивает CORS | +| `LoggingConfigurer` | `channels()` | регистрирует лог-каналы | +| `ServerConfigurer` | `swooleConfig()` | тюнит Swoole-сервер | +| `HealthIndicator` | `health()` | тот же discovery, но **собирается в список** проверок, а не «настраивает» | + +--- + +## 🟡 3. Health / Actuator — способ отдачи РЕШЁН, индикатор ПЕРЕПИШЕМ + +**Способ отдачи — принято (оба). Форма индикатора — будем переписывать, обсудим +отдельно** (не фиксируем реализацию сейчас). + +### Важное разделение (зафиксировать в голове) +- **Health-проверка (логика)** — транспорт-независима, работает и в headless + (например, приложение из только Daemon + Schedule). +- **`/actuator/*`** — это HTTP-**роуты** (`Health::configure()` регистрирует + мэппинги, читает `Router`) → без слушателя по HTTP их прочитать некому. + +### ✅ Способ отдачи health — ОБА +- **`call health` (CLI)** — команда зовёт индикатор, печатает JSON, exit 0/1. + Работает всегда, в т.ч. headless. Для k8s `exec`-проб / cron. +- **`#[EnableActuator(port: 9000)]`** — отдельный management-компонент (крошечный + сервер), поднимается **даже без `#[EnableWeb]`** (калька Spring + `management.server.port`). +- Если есть `#[EnableWeb]` → `/actuator/*` на основном сервере, как сейчас. +- `#[EnableActuator(...)]` заменяет `Health::configure()` — параметры (`port`, + `middleware`, `indicator`) переезжают в атрибут на App-классе. + +### 🟡 Форма индикатора — ПЕРЕПИШЕМ (обсудить позже) +Текущая модель: **один** `HealthIndicator` с 6 методами (`health/info/metrics/env/ +loggers/mappings`), кастом через наследование + `Health::configure(indicator:)`. +Это НЕ спринговский «много маленьких проверок». Обсудили направления: +- **B1** — оставить один индикатор (минимум изменений). +- **B2** — разбить на маленькие `HealthContributor` (drop-in, фреймворк сам находит + через `ImplementorCollector` и агрегирует; системные секции остаются встроенными). + +> Решение: **скорее всего перепишем** (тяготеем к B2 / drop-in), но детали — +> отдельным обсуждением. Пока НЕ реализуем. + +### Не путать с Process/Daemon-статусом +`DaemonStatus`/`WorkerStatus` (через store, `call daemon status`) — это +**process-level** liveness (жив ли воркер, рестарты). Health/Actuator — **app-level** +(БД, память, диск). Разные вещи, не сливаем. + +--- + +## ✅ 4. Import + Starter (замена `plugins()`) — переименовано + +**Принято.** `plugins()` → `import`. Два уровня, как в Java (не путать): + +### A. `#[Import(...)]` — явная форма (делаем сразу) +Переименованный `Plugin::registry()` в атрибут. Ты контролируешь prefix / `required`. + +```php +#[Import('acme/auth-plugin', '/auth')] +#[Import('acme/billing', '/billing', required: false)] +final class App extends WinterApplication { /* ... */ } +``` + +Под капотом — существующий механизм (`Composer\InstalledVersions::getInstallPath`, +скан `src/` пакета). Просто хук `plugins()` → атрибуты на App. + +### B. True starter — авто-конфиг (фича поверх, позже) +Пакет **сам объявляет себя** winter-стартером через `composer.json`, ядро сканит +установленные пакеты и подключает **без строк в App** (= Spring Boot starter: +`composer require` → включилось): + +```jsonc +// composer.json пакета acme/billing-starter +"extra": { + "winter": { + "starter": true, + "prefix": "/billing", + "config": "Acme\\Billing\\BillingConfiguration" // его #[Configuration] + } +} +``` + +- Новый механизм: читать `extra.winter` из `composer.json` установленных пакетов + (composer-идиома вместо спринговского `AutoConfiguration.imports`). +- `#[Import('acme/billing')]` остаётся как **override** авто-старта (сменить prefix, + отключить, `required:false`). +- **Порядок:** `#[Import]` сейчас, starter-autoconfig — отдельной фичей. + +--- + +## ✅ 5. Web / Server — `ServerConfigurer` УБИРАЕМ, сливаем в web + +**Принято.** Тюнинг сервера — это web-tier concern, отдельная сущность не нужна. +`swooleConfig()` уходит. Два уровня: + +> ⚠️ ПОПРАВКА (после разбора): `#[EnableWeb]` **отменён** — такой аннотации в Java +> нет (web в Spring включается наличием зависимости, не аннотацией). Ручки сервера +> переезжают на существующий `Component::http(host, port)` + `.env` + `WebConfigurer`. +> См. §6 «Компоненты». + +### Частые ручки — на `Component::http()` + `.env` + +```php +Component::http(host: '0.0.0.0', port: 8000) // host/port — как сейчас +// SERVER_WORKERS=8, SERVER_MAX_REQUEST=5000 — в .env +``` + +### Глубокий тюнинг — метод в `WebConfigurer` (тот же класс, что CORS) +Чтобы не заставлять реализовывать оба метода — **абстрактный адаптер с пустыми +дефолтами** (= спринговский `WebMvcConfigurerAdapter`): наследуешь, переопределяешь +только нужное. + +```php +final class WebConfig extends WebConfigurerAdapter // пустые дефолты configureCors + configureServer +{ + public function configureServer(ServerSettings $s): void // override только это + { + $s->set('ssl_cert_file', '/etc/ssl/app.pem'); + } +} +``` + +> Итог: `ServerConfigurer` из §2-таблицы **удалён**. Сервер = часть web-поверхности: +> `#[EnableWeb]` (ручки) + `WebConfigurer::configureServer()` (редкий тюнинг). + +--- + +## ✅ 6. Компоненты (что запускается) — оставляем метод `components()` + +**Принято.** Никаких `#[EnableWeb]` (в Java такой аннотации нет — web включается +зависимостью, не аннотацией). Явный сигнал «из чего собрано приложение» = метод. + +```php +protected static function components(): array +{ + return [ + Component::http(port: 8000), // есть #[EnableWeb]? — НЕТ, только это + Component::process(KernelSys::class), + Component::daemon(Emails::class), + Component::scheduler(), + ]; +} +``` + +- Честно, гибко (можно `if (env(...))`), без фейковых аннотаций. +- Параметры web-сервера: `Component::http(host, port)` + `.env` (`SERVER_*`) + + `WebConfigurer::configureServer()` (редкий тюнинг). См. §5. +- `#[Import]` (§4) остаётся отдельно — `@Import` это **реальная** Spring-аннотация. +- Реальные Java-тоглы (`@EnableScheduling`/`@EnableAsync`) НЕ вводим — scheduler это + просто `Component::scheduler()`, один механизм. + +--- + +## ✅ 7. Логи (каналы) — как есть, норм + +**Принято, не усложняем.** Базовые каналы (`http`, `sys`) + кастомные через `.env` +(`LOG_{NAME}_*`). Редкий код (динамические каналы) — интерфейс `LoggingConfigurer` +(тот же discovery-паттерн, что §2). Ничего не переделываем. + +--- + +## ✅ 8. Аргументы `main(array $args)` — минимальные, без `--profile` + +**Принято.** Только реальные ручки: `--port`, `--host` и т.п. Спринговские профили +(`--profile`) НЕ нужны — выкинули. Приоритет: **CLI-аргумент > .env > дефолт**. + +--- + +## ✅ 9. Точка входа + Boot order — РЕАЛИЗОВАНО + +**Принято и написано кодом.** `Boot`/`Application` НЕ тронуты — `WinterApplication` +это параллельный самостоятельный вход. + +- `App::main($argv)` → `WinterApplication::run($argv)`. +- **Диспетчеризация:** `call run`/`run dev` → поднять приложение (`serve`); голый + `call` → help, любой другой глагол (`make/daemon/cfg/...`) → консольный `Core`. + `run` перехватывается ДО `Core` (старый `Run`-command завязан на `Application`). +- **Аргументы:** `ApplicationArguments` (`--port/--host/-w`), приоритет CLI > .env > + дефолт. `--profile` выкинут (§8). +- **Boot order:** `Kernel::init` рано (курица-яйцо); затем ОДИН скан с коллекторами + `DICollector` + `ConfigurationCollector` + `AsyncCollector` + + `ImplementorCollector(WebConfigurer/LoggingConfigurer)`; после скана — apply + logging → cors → imports. Кэш `Scanner` хранит только список FQCN → добавление + коллекторов безопасно. + +--- + +## 📦 Статус реализации (готово в коде) + +Файлы: `src/WinterApplication.php`, `src/App/{ApplicationArguments,Scope}.php`, +`src/App/Attribute/{Configuration,Bean,Value,Import}.php`, +`src/Collector/ConfigurationCollector.php`, +`src/App/Config/{WebConfigurer,WebConfigurerAdapter,CorsRegistry,ServerSettings,LoggingConfigurer,ChannelRegistry}.php`. +Тесты: `tests/App/*` (11). **Весь сьют: 1416 зелёных.** + +Схема потока: `doc/winter-application-flow.md`. + +## ⬜ Осталось (отдельными шагами) +- 🟡 **Health/Actuator** — переписать индикатор + `call health` + `#[EnableActuator(port)]`. +- 🟡 **WebSocket** — порт движка (`Component::websocket()` пока throw). +- 🟡 **Starter-autoconfig** — `composer.json extra.winter` (сейчас только `#[Import]`). +- ⬜ **Back-compat** — сосуществование/удаление `BaseBoot`/`Application`; демо-`bootstrap` + на `WinterApplication`. +- ⬜ **Swoole-валидация** — `serveHttp` локально не проверялся (нет swoole на dev-боксе). + +> Полный черновой обзор со всеми примерами: `docs/winter-application-redesign.md`. diff --git a/doc/winter-application-flow.md b/doc/winter-application-flow.md new file mode 100644 index 0000000..2a7db13 --- /dev/null +++ b/doc/winter-application-flow.md @@ -0,0 +1,255 @@ +# WinterApplication — схема работы (как что движется) + +> Карта нового загрузчика: файлы, поток управления, поток данных. Читать при +> тестах и детальном разборе. `Boot`/`Application` НЕ тронуты — это параллельный, +> самостоятельный вход. Всё покрыто тестами (`tests/App`, суммарно 1416 зелёных). + +--- + +## 1. Карта файлов (что появилось) + +| Файл | Роль | +|---|---| +| `src/WinterApplication.php` | **точка входа**: `main()`/`run()` → boot → dispatch → serve | +| `src/App/ApplicationArguments.php` | парсинг `argv` (`--port`, `-w`, command/sub) | +| `src/App/Scope.php` | enum scope бина: Singleton / Transient / Request | +| `src/App/Attribute/Configuration.php` | метка класса-конфигурации (Spring `@Configuration`) | +| `src/App/Attribute/Bean.php` | метка фабричного метода (Spring `@Bean`) | +| `src/App/Attribute/Value.php` | инжект значения из `.env` в параметр бина (`@Value`) | +| `src/App/Attribute/Import.php` | подключение пакета-плагина (`@Import`), repeatable | +| `src/Collector/ConfigurationCollector.php` | регистрирует `#[Bean]`-методы в контейнер | +| `src/App/Config/WebConfigurer.php` | контракт: CORS + тюнинг сервера (`WebMvcConfigurer`) | +| `src/App/Config/WebConfigurerAdapter.php` | пустые дефолты обоих методов (адаптер) | +| `src/App/Config/CorsRegistry.php` | fluent-билдер CORS → `Cors::configure()` | +| `src/App/Config/ServerSettings.php` | опции Swoole из `.env` + тюнинг | +| `src/App/Config/LoggingConfigurer.php` | контракт: доп. лог-каналы | +| `src/App/Config/ChannelRegistry.php` | билдер каналов → `Kernel::channel()` | +| `tests/App/*` | тесты: аргументы + Beans-коллектор | + +Переиспользуется как есть: `Kernel`, `Container`, `Scanner`, `DICollector`, +`AsyncCollector`, `ImplementorCollector`, `Cors`, `Plugin`, `Router`, `DevWatcher`, +`ForkReset`, `Component`/`ComponentKind`, консольный `Core`. + +--- + +## 2. Общий поток (сверху вниз) + +``` + call → App::main($argv) (dev/call — единственный вход) + │ + ▼ + WinterApplication::run($argv) + │ + ┌──────────┴───────────────────────────────────────────────┐ + │ 1. ApplicationArguments::parse($argv) │ argv → объект args + │ command / sub / --port / -w / raw │ + ├───────────────────────────────────────────────────────────┤ + │ 2. bootstrap($args) ← СЕРДЦЕ (см. §3) │ ядро + скан + конфиг + ├───────────────────────────────────────────────────────────┤ + │ 3. DISPATCH по command: │ + │ 'run' | 'run dev' → serve($watch,$args) (§5) │ поднять приложение + │ пусто | make|cfg|... → new Core($argv)->run() │ консоль (пусто → Help) + └───────────────────────────────────────────────────────────┘ +``` + +Решение по диспетчеризации: **поднять приложение только по `call run`/`call run +dev`**; голый `call` и любой другой глагол = консольная команда (тот же `Core`, что +и в старом `cli()`; голый `call` → `Help`). `run` перехватывается ДО `Core` (старый +`Run`-command завязан на `Application`, его не трогаем). + +--- + +## 3. Фаза boot — `bootstrap($args)` (откуда что берётся) + +``` +bootstrap($args) + │ + ├─ configure($args) → Kernel::init(pathRoot: rootPath()) + │ .env, логгер (sys/http), timezone, thread + │ [РАНО: до скана — курица-яйцо] + │ + ├─ $c = Container::init() + │ + └─ ОДИН скан проекта (Scanner::run(pathRoot, cache di.php)): + collect(DICollector) → #[Singleton]/#[Request]/#[Transient] + collect(ConfigurationCollector) → #[Configuration] + #[Bean] → фабрики в $c + collect(AsyncCollector) → #[Async]-прокси + collect(ImplementorCollector(WebConfigurer)) → список классов + collect(ImplementorCollector(LoggingConfigurer)) → список классов + execute() + │ + ▼ после скана — ПРИМЕНИТЬ найденное: + applyLogging($c, найденные LoggingConfigurer) → ChannelRegistry → Kernel::channel() + applyCors($c, найденные WebConfigurer) → CorsRegistry → Cors::configure() + (+ запомнить классы для §5) + applyImports() → читает #[Import] на App-классе → Plugin::registry() +``` + +Ключевое: **конфигурация не вызывается как хуки на App-классе — она НАХОДИТСЯ +сканером** (обычные классы в проекте) и применяется после скана. App-класс знает +только `components()` + `configure()` + свои `#[Import]`-атрибуты. + +### Как `#[Bean]` попадает в контейнер (ConfigurationCollector) + +``` +#[Configuration] class AppConfig + ├─ #[Bean] cache(): CacheInterface → $c->singleton(CacheInterface, factory) + ├─ #[Bean(scope: Transient)] q(): Query → $c->transient(Query, factory) + └─ сам AppConfig → $c->singleton(AppConfig) (общий инстанс) + +factory(при resolve): + $config = $c->make(AppConfig) ← общий инстанс конфигурации + аргументы метода: + #[Value('KEY', def)] → env('KEY', def) ← скаляр из .env + иной тип → $c->make(тип) ← автowire + return $config->method(...аргументы) +``` +> Бин ОБЯЗАН возвращать объект (контейнер инжектит свойства в результат) — скаляры +> только через `.env`/`#[Value]`. Иначе коллектор кидает понятную ошибку. + +--- + +## 4. Как находятся конфигураторы (discovery) + +Один и тот же механизм для CORS/сервера/каналов — существующий `ImplementorCollector`: + +``` +Кодер кладёт класс: Фреймворк на скане: + class WebConfig ImplementorCollector(WebConfigurer) + implements WebConfigurer ──────► ->getResult() = [WebConfig, ...] + { configureCors(...) } после скана: $c->make(WebConfig) + ->configureCors($registry) + $registry->apply() → Cors::configure() +``` + +Ноль регистрации. Положил файл → нашёлся → применился. Удалил → дефолт. + +--- + +## 5. Фаза serve — `serve($watch, $args)` + +``` +serve() + ├─ components() → классификация: + │ Http → $http (максимум один) + │ WebSocket → ⛔ throw (порт легаси-движка ещё не готов) + │ Process/Daemon/Scheduler → companions[] + │ + ├─ есть $http? ──ДА──► serveHttp() (нужен ext-swoole) + │ host/port: args --port/--host > Component::http > дефолт + │ server->set( ServerSettings::fromEnv() + WebConfigurer::configureServer ) + │ Router::fromScan(pathRoot) + static(public) + │ companions → $server->addProcess(...) (супервизор) + │ workerStart → канал 'http' + CoroutineContext + │ companions-child → канал 'sys' + ProcessContext + ForkReset + │ $watch → DevWatcher (память + hot-reload через reexec) + │ $server->start() + │ + └─ нет $http? ──► serveHeadless() + 1 компонент → foreground $class::start() + несколько → pcntl fork на каждый + waitpid + форвард SIGTERM/SIGINT + (работает и без swoole) +``` + +--- + +## 6. Что пишет кодер (полный пример) + +```php +// App.php — тонкий класс приложения +#[Import('acme/auth-plugin', '/auth')] // плагин (опц.) +final class App extends WinterApplication +{ + protected static function configure(ApplicationArguments $args): void + { + Kernel::init(pathRoot: __DIR__); // или убрать → rootPath() сам выведет + } + + protected static function components(): array + { + return [ + Component::http(port: 8000), // web (опц.) + Component::daemon(Emails::class), + Component::scheduler(), + ]; + } +} +``` + +```php +// call — единственный launcher +require __DIR__ . '/vendor/autoload.php'; +require __DIR__ . '/App.php'; +App::main($argv); +``` + +Опциональные конфиг-классы (кладёшь — находятся сами): + +```php +#[Configuration] +final class AppConfig +{ + #[Bean] + public function mailer(#[Value('MAIL_HOST')] string $host): MailerInterface + { + return new SmtpMailer($host); + } +} + +final class WebConfig extends WebConfigurerAdapter +{ + public function configureCors(CorsRegistry $cors): void + { + $cors->allowedOrigins('https://app.example.com')->allowCredentials(); + } + public function configureServer(ServerSettings $s): void + { + $s->workers(8)->maxRequest(5000); + } +} +``` + +Как запускать: + +```bash +php call # help (консоль, Core → Help) +php call run # поднять приложение, DevWatcher off +php call run dev # + DevWatcher (память + hot-reload) +php call run --port=8080 # override порта +php call make -c UserController # консольная команда (через Core) +php call daemon main.Emails start # standalone-компонент +``` + +--- + +## 7. Каналы логов (куда что пишется) + +| Контекст | Канал | Где ставится | +|---|---|---| +| HTTP-запрос (worker) | `http` | `serveHttp` on('workerStart') + CoroutineContext | +| master / companions / console / framework | `sys` | run() перед Core; child-замыкания addProcess/headless | + +Кастомные каналы — `.env` (`LOG_{NAME}_*`) или `LoggingConfigurer`. + +--- + +## 8. Что ОТЛОЖЕНО (не в этом коде) + +- 🟡 **WebSocket** — `Component::websocket()` пока `throw` (порт легаси-движка). +- 🟡 **Health/Actuator** — индикатор переписываем; `call health` + `#[EnableActuator(port)]` + ещё не реализованы. +- 🟡 **Starter-autoconfig** — авто-подключение пакетов через `composer.json extra.winter` + (сейчас только явный `#[Import]`). +- ⬜ **Back-compat** — сосуществование со старым `Boot`/`Application` (решить). + +--- + +## 9. Чем проверять + +```bash +vendor/bin/phpunit tests/App # тесты нового кода (11) +vendor/bin/phpunit # весь сьют (1416, App включён в phpunit.xml) +``` +> Swoole на dev-боксе не загружен → HTTP-путь `serveHttp` локально не проверяется +> (как и раньше для `Application`); валидируется на timeline. Консоль/headless/ +> коллекторы/аргументы — проверяемы и покрыты. diff --git a/docs/winter-application-redesign.md b/docs/winter-application-redesign.md new file mode 100644 index 0000000..84b156e --- /dev/null +++ b/docs/winter-application-redesign.md @@ -0,0 +1,572 @@ +# WinterApplication — редизайн загрузчика (proposal) + +> Цель: убрать «бог-класс» `Boot`, где один класс держит `components()`, +> `configure()`, `providers()`, `channels()`, `httpCors()`, `health()`, +> `plugins()`, `swooleConfig()`. Приходим к Spring-модели: **тонкий entry-класс +> + конфигурация, разъехавшаяся по классам, которые находит сканер.** +> +> Это разбор дизайна, а не финальный код. Каждый раздел показывает: (1) Spring- +> аналог, (2) что даёт фреймворк, (3) что пишет кодер. + +--- + +## 0. Как это выглядит «до» и «после» + +### Сейчас (один класс на всё) + +```php +class Boot extends Application +{ + protected static function components(): array { /* http, process... */ } + protected static function configure(): void { Kernel::init(...); } + protected static function providers(Container $c): void { /* биндинги */ } + protected static function channels(): void { /* каналы логов */ } + protected static function httpCors(): void { Cors::configure(...); } + protected static function health(): void { Health::configure(...); } + protected static function plugins(): void { Plugin::registry(...); } + public static function swooleConfig(): array { return [...]; } +} +``` + +### После (тонкий вход + разнесённая конфигурация) + +```php +#[EnableWeb(port: 8000)] +#[EnableScheduling] +#[EnableDaemon(Emails::class)] +#[EnablePlugin('acme/auth-plugin', '/auth')] +final class App extends WinterApplication +{ + public static function main(array $args): never + { + return self::run(App::class, $args); + } +} +``` + +Всё остальное (беды/beans, CORS, health, каналы, настройки сервера) — **обычные +классы в проекте**, которые сканер сам находит. `App` больше не знает про них. + +--- + +## 1. Точка входа: `WinterApplication` + `main(array $args)` + +### Spring-аналог + +```java +@SpringBootApplication +public class MyApp { + public static void main(String[] args) { + SpringApplication.run(MyApp.class, args); // единственный вход + } +} +``` + +`SpringApplication.run(...)` делает всё: читает `application.properties`, сканирует +`@Component`, поднимает встроенный сервер. Аргументы `--server.port=8081` +перекрывают свойства (Spring это зовёт *relaxed binding*). + +### Что даёт фреймворк + +Новый абстрактный класс `WinterApplication` (заменяет `BaseBoot`/`Application`): + +```php +namespace Flytachi\Winter\K2; + +abstract class WinterApplication +{ + /** + * Единственный вход приложения. Парсит аргументы, поднимает ядро, сканирует + * проект, применяет конфигураторы и либо поднимает компоненты, либо выполняет + * console-команду (make/daemon/...). + * + * @param class-string $appClass + * @param array $args сырой $argv (имя скрипта в [0]) + */ + final public static function run(string $appClass, array $args): never + { + $arguments = ApplicationArguments::parse($args); // --port=8080 --profile=prod ... + // ... bootstrap ядра + скан + применение конфигураторов + запуск ... + } + + /** + * Опциональный override путей ядра — нужен только если каталоги проекта + * нестандартные. По умолчанию pathRoot выводится из расположения App. + */ + protected static function configure(ApplicationArguments $args): void + { + Kernel::init(pathRoot: static::rootPath()); + } +} +``` + +### Что пишет кодер + +Файл `App.php` (класс приложения): + +```php +#[EnableWeb(port: 8000)] +final class App extends WinterApplication +{ + public static function main(array $args): never + { + return self::run(App::class, $args); + } +} +``` + +Файл `call` (единственный launcher, как `java -jar`): + +```php +#!/usr/bin/env php +` парсятся в типизированный объект. +Они кладутся **поверх** `.env`/атрибутов как override: + +```bash +php call --port=8080 # перебить порт web-компонента +php call --profile=prod # выбрать профиль (аналог Spring profiles) +php call make -c UserController # console-команда — тоже через App::main +``` + +`--port=8080` побеждает `#[EnableWeb(port: 8000)]`. Приоритет: +**аргумент CLI > .env > атрибут/дефолт.** + +--- + +## 2. Beans / DI-биндинги: `#[Configuration]` + `#[Bean]` + +> Заменяет `providers(Container $c)`. + +### Spring-аналог + +```java +@Configuration +public class AppConfig { + @Bean + public MailerInterface mailer(@Value("${mail.host}") String host) { + return new SmtpMailer(host); + } +} +``` + +Класс с `@Configuration`, методы с `@Bean` возвращают объекты — Spring кладёт их в +контейнер. Тип возврата = ключ бина. Аргументы метода — автоинжектятся. + +### Что даёт фреймворк + +- Атрибут `#[Configuration]` (маркер класса-конфигурации). +- Атрибут `#[Bean]` (маркер фабричного метода). +- Атрибут `#[Value('ENV_KEY')]` — инжект значения из `.env` (аналог `@Value`). +- Новый коллектор `ConfigurationCollector`: на скане находит `#[Configuration]`- + классы, для каждого `#[Bean]`-метода регистрирует фабрику в `Container` + (ключ = тип возврата метода, аргументы = autowire). + +### Что пишет кодер + +```php +namespace Main\Config; + +use Flytachi\Winter\K2\App\Attribute\Configuration; +use Flytachi\Winter\K2\App\Attribute\Bean; +use Flytachi\Winter\K2\App\Attribute\Value; + +#[Configuration] +final class AppConfig +{ + #[Bean] + public function mailer(#[Value('MAIL_HOST')] string $host): MailerInterface + { + return new SmtpMailer($host); + } + + // Аргументы бинов автоинжектятся из контейнера — как в конструкторах. + #[Bean] + public function cache(LoggerInterface $logger): CacheInterface + { + return new RedisCache(env('REDIS_URL'), $logger); + } +} +``` + +Никаких `$c->bind(...)` в entry-классе. Хочешь новый сервис — создаёшь метод в +любом `#[Configuration]`-классе, сканер подхватит. Простые `#[Singleton]`/ +`#[Service]`-классы (авто-DI по атрибуту) работают как раньше — `#[Bean]` нужен +только когда сборку объекта нельзя выразить атрибутом (интерфейс→реализация, +фабрика, скаляр из env). + +--- + +## 3. CORS: интерфейс `WebConfigurer` + +> Заменяет `httpCors()`. + +### Spring-аналог + +```java +@Configuration +public class WebConfig implements WebMvcConfigurer { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOrigins("https://app.example.com") + .allowCredentials(true); + } +} +``` + +Реализуешь интерфейс `WebMvcConfigurer`, Spring находит его и вызывает +`addCorsMappings(...)` при старте. + +### Что даёт фреймворк + +- Интерфейс `WebConfigurer` с методом `configureCors(CorsRegistry $cors): void`. +- Класс `CorsRegistry` — fluent-обёртка, которая внутри зовёт существующий + `Cors::configure(...)`. +- На boot: `ImplementorCollector(WebConfigurer::class)` находит все реализации и + вызывает их (аналог того, как сейчас находятся Controller'ы). + +```php +interface WebConfigurer +{ + public function configureCors(CorsRegistry $cors): void; +} +``` + +### Что пишет кодер + +```php +namespace Main\Config; + +use Flytachi\Winter\K2\Http\Cors\WebConfigurer; +use Flytachi\Winter\K2\Http\Cors\CorsRegistry; + +final class WebConfig implements WebConfigurer +{ + public function configureCors(CorsRegistry $cors): void + { + $cors->allowedOrigins('https://app.example.com') + ->allowedHeaders('Content-Type', 'Authorization', 'X-Request-Id') + ->exposeHeaders('X-Request-Id') + ->allowCredentials(true) + ->maxAge(3600); + } +} +``` + +Нет CORS-класса → политика дефолтная (wildcard). Per-route по-прежнему через +`#[CrossOrigin]` на контроллере — этот механизм не трогаем. + +--- + +## 4. Health / Actuator: авто-discovery `HealthIndicator` + +> Заменяет `health()`. + +### Spring-аналог + +```java +@Component +public class DatabaseHealthIndicator implements HealthIndicator { + @Override + public Health health() { + return db.ping() ? Health.up().build() : Health.down().build(); + } +} +``` + +Просто объявляешь `@Component`, реализующий `HealthIndicator`. Actuator сам его +находит и агрегирует в `/actuator/health`. Никакой регистрации. + +### Что даёт фреймворк + +- Интерфейс `HealthIndicator` с методом `health(): Health`. +- Коллектор находит все реализации и регистрирует в агрегаторе `/actuator/health`. +- Сам actuator включается атрибутом `#[EnableActuator]` на App (либо по умолчанию + для web-компонента). Защита middleware — параметр атрибута. + +### Что пишет кодер + +Включить actuator (на App-классе): + +```php +#[EnableActuator(middleware: InternalOnlyMiddleware::class)] +final class App extends WinterApplication { /* ... */ } +``` + +Добавить свою проверку (обычный класс, сканер найдёт): + +```php +namespace Main\Health; + +use Flytachi\Winter\K2\Http\Health\HealthIndicator; +use Flytachi\Winter\K2\Http\Health\Health; + +final class DatabaseHealth implements HealthIndicator +{ + public function __construct(private Db $db) {} // автоинжект + + public function health(): Health + { + return $this->db->ping() + ? Health::up()->withDetail('latency_ms', $this->db->latency()) + : Health::down()->withDetail('reason', 'connection failed'); + } +} +``` + +--- + +## 5. Каналы логов: `.env` + опциональный `LoggingConfigurer` + +> Заменяет `channels()`. + +### Spring-аналог + +В Spring каналы/аппендеры настраиваются в `logback-spring.xml` или через +`application.properties` — почти никогда в коде. Только сложные случаи — через код. + +### Что даёт фреймворк + +- Базовые каналы (`http`, `sys`) уже регистрируются в `Kernel::init`. +- Кастомные каналы — из `.env` (как сейчас, `LOG_{NAME}_*`), плюс объявление имён. +- Для кода — интерфейс `LoggingConfigurer` с `configureChannels(ChannelRegistry)`. + +### Что пишет кодер + +Чаще всего — только `.env`: + +```dotenv +LOG_JOB_LEVEL=debug +LOG_JOB_OUTPUT=file +LOG_JOB_FILE=/var/log/app/job.log +``` + +Если нужен код (динамические каналы): + +```php +namespace Main\Config; + +use Flytachi\Winter\K2\Logging\LoggingConfigurer; +use Flytachi\Winter\K2\Logging\ChannelRegistry; + +final class LoggingConfig implements LoggingConfigurer +{ + public function configureChannels(ChannelRegistry $channels): void + { + $channels->add('job'); + $channels->add('audit'); + } +} +``` + +Использование в коде не меняется: +`LoggerFactory::getLogger(MyJob::class, 'job')->info('started')`. + +--- + +## 6. Плагины: атрибуты `#[EnablePlugin]` + +> Заменяет `plugins()`. + +### Spring-аналог + +Модульность в Spring — это `@Import` и стартеры (`spring-boot-starter-*`). Подключил +зависимость → авто-конфигурация подхватилась. Ближайшая калька — декларативные +`@Enable*`/`@Import` на главном классе. + +### Что даёт фреймворк + +- Атрибут `#[EnablePlugin(package, prefix, required)]` — повторяемый. +- На boot читаются атрибуты App-класса → вызывается существующий + `Plugin::registry(...)`. + +### Что пишет кодер + +```php +#[EnablePlugin('acme/auth-plugin', '/auth')] +#[EnablePlugin('acme/billing-plugin', '/billing')] +#[EnablePlugin('acme/experimental', '/x', required: false)] +final class App extends WinterApplication { /* ... */ } +``` + +Читается сверху класса, декларативно. `src/` каждого плагина сканируется +автоматически — как сейчас. + +--- + +## 7. Настройки сервера (Swoole): `ServerConfigurer` / `.env` + +> Заменяет `swooleConfig()`. + +### Spring-аналог + +```properties +server.port=8080 +server.tomcat.threads.max=200 +``` + +Настройки встроенного сервера — свойства `server.*`. Для кода — +`WebServerFactoryCustomizer`. + +### Что даёт фреймворк + +- `.env`-свойства `SERVER_*` (workers, max_request, ...). +- Опциональный `ServerConfigurer` с `configure(ServerSettings $s)` для тонкой + настройки в коде (проброс в `\Swoole\Http\Server::set()`). + +### Что пишет кодер + +`.env`: + +```dotenv +SERVER_WORKERS=8 +SERVER_MAX_REQUEST=5000 +``` + +или код: + +```php +final class ServerConfig implements ServerConfigurer +{ + public function configure(ServerSettings $s): void + { + $s->workers(swoole_cpu_num() * 2) + ->maxRequest(5000) + ->maxRequestGrace(500); + } +} +``` + +--- + +## 8. Что запускается: атрибуты `#[Enable*]` + +> Заменяет `components()`. + +### Spring-аналог + +```java +@EnableScheduling // включить планировщик +@EnableAsync // включить async +@SpringBootApplication +public class MyApp { ... } +``` + +В Spring «что умеет приложение» — это набор `@Enable*` + наличие сервера в classpath. + +### Что даёт фреймворк + +Атрибуты на App-классе, читаются на boot и превращаются в `Component`-манифест: + +| Атрибут | Аналог сейчас | +|---|---| +| `#[EnableWeb(host, port)]` | `Component::http(...)` | +| `#[EnableScheduling]` | `Component::scheduler()` | +| `#[EnableProcess(Class::class)]` | `Component::process(...)` | +| `#[EnableDaemon(Class::class)]` | `Component::daemon(...)` | + +### Что пишет кодер + +```php +#[EnableWeb(port: 8000)] +#[EnableScheduling] +#[EnableProcess(KernelSys::class)] +#[EnableDaemon(Emails::class)] +final class App extends WinterApplication +{ + public static function main(array $args): never + { + return self::run(App::class, $args); + } +} +``` + +- Есть `#[EnableWeb]` → поднимается Swoole HTTP + компаньоны рядом (addProcess). +- Нет `#[EnableWeb]` → headless (только фоновые). +- `--port=8080` из CLI перебивает порт. + +> Развилка, которую надо решить: `#[Enable*]`-атрибуты **или** оставить метод +> `components(): array` (гибче для условной сборки — `if (env(...))`), **или** оба +> (атрибуты для типового, метод как escape-hatch). + +--- + +## 9. Порядок boot (что за чем) + +``` +App::main($argv) + → WinterApplication::run(App::class, $argv) + 1. ApplicationArguments::parse($argv) — --port, --profile, ... + 2. configure(args) → Kernel::init(...) — пути, .env, логгер (РАНО, до скана) + 3. DI-скан проекта (существующий Scanner), коллекторы: + • DICollector — #[Singleton]/#[Service]/... (как сейчас) + • AsyncCollector — #[Async]-прокси (как сейчас) + • ConfigurationCollector— #[Configuration]/#[Bean] (НОВОЕ) + • ImplementorCollector — WebConfigurer / LoggingConfigurer / + ServerConfigurer / HealthIndicator (НОВОЕ) + 4. Применить конфигураторы: logging → cors → health + 5. Прочитать атрибуты App: #[Enable*], #[EnablePlugin], #[EnableActuator] + 6. Диспетчеризация: + • есть console-команда в args (make/daemon/schedule/...) → выполнить её + • иначе → поднять компоненты (serve): Swoole + компаньоны / headless +``` + +Ключевой нюанс (курица-яйцо): шаг 2 (`Kernel::init` — пути/env/лог) **обязан** +отработать до скана, поэтому он остаётся на App-классе/конвенции и **не** может быть +discovered-классом. Всё остальное — находится сканом. + +--- + +## 10. Итог: какие классы появляются + +### Даёт фреймворк (winter-kernel) + +| Класс / атрибут | Роль | +|---|---| +| `WinterApplication` | базовый entry-класс, `run()` / `main()` | +| `ApplicationArguments` | парсинг `--key=value` из argv | +| `#[Configuration]`, `#[Bean]`, `#[Value]` | beans вместо `providers()` | +| `ConfigurationCollector` | сбор `#[Bean]`-фабрик на скане | +| `WebConfigurer` + `CorsRegistry` | CORS вместо `httpCors()` | +| `HealthIndicator` (+ авто-агрегатор) | health вместо `health()` | +| `LoggingConfigurer` + `ChannelRegistry` | каналы вместо `channels()` | +| `ServerConfigurer` + `ServerSettings` | сервер вместо `swooleConfig()` | +| `#[EnableWeb]`, `#[EnableScheduling]`, `#[EnableProcess]`, `#[EnableDaemon]`, `#[EnablePlugin]`, `#[EnableActuator]` | манифест вместо `components()`/`plugins()` | + +### Пишет кодер (в своём проекте) + +| Файл | Что это | +|---|---| +| `App.php` | тонкий класс с `main()` + `#[Enable*]` | +| `call` | `App::main($argv)` | +| `Config/AppConfig.php` | `#[Configuration]` с `#[Bean]`-методами (опц.) | +| `Config/WebConfig.php` | `implements WebConfigurer` (опц., CORS) | +| `Health/DatabaseHealth.php` | `implements HealthIndicator` (опц.) | +| `Config/LoggingConfig.php` | `implements LoggingConfigurer` (опц.) | +| `Config/ServerConfig.php` | `implements ServerConfigurer` (опц.) | + +**Всё «опц.» — реально опционально**: нет класса → дефолт фреймворка. App-класс +худеет до `main()` + атрибутов; конфиг перестаёт торчать protected-методами в API +наследника (это отдельно важно по твоему принципу инкапсуляции). + +--- + +## 11. Открытые развилки (решить до кода) + +1. **Config-механизм** — full Spring (Configuration/Bean + Configurer-интерфейсы) + / лёгкий (один `AppConfig` с перенесёнными хуками) / гибрид. +2. **Components** — `#[Enable*]`-атрибуты / метод `components()` / оба. +3. **Console vs serve** — `App::main` диспетчеризует и команды, и подъём приложения + (нужно решить: `call run` остаётся отдельным словом, или подъём = дефолт без + команды). +4. **Back-compat** — оставляем ли старый `BaseBoot`/`Application` как deprecated + слой на переходный период, или рубим сразу (Swoole-only приоритет уже задан). diff --git a/phpunit.xml b/phpunit.xml index 04e9d15..e52afb3 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -37,6 +37,9 @@ tests/Concurrent + + tests/App + diff --git a/src/App/ApplicationArguments.php b/src/App/ApplicationArguments.php new file mode 100644 index 0000000..b2f63e2 --- /dev/null +++ b/src/App/ApplicationArguments.php @@ -0,0 +1,112 @@ + .env > default. + * There are no Spring-style profiles; only real knobs (`--port`, `--host`, ...). + */ +final class ApplicationArguments +{ + /** + * @param list $raw Original argv (script name in [0]). + * @param list $positionals Bare words after the script name. + * @param array $options Long options (`true` = present, no value). + * @param array $flags Short flags. + */ + private function __construct( + private array $raw, + private array $positionals, + private array $options, + private array $flags, + ) { + } + + /** + * @param list $argv Raw argv (script name in [0]). + */ + public static function parse(array $argv): self + { + $tokens = array_slice(array_values($argv), 1); + + $positionals = []; + $options = []; + $flags = []; + + foreach ($tokens as $token) { + if (str_starts_with($token, '--')) { + $body = substr($token, 2); + if ($body === '') { + continue; + } + if (str_contains($body, '=')) { + [$key, $val] = explode('=', $body, 2); + $options[$key] = $val; + } else { + $options[$body] = true; + } + } elseif (str_starts_with($token, '-') && $token !== '-') { + foreach (str_split(substr($token, 1)) as $ch) { + $flags[$ch] = true; + } + } else { + $positionals[] = $token; + } + } + + return new self(array_values($argv), $positionals, $options, $flags); + } + + /** The command word (argv[1]) or null (bare invocation). */ + public function command(): ?string + { + return $this->positionals[0] ?? null; + } + + /** The sub-command word (argv[2]) or null. */ + public function sub(): ?string + { + return $this->positionals[1] ?? null; + } + + /** True if a long option `--key` was passed (with or without a value). */ + public function has(string $key): bool + { + return array_key_exists($key, $this->options); + } + + /** True if a short flag `-x` was passed. */ + public function flag(string $char): bool + { + return isset($this->flags[$char]); + } + + /** A long option's value, or $default if absent (or present without a value). */ + public function option(string $key, ?string $default = null): ?string + { + $value = $this->options[$key] ?? null; + return is_string($value) ? $value : $default; + } + + /** A long option parsed as int, or $default if absent / not numeric. */ + public function int(string $key, int $default): int + { + $value = $this->option($key); + return $value !== null && is_numeric($value) ? (int) $value : $default; + } + + /** The original argv (to hand off to the console dispatcher). */ + public function raw(): array + { + return $this->raw; + } +} diff --git a/src/App/Attribute/Bean.php b/src/App/Attribute/Bean.php new file mode 100644 index 0000000..1894c95 --- /dev/null +++ b/src/App/Attribute/Bean.php @@ -0,0 +1,44 @@ +add('job')->add('audit'); + * ``` + */ +final class ChannelRegistry +{ + /** @var list */ + private array $names = []; + + public function add(string $name): self + { + $this->names[] = $name; + return $this; + } + + /** + * Registers every declared channel with the kernel. + */ + public function apply(): void + { + foreach ($this->names as $name) { + Kernel::channel($name); + } + } +} diff --git a/src/App/Config/CorsRegistry.php b/src/App/Config/CorsRegistry.php new file mode 100644 index 0000000..9e78971 --- /dev/null +++ b/src/App/Config/CorsRegistry.php @@ -0,0 +1,97 @@ +allowedOrigins('https://app.example.com') + * ->allowedHeaders('Content-Type', 'Authorization') + * ->exposeHeaders('X-Request-Id') + * ->allowCredentials(true) + * ->maxAge(3600); + * ``` + */ +final class CorsRegistry +{ + /** @var list */ + private array $origins = []; + /** @var list */ + private array $allowHeaders = []; + /** @var list */ + private array $exposeHeaders = []; + /** @var list */ + private array $vary = []; + private bool $credentials = false; + private int $maxAge = 0; + private bool $touched = false; + + public function allowedOrigins(string ...$origins): self + { + $this->origins = array_merge($this->origins, array_values($origins)); + return $this->touch(); + } + + public function allowedHeaders(string ...$headers): self + { + $this->allowHeaders = array_merge($this->allowHeaders, array_values($headers)); + return $this->touch(); + } + + public function exposeHeaders(string ...$headers): self + { + $this->exposeHeaders = array_merge($this->exposeHeaders, array_values($headers)); + return $this->touch(); + } + + public function vary(string ...$headers): self + { + $this->vary = array_merge($this->vary, array_values($headers)); + return $this->touch(); + } + + public function allowCredentials(bool $enabled = true): self + { + $this->credentials = $enabled; + return $this->touch(); + } + + public function maxAge(int $seconds): self + { + $this->maxAge = $seconds; + return $this->touch(); + } + + /** True once any method has been called (so an empty configurer is a no-op). */ + public function isTouched(): bool + { + return $this->touched; + } + + /** + * Applies the collected policy to the global {@see Cors} config. + */ + public function apply(): void + { + Cors::configure( + origins: $this->origins, + allowHeaders: $this->allowHeaders, + exposeHeaders: $this->exposeHeaders, + credentials: $this->credentials, + maxAge: $this->maxAge, + vary: $this->vary, + ); + } + + private function touch(): self + { + $this->touched = true; + return $this; + } +} diff --git a/src/App/Config/LoggingConfigurer.php b/src/App/Config/LoggingConfigurer.php new file mode 100644 index 0000000..857475c --- /dev/null +++ b/src/App/Config/LoggingConfigurer.php @@ -0,0 +1,25 @@ +add('job')->add('audit'); + * } + * } + * ``` + */ +interface LoggingConfigurer +{ + public function configureChannels(ChannelRegistry $channels): void; +} diff --git a/src/App/Config/ServerSettings.php b/src/App/Config/ServerSettings.php new file mode 100644 index 0000000..7328e38 --- /dev/null +++ b/src/App/Config/ServerSettings.php @@ -0,0 +1,80 @@ +workers(swoole_cpu_num() * 2) + * ->maxRequest(5000) + * ->set('ssl_cert_file', '/etc/ssl/app.pem'); + * ``` + */ +final class ServerSettings +{ + /** @param array $options */ + private function __construct(private array $options = []) + { + } + + /** + * Seeds base options from the environment. Only variables that are actually + * set contribute a key (so Swoole defaults apply otherwise). + */ + public static function fromEnv(): self + { + $options = []; + $map = [ + 'SERVER_WORKERS' => 'worker_num', + 'SERVER_TASKS' => 'task_worker_num', + 'SERVER_MAX_REQUEST' => 'max_request', + 'SERVER_MAX_REQUEST_GRACE' => 'max_request_grace', + ]; + foreach ($map as $envKey => $swooleKey) { + $raw = env($envKey); + if ($raw !== null && is_numeric($raw)) { + $options[$swooleKey] = (int) $raw; + } + } + return new self($options); + } + + public function workers(int $count): self + { + return $this->set('worker_num', $count); + } + + public function taskWorkers(int $count): self + { + return $this->set('task_worker_num', $count); + } + + public function maxRequest(int $count): self + { + return $this->set('max_request', $count); + } + + public function maxRequestGrace(int $count): self + { + return $this->set('max_request_grace', $count); + } + + /** Set any raw Swoole option. */ + public function set(string $key, mixed $value): self + { + $this->options[$key] = $value; + return $this; + } + + /** @return array */ + public function toArray(): array + { + return $this->options; + } +} diff --git a/src/App/Config/WebConfigurer.php b/src/App/Config/WebConfigurer.php new file mode 100644 index 0000000..34f306f --- /dev/null +++ b/src/App/Config/WebConfigurer.php @@ -0,0 +1,24 @@ +allowedOrigins('https://app.example.com')->allowCredentials(); + * } + * } + * ``` + */ +abstract class WebConfigurerAdapter implements WebConfigurer +{ + public function configureCors(CorsRegistry $cors): void + { + } + + public function configureServer(ServerSettings $server): void + { + } +} diff --git a/src/App/Scope.php b/src/App/Scope.php new file mode 100644 index 0000000..e2cad89 --- /dev/null +++ b/src/App/Scope.php @@ -0,0 +1,23 @@ +collect(new ConfigurationCollector($container))->execute(); + * ``` + */ +final readonly class ConfigurationCollector implements CollectorInterface +{ + public function __construct(private Container $container) + { + } + + public function collect(string $class, ReflectionClass $ref): void + { + if ($ref->getAttributes(Configuration::class) === []) { + return; + } + + // One shared instance of the configuration class for all of its beans. + $this->container->singleton($class); + + foreach ($ref->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + $attributes = $method->getAttributes(Bean::class); + if ($attributes === []) { + continue; + } + + /** @var Bean $bean */ + $bean = $attributes[0]->newInstance(); + $key = $bean->name ?? self::returnTypeOf($method, $class); + self::assertObjectReturn($method, $class); + $factory = $this->factoryFor($class, $method->getName()); + + match ($bean->scope) { + Scope::Singleton => $this->container->singleton($key, $factory), + Scope::Transient => $this->container->transient($key, $factory), + Scope::Request => $this->container->request($key, $factory), + }; + } + } + + /** + * Builds the closure that invokes the bean method with resolved arguments. + */ + private function factoryFor(string $class, string $method): \Closure + { + return function (Container $c) use ($class, $method): mixed { + $config = $c->make($class); + $args = self::resolveArguments($c, new ReflectionMethod($class, $method)); + return $config->{$method}(...$args); + }; + } + + /** + * @return list + */ + private static function resolveArguments(Container $c, ReflectionMethod $method): array + { + $args = []; + foreach ($method->getParameters() as $parameter) { + $args[] = self::resolveParameter($c, $parameter); + } + return $args; + } + + private static function resolveParameter(Container $c, ReflectionParameter $parameter): mixed + { + $valueAttributes = $parameter->getAttributes(Value::class); + if ($valueAttributes !== []) { + /** @var Value $value */ + $value = $valueAttributes[0]->newInstance(); + return env($value->key, $value->default); + } + + $type = $parameter->getType(); + if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { + return $c->make($type->getName()); + } + + if ($parameter->isDefaultValueAvailable()) { + return $parameter->getDefaultValue(); + } + + throw new \RuntimeException(sprintf( + 'Cannot resolve bean parameter $%s of %s::%s() — add a type hint, a #[Value], or a default.', + $parameter->getName(), + $parameter->getDeclaringClass()?->getName() ?? '?', + $parameter->getDeclaringFunction()->getName(), + )); + } + + /** + * A bean's value is stored in the container, which injects properties into + * every resolved instance — so a bean must produce an object, never a scalar. + * Scalars belong in .env / {@see Value}. + */ + private static function assertObjectReturn(ReflectionMethod $method, string $class): void + { + $type = $method->getReturnType(); + if (!$type instanceof ReflectionNamedType || $type->isBuiltin()) { + throw new \RuntimeException(sprintf( + '#[Bean] %s::%s() must return a class/interface — beans hold objects; ' + . 'use .env / #[Value] for scalar configuration.', + $class, + $method->getName(), + )); + } + } + + private static function returnTypeOf(ReflectionMethod $method, string $class): string + { + $type = $method->getReturnType(); + if (!$type instanceof ReflectionNamedType || $type->isBuiltin()) { + throw new \RuntimeException(sprintf( + '#[Bean] %s::%s() must declare a class/interface return type (the binding key), ' + . 'or pass an explicit name: #[Bean(name: ...)].', + $class, + $method->getName(), + )); + } + return $type->getName(); + } +} diff --git a/src/WinterApplication.php b/src/WinterApplication.php new file mode 100644 index 0000000..8fefca2 --- /dev/null +++ b/src/WinterApplication.php @@ -0,0 +1,483 @@ +> */ + private static array $webConfigurers = []; + + /** Returns the concrete application class name set during boot. */ + public static function getAppClass(): string + { + return self::$appClass; + } + + // ── Hooks (override in your App class) ──────────────────────────────────── + + /** + * Declares the long-lived components this application is made of. + * + * Build each entry with a {@see Component} factory — never the constructor. + * + * @return list + */ + abstract protected static function components(): array; + + /** + * Initialise the kernel — paths, .env, logging, timezone. Runs before the + * scan (it decides where the scan looks), so it cannot be a discovered class. + * + * The default derives the project root from the App class's own file; override + * only for non-standard layouts. + */ + protected static function configure(ApplicationArguments $args): void + { + Kernel::init(pathRoot: static::rootPath()); + } + + // ── Entry ───────────────────────────────────────────────────────────────── + + /** + * The application's `main()` — the single front door. Typically the App class + * just forwards to {@see run()}: + * ``` + * public static function main(array $argv): never { static::run($argv); } + * ``` + * + * @param array $argv Raw $argv (script name in [0]). + */ + public static function main(array $argv = []): never + { + static::run($argv); + } + + /** + * Boots the application once, then dispatches: + * - `call run` / `call run dev` → bring the app up ({@see serve()}); + * - bare `call` or any other verb → the console dispatcher (bare → help). + * + * @param array $argv Raw $argv (script name in [0]). + */ + final public static function run(array $argv = []): never + { + $args = ApplicationArguments::parse($argv); + static::bootstrap($args); + + if ($args->command() === 'run') { + $watch = $args->sub() === 'dev' || $args->flag('w') || $args->has('watcher'); + static::serve($watch, $args); + } + + // Bare `call` and every other verb are console commands (bare → Help); they + // run after the same boot. + LoggerFactory::setDefaultChannel('sys'); + new Core($args->raw())->run(); + exit(0); + } + + // ── Boot ────────────────────────────────────────────────────────────────── + + /** + * The boot sequence: kernel init → one project scan (DI + beans + configurers) + * → apply discovered configuration (logging, CORS, imports). Every entry runs + * this first. + */ + protected static function bootstrap(ApplicationArguments $args): void + { + self::$appClass = static::class; + static::configure($args); + + $c = Container::init(); + self::$container = $c; + $debug = (bool) env('DEBUG', false); + + $async = new AsyncCollector( + $c, + ProxyFactory::forKernel($debug), + $debug ? null : Kernel::$pathStorageVolatile . '/async.php', + ); + $config = new ConfigurationCollector($c); + $webCollector = new ImplementorCollector(WebConfigurer::class); + $logCollector = new ImplementorCollector(LoggingConfigurer::class); + + Scanner::run( + rootDir: Kernel::$pathRoot, + cache: $debug ? null : Kernel::$pathStorageVolatile . '/di.php', + ) + ->collect(new DICollector($c)) + ->collect($config) + ->collect($async) + ->collect($webCollector) + ->collect($logCollector) + ->execute(); + + $async->flush(); + + // Default contextual logger: an injected LoggerInterface is named after the + // class it is injected into. + $c->contextual( + LoggerInterface::class, + static fn(Container $c, ?string $consumer) => LoggerFactory::getLogger($consumer ?? 'app'), + ); + + static::applyLogging($c, $logCollector->getResult()); + static::applyCors($c, $webCollector->getResult()); + static::applyImports(); + } + + /** + * @param list<\ReflectionClass> $configurers + */ + private static function applyLogging(Container $c, array $configurers): void + { + if ($configurers === []) { + return; + } + $registry = new ChannelRegistry(); + foreach ($configurers as $ref) { + /** @var LoggingConfigurer $configurer */ + $configurer = $c->make($ref->getName()); + $configurer->configureChannels($registry); + } + $registry->apply(); + } + + /** + * @param list<\ReflectionClass> $configurers + */ + private static function applyCors(Container $c, array $configurers): void + { + $registry = new CorsRegistry(); + $classes = []; + foreach ($configurers as $ref) { + $classes[] = $ref->getName(); + /** @var WebConfigurer $configurer */ + $configurer = $c->make($ref->getName()); + $configurer->configureCors($registry); + } + self::$webConfigurers = $classes; + + if ($registry->isTouched()) { + $registry->apply(); + } + } + + private static function applyImports(): void + { + foreach (new \ReflectionClass(static::class)->getAttributes(Import::class) as $attribute) { + /** @var Import $import */ + $import = $attribute->newInstance(); + Plugin::registry($import->package, $import->prefix, $import->required); + } + } + + // ── Serve ───────────────────────────────────────────────────────────────── + + /** + * Brings the application up and blocks until shutdown. With a + * {@see Component::http()} declared: one Swoole HTTP server plus every other + * component supervised via `addProcess`. Without it: headless. + * + * @param bool $watch Attach the DevWatcher (memory + hot-reload) — development. + */ + final public static function serve(bool $watch, ApplicationArguments $args): never + { + /** @var ?Component $http */ + $http = null; + /** @var list $sockets */ + $sockets = []; + /** @var list $companions */ + $companions = []; + + foreach (static::components() as $component) { + if (!$component instanceof Component) { + throw new ApplicationConfigException( + 'components() must return ' . Component::class + . ' instances; got ' . get_debug_type($component) . '.' + ); + } + match ($component->kind) { + ComponentKind::Http => $http = $component, + ComponentKind::WebSocket => $sockets[] = $component, + default => $companions[] = $component, + }; + } + + if ($sockets !== []) { + throw new ApplicationConfigException( + 'WebSocket components are not hosted by the bundled runtime yet — ' + . 'the port from the legacy engine is pending.' + ); + } + + $logger = LoggerFactory::getLogger(static::class); + + if ($http !== null) { + static::serveHttp($http, $companions, $watch, $args, $logger); + } + + static::serveHeadless($companions, $logger); + } + + /** + * Web bundle: the Http component becomes the Swoole server; every other + * component is attached with addProcess so the master supervises it. + * + * @param list $companions + */ + private static function serveHttp( + Component $http, + array $companions, + bool $watch, + ApplicationArguments $args, + LoggerInterface $logger, + ): never { + if (!extension_loaded('swoole')) { + throw new ApplicationConfigException( + '`call run` with a web tier needs ext-swoole (pecl install swoole).' + ); + } + + $host = $args->option('host', $http->host) ?? $http->host; + $port = $args->int('port', $http->port); + + $router = Router::fromScan(Kernel::$pathRoot); + $router->static(Kernel::$pathPublic); + + \Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL); + Runtime::boot(RuntimeMode::Swoole); + + $server = new \Swoole\Http\Server($host, $port); + $server->set(static::buildServerSettings()); + + $names = []; + foreach ($companions as $companion) { + $class = (string) $companion->class; + $names[] = self::shortName($class); + $server->addProcess(new \Swoole\Process( + static function () use ($class): void { + // Reset runtime hooks (flags = 0) so the child is a clean plain + // process, exactly like a standalone launch. + \Swoole\Runtime::enableCoroutine(0); + LoggerFactory::setContextStorage(new ProcessContext()); + LoggerFactory::setDefaultChannel('sys'); + ForkReset::runAll(); + $class::start(); + } + )); + } + + $handler = static function (\Swoole\Http\Request $req, \Swoole\Http\Response $res) use ($router): void { + $request = new SwooleRequest($req); + $isHead = strtoupper($request->getMethod()) === 'HEAD'; + $router->handle($request, new SwooleResponse($res, $isHead)); + }; + + // Request workers log on 'http' with per-request coroutine isolation. + $workerStart = static function (\Swoole\Http\Server $server, int $workerId): void { + LoggerFactory::setContextStorage(new CoroutineContext()); + LoggerFactory::setDefaultChannel('http'); + }; + + $dev = $watch ? new DevWatcher([Kernel::$pathRoot]) : null; + if ($dev !== null) { + $dev->attach($server, $workerStart); + $server->on('request', $dev->wrap($handler)); + } else { + $server->on('workerStart', $workerStart); + $server->on('request', $handler); + } + + $logger->info(sprintf( + 'Application up: http://%s:%d%s%s', + $host, + $port, + $names === [] ? '' : ' + [' . implode(', ', $names) . ']', + $watch ? ' (dev/watch)' : '' + )); + + $server->start(); + + if ($dev !== null && $dev->reloadRequested()) { + $dev->reexec(); + } + + exit(0); + } + + /** + * No web tier: run the background components directly — one in the foreground, + * several under a small pcntl supervisor that forwards the stop signal. + * + * @param list $companions + */ + private static function serveHeadless(array $companions, LoggerInterface $logger): never + { + if ($companions === []) { + throw new ApplicationConfigException( + 'Nothing to run: components() is empty. Declare at least one ' + . 'Component::http()/process()/daemon()/scheduler().' + ); + } + + if (count($companions) === 1) { + $class = (string) $companions[0]->class; + $logger->info('Application up (headless): ' . self::shortName($class)); + $class::start(); + exit(0); + } + + if (!function_exists('pcntl_fork')) { + throw new ApplicationConfigException( + 'Running several headless components needs ext-pcntl.' + ); + } + + $children = []; + foreach ($companions as $companion) { + $class = (string) $companion->class; + $pid = pcntl_fork(); + if ($pid === -1) { + throw new \RuntimeException("WinterApplication: fork failed for {$class}."); + } + if ($pid === 0) { + if (extension_loaded('swoole')) { + \Swoole\Runtime::enableCoroutine(0); + } + LoggerFactory::setContextStorage(new ProcessContext()); + LoggerFactory::setDefaultChannel('sys'); + ForkReset::runAll(); + $class::start(); + exit(0); + } + $children[$pid] = self::shortName($class); + } + + $forward = static function (int $signo) use (&$children): void { + foreach (array_keys($children) as $pid) { + @posix_kill($pid, $signo); + } + }; + pcntl_async_signals(true); + pcntl_signal(SIGTERM, $forward); + pcntl_signal(SIGINT, $forward); + + $logger->info('Application up (headless): [' . implode(', ', $children) . ']'); + + while ($children !== []) { + $pid = pcntl_waitpid(-1, $status); + if ($pid > 0) { + unset($children[$pid]); + } + } + + exit(0); + } + + // ── Internal ────────────────────────────────────────────────────────────── + + /** + * Base Swoole options from .env, tuned by every discovered {@see WebConfigurer}. + * + * @return array + */ + private static function buildServerSettings(): array + { + $settings = ServerSettings::fromEnv(); + $c = self::$container; + if ($c !== null) { + foreach (self::$webConfigurers as $class) { + /** @var WebConfigurer $configurer */ + $configurer = $c->make($class); + $configurer->configureServer($settings); + } + } + return $settings->toArray(); + } + + protected static function rootPath(): string + { + return dirname((string) new \ReflectionClass(static::class)->getFileName()); + } + + private static function shortName(string $class): string + { + return new \ReflectionClass($class)->getShortName(); + } +} diff --git a/tests/App/ApplicationArgumentsTest.php b/tests/App/ApplicationArgumentsTest.php new file mode 100644 index 0000000..216791c --- /dev/null +++ b/tests/App/ApplicationArgumentsTest.php @@ -0,0 +1,57 @@ +command()); + self::assertSame('dev', $args->sub()); + } + + public function test_bare_invocation_has_no_command(): void + { + $args = ApplicationArguments::parse(['call']); + self::assertNull($args->command()); + self::assertNull($args->sub()); + } + + public function test_long_options_with_and_without_value(): void + { + $args = ApplicationArguments::parse(['call', 'run', '--port=8080', '--watcher']); + self::assertSame('8080', $args->option('port')); + self::assertSame(8080, $args->int('port', 8000)); + self::assertTrue($args->has('watcher')); + self::assertNull($args->option('watcher')); // present, no value + self::assertSame('0.0.0.0', $args->option('host', '0.0.0.0')); + } + + public function test_int_falls_back_when_absent_or_non_numeric(): void + { + $args = ApplicationArguments::parse(['call', 'run', '--port=abc']); + self::assertSame(8000, $args->int('port', 8000)); + self::assertSame(9000, $args->int('missing', 9000)); + } + + public function test_short_flags_expand(): void + { + $args = ApplicationArguments::parse(['call', 'run', '-w']); + self::assertTrue($args->flag('w')); + self::assertFalse($args->flag('x')); + } + + public function test_raw_is_preserved_for_console_handoff(): void + { + $argv = ['call', 'make', '-c', 'UserController']; + $args = ApplicationArguments::parse($argv); + self::assertSame($argv, $args->raw()); + self::assertSame('make', $args->command()); + } +} diff --git a/tests/App/ConfigurationCollectorTest.php b/tests/App/ConfigurationCollectorTest.php new file mode 100644 index 0000000..630ced8 --- /dev/null +++ b/tests/App/ConfigurationCollectorTest.php @@ -0,0 +1,127 @@ +collect( + BeansFixtureConfig::class, + new ReflectionClass(BeansFixtureConfig::class), + ); + } + + public function test_singleton_bean_keyed_by_return_type(): void + { + $c = Container::init(); + $this->collect($c); + + $a = $c->make(CacheContract::class); + $b = $c->make(CacheContract::class); + + self::assertInstanceOf(RedisCacheFixture::class, $a); + self::assertSame($a, $b, 'a #[Bean] defaults to singleton scope'); + } + + public function test_transient_bean_returns_fresh_instances(): void + { + $c = Container::init(); + $this->collect($c); + + $q1 = $c->make(QueryBuilderFixture::class); + $q2 = $c->make(QueryBuilderFixture::class); + + self::assertNotSame($q1, $q2, 'Scope::Transient yields a new instance each resolve'); + } + + public function test_value_parameter_uses_default_when_env_unset(): void + { + unset($_ENV['FIXTURE_CACHE_URL']); + $c = Container::init(); + $this->collect($c); + + $cache = $c->make(CacheContract::class); + self::assertSame('default-url', $cache->url); + } + + public function test_value_parameter_reads_env(): void + { + $_ENV['FIXTURE_CACHE_URL'] = 'redis://from-env'; + try { + $c = Container::init(); + $this->collect($c); // re-register clears the cached singleton + $cache = $c->make(CacheContract::class); + self::assertSame('redis://from-env', $cache->url); + } finally { + unset($_ENV['FIXTURE_CACHE_URL']); + } + } + + public function test_explicit_name_binding(): void + { + $c = Container::init(); + $this->collect($c); + + $primary = $c->make('primary.cache'); + self::assertInstanceOf(RedisCacheFixture::class, $primary); + self::assertSame('primary', $primary->url); + } +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +interface CacheContract +{ +} + +final class RedisCacheFixture implements CacheContract +{ + public function __construct(public string $url) + { + } +} + +final class QueryBuilderFixture +{ + private static int $counter = 0; + public int $id; + + public function __construct() + { + $this->id = ++self::$counter; + } +} + +#[Configuration] +final class BeansFixtureConfig +{ + #[Bean] + public function cache(#[Value('FIXTURE_CACHE_URL', 'default-url')] string $url): CacheContract + { + return new RedisCacheFixture($url); + } + + #[Bean(scope: Scope::Transient)] + public function query(): QueryBuilderFixture + { + return new QueryBuilderFixture(); + } + + #[Bean(name: 'primary.cache')] + public function primaryCache(): RedisCacheFixture + { + return new RedisCacheFixture('primary'); + } +} From eaab540517ac6d1bfb14363482e3972dc69f127e Mon Sep 17 00:00:00 2001 From: flytachi Date: Tue, 28 Jul 2026 13:32:11 +0500 Subject: [PATCH 24/71] starter --- src/App/Attribute/EnableAsync.php | 26 ++++ src/App/Attribute/EnableDaemon.php | 29 +++++ src/App/Attribute/EnableProcess.php | 29 +++++ src/App/Attribute/EnableScheduler.php | 30 +++++ src/App/Attribute/EnableWeb.php | 25 ++++ src/App/Config/ServerSettings.php | 41 +++++- src/App/Config/WebConfigurer.php | 12 +- src/App/Config/WebConfigurerAdapter.php | 8 +- src/Application.php | 4 + src/BaseBoot.php | 4 + src/WinterApplication.php | 160 ++++++++++++++++-------- tests/App/EnableManifestTest.php | 119 ++++++++++++++++++ tests/App/ServerSettingsTest.php | 73 +++++++++++ 13 files changed, 495 insertions(+), 65 deletions(-) create mode 100644 src/App/Attribute/EnableAsync.php create mode 100644 src/App/Attribute/EnableDaemon.php create mode 100644 src/App/Attribute/EnableProcess.php create mode 100644 src/App/Attribute/EnableScheduler.php create mode 100644 src/App/Attribute/EnableWeb.php create mode 100644 tests/App/EnableManifestTest.php create mode 100644 tests/App/ServerSettingsTest.php diff --git a/src/App/Attribute/EnableAsync.php b/src/App/Attribute/EnableAsync.php new file mode 100644 index 0000000..b575ccb --- /dev/null +++ b/src/App/Attribute/EnableAsync.php @@ -0,0 +1,26 @@ + $class The Daemon class to supervise. + */ + public function __construct(public string $class) + { + } +} diff --git a/src/App/Attribute/EnableProcess.php b/src/App/Attribute/EnableProcess.php new file mode 100644 index 0000000..5503953 --- /dev/null +++ b/src/App/Attribute/EnableProcess.php @@ -0,0 +1,29 @@ + $class The Process class to run. + */ + public function __construct(public string $class) + { + } +} diff --git a/src/App/Attribute/EnableScheduler.php b/src/App/Attribute/EnableScheduler.php new file mode 100644 index 0000000..d5d48ba --- /dev/null +++ b/src/App/Attribute/EnableScheduler.php @@ -0,0 +1,30 @@ + $class Scheduler class (default: the built-in one). + */ + public function __construct(public string $class = Scheduler::class) + { + } +} diff --git a/src/App/Attribute/EnableWeb.php b/src/App/Attribute/EnableWeb.php new file mode 100644 index 0000000..6ebc552 --- /dev/null +++ b/src/App/Attribute/EnableWeb.php @@ -0,0 +1,25 @@ + $options */ - private function __construct(private array $options = []) - { + private function __construct( + private string $host, + private int $port, + private array $options = [], + ) { } /** - * Seeds base options from the environment. Only variables that are actually - * set contribute a key (so Swoole defaults apply otherwise). + * Seeds the bind address and base Swoole options. Host/port are passed in (the + * framework's default policy is `--host`/`--port`); tuning options come from the + * environment — only variables that are actually set contribute a key (so Swoole + * defaults apply otherwise). */ - public static function fromEnv(): self + public static function fromEnv(string $host = '0.0.0.0', int $port = 8000): self { $options = []; $map = [ @@ -42,7 +47,31 @@ public static function fromEnv(): self $options[$swooleKey] = (int) $raw; } } - return new self($options); + return new self($host, $port, $options); + } + + /** Bind host (e.g. '0.0.0.0', '127.0.0.1'). */ + public function host(string $host): self + { + $this->host = $host; + return $this; + } + + /** Bind port. */ + public function port(int $port): self + { + $this->port = $port; + return $this; + } + + public function getHost(): string + { + return $this->host; + } + + public function getPort(): int + { + return $this->port; } public function workers(int $count): self diff --git a/src/App/Config/WebConfigurer.php b/src/App/Config/WebConfigurer.php index 34f306f..68e6424 100644 --- a/src/App/Config/WebConfigurer.php +++ b/src/App/Config/WebConfigurer.php @@ -4,6 +4,8 @@ namespace Flytachi\Winter\K2\App\Config; +use Flytachi\Winter\K2\App\ApplicationArguments; + /** * Web-tier configuration contract — the winter analogue of Spring's * `WebMvcConfigurer`. Any class implementing it is discovered on scan and invoked @@ -11,7 +13,13 @@ * * It carries two concerns of the web tier: * - {@see configureCors()} — the global CORS policy (request-time); - * - {@see configureServer()} — Swoole server tuning (master, before workers fork). + * - {@see configureServer()} — the bind address (host/port) and Swoole server + * tuning (master, before workers fork). + * + * {@see configureServer()} receives the parsed CLI arguments so the coder decides + * where the bind address comes from — a `--port` flag, a custom flag, .env, or a + * literal. The handle is pre-seeded with the framework default (`--host`/`--port`, + * fallback `0.0.0.0:8000`), so leaving it untouched keeps that default. * * Implement this interface directly to handle both, or extend * {@see WebConfigurerAdapter} to override only the one you need. @@ -20,5 +28,5 @@ interface WebConfigurer { public function configureCors(CorsRegistry $cors): void; - public function configureServer(ServerSettings $server): void; + public function configureServer(ServerSettings $server, ApplicationArguments $args): void; } diff --git a/src/App/Config/WebConfigurerAdapter.php b/src/App/Config/WebConfigurerAdapter.php index 7659e6d..f58fb9e 100644 --- a/src/App/Config/WebConfigurerAdapter.php +++ b/src/App/Config/WebConfigurerAdapter.php @@ -4,6 +4,8 @@ namespace Flytachi\Winter\K2\App\Config; +use Flytachi\Winter\K2\App\ApplicationArguments; + /** * Empty-default base for {@see WebConfigurer} — the winter analogue of Spring's * `WebMvcConfigurerAdapter`. Extend it and override only the concern you care @@ -12,9 +14,9 @@ * ``` * final class WebConfig extends WebConfigurerAdapter * { - * public function configureCors(CorsRegistry $cors): void + * public function configureServer(ServerSettings $server, ApplicationArguments $args): void * { - * $cors->allowedOrigins('https://app.example.com')->allowCredentials(); + * $server->port($args->int('port', 8000))->workers(swoole_cpu_num() * 2); * } * } * ``` @@ -25,7 +27,7 @@ public function configureCors(CorsRegistry $cors): void { } - public function configureServer(ServerSettings $server): void + public function configureServer(ServerSettings $server, ApplicationArguments $args): void { } } diff --git a/src/Application.php b/src/Application.php index 6fdad33..ef62f83 100644 --- a/src/Application.php +++ b/src/Application.php @@ -22,6 +22,10 @@ /** * Single application entry point — the framework's answer to a Java `main()`. * + * @deprecated Use {@see WinterApplication} (declarative #[Enable*] manifest) instead. + * This legacy base — `Boot extends Application extends BaseBoot` with protected + * config hooks — stays only for the transition and will be removed. + * * Extend it once, declare what the application contains via {@see components()}, * then route the CLI through {@see run()} from a single file: * ``` diff --git a/src/BaseBoot.php b/src/BaseBoot.php index 2bdd1c2..ae577d6 100644 --- a/src/BaseBoot.php +++ b/src/BaseBoot.php @@ -22,6 +22,10 @@ /** * Application bootstrap base — Java Boot-style entry point. * + * @deprecated Use {@see WinterApplication} instead. This legacy base and its + * protected config hooks (providers/channels/httpCors/health/plugins/swooleConfig) + * stay only for the transition and will be removed. + * * Extend in bootstrap.php, override only the hooks you need, * then call one entry point from each runtime file. * diff --git a/src/WinterApplication.php b/src/WinterApplication.php index 8fefca2..8cf8ce5 100644 --- a/src/WinterApplication.php +++ b/src/WinterApplication.php @@ -11,6 +11,11 @@ use Flytachi\Winter\DI\Scanner; use Flytachi\Winter\K2\App\ApplicationArguments; use Flytachi\Winter\K2\App\ApplicationConfigException; +use Flytachi\Winter\K2\App\Attribute\EnableAsync; +use Flytachi\Winter\K2\App\Attribute\EnableDaemon; +use Flytachi\Winter\K2\App\Attribute\EnableProcess; +use Flytachi\Winter\K2\App\Attribute\EnableScheduler; +use Flytachi\Winter\K2\App\Attribute\EnableWeb; use Flytachi\Winter\K2\App\Attribute\Import; use Flytachi\Winter\K2\App\Component; use Flytachi\Winter\K2\App\ComponentKind; @@ -39,24 +44,20 @@ * built around scanner-discovered configuration (Spring-style), with no * "god bootstrap class". * - * Extend it once, declare what the application contains via {@see components()}, + * Extend it once, declare what the application contains with #[Enable*] attributes, * then route the CLI through {@see main()} from a single file: * ``` + * #[EnableWeb] + * #[EnableAsync] + * #[EnableScheduler] + * #[EnableProcess(SnmpProc::class)] + * #[EnableDaemon(Emails::class)] * #[Import('acme/auth-plugin', '/auth')] * final class App extends WinterApplication * { - * protected static function configure(ApplicationArguments $args): void + * public static function main(array $args): never * { - * Kernel::init(pathRoot: __DIR__); - * } - * - * protected static function components(): array - * { - * return [ - * Component::http(port: 8000), // web server (optional) - * Component::daemon(Emails::class), - * Component::scheduler(), - * ]; + * return self::run($args); * } * } * @@ -64,10 +65,15 @@ * App::main($argv); * ``` * + * The manifest is declarative: each #[Enable*] on the App class maps to one + * {@see Component} ({@see EnableWeb} → http, {@see EnableProcess}/{@see EnableDaemon} + * → workers, {@see EnableScheduler} → scheduler), except {@see EnableAsync}, which + * only toggles #[Async] proxying during boot. + * * Configuration is not a set of hook methods on this class; it lives in ordinary * classes the scanner finds: * - {@see App\Attribute\Configuration}/{@see App\Attribute\Bean} — DI factories; - * - {@see WebConfigurer} — CORS + Swoole server tuning; + * - {@see WebConfigurer} — CORS + Swoole server tuning (host/port); * - {@see LoggingConfigurer} — extra log channels; * - {@see Import} attributes — plugin packages. * @@ -90,15 +96,6 @@ public static function getAppClass(): string // ── Hooks (override in your App class) ──────────────────────────────────── - /** - * Declares the long-lived components this application is made of. - * - * Build each entry with a {@see Component} factory — never the constructor. - * - * @return list - */ - abstract protected static function components(): array; - /** * Initialise the kernel — paths, .env, logging, timezone. Runs before the * scan (it decides where the scan looks), so it cannot be a discovered class. @@ -122,7 +119,7 @@ protected static function configure(ApplicationArguments $args): void * * @param array $argv Raw $argv (script name in [0]). */ - public static function main(array $argv = []): never + public static function main(array $argv): never { static::run($argv); } @@ -167,27 +164,38 @@ protected static function bootstrap(ApplicationArguments $args): void self::$container = $c; $debug = (bool) env('DEBUG', false); - $async = new AsyncCollector( - $c, - ProxyFactory::forKernel($debug), - $debug ? null : Kernel::$pathStorageVolatile . '/async.php', - ); $config = new ConfigurationCollector($c); $webCollector = new ImplementorCollector(WebConfigurer::class); $logCollector = new ImplementorCollector(LoggingConfigurer::class); - Scanner::run( + // #[Async] proxying is opt-in, like Spring's @EnableAsync: the collector is + // created and wired only when #[EnableAsync] is present. Without it, classes + // carrying #[Async] are not proxied and their methods run synchronously. It + // must run after DICollector (which rebinds a class to itself), so it is + // collected last. + $async = static::hasAttribute(EnableAsync::class) + ? new AsyncCollector( + $c, + ProxyFactory::forKernel($debug), + $debug ? null : Kernel::$pathStorageVolatile . '/async.php', + ) + : null; + + $scan = Scanner::run( rootDir: Kernel::$pathRoot, cache: $debug ? null : Kernel::$pathStorageVolatile . '/di.php', ) ->collect(new DICollector($c)) ->collect($config) - ->collect($async) ->collect($webCollector) - ->collect($logCollector) - ->execute(); + ->collect($logCollector); + + if ($async !== null) { + $scan->collect($async); + } + $scan->execute(); - $async->flush(); + $async?->flush(); // Default contextual logger: an injected LoggerInterface is named after the // class it is injected into. @@ -258,6 +266,14 @@ private static function applyImports(): void */ final public static function serve(bool $watch, ApplicationArguments $args): never { + $components = static::resolveComponents(); + if ($components === []) { + throw new ApplicationConfigException( + 'No components declared on ' . static::class . ': add at least one ' + . '#[EnableWeb], #[EnableProcess], #[EnableDaemon] or #[EnableScheduler].' + ); + } + /** @var ?Component $http */ $http = null; /** @var list $sockets */ @@ -265,13 +281,7 @@ final public static function serve(bool $watch, ApplicationArguments $args): nev /** @var list $companions */ $companions = []; - foreach (static::components() as $component) { - if (!$component instanceof Component) { - throw new ApplicationConfigException( - 'components() must return ' . Component::class - . ' instances; got ' . get_debug_type($component) . '.' - ); - } + foreach ($components as $component) { match ($component->kind) { ComponentKind::Http => $http = $component, ComponentKind::WebSocket => $sockets[] = $component, @@ -281,15 +291,14 @@ final public static function serve(bool $watch, ApplicationArguments $args): nev if ($sockets !== []) { throw new ApplicationConfigException( - 'WebSocket components are not hosted by the bundled runtime yet — ' - . 'the port from the legacy engine is pending.' + 'WebSocket components are not hosted by the bundled runtime yet.' ); } $logger = LoggerFactory::getLogger(static::class); if ($http !== null) { - static::serveHttp($http, $companions, $watch, $args, $logger); + static::serveHttp($companions, $watch, $args, $logger); } static::serveHeadless($companions, $logger); @@ -302,7 +311,6 @@ final public static function serve(bool $watch, ApplicationArguments $args): nev * @param list $companions */ private static function serveHttp( - Component $http, array $companions, bool $watch, ApplicationArguments $args, @@ -314,8 +322,9 @@ private static function serveHttp( ); } - $host = $args->option('host', $http->host) ?? $http->host; - $port = $args->int('port', $http->port); + $settings = static::buildServerSettings($args); + $host = $settings->getHost(); + $port = $settings->getPort(); $router = Router::fromScan(Kernel::$pathRoot); $router->static(Kernel::$pathPublic); @@ -324,7 +333,7 @@ private static function serveHttp( Runtime::boot(RuntimeMode::Swoole); $server = new \Swoole\Http\Server($host, $port); - $server->set(static::buildServerSettings()); + $server->set($settings->toArray()); $names = []; foreach ($companions as $companion) { @@ -453,22 +462,65 @@ private static function serveHeadless(array $companions, LoggerInterface $logger // ── Internal ────────────────────────────────────────────────────────────── /** - * Base Swoole options from .env, tuned by every discovered {@see WebConfigurer}. - * - * @return array + * Builds the server settings: bind address + Swoole options. The default bind + * policy is `--host`/`--port` (fallback `0.0.0.0:8000`); base Swoole options come + * from .env; every discovered {@see WebConfigurer} may then tune both, re-deriving + * host/port from any source (env, a custom flag, a literal) via the handle. */ - private static function buildServerSettings(): array + private static function buildServerSettings(ApplicationArguments $args): ServerSettings { - $settings = ServerSettings::fromEnv(); + $settings = ServerSettings::fromEnv( + $args->option('host', '0.0.0.0') ?? '0.0.0.0', + $args->int('port', 8000), + ); $c = self::$container; if ($c !== null) { foreach (self::$webConfigurers as $class) { /** @var WebConfigurer $configurer */ $configurer = $c->make($class); - $configurer->configureServer($settings); + $configurer->configureServer($settings, $args); } } - return $settings->toArray(); + return $settings; + } + + /** + * Builds the component manifest from the App class's #[Enable*] attributes. + * Each attribute maps to one {@see Component}; {@see EnableAsync} is not here — + * it is a boot toggle read in {@see bootstrap()}, not a component. + * + * @return list + */ + private static function resolveComponents(): array + { + $ref = new \ReflectionClass(static::class); + $components = []; + + if ($ref->getAttributes(EnableWeb::class) !== []) { + $components[] = Component::http(); + } + $scheduler = $ref->getAttributes(EnableScheduler::class); + if ($scheduler !== []) { + $components[] = Component::scheduler($scheduler[0]->newInstance()->class); + } + foreach ($ref->getAttributes(EnableProcess::class) as $attribute) { + $components[] = Component::process($attribute->newInstance()->class); + } + foreach ($ref->getAttributes(EnableDaemon::class) as $attribute) { + $components[] = Component::daemon($attribute->newInstance()->class); + } + + return $components; + } + + /** + * True if the App class carries the given attribute. + * + * @param class-string $attribute + */ + private static function hasAttribute(string $attribute): bool + { + return new \ReflectionClass(static::class)->getAttributes($attribute) !== []; } protected static function rootPath(): string diff --git a/tests/App/EnableManifestTest.php b/tests/App/EnableManifestTest.php new file mode 100644 index 0000000..31e37a6 --- /dev/null +++ b/tests/App/EnableManifestTest.php @@ -0,0 +1,119 @@ + $app + * @return list + */ + private function resolve(string $app): array + { + return new ReflectionMethod($app, 'resolveComponents')->invoke(null); + } + + /** + * @param class-string $app + * @param class-string $attribute + */ + private function hasAttr(string $app, string $attribute): bool + { + return new ReflectionMethod($app, 'hasAttribute')->invoke(null, $attribute); + } + + public function test_full_manifest_maps_every_attribute_in_order(): void + { + $c = $this->resolve(FullApp::class); + + self::assertCount(5, $c); + self::assertSame(ComponentKind::Http, $c[0]->kind); + self::assertSame(ComponentKind::Scheduler, $c[1]->kind); + self::assertSame(Scheduler::class, $c[1]->class); + self::assertSame(ComponentKind::Process, $c[2]->kind); + self::assertSame(ComponentKind::Process, $c[3]->kind); + self::assertSame(ComponentKind::Daemon, $c[4]->kind); + } + + public function test_repeatable_process_preserves_declaration_order(): void + { + $c = $this->resolve(FullApp::class); + + self::assertSame('Main\\Proc\\A', $c[2]->class); + self::assertSame('Main\\Proc\\B', $c[3]->class); + self::assertSame('Main\\Daemon\\E', $c[4]->class); + } + + public function test_scheduler_uses_declared_class(): void + { + $c = $this->resolve(CustomSchedulerApp::class); + + self::assertCount(1, $c); + self::assertSame(ComponentKind::Scheduler, $c[0]->kind); + self::assertSame('Custom\\Sched', $c[0]->class); + } + + public function test_headless_app_has_no_http(): void + { + $c = $this->resolve(HeadlessApp::class); + + self::assertCount(1, $c); + self::assertSame(ComponentKind::Process, $c[0]->kind); + self::assertSame('Main\\Proc\\Only', $c[0]->class); + } + + public function test_empty_app_yields_empty_manifest(): void + { + self::assertSame([], $this->resolve(EmptyApp::class)); + } + + public function test_enable_async_is_detected(): void + { + self::assertTrue($this->hasAttr(FullApp::class, EnableAsync::class)); + self::assertFalse($this->hasAttr(HeadlessApp::class, EnableAsync::class)); + } +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +#[EnableWeb] +#[EnableAsync] +#[EnableScheduler] +#[EnableProcess('Main\\Proc\\A')] +#[EnableProcess('Main\\Proc\\B')] +#[EnableDaemon('Main\\Daemon\\E')] +final class FullApp extends WinterApplication +{ +} + +#[EnableScheduler('Custom\\Sched')] +final class CustomSchedulerApp extends WinterApplication +{ +} + +#[EnableProcess('Main\\Proc\\Only')] +final class HeadlessApp extends WinterApplication +{ +} + +final class EmptyApp extends WinterApplication +{ +} diff --git a/tests/App/ServerSettingsTest.php b/tests/App/ServerSettingsTest.php new file mode 100644 index 0000000..940ef6a --- /dev/null +++ b/tests/App/ServerSettingsTest.php @@ -0,0 +1,73 @@ +getHost()); + self::assertSame(8000, $s->getPort()); + } + + public function test_seeded_bind_address(): void + { + $s = ServerSettings::fromEnv('127.0.0.1', 9000); + + self::assertSame('127.0.0.1', $s->getHost()); + self::assertSame(9000, $s->getPort()); + } + + public function test_setters_override_seed(): void + { + $s = ServerSettings::fromEnv('0.0.0.0', 8000)->host('10.0.0.1')->port(1234); + + self::assertSame('10.0.0.1', $s->getHost()); + self::assertSame(1234, $s->getPort()); + } + + public function test_host_and_port_are_not_swoole_options(): void + { + $s = ServerSettings::fromEnv('1.2.3.4', 80); + $options = $s->toArray(); + + self::assertArrayNotHasKey('host', $options); + self::assertArrayNotHasKey('port', $options); + } + + public function test_env_seeds_tuning_options(): void + { + $_ENV['SERVER_WORKERS'] = '4'; + try { + $s = ServerSettings::fromEnv(); + self::assertSame(4, $s->toArray()['worker_num']); + } finally { + unset($_ENV['SERVER_WORKERS']); + } + } + + public function test_fluent_options(): void + { + $s = ServerSettings::fromEnv() + ->workers(8) + ->maxRequest(5000) + ->set('ssl_cert_file', '/etc/ssl/app.pem'); + $options = $s->toArray(); + + self::assertSame(8, $options['worker_num']); + self::assertSame(5000, $options['max_request']); + self::assertSame('/etc/ssl/app.pem', $options['ssl_cert_file']); + } +} From ec523fc22f86eeeab1b09716d7fdc7267c80b895 Mon Sep 17 00:00:00 2001 From: flytachi Date: Tue, 28 Jul 2026 14:07:20 +0500 Subject: [PATCH 25/71] starter --- console/Command/Di.php | 60 ++++++++++++---- console/Command/Run.php | 22 +++--- src/App/Banner.php | 145 ++++++++++++++++++++++++++++++++++++++ src/WinterApplication.php | 67 ++++++++++++++++-- 4 files changed, 268 insertions(+), 26 deletions(-) create mode 100644 src/App/Banner.php diff --git a/console/Command/Di.php b/console/Command/Di.php index 3fd2f92..1cbaf2c 100644 --- a/console/Command/Di.php +++ b/console/Command/Di.php @@ -9,11 +9,13 @@ use Flytachi\Winter\DI\Collector\DICollector; use Flytachi\Winter\DI\Contract\CollectorInterface; use Flytachi\Winter\DI\Scanner; +use Flytachi\Winter\K2\App\Attribute\EnableAsync; use Flytachi\Winter\K2\Concurrent\Async\AsyncCollector; use Flytachi\Winter\K2\Concurrent\Async\Proxy\BypassScanner; use Flytachi\Winter\K2\Concurrent\Async\Proxy\ProxyFactory; use Flytachi\Winter\K2\Concurrent\Async\Proxy\ProxyGenerator; use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\K2\WinterApplication; use ReflectionClass; use ReflectionMethod; @@ -50,7 +52,9 @@ public function handle(): void } /** - * Cache file location — kept in sync with BaseBoot::boot(). + * Cache file location — kept in sync with WinterApplication::bootstrap(). The + * cache is the FQCN class list, written by the Scanner independently of any + * collector, so it is the same whichever collectors boot wires. */ private static function cachePath(): string { @@ -58,13 +62,27 @@ private static function cachePath(): string } /** - * List of classes carrying #[Async] — kept in sync with BaseBoot::boot(). + * List of classes carrying #[Async] — kept in sync with WinterApplication::bootstrap(). */ private static function asyncCachePath(): string { return Kernel::$pathStorageVolatile . '/async.php'; } + /** + * Whether #[Async] proxies should be built, mirroring the WinterApplication boot + * decision: enabled when the running app class carries #[EnableAsync]. bootstrap() + * has already run by the time this command dispatches, so the app class is known + * without a scan. A legacy Application/BaseBoot project sets no app class — there + * #[Async] proxying is always on. + */ + private static function asyncEnabled(): bool + { + $app = WinterApplication::getAppClass(); + + return $app === '' || new ReflectionClass($app)->getAttributes(EnableAsync::class) !== []; + } + /** * Removes a cache file and drops it from the opcode cache. */ @@ -144,26 +162,38 @@ private function showArg(string $pattern): void private function buildArg(): bool { $cachePath = self::cachePath(); - $factory = ProxyFactory::forKernel(refresh: true); // Force a rebuild: both caches short-circuit when their file exists. self::forget($cachePath); self::forget(self::asyncCachePath()); - $async = new AsyncCollector(Container::init(), $factory, self::asyncCachePath()); + // #[Async] proxying is opt-in (WinterApplication reads #[EnableAsync] at boot); + // build the proxies only when the app enables it, so `di build` mirrors boot. + $asyncEnabled = self::asyncEnabled(); + $container = Container::init(); + $factory = ProxyFactory::forKernel(refresh: true); + $async = $asyncEnabled + ? new AsyncCollector($container, $factory, self::asyncCachePath()) + : null; try { // Drop proxies of services that no longer exist or lost the attribute. - $factory->clear(); + if ($async !== null) { + $factory->clear(); + } - // Same call BaseBoot::boot() makes — populates a fresh Container and - // writes the FQCN list to $cachePath as a side effect. - Scanner::run(rootDir: Kernel::$pathRoot, cache: $cachePath) - ->collect(new DICollector(Container::getInstance())) - ->collect($async) - ->execute(); + // Same scan WinterApplication::bootstrap() makes — populates a fresh + // Container and writes the FQCN list to $cachePath as a side effect. The + // class list and the #[Async] proxies come from the same scan on purpose: + // two commands would leave a window where one is stale. + $scan = Scanner::run(rootDir: Kernel::$pathRoot, cache: $cachePath) + ->collect(new DICollector($container)); + if ($async !== null) { + $scan->collect($async); + } + $scan->execute(); - $async->flush(); + $async?->flush(); } catch (\Throwable $e) { // The class list is written before collectors run, so report what did survive. $this->reportCache($cachePath); @@ -180,6 +210,12 @@ private function buildArg(): bool return false; } + if ($async === null) { + self::printBadge("async proxies", 'DISABLED (no #[EnableAsync])', 34, 33); + + return true; + } + $proxied = $async->proxied(); if ($proxied === []) { self::printBadge("async proxies", 'NONE', 34, 33); diff --git a/console/Command/Run.php b/console/Command/Run.php index 02f5d67..8f00ef0 100644 --- a/console/Command/Run.php +++ b/console/Command/Run.php @@ -21,14 +21,18 @@ public function handle(): void || in_array('w', $this->args['flags'] ?? [], true) || isset($this->args['options']['watcher']); + // WinterApplication owns `run`: it serves from its own run() before the + // console dispatcher is ever reached, so this command handles only the legacy + // Application path and will be removed together with it. $bootClass = BaseBoot::getBootClass(); if ($bootClass === '' || !is_subclass_of($bootClass, Application::class)) { - self::printWarning("`call run` requires your Boot class to extend Application."); - self::printInfo("Change `extends BaseBoot` to `extends Application` and declare components()."); - self::printInfo("Docs: docs/starter/00-quickstart.md"); + self::printWarning("`call run` needs a WinterApplication entry class."); + self::printInfo("Extend WinterApplication and declare components with #[Enable*] attributes."); + self::printInfo("Docs: doc-new/winter-application.md"); return; } + self::printWarning("Legacy Application path (deprecated) — prefer WinterApplication + #[Enable*]."); self::printSuccess($watch ? "Starting application (dev / watch)" : "Starting application"); // serve() blocks until shutdown and exits the process itself. @@ -48,12 +52,12 @@ public static function help(): void self::printDivider($cl); self::printLabel("What runs", $cl); - self::print("Everything declared in your App::components():", $cl); - self::print(" Component::http() -> the Swoole HTTP server (main)", $cl); - self::print(" Component::process() -> a managed Process, attached via addProcess", $cl); - self::print(" Component::daemon() -> a supervised Daemon fleet", $cl); - self::print(" Component::scheduler() -> the #[Scheduled] scheduler", $cl); - self::print("With no Component::http() the app runs headless (background only).", $cl); + self::print("Everything declared on your WinterApplication via #[Enable*]:", $cl); + self::print(" #[EnableWeb] -> the Swoole HTTP server (main)", $cl); + self::print(" #[EnableProcess()] -> a managed Process, attached via addProcess", $cl); + self::print(" #[EnableDaemon()] -> a supervised Daemon fleet", $cl); + self::print(" #[EnableScheduler] -> the #[Scheduled] scheduler", $cl); + self::print("With no #[EnableWeb] the app runs headless (background only).", $cl); self::printLabel("What runs", $cl); self::printDivider($cl); diff --git a/src/App/Banner.php b/src/App/Banner.php new file mode 100644 index 0000000..871bb61 --- /dev/null +++ b/src/App/Banner.php @@ -0,0 +1,145 @@ + $rows Ordered label/value lines (web, daemon, …). + * @param float $elapsedMs Milliseconds from boot start to now. + */ + public static function print(array $rows, float $elapsedMs): void + { + echo self::render($rows, $elapsedMs); + } + + /** + * @param list $rows Ordered label/value lines. + * @param float $elapsedMs Milliseconds from boot start to now. + */ + public static function render(array $rows, float $elapsedMs): string + { + $last = count(self::MARK) - 1; + $out = "\n"; + foreach (self::MARK as $i => $line) { + $flake = ($i === 0 || $i === $last) ? '❄' : ' '; + $out .= ' ' . self::LABEL . $flake . self::RESET + . ' ' . self::MARK_COLOR . $line . self::RESET . "\n"; + } + $out .= "\n"; + + $out .= ' ' . self::META . ':: ' . self::RESET + . self::VALUE . 'winter-kernel' . self::RESET + . self::META . ' ::' . self::RESET + . ' ' . self::META . '(v' . self::version() . ')' . self::RESET + . ' ' . self::LABEL . implode(' · ', self::metaTail()) . self::RESET . "\n"; + + $out .= ' ' . self::LABEL . str_repeat('─', 53) . self::RESET . "\n"; + foreach ($rows as [$label, $value]) { + $out .= sprintf( + " %s%-11s%s %s%s%s\n", + self::LABEL, + $label, + self::RESET, + self::VALUE, + $value, + self::RESET, + ); + } + $out .= ' ' . self::LABEL . str_repeat('─', 53) . self::RESET . "\n"; + + $out .= ' ' . self::OK . '✔' . self::RESET + . ' ' . self::BOLD . 'Application up' . self::RESET + . self::META . ' in ' . self::elapsed($elapsedMs) . self::RESET . "\n\n"; + + return $out; + } + + /** + * True unless the banner is suppressed by `--no-banner`, `WINTER_BANNER=off`, or a + * non-interactive STDOUT (piped output, a service manager, a log file). + */ + public static function isEnabled(ApplicationArguments $args): bool + { + if ($args->has('no-banner')) { + return false; + } + + $flag = env('WINTER_BANNER'); + if ($flag !== null && in_array(strtolower((string) $flag), ['off', '0', 'false', 'no'], true)) { + return false; + } + + return defined('STDOUT') && stream_isatty(STDOUT); + } + + /** + * @return list Runtime / PHP / PID parts of the meta line. + */ + private static function metaTail(): array + { + $tail = []; + if (extension_loaded('swoole')) { + $tail[] = 'Swoole ' . (defined('SWOOLE_VERSION') ? SWOOLE_VERSION : (phpversion('swoole') ?: '?')); + } + $tail[] = 'PHP ' . PHP_VERSION; + $tail[] = 'PID ' . getmypid(); + + return $tail; + } + + private static function version(): string + { + try { + if (InstalledVersions::isInstalled(self::PKG)) { + return InstalledVersions::getPrettyVersion(self::PKG) ?? 'dev'; + } + } catch (\Throwable) { + // Fall through to 'dev' when Composer runtime metadata is unavailable. + } + + return 'dev'; + } + + private static function elapsed(float $ms): string + { + return $ms < 1000.0 + ? (int) round($ms) . ' ms' + : round($ms / 1000, 2) . ' s'; + } +} diff --git a/src/WinterApplication.php b/src/WinterApplication.php index 8cf8ce5..428e180 100644 --- a/src/WinterApplication.php +++ b/src/WinterApplication.php @@ -17,6 +17,7 @@ use Flytachi\Winter\K2\App\Attribute\EnableScheduler; use Flytachi\Winter\K2\App\Attribute\EnableWeb; use Flytachi\Winter\K2\App\Attribute\Import; +use Flytachi\Winter\K2\App\Banner; use Flytachi\Winter\K2\App\Component; use Flytachi\Winter\K2\App\ComponentKind; use Flytachi\Winter\K2\App\Config\ChannelRegistry; @@ -85,6 +86,8 @@ abstract class WinterApplication { private static string $appClass = ''; private static ?Container $container = null; + /** Monotonic boot start (hrtime ns), for the startup banner's "up in N ms". */ + private static int $bootStartedAt = 0; /** @var list> */ private static array $webConfigurers = []; @@ -133,6 +136,7 @@ public static function main(array $argv): never */ final public static function run(array $argv = []): never { + self::$bootStartedAt = hrtime(true); $args = ApplicationArguments::parse($argv); static::bootstrap($args); @@ -301,7 +305,7 @@ final public static function serve(bool $watch, ApplicationArguments $args): nev static::serveHttp($companions, $watch, $args, $logger); } - static::serveHeadless($companions, $logger); + static::serveHeadless($companions, $args, $logger); } /** @@ -373,6 +377,10 @@ static function () use ($class): void { $server->on('request', $handler); } + if (Banner::isEnabled($args)) { + Banner::print(static::bannerRows($companions, $host, $port), self::elapsedMs()); + } + $logger->info(sprintf( 'Application up: http://%s:%d%s%s', $host, @@ -396,15 +404,22 @@ static function () use ($class): void { * * @param list $companions */ - private static function serveHeadless(array $companions, LoggerInterface $logger): never - { + private static function serveHeadless( + array $companions, + ApplicationArguments $args, + LoggerInterface $logger, + ): never { if ($companions === []) { throw new ApplicationConfigException( - 'Nothing to run: components() is empty. Declare at least one ' - . 'Component::http()/process()/daemon()/scheduler().' + 'Nothing to run: no components declared. Add at least one ' + . '#[EnableWeb]/#[EnableProcess]/#[EnableDaemon]/#[EnableScheduler].' ); } + if (Banner::isEnabled($args)) { + Banner::print(static::bannerRows($companions, null, null), self::elapsedMs()); + } + if (count($companions) === 1) { $class = (string) $companions[0]->class; $logger->info('Application up (headless): ' . self::shortName($class)); @@ -523,6 +538,48 @@ private static function hasAttribute(string $attribute): bool return new \ReflectionClass(static::class)->getAttributes($attribute) !== []; } + /** + * Builds the startup-banner rows from the live manifest: the web endpoint (when + * hosting one), each companion, and the async toggle. Only what is actually + * running appears. + * + * @param list $companions + * @return list + */ + private static function bannerRows(array $companions, ?string $host, ?int $port): array + { + $rows = []; + if ($host !== null) { + $rows[] = ['web', sprintf('http://%s:%d', $host, $port)]; + } + foreach ($companions as $companion) { + $rows[] = $companion->kind === ComponentKind::Scheduler + ? ['scheduler', 'enabled'] + : [self::componentLabel($companion->kind), self::shortName((string) $companion->class)]; + } + if (static::hasAttribute(EnableAsync::class)) { + $rows[] = ['async', 'enabled']; + } + + return $rows; + } + + private static function componentLabel(ComponentKind $kind): string + { + return match ($kind) { + ComponentKind::Daemon => 'daemon', + default => 'process', + }; + } + + /** Milliseconds from boot start to now (0.0 before {@see run()} sets the mark). */ + private static function elapsedMs(): float + { + return self::$bootStartedAt > 0 + ? (hrtime(true) - self::$bootStartedAt) / 1e6 + : 0.0; + } + protected static function rootPath(): string { return dirname((string) new \ReflectionClass(static::class)->getFileName()); From 6f03f548b22219fd05753ee258de7ba0e5bbc9bb Mon Sep 17 00:00:00 2001 From: flytachi Date: Tue, 28 Jul 2026 15:39:20 +0500 Subject: [PATCH 26/71] starter --- src/App/Attribute/EnableActuator.php | 38 ++++++ src/Http/Health/Health.php | 21 ++++ src/Http/Health/HealthContributor.php | 40 ++++++ src/Http/Health/HealthIndicator.php | 28 ++++- src/Http/Health/HealthStatus.php | 61 +++++++++ src/Http/Health/Status.php | 17 +++ src/WinterApplication.php | 33 ++++- tests/Http/ActuatorTest.php | 172 ++++++++++++++++++++++++++ 8 files changed, 405 insertions(+), 5 deletions(-) create mode 100644 src/App/Attribute/EnableActuator.php create mode 100644 src/Http/Health/HealthContributor.php create mode 100644 src/Http/Health/HealthStatus.php create mode 100644 src/Http/Health/Status.php create mode 100644 tests/Http/ActuatorTest.php diff --git a/src/App/Attribute/EnableActuator.php b/src/App/Attribute/EnableActuator.php new file mode 100644 index 0000000..254a2f2 --- /dev/null +++ b/src/App/Attribute/EnableActuator.php @@ -0,0 +1,38 @@ +|null $middleware Optional guard middleware. + * @param class-string|null $indicator Full report override + * (defaults to the built-in indicator). + */ + public function __construct( + public ?string $middleware = null, + public ?string $indicator = null, + ) { + } +} diff --git a/src/Http/Health/Health.php b/src/Http/Health/Health.php index bf27ac8..096001c 100644 --- a/src/Http/Health/Health.php +++ b/src/Http/Health/Health.php @@ -11,6 +11,8 @@ final class Health private static ?array $config = null; private static string $rootDir = ''; private static array $mappings = []; + /** @var list> */ + private static array $contributors = []; private function __construct() { @@ -54,6 +56,25 @@ public static function getMappings(): array return self::$mappings; } + /** + * The discovered {@see HealthContributor} classes, merged into `/actuator/health` + * by the aggregator. Resolved from the container per request. + * + * @param list> $contributors + */ + public static function setContributors(array $contributors): void + { + self::$contributors = $contributors; + } + + /** + * @return list> + */ + public static function getContributors(): array + { + return self::$contributors; + } + // ── System info helpers (used by HealthIndicator) ───────────────────────── public static function cpu(): array diff --git a/src/Http/Health/HealthContributor.php b/src/Http/Health/HealthContributor.php new file mode 100644 index 0000000..260e060 --- /dev/null +++ b/src/Http/Health/HealthContributor.php @@ -0,0 +1,40 @@ +db->ping() + * ? HealthStatus::up()->withDetail('latency_ms', $this->db->latency()) + * : HealthStatus::down()->withDetail('reason', 'connection failed'); + * } + * } + * ``` + */ +interface HealthContributor +{ + /** The component key this check appears under in the report (e.g. 'db'). */ + public function name(): string; + + /** Runs the check; called live on every `/actuator/health` request. */ + public function check(): HealthStatus; +} diff --git a/src/Http/Health/HealthIndicator.php b/src/Http/Health/HealthIndicator.php index 9e2b787..0301344 100644 --- a/src/Http/Health/HealthIndicator.php +++ b/src/Http/Health/HealthIndicator.php @@ -6,6 +6,7 @@ use Composer\InstalledVersions; use Flytachi\Winter\Base\Runtime; +use Flytachi\Winter\DI\Container; use Flytachi\Winter\DI\Scanner; use Flytachi\Winter\K2\Collector\ImplementorCollector; use Flytachi\Winter\K2\Http\Header; @@ -24,9 +25,14 @@ public function health(): array 'cache' => $this->cacheHealth($rootDir), 'disk' => $this->diskHealth(), 'memory' => $this->memoryHealth(), - 'custom' => $this->customHealth(), ]; + // Merge every discovered HealthContributor, keyed by its name(). A contributor + // may override a built-in component by reusing its key. + foreach ($this->contributors() as $name => $status) { + $components[$name] = $status; + } + $statuses = array_column($components, 'status'); $overall = 'up'; @@ -296,10 +302,24 @@ private function thresholdStatus(float $percent, string $label): array return ['up', null]; } - // ── Override to add custom health checks ────────────────────────────────── + // ── Custom health checks (discovered HealthContributor implementations) ──── - protected function customHealth(): array + /** + * Resolves every registered {@see HealthContributor} from the container and runs + * it live. Keyed by {@see HealthContributor::name()}. + * + * @return array}> + */ + private function contributors(): array { - return ['status' => 'up', 'details' => []]; + $container = Container::getInstance(); + $out = []; + foreach (Health::getContributors() as $class) { + /** @var HealthContributor $contributor */ + $contributor = $container->make($class); + $out[$contributor->name()] = $contributor->check()->toArray(); + } + + return $out; } } diff --git a/src/Http/Health/HealthStatus.php b/src/Http/Health/HealthStatus.php new file mode 100644 index 0000000..6082883 --- /dev/null +++ b/src/Http/Health/HealthStatus.php @@ -0,0 +1,61 @@ +db->ping() + * ? HealthStatus::up()->withDetail('latency_ms', $this->db->latency()) + * : HealthStatus::down()->withDetail('reason', 'connection failed'); + * ``` + */ +final class HealthStatus +{ + /** @var array */ + private array $details = []; + + private function __construct(private readonly Status $status) + { + } + + public static function up(): self + { + return new self(Status::Up); + } + + public static function degraded(): self + { + return new self(Status::Degraded); + } + + public static function down(): self + { + return new self(Status::Down); + } + + public function withDetail(string $key, mixed $value): self + { + $this->details[$key] = $value; + return $this; + } + + public function status(): Status + { + return $this->status; + } + + /** + * The wire shape merged into the actuator report. + * + * @return array{status: string, details: array} + */ + public function toArray(): array + { + return ['status' => $this->status->value, 'details' => $this->details]; + } +} diff --git a/src/Http/Health/Status.php b/src/Http/Health/Status.php new file mode 100644 index 0000000..f2eb2e0 --- /dev/null +++ b/src/Http/Health/Status.php @@ -0,0 +1,17 @@ +collect(new DICollector($c)) ->collect($config) ->collect($webCollector) - ->collect($logCollector); + ->collect($logCollector) + ->collect($actuatorCollector); if ($async !== null) { $scan->collect($async); @@ -210,6 +216,7 @@ protected static function bootstrap(ApplicationArguments $args): void static::applyLogging($c, $logCollector->getResult()); static::applyCors($c, $webCollector->getResult()); + static::applyActuator($actuatorCollector->getResult()); static::applyImports(); } @@ -259,6 +266,30 @@ private static function applyImports(): void } } + /** + * Enables the actuator when the App class carries {@see EnableActuator}: registers + * the discovered {@see HealthContributor} classes and hands the indicator + + * optional guard middleware to {@see Health}, which {@see Router::fromScan()} then + * wires into the `/actuator/*` routes. No attribute → actuator stays off. + * + * @param list<\ReflectionClass> $contributors + */ + private static function applyActuator(array $contributors): void + { + $attributes = new \ReflectionClass(static::class)->getAttributes(EnableActuator::class); + if ($attributes === []) { + return; + } + + /** @var EnableActuator $actuator */ + $actuator = $attributes[0]->newInstance(); + Health::setContributors(array_map( + static fn(\ReflectionClass $ref): string => $ref->getName(), + $contributors, + )); + Health::configure($actuator->indicator ?? HealthIndicator::class, $actuator->middleware); + } + // ── Serve ───────────────────────────────────────────────────────────────── /** diff --git a/tests/Http/ActuatorTest.php b/tests/Http/ActuatorTest.php new file mode 100644 index 0000000..a07a5cf --- /dev/null +++ b/tests/Http/ActuatorTest.php @@ -0,0 +1,172 @@ +setValue(null, null); + new ReflectionProperty(Health::class, 'contributors')->setValue(null, []); + Health::setRootDir(''); + Container::init(); + } + + // ── HealthStatus value object ───────────────────────────────────────────── + + public function test_status_factories(): void + { + self::assertSame(Status::Up, HealthStatus::up()->status()); + self::assertSame(Status::Degraded, HealthStatus::degraded()->status()); + self::assertSame(Status::Down, HealthStatus::down()->status()); + } + + public function test_to_array_carries_status_and_details(): void + { + self::assertSame( + ['status' => 'up', 'details' => ['latency_ms' => 3]], + HealthStatus::up()->withDetail('latency_ms', 3)->toArray(), + ); + self::assertSame( + ['status' => 'down', 'details' => []], + HealthStatus::down()->toArray(), + ); + } + + // ── Contributor discovery / aggregation ─────────────────────────────────── + + public function test_contributor_is_merged_under_its_name(): void + { + Health::setContributors([UpContributor::class]); + + $components = (new HealthIndicator())->health()['components']; + + self::assertSame(['status' => 'up', 'details' => ['x' => 1]], $components['up-check']); + } + + public function test_down_contributor_forces_overall_down(): void + { + Health::setContributors([DownContributor::class]); + + $report = (new HealthIndicator())->health(); + + self::assertSame('down', $report['components']['down-check']['status']); + self::assertSame('down', $report['status']); + } + + public function test_contributor_may_override_a_builtin_component(): void + { + Health::setContributors([DbOverrideContributor::class]); + + $components = (new HealthIndicator())->health()['components']; + + self::assertSame(['status' => 'up', 'details' => ['driver' => 'fake']], $components['db']); + } + + public function test_no_contributors_keeps_builtin_components_only(): void + { + $components = (new HealthIndicator())->health()['components']; + + self::assertArrayHasKey('disk', $components); + self::assertArrayNotHasKey('up-check', $components); + } + + // ── #[EnableActuator] wiring ────────────────────────────────────────────── + + public function test_no_attribute_leaves_actuator_off(): void + { + new ReflectionMethod(PlainActuatorApp::class, 'applyActuator')->invoke(null, []); + + self::assertNull(Health::getConfig()); + } + + public function test_attribute_enables_actuator_with_guard_and_contributors(): void + { + new ReflectionMethod(GuardedActuatorApp::class, 'applyActuator') + ->invoke(null, [new ReflectionClass(UpContributor::class)]); + + self::assertSame( + ['indicator' => HealthIndicator::class, 'middleware' => 'Acme\\Guard'], + Health::getConfig(), + ); + self::assertSame([UpContributor::class], Health::getContributors()); + } +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +final class UpContributor implements HealthContributor +{ + public function name(): string + { + return 'up-check'; + } + + public function check(): HealthStatus + { + return HealthStatus::up()->withDetail('x', 1); + } +} + +final class DownContributor implements HealthContributor +{ + public function name(): string + { + return 'down-check'; + } + + public function check(): HealthStatus + { + return HealthStatus::down(); + } +} + +final class DbOverrideContributor implements HealthContributor +{ + public function name(): string + { + return 'db'; + } + + public function check(): HealthStatus + { + return HealthStatus::up()->withDetail('driver', 'fake'); + } +} + +final class PlainActuatorApp extends WinterApplication +{ + public static function main(array $a): never + { + exit(0); + } +} + +#[EnableActuator(middleware: 'Acme\\Guard')] +final class GuardedActuatorApp extends WinterApplication +{ + public static function main(array $a): never + { + exit(0); + } +} From f2bcea0e50851ec2bb5a59d402d142dc88a5e441 Mon Sep 17 00:00:00 2001 From: flytachi Date: Tue, 28 Jul 2026 16:40:57 +0500 Subject: [PATCH 27/71] starter --- console/Command/Di.php | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/console/Command/Di.php b/console/Command/Di.php index 1cbaf2c..4053173 100644 --- a/console/Command/Di.php +++ b/console/Command/Di.php @@ -169,16 +169,18 @@ private function buildArg(): bool // #[Async] proxying is opt-in (WinterApplication reads #[EnableAsync] at boot); // build the proxies only when the app enables it, so `di build` mirrors boot. - $asyncEnabled = self::asyncEnabled(); - $container = Container::init(); - $factory = ProxyFactory::forKernel(refresh: true); - $async = $asyncEnabled - ? new AsyncCollector($container, $factory, self::asyncCachePath()) - : null; + // When async is off nothing about the proxy factory is touched. + $container = Container::init(); + $factory = null; + $async = null; + if (self::asyncEnabled()) { + $factory = ProxyFactory::forKernel(refresh: true); + $async = new AsyncCollector($container, $factory, self::asyncCachePath()); + } try { // Drop proxies of services that no longer exist or lost the attribute. - if ($async !== null) { + if ($factory !== null) { $factory->clear(); } @@ -229,7 +231,9 @@ private function buildArg(): bool 34, 32 ); - self::printInfo($factory->directory()); + if ($factory !== null) { + self::printInfo($factory->directory()); + } $this->reportBypasses(array_keys($proxied)); From bfc3f3ed84fed02f26fe8ab55fc7de6c1e167586 Mon Sep 17 00:00:00 2001 From: flytachi Date: Tue, 28 Jul 2026 18:37:04 +0500 Subject: [PATCH 28/71] starter --- console/Command/Complete.php | 57 -- console/Command/Di.php | 2 +- console/Command/Make.php | 70 +-- console/Command/Run.php | 32 +- console/Command/Thread.php | 422 --------------- console/Core.php | 1 - console/Template/Build/phpstormMeta | 6 +- console/Template/Make/DaemonTemplate | 30 +- console/Template/Make/JobTemplate | 13 - console/Template/Make/ProcessTemplate | 26 +- console/Template/Make/WebSocketTemplate | 22 - dev/bootstrap.php | 3 +- docs/configuration/02-logging.md | 8 +- docs/starter.md | 4 +- src/App/Component.php | 2 +- src/App/ComponentKind.php | 2 +- src/Application.php | 318 ----------- src/BaseBoot.php | 508 ------------------ src/Core/ClassScanner.php | 2 +- src/Http/Response/ExceptionWrapper.php | 3 +- src/Kernel.php | 14 +- src/Old/Process/Core/DaemonStore.php | 28 - src/Old/Process/Core/Dispatch.php | 84 --- src/Old/Process/Core/DispatchStore.php | 25 - src/Old/Process/Core/Dispatchable.php | 14 - src/Old/Process/Core/WinterRunner.php | 81 --- src/Old/Process/DaemonException.php | 15 - src/Old/Process/Entity/TCondition.php | 15 - src/Old/Process/Entity/TDInfo.php | 14 - src/Old/Process/Entity/TDStatus.php | 23 - src/Old/Process/Entity/TInfo.php | 14 - src/Old/Process/Entity/TStats.php | 54 -- src/Old/Process/Entity/TStatus.php | 21 - .../Process/Socket/Web/PDU/DecodedFrame.php | 14 - src/Old/Process/Socket/Web/PDU/Msg.php | 23 - src/Old/Process/Socket/Web/PDU/WSResource.php | 53 -- .../Socket/Web/SocketWebServerHandler.php | 42 -- .../Process/Socket/Web/ThreadWebSocket.php | 227 -------- .../Process/Socket/Web/WebSocketProtocol.php | 195 ------- src/Old/Process/ThreadDaemon.php | 131 ----- src/Old/Process/ThreadJob.php | 32 -- src/Old/Process/ThreadProcess.php | 34 -- src/Old/Process/Traits/ThreadDaemonFork.php | 190 ------- .../Process/Traits/ThreadDaemonHandler.php | 86 --- .../Process/Traits/ThreadDaemonStatement.php | 139 ----- src/Old/Process/Traits/ThreadFork.php | 188 ------- src/Old/Process/Traits/ThreadJobHandler.php | 44 -- .../Process/Traits/ThreadProcessHandler.php | 83 --- .../Process/Traits/ThreadSignalHandler.php | 28 - src/Process/ProcessStore.php | 5 +- src/Route/DevWatcher.php | 2 +- src/Stereotype/Daemon.php | 11 - src/Stereotype/Job.php | 11 - src/Stereotype/Process.php | 11 - src/Stereotype/WebSocket.php | 11 - 55 files changed, 60 insertions(+), 3433 deletions(-) delete mode 100644 console/Command/Thread.php delete mode 100644 console/Template/Make/JobTemplate delete mode 100644 console/Template/Make/WebSocketTemplate delete mode 100644 src/Application.php delete mode 100644 src/BaseBoot.php delete mode 100644 src/Old/Process/Core/DaemonStore.php delete mode 100644 src/Old/Process/Core/Dispatch.php delete mode 100644 src/Old/Process/Core/DispatchStore.php delete mode 100644 src/Old/Process/Core/Dispatchable.php delete mode 100644 src/Old/Process/Core/WinterRunner.php delete mode 100644 src/Old/Process/DaemonException.php delete mode 100644 src/Old/Process/Entity/TCondition.php delete mode 100644 src/Old/Process/Entity/TDInfo.php delete mode 100644 src/Old/Process/Entity/TDStatus.php delete mode 100644 src/Old/Process/Entity/TInfo.php delete mode 100644 src/Old/Process/Entity/TStats.php delete mode 100644 src/Old/Process/Entity/TStatus.php delete mode 100644 src/Old/Process/Socket/Web/PDU/DecodedFrame.php delete mode 100644 src/Old/Process/Socket/Web/PDU/Msg.php delete mode 100644 src/Old/Process/Socket/Web/PDU/WSResource.php delete mode 100644 src/Old/Process/Socket/Web/SocketWebServerHandler.php delete mode 100644 src/Old/Process/Socket/Web/ThreadWebSocket.php delete mode 100644 src/Old/Process/Socket/Web/WebSocketProtocol.php delete mode 100644 src/Old/Process/ThreadDaemon.php delete mode 100644 src/Old/Process/ThreadJob.php delete mode 100644 src/Old/Process/ThreadProcess.php delete mode 100644 src/Old/Process/Traits/ThreadDaemonFork.php delete mode 100644 src/Old/Process/Traits/ThreadDaemonHandler.php delete mode 100644 src/Old/Process/Traits/ThreadDaemonStatement.php delete mode 100644 src/Old/Process/Traits/ThreadFork.php delete mode 100644 src/Old/Process/Traits/ThreadJobHandler.php delete mode 100644 src/Old/Process/Traits/ThreadProcessHandler.php delete mode 100644 src/Old/Process/Traits/ThreadSignalHandler.php delete mode 100644 src/Stereotype/Daemon.php delete mode 100644 src/Stereotype/Job.php delete mode 100644 src/Stereotype/Process.php delete mode 100644 src/Stereotype/WebSocket.php diff --git a/console/Command/Complete.php b/console/Command/Complete.php index 5a2eeb2..1953655 100644 --- a/console/Command/Complete.php +++ b/console/Command/Complete.php @@ -7,13 +7,10 @@ use Flytachi\Winter\Console\Core; use Flytachi\Winter\Console\Inc\Cmd; use Flytachi\Winter\Console\Inc\CmdCustom; -use Flytachi\Winter\K2\Collector\ImplementorCollector; use Flytachi\Winter\K2\Collector\SubclassCollector; use Flytachi\Winter\K2\Core\ClassScanner; use Flytachi\Winter\K2\Process\Daemon\Daemon as DaemonUnit; use Flytachi\Winter\K2\Process\Process as ProcessUnit; -use Flytachi\Winter\K2\Old\Process\Core\Dispatchable; -use Flytachi\Winter\K2\Old\Process\ThreadDaemon; class Complete extends Cmd { @@ -34,10 +31,8 @@ class Complete extends Cmd '-d:Dto — Data Transfer Object', '-q:Request — validated request object', '-p:Response — custom HTTP response', - '-J:Job — async queue job', '-P:Process — long-running process', '-N:Daemon — background daemon', - '-W:WebSocket — WebSocket handler', '-D:DbConfig — database configuration', '-R:RedisConfig — Redis configuration', '-n:Cmd — custom console command', @@ -99,12 +94,6 @@ class Complete extends Cmd 'storage init' => ['-s:storage', '-c:storage/cache', '-l:storage/logs'], 'storage clean' => ['-s:storage', '-c:storage/cache', '-l:storage/logs'], - // --- thread / th --- - 'thread' => [ - 'list:list all Dispatchable classes', - 'daemons:list daemons with live status', - ], - // --- process / proc --- 'process' => [ 'list:list all processes with live state', @@ -198,30 +187,6 @@ private function suggest(?string $cmd, ?string $sub, ?string $act, string $curre $base = array_merge($base, $this->getScriptClasses()); } - // thread: list + classes at top level; once a class is selected, - // suggestions depend on the action level: - // no action yet → lifecycle commands + -d (no-command/toggle flag) - // after `status` → -v (detailed status flag) - if ($resolved === 'thread' && $sub !== null && !in_array($sub, ['list', 'daemons'], true)) { - $isDaemon = in_array($sub, array_map('strtolower', $this->getDaemonClasses())); - if ($act === null) { - $base = $isDaemon - ? [ - 'start:start daemon in background', - 'stop:stop running daemon', - 'status:show daemon status (-v for detail)', - '-d:toggle start in background', - ] - : ['-d:dispatch as background process']; - } elseif ($isDaemon && $act === 'status') { - $base = ['-v:detailed status (resources + forks)']; - } else { - $base = []; - } - } elseif ($resolved === 'thread' && $sub === null) { - $base = array_merge($this->getDispatchableClasses(), $base); - } - // process: list + classes at top level; once a class is selected, // suggest lifecycle actions, then flags per action. if ($resolved === 'process' && $sub !== null && $sub !== 'list') { @@ -313,28 +278,6 @@ private function getScriptClasses(): array ); } - private function getDispatchableClasses(): array - { - $collector = new ImplementorCollector(Dispatchable::class); - ClassScanner::scan($collector); - - return array_map( - fn(\ReflectionClass $ref) => str_replace('\\', '.', $ref->getName()), - $collector->getResult() - ); - } - - private function getDaemonClasses(): array - { - $collector = new SubclassCollector(ThreadDaemon::class); - ClassScanner::scan($collector); - - return array_map( - fn(\ReflectionClass $ref) => str_replace('\\', '.', $ref->getName()), - $collector->getResult() - ); - } - private function getProcessClasses(): array { $collector = new SubclassCollector(ProcessUnit::class); diff --git a/console/Command/Di.php b/console/Command/Di.php index 4053173..12041c5 100644 --- a/console/Command/Di.php +++ b/console/Command/Di.php @@ -73,7 +73,7 @@ private static function asyncCachePath(): string * Whether #[Async] proxies should be built, mirroring the WinterApplication boot * decision: enabled when the running app class carries #[EnableAsync]. bootstrap() * has already run by the time this command dispatches, so the app class is known - * without a scan. A legacy Application/BaseBoot project sets no app class — there + * without a scan. A non-WinterApplication entry sets no app class — there * #[Async] proxying is always on. */ private static function asyncEnabled(): bool diff --git a/console/Command/Make.php b/console/Command/Make.php index d742c16..827b1c7 100644 --- a/console/Command/Make.php +++ b/console/Command/Make.php @@ -73,18 +73,12 @@ private function resolution(): void if (in_array('p', $this->args['flags'])) { $this->createResponse($templateName); } - if (in_array('J', $this->args['flags'])) { - $this->createJob($templateName); - } if (in_array('P', $this->args['flags'])) { $this->createProcess($templateName); } if (in_array('N', $this->args['flags'])) { $this->createDaemon($templateName); } - if (in_array('W', $this->args['flags'])) { - $this->createWebSocket($templateName); - } if (in_array('D', $this->args['flags'])) { $this->createConfig($templateName); } @@ -225,39 +219,13 @@ private function createResponse(string $name): void $this->createFile($info['className'], $info['path'], $code, 'response'); } - private function createJob(string $name): void - { - $info = $this->getInfo($name, 'Job', 'JobTemplate'); - $this->smartInfo( - $info, - 'Threads/Jobs', - 'Threads/Job', - 'Thread/Jobs', - 'Thread/Job', - 'Jobs', - 'Job', - 'Threads', - 'Thread' - ); - $code = file_get_contents($info['template']); - $code = str_replace("__namespace__", $info['namespace'], $code); - $code = str_replace("__className__", $info['className'], $code); - $this->createFile($info['className'], $info['path'], $code, 'job'); - } - private function createProcess(string $name): void { $info = $this->getInfo($name, 'Process', 'ProcessTemplate'); $this->smartInfo( $info, - 'Threads/Processes', - 'Threads/Process', - 'Thread/Processes', - 'Thread/Process', 'Processes', - 'Process', - 'Threads', - 'Thread' + 'Process' ); $code = file_get_contents($info['template']); $code = str_replace("__namespace__", $info['namespace'], $code); @@ -270,14 +238,8 @@ private function createDaemon(string $name): void $info = $this->getInfo($name, 'Daemon', 'DaemonTemplate'); $this->smartInfo( $info, - 'Threads/Daemons', - 'Threads/Daemon', - 'Thread/Daemons', - 'Thread/Daemon', 'Daemons', - 'Daemon', - 'Threads', - 'Thread' + 'Daemon' ); $code = file_get_contents($info['template']); $code = str_replace("__namespace__", $info['namespace'], $code); @@ -285,26 +247,6 @@ private function createDaemon(string $name): void $this->createFile($info['className'], $info['path'], $code, 'daemon'); } - private function createWebSocket(string $name): void - { - $info = $this->getInfo($name, 'WebSocket', 'WebSocketTemplate'); - $this->smartInfo( - $info, - 'Threads/WebSockets', - 'Threads/WebSocket', - 'Thread/WebSockets', - 'Thread/WebSocket', - 'WebSockets', - 'WebSocket', - 'Threads', - 'Thread' - ); - $code = file_get_contents($info['template']); - $code = str_replace("__namespace__", $info['namespace'], $code); - $code = str_replace("__className__", $info['className'], $code); - $this->createFile($info['className'], $info['path'], $code, 'websocket'); - } - private function createConfig(string $name): void { $info = $this->getInfo($name, 'DbConfig', 'DbConfigTemplate'); @@ -400,9 +342,7 @@ private function getInfo(string $way, string $prefix, string $templateName): arr 'Entity' => 'Entities', 'Dto' => 'Dto', 'Request' => 'Requests', - 'Job' => 'Jobs', 'Daemon', 'Process' => 'Processes', - 'WebSocket' => 'Sockets', 'Cmd' => 'Commands', default => 'Utils', }; @@ -531,12 +471,10 @@ public static function help(): void self::printBadge('-t', 'Store (suffix: Store)', $cl, 36); self::printLabel("Flags — Business", $cl); - self::printLabel("Flags — Async / Process", $cl); - self::printBadge('-J', 'Job (suffix: Job)', $cl, 36); + self::printLabel("Flags — Process", $cl); self::printBadge('-P', 'Process (suffix: Process)', $cl, 36); self::printBadge('-N', 'Daemon (suffix: Daemon)', $cl, 36); - self::printBadge('-W', 'WebSocket (suffix: WebSocket)', $cl, 36); - self::printLabel("Flags — Async / Process", $cl); + self::printLabel("Flags — Process", $cl); self::printLabel("Flags — Config / Console", $cl); self::printBadge('-D', 'DbConfig (suffix: DbConfig)', $cl, 36); diff --git a/console/Command/Run.php b/console/Command/Run.php index 8f00ef0..7b4a031 100644 --- a/console/Command/Run.php +++ b/console/Command/Run.php @@ -5,8 +5,6 @@ namespace Flytachi\Winter\Console\Command; use Flytachi\Winter\Console\Inc\Cmd; -use Flytachi\Winter\K2\Application; -use Flytachi\Winter\K2\BaseBoot; class Run extends Cmd { @@ -16,27 +14,15 @@ public function handle(): void { self::printTitle("Run", 34); - $sub = $this->args['arguments'][1] ?? null; - $watch = $sub === 'dev' - || in_array('w', $this->args['flags'] ?? [], true) - || isset($this->args['options']['watcher']); - - // WinterApplication owns `run`: it serves from its own run() before the - // console dispatcher is ever reached, so this command handles only the legacy - // Application path and will be removed together with it. - $bootClass = BaseBoot::getBootClass(); - if ($bootClass === '' || !is_subclass_of($bootClass, Application::class)) { - self::printWarning("`call run` needs a WinterApplication entry class."); - self::printInfo("Extend WinterApplication and declare components with #[Enable*] attributes."); - self::printInfo("Docs: doc-new/winter-application.md"); - return; - } - - self::printWarning("Legacy Application path (deprecated) — prefer WinterApplication + #[Enable*]."); - self::printSuccess($watch ? "Starting application (dev / watch)" : "Starting application"); - - // serve() blocks until shutdown and exits the process itself. - $bootClass::serve($watch); + // `run` is owned by the application entry: WinterApplication::run() serves the + // app from its own process before the console dispatcher is ever reached. + // Reaching this command means the entry did not intercept `run`. + self::printWarning("`call run` is served by your WinterApplication entry, not this command."); + self::printInfo("Ensure your `call` launcher calls App::main(\$argv), where App extends WinterApplication."); + self::printInfo("Declare components with #[EnableWeb] / #[EnableProcess] / #[EnableDaemon] / #[EnableScheduler]."); + self::printInfo("Docs: doc-new/winter-application.md"); + + self::printTitle("Run", 34); } public static function help(): void diff --git a/console/Command/Thread.php b/console/Command/Thread.php deleted file mode 100644 index cb890e6..0000000 --- a/console/Command/Thread.php +++ /dev/null @@ -1,422 +0,0 @@ -args['arguments']) > 1) { - $this->resolution(); - } else { - self::help(); - } - - self::printTitle("Thread", 34); - } - - private function resolution(): void - { - switch ($this->args['arguments'][1] ?? '') { - case 'list': - $this->listArg(); - break; - case 'daemons': - $this->daemonsArg(); - break; - default: - $this->runArg($this->args['arguments'][1]); - break; - } - } - - private function runArg(string $input): void - { - if (!extension_loaded('pcntl')) { - self::printWarning("Extension 'pcntl' is not loaded — async signals unavailable."); - return; - } - - pcntl_async_signals(true); - - $class = str_replace( - '/', - '\\', - implode('/', array_map( - fn($word) => ucfirst($word), - explode('/', str_replace('.', '/', $input)) - )) - ); - $name = basename(str_replace('\\', '/', $class)); - - if (!class_exists($class)) { - self::printWarning("Class '$name' not found."); - self::printInfo("Resolved: $class"); - self::printInfo("Run 'call thread list' to see available threads."); - } elseif (!is_subclass_of($class, Dispatchable::class)) { - self::printWarning("Class '$name' does not implement Dispatchable."); - self::printInfo("Resolved: $class"); - } elseif (is_subclass_of($class, ThreadDaemon::class)) { - $this->daemonAction($class); - } else { - $inBackground = in_array('d', $this->args['flags']); - if ($inBackground) { - $this->threadRunnableToBack($class); - } else { - $this->threadRunnable($class); - } - } - } - - /** - * @param class-string $class - */ - private function daemonAction(string $class): void - { - match (strtolower($this->args['arguments'][2] ?? '')) { - 'start' => $this->daemonStart($class), - 'stop' => $this->daemonStop($class), - 'status' => $this->daemonStatus($class, in_array('v', $this->args['flags'])), - '' => $this->daemonToggle($class), - default => self::printWarning("Unknown daemon action (use start|stop|status)."), - }; - } - - /** - * Default daemon behavior: toggle between start and stop based on status. - * - * @param class-string $class - */ - private function daemonToggle(string $class): void - { - $info = $class::status(); - if ($info) { - self::printInfo("Already running [PID:{$info->status->pid}], stopping..."); - $this->daemonStop($class); - } elseif (in_array('d', $this->args['flags'])) { - $this->daemonStart($class); - } else { - $this->threadRunnable($class); - } - } - - /** - * @param class-string $class - */ - private function daemonStart(string $class): void - { - try { - $pid = $class::dispatch(); - self::printSuccess("Started: $class"); - self::printKeyValue("PID", (string) $pid, 12, 34, 32); - } catch (DaemonException $e) { - self::printWarning($e->getMessage()); - } - } - - /** - * @param class-string $class - */ - private function daemonStop(string $class): void - { - try { - $info = $class::status(); - $class::stop(); - self::printSuccess("Stopped: $class"); - if ($info) { - self::printKeyValue("PID", (string) $info->status->pid, 12, 34, 32); - } - } catch (DaemonException $e) { - self::printWarning($e->getMessage()); - } - } - - /** - * @param class-string $class - */ - private function daemonStatus(string $class, bool $detailed): void - { - $dot = str_replace('\\', '.', $class); - $info = $class::status($detailed); - - self::printLabel("Daemon Status", 34); - - if (!$info) { - self::printBadge($dot, '○ STOPPED', 34, 31); - self::printInfo("The daemon is not running."); - self::printLabel("Daemon Status", 34); - return; - } - - $s = $info->status; - self::printBadge($dot, '● RUNNING', 34, 32); - self::printDivider(); - - self::printKeyValue("PID", (string) $s->pid, 12, 34, 36); - self::printKeyValue("Condition", $s->condition->name, 12, 34, 36); - self::printKeyValue("Started", $s->getStartedAt(), 12, 34, 36); - self::printKeyValue("Uptime", $this->formatDuration(time() - $s->startedAt), 12, 34, 36); - if ($s->streamRps) { - self::printKeyValue("Stream RPS", (string) $s->streamRps, 12, 34, 36); - } - self::printKeyValue("Forks", (string) $this->daemonForkQty($class), 12, 34, 36); - - foreach ($s->info as $key => $value) { - self::printKeyValue( - (string) $key, - is_scalar($value) ? (string) $value : json_encode($value), - 12, - 34, - 90 - ); - } - - if ($detailed) { - self::printDivider(); - self::printLabel("Resources", 34); - if ($info->stats) { - $st = $info->stats; - self::printKeyValue("User", $st->user, 12, 34, 35); - self::printKeyValue("PPID", (string) $st->ppid, 12, 34, 35); - self::printKeyValue("CPU", $st->cpu . ' %', 12, 34, 35); - self::printKeyValue( - "Memory", - $st->mem . ' % (' . round($st->rssMb(), 1) . ' MB)', - 12, - 34, - 35 - ); - self::printKeyValue("Elapsed", $st->etime, 12, 34, 35); - self::printKeyValue("Command", $st->command, 12, 34, 35); - } else { - self::printInfo("Resource stats unavailable (process gone or 'ps' returned nothing)."); - } - - $this->daemonForks($class); - } - - self::printLabel("Daemon Status", 34); - } - - /** - * @param class-string $class - */ - private function daemonForkQty(string $class): int - { - try { - return $class::forkQty(); - } catch (\Throwable) { - return 0; - } - } - - /** - * Render the daemon's child forks with per-fork resource stats. - * - * @param class-string $class - */ - private function daemonForks(string $class): void - { - try { - $forks = $class::forkListInfo(true); - } catch (\Throwable) { - $forks = []; - } - - self::printDivider(); - self::printLabel("Forks (" . count($forks) . ")", 34); - - if ($forks === []) { - self::printInfo("No active forks."); - return; - } - - foreach ($forks as $fork) { - $fs = $fork->status; - $line = sprintf( - "#%-7d %-11s %s", - $fs->pid, - $fs->condition->name, - $this->formatDuration(time() - $fs->startedAt) - ); - if ($fork->stats) { - $line .= sprintf( - " cpu %s%% rss %s MB", - $fork->stats->cpu, - round($fork->stats->rssMb(), 1) - ); - } - self::print($line, 36); - } - } - - /** - * Human-readable duration, e.g. 90061 → "1d 1h". - */ - private function formatDuration(int $seconds): string - { - $seconds = max(0, $seconds); - $units = ['d' => 86400, 'h' => 3600, 'm' => 60, 's' => 1]; - - $parts = []; - foreach ($units as $suffix => $size) { - $value = intdiv($seconds, $size); - $seconds %= $size; - if ($value > 0) { - $parts[] = $value . $suffix; - } - } - - return $parts === [] ? '0s' : implode(' ', array_slice($parts, 0, 2)); - } - - private function listArg(): void - { - $collector = new ImplementorCollector(Dispatchable::class); - ClassScanner::scan($collector); - $threads = $collector->getResult(); - - self::printLabel("Available Threads", 34); - if (empty($threads)) { - self::printWarning("No Dispatchable classes found."); - self::printInfo("Create one that implements Dispatchable."); - } else { - foreach ($threads as $ref) { - $dotName = str_replace('\\', '.', $ref->getName()); - [$type, $badgeColor] = match (true) { - $ref->isSubclassOf(ThreadDaemon::class) => ['Daemon', 35], - $ref->isSubclassOf(ThreadJob::class) => ['Job', 36], - $ref->isSubclassOf(ThreadProcess::class) => ['Process', 36], - default => ['Dispatchable', 36], - }; - self::printBadge($dotName, $type, 34, $badgeColor); - } - } - self::printLabel("Available Threads", 34); - } - - private function daemonsArg(): void - { - $collector = new SubclassCollector(ThreadDaemon::class); - ClassScanner::scan($collector); - $daemons = $collector->getResult(); - - self::printLabel("Available Daemons", 34); - if (empty($daemons)) { - self::printWarning("No Daemon classes found."); - self::printInfo("Create one that extends ThreadDaemon."); - } else { - foreach ($daemons as $ref) { - $this->printDaemonRow($ref->getName()); - } - } - self::printLabel("Available Daemons", 34); - } - - /** - * Render one daemon row: name, live state, fork count and uptime. - * - * @param class-string $class - */ - private function printDaemonRow(string $class): void - { - $dotName = str_replace('\\', '.', $class); - $info = $class::status(); - - echo "\033[34m" . str_pad(" |\t $dotName ", 65, '.') . " "; - if ($info) { - $forks = $this->daemonForkQty($class); - $uptime = $this->formatDuration(time() - $info->status->startedAt); - echo "\033[32m[● RUNNING]\033[36m [forks:{$forks}]\033[90m {$uptime}"; - } else { - echo "\033[31m[○ STOPPED]"; - } - echo "\033[0m\n"; - } - - /** - * @param class-string $class - */ - private function threadRunnable(string $class): void - { - self::printInfo("Starting: $class"); - ($class)::start(); - self::printSuccess("Finished: $class"); - } - - /** - * @param class-string $class - */ - private function threadRunnableToBack(string $class): void - { - $pid = ($class)::dispatch(); - self::printSuccess("Dispatched: $class"); - self::printKeyValue("PID", (string) $pid, 10, 34, 32); - } - - public static function help(): void - { - $cl = 34; - self::printTitle("Thread Help", $cl); - - self::printLabel("Usage", $cl); - self::print("call thread [args] -[flags]", $cl); - self::print("call th [args] -[flags] (alias)", $cl); - self::printLabel("Usage", $cl); - - self::printLabel("Commands", $cl); - self::printBadge('list', 'list all Dispatchable classes', $cl, 36); - self::printBadge('daemons', 'list daemons with live status', $cl, 36); - self::printBadge('', 'run thread in foreground', $cl, 36); - self::printBadge(' -d', 'dispatch to background', $cl, 36); - self::printLabel("Commands", $cl); - - self::printLabel("Daemon Commands", $cl); - self::printBadge('', 'toggle: stop if running, else start (foreground)', $cl, 35); - self::printBadge(' -d', 'toggle: start in background (-d only here)', $cl, 35); - self::printBadge(' start', 'start daemon in background', $cl, 35); - self::printBadge(' stop', 'stop running daemon', $cl, 35); - self::printBadge(' status', 'show daemon status', $cl, 35); - self::printBadge(' status -v', 'detailed: resources + forks', $cl, 35); - self::printLabel("Daemon Commands", $cl); - - self::printLabel("Flags", $cl); - self::printKeyValue("-d", "dispatch task as background process", 10, $cl, 36); - self::printKeyValue("-v", "verbose daemon status (resources + forks)", 10, $cl, 36); - self::printLabel("Flags", $cl); - - self::printDivider($cl); - - self::printLabel("Examples", $cl); - self::printInfo("call thread list"); - self::printInfo("call thread daemons"); - self::printInfo("call thread main.threads.ExampleJob"); - self::printInfo("call thread main.threads.ExampleJob -d"); - self::printInfo("call thread main.threads.ExampleDaemon"); - self::printInfo("call thread main.threads.ExampleDaemon status"); - self::printInfo("call thread main.threads.ExampleDaemon status -v"); - self::printInfo("call thread main.threads.ExampleDaemon stop"); - self::printLabel("Examples", $cl); - - self::printDivider($cl); - self::printInfo("Docs: https://winterframe.net/docs/3.0.0/cmd-thread"); - - self::printTitle("Thread Help", $cl); - } -} diff --git a/console/Core.php b/console/Core.php index f04ccf1..837583b 100644 --- a/console/Core.php +++ b/console/Core.php @@ -11,7 +11,6 @@ class Core extends CoreHandle /** Short aliases → Command class name */ protected static array $aliases = [ 'sc' => 'Script', - 'th' => 'Thread', 'proc' => 'Process', 'dmn' => 'Daemon', 'sch' => 'Schedule', diff --git a/console/Template/Build/phpstormMeta b/console/Template/Build/phpstormMeta index 86cbc28..504a25b 100644 --- a/console/Template/Build/phpstormMeta +++ b/console/Template/Build/phpstormMeta @@ -14,25 +14,21 @@ namespace PHPSTORM_META { 'LOG_OUTPUT', 'LOG_FILE', 'LOG_FILE_MAX', - 'LOG_SYSLOG_IDENT', 'LOG_SYS_LEVEL', 'LOG_SYS_FORMAT', 'LOG_SYS_OUTPUT', 'LOG_SYS_FILE', 'LOG_SYS_FILE_MAX', - 'LOG_SYS_SYSLOG_IDENT', 'LOG_HTTP_LEVEL', 'LOG_HTTP_FORMAT', 'LOG_HTTP_OUTPUT', 'LOG_HTTP_FILE', 'LOG_HTTP_FILE_MAX', - 'LOG_HTTP_SYSLOG_IDENT', 'LOG_CLI_LEVEL', 'LOG_CLI_FORMAT', 'LOG_CLI_OUTPUT', 'LOG_CLI_FILE', - 'LOG_CLI_FILE_MAX', - 'LOG_CLI_SYSLOG_IDENT' + 'LOG_CLI_FILE_MAX' ); // 2. Говорим, что 0-й аргумент функции env() должен быть из этого набора diff --git a/console/Template/Make/DaemonTemplate b/console/Template/Make/DaemonTemplate index e7f3918..9ea9ac7 100644 --- a/console/Template/Make/DaemonTemplate +++ b/console/Template/Make/DaemonTemplate @@ -2,23 +2,25 @@ namespace __namespace__; -use Flytachi\Winter\K2\Stereotype\Daemon; +use Flytachi\Winter\K2\Process\Daemon\Daemon; -class __className__ extends Daemon +final class __className__ extends Daemon { - public function resolution(mixed $data = null): void - { - $this->logger?->info('START'); - $this->prepare(); - $pid = $this->forkAnonymous(); - $this->wait($pid, function ($pid, $status) { - $this->logger?->info("PROC finally (status {$status})"); - }); - $this->logger?->info('END'); - } + /** Baseline fleet size — replicas the master keeps alive. */ + protected int $replicas = 3; - public function anonymousResolution(mixed $data = null): void + /** + * The worker body — the daemon is also the worker, so this runs in every + * replica. (Alternatively drop this and set `$workerClass` to a Process class.) + */ + protected function workerRun(): void { - $this->logger?->info("PROC running"); + while ($this->isRunning()) { + $this->markBusy(); + // ... work ... + $this->markIdle(); + + $this->sleep(1.0); + } } } diff --git a/console/Template/Make/JobTemplate b/console/Template/Make/JobTemplate deleted file mode 100644 index dfac219..0000000 --- a/console/Template/Make/JobTemplate +++ /dev/null @@ -1,13 +0,0 @@ -logger->info('RUN'); - } -} diff --git a/console/Template/Make/ProcessTemplate b/console/Template/Make/ProcessTemplate index a6c12d9..59ba4f9 100644 --- a/console/Template/Make/ProcessTemplate +++ b/console/Template/Make/ProcessTemplate @@ -2,22 +2,22 @@ namespace __namespace__; -use Flytachi\Winter\K2\Stereotype\Process; +use Flytachi\Winter\K2\Process\Process; -class __className__ extends Process +final class __className__ extends Process { - public function resolution(mixed $data = null): void + /** + * The process body — loop while the process is running. A stop signal flips + * isRunning() to false; an idle sleep() is interrupted at once. + */ + public function run(): void { - $this->logger->info('START'); - $pid = $this->forkAnonymous(); - $this->wait($pid, function ($pid, $status) { - $this->logger->info("fork finally (status {$status})"); - }); - $this->logger->info('END'); - } + while ($this->isRunning()) { + $this->markBusy(); + // ... work ... + $this->markIdle(); - public function anonymousResolution(mixed $data = null): void - { - $this->logger->info("fork running"); + $this->sleep(1.0); + } } } diff --git a/console/Template/Make/WebSocketTemplate b/console/Template/Make/WebSocketTemplate deleted file mode 100644 index 4a52fb9..0000000 --- a/console/Template/Make/WebSocketTemplate +++ /dev/null @@ -1,22 +0,0 @@ - - */ - protected static function components(): array - { - return []; - } - - /** - * The CLI front door (the app's `main()`). Boots once and dispatches the - * console command in $argv, then exits. `call run` / `call run dev` reach - * {@see serve()} from here. - * - * @param array $argv Raw $argv (script name in [0]). - */ - final public static function run(array $argv = []): never - { - static::cli($argv); - } - - /** - * Brings the application up and blocks until shutdown. Called by the `run` - * console command (so the boot sequence has already run — this does not - * re-boot). - * - * With a {@see Component::http()} declared: builds one Swoole HTTP server and - * attaches every other component as a supervised `addProcess`, co-terminating - * with the server. The {@see DevWatcher} (memory reporting + code hot-reload) - * is attached only when $watch is true (`call run dev`). With no Http component: - * runs headless — a single component in the foreground, or several under a small - * pcntl supervisor. - * - * @param bool $watch Attach the DevWatcher — memory + hot-reload (development). - */ - final public static function serve(bool $watch = false): never - { - /** @var ?Component $http */ - $http = null; - /** @var list $sockets */ - $sockets = []; - /** @var list $companions */ - $companions = []; - - foreach (static::components() as $component) { - if (!$component instanceof Component) { - throw new ApplicationConfigException( - 'components() must return ' . Component::class - . ' instances; got ' . get_debug_type($component) . '.' - ); - } - match ($component->kind) { - ComponentKind::Http => $http = $component, - ComponentKind::WebSocket => $sockets[] = $component, - default => $companions[] = $component, - }; - } - - if ($sockets !== []) { - throw new ApplicationConfigException( - 'WebSocket components are not hosted by the bundled runtime yet — ' - . 'the port from the legacy engine is pending.' - ); - } - - $logger = LoggerFactory::getLogger(static::class); - - if ($http !== null) { - static::serveHttp($http, $companions, $watch, $logger); - } - - static::serveHeadless($companions, $logger); - } - - // ── Internal ────────────────────────────────────────────────────────────── - - /** - * Web bundle: the Http component becomes the Swoole server; every other - * component is attached with addProcess so the master supervises it and - * stops it together with the server. - * - * @param list $companions - */ - private static function serveHttp(Component $http, array $companions, bool $watch, LoggerInterface $logger): never - { - if (!extension_loaded('swoole')) { - throw new ApplicationConfigException( - '`call run` with a web tier needs ext-swoole (pecl install swoole).' - ); - } - - // The 'http' channel + coroutine context belong to request workers only - // (set in workerStart below). The master and the addProcess companions - // stay on 'sys' with process context — a background component must not - // log as if it were an HTTP request. - $router = Router::fromScan(Kernel::$pathRoot); - $router->static(Kernel::$pathPublic); - - \Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL); - Runtime::boot(RuntimeMode::Swoole); - - $server = new \Swoole\Http\Server($http->host, $http->port); - $server->set(static::swooleConfig()); - - $names = []; - foreach ($companions as $companion) { - $class = (string) $companion->class; - $names[] = self::shortName($class); - $server->addProcess(new \Swoole\Process( - static function () use ($class): void { - // The bundle process turned runtime hooks on for the HTTP - // reactor; clear them (flags = 0) so this child is a clean - // plain process and each component boots its own runtime - // inside start(), exactly as a standalone launch would. - \Swoole\Runtime::enableCoroutine(0); - // Background component: log on the system channel with process - // context, exactly like a standalone launch. - LoggerFactory::setContextStorage(new ProcessContext()); - LoggerFactory::setDefaultChannel('sys'); - // The fork copies the parent's open fds; drop them so the - // component reconnects in place (see Process::afterFork()). - ForkReset::runAll(); - $class::start(); - } - )); - } - - $handler = static function (\Swoole\Http\Request $req, \Swoole\Http\Response $res) use ($router): void { - $request = new SwooleRequest($req); - $isHead = strtoupper($request->getMethod()) === 'HEAD'; - $router->handle($request, new SwooleResponse($res, $isHead)); - }; - - // Request workers log on 'http' with per-request coroutine isolation. - // addProcess companions never receive workerStart, so they keep 'sys'. - $workerStart = static function (\Swoole\Http\Server $server, int $workerId): void { - LoggerFactory::setContextStorage(new CoroutineContext()); - LoggerFactory::setDefaultChannel('http'); - }; - - // Dev mode: the DevWatcher reports memory and hot-reloads on code changes - // by restarting the whole process (see reexec() after start()). - $dev = $watch ? new DevWatcher([Kernel::$pathRoot]) : null; - if ($dev !== null) { - $dev->attach($server, $workerStart); - $server->on('request', $dev->wrap($handler)); - } else { - $server->on('workerStart', $workerStart); - $server->on('request', $handler); - } - - $logger->info(sprintf( - 'Application up: http://%s:%d%s%s', - $http->host, - $http->port, - $names === [] ? '' : ' + [' . implode(', ', $names) . ']', - $watch ? ' (dev/watch)' : '' - )); - - $server->start(); - - // start() returns when a dev code change stopped the server — re-exec into - // a fresh `call run dev` so the change is fully picked up. - if ($dev !== null && $dev->reloadRequested()) { - $dev->reexec(); - } - - exit(0); - } - - /** - * No web tier: run the background components directly. One component runs in - * the foreground (fully managed on its own); several are forked and reaped by - * a small supervisor that forwards a stop signal to the whole group. Works - * with or without ext-swoole (each component picks its own engine). - * - * @param list $companions - */ - private static function serveHeadless(array $companions, LoggerInterface $logger): never - { - if ($companions === []) { - throw new ApplicationConfigException( - 'Nothing to run: components() is empty. Declare at least one ' - . 'Component::http()/process()/daemon()/scheduler().' - ); - } - - if (count($companions) === 1) { - $class = (string) $companions[0]->class; - $logger->info('Application up (headless): ' . self::shortName($class)); - $class::start(); - exit(0); - } - - if (!function_exists('pcntl_fork')) { - throw new ApplicationConfigException( - 'Running several headless components needs ext-pcntl.' - ); - } - - $children = []; - foreach ($companions as $companion) { - $class = (string) $companion->class; - $pid = pcntl_fork(); - if ($pid === -1) { - throw new \RuntimeException("Application: fork failed for {$class}."); - } - if ($pid === 0) { - if (extension_loaded('swoole')) { - \Swoole\Runtime::enableCoroutine(0); - } - LoggerFactory::setContextStorage(new ProcessContext()); - LoggerFactory::setDefaultChannel('sys'); - ForkReset::runAll(); - $class::start(); - exit(0); - } - $children[$pid] = self::shortName($class); - } - - $forward = static function (int $signo) use (&$children): void { - foreach (array_keys($children) as $pid) { - @posix_kill($pid, $signo); - } - }; - pcntl_async_signals(true); - pcntl_signal(SIGTERM, $forward); - pcntl_signal(SIGINT, $forward); - - $logger->info('Application up (headless): [' . implode(', ', $children) . ']'); - - while ($children !== []) { - $pid = pcntl_waitpid(-1, $status); - if ($pid > 0) { - unset($children[$pid]); - } - } - - exit(0); - } - - private static function shortName(string $class): string - { - return new \ReflectionClass($class)->getShortName(); - } -} diff --git a/src/BaseBoot.php b/src/BaseBoot.php deleted file mode 100644 index ae577d6..0000000 --- a/src/BaseBoot.php +++ /dev/null @@ -1,508 +0,0 @@ -register(AppServiceProvider::class); - * $c->register(DatabaseServiceProvider::class); - * - * // Manual bindings - * $c->singleton(CacheInterface::class, RedisCache::class); - * $c->bind(MailerInterface::class, fn($c) => - * new SmtpMailer(env('MAIL_HOST'), $c->make(LoggerInterface::class)) - * ); - * $c->set('config.timeout', (int) env('APP_TIMEOUT', 30)); - * } - * ``` - */ - protected static function providers(Container $c): void - { - } - - /** - * Register additional log channels via Kernel::channel(). - * - * Built-in channels (http, sys) are registered automatically by Kernel::init(). - * Call this hook to add custom channels. Each channel reads LOG_{NAME}_* env vars - * with the same fallback chain as the built-in channels. - * - * Rules: - * - Call AFTER configure() (already guaranteed by boot order). - * - Channel name is lowercase by convention; env prefix is uppercased automatically. - * - If a channel is requested but not registered, it falls back to the default channel. - * - * ``` - * protected static function channels(): void - * { - * Kernel::channel('job'); - * Kernel::channel('daemon'); - * } - * ``` - * - * Usage anywhere in application code: - * LoggerFactory::getLogger(MyJob::class, 'job')->info('started'); - * LoggerFactory::channel('daemon')->warning('slow tick'); - * - * .env for custom channels: - * LOG_JOB_LEVEL=debug - * LOG_JOB_OUTPUT=file - * LOG_JOB_FILE=/var/log/app/job.log - * LOG_JOB_FILE_MAX=7 - */ - protected static function channels(): void - { - } - - /** - * Configure global CORS policy via Cors::configure(). - * - * Applied to every response — including 404, 405, and 5xx — before route - * dispatch. Per-route overrides are available via #[CrossOrigin] on any - * controller class or method; method-level takes priority over class-level. - * - * ``` - * protected static function httpCors(): void - * { - * Cors::configure( - * origins: ['https://app.example.com', 'https://admin.example.com'], - * allowHeaders: ['Content-Type', 'Authorization', 'X-Request-Id'], - * exposeHeaders: ['X-Request-Id'], - * credentials: true, - * maxAge: 3600, - * ); - * } - * ``` - * - * Cors::configure() parameters: - * origins string[] Allowed origins. Empty → wildcard '*'. - * allowHeaders string[] Headers the browser may send (preflight). - * exposeHeaders string[] Headers exposed to the browser (response). - * credentials bool Send Access-Control-Allow-Credentials: true. - * maxAge int Preflight cache lifetime in seconds. - */ - protected static function httpCors(): void - { - } - - /** - * Configure health / actuator endpoints via Health::configure(). - * - * Registers read-only diagnostic endpoints under /actuator. - * All endpoints return JSON. Useful for load-balancer probes and monitoring. - * - * Endpoints (GET): - * /actuator — full aggregated report - * /actuator/health — overall status: up | degraded | down - * degraded: ≥80% resource usage | down: ≥90% or connection failed - * /actuator/info — PHP version, SAPI, framework version, project meta - * /actuator/metrics — CPU load, memory, disk, opcache stats, uptime - * /actuator/env — custom env values (override env() in your indicator) - * /actuator/loggers — active log channels and their configured levels - * /actuator/mappings — registered route table - * - * ``` - * protected static function health(): void - * { - * // Default built-in indicator, open access: - * Health::configure(); - * - * // Custom indicator + middleware guard: - * Health::configure( - * indicator: App\Health\AppHealthIndicator::class, - * middleware: App\Http\Middleware\InternalOnlyMiddleware::class, - * ); - * } - * ``` - */ - protected static function health(): void - { - } - - /** - * Register plugins via Plugin::registry(). - * - * Registers Composer packages as route-prefixed sub-applications. - * Each plugin's src/ directory is scanned for controllers automatically - * by Router::fromScan() / Router::resolve() — no extra wiring required. - * - * ``` - * protected static function plugins(): void - * { - * Plugin::registry('acme/auth-plugin', '/auth'); - * Plugin::registry('acme/billing-plugin', '/billing'); - * } - * ``` - * - * Plugin::registry() parameters: - * package string Composer package name (e.g. 'acme/billing'). - * prefix string URL prefix (e.g. '/billing'). - * required bool Throw if package is not installed (default: true). - */ - protected static function plugins(): void - { - } - - /** - * Swoole HTTP server settings passed to \Swoole\Http\Server::set(). - * - * Override to tune concurrency, request limits, SSL, and other Swoole options. - * Return an empty array to use Swoole's built-in defaults. - * - * ``` - * protected static function swooleConfig(): array - * { - * return [ - * 'worker_num' => swoole_cpu_num() * 2, - * 'max_request' => 5000, - * 'max_request_grace' => 500, - * 'enable_coroutine' => true, - * ]; - * } - * ``` - * - * @return array - */ - public static function swooleConfig(): array - { - return []; - } - - /** - * Last-resort handler for errors thrown before Router::handle() installs - * its own try/catch — boot failures, DI scan errors, ambiguous routes, etc. - * - * Mirrors Router::sendError() so boot-time and request-time errors render - * through the same ExceptionWrapper pipeline. Override in your Boot class - * to customise (Sentry reporting, branded error page, etc.). - */ - protected static function handleBootError(\Throwable $e, HttpResponse $response): void - { - try { - LoggerFactory::getLogger(static::class)->alert( - 'Boot failure: ' . $e->getMessage(), - ['exception' => $e] - ); - } catch (\Throwable) { - error_log(sprintf( - '[winter-kernel] Boot failure: %s in %s:%d', - $e->getMessage(), - $e->getFile(), - $e->getLine() - )); - } - - while (ob_get_level() > 0) { - ob_end_clean(); - } - - try { - $exc = ExceptionWrapper::wrap($e); - $body = $exc->getBody(); - $response->status($exc->getHttpCode()->value); - foreach ($exc->getHeader() as $key => $value) { - $response->header($key, $value); - } - $response->end($body); - } catch (\Throwable) { - $response->status(500); - $response->header('Content-Type', 'text/plain; charset=utf-8'); - $response->end('Internal Server Error'); - } - } - - // ── Entry points ────────────────────────────────────────────────────────── - - public static function base(?string $defaultChannelName = null): void - { - self::boot(); - if ($defaultChannelName !== null) { - Kernel::channel($defaultChannelName); - LoggerFactory::setDefaultChannel($defaultChannelName); - } - } - - /** - * FPM entry point — one request per process lifecycle. - * - * Reads the HTTP request from PHP superglobals ($_SERVER, $_GET, $_POST, - * $_FILES, php://input) and writes the response via http_response_code() / - * header() / echo. No shared state between requests. - * - * Route caching: - * DEBUG=false → loads from storage/cache/mapping.php when it exists; - * scans and writes cache on first boot after deployment. - * DEBUG=true → always rescans (dev mode, no stale cache). - * - * Static files: - * GET requests whose URI maps to an existing file in Kernel::$pathPublic - * are served directly — skipping route dispatch entirely. In FPM+nginx - * setups nginx already handles this, so the check is a no-op in production - * if nginx is configured correctly. - * - * Request pipeline: - * 1. Header::init() — snapshot superglobals into the Header bag - * 2. Locale::initFromRequest() — detect Accept-Language / locale cookie - * 3. Static file check — short-circuit for existing public files - * 4. Global CORS headers — applied before dispatch (covers 404/500 too) - * 5. OPTIONS preflight — returns 204 before handler invocation - * 6. Route dispatch — O(1) static map → chunked regex dynamic scan - * 7. Per-route #[CrossOrigin] — overrides global CORS if present - * 8. Middleware before() — run in declaration order - * 9. Controller method — resolved via ReflectionCache + ParameterResolver - * 10. Middleware after() — run in reverse order - * 11. Response serialise — Sendable::send() or ResponseEntity::ok()->send() - * 12. Error handling — ExceptionWrapper maps Throwable → HTTP response - */ - final public static function web(): never - { - $isHead = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'HEAD'; - $response = new FpmResponse($isHead); - try { - self::boot(); - LoggerFactory::setDefaultChannel('http'); - - $router = Router::resolve(Kernel::$pathRoot); - $router->static(Kernel::$pathPublic); - $router->handle(new FpmRequest(), $response); - } catch (\Throwable $e) { - self::handleBootError($e, $response); - } - - exit(0); - } - - /** - * CLI console entry point. - * - * Parses $argv and dispatches to the matching console command class under - * Flytachi\Winter\Console\Command\{Name}. The first argument selects the - * command; aliases defined in Console\Core::$aliases are resolved first. - * - * Built-in commands: Make, Run, Script, Thread, Help, Cfg, Serve, Complete. - * Custom commands live in your project and are discovered via the scanner. - * - * Usage examples (from the 'call' binary): - * ``` - * ./call make:controller UserController - * ./call run MyDaemon - * ./call help - * ``` - * - * The 'sys' log channel is activated so all log writes go to the system output. - * To inject per-session fields into every log line: - * LoggerFactory::contextStorage()->set('job', 'import'); - * - * @param array $argv Raw $argv from the CLI script (script name in [0]) - */ - final public static function cli(array $argv = []): never - { - self::boot(); - LoggerFactory::setDefaultChannel('sys'); - - new Core($argv)->run(); - - exit(0); - } - - /** - * Thread / job executor entry point. - * - * Deserializes a Runnable object from stdin (PAYLOAD_PIPE) or shared memory - * (PAYLOAD_SHM, requires ext-shmop) and executes it in a child process - * spawned by the Thread dispatcher. - * - * This method is called by the wKernelExecutor binary — you do not invoke it - * directly. The binary is referenced by Thread::dispatch() internally. - * - * Payload sources (selected automatically by the dispatcher): - * PAYLOAD_PIPE — serialised Runnable written to the child's stdin pipe - * PAYLOAD_SHM — serialised Runnable placed in a shared memory segment - * (avoids fd conflicts in Swoole; requires ext-shmop) - * - * CLI flags accepted by the executor binary: - * --namespace=App Process title namespace prefix - * --name=MyJob Override the process title name (default: class short name) - * --tag=worker Process title tag (default: 'runnable') - * --shmkey=1234 Read payload from SHM segment with this key instead of stdin - * --debug Enable full error reporting in the child process - * --arg-key=value Pass custom arguments to Runnable::run() as ['key' => 'value'] - * --arg-flag Pass boolean flag to Runnable::run() as ['flag' => true] - * - * @param array $argv Raw $argv from the wKernelExecutor binary - */ - final public static function executor(array $argv = []): never - { - self::boot(); - LoggerFactory::setDefaultChannel('sys'); - $options = getopt('', ['namespace::', 'name::', 'tag::', 'debug', 'detach', 'shmkey::']); - exit(WinterRunner::adaptive()->execute($options)); - } - - // ── Internal ────────────────────────────────────────────────────────────── - - /** - * Runs the boot sequence once (kernel init, DI scan, providers, channels, - * plugins, CORS, health). Every entry point calls this first. Protected so a - * subclass entry point (e.g. {@see Application::serve()}) can reuse it. - */ - protected static function boot(): void - { - self::$bootClass = static::class; - static::configure(); - - $c = Container::init(); - $debug = (bool) env('DEBUG', false); - - // Swaps classes carrying #[Async] for their generated proxies. Shares the - // scan with DICollector and must run after it — that collector rebinds a - // class to itself, which would undo the substitution. - $async = new AsyncCollector( - $c, - ProxyFactory::forKernel($debug), - $debug ? null : Kernel::$pathStorageVolatile . '/async.php', - ); - - Scanner::run( - rootDir: Kernel::$pathRoot, - cache: $debug ? null - : Kernel::$pathStorageVolatile . '/di.php', - ) - ->collect(new DICollector($c)) - ->collect($async) - ->execute(); - - $async->flush(); - - // Default contextual logger: #[Autowired] LoggerInterface $logger resolves to a - // logger named after the class it is injected into. Override in providers() by - // re-registering contextual(LoggerInterface::class, …). - $c->contextual( - LoggerInterface::class, - static fn(Container $c, ?string $consumer) => LoggerFactory::getLogger($consumer ?? 'app'), - ); - - static::providers($c); - static::channels(); - static::plugins(); - static::httpCors(); - static::health(); - } -} diff --git a/src/Core/ClassScanner.php b/src/Core/ClassScanner.php index 3962e47..78813b6 100644 --- a/src/Core/ClassScanner.php +++ b/src/Core/ClassScanner.php @@ -18,7 +18,7 @@ * (vendor excluded automatically) plus each plugin's `src` directory. * * Usage: - * $collector = new ImplementorCollector(Dispatchable::class); + * $collector = new ImplementorCollector(HealthContributor::class); * ClassScanner::scan($collector); * $refs = $collector->getResult(); // ReflectionClass[] */ diff --git a/src/Http/Response/ExceptionWrapper.php b/src/Http/Response/ExceptionWrapper.php index dc50781..394f816 100644 --- a/src/Http/Response/ExceptionWrapper.php +++ b/src/Http/Response/ExceptionWrapper.php @@ -30,8 +30,7 @@ * match expression to mask new sensitive exception types. redactMessage() * MUTATES the Throwable's $message in place (clone is not used — some * Throwables in PHP are not cloneable), so callers must log the original - * message BEFORE invoking wrap(). Router::sendError() and - * BaseBoot::handleBootError() already follow this order. + * message BEFORE invoking wrap(). Router::sendError() already follows this order. * * Specific handlers (with exception class names) are tried first. * Catch-all handlers (without class names) are tried last. diff --git a/src/Kernel.php b/src/Kernel.php index a80bb62..2af2f5b 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -4,7 +4,6 @@ namespace Flytachi\Winter\K2; -use Flytachi\Winter\Base\Runtime; use Flytachi\Winter\K2\Core\KernelStore; use Flytachi\Winter\K2\Process\ForkReset; use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; @@ -139,19 +138,16 @@ private static function buildChannelConfig(string $channel): array 'output' => $output, 'file_path' => $filePath ? (string) $filePath : null, 'file_max' => (int) (env($prefix . 'FILE_MAX') ?? env('LOG_FILE_MAX', 30)), - 'syslog_ident' => (string) (env($prefix . 'SYSLOG_IDENT') ?? env('LOG_SYSLOG_IDENT', 'winter')), + // Fixed syslog program tag — winter-logger requires the key; not a knob. + 'syslog_ident' => 'winter', ]; } private static function resolveOutput(string $raw): string { - if ($raw !== 'auto') { - return $raw; - } - if (getenv('KUBERNETES_SERVICE_HOST') !== false || file_exists('/.dockerenv')) { - return 'syslog'; - } - return Runtime::isSwoole() ? 'stdout' : 'stderr'; + // `auto` → stdout everywhere; whatever runs the process (orchestrator, + // supervisor, terminal) captures stdout. Explicit values pass through. + return $raw === 'auto' ? 'stdout' : $raw; } private static function threadRunnerPath(): string diff --git a/src/Old/Process/Core/DaemonStore.php b/src/Old/Process/Core/DaemonStore.php deleted file mode 100644 index 25a92d3..0000000 --- a/src/Old/Process/Core/DaemonStore.php +++ /dev/null @@ -1,28 +0,0 @@ -mainKey = str_replace('\\', '.', $className); - } - - final public function main(): FileStorage - { - return Kernel::runnable($this->mainKey); - } - - final public function threads(): FileStorage - { - return Kernel::runnable($this->mainKey . '/threads', false); - } -} diff --git a/src/Old/Process/Core/Dispatch.php b/src/Old/Process/Core/Dispatch.php deleted file mode 100644 index 62dc641..0000000 --- a/src/Old/Process/Core/Dispatch.php +++ /dev/null @@ -1,84 +0,0 @@ -make(static::class); - $thread = new Thread( - $runnable, - $runnable->exNamespace, - $runnable->exName, - $runnable->exTag - ); - $arguments = []; - if (!empty($data)) { - $storeKey = uniqid('cache-'); - DispatchStore::push($storeKey, $data); - $arguments['storeKey'] = $storeKey; - } - - return $thread->start( - arguments: $arguments - ); - } - - final public static function start(mixed $data = null): void - { - $runnable = Container::getInstance()->make(static::class); - $arguments = []; - if (!empty($data)) { - $storeKey = uniqid('cache-'); - DispatchStore::push($storeKey, $data); - $arguments['storeKey'] = $storeKey; - } - $runnable->run($arguments); - } - - final public function run(array $args): void - { - try { - $this->resolutionStart(); - $this->resolution(isset($args['storeKey']) - ? DispatchStore::pop($args['storeKey']) - : null); - } catch (\Throwable $e) { - $this->logger->error( - $e->getMessage() - . (env('DEBUG', false) - ? "\n" . $e->getTraceAsString() - : '' - ) - ); - } finally { - $this->resolutionEnd(); - } - } - - protected function resolutionStart(): void - { - $this->pid = getmypid(); - $this->logger = LoggerFactory::getLogger(static::class); - } - - abstract protected function resolutionEnd(): void; -} diff --git a/src/Old/Process/Core/DispatchStore.php b/src/Old/Process/Core/DispatchStore.php deleted file mode 100644 index d71ca5a..0000000 --- a/src/Old/Process/Core/DispatchStore.php +++ /dev/null @@ -1,25 +0,0 @@ -write($storeKey, $data); - } - - final public static function pop(string $storeKey): mixed - { - $fs = Kernel::volatile(self::$ES_NAME); - $data = $fs->read($storeKey); - $fs->del($storeKey); - return $data; - } -} diff --git a/src/Old/Process/Core/Dispatchable.php b/src/Old/Process/Core/Dispatchable.php deleted file mode 100644 index 25d0ffa..0000000 --- a/src/Old/Process/Core/Dispatchable.php +++ /dev/null @@ -1,14 +0,0 @@ -receive($options); - } catch (\Throwable $e) { - $logger->alert($e->getMessage()); - return 1; - } - if ($payload === '') { - $logger->alert('No payload received.'); - return 1; - } - - try { - $runnable = \Opis\Closure\unserialize($payload, $this->security); - } catch (\Throwable $e) { - $logger->alert('Failed to deserialize payload: ' . $e->getMessage()); - return 1; - } - - unset($payload); - - if (!$runnable instanceof Runnable) { - $logger->critical('The provided payload is not a valid Runnable object.'); - return 1; - } - - if (isset($options['detach'])) { - try { - $this->daemonize(); - } catch (ThreadException $e) { - $logger->alert($e->getMessage()); - return 1; - } - } - - $this->setProcessTitle($options, $runnable); - - try { - $runnable->run($this->parseArgs()); - return 0; - } catch (\Throwable $e) { - $logger->critical('Uncaught exception in background process: ' . $e->getMessage()); - if (env('DEBUG', false)) { - $logger->critical($e->getTraceAsString()); - } - return 1; - } - } -} diff --git a/src/Old/Process/DaemonException.php b/src/Old/Process/DaemonException.php deleted file mode 100644 index 938ee79..0000000 --- a/src/Old/Process/DaemonException.php +++ /dev/null @@ -1,15 +0,0 @@ -startedAt); - } -} diff --git a/src/Old/Process/Entity/TInfo.php b/src/Old/Process/Entity/TInfo.php deleted file mode 100644 index 6408c97..0000000 --- a/src/Old/Process/Entity/TInfo.php +++ /dev/null @@ -1,14 +0,0 @@ -rssKb / 1024; - } -} diff --git a/src/Old/Process/Entity/TStatus.php b/src/Old/Process/Entity/TStatus.php deleted file mode 100644 index d7a6050..0000000 --- a/src/Old/Process/Entity/TStatus.php +++ /dev/null @@ -1,21 +0,0 @@ -startedAt); - } -} diff --git a/src/Old/Process/Socket/Web/PDU/DecodedFrame.php b/src/Old/Process/Socket/Web/PDU/DecodedFrame.php deleted file mode 100644 index 892dc0e..0000000 --- a/src/Old/Process/Socket/Web/PDU/DecodedFrame.php +++ /dev/null @@ -1,14 +0,0 @@ -error) { - return "[type:{$this->type}, error:{$this->error}]"; - } - return "[type:{$this->type}, payload:{$this->payload}]"; - } -} diff --git a/src/Old/Process/Socket/Web/PDU/WSResource.php b/src/Old/Process/Socket/Web/PDU/WSResource.php deleted file mode 100644 index e5ce673..0000000 --- a/src/Old/Process/Socket/Web/PDU/WSResource.php +++ /dev/null @@ -1,53 +0,0 @@ -connect = $connect; - $this->info = $info; - } - - public function getConnect() - { - return $this->connect; - } - - public function getInfo(string $key): mixed - { - return $this->info[$key] ?? null; - } - - public function info(): array - { - return $this->info ?? []; - } - - public function getStore(): array - { - return $this->store; - } - - public function setStore(array $store): void - { - $this->store = $store; - } - - public function __toString(): string - { - return (string) $this->connect; - } -} diff --git a/src/Old/Process/Socket/Web/SocketWebServerHandler.php b/src/Old/Process/Socket/Web/SocketWebServerHandler.php deleted file mode 100644 index 232a648..0000000 --- a/src/Old/Process/Socket/Web/SocketWebServerHandler.php +++ /dev/null @@ -1,42 +0,0 @@ -resolutionEnd(); - $this->asInterrupt(); - exit(); - } - - private function signTermination(): never - { - $this->resolutionEnd(); - $this->asTermination(); - exit(1); - } - - private function signClose(): never - { - $this->resolutionEnd(); - $this->asClose(); - exit(1); - } - - protected function asInterrupt(): void - { - $this->logger->notice("INTERRUPTED"); - } - protected function asTermination(): void - { - $this->logger->warning("TERMINATION"); - } - protected function asClose(): void - { - $this->logger->notice("CLOSE"); - } -} diff --git a/src/Old/Process/Socket/Web/ThreadWebSocket.php b/src/Old/Process/Socket/Web/ThreadWebSocket.php deleted file mode 100644 index ad292f0..0000000 --- a/src/Old/Process/Socket/Web/ThreadWebSocket.php +++ /dev/null @@ -1,227 +0,0 @@ -prepareSignalHandler(); - } - - final public static function dispatch(mixed $data = null): int - { - return parent::dispatch($data); - } - - /** @throws ThreadException */ - final public function resolution(mixed $data = null): void - { - if (is_array($data)) { - $this->ip = (string) ($data['ip'] ?? $this->ip); - $this->port = (int) ($data['port'] ?? $this->port); - } - - $this->logger->debug("Starting the Web Server...[tcp://{$this->ip}:{$this->port}]"); - - try { - $this->resourceConnection = stream_socket_server( - "tcp://{$this->ip}:{$this->port}", - $errno, - $errorStr, - STREAM_SERVER_BIND | STREAM_SERVER_LISTEN - ); - if (!$this->resourceConnection) { - throw new ThreadException("Cannot start server: {$errorStr}({$errno})"); - } - - stream_set_blocking($this->resourceConnection, false); - $this->logger->debug("Server is running. Listening for connections..."); - $this->startTime = time(); - $this->listen(); - } catch (\Throwable $exception) { - $this->logger->critical($exception->getMessage()); - } finally { - $this->socketClose(); - } - } - - final protected function resolutionEnd(): void - { - $this->socketClose(); - } - - final protected function disconnectClient(WSResource $resource): void - { - try { - $this->handleDisconnect($resource); - } catch (\Throwable $exception) { - $this->logger->error('handlerDisconnect: ' . $exception->getMessage()); - } - - @fwrite($resource->getConnect(), WebSocketProtocol::encode('Connection closed', 'close', false, 1000)); - @fclose($resource->getConnect()); - unset($this->connects[(string) $resource]); - $this->logger->debug("Client disconnected: {$resource}"); - } - - final protected function socketClose(): void - { - foreach ($this->connects as $resource) { - $this->disconnectClient($resource); - } - $this->connects = []; - - if (is_resource($this->resourceConnection)) { - fclose($this->resourceConnection); - $this->resourceConnection = null; - } - $this->logger->debug("All connections closed."); - } - - public function send(WSResource $resource, string $payload, string $type = 'text'): void - { - if (!isset($this->connects[(string) $resource])) { - $this->logger->warning("Attempted to send to a non-existent or closed connection: {$resource}"); - return; - } - $frame = WebSocketProtocol::encode($payload, $type); - $resource->writeBuffer .= $frame; - $this->logger->debug("Queued " . strlen($frame) . " bytes to send to {$resource}"); - } - - private function listen(): void - { - while (true) { - $read = array_map(fn(WSResource $res) => $res->getConnect(), $this->connects); - $read[] = $this->resourceConnection; - $write = []; - foreach ($this->connects as $resource) { - if (strlen($resource->writeBuffer) > 0) { - $write[] = $resource->getConnect(); - } - } - $except = null; - - $seconds = intdiv($this->loopInterval, 1_000_000); - $microseconds = $this->loopInterval % 1_000_000; - $activity = @stream_select($read, $write, $except, $seconds, $microseconds); - if ($activity === false) { - continue; - } - - if (in_array($this->resourceConnection, $read, true)) { - if ($newConnection = stream_socket_accept($this->resourceConnection, 0)) { - stream_set_blocking($newConnection, false); - $info = WebSocketProtocol::handshake($newConnection); - if ($info !== false) { - $resource = new WSResource($newConnection, $info); - $this->connects[(string) $newConnection] = $resource; - $this->logger->debug("New client connected: {$resource}"); - try { - $this->handleConnect($resource); - } catch (\Throwable $exception) { - $this->logger->error('handlerConnect: ' . $exception->getMessage()); - } - } - } - unset($read[array_search($this->resourceConnection, $read, true)]); - } - - foreach ($read as $connect) { - $resource = $this->connects[(string) $connect]; - $data = @fread($connect, 65535); - - if ($data === false || ($data === '' && feof($connect))) { - $this->logger->debug("Client {$resource} has disconnected (EOF)."); - $this->disconnectClient($resource); - continue; - } - if ($data === '') { - continue; - } - - $resource->readBuffer .= $data; - while (strlen($resource->readBuffer) > 0) { - $decodedFrame = WebSocketProtocol::decode($resource->readBuffer); - if ($decodedFrame === false) { - break; - } - - $resource->readBuffer = substr($resource->readBuffer, $decodedFrame->frameLength); - $msg = $decodedFrame->msg; - - if ($msg->type === 'error' || $msg->type === 'close') { - if ($msg->type === 'error') { - $this->logger->warning( - "Received '{$msg->type}' frame from {$resource}. Closing connection." - ); - } - $this->disconnectClient($resource); - break; - } - - try { - $this->handle($resource, $msg); - } catch (\Throwable $exception) { - $this->logger->error('handler: ' . $exception->getMessage()); - } - } - } - - foreach ($write as $connect) { - $resource = $this->connects[(string) $connect]; - $bytesWritten = @fwrite($connect, $resource->writeBuffer); - if ($bytesWritten === false) { - $this->disconnectClient($resource); - continue; - } - $resource->writeBuffer = $bytesWritten === strlen($resource->writeBuffer) - ? '' - : substr($resource->writeBuffer, $bytesWritten); - } - - if ($this->timeWorkLimit > 0 && (time() - $this->startTime) > $this->timeWorkLimit) { - $this->logger->notice('Time limit reached. Stopping server.'); - break; - } - - pcntl_signal_dispatch(); - $this->loop(); - } - } - - protected function loop(): void - { - } -} diff --git a/src/Old/Process/Socket/Web/WebSocketProtocol.php b/src/Old/Process/Socket/Web/WebSocketProtocol.php deleted file mode 100644 index 7636666..0000000 --- a/src/Old/Process/Socket/Web/WebSocketProtocol.php +++ /dev/null @@ -1,195 +0,0 @@ - 136, - 'ping' => 137, - 'pong' => 138, - default => 129, // text - }; - - if ($payloadLength > 65535) { - $payloadLengthBin = str_split(sprintf('%064b', $payloadLength), 8); - $frameHead[1] = $masked ? 255 : 127; - for ($i = 0; $i < 8; $i++) { - $frameHead[$i + 2] = bindec($payloadLengthBin[$i]); - } - if ($frameHead[2] > 127) { - throw new \Exception('Frame too large (1004)'); - } - } elseif ($payloadLength > 125) { - $payloadLengthBin = str_split(sprintf('%016b', $payloadLength), 8); - $frameHead[1] = $masked ? 254 : 126; - $frameHead[2] = bindec($payloadLengthBin[0]); - $frameHead[3] = bindec($payloadLengthBin[1]); - } else { - $frameHead[1] = $masked ? $payloadLength + 128 : $payloadLength; - } - - foreach (array_keys($frameHead) as $i) { - $frameHead[$i] = chr($frameHead[$i]); - } - - $mask = []; - if ($masked) { - for ($i = 0; $i < 4; $i++) { - $mask[$i] = chr(rand(0, 255)); - } - $frameHead = array_merge($frameHead, $mask); - } - $frame = implode('', $frameHead); - - for ($i = 0; $i < $payloadLength; $i++) { - $frame .= $masked ? $payload[$i] ^ $mask[$i % 4] : $payload[$i]; - } - - return $frame; - } - - public static function decode(string $buffer): DecodedFrame|false - { - $bufferLength = strlen($buffer); - if ($bufferLength < 2) { - return false; - } - - $firstByte = ord($buffer[0]); - $secondByte = ord($buffer[1]); - $opcode = $firstByte & 15; - $isMasked = ($secondByte & 128) === 128; - $payloadLength = $secondByte & 127; - - if (!$isMasked) { - return new DecodedFrame(new Msg('error', '', 'Protocol error: Frame not masked (1002)'), 2); - } - - $type = match ($opcode) { - 1 => 'text', - 2 => 'binary', - 8 => 'close', - 9 => 'ping', - 10 => 'pong', - default => null, - }; - - if ($type === null) { - return new DecodedFrame(new Msg('error', '', "Unknown opcode: {$opcode} (1003)"), 2); - } - - $headerOffset = 2; - if ($payloadLength === 126) { - if ($bufferLength < 4) { - return false; - } - $payloadLength = unpack('n', substr($buffer, 2, 2))[1]; - $headerOffset = 4; - } elseif ($payloadLength === 127) { - if ($bufferLength < 10) { - return false; - } - $parts = unpack('N2', substr($buffer, 2, 8)); - if ($parts[1] > 0 || $parts[2] < 0) { - return new DecodedFrame(new Msg('error', '', 'Frame too large (1009)'), 10); - } - $payloadLength = $parts[2]; - $headerOffset = 10; - } - - $payloadOffset = $headerOffset + 4; - $frameLength = $payloadOffset + $payloadLength; - - if ($bufferLength < $frameLength) { - return false; - } - - $mask = substr($buffer, $headerOffset, 4); - $payload = ''; - for ($i = 0; $i < $payloadLength; $i++) { - $payload .= $buffer[$payloadOffset + $i] ^ $mask[$i % 4]; - } - - return new DecodedFrame(new Msg($type, $payload), $frameLength); - } -} diff --git a/src/Old/Process/ThreadDaemon.php b/src/Old/Process/ThreadDaemon.php deleted file mode 100644 index f905605..0000000 --- a/src/Old/Process/ThreadDaemon.php +++ /dev/null @@ -1,131 +0,0 @@ -prepareSignalHandler(); - self::store()->main()->write(static::hashName(), new TDStatus( - pid: $this->pid, - className: static::class, - condition: TCondition::STARTED, - startedAt: time(), - streamRps: $this->streamRps, - info: [] - )); - } - - final protected function resolutionEnd(): void - { - self::store()->main()->del(static::hashName()); - } - - /** - * @throws DaemonException - */ - final public static function dispatch(mixed $data = null): int - { - $info = static::status(); - if ($info) { - throw new DaemonException( - "Daemon already exist [PID:{$info->status->pid}] ({$info->status->getStartedAt()})", - HttpCode::LOCKED->value - ); - } else { - return parent::dispatch($data); - } - } - - final protected function streaming(callable $complianceCallable, ?callable $negationCallable = null): void - { - while (true) { - if (static::forkQty() < $this->streamRps) { - $complianceCallable(); - } else { - if ($negationCallable !== null) { - $negationCallable(); - } - } - usleep((int) ($this->streamRps < 1000 ? ceil(1_000_000 / $this->streamRps) : 1000)); - pcntl_signal_dispatch(); - } - } - - final public static function status(bool $showStats = false): ?TDInfo - { - try { - $key = static::hashName(); - /** @var ?TDStatus $status */ - $status = self::store()->main()->read($key); - if (!$status) { - return null; - } - - if (!posix_getpgid($status->pid)) { - self::store()->main()->del($key); - return null; - } - - return new TDInfo( - status: $status, - stats: $showStats ? TStats::ofPid($status->pid) : null - ); - } catch (FileStorageException) { - return null; - } - } - - /** - * @throws DaemonException - */ - final public static function stop(): bool - { - $info = static::status(); - if ($info) { - return Signal::interrupt($info->status->pid); - } else { - throw new DaemonException('Daemon has not started', HttpCode::LOCKED->value); - } - } -} diff --git a/src/Old/Process/ThreadJob.php b/src/Old/Process/ThreadJob.php deleted file mode 100644 index f88911d..0000000 --- a/src/Old/Process/ThreadJob.php +++ /dev/null @@ -1,32 +0,0 @@ -prepareSignalHandler(); - } - - final protected function resolutionEnd(): void - { - } - - final public static function dispatch(mixed $data = null): int - { - return parent::dispatch($data); - } -} diff --git a/src/Old/Process/ThreadProcess.php b/src/Old/Process/ThreadProcess.php deleted file mode 100644 index 5354667..0000000 --- a/src/Old/Process/ThreadProcess.php +++ /dev/null @@ -1,34 +0,0 @@ -prepareSignalHandler(); - } - - final protected function resolutionEnd(): void - { - } - - final public static function dispatch(mixed $data = null): int - { - return parent::dispatch($data); - } -} diff --git a/src/Old/Process/Traits/ThreadDaemonFork.php b/src/Old/Process/Traits/ThreadDaemonFork.php deleted file mode 100644 index 932e8a5..0000000 --- a/src/Old/Process/Traits/ThreadDaemonFork.php +++ /dev/null @@ -1,190 +0,0 @@ - $childrenPids Children process ids */ - protected array $childrenPids = []; - private bool $iAmChild = false; - - final protected function fork(callable $function): int - { - try { - $pid = pcntl_fork(); - if ($pid != -1) { - if ($pid == 0) { - // Child process - try { - $this->pid = getmypid(); - $this->forkStart('fork'); - try { - $function(); - } catch (\Throwable $exception) { - $this->logger->critical( - 'Process fork logic => ' . $exception->getMessage() - . (env('DEBUG', false) - ? "\n" . $exception->getTraceAsString() - : '' - ) - ); - } - } catch (\Throwable $exception) { - $this->logger->critical( - 'Process fork => ' . $exception->getMessage() - . (env('DEBUG', false) - ? "\n" . $exception->getTraceAsString() - : '' - ) - ); - } finally { - $this->forkEnd(); - exit(0); - } - } else { - // Parent process - if ($this->childrenPidSave) { - $this->childrenPids[] = $pid; - } - return $pid; - } - } else { - throw new RuntimeException("Unable to fork process."); - } - } catch (\Throwable $e) { - $this->logger->critical( - $e->getMessage() - . (env('DEBUG', false) - ? "\n" . $e->getTraceAsString() - : '' - ) - ); - return 0; - } - } - - final protected function forkAnonymous(mixed $data = null): int - { - try { - $pid = pcntl_fork(); - if ($pid != -1) { - if ($pid == 0) { - // Child process - try { - $this->pid = getmypid(); - $this->forkStart('anonymous'); - try { - $this->anonymousResolution($data); - } catch (\Throwable $exception) { - $this->logger->critical( - 'Process fork logic (anonymous) => ' . $exception->getMessage() - . (env('DEBUG', false) - ? "\n" . $exception->getTraceAsString() - : '' - ) - ); - } - } catch (\Throwable $exception) { - $this->logger->critical( - 'Process fork (anonymous) => ' . $exception->getMessage() - . (env('DEBUG', false) - ? "\n" . $exception->getTraceAsString() - : '' - ) - ); - } finally { - $this->forkEnd(); - exit(0); - } - } else { - // Parent process - if ($this->childrenPidSave) { - $this->childrenPids[] = $pid; - } - return $pid; - } - } else { - throw new RuntimeException("Unable to fork process."); - } - } catch (\Throwable $e) { - $this->logger->critical( - $e->getMessage() - . (env('DEBUG', false) - ? "\n" . $e->getTraceAsString() - : '' - ) - ); - return 0; - } - } - - protected function forkStart(string $tag): void - { - $this->iAmChild = true; - $this->logger = LoggerFactory::getLogger(static::class); - if ( - PHP_SAPI === 'cli' - && empty($_SERVER['REMOTE_ADDR']) - && function_exists('pcntl_signal') - ) { - $parentTitle = cli_get_process_title(); - $title = str_replace( - $this->exNamespace, - ($this->exNamespace . '(fork)'), - $parentTitle - ); - $title = str_replace($this->exTag, $tag, $title); - cli_set_process_title($title); - } - $this->preparationForkBefore($this->pid); - } - - protected function forkEnd(): void - { - $this->preparationForkAfter($this->pid); - } - - public function anonymousResolution(mixed $data = null): void - { - $this->logger->info("-forkAnonymous- running"); - } - - final public function wait(int $pid, ?callable $callableEndChild = null): void - { - if ( - PHP_SAPI === 'cli' - && empty($_SERVER['REMOTE_ADDR']) - && function_exists('pcntl_signal') - ) { - pcntl_waitpid($pid, $status); - if (!is_null($callableEndChild)) { - $callableEndChild($pid, $status); - } - pcntl_signal_dispatch(); - } - } - - final public function waitAll(?callable $callableEndChild = null): void - { - if ( - PHP_SAPI === 'cli' - && empty($_SERVER['REMOTE_ADDR']) - && function_exists('pcntl_signal') - ) { - foreach (static::forkList() as $pid) { - pcntl_waitpid($pid, $status); - if (!is_null($callableEndChild)) { - $callableEndChild($pid, $status); - } - pcntl_signal_dispatch(); - } - } - } -} diff --git a/src/Old/Process/Traits/ThreadDaemonHandler.php b/src/Old/Process/Traits/ThreadDaemonHandler.php deleted file mode 100644 index d015dd6..0000000 --- a/src/Old/Process/Traits/ThreadDaemonHandler.php +++ /dev/null @@ -1,86 +0,0 @@ -iAmChild) { - foreach (static::forkList() as $childPid) { - posix_kill($childPid, SIGINT); - pcntl_waitpid($childPid, $status); - } - $this->resolutionEnd(); - $this->asInterrupt(); - } else { - $this->preparationForkAfter($this->pid); - $this->asChildInterrupt(); - } - exit(); - } - - private function signTermination(): never - { - if (!$this->iAmChild) { - foreach (static::forkList() as $childPid) { - posix_kill($childPid, SIGTERM); - pcntl_waitpid($childPid, $status); - } - $this->resolutionEnd(); - $this->asTermination(); - } else { - $this->preparationForkAfter($this->pid); - $this->asChildTermination(); - } - exit(1); - } - - private function signClose(): never - { - if (!$this->iAmChild) { - foreach (static::forkList() as $childPid) { - posix_kill($childPid, SIGHUP); - pcntl_waitpid($childPid, $status); - } - $this->resolutionEnd(); - $this->asClose(); - } else { - $this->preparationForkAfter($this->pid); - $this->asChildClose(); - } - exit(1); - } - - protected function asInterrupt(): void - { - $this->logger->notice("INTERRUPTED"); - } - - protected function asTermination(): void - { - $this->logger->warning("TERMINATION"); - } - - protected function asClose(): void - { - $this->logger->notice("CLOSE"); - } - - protected function asChildInterrupt(): void - { - $this->logger->notice("INTERRUPTED CHILD"); - } - - protected function asChildTermination(): void - { - $this->logger->warning("TERMINATION CHILD"); - } - - protected function asChildClose(): void - { - $this->logger->notice("CLOSE CHILD"); - } -} diff --git a/src/Old/Process/Traits/ThreadDaemonStatement.php b/src/Old/Process/Traits/ThreadDaemonStatement.php deleted file mode 100644 index b15e226..0000000 --- a/src/Old/Process/Traits/ThreadDaemonStatement.php +++ /dev/null @@ -1,139 +0,0 @@ -threads()->keys(); - return count($keys); - } - - /** - * @throws FileStorageException - */ - final public static function forkList(): array - { - $keys = static::store()->threads()->keys(); - foreach ($keys as $key => $path) { - $keys[$key] = (int) trim($path, '_'); - } - return $keys; - } - - /** - * @return TInfo[] - * @throws FileStorageException - */ - final public static function forkListInfo(bool $showStats = false): array - { - $store = static::store()->threads(); - $keys = $store->keys(); - foreach ($keys as $key => $path) { - $pid = (int) trim($path, '_'); - $keys[$key] = new TInfo( - status: $store->read($path), - stats: $showStats ? TStats::ofPid($pid) : null - ); - } - return $keys; - } - - /** - * @throws FileStorageException - */ - final public static function forkInfo(int $forkPid, bool $showStats = false): ?TInfo - { - $store = static::store()->threads(); - $status = $store->read("_{$forkPid}_"); - if (!$status) { - return null; - } - - return new TInfo( - status: $status, - stats: $showStats ? TStats::ofPid($forkPid) : null - ); - } - - final public static function forkSetCondition(int $threadPid, TCondition $newCondition): void - { - $store = static::store()->threads(); - /** @var TStatus $status */ - $status = $store->read("_{$threadPid}_"); - $status->condition = $newCondition; - $store->write("_{$threadPid}_", $status); - } - - final protected function setCondition(TCondition $newCondition): void - { - $store = static::store()->main(); - $key = static::hashName(); - /** @var TDStatus $status */ - $status = $store->read($key); - $status->condition = $newCondition; - $store->write($key, $status); - } - - final protected function setInfo(array $newInfo): void - { - $store = static::store()->main(); - $key = static::hashName(); - /** @var TDStatus $status */ - $status = $store->read($key); - $status->info = $newInfo; - $store->write($key, $status); - } - - final protected function prepare(int $streamRps = 0): void - { - $store = static::store()->main(); - $key = static::hashName(); - /** @var TDStatus $status */ - $status = $store->read($key); - $status->condition = TCondition::PREPARATION; - $store->write($key, $status); - - $status->streamRps = $streamRps; - $this->streamRps = $streamRps; - $store->write($key, $status); - $this->preparation(); - - /** @var TDStatus $status */ - $status = $store->read($key); - $status->condition = TCondition::ACTIVE; - $store->write($key, $status); - } - - protected function preparationForkBefore(int $forkPid): void - { - static::store()->threads() - ->write("_{$forkPid}_", new TStatus( - pid: $forkPid, - condition: TCondition::STARTED, - startedAt: time() - )); - } - - protected function preparationForkAfter(int $forkPid): void - { - static::store()->threads()->del("_{$forkPid}_"); - } -} diff --git a/src/Old/Process/Traits/ThreadFork.php b/src/Old/Process/Traits/ThreadFork.php deleted file mode 100644 index 8331abf..0000000 --- a/src/Old/Process/Traits/ThreadFork.php +++ /dev/null @@ -1,188 +0,0 @@ - $childrenPids Children process ids */ - protected array $childrenPids = []; - private bool $iAmChild = false; - - final protected function fork(callable $function): int - { - try { - $pid = pcntl_fork(); - if ($pid != -1) { - if ($pid == 0) { - // Child process - try { - $this->pid = getmypid(); - $this->forkStart('fork'); - try { - $function(); - } catch (\Throwable $exception) { - $this->logger->critical( - 'Process fork logic => ' . $exception->getMessage() - . (env('DEBUG', false) - ? "\n" . $exception->getTraceAsString() - : '' - ) - ); - } - } catch (\Throwable $exception) { - $this->logger->critical( - 'Process fork => ' . $exception->getMessage() - . (env('DEBUG', false) - ? "\n" . $exception->getTraceAsString() - : '' - ) - ); - } finally { - $this->forkEnd(); - exit(0); - } - } else { - // Parent process - if ($this->childrenPidSave) { - $this->childrenPids[] = $pid; - } - return $pid; - } - } else { - throw new RuntimeException("Unable to fork process."); - } - } catch (\Throwable $e) { - $this->logger->critical( - $e->getMessage() - . (env('DEBUG', false) - ? "\n" . $e->getTraceAsString() - : '' - ) - ); - return 0; - } - } - - final protected function forkAnonymous(mixed $data = null): int - { - try { - $pid = pcntl_fork(); - if ($pid != -1) { - if ($pid == 0) { - // Child process - try { - $this->pid = getmypid(); - $this->forkStart('anonymous'); - try { - $this->anonymousResolution($data); - } catch (\Throwable $exception) { - $this->logger->critical( - 'Process fork logic (anonymous) => ' . $exception->getMessage() - . (env('DEBUG', false) - ? "\n" . $exception->getTraceAsString() - : '' - ) - ); - } - } catch (\Throwable $exception) { - $this->logger->critical( - 'Process fork (anonymous) => ' . $exception->getMessage() - . (env('DEBUG', false) - ? "\n" . $exception->getTraceAsString() - : '' - ) - ); - } finally { - $this->forkEnd(); - exit(0); - } - } else { - // Parent process - if ($this->childrenPidSave) { - $this->childrenPids[] = $pid; - } - return $pid; - } - } else { - throw new RuntimeException("Unable to fork process."); - } - } catch (\Throwable $e) { - $this->logger->critical( - $e->getMessage() - . (env('DEBUG', false) - ? "\n" . $e->getTraceAsString() - : '' - ) - ); - return 0; - } - } - - protected function forkStart(string $tag): void - { - $this->iAmChild = true; - $this->logger = LoggerFactory::getLogger(static::class); - if ( - PHP_SAPI === 'cli' - && empty($_SERVER['REMOTE_ADDR']) - && function_exists('pcntl_signal') - ) { - $parentTitle = cli_get_process_title(); - $title = str_replace( - $this->exNamespace, - ($this->exNamespace . '(fork)'), - $parentTitle - ); - $title = str_replace($this->exTag, $tag, $title); - cli_set_process_title($title); - } - } - - protected function forkEnd(): void - { - } - - public function anonymousResolution(mixed $data = null): void - { - $this->logger->info("-forkAnonymous- running"); - } - - final public function wait(int $pid, ?callable $callableEndChild = null): void - { - if ( - PHP_SAPI === 'cli' - && empty($_SERVER['REMOTE_ADDR']) - && function_exists('pcntl_signal') - ) { - pcntl_waitpid($pid, $status); - if (!is_null($callableEndChild)) { - $callableEndChild($pid, $status); - } - pcntl_signal_dispatch(); - } - } - - final public function waitAll(?callable $callableEndChild = null): void - { - if ( - PHP_SAPI === 'cli' - && empty($_SERVER['REMOTE_ADDR']) - && function_exists('pcntl_signal') - ) { - foreach ($this->childrenPids as $pid) { - pcntl_waitpid($pid, $status); - if (!is_null($callableEndChild)) { - $callableEndChild($pid, $status); - } - pcntl_signal_dispatch(); - } - } - } -} diff --git a/src/Old/Process/Traits/ThreadJobHandler.php b/src/Old/Process/Traits/ThreadJobHandler.php deleted file mode 100644 index 04b5b24..0000000 --- a/src/Old/Process/Traits/ThreadJobHandler.php +++ /dev/null @@ -1,44 +0,0 @@ -resolutionEnd(); - $this->asInterrupt(); - exit(); - } - - private function signTermination(): never - { - $this->resolutionEnd(); - $this->asTermination(); - exit(1); - } - - private function signClose(): never - { - $this->resolutionEnd(); - $this->asClose(); - exit(1); - } - - protected function asInterrupt(): void - { - $this->logger->notice("INTERRUPTED"); - } - - protected function asTermination(): void - { - $this->logger->warning("TERMINATION"); - } - - protected function asClose(): void - { - $this->logger->notice("CLOSE"); - } -} diff --git a/src/Old/Process/Traits/ThreadProcessHandler.php b/src/Old/Process/Traits/ThreadProcessHandler.php deleted file mode 100644 index 4bfdd51..0000000 --- a/src/Old/Process/Traits/ThreadProcessHandler.php +++ /dev/null @@ -1,83 +0,0 @@ -iAmChild) { - foreach ($this->childrenPids as $childPid) { - posix_kill($childPid, SIGINT); - pcntl_waitpid($childPid, $status); - } - $this->resolutionEnd(); - $this->asInterrupt(); - } else { - $this->asChildInterrupt(); - } - exit(); - } - - private function signTermination(): never - { - if (!$this->iAmChild) { - foreach ($this->childrenPids as $childPid) { - posix_kill($childPid, SIGTERM); - pcntl_waitpid($childPid, $status); - } - $this->resolutionEnd(); - $this->asTermination(); - } else { - $this->asChildTermination(); - } - exit(1); - } - - private function signClose(): never - { - if (!$this->iAmChild) { - foreach ($this->childrenPids as $childPid) { - posix_kill($childPid, SIGHUP); - pcntl_waitpid($childPid, $status); - } - $this->resolutionEnd(); - $this->asClose(); - } else { - $this->asChildClose(); - } - exit(1); - } - - protected function asInterrupt(): void - { - $this->logger->notice("INTERRUPTED"); - } - - protected function asTermination(): void - { - $this->logger->warning("TERMINATION"); - } - - protected function asClose(): void - { - $this->logger->notice("CLOSE"); - } - - protected function asChildInterrupt(): void - { - $this->logger->notice("INTERRUPTED CHILD"); - } - - protected function asChildTermination(): void - { - $this->logger->warning("TERMINATION CHILD"); - } - - protected function asChildClose(): void - { - $this->logger->notice("CLOSE CHILD"); - } -} diff --git a/src/Old/Process/Traits/ThreadSignalHandler.php b/src/Old/Process/Traits/ThreadSignalHandler.php deleted file mode 100644 index bc431e2..0000000 --- a/src/Old/Process/Traits/ThreadSignalHandler.php +++ /dev/null @@ -1,28 +0,0 @@ -signClose(); - }); - pcntl_signal(SIGINT, function () { - $this->signInterrupt(); - }); - pcntl_signal(SIGTERM, function () { - $this->signTermination(); - }); - } - } -} diff --git a/src/Process/ProcessStore.php b/src/Process/ProcessStore.php index 0c0c6b3..60da9a3 100644 --- a/src/Process/ProcessStore.php +++ b/src/Process/ProcessStore.php @@ -10,9 +10,8 @@ /** * Locates the runnable store for a process class. * - * One record per class, keyed by the dotted class name — the same convention - * as {@see \Flytachi\Winter\K2\Old\Process\Core\DaemonStore}, so CLI and web read - * from a single place. + * One record per class, keyed by the dotted class name, so CLI and web read from + * a single place. */ final class ProcessStore { diff --git a/src/Route/DevWatcher.php b/src/Route/DevWatcher.php index b1d73ec..69dcdf0 100644 --- a/src/Route/DevWatcher.php +++ b/src/Route/DevWatcher.php @@ -22,7 +22,7 @@ * old controller/service classes cached in the master. Re-exec'ing the process image * is the only reliable way to reflect changes to master-loaded code. * - * Usage (from Application::serveHttp, dev path): + * Usage (from WinterApplication::serveHttp, dev path): * $dev = new DevWatcher([Kernel::$pathRoot]); * $dev->attach($server, $onWorkerStart); * $server->on('request', $dev->wrap($handler)); diff --git a/src/Stereotype/Daemon.php b/src/Stereotype/Daemon.php deleted file mode 100644 index c4881c3..0000000 --- a/src/Stereotype/Daemon.php +++ /dev/null @@ -1,11 +0,0 @@ - Date: Wed, 29 Jul 2026 14:08:49 +0500 Subject: [PATCH 29/71] starter --- src/Ppa/Pool/PpaConnectionPool.php | 142 +++++++++++++++++++++++++---- 1 file changed, 124 insertions(+), 18 deletions(-) diff --git a/src/Ppa/Pool/PpaConnectionPool.php b/src/Ppa/Pool/PpaConnectionPool.php index 103a4d1..171da45 100644 --- a/src/Ppa/Pool/PpaConnectionPool.php +++ b/src/Ppa/Pool/PpaConnectionPool.php @@ -24,8 +24,13 @@ * CDO is borrowed and cached in the coroutine context; a `defer` returns it * automatically when the coroutine ends — no manual release anywhere in the codebase. * - * Broken connections: pass `null` to `Swoole\ConnectionPool::put()` and the pool - * will discard and recreate the slot automatically. + * Dead connections: a pooled connection can die while it sits idle in the pool — + * the server recycles it (`server_lifetime` / idle timeout), a network blip drops + * it, or a connection pooler in front of the database (e.g. PgBouncer) closes the + * upstream. `Swoole\ConnectionPool` does not detect this, so every borrow is + * validated with a trivial round-trip; a dead one is discarded (`put(null)` opens + * a fresh slot) and re-borrowed. Without this, a recycled connection is handed to + * the next coroutine and surfaces as an intermittent SSL error or read timeout. * * ## Pool size * Configs that implement {@see PpaPoolConfigInterface} (via {@see PpaPoolTrait}) @@ -51,6 +56,21 @@ final class PpaConnectionPool */ private const int DEFAULT_POOL_SIZE = 5; + /** + * How many times a borrowed-but-dead connection is discarded and reopened + * before giving up. Each `put(null)` opens a brand-new socket, so one retry is + * normally enough; the small margin covers several stale slots in a row. + */ + private const int MAX_STALE_RETRIES = 2; + + /** + * Seconds a static (non-coroutine) connection may sit idle before the next + * handout re-probes it. Rapid successive calls (an FPM request) stay under this + * and skip the probe; a long-running process that was idle longer gets its cached + * connection validated — and transparently reopened if the server recycled it. + */ + private const float STATIC_VALIDATE_IDLE = 1.0; + /** * Swoole: one ConnectionPool per config class. * @var array @@ -69,6 +89,12 @@ final class PpaConnectionPool */ private static array $static = []; + /** + * Monotonic seconds a static CDO was last handed out — drives idle re-validation. + * @var array + */ + private static array $staticLastUsed = []; + private static function logger(): LoggerInterface { return LoggerFactory::getLogger('PPA'); @@ -148,6 +174,7 @@ public static function reset(): void { self::$pools = []; self::$static = []; + self::$staticLastUsed = []; self::$configs = []; } @@ -156,15 +183,34 @@ public static function reset(): void // ------------------------------------------------------------------------- /** - * FPM path: singleton CDO per config class for the process lifetime. + * Non-coroutine path (FPM, CLI, a Sync-engine process): one CDO per config class, + * cached for the process lifetime. + * + * Under FPM the process is short-lived, so the connection cannot go stale within a + * request. A long-running process, however, can outlive its connection — the + * server or a pooler (PgBouncer) recycles it while idle — so after an idle gap + * ({@see STATIC_VALIDATE_IDLE}) the cached connection is probed with {@see isAlive()} + * and transparently reopened if dead. Rapid successive calls skip the probe, so the + * FPM hot path pays nothing. */ private static function staticDb(string $configClass): CDO { $key = base64_encode($configClass); - if (!isset(self::$static[$key])) { - self::$static[$key] = self::getConfigDb($configClass)->connection(); - self::logger()->debug("FPM connection opened: {$configClass}"); + $now = hrtime(true) / 1e9; + + if (isset(self::$static[$key])) { + $idle = $now - (self::$staticLastUsed[$key] ?? $now); + if ($idle < self::STATIC_VALIDATE_IDLE || self::isAlive(self::$static[$key])) { + self::$staticLastUsed[$key] = $now; + return self::$static[$key]; + } + self::logger()->warning("static connection stale, reconnecting: {$configClass}"); + unset(self::$static[$key]); } + + self::$static[$key] = self::getConfigDb($configClass)->connection(); + self::$staticLastUsed[$key] = $now; + self::logger()->debug("connection opened: {$configClass}"); return self::$static[$key]; } @@ -187,6 +233,44 @@ private static function coroutineDb(string $configClass): CDO $cid = \Swoole\Coroutine::getCid(); self::logger()->debug("cid={$cid} borrow: {$configClass}"); + $cdo = self::borrowLive($swPool, $timeout, $configClass, $cid); + + $ctx[$ctxKey] = $cdo; + + // Auto-return when the coroutine finishes (normal exit OR exception). + // $cdo is captured directly — safer than reading from $ctx during teardown. + \Swoole\Coroutine::defer(static function () use ($swPool, $cdo, $cid, $configClass): void { + self::logger()->debug("cid={$cid} release: {$configClass}"); + $swPool->put($cdo); + }); + } + + $driver = $ctx[$ctxKey]->getAttribute(\PDO::ATTR_DRIVER_NAME); + if (!empty($driver)) { + $ctx[$ctxKey]->applyDatabaseTimezone($driver, date_default_timezone_get()); + } + + return $ctx[$ctxKey]; + } + + /** + * Borrows a connection from the pool and guarantees it is live. A pooled + * connection may have died while idle (server recycle / idle timeout / a pooler + * like PgBouncer closing the upstream), which `Swoole\ConnectionPool` cannot + * detect. Each borrow is probed with {@see isAlive()}; a dead one is discarded — + * `put(null)` decrements the slot and opens a fresh socket — and re-borrowed, up + * to {@see MAX_STALE_RETRIES} times. + * + * @throws PpaPoolException On connect failure, pool exhaustion, or if no live + * connection can be obtained within the retry budget. + */ + private static function borrowLive( + \Swoole\ConnectionPool $swPool, + float $timeout, + string $configClass, + int $cid, + ): CDO { + for ($attempt = 0; $attempt <= self::MAX_STALE_RETRIES; ++$attempt) { try { /** @var CDO|false $cdo */ $cdo = $swPool->get($timeout); @@ -206,22 +290,44 @@ private static function coroutineDb(string $configClass): CDO ); } - $ctx[$ctxKey] = $cdo; + if (self::isAlive($cdo)) { + return $cdo; + } - // Auto-return when the coroutine finishes (normal exit OR exception). - // $cdo is captured directly — safer than reading from $ctx during teardown. - \Swoole\Coroutine::defer(static function () use ($swPool, $cdo, $cid, $configClass): void { - self::logger()->debug("cid={$cid} release: {$configClass}"); - $swPool->put($cdo); - }); + // Dead connection (idle-recycled by the server / pooler): discard it and + // let the pool open a fresh one. put(null) makes a new socket into the slot. + self::logger()->warning("cid={$cid} stale connection discarded, reopening: {$configClass}"); + try { + $swPool->put(null); + } catch (\Throwable $e) { + self::logger()->error("cid={$cid} reconnect failed: {$configClass} — {$e->getMessage()}"); + throw new PpaPoolException( + "PpaConnectionPool: reconnect failed for [{$configClass}] — {$e->getMessage()}", + previous: $e + ); + } } - $driver = $ctx[$ctxKey]->getAttribute(\PDO::ATTR_DRIVER_NAME); - if (!empty($driver)) { - $ctx[$ctxKey]->applyDatabaseTimezone($driver, date_default_timezone_get()); - } + throw new PpaPoolException( + "PpaConnectionPool: could not obtain a live connection for [{$configClass}] " + . "after " . (self::MAX_STALE_RETRIES + 1) . " attempts" + ); + } - return $ctx[$ctxKey]; + /** + * Lightweight liveness probe: a trivial round-trip that fails on a dead socket. + * The driver name is cached on the CDO (no round-trip), so this is safe to call + * on a dead connection. In non-debug mode PDO returns `false` on failure instead + * of throwing, so both a `false` result and a thrown error mean "dead". + */ + private static function isAlive(CDO $cdo): bool + { + try { + $sql = $cdo->getDriverName() === 'oci' ? 'SELECT 1 FROM DUAL' : 'SELECT 1'; + return $cdo->query($sql) !== false; + } catch (\Throwable) { + return false; + } } /** From 8b20fd0e4c0ec81b28b3f155473c57b74701acbe Mon Sep 17 00:00:00 2001 From: flytachi Date: Wed, 29 Jul 2026 14:39:34 +0500 Subject: [PATCH 30/71] starter --- src/Ppa/Pool/PpaConnectionPool.php | 142 ++++------------------------- 1 file changed, 18 insertions(+), 124 deletions(-) diff --git a/src/Ppa/Pool/PpaConnectionPool.php b/src/Ppa/Pool/PpaConnectionPool.php index 171da45..103a4d1 100644 --- a/src/Ppa/Pool/PpaConnectionPool.php +++ b/src/Ppa/Pool/PpaConnectionPool.php @@ -24,13 +24,8 @@ * CDO is borrowed and cached in the coroutine context; a `defer` returns it * automatically when the coroutine ends — no manual release anywhere in the codebase. * - * Dead connections: a pooled connection can die while it sits idle in the pool — - * the server recycles it (`server_lifetime` / idle timeout), a network blip drops - * it, or a connection pooler in front of the database (e.g. PgBouncer) closes the - * upstream. `Swoole\ConnectionPool` does not detect this, so every borrow is - * validated with a trivial round-trip; a dead one is discarded (`put(null)` opens - * a fresh slot) and re-borrowed. Without this, a recycled connection is handed to - * the next coroutine and surfaces as an intermittent SSL error or read timeout. + * Broken connections: pass `null` to `Swoole\ConnectionPool::put()` and the pool + * will discard and recreate the slot automatically. * * ## Pool size * Configs that implement {@see PpaPoolConfigInterface} (via {@see PpaPoolTrait}) @@ -56,21 +51,6 @@ final class PpaConnectionPool */ private const int DEFAULT_POOL_SIZE = 5; - /** - * How many times a borrowed-but-dead connection is discarded and reopened - * before giving up. Each `put(null)` opens a brand-new socket, so one retry is - * normally enough; the small margin covers several stale slots in a row. - */ - private const int MAX_STALE_RETRIES = 2; - - /** - * Seconds a static (non-coroutine) connection may sit idle before the next - * handout re-probes it. Rapid successive calls (an FPM request) stay under this - * and skip the probe; a long-running process that was idle longer gets its cached - * connection validated — and transparently reopened if the server recycled it. - */ - private const float STATIC_VALIDATE_IDLE = 1.0; - /** * Swoole: one ConnectionPool per config class. * @var array @@ -89,12 +69,6 @@ final class PpaConnectionPool */ private static array $static = []; - /** - * Monotonic seconds a static CDO was last handed out — drives idle re-validation. - * @var array - */ - private static array $staticLastUsed = []; - private static function logger(): LoggerInterface { return LoggerFactory::getLogger('PPA'); @@ -174,7 +148,6 @@ public static function reset(): void { self::$pools = []; self::$static = []; - self::$staticLastUsed = []; self::$configs = []; } @@ -183,34 +156,15 @@ public static function reset(): void // ------------------------------------------------------------------------- /** - * Non-coroutine path (FPM, CLI, a Sync-engine process): one CDO per config class, - * cached for the process lifetime. - * - * Under FPM the process is short-lived, so the connection cannot go stale within a - * request. A long-running process, however, can outlive its connection — the - * server or a pooler (PgBouncer) recycles it while idle — so after an idle gap - * ({@see STATIC_VALIDATE_IDLE}) the cached connection is probed with {@see isAlive()} - * and transparently reopened if dead. Rapid successive calls skip the probe, so the - * FPM hot path pays nothing. + * FPM path: singleton CDO per config class for the process lifetime. */ private static function staticDb(string $configClass): CDO { $key = base64_encode($configClass); - $now = hrtime(true) / 1e9; - - if (isset(self::$static[$key])) { - $idle = $now - (self::$staticLastUsed[$key] ?? $now); - if ($idle < self::STATIC_VALIDATE_IDLE || self::isAlive(self::$static[$key])) { - self::$staticLastUsed[$key] = $now; - return self::$static[$key]; - } - self::logger()->warning("static connection stale, reconnecting: {$configClass}"); - unset(self::$static[$key]); + if (!isset(self::$static[$key])) { + self::$static[$key] = self::getConfigDb($configClass)->connection(); + self::logger()->debug("FPM connection opened: {$configClass}"); } - - self::$static[$key] = self::getConfigDb($configClass)->connection(); - self::$staticLastUsed[$key] = $now; - self::logger()->debug("connection opened: {$configClass}"); return self::$static[$key]; } @@ -233,44 +187,6 @@ private static function coroutineDb(string $configClass): CDO $cid = \Swoole\Coroutine::getCid(); self::logger()->debug("cid={$cid} borrow: {$configClass}"); - $cdo = self::borrowLive($swPool, $timeout, $configClass, $cid); - - $ctx[$ctxKey] = $cdo; - - // Auto-return when the coroutine finishes (normal exit OR exception). - // $cdo is captured directly — safer than reading from $ctx during teardown. - \Swoole\Coroutine::defer(static function () use ($swPool, $cdo, $cid, $configClass): void { - self::logger()->debug("cid={$cid} release: {$configClass}"); - $swPool->put($cdo); - }); - } - - $driver = $ctx[$ctxKey]->getAttribute(\PDO::ATTR_DRIVER_NAME); - if (!empty($driver)) { - $ctx[$ctxKey]->applyDatabaseTimezone($driver, date_default_timezone_get()); - } - - return $ctx[$ctxKey]; - } - - /** - * Borrows a connection from the pool and guarantees it is live. A pooled - * connection may have died while idle (server recycle / idle timeout / a pooler - * like PgBouncer closing the upstream), which `Swoole\ConnectionPool` cannot - * detect. Each borrow is probed with {@see isAlive()}; a dead one is discarded — - * `put(null)` decrements the slot and opens a fresh socket — and re-borrowed, up - * to {@see MAX_STALE_RETRIES} times. - * - * @throws PpaPoolException On connect failure, pool exhaustion, or if no live - * connection can be obtained within the retry budget. - */ - private static function borrowLive( - \Swoole\ConnectionPool $swPool, - float $timeout, - string $configClass, - int $cid, - ): CDO { - for ($attempt = 0; $attempt <= self::MAX_STALE_RETRIES; ++$attempt) { try { /** @var CDO|false $cdo */ $cdo = $swPool->get($timeout); @@ -290,44 +206,22 @@ private static function borrowLive( ); } - if (self::isAlive($cdo)) { - return $cdo; - } + $ctx[$ctxKey] = $cdo; - // Dead connection (idle-recycled by the server / pooler): discard it and - // let the pool open a fresh one. put(null) makes a new socket into the slot. - self::logger()->warning("cid={$cid} stale connection discarded, reopening: {$configClass}"); - try { - $swPool->put(null); - } catch (\Throwable $e) { - self::logger()->error("cid={$cid} reconnect failed: {$configClass} — {$e->getMessage()}"); - throw new PpaPoolException( - "PpaConnectionPool: reconnect failed for [{$configClass}] — {$e->getMessage()}", - previous: $e - ); - } + // Auto-return when the coroutine finishes (normal exit OR exception). + // $cdo is captured directly — safer than reading from $ctx during teardown. + \Swoole\Coroutine::defer(static function () use ($swPool, $cdo, $cid, $configClass): void { + self::logger()->debug("cid={$cid} release: {$configClass}"); + $swPool->put($cdo); + }); } - throw new PpaPoolException( - "PpaConnectionPool: could not obtain a live connection for [{$configClass}] " - . "after " . (self::MAX_STALE_RETRIES + 1) . " attempts" - ); - } - - /** - * Lightweight liveness probe: a trivial round-trip that fails on a dead socket. - * The driver name is cached on the CDO (no round-trip), so this is safe to call - * on a dead connection. In non-debug mode PDO returns `false` on failure instead - * of throwing, so both a `false` result and a thrown error mean "dead". - */ - private static function isAlive(CDO $cdo): bool - { - try { - $sql = $cdo->getDriverName() === 'oci' ? 'SELECT 1 FROM DUAL' : 'SELECT 1'; - return $cdo->query($sql) !== false; - } catch (\Throwable) { - return false; + $driver = $ctx[$ctxKey]->getAttribute(\PDO::ATTR_DRIVER_NAME); + if (!empty($driver)) { + $ctx[$ctxKey]->applyDatabaseTimezone($driver, date_default_timezone_get()); } + + return $ctx[$ctxKey]; } /** From ffbb9ba3ae0995de0b2b711d76fa709aa6896118 Mon Sep 17 00:00:00 2001 From: flytachi Date: Fri, 31 Jul 2026 14:40:53 +0500 Subject: [PATCH 31/71] docker --- console/Command/Cfg.php | 45 ++----- console/Command/Complete.php | 2 +- console/Template/Docker/Dockerfile | 121 ++++-------------- console/Template/Docker/docker-compose.yml | 11 +- .../Template/Docker/docker/dependencies.sh | 89 ------------- .../Docker/docker/dependencies/10-bcmath.sh | 9 ++ .../Docker/docker/dependencies/20-pgsql.sh | 15 +++ .../Docker/docker/dependencies/30-mysql.sh | 14 ++ .../Docker/docker/dependencies/40-redis.sh | 12 ++ console/Template/Docker/docker/entrypoint.sh | 22 ++++ .../Template/Docker/docker/fpm/entrypoint.sh | 10 -- console/Template/Docker/docker/fpm/nginx.conf | 104 --------------- .../Template/Docker/docker/fpm/php-fpm.conf | 87 ------------- .../Docker/docker/fpm/php-opcache.ini | 86 ------------- .../docker/{swoole => }/php-opcache.ini | 25 ++-- .../Docker/docker/swoole/entrypoint.sh | 19 --- src/Route/DevWatcher.php | 16 +++ 17 files changed, 141 insertions(+), 546 deletions(-) delete mode 100644 console/Template/Docker/docker/dependencies.sh create mode 100644 console/Template/Docker/docker/dependencies/10-bcmath.sh create mode 100644 console/Template/Docker/docker/dependencies/20-pgsql.sh create mode 100644 console/Template/Docker/docker/dependencies/30-mysql.sh create mode 100644 console/Template/Docker/docker/dependencies/40-redis.sh create mode 100644 console/Template/Docker/docker/entrypoint.sh delete mode 100644 console/Template/Docker/docker/fpm/entrypoint.sh delete mode 100644 console/Template/Docker/docker/fpm/nginx.conf delete mode 100644 console/Template/Docker/docker/fpm/php-fpm.conf delete mode 100644 console/Template/Docker/docker/fpm/php-opcache.ini rename console/Template/Docker/docker/{swoole => }/php-opcache.ini (74%) delete mode 100644 console/Template/Docker/docker/swoole/entrypoint.sh diff --git a/console/Command/Cfg.php b/console/Command/Cfg.php index 316e098..cd4c935 100644 --- a/console/Command/Cfg.php +++ b/console/Command/Cfg.php @@ -200,37 +200,18 @@ private function envShow(): void private function dockerArg(): void { - $runtime = array_key_exists('swoole', $this->args['options']) ? 'swoole' : 'fpm'; - - // Single unified template: one stable Dockerfile + docker/ (fpm, swoole, - // dependencies.sh) + compose. The runtime is selected by the RUNTIME - // build arg, not by which files are copied. + // Flat Swoole template: Dockerfile + docker-compose.yml + docker/ + // (entrypoint.sh, php-opcache.ini, dependencies/*.sh). dev vs prod is the + // DEV var in compose; DB drivers / extensions are the dependencies/ scripts. multiCopy($this->templatePath . '/Docker', Kernel::$pathRoot); - $this->setComposeRuntime($runtime); - self::printBadge('runtime', $runtime, 34, 36); self::printBadge('docker/', 'CREATED', 34, 32); - self::printBadge('docker/dependencies.sh', 'CREATED', 34, 32); + self::printBadge('docker/dependencies/', 'CREATED', 34, 32); self::printBadge('.dockerignore', 'CREATED', 34, 32); self::printBadge('docker-compose.yml', 'CREATED', 34, 32); self::printBadge('Dockerfile', 'CREATED', 34, 32); - self::printInfo("Switch runtime anytime: set RUNTIME (fpm|swoole) in docker-compose.yml"); - self::printInfo("Add extensions/cron: edit docker/dependencies.sh"); - } - - /** - * Pin the default RUNTIME build arg in the freshly scaffolded compose file. - * The template ships `RUNTIME: fpm`; on `--swoole` we flip it to swoole so - * `docker compose build` picks the requested runtime out of the box. - */ - private function setComposeRuntime(string $runtime): void - { - $compose = Kernel::$pathRoot . '/docker-compose.yml'; - if (is_file($compose)) { - $content = file_get_contents($compose); - $content = preg_replace('/(RUNTIME:\s*)(?:fpm|swoole)/', '${1}' . $runtime, $content, 1); - file_put_contents($compose, $content); - } + self::printInfo("Run: docker compose up (dev, hot-reload) | DEV=false docker compose up (prod)"); + self::printInfo("DB drivers / extensions: keep or remove docker/dependencies/*.sh"); } private function completionArg(): void @@ -356,11 +337,11 @@ public static function help(): void // docker self::printLabel("docker — scaffold Docker files", $cl); - self::print("--fpm default runtime = PHP-FPM + Nginx (default)", $cl); - self::print("--swoole default runtime = Swoole HTTP server", $cl); - self::print(" one stable Dockerfile serves both; switch via", $cl); - self::print(" RUNTIME in docker-compose.yml. Extra packages,", $cl); - self::print(" redis/pgsql/mysql, cron → docker/dependencies.sh", $cl); + self::print("Swoole image. dev vs prod = DEV in docker-compose.yml:", $cl); + self::print(" docker compose up dev (hot-reload, opcache off)", $cl); + self::print(" DEV=false docker compose up prod (opcache on, no watcher)", $cl); + self::print("DB drivers / extensions / cron → docker/dependencies/*.sh", $cl); + self::print(" (keep or remove; bcmath, pgsql, mysql, redis shipped)", $cl); self::printLabel("docker — scaffold Docker files", $cl); // examples @@ -372,9 +353,7 @@ public static function help(): void self::printInfo("call cfg env -i"); self::printInfo("call cfg env -s"); self::printInfo("call cfg env -s --file"); - self::printInfo("call cfg docker (fpm mode, default)"); - self::printInfo("call cfg docker --fpm (explicit fpm mode)"); - self::printInfo("call cfg docker --swoole (swoole mode)"); + self::printInfo("call cfg docker (scaffold Docker files)"); self::printInfo("call cfg completion (print scripts to stdout)"); self::printInfo("call cfg completion -i (install: ~/.zsh/completions/_call or ~/.bash_completion.d/call)"); self::printInfo("call cfg completion -if (force update installed file)"); diff --git a/console/Command/Complete.php b/console/Command/Complete.php index 1953655..472b9b7 100644 --- a/console/Command/Complete.php +++ b/console/Command/Complete.php @@ -49,7 +49,7 @@ class Complete extends Cmd ], 'cfg key' => ['-g:generate WINTER_KEY', '-s:show current key'], 'cfg env' => ['-i:create .env from template', '-s:show loaded env vars', '--file:show raw .env file'], - 'cfg docker' => ['--fpm:PHP-FPM + Nginx mode (default)', '--swoole:Swoole HTTP server mode'], + 'cfg docker' => [], 'cfg completion' => ['-i:install globally (once per machine)', '-if:force reinstall', '-f:force flag'], // --- run --- diff --git a/console/Template/Docker/Dockerfile b/console/Template/Docker/Dockerfile index f2197c5..082e6af 100644 --- a/console/Template/Docker/Dockerfile +++ b/console/Template/Docker/Dockerfile @@ -1,24 +1,16 @@ # syntax=docker/dockerfile:1 # ============================================================================= -# Winter — unified image. One Dockerfile for both runtimes. +# Winter — Swoole image. # ----------------------------------------------------------------------------- -# Pick the runtime at build time (do NOT edit this file): -# docker build --build-arg RUNTIME=fpm . # PHP-FPM + Nginx (default) -# docker build --build-arg RUNTIME=swoole . # Swoole HTTP server -# or set `RUNTIME` in docker-compose.yml. -# -# Everything runtime-specific lives in docker// and the two base -# stages below. Extra components (DB drivers, redis, cron, ...) go in -# docker/dependencies.sh — you should not need to touch this file. -# -# Requires BuildKit (Docker 23+, or DOCKER_BUILDKIT=1) — used for `FROM -# base-${RUNTIME}` and `ARG` interpolation in FROM. +# One runtime (Swoole HTTP server). You should not need to edit this file: +# - DB drivers / PHP extensions / cron → docker/dependencies/*.sh +# (delete the ones you don't need; they run in filename order) +# - dev vs prod → DEV in docker-compose.yml # ============================================================================= -ARG RUNTIME=fpm # ───────────────────────────────────────────────────────────────────────────── -# builder (shared) — composer install, no dev deps. vendor is copied into the -# runtime stage; composer itself never ships in the final image. +# builder — composer install, no dev deps. vendor is copied into the runtime +# stage; composer itself never ships in the final image. # ───────────────────────────────────────────────────────────────────────────── FROM alpine:3.23.3 AS builder WORKDIR /var/www/html @@ -33,60 +25,10 @@ RUN curl -sS https://getcomposer.org/installer | php85 -- --install-dir=/usr/loc && rm /usr/local/bin/composer # ───────────────────────────────────────────────────────────────────────────── -# base-fpm — slim Alpine + prebuilt php85 packages + nginx + runit (multi-service) +# final — maintained phpswoole base (swoole + opcache precompiled). # ───────────────────────────────────────────────────────────────────────────── -FROM alpine:3.23.3 AS base-fpm +FROM phpswoole/swoole:php8.5-alpine AS final WORKDIR /var/www/html -ENV APP_GROUP=nginx - -RUN apk add --no-cache php85 \ - php85-dom php85-xml php85-xmlwriter \ - php85-tokenizer php85-common php85-session \ - php85-ctype php85-phar php85-pcntl php85-fileinfo \ - php85-posix php85-mbstring php85-simplexml \ - php85-iconv php85-pdo php85-fpm php85-curl \ - php85-openssl php85-sockets \ - nginx runit procps bash \ - && rm -rf /var/cache/apk/* \ - && (test -f /usr/bin/php || ln -s /usr/bin/php85 /usr/bin/php) \ - && adduser -D -H -s /sbin/nologin winter \ - && adduser winter nginx \ - && mkdir -p /var/run/ && touch /run/php8.5-fpm.pid \ - && echo "variables_order = 'EGPCS'" > /etc/php85/conf.d/99-custom.ini \ - && sed -i '/^\[global\]/a error_log = /proc/self/fd/2' /etc/php85/php-fpm.conf \ - && mkdir -p /etc/service/php-fpm /etc/service/nginx \ - && printf '#!/bin/sh\n[ -e /etc/service/syslog/run ] && while [ ! -S /dev/log ]; do sleep 1; done\nexec php-fpm85 -F\n' > /etc/service/php-fpm/run \ - && printf '#!/bin/sh\n[ -e /etc/service/syslog/run ] && while [ ! -S /dev/log ]; do sleep 1; done\nexec nginx -g "daemon off;"\n' > /etc/service/nginx/run \ - && chmod +x /etc/service/php-fpm/run /etc/service/nginx/run - -# ── syslog (ENABLED by default — comment this whole block to DISABLE) ───────── -# syslogd tails /dev/log and streams it to stdout, so `docker logs` shows nginx + -# php-fpm + app logs together. The php-fpm/nginx run scripts wait for /dev/log -# ONLY while this service exists, so disabling it never hangs startup. -# To disable: comment this block, then switch to the stdout/stderr variants in -# docker/fpm/nginx.conf and docker/fpm/php-fpm.conf (both marked in-file). -RUN mkdir -p /etc/service/syslog \ - && printf '#!/bin/sh\nexec syslogd -n -O /dev/stdout\n' > /etc/service/syslog/run \ - && chmod +x /etc/service/syslog/run \ - && sed -i 's#^error_log = /proc/self/fd/2#error_log = syslog#' /etc/php85/php-fpm.conf -# ───────────────────────────────────────────────────────────────────────────── - -COPY docker/fpm/nginx.conf /etc/nginx/nginx.conf -COPY docker/fpm/php-fpm.conf /etc/php85/php-fpm.d/www.conf - -# Opcache (fpm-flavored: enable_cli=0). Toggle off for dev via DISABLE_OPCACHE=true. -ARG DISABLE_OPCACHE=false -COPY docker/fpm/php-opcache.ini /tmp/php-opcache.ini -RUN if [ "$DISABLE_OPCACHE" = "false" ]; then \ - cp /tmp/php-opcache.ini /etc/php85/conf.d/10-opcache.ini; \ - fi && rm -f /tmp/php-opcache.ini - -# ───────────────────────────────────────────────────────────────────────────── -# base-swoole — maintained phpswoole base (swoole + opcache precompiled) -# ───────────────────────────────────────────────────────────────────────────── -FROM phpswoole/swoole:php8.5-alpine AS base-swoole -WORKDIR /var/www/html -ENV APP_GROUP=winter RUN apk add --no-cache su-exec procps \ && rm -rf /var/cache/apk/* \ @@ -94,29 +36,21 @@ RUN apk add --no-cache su-exec procps \ RUN docker-php-ext-install -j"$(nproc)" pcntl -# Opcache (swoole-flavored: enable_cli=1). Toggle off for dev via DISABLE_OPCACHE=true. -ARG DISABLE_OPCACHE=false -COPY docker/swoole/php-opcache.ini /tmp/php-opcache.ini -RUN if [ "$DISABLE_OPCACHE" = "false" ]; then \ - cp /tmp/php-opcache.ini /usr/local/etc/php/conf.d/10-opcache.ini; \ - fi && rm -f /tmp/php-opcache.ini - -# ───────────────────────────────────────────────────────────────────────────── -# final (shared tail) — base is selected by RUNTIME. Everything below is -# identical for both runtimes; per-runtime bits come from ENV APP_GROUP (set in -# the base) and docker//entrypoint.sh. -# ───────────────────────────────────────────────────────────────────────────── -FROM base-${RUNTIME} AS final -ARG RUNTIME -ENV RUNTIME=${RUNTIME} -WORKDIR /var/www/html - -# User hook: extra packages / cron / custom build steps. Placed BEFORE the app -# code copy so this (rarely-changing) layer stays cached across code edits. -COPY docker/dependencies.sh /tmp/dependencies.sh -RUN sh /tmp/dependencies.sh \ - && rm -f /tmp/dependencies.sh \ - && rm -rf /var/cache/apk/* /tmp/pear +# Opcache config staged for the entrypoint to activate at runtime (no rebuild to +# switch): prod → on (tuned, enable_cli=1); dev (DEV=true) → left off so mounted +# code is always live. +COPY docker/php-opcache.ini /opt/winter/php-opcache.ini + +# User hook: DB drivers / PHP extensions / cron. Modular scripts in +# docker/dependencies/ — delete what you don't need; they run in filename order +# (numeric prefixes). Placed BEFORE the app code copy so this rarely-changing +# layer stays cached across code edits. +COPY docker/dependencies/ /tmp/dependencies/ +RUN for f in /tmp/dependencies/*.sh; do \ + [ -e "$f" ] || continue; \ + echo "→ deps: $f"; sh "$f"; \ + done \ + && rm -rf /tmp/dependencies /var/cache/apk/* /tmp/pear # vendor from builder (composer not included in the runtime image) COPY --from=builder /var/www/html/vendor ./vendor @@ -124,16 +58,15 @@ COPY --from=builder /var/www/html/vendor ./vendor # Application code COPY . /var/www/html -RUN chown -R winter:${APP_GROUP} /var/www/html \ +RUN chown -R winter:winter /var/www/html \ && chmod -R 755 /var/www/html/public \ && chmod -R 775 /var/www/html/storage # Warm shell completion (best-effort; never fails the build) RUN php call cfg completion -if || true -# Entrypoint — one line, the actual script is per-runtime -COPY docker/${RUNTIME}/entrypoint.sh /entrypoint.sh +COPY docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh -EXPOSE 80 +EXPOSE 8000 ENTRYPOINT ["/entrypoint.sh"] diff --git a/console/Template/Docker/docker-compose.yml b/console/Template/Docker/docker-compose.yml index b9d717b..f74b56e 100644 --- a/console/Template/Docker/docker-compose.yml +++ b/console/Template/Docker/docker-compose.yml @@ -1,17 +1,16 @@ services: server: + container_name: ${COMPOSE_PROJECT_NAME:-app} network_mode: bridge build: context: . dockerfile: Dockerfile - args: - # Runtime: fpm | swoole. Change this one line to switch — no regenerate. - RUNTIME: fpm - # Dev: opcache off so mounted code changes are picked up live. - DISABLE_OPCACHE: "true" environment: - COMPOSE_BAKE=true + # Default (no DEV) → prod: `call run`, tuned opcache on. Opt into dev per run + # (no rebuild): DEV=true docker compose up → `call run dev`, opcache off. + - DEV=${DEV:-false} ports: - - "8000:80" + - "8000:8000" volumes: - ./:/var/www/html/ diff --git a/console/Template/Docker/docker/dependencies.sh b/console/Template/Docker/docker/dependencies.sh deleted file mode 100644 index 09d5b46..0000000 --- a/console/Template/Docker/docker/dependencies.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/bin/sh -# ============================================================================= -# dependencies.sh — extra components & custom build steps. -# ----------------------------------------------------------------------------- -# Runs as ROOT during image build (before the app code is copied, so this layer -# stays cached across code edits). Everything here is OPT-IN: uncomment what you -# need. An empty script is a valid no-op. -# -# $RUNTIME tells you which base you are on: -# fpm → slim Alpine, classic sync workers. Extensions from apk (php85-); -# plain blocking drivers are fine — there is no event loop. -# swoole → phpswoole base, coroutine workers. The framework runs with -# `Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL ^ PROC)`, so the -# STANDARD pdo/phpredis drivers are transparently made non-blocking by -# Swoole's runtime hooks — you do NOT need special "coroutine" clients. -# The base already bundles: pdo_mysql, mysqlnd, pdo_sqlite, redis, and -# swoole compiled WITH coroutine_pgsql (libpq). See notes per-DB below. -# -# After editing, rebuild: docker compose build (or: docker build ...) -# ============================================================================= -set -e - -# ----------------------------------------------------------------------------- -# 1) PostgreSQL (pdo_pgsql + pgsql) -# swoole: the coroutine PG engine is already compiled into swoole, but the -# PDO pgsql DRIVER is NOT bundled — install it. Once present, SWOOLE_HOOK_PDO_PGSQL -# (active in the framework's hook mask) makes it non-blocking automatically. -# ----------------------------------------------------------------------------- -# if [ "$RUNTIME" = "fpm" ]; then -# apk add --no-cache php85-pgsql php85-pdo_pgsql -# else -# apk add --no-cache libpq \ -# && apk add --no-cache --virtual .pg-deps postgresql-dev \ -# && docker-php-ext-install -j"$(nproc)" pdo_pgsql pgsql \ -# && apk del .pg-deps -# fi - -# ----------------------------------------------------------------------------- -# 2) MySQL / MariaDB (pdo_mysql + mysqli) -# swoole: pdo_mysql + mysqlnd are ALREADY in the base (coroutine-safe via the -# mysqlnd/stream hooks) — nothing to install unless you specifically need mysqli. -# ----------------------------------------------------------------------------- -# if [ "$RUNTIME" = "fpm" ]; then -# apk add --no-cache php85-pdo_mysql php85-mysqli php85-mysqlnd -# else -# docker-php-ext-install -j"$(nproc)" mysqli # pdo_mysql already present -# fi - -# ----------------------------------------------------------------------------- -# 3) Redis -# swoole: phpredis is ALREADY in the base and is coroutinized transparently by -# Swoole's socket hooks (Swoole 6 dropped the old SWOOLE_HOOK_REDIS constant — -# the plain phpredis client just works). Nothing to install. -# ----------------------------------------------------------------------------- -# if [ "$RUNTIME" = "fpm" ]; then -# apk add --no-cache php85-pecl-redis -# fi - -# ----------------------------------------------------------------------------- -# 4) Cron (busybox crond — ALREADY in both bases, no package to install) -# crond runs as root and executes each crontab as the user matching its -# FILENAME, so a `winter` crontab runs jobs as winter — no su-exec/chpst needed. -# The crontab is identical for both runtimes; only HOW crond starts differs: -# fpm → runit service (below, build-time) -# swoole → a `crond` line in docker/swoole/entrypoint.sh (runtime) -# ----------------------------------------------------------------------------- -# mkdir -p /etc/crontabs -# cat > /etc/crontabs/winter <<'CRON' -# 0 1 * * * cd /var/www/html && php call storage clean -c -# 0 1 * * * cd /var/www/html && php call sc io.scripts.orphanFolderGc -# 30 1 * * * cd /var/www/html && php call sc io.scripts.orphanBlobGc -# CRON -# -# if [ "$RUNTIME" = "fpm" ]; then -# # fpm has runit → supervise crond as a service (auto-starts with the rest). -# # -f = foreground (required so runit keeps it supervised). -# mkdir -p /etc/service/cron -# printf '#!/bin/sh\nexec crond -f -l 8\n' > /etc/service/cron/run -# chmod +x /etc/service/cron/run -# fi -# # swoole has NO supervisor → uncomment the `crond` line in -# # docker/swoole/entrypoint.sh (crond backgrounds itself, then the server execs) - -# ----------------------------------------------------------------------------- -# 5) Anything else — timezone, CA certs, CLI tools, PHP ini tweaks, ... -# ----------------------------------------------------------------------------- -# apk add --no-cache tzdata \ -# && cp /usr/share/zoneinfo/Asia/Tashkent /etc/localtime \ -# && echo "Asia/Tashkent" > /etc/timezone diff --git a/console/Template/Docker/docker/dependencies/10-bcmath.sh b/console/Template/Docker/docker/dependencies/10-bcmath.sh new file mode 100644 index 0000000..6a46fa6 --- /dev/null +++ b/console/Template/Docker/docker/dependencies/10-bcmath.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +# bcmath — arbitrary-precision arithmetic (framework Number / money math). +if php -m | grep -qi '^bcmath$'; then + echo "bcmath already present — skip" +else + docker-php-ext-install -j"$(nproc)" bcmath +fi diff --git a/console/Template/Docker/docker/dependencies/20-pgsql.sh b/console/Template/Docker/docker/dependencies/20-pgsql.sh new file mode 100644 index 0000000..02216a8 --- /dev/null +++ b/console/Template/Docker/docker/dependencies/20-pgsql.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -e + +# PostgreSQL (pdo_pgsql + pgsql). +# swoole ships the coroutine PG engine, but NOT the PDO pgsql driver — install it. +# Once present, SWOOLE_HOOK_PDO_PGSQL (in the framework's hook mask) makes it +# non-blocking automatically. +if php -m | grep -qi '^pdo_pgsql$'; then + echo "pdo_pgsql already present — skip" +else + apk add --no-cache libpq \ + && apk add --no-cache --virtual .pg-deps postgresql-dev \ + && docker-php-ext-install -j"$(nproc)" pdo_pgsql pgsql \ + && apk del .pg-deps +fi diff --git a/console/Template/Docker/docker/dependencies/30-mysql.sh b/console/Template/Docker/docker/dependencies/30-mysql.sh new file mode 100644 index 0000000..4d1d05c --- /dev/null +++ b/console/Template/Docker/docker/dependencies/30-mysql.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -e + +# MySQL / MariaDB — pdo_mysql is the driver CDO uses (PDO). mysqli is optional; +# uncomment the block below if your code needs it. +if php -m | grep -qi '^pdo_mysql$'; then + echo "pdo_mysql already present — skip" +else + docker-php-ext-install -j"$(nproc)" pdo_mysql +fi + +# if ! php -m | grep -qi '^mysqli$'; then +# docker-php-ext-install -j"$(nproc)" mysqli +# fi diff --git a/console/Template/Docker/docker/dependencies/40-redis.sh b/console/Template/Docker/docker/dependencies/40-redis.sh new file mode 100644 index 0000000..739a9d1 --- /dev/null +++ b/console/Template/Docker/docker/dependencies/40-redis.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -e + +# phpredis (\Redis) — the framework's Redis client (winter-cache). Many bases +if php -m | grep -qi '^redis$'; then + echo "redis already present — skip" +else + apk add --no-cache --virtual .redis-deps $PHPIZE_DEPS \ + && pecl install redis \ + && docker-php-ext-enable redis \ + && apk del .redis-deps +fi diff --git a/console/Template/Docker/docker/entrypoint.sh b/console/Template/Docker/docker/entrypoint.sh new file mode 100644 index 0000000..638b36f --- /dev/null +++ b/console/Template/Docker/docker/entrypoint.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# Swoole entrypoint. su-exec exec-replaces itself, so the Swoole master becomes +# PID 1 and receives SIGTERM directly → graceful shutdown (Swoole reaps its own +# workers, no zombies). App logs go straight to stdout, so `docker logs` shows +# them without any syslog relay. + +# If you set up a crontab in a docker/dependencies/ script, start crond here: +# crond -l 8 + +# Opcache is toggled here (runtime, as root before su-exec), so switching dev/prod +# needs no rebuild. Idempotent: safe on a fresh or a restarted container. +OPCACHE_CONF=/usr/local/etc/php/conf.d/10-opcache.ini + +if [ "${DEV:-false}" = "true" ]; then + # Development: opcache off (mounted code always live) + DevWatcher hot-reload. + rm -f "$OPCACHE_CONF" + exec su-exec winter php /var/www/html/call run dev +else + # Production: tuned opcache on. + cp /opt/winter/php-opcache.ini "$OPCACHE_CONF" + exec su-exec winter php /var/www/html/call run +fi diff --git a/console/Template/Docker/docker/fpm/entrypoint.sh b/console/Template/Docker/docker/fpm/entrypoint.sh deleted file mode 100644 index 70a9dc0..0000000 --- a/console/Template/Docker/docker/fpm/entrypoint.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh -# FPM runtime entrypoint. -# Re-own the volatile store to winter (0700) before services boot, then hand off -# to runit which supervises syslog + php-fpm + nginx. -# Path = /tmp/flytachi.winter.volatile.. -VOL=/tmp/flytachi.winter.volatile.html -mkdir -p "$VOL" -chown -R winter:winter "$VOL" -chmod 0700 "$VOL" -exec runsvdir /etc/service diff --git a/console/Template/Docker/docker/fpm/nginx.conf b/console/Template/Docker/docker/fpm/nginx.conf deleted file mode 100644 index 94ee061..0000000 --- a/console/Template/Docker/docker/fpm/nginx.conf +++ /dev/null @@ -1,104 +0,0 @@ -user nginx; -worker_processes auto; -pid /run/nginx.pid; -# syslog ON (default). If you disabled the syslog service in the Dockerfile, -# comment the next line and uncomment the /dev/stderr one below. -error_log syslog:server=unix:/dev/log,tag=nginx warn; -#error_log /dev/stderr warn; - -events { - worker_connections 1024; - use epoll; - multi_accept on; -} - -http { - server_tokens off; - keepalive_timeout 30s; - client_max_body_size 20m; - - gzip on; - gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; - - # Block known bad bots and scrapers - map $http_user_agent $blocked_agent { - default 0; - ~*LWP::Simple 1; - ~*BBBike 1; - ~*wget 1; - ~*msnbot 1; - ~*scrapbot 1; - } - - # Block spam referers - map $http_referer $blocked_referer { - default 0; - ~*babes 1; - ~*forsale 1; - ~*girl 1; - ~*jewelry 1; - ~*nudit 1; - ~*organic 1; - ~*poker 1; - ~*porn 1; - ~*sex 1; - ~*teen 1; - } - -# # Hotlink protection (uncomment to enable) -# map $http_referer $block_hotlink { -# default 1; # block by default -# "" 0; # allow direct requests (curl, mobile apps) -# ~*localhost 0; -# ~*google\. 0; # Google search preview -# ~*telegram\. 0; # Telegram link preview -# ~*facebook\. 0; # Facebook / WhatsApp OG preview -# ~*twitter\. 0; # Twitter card preview -# } - - log_format main '[nginx] $remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent'; - # syslog ON (default). If syslog is disabled, use the /dev/stdout line instead. - access_log syslog:server=unix:/dev/log,tag=nginx main; - #access_log /dev/stdout main; - - server { - listen 80; - server_name localhost; - root /var/www/html/public/; - include mime.types; - - if ($blocked_agent) { return 403; } - if ($blocked_referer) { return 403; } - - location / { - index index.php; - try_files $uri $uri/ /index.php?$args; - } - - location ~ \.php$ { - include fastcgi_params; - - fastcgi_pass unix:/var/run/php-fpm.sock; - fastcgi_index index.php; - fastcgi_buffers 8 16k; - fastcgi_buffer_size 32k; - fastcgi_read_timeout 60s; - - fastcgi_param DOCUMENT_ROOT $realpath_root; - fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; - fastcgi_param HTTP_X_REAL_IP $http_x_real_ip; - fastcgi_param HTTP_X_FORWARDED_FOR $http_x_forwarded_for; - } - - location ~ /\.ht { - deny all; - } - -# location ~* \.(webp|jpg|jpeg|gif|png|svg|js|css|ico)$ { -# expires 30d; -# add_header Cache-Control "public, no-transform"; -# -# if ($block_hotlink) { return 403; } -# } - } -} \ No newline at end of file diff --git a/console/Template/Docker/docker/fpm/php-fpm.conf b/console/Template/Docker/docker/fpm/php-fpm.conf deleted file mode 100644 index eb4a4c8..0000000 --- a/console/Template/Docker/docker/fpm/php-fpm.conf +++ /dev/null @@ -1,87 +0,0 @@ -[www] - -; Unix user/group of FPM child processes -user = winter -group = winter - -; FastCGI socket (unix socket or ip:port) -listen = /var/run/php-fpm.sock - -; Allow nginx to access the socket -listen.group = nginx - -; Process manager: static | dynamic | ondemand -pm = dynamic - -; Maximum number of child processes (simultaneous requests) -; -; Formula: max_children = available_RAM / avg_PHP_process_memory -; Check avg memory: ps --no-headers -o "rss,cmd" -C php-fpm | awk '{sum+=$1} END {print sum/NR/1024 " MB"}' -; -; Recommended values based on available RAM: -; 512 MB RAM → max_children = 5–10 (small dev container) -; 1 GB RAM → max_children = 10–15 (light API / dev) -; 2 GB RAM → max_children = 20–30 (medium API, CRM) -; 4 GB RAM → max_children = 40–60 (production, ERP) -; 8 GB RAM → max_children = 80–120 (high-load backend) -; -pm.max_children = 25 - -; Child processes created on startup = (min_spare + max_spare) / 2 -pm.start_servers = 8 - -; Min idle processes (~20% of max_children) -pm.min_spare_servers = 5 - -; Max idle processes (~40% of max_children) -pm.max_spare_servers = 10 - -; Requests per child before respawn (0 = unlimited) -; -; Recommended values: -; Development 500 (frequent restarts acceptable) -; Production (stable app) 0 (no limit, maximize performance) -; Production (memory leak risk) 500–1000 -; Apps using Thread/async jobs 200–500 (child processes add memory pressure) -; -pm.max_requests = 500 - -; FPM status page URI (uncomment to enable: GET /status) -;pm.status_path = /status - -; Slow request log (requires slowlog path) -;slowlog = log/$pool.slow.log -;request_slowlog_timeout = 5s - -; Kill request after timeout (must match nginx fastcgi_read_timeout) -; -; Recommended values: -; API / CRM / ERP (standard) 60s -; File uploads / imports 120–300s -; Long background tasks 300s+ -; Development (no timeout) 0 -; -request_terminate_timeout = 60s - -; Capture worker stdout/stderr into main FPM error log -;catch_workers_output = yes - -; Pass all ENV variables from host to PHP workers (getenv(), $_ENV, $_SERVER) -clear_env = no - -; Per-worker PHP ini overrides -;php_flag[display_errors] = off -; syslog ON (default). If you disabled the syslog service in the Dockerfile, -; comment the syslog line and uncomment the stderr one so worker/app errors -; still reach `docker logs`. -php_admin_value[error_log] = syslog -;php_admin_value[error_log] = /proc/self/fd/2 -php_admin_flag[log_errors] = on -; -; Recommended memory_limit based on application type: -; Small API / blog 64M -; CRM / ERP / e-commerce 128M -; File processing / imports 256M -; Data-heavy reports / exports 512M+ -; -;php_admin_value[memory_limit] = 128M \ No newline at end of file diff --git a/console/Template/Docker/docker/fpm/php-opcache.ini b/console/Template/Docker/docker/fpm/php-opcache.ini deleted file mode 100644 index fc87900..0000000 --- a/console/Template/Docker/docker/fpm/php-opcache.ini +++ /dev/null @@ -1,86 +0,0 @@ -; -------------------------- -; Opcache Configuration File -; -------------------------- - -; Enables Opcache to speed up PHP execution -opcache.enable=1 - -; Amount of memory allocated for caching PHP scripts (in megabytes) -; -; Recommended values based on project size: -; Small (landing page, blog) 64M -; Medium (API, CRM, e-commerce) 128M -; Large (high-load backend, WebSocket) 256M+ -; -opcache.memory_consumption=64 - -; Maximum number of files that can be cached -; -; Recommended values based on project size: -; Small (landing page, blog) 4000 -; Medium (API, CRM, e-commerce) 10000 -; Large (high-load backend, WebSocket) 20000+ -; -opcache.max_accelerated_files=10000 - -; Interval (in seconds) between file update checks -; If a file is modified, Opcache will refresh the cache. -opcache.revalidate_freq=10 - -; File change detection -; 0 - Opcache will not check for file changes (manual cache reset required) -; 1 - Opcache checks for file modifications (useful for development) -opcache.validate_timestamps=0 - -; Amount of memory (in megabytes) allocated for caching interned strings -; This helps save memory and speed up execution -; -; Recommended values based on project size: -; Small (landing page, blog) 8M -; Medium (API, CRM, e-commerce) 16M -; Large (high-load backend, WebSocket) 32M+ -; -opcache.interned_strings_buffer=16 - -; Enables optimized PHP shutdown -; 1 - Uses fast memory cleanup, reducing system load -opcache.fast_shutdown=1 - -; -------------------------- -; JIT Configuration -; -------------------------- - -; Enables JIT compilation to improve performance -; JIT compiles PHP code into machine code for faster execution -; JIT Modes: -; 1205 - Function + Side traces (no loop unrolling), stable for most apps -; 1255 - Tracing mode with loop optimizations (faster but may use more memory) -; 0 - Disables JIT -opcache.jit=0 - -; Recommended JIT buffer size based on project scale: -; -; Small (landing page, blog) 32M -; Medium (API, CRM, e-commerce) 64M -; Large (high-load backend, WebSocket, Queues) 128M -; Very large (data processing, High Load) 256M+ -; -; Amount of memory allocated for JIT compilation (in megabytes) -opcache.jit_buffer_size=64M - -; -------------------------- -; CLI Opcache Configuration -; -------------------------- - -; Enables Opcache for CLI mode (useful for long-running PHP processes) -; This is important for Threads, WebSockets, and Daemon Processes -opcache.enable_cli=0 - -; Recommended settings for different environments: -; Development: -; opcache.validate_timestamps=1 -; opcache.revalidate_freq=0 -; -; Production: -; opcache.validate_timestamps=0 -; opcache.revalidate_freq=10 \ No newline at end of file diff --git a/console/Template/Docker/docker/swoole/php-opcache.ini b/console/Template/Docker/docker/php-opcache.ini similarity index 74% rename from console/Template/Docker/docker/swoole/php-opcache.ini rename to console/Template/Docker/docker/php-opcache.ini index 17d48aa..cb52962 100644 --- a/console/Template/Docker/docker/swoole/php-opcache.ini +++ b/console/Template/Docker/docker/php-opcache.ini @@ -50,23 +50,14 @@ opcache.fast_shutdown=1 ; JIT Configuration ; -------------------------- -; Enables JIT compilation to improve performance -; JIT compiles PHP code into machine code for faster execution -; JIT Modes: -; 1205 - Function + Side traces (no loop unrolling), stable for most apps -; 1255 - Tracing mode with loop optimizations (faster but may use more memory) -; 0 - Disables JIT -opcache.jit=1205 - -; Recommended JIT buffer size based on project scale: -; -; Small (landing page, blog) 32M -; Medium (API, CRM, e-commerce) 64M -; Large (high-load backend, WebSocket, Queues) 128M -; Very large (data processing, High Load) 256M+ -; -; Amount of memory allocated for JIT compilation (in megabytes) -opcache.jit_buffer_size=64M +; JIT is DISABLED on purpose. Swoole registers user opcode handlers, which are +; incompatible with opcache JIT — PHP auto-disables JIT and warns at startup +; ("JIT is incompatible with third party extensions ..."). A long-lived Swoole +; process gains little from JIT anyway, so it is turned off explicitly to keep the +; startup clean. (Modes, for reference: 1205 function+side-traces, 1255 tracing, +; 0 off.) +opcache.jit=0 +opcache.jit_buffer_size=0 ; -------------------------- ; CLI Opcache Configuration diff --git a/console/Template/Docker/docker/swoole/entrypoint.sh b/console/Template/Docker/docker/swoole/entrypoint.sh deleted file mode 100644 index 7cadbf8..0000000 --- a/console/Template/Docker/docker/swoole/entrypoint.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/sh -# Swoole runtime entrypoint. -# su-exec exec-replaces itself, so the Swoole master becomes PID 1 and receives -# SIGTERM directly → graceful shutdown. Swoole reaps its own workers (no zombies). - -# ── syslog (ENABLED by default — comment the line to DISABLE) ───────────────── -# Runs busybox syslogd in the background so that, IF you set LOG_OUTPUT=syslog in -# .env, app logs surface in `docker logs` (/dev/log → syslogd → stdout). The -# framework picks the log target itself (default: straight to stdout), so with -# stdout output this daemon just sits idle — harmless. Comment to drop it. -syslogd -O /dev/stdout -# ───────────────────────────────────────────────────────────────────────────── - -# If you set up a crontab in docker/dependencies.sh, start crond here first. -# busybox crond backgrounds itself, so it runs alongside the server below. -# It stays root and executes the `winter` crontab as winter (no su-exec needed). -# crond -l 8 - -exec su-exec winter php /var/www/html/call run --port=80 diff --git a/src/Route/DevWatcher.php b/src/Route/DevWatcher.php index 69dcdf0..02c7b45 100644 --- a/src/Route/DevWatcher.php +++ b/src/Route/DevWatcher.php @@ -38,6 +38,7 @@ final class DevWatcher private array $snapshot = []; private bool $reloadRequested = false; private ?int $timerId = null; + private bool $stopping = false; /** * @param list $watchPaths Directories scanned for `.php` changes. @@ -77,6 +78,9 @@ public function attach(Server $server, ?callable $onWorkerStart = null): void $server->on('start', function (Server $server): void { $this->snapshot = $this->scan(); $this->timerId = Timer::tick((int) ($this->interval * 1000), function () use ($server): void { + if ($this->stopping) { + return; + } $current = $this->scan(); if ($current === $this->snapshot) { return; @@ -94,6 +98,18 @@ public function attach(Server $server, ?callable $onWorkerStart = null): void $server->shutdown(); }); }); + + // Stop the watch cleanly on shutdown: flag the stop and clear the poll + // timer so no filesystem scan runs during reactor teardown. Otherwise the + // (hooked) scan is left as a sleeping coroutine and Swoole force-kills the + // worker after its exit timeout ("all coroutines are asleep - deadlock"). + $server->on('beforeShutdown', function (Server $server): void { + $this->stopping = true; + if ($this->timerId !== null) { + Timer::clear($this->timerId); + $this->timerId = null; + } + }); } public function wrap(callable $handler): callable From 72b93e26cdde8fc394cb0d65b12644eb5f312787 Mon Sep 17 00:00:00 2001 From: flytachi Date: Fri, 31 Jul 2026 15:43:46 +0500 Subject: [PATCH 32/71] docker --- console/Template/Build/phpstormMeta | 11 ++++++++++- src/Kernel.php | 24 +++++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/console/Template/Build/phpstormMeta b/console/Template/Build/phpstormMeta index 504a25b..0327995 100644 --- a/console/Template/Build/phpstormMeta +++ b/console/Template/Build/phpstormMeta @@ -14,21 +14,25 @@ namespace PHPSTORM_META { 'LOG_OUTPUT', 'LOG_FILE', 'LOG_FILE_MAX', + 'LOG_COLOR', 'LOG_SYS_LEVEL', 'LOG_SYS_FORMAT', 'LOG_SYS_OUTPUT', 'LOG_SYS_FILE', 'LOG_SYS_FILE_MAX', + 'LOG_SYS_COLOR', 'LOG_HTTP_LEVEL', 'LOG_HTTP_FORMAT', 'LOG_HTTP_OUTPUT', 'LOG_HTTP_FILE', 'LOG_HTTP_FILE_MAX', + 'LOG_HTTP_COLOR', 'LOG_CLI_LEVEL', 'LOG_CLI_FORMAT', 'LOG_CLI_OUTPUT', 'LOG_CLI_FILE', - 'LOG_CLI_FILE_MAX' + 'LOG_CLI_FILE_MAX', + 'LOG_CLI_COLOR' ); // 2. Говорим, что 0-й аргумент функции env() должен быть из этого набора @@ -39,6 +43,7 @@ namespace PHPSTORM_META { registerArgumentsSet('log_levels', 'info', 'debug', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency'); registerArgumentsSet('log_formats', 'line', 'json'); registerArgumentsSet('log_outputs', 'auto', 'stdout', 'stderr', 'syslog', 'file', 'null'); + registerArgumentsSet('log_colors', 'auto', 'always', 'never'); registerArgumentsSet('time_zones', 'UTC', 'Europe/Moscow', 'America/New_York', 'Asia/Tokyo'); // 4. Связываем: если 0-й аргумент равен X, то для 1-го аргумента предлагаем набор Y @@ -50,20 +55,24 @@ namespace PHPSTORM_META { 'LOG_LEVEL' => argumentsSet('log_levels'), 'LOG_FORMAT' => argumentsSet('log_formats'), 'LOG_OUTPUT' => argumentsSet('log_outputs'), + 'LOG_COLOR' => argumentsSet('log_colors'), // Системные логи 'LOG_SYS_LEVEL' => argumentsSet('log_levels'), 'LOG_SYS_FORMAT' => argumentsSet('log_formats'), 'LOG_SYS_OUTPUT' => argumentsSet('log_outputs'), + 'LOG_SYS_COLOR' => argumentsSet('log_colors'), // HTTP 'LOG_HTTP_LEVEL' => argumentsSet('log_levels'), 'LOG_HTTP_FORMAT' => argumentsSet('log_formats'), 'LOG_HTTP_OUTPUT' => argumentsSet('log_outputs'), + 'LOG_HTTP_COLOR' => argumentsSet('log_colors'), // CLI 'LOG_CLI_LEVEL' => argumentsSet('log_levels'), 'LOG_CLI_FORMAT' => argumentsSet('log_formats'), 'LOG_CLI_OUTPUT' => argumentsSet('log_outputs'), + 'LOG_CLI_COLOR' => argumentsSet('log_colors'), }); } diff --git a/src/Kernel.php b/src/Kernel.php index 2af2f5b..e5f1671 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -126,16 +126,28 @@ private static function buildChannelConfig(string $channel): array $rawOutput = (string) (env($prefix . 'OUTPUT') ?? env('LOG_OUTPUT', 'auto')); $output = self::resolveOutput($rawOutput); + $format = (string) (env($prefix . 'FORMAT') ?? env('LOG_FORMAT', 'line')); $filePath = env($prefix . 'FILE') ?? env('LOG_FILE'); if ($output === 'file' && empty($filePath)) { $filePath = self::$pathStorageLog . '/' . $channel . '.log'; } + // ANSI colour — line format only, never JSON. LOG_COLOR = auto|always|never + // (auto = colour only when the output is an interactive terminal, mirroring + // Spring's `detect` / Postgres' PG_COLOR). + $colorMode = strtolower((string) (env($prefix . 'COLOR') ?? env('LOG_COLOR', 'auto'))); + $color = $format === 'line' && match ($colorMode) { + 'always' => true, + 'never' => false, + default => self::outputIsTty($output), + }; + return [ 'level' => $levelStr, - 'format' => (string) (env($prefix . 'FORMAT') ?? env('LOG_FORMAT', 'line')), + 'format' => $format, 'output' => $output, + 'color' => $color, 'file_path' => $filePath ? (string) $filePath : null, 'file_max' => (int) (env($prefix . 'FILE_MAX') ?? env('LOG_FILE_MAX', 30)), // Fixed syslog program tag — winter-logger requires the key; not a knob. @@ -150,6 +162,16 @@ private static function resolveOutput(string $raw): string return $raw === 'auto' ? 'stdout' : $raw; } + /** True when the resolved log output is an interactive terminal (for LOG_COLOR=auto). */ + private static function outputIsTty(string $output): bool + { + return match ($output) { + 'stdout' => defined('STDOUT') && stream_isatty(STDOUT), + 'stderr' => defined('STDERR') && stream_isatty(STDERR), + default => false, + }; + } + private static function threadRunnerPath(): string { $runner = env('WINTER_THREAD_RUNNER'); From f275cd2e631560d696a1799d834bfe3f2804b2ac Mon Sep 17 00:00:00 2001 From: flytachi Date: Fri, 31 Jul 2026 16:00:40 +0500 Subject: [PATCH 33/71] docker --- src/Route/DevWatcher.php | 48 +++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/src/Route/DevWatcher.php b/src/Route/DevWatcher.php index 02c7b45..65f4ff8 100644 --- a/src/Route/DevWatcher.php +++ b/src/Route/DevWatcher.php @@ -39,6 +39,7 @@ final class DevWatcher private bool $reloadRequested = false; private ?int $timerId = null; private bool $stopping = false; + private readonly bool $color; /** * @param list $watchPaths Directories scanned for `.php` changes. @@ -50,6 +51,23 @@ public function __construct( private readonly float $interval = 1.0, private readonly array $exclude = ['vendor', 'storage', '.git', 'node_modules'], ) { + $this->color = self::wantsColor(); + } + + /** Colour the dev output using the same LOG_COLOR contract as the logger. */ + private static function wantsColor(): bool + { + return match (strtolower((string) (env('LOG_COLOR', 'auto')))) { + 'always' => true, + 'never' => false, + default => defined('STDOUT') && stream_isatty(STDOUT), + }; + } + + /** Wraps text in an ANSI colour when colour is on; plain text otherwise. */ + private function paint(string $code, string $text): string + { + return $this->color ? "\033[{$code}m{$text}\033[0m" : $text; } /** @@ -63,11 +81,9 @@ public function attach(Server $server, ?callable $onWorkerStart = null): void $server->on('workerStart', function (Server $server, int $workerId) use ($onWorkerStart): void { $this->workerId = $workerId; $this->baseline = memory_get_usage(false); - echo sprintf( - "[Worker %d] START | Baseline: %s\n", - $this->workerId, - $this->format($this->baseline) - ); + echo $this->paint('32', '●') . ' ' . $this->paint('36', '[dev]') + . ' worker ' . $this->workerId + . $this->paint('90', ' · baseline ') . $this->format($this->baseline) . "\n"; if ($onWorkerStart !== null) { $onWorkerStart($server, $workerId); } @@ -86,10 +102,9 @@ public function attach(Server $server, ?callable $onWorkerStart = null): void return; } $changed = $this->firstChange($this->snapshot, $current); - echo sprintf( - "\n[dev] change detected%s — restarting server...\n", - $changed !== null ? " ({$changed})" : '' - ); + echo "\n" . $this->paint('33', '↻ [dev]') . ' change' + . ($changed !== null ? $this->paint('90', ' · ') . $this->paint('1;33', $changed) : '') + . $this->paint('90', ' — restarting…') . "\n"; $this->reloadRequested = true; if ($this->timerId !== null) { Timer::clear($this->timerId); @@ -121,15 +136,12 @@ public function wrap(callable $handler): callable $after = memory_get_usage(false); - echo sprintf( - "[Worker %d] REQUEST => (before: %s, after: %s, delta: %s, growth: %s, peak: %s)\n", - $this->workerId, - $this->format($before), - $this->format($after), - $this->formatDelta($after - $before), - $this->formatDelta($after - $this->baseline), - $this->format(memory_get_peak_usage(false)) - ); + echo $this->paint('36', '[dev]') . ' worker ' . $this->workerId + . $this->paint('90', ' · before ') . $this->format($before) + . $this->paint('90', ' · after ') . $this->format($after) + . $this->paint('90', ' · Δ ') . $this->formatDelta($after - $before) + . $this->paint('90', ' · growth ') . $this->formatDelta($after - $this->baseline) + . $this->paint('90', ' · peak ') . $this->format(memory_get_peak_usage(false)) . "\n"; }; } From b77100235715fb6130acc4db97a8b92b13e1ab6a Mon Sep 17 00:00:00 2001 From: flytachi Date: Fri, 31 Jul 2026 16:07:41 +0500 Subject: [PATCH 34/71] docker dev --- src/Route/DevWatcher.php | 69 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/Route/DevWatcher.php b/src/Route/DevWatcher.php index 65f4ff8..f3541ff 100644 --- a/src/Route/DevWatcher.php +++ b/src/Route/DevWatcher.php @@ -101,6 +101,23 @@ public function attach(Server $server, ?callable $onWorkerStart = null): void if ($current === $this->snapshot) { return; } + + // Validate changed files before restarting: a mid-edit syntax error + // would kill the re-exec'd boot with no recovery. Keep the current + // (working) server up and report the error; the fix triggers the reload. + $invalid = $this->firstSyntaxError($this->changedPhpFiles($this->snapshot, $current)); + if ($invalid !== null) { + [$file, $error] = $invalid; + echo "\n" . $this->paint('31', '✗ [dev]') . ' syntax error in ' + . $this->paint('1;31', basename($file)) + . $this->paint('90', ' — keeping current server') . "\n" + . ' ' . $this->paint('90', $this->errorLine($error)) . "\n"; + // Acknowledge this change so the same break isn't re-linted every + // tick; the next edit (the fix) is a fresh change → re-checked. + $this->snapshot = $current; + return; + } + $changed = $this->firstChange($this->snapshot, $current); echo "\n" . $this->paint('33', '↻ [dev]') . ' change' . ($changed !== null ? $this->paint('90', ' · ') . $this->paint('1;33', $changed) : '') @@ -210,6 +227,58 @@ private function firstChange(array $old, array $new): ?string return null; } + /** + * Added or modified files still on disk (removals can't be linted and are a + * valid reason to reload). + * + * @return list + */ + private function changedPhpFiles(array $old, array $new): array + { + $files = []; + foreach ($new as $path => $mtime) { + if ((!isset($old[$path]) || $old[$path] !== $mtime) && is_file($path)) { + $files[] = $path; + } + } + return $files; + } + + /** + * `php -l` each file; returns [path, output] of the first that fails to parse, + * or null when all are valid (or validation is unavailable, so the reload just + * proceeds as before). + * + * @param list $files + * @return array{0: string, 1: string}|null + */ + private function firstSyntaxError(array $files): ?array + { + if (!function_exists('exec')) { + return null; + } + foreach ($files as $file) { + $out = []; + $code = 0; + exec(escapeshellarg(PHP_BINARY) . ' -l ' . escapeshellarg($file) . ' 2>&1', $out, $code); + if ($code !== 0) { + return [$file, implode("\n", $out)]; + } + } + return null; + } + + /** The "Parse error: ..." line from `php -l` output, for a compact notice. */ + private function errorLine(string $output): string + { + foreach (explode("\n", $output) as $line) { + if (stripos($line, 'error') !== false) { + return trim($line); + } + } + return trim(strtok($output, "\n") ?: $output); + } + private function format(int $bytes): string { if ($bytes >= 1024 * 1024) { From d488b90b67662e5530047cffcda05294e43d3726 Mon Sep 17 00:00:00 2001 From: flytachi Date: Fri, 31 Jul 2026 18:41:10 +0500 Subject: [PATCH 35/71] pool --- src/ConnectionPool/ConnectionFactory.php | 31 +++ src/ConnectionPool/ConnectionPool.php | 222 +++++++++++++++++++ src/ConnectionPool/PoolEntry.php | 26 +++ src/ConnectionPool/PoolException.php | 36 +++ src/ConnectionPool/PoolPolicy.php | 45 ++++ src/Ppa/Pool/CdoConnectionFactory.php | 59 +++++ src/Ppa/Pool/PpaConnectionPool.php | 108 ++++----- tests/ConnectionPool/ConnectionPoolTest.php | 232 ++++++++++++++++++++ tests/ConnectionPool/PoolPolicyTest.php | 30 +++ 9 files changed, 735 insertions(+), 54 deletions(-) create mode 100644 src/ConnectionPool/ConnectionFactory.php create mode 100644 src/ConnectionPool/ConnectionPool.php create mode 100644 src/ConnectionPool/PoolEntry.php create mode 100644 src/ConnectionPool/PoolException.php create mode 100644 src/ConnectionPool/PoolPolicy.php create mode 100644 src/Ppa/Pool/CdoConnectionFactory.php create mode 100644 tests/ConnectionPool/ConnectionPoolTest.php create mode 100644 tests/ConnectionPool/PoolPolicyTest.php diff --git a/src/ConnectionPool/ConnectionFactory.php b/src/ConnectionPool/ConnectionFactory.php new file mode 100644 index 0000000..76ab7bf --- /dev/null +++ b/src/ConnectionPool/ConnectionFactory.php @@ -0,0 +1,31 @@ +clock = $clock ?? static fn(): float => hrtime(true) / 1e9; + $this->idle = new Channel($this->policy->maximumPoolSize); + } + + /** + * Borrows a live connection: reuse an idle one (idle-gated probe), grow up to + * maximumPoolSize, or wait connectionTimeout for a release. Expired or dead + * connections are retired and a fresh one is obtained. + * + * @throws PoolException On exhaustion, connect failure, or repeated dead borrows. + */ + public function borrow(): PoolEntry + { + for ($attempt = 0; $attempt <= self::MAX_RETIRE_LOOPS; ++$attempt) { + $entry = $this->acquire(); + if ($entry === null) { + throw PoolException::exhausted($this->policy->connectionTimeout); + } + if ($this->isExpired($entry) || ($this->needsProbe($entry) && !$this->probe($entry))) { + $this->discard($entry); + continue; + } + $entry->lastUsedAt = $this->now(); + return $entry; + } + + throw PoolException::unusable(self::MAX_RETIRE_LOOPS + 1); + } + + /** Returns a borrowed connection to the pool for reuse. */ + public function release(PoolEntry $entry): void + { + if ($this->idle === null) { + $this->discard($entry); + return; + } + $entry->lastUsedAt = $this->now(); + $this->idle->push($entry); + } + + /** + * Retires a connection (close + free the slot) instead of returning it — for a + * connection-level failure (SQLSTATE 08xxx etc.) detected during use, so the dead + * connection is never handed out again. + */ + public function evict(PoolEntry $entry): void + { + $this->discard($entry); + } + + /** @return array{total: int, idle: int, active: int, maximum: int} */ + public function stats(): array + { + $idle = $this->idle?->length() ?? 0; + return [ + 'total' => $this->total, + 'idle' => $idle, + 'active' => $this->total - $idle, + 'maximum' => $this->policy->maximumPoolSize, + ]; + } + + /** Closes every idle connection and the pool itself. */ + public function close(): void + { + if ($this->idle === null) { + return; + } + while ($this->idle->length() > 0) { + $entry = $this->idle->pop(0.001); + if ($entry instanceof PoolEntry) { + $this->safeClose($entry->resource); + } + } + $this->idle->close(); + $this->idle = null; + $this->total = 0; + } + + // ── internals ────────────────────────────────────────────────────────────── + + /** Gets an idle connection, grows the pool, or waits for a release. */ + private function acquire(): ?PoolEntry + { + if ($this->idle !== null && $this->idle->length() > 0) { + $entry = $this->idle->pop(0.001); + if ($entry instanceof PoolEntry) { + return $entry; + } + } + if ($this->total < $this->policy->maximumPoolSize) { + return $this->make(); + } + $entry = $this->idle?->pop($this->policy->connectionTimeout); + return $entry instanceof PoolEntry ? $entry : null; + } + + private function make(): PoolEntry + { + // Reserve the slot before the (possibly yielding) connect so concurrent + // borrows don't over-provision past maximumPoolSize. + ++$this->total; + try { + $resource = $this->factory->create(); + } catch (Throwable $e) { + --$this->total; + throw PoolException::connectFailed($e); + } + $now = $this->now(); + return new PoolEntry($resource, $now, $now, $this->computeExpiry($now)); + } + + private function discard(PoolEntry $entry): void + { + $this->safeClose($entry->resource); + if ($this->total > 0) { + --$this->total; + } + } + + private function isExpired(PoolEntry $entry): bool + { + return $entry->expiresAt !== null && $this->now() >= $entry->expiresAt; + } + + private function needsProbe(PoolEntry $entry): bool + { + return ($this->now() - $entry->lastUsedAt) > $this->policy->aliveBypassWindow; + } + + private function probe(PoolEntry $entry): bool + { + try { + return $this->factory->validate($entry->resource); + } catch (Throwable) { + return false; + } + } + + private function computeExpiry(float $now): ?float + { + if ($this->policy->maxLifetime <= 0.0) { + return null; + } + $life = $this->policy->maxLifetime; + $jit = $this->policy->maxLifetimeJitter; + if ($jit > 0.0) { + $spread = $life * $jit; + $life += (mt_rand() / mt_getrandmax()) * 2 * $spread - $spread; + } + return $now + $life; + } + + private function safeClose(object $resource): void + { + try { + $this->factory->close($resource); + } catch (Throwable) { + } + } + + private function now(): float + { + return ($this->clock)(); + } +} diff --git a/src/ConnectionPool/PoolEntry.php b/src/ConnectionPool/PoolEntry.php new file mode 100644 index 0000000..dce0498 --- /dev/null +++ b/src/ConnectionPool/PoolEntry.php @@ -0,0 +1,26 @@ +getMessage(), + previous: $previous, + ); + } + + public static function unusable(int $attempts): self + { + return new self("ConnectionPool: could not obtain a live connection after {$attempts} attempts."); + } +} diff --git a/src/ConnectionPool/PoolPolicy.php b/src/ConnectionPool/PoolPolicy.php new file mode 100644 index 0000000..551535f --- /dev/null +++ b/src/ConnectionPool/PoolPolicy.php @@ -0,0 +1,45 @@ + $configClass Config to instantiate per slot. + * @param LoggerInterface $logger PPA channel logger, injected into each config. + */ + public function __construct( + private string $configClass, + private LoggerInterface $logger, + ) { + } + + /** Opens one independent connection (own socket) via a fresh config instance. */ + public function create(): object + { + /** @var DbConfigInterface $config */ + $config = new ($this->configClass)(); + $config->setUp(); + $config->setLogger($this->logger); + $config->connect(); + $this->logger->debug("slot opened: {$this->configClass} dsn={$config->getDns()}"); + return $config; + } + + /** Liveness probe — delegates to the driver's own `SELECT 1` (`false` = dead). */ + public function validate(object $connection): bool + { + /** @var DbConfigInterface $connection */ + return $connection->ping(); + } + + /** Drops the CDO reference so its socket is closed. */ + public function close(object $connection): void + { + /** @var DbConfigInterface $connection */ + $connection->disconnect(); + } +} diff --git a/src/Ppa/Pool/PpaConnectionPool.php b/src/Ppa/Pool/PpaConnectionPool.php index 103a4d1..f413935 100644 --- a/src/Ppa/Pool/PpaConnectionPool.php +++ b/src/Ppa/Pool/PpaConnectionPool.php @@ -8,6 +8,10 @@ use Flytachi\Winter\Cdo\Config\Common\DbConfigInterface; use Flytachi\Winter\Cdo\Connection\CDO; use Flytachi\Winter\Base\Runtime; +use Flytachi\Winter\K2\ConnectionPool\ConnectionPool; +use Flytachi\Winter\K2\ConnectionPool\PoolEntry; +use Flytachi\Winter\K2\ConnectionPool\PoolException; +use Flytachi\Winter\K2\ConnectionPool\PoolPolicy; use Psr\Log\LoggerInterface; /** @@ -18,14 +22,20 @@ * one `CDO` instance per config class per process, reused for the entire request. * * ## Swoole (coroutines) - * Uses {@see \Swoole\ConnectionPool} (which wraps `Swoole\Coroutine\Channel`) - * per config class. Connections are created **lazily** — only when first requested, - * up to `poolMaxConnections`. On the **first** `db()` call inside a coroutine one - * CDO is borrowed and cached in the coroutine context; a `defer` returns it - * automatically when the coroutine ends — no manual release anywhere in the codebase. + * Uses the framework's {@see ConnectionPool} (a HikariCP-inspired pool over a + * `Swoole\Coroutine\Channel`) per config class. Connections are created **lazily** — + * only when first requested, up to `poolMaxConnections`. On the **first** `db()` call + * inside a coroutine one connection is borrowed and cached in the coroutine context; + * a `defer` returns it automatically when the coroutine ends — no manual release + * anywhere in the codebase. * - * Broken connections: pass `null` to `Swoole\ConnectionPool::put()` and the pool - * will discard and recreate the slot automatically. + * Unlike a plain `Swoole\ConnectionPool` (a dumb channel), {@see ConnectionPool} + * actively keeps connections usable across a database outage: a connection idle beyond + * `aliveBypassWindow` is probed on borrow ({@see CdoConnectionFactory::validate()} → + * `ping()`) and a dead one is retired for a fresh socket, and a connection older than + * `maxLifetime` is rotated before it can go stale — restoring the FPM-era resilience + * (fresh connection ⇒ self-heal after recovery) without a per-borrow probe on hot + * connections. * * ## Pool size * Configs that implement {@see PpaPoolConfigInterface} (via {@see PpaPoolTrait}) @@ -53,7 +63,7 @@ final class PpaConnectionPool /** * Swoole: one ConnectionPool per config class. - * @var array + * @var array */ private static array $pools = []; @@ -169,8 +179,10 @@ private static function staticDb(string $configClass): CDO } /** - * Swoole path: borrow from Swoole\ConnectionPool on first call in this coroutine, - * cache in coroutine context, auto-release via defer on coroutine end. + * Swoole path: borrow one connection from the {@see ConnectionPool} on the first + * call in this coroutine, cache the {@see PoolEntry} in coroutine context, and + * auto-release via defer when the coroutine ends. The pool validates idle + * connections and rotates aged ones on borrow (see the class docblock). */ private static function coroutineDb(string $configClass): CDO { @@ -178,61 +190,54 @@ private static function coroutineDb(string $configClass): CDO $ctx = \Swoole\Coroutine::getContext(); if (!isset($ctx[$ctxKey])) { - $swPool = self::swPool($configClass); - $config = self::getConfigDb($configClass); - $timeout = $config instanceof PpaPoolConfigInterface - ? $config->getPoolWaitTimeout() - : 3.0; - - $cid = \Swoole\Coroutine::getCid(); + $pool = self::pool($configClass); + $cid = \Swoole\Coroutine::getCid(); self::logger()->debug("cid={$cid} borrow: {$configClass}"); try { - /** @var CDO|false $cdo */ - $cdo = $swPool->get($timeout); - } catch (\Throwable $e) { - self::logger()->error("cid={$cid} connect failed: {$configClass} — {$e->getMessage()}"); + $entry = $pool->borrow(); + } catch (PoolException $e) { + self::logger()->error("cid={$cid} borrow failed: {$configClass} — {$e->getMessage()}"); throw new PpaPoolException( "PpaConnectionPool: connection failed for [{$configClass}] — {$e->getMessage()}", previous: $e ); } - if ($cdo === false) { - self::logger()->error("cid={$cid} exhausted: {$configClass} (timeout={$timeout}s)"); - throw new PpaPoolException( - "PpaConnectionPool: no free connection for [{$configClass}] " - . "within {$timeout}s — increase poolMaxConnections or poolWaitTimeout" - ); - } - - $ctx[$ctxKey] = $cdo; + $ctx[$ctxKey] = $entry; // Auto-return when the coroutine finishes (normal exit OR exception). - // $cdo is captured directly — safer than reading from $ctx during teardown. - \Swoole\Coroutine::defer(static function () use ($swPool, $cdo, $cid, $configClass): void { + // $entry is captured directly — safer than reading from $ctx during teardown. + \Swoole\Coroutine::defer(static function () use ($pool, $entry, $cid, $configClass): void { self::logger()->debug("cid={$cid} release: {$configClass}"); - $swPool->put($cdo); + $pool->release($entry); }); } - $driver = $ctx[$ctxKey]->getAttribute(\PDO::ATTR_DRIVER_NAME); + /** @var PoolEntry $entry */ + $entry = $ctx[$ctxKey]; + /** @var DbConfigInterface $config */ + $config = $entry->resource; + $cdo = $config->connection(); + + $driver = $cdo->getAttribute(\PDO::ATTR_DRIVER_NAME); if (!empty($driver)) { - $ctx[$ctxKey]->applyDatabaseTimezone($driver, date_default_timezone_get()); + $cdo->applyDatabaseTimezone($driver, date_default_timezone_get()); } - return $ctx[$ctxKey]; + return $cdo; } /** - * Returns (and lazily creates) the Swoole\ConnectionPool for the given config class. + * Returns (and lazily creates) the {@see ConnectionPool} for the given config class. * - * The factory callable passed to Swoole\ConnectionPool creates a fresh CDO - * from a dedicated config instance per slot — guaranteeing independent sockets. - * Swoole\ConnectionPool itself is lazy: it calls the factory only when a slot - * is needed (up to `poolMaxConnections`). + * The {@see CdoConnectionFactory} opens one independent CDO per slot (own socket). + * The pool is lazy: it opens a connection only when a slot is needed (up to + * `maximumPoolSize`). Sizing/timeout come from {@see PpaPoolConfigInterface} when + * the config implements it; `maxLifetime`/`aliveBypassWindow` use the + * {@see PoolPolicy} defaults. */ - private static function swPool(string $configClass): \Swoole\ConnectionPool + private static function pool(string $configClass): ConnectionPool { $key = base64_encode($configClass); if (!isset(self::$pools[$key])) { @@ -240,21 +245,16 @@ private static function swPool(string $configClass): \Swoole\ConnectionPool $maxConn = $config instanceof PpaPoolConfigInterface ? $config->getPoolMaxConnections() : self::DEFAULT_POOL_SIZE; + $timeout = $config instanceof PpaPoolConfigInterface + ? $config->getPoolWaitTimeout() + : 3.0; self::logger()->debug("pool created: {$configClass} maxConnections={$maxConn}"); - // Factory: each call creates one independent CDO (own socket). - $factory = static function () use ($configClass): CDO { - /** @var DbConfigInterface $slotConfig */ - $slotConfig = new $configClass(); - $slotConfig->setUp(); - $slotConfig->setLogger(self::logger()); - $cdo = $slotConfig->connection(); - self::logger()->debug("slot opened: {$configClass} dsn={$slotConfig->getDns()}"); - return $cdo; - }; - - self::$pools[$key] = new \Swoole\ConnectionPool($factory, $maxConn); + self::$pools[$key] = new ConnectionPool( + new CdoConnectionFactory($configClass, self::logger()), + new PoolPolicy(maximumPoolSize: $maxConn, connectionTimeout: $timeout), + ); } return self::$pools[$key]; } diff --git a/tests/ConnectionPool/ConnectionPoolTest.php b/tests/ConnectionPool/ConnectionPoolTest.php new file mode 100644 index 0000000..8371772 --- /dev/null +++ b/tests/ConnectionPool/ConnectionPoolTest.php @@ -0,0 +1,232 @@ +borrow(); + $out = ['isEntry' => $e instanceof PoolEntry, 'created' => $f->created, 'validated' => $f->validated]; + }); + + self::assertTrue($out['isEntry']); + self::assertSame(1, $out['created']); + self::assertSame(0, $out['validated'], 'a freshly opened connection is not probed'); + } + + public function test_reuses_idle_connection_within_bypass_window(): void + { + $f = new MockFactory(); + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$out): void { + $pool = new ConnectionPool($f, new PoolPolicy(aliveBypassWindow: 0.5)); + $a = $pool->borrow(); + $pool->release($a); + $b = $pool->borrow(); // immediate → idle < window → no probe + $out = ['same' => $a === $b, 'created' => $f->created, 'validated' => $f->validated]; + }); + + self::assertTrue($out['same'], 'idle connection is reused'); + self::assertSame(1, $out['created']); + self::assertSame(0, $out['validated'], 'hot connection skips the probe'); + } + + public function test_probes_connection_idle_beyond_bypass_window(): void + { + $f = new MockFactory(); + $time = 1000.0; + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$time, &$out): void { + $pool = new ConnectionPool($f, new PoolPolicy(aliveBypassWindow: 0.5), static function () use (&$time): float { + return $time; + }); + $a = $pool->borrow(); + $pool->release($a); + $time = 1002.0; // idle 2s > 0.5 window + $b = $pool->borrow(); // → probe (alive) → reuse + $out = ['same' => $a === $b, 'validated' => $f->validated, 'created' => $f->created]; + }); + + self::assertTrue($out['same']); + self::assertSame(1, $out['validated'], 'idle-beyond-window connection is probed'); + self::assertSame(1, $out['created']); + } + + public function test_retires_dead_connection_and_opens_fresh(): void + { + $f = new MockFactory(); + $time = 1000.0; + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$time, &$out): void { + $pool = new ConnectionPool($f, new PoolPolicy(aliveBypassWindow: 0.5), static function () use (&$time): float { + return $time; + }); + $a = $pool->borrow(); + $pool->release($a); + $time = 1002.0; + $f->alive = false; // the pooled connection has died + $b = $pool->borrow(); // probe fails → retire $a → open fresh + $out = ['same' => $a === $b, 'created' => $f->created, 'closed' => $f->closed]; + }); + + self::assertFalse($out['same'], 'a fresh connection replaces the dead one'); + self::assertSame(2, $out['created']); + self::assertSame(1, $out['closed'], 'the dead connection was closed'); + } + + public function test_retires_connection_past_max_lifetime(): void + { + $f = new MockFactory(); + $time = 1000.0; + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$time, &$out): void { + $pool = new ConnectionPool( + $f, + new PoolPolicy(maxLifetime: 10.0, aliveBypassWindow: 0.5, maxLifetimeJitter: 0.0), + static function () use (&$time): float { + return $time; + }, + ); + $a = $pool->borrow(); // expires at 1010 + $pool->release($a); + $time = 1011.0; // past maxLifetime + $b = $pool->borrow(); // expired → retire → fresh + $out = ['same' => $a === $b, 'created' => $f->created]; + }); + + self::assertFalse($out['same']); + self::assertSame(2, $out['created']); + } + + public function test_exhaustion_throws_after_connection_timeout(): void + { + $f = new MockFactory(); + $caught = false; + $created = 0; + \Swoole\Coroutine\run(function () use ($f, &$caught, &$created): void { + $pool = new ConnectionPool($f, new PoolPolicy(maximumPoolSize: 2, connectionTimeout: 0.05)); + $pool->borrow(); + $pool->borrow(); // pool full (2/2), both held + try { + $pool->borrow(); // no release → waits 0.05s → exhausted + } catch (PoolException) { + $caught = true; + } + $created = $f->created; + }); + + self::assertTrue($caught, 'a full pool fails fast after connectionTimeout'); + self::assertSame(2, $created); + } + + public function test_connect_failure_throws(): void + { + $f = new MockFactory(); + $f->failCreate = true; + $caught = false; + \Swoole\Coroutine\run(function () use ($f, &$caught): void { + $pool = new ConnectionPool($f); + try { + $pool->borrow(); + } catch (PoolException) { + $caught = true; + } + }); + + self::assertTrue($caught); + self::assertSame(0, $f->created); + } + + public function test_max_lifetime_jitter_within_bounds(): void + { + $f = new MockFactory(); + $expiry = null; + \Swoole\Coroutine\run(function () use ($f, &$expiry): void { + $pool = new ConnectionPool( + $f, + new PoolPolicy(maxLifetime: 100.0, maxLifetimeJitter: 0.1), + static fn(): float => 1000.0, + ); + $expiry = $pool->borrow()->expiresAt; + }); + + self::assertNotNull($expiry); + self::assertGreaterThanOrEqual(1000.0 + 90.0, $expiry, 'within -10% jitter'); + self::assertLessThanOrEqual(1000.0 + 110.0, $expiry, 'within +10% jitter'); + } + + public function test_stats_track_total_idle_active(): void + { + $f = new MockFactory(); + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$out): void { + $pool = new ConnectionPool($f, new PoolPolicy(maximumPoolSize: 5)); + $a = $pool->borrow(); + $pool->borrow(); + $out['held'] = $pool->stats(); + $pool->release($a); + $out['oneBack'] = $pool->stats(); + }); + + self::assertSame(['total' => 2, 'idle' => 0, 'active' => 2, 'maximum' => 5], $out['held']); + self::assertSame(['total' => 2, 'idle' => 1, 'active' => 1, 'maximum' => 5], $out['oneBack']); + } +} + +// ── Fixtures ──────────────────────────────────────────────────────────────────── + +final class MockFactory implements ConnectionFactory +{ + public int $created = 0; + public int $closed = 0; + public int $validated = 0; + public bool $alive = true; + public bool $failCreate = false; + + public function create(): object + { + if ($this->failCreate) { + throw new \RuntimeException('connect refused'); + } + ++$this->created; + return (object) ['id' => $this->created]; + } + + public function validate(object $connection): bool + { + ++$this->validated; + return $this->alive; + } + + public function close(object $connection): void + { + ++$this->closed; + } +} diff --git a/tests/ConnectionPool/PoolPolicyTest.php b/tests/ConnectionPool/PoolPolicyTest.php new file mode 100644 index 0000000..f968700 --- /dev/null +++ b/tests/ConnectionPool/PoolPolicyTest.php @@ -0,0 +1,30 @@ +maximumPoolSize); + self::assertSame(15.0, $p->connectionTimeout); + self::assertSame(1800.0, $p->maxLifetime); + self::assertSame(0.5, $p->aliveBypassWindow); + self::assertSame(0.1, $p->maxLifetimeJitter); + } + + public function test_overrides(): void + { + $p = new PoolPolicy(maximumPoolSize: 20, maxLifetime: 0.0); + + self::assertSame(20, $p->maximumPoolSize); + self::assertSame(0.0, $p->maxLifetime, 'maxLifetime 0 disables rotation'); + } +} From c692fb33267a842c5247fb4f08838df4d77d6e7f Mon Sep 17 00:00:00 2001 From: flytachi Date: Fri, 31 Jul 2026 18:58:06 +0500 Subject: [PATCH 36/71] pool --- phpunit.xml | 3 + src/ConnectionPool/SingleConnection.php | 143 ++++++++++++++++++ src/Ppa/Pool/PpaConnectionPool.php | 24 ++- tests/ConnectionPool/ConnectionPoolTest.php | 32 ---- tests/ConnectionPool/MockFactory.php | 42 +++++ tests/ConnectionPool/SingleConnectionTest.php | 142 +++++++++++++++++ 6 files changed, 349 insertions(+), 37 deletions(-) create mode 100644 src/ConnectionPool/SingleConnection.php create mode 100644 tests/ConnectionPool/MockFactory.php create mode 100644 tests/ConnectionPool/SingleConnectionTest.php diff --git a/phpunit.xml b/phpunit.xml index e52afb3..ba7b68b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -40,6 +40,9 @@ tests/App + + tests/ConnectionPool + diff --git a/src/ConnectionPool/SingleConnection.php b/src/ConnectionPool/SingleConnection.php new file mode 100644 index 0000000..694eacf --- /dev/null +++ b/src/ConnectionPool/SingleConnection.php @@ -0,0 +1,143 @@ +clock = $clock ?? static fn(): float => hrtime(true) / 1e9; + } + + /** + * Returns the live connection, reopening it when the current one has died + * (idle-gated probe) or aged past maxLifetime. + * + * @throws PoolException On connect failure. + */ + public function get(): object + { + $now = $this->now(); + if ($this->entry !== null + && ($this->isExpired($this->entry, $now) + || ($this->needsProbe($this->entry, $now) && !$this->probe($this->entry))) + ) { + $this->discard(); + } + if ($this->entry === null) { + $this->entry = $this->open($now); + } + $this->entry->lastUsedAt = $this->now(); + return $this->entry->resource; + } + + /** + * Retires the current connection (close + forget) — for a connection-level failure + * detected during use, so the next {@see get()} opens a fresh one. + */ + public function evict(): void + { + $this->discard(); + } + + /** Closes the connection and forgets it. */ + public function close(): void + { + $this->discard(); + } + + // ── internals ────────────────────────────────────────────────────────────── + + private function open(float $now): PoolEntry + { + try { + $resource = $this->factory->create(); + } catch (Throwable $e) { + throw PoolException::connectFailed($e); + } + $expiresAt = $this->policy->maxLifetime > 0.0 ? $now + $this->policy->maxLifetime : null; + return new PoolEntry($resource, $now, $now, $expiresAt); + } + + private function discard(): void + { + if ($this->entry !== null) { + $this->safeClose($this->entry->resource); + $this->entry = null; + } + } + + private function isExpired(PoolEntry $entry, float $now): bool + { + return $entry->expiresAt !== null && $now >= $entry->expiresAt; + } + + private function needsProbe(PoolEntry $entry, float $now): bool + { + return ($now - $entry->lastUsedAt) > $this->policy->aliveBypassWindow; + } + + private function probe(PoolEntry $entry): bool + { + try { + return $this->factory->validate($entry->resource); + } catch (Throwable) { + return false; + } + } + + private function safeClose(object $resource): void + { + try { + $this->factory->close($resource); + } catch (Throwable) { + } + } + + private function now(): float + { + return ($this->clock)(); + } +} diff --git a/src/Ppa/Pool/PpaConnectionPool.php b/src/Ppa/Pool/PpaConnectionPool.php index f413935..420a762 100644 --- a/src/Ppa/Pool/PpaConnectionPool.php +++ b/src/Ppa/Pool/PpaConnectionPool.php @@ -12,6 +12,7 @@ use Flytachi\Winter\K2\ConnectionPool\PoolEntry; use Flytachi\Winter\K2\ConnectionPool\PoolException; use Flytachi\Winter\K2\ConnectionPool\PoolPolicy; +use Flytachi\Winter\K2\ConnectionPool\SingleConnection; use Psr\Log\LoggerInterface; /** @@ -74,8 +75,9 @@ final class PpaConnectionPool private static array $configs = []; /** - * FPM: one CDO per config class for the lifetime of the process/request. - * @var array + * FPM / non-coroutine: one self-maintaining {@see SingleConnection} per config + * class for the lifetime of the process. + * @var array */ private static array $static = []; @@ -166,16 +168,28 @@ public static function reset(): void // ------------------------------------------------------------------------- /** - * FPM path: singleton CDO per config class for the process lifetime. + * FPM / non-coroutine path: one {@see SingleConnection} per config class for the + * process lifetime. For a short FPM request the connection is freshly opened, so + * the liveness checks are near no-ops; for a long-running non-coroutine process + * (e.g. a Sync daemon querying the DB) the same idle-gate + maxLifetime that the + * coroutine pool applies keep the connection healthy across a DB outage. */ private static function staticDb(string $configClass): CDO { $key = base64_encode($configClass); if (!isset(self::$static[$key])) { - self::$static[$key] = self::getConfigDb($configClass)->connection(); + // Register the config for diagnostics (showDbConfigs) and future static knobs. + self::getConfigDb($configClass); + self::$static[$key] = new SingleConnection( + new CdoConnectionFactory($configClass, self::logger()), + new PoolPolicy(), + ); self::logger()->debug("FPM connection opened: {$configClass}"); } - return self::$static[$key]; + + /** @var DbConfigInterface $config */ + $config = self::$static[$key]->get(); + return $config->connection(); } /** diff --git a/tests/ConnectionPool/ConnectionPoolTest.php b/tests/ConnectionPool/ConnectionPoolTest.php index 8371772..8490736 100644 --- a/tests/ConnectionPool/ConnectionPoolTest.php +++ b/tests/ConnectionPool/ConnectionPoolTest.php @@ -4,7 +4,6 @@ namespace Flytachi\Winter\K2\Tests\ConnectionPool; -use Flytachi\Winter\K2\ConnectionPool\ConnectionFactory; use Flytachi\Winter\K2\ConnectionPool\ConnectionPool; use Flytachi\Winter\K2\ConnectionPool\PoolEntry; use Flytachi\Winter\K2\ConnectionPool\PoolException; @@ -199,34 +198,3 @@ public function test_stats_track_total_idle_active(): void self::assertSame(['total' => 2, 'idle' => 1, 'active' => 1, 'maximum' => 5], $out['oneBack']); } } - -// ── Fixtures ──────────────────────────────────────────────────────────────────── - -final class MockFactory implements ConnectionFactory -{ - public int $created = 0; - public int $closed = 0; - public int $validated = 0; - public bool $alive = true; - public bool $failCreate = false; - - public function create(): object - { - if ($this->failCreate) { - throw new \RuntimeException('connect refused'); - } - ++$this->created; - return (object) ['id' => $this->created]; - } - - public function validate(object $connection): bool - { - ++$this->validated; - return $this->alive; - } - - public function close(object $connection): void - { - ++$this->closed; - } -} diff --git a/tests/ConnectionPool/MockFactory.php b/tests/ConnectionPool/MockFactory.php new file mode 100644 index 0000000..3c97cee --- /dev/null +++ b/tests/ConnectionPool/MockFactory.php @@ -0,0 +1,42 @@ +failCreate) { + throw new \RuntimeException('connect refused'); + } + ++$this->created; + return (object) ['id' => $this->created]; + } + + public function validate(object $connection): bool + { + ++$this->validated; + return $this->alive; + } + + public function close(object $connection): void + { + ++$this->closed; + } +} diff --git a/tests/ConnectionPool/SingleConnectionTest.php b/tests/ConnectionPool/SingleConnectionTest.php new file mode 100644 index 0000000..578c67b --- /dev/null +++ b/tests/ConnectionPool/SingleConnectionTest.php @@ -0,0 +1,142 @@ +get(); + + self::assertSame(1, $f->created); + self::assertSame(0, $f->validated, 'a freshly opened connection is not probed'); + self::assertSame(1, $r->id); + } + + public function test_reuses_connection_within_bypass_window(): void + { + $f = new MockFactory(); + $c = new SingleConnection($f, new PoolPolicy(aliveBypassWindow: 0.5)); + + $a = $c->get(); + $b = $c->get(); // immediate → idle < window → no probe + + self::assertSame($a, $b); + self::assertSame(1, $f->created); + self::assertSame(0, $f->validated, 'hot connection skips the probe'); + } + + public function test_probes_connection_idle_beyond_bypass_window(): void + { + $f = new MockFactory(); + $time = 1000.0; + $c = new SingleConnection($f, new PoolPolicy(aliveBypassWindow: 0.5), static function () use (&$time): float { + return $time; + }); + + $a = $c->get(); + $time = 1002.0; // idle 2s > 0.5 window + $b = $c->get(); // → probe (alive) → reuse + + self::assertSame($a, $b); + self::assertSame(1, $f->validated, 'idle-beyond-window connection is probed'); + self::assertSame(1, $f->created); + } + + public function test_retires_dead_connection_and_reopens(): void + { + $f = new MockFactory(); + $time = 1000.0; + $c = new SingleConnection($f, new PoolPolicy(aliveBypassWindow: 0.5), static function () use (&$time): float { + return $time; + }); + + $a = $c->get(); + $time = 1002.0; + $f->alive = false; // the connection has died + $b = $c->get(); // probe fails → retire → reopen + + self::assertNotSame($a, $b); + self::assertSame(2, $f->created); + self::assertSame(1, $f->closed, 'the dead connection was closed'); + } + + public function test_retires_connection_past_max_lifetime(): void + { + $f = new MockFactory(); + $time = 1000.0; + $c = new SingleConnection( + $f, + new PoolPolicy(maxLifetime: 10.0, aliveBypassWindow: 0.5), + static function () use (&$time): float { + return $time; + }, + ); + + $a = $c->get(); // expires at 1010 + $time = 1011.0; // past maxLifetime + $b = $c->get(); // expired → retire → reopen + + self::assertNotSame($a, $b); + self::assertSame(2, $f->created); + } + + public function test_maxlifetime_zero_disables_rotation(): void + { + $f = new MockFactory(); + $time = 1000.0; + $c = new SingleConnection( + $f, + new PoolPolicy(maxLifetime: 0.0, aliveBypassWindow: 0.5), + static function () use (&$time): float { + return $time; + }, + ); + + $a = $c->get(); + $time = 999_999.0; // ancient, but rotation is off + $b = $c->get(); // idle → probe (alive) → reuse, never rotated + + self::assertSame($a, $b); + self::assertSame(1, $f->created); + } + + public function test_connect_failure_throws(): void + { + $f = new MockFactory(); + $f->failCreate = true; + $c = new SingleConnection($f); + + $this->expectException(PoolException::class); + $c->get(); + } + + public function test_evict_forces_reopen_on_next_get(): void + { + $f = new MockFactory(); + $c = new SingleConnection($f); + + $a = $c->get(); + $c->evict(); + $b = $c->get(); + + self::assertNotSame($a, $b); + self::assertSame(2, $f->created); + self::assertSame(1, $f->closed); + } +} From e6ca022dab54a01f20ed0ca9c315c0af528fef66 Mon Sep 17 00:00:00 2001 From: flytachi Date: Sat, 1 Aug 2026 14:04:35 +0500 Subject: [PATCH 37/71] pool --- src/ConnectionPool/ConnectionPool.php | 127 +++++++++- src/ConnectionPool/PoolPolicy.php | 25 ++ src/Http/Health/HealthIndicator.php | 38 +++ src/Ppa/Pool/PpaConnectionPool.php | 47 +++- src/Ppa/Pool/PpaPoolConfigInterface.php | 21 ++ src/Ppa/Pool/PpaPoolTrait.php | 18 ++ tests/ConnectionPool/HousekeeperTest.php | 222 ++++++++++++++++++ tests/Ppa/Pool/PpaConnectionPoolStatsTest.php | 107 +++++++++ tests/Ppa/Pool/PpaPoolTraitTest.php | 47 ++++ 9 files changed, 642 insertions(+), 10 deletions(-) create mode 100644 tests/ConnectionPool/HousekeeperTest.php create mode 100644 tests/Ppa/Pool/PpaConnectionPoolStatsTest.php create mode 100644 tests/Ppa/Pool/PpaPoolTraitTest.php diff --git a/src/ConnectionPool/ConnectionPool.php b/src/ConnectionPool/ConnectionPool.php index 2206108..c784c56 100644 --- a/src/ConnectionPool/ConnectionPool.php +++ b/src/ConnectionPool/ConnectionPool.php @@ -40,6 +40,9 @@ final class ConnectionPool /** Connections opened (idle in the channel + borrowed out). */ private int $total = 0; + /** Swoole timer id of the background housekeeper, or null when not armed. */ + private ?int $timerId = null; + /** @var Closure(): float Monotonic seconds source (test seam). */ private readonly Closure $clock; @@ -67,6 +70,7 @@ public function __construct( */ public function borrow(): PoolEntry { + $this->ensureHousekeeper(); for ($attempt = 0; $attempt <= self::MAX_RETIRE_LOOPS; ++$attempt) { $entry = $this->acquire(); if ($entry === null) { @@ -116,9 +120,28 @@ public function stats(): array ]; } - /** Closes every idle connection and the pool itself. */ + /** + * Stops the housekeeper and drops every pooled connection **without closing it** + * — the fork-safe counterpart of {@see close()}. + * + * A fork copies file descriptors, so a child must never close an inherited socket + * (that would tear down the connection its parent is still using); it must simply + * forget it and open its own. Clearing the timer is the part {@see close()} and + * this share: a `Timer::tick` callback holds a reference to the pool, so a pool + * merely dereferenced would stay alive and keep maintaining connections nobody + * uses. See {@see \Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool::reset()}. + */ + public function abandon(): void + { + $this->clearHousekeeper(); + $this->idle = null; + $this->total = 0; + } + + /** Closes every idle connection and the pool itself (also stops the housekeeper). */ public function close(): void { + $this->clearHousekeeper(); if ($this->idle === null) { return; } @@ -133,6 +156,108 @@ public function close(): void $this->total = 0; } + // ── housekeeping ───────────────────────────────────────────────────────────── + + /** + * Arms the background housekeeper on first borrow, once, when maintenance is + * enabled and Swoole is present. The first borrow always runs inside a coroutine, + * so the reactor exists to host the timer. A no-op when maintenance is off (an + * unconfigured pool never arms a timer). + */ + private function ensureHousekeeper(): void + { + if ($this->timerId !== null + || !$this->policy->housekeepingEnabled() + || !extension_loaded('swoole') + ) { + return; + } + $ms = (int) max(1000.0, $this->policy->housekeepingInterval * 1000.0); + $this->timerId = \Swoole\Timer::tick($ms, function (): void { + try { + $this->maintain(); + } catch (Throwable) { + // A maintenance pass must never kill the timer. + } + }); + } + + /** Disarms the housekeeping timer, if armed. */ + private function clearHousekeeper(): void + { + if ($this->timerId === null) { + return; + } + if (extension_loaded('swoole')) { + \Swoole\Timer::clear($this->timerId); + } + $this->timerId = null; + } + + /** + * One background maintenance pass over the idle connections: retire aged ones + * (maxLifetime), shrink idle-too-long ones toward `minimumIdle` (idleTimeout), + * proactively probe long-idle survivors (keepaliveTime), then top up warm + * connections to `minimumIdle`. Borrowed-out connections are not in the channel, + * so this never touches an in-use connection. + */ + private function maintain(): void + { + if ($this->idle === null) { + return; + } + $now = $this->now(); + $count = $this->idle->length(); + $shrinkable = max(0, $this->total - $this->policy->minimumIdle); + $survivors = []; + + for ($i = 0; $i < $count; ++$i) { + $entry = $this->idle->pop(0.001); + if (!$entry instanceof PoolEntry) { + break; // drained by a concurrent borrow + } + // maxLifetime — always retire; the floor is refilled by top-up below. + if ($this->isExpired($entry)) { + $this->discard($entry); + continue; + } + // idleTimeout — shrink toward minimumIdle, within budget. + if ($this->policy->idleTimeout > 0.0 + && $shrinkable > 0 + && ($now - $entry->lastUsedAt) >= $this->policy->idleTimeout + ) { + --$shrinkable; + $this->discard($entry); + continue; + } + // keepalive — proactive probe. Does NOT reset lastUsedAt, so idleTimeout + // keeps measuring real application idleness. + if ($this->policy->keepaliveTime > 0.0 + && ($now - $entry->lastUsedAt) >= $this->policy->keepaliveTime + && !$this->probe($entry) + ) { + $this->discard($entry); + continue; + } + $survivors[] = $entry; + } + + foreach ($survivors as $entry) { + $this->idle->push($entry); + } + + // minimumIdle — reopen warm connections up to the floor (best-effort). + while ($this->total < $this->policy->minimumIdle + && $this->total < $this->policy->maximumPoolSize + ) { + try { + $this->idle->push($this->make()); + } catch (PoolException) { + break; // DB unreachable — retry on the next pass + } + } + } + // ── internals ────────────────────────────────────────────────────────────── /** Gets an idle connection, grows the pool, or waits for a release. */ diff --git a/src/ConnectionPool/PoolPolicy.php b/src/ConnectionPool/PoolPolicy.php index 551535f..d235a18 100644 --- a/src/ConnectionPool/PoolPolicy.php +++ b/src/ConnectionPool/PoolPolicy.php @@ -28,6 +28,18 @@ * `aliveBypassWindow`, default 500ms). * @param float $maxLifetimeJitter Fraction `0..1` of random spread applied to * `maxLifetime` so connections don't all expire at the same instant. + * @param float $housekeepingInterval Seconds between background {@see ConnectionPool} + * maintenance passes. Only relevant when a maintenance knob below is enabled. + * @param float $keepaliveTime Seconds: the housekeeper proactively probes idle + * connections idle at least this long, retiring dead ones **before** a borrow + * sees them (and keeping idle-killing proxies/firewalls from dropping them). + * `0` = disabled (HikariCP `keepaliveTime`). Swoole only. + * @param float $idleTimeout Seconds: the housekeeper closes connections idle at + * least this long, shrinking the pool down to `minimumIdle`. `0` = never shrink + * (HikariCP `idleTimeout`). Swoole only. + * @param int $minimumIdle Warm floor the housekeeper maintains — it reopens + * connections up to this count and never shrinks below it. `0` = fully lazy + * (HikariCP `minimumIdle`). Swoole only. */ public function __construct( public int $maximumPoolSize = 10, @@ -35,6 +47,10 @@ public function __construct( public float $maxLifetime = 1800.0, public float $aliveBypassWindow = 0.5, public float $maxLifetimeJitter = 0.1, + public float $housekeepingInterval = 30.0, + public float $keepaliveTime = 0.0, + public float $idleTimeout = 0.0, + public int $minimumIdle = 0, ) { } @@ -42,4 +58,13 @@ public static function default(): self { return new self(); } + + /** + * Whether any background maintenance is enabled — the pool arms its housekeeping + * timer only when this is true, so an unconfigured pool pays nothing. + */ + public function housekeepingEnabled(): bool + { + return $this->keepaliveTime > 0.0 || $this->idleTimeout > 0.0 || $this->minimumIdle > 0; + } } diff --git a/src/Http/Health/HealthIndicator.php b/src/Http/Health/HealthIndicator.php index 0301344..7aadde6 100644 --- a/src/Http/Health/HealthIndicator.php +++ b/src/Http/Health/HealthIndicator.php @@ -10,6 +10,7 @@ use Flytachi\Winter\DI\Scanner; use Flytachi\Winter\K2\Collector\ImplementorCollector; use Flytachi\Winter\K2\Http\Header; +use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; class HealthIndicator implements HealthIndicatorInterface { @@ -22,6 +23,7 @@ public function health(): array $rootDir = Health::getRootDir(); $components = [ 'db' => $this->dbHealth($rootDir), + 'pool' => $this->poolHealth(), 'cache' => $this->cacheHealth($rootDir), 'disk' => $this->diskHealth(), 'memory' => $this->memoryHealth(), @@ -209,6 +211,42 @@ final protected function dbHealth(string $rootDir): array return ['status' => $worstStatus, 'details' => $details]; } + // ── Connection-pool utilisation (PpaConnectionPool) ─────────────────────── + + /** + * Live utilisation of every Swoole coroutine pool ({@see PpaConnectionPool::stats()}), + * one detail entry per config. Reports `degraded` for any pool that is saturated + * (all connections handed out — `active >= maximum`, none idle), which is the + * signal that borrows are starting to queue. Numbers are per worker (see + * {@see PpaConnectionPool::stats()}); an empty report (FPM, or no pool used yet) + * is `up`. + * + * @return array{status: string, details: array} + */ + private function poolHealth(): array + { + $details = []; + $worstStatus = 'up'; + + foreach (PpaConnectionPool::stats() as $config => $stat) { + $saturated = $stat['maximum'] > 0 && $stat['active'] >= $stat['maximum']; + $status = $saturated ? 'degraded' : 'up'; + if ($status === 'degraded' && $worstStatus === 'up') { + $worstStatus = 'degraded'; + } + + $details[$config] = [ + 'status' => $status, + 'active' => $stat['active'], + 'idle' => $stat['idle'], + 'total' => $stat['total'], + 'maximum' => $stat['maximum'], + ]; + } + + return ['status' => $worstStatus, 'details' => $details]; + } + // ── Cache health (requires flytachi/winter-cache) ───────────────────────── final protected function cacheHealth(string $rootDir): array diff --git a/src/Ppa/Pool/PpaConnectionPool.php b/src/Ppa/Pool/PpaConnectionPool.php index 420a762..53d5707 100644 --- a/src/Ppa/Pool/PpaConnectionPool.php +++ b/src/Ppa/Pool/PpaConnectionPool.php @@ -141,6 +141,25 @@ public static function showDbConfigs(): array return self::$configs; } + /** + * Live utilisation of every Swoole coroutine pool, keyed by config FQCN — the + * HikariCP-style view (active / idle / total vs maximum) for `/actuator/health`. + * + * These numbers are **per worker**: each Swoole worker holds its own in-memory + * pool (as HikariCP is per-JVM), so a health request reflects the worker that + * served it. The static FPM/non-coroutine path has no pool and is not reported. + * + * @return array + */ + public static function stats(): array + { + $out = []; + foreach (self::$pools as $key => $pool) { + $out[base64_decode($key)] = $pool->stats(); + } + return $out; + } + /** * Drops every cached connection, pool and config so the next `db()` opens * fresh sockets — the fork-safety reset. @@ -158,6 +177,13 @@ public static function showDbConfigs(): array */ public static function reset(): void { + // Abandon (never close) each pool first: a housekeeping Timer::tick callback + // holds a reference to its pool, so a pool that is merely dereferenced would + // stay alive and keep maintaining connections this process no longer owns. + // abandon() clears that timer without touching the inherited sockets. + foreach (self::$pools as $pool) { + $pool->abandon(); + } self::$pools = []; self::$static = []; self::$configs = []; @@ -255,19 +281,22 @@ private static function pool(string $configClass): ConnectionPool { $key = base64_encode($configClass); if (!isset(self::$pools[$key])) { - $config = self::getConfigDb($configClass); - $maxConn = $config instanceof PpaPoolConfigInterface - ? $config->getPoolMaxConnections() - : self::DEFAULT_POOL_SIZE; - $timeout = $config instanceof PpaPoolConfigInterface - ? $config->getPoolWaitTimeout() - : 3.0; + $config = self::getConfigDb($configClass); + $policy = $config instanceof PpaPoolConfigInterface + ? new PoolPolicy( + maximumPoolSize: $config->getPoolMaxConnections(), + connectionTimeout: $config->getPoolWaitTimeout(), + keepaliveTime: $config->getKeepaliveTime(), + idleTimeout: $config->getIdleTimeout(), + minimumIdle: $config->getMinimumIdle(), + ) + : new PoolPolicy(maximumPoolSize: self::DEFAULT_POOL_SIZE, connectionTimeout: 3.0); - self::logger()->debug("pool created: {$configClass} maxConnections={$maxConn}"); + self::logger()->debug("pool created: {$configClass} maxConnections={$policy->maximumPoolSize}"); self::$pools[$key] = new ConnectionPool( new CdoConnectionFactory($configClass, self::logger()), - new PoolPolicy(maximumPoolSize: $maxConn, connectionTimeout: $timeout), + $policy, ); } return self::$pools[$key]; diff --git a/src/Ppa/Pool/PpaPoolConfigInterface.php b/src/Ppa/Pool/PpaPoolConfigInterface.php index 69c3bbb..30652ff 100644 --- a/src/Ppa/Pool/PpaPoolConfigInterface.php +++ b/src/Ppa/Pool/PpaPoolConfigInterface.php @@ -26,4 +26,25 @@ interface PpaPoolConfigInterface { public function getPoolMaxConnections(): int; public function getPoolWaitTimeout(): float; + + /** + * Seconds after which the background housekeeper proactively probes an idle + * connection (retiring dead ones before a borrow sees them). `0` = disabled. + * Swoole only — has no effect under FPM. See HikariCP `keepaliveTime`. + */ + public function getKeepaliveTime(): float; + + /** + * Seconds after which the housekeeper closes an idle connection, shrinking the + * pool down to {@see getMinimumIdle()}. `0` = never shrink. Swoole only. See + * HikariCP `idleTimeout`. + */ + public function getIdleTimeout(): float; + + /** + * Warm connection floor the housekeeper maintains (reopens up to this count, + * never shrinks below it). `0` = fully lazy. Swoole only. See HikariCP + * `minimumIdle`. + */ + public function getMinimumIdle(): int; } diff --git a/src/Ppa/Pool/PpaPoolTrait.php b/src/Ppa/Pool/PpaPoolTrait.php index 8c5b299..1f53d58 100644 --- a/src/Ppa/Pool/PpaPoolTrait.php +++ b/src/Ppa/Pool/PpaPoolTrait.php @@ -23,6 +23,9 @@ * * @property int $poolMaxConnections Maximum number of CDO connections in the pool (default: 5). * @property float $poolWaitTimeout Seconds to wait for a free slot before {@see PpaPoolException} (default: 3.0). + * @property float $keepaliveTime Background probe of idle connections; 0 = off (default: 0.0). Swoole only. + * @property float $idleTimeout Close idle connections after N seconds; 0 = never (default: 0.0). Swoole only. + * @property int $minimumIdle Warm connection floor; 0 = fully lazy (default: 0). Swoole only. */ trait PpaPoolTrait { @@ -35,4 +38,19 @@ public function getPoolWaitTimeout(): float { return $this->poolWaitTimeout ?? 3.0; } + + public function getKeepaliveTime(): float + { + return $this->keepaliveTime ?? 0.0; + } + + public function getIdleTimeout(): float + { + return $this->idleTimeout ?? 0.0; + } + + public function getMinimumIdle(): int + { + return $this->minimumIdle ?? 0; + } } diff --git a/tests/ConnectionPool/HousekeeperTest.php b/tests/ConnectionPool/HousekeeperTest.php new file mode 100644 index 0000000..a72f7af --- /dev/null +++ b/tests/ConnectionPool/HousekeeperTest.php @@ -0,0 +1,222 @@ +close()` before the coroutine ends: the first `borrow()` + * with maintenance enabled arms a real `Swoole\Timer::tick`, which keeps the reactor + * alive — `close()` clears it so `Swoole\Coroutine\run` can return. + */ +final class HousekeeperTest extends TestCase +{ + protected function setUp(): void + { + if (!extension_loaded('swoole')) { + self::markTestSkipped('ConnectionPool needs a Swoole coroutine context.'); + } + } + + private static function maintain(ConnectionPool $pool): void + { + (new ReflectionMethod($pool, 'maintain'))->invoke($pool); + } + + public function test_keepalive_retires_dead_idle_connection(): void + { + $f = new MockFactory(); + $time = 1000.0; + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$time, &$out): void { + $pool = new ConnectionPool( + $f, + new PoolPolicy(keepaliveTime: 10.0), + static function () use (&$time): float { + return $time; + }, + ); + $pool->release($pool->borrow()); // idle, lastUsedAt = 1000 + $time = 1011.0; // idle 11s >= keepaliveTime + $f->alive = false; // died while idle + self::maintain($pool); + $out = ['validated' => $f->validated, 'closed' => $f->closed, 'stats' => $pool->stats()]; + $pool->close(); + }); + + self::assertSame(1, $out['validated'], 'a long-idle connection is probed'); + self::assertSame(1, $out['closed'], 'the dead connection is retired'); + self::assertSame(0, $out['stats']['total']); + } + + public function test_keepalive_keeps_live_idle_connection(): void + { + $f = new MockFactory(); + $time = 1000.0; + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$time, &$out): void { + $pool = new ConnectionPool($f, new PoolPolicy(keepaliveTime: 10.0), static function () use (&$time): float { + return $time; + }); + $pool->release($pool->borrow()); + $time = 1011.0; + self::maintain($pool); + $out = ['validated' => $f->validated, 'closed' => $f->closed, 'stats' => $pool->stats()]; + $pool->close(); + }); + + self::assertSame(1, $out['validated']); + self::assertSame(0, $out['closed'], 'a live connection survives'); + self::assertSame(1, $out['stats']['idle']); + } + + public function test_keepalive_skips_hot_idle_connection(): void + { + $f = new MockFactory(); + $time = 1000.0; + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$time, &$out): void { + $pool = new ConnectionPool($f, new PoolPolicy(keepaliveTime: 10.0), static function () use (&$time): float { + return $time; + }); + $pool->release($pool->borrow()); + $time = 1005.0; // idle 5s < keepaliveTime + self::maintain($pool); + $out = ['validated' => $f->validated, 'stats' => $pool->stats()]; + $pool->close(); + }); + + self::assertSame(0, $out['validated'], 'a recently-used connection is not probed'); + self::assertSame(1, $out['stats']['idle']); + } + + public function test_idle_timeout_shrinks_toward_minimum_idle(): void + { + $f = new MockFactory(); + $time = 1000.0; + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$time, &$out): void { + $pool = new ConnectionPool( + $f, + new PoolPolicy(maximumPoolSize: 5, idleTimeout: 10.0, minimumIdle: 1), + static function () use (&$time): float { + return $time; + }, + ); + $a = $pool->borrow(); + $b = $pool->borrow(); + $c = $pool->borrow(); // total = 3 + $pool->release($a); + $pool->release($b); + $pool->release($c); // idle = 3, all lastUsedAt = 1000 + $time = 1011.0; // idle 11s >= idleTimeout + self::maintain($pool); + $out = ['closed' => $f->closed, 'stats' => $pool->stats()]; + $pool->close(); + }); + + self::assertSame(2, $out['closed'], 'shrinks 3 → minimumIdle 1'); + self::assertSame(1, $out['stats']['total']); + self::assertSame(1, $out['stats']['idle']); + } + + public function test_minimum_idle_tops_up_from_empty(): void + { + $f = new MockFactory(); + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$out): void { + $pool = new ConnectionPool($f, new PoolPolicy(maximumPoolSize: 5, minimumIdle: 2)); + self::maintain($pool); // nothing idle → open the warm floor + $out = ['created' => $f->created, 'stats' => $pool->stats()]; + $pool->close(); + }); + + self::assertSame(2, $out['created']); + self::assertSame(2, $out['stats']['idle']); + self::assertSame(2, $out['stats']['total']); + } + + public function test_maintain_retires_expired_connection(): void + { + $f = new MockFactory(); + $time = 1000.0; + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$time, &$out): void { + $pool = new ConnectionPool( + $f, + new PoolPolicy(maxLifetime: 10.0, maxLifetimeJitter: 0.0, keepaliveTime: 5.0), + static function () use (&$time): float { + return $time; + }, + ); + $pool->release($pool->borrow()); // expires at 1010 + $time = 1011.0; // past maxLifetime + self::maintain($pool); + $out = ['closed' => $f->closed, 'validated' => $f->validated, 'stats' => $pool->stats()]; + $pool->close(); + }); + + self::assertSame(1, $out['closed']); + self::assertSame(0, $out['validated'], 'expired connection is retired without a probe'); + self::assertSame(0, $out['stats']['total']); + } + + public function test_abandon_clears_the_timer_without_closing_sockets(): void + { + $f = new MockFactory(); + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$out): void { + $pool = new ConnectionPool($f, new PoolPolicy(keepaliveTime: 10.0)); + $pool->release($pool->borrow()); // arms the housekeeper, 1 idle connection + $armed = (new \ReflectionProperty(ConnectionPool::class, 'timerId'))->getValue($pool) !== null; + + $pool->abandon(); + + $out = [ + 'armedBefore' => $armed, + 'armedAfter' => (new \ReflectionProperty(ConnectionPool::class, 'timerId'))->getValue($pool) !== null, + 'closed' => $f->closed, + ]; + }); + + self::assertTrue($out['armedBefore'], 'the housekeeper arms on first borrow'); + self::assertFalse($out['armedAfter'], 'abandon() disarms it — an orphaned pool must not keep maintaining'); + self::assertSame(0, $out['closed'], 'inherited sockets are forgotten, never closed (fork safety)'); + } + + public function test_maintain_leaves_borrowed_connections_untouched(): void + { + $f = new MockFactory(); + $time = 1000.0; + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$time, &$out): void { + $pool = new ConnectionPool( + $f, + new PoolPolicy(maximumPoolSize: 5, idleTimeout: 1.0, keepaliveTime: 1.0), + static function () use (&$time): float { + return $time; + }, + ); + $pool->borrow(); + $pool->borrow(); // both held — not in the idle channel + $time = 1010.0; + self::maintain($pool); + $out = ['closed' => $f->closed, 'validated' => $f->validated, 'stats' => $pool->stats()]; + $pool->close(); + }); + + self::assertSame(0, $out['closed'], 'in-use connections are never maintained'); + self::assertSame(0, $out['validated']); + self::assertSame(2, $out['stats']['total']); + } +} diff --git a/tests/Ppa/Pool/PpaConnectionPoolStatsTest.php b/tests/Ppa/Pool/PpaConnectionPoolStatsTest.php new file mode 100644 index 0000000..8f76a69 --- /dev/null +++ b/tests/Ppa/Pool/PpaConnectionPoolStatsTest.php @@ -0,0 +1,107 @@ + $pools */ + private function withPools(array $pools, callable $body): void + { + $prop = new ReflectionProperty(PpaConnectionPool::class, 'pools'); + $original = $prop->getValue(); + try { + $prop->setValue(null, $pools); + $body(); + } finally { + $prop->setValue(null, $original); + } + } + + private static function fullPool(int $maximum): ConnectionPool + { + $pool = new ConnectionPool(new MockFactory(), new PoolPolicy(maximumPoolSize: $maximum)); + // Simulate every connection handed out (active == maximum, none idle). + (new ReflectionProperty(ConnectionPool::class, 'total'))->setValue($pool, $maximum); + return $pool; + } + + public function test_stats_keys_by_config_fqcn(): void + { + $pool = new ConnectionPool(new MockFactory(), new PoolPolicy(maximumPoolSize: 7)); + $this->withPools([base64_encode('App\\Config\\MainDb') => $pool], function (): void { + self::assertSame( + ['App\\Config\\MainDb' => ['total' => 0, 'idle' => 0, 'active' => 0, 'maximum' => 7]], + PpaConnectionPool::stats(), + ); + }); + } + + public function test_pool_health_reports_up_when_slack_available(): void + { + $pool = new ConnectionPool(new MockFactory(), new PoolPolicy(maximumPoolSize: 5)); + $this->withPools([base64_encode('App\\Config\\MainDb') => $pool], function (): void { + $component = (new ReflectionMethod(HealthIndicator::class, 'poolHealth')) + ->invoke(new HealthIndicator()); + + self::assertSame('up', $component['status']); + self::assertSame('up', $component['details']['App\\Config\\MainDb']['status']); + self::assertSame(5, $component['details']['App\\Config\\MainDb']['maximum']); + }); + } + + public function test_pool_health_flags_saturated_pool_as_degraded(): void + { + $this->withPools( + [ + base64_encode('App\\Config\\MainDb') => self::fullPool(2), + base64_encode('App\\Config\\OtherDb') => new ConnectionPool(new MockFactory(), new PoolPolicy(maximumPoolSize: 5)), + ], + function (): void { + $component = (new ReflectionMethod(HealthIndicator::class, 'poolHealth')) + ->invoke(new HealthIndicator()); + + self::assertSame('degraded', $component['status'], 'a saturated pool degrades the whole component'); + self::assertSame('degraded', $component['details']['App\\Config\\MainDb']['status']); + self::assertSame(2, $component['details']['App\\Config\\MainDb']['active']); + self::assertSame('up', $component['details']['App\\Config\\OtherDb']['status']); + }, + ); + } + + public function test_pool_health_is_up_when_no_pools(): void + { + $this->withPools([], function (): void { + $component = (new ReflectionMethod(HealthIndicator::class, 'poolHealth')) + ->invoke(new HealthIndicator()); + + self::assertSame('up', $component['status']); + self::assertSame([], $component['details']); + }); + } +} diff --git a/tests/Ppa/Pool/PpaPoolTraitTest.php b/tests/Ppa/Pool/PpaPoolTraitTest.php new file mode 100644 index 0000000..91c3c3a --- /dev/null +++ b/tests/Ppa/Pool/PpaPoolTraitTest.php @@ -0,0 +1,47 @@ +getPoolMaxConnections()); + self::assertSame(3.0, $config->getPoolWaitTimeout()); + self::assertSame(0.0, $config->getKeepaliveTime(), 'housekeeping off by default'); + self::assertSame(0.0, $config->getIdleTimeout(), 'no shrink by default'); + self::assertSame(0, $config->getMinimumIdle(), 'fully lazy by default'); + } + + public function test_property_overrides(): void + { + $config = new class { + use PpaPoolTrait; + + public int $poolMaxConnections = 20; + public float $poolWaitTimeout = 5.0; + public float $keepaliveTime = 30.0; + public float $idleTimeout = 600.0; + public int $minimumIdle = 4; + }; + + self::assertSame(20, $config->getPoolMaxConnections()); + self::assertSame(5.0, $config->getPoolWaitTimeout()); + self::assertSame(30.0, $config->getKeepaliveTime()); + self::assertSame(600.0, $config->getIdleTimeout()); + self::assertSame(4, $config->getMinimumIdle()); + } +} From 7e6bab761ed2e9bdf29f4b4365ab429df0d7d784 Mon Sep 17 00:00:00 2001 From: flytachi Date: Sat, 1 Aug 2026 14:34:57 +0500 Subject: [PATCH 38/71] pool --- console/Command/Complete.php | 2 + console/Command/Db.php | 68 +++++++ src/Http/Health/HealthIndicator.php | 149 +++++++------- src/Ppa/Pool/PoolTelemetry.php | 186 +++++++++++++++++ src/WinterApplication.php | 5 +- tests/Ppa/Pool/PoolTelemetryTest.php | 188 ++++++++++++++++++ tests/Ppa/Pool/PpaConnectionPoolStatsTest.php | 35 ++-- 7 files changed, 547 insertions(+), 86 deletions(-) create mode 100644 src/Ppa/Pool/PoolTelemetry.php create mode 100644 tests/Ppa/Pool/PoolTelemetryTest.php diff --git a/console/Command/Complete.php b/console/Command/Complete.php index 472b9b7..ab1e1e3 100644 --- a/console/Command/Complete.php +++ b/console/Command/Complete.php @@ -118,8 +118,10 @@ class Complete extends Cmd 'ping:check DB connection and latency', 'migrate:run migrations against connected databases', 'sql:preview generated SQL without executing', + 'pool:show connection-pool utilisation of the running server', ], 'db ping' => [], // auto-scans project + all plugins + 'db pool' => [], // reads what the running workers publish 'db migrate' => [ '-s:schemes only', '-t:tables only', '-i:indexes only', '-c:constraints only', '--plugin=:target a single plugin', '--plugins:target all plugins', diff --git a/console/Command/Db.php b/console/Command/Db.php index eb9f614..54e78e9 100644 --- a/console/Command/Db.php +++ b/console/Command/Db.php @@ -8,6 +8,7 @@ use Flytachi\Winter\K2\Ppa\DeclarationItem; use Flytachi\Winter\K2\Ppa\PPAMapping; use Flytachi\Winter\K2\Ppa\Mapping\Structure\Table; +use Flytachi\Winter\K2\Ppa\Pool\PoolTelemetry; use Flytachi\Winter\K2\Plugin; class Db extends Cmd @@ -42,6 +43,9 @@ private function resolution(): void case 'sql': $this->showSql(); break; + case 'pool': + $this->pool(); + break; default: self::printWarning("Unknown argument '{$this->args['arguments'][1]}'"); self::printInfo("Run 'call db --help' to see available commands."); @@ -114,6 +118,69 @@ private function ping(): void } } + /** + * Shows connection-pool utilisation of the running server. + * + * A pool lives in one worker's memory and the CLI is a separate process, so this + * reads what each worker publishes to the shared store ({@see PoolTelemetry}) — + * the same indirection `call process status` uses. Numbers are therefore as fresh + * as the last publish (see the `age` column), and a stopped worker's record simply + * expires. + */ + private function pool(): void + { + $records = PoolTelemetry::snapshot(); + + if ($records === []) { + self::printWarning('No pool telemetry found.'); + self::printInfo('A pool lives inside the running server, so the CLI reads what workers publish.'); + self::printInfo('Check that the server is running, that it queries the database, and that' + . ' PPA_POOL_TELEMETRY is not 0 (current interval: ' . PoolTelemetry::interval() . 's).'); + return; + } + + self::printTitle('Connection pools'); + foreach (PoolTelemetry::aggregate() as $configClass => $stat) { + self::printLabel($configClass, 34); + self::printKeyValue('active', (string) $stat['active'], 12, 34, 36); + self::printKeyValue('idle', (string) $stat['idle'], 12, 34, 36); + self::printKeyValue('total', (string) $stat['total'], 12, 34, 36); + self::printKeyValue('maximum', (string) $stat['maximum'], 12, 34, 36); + self::printKeyValue('workers', (string) $stat['workers'], 12, 34, 36); + + // Saturation is per worker: a borrow queues on its own worker's pool, so + // one saturated worker matters even when the fleet total shows slack. + if ($stat['saturated'] > 0) { + self::printKeyValue('saturated', "{$stat['saturated']} of {$stat['workers']} workers", 12, 34, 33); + self::printBadge($configClass, 'SATURATED', 34, 33); + } else { + self::printBadge($configClass, 'OK', 34, 32); + } + } + + self::printSplit('per worker'); + $now = time(); + foreach ($records as $record) { + foreach ($record['pools'] as $configClass => $stat) { + self::printKeyValue( + 'worker#' . $record['worker'], + sprintf( + '%-40s active=%d idle=%d total=%d max=%d age=%ds', + $configClass, + $stat['active'], + $stat['idle'], + $stat['total'], + $stat['maximum'], + max(0, $now - (int) ($record['at'] ?? $now)), + ), + 12, + 34, + 36, + ); + } + } + } + private function showSql(): void { foreach ($this->resolveTargets() as $label => $rootDir) { @@ -416,6 +483,7 @@ public static function help(): void self::printBadge('ping', 'check DB connection and latency', $cl, 36); self::printBadge('migrate', 'run migrations against connected databases', $cl, 36); self::printBadge('sql', 'preview generated SQL without executing', $cl, 36); + self::printBadge('pool', 'show connection-pool utilisation of the running server', $cl, 36); self::printLabel("Commands", $cl); self::printLabel("Flags", $cl); diff --git a/src/Http/Health/HealthIndicator.php b/src/Http/Health/HealthIndicator.php index 7aadde6..3f33252 100644 --- a/src/Http/Health/HealthIndicator.php +++ b/src/Http/Health/HealthIndicator.php @@ -23,7 +23,6 @@ public function health(): array $rootDir = Health::getRootDir(); $components = [ 'db' => $this->dbHealth($rootDir), - 'pool' => $this->poolHealth(), 'cache' => $this->cacheHealth($rootDir), 'disk' => $this->diskHealth(), 'memory' => $this->memoryHealth(), @@ -159,92 +158,98 @@ public function mappings(): array final protected function dbHealth(string $rootDir): array { $interface = 'Flytachi\Winter\Cdo\Config\Common\DbConfigInterface'; - if ($rootDir === '' || !interface_exists($interface)) { - return ['status' => 'up', 'details' => []]; - } - - $collector = new ImplementorCollector($interface); - Scanner::run($rootDir)->collect($collector)->execute(); - $details = []; - $worstStatus = 'up'; - - foreach ($collector->getResult() as $ref) { - /** @var \Flytachi\Winter\Cdo\Config\Common\DbConfigInterface $config */ - $config = $ref->newInstance(); - $config->setUp(); - - try { - $result = $config->pingDetail(); - $latency = $result['latency'] ?? null; - - if (!$result['status']) { - $status = 'down'; - } elseif ($latency !== null && $latency >= self::DEGRADED_LATENCY_MS) { - $status = 'degraded'; - } else { - $status = 'up'; + $details = []; + + if ($rootDir !== '' && interface_exists($interface)) { + $collector = new ImplementorCollector($interface); + Scanner::run($rootDir)->collect($collector)->execute(); + + foreach ($collector->getResult() as $ref) { + /** @var \Flytachi\Winter\Cdo\Config\Common\DbConfigInterface $config */ + $config = $ref->newInstance(); + $config->setUp(); + + try { + $result = $config->pingDetail(); + $latency = $result['latency'] ?? null; + + if (!$result['status']) { + $status = 'down'; + } elseif ($latency !== null && $latency >= self::DEGRADED_LATENCY_MS) { + $status = 'degraded'; + } else { + $status = 'up'; + } + + $details[$ref->getName()] = [ + 'status' => $status, + 'driver' => $config->getDriver(), + 'latency' => $latency, + 'error' => $result['error'] ?? null, + ]; + } catch (\Throwable $e) { + $details[$ref->getName()] = [ + 'status' => 'down', + 'driver' => $config->getDriver(), + 'latency' => null, + 'error' => $e->getMessage(), + ]; } - - if ($status === 'down') { - $worstStatus = 'down'; - } elseif ($status === 'degraded' && $worstStatus !== 'down') { - $worstStatus = 'degraded'; - } - - $details[$ref->getName()] = [ - 'status' => $status, - 'driver' => $config->getDriver(), - 'latency' => $latency, - 'error' => $result['error'] ?? null, - ]; - } catch (\Throwable $e) { - $details[$ref->getName()] = [ - 'status' => 'down', - 'driver' => $config->getDriver(), - 'latency' => null, - 'error' => $e->getMessage(), - ]; - $worstStatus = 'down'; } } - return ['status' => $worstStatus, 'details' => $details]; - } + $details = $this->mergePoolUtilisation($details); + $statuses = array_column($details, 'status'); - // ── Connection-pool utilisation (PpaConnectionPool) ─────────────────────── + return [ + 'status' => match (true) { + in_array('down', $statuses, true) => 'down', + in_array('degraded', $statuses, true) => 'degraded', + default => 'up', + }, + 'details' => $details, + ]; + } /** - * Live utilisation of every Swoole coroutine pool ({@see PpaConnectionPool::stats()}), - * one detail entry per config. Reports `degraded` for any pool that is saturated - * (all connections handed out — `active >= maximum`, none idle), which is the - * signal that borrows are starting to queue. Numbers are per worker (see - * {@see PpaConnectionPool::stats()}); an empty report (FPM, or no pool used yet) - * is `up`. + * Folds live pool utilisation ({@see PpaConnectionPool::stats()}) into the per + * datasource entries, so one entry carries both reachability (fresh ping) and + * how loaded its pool is — they are keyed by the same config FQCN. + * + * A saturated pool (every connection handed out — `active >= maximum`, none idle) + * degrades that datasource: it is the signal that borrows are starting to queue. + * A datasource with no pool in this worker reports `pool: null` — the FPM path, + * or simply a config not used yet. Numbers are per worker (see + * {@see PpaConnectionPool::stats()}); a pool whose config the scan did not reach + * still gets an entry, so a live pool is never invisible. * - * @return array{status: string, details: array} + * @param array> $details + * @return array> */ - private function poolHealth(): array + private function mergePoolUtilisation(array $details): array { - $details = []; - $worstStatus = 'up'; - foreach (PpaConnectionPool::stats() as $config => $stat) { - $saturated = $stat['maximum'] > 0 && $stat['active'] >= $stat['maximum']; - $status = $saturated ? 'degraded' : 'up'; - if ($status === 'degraded' && $worstStatus === 'up') { - $worstStatus = 'degraded'; + $details[$config] ??= [ + 'status' => 'up', + 'driver' => null, + 'latency' => null, + 'error' => null, + ]; + + if ($stat['maximum'] > 0 && $stat['active'] >= $stat['maximum'] + && $details[$config]['status'] === 'up' + ) { + $details[$config]['status'] = 'degraded'; } - $details[$config] = [ - 'status' => $status, - 'active' => $stat['active'], - 'idle' => $stat['idle'], - 'total' => $stat['total'], - 'maximum' => $stat['maximum'], - ]; + $details[$config]['pool'] = $stat; } - return ['status' => $worstStatus, 'details' => $details]; + foreach ($details as $config => $entry) { + $details[$config]['pool'] = $entry['pool'] ?? null; + } + + return $details; } // ── Cache health (requires flytachi/winter-cache) ───────────────────────── diff --git a/src/Ppa/Pool/PoolTelemetry.php b/src/Ppa/Pool/PoolTelemetry.php new file mode 100644 index 0000000..a5fcc7d --- /dev/null +++ b/src/Ppa/Pool/PoolTelemetry.php @@ -0,0 +1,186 @@ + self::publish($workerId, $ttl), + ); + } + + /** Stops publishing and drops this worker's record. */ + public static function stop(int $workerId): void + { + if (self::$timerId !== null) { + if (extension_loaded('swoole')) { + \Swoole\Timer::clear(self::$timerId); + } + self::$timerId = null; + } + + try { + self::store()->del(self::recordKey($workerId)); + } catch (\Throwable) { + // Telemetry must never break a shutdown. + } + } + + /** + * Reads every worker record still alive, newest state as each worker last published + * it. Expired records (dead workers) are skipped by the store's TTL. + * + * @return list}> + */ + public static function snapshot(): array + { + try { + $store = self::store(); + } catch (\Throwable) { + return []; + } + + $records = []; + foreach ($store->keys() as $key) { + $record = $store->read($key); + if (is_array($record) && isset($record['worker'], $record['pools'])) { + $records[] = $record; + } + } + + usort($records, static fn(array $a, array $b): int => $a['worker'] <=> $b['worker']); + + return $records; + } + + /** + * Aggregates {@see snapshot()} across workers, per config — the fleet-wide view a + * single actuator response cannot give. + * + * `saturated` counts the workers whose pool for that config is fully handed out. + * It is deliberately **not** derived from the summed totals: a borrow queues on + * its own worker's pool, so one saturated worker is a real stall even while the + * fleet as a whole looks to have slack. + * + * @return array + */ + public static function aggregate(): array + { + $out = []; + foreach (self::snapshot() as $record) { + foreach ($record['pools'] as $config => $stat) { + $acc = $out[$config] ??= [ + 'total' => 0, 'idle' => 0, 'active' => 0, 'maximum' => 0, 'workers' => 0, 'saturated' => 0, + ]; + $saturated = $stat['maximum'] > 0 && $stat['active'] >= $stat['maximum']; + $out[$config] = [ + 'total' => $acc['total'] + $stat['total'], + 'idle' => $acc['idle'] + $stat['idle'], + 'active' => $acc['active'] + $stat['active'], + 'maximum' => $acc['maximum'] + $stat['maximum'], + 'workers' => $acc['workers'] + 1, + 'saturated' => $acc['saturated'] + ($saturated ? 1 : 0), + ]; + } + } + + return $out; + } + + // ── internals ────────────────────────────────────────────────────────────── + + /** + * Writes this worker's current utilisation. A worker holding no pool writes + * nothing — an application that never touches PPA leaves no records behind. + */ + private static function publish(int $workerId, int $ttl): void + { + try { + $pools = PpaConnectionPool::stats(); + if ($pools === []) { + return; + } + + self::store()->write( + self::recordKey($workerId), + ['worker' => $workerId, 'at' => time(), 'pools' => $pools], + time() + $ttl, + ); + } catch (\Throwable) { + // Telemetry is best-effort: a failed write must never disturb the worker. + } + } + + private static function recordKey(int $workerId): string + { + return 'worker.' . $workerId; + } + + /** Non-hashed keys so {@see FileStorage::keys()} round-trips back into `read()`. */ + private static function store(): FileStorage + { + return Kernel::runnable(self::STORE, false); + } +} diff --git a/src/WinterApplication.php b/src/WinterApplication.php index 4f432e0..db76f78 100644 --- a/src/WinterApplication.php +++ b/src/WinterApplication.php @@ -36,6 +36,7 @@ use Flytachi\Winter\DI\Collector\DICollector; use Flytachi\Winter\K2\Http\Adapter\SwooleRequest; use Flytachi\Winter\K2\Http\Adapter\SwooleResponse; +use Flytachi\Winter\K2\Ppa\Pool\PoolTelemetry; use Flytachi\Winter\K2\Process\ForkReset; use Flytachi\Winter\K2\Route\DevWatcher; use Flytachi\Winter\K2\Route\Router; @@ -393,10 +394,12 @@ static function () use ($class): void { $router->handle($request, new SwooleResponse($res, $isHead)); }; - // Request workers log on 'http' with per-request coroutine isolation. + // Request workers log on 'http' with per-request coroutine isolation, and + // publish their connection-pool utilisation so `call db pool` can read it. $workerStart = static function (\Swoole\Http\Server $server, int $workerId): void { LoggerFactory::setContextStorage(new CoroutineContext()); LoggerFactory::setDefaultChannel('http'); + PoolTelemetry::start($workerId); }; $dev = $watch ? new DevWatcher([Kernel::$pathRoot]) : null; diff --git a/tests/Ppa/Pool/PoolTelemetryTest.php b/tests/Ppa/Pool/PoolTelemetryTest.php new file mode 100644 index 0000000..8031bf8 --- /dev/null +++ b/tests/Ppa/Pool/PoolTelemetryTest.php @@ -0,0 +1,188 @@ +originalEnv = $_ENV['PPA_POOL_TELEMETRY'] ?? null; + + $prop = new ReflectionProperty(KernelConfig::class, 'pathStorageRunnable'); + $this->originalPath = $prop->isInitialized() ? $prop->getValue() : null; + + $this->storage = sys_get_temp_dir() . '/wk_pool_' . getmypid() . '_' . bin2hex(random_bytes(4)); + @mkdir($this->storage, 0777, true); + KernelConfig::$pathStorageRunnable = $this->storage; + + // Kernel caches FileStorage by name against the path it was first built with. + $this->clearRunnableCache(); + } + + protected function tearDown(): void + { + if ($this->originalEnv === null) { + unset($_ENV['PPA_POOL_TELEMETRY']); + } else { + $_ENV['PPA_POOL_TELEMETRY'] = $this->originalEnv; + } + + if ($this->originalPath !== null) { + KernelConfig::$pathStorageRunnable = $this->originalPath; + } + $this->clearRunnableCache(); + + foreach (glob($this->storage . '/*/*') ?: [] as $file) { + @unlink($file); + } + foreach (glob($this->storage . '/*') ?: [] as $dir) { + @rmdir($dir); + } + @rmdir($this->storage); + } + + private function clearRunnableCache(): void + { + (new ReflectionProperty(KernelStore::class, 'runnable'))->setValue(null, []); + } + + /** Writes a record exactly as a worker publishes it. */ + private function publishRecord(int $worker, array $pools, int $ttl = 60): void + { + Kernel::runnable('ppa.pool', false)->write( + 'worker.' . $worker, + ['worker' => $worker, 'at' => time(), 'pools' => $pools], + time() + $ttl, + ); + } + + // ── interval knob ────────────────────────────────────────────────────────── + + public function test_interval_defaults_to_five_seconds(): void + { + unset($_ENV['PPA_POOL_TELEMETRY']); + + self::assertSame(5.0, PoolTelemetry::interval()); + } + + public function test_interval_zero_disables_telemetry(): void + { + $_ENV['PPA_POOL_TELEMETRY'] = '0'; + + self::assertSame(0.0, PoolTelemetry::interval(), '0 turns publishing off entirely'); + } + + public function test_interval_is_floored_at_one_second(): void + { + $_ENV['PPA_POOL_TELEMETRY'] = '0.2'; + + self::assertSame(1.0, PoolTelemetry::interval(), 'telemetry is not a heartbeat'); + } + + public function test_interval_honours_an_explicit_value(): void + { + $_ENV['PPA_POOL_TELEMETRY'] = '30'; + + self::assertSame(30.0, PoolTelemetry::interval()); + } + + // ── store round-trip ─────────────────────────────────────────────────────── + + public function test_snapshot_reads_every_worker_record_in_order(): void + { + $this->publishRecord(2, ['App\\Config\\MainDb' => ['total' => 1, 'idle' => 1, 'active' => 0, 'maximum' => 5]]); + $this->publishRecord(0, ['App\\Config\\MainDb' => ['total' => 3, 'idle' => 2, 'active' => 1, 'maximum' => 5]]); + + $snapshot = PoolTelemetry::snapshot(); + + self::assertCount(2, $snapshot); + self::assertSame([0, 2], array_column($snapshot, 'worker'), 'records come back ordered by worker'); + } + + public function test_snapshot_skips_expired_records_of_dead_workers(): void + { + $this->publishRecord(0, ['App\\Config\\MainDb' => ['total' => 1, 'idle' => 1, 'active' => 0, 'maximum' => 5]]); + $this->publishRecord(1, ['App\\Config\\MainDb' => ['total' => 1, 'idle' => 1, 'active' => 0, 'maximum' => 5]], -1); + + $snapshot = PoolTelemetry::snapshot(); + + self::assertSame([0], array_column($snapshot, 'worker'), 'a worker that stopped refreshing simply expires'); + } + + public function test_aggregate_sums_each_config_across_workers(): void + { + $this->publishRecord(0, [ + 'App\\Config\\MainDb' => ['total' => 5, 'idle' => 3, 'active' => 2, 'maximum' => 10], + ]); + $this->publishRecord(1, [ + 'App\\Config\\MainDb' => ['total' => 4, 'idle' => 1, 'active' => 3, 'maximum' => 10], + 'App\\Config\\OtherDb' => ['total' => 2, 'idle' => 2, 'active' => 0, 'maximum' => 5], + ]); + + $aggregate = PoolTelemetry::aggregate(); + + self::assertSame( + ['total' => 9, 'idle' => 4, 'active' => 5, 'maximum' => 20, 'workers' => 2, 'saturated' => 0], + $aggregate['App\\Config\\MainDb'], + 'the fleet view a single actuator response cannot give', + ); + self::assertSame( + ['total' => 2, 'idle' => 2, 'active' => 0, 'maximum' => 5, 'workers' => 1, 'saturated' => 0], + $aggregate['App\\Config\\OtherDb'], + ); + } + + public function test_aggregate_flags_a_single_saturated_worker(): void + { + // Worker 1 is fully handed out; the fleet sum (12 of 20) still looks roomy. + $this->publishRecord(0, [ + 'App\\Config\\MainDb' => ['total' => 5, 'idle' => 3, 'active' => 2, 'maximum' => 10], + ]); + $this->publishRecord(1, [ + 'App\\Config\\MainDb' => ['total' => 10, 'idle' => 0, 'active' => 10, 'maximum' => 10], + ]); + + $stat = PoolTelemetry::aggregate()['App\\Config\\MainDb']; + + self::assertSame(12, $stat['active']); + self::assertSame(20, $stat['maximum']); + self::assertSame( + 1, + $stat['saturated'], + 'a borrow queues on its own worker, so summed slack must not hide a stalled worker', + ); + } + + public function test_snapshot_is_empty_when_nothing_published(): void + { + self::assertSame([], PoolTelemetry::snapshot()); + self::assertSame([], PoolTelemetry::aggregate()); + } + + public function test_publish_writes_nothing_when_the_worker_holds_no_pool(): void + { + // PpaConnectionPool has no pools in this process, so there is nothing to report. + (new ReflectionMethod(PoolTelemetry::class, 'publish'))->invoke(null, 0, 60); + + self::assertSame([], PoolTelemetry::snapshot(), 'an app that never touches PPA leaves no records'); + } +} diff --git a/tests/Ppa/Pool/PpaConnectionPoolStatsTest.php b/tests/Ppa/Pool/PpaConnectionPoolStatsTest.php index 8f76a69..9a7945a 100644 --- a/tests/Ppa/Pool/PpaConnectionPoolStatsTest.php +++ b/tests/Ppa/Pool/PpaConnectionPoolStatsTest.php @@ -62,20 +62,31 @@ public function test_stats_keys_by_config_fqcn(): void }); } - public function test_pool_health_reports_up_when_slack_available(): void + /** Runs the `db` component with the scan skipped, so only the pool merge is exercised. */ + private static function dbComponent(): array + { + return (new ReflectionMethod(HealthIndicator::class, 'dbHealth')) + ->invoke(new HealthIndicator(), ''); + } + + public function test_db_component_nests_pool_utilisation(): void { $pool = new ConnectionPool(new MockFactory(), new PoolPolicy(maximumPoolSize: 5)); $this->withPools([base64_encode('App\\Config\\MainDb') => $pool], function (): void { - $component = (new ReflectionMethod(HealthIndicator::class, 'poolHealth')) - ->invoke(new HealthIndicator()); + $component = self::dbComponent(); self::assertSame('up', $component['status']); - self::assertSame('up', $component['details']['App\\Config\\MainDb']['status']); - self::assertSame(5, $component['details']['App\\Config\\MainDb']['maximum']); + $entry = $component['details']['App\\Config\\MainDb']; + self::assertSame('up', $entry['status']); + self::assertSame( + ['total' => 0, 'idle' => 0, 'active' => 0, 'maximum' => 5], + $entry['pool'], + 'utilisation lives under the datasource it belongs to', + ); }); } - public function test_pool_health_flags_saturated_pool_as_degraded(): void + public function test_saturated_pool_degrades_its_datasource(): void { $this->withPools( [ @@ -83,22 +94,20 @@ public function test_pool_health_flags_saturated_pool_as_degraded(): void base64_encode('App\\Config\\OtherDb') => new ConnectionPool(new MockFactory(), new PoolPolicy(maximumPoolSize: 5)), ], function (): void { - $component = (new ReflectionMethod(HealthIndicator::class, 'poolHealth')) - ->invoke(new HealthIndicator()); + $component = self::dbComponent(); - self::assertSame('degraded', $component['status'], 'a saturated pool degrades the whole component'); + self::assertSame('degraded', $component['status'], 'a saturated pool degrades the db component'); self::assertSame('degraded', $component['details']['App\\Config\\MainDb']['status']); - self::assertSame(2, $component['details']['App\\Config\\MainDb']['active']); + self::assertSame(2, $component['details']['App\\Config\\MainDb']['pool']['active']); self::assertSame('up', $component['details']['App\\Config\\OtherDb']['status']); }, ); } - public function test_pool_health_is_up_when_no_pools(): void + public function test_db_component_is_up_when_no_pools(): void { $this->withPools([], function (): void { - $component = (new ReflectionMethod(HealthIndicator::class, 'poolHealth')) - ->invoke(new HealthIndicator()); + $component = self::dbComponent(); self::assertSame('up', $component['status']); self::assertSame([], $component['details']); From 418060c72d287afc5b3719e94ea4eda1bba3292b Mon Sep 17 00:00:00 2001 From: flytachi Date: Sat, 1 Aug 2026 15:23:14 +0500 Subject: [PATCH 39/71] pool --- src/Kernel.php | 7 +- src/Ppa/Pool/BorrowedConnection.php | 26 +++++ src/Ppa/Pool/ConnectionLoss.php | 63 ++++++++++++ src/Ppa/Pool/PpaConnectionPool.php | 74 ++++++++++++-- src/Ppa/Repository/RepositoryCrudTrait.php | 7 ++ src/Ppa/Repository/RepositoryViewTrait.php | 7 ++ src/WinterApplication.php | 58 +++++++++++ tests/ConnectionPool/ConnectionPoolTest.php | 19 ++++ tests/Ppa/Pool/ConnectionLossTest.php | 90 +++++++++++++++++ tests/Ppa/Pool/ReportFailureTest.php | 104 ++++++++++++++++++++ wKernelRunner | 48 +++++++-- 11 files changed, 487 insertions(+), 16 deletions(-) create mode 100644 src/Ppa/Pool/BorrowedConnection.php create mode 100644 src/Ppa/Pool/ConnectionLoss.php create mode 100644 tests/Ppa/Pool/ConnectionLossTest.php create mode 100644 tests/Ppa/Pool/ReportFailureTest.php diff --git a/src/Kernel.php b/src/Kernel.php index e5f1671..9ad0106 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -63,8 +63,11 @@ public static function init( self::bootLogger(); - // thread — route each launch by runtime: Swoole\Process inside a coroutine - // (proc_open corrupts the reactor's fds there), proc_open everywhere else. + // thread — both backends spawn the same `php ` child; only the way + // the shell is invoked differs. Inside a coroutine proc_open corrupts the + // reactor's descriptors and Swoole\Process is refused while its async-io + // threads are up, so the launcher shells out via Coroutine\System::exec(); + // everywhere else proc_open is used unchanged. Thread::bindLauncher(AdaptiveLauncher::adaptive( secret: env('WINTER_KEY', ''), runnerPath: self::threadRunnerPath(), diff --git a/src/Ppa/Pool/BorrowedConnection.php b/src/Ppa/Pool/BorrowedConnection.php new file mode 100644 index 0000000..a228f22 --- /dev/null +++ b/src/Ppa/Pool/BorrowedConnection.php @@ -0,0 +1,26 @@ +getPrevious()) { + if ($cause instanceof PDOException && self::matches($cause)) { + return true; + } + } + + return false; + } + + private static function matches(PDOException $error): bool + { + $info = $error->errorInfo; + // errorInfo is authoritative; fall back to the code, which carries the + // SQLSTATE for exceptions raised outside a statement. + $sqlState = is_array($info) && isset($info[0]) ? (string) $info[0] : (string) $error->getCode(); + $driverCode = is_array($info) && isset($info[1]) ? $info[1] : null; + + return str_starts_with($sqlState, self::CONNECTION_CLASS) + || in_array($sqlState, self::SERVER_SHUTDOWN, true) + || (is_int($driverCode) && in_array($driverCode, self::MYSQL_LOST, true)); + } +} diff --git a/src/Ppa/Pool/PpaConnectionPool.php b/src/Ppa/Pool/PpaConnectionPool.php index 53d5707..6cbcfae 100644 --- a/src/Ppa/Pool/PpaConnectionPool.php +++ b/src/Ppa/Pool/PpaConnectionPool.php @@ -14,6 +14,7 @@ use Flytachi\Winter\K2\ConnectionPool\PoolPolicy; use Flytachi\Winter\K2\ConnectionPool\SingleConnection; use Psr\Log\LoggerInterface; +use Throwable; /** * PpaConnectionPool — driver-agnostic connection pool for FPM and Swoole. @@ -141,6 +142,58 @@ public static function showDbConfigs(): array return self::$configs; } + /** + * Reports a failure that happened **while using** a borrowed connection, so a dead + * one is retired instead of being handed to the next caller. + * + * Only a genuine connection loss evicts ({@see ConnectionLoss}) — a constraint + * violation or a syntax error means the connection is healthy and is left alone. + * + * The pool deliberately does **not** retry the failed statement. It cannot know + * what was executed: the break may have happened after the server applied the + * write, so replaying it could duplicate the effect, and replaying one statement + * of an interrupted transaction is meaningless. The request fails once; the + * connection is thrown away, so the next one — including the next query in this + * same request — gets a healthy connection. + * + * @param class-string $configClass Config whose connection failed. + * @param Throwable $error The failure as thrown by CDO/PDO. + * @return bool Whether the connection was classified as lost and evicted. + */ + public static function reportFailure(string $configClass, Throwable $error): bool + { + if (!ConnectionLoss::isLost($error)) { + return false; + } + + $key = base64_encode($configClass); + + if (Runtime::isSwooleCoroutine()) { + $ctxKey = 'ppa_cdo_' . $key; + $ctx = \Swoole\Coroutine::getContext(); + $held = $ctx[$ctxKey] ?? null; + if (!$held instanceof BorrowedConnection) { + return false; + } + // Mark for the defer to evict, and drop it from the context so the next + // query in this same coroutine borrows a fresh connection. + $held->dead = true; + unset($ctx[$ctxKey]); + + return true; + } + + // Static (FPM / non-coroutine) path: close now, reopen lazily on next use. + if (isset(self::$static[$key])) { + self::$static[$key]->evict(); + self::logger()->warning("evict: {$configClass} (connection lost in use)"); + + return true; + } + + return false; + } + /** * Live utilisation of every Swoole coroutine pool, keyed by config FQCN — the * HikariCP-style view (active / idle / total vs maximum) for `/actuator/health`. @@ -244,20 +297,27 @@ private static function coroutineDb(string $configClass): CDO ); } - $ctx[$ctxKey] = $entry; + $held = new BorrowedConnection($entry); + $ctx[$ctxKey] = $held; // Auto-return when the coroutine finishes (normal exit OR exception). - // $entry is captured directly — safer than reading from $ctx during teardown. - \Swoole\Coroutine::defer(static function () use ($pool, $entry, $cid, $configClass): void { + // $held is captured directly — safer than reading from $ctx during teardown — + // and carries the verdict {@see reportFailure()} may have left on it. + \Swoole\Coroutine::defer(static function () use ($pool, $held, $cid, $configClass): void { + if ($held->dead) { + self::logger()->warning("cid={$cid} evict: {$configClass} (connection lost in use)"); + $pool->evict($held->entry); + return; + } self::logger()->debug("cid={$cid} release: {$configClass}"); - $pool->release($entry); + $pool->release($held->entry); }); } - /** @var PoolEntry $entry */ - $entry = $ctx[$ctxKey]; + /** @var BorrowedConnection $held */ + $held = $ctx[$ctxKey]; /** @var DbConfigInterface $config */ - $config = $entry->resource; + $config = $held->entry->resource; $cdo = $config->connection(); $driver = $cdo->getAttribute(\PDO::ATTR_DRIVER_NAME); diff --git a/src/Ppa/Repository/RepositoryCrudTrait.php b/src/Ppa/Repository/RepositoryCrudTrait.php index 649d4f8..5380328 100644 --- a/src/Ppa/Repository/RepositoryCrudTrait.php +++ b/src/Ppa/Repository/RepositoryCrudTrait.php @@ -8,6 +8,7 @@ use Flytachi\Winter\Cdo\Connection\CDOException; use Flytachi\Winter\Cdo\Qb; use Flytachi\Winter\K2\Ppa\Entity\RepositoryCrudInterface; +use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; /** * Provides concrete write-operation implementations for repository classes. @@ -40,6 +41,7 @@ public function insert(object|array $entity): mixed try { return $this->db()->insert($this->originTable(), $entity); } catch (CDOException $exception) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $exception); throw new RepositoryException($exception->getMessage(), $exception->getCode(), $exception); } } @@ -57,6 +59,7 @@ public function insertGroup(array|object ...$entities): void try { $this->db()->insertGroup($this->originTable(), $entities); } catch (CDOException $exception) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $exception); throw new RepositoryException($exception->getMessage(), $exception->getCode(), $exception); } } @@ -75,6 +78,7 @@ public function update(object|array $entity, Qb $qb): int|string try { return $this->db()->update($this->originTable(), $entity, $qb); } catch (CDOException $exception) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $exception); throw new RepositoryException($exception->getMessage(), $exception->getCode(), $exception); } } @@ -92,6 +96,7 @@ public function delete(Qb $qb): int|string try { return $this->db()->delete($this->originTable(), $qb); } catch (CDOException $exception) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $exception); throw new RepositoryException($exception->getMessage(), $exception->getCode(), $exception); } } @@ -114,6 +119,7 @@ public function upsert( try { return $this->db()->upsert($this->originTable(), $entity, $conflictColumns, $updateColumns); } catch (CDOException $exception) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $exception); throw new RepositoryException($exception->getMessage(), $exception->getCode(), $exception); } } @@ -136,6 +142,7 @@ public function upsertGroup( try { $this->db()->upsertGroup($this->originTable(), $entities, $conflictColumns, $updateColumns); } catch (CDOException $exception) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $exception); throw new RepositoryException($exception->getMessage(), $exception->getCode(), $exception); } } diff --git a/src/Ppa/Repository/RepositoryViewTrait.php b/src/Ppa/Repository/RepositoryViewTrait.php index 55cc01b..ab6d3a7 100644 --- a/src/Ppa/Repository/RepositoryViewTrait.php +++ b/src/Ppa/Repository/RepositoryViewTrait.php @@ -10,6 +10,7 @@ use Flytachi\Winter\Cdo\Qb; use Flytachi\Winter\K2\Ppa\Entity\EntityException; use Flytachi\Winter\K2\Ppa\Entity\RepositoryViewInterface; +use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; use PDO; use Throwable; @@ -60,6 +61,7 @@ final public function rawFetch(string $sql, array $binds = [], ?string $entityCl $entityClassName ?: $this->state()->entityClassName ); } catch (Throwable $th) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $th); throw new RepositoryException($th->getMessage(), previous: $th); } } @@ -85,6 +87,7 @@ final public function find(?string $entityClassName = null): ?object $this->cleanCache(); return $stmt->getStmt()->fetchObject($resolvedClass) ?: null; } catch (Throwable $th) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $th); throw new RepositoryException($th->getMessage(), previous: $th); } } @@ -108,6 +111,7 @@ final public function findColumn(int $column = 0): mixed $this->cleanCache(); return $stmt->getStmt()->fetchColumn($column); } catch (Throwable $th) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $th); throw new RepositoryException($th->getMessage(), previous: $th); } } @@ -132,6 +136,7 @@ final public function findAll(?string $entityClassName = null): array $this->cleanCache(); return $stmt->getStmt()->fetchAll(PDO::FETCH_CLASS, $resolvedClass); } catch (Throwable $th) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $th); throw new RepositoryException($th->getMessage(), previous: $th); } } @@ -156,6 +161,7 @@ final public function count(): int $this->cleanCache(); return (int) $stmt->getStmt()->fetchColumn(); } catch (Throwable $th) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $th); throw new RepositoryException($th->getMessage(), previous: $th); } } @@ -181,6 +187,7 @@ final public function exists(): bool $this->cleanCache(); return (bool) $stmt->getStmt()->fetchColumn(); } catch (Throwable $th) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $th); throw new RepositoryException($th->getMessage(), previous: $th); } } diff --git a/src/WinterApplication.php b/src/WinterApplication.php index db76f78..d95bdd4 100644 --- a/src/WinterApplication.php +++ b/src/WinterApplication.php @@ -43,6 +43,7 @@ use Flytachi\Winter\Logger\Context\CoroutineContext; use Flytachi\Winter\Logger\Context\ProcessContext; use Flytachi\Winter\Logger\LoggerFactory; +use Flytachi\Winter\Thread\Runner\AdaptiveRunner; use Psr\Log\LoggerInterface; /** @@ -132,6 +133,63 @@ public static function main(array $argv): never static::run($argv); } + /** + * The background-launch entry — the child side of {@see Process::dispatch()}. + * + * Detaching a process cannot be a fork: the parent may be a Swoole worker whose + * reactor must not be duplicated, so the launcher spawns a **fresh PHP process** + * running `vendor/bin/wKernelRunner`, which lands here. The application has to be + * booted again from scratch — a new process shares nothing — before the staged + * payload can run, otherwise the dispatched class has no container, no logging + * and no configuration. + * + * So this is {@see run()} without the dispatch: the same {@see bootstrap()}, + * then the thread payload instead of the console. The launcher's own options + * (`--namespace`, `--name`, `--tag`, `--debug`, `--detach`, `--shmkey`) are read + * straight from the command line by `getopt()`; `--detach` makes the runner + * daemonise itself. + * + * @param array $argv Raw $argv (script name in [0]). + */ + final public static function executor(array $argv): never + { + static::bootstrap(ApplicationArguments::parse($argv)); + + exit(AdaptiveRunner::adaptive()->execute( + getopt('', ['namespace::', 'name::', 'tag::', 'debug', 'detach', 'shmkey::']) + )); + } + + /** + * Locates the application class the project's bootstrap file declared, so a + * generic entry point (the thread runner) can reach {@see executor()} without + * knowing the project's naming. + * + * Call it only after the bootstrap file has been required: it looks at what is + * actually declared, and a project declares exactly one application class. + * + * @return class-string + */ + public static function discoverAppClass(): string + { + $found = array_values(array_filter( + get_declared_classes(), + static fn(string $class): bool => is_subclass_of($class, self::class), + )); + + return match (count($found)) { + 1 => $found[0], + 0 => throw new ApplicationConfigException( + 'No application class found. The bootstrap file must declare a class ' + . 'extending ' . self::class . '.' + ), + default => throw new ApplicationConfigException( + 'Several application classes are declared (' . implode(', ', $found) + . '); the bootstrap file must declare exactly one.' + ), + }; + } + /** * Boots the application once, then dispatches: * - `call run` / `call run dev` → bring the app up ({@see serve()}); diff --git a/tests/ConnectionPool/ConnectionPoolTest.php b/tests/ConnectionPool/ConnectionPoolTest.php index 8490736..aca871e 100644 --- a/tests/ConnectionPool/ConnectionPoolTest.php +++ b/tests/ConnectionPool/ConnectionPoolTest.php @@ -181,6 +181,25 @@ public function test_max_lifetime_jitter_within_bounds(): void self::assertLessThanOrEqual(1000.0 + 110.0, $expiry, 'within +10% jitter'); } + public function test_evict_retires_the_connection_and_frees_its_slot(): void + { + $f = new MockFactory(); + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$out): void { + $pool = new ConnectionPool($f, new PoolPolicy(maximumPoolSize: 1)); + $a = $pool->borrow(); + + $pool->evict($a); // died in use — retire instead of returning it + + $b = $pool->borrow(); // the freed slot allows a fresh connection + $out = ['same' => $a === $b, 'created' => $f->created, 'closed' => $f->closed]; + }); + + self::assertFalse($out['same'], 'an evicted connection is never handed out again'); + self::assertSame(2, $out['created']); + self::assertSame(1, $out['closed']); + } + public function test_stats_track_total_idle_active(): void { $f = new MockFactory(); diff --git a/tests/Ppa/Pool/ConnectionLossTest.php b/tests/Ppa/Pool/ConnectionLossTest.php new file mode 100644 index 0000000..1e9ad0c --- /dev/null +++ b/tests/Ppa/Pool/ConnectionLossTest.php @@ -0,0 +1,90 @@ +errorInfo = [$sqlState, $driverCode, $message]; + return $e; + } + + /** CDO always wraps the original PDOException as `previous`. */ + private static function wrapped(PDOException $inner): CDOException + { + return new CDOException($inner->getMessage(), previous: $inner); + } + + public function test_sqlstate_class_08_is_a_lost_connection(): void + { + foreach (['08000', '08001', '08003', '08004', '08006', '08007', '08S01'] as $state) { + self::assertTrue( + ConnectionLoss::isLost(self::pdo($state)), + "SQLSTATE {$state} is a connection exception", + ); + } + } + + public function test_postgres_server_shutdown_states_are_a_lost_connection(): void + { + foreach (['57P01', '57P02', '57P03'] as $state) { + self::assertTrue(ConnectionLoss::isLost(self::pdo($state)), "SQLSTATE {$state} terminates the connection"); + } + } + + public function test_mysql_gone_away_driver_codes_are_a_lost_connection(): void + { + // MySQL reports these as HY000 plus a driver code. + self::assertTrue(ConnectionLoss::isLost(self::pdo('HY000', 2006, 'MySQL server has gone away'))); + self::assertTrue(ConnectionLoss::isLost(self::pdo('HY000', 2013, 'Lost connection during query'))); + } + + public function test_query_errors_leave_the_connection_alone(): void + { + // A healthy server rejecting a bad query — evicting here would churn the pool. + self::assertFalse(ConnectionLoss::isLost(self::pdo('23505', 7, 'duplicate key')), 'constraint violation'); + self::assertFalse(ConnectionLoss::isLost(self::pdo('42601', 7, 'syntax error')), 'syntax error'); + self::assertFalse(ConnectionLoss::isLost(self::pdo('23503', 7, 'foreign key')), 'foreign key violation'); + self::assertFalse(ConnectionLoss::isLost(self::pdo('HY000', 1213, 'deadlock')), 'deadlock — connection lives'); + } + + public function test_it_unwraps_the_cdo_exception_chain(): void + { + self::assertTrue( + ConnectionLoss::isLost(self::wrapped(self::pdo('08006'))), + 'CDO wraps the PDOException, so the cause chain must be walked', + ); + self::assertFalse(ConnectionLoss::isLost(self::wrapped(self::pdo('23505')))); + } + + public function test_non_database_failures_are_not_a_lost_connection(): void + { + self::assertFalse(ConnectionLoss::isLost(new RuntimeException('something else'))); + } + + public function test_it_falls_back_to_the_code_when_error_info_is_absent(): void + { + // PDO raises connect-time failures with no errorInfo and SQLSTATE in the code. + $error = new PDOException('could not connect'); + (new \ReflectionProperty(\Exception::class, 'code'))->setValue($error, '08006'); + + self::assertTrue(ConnectionLoss::isLost($error)); + } +} diff --git a/tests/Ppa/Pool/ReportFailureTest.php b/tests/Ppa/Pool/ReportFailureTest.php new file mode 100644 index 0000000..2dfcb38 --- /dev/null +++ b/tests/Ppa/Pool/ReportFailureTest.php @@ -0,0 +1,104 @@ +errorInfo = [$sqlState, null, 'boom']; + + return new CDOException('boom', previous: $pdo); + } + + private static function held(): BorrowedConnection + { + return new BorrowedConnection(new PoolEntry(new \stdClass(), 0.0, 0.0, null)); + } + + public function test_a_lost_connection_is_marked_dead_and_dropped_from_the_context(): void + { + $out = []; + \Swoole\Coroutine\run(static function () use (&$out): void { + $held = self::held(); + \Swoole\Coroutine::getContext()[self::ctxKey()] = $held; + + $evicted = PpaConnectionPool::reportFailure(self::CONFIG, self::failure('08006')); + + $out = [ + 'evicted' => $evicted, + 'dead' => $held->dead, + 'stillHeld' => isset(\Swoole\Coroutine::getContext()[self::ctxKey()]), + ]; + }); + + self::assertTrue($out['evicted']); + self::assertTrue($out['dead'], 'the defer must evict this connection instead of pooling it again'); + self::assertFalse($out['stillHeld'], 'the next query in this request borrows a fresh connection'); + } + + public function test_a_query_error_leaves_the_connection_pooled(): void + { + $out = []; + \Swoole\Coroutine\run(static function () use (&$out): void { + $held = self::held(); + \Swoole\Coroutine::getContext()[self::ctxKey()] = $held; + + // 23505 — unique violation: the server is healthy, the query was not. + $evicted = PpaConnectionPool::reportFailure(self::CONFIG, self::failure('23505')); + + $out = [ + 'evicted' => $evicted, + 'dead' => $held->dead, + 'stillHeld' => isset(\Swoole\Coroutine::getContext()[self::ctxKey()]), + ]; + }); + + self::assertFalse($out['evicted']); + self::assertFalse($out['dead'], 'a healthy connection must not be churned'); + self::assertTrue($out['stillHeld'], 'it stays borrowed for the rest of the request'); + } + + public function test_it_is_a_no_op_when_this_coroutine_holds_no_connection(): void + { + $evicted = true; + \Swoole\Coroutine\run(static function () use (&$evicted): void { + $evicted = PpaConnectionPool::reportFailure(self::CONFIG, self::failure('08006')); + }); + + self::assertFalse($evicted, 'nothing borrowed here — nothing to evict'); + } +} diff --git a/wKernelRunner b/wKernelRunner index 1fc3563..88c3225 100755 --- a/wKernelRunner +++ b/wKernelRunner @@ -3,18 +3,52 @@ declare(strict_types=1); -if (PHP_VERSION_ID < 80300) { - fwrite(STDERR, "Please use PHP version 8.3 or higher.\n"); +/* + wKernelRunner — the child side of Process::dispatch(). + ------------------------------------------------------ + Detaching a process cannot be a fork (the parent may be a Swoole worker whose + reactor must not be duplicated), so the thread launcher spawns a fresh PHP + process running this file: + + php vendor/bin/wKernelRunner --namespace=… --name=… [--detach] [--shmkey=…] + + A new process shares nothing with its parent, so the application is booted again + from scratch before the staged payload runs — otherwise the dispatched class has + no container, no logging and no configuration. + + The project's bootstrap file is the single place that loads the autoloader and + declares the application class, exactly as `call` uses it; this runner follows the + same convention and then hands off to WinterApplication::executor(). +*/ + +use Flytachi\Winter\K2\WinterApplication; + +if (PHP_VERSION_ID < 80400) { + fwrite(STDERR, "Please use PHP version 8.4 or higher.\n"); exit(1); } -$fileAutoloader = dirname(__DIR__, 3) . '/bootstrap.php'; +// vendor/flytachi/winter-kernel/wKernelRunner → the project root. +$bootstrap = dirname(__DIR__, 3) . '/bootstrap.php'; -if (!file_exists($fileAutoloader)) { - fwrite(STDERR, "Error: bootstrap.php not found at {$fileAutoloader}\n"); +if (!file_exists($bootstrap)) { + fwrite(STDERR, "Error: bootstrap.php not found at {$bootstrap}\n"); exit(1); } -require_once $fileAutoloader; +require_once $bootstrap; + +// A background worker outlives the request that spawned it and streams its output +// to a log, so no time limit, no output buffering and no abort on client hang-up. +set_time_limit(0); +ob_implicit_flush(); +ignore_user_abort(true); + +try { + $appClass = WinterApplication::discoverAppClass(); +} catch (Throwable $e) { + fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n"); + exit(1); +} -Boot::executor($argv); +$appClass::executor($argv); From 0917c1265bb79f387be04ebaac7aac51a703e313 Mon Sep 17 00:00:00 2001 From: flytachi Date: Sat, 1 Aug 2026 15:34:37 +0500 Subject: [PATCH 40/71] pool --- .../Integration/DispatchRunnerTest.php | 191 ++++++++++++++++++ .../Fixtures/DispatchMarkerProcess.php | 31 +++ 2 files changed, 222 insertions(+) create mode 100644 tests/Process/Integration/DispatchRunnerTest.php create mode 100644 tests/Process/Integration/Fixtures/DispatchMarkerProcess.php diff --git a/tests/Process/Integration/DispatchRunnerTest.php b/tests/Process/Integration/DispatchRunnerTest.php new file mode 100644 index 0000000..0e59b9d --- /dev/null +++ b/tests/Process/Integration/DispatchRunnerTest.php @@ -0,0 +1,191 @@ +project = sys_get_temp_dir() . '/wk_dispatch_' . getmypid() . '_' . bin2hex(random_bytes(4)); + $this->buildProject(); + + // The launcher signs the payload with this; Kernel::init reads it when it binds + // the launcher, and Dotenv is immutable so it will not overwrite it. + $this->originalKey = $_ENV['WINTER_KEY'] ?? null; + $_ENV['WINTER_KEY'] = self::SECRET; + + Kernel::init( + pathRoot: $this->project, + pathStorageRunnable: $this->project . '/storage/runnable', + ); + + // Kernel caches FileStorage by name against the path it was first built with. + foreach (['runnable', 'storages', 'volatiles'] as $cache) { + (new \ReflectionProperty(KernelStore::class, $cache))->setValue(null, []); + } + + @unlink(DispatchMarkerProcess::markerPath()); + } + + protected function tearDown(): void + { + $pid = $this->markerPid(); + if ($pid !== null && $this->isAlive($pid)) { + @posix_kill($pid, SIGTERM); + $deadline = microtime(true) + 4.0; + while ($this->isAlive($pid) && microtime(true) < $deadline) { + usleep(50_000); + } + @posix_kill($pid, SIGKILL); + } + + @unlink(DispatchMarkerProcess::markerPath()); + $this->removeTree($this->project); + + if ($this->originalKey === null) { + unset($_ENV['WINTER_KEY']); + } else { + $_ENV['WINTER_KEY'] = $this->originalKey; + } + } + + public function test_dispatch_boots_the_application_in_a_detached_process(): void + { + $log = $this->project . '/runner.log'; + + $pid = DispatchMarkerProcess::dispatch(output: $log); + + self::assertGreaterThan(0, $pid, 'the launcher returns the spawned shell PID'); + + $childPid = $this->awaitMarker(10.0); + + // The runner writes boot failures here ("No application class found", + // "bootstrap.php not found", a signature mismatch...), so an empty log is + // the proof that the whole chain got through cleanly. + self::assertSame('', trim((string) @file_get_contents($log)), 'the runner reported no error'); + self::assertNotNull($childPid, 'the dispatched process reached run() and wrote its marker'); + self::assertTrue($this->isAlive($childPid), 'it keeps running detached from this test process'); + } + + // ── fixture project ──────────────────────────────────────────────────────── + + /** + * Lays out a throwaway project the way composer installs one, because the runner + * resolves the project root from its own location (`dirname(__DIR__, 3)`): + * + * /bootstrap.php + * /vendor/bin/wKernelRunner (composer bin proxy) + * /vendor/flytachi/winter-kernel/wKernelRunner + */ + private function buildProject(): void + { + $repo = dirname(__DIR__, 3); + $pkg = $this->project . '/vendor/flytachi/winter-kernel'; + + @mkdir($this->project . '/vendor/bin', 0777, true); + @mkdir($pkg, 0777, true); + @mkdir($this->project . '/storage/runnable', 0777, true); + + // Copied, never symlinked: PHP resolves __DIR__ through symlinks, which would + // point the runner at this repository instead of the throwaway project. + copy($repo . '/wKernelRunner', $pkg . '/wKernelRunner'); + + file_put_contents( + $this->project . '/vendor/bin/wKernelRunner', + "#!/usr/bin/env php\nproject . '/bootstrap.php', <<project . '/.env', "WINTER_KEY=" . self::SECRET . "\nLOG_LEVEL=\n"); + } + + // ── helpers ──────────────────────────────────────────────────────────────── + + /** Waits for the detached child to announce itself, returning its PID. */ + private function awaitMarker(float $timeout): ?int + { + $deadline = microtime(true) + $timeout; + while (microtime(true) < $deadline) { + $pid = $this->markerPid(); + if ($pid !== null) { + return $pid; + } + usleep(100_000); + } + + return null; + } + + private function markerPid(): ?int + { + $raw = @file_get_contents(DispatchMarkerProcess::markerPath()); + return is_string($raw) && trim($raw) !== '' ? (int) trim($raw) : null; + } + + /** posix_getpgid needs no permission, so it works across users. */ + private function isAlive(int $pid): bool + { + return $pid > 0 && @posix_getpgid($pid) !== false; + } + + private function removeTree(string $path): void + { + if ($path === '' || !is_dir($path)) { + return; + } + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($items as $item) { + $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); + } + @rmdir($path); + } +} diff --git a/tests/Process/Integration/Fixtures/DispatchMarkerProcess.php b/tests/Process/Integration/Fixtures/DispatchMarkerProcess.php new file mode 100644 index 0000000..6bfbab9 --- /dev/null +++ b/tests/Process/Integration/Fixtures/DispatchMarkerProcess.php @@ -0,0 +1,31 @@ +isRunning()) { + $this->sleep(0.2); + } + } +} From aca4f47c97fcc295d454e3f77a24810436758409 Mon Sep 17 00:00:00 2001 From: flytachi Date: Sat, 1 Aug 2026 15:59:00 +0500 Subject: [PATCH 41/71] pool --- src/ConnectionPool/SingleConnection.php | 10 +++++++ src/Ppa/Pool/CdoConnectionFactory.php | 27 ++++++++++++++++-- src/Ppa/Pool/ConnectionLoss.php | 37 +++++++++++++++++++++++++ src/Ppa/Pool/PpaConnectionPool.php | 24 +++++++++++----- tests/Ppa/Pool/ConnectionLossTest.php | 32 +++++++++++++++++++++ 5 files changed, 121 insertions(+), 9 deletions(-) diff --git a/src/ConnectionPool/SingleConnection.php b/src/ConnectionPool/SingleConnection.php index 694eacf..dafcab5 100644 --- a/src/ConnectionPool/SingleConnection.php +++ b/src/ConnectionPool/SingleConnection.php @@ -73,6 +73,16 @@ public function get(): object return $this->entry->resource; } + /** + * The connection currently held, without any lifecycle checks — for a caller that + * needs to inspect it (a liveness probe after a failure) rather than use it. + * `null` when nothing is open. + */ + public function peek(): ?object + { + return $this->entry?->resource; + } + /** * Retires the current connection (close + forget) — for a connection-level failure * detected during use, so the next {@see get()} opens a fresh one. diff --git a/src/Ppa/Pool/CdoConnectionFactory.php b/src/Ppa/Pool/CdoConnectionFactory.php index 52cfc34..48e1dab 100644 --- a/src/Ppa/Pool/CdoConnectionFactory.php +++ b/src/Ppa/Pool/CdoConnectionFactory.php @@ -43,11 +43,34 @@ public function create(): object return $config; } - /** Liveness probe — delegates to the driver's own `SELECT 1` (`false` = dead). */ + /** Liveness probe — `false` when the connection is dead. */ public function validate(object $connection): bool { /** @var DbConfigInterface $connection */ - return $connection->ping(); + return self::probe($connection); + } + + /** + * Round-trips `SELECT 1` and reports whether the connection answered. + * + * It deliberately does **not** use `DbConfigInterface::ping()`: that method + * catches `CDOException` only, while `PDO::query()` raises a `PDOException` + * (unrelated to it), and its `return` inside `finally` swallows the exception — + * so it answers `true` for a connection that is already dead. Verified against + * live PostgreSQL and MariaDB: a killed connection still pinged `true`. Relying + * on it would silently disable the idle-gate and keepalive, which exist + * precisely to retire dead connections. + * + * Catching `Throwable` is the point: any failure to complete the round trip + * means the connection cannot be handed out. + */ + public static function probe(DbConfigInterface $config): bool + { + try { + return $config->connection()->query('SELECT 1') !== false; + } catch (\Throwable) { + return false; + } } /** Drops the CDO reference so its socket is closed. */ diff --git a/src/Ppa/Pool/ConnectionLoss.php b/src/Ppa/Pool/ConnectionLoss.php index 15373da..4d540da 100644 --- a/src/Ppa/Pool/ConnectionLoss.php +++ b/src/Ppa/Pool/ConnectionLoss.php @@ -33,6 +33,9 @@ final class ConnectionLoss /** MySQL driver codes: server gone away / connection lost mid-query. */ private const array MYSQL_LOST = [2006, 2013, 2055]; + /** SQLSTATE PDO reports when the driver gave it nothing to map. */ + private const string UNMAPPED = 'HY000'; + /** * Whether this failure means the connection itself died (as opposed to the query * being rejected by a healthy server). @@ -48,6 +51,40 @@ public static function isLost(Throwable $error): bool return false; } + /** + * Whether the driver's verdict is inconclusive, so only a live probe can tell a + * dead connection from a rejected query. + * + * PDO_pgsql is the reason this exists. A killed PostgreSQL connection does **not** + * arrive as SQLSTATE `08006`: with the socket gone there is no result object to + * take a SQLSTATE from, so PDO falls back to `HY000` with driver code `7` — the + * very same code it reports for an ordinary syntax error. Verified live: a + * terminated backend yields `["HY000", 7, "terminating connection…"]`. Matching on + * the message instead is not an option either, since PostgreSQL translates it + * (`lc_messages`). + * + * A well-formed SQLSTATE from any other class means the server answered and is + * alive, so those are decided here and never probed. + */ + public static function isUndecided(Throwable $error): bool + { + if (self::isLost($error)) { + return false; + } + + for ($cause = $error; $cause !== null; $cause = $cause->getPrevious()) { + if (!$cause instanceof PDOException) { + continue; + } + $info = $cause->errorInfo; + $sqlState = is_array($info) && isset($info[0]) ? (string) $info[0] : (string) $cause->getCode(); + + return $sqlState === self::UNMAPPED || $sqlState === '' || $sqlState === '0'; + } + + return false; + } + private static function matches(PDOException $error): bool { $info = $error->errorInfo; diff --git a/src/Ppa/Pool/PpaConnectionPool.php b/src/Ppa/Pool/PpaConnectionPool.php index 6cbcfae..ac9ed0f 100644 --- a/src/Ppa/Pool/PpaConnectionPool.php +++ b/src/Ppa/Pool/PpaConnectionPool.php @@ -162,7 +162,9 @@ public static function showDbConfigs(): array */ public static function reportFailure(string $configClass, Throwable $error): bool { - if (!ConnectionLoss::isLost($error)) { + $lost = ConnectionLoss::isLost($error); + $undecided = !$lost && ConnectionLoss::isUndecided($error); + if (!$lost && !$undecided) { return false; } @@ -175,23 +177,31 @@ public static function reportFailure(string $configClass, Throwable $error): boo if (!$held instanceof BorrowedConnection) { return false; } + $config = $held->entry->resource; + if ($undecided && $config instanceof DbConfigInterface && CdoConnectionFactory::probe($config)) { + return false; // the driver was vague but the connection answered + } // Mark for the defer to evict, and drop it from the context so the next // query in this same coroutine borrows a fresh connection. $held->dead = true; unset($ctx[$ctxKey]); + self::logger()->warning("evict: {$configClass} (connection lost in use)"); return true; } // Static (FPM / non-coroutine) path: close now, reopen lazily on next use. - if (isset(self::$static[$key])) { - self::$static[$key]->evict(); - self::logger()->warning("evict: {$configClass} (connection lost in use)"); - - return true; + if (!isset(self::$static[$key])) { + return false; + } + $config = self::$static[$key]->peek(); + if ($undecided && $config instanceof DbConfigInterface && CdoConnectionFactory::probe($config)) { + return false; } + self::$static[$key]->evict(); + self::logger()->warning("evict: {$configClass} (connection lost in use)"); - return false; + return true; } /** diff --git a/tests/Ppa/Pool/ConnectionLossTest.php b/tests/Ppa/Pool/ConnectionLossTest.php index 1e9ad0c..7c037ba 100644 --- a/tests/Ppa/Pool/ConnectionLossTest.php +++ b/tests/Ppa/Pool/ConnectionLossTest.php @@ -74,6 +74,38 @@ public function test_it_unwraps_the_cdo_exception_chain(): void self::assertFalse(ConnectionLoss::isLost(self::wrapped(self::pdo('23505')))); } + public function test_postgres_reports_a_killed_connection_as_undecided(): void + { + // Verified against a live PostgreSQL: killing the backend yields exactly this, + // NOT SQLSTATE 08006 — with the socket gone there is no result object to take a + // SQLSTATE from, so PDO falls back to HY000 with libpq's generic code 7. + $error = self::pdo('HY000', 7, 'FATAL: terminating connection due to administrator command'); + + self::assertFalse(ConnectionLoss::isLost($error), 'the code alone cannot decide it'); + self::assertTrue(ConnectionLoss::isUndecided($error), 'so a live probe has to'); + } + + public function test_a_decided_sqlstate_is_never_probed(): void + { + // Driver code 7 again — identical to the killed connection above; only the + // SQLSTATE tells them apart, which is why the class must be trusted over it. + self::assertFalse(ConnectionLoss::isUndecided(self::pdo('42P01', 7, 'syntax error'))); + self::assertFalse(ConnectionLoss::isUndecided(self::pdo('23505', 7, 'duplicate key'))); + self::assertFalse(ConnectionLoss::isUndecided(self::pdo('25P02', 7, 'transaction is aborted'))); + } + + public function test_a_connection_already_decided_lost_is_not_undecided(): void + { + // MariaDB: HY000 too, but the driver code settles it — no probe needed. + self::assertTrue(ConnectionLoss::isLost(self::pdo('HY000', 2006, 'gone away'))); + self::assertFalse(ConnectionLoss::isUndecided(self::pdo('HY000', 2006, 'gone away'))); + } + + public function test_undecided_survives_the_cdo_wrapper(): void + { + self::assertTrue(ConnectionLoss::isUndecided(self::wrapped(self::pdo('HY000', 7, 'terminating')))); + } + public function test_non_database_failures_are_not_a_lost_connection(): void { self::assertFalse(ConnectionLoss::isLost(new RuntimeException('something else'))); From 3ecf7628510288b41b3e052b422015c3b3c2657f Mon Sep 17 00:00:00 2001 From: flytachi Date: Sat, 1 Aug 2026 17:43:37 +0500 Subject: [PATCH 42/71] new ressource --- console/Template/Docker/Dockerfile | 4 +- src/App/Config/ServerSettings.php | 43 ++++++++ src/Core/KernelConfig.php | 11 +-- src/Kernel.php | 2 - src/Route/Router.php | 68 +++---------- src/WinterApplication.php | 1 - tests/App/StaticPathTest.php | 121 +++++++++++++++++++++++ tests/Configuration/KernelConfigTest.php | 28 +++++- 8 files changed, 206 insertions(+), 72 deletions(-) create mode 100644 tests/App/StaticPathTest.php diff --git a/console/Template/Docker/Dockerfile b/console/Template/Docker/Dockerfile index 082e6af..374e7ca 100644 --- a/console/Template/Docker/Dockerfile +++ b/console/Template/Docker/Dockerfile @@ -58,8 +58,10 @@ COPY --from=builder /var/www/html/vendor ./vendor # Application code COPY . /var/www/html +# chown covers the whole tree; storage additionally needs to be group-writable. +# No separate mode for web assets: Swoole serves them from the worker itself, so +# there is no second process that has to be able to read them. RUN chown -R winter:winter /var/www/html \ - && chmod -R 755 /var/www/html/public \ && chmod -R 775 /var/www/html/storage # Warm shell completion (best-effort; never fails the build) diff --git a/src/App/Config/ServerSettings.php b/src/App/Config/ServerSettings.php index c59df96..a4f2582 100644 --- a/src/App/Config/ServerSettings.php +++ b/src/App/Config/ServerSettings.php @@ -4,6 +4,9 @@ namespace Flytachi\Winter\K2\App\Config; +use Flytachi\Winter\K2\App\ApplicationConfigException; +use Flytachi\Winter\K2\Kernel; + /** * Fluent builder for the Swoole HTTP server options — the replacement for the old * `swooleConfig()` hook. Base values come from .env (`SERVER_*`), then each @@ -94,6 +97,46 @@ public function maxRequestGrace(int $count): self return $this->set('max_request_grace', $count); } + /** + * Serves static files from `$path` using Swoole's own handler. + * + * Static content is opt-in: say nothing here and no file is ever served, which is + * what an API-only service wants. Swoole answers these requests in C, before PHP + * is involved — it streams the file instead of reading it into the worker, honours + * `Range`, and cannot be walked out of the directory with `..`. + * + * ``` + * $server->staticPath('resources/static', ['/assets', '/favicon.ico']); + * ``` + * + * Because those requests never reach PHP, middleware, CORS and request logging do + * not apply to them. + * + * @param string $path Directory to serve from; relative paths resolve against the + * project root. + * @param list $locations URI prefixes that are even considered static. + * Leaving it empty exposes **every** file under `$path` and makes Swoole check + * the filesystem for each incoming request, so naming the prefixes is worth it. + * @throws ApplicationConfigException When the directory does not exist — a typo + * here would otherwise surface as silent 404s at runtime. + */ + public function staticPath(string $path, array $locations = []): self + { + $dir = str_starts_with($path, '/') + ? $path + : rtrim(Kernel::$pathRoot, '/\\') . '/' . ltrim($path, '/\\'); + $dir = rtrim($dir, '/\\'); + + if (!is_dir($dir)) { + throw new ApplicationConfigException("Static directory does not exist: {$dir}"); + } + + $this->set('document_root', $dir); + $this->set('enable_static_handler', true); + + return $locations === [] ? $this : $this->set('static_handler_locations', array_values($locations)); + } + /** Set any raw Swoole option. */ public function set(string $key, mixed $value): self { diff --git a/src/Core/KernelConfig.php b/src/Core/KernelConfig.php index ff9320a..f93115f 100644 --- a/src/Core/KernelConfig.php +++ b/src/Core/KernelConfig.php @@ -8,7 +8,6 @@ abstract class KernelConfig { public static string $pathRoot; public static string $pathEnv; - public static string $pathPublic; public static string $pathResource; public static string $pathStorage; public static string $pathStorageLog; @@ -19,7 +18,6 @@ abstract class KernelConfig /** * @param string|null $pathRoot * @param string|null $pathEnv - * @param string|null $pathPublic * @param string|null $pathResource * @param string|null $pathStorage * @param string|null $pathStorageLog @@ -31,7 +29,6 @@ abstract class KernelConfig public static function init( ?string $pathRoot = null, ?string $pathEnv = null, - ?string $pathPublic = null, ?string $pathResource = null, ?string $pathStorage = null, ?string $pathStorageLog = null, @@ -49,13 +46,8 @@ public static function init( $pathEnv = $pathRoot . '/.env'; } - // public - if ($pathPublic === null) { - $pathPublic = $pathRoot . '/public'; - } - // resource - if ($pathStorageLog === null) { + if ($pathResource === null) { $pathResource = $pathRoot . '/resources'; } @@ -81,7 +73,6 @@ public static function init( self::$pathRoot = $pathRoot; self::$pathEnv = $pathEnv; - self::$pathPublic = $pathPublic; self::$pathResource = $pathResource; self::$pathStorage = $pathStorage; self::$pathStorageLog = $pathStorageLog; diff --git a/src/Kernel.php b/src/Kernel.php index 9ad0106..7b98234 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -25,7 +25,6 @@ final class Kernel extends KernelStore public static function init( ?string $pathRoot = null, ?string $pathEnv = null, - ?string $pathPublic = null, ?string $pathResource = null, ?string $pathStorage = null, ?string $pathStorageLog = null, @@ -37,7 +36,6 @@ public static function init( parent::init( $pathRoot, $pathEnv, - $pathPublic, $pathResource, $pathStorage, $pathStorageLog, diff --git a/src/Route/Router.php b/src/Route/Router.php index e2eca2e..b9e49b5 100644 --- a/src/Route/Router.php +++ b/src/Route/Router.php @@ -51,7 +51,8 @@ * // Per-route override: #[CrossOrigin] attribute on controller class or method * * ── Static files ───────────────────────────────────────────────────────────── - * $router->static(Kernel::$pathPublic); + * Not the router's job. Swoole serves them itself, in C, before PHP is reached — + * declare the directory with {@see \Flytachi\Winter\K2\App\Config\ServerSettings::staticPath()}. * * ── Dispatch ───────────────────────────────────────────────────────────────── * $router->handle(new SwooleRequest($req), new SwooleResponse($res)); @@ -67,23 +68,6 @@ class Router private ?Dispatcher $dispatcher = null; - private ?string $publicDir = null; - - // ── Static file serving ─────────────────────────────────────────────────── - - /** - * Serve static files from $publicDir for GET requests that match an existing file. - * Required for Swoole — unlike FPM+nginx, Swoole does not serve files natively. - * - * Example: - * $router->static(__DIR__ . '/public'); - */ - public function static(string $publicDir): static - { - $this->publicDir = rtrim($publicDir, '/\\'); - return $this; - } - // ── Route registration ──────────────────────────────────────────────────── /** @@ -401,16 +385,15 @@ public function dispatch(string $method, string $uri): RouteResult * 1. Header::init() — snapshot request headers into the static bag * 2. Locale::initFromRequest() — detect Accept-Language / locale cookie * 3. Swoole context — stamp start time, method, uri in coroutine ctx - * 4. Static file check — short-circuit for existing files (GET only) - * 5. Global CORS headers — applied before dispatch (covers 404 / 500 too) - * 6. OPTIONS preflight — returns 204 before handler invocation - * 7. Route dispatch — O(1) static map → chunked regex dynamic scan - * 8. Per-route #[CrossOrigin] — overrides global CORS if present - * 9. Middleware before() — run in declaration order - * 10. Controller method — resolved via ReflectionCache + ParameterResolver - * 11. Middleware after() — run in reverse order - * 12. Response serialise — Sendable::send() or ResponseEntity::ok()->send() - * 13. Error handling — ExceptionWrapper maps Throwable → HTTP response + * 4. Global CORS headers — applied before dispatch (covers 404 / 500 too) + * 5. OPTIONS preflight — returns 204 before handler invocation + * 6. Route dispatch — O(1) static map → chunked regex dynamic scan + * 7. Per-route #[CrossOrigin] — overrides global CORS if present + * 8. Middleware before() — run in declaration order + * 9. Controller method — resolved via ReflectionCache + ParameterResolver + * 10. Middleware after() — run in reverse order + * 11. Response serialise — Sendable::send() or ResponseEntity::ok()->send() + * 12. Error handling — ExceptionWrapper maps Throwable → HTTP response */ public function handle(HttpRequest $request, HttpResponse $response): void { @@ -424,17 +407,6 @@ public function handle(HttpRequest $request, HttpResponse $response): void $ctx['__request_uri'] = $request->getUri(); } - // static - files (js,css,media) - if (Runtime::isSwoole() && $this->publicDir !== null && strtoupper($request->getMethod()) === 'GET') { - $uri = $request->getUri(); - $path = ($pos = strpos($uri, '?')) !== false ? substr($uri, 0, $pos) : $uri; - $file = $this->publicDir . $path; - if (is_file($file)) { - $this->serveStaticFile($file, $response); - return; - } - } - try { $method = $request->getMethod(); @@ -702,24 +674,6 @@ private function extractRouteCors(mixed $stored): ?array } return null; } - - // ── Static file helper ──────────────────────────────────────────────────── - - private function serveStaticFile(string $filePath, HttpResponse $response): void - { - $content = file_get_contents($filePath); - if ($content === false) { - $response->status(500); - $response->end(''); - return; - } - $mime = mime_content_type($filePath) ?: 'application/octet-stream'; - $response->status(200); - $response->header('Content-Type', $mime); - $response->header('Cache-Control', 'public, max-age=86400'); - $response->end($content); - } - // ── Debug helpers ───────────────────────────────────────────────────────── /** @return list */ diff --git a/src/WinterApplication.php b/src/WinterApplication.php index d95bdd4..09eba1c 100644 --- a/src/WinterApplication.php +++ b/src/WinterApplication.php @@ -421,7 +421,6 @@ private static function serveHttp( $port = $settings->getPort(); $router = Router::fromScan(Kernel::$pathRoot); - $router->static(Kernel::$pathPublic); \Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL); Runtime::boot(RuntimeMode::Swoole); diff --git a/tests/App/StaticPathTest.php b/tests/App/StaticPathTest.php new file mode 100644 index 0000000..601e2b0 --- /dev/null +++ b/tests/App/StaticPathTest.php @@ -0,0 +1,121 @@ +originalRoot = $prop->isInitialized() ? $prop->getValue() : null; + + $this->root = sys_get_temp_dir() . '/wk_static_cfg_' . getmypid() . '_' . bin2hex(random_bytes(4)); + @mkdir($this->root . '/resources/static', 0777, true); + KernelConfig::$pathRoot = $this->root; + } + + protected function tearDown(): void + { + @rmdir($this->root . '/resources/static'); + @rmdir($this->root . '/resources'); + @rmdir($this->root); + + if ($this->originalRoot !== null) { + KernelConfig::$pathRoot = $this->originalRoot; + } + } + + private function settings(): ServerSettings + { + return ServerSettings::fromEnv(); + } + + public function test_static_is_off_until_asked_for(): void + { + $options = $this->settings()->toArray(); + + self::assertArrayNotHasKey('document_root', $options); + self::assertArrayNotHasKey('enable_static_handler', $options, 'an API-only service serves no files'); + } + + public function test_a_relative_path_resolves_against_the_project_root(): void + { + $options = $this->settings()->staticPath('resources/static')->toArray(); + + self::assertSame($this->root . '/resources/static', $options['document_root']); + self::assertTrue($options['enable_static_handler']); + } + + public function test_an_absolute_path_is_used_as_is(): void + { + $options = $this->settings()->staticPath($this->root . '/resources/static')->toArray(); + + self::assertSame($this->root . '/resources/static', $options['document_root']); + } + + public function test_trailing_slashes_are_trimmed(): void + { + $options = $this->settings()->staticPath('resources/static/')->toArray(); + + self::assertSame($this->root . '/resources/static', $options['document_root']); + } + + public function test_locations_restrict_which_prefixes_are_treated_as_static(): void + { + $options = $this->settings() + ->staticPath('resources/static', ['/assets', '/favicon.ico']) + ->toArray(); + + self::assertSame(['/assets', '/favicon.ico'], $options['static_handler_locations']); + } + + public function test_no_locations_means_no_restriction(): void + { + $options = $this->settings()->staticPath('resources/static')->toArray(); + + self::assertArrayNotHasKey( + 'static_handler_locations', + $options, + 'omitting the list exposes the whole directory — Swoole checks every request', + ); + } + + public function test_a_missing_directory_fails_the_boot(): void + { + $this->expectException(ApplicationConfigException::class); + $this->expectExceptionMessage('Static directory does not exist'); + + $this->settings()->staticPath('resources/typo'); + } + + public function test_it_keeps_other_options_intact(): void + { + $options = $this->settings() + ->workers(4) + ->staticPath('resources/static') + ->maxRequest(1000) + ->toArray(); + + self::assertSame(4, $options['worker_num']); + self::assertSame(1000, $options['max_request']); + self::assertSame($this->root . '/resources/static', $options['document_root']); + } +} diff --git a/tests/Configuration/KernelConfigTest.php b/tests/Configuration/KernelConfigTest.php index a6d9521..513e3a6 100644 --- a/tests/Configuration/KernelConfigTest.php +++ b/tests/Configuration/KernelConfigTest.php @@ -84,13 +84,39 @@ public function test_init_resolves_default_paths_under_root(): void self::assertSame($this->tmpDir, KernelConfig::$pathRoot); self::assertSame($this->tmpDir . '/.env', KernelConfig::$pathEnv); - self::assertSame($this->tmpDir . '/public', KernelConfig::$pathPublic); + self::assertSame($this->tmpDir . '/resources', KernelConfig::$pathResource); self::assertSame($this->tmpDir . '/storage', KernelConfig::$pathStorage); self::assertSame($this->tmpDir . '/storage/logs', KernelConfig::$pathStorageLog); self::assertSame($this->tmpDir . '/storage/cache', KernelConfig::$pathStorageCache); self::assertSame($this->tmpDir . '/storage/runnable', KernelConfig::$pathStorageRunnable); } + /** + * `$pathResource` used to be derived under `if ($pathStorageLog === null)`, so + * passing a log path alone left it unassigned and init died on the typed property. + */ + public function test_an_explicit_log_path_does_not_break_resource_resolution(): void + { + KernelConfig::init( + pathRoot: $this->tmpDir, + pathStorageLog: $this->tmpDir . '/var/log', + ); + + self::assertSame($this->tmpDir . '/resources', KernelConfig::$pathResource); + self::assertSame($this->tmpDir . '/var/log', KernelConfig::$pathStorageLog); + } + + /** The mirror case: an explicit resource path used to be silently overwritten. */ + public function test_an_explicit_resource_path_is_kept(): void + { + KernelConfig::init( + pathRoot: $this->tmpDir, + pathResource: $this->tmpDir . '/app/views', + ); + + self::assertSame($this->tmpDir . '/app/views', KernelConfig::$pathResource); + } + public function test_volatile_path_uses_temp_dir_when_isTmpVolatile_is_true(): void { KernelConfig::init(pathRoot: $this->tmpDir, isTmpVolatile: true); From 98de60df5c39c3f20c858e8deeb36e445da1be0c Mon Sep 17 00:00:00 2001 From: flytachi Date: Sat, 1 Aug 2026 18:58:24 +0500 Subject: [PATCH 43/71] tests --- phpunit.xml | 4 + src/Http/Response/ResponseView.php | 17 +- tests/Http/Response/ResponseViewPathTest.php | 108 ++++++++++ tests/Route/Fixtures/FakeRequest.php | 105 ++++++++++ tests/Route/Fixtures/FakeResponse.php | 61 ++++++ tests/Route/Fixtures/RecordingMiddleware.php | 58 ++++++ tests/Route/RouterDispatchTest.php | 202 +++++++++++++++++++ tests/Route/RouterMiddlewareTest.php | 98 +++++++++ 8 files changed, 648 insertions(+), 5 deletions(-) create mode 100644 tests/Http/Response/ResponseViewPathTest.php create mode 100644 tests/Route/Fixtures/FakeRequest.php create mode 100644 tests/Route/Fixtures/FakeResponse.php create mode 100644 tests/Route/Fixtures/RecordingMiddleware.php create mode 100644 tests/Route/RouterDispatchTest.php create mode 100644 tests/Route/RouterMiddlewareTest.php diff --git a/phpunit.xml b/phpunit.xml index ba7b68b..6390cce 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -43,6 +43,10 @@ tests/ConnectionPool + + tests/Route + tests/Route/Fixtures + diff --git a/src/Http/Response/ResponseView.php b/src/Http/Response/ResponseView.php index 4688566..a27917c 100644 --- a/src/Http/Response/ResponseView.php +++ b/src/Http/Response/ResponseView.php @@ -12,18 +12,25 @@ /** * PHP template response — port of ViewBase + View. * - * Configure once per app (e.g. in bootstrap): - * ResponseView::setBasePath(__DIR__ . '/resources/views'); + * Views live in `resources/views` by default; no configuration is needed. Override + * the root only for a non-standard layout: + * ResponseView::setBasePath(__DIR__ . '/theme'); * * Factory methods: * ResponseView::view('user/profile', ['user' => $user]) * ResponseView::render('layouts/main', 'user/profile', ['user' => $user]) * - * Template receives all $data keys as variables plus $content (rendered resource). - * Resource receives all $data keys as variables. + * The two names are not interchangeable: the **template** is the layout, and it + * receives all $data keys plus $content (the rendered resource); the **resource** is + * the page, and it receives the $data keys. Both are resolved under the same root, so + * the directory is named after neither — `views` covers both, with layouts + * conventionally under `views/layouts`. */ class ResponseView implements Sendable { + /** Directory under {@see Kernel::$pathResource} holding the views. */ + private const string DEFAULT_DIR = 'views'; + private static string $basePath = ''; private ?string $templateName; @@ -39,7 +46,7 @@ private function __construct( HttpCode $httpCode, ) { if (empty(self::getBasePath())) { - self::setBasePath(Kernel::$pathResource); + self::setBasePath(Kernel::$pathResource . '/' . self::DEFAULT_DIR); } $this->templateName = $templateName; $this->resourceName = $resourceName; diff --git a/tests/Http/Response/ResponseViewPathTest.php b/tests/Http/Response/ResponseViewPathTest.php new file mode 100644 index 0000000..80d2b1b --- /dev/null +++ b/tests/Http/Response/ResponseViewPathTest.php @@ -0,0 +1,108 @@ +originalRoot = $prop->isInitialized() ? $prop->getValue() : null; + + $this->root = sys_get_temp_dir() . '/wk_views_' . getmypid() . '_' . bin2hex(random_bytes(4)); + @mkdir($this->root . '/views/layouts', 0777, true); + @mkdir($this->root . '/views/user', 0777, true); + + file_put_contents($this->root . '/views/user/profile.php', '

profile

'); + file_put_contents($this->root . '/views/layouts/main.php', ''); + // A stray file at the resource root must NOT be reachable any more. + file_put_contents($this->root . '/legacy.php', '

legacy

'); + + KernelConfig::$pathResource = $this->root; + ResponseView::setBasePath(''); + } + + protected function tearDown(): void + { + foreach (['/views/user/profile.php', '/views/layouts/main.php', '/legacy.php'] as $file) { + @unlink($this->root . $file); + } + foreach (['/views/layouts', '/views/user', '/views'] as $dir) { + @rmdir($this->root . $dir); + } + @rmdir($this->root); + + ResponseView::setBasePath(''); + if ($this->originalRoot !== null) { + KernelConfig::$pathResource = $this->originalRoot; + } + } + + public function test_views_resolve_under_resources_views_by_default(): void + { + ResponseView::view('user/profile'); + + self::assertSame($this->root . '/views', ResponseView::getBasePath()); + } + + public function test_a_layout_and_a_page_share_the_same_root(): void + { + ResponseView::render('layouts/main', 'user/profile'); + + self::assertSame($this->root . '/views', ResponseView::getBasePath()); + } + + public function test_files_directly_under_resources_are_no_longer_views(): void + { + self::assertFileExists($this->root . '/legacy.php', 'the file is there; only the root moved'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('View resource not found'); + + ResponseView::view('legacy'); + } + + public function test_a_missing_view_fails_loudly(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('View resource not found'); + + ResponseView::view('user/nope'); + } + + public function test_a_missing_layout_fails_loudly(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('View template not found'); + + ResponseView::render('layouts/nope', 'user/profile'); + } + + public function test_an_explicit_base_path_still_wins(): void + { + // The escape hatch for a project that keeps views somewhere else. + ResponseView::setBasePath($this->root); + + ResponseView::view('legacy'); + + self::assertSame($this->root, ResponseView::getBasePath()); + } +} diff --git a/tests/Route/Fixtures/FakeRequest.php b/tests/Route/Fixtures/FakeRequest.php new file mode 100644 index 0000000..9e3b0c4 --- /dev/null +++ b/tests/Route/Fixtures/FakeRequest.php @@ -0,0 +1,105 @@ + $headers */ + public function __construct( + private readonly string $method = 'GET', + private readonly string $uri = '/', + private readonly array $headers = [], + private readonly array $query = [], + private readonly array $body = [], + ) { + } + + public function getMethod(): string + { + return $this->method; + } + + public function getUri(): string + { + return $this->uri; + } + + public function getQueryParams(): array + { + return $this->query; + } + + public function getParsedBody(): array + { + return $this->body; + } + + public function getRawBody(): string + { + return ''; + } + + public function getHeader(string $name): ?string + { + foreach ($this->headers as $key => $value) { + if (strcasecmp($key, $name) === 0) { + return $value; + } + } + + return null; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function getUploadedFiles(): array + { + return []; + } + + public function getServerParam(string $key): ?string + { + return null; + } + + public function getClientIp(): string + { + return '127.0.0.1'; + } + + public function getClientTimezone(): ?string + { + return null; + } + + public function getScheme(): string + { + return 'http'; + } + + public function getHost(): string + { + return 'localhost'; + } + + public function getPort(): int + { + return 80; + } + + public function getBaseUrl(): string + { + return 'http://localhost'; + } +} diff --git a/tests/Route/Fixtures/FakeResponse.php b/tests/Route/Fixtures/FakeResponse.php new file mode 100644 index 0000000..d600b71 --- /dev/null +++ b/tests/Route/Fixtures/FakeResponse.php @@ -0,0 +1,61 @@ + */ + public array $headers = []; + public ?string $body = null; + public bool $ended = false; + public ?string $sentFile = null; + + public function status(int $code): void + { + $this->status = $code; + } + + public function header(string $name, string $value): void + { + $this->headers[$name] = $value; + } + + public function end(string $body = ''): void + { + $this->body = $body; + $this->ended = true; + } + + public function sendfile(string $path, int $offset = 0, int $length = 0): void + { + $this->sentFile = $path; + $this->ended = true; + } + + /** Case-insensitive header lookup, as a client would see it. */ + public function header_(string $name): ?string + { + foreach ($this->headers as $key => $value) { + if (strcasecmp($key, $name) === 0) { + return $value; + } + } + + return null; + } + + /** The decoded JSON body, or null when the body is absent or not JSON. */ + public function json(): mixed + { + return $this->body === null ? null : json_decode($this->body, true); + } +} diff --git a/tests/Route/Fixtures/RecordingMiddleware.php b/tests/Route/Fixtures/RecordingMiddleware.php new file mode 100644 index 0000000..f8eb353 --- /dev/null +++ b/tests/Route/Fixtures/RecordingMiddleware.php @@ -0,0 +1,58 @@ + */ + public static array $trace = []; + + public static function reset(): void + { + self::$trace = []; + } + + protected function tag(): string + { + return 'mw'; + } + + public function before(HttpRequest $request, HttpResponse $response): void + { + self::$trace[] = 'before:' . $this->tag(); + } + + public function after(mixed $result): mixed + { + self::$trace[] = 'after:' . $this->tag(); + + return is_string($result) ? $result . '|' . $this->tag() : $result; + } +} + +final class FirstMiddleware extends RecordingMiddleware +{ + protected function tag(): string + { + return 'first'; + } +} + +final class SecondMiddleware extends RecordingMiddleware +{ + protected function tag(): string + { + return 'second'; + } +} diff --git a/tests/Route/RouterDispatchTest.php b/tests/Route/RouterDispatchTest.php new file mode 100644 index 0000000..eee56b0 --- /dev/null +++ b/tests/Route/RouterDispatchTest.php @@ -0,0 +1,202 @@ +handle(new FakeRequest($method, $uri), $response); + + return $response; + } + + // ── Matching ─────────────────────────────────────────────────────────────── + + public function test_a_static_route_reaches_its_handler(): void + { + $router = new Router()->get('/ping', static fn(): string => 'pong'); + + $response = $this->send($router, 'GET', '/ping'); + + self::assertSame(200, $response->status); + self::assertSame('pong', $response->body); + } + + public function test_a_dynamic_segment_is_passed_to_the_handler(): void + { + $router = new Router()->get('/users/{id:\d+}', static fn($req, $res, array $p): array => ['id' => $p['id']]); + + $response = $this->send($router, 'GET', '/users/42'); + + self::assertSame(200, $response->status); + self::assertSame(['id' => '42'], $response->json()); + } + + public function test_a_segment_constraint_is_enforced(): void + { + $router = new Router()->get('/users/{id:\d+}', static fn(): string => 'never'); + + $response = $this->send($router, 'GET', '/users/abc'); + + self::assertSame(404, $response->status, 'a non-numeric id must not match \d+'); + } + + public function test_an_unknown_path_is_not_found(): void + { + $router = new Router()->get('/ping', static fn(): string => 'pong'); + + $response = $this->send($router, 'GET', '/nope'); + + self::assertSame(404, $response->status); + self::assertSame(['code' => 404, 'message' => 'Not Found'], $response->json()); + } + + public function test_a_known_path_with_the_wrong_method_reports_what_is_allowed(): void + { + $router = new Router() + ->post('/users', static fn(): string => 'created') + ->put('/users', static fn(): string => 'replaced'); + + $response = $this->send($router, 'DELETE', '/users'); + + self::assertSame(405, $response->status); + self::assertNotNull($response->header_('Allow')); + foreach (['POST', 'PUT'] as $method) { + self::assertStringContainsString($method, (string) $response->header_('Allow')); + } + } + + public function test_each_verb_helper_registers_its_own_method(): void + { + $router = new Router(); + foreach (['get', 'post', 'put', 'patch', 'delete'] as $verb) { + $router->{$verb}('/thing', static fn(): string => $verb); + } + + foreach (['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as $method) { + self::assertSame(200, $this->send($router, $method, '/thing')->status, $method); + } + } + + public function test_options_is_answered_before_dispatch(): void + { + $router = new Router()->post('/users', static fn(): string => 'created'); + + $response = $this->send($router, 'OPTIONS', '/users'); + + self::assertSame(204, $response->status, 'preflight is intercepted, the handler never runs'); + self::assertSame('', $response->body); + } + + // ── Registration guards ──────────────────────────────────────────────────── + + public function test_a_duplicate_static_route_is_rejected(): void + { + $router = new Router()->get('/ping', static fn(): string => 'a'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Ambiguous handler methods mapped'); + + $router->get('/ping', static fn(): string => 'b'); + } + + public function test_a_duplicate_dynamic_route_is_rejected(): void + { + $router = new Router()->get('/users/{id}', static fn(): string => 'a'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Ambiguous handler methods mapped'); + + $router->get('/users/{id}', static fn(): string => 'b'); + } + + public function test_the_same_path_under_different_methods_is_fine(): void + { + $router = new Router() + ->get('/users', static fn(): string => 'list') + ->post('/users', static fn(): string => 'create'); + + self::assertSame('list', $this->send($router, 'GET', '/users')->body); + self::assertSame('create', $this->send($router, 'POST', '/users')->body); + } + + // ── Response serialisation ───────────────────────────────────────────────── + + public function test_an_array_return_is_serialised_as_json(): void + { + $router = new Router()->get('/j', static fn(): array => ['a' => 1, 'b' => [2, 3]]); + + self::assertSame(['a' => 1, 'b' => [2, 3]], $this->send($router, 'GET', '/j')->json()); + } + + public function test_a_null_return_sends_nothing(): void + { + // The handler took over the response itself; the router must not write over it. + $router = new Router()->get('/silent', static fn(): null => null); + + $response = $this->send($router, 'GET', '/silent'); + + self::assertFalse($response->ended); + self::assertNull($response->status); + } + + // ── Failures ─────────────────────────────────────────────────────────────── + + public function test_an_unexpected_exception_becomes_a_500(): void + { + $router = new Router()->get('/boom', static function (): never { + throw new RuntimeException('kaboom'); + }); + + $response = $this->send($router, 'GET', '/boom'); + + self::assertSame(500, $response->status); + self::assertSame('kaboom', $response->json()['message'] ?? null); + } + + public function test_a_response_exception_carries_its_own_status(): void + { + $router = new Router()->get('/teapot', static function (): never { + throw new ResponseException('short and stout', HttpCode::IM_A_TEAPOT); + }); + + $response = $this->send($router, 'GET', '/teapot'); + + self::assertSame(418, $response->status); + self::assertSame('short and stout', $response->json()['message'] ?? null); + } + + // ── Introspection ────────────────────────────────────────────────────────── + + public function test_the_route_summary_lists_every_registration(): void + { + $router = new Router() + ->get('/a', static fn(): string => 'a') + ->post('/b/{id:\d+}', static fn(): string => 'b'); + + $summary = $router->getRoutesSummary(); + + $pairs = array_map(static fn(array $r): string => $r['method'] . ' ' . $r['path'], $summary); + self::assertContains('GET /a', $pairs); + self::assertContains('POST /b/{id:\d+}', $pairs); + } +} diff --git a/tests/Route/RouterMiddlewareTest.php b/tests/Route/RouterMiddlewareTest.php new file mode 100644 index 0000000..8845de1 --- /dev/null +++ b/tests/Route/RouterMiddlewareTest.php @@ -0,0 +1,98 @@ + $middlewares */ + private function dispatch(array $middlewares, mixed $handler = null): FakeResponse + { + $router = new Router(); + $router->add('GET', '/m', $handler ?? static fn(): string => 'body', $middlewares); + + $response = new FakeResponse(); + $router->handle(new FakeRequest('GET', '/m'), $response); + + return $response; + } + + private static function def(string $class): array + { + return ['class' => $class, 'args' => []]; + } + + public function test_before_runs_in_order_and_after_unwinds_in_reverse(): void + { + $this->dispatch([self::def(FirstMiddleware::class), self::def(SecondMiddleware::class)]); + + self::assertSame( + ['before:first', 'before:second', 'after:second', 'after:first'], + RecordingMiddleware::$trace, + ); + } + + public function test_after_can_transform_the_result_on_the_way_out(): void + { + $response = $this->dispatch([self::def(FirstMiddleware::class), self::def(SecondMiddleware::class)]); + + // 'body' → innermost (second) wraps first, then first — mirroring the unwind. + self::assertSame('body|second|first', $response->body); + } + + public function test_a_single_middleware_still_runs_both_hooks(): void + { + $response = $this->dispatch([self::def(FirstMiddleware::class)]); + + self::assertSame(['before:first', 'after:first'], RecordingMiddleware::$trace); + self::assertSame('body|first', $response->body); + } + + public function test_a_route_without_middleware_is_untouched(): void + { + $response = $this->dispatch([]); + + self::assertSame([], RecordingMiddleware::$trace); + self::assertSame('body', $response->body); + } + + public function test_a_failing_handler_skips_the_after_hooks(): void + { + // after() is the unwind of a *successful* call; on a throw the error path takes + // over, so a middleware must not assume after() always follows before(). + $response = $this->dispatch( + [self::def(FirstMiddleware::class)], + static function (): never { + throw new RuntimeException('kaboom'); + }, + ); + + self::assertSame(['before:first'], RecordingMiddleware::$trace); + self::assertSame(500, $response->status); + } +} From e4ae56c114e508ce814921d1eaaee6e64e3566dc Mon Sep 17 00:00:00 2001 From: flytachi Date: Sat, 1 Aug 2026 19:20:00 +0500 Subject: [PATCH 44/71] tests --- src/Schedule/Scheduler.php | 2 +- tests/Route/ApplicationBootTest.php | 136 +++++++++++ tests/Route/Fixtures/App/DemoController.php | 60 +++++ tests/Route/Fixtures/App/GreetingService.php | 17 ++ tests/Route/Fixtures/App/ServeApp.php | 39 +++ tests/Route/ServeHttpTest.php | 225 ++++++++++++++++++ .../Schedule/SchedulerExtensionPointTest.php | 44 ++++ 7 files changed, 522 insertions(+), 1 deletion(-) create mode 100644 tests/Route/ApplicationBootTest.php create mode 100644 tests/Route/Fixtures/App/DemoController.php create mode 100644 tests/Route/Fixtures/App/GreetingService.php create mode 100644 tests/Route/Fixtures/App/ServeApp.php create mode 100644 tests/Route/ServeHttpTest.php create mode 100644 tests/Schedule/SchedulerExtensionPointTest.php diff --git a/src/Schedule/Scheduler.php b/src/Schedule/Scheduler.php index e6224af..03cb094 100644 --- a/src/Schedule/Scheduler.php +++ b/src/Schedule/Scheduler.php @@ -30,7 +30,7 @@ * {@see dispatch()}, and stopped with {@see stop()} — or through `call schedule`. * SIGHUP re-scans the annotated methods without a restart. */ -final class Scheduler extends Process +class Scheduler extends Process { /** Shortest idle pause between loop passes, in seconds (avoids a busy spin). */ private const float MIN_SLEEP = 0.01; diff --git a/tests/Route/ApplicationBootTest.php b/tests/Route/ApplicationBootTest.php new file mode 100644 index 0000000..df37e6a --- /dev/null +++ b/tests/Route/ApplicationBootTest.php @@ -0,0 +1,136 @@ +isInitialized() ? $prop->getValue() : null; + + // Errors are silenced when DEBUG is off, which would hide a boot failure. + $_ENV['DEBUG'] = 'true'; + + $appDir = __DIR__ . '/Fixtures/App'; + Kernel::init(pathRoot: $appDir); + + $container = Container::init(); + Scanner::run($appDir)->collect(new DICollector($container))->execute(); + + self::$router = Router::fromScan($appDir); + } + + public static function tearDownAfterClass(): void + { + unset($_ENV['DEBUG']); + self::$router = null; + + if (self::$originalRoot !== null) { + KernelConfig::$pathRoot = self::$originalRoot; + } + } + + /** @param array $query */ + private function send(string $method, string $uri, array $query = []): FakeResponse + { + $response = new FakeResponse(); + self::$router->handle(new FakeRequest($method, $uri, [], $query), $response); + + return $response; + } + + public function test_the_scan_discovers_routes_from_attributes(): void + { + $routes = array_map( + static fn(array $r): string => $r['method'] . ' ' . $r['path'], + self::$router->getRoutesSummary(), + ); + + self::assertContains('GET /demo/ping', $routes, 'nothing was registered by hand here'); + self::assertContains('POST /demo/items', $routes); + } + + public function test_the_class_prefix_combines_with_the_method_path(): void + { + // #[RequestMapping('/demo')] + #[GetMapping('/ping')] + self::assertSame(200, $this->send('GET', '/demo/ping')->status); + self::assertSame(404, $this->send('GET', '/ping')->status, 'the prefix is not optional'); + } + + public function test_the_container_injects_the_controller_dependency(): void + { + $response = $this->send('GET', '/demo/hello/world'); + + // 'hello world' can only come from the autowired GreetingService. + self::assertSame(['message' => 'hello world'], $response->json()); + } + + public function test_a_path_variable_reaches_the_method_argument(): void + { + self::assertSame(['message' => 'hello winter'], $this->send('GET', '/demo/hello/winter')->json()); + } + + public function test_query_parameters_bind_and_defaults_apply(): void + { + self::assertSame( + ['q' => 'winter', 'limit' => 10], + $this->send('GET', '/demo/search', ['q' => 'winter'])->json(), + 'limit is absent, so its PHP default is used', + ); + + self::assertSame( + ['q' => 'winter', 'limit' => 5], + $this->send('GET', '/demo/search', ['q' => 'winter', 'limit' => '5'])->json(), + 'and it is cast to the declared int', + ); + } + + public function test_the_request_object_is_injectable_by_type(): void + { + self::assertSame( + ['method' => 'GET', 'uri' => '/demo/echo'], + $this->send('GET', '/demo/echo')->json(), + ); + } + + public function test_the_http_method_of_the_mapping_is_honoured(): void + { + self::assertSame(200, $this->send('POST', '/demo/items')->status); + + $wrongMethod = $this->send('GET', '/demo/items'); + self::assertSame(405, $wrongMethod->status); + self::assertStringContainsString('POST', (string) $wrongMethod->header_('Allow')); + } + + public function test_an_unmapped_path_is_not_found(): void + { + self::assertSame(404, $this->send('GET', '/demo/nothing-here')->status); + } +} diff --git a/tests/Route/Fixtures/App/DemoController.php b/tests/Route/Fixtures/App/DemoController.php new file mode 100644 index 0000000..9af00fe --- /dev/null +++ b/tests/Route/Fixtures/App/DemoController.php @@ -0,0 +1,60 @@ + $this->greetings->greet($name)]; + } + + /** Query parameters and their defaults. */ + #[GetMapping('/search')] + public function search(#[RequestParam] string $q, #[RequestParam] int $limit = 10): array + { + return ['q' => $q, 'limit' => $limit]; + } + + /** The raw request object is injectable by type. */ + #[GetMapping('/echo')] + public function echoMethod(HttpRequest $request): array + { + return ['method' => $request->getMethod(), 'uri' => $request->getUri()]; + } + + #[PostMapping('/items')] + public function create(): array + { + return ['created' => true]; + } +} diff --git a/tests/Route/Fixtures/App/GreetingService.php b/tests/Route/Fixtures/App/GreetingService.php new file mode 100644 index 0000000..186e932 --- /dev/null +++ b/tests/Route/Fixtures/App/GreetingService.php @@ -0,0 +1,17 @@ +> %s 2>&1 & echo $!', + escapeshellarg(self::$storage), + escapeshellarg(PHP_BINARY), + escapeshellarg(self::$runner), + escapeshellarg(self::$log), + ); + self::$pid = (int) trim((string) shell_exec($command)); + + if (!self::awaitReady(15.0)) { + $log = (string) @file_get_contents(self::$log); + self::stopServer(); + self::markTestSkipped("the server did not come up in time:\n" . substr($log, 0, 600)); + } + } + + public static function tearDownAfterClass(): void + { + self::stopServer(); + + @unlink(self::$runner); + @unlink(self::$log); + foreach (glob(self::$storage . '/*/*') ?: [] as $file) { + @unlink($file); + } + foreach (glob(self::$storage . '/*') ?: [] as $dir) { + is_dir($dir) ? @rmdir($dir) : @unlink($dir); + } + @rmdir(self::$storage); + } + + // ── The requests ─────────────────────────────────────────────────────────── + + public function test_it_answers_a_simple_route(): void + { + $response = $this->request('GET', '/demo/ping'); + + self::assertSame(200, $response['status']); + self::assertSame('pong', $response['body']); + } + + public function test_a_path_variable_survives_the_wire(): void + { + $response = $this->request('GET', '/demo/hello/winter'); + + self::assertSame(200, $response['status']); + self::assertSame(['message' => 'hello winter'], json_decode($response['body'], true)); + } + + public function test_a_query_string_is_parsed_and_cast(): void + { + $response = $this->request('GET', '/demo/search?q=snow&limit=3'); + + self::assertSame(['q' => 'snow', 'limit' => 3], json_decode($response['body'], true)); + } + + public function test_a_post_route_is_reachable(): void + { + $response = $this->request('POST', '/demo/items'); + + self::assertSame(200, $response['status']); + self::assertSame(['created' => true], json_decode($response['body'], true)); + } + + public function test_an_unknown_path_returns_404_over_http(): void + { + $response = $this->request('GET', '/definitely-not-here'); + + self::assertSame(404, $response['status']); + } + + public function test_the_wrong_method_returns_405_with_allow(): void + { + $response = $this->request('GET', '/demo/items'); + + self::assertSame(405, $response['status']); + self::assertStringContainsString('POST', self::headerOf($response['headers'], 'Allow')); + } + + public function test_json_responses_carry_a_json_content_type(): void + { + $response = $this->request('GET', '/demo/hello/winter'); + + self::assertStringContainsString( + 'application/json', + strtolower(self::headerOf($response['headers'], 'Content-Type')), + ); + } + + // ── Plumbing ─────────────────────────────────────────────────────────────── + + /** @return array{status: int, body: string, headers: list} */ + private function request(string $method, string $path): array + { + $context = stream_context_create(['http' => [ + 'method' => $method, + 'timeout' => 5, + 'ignore_errors' => true, // 4xx/5xx must come back as a response, not a warning + ]]); + + $body = @file_get_contents(self::url($path), false, $context); + $headers = $http_response_header ?? []; + + return [ + 'status' => self::statusOf($headers), + 'body' => $body === false ? '' : $body, + 'headers' => $headers, + ]; + } + + private static function url(string $path): string + { + return 'http://127.0.0.1:' . self::$port . $path; + } + + /** @param list $headers */ + private static function statusOf(array $headers): int + { + foreach ($headers as $line) { + if (preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $m) === 1) { + return (int) $m[1]; + } + } + + return 0; + } + + /** @param list $headers */ + private static function headerOf(array $headers, string $name): string + { + foreach ($headers as $line) { + $parts = explode(':', $line, 2); + if (count($parts) === 2 && strcasecmp(trim($parts[0]), $name) === 0) { + return trim($parts[1]); + } + } + + return ''; + } + + private static function awaitReady(float $timeout): bool + { + $deadline = microtime(true) + $timeout; + while (microtime(true) < $deadline) { + $socket = @fsockopen('127.0.0.1', self::$port, $errno, $errstr, 0.3); + if ($socket !== false) { + fclose($socket); + return true; + } + usleep(200_000); + } + + return false; + } + + private static function stopServer(): void + { + if (self::$pid > 0) { + @exec(sprintf('kill -TERM %d 2>/dev/null', self::$pid)); + usleep(500_000); + @exec(sprintf('kill -KILL %d 2>/dev/null', self::$pid)); + self::$pid = 0; + } + } + + /** Asks the OS for an unused port, so parallel runs do not collide. */ + private static function freePort(): int + { + $socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); + $name = stream_socket_get_name($socket, false); + fclose($socket); + + return (int) substr((string) $name, strrpos((string) $name, ':') + 1); + } +} diff --git a/tests/Schedule/SchedulerExtensionPointTest.php b/tests/Schedule/SchedulerExtensionPointTest.php new file mode 100644 index 0000000..321d125 --- /dev/null +++ b/tests/Schedule/SchedulerExtensionPointTest.php @@ -0,0 +1,44 @@ +` so an + * application can subclass it and source tasks its own way. Marking the class `final` + * once silently revoked that: the subclass stopped loading, the forked scheduler died + * without a word, and only an integration test — excluded from the default run — + * noticed, by timing out. These assertions are cheap and fail immediately instead. + */ +final class SchedulerExtensionPointTest extends TestCase +{ + public function test_the_scheduler_can_be_subclassed(): void + { + self::assertFalse( + new ReflectionClass(Scheduler::class)->isFinal(), + 'EnableScheduler documents a subclass overriding discovery, so this cannot be final', + ); + } + + public function test_discovery_is_an_overridable_hook(): void + { + $discover = new ReflectionMethod(Scheduler::class, 'discover'); + + self::assertTrue($discover->isProtected(), 'subclasses override discover() to source tasks'); + self::assertFalse($discover->isFinal()); + } + + public function test_the_attribute_defaults_to_the_built_in_scheduler(): void + { + self::assertSame(Scheduler::class, new EnableScheduler()->class); + } +} From f22a88bbf365bbb22d565dadaac5d33c8aed295f Mon Sep 17 00:00:00 2001 From: flytachi Date: Sat, 1 Aug 2026 23:07:07 +0500 Subject: [PATCH 45/71] tests --- CLAUDE.md | 234 +++++++++++++++++++- dev/bootstrap.php | 232 ++----------------- dev/call | 11 +- dev/main/S1Job.php | 24 -- dev/public/.htaccess | 7 - dev/public/index.php | 49 ---- dev/public/static/winter/debug.css | 107 --------- dev/public/static/winter/debug.js | 22 -- dev/public/static/winter/logo.svg | 9 - dev/wDevRunner | 40 +++- src/Http/Response/ExceptionResponseBase.php | 28 ++- src/Http/Response/RenderContext.php | 217 +----------------- src/Http/Response/ResponseView.php | 2 +- src/Route/Router.php | 5 - 14 files changed, 321 insertions(+), 666 deletions(-) delete mode 100644 dev/main/S1Job.php delete mode 100644 dev/public/.htaccess delete mode 100644 dev/public/index.php delete mode 100644 dev/public/static/winter/debug.css delete mode 100644 dev/public/static/winter/debug.js delete mode 100644 dev/public/static/winter/logo.svg diff --git a/CLAUDE.md b/CLAUDE.md index 8ae404a..8a2d682 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,9 @@ -# CLAUDE.md — winter-kernel: Process/Daemon layer handoff +# CLAUDE.md — winter-kernel: Process/Daemon + ConnectionPool handoff -This file orients you (Claude) to the work done on the **Process/Daemon** layer of -`winter-kernel`. Read it fully before touching that layer. It describes what was -built, how it works, why, and the rules to keep. +This file orients you (Claude) to the work done on `winter-kernel`: the +**Process/Daemon** layer (§1–§12), the **ConnectionPool** layer (§13), and the +**project layout / static files** (§14). Read the relevant part fully before +touching it. It describes what was built, how it works, why, and the rules to keep. > **winter-kernel** is a PHP 8.4+ framework kernel (a library, not an app). It runs > under two runtimes: **Swoole** (coroutines) and **FPM/CLI** (plain processes). @@ -398,3 +399,228 @@ past-grace force path). - The maxRestarts→FAILED slot-free ordering and the NEVER→RETIRED terminal state in §4. - Daemon status must heartbeat-persist (~1s) in the loop, not only on fleet-size changes, or activity/STARTING→RUNNING never reach the store. +- **`wKernelRunner` is load-bearing — do not delete it.** It looks like a leftover (it is + a bare file in the repo root, shipped as composer `"bin"`), but it is the child side of + `dispatch()`: `Process::dispatch()` → `Thread` launcher → `php vendor/bin/wKernelRunner + --detach` → composer bin proxy → the project's `bootstrap.php` → + `WinterApplication::discoverAppClass()` → `::executor($argv)` → `bootstrap()` → + `AdaptiveRunner`. Detaching cannot be a fork (the parent may be a Swoole worker whose + reactor must not be duplicated), so a **fresh PHP process** must boot the app again. + Two traps: the runner resolves the project root as `dirname(__DIR__, 3)`, so it must be + **copied, never symlinked** (PHP resolves `__DIR__` through symlinks); and `WINTER_KEY` + must reach `$_ENV` (`env()` reads `$_ENV` only, never `getenv()`) or the child rejects + the signed payload. Covered by `tests/Process/Integration/DispatchRunnerTest.php` — + every other Process test forks directly and never executes the runner, which is why a + broken runner once went unnoticed. +- Both thread launchers spawn the same `php ` child; only the shell call + differs (`Coroutine\System::exec` inside a coroutine, `proc_open` elsewhere). There is + no `Swoole\Process` path — Swoole refuses one while its async-io threads are up. + +--- + +## 13. `ConnectionPool` — the "HikariCP-lite" layer + +### The problem it solves + +Under FPM every request got a **fresh** connection, so a database outage healed itself: +the process died, the next one reconnected. A long-lived Swoole worker keeps connections +in memory, and a plain `Swoole\ConnectionPool` is a **dumb channel** (`get`/`put`, zero +maintenance): after the DB comes back, the dead sockets are still in the pool and +`put($cdo)` returns each corpse for the next borrower. This layer restores FPM-level +self-healing without paying per-borrow. + +> **Do NOT "fix" this with a `SELECT 1` on every borrow.** That was tried and reverted — +> it adds a round-trip to every query and churns healthy connections. HikariCP does not +> do it either. The mechanism below is the idle-gate; keep it. + +### Location & shape + +| Path | What | +|---|---| +| `src/ConnectionPool/` | the generic, **self-contained** module (no PPA/CDO inside — it can be `git mv`d into its own package) | +| `src/Ppa/Pool/` | the PPA adapter that wires CDO into it | + +Module: `ConnectionPool` (coroutine, `Swoole\Coroutine\Channel`) · `SingleConnection` +(FPM/non-coroutine, **no** Channel) · `ConnectionFactory` (create/validate/close) · +`PoolEntry` · `PoolPolicy` · `PoolException`. Both take an injectable `Closure $clock` +— that is the seam that makes idle/lifetime logic unit-testable without a live DB. + +`Runtime::isSwooleCoroutine()` picks the path: coroutine → `ConnectionPool`, +everything else → `SingleConnection`. **`SingleConnection` is not coroutine-safe**; it +is only ever reached off the coroutine path, and that invariant is what keeps it simple. + +### `PoolPolicy` knobs + +| Knob | Default | Meaning | +|---|---|---| +| `maximumPoolSize` | 10 | upper bound | +| `connectionTimeout` | 15.0 | wait for a free connection, then fail fast | +| `maxLifetime` | 1800.0 | rotate by age (`0` = never); jittered by `maxLifetimeJitter` (0.1) | +| `aliveBypassWindow` | 0.5 | **idle-gate**: idle less than this ⇒ skip the probe | +| `housekeepingInterval` | 30.0 | background pass period (clamped to ≥1s) | +| `keepaliveTime` | 0.0 (off) | probe long-idle connections in the background | +| `idleTimeout` | 0.0 (off) | close idle connections down to `minimumIdle` | +| `minimumIdle` | 0 (lazy) | warm floor | + +The last three are **off by default**, and `housekeepingEnabled()` gates the timer — an +unconfigured pool never arms one. Housekeeping is **Swoole-only** (it needs a timer); +`SingleConnection` gets idle-gate + `maxLifetime` on `get()` and nothing else. + +### `aliveBypassWindow` — the point of the whole design + +Bigger window = *fewer* probes = **weaker** healing (the intuition runs backwards, so +read it twice). At 0.5s a hot connection reused within the window pays nothing, while a +connection that sat through an outage is always probed before it is handed out. Do not +raise the default to "reduce overhead" — hot connections already pay zero. + +### PPA wiring (`src/Ppa/Pool/`) + +- `CdoConnectionFactory` — pools the **config instance**, not the raw CDO (winter-cdo's + config owns the connection): `create` = `new $configClass` + `connect()`, + `validate` = `ping()`, `close` = `disconnect()`. +- `PpaConnectionPool` keeps the public API it always had (`db`/`getConfigDb`/ + `showDbConfigs`/`reset`) — repositories were not touched. +- Knobs are per config via `PpaPoolConfigInterface` + defaults in `PpaPoolTrait`, so a + config using the trait never breaks when knobs are added. **Add new knobs the same + way** (interface method + trait default), never as a bare interface method. + +### Evict on connection loss (no retry — deliberate) + +`ConnectionLoss` separates a **dead socket** (SQLSTATE class `08`, PG `57P01/02/03`, +MySQL driver codes 2006/2013/2055) from a **rejected query** (`23xxx`, `42xxx`, +deadlock) by walking the `previous` chain — CDO wraps the original `PDOException`. +Only the first evicts; churning the pool on every constraint violation would be a bug. + +`PpaConnectionPool::reportFailure($configClass, $e)` is called from the 12 catch blocks +that already existed in `RepositoryCrudTrait`/`RepositoryViewTrait`. On a loss it flips +`BorrowedConnection::$dead` (so the coroutine's `defer` evicts instead of releasing) and +drops the entry from the coroutine context, so the next query — including the next one +in the same request — borrows a fresh connection. + +**Never add a retry here.** The pool cannot know what ran: the break may have happened +*after* the server applied the write (replay ⇒ duplicate), and replaying one statement +of an interrupted transaction is meaningless. One request fails; the connection dies. + +### Observability + +`PpaConnectionPool::stats()` → per-config `{total, idle, active, maximum}`. Two consumers: + +1. **Actuator** — folded into the `db` component of `/actuator/health` (one entry per + datasource carrying both reachability and `pool`), not a separate component. +2. **`call db pool`** — reads per-worker records published by `PoolTelemetry` to + `Kernel::runnable('ppa.pool', false)`, the same store indirection `call process + status` uses (the CLI is a different process and can never see a worker's memory). + Interval via `PPA_POOL_TELEMETRY` (default 5s, `0` = off); records carry a TTL of + three intervals so a dead worker's record expires by itself; a worker holding no pool + writes nothing. + +Numbers are **per worker** (each has its own pool, like HikariCP per-JVM). Saturation is +therefore counted per worker, never derived from fleet sums — a summed pool can look +roomy while one worker is fully blocked. + +### Gotchas — already solved, don't reintroduce + +- **`reset()` must `abandon()` each pool.** A `Timer::tick` callback holds a reference to + its pool, so a merely dereferenced pool stays alive and keeps maintaining connections + the process no longer owns. `abandon()` clears the timer and drops references + **without closing sockets** — a forked child must never close an inherited fd. +- **Keepalive must not touch `lastUsedAt`.** It measures application idleness; resetting + it would make `idleTimeout` never fire. +- **`make()` reserves the slot before connecting** (`++$total` then `create()`, rollback + in `catch`) so concurrent borrows cannot over-provision past `maximumPoolSize`. +- **`PoolTelemetry` uses `Kernel::runnable($name, false)`** — non-hashed keys, because + the CLI enumerates records with `keys()` and feeds them back to `read()`; with hashing + those are HMACs and would be hashed a second time. +- SQLite: the pool runs but an embedded DB has no connection to lose; use + `poolMaxConnections: 1` (and `maxLifetime: 0` for `:memory:`, where every connection is + a *separate* database and rotation would destroy the data). + +### Tests + +`tests/ConnectionPool/` (module: pool, `SingleConnection`, housekeeper decisions via +reflection under a controllable clock) and `tests/Ppa/Pool/` (classifier, `reportFailure` +in a real coroutine, telemetry store round-trip, actuator merge). All deterministic, no +live DB. Live drivers: `tests/Integration/Pool/` under `#[Group('pool')]`, enabled by +`PG_TEST_DSN` / `MYSQL_TEST_DSN` / `MARIADB_TEST_DSN`. + +Design history and rationale: `doc-new/ppa-hikaricp-lite.md`. + +--- + +## 14. Project layout & static files + +### Layout + +``` +resources/ + static/ web assets — served by Swoole, see below + views/ view files — ResponseView's default root +storage/ logs, cache, runnable records +``` + +`views`, not `templates`, on purpose: inside {@see ResponseView} a *template* is the +**layout** (it receives `$content`) and a *resource* is the **page**. Both live under +the same root, so naming that root after either role would be wrong — `views` covers +both, with layouts conventionally under `views/layouts`. (Spring uses `templates/`, +but it has no such split, so the name does not collide there.) + +**There is no `public/`, and the kernel has no notion of one.** It existed for the FPM +document-root model — nginx needs a directory to aim at, and `index.php` must live +inside it so sources are not web-reachable. Swoole has no document root: the server +process decides what it serves. FPM is moving to a separate `winter-fpm` project, which +will own its own document root. + +`Kernel::init()` therefore takes `pathResource`, `pathStorage*` and no `pathPublic`. + +### Static files — Swoole serves them, the framework does not + +Opt-in, declared where the rest of the web config lives: + +```php +public function configureServer(ServerSettings $server, ApplicationArguments $args): void +{ + $server->port(8000) + ->staticPath('resources/static', ['/assets', '/favicon.ico']); +} +``` + +`staticPath()` resolves a relative path against `Kernel::$pathRoot`, **throws +`ApplicationConfigException` if the directory is missing** (a typo would otherwise be +silent 404s at runtime), and sets Swoole's `document_root` / `enable_static_handler` / +`static_handler_locations`. Say nothing and no file is ever served — which is what an +API-only service wants. + +The second argument is a whitelist of URI prefixes that are *considered* static. +Without it every file under the directory is downloadable and Swoole checks the +filesystem for every incoming request; with it, only those prefixes pay. + +Two consequences worth knowing: + +- **Static responses never reach PHP**, so middleware, CORS and request logging do not + apply to them. (No regression: the old PHP implementation also served before the + CORS block.) +- **One directory only.** `document_root` is a single value — a second `staticPath()` + call cannot mount a plugin's assets from another directory. If that is ever needed, + the answer is collecting them into the one root (a `call assets link`-style step), + not a second root. + +### Do not reimplement static serving in PHP — it was removed for cause + +`Router` used to do it (`static()`, `$publicDir`, a branch in `handle()`, +`serveStaticFile()`). All of it is gone. Verified against a live Swoole server before +removing: + +- **Path traversal.** `$file = $this->publicDir . $path` joined the *raw* `request_uri`; + Swoole does not canonicalise it. `GET /../../../etc/passwd` returned the file — + arbitrary read, bounded only by the worker's permissions. +- **Memory.** `serveStaticFile()` used `file_get_contents()`, so a 50 MB download meant + +50 MB RSS per concurrent request. Swoole streams instead. +- **Cost on every request.** `pathPublic` was always derived and always wired, so every + project paid an `is_file()` syscall on every GET — including those with no static + files at all. +- No `Range` (no seeking/resume), no `ETag`/`304`, `mime_content_type()` per request. + +Swoole's handler covers all of it in C, including refusing to escape `document_root` +(verified: the traversal requests above return 404 there). + +Design history and rationale: `doc-new/public-resources-layout.md`. diff --git a/dev/bootstrap.php b/dev/bootstrap.php index cb447ea..77c3cea 100644 --- a/dev/bootstrap.php +++ b/dev/bootstrap.php @@ -2,224 +2,40 @@ declare(strict_types=1); -use Flytachi\Winter\DI\Container; -use Flytachi\Winter\K2\App\Component; -use Flytachi\Winter\K2\Application; -use Flytachi\Winter\K2\Http\Cors; -use Flytachi\Winter\K2\Http\Health\Health; -use Flytachi\Winter\K2\Kernel; -use Flytachi\Winter\K2\Plugin; +use Flytachi\Winter\K2\App\Attribute\EnableActuator; +use Flytachi\Winter\K2\App\Attribute\EnableWeb; +use Flytachi\Winter\K2\WinterApplication; require __DIR__ . '/vendor/autoload.php'; /** - * Boot — application bootstrap class. + * The dev playground application. * - * Extend BaseBoot and override only the hooks you need. - * All hooks are optional — omit them to use framework defaults. + * This file is the whole bootstrap: load the autoloader, declare what the application + * contains. There are no configuration hooks to override any more — everything else is + * an ordinary class the scanner finds: * - * Boot order (called automatically by every entry point): - * 1. configure() — Kernel::init(), paths, .env, logging - * 2. DI scan — auto-discovers #[Singleton] / #[Request] / #[Transient] - * 3. providers() — service providers and manual bindings - * 4. channels() — custom log channels - * 5. plugins() — route-prefixed sub-applications - * 6. httpCors() — global CORS policy - * 7. health() — /actuator endpoints + * #[Configuration] / #[Bean] → DI factories + * WebConfigurer → host, port, Swoole tuning, CORS, static files + * LoggingConfigurer → extra log channels + * #[Import('pkg', '/prefix')] → plugin packages * * Entry points: - * Boot::run($argv) call — CLI + `call run` (Swoole, all components) - * Boot::web() public/index.php — FPM (web tier, per request) - * Boot::executor($argv) wKernelExecutor — thread / job runner + * php call → console (bare = help) + * php call run [dev] → bring the application up (Swoole; `dev` = watcher) + * php call daemon start [-d] | stop | status + * php call process start [-d] | stop | status */ -class Boot extends Application +#[EnableWeb] +#[EnableActuator] +// #[EnableAsync] // proxy #[Async] methods +// #[EnableScheduler] // run Main\Schedule\DemoTasks beside the server +// #[EnableProcess(\Main\Process\SendProc::class)] // a worker running beside the server +// #[EnableDaemon(\Main\Process\StableDaemon::class)] // a supervised fleet beside the server +final class Application extends WinterApplication { - /** - * Components — what this application is made of. - * - * `call run` / `call run dev` bring these up: the Http one becomes the Swoole - * server, the rest run beside it (addProcess). Remove Http to run headless. - * - * @return list - */ - protected static function components(): array + public static function main(array $argv): never { - return [ - Component::http(port: 8000), - // Component::process(\Main\KernelSys::class), - // Component::scheduler(), - ]; - } - - /** - * Kernel — paths, .env, logging, timezone. - * - * All parameters are optional; omitted ones are derived from $pathRoot. - * Logging is configured entirely via .env — see LOG_* variables below. - * - * Paths: - * pathRoot Project root (default: cwd) - * pathEnv .env file location (default: $pathRoot/.env) - * pathPublic Web-accessible directory (default: $pathRoot/public) - * pathResource View / template directory (default: $pathRoot/resources) - * pathStorage Writable storage root (default: $pathRoot/storage) - * pathStorageLog Log files directory (default: $pathStorage/logs) - * pathStorageCache Cache files directory (default: $pathStorage/cache) - * pathStorageRunnable Runnable task files (default: $pathStorage/runnable) - * - * .env logging variables: - * LOG_LEVEL=info Minimum severity: DEBUG|INFO|NOTICE|WARNING|ERROR|... - * Empty → logging disabled (NullLogger for all channels) - * LOG_FORMAT=line Output format: line | json - * LOG_OUTPUT=auto Destination: auto | stdout | stderr | syslog | file | null - * auto — always stdout (orchestrator/supervisor captures it) - * LOG_FILE= Absolute path when LOG_OUTPUT=file - * LOG_FILE_MAX=30 Number of daily rotating files to keep - * - * Per-channel overrides (LOG_{CHANNEL}_* takes priority over global): - * LOG_HTTP_LEVEL=warning - * LOG_HTTP_OUTPUT=file - * LOG_HTTP_FILE=/var/log/app/http.log - * LOG_CLI_OUTPUT=stderr - * LOG_SYS_OUTPUT=syslog - */ - protected static function configure(): void - { - Kernel::init(pathRoot: __DIR__); - } - - /** - * DI — service providers and manual bindings. - * - * Called after the Scanner auto-discovers #[Singleton] / #[Request] / #[Transient]. - * Use this hook to bind interfaces to implementations, register factories, - * or set named scalar values that cannot be expressed via attributes. - * - * Service providers (group related bindings): - * $c->register(AppServiceProvider::class); - * $c->register(DatabaseServiceProvider::class); - * - * Manual bindings: - * $c->singleton(CacheInterface::class, RedisCache::class); - * $c->request(AuthContext::class); - * $c->transient(QueryBuilder::class); - * $c->bind(MailerInterface::class, fn(Container $c) => - * new SmtpMailer(env('MAIL_HOST'), $c->make(LoggerInterface::class)) - * ); - * - * Named scalar values (inject via #[Inject('config.timeout')]): - * $c->set('config.timeout', (int) env('APP_TIMEOUT', 30)); - * $c->set('app.name', env('APP_NAME', 'Winter')); - */ - protected static function providers(Container $c): void - { - // $c->register(AppServiceProvider::class); - } - - /** - * Logging — additional channels beyond the built-in sys / http / cli. - * - * Each channel reads LOG_{NAME}_* env vars with the same fallback chain - * as the built-in channels. Channel name is lowercase by convention. - * - * Kernel::channel('job'); - * Kernel::channel('daemon'); - * - * Usage in application code: - * LoggerFactory::getLogger(MyJob::class, 'job')->info('started'); - * LoggerFactory::channel('daemon')->warning('slow tick'); - * - * .env for custom channels: - * LOG_JOB_LEVEL=debug - * LOG_JOB_OUTPUT=file - * LOG_JOB_FILE=/var/log/app/job.log - * LOG_JOB_FILE_MAX=7 - */ - protected static function channels(): void - { - // Kernel::channel('job'); - } - - /** - * CORS — global Cross-Origin policy. - * - * Applied to every response (including 404 / 500) before route dispatch. - * Per-route overrides are available via #[CrossOrigin] on controller class or method. - * - * Cors::configure( - * origins: ['https://app.example.com'], - * allowHeaders: ['Content-Type', 'Authorization', 'X-Request-Id'], - * exposeHeaders: ['X-Request-Id'], - * credentials: true, - * maxAge: 3600, - * ); - * - * Empty origins array → wildcard '*' (any origin allowed). - */ - protected static function httpCors(): void - { - // Cors::configure(); - } - - /** - * Health / Actuator — diagnostic endpoints under /actuator. - * - * Endpoints (GET): - * /actuator — full aggregated report - * /actuator/health — up | degraded | down - * /actuator/info — PHP version, SAPI, framework meta - * /actuator/metrics — CPU, memory, disk, opcache, uptime - * /actuator/env — custom env values - * /actuator/loggers — active channels and levels - * /actuator/mappings — registered route table - * - * Default (built-in indicator, open access): - * Health::configure(); - * - * Custom indicator + middleware guard: - * Health::configure( - * indicator: App\Health\AppHealthIndicator::class, - * middleware: App\Http\Middleware\InternalOnlyMiddleware::class, - * ); - */ - protected static function health(): void - { - // Health::configure(); - } - - /** - * Plugins — route-prefixed sub-applications. - * - * Each plugin's src/ directory is scanned for controllers automatically. - * No extra wiring required — routes are discovered on scan. - * - * Plugin::registry('acme/auth-plugin', '/auth'); - * Plugin::registry('acme/billing-plugin', '/billing'); - * - * Parameters: - * package Composer package name (e.g. 'acme/billing') - * prefix URL prefix (e.g. '/billing') - * required Throw if not installed (default: true) - */ - protected static function plugins(): void - { - // Plugin::registry('', ''); - } - - /** - * Swoole server settings passed to \Swoole\Http\Server::set(). - * - * Override to tune concurrency, request limits, and other Swoole options. - * Return an empty array to use Swoole defaults. - * - * return [ - * 'worker_num' => swoole_cpu_num() * 2, - * 'max_request' => 5000, - * 'max_request_grace' => 500, - * ]; - */ - public static function swooleConfig(): array - { - return []; + parent::run($argv); } } diff --git a/dev/call b/dev/call index 0a067b5..cdb96d6 100755 --- a/dev/call +++ b/dev/call @@ -8,7 +8,7 @@ If the requirement is not met the script exits with a human-readable message instead of a cryptic parse error. */ -if (PHP_VERSION_ID >= 80300) { +if (PHP_VERSION_ID >= 80400) { /* Working directory @@ -22,14 +22,15 @@ if (PHP_VERSION_ID >= 80300) { /* Bootstrap --------- - Initialises the framework (autoload, Kernel::init, optional CORS / Health / - Plugin config). See bootstrap.php for full parameter reference. + Loads the autoloader and declares the application class. Everything the app + contains is declared there with #[Enable*] attributes; the rest of the + configuration lives in scanned classes. See bootstrap.php. */ require './bootstrap.php'; - Boot::run($argv); + Application::main($argv); } else { - echo "\033[33m"." Please use PHP version 8.3 or higher.\n"; + echo "\033[33m"." Please use PHP version 8.4 or higher.\n"; echo "\033[34m"." Current PHP version => " . PHP_VERSION . "\n"; } diff --git a/dev/main/S1Job.php b/dev/main/S1Job.php deleted file mode 100644 index 8f00fbd..0000000 --- a/dev/main/S1Job.php +++ /dev/null @@ -1,24 +0,0 @@ -logger->info('RUN'); - $this->service->send(); - for ($i = 1; $i <= 10; $i++) { - sleep(1); - $this->logger->info('Iteration ' . $i); - } - $this->logger->info('END'); - } -} diff --git a/dev/public/.htaccess b/dev/public/.htaccess deleted file mode 100644 index 81e5b77..0000000 --- a/dev/public/.htaccess +++ /dev/null @@ -1,7 +0,0 @@ -Options +FollowSymLinks -MultiViews - -RewriteEngine On -RewriteCond %{REQUEST_FILENAME} !-f -RewriteCond %{REQUEST_FILENAME} !-d -RewriteRule .* index.php [L] -RewriteRule .* - [e=HTTP_AUTHORIZATION:%{HTTP:Authorization}] \ No newline at end of file diff --git a/dev/public/index.php b/dev/public/index.php deleted file mode 100644 index 822e5d9..0000000 --- a/dev/public/index.php +++ /dev/null @@ -1,49 +0,0 @@ -send() - l. Error handling ExceptionWrapper maps Throwable → HTTP response - - Route cache: - DEBUG=false — loads from storage/cache/mapping.php on warm boots; - scans and writes cache on first boot after deployment. - DEBUG=true — always rescans (dev mode, no stale routes). - - To inject per-request context fields into every log line: - $ctx = LoggerFactory::contextStorage(); - $ctx->set('request_id', uniqid('', true)); - $ctx->set('user_id', $authenticatedUserId); -*/ -Boot::web(); diff --git a/dev/public/static/winter/debug.css b/dev/public/static/winter/debug.css deleted file mode 100644 index 81f0072..0000000 --- a/dev/public/static/winter/debug.css +++ /dev/null @@ -1,107 +0,0 @@ -#winter_debug-btn { - font-size: 15px; - cursor: pointer; - background-color: #111; - color: red; - padding: 3px 9px; - border: none; - border-radius: 10%; - position: fixed; - bottom: 5px; - right: 5px; - z-index: 9999999; -} -#winter_debug-btn:hover { - background-color: red; - color: white; -} - -#winter_debug-bar { - display: none; - background-color: #111; - position: fixed; - bottom: 0; - right: 0; - left: 0; - height: 47%; - transition: 0.5s; - padding-top: 5px; - border-style: solid; - border-color: #c3fc04; - border-width: medium; - overflow-x: hidden; - z-index: 9999999; -} -#winter_debug-bar_body-indicator { - padding-top: 5px; - font-family: Georgia, serif; - font-size: 14px; - align-content: center; - align-items: center; - text-align: center; - color: #b2d7f6; -} - -#winter_debug-bar_body-accordion-container { - margin: 10px 0; -} -#winter_debug-bar_body-accordion-container .winter_debug-accordion-body { - width: calc(100% - 40px); - margin: 0 auto; - height: 0; - background-color: black; - line-height: 18px; - padding: 0 30px; - box-sizing: border-box; - transition: color 0.5s, padding 0.5s; - overflow: hidden; - font-family: Verdana, sans-serif; - font-size: 14px; - box-shadow: 0 4px 8px rgba(0,0,0,0.2), 0 10px 16px rgba(0,0,0,0.2); -} -#winter_debug-bar_body-accordion-container .winter_debug-accordion-body pre { - color: lime !important; - margin: 0 0 10px; - white-space: pre-wrap; - word-wrap: break-word; -} -#winter_debug-bar_body-accordion-container label { - cursor: pointer; - background-color: #082236; - display: block; - padding: 10px 20px; - width: 100%; - color: #BFE2FF; - font-weight: 300; - box-sizing: border-box; - z-index: 100; - font-family: Verdana, sans-serif; - font-size: 16px; - margin: 0 0 5px; - transition: color .35s; -} -#winter_debug-bar_body-accordion-container label:hover { - color: #FFF; -} -#winter_debug-bar_body-accordion-container input{ - display: none; -} -#winter_debug-bar_body-accordion-container label:before { - content: '\276F'; - float: right; -} -#winter_debug-bar_body-accordion-container input:checked + label { - background-color: #082236; - color: red; - box-shadow: 0 8px 26px rgba(0,0,0,0.4), 0 28px 30px rgba(0,0,0,0.3); -} -#winter_debug-bar_body-accordion-container input:checked + label:before { - transition: transform .35s; - transform: rotate(90deg); -} -#winter_debug-bar_body-accordion-container input:checked + label + .winter_debug-accordion-body { - height: auto; - margin-top: -5px; - color: lime !important; - padding: 20px 30px 10px; -} \ No newline at end of file diff --git a/dev/public/static/winter/debug.js b/dev/public/static/winter/debug.js deleted file mode 100644 index f40e028..0000000 --- a/dev/public/static/winter/debug.js +++ /dev/null @@ -1,22 +0,0 @@ -function WinterDebugBar() -{ - let bar = document.getElementById("winter_debug-bar"); - let btn = document.getElementById("winter_debug-btn"); - if (bar) { - if (bar.style.display !== "block") { - console.log('open debug panel'); - bar.style.display = "block"; - btn.style.bottom = "49%"; - btn.style.color = "white"; - btn.style.backgroundColor = "red"; - } else { - console.log('close debug panel'); - bar.style.display = "none"; - btn.style.bottom = "5px"; - btn.style.color = "red"; - btn.style.backgroundColor = "#111"; - } - } else { - alert("Debug блок не найден"); - } -} \ No newline at end of file diff --git a/dev/public/static/winter/logo.svg b/dev/public/static/winter/logo.svg deleted file mode 100644 index cdab276..0000000 --- a/dev/public/static/winter/logo.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/dev/wDevRunner b/dev/wDevRunner index c2d5f2e..3719616 100755 --- a/dev/wDevRunner +++ b/dev/wDevRunner @@ -3,18 +3,44 @@ declare(strict_types=1); -if (PHP_VERSION_ID < 80300) { - fwrite(STDERR, "Please use PHP version 8.3 or higher.\n"); +/* + wDevRunner — the playground's thread runner. + -------------------------------------------- + Same job as the kernel's `vendor/bin/wKernelRunner`: the child side of + Process::dispatch(). It exists separately here only because `dev/` is not a real + composer installation of the kernel (vendor/ holds a symlink to the repository), + so `.env` points WINTER_THREAD_RUNNER at this file instead. + + Detaching cannot be a fork, so the launcher spawns a fresh PHP process running + this script; the application is booted again from scratch before the staged + payload runs. +*/ + +use Flytachi\Winter\K2\WinterApplication; + +if (PHP_VERSION_ID < 80400) { + fwrite(STDERR, "Please use PHP version 8.4 or higher.\n"); exit(1); } -$fileAutoloader = __DIR__ . '/bootstrap.php'; +$bootstrap = __DIR__ . '/bootstrap.php'; -if (!file_exists($fileAutoloader)) { - fwrite(STDERR, "Error: bootstrap.php not found at {$fileAutoloader}\n"); +if (!file_exists($bootstrap)) { + fwrite(STDERR, "Error: bootstrap.php not found at {$bootstrap}\n"); exit(1); } -require_once $fileAutoloader; +require_once $bootstrap; + +set_time_limit(0); +ob_implicit_flush(); +ignore_user_abort(true); + +try { + $appClass = WinterApplication::discoverAppClass(); +} catch (Throwable $e) { + fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n"); + exit(1); +} -Boot::executor($argv); +$appClass::executor($argv); diff --git a/src/Http/Response/ExceptionResponseBase.php b/src/Http/Response/ExceptionResponseBase.php index d649a2b..f984c10 100644 --- a/src/Http/Response/ExceptionResponseBase.php +++ b/src/Http/Response/ExceptionResponseBase.php @@ -87,17 +87,18 @@ protected function contentHtml(): string $httpMessage = $this->httpCode->message(); $message = htmlspecialchars($this->throwable->getMessage(), ENT_QUOTES); + $logo = self::logo(); + return << - {$code} {$httpMessage}
-
logotype
+
{$logo}
Winter {$code} — {$httpMessage}

{$message}

@@ -106,6 +107,29 @@ protected function contentHtml(): string HTML; } + /** + * The mark, inlined rather than linked. + * + * This page is what a visitor sees when the application is already failing, so it + * must not depend on the application being configured correctly: static serving is + * opt-in, and a linked asset would simply 404. Inlining the element (rather than a + * `data:` URI) also keeps it working under a strict `img-src` policy. + */ + private static function logo(): string + { + return <<<'SVG' + + + + + + + + + + SVG; + } + final protected function validationRequests(): array { if ($this->throwable instanceof ValidationException) { diff --git a/src/Http/Response/RenderContext.php b/src/Http/Response/RenderContext.php index ae75721..6ee6cd3 100644 --- a/src/Http/Response/RenderContext.php +++ b/src/Http/Response/RenderContext.php @@ -15,26 +15,13 @@ * Lifecycle (managed by ResponseView::renderContent()): * RenderContext::push(...) — before rendering begins * RenderContext::current() — inside any template or partial - * RenderContext::pop() — after debug output is appended (via finally) - * - * Debug meta (only when DEBUG=true, set by Router before send()): - * RenderContext::setMeta($class, $method) - * RenderContext::setRoutes($routes) + * RenderContext::pop() — after rendering finishes (via finally) */ final class RenderContext { // ── Static stack (FPM) ──────────────────────────────────────────────────── private static array $stack = []; - // ── Pending debug meta (Router → push, consumed once) ──────────────────── - private static ?string $pendingController = null; - private static ?string $pendingMethod = null; - private static array $pendingRoutes = []; - - // ── Instance fields ─────────────────────────────────────────────────────── - private ?string $controllerClass; - private ?string $controllerMethod; - private array $routes = []; private array $resourceAdditional = []; private function __construct( @@ -43,57 +30,6 @@ private function __construct( private readonly ?string $templateName, private readonly string $resourceName, ) { - [$this->controllerClass, $this->controllerMethod] = self::consumeMeta(); - $this->routes = self::consumeRoutes(); - } - - // ── Debug meta (called by Router, only when DEBUG=true) ─────────────────── - - public static function setMeta(string $controllerClass, string $controllerMethod): void - { - if (Runtime::isSwooleCoroutine()) { - \Swoole\Coroutine::getContext()['__render_meta'] = [$controllerClass, $controllerMethod]; - } else { - self::$pendingController = $controllerClass; - self::$pendingMethod = $controllerMethod; - } - } - - /** @param list $routes */ - public static function setRoutes(array $routes): void - { - if (Runtime::isSwooleCoroutine()) { - \Swoole\Coroutine::getContext()['__render_routes'] = $routes; - } else { - self::$pendingRoutes = $routes; - } - } - - private static function consumeMeta(): array - { - if (Runtime::isSwooleCoroutine()) { - $co = \Swoole\Coroutine::getContext(); - $meta = $co['__render_meta'] ?? [null, null]; - unset($co['__render_meta']); - return $meta; - } - $meta = [self::$pendingController, self::$pendingMethod]; - self::$pendingController = null; - self::$pendingMethod = null; - return $meta; - } - - private static function consumeRoutes(): array - { - if (Runtime::isSwooleCoroutine()) { - $co = \Swoole\Coroutine::getContext(); - $routes = $co['__render_routes'] ?? []; - unset($co['__render_routes']); - return $routes; - } - $routes = self::$pendingRoutes; - self::$pendingRoutes = []; - return $routes; } // ── Lifecycle ───────────────────────────────────────────────────────────── @@ -184,155 +120,4 @@ public function isActiveLink( return $uri === $link ? $classNameSuccess : $classNameNone; } - - // ── Debug panel ─────────────────────────────────────────────────────────── - - public function debugger(): string - { - if (!env('DEBUG', false)) { - return ''; - } - - if (Runtime::isSwooleCoroutine()) { - $start = \Swoole\Coroutine::getContext()['__request_start'] ?? microtime(true); - } else { - $start = defined('WINTER_STARTUP_TIME') ? WINTER_STARTUP_TIME : microtime(true); - } - $delta = max(round(microtime(true) - $start, 3), 0.001); - $memory = function_exists('bytes') - ? bytes(memory_get_usage(), 'MiB') - : round(memory_get_usage() / 1048576, 2) . ' MiB'; - - if (Runtime::isSwooleCoroutine()) { - $ctx = \Swoole\Coroutine::getContext(); - $method = htmlspecialchars($ctx['__request_method'] ?? 'CLI', ENT_QUOTES); - $uri = htmlspecialchars($ctx['__request_uri'] ?? '/', ENT_QUOTES); - } else { - $method = htmlspecialchars($_SERVER['REQUEST_METHOD'] ?? 'CLI', ENT_QUOTES); - $uri = htmlspecialchars($_SERVER['REQUEST_URI'] ?? '/', ENT_QUOTES); - } - - $templateDisplay = $this->templateName - ? str_replace($this->basePath, '', $this->basePath . '/' . $this->templateName . '.php') - : null; - $resourceDisplay = str_replace($this->basePath, '', $this->basePath . '/' . $this->resourceName . '.php'); - $additionalDisplay = array_map( - fn($p) => str_replace($this->basePath, '', $p), - $this->resourceAdditional - ); - - $general = $this->esc(print_r([ - 'sapi' => PHP_SAPI, - 'runtime' => Runtime::mode()->name, - 'timezone' => date_default_timezone_get(), - 'date' => date(DATE_ATOM), - 'controllerClass' => $this->controllerClass, - 'controllerClassMethod' => $this->controllerMethod, - 'template' => $templateDisplay, - 'resource' => $resourceDisplay, - 'resourceAdditional' => $additionalDisplay, - 'resourceData' => $this->data, - ], true)); - - $routingRows = ''; - foreach ($this->routes as $r) { - $m = htmlspecialchars($r['method'], ENT_QUOTES); - $p = htmlspecialchars($r['path'], ENT_QUOTES); - $h = htmlspecialchars($r['handler'], ENT_QUOTES); - $color = self::methodColor($r['method']); - $routingRows .= << - - $m - - $p - $h - - HTML; - } - - $routingTable = $routingRows !== '' - ? << - - - - - - - - - $routingRows -
MethodPathHandler
- - HTML - : '
No routes registered
'; - - $globals = ''; - foreach ($GLOBALS as $name => $info) { - if (empty($info)) { - continue; - } - $safeName = htmlspecialchars(ltrim($name, '_'), ENT_QUOTES); - $safeContent = $this->esc(print_r($info, true)); - $globals .= << - -
-
$safeContent
-
- HTML; - } - - return << - - - -
-
- [$method] - $uri - Memory: $memory  |  - Time: $delta sec -
-
- - - -
-
$general
-
- - - -
- $routingTable -
- -
- $globals -
-
- HTML; - } - - private static function methodColor(string $method): string - { - return match (strtoupper($method)) { - 'GET' => '#28a745', - 'POST' => '#007bff', - 'PUT' => '#fd7e14', - 'PATCH' => '#6f42c1', - 'DELETE' => '#dc3545', - 'OPTIONS' => '#6c757d', - default => '#343a40', - }; - } - - private function esc(string $value): string - { - return htmlspecialchars($value, ENT_QUOTES); - } } diff --git a/src/Http/Response/ResponseView.php b/src/Http/Response/ResponseView.php index a27917c..6c0d258 100644 --- a/src/Http/Response/ResponseView.php +++ b/src/Http/Response/ResponseView.php @@ -134,7 +134,7 @@ private function renderContent(): string ? $this->capture($this->templatePath(), $this->data) : $resource; - return $html . RenderContext::current()?->debugger(); + return $html; } finally { RenderContext::pop(); } diff --git a/src/Route/Router.php b/src/Route/Router.php index b9e49b5..a6b52c3 100644 --- a/src/Route/Router.php +++ b/src/Route/Router.php @@ -20,7 +20,6 @@ use Flytachi\Winter\K2\Http\Response\Collector\ExceptionCollector; use Flytachi\Winter\K2\Localization\Locale; use Flytachi\Winter\K2\Http\Response\ExceptionWrapper; -use Flytachi\Winter\K2\Http\Response\RenderContext; use Flytachi\Winter\K2\Http\Response\ResponseEntity; use Flytachi\Winter\K2\Http\Response\ResponseException; use Flytachi\Winter\K2\Http\Response\Sendable; @@ -414,7 +413,6 @@ public function handle(HttpRequest $request, HttpResponse $response): void LoggerFactory::getLogger(self::class)->debug( $request->getClientIp() . " -- $method " . $request->getUri() ); - RenderContext::setRoutes($this->getRoutesSummary()); } // ── Global CORS applied eagerly (covers 404, 405, and errors too) ─ @@ -481,9 +479,6 @@ private function invoke(mixed $stored, HttpRequest $req, HttpResponse $res, arra $object = Container::getInstance()->make($class); $refMethod = ReflectionCache::method($class, $methodName); $args = ParameterResolver::resolve($refMethod, $req, $res, $params); - if (env('DEBUG', false)) { - RenderContext::setMeta($class, $methodName); - } $result = $refMethod->invokeArgs($object, $args); } else { $result = ($handler)($req, $res, $params); From ebb230b15d1c83b0934365106bed4f5ebec9dd47 Mon Sep 17 00:00:00 2001 From: flytachi Date: Sat, 1 Aug 2026 23:19:22 +0500 Subject: [PATCH 46/71] tests --- CLAUDE.md | 30 +++++++++++++++++++++--------- src/App/Config/ServerSettings.php | 21 ++++++++++++--------- src/Ppa/Pool/PpaConnectionPool.php | 23 +++++++++++++++++++++++ src/WinterApplication.php | 11 +++++++++++ tests/App/StaticPathTest.php | 24 +++++++++++++----------- 5 files changed, 80 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8a2d682..759cbe5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -580,22 +580,21 @@ Opt-in, declared where the rest of the web config lives: public function configureServer(ServerSettings $server, ApplicationArguments $args): void { $server->port(8000) - ->staticPath('resources/static', ['/assets', '/favicon.ico']); + ->staticPath('resources/static'); // resources/static/app.css → /app.css } ``` `staticPath()` resolves a relative path against `Kernel::$pathRoot`, **throws `ApplicationConfigException` if the directory is missing** (a typo would otherwise be -silent 404s at runtime), and sets Swoole's `document_root` / `enable_static_handler` / -`static_handler_locations`. Say nothing and no file is ever served — which is what an -API-only service wants. +silent 404s at runtime), and sets Swoole's `document_root` + `enable_static_handler`. +Say nothing and no file is ever served — which is what an API-only service wants. -The second argument is a whitelist of URI prefixes that are *considered* static. -Without it every file under the directory is downloadable and Swoole checks the -filesystem for every incoming request; with it, only those prefixes pay. - -Two consequences worth knowing: +Three consequences worth knowing: +- **The directory is the URL root.** Swoole appends the whole request path to it, so + the layout on disk mirrors the layout in URLs. Point it at a directory holding assets + and nothing else: everything under it becomes downloadable. (Pointing it at + `resources` would expose `resources/views` — executable PHP.) - **Static responses never reach PHP**, so middleware, CORS and request logging do not apply to them. (No regression: the old PHP implementation also served before the CORS block.) @@ -604,6 +603,19 @@ Two consequences worth knowing: the answer is collecting them into the one root (a `call assets link`-style step), not a second root. +Swoole checks the filesystem on each request to decide whether it is a static one. +Narrowing that to certain prefixes is a tuning knob rather than part of the API — +`->set('static_handler_locations', ['/assets'])` when a profile says it matters. + +### The framework ships no assets + +Its pages are self-contained: the error page inlines its `` mark rather than +linking one. That is deliberate — the error page is what a visitor sees when the +application is already failing, so it must not depend on the application being +configured correctly. **Do not reintroduce a `/static/...` URL into kernel output**: +static serving is opt-in, so a linked asset simply 404s in a project that never +enabled it. + ### Do not reimplement static serving in PHP — it was removed for cause `Router` used to do it (`static()`, `$publicDir`, a branch in `handle()`, diff --git a/src/App/Config/ServerSettings.php b/src/App/Config/ServerSettings.php index a4f2582..67ae25e 100644 --- a/src/App/Config/ServerSettings.php +++ b/src/App/Config/ServerSettings.php @@ -106,21 +106,26 @@ public function maxRequestGrace(int $count): self * `Range`, and cannot be walked out of the directory with `..`. * * ``` - * $server->staticPath('resources/static', ['/assets', '/favicon.ico']); + * $server->staticPath('resources/static'); // resources/static/app.css → /app.css * ``` * + * The directory *is* the URL root: Swoole appends the whole request path to it, so + * the layout on disk mirrors the layout in URLs. Point it at a directory that holds + * assets and nothing else — every file under it becomes downloadable. + * * Because those requests never reach PHP, middleware, CORS and request logging do * not apply to them. * + * Swoole checks the filesystem for each request to decide whether it is a static + * one. To limit that to certain prefixes, set the underlying option directly: + * `->set('static_handler_locations', ['/assets'])`. + * * @param string $path Directory to serve from; relative paths resolve against the * project root. - * @param list $locations URI prefixes that are even considered static. - * Leaving it empty exposes **every** file under `$path` and makes Swoole check - * the filesystem for each incoming request, so naming the prefixes is worth it. * @throws ApplicationConfigException When the directory does not exist — a typo * here would otherwise surface as silent 404s at runtime. */ - public function staticPath(string $path, array $locations = []): self + public function staticPath(string $path): self { $dir = str_starts_with($path, '/') ? $path @@ -131,10 +136,8 @@ public function staticPath(string $path, array $locations = []): self throw new ApplicationConfigException("Static directory does not exist: {$dir}"); } - $this->set('document_root', $dir); - $this->set('enable_static_handler', true); - - return $locations === [] ? $this : $this->set('static_handler_locations', array_values($locations)); + return $this->set('document_root', $dir) + ->set('enable_static_handler', true); } /** Set any raw Swoole option. */ diff --git a/src/Ppa/Pool/PpaConnectionPool.php b/src/Ppa/Pool/PpaConnectionPool.php index ac9ed0f..3addfe6 100644 --- a/src/Ppa/Pool/PpaConnectionPool.php +++ b/src/Ppa/Pool/PpaConnectionPool.php @@ -238,6 +238,29 @@ public static function stats(): array * Keep connections lazy (do not query from a supervisor before it forks * workers) so this stays a cheap no-op in the common case. */ + /** + * Closes every pool and connection this process owns — the worker-shutdown + * counterpart of {@see reset()}. + * + * The difference matters. {@see reset()} is for a **forked child**, which must + * forget inherited sockets without closing them. Here the process genuinely owns + * them, so they are closed properly; just as importantly, closing a pool releases + * its housekeeping timer, and a live timer would keep the worker's reactor from + * draining until Swoole force-kills it. + */ + public static function shutdown(): void + { + foreach (self::$pools as $pool) { + $pool->close(); + } + foreach (self::$static as $connection) { + $connection->close(); + } + self::$pools = []; + self::$static = []; + self::$configs = []; + } + public static function reset(): void { // Abandon (never close) each pool first: a housekeeping Timer::tick callback diff --git a/src/WinterApplication.php b/src/WinterApplication.php index 09eba1c..de64235 100644 --- a/src/WinterApplication.php +++ b/src/WinterApplication.php @@ -37,6 +37,7 @@ use Flytachi\Winter\K2\Http\Adapter\SwooleRequest; use Flytachi\Winter\K2\Http\Adapter\SwooleResponse; use Flytachi\Winter\K2\Ppa\Pool\PoolTelemetry; +use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; use Flytachi\Winter\K2\Process\ForkReset; use Flytachi\Winter\K2\Route\DevWatcher; use Flytachi\Winter\K2\Route\Router; @@ -459,6 +460,15 @@ static function () use ($class): void { PoolTelemetry::start($workerId); }; + // A worker cannot leave while its reactor still holds a repeating timer, so a + // shutdown would hang until Swoole force-kills it ("worker exit timeout"). + // `workerExit` fires exactly while the reactor is trying to drain, which is + // where those timers have to be released. + $workerExit = static function (\Swoole\Http\Server $server, int $workerId): void { + PoolTelemetry::stop($workerId); + PpaConnectionPool::shutdown(); + }; + $dev = $watch ? new DevWatcher([Kernel::$pathRoot]) : null; if ($dev !== null) { $dev->attach($server, $workerStart); @@ -467,6 +477,7 @@ static function () use ($class): void { $server->on('workerStart', $workerStart); $server->on('request', $handler); } + $server->on('workerExit', $workerExit); if (Banner::isEnabled($args)) { Banner::print(static::bannerRows($companions, $host, $port), self::elapsedMs()); diff --git a/tests/App/StaticPathTest.php b/tests/App/StaticPathTest.php index 601e2b0..bb8a09f 100644 --- a/tests/App/StaticPathTest.php +++ b/tests/App/StaticPathTest.php @@ -78,26 +78,28 @@ public function test_trailing_slashes_are_trimmed(): void self::assertSame($this->root . '/resources/static', $options['document_root']); } - public function test_locations_restrict_which_prefixes_are_treated_as_static(): void - { - $options = $this->settings() - ->staticPath('resources/static', ['/assets', '/favicon.ico']) - ->toArray(); - - self::assertSame(['/assets', '/favicon.ico'], $options['static_handler_locations']); - } - - public function test_no_locations_means_no_restriction(): void + public function test_it_does_not_impose_a_prefix_filter(): void { $options = $this->settings()->staticPath('resources/static')->toArray(); self::assertArrayNotHasKey( 'static_handler_locations', $options, - 'omitting the list exposes the whole directory — Swoole checks every request', + 'the directory is the URL root; narrowing it is a tuning knob, set() covers it', ); } + public function test_the_prefix_filter_remains_reachable_through_set(): void + { + $options = $this->settings() + ->staticPath('resources/static') + ->set('static_handler_locations', ['/assets']) + ->toArray(); + + self::assertSame(['/assets'], $options['static_handler_locations']); + self::assertSame($this->root . '/resources/static', $options['document_root']); + } + public function test_a_missing_directory_fails_the_boot(): void { $this->expectException(ApplicationConfigException::class); From bad8470cd7c78c84be5e79c9bb41010fdc9aed8c Mon Sep 17 00:00:00 2001 From: flytachi Date: Sat, 1 Aug 2026 23:39:40 +0500 Subject: [PATCH 47/71] pool fix - grace-down final --- src/Ppa/Pool/PoolTelemetry.php | 14 ++- tests/Route/GracefulShutdownTest.php | 82 ++++++++++++++ tests/Route/ServeHttpTest.php | 159 +++------------------------ 3 files changed, 109 insertions(+), 146 deletions(-) create mode 100644 tests/Route/GracefulShutdownTest.php diff --git a/src/Ppa/Pool/PoolTelemetry.php b/src/Ppa/Pool/PoolTelemetry.php index a5fcc7d..3aa94ef 100644 --- a/src/Ppa/Pool/PoolTelemetry.php +++ b/src/Ppa/Pool/PoolTelemetry.php @@ -74,12 +74,16 @@ public static function start(int $workerId): void /** Stops publishing and drops this worker's record. */ public static function stop(int $workerId): void { - if (self::$timerId !== null) { - if (extension_loaded('swoole')) { - \Swoole\Timer::clear(self::$timerId); - } - self::$timerId = null; + if (self::$timerId === null) { + // Never published, so there is no record to drop — and asking for the store + // would create its directory in an application that has no pool at all. + return; + } + + if (extension_loaded('swoole')) { + \Swoole\Timer::clear(self::$timerId); } + self::$timerId = null; try { self::store()->del(self::recordKey($workerId)); diff --git a/tests/Route/GracefulShutdownTest.php b/tests/Route/GracefulShutdownTest.php new file mode 100644 index 0000000..c603d65 --- /dev/null +++ b/tests/Route/GracefulShutdownTest.php @@ -0,0 +1,82 @@ +server = new ServerProcess(); + if (!$this->server->start()) { + $log = $this->server->log(); + $this->server->stop(); + $this->server = null; + self::markTestSkipped("the server did not come up in time:\n" . substr($log, 0, 600)); + } + } + + protected function tearDown(): void + { + $this->server?->stop(); + $this->server = null; + } + + public function test_sigterm_stops_the_server_without_a_forced_kill(): void + { + // Serve something first: a worker that has handled a request is the one whose + // timers and pools are actually live. + self::assertSame(200, $this->server->request('GET', '/demo/ping')['status']); + + $this->server->signal(SIGTERM); + + self::assertTrue( + $this->server->awaitExit(self::EXIT_BUDGET), + "the server was still running " . self::EXIT_BUDGET . "s after SIGTERM:\n" . $this->server->log(), + ); + + $log = $this->server->log(); + foreach (['9101', 'exit timeout', 'forced termination'] as $symptom) { + self::assertStringNotContainsStringIgnoringCase( + $symptom, + $log, + "the reactor could not drain — something in the worker still holds a timer:\n" . $log, + ); + } + } + + public function test_it_stops_cleanly_even_without_serving_anything(): void + { + // The timers are armed at workerStart, so an idle worker has to release them too. + $this->server->signal(SIGTERM); + + self::assertTrue($this->server->awaitExit(self::EXIT_BUDGET), $this->server->log()); + self::assertStringNotContainsStringIgnoringCase('9101', $this->server->log()); + } +} diff --git a/tests/Route/ServeHttpTest.php b/tests/Route/ServeHttpTest.php index fed8aee..d7307c0 100644 --- a/tests/Route/ServeHttpTest.php +++ b/tests/Route/ServeHttpTest.php @@ -4,6 +4,7 @@ namespace Flytachi\Winter\K2\Tests\Route; +use Flytachi\Winter\K2\Tests\Route\Fixtures\ServerProcess; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -22,11 +23,7 @@ #[Group('integration')] final class ServeHttpTest extends TestCase { - private static int $port = 0; - private static int $pid = 0; - private static string $storage = ''; - private static string $runner = ''; - private static string $log = ''; + private static ?ServerProcess $server = null; public static function setUpBeforeClass(): void { @@ -34,56 +31,24 @@ public static function setUpBeforeClass(): void self::markTestSkipped('serving needs the Swoole extension.'); } - self::$port = self::freePort(); - self::$storage = sys_get_temp_dir() . '/wk_serve_' . getmypid() . '_' . bin2hex(random_bytes(4)); - self::$runner = self::$storage . '.php'; - self::$log = self::$storage . '.log'; - - // The entry a project would write by hand: load the autoloader, run the app. - $autoload = dirname(__DIR__, 2) . '/vendor/autoload.php'; - file_put_contents(self::$runner, sprintf( - "> %s 2>&1 & echo $!', - escapeshellarg(self::$storage), - escapeshellarg(PHP_BINARY), - escapeshellarg(self::$runner), - escapeshellarg(self::$log), - ); - self::$pid = (int) trim((string) shell_exec($command)); - - if (!self::awaitReady(15.0)) { - $log = (string) @file_get_contents(self::$log); - self::stopServer(); + self::$server = new ServerProcess(); + if (!self::$server->start()) { + $log = self::$server->log(); + self::$server->stop(); + self::$server = null; self::markTestSkipped("the server did not come up in time:\n" . substr($log, 0, 600)); } } public static function tearDownAfterClass(): void { - self::stopServer(); - - @unlink(self::$runner); - @unlink(self::$log); - foreach (glob(self::$storage . '/*/*') ?: [] as $file) { - @unlink($file); - } - foreach (glob(self::$storage . '/*') ?: [] as $dir) { - is_dir($dir) ? @rmdir($dir) : @unlink($dir); - } - @rmdir(self::$storage); + self::$server?->stop(); + self::$server = null; } - // ── The requests ─────────────────────────────────────────────────────────── - public function test_it_answers_a_simple_route(): void { - $response = $this->request('GET', '/demo/ping'); + $response = self::$server->request('GET', '/demo/ping'); self::assertSame(200, $response['status']); self::assertSame('pong', $response['body']); @@ -91,7 +56,7 @@ public function test_it_answers_a_simple_route(): void public function test_a_path_variable_survives_the_wire(): void { - $response = $this->request('GET', '/demo/hello/winter'); + $response = self::$server->request('GET', '/demo/hello/winter'); self::assertSame(200, $response['status']); self::assertSame(['message' => 'hello winter'], json_decode($response['body'], true)); @@ -99,14 +64,14 @@ public function test_a_path_variable_survives_the_wire(): void public function test_a_query_string_is_parsed_and_cast(): void { - $response = $this->request('GET', '/demo/search?q=snow&limit=3'); + $response = self::$server->request('GET', '/demo/search?q=snow&limit=3'); self::assertSame(['q' => 'snow', 'limit' => 3], json_decode($response['body'], true)); } public function test_a_post_route_is_reachable(): void { - $response = $this->request('POST', '/demo/items'); + $response = self::$server->request('POST', '/demo/items'); self::assertSame(200, $response['status']); self::assertSame(['created' => true], json_decode($response['body'], true)); @@ -114,112 +79,24 @@ public function test_a_post_route_is_reachable(): void public function test_an_unknown_path_returns_404_over_http(): void { - $response = $this->request('GET', '/definitely-not-here'); - - self::assertSame(404, $response['status']); + self::assertSame(404, self::$server->request('GET', '/definitely-not-here')['status']); } public function test_the_wrong_method_returns_405_with_allow(): void { - $response = $this->request('GET', '/demo/items'); + $response = self::$server->request('GET', '/demo/items'); self::assertSame(405, $response['status']); - self::assertStringContainsString('POST', self::headerOf($response['headers'], 'Allow')); + self::assertStringContainsString('POST', ServerProcess::headerOf($response['headers'], 'Allow')); } public function test_json_responses_carry_a_json_content_type(): void { - $response = $this->request('GET', '/demo/hello/winter'); + $response = self::$server->request('GET', '/demo/hello/winter'); self::assertStringContainsString( 'application/json', - strtolower(self::headerOf($response['headers'], 'Content-Type')), + strtolower(ServerProcess::headerOf($response['headers'], 'Content-Type')), ); } - - // ── Plumbing ─────────────────────────────────────────────────────────────── - - /** @return array{status: int, body: string, headers: list} */ - private function request(string $method, string $path): array - { - $context = stream_context_create(['http' => [ - 'method' => $method, - 'timeout' => 5, - 'ignore_errors' => true, // 4xx/5xx must come back as a response, not a warning - ]]); - - $body = @file_get_contents(self::url($path), false, $context); - $headers = $http_response_header ?? []; - - return [ - 'status' => self::statusOf($headers), - 'body' => $body === false ? '' : $body, - 'headers' => $headers, - ]; - } - - private static function url(string $path): string - { - return 'http://127.0.0.1:' . self::$port . $path; - } - - /** @param list $headers */ - private static function statusOf(array $headers): int - { - foreach ($headers as $line) { - if (preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $m) === 1) { - return (int) $m[1]; - } - } - - return 0; - } - - /** @param list $headers */ - private static function headerOf(array $headers, string $name): string - { - foreach ($headers as $line) { - $parts = explode(':', $line, 2); - if (count($parts) === 2 && strcasecmp(trim($parts[0]), $name) === 0) { - return trim($parts[1]); - } - } - - return ''; - } - - private static function awaitReady(float $timeout): bool - { - $deadline = microtime(true) + $timeout; - while (microtime(true) < $deadline) { - $socket = @fsockopen('127.0.0.1', self::$port, $errno, $errstr, 0.3); - if ($socket !== false) { - fclose($socket); - return true; - } - usleep(200_000); - } - - return false; - } - - private static function stopServer(): void - { - if (self::$pid > 0) { - @exec(sprintf('kill -TERM %d 2>/dev/null', self::$pid)); - usleep(500_000); - @exec(sprintf('kill -KILL %d 2>/dev/null', self::$pid)); - self::$pid = 0; - } - } - - /** Asks the OS for an unused port, so parallel runs do not collide. */ - private static function freePort(): int - { - $socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); - $name = stream_socket_get_name($socket, false); - fclose($socket); - - return (int) substr((string) $name, strrpos((string) $name, ':') + 1); - } } From 33a807ff9c507bf9c225cb340ca1a40b613c7be9 Mon Sep 17 00:00:00 2001 From: flytachi Date: Sun, 2 Aug 2026 00:39:39 +0500 Subject: [PATCH 48/71] zz --- CLAUDE.md | 101 ++++- README.md | 203 +++++++++ dev/main/WebConfig.php | 24 ++ dev/resources/static/winter/logo.svg | 9 + docs/architecture/01-routing.md | 11 +- docs/concurrent/00-overview.md | 2 +- docs/concurrent/03-async.md | 2 +- docs/configuration/01-kernel.md | 180 ++++---- docs/configuration/02-logging.md | 6 +- docs/configuration/03-cors.md | 48 ++- docs/configuration/04-health.md | 48 ++- docs/configuration/05-plugins.md | 26 +- docs/configuration/07-di.md | 11 +- docs/configuration/08-runtime.md | 206 ++++----- docs/console/00-overview.md | 7 +- docs/console/04-run.md | 13 +- docs/console/06-db.md | 33 ++ docs/console/07-mapping.md | 6 +- docs/console/09-thread.md | 253 ----------- docs/console/10-di.md | 4 +- docs/console/11-complete.md | 2 +- docs/ppa/17-pool.md | 185 +++++--- docs/process/daemon/01-workers.md | 2 +- docs/starter.md | 455 -------------------- docs/starter/00-quickstart.md | 465 +++++++------------- docs/threads/00-overview.md | 263 ------------ docs/threads/01-job.md | 159 ------- docs/threads/02-process.md | 243 ----------- docs/threads/03-daemon.md | 349 --------------- docs/threads/04-websocket.md | 307 ------------- docs/winter-application-redesign.md | 572 ------------------------- tests/Route/Fixtures/ServerProcess.php | 192 +++++++++ 32 files changed, 1121 insertions(+), 3266 deletions(-) create mode 100644 dev/main/WebConfig.php create mode 100644 dev/resources/static/winter/logo.svg delete mode 100644 docs/console/09-thread.md delete mode 100644 docs/starter.md delete mode 100644 docs/threads/00-overview.md delete mode 100644 docs/threads/01-job.md delete mode 100644 docs/threads/02-process.md delete mode 100644 docs/threads/03-daemon.md delete mode 100644 docs/threads/04-websocket.md delete mode 100644 docs/winter-application-redesign.md create mode 100644 tests/Route/Fixtures/ServerProcess.php diff --git a/CLAUDE.md b/CLAUDE.md index 759cbe5..c32e38b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,9 +1,10 @@ # CLAUDE.md — winter-kernel: Process/Daemon + ConnectionPool handoff This file orients you (Claude) to the work done on `winter-kernel`: the -**Process/Daemon** layer (§1–§12), the **ConnectionPool** layer (§13), and the -**project layout / static files** (§14). Read the relevant part fully before -touching it. It describes what was built, how it works, why, and the rules to keep. +**Process/Daemon** layer (§1–§12), the **ConnectionPool** layer (§13), the +**project layout / static files** (§14), and the **`WinterApplication` starter** (§15). +Read the relevant part fully before touching it. It describes what was built, how it +works, why, and the rules to keep. > **winter-kernel** is a PHP 8.4+ framework kernel (a library, not an app). It runs > under two runtimes: **Swoole** (coroutines) and **FPM/CLI** (plain processes). @@ -358,10 +359,18 @@ Dev demos (runnable): `dev/main/Process/*.php` (StableDaemon, CrashDaemon, Fleet ## 10. Docs -`docs/process/` (`00-overview`, `01-lifecycle`, `02-concurrency`, `03-control`) and -`docs/process/daemon/` (`00-overview`, `01-workers`, `02-autoscaling`, `03-control`). -Mature, behaviour-focused, English, verified against the code. Keep them accurate if you -change the API. +`docs/` is the in-repo reference, English, behaviour-focused, verified against the code — +routing and request binding, responses, PPA, processes and daemons, scheduling, console, +configuration, plus `starter/00-quickstart.md`. `README.md` is the install-and-run entry +point. **Keep both accurate when you change an API**: they were audited class-by-class +via reflection, and every framework symbol they name resolves. + +The user's public documentation site lives in a separate repository and is his own +concern — do not try to keep it in sync from here. + +`doc-new/` no longer exists. It held working design notes while the ConnectionPool, +layout and starter work was in flight; those are now §13, §14 and §15 of this file. +Do not recreate it — design rationale belongs here, user-facing prose in `docs/`. --- @@ -543,7 +552,7 @@ in a real coroutine, telemetry store round-trip, actuator merge). All determinis live DB. Live drivers: `tests/Integration/Pool/` under `#[Group('pool')]`, enabled by `PG_TEST_DSN` / `MYSQL_TEST_DSN` / `MARIADB_TEST_DSN`. -Design history and rationale: `doc-new/ppa-hikaricp-lite.md`. +Design history: this section is the record; the working notes it came from are gone. --- @@ -635,4 +644,78 @@ removing: Swoole's handler covers all of it in C, including refusing to escape `document_root` (verified: the traversal requests above return 404 there). -Design history and rationale: `doc-new/public-resources-layout.md`. +Design history: this section is the record; the working notes it came from are gone. + + +--- + +## 15. `WinterApplication` — the starter + +### What replaced what + +There is no god bootstrap class any more. The old `BaseBoot`/`Application` exposed seven +hooks (`configure`, `providers`, `channels`, `httpCors`, `health`, `plugins`, +`swooleConfig`) that every project had to override; all of them are gone except +`configure()`. + +```php +#[EnableWeb] +#[EnableActuator] +final class Application extends WinterApplication +{ + public static function main(array $argv): never { parent::run($argv); } +} +``` + +Two rules carry the design: + +- **The manifest is declarative.** `#[Enable*]` on the application class says what the + application *is made of*. Each attribute maps to one `Component` + (`EnableWeb` → http, `EnableProcess`/`EnableDaemon` → workers, `EnableScheduler` → + scheduler), except `EnableAsync`, which only toggles `#[Async]` proxying during boot. +- **Configuration is discovered, not hooked.** `#[Configuration]`/`#[Bean]`, + `WebConfigurer`, `LoggingConfigurer`, `HealthContributor`, `#[Import]` — all found by + the single scan pass. Adding configuration is adding a class. + +An empty manifest is an error (`ApplicationConfigException`), not a silently idle +application. + +### Entries + +| Entry | Who calls it | +|---|---| +| `main($argv)` → `run($argv)` | the project's `call` | +| `serve()` | `run` when the verb is `run` | +| `executor($argv)` | **only** `vendor/bin/wKernelRunner` — the child side of `dispatch()` (see §12) | +| `discoverAppClass()` | that runner, to find the app class after requiring `bootstrap.php` | + +`configure()` is the one hook that survived, and it must stay a method: it runs +`Kernel::init()`, which decides *where the scan looks*, so it cannot itself be a +discovered class. Its default derives the project root from the application class's own +file. + +### Boot order (`bootstrap()`) + +``` +1. $appClass = static::class +2. configure() ← Kernel::init: paths, .env, logging +3. Container::init() +4. ONE Scanner pass ← DICollector + ConfigurationCollector + WebConfigurer + + LoggingConfigurer + HealthContributor (+ AsyncCollector + only when #[EnableAsync] is present) +5. contextual LoggerInterface binding +6. applyLogging → applyCors → applyActuator → applyImports +``` + +One pass, not one per concern — adding a collector means adding it to that pass, never a +second `Scanner::run()`. + +### Gotchas + +- **`main()` must not declare a default for `$argv`.** It once did (`array $argv = []`) + and broke every subclass that overrode `main(array $args)`; PHP rejects the narrower + signature. Caught only by a smoke run. +- **`#[EnableAsync]` gates the collector itself**, not just a flag: without it the + `AsyncCollector` is never created, so `#[Async]` methods run synchronously. It is + collected last, after `DICollector` rebinds a class to itself. +- The banner is suppressed by `--no-banner`, `WINTER_BANNER=off`, or a non-TTY stdout. diff --git a/README.md b/README.md index 720fcf9..88e5547 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,206 @@ [![Latest Version on Packagist](https://img.shields.io/packagist/v/flytachi/winter-kernel.svg)](https://packagist.org/packages/flytachi/winter-kernel) [![PHP Version Require](https://img.shields.io/packagist/php-v/flytachi/winter-kernel.svg?style=flat-square)](https://packagist.org/packages/flytachi/winter-kernel) [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE) + +The kernel of the Winter framework — a PHP 8.4 library that turns a directory of +classes into a running application. It carries the HTTP layer, dependency injection, +the database layer (PPA), managed processes and daemons, scheduling, and the console. + +It is a **library, not a skeleton**: there is nothing to scaffold and no directory tree +to create. You add it to a project, write one class that says what the application +contains, and run it. + +--- + +## Requirements + +| | | +|---|---| +| PHP | **8.4+** | +| Extensions | `pcntl`, `posix`, `fileinfo` (required) · `swoole` (for the HTTP server, coroutines and connection pooling) · `pdo` for a database | + +Everything else is pulled by composer. + +--- + +## Install + +```bash +composer require flytachi/winter-kernel +``` + +## The whole application: two files + +**`bootstrap.php`** — loads the autoloader and declares what the application contains: + +```php + true]; + } +} +``` + +--- + +## What the application contains — `#[Enable*]` + +The attributes on the application class are the manifest. Each one adds a component; +declare none and the boot fails rather than starting an application that does nothing. + +| Attribute | Effect | +|---|---| +| `#[EnableWeb]` | the Swoole HTTP server | +| `#[EnableActuator]` | `/actuator` diagnostics (health, info, metrics, mappings) | +| `#[EnableScheduler]` | runs `#[Scheduled]` methods on their triggers | +| `#[EnableProcess(Foo::class)]` | a managed worker beside the server | +| `#[EnableDaemon(Bar::class)]` | a supervised fleet of workers beside the server | +| `#[EnableAsync]` | proxies `#[Async]` methods so they run off the request | +| `#[Import('vendor/pkg', '/prefix')]` | mounts a plugin package under a URL prefix | + +Everything else is configured by ordinary classes the scan finds — there are no +configuration hooks to override: + +```php +#[Configuration] / #[Bean] // DI factories +WebConfigurer // host, port, Swoole tuning, CORS, static files +LoggingConfigurer // extra log channels +``` + +--- + +## Project layout + +Only two directories are conventional, and both are optional: + +``` +resources/ + static/ web assets — served by Swoole when enabled (see WebConfigurer) + views/ view files — ResponseView's default root +storage/ + logs/ cache/ runnable/ created on demand, never committed +``` + +There is no `public/`: that belongs to the FPM document-root model, and the server here +decides for itself what it serves. Static files are opt-in: + +```php +final class WebConfig extends WebConfigurerAdapter +{ + public function configureServer(ServerSettings $server, ApplicationArguments $args): void + { + $server->port(8000) + ->staticPath('resources/static'); // resources/static/app.css → /app.css + } +} +``` + +## Configuration + +`.env` is optional; every variable has a working default. + +| Variable | Default | Meaning | +|---|---|---| +| `DEBUG` | `false` | verbose errors and a full rescan on every boot | +| `LOG_LEVEL` | *(empty — logging off)* | `debug`…`emergency` | +| `LOG_OUTPUT` | `auto` → stdout | `stdout`, `stderr`, `file`, `syslog`, `null` | +| `LOG_FILE` | `storage/logs/.log` | absolute path when `LOG_OUTPUT=file` | +| `SERVER_WORKERS` | Swoole default | worker count | +| `WINTER_KEY` | *(none)* | signs payloads handed to background processes | +| `PPA_POOL_TELEMETRY` | `5` | seconds between pool-stat publishes; `0` disables | + +--- + +## Console + +```bash +php call # command list +php call run [dev] # serve +php call make -c UserController # scaffold a class +php call daemon start [-d] | stop | status +php call process start [-d] | stop | status +php call db ping | migrate | sql | pool +php call schedule start [-d] | stop | status +php call cfg completion -i # shell completion +``` + +Class names use dot notation: `main.process.Emails` → `Main\Process\Emails`. + +--- + +## Runtimes + +The kernel runs the same application two ways: + +- **Swoole** — one long-lived server process; coroutines, connection pooling and static + files handled in C. This is the primary target. +- **CLI / plain processes** — the console, and processes or daemons started on their own. + +FPM is not served by the kernel itself; it is moving to a separate `winter-fpm` project. + +--- + +## Documentation + +- [`docs/starter/00-quickstart.md`](docs/starter/00-quickstart.md) — from an empty + directory to a served request, then to a background worker. +- [`docs/`](docs) — the reference for each subsystem (routing, request binding, + responses, PPA, processes and daemons, scheduling, console, configuration). + +## Development + +```bash +vendor/bin/phpunit # the default suite +vendor/bin/phpunit --group integration # real forks, signals and servers +vendor/bin/phpcs # PSR-12 +``` + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/dev/main/WebConfig.php b/dev/main/WebConfig.php new file mode 100644 index 0000000..eed0e86 --- /dev/null +++ b/dev/main/WebConfig.php @@ -0,0 +1,24 @@ +staticPath('resources/static'); + } +} diff --git a/dev/resources/static/winter/logo.svg b/dev/resources/static/winter/logo.svg new file mode 100644 index 0000000..cdab276 --- /dev/null +++ b/dev/resources/static/winter/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/docs/architecture/01-routing.md b/docs/architecture/01-routing.md index d63a137..fcc8238 100644 --- a/docs/architecture/01-routing.md +++ b/docs/architecture/01-routing.md @@ -146,13 +146,18 @@ All errors — including middleware abort and validation failures — are caught ## Static file serving -Required for Swoole (unlike FPM+nginx, Swoole does not serve static files natively): +Not the router's job. Swoole serves files itself, in C, before PHP is reached — it +streams them, honours `Range`, and cannot be walked out of the directory with `..`. +Declare the directory and it never touches the dispatch pipeline: ```php -$router->static(Kernel::$pathPublic); +$server->staticPath('resources/static'); // resources/static/app.css → /app.css ``` -On a `GET` request, the router checks whether the URI maps to an existing file under `$publicDir`. If found, the file is served directly with auto-detected MIME type and a 24-hour `Cache-Control: public, max-age=86400`. No route matching occurs. +Static serving is opt-in: say nothing and no file is ever served, which is what an +API-only service wants. Because those responses never reach PHP, middleware, CORS and +request logging do not apply to them. See +[`../configuration/08-runtime.md`](../configuration/08-runtime.md). --- diff --git a/docs/concurrent/00-overview.md b/docs/concurrent/00-overview.md index 8adf22d..50f09f0 100644 --- a/docs/concurrent/00-overview.md +++ b/docs/concurrent/00-overview.md @@ -150,4 +150,4 @@ result: - [`configuration/07-di.md`](../configuration/07-di.md) — the container the proxies are registered in - [`console/10-di.md`](../console/10-di.md) — the `call di` command -- [`threads/00-overview.md`](../threads/00-overview.md) — jobs and daemons that own their own process +- [`process/00-overview.md`](../process/00-overview.md) — managed workers and supervised fleets that own their own process diff --git a/docs/concurrent/03-async.md b/docs/concurrent/03-async.md index 234edb8..891779d 100644 --- a/docs/concurrent/03-async.md +++ b/docs/concurrent/03-async.md @@ -236,7 +236,7 @@ budget across several methods — register an executor under that id as a single and name it: ```php -// Boot::providers() +// a #[Configuration] class the scan finds $c->singleton('reports', fn() => Executors::newFixedExecutor(3)); // at most 3 at once // the method diff --git a/docs/configuration/01-kernel.md b/docs/configuration/01-kernel.md index f3bda79..96aad9e 100644 --- a/docs/configuration/01-kernel.md +++ b/docs/configuration/01-kernel.md @@ -1,48 +1,49 @@ # Kernel & Bootstrap -The kernel resolves application paths, loads `.env`, configures logging and the thread runner, and exposes a small `Boot` class that you extend to wire up everything else. +The kernel resolves application paths, loads `.env`, and configures logging and the thread runner. It runs once, at the very start of the boot, before anything is scanned. This page describes the configuration knobs at the bottom of the stack. Logging, CORS, health, and plugins are documented separately — links at the end. --- -## The Boot class +## When it runs -Every application has one `Boot` class that extends `Flytachi\Winter\K2\BaseBoot`. It overrides only the hooks it needs and is called from one of four entry points (`web()`, `swoole()`, `cli()`, `executor()`). +`Kernel::init()` is the first thing the boot does, because it decides where the scan +will look. Everything after it — DI, configurers, routes — depends on the paths it sets. -```php -// bootstrap.php -use Flytachi\Winter\K2\BaseBoot; -use Flytachi\Winter\K2\Kernel; +You normally never call it: `WinterApplication::configure()` does, deriving the project +root from the application class's own file. -class Boot extends BaseBoot +```php +// bootstrap.php — no Kernel::init() in sight +#[EnableWeb] +final class Application extends WinterApplication { - protected static function configure(): void - { - Kernel::init(pathRoot: __DIR__); - } + public static function main(array $argv): never { parent::run($argv); } } ``` +Override `configure()` only for a non-standard layout — for instance to keep runtime +files outside the project: + ```php -// public/index.php -require __DIR__ . '/../bootstrap.php'; -Boot::web(); -``` +use Flytachi\Winter\K2\App\ApplicationArguments; +use Flytachi\Winter\K2\Kernel; -The full hook list: +#[EnableWeb] +final class Application extends WinterApplication +{ + protected static function configure(ApplicationArguments $args): void + { + Kernel::init(pathRoot: __DIR__, pathStorage: '/var/lib/myapp'); + } -| Hook | When called | Purpose | -|---|---|---| -| `configure()` | first, before everything else | Call `Kernel::init(...)`, set paths and timezone. | -| `providers(Container $c)` | after DI scan | Manual DI bindings (factories, named scalars, service providers). | -| `channels()` | after `configure()` | Register custom log channels via `Kernel::channel('name')`. | -| `httpCors()` | before request dispatch | Call `Cors::configure(...)`. | -| `health()` | before request dispatch | Call `Health::configure(...)`. | -| `plugins()` | before route scan | Call `Plugin::registry(...)` for each plugin. | -| `swooleConfig()` | only in `swoole()` mode | Return options for `Swoole\Http\Server::set()`. | + public static function main(array $argv): never { parent::run($argv); } +} +``` -All hooks except `swooleConfig()` are `protected` — override only what you need; defaults are no-ops or sane defaults. +Because `configure()` decides where the scan looks, it cannot itself be a discovered +class — it is the one thing that stays a method on the application. --- @@ -52,7 +53,6 @@ All hooks except `swooleConfig()` are `protected` — override only what you nee Kernel::init( pathRoot: __DIR__, // project root pathEnv: __DIR__ . '/.env', - pathPublic: __DIR__ . '/public', pathResource: __DIR__ . '/resources', pathStorage: __DIR__ . '/storage', pathStorageLog: __DIR__ . '/storage/logs', @@ -62,12 +62,13 @@ Kernel::init( ); ``` -Every parameter is **optional**. When `pathRoot` is omitted, it is derived from the calling location (`dirname(__DIR__, 5)`). All other paths are derived from `pathRoot` if not given: +Every parameter is **optional**. When `pathRoot` is omitted, it is derived from the calling location. All other paths are derived from `pathRoot` if not given. + +There is no `pathPublic`: a document root is an FPM concept, and the Swoole server decides for itself what it serves (see [`../starter/00-quickstart.md`](../starter/00-quickstart.md)). | Param | Default | |---|---| | `pathEnv` | `$pathRoot . '/.env'` | -| `pathPublic` | `$pathRoot . '/public'` | | `pathResource` | `$pathRoot . '/resources'` | | `pathStorage` | `$pathRoot . '/storage'` | | `pathStorageLog` | `$pathStorage . '/logs'` | @@ -79,7 +80,6 @@ After `init()`, all of these are available as public static properties on `Kerne ```php Kernel::$pathRoot Kernel::$pathEnv -Kernel::$pathPublic Kernel::$pathResource Kernel::$pathStorage Kernel::$pathStorageLog @@ -146,12 +146,15 @@ The constant `WINTER_STARTUP_TIME` is defined here if not already set elsewhere. ### Thread runner discovery -`bindThread()` looks for the executor binary in this order: +Detaching a process spawns a fresh PHP process running the **thread runner**, which +boots the application again and runs the staged payload. `Kernel::init()` resolves it: -1. `WINTER_THREAD_RUNNER` env var (absolute path) -2. `/vendor/bin/wKernelExecutor` -3. `/vendor/bin/wExecutor` -4. If none exists, the runner is left unbound — `Thread::dispatch()` will fail at runtime. +1. `WINTER_THREAD_RUNNER` env var — an absolute path, used when the file exists; +2. otherwise `/vendor/bin/wKernelRunner`, the binary this package ships. + +The runner resolves the project root from its own location, so it must be **copied** +into place rather than symlinked — PHP resolves `__DIR__` through symlinks and the path +would point at the package instead of the project. When `ext-shmop` is loaded, payload mode is set to `PAYLOAD_SHM` automatically (avoids fd conflicts in Swoole). @@ -242,67 +245,45 @@ process-wide static under FPM. ## Entry points -A complete request lifecycle from each entry point. Detailed pipelines live in [`../architecture/01-routing.md`](../architecture/01-routing.md#request-handling-pipeline). - -### `Boot::web()` — FPM / nginx +There is one: `Application::main($argv)`, reached from `call`. What happens next depends +on the verb, not on a different entry file. ```php -// public/index.php -require __DIR__ . '/../bootstrap.php'; -Boot::web(); +#!/usr/bin/env php + swoole_cpu_num() * 2, - 'max_request' => 5000, - 'max_request_grace' => 500, - 'enable_coroutine' => true, - ]; -} -``` - -Default log channel is `http`. - -### `Boot::cli($argv)` — Console - -```php -// call -require __DIR__ . '/bootstrap.php'; -Boot::cli($argv); -``` +| Invocation | What runs | +|---|---| +| `php call run` | boots, then serves the components in the manifest (`serve()`) | +| `php call run dev` | the same with the file watcher | +| `php call ` | boots, then hands the verb to the console | +| `php call` | boots, prints the command list | -Runs the console application (`Flytachi\Winter\Console\Core`). Default log channel is `cli`. +Two more entries exist but are not called by hand: -### `Boot::executor($argv)` — Thread / job runner +- **`WinterApplication::executor($argv)`** — the child side of `Process::dispatch()`. + Detaching cannot be a fork (the parent may be a Swoole worker whose reactor must not + be duplicated), so the launcher spawns a fresh PHP process running + `vendor/bin/wKernelRunner`, which boots the application again and runs the staged + payload. Its CLI flags are the thread runner's own: -Invoked by the `wKernelExecutor` binary (you do not call this directly). Reads a serialised `Runnable` from stdin or shared memory, runs it, exits with the appropriate status code. Default log channel is `cli`. + | Flag | Purpose | + |---|---| + | `--namespace=App` | process title namespace prefix | + | `--name=MyJob` | process title name (default: class short name) | + | `--tag=worker` | process title tag | + | `--shmkey=1234` | read the payload from a SHM segment instead of stdin | + | `--detach` | daemonise (fork + `setsid`) before running | + | `--debug` | full error reporting in the child | -CLI flags accepted by the binary: +- **`WinterApplication::discoverAppClass()`** — how that runner finds your application + class after requiring `bootstrap.php`, without knowing its name. -| Flag | Purpose | -|---|---| -| `--namespace=App` | Process title namespace prefix | -| `--name=MyJob` | Override process title name (default: class short name) | -| `--tag=worker` | Process title tag (default: `runnable`) | -| `--shmkey=1234` | Read payload from SHM segment instead of stdin | -| `--debug` | Enable full error reporting in the child | -| `--arg-key=value` | Pass `['key' => 'value']` into `Runnable::run()` | -| `--arg-flag` | Pass `['flag' => true]` into `Runnable::run()` | +FPM has no entry here; it is moving to a separate `winter-fpm` project. --- @@ -318,20 +299,23 @@ CLI flags accepted by the binary: ## Boot order -`BaseBoot` runs the hooks in a fixed order — knowing this matters when you cross-reference services: +The boot runs in a fixed order — knowing it matters when you cross-reference services: ``` -1. configure() ← Kernel::init() + .env + paths -2. DI Scanner pass ← discovers #[Singleton] / #[Request] / #[Transient] -3. providers(Container) ← manual DI bindings -4. channels() ← Kernel::channel('job') etc. -5. httpCors() ← Cors::configure() -6. health() ← Health::configure() -7. plugins() ← Plugin::registry() -8. ← web() / swoole() / cli() / executor() +1. configure() ← Kernel::init(): paths, .env, logging +2. Container::init() ← the shared container +3. one Scanner pass ← DI classes, #[Configuration]/#[Bean], + WebConfigurer, LoggingConfigurer, HealthContributor +4. apply logging ← discovered LoggingConfigurer classes +5. apply CORS ← discovered WebConfigurer classes +6. apply actuator ← #[EnableActuator] + discovered contributors +7. apply imports ← #[Import] plugin packages +8. dispatch ← serve() or the console ``` -`BaseBoot::getBootClass()` returns the concrete `Boot` class name set during step 1. +Everything from step 3 onward is discovered, not registered: adding a configurer is +adding a class. `WinterApplication::getAppClass()` returns the concrete application +class name, set at the start of step 1. --- diff --git a/docs/configuration/02-logging.md b/docs/configuration/02-logging.md index 0b0fdc4..bc8c649 100644 --- a/docs/configuration/02-logging.md +++ b/docs/configuration/02-logging.md @@ -31,7 +31,7 @@ the default. Only the HTTP request path switches to `http`; everything else stay | `call run` — request workers | `http` | `CoroutineContext` (per coroutine) | | `call run` — master + components (Process/Daemon/Scheduler) | `sys` | `ProcessContext` (per process) | | `call` (CLI commands) | `sys` | `ProcessContext` (per process) | -| `wKernelExecutor` (threads/jobs) | `sys` | `ProcessContext` | +| `wKernelRunner` (detached processes) | `sys` | `ProcessContext` | --- @@ -223,13 +223,13 @@ class OrderController extends Controller } ``` -**How it works.** `BaseBoot` registers a [contextual binding](https://github.com/flytachi/winter-di) +**How it works.** The boot registers a [contextual binding](https://github.com/flytachi/winter-di) for `Psr\Log\LoggerInterface` by default — the container resolves the injected logger to `LoggerFactory::getLogger()`. So the logger is automatically named after the class it lives in, with the same per-class `(ClassName)` output you'd get from the factory — but injected by type. -**Override** the default in `Boot::providers()` — e.g. pin a channel or swap the +**Override** the default in a `#[Configuration]` class — e.g. pin a channel or swap the factory entirely (re-registering wins, since `contextual()` is last-write for the key): ```php diff --git a/docs/configuration/03-cors.md b/docs/configuration/03-cors.md index 24157b0..1959ae2 100644 --- a/docs/configuration/03-cors.md +++ b/docs/configuration/03-cors.md @@ -2,7 +2,7 @@ Winter K2 has two layers of CORS configuration, both modelled after Spring's `@CrossOrigin`: -1. **Global** — `Cors::configure(...)` in your `Boot::httpCors()` hook. Applied to every response, including 404, 405, and error responses. +1. **Global** — a `WebConfigurer` the scan finds. Applied to every response, including 404, 405, and error responses. 2. **Per-route** — `#[CrossOrigin(...)]` attribute on a controller class or method. **Overrides** the global config (does not merge with it) for that specific route. The OPTIONS preflight is handled automatically by the Router before middleware or the controller runs. @@ -11,38 +11,42 @@ The OPTIONS preflight is handled automatically by the Router before middleware o ## Global CORS -Configure once during boot. The simplest example: +Global CORS is declared by any class extending `WebConfigurerAdapter` — there is no hook +to override on the application class, the scan finds the configurer wherever it lives: ```php -use Flytachi\Winter\K2\Http\Cors; +use Flytachi\Winter\K2\App\Config\CorsRegistry; +use Flytachi\Winter\K2\App\Config\WebConfigurerAdapter; -class Boot extends BaseBoot +final class WebConfig extends WebConfigurerAdapter { - protected static function httpCors(): void + public function configureCors(CorsRegistry $cors): void { - Cors::configure( - origins: ['https://app.example.com'], - allowHeaders: ['Content-Type', 'Authorization', 'X-Request-Id'], - exposeHeaders: ['X-Request-Id'], - credentials: true, - maxAge: 3600, - ); + $cors->allowedOrigins('https://app.example.com') + ->allowedHeaders('Content-Type', 'Authorization', 'X-Request-Id') + ->exposeHeaders('X-Request-Id') + ->allowCredentials() + ->maxAge(3600); } } ``` -### `Cors::configure()` parameters +Each method takes a variadic list rather than an array, and returns the registry so the +calls chain. Touch nothing and no global CORS is applied. -| Parameter | Type | Default | Description | -|---|---|---|---| -| `origins` | `string[]` | `[]` | Allowed origins. Empty → `Access-Control-Allow-Origin: *`. Multiple origins → reflects the matching request `Origin` and adds `Vary: Origin`. | -| `allowHeaders` | `string[]` | `[]` | Request headers the browser may send. Empty → reflects `Access-Control-Request-Headers`. | -| `exposeHeaders` | `string[]` | `[]` | Response headers exposed to JavaScript. | -| `credentials` | `bool` | `false` | Sends `Access-Control-Allow-Credentials: true`. **Requires explicit `origins`** — incompatible with the wildcard `*`. | -| `maxAge` | `int` | `0` | Preflight cache TTL (`Access-Control-Max-Age`). `0` = header not sent. | -| `vary` | `string[]` | `[]` | Extra `Vary` header values appended to the response. | +### The settings -When this hook is not overridden, no global CORS is sent — every response is same-origin only. +| Method | Default | Description | +|---|---|---| +| `allowedOrigins(...)` | none | Allowed origins. None → `Access-Control-Allow-Origin: *`. Several → reflects the matching request `Origin` and adds `Vary: Origin`. | +| `allowedHeaders(...)` | none | Request headers the browser may send. None → reflects `Access-Control-Request-Headers`. | +| `exposeHeaders(...)` | none | Response headers exposed to JavaScript. | +| `allowCredentials(bool)` | `false` | Sends `Access-Control-Allow-Credentials: true`. **Requires explicit origins** — incompatible with the wildcard `*`. | +| `maxAge(int)` | `0` | Preflight cache TTL (`Access-Control-Max-Age`). `0` = header not sent. | +| `vary(...)` | none | Extra `Vary` header values appended to the response. | + +When no configurer touches the registry, no global CORS is sent — every response is +same-origin only. Source: `src/Http/Cors.php`. diff --git a/docs/configuration/04-health.md b/docs/configuration/04-health.md index e33ded5..dcf4d1f 100644 --- a/docs/configuration/04-health.md +++ b/docs/configuration/04-health.md @@ -1,6 +1,6 @@ # Health / Actuator -Read-only diagnostic endpoints under `/actuator/*` — modelled after Spring Boot Actuator. Disabled by default; opt in from your `Boot::health()` hook. +Read-only diagnostic endpoints under `/actuator/*` — modelled after Spring Boot Actuator. Disabled by default; opt in with `#[EnableActuator]` on the application class. The endpoints are useful for: @@ -15,15 +15,21 @@ The endpoints are useful for: Override `health()` in your `Boot` class and call `Health::configure()`: ```php -use Flytachi\Winter\K2\Http\Health\Health; +use Flytachi\Winter\K2\App\Attribute\EnableActuator; +use Flytachi\Winter\K2\WinterApplication; -class Boot extends BaseBoot -{ - protected static function health(): void - { - Health::configure(); // default built-in indicator, open access - } -} +#[EnableWeb] +#[EnableActuator] // default built-in indicator, open access +final class Application extends WinterApplication { /* ... */ } +``` + +The attribute accepts a custom indicator and a guard middleware: + +```php +#[EnableActuator( + indicator: App\Health\AppHealthIndicator::class, + middleware: App\Http\Middleware\InternalOnlyMiddleware::class, +)] ``` To restrict access or replace the indicator, pass arguments: @@ -87,7 +93,7 @@ Source: `src/Route/Router.php` (`registerHealth()`). { "status": "degraded", "components": { - "db": {"status": "up", "details": {...}}, + "db": {"status": "up", "details": {...}}, // per datasource, pool nested inside "cache": {"status": "up", "details": {...}}, "disk": {"status": "degraded", "details": {"usage_percent": 85.4, "warning": "Disk usage above 80%"}}, "memory": {"status": "up", "details": {...}}, @@ -121,6 +127,28 @@ Optional — only reports `up` and an empty `details` map unless one of these pa `HealthIndicator` scans `Kernel::$pathRoot` for implementations and calls `pingDetail()` on each. A latency ≥ **500 ms** flips the component to `degraded`. Connection failure → `down`. +The `db` component carries **one entry per datasource**, holding both its reachability +and how loaded its connection pool is: + +```json +"db": { + "status": "up", + "details": { + "App\\Config\\AppDb": { + "status": "up", "driver": "pgsql", "latency": 1.2, "error": null, + "pool": {"total": 5, "idle": 3, "active": 2, "maximum": 10} + } + } +} +``` + +The two answer different questions — the ping says whether the database is reachable, the +pool says whether this worker has connections left — so they live together rather than in +separate components. A saturated pool (`active >= maximum`) degrades its datasource. +`pool` is `null` when the worker holds no pool for that config, and the numbers are **per +worker**: a health request reports the worker that served it. For the fleet-wide view use +[`call db pool`](../console/06-db.md). + --- ## Authoring a custom indicator diff --git a/docs/configuration/05-plugins.md b/docs/configuration/05-plugins.md index 9c0492f..f1c34df 100644 --- a/docs/configuration/05-plugins.md +++ b/docs/configuration/05-plugins.md @@ -1,9 +1,9 @@ # Plugins -Plugins are regular Composer packages that contribute controllers, exception handlers, and DI services under a URL prefix. Each plugin registration is one line in your `Boot::plugins()` hook: +Plugins are regular Composer packages that contribute controllers, exception handlers, and DI services under a URL prefix. Each plugin is one attribute on the application class: ```php -Plugin::registry('acme/billing-plugin', '/billing'); +#[Import('acme/billing-plugin', '/billing')] ``` After that, every `#[Controller]` discovered under `vendor/acme/billing-plugin/src/` is mounted under `/billing/...`. No extra wiring needed. @@ -15,18 +15,18 @@ After that, every `#[Controller]` discovered under `vendor/acme/billing-plugin/s Override `plugins()` in your `Boot` class: ```php -use Flytachi\Winter\K2\Plugin; +use Flytachi\Winter\K2\App\Attribute\Import; +use Flytachi\Winter\K2\WinterApplication; -class Boot extends BaseBoot -{ - protected static function plugins(): void - { - Plugin::registry('acme/auth-plugin', '/auth'); - Plugin::registry('acme/billing-plugin', '/billing'); - } -} +#[EnableWeb] +#[Import('acme/auth-plugin', '/auth')] +#[Import('acme/billing-plugin', '/billing')] +final class Application extends WinterApplication { /* ... */ } ``` +A package that must be present but is not installed fails the boot; pass +`required: false` to make it optional. + ### `Plugin::registry()` parameters ```php @@ -57,11 +57,11 @@ Source: `src/Plugin.php`. Plugin registration runs **before** the route scan. During `Router::fromScan()` (or the first request after a clean cache): ``` -1. Boot::plugins() runs ← Plugin::registry() entries collected +1. #[Import] attributes applied ← plugin packages registered 2. Router::fromScan(pathRoot) ← scans your application src/ 3. For each registered plugin: scan vendor//src/ ← MappingCollector with prefix -4. Health::configure() endpoints ← only the app-level ones +4. #[EnableActuator] endpoints ← only the app-level ones 5. Cache compiled routes ← if DEBUG=false ``` diff --git a/docs/configuration/07-di.md b/docs/configuration/07-di.md index 08bb4a9..0b08f25 100644 --- a/docs/configuration/07-di.md +++ b/docs/configuration/07-di.md @@ -13,7 +13,7 @@ then hands the container to your `providers()` hook for manual wiring. ## What boot does for you -`BaseBoot::boot()` performs the DI setup automatically, before any of the +The application boot performs the DI setup automatically, before any of the HTTP/CLI entry points run: ``` @@ -70,9 +70,10 @@ discovered bindings. use Flytachi\Winter\DI\Container; use Psr\Log\LoggerInterface; -class Boot extends BaseBoot +#[Configuration] +final class AppConfig { - protected static function providers(Container $c): void + public function register(Container $c): void { // Service providers $c->register(AppServiceProvider::class); @@ -169,7 +170,7 @@ you rarely call it directly, but it's worth knowing where injection happens: Because the same container backs all of these, a `#[Singleton]` is shared within a process but **not** across forked child processes — each thread -rebuilds its own graph. See [`../threads/01-job.md`](../threads/01-job.md). +rebuilds its own graph. See [`../process/00-overview.md`](../process/00-overview.md). --- @@ -184,7 +185,7 @@ services in a plugin land in the shared container automatically. See ## Source -- `src/BaseBoot.php` — `boot()` (Scanner + contextual logger), `providers()` hook +- `src/WinterApplication.php` — `bootstrap()` (Scanner + contextual logger) and the `#[Enable*]` manifest - `Flytachi\Winter\DI\Container` — `init()`, `register()`, `singleton()`, `bind()`, `set()`, `contextual()`, `make()` - `Flytachi\Winter\DI\Scanner` + `Collector\DICollector` — auto-discovery diff --git a/docs/configuration/08-runtime.md b/docs/configuration/08-runtime.md index ddd57c2..f0a449e 100644 --- a/docs/configuration/08-runtime.md +++ b/docs/configuration/08-runtime.md @@ -1,22 +1,24 @@ -# Runtime — FPM & Swoole +# Runtime -The same application runs under two HTTP runtimes without touching a line of -controller or middleware code: +The kernel runs the same application code two ways, and the difference that reaches your +code is not the transport — it is **how long a process lives**. -| Runtime | Entry point | Process model | -|---------|-------------|---------------| -| PHP-FPM / Apache (CGI) | `Boot::web()` | One request per process, no shared state | -| Swoole HTTP server | `Boot::swoole()` | Long-lived workers, routes & state stay in memory | +| Runtime | Started by | Process model | +|---|---|---| +| Swoole HTTP server | `php call run` | long-lived workers; routes, singletons and connection pools stay in memory | +| Plain process | `php call `, `call process` / `call daemon`, the scheduler | one process per invocation, or a long-lived worker without a reactor | -You pick the runtime by which entry point your bootstrap file calls — the -kernel handles the rest. +FPM is not served by the kernel. That is a deliberate boundary: a document root, +`public/index.php` and per-request teardown belong to the FPM model, and they are moving +to a separate `winter-fpm` project. The adapters below still exist and are tested, so +that project has a foundation to build on. --- ## Why your code doesn't change -Every K2 internal — `Router`, `ParameterResolver`, middleware, controllers — -depends only on two interfaces, never on a concrete transport: +Every internal — `Router`, `ParameterResolver`, middleware, controllers — depends on two +interfaces, never on a concrete transport: ```php namespace Flytachi\Winter\K2\Http\Contracts; @@ -25,12 +27,12 @@ interface HttpRequest { /* getMethod(), getUri(), getHeader(), getRawBody(), interface HttpResponse { /* status(), header(), end(), sendfile() */ } ``` -Each runtime ships a thin adapter pair: +Each transport ships a thin adapter pair: -| Contract | FPM adapter | Swoole adapter | -|----------|-------------|----------------| -| `HttpRequest` | `FpmRequest` — reads `$_SERVER` / `$_GET` / `$_POST` / `php://input` | `SwooleRequest` — wraps `Swoole\Http\Request` | -| `HttpResponse` | `FpmResponse` — `http_response_code()` / `header()` / `echo` | `SwooleResponse` — thin proxy over `Swoole\Http\Response` | +| Contract | Swoole adapter | FPM adapter | +|---|---|---| +| `HttpRequest` | `SwooleRequest` — wraps `Swoole\Http\Request` | `FpmRequest` — reads `$_SERVER` / `$_GET` / `$_POST` / `php://input` | +| `HttpResponse` | `SwooleResponse` — proxy over `Swoole\Http\Response` | `FpmResponse` — `http_response_code()` / `header()` / `echo` | `Router::handle()` takes the interfaces: @@ -38,158 +40,112 @@ Each runtime ships a thin adapter pair: public function handle(HttpRequest $request, HttpResponse $response): void ``` -So the dispatch pipeline, route table, validation, and response serialization -are identical in both modes. Only the boundary objects differ. +So the dispatch pipeline, route table, validation and response serialization are +identical whichever transport sits on the boundary. Only the boundary objects differ. --- -## FPM — `Boot::web()` +## Serving -```php -// public/index.php -require __DIR__ . '/../bootstrap.php'; -Boot::web(); +```bash +php call run # bind and serve +php call run dev # the same, restarting on file changes +php call run --host=127.0.0.1 --port=9501 ``` -One request lives in one process and then dies — no state survives between -requests. The pipeline: +What happens on `run`: ``` -Boot::web() - ├── boot() ← configure + DI scan (once per request) - ├── Router::resolve(pathRoot) ← cached route table when DEBUG=false - ├── $router->static(pathPublic) ← serve existing public files directly - └── $router->handle(new FpmRequest(), new FpmResponse()) -``` - -**Route cache.** `Router::resolve()` avoids re-scanning on every request: - -| `DEBUG` | Behavior | -|---------|----------| -| `false` | Loads `storage/volatile/mapping.php` if present; otherwise scans **once** and writes the cache for subsequent requests (first boot after deploy). | -| `true` | Always rescans; cache is never read or written (dev mode). | - -If the cache write fails, the request still serves — the kernel logs a -warning and runs uncached. - -**Static files.** `static(pathPublic)` short-circuits GET requests whose URI -maps to an existing file in `Kernel::$pathPublic`, skipping route dispatch. -Behind nginx this is usually a no-op because nginx serves the file first. - ---- - -## Swoole — `Boot::swoole()` - -```php -// server.php -require __DIR__ . '/bootstrap.php'; -Boot::swoole(); // defaults: 0.0.0.0:9501 -// or: Boot::swoole('0.0.0.0', 8080); -``` - -Requires `ext-swoole`. The route scan runs **once at startup** and stays in -memory for the whole server lifetime — every request reuses the same -`Router` instance: - -``` -Boot::swoole(host, port) - ├── boot() ← configure + DI scan (once, at startup) - ├── Router::fromScan(pathRoot) ← scan once, keep routes in memory - ├── $router->static(pathPublic) - ├── Runtime::enableCoroutine(SWOOLE_HOOK_ALL) ← all blocking I/O made coroutine-friendly - ├── Runtime::boot(RuntimeMode::Swoole) +Application::main($argv) + ├── boot ← Kernel::init, scan, DI, configurers + ├── Router::fromScan(Kernel::$pathRoot) + ├── Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL) ├── new Swoole\Http\Server(host, port) - ├── $server->set(static::swooleConfig()) - ├── MemoryWatcher attached ← per-worker memory baseline reporting + ├── $server->set($settings->toArray()) ← from .env + WebConfigurer └── on('request', fn($req, $res) => $router->handle( new SwooleRequest($req), new SwooleResponse($res))) ``` -`SWOOLE_HOOK_ALL` makes PDO, cURL, file, and `sleep()` coroutine-aware, so -blocking I/O yields instead of stalling the worker. Coroutine isolation keeps -per-request state separate even though workers are shared. +`SWOOLE_HOOK_ALL` makes PDO, cURL, file I/O and `sleep()` coroutine-aware, so blocking +calls yield instead of stalling the worker. Coroutine isolation keeps per-request state +separate even though the worker is shared. -**Server tuning** — override `swooleConfig()` in your `Boot` class; the array -is passed straight to `Swoole\Http\Server::set()`: +Server tuning is a `WebConfigurer` the scan finds, not a hook on the application class: ```php -protected static function swooleConfig(): array +final class WebConfig extends WebConfigurerAdapter { - return [ - 'worker_num' => swoole_cpu_num() * 2, - 'max_request' => 5000, - 'enable_coroutine' => true, - ]; + public function configureServer(ServerSettings $server, ApplicationArguments $args): void + { + $server->workers(swoole_cpu_num() * 2) + ->maxRequest(5000) + ->maxRequestGrace(500) + ->set('ssl_cert_file', '/etc/ssl/app.pem'); // any raw Swoole option + } } ``` -Return `[]` for Swoole's built-in defaults. A `max_request` ceiling is a -cheap safety net — workers recycle after N requests, bounding any slow leak. - -**MemoryWatcher** records each worker's memory baseline at `workerStart` and -reports per-request growth — useful for spotting leaks introduced by shared -state. +The `.env` shorthands `SERVER_WORKERS`, `SERVER_TASKS`, `SERVER_MAX_REQUEST` and +`SERVER_MAX_REQUEST_GRACE` seed the same settings before the configurer runs. --- ## The one thing to watch: shared state -This is the only behavioral difference that reaches your code. +A Swoole worker is long-lived, so a `#[Singleton]` is created once and **reused across +many requests** in that worker. That is a feature — no per-request rebuild cost — but it +means: -Under **FPM**, every request starts from a clean process, so a `#[Singleton]` -effectively lives for one request. Under **Swoole**, a worker is long-lived — -a `#[Singleton]` is created once and **reused across many requests** in that -worker. That is a feature (no per-request rebuild cost), but it means: +- Do **not** store per-request data (the current user, a request id, a fetched entity) + on a singleton; it bleeds into the next request. +- Keep singletons stateless, or scope per-request data with `#[Request]` (see + [`07-di.md`](07-di.md)). +- Avoid module-global mutable statics for request data, for the same reason. -- Do **not** store per-request data (the current user, a request ID, a - fetched entity) on a singleton — it will bleed into the next request. -- Keep singletons stateless, or scope per-request data with `#[Request]` - (see the `flytachi/winter-di` docs and [`07-di.md`](07-di.md)). -- Avoid module-global mutable statics for request data — same reason. +Code that leans on "the process dies after each request" is exactly the code that breaks +here. -Code written this way runs identically under both runtimes. Code that leans -on "the process dies after each request" works under FPM and breaks under -Swoole. +### Timers keep a worker alive ---- - -## Non-HTTP entry points - -The same `Boot` also drives the CLI and the thread executor — these are not -HTTP runtimes but share `boot()`: +Anything that arms a `Swoole\Timer` inside a worker must release it when the worker +exits, or the reactor never drains and Swoole force-kills the worker on shutdown +(`Worker_reactor_try_to_exit(): worker exit timeout`). The kernel releases its own in the +`workerExit` handler; do the same for yours. -| Entry point | Used by | Purpose | -|-------------|---------|---------| -| `Boot::cli($argv)` | `call` binary | Console commands | -| `Boot::executor($argv)` | `wKernelExecutor` | Runs a `Runnable` in a forked child (threads/jobs) | +--- -See [`../console/00-overview.md`](../console/00-overview.md) and -[`../threads/00-overview.md`](../threads/00-overview.md). +## Non-HTTP execution ---- +The same application boots for everything else, through the same entry: -## Choosing a runtime +| Invocation | Purpose | +|---|---| +| `php call ` | console commands | +| `php call process\|daemon start [-d]` | managed workers and supervised fleets | +| `php call schedule start [-d]` | the scheduler | -| Prefer… | When | -|---------|------| -| FPM | Standard hosting, nginx/Apache in front, no persistent connections, simplest ops. | -| Swoole | High throughput / low latency, WebSockets, persistent pools, in-memory caches — and your code is stateless across requests. | +A detached start (`-d`) does not fork the current process: the launcher spawns a fresh +PHP process running `vendor/bin/wKernelRunner`, which boots the application again and +runs the staged payload. See [`../process/03-control.md`](../process/03-control.md). -Both read the same `.env`, the same `Boot` hooks, and the same route table. -Switching is changing which entry point the runtime file calls. +Without a reactor, coroutine-only machinery degrades on purpose — `Process::spawn()` +forks instead of starting coroutines, and the connection pool falls back to a single +self-maintaining connection. --- ## Source -- `src/BaseBoot.php` — `web()`, `swoole()`, `swooleConfig()` entry points +- `src/WinterApplication.php` — the boot, `serve()`, and the `workerStart` / `workerExit` handlers +- `src/App/Config/ServerSettings.php` — the Swoole options builder - `src/Http/Contracts/HttpRequest.php`, `HttpResponse.php` — the transport-agnostic contracts -- `src/Http/Adapter/FpmRequest.php`, `FpmResponse.php`, `SwooleRequest.php`, `SwooleResponse.php` -- `src/Route/Router.php` — `resolve()` (FPM, cached), `fromScan()` (Swoole, in-memory), `handle()` -- `src/Route/MemoryWatcher.php` — Swoole per-worker memory reporting +- `src/Http/Adapter/SwooleRequest.php`, `SwooleResponse.php`, `FpmRequest.php`, `FpmResponse.php` +- `src/Route/Router.php` — `resolve()` (cached), `fromScan()` (live), `handle()` +- `src/Route/DevWatcher.php` — the `run dev` file watcher ## See also -- [`01-kernel.md`](01-kernel.md) — the `Boot` class and entry points +- [`01-kernel.md`](01-kernel.md) — paths, `.env`, and the boot order - [`07-di.md`](07-di.md) — singleton lifetime and the shared-state caveat - [`../architecture/01-routing.md`](../architecture/01-routing.md) — the dispatch pipeline behind `handle()` +- [`../process/00-overview.md`](../process/00-overview.md) — processes and daemons diff --git a/docs/console/00-overview.md b/docs/console/00-overview.md index c271e23..dc421e3 100644 --- a/docs/console/00-overview.md +++ b/docs/console/00-overview.md @@ -5,8 +5,8 @@ single binary — `call` — that dispatches to a small set of built-in commands (scaffolding, config, runtime, mapping, DB, threads…) and to any custom command the project or its plugins register. -The entry point on disk is `wKernelExecutor`, which boots the kernel and -hands `$argv` to `Boot::executor()`. In a typical project the user-facing +The entry point on disk is `call`, which requires `bootstrap.php` and hands `$argv` +to `Application::main()`; any verb other than `run` goes to the console. In a typical project the user-facing binary is `call` (a project script, alias, or symlink to the executor); the rest of this section uses `call` as the canonical invocation. @@ -179,10 +179,9 @@ it directly. See [11-complete.md](11-complete.md). | [03](03-cfg.md) | `cfg` | Manage configuration, `.env`, key, Docker, completion | | [04](04-run.md) | `run` | Start the HTTP server (Swoole / dev) | | [05](05-script.md) | `script` | Run custom `Cmd` / `CmdCustom` scripts (alias `sc`) | -| [06](06-db.md) | `db` | Database ping / migrate / SQL preview | +| [06](06-db.md) | `db` | Database ping / migrate / SQL preview / pool stats | | [07](07-mapping.md) | `mapping` | Build / clean / show route cache | | [08](08-storage.md) | `storage` | Initialize and clean `storage/` folders | -| [09](09-thread.md) | `thread` | Run `Dispatchable` tasks (alias `th`) | | [10](10-di.md) | `di` | Build / clean / show DI scanner cache | | [11](11-complete.md) | `complete`| Shell-completion endpoint (internal) | | [12](12-schedule.md) | `schedule`| Run the scheduler; list `#[Scheduled]` tasks (alias `sch`) | diff --git a/docs/console/04-run.md b/docs/console/04-run.md index 94d845d..453aa28 100644 --- a/docs/console/04-run.md +++ b/docs/console/04-run.md @@ -38,9 +38,9 @@ a warning and exits. | `--max_request_grace=` | off | `max_request_grace` | | `-w` / `--watcher` | off | Enable `MemoryWatcher` to recycle workers on RSS pressure | -CLI options **override** the base config returned by -`Boot::swooleConfig()` (`Flytachi\Winter\K2\BaseBoot`). Anything not -overridden falls back to the Boot class's value or Swoole defaults. +CLI options are read by `ServerSettings::fromEnv()` together with the `SERVER_*` +environment variables; a discovered `WebConfigurer::configureServer()` then tunes the +result. Anything left alone falls back to Swoole's own defaults. ### Runtime behavior @@ -53,7 +53,8 @@ On start, `run`: 5. Swaps the log context to `CoroutineContext` so per-request fields (request_id, user_id, …) are isolated per coroutine. 6. Sets the `http` log channel as default. -7. Maps `Router::static(Kernel::$pathPublic)` for static assets. +7. Applies the Swoole options built from `.env`, CLI flags and the `WebConfigurer` + (static files among them, when `staticPath()` was declared). 8. Sets a descriptive `cli_set_process_title()` for `ps` visibility. 9. With `-w`: wraps the request handler in `MemoryWatcher` to track per-request memory growth. @@ -73,7 +74,7 @@ call run --host=127.0.0.1 --port=9501 ## `call run dev` — PHP built-in dev server A `passthru` wrapper around `php -S`. No Swoole, no workers, no -coroutines — just one process serving from `Kernel::$pathPublic`. +coroutines — just one process serving the declared static directory. ### Options @@ -121,5 +122,5 @@ Use this only for local development — there is no concurrency. - [`../architecture/01-routing.md`](../architecture/01-routing.md) — how routes are scanned - [07-mapping.md](07-mapping.md) — `Router` cache management -- [`../configuration/01-kernel.md`](../configuration/01-kernel.md) — `Boot::swooleConfig()` +- [`../configuration/08-runtime.md`](../configuration/08-runtime.md) — server settings and the runtime - [`../configuration/02-logging.md`](../configuration/02-logging.md) — per-coroutine log context diff --git a/docs/console/06-db.md b/docs/console/06-db.md index 46c95a5..dd61903 100644 --- a/docs/console/06-db.md +++ b/docs/console/06-db.md @@ -20,6 +20,7 @@ call db [-flags] [--plugin= | --plugins] | `ping` | Connect to every configured DB, report driver/DSN/latency | | `migrate` | Run DDL against connected databases | | `sql` | Render DDL to stdout (no execution) | +| `pool` | Show connection-pool utilisation of the running server | `migrate` and `sql` are filtered by **kind flags** and **plugin scope**. @@ -115,6 +116,38 @@ with no `#[Table]` entities is also skipped. --- +## `call db pool` + +Shows how loaded the connection pools are, aggregated over the workers of a **running** +server: + +```bash +call db pool +``` + +``` +App\Config\AppDb + active 12 · idle 3 · total 15 · maximum 20 · workers 2 + saturated 1 of 2 workers [SATURATED] +per worker + worker#0 App\Config\AppDb active=2 idle=3 total=5 max=10 age=0s + worker#1 App\Config\AppDb active=10 idle=0 total=10 max=10 age=3s +``` + +A pool lives inside the server's memory and the CLI is a separate process, so this reads +what each worker publishes to the shared store — not the pool itself. Two consequences: + +- numbers are as fresh as the last publish (the `age` column), controlled by + `PPA_POOL_TELEMETRY` (seconds, default `5`, `0` disables); +- with no server running, the command says so instead of printing an empty table. + +**Saturation is counted per worker**, never derived from the totals: a borrow queues on +its own worker's pool, so one blocked worker matters even while the fleet looks roomy. + +See [`../ppa/17-pool.md`](../ppa/17-pool.md). + +--- + ## `call db sql` Same scanning + DDL generation as `migrate`, but writes statements to diff --git a/docs/console/07-mapping.md b/docs/console/07-mapping.md index 3ed1058..94b13fa 100644 --- a/docs/console/07-mapping.md +++ b/docs/console/07-mapping.md @@ -3,7 +3,7 @@ Manages the on-disk route cache. Routing is normally scanned from controllers at boot via `Router::fromScan(Kernel::$pathRoot)`; on every boot. For production you compile that scan into a single PHP file with -`mapping build`, and `BaseBoot` loads it with `Router::fromCache()` +`mapping build`, and the boot loads it with `Router::fromCache()` instead. --- @@ -45,8 +45,8 @@ call mapping build Run this in your image build / deploy step so production cold starts don't pay for the controller scan. -`Router::fromCache()` is what `BaseBoot::boot()` prefers when the cache -file exists; `fromScan()` is the live fallback. +`Router::fromCache()` is what the boot prefers when the cache file exists; +`fromScan()` is the live fallback. --- diff --git a/docs/console/09-thread.md b/docs/console/09-thread.md deleted file mode 100644 index bfdefb3..0000000 --- a/docs/console/09-thread.md +++ /dev/null @@ -1,253 +0,0 @@ -# `call thread` (`call th`) — run Dispatchable tasks & manage daemons - -Runs a `Dispatchable` class — a queue job, long-lived process, websocket -handler, or daemon — in the foreground (blocking) or detached as a -background process. Daemons get a full lifecycle: start, stop, status and -a live overview. - -Alias: **`th`**. - ---- - -## Synopsis - -``` -call thread list -call thread daemons -call thread [-d] -call thread [start | stop | status [-v]] [-d] -``` - -| Command | Purpose | -|---------------------------------|----------------------------------------------------------------| -| `list` | List every non-abstract `Dispatchable` class, tagged by kind | -| `daemons` | List daemons with live state, fork count and uptime | -| `` | Foreground — `Class::start()` (blocking) | -| ` -d` | Background — `Class::dispatch()` (returns child PID) | -| `` | Toggle: stop if running, else start in foreground | -| ` -d` | Toggle: stop if running, else start in background | -| ` start` | Start the daemon in background (`::dispatch()`) | -| ` stop` | Stop the running daemon (`::stop()`) | -| ` status` | Show daemon state, PID, uptime, fork count | -| ` status -v` | Detailed: process resources + child fork list | - -There is no `run` sub-command — the class is passed directly. The runner -requires the `pcntl` extension for async signal handling; without it, -running a thread exits with a warning. - -Class resolution is dot-notation: `ucfirst` each segment, `.` → `/` → `\` -(e.g. `main.threads.ExampleJob` → `Main\Threads\ExampleJob`). A missing -class or one that doesn't implement `Dispatchable` produces a clear -warning instead of a stack trace. - ---- - -## `call thread list` - -Lists every non-abstract class implementing `Dispatchable`, discovered -across the project and registered plugins. The badge tells you which kind -it is: - -```bash -call thread list -``` - -``` - | [============ Thread ============] - | [ Available Threads ] - | Main.TestDaemon ............... [Daemon] - | Main.S1Job ..................... [Job] - | Main.TestJob ................... [Job] - | Main.TestProcess ............... [Process] - | [ Available Threads ] -``` - -`Daemon` is highlighted in a distinct colour; `Job` / `Process` and any -other `Dispatchable` (`Dispatchable` badge) share the default colour. - ---- - -## `call thread daemons` - -A focused view of `ThreadDaemon` subclasses with their **live** state: - -```bash -call thread daemons -``` - -``` - | [ Available Daemons ] - | Main.TestDaemon ............... [● RUNNING] [forks:2] 2m 14s - | Main.Cleanup .................. [○ STOPPED] - | [ Available Daemons ] -``` - -For a running daemon the row shows the active fork count and uptime; -stopped daemons show just the state. - ---- - -## Running a thread (Job / Process) - -Resolves the class, confirms it implements `Dispatchable`, then: - -| Mode | Call | Behavior | -|-------------------|----------------------|---------------------------------------------------| -| Foreground | `$class::start()` | Blocks until the task finishes — output & signals on the current TTY | -| Background (`-d`) | `$class::dispatch()` | Forks a detached child; prints the new PID | - -```bash -call thread main.threads.ExampleJob # foreground -call thread main.threads.ExampleJob -d # detach, prints PID -``` - -``` - | [✓] Dispatched: Main\Threads\ExampleJob - | PID 48213 -``` - ---- - -## Managing a daemon - -When the resolved class is a `ThreadDaemon`, `thread` switches to -lifecycle mode. - -### Toggle (no sub-command) - -`call thread ` is a smart toggle: - -- **running** → stops it (prints the PID it stopped); -- **stopped** → starts it in the **foreground** (blocking), or in the - **background** with `-d`. - -```bash -call thread main.threads.Cleanup # stopped → foreground start; running → stop -call thread main.threads.Cleanup -d # stopped → background start; running → stop -``` - -`-d` is only read in this toggle (no-command) mode — start/stop/status -ignore it. - -### `start` / `stop` - -```bash -call thread main.threads.Cleanup start # always background dispatch -call thread main.threads.Cleanup stop -``` - -``` - | [✓] Started: Main\Threads\Cleanup - | PID 48213 -``` - -``` - | [✓] Stopped: Main\Threads\Cleanup - | PID 48213 -``` - -Starting an already-running daemon, or stopping one that isn't running, -prints a clear warning instead of an error. - -### `status` and `status -v` - -`status` is the lightweight view; add `-v` for resource stats and the -child-fork list. - -```bash -call thread main.threads.Cleanup status -call thread main.threads.Cleanup status -v -``` - -``` - | [ Daemon Status ] - | Main.Threads.Cleanup ............... [● RUNNING] - | - - - - - - - - - - - - - - - - - - - - - | PID 48213 - | Condition ACTIVE - | Started 2026-06-29 09:16:18 +00:00 - | Uptime 2m 14s - | Stream RPS 4 - | Forks 2 - | [ Daemon Status ] -``` - -With `-v`, two more sections are appended: - -``` - | - - - - - - - - - - - - - - - - - - - - - | [ Resources ] - | User www-data - | PPID 1 - | CPU 0.1 % - | Memory 0.4 % (24.5 MB) - | Elapsed 02:14 - | Command php .../wKernelExecutor ... - | - - - - - - - - - - - - - - - - - - - - - | [ Forks (2) ] - | #48261 ACTIVE 2m 10s cpu 0.1% rss 8.2 MB - | #48262 WAITING 2m 09s cpu 0.0% rss 7.8 MB - | [ Daemon Status ] -``` - -A stopped daemon prints `[○ STOPPED]` and a one-line note. - ---- - -## Foreground vs background - -| Use foreground when… | Use `-d` / background when… | -|-------------------------------------------------|---------------------------------------------| -| You want output in your terminal | You want to detach and walk away | -| It's a one-off job | Starting a long-running daemon / worker | -| You're inside a systemd/Docker entrypoint | You need the PID to track or signal later | - -For production daemons, prefer a real supervisor (systemd, supervisord, -Docker `restart: always`) pointing at the daemon in **foreground** — let -the supervisor own the process lifecycle. The `start`/`stop`/`-d` actions -are for ad-hoc management from a shell. - ---- - -## Examples - -```bash -call thread list -call thread daemons -call thread main.threads.ExampleJob -call thread main.threads.ExampleJob -d -call thread main.threads.Cleanup # toggle (foreground) -call thread main.threads.Cleanup -d # toggle (background) -call thread main.threads.Cleanup start -call thread main.threads.Cleanup status -call thread main.threads.Cleanup status -v -call thread main.threads.Cleanup stop -``` - ---- - -## Notes - -- `pcntl_async_signals(true)` is enabled before running a thread so Ctrl-C - and `kill` behave predictably in foreground mode. -- `list`, `daemons`, and the `Complete` suggestion engine share the same - unified class discovery (`ClassScanner` + collectors) — newly added - classes appear immediately after autoload regenerates. -- Tab-completion is context-aware: after a daemon class it suggests - `start` / `stop` / `status` / `-d`; after `status` it suggests `-v`; - after a Job/Process it suggests `-d`. - ---- - -## Source - -- `console/Command/Thread.php` -- Discovery: `src/Core/ClassScanner.php`, `src/Collector/{ImplementorCollector,SubclassCollector}.php` -- Contract: `src/Process/Core/Dispatchable.php` -- Stereotypes that implement it: `src/Stereotype/{Job,Daemon,Process,WebSocket}.php` -- Engine: `src/Process/Thread{Job,Daemon,Process}.php` - -## See also - -- [02-make.md](02-make.md) — scaffold a `Job` / `Daemon` / `Process` / `WebSocket` -- [05-script.md](05-script.md) — the analogous `Cmd`/`CmdCustom` runner diff --git a/docs/console/10-di.md b/docs/console/10-di.md index c3500ca..904e5c6 100644 --- a/docs/console/10-di.md +++ b/docs/console/10-di.md @@ -35,8 +35,8 @@ Forces a fresh scan: 1. Unlinks the existing cache file (and invalidates opcache for it). 2. Runs `Scanner::run(rootDir: Kernel::$pathRoot, cache: $cachePath)` - with `new DICollector(Container::init())` — the same call - `BaseBoot::boot()` makes during normal startup. + with `new DICollector(Container::init())` — the same call the application + boot (`WinterApplication::bootstrap()`) makes during normal startup. 3. Invalidates opcache again so subsequent loads see the new file. 4. Reports the discovered class count. diff --git a/docs/console/11-complete.md b/docs/console/11-complete.md index e61242f..1b0a243 100644 --- a/docs/console/11-complete.md +++ b/docs/console/11-complete.md @@ -161,4 +161,4 @@ Each prints the candidate list to stdout, one per line. - [03-cfg.md](03-cfg.md#cfg-completion--shell-tab-completion) — installing completion - [01-help.md](01-help.md) — `call help` discovery - [05-script.md](05-script.md) — `call sc list` (same discovery as `sc` completion) -- [09-thread.md](09-thread.md) — `call thread list` / `daemons` (same discovery as `thread` completion) +- [../process/03-control.md](../process/03-control.md) — `call process` / `call daemon`, which the completion discovers the same way diff --git a/docs/ppa/17-pool.md b/docs/ppa/17-pool.md index 0a1a179..ca4a592 100644 --- a/docs/ppa/17-pool.md +++ b/docs/ppa/17-pool.md @@ -1,25 +1,44 @@ # Pool — Connection Pool -`PpaConnectionPool` is the unified connection manager for both **FPM** and **Swoole** runtimes. -It replaces CDO's `ConnectionPool` with a driver-agnostic, pool-aware alternative. +`PpaConnectionPool` is the connection manager for both runtimes. Under Swoole it is a +pool of live connections shared by coroutines; on a plain process it is a single +connection kept healthy for the life of the process. + +The reason it exists is not reuse but **resilience**. Under FPM a database outage healed +itself for free: the process died and the next one reconnected. A long-lived worker keeps +its connections in memory, and a plain channel-based pool hands the same dead sockets out +forever once the database has gone away and come back. This pool is modelled on HikariCP +and actively keeps its connections usable. --- ## How it works | Runtime | Behaviour | -|---------|-----------| -| **FPM** | One `CDO` per config class per process. Reused for the entire request. | -| **Swoole** | One `Swoole\ConnectionPool` (backed by `Swoole\Coroutine\Channel`) per config class. Connections are borrowed on first `db()` call inside a coroutine and automatically returned via `defer` when the coroutine ends. | +|---|---| +| **Swoole** | One pool per config class over a `Coroutine\Channel`. A connection is borrowed on the first `db()` call inside a coroutine, cached for that coroutine, and returned automatically by a `defer` when it ends. | +| **Plain process** (console, `call process`, FPM) | One self-maintaining connection per config class, kept for the life of the process. | + +Both apply the same lifecycle rules on every borrow: + +- **Idle-gated validation.** A connection idle longer than `aliveBypassWindow` (500 ms) + is probed before it is handed out; a dead one is retired and replaced. A connection in + active use skips the probe entirely, so healthy traffic pays nothing. +- **maxLifetime rotation.** A connection older than `maxLifetime` (30 min, jittered) is + replaced before the server can drop it. +- **connectionTimeout.** A borrow waits at most `poolWaitTimeout` for a free connection, + then fails fast with `PpaPoolException`. -Broken connections in Swoole mode: pass `null` to `Swoole\ConnectionPool::put()` — the pool discards and recreates the slot automatically. +> A `SELECT 1` on *every* borrow is deliberately **not** what happens — that costs a +> round trip per query and churns healthy connections. Only connections that actually sat +> idle are probed. --- ## Making a config pool-aware -By default every config class gets **1 connection** (safe, consistent with FPM). -To increase the pool size implement `PpaPoolConfigInterface` via `PpaPoolTrait`: +By default a config gets a small pool with the lifecycle rules above. To tune it, +implement `PpaPoolConfigInterface` via `PpaPoolTrait`: ```php use Flytachi\Winter\Cdo\Config\PgDbConfig; @@ -30,8 +49,8 @@ class AppDb extends PgDbConfig implements PpaPoolConfigInterface { use PpaPoolTrait; - public int $poolMaxConnections = 10; // max simultaneous CDO connections - public float $poolWaitTimeout = 5.0; // seconds to wait before PpaPoolException + public int $poolMaxConnections = 10; // upper bound + public float $poolWaitTimeout = 5.0; // seconds before PpaPoolException public function setUp(): void { @@ -44,69 +63,137 @@ class AppDb extends PgDbConfig implements PpaPoolConfigInterface } ``` +The trait supplies defaults for every knob, so a config declares only what it changes and +never breaks when a new one is added. + +| Property | Default | Meaning | +|---|---|---| +| `$poolMaxConnections` | `5` | upper bound on connections per config | +| `$poolWaitTimeout` | `3.0` | seconds to wait for a free connection | +| `$keepaliveTime` | `0` (off) | background probe of connections idle at least this long | +| `$idleTimeout` | `0` (never) | close connections idle at least this long, down to `$minimumIdle` | +| `$minimumIdle` | `0` (lazy) | warm connections to keep open | + +The last three drive a background housekeeper and are **Swoole-only** — a plain process +has no timer to run them on. Leave them at zero and no timer is ever armed. + +Sizing matters against the server: `worker_num × poolMaxConnections × instances` must +stay under the database's `max_connections`. + --- -## API +## Failure handling -### `PpaConnectionPool::db(string $configClass): CDO` +A query can fail for two very different reasons, and the pool separates them: + +- **The connection died** — SQLSTATE class `08`, PostgreSQL `57P01/02/03`, MySQL driver + codes 2006/2013/2055. The connection is evicted instead of returned, so the next + borrow — including the next query in the same request — gets a fresh one. +- **The query was rejected** — a constraint violation (`23xxx`), a syntax error + (`42xxx`), a deadlock. The server is healthy; the connection is left alone. + +PostgreSQL needs care here: PDO does not report a lost connection as `08006`. With the +socket gone there is no result to take a SQLSTATE from, so it arrives as `HY000` with +libpq's generic code `7` — the same code an ordinary syntax error carries. When the +driver's verdict is that inconclusive, the pool probes the connection and decides from +the answer. -Returns an active `CDO` for the given config class. -- FPM: process-level singleton. -- Swoole: borrows from the pool on first call per coroutine, auto-releases on coroutine end. +**The failed statement is never retried.** The pool cannot know what ran: the break may +have happened after the server applied the write, so a replay could duplicate it, and +replaying one statement of an interrupted transaction is meaningless. One request fails; +the connection is thrown away. + +Repositories report failures automatically. Code that uses `db()` directly can do the +same: ```php -use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; +try { + $cdo->query($sql); +} catch (Throwable $e) { + PpaConnectionPool::reportFailure(AppDb::class, $e); // evicts only on a real loss + throw $e; +} +``` + +--- + +## Observability + +Each worker holds its own pool, so numbers are **per worker** — a saturated worker is a +real stall even when the fleet total looks roomy. + +```bash +php call db pool +``` + +reads what the workers publish and prints the fleet: -$cdo = PpaConnectionPool::db(AppDb::class); -$rows = $cdo->query("SELECT * FROM users")->fetchAll(); ``` +App\Config\AppDb + active 12 · idle 3 · total 15 · maximum 20 · workers 2 + saturated 1 of 2 workers [SATURATED] +per worker + worker#0 App\Config\AppDb active=2 idle=3 total=5 max=10 age=0s + worker#1 App\Config\AppDb active=10 idle=0 total=10 max=10 age=3s +``` + +The CLI is a separate process and cannot read a running server's memory, so each worker +publishes its stats to the shared store on a timer — `PPA_POOL_TELEMETRY` (seconds, +default `5`, `0` disables). Records carry a TTL of three intervals, so a worker that +stops simply expires; a worker holding no pool writes nothing at all. + +The same numbers appear in the `db` component of `/actuator/health`, nested under the +datasource they belong to, where a saturated pool marks it `degraded`. + +--- + +## API + +### `PpaConnectionPool::db(string $configClass): CDO` + +Returns a live connection. Inside a coroutine it borrows one and registers the automatic +return; elsewhere it hands back the process-wide connection. Throws `PpaPoolException` +when no connection can be obtained in time. ### `PpaConnectionPool::getConfigDb(string $configClass): DbConfigInterface` -Returns the initialised (after `setUp()`) config instance. -Cached after first access — `setUp()` is called only once per config class. +The initialised config instance (`setUp()` already called), cached per class. ### `PpaConnectionPool::showDbConfigs(): DbConfigInterface[]` -Returns all registered config instances. Used internally by Health checks. +Every registered config — for diagnostics. ---- +### `PpaConnectionPool::stats(): array` -## `PpaPoolConfigInterface` +Live utilisation of each pool in **this** process: `total`, `idle`, `active`, `maximum` +keyed by config class. -```php -interface PpaPoolConfigInterface -{ - public function getPoolMaxConnections(): int; // default via trait: 5 - public function getPoolWaitTimeout(): float; // default via trait: 3.0 -} -``` +### `PpaConnectionPool::reportFailure(string $configClass, Throwable $e): bool` + +Classifies a failure and evicts the connection when it is genuinely lost. Returns whether +it evicted. -### `PpaPoolTrait` +### `PpaConnectionPool::reset()` / `::shutdown()` -Drop-in implementation of `PpaPoolConfigInterface`. -Does **not** declare properties — define `$poolMaxConnections` and `$poolWaitTimeout` -directly on your class to override the defaults. +Two opposite things, and the difference matters: -| Property | Type | Default | -|----------|------|---------| -| `$poolMaxConnections` | `int` | `5` | -| `$poolWaitTimeout` | `float` | `3.0` | +- **`reset()`** — for a **forked child**: forget inherited connections **without closing + them**, since the sockets still belong to the parent. Registered as the fork-safety + reset, so a daemon worker reconnects on its own. +- **`shutdown()`** — for a process that genuinely owns its connections: close them + properly and release the housekeeping timers. The kernel calls it on worker exit. --- ## Exceptions -`PpaPoolException` is thrown when: -- Pool is exhausted and `$poolWaitTimeout` is exceeded. -- Connection factory throws during slot creation. +`PpaPoolException` — no connection could be obtained: the pool was exhausted within +`poolWaitTimeout`, or opening one failed. -```php -use Flytachi\Winter\K2\Ppa\Pool\PpaPoolException; +--- -try { - $cdo = PpaConnectionPool::db(AppDb::class); -} catch (PpaPoolException $e) { - // log and return 503 -} -``` +## See also + +- [`02-configuration.md`](02-configuration.md) — declaring a database config +- [`../configuration/04-health.md`](../configuration/04-health.md) — the actuator report +- [`../console/06-db.md`](../console/06-db.md) — `call db` diff --git a/docs/process/daemon/01-workers.md b/docs/process/daemon/01-workers.md index e752a60..7e54b85 100644 --- a/docs/process/daemon/01-workers.md +++ b/docs/process/daemon/01-workers.md @@ -30,7 +30,7 @@ per-worker fleet table shows the underlying zero-based `SLOT`. The supervisor is a plain `pcntl` loop with **no event loop running**, so it forks each worker with `pcntl_fork()`. Forking before any reactor starts is safe — the child then boots its own clean Swoole coroutine runtime (or a plain fork runtime -without Swoole). Fork is the right tool here, not the [Thread](../../../vendor/flytachi/winter-thread/docs/README.md) +without Swoole). Fork is the right tool here, not the Thread launcher, because supervision needs the direct parent↔child relationship: exact `waitpid` exit codes, reaping, and per-slot signalling. (Thread's detached launch re-parents to init and *loses* that relationship; it is used one level up, to send diff --git a/docs/starter.md b/docs/starter.md deleted file mode 100644 index ca0a434..0000000 --- a/docs/starter.md +++ /dev/null @@ -1,455 +0,0 @@ -# Winter — Project Starter - -The recommended way to start a Winter project is the -[`flytachi/winter`](https://github.com/flytachi/winter) starter -repository. It is a minimal Composer **project** package that pulls in -`flytachi/winter-kernel`, ships a single `Boot` class with every hook -documented in-place, and is ready to run via FPM or CLI after one -command. - -This page walks the starter end-to-end so you can rebuild it by hand -in an existing repo, or simply understand what each file does. - ---- - -## TL;DR — create a project - -```bash -composer create-project flytachi/winter my-app -cd my-app -php call run dev # http://0.0.0.0:8000 -``` - -`composer create-project` runs `post-create-project-cmd`, which does: - -1. `chmod -R 777 storage` -2. `php call cfg init` — patches `composer.json` (rewrites `name` to - `project/`, blanks `authors`, removes keywords), copies `.env` - from template, generates a fresh 64-char `WINTER_KEY`, and drops the - PhpStorm meta stub. -3. Prints a tip about installing shell completion. - -After that you have a working project — open `main/MainController.php` -and start writing. - ---- - -## Directory layout - -``` -my-app/ -├── bootstrap.php — defines Boot extends BaseBoot (all hooks documented) -├── call — CLI entry; runs Boot::cli($argv) -├── composer.json — project package; depends on flytachi/winter-kernel -├── public/ -│ ├── index.php — FPM / Swoole front controller; runs Boot::web() -│ └── static/ — web-served assets -├── main/ — PSR-4 root (namespace Main\) -│ └── MainController.php -├── storage/ -│ ├── cache/ — kernel + app caches (mapping.php, di.php, …) -│ └── logs/ — log files when LOG_OUTPUT=file -├── .env — environment variables (WINTER_KEY, LOG_*, etc.) -└── vendor/ -``` - -Three things you'd add as the project grows: - -| Add when… | Path | -|-------------------------------------------|---------------------| -| You need view templates | `resources/` | -| You add a Swoole runtime | `server.php` | -| You need a thread/job executor binary | `wKernelExecutor` | - ---- - -## `composer.json` - -```json -{ - "name": "flytachi/winter", - "type": "project", - "scripts": { - "post-create-project-cmd": [ - "chmod -R 777 storage", - "@php call cfg init" - ], - "dev-server": "@php -S 0.0.0.0:8000 -t ./public" - }, - "autoload": { - "psr-4": { "Main\\": "main/" } - }, - "require": { - "php": ">=8.3", - "flytachi/winter-kernel": "^3.0" - } -} -``` - -Key points: - -- **`type: project`** — not a library; it's an application scaffold. -- **One PSR-4 prefix** (`Main\\` → `main/`) — add more as you split - things into modules. The kernel's `Scanner` and `Router` follow - every PSR-4 root, so any additional namespace is auto-discovered. -- **`post-create-project-cmd`** delegates the heavy lifting to - `cfg init` (see [`console/03-cfg.md`](console/03-cfg.md#cfg-init)) so - the recipe stays single-source. - ---- - -## `bootstrap.php` — the `Boot` class - -The whole framework configuration lives in **one file**: a class that -extends `BaseBoot` and overrides only the hooks you need. Hooks are -called in a fixed order from every entry point. - -### Hook order - -``` -1. configure() ← Kernel::init() — paths, .env, logging, timezone -2. DI scan ← auto-discovers #[Singleton] / #[Request] / #[Transient] -3. providers($c) ← manual bindings, factories, scalar values -4. channels() ← extra log channels beyond http / sys -5. plugins() ← route-prefixed sub-applications -6. httpCors() ← global CORS policy -7. health() ← /actuator endpoints -``` - -### Minimal `Boot` - -```php -register(AppServiceProvider::class); - // $c->singleton(CacheInterface::class, RedisCache::class); - // $c->bind(MailerInterface::class, fn(Container $c) => - // new SmtpMailer(env('MAIL_HOST'), $c->make(LoggerInterface::class)) - // ); - // $c->set('config.timeout', (int) env('APP_TIMEOUT', 30)); - } - - /** - * Logging — extra channels beyond http / sys. - * Each channel reads LOG_{NAME}_* env with the same fallback chain. - */ - protected static function channels(): void - { - // Kernel::channel('job'); - // Kernel::channel('daemon'); - } - - /** - * CORS — global policy, applied to every response (incl. 404/500). - * Per-route overrides via #[CrossOrigin]. - */ - protected static function httpCors(): void - { - // Cors::configure( - // origins: ['https://app.example.com'], - // allowHeaders: ['Content-Type', 'Authorization'], - // credentials: true, - // maxAge: 3600, - // ); - } - - /** - * Health — diagnostic endpoints under /actuator. - * /actuator full report - * /actuator/health up | degraded | down - * /actuator/info PHP / SAPI / framework meta - * /actuator/metrics CPU / memory / disk / opcache / uptime - * /actuator/env custom env values - * /actuator/loggers active channels and levels - * /actuator/mappings registered route table - */ - protected static function health(): void - { - // Health::configure(); - // Health::configure( - // indicator: App\Health\AppHealthIndicator::class, - // middleware: App\Http\Middleware\InternalOnlyMiddleware::class, - // ); - } - - /** - * Plugins — route-prefixed sub-applications. Each plugin's src/ - * is scanned for controllers automatically. - */ - protected static function plugins(): void - { - // Plugin::registry('acme/auth', '/auth'); - // Plugin::registry('acme/billing', '/billing'); - } -} -``` - -Every hook is `protected static` — override only what you need. The -defaults are no-ops or sane fallbacks. For deep reference, see -[`configuration/01-kernel.md`](configuration/01-kernel.md). - ---- - -## Entry points - -Each runtime has its own one-liner: - -| File | Runtime | Body | -|-------------------------|-----------------------|-------------------| -| `public/index.php` | PHP-FPM / built-in dev| `Boot::web()` | -| `server.php` (optional) | Swoole HTTP server | `Boot::swoole()` | -| `call` | CLI | `Boot::cli($argv)`| -| `wKernelExecutor` (optional) | Thread / job runner | `Boot::executor($argv)` | - -### `public/index.php` - -```php -= 80300) { - chdir(__DIR__); - require './bootstrap.php'; - Boot::cli($argv); -} else { - echo "Please use PHP version 8.3 or higher.\n"; -} -``` - -Three things to note: - -1. **PHP version guard** — fail fast with a clear message instead of a - parse error on older runtimes. -2. **`chdir(__DIR__)`** — pins CWD to the project root so the `.env` - lookup and relative paths inside `bootstrap.php` resolve correctly - no matter where you invoke `call` from. -3. **`Boot::cli($argv)`** — dispatches to `console/Command/*` (see - [`console/00-overview.md`](console/00-overview.md)). - -Make sure it's executable: `chmod +x call`. - ---- - -## `.env` - -Minimal starter: - -```dotenv -WINTER_KEY=72aedae44ec31dec144eb297bdfcf64005b250b0562f428ab50bdd9da2e521d2 -TIME_ZONE=UTC -DEBUG=true - -LOG_LEVEL=info -LOG_FORMAT=line -LOG_OUTPUT=auto -#LOG_FILE=/var/log/app/winter.log -LOG_FILE_MAX=30 -``` - -Variables: - -| Variable | Effect | -|--------------------|------------------------------------------------------------------------| -| `WINTER_KEY` | 32-byte project secret — signing keys, tokens | -| `TIME_ZONE` | `date_default_timezone_set()` source | -| `DEBUG` | `true` — disables route + DI caches (always live scan), surfaces stack traces in logs and exception responses | -| `LOG_LEVEL` | Minimum severity: `DEBUG / INFO / NOTICE / WARNING / ERROR / …`. **Empty → logging disabled** (NullLogger on all channels) | -| `LOG_FORMAT` | `line` or `json` | -| `LOG_OUTPUT` | `auto / stdout / stderr / syslog / file / null` — `auto` → `stdout` everywhere (whatever runs the process captures it) | -| `LOG_FILE` | Absolute path when `LOG_OUTPUT=file` | -| `LOG_FILE_MAX` | Number of daily-rotating files to keep | - -For per-channel overrides (`LOG_HTTP_*`, `LOG_SYS_*`, custom channels) -see [`configuration/02-logging.md`](configuration/02-logging.md). - -**Regenerate the key** any time: - -```bash -php call cfg key -g -``` - ---- - -## `main/MainController.php` - -The starter ships one controller as a smoke test: - -```php - -- **Kernel**: -- **`BaseBoot`**: `vendor/flytachi/winter-kernel/src/BaseBoot.php` -- **`Kernel::init()`**: `vendor/flytachi/winter-kernel/src/Kernel.php` - -## See also - -- [`configuration/01-kernel.md`](configuration/01-kernel.md) — every `Boot` hook in depth -- [`configuration/02-logging.md`](configuration/02-logging.md) — `LOG_*` env reference -- [`configuration/03-cors.md`](configuration/03-cors.md) — global vs per-route CORS -- [`configuration/04-health.md`](configuration/04-health.md) — `/actuator` setup -- [`configuration/05-plugins.md`](configuration/05-plugins.md) — `Plugin::registry()` -- [`configuration/06-db.md`](configuration/06-db.md) — DB config classes -- [`console/00-overview.md`](console/00-overview.md) — the `call` CLI -- [`console/03-cfg.md`](console/03-cfg.md) — `cfg init` / `key` / `env` / `docker` -- [`architecture/01-routing.md`](architecture/01-routing.md) — controller discovery diff --git a/docs/starter/00-quickstart.md b/docs/starter/00-quickstart.md index 3aa7f25..9755706 100644 --- a/docs/starter/00-quickstart.md +++ b/docs/starter/00-quickstart.md @@ -1,437 +1,284 @@ # Winter — Quickstart (from zero to running) -This is the shortest honest path from an empty folder to a running Winter -application. You just did: +A Winter project is not scaffolded. There is no skeleton to clone and no directory tree +to create: you require the kernel, write one class that says what the application +contains, and run it. Everything else — the DI graph, the route table, the storage +directories — is discovered or created on demand. -```bash -mkdir my-app && cd my-app -composer require flytachi/winter-kernel -``` - -Now you have `vendor/` and nothing else. This page adds the handful of files a -project needs, explains the **one entry point** (`App::run()`), and shows how to -run the app. - -If you would rather not type these files by hand, skip to -[Using the starter template](#using-the-starter-template) — `composer -create-project flytachi/winter` writes all of them for you. +This page walks that from an empty directory to a served request, then to a background +worker. For the shortest possible path, the [README](../../README.md) has it in two files. --- -## The mental model in one picture - -You write **one** application class that declares **what your app contains** — -its *components*. A component is any long-lived thing: the web server, a -background `Process`, a supervised `Daemon`, the `Scheduler`. One command brings -them all up in a single process. +## The mental model ``` - App::components() = [ http, process, daemon, scheduler ] - │ - php call run ← run the whole app (prod) - php call run dev ← same + MemoryWatcher (dev) - │ - ONE Swoole process - ┌─────────────┬──────────┬───────────┐ - HTTP :8000 Process Daemon Scheduler - (addProcess, supervised, co-terminating) +call ──► bootstrap.php ──► Application::main($argv) + │ + ├─ boot: Kernel::init → scan → DI, configurers, plugins + │ + ├─ `run` → serve the components in the manifest + └─ else → hand the verb to the console ``` -- The web tier is **just a component** (`Component::http()`), not a hard - requirement. Declare it and `call run` serves HTTP; omit it and the app runs - **headless** (background components only). -- **Swoole** hosts everything in one process, like a JVM. -- **FPM** hosts *only the web tier*, one request at a time — because php-fpm is - not your process. Anything long-lived runs as its own `call` process next to - it. (See [Deployment shapes](#deployment-shapes).) +Two ideas carry the design: -You do not choose a "runtime" per app. You list components; the substrate decides -how many share a process. +- **The manifest is declarative.** `#[Enable*]` attributes on the application class say + *what the application is made of*. Nothing is registered by hand. +- **Configuration is discovered, not injected.** There are no hooks to override on the + application class. A `WebConfigurer`, a `#[Configuration]` class, a `LoggingConfigurer` + — the scan finds them wherever they live. --- -## Step 1 — `composer.json` autoload +## Step 1 — composer + +```bash +composer require flytachi/winter-kernel +``` -`composer require` created a `composer.json`. Add one PSR-4 root so the kernel's -scanner and router can discover your classes: +Give your own code a namespace in `composer.json`: ```json { - "type": "project", "autoload": { "psr-4": { "Main\\": "main/" } - }, - "require": { - "php": ">=8.3", - "flytachi/winter-kernel": "^3.0" } } ``` -Then: - ```bash composer dump-autoload ``` -Every class under `main/` is now namespace `Main\`. Add more PSR-4 roots as the -project grows — the scanner follows all of them. +Nothing forces the name `Main\` or the directory `main/` — the scan walks the project +root, so any autoloadable layout works. ---- - -## Step 2 — `bootstrap.php` (your application class) +## Step 2 — the application class -This is the heart of the project — the equivalent of a Spring -`@SpringBootApplication`. It configures the kernel **and declares your -components**. +`bootstrap.php` is the only file the entry points share. It loads the autoloader and +declares the application: ```php = 80300) { - chdir(__DIR__); - require './bootstrap.php'; - App::run($argv); // ← the single entry point (the app's main()) -} else { - echo "Please use PHP 8.3 or higher.\n"; -} +chdir(__DIR__); +require './bootstrap.php'; +Application::main($argv); ``` -`App::run($argv)` boots once and dispatches the console command — `run`, -`run dev`, `make`, `daemon`, `schedule`, or your own. Make it executable: - -```bash -chmod +x call -``` +There is no `public/index.php`. That file belongs to the FPM document-root model, where +nginx needs a directory to aim at; the Swoole server decides for itself what it serves. -### `public/index.php` — the FPM web adapter - -FPM is not a persistent process, so it cannot go through `call run`. It gets its -own two-line front controller that runs the web tier per request: - -```php - $id]; } } ``` -No registration needed — it is discovered on scan. Confirm with: +Two requirements, both easy to miss: + +- the class **extends `Controller`** — the scan collects controllers by that, not by an + attribute, so a class carrying only mapping attributes contributes no routes; +- the class-level `#[RequestMapping]` prefix combines with each method path, making the + route above `/users/{id}`. ```bash -php call mapping show +php call run +curl localhost:8000/users/42 # {"id":42} ``` ---- +Method arguments are filled by annotation — `#[PathVariable]`, `#[RequestParam]`, +`#[RequestBody]`, `#[RequestHeader]` — or by type, where an `HttpRequest` or +`HttpResponse` parameter receives the raw object. See +[`../architecture/04-request/00-overview.md`](../architecture/04-request/00-overview.md). -## Step 6 — run it +## Step 5 — configuration, when you need it -Your folder now looks like this: +`.env` is optional and every variable has a working default. Development usually wants: -``` -my-app/ -├── bootstrap.php App extends Application (config + components) -├── call App::run($argv) ← the one entry -├── composer.json Main\ → main/ -├── public/index.php App::web() ← FPM adapter only -├── main/ -│ └── MainController.php -├── storage/{cache,logs}/ -├── .env -└── vendor/ +```dotenv +DEBUG=true +LOG_LEVEL=debug ``` -Run it: +Web settings live in a class the scan finds — not in the application class: -| Command | What runs | -|---|---| -| `php call run` | **Production** — every component in one Swoole process, MemoryWatcher off | -| `php call run dev` | **Development** — same, MemoryWatcher on | -| `php call ` | Console — `make`, `mapping`, `di`, your commands | -| nginx → `public/index.php` | Web only, under PHP-FPM | +```php + `call run` with a web tier needs ext-swoole (`pecl install swoole`). Without a -> web tier the app runs headless and works without swoole (each component picks -> its own engine). +final class WebConfig extends WebConfigurerAdapter +{ + public function configureServer(ServerSettings $server, ApplicationArguments $args): void + { + $server->port(8000) + ->workers(swoole_cpu_num() * 2) + ->staticPath('resources/static'); // resources/static/app.css → /app.css + } +} +``` ---- +Static serving is opt-in: omit `staticPath()` and no file is ever served, which is what +an API-only service wants. CORS is configured in the same class — see +[`../configuration/03-cors.md`](../configuration/03-cors.md), and +[`../configuration/02-logging.md`](../configuration/02-logging.md) for log channels. -## Step 7 — add a background component +## Step 6 — a background component -Say you want a worker that runs forever alongside the web server. Write it as a -`Process`: +Long-lived work is a **Process** (one worker) or a **Daemon** (a supervised fleet): ```php isRunning()) { - $this->sleep(3); - // ... periodic work ... + $this->sleep(1.0); + // ... work ... } } } ``` -Add one line to `components()`: +Run it on its own: -```php -protected static function components(): array -{ - return [ - Component::http(port: 8000), - Component::process(\Main\KernelSys::class), // ← new - ]; -} -``` - -Now `php call run` runs **both** in one Swoole process: - -``` -Application up: http://0.0.0.0:8000 + [KernelSys] +```bash +php call process main.process.EmailWorker start # foreground +php call process main.process.EmailWorker start -d # detached +php call process main.process.EmailWorker status +php call process main.process.EmailWorker stop ``` -`Ctrl-C` (SIGTERM) stops the server *and* `KernelSys` together — the Swoole -master supervises the companion and terminates it with the server. - -The same works for a `Daemon` (`Component::daemon(...)`) and the scheduler -(`Component::scheduler()`). Each companion behaves exactly as if you had launched -it standalone (`call daemon|process|schedule`). - -### Headless (no web) - -Drop `Component::http()` and `call run` runs only the background components — one -in the foreground, several under a small supervisor. Useful for a worker-only or -scheduler-only deployment: +…or beside the server, by adding it to the manifest: ```php -protected static function components(): array -{ - return [ - Component::daemon(\Main\Emails::class), - Component::scheduler(), - ]; -} +#[EnableWeb] +#[EnableProcess(\Main\Process\EmailWorker::class)] +final class Application extends WinterApplication { /* ... */ } ``` -``` -Application up (headless): [Emails, Scheduler] -``` +Scheduled methods work the same way — annotate with `#[Scheduled]`, add +`#[EnableScheduler]`. See [`../process/00-overview.md`](../process/00-overview.md) and +[`../schedule/00-overview.md`](../schedule/00-overview.md). --- -## Deployment shapes - -The same code runs two ways; only the process layout differs. - -### Swoole — all in one (the JVM shape) - -``` -php call run ── ONE process - ├─ HTTP :8000 - ├─ KernelSys (addProcess, supervised) - ├─ Emails daemon (addProcess, supervised) - └─ Scheduler (addProcess, supervised) -``` - -One command, one process, co-terminating. This is the recommended shape when the -app has any long-lived component. - -### FPM — web on fpm, everything else standalone +## Project layout -FPM only serves the web tier. Long-lived components run as their own processes -(systemd units, separate containers, `-d` detached): +Only two directories are conventional, and both appear on demand: ``` -nginx → php-fpm → public/index.php → App::web() # HTTP, per request -+ php call process main.KernelSys start -d # separate process -+ php call daemon main.Emails start -d # separate process -+ php call schedule start -d # separate process +composer.json +bootstrap.php the application class +call the entry point +main/ your code (any autoloadable namespace) +resources/ + static/ web assets — served only when staticPath() says so + views/ ResponseView's default root +storage/ + logs/ cache/ runnable/ created when first used, never committed ``` -`components()` does not change — under FPM the non-web entries are simply not -hosted by the request process; you start them yourself. One Docker image, and the -container's `command:` picks the role: - -```yaml -web: command: php call run # or php-fpm for the FPM shape -worker: command: php call daemon main.Emails start -scheduler: command: php call schedule start -``` - -> **Why FPM is the odd one out:** php-fpm master is not your process — it invokes -> your code per request and recycles the worker. There is no persistent loop for a -> daemon or scheduler to live in, so those always need their own process. If your -> app has a daemon or scheduler, you already need a persistent process — at that -> point Swoole (`call run`) is usually the simpler choice. - ---- +`storage/` is deliberately separate from `resources/`: one is written by the runtime, +the other is read by it. Views are `include`d PHP, so the directory the framework +executes from is never the directory it writes to. -## Cheat sheet - -```bash -# scaffold checklist (fresh clone) -composer install -mkdir -p storage/{cache,logs} && chmod -R 777 storage -chmod +x call -php call cfg key -g - -# run -php call run # production: all components, one process -php call run dev # development: + MemoryWatcher -php call mapping show # list routes - -# run a single component standalone (split / FPM deploy) -php call process main.KernelSys start [-d] -php call daemon main.Emails start [-d] -php call schedule start [-d] - -# production caches -php call mapping build -php call di build -``` - ---- - -## Using the starter template - -Everything above is generated for you by the starter repository: - -```bash -composer create-project flytachi/winter my-app -cd my-app -php call run -``` +## Deployment shapes -See [`../starter.md`](../starter.md) for the file-by-file breakdown of the -generated project. +| Shape | Command | Notes | +|---|---|---| +| Server | `php call run` | one long-lived Swoole process; log to stdout and let the orchestrator collect | +| Server + workers | `php call run` with `#[EnableProcess]` / `#[EnableDaemon]` | workers supervised beside the server | +| Headless | `php call run` with no `#[EnableWeb]` | workers and scheduler only, no HTTP | +| One-off | `php call ` | console commands, migrations, diagnostics | ---- +In containers keep `LOG_OUTPUT` at its default (stdout) and let the orchestrator collect +it; give `storage/` a volume only if the runtime records must survive a restart. -## See also +## Where to go next -- [`../configuration/01-kernel.md`](../configuration/01-kernel.md) — every `configure()` / hook option -- [`../process/00-overview.md`](../process/00-overview.md) — writing a `Process` -- [`../process/daemon/00-overview.md`](../process/daemon/00-overview.md) — writing a `Daemon` -- [`../schedule/00-overview.md`](../schedule/00-overview.md) — `#[Scheduled]` tasks -- [`../console/00-overview.md`](../console/00-overview.md) — the `call` CLI +- [`../architecture/01-routing.md`](../architecture/01-routing.md) — routing and the request pipeline +- [`../configuration/07-di.md`](../configuration/07-di.md) — dependency injection +- [`../ppa/00-overview.md`](../ppa/00-overview.md) — the database layer +- [`../process/00-overview.md`](../process/00-overview.md) — processes and daemons +- [`../console/00-overview.md`](../console/00-overview.md) — the console diff --git a/docs/threads/00-overview.md b/docs/threads/00-overview.md deleted file mode 100644 index fd306d3..0000000 --- a/docs/threads/00-overview.md +++ /dev/null @@ -1,263 +0,0 @@ -# Winter Threads — Overview - -The Threads unit covers everything the kernel does outside the HTTP -request cycle: one-shot background jobs, long-running worker pools, -singleton daemons, and WebSocket servers. - -All four thread types share a single hierarchy: - -``` -Runnable (winter-thread) - ↑ -Dispatchable (src/Process/Core/Dispatchable.php) - ↑ -Dispatch ─── abstract base for every thread type - ↑ - ├── ThreadJob ← Stereotype\Job - ├── ThreadProcess ← Stereotype\Process - ├── ThreadDaemon ← Stereotype\Daemon - └── ThreadWebSocket ← Stereotype\WebSocket -``` - -The four `Stereotype\*` classes are paper-thin aliases — your code -extends one of them, the framework owns everything else. - ---- - -## Stereotype map - -| Stereotype | Internal class | `exNamespace` | Forks? | Singleton? | Use case | -|-------------|-------------------|---------------|---------|------------|-----------------------------------| -| `Job` | `ThreadJob` | `job` | no | no | One-shot fire-and-forget work | -| `Process` | `ThreadProcess` | `process` | yes | no | Long-lived worker pool | -| `Daemon` | `ThreadDaemon` | `daemon` | yes | yes | Single supervised long-running service | -| `WebSocket` | `ThreadWebSocket` | `web-socket` | no | no | TCP WebSocket server | - -`exNamespace` shows up in `ps` via `cli_set_process_title()`, so you can -spot Winter processes at a glance: - -``` -Winter daemon -> runnable App.Daemons.Cleanup -Winter job(fork) -> fork App.Jobs.SendInvoice -``` - ---- - -## Entry points - -Every `Dispatchable` exposes two static entry points (defined on -`Dispatch`, made `final` on each thread type): - -| Method | Behavior | -|------------------------------|-------------------------------------------------------| -| `Class::start($data = null)` | Foreground — runs in the **current** process, blocks | -| `Class::dispatch($data = null)` | Background — forks via `Thread::start()`, returns child PID | - -`Daemon` additionally exposes: - -| Method | Behavior | -|---------------------------------|----------------------------------------------------| -| `Class::status(bool $showStats=false)` | `?TDInfo` — current PID, condition, optional `ps` stats | -| `Class::stop()` | `bool` — send SIGINT to the running instance | - -From the CLI: - -```bash -call thread app.threads.jobs.SendInvoice # foreground -call thread app.threads.jobs.SendInvoice -d # background -call thread list # discover Dispatchable classes -call thread daemons # daemons with live status -``` - -See [`../console/09-thread.md`](../console/09-thread.md). - ---- - -## Lifecycle inside `Dispatch::run()` - -``` -Dispatch::dispatch($data) / ::start($data) - ↓ -Container::make(static::class) ← DI: constructor injection + - #[Autowired] both work here - ↓ -DispatchStore::push($key, $data) ← only if $data is non-empty - ↓ -Thread::start(['storeKey' => $key]) ← fork (or run in-place for ::start) - ↓ (child / current process) -Dispatch::run($args) - ├── resolutionStart() ← logger + signal handler - ├── resolution($data) ← YOUR CODE - └── resolutionEnd() ← cleanup hook (per-stereotype) -``` - -`run()` wraps `resolution()` in `try/catch/finally`; uncaught throws are -logged through the resolved logger, and the child exits cleanly. - ---- - -## DI in threads - -`Dispatch::dispatch()` / `Dispatch::start()` instantiate the class via -`Container::getInstance()->make(static::class)`. That means **both -constructor injection and `#[Autowired]` properties work** the same way -as in controllers / services / console commands: - -```php -use Flytachi\Winter\K2\Stereotype\Job; -use Flytachi\Winter\DI\Attribute\Autowired; - -class SendInvoice extends Job -{ - #[Autowired] - private MailService $mail; - - public function __construct( - private InvoiceRepository $invoices, // also injected - ) {} - - public function resolution(mixed $data = null): void - { - $invoice = $this->invoices->find($data['id']); - $this->mail->send($invoice); - } -} -``` - -The container is re-resolved **inside the child process** (after fork), -so dependencies are fresh — DB handles, log channels, file streams are -not inherited from the parent. - ---- - -## Passing data into a thread (`DispatchStore`) - -Forked PHP processes don't share heap. To get data into the child, -`Dispatch` marshals `$data` through `DispatchStore`: - -``` -parent: Class::dispatch(['orderId' => 42]) - ↓ -DispatchStore::push("cache-abc123", ['orderId' => 42]) ← Kernel::volatile('dispatcher') - ↓ -Thread::start(arguments: ['storeKey' => 'cache-abc123']) ← fork - ↓ -child: $data = DispatchStore::pop("cache-abc123") ← read + delete - ↓ -resolution($data) -``` - -`pop()` is destructive — the key is removed after the read, so the -side-channel file is short-lived. For `::start()` (foreground) the same -mechanism is used; the data round-trip just happens in the same process. - -Pass anything serializable — arrays, scalars, DTOs. Don't pass live -resources (connections, file handles), they don't survive the round trip. - ---- - -## Logger - -Each child gets its own logger in `resolutionStart()`: - -```php -$this->logger = LoggerFactory::getLogger(static::class); -``` - -The class name is the channel, so per-thread logs sort naturally by -`App\Jobs\SendInvoice`, `App\Daemons\Cleanup`, etc. Configure log sinks -via the `LOGGER_*` env vars — see -[`../configuration/02-logging.md`](../configuration/02-logging.md). - ---- - -## Signal handling - -`ThreadSignalHandler` (mixed into Job / Process / Daemon / WebSocket) -wires three POSIX signals to overridable hooks. Async signals are -enabled (`pcntl_async_signals(true)`): - -| Signal | Internal handler | Override | Default log | -|----------|--------------------|---------------------------|-------------| -| `SIGHUP` | `signClose()` | `asClose()` | notice CLOSE | -| `SIGINT` | `signInterrupt()` | `asInterrupt()` | notice INTERRUPTED | -| `SIGTERM`| `signTermination()`| `asTermination()` | warning TERMINATION | - -`Process` and `Daemon` additionally propagate the signal to their child -forks (`posix_kill($childPid, $sig)` + `pcntl_waitpid`) and expose a -`asChildXxx()` family — useful when the parent and children need -different shutdown logic (e.g. the parent flushes a queue, the child -just exits). - -For event loops that don't naturally `read()`/`select()`, call -`pcntl_signal_dispatch()` periodically so signals are delivered while -busy. - ---- - -## Process tagging (`ps` visibility) - -`cli_set_process_title()` is called by `Dispatch` and again on every -fork (`ThreadFork::forkStart()`), producing predictable patterns: - -``` -Winter -> -Winter (fork) -> fork -Winter (fork) -> anonymous -``` - -| Field | Default | Override via | -|----------------|----------------------|-----------------------------| -| `exNamespace` | `dispatch` | per stereotype (`job`, `daemon`, `process`, `web-socket`) | -| `exTag` | `runnable` | replaced inside forks | -| `exName` | `null` | set in subclass property | - ---- - -## Choosing a stereotype - -``` -Need: Stereotype: -───────────────────────────────────── ───────────── -One unit of work, then exit Job -Long-running worker that forks per task Process -Long-running singleton + state + control Daemon -Live WebSocket server WebSocket -``` - -| Concern | Job | Process | Daemon | WebSocket | -|--------------------------------------|:---:|:-------:|:------:|:---------:| -| Forks children | | ✓ | ✓ | | -| Single-instance lock (`status()`) | | | ✓ | | -| State persistence (`DaemonStore`) | | | ✓ | | -| Rate-limited stream (`streaming()`) | | | ✓ | | -| Built-in protocol loop | | | | ✓ | - ---- - -## Per-type pages - -| Page | Stereotype | When to read it | -|---------------------|-------------|------------------------------------------| -| [01-job.md](01-job.md) | `Job` | The simplest case — start here | -| [02-process.md](02-process.md) | `Process` | Forking workers + signal propagation | -| [03-daemon.md](03-daemon.md) | `Daemon` | Singletons, status, streaming | -| [04-websocket.md](04-websocket.md) | `WebSocket` | TCP WebSocket server | - ---- - -## Source - -- Contracts: `src/Process/Core/Dispatchable.php`, `Dispatch.php` -- Stores: `DispatchStore.php`, `DaemonStore.php` -- Stereotypes: `src/Stereotype/{Job,Process,Daemon,WebSocket}.php` -- Internal classes: `src/Process/Thread{Job,Process,Daemon}.php`, - `src/Process/Socket/Web/ThreadWebSocket.php` -- Traits: `src/Process/Traits/Thread*Handler.php`, `ThreadFork.php`, - `ThreadDaemonFork.php`, `ThreadDaemonStatement.php`, `ThreadSignalHandler.php` - -## See also - -- [`../console/09-thread.md`](../console/09-thread.md) — `call thread ` / `list` / `daemons` -- [`../console/02-make.md`](../console/02-make.md) — scaffolding (`-J`, `-P`, `-N`, `-W`) -- [`../configuration/02-logging.md`](../configuration/02-logging.md) — log channels diff --git a/docs/threads/01-job.md b/docs/threads/01-job.md deleted file mode 100644 index d3f588b..0000000 --- a/docs/threads/01-job.md +++ /dev/null @@ -1,159 +0,0 @@ -# Job — fire-and-forget background tasks - -A `Job` is the simplest `Dispatchable` — runs `resolution()` once, -no forking inside the body, no state, no cluster lock. Use it for -one-shot work that must outlive the originating request. - ---- - -## Stereotype - -```php -namespace App\Threads\Jobs; - -use Flytachi\Winter\K2\Stereotype\Job; -use Flytachi\Winter\DI\Attribute\Autowired; - -class SendInvoice extends Job -{ - #[Autowired] - private MailService $mail; - - public function __construct( - private InvoiceRepository $invoices, - ) {} - - public function resolution(mixed $data = null): void - { - $invoice = $this->invoices->find($data['orderId']); - $this->mail->send($invoice); - } -} -``` - -`Job extends ThreadJob extends Dispatch` — three layers, but everything -you implement lives in `resolution()`. `exNamespace` is hardcoded to -`'job'`. - -DI is real: both constructor injection and `#[Autowired]` resolve -when `Container::make()` instantiates the class inside the child. - ---- - -## Running - -| Call | Behavior | -|-------------------------------------------------------------------|----------| -| `SendInvoice::start(['orderId' => 42])` | Foreground — blocks the caller | -| `SendInvoice::dispatch(['orderId' => 42])` | Background fork; returns child PID | -| `call thread app.threads.jobs.SendInvoice` | CLI foreground | -| `call thread app.threads.jobs.SendInvoice -d` | CLI background | - -`$data` is whatever serializable payload the job needs. It is marshaled -to the child through `DispatchStore` (see -[00-overview.md](00-overview.md#passing-data-into-a-thread-dispatchstore)). - ---- - -## Lifecycle - -``` -Dispatch::dispatch($data) - ↓ -Container::make(static::class) ← fresh DI instance - ↓ -DispatchStore::push($key, $data) ← if $data non-empty - ↓ -Thread::start(['storeKey' => $key]) ← fork - ↓ (inside the child) -Dispatch::run($args) - ├── resolutionStart() ← logger + signal handler - ├── resolution($data) ← YOUR CODE - └── resolutionEnd() ← no-op for Job -``` - -`ThreadJob::resolutionEnd()` is `final` and empty — Jobs don't need -post-work cleanup. If you need it, put it inside `resolution()`'s own -`try/finally`. - ---- - -## Signals - -`ThreadJobHandler` wires `SIGHUP` / `SIGINT` / `SIGTERM` to immediate -exit hooks. Each first calls `resolutionEnd()`, then your overridable -hook, then `exit()`: - -| Signal | Internal handler | Override | Default log | -|----------|--------------------|-------------------|---------------------| -| `SIGHUP` | `signClose()` | `asClose()` | `notice "CLOSE"` | -| `SIGINT` | `signInterrupt()` | `asInterrupt()` | `notice "INTERRUPTED"` | -| `SIGTERM`| `signTermination()`| `asTermination()` | `warning "TERMINATION"` | - -For short jobs you usually don't override anything. For long-running -`resolution()` bodies that loop over work, call -`pcntl_signal_dispatch()` once per iteration so signals are processed -between units of work. - -```php -public function resolution(mixed $data = null): void -{ - foreach ($this->invoices->pending() as $invoice) { - $this->mail->send($invoice); - pcntl_signal_dispatch(); // graceful Ctrl-C between sends - } -} -``` - ---- - -## Errors - -Exceptions in `resolution()` are caught by `Dispatch::run()`, logged -via the resolved logger, and the child exits cleanly (no zombie, no -re-throw to the parent). `DEBUG=true` appends the stack trace to the -log entry; otherwise just the message. - -If the job needs retry / DLQ semantics, build them on top — the kernel -does not retry failed jobs automatically. - ---- - -## Examples - -```php -// from an HTTP controller — don't block the response -SendInvoice::dispatch(['orderId' => $order->id]); - -// from another job (chained foreground) -RebuildSearchIndex::start(); - -// from CLI for ad-hoc execution -// call thread app.threads.jobs.SendInvoice -d -``` - ---- - -## When to use Job vs Process vs Daemon - -| Need | Stereotype | Why | -|-------------------------------------------|------------|--------------------------------------| -| One unit of work, then exit | `Job` | No state, no fork bookkeeping | -| Long-running worker that forks per task | `Process` | `ThreadFork` + child signal handling | -| Long-running singleton + state + control | `Daemon` | `status()` / `stop()` + cluster lock | - ---- - -## Source - -- `src/Stereotype/Job.php` -- `src/Process/ThreadJob.php` -- `src/Process/Core/Dispatch.php`, `Dispatchable.php`, `DispatchStore.php` -- `src/Process/Traits/ThreadJobHandler.php`, `ThreadSignalHandler.php` - -## See also - -- [00-overview.md](00-overview.md) — `Dispatch` lifecycle, DI, data passing -- [02-process.md](02-process.md) — forking workers -- [`../console/09-thread.md`](../console/09-thread.md) — `call thread ` -- [`../console/02-make.md`](../console/02-make.md) — `call make .X -J` scaffold diff --git a/docs/threads/02-process.md b/docs/threads/02-process.md deleted file mode 100644 index 86d9967..0000000 --- a/docs/threads/02-process.md +++ /dev/null @@ -1,243 +0,0 @@ -# Process — long-running forking workers - -A `Process` is a long-lived `Dispatchable` that owns its own child -forks. Unlike `Job` (run-once) and `Daemon` (singleton with state), -`Process` is a general-purpose forking parent — you decide when to -fork, how many children to keep, and when to wait. - ---- - -## Stereotype - -```php -namespace App\Threads\Processes; - -use Flytachi\Winter\K2\Stereotype\Process; - -class QueueWorker extends Process -{ - public function __construct( - private QueueRepository $queue, - ) {} - - public function resolution(mixed $data = null): void - { - // parent loop — drains the queue, forks per batch - while (true) { - $batch = $this->queue->pull(size: 50); - if (!$batch) { - sleep(1); - pcntl_signal_dispatch(); - continue; - } - - foreach (array_chunk($batch, 10) as $chunk) { - $this->fork(function () use ($chunk) { - foreach ($chunk as $item) { - $this->process($item); - } - }); - } - - $this->waitAll(); // join all children before next pass - pcntl_signal_dispatch(); - } - } - - private function process(array $item): void - { - // child body - } -} -``` - -`Process extends ThreadProcess extends Dispatch`. `exNamespace = 'process'`. -`ThreadFork` brings the fork primitives; `ThreadProcessHandler` brings -signal propagation to children. - -DI works the same as in Job / Daemon — `Container::make()` instantiates -the parent, and forks **inherit** that instance (so injected services -are usable in children). - ---- - -## Running - -| Call | Behavior | -|---------------------------------------------------------------|----------| -| `QueueWorker::start()` | Foreground | -| `QueueWorker::dispatch()` | Background fork; returns PID | -| `call thread app.threads.processes.QueueWorker` | CLI foreground | -| `call thread app.threads.processes.QueueWorker -d` | CLI background | - -There is **no cluster lock** on `Process` — `dispatch()` of the same -class twice will spin up two independent supervisors. If you want -"one and only one" semantics, use [`Daemon`](03-daemon.md) instead. - ---- - -## Forking primitives (`ThreadFork`) - -| Method | Use | Returns | -|--------|-----|---------| -| `fork(callable $fn): int` | Run `$fn` in a child fork | child PID | -| `forkAnonymous(mixed $data = null): int` | Run `anonymousResolution($data)` in a child | child PID | -| `wait(int $pid, ?callable $cb = null)` | `waitpid` one specific child | — | -| `waitAll(?callable $cb = null)` | `waitpid` every child you've forked | — | - -State on the parent: - -| Property | Default | Meaning | -|-----------------------|---------|---------| -| `$childrenPidSave` | `true` | Track every child PID for later `waitAll()` | -| `$childrenPids` | `[]` | The tracked PIDs | -| `$iAmChild` | `false` | Flipped to `true` inside `forkStart()` so signal handlers can branch | - -Each fork: - -1. Sets `$this->pid = getmypid()` -2. Re-resolves the logger (`LoggerFactory::getLogger(static::class)`) -3. Rewrites `cli_set_process_title()` to include `(fork)` and the tag - (`fork` for `fork()`, `anonymous` for `forkAnonymous()`) -4. Runs your callable, swallows any throw → logs `critical` -5. **Always** calls `exit(0)` — the fork never returns to caller code - -```php -$pid = $this->fork(function () { - // child body -}); -// only the parent reaches here; child has already exit()-ed -``` - -### `forkAnonymous` + `anonymousResolution` - -For a default callable you don't want to write out every time, override -the protected `anonymousResolution(mixed $data = null)` method and call -`forkAnonymous($data)`: - -```php -public function anonymousResolution(mixed $data = null): void -{ - $this->process($data['item']); -} - -// elsewhere -$this->forkAnonymous(['item' => $row]); -``` - ---- - -## Signals (parent + children) - -`ThreadProcessHandler` is signal-aware of `iAmChild`: - -| Signal | Parent path | Child path | -|----------|---------------------------------------------------|----------------------| -| `SIGHUP` | propagate to children → `resolutionEnd()` → `asClose()` → exit | `asChildClose()` → exit | -| `SIGINT` | propagate to children → `resolutionEnd()` → `asInterrupt()` → exit | `asChildInterrupt()` → exit | -| `SIGTERM`| propagate to children → `resolutionEnd()` → `asTermination()` → exit | `asChildTermination()` → exit | - -The parent walks `$childrenPids`, sends the same signal to each, -`waitpid()`s, then exits. The child only runs its own -`asChildXxx()` hook and exits. Override either family to log custom -messages or do final cleanup: - -```php -protected function asInterrupt(): void -{ - parent::asInterrupt(); // notice "INTERRUPTED" - $this->queue->returnInFlight(); -} - -protected function asChildInterrupt(): void -{ - parent::asChildInterrupt(); // notice "INTERRUPTED CHILD" -} -``` - -For long loops without natural syscall yields, call -`pcntl_signal_dispatch()` per iteration so signals are delivered. - ---- - -## Errors - -Inside `fork()` / `forkAnonymous()`, exceptions are caught and logged -at `critical` level; the child exits `0` regardless. The parent never -sees a child's exception. - -Exceptions in the parent body (your `resolution()` loop) flow through -`Dispatch::run()`'s catch: logged, then `resolutionEnd()` runs, then -the parent exits. - ---- - -## Examples - -### Queue drain with bounded fork count - -```php -public function resolution(mixed $data = null): void -{ - while (true) { - while (count($this->childrenPids) >= 8) { - $finished = pcntl_wait($status); - $this->childrenPids = array_diff($this->childrenPids, [$finished]); - } - $batch = $this->queue->pull(1); - if ($batch) { - $this->fork(fn() => $this->process($batch[0])); - } else { - usleep(100_000); - } - pcntl_signal_dispatch(); - } -} -``` - -### Periodic supervision - -```php -public function resolution(mixed $data = null): void -{ - while (true) { - $this->fork(fn() => $this->tick()); - $this->waitAll(); - sleep(60); - pcntl_signal_dispatch(); - } -} -``` - ---- - -## When to use Process vs Daemon - -| Concern | `Process` | `Daemon` | -|------------------------------------------------|------------------------|-------------------------------------------| -| One-of-a-kind running on the box? | not enforced | enforced via `DaemonStore` cluster lock | -| Per-fork status tracking | manual (`$childrenPids`) | built-in (`forkList()`, `forkListInfo()`) | -| `Class::status()` / `Class::stop()` from outside | not provided | provided | -| `streaming()` rate-limited fork loop | not provided | provided | -| Just a parent loop with forks | ✓ | overkill | - -If you find yourself adding singleton checks, status persistence, or -external "stop me" hooks to a `Process`, you want [`Daemon`](03-daemon.md). - ---- - -## Source - -- `src/Stereotype/Process.php` -- `src/Process/ThreadProcess.php` -- `src/Process/Traits/ThreadFork.php`, `ThreadProcessHandler.php`, - `ThreadSignalHandler.php` -- `src/Process/Core/Dispatch.php`, `Dispatchable.php` - -## See also - -- [00-overview.md](00-overview.md) — `Dispatch` lifecycle and DI -- [01-job.md](01-job.md) — simpler one-shot variant -- [03-daemon.md](03-daemon.md) — singleton with state + control -- [`../console/09-thread.md`](../console/09-thread.md) — running via `call thread` -- [`../console/02-make.md`](../console/02-make.md) — scaffold with `-P` diff --git a/docs/threads/03-daemon.md b/docs/threads/03-daemon.md deleted file mode 100644 index ef17117..0000000 --- a/docs/threads/03-daemon.md +++ /dev/null @@ -1,349 +0,0 @@ -# Daemon — supervised, single-instance, stateful service - -A `Daemon` is a long-running `Dispatchable` with three things `Process` -doesn't have: - -1. **Cluster lock** — `dispatch()` refuses to start if an instance is - already running. -2. **Persistent status** via `DaemonStore` — `Class::status()` and - `Class::stop()` work from any other PHP process on the same box. -3. **A rate-limited fork engine** (`streaming()`) for "keep N children - busy" patterns. - -Use it for services you want supervised by a real init system -(systemd / Docker `restart: always`) and operable from CLI. - ---- - -## Stereotype - -```php -namespace App\Threads\Daemons; - -use Flytachi\Winter\K2\Stereotype\Daemon; -use Flytachi\Winter\DI\Attribute\Autowired; - -class Cleanup extends Daemon -{ - #[Autowired] - private LogRotator $rotator; - - public function resolution(mixed $data = null): void - { - $this->prepare(streamRps: 10); // optional — sets condition + rate - - $this->streaming( - complianceCallable: function () { - $this->fork(fn() => $this->rotator->rotateOne()); - }, - ); - } -} -``` - -`Daemon extends ThreadDaemon extends Dispatch`. `exNamespace = 'daemon'`. -Mixes in `ThreadDaemonFork`, `ThreadDaemonHandler`, `ThreadSignalHandler`, -and `ThreadDaemonStatement`. - -DI works the same — `Container::make()` instantiates inside the child, -forks inherit the instance. - ---- - -## Running - -| Call | Behavior | -|------------------------------------------------------------|----------| -| `Cleanup::dispatch()` | Background fork (typical). Throws if already running. | -| `Cleanup::start()` | Foreground (current process). Same lock-protected lifecycle inside `resolution()`. | -| `Cleanup::status($showStats = false)` | `?TDInfo` — `null` if not running | -| `Cleanup::stop()` | Send SIGINT to the running PID; `bool` | -| `call thread app.threads.daemons.Cleanup start` | CLI background start | -| `call thread app.threads.daemons.Cleanup stop` | CLI stop | -| `call thread app.threads.daemons.Cleanup status [-v]` | CLI status (`-v` = resources + forks) | -| `call thread app.threads.daemons.Cleanup` | CLI toggle (foreground); add `-d` for background | - -For production, run the daemon under a supervisor (`systemd`, Docker -`restart: always`) in foreground — let the supervisor own the lifecycle. -The `start` / `stop` / `-d` actions are for ad-hoc management; see -[../console/09-thread.md](../console/09-thread.md) for the full lifecycle. - ---- - -## Cluster lock (`DaemonStore`) - -The lock key is `xxh64(static::class)` — same class, same key, machine-wide. - -`dispatch()` does: - -``` -$info = Class::status() - ↓ -if $info: throw DaemonException("Cluster process already exist [PID:...]") -else: parent::dispatch($data) // proceed with fork -``` - -`resolutionStart()` writes a `TDStatus` row: - -```php -new TDStatus( - pid: getmypid(), - className: static::class, - condition: TCondition::STARTED, - startedAt: time(), - streamRps: $this->streamRps, - info: [], -); -``` - -`resolutionEnd()` removes it. So `status()` reflects the live state — -if it returns non-null but `posix_getpgid($status->pid)` is dead, the -key is auto-pruned and `status()` returns `null`. Crash-resilient. - -`DaemonStore` is two `FileStorage` slots under -`Kernel::runnable("")`: - -| Slot | Holds | -|--------------|------------------------------------------------------| -| `main()` | One key — the daemon's `TDStatus` (or `TDInfo` view) | -| `threads()` | One key per active fork — `__` → `TStatus` | - ---- - -## Status & stop from outside - -Anywhere in the same project (HTTP controller, CLI script, another -daemon) you can introspect: - -```php -$info = Cleanup::status(showStats: true); -if ($info) { - echo "Cleanup PID {$info->status->pid}"; - echo " started {$info->status->getStartedAt()}"; - echo " condition: {$info->status->condition->name}"; - if ($info->stats) { - echo " RSS {$info->stats->rssMb()} MB"; - } -} - -if ($info) { - Cleanup::stop(); // SIGINT to PID; bool -} -``` - -`stop()` throws `DaemonException` if there is no live instance. - ---- - -## State during a run - -`ThreadDaemonStatement` exposes a few self-mutators (the daemon updates -its own record): - -| Method | Effect | -|------------------------------------------------|--------| -| `prepare(int $streamRps = 0)` | Set condition `PREPARATION` → `ACTIVE`, store `streamRps`, call `preparation()` hook | -| `setCondition(TCondition $c)` | Update the daemon's `condition` | -| `setInfo(array $info)` | Update the freeform `info` blob on `TDStatus` | -| `preparation()` (override) | Hook called inside `prepare()` for one-time setup | - -Conditions (`TCondition` enum): - -| Case | Value | -|---------------|-------| -| `STARTED` | 0 | -| `ACTIVE` | 1 | -| `PREPARATION` | 2 | -| `WAITING` | 3 | -| `CHECKING` | 4 | -| `PASSIVE` | 5 | - ---- - -## Forks & fork-status - -`ThreadDaemonFork` overrides `forkStart()` / `forkEnd()` to: - -- write a `TStatus` for the fork into `store()->threads()` keyed - `__` (so the fork is observable); -- delete it on exit. - -That gives you these static introspection helpers (also from outside the -process): - -| Method | Returns | -|----------------------------------------------------------|---------| -| `Class::forkQty()` | `int` — current fork count | -| `Class::forkList()` | `int[]` — child PIDs | -| `Class::forkListInfo(bool $showStats = false)` | `TInfo[]` — one per child, optional `ps` stats | -| `Class::forkInfo(int $pid, bool $showStats = false)` | `?TInfo` for a single child | -| `Class::forkSetCondition(int $pid, TCondition)` | Update a fork's condition | - -Children's lifecycle hooks `preparationForkBefore()` / -`preparationForkAfter()` are open for override if you need to do more -than write a `TStatus`. - ---- - -## `streaming()` — rate-limited fork loop - -```php -final protected function streaming( - callable $complianceCallable, - ?callable $negationCallable = null, -): void -``` - -A built-in supervisor loop: - -``` -while (true) { - if (forkQty() < $this->streamRps) { - $complianceCallable(); // typically: $this->fork(...) - } else if ($negationCallable) { - $negationCallable(); // e.g. "log skipped", "yield" - } - usleep( $streamRps < 1000 ? 1e6/$streamRps : 1000 ); - pcntl_signal_dispatch(); -} -``` - -So `$streamRps` sets the cap on **concurrent forks**, not jobs/sec: - -- `streamRps = 10` → at most 10 child forks alive at once; spawn when - one finishes (because `forkQty()` drops below 10). -- `streamRps = 0` → never spawns (use the negation branch only). - -Use it when you want a constant in-flight worker count without writing -the loop yourself: - -```php -public function resolution(mixed $data = null): void -{ - $this->prepare(streamRps: 50); - $this->streaming( - complianceCallable: function () { - $this->fork(fn() => $this->doOne()); - }, - negationCallable: function () { - // optional — runs when at capacity - }, - ); -} -``` - ---- - -## Signals (parent + forks) - -`ThreadDaemonHandler` mirrors `ThreadProcessHandler` but walks -`forkList()` from the store (the source of truth) rather than the -in-memory `$childrenPids`: - -| Signal | Parent path | Fork path | -|----------|----------------------------------------------------------------|-----------| -| `SIGHUP` | propagate → `resolutionEnd()` → `asClose()` → exit | `preparationForkAfter()` → `asChildClose()` → exit | -| `SIGINT` | propagate → `resolutionEnd()` → `asInterrupt()` → exit | `preparationForkAfter()` → `asChildInterrupt()` → exit | -| `SIGTERM`| propagate → `resolutionEnd()` → `asTermination()` → exit | `preparationForkAfter()` → `asChildTermination()` → exit | - -`resolutionEnd()` is what deletes the main-store key, so a clean SIGINT -takes the daemon out of `status()` immediately. - -`Class::stop()` is just SIGINT to the recorded PID, so it produces the -same path as Ctrl-C on a foreground run. - ---- - -## Errors - -- `DaemonException` — produced by `dispatch()` (already running) and - `stop()` (not running). HTTP code `LOCKED` (423), critical log level. -- Exceptions inside `resolution()` flow through `Dispatch::run()`'s - catch: logged, `resolutionEnd()` runs (key is removed from the store), - daemon exits cleanly. -- Exceptions inside a fork go through `ThreadDaemonFork::fork()`'s catch - and exit `0` — they do not kill the daemon. - ---- - -## Examples - -### Periodic check / heartbeat - -```php -public function resolution(mixed $data = null): void -{ - $this->prepare(); // sets ACTIVE without streaming - while (true) { - try { - $this->fork(fn() => $this->healthCheck()); - } finally { - sleep(30); - pcntl_signal_dispatch(); - } - } -} - -protected function healthCheck(): void -{ - // child body -} -``` - -### Bounded concurrency over a queue - -```php -public function resolution(mixed $data = null): void -{ - $this->prepare(streamRps: 20); // cap at 20 in-flight - $this->streaming(function () { - $this->fork(fn() => $this->pullAndHandle()); - }); -} -``` - -### Operate from a controller - -```php -public function start(): array -{ - return ['pid' => Cleanup::dispatch()]; // throws if running -} - -public function stop(): bool -{ - return Cleanup::stop(); -} - -public function status(): ?array -{ - $info = Cleanup::status(showStats: true); - return $info ? [ - 'pid' => $info->status->pid, - 'condition' => $info->status->condition->name, - 'started' => $info->status->getStartedAt(), - 'rssMb' => $info->stats?->rssMb(), - 'forks' => Cleanup::forkQty(), - ] : null; -} -``` - ---- - -## Source - -- `src/Stereotype/Daemon.php` -- `src/Process/ThreadDaemon.php`, `DaemonException.php` -- `src/Process/Core/DaemonStore.php`, `Dispatch.php`, `Dispatchable.php` -- `src/Process/Traits/ThreadDaemonFork.php`, - `ThreadDaemonHandler.php`, `ThreadDaemonStatement.php`, - `ThreadSignalHandler.php` -- `src/Process/Entity/{TCondition,TDInfo,TDStatus,TInfo,TStatus,TStats}.php` - -## See also - -- [00-overview.md](00-overview.md) — `Dispatch` lifecycle and DI -- [02-process.md](02-process.md) — the unlocked, lighter forking variant -- [`../console/09-thread.md`](../console/09-thread.md) — `call thread start|stop|status` / `daemons` -- [`../console/02-make.md`](../console/02-make.md) — scaffold with `-N` -- [`../configuration/02-logging.md`](../configuration/02-logging.md) — per-channel logs diff --git a/docs/threads/04-websocket.md b/docs/threads/04-websocket.md deleted file mode 100644 index 5875ae5..0000000 --- a/docs/threads/04-websocket.md +++ /dev/null @@ -1,307 +0,0 @@ -# WebSocket — TCP WebSocket server - -A `WebSocket` is a `Dispatchable` that owns a `stream_socket_server` -bound to a TCP port, performs the RFC 6455 handshake on accept, and -exposes three abstract hooks for connect / message / disconnect. - -It runs single-process (no fork inside the server loop) — concurrency -comes from `stream_select()` multiplexing. - ---- - -## Stereotype - -```php -namespace App\Threads\WebSockets; - -use Flytachi\Winter\K2\Stereotype\WebSocket; -use Flytachi\Winter\K2\Process\Socket\Web\PDU\Msg; -use Flytachi\Winter\K2\Process\Socket\Web\PDU\WSResource; - -class Chat extends WebSocket -{ - protected string $ip = '0.0.0.0'; - protected int $port = 9001; - - protected function handleConnect(WSResource $resource): void - { - $this->logger->info("Joined: {$resource}"); - $this->send($resource, 'Welcome'); - } - - protected function handle(WSResource $resource, Msg $msg): void - { - if ($msg->type === 'text') { - // broadcast to everyone else - foreach ($this->connects as $peer) { - if ($peer !== $resource) { - $this->send($peer, $msg->payload); - } - } - } - } - - protected function handleDisconnect(WSResource $resource): void - { - $this->logger->info("Left: {$resource}"); - } -} -``` - -`WebSocket extends ThreadWebSocket extends Dispatch`. -`exNamespace = 'web-socket'`. - -DI works identically — `Container::make()` instantiates the class, -`#[Autowired]` resolves. - ---- - -## Running - -| Call | Behavior | -|-----------------------------------------------------------------|----------| -| `Chat::start(['ip' => '0.0.0.0', 'port' => 9001])` | Foreground | -| `Chat::dispatch(['ip' => '0.0.0.0', 'port' => 9001])` | Background fork; returns PID | -| `call thread app.threads.websockets.Chat` | CLI foreground | -| `call thread app.threads.websockets.Chat -d` | CLI background | - -The optional `$data` map is read by `resolution()` and overrides the -`$ip` / `$port` properties for that run, so the same class can be -bound to different ports without subclassing: - -```php -Chat::dispatch(['port' => 9002]); -Chat::dispatch(['port' => 9003]); -``` - -There is **no cluster lock** (unlike `Daemon`). If you need a -singleton bind per machine, wrap launching in a `Daemon` or let the -supervisor enforce it. - ---- - -## Abstract hooks - -Every concrete `WebSocket` must implement three methods. The server -loop catches `\Throwable` around each one and logs it — your code -won't take the server down. - -```php -abstract protected function handleConnect(WSResource $resource): void; -abstract protected function handle(WSResource $resource, Msg $msg): void; -abstract protected function handleDisconnect(WSResource $resource): void; -``` - -| Hook | Fires when | -|---------------------|------------------------------------------------------| -| `handleConnect()` | After successful handshake; `$resource` registered | -| `handle()` | A complete frame was decoded — `$msg->type` ∈ `text / binary / ping / pong` | -| `handleDisconnect()`| Before the connection is torn down (EOF, error frame, close, server stop) | - -Note `close` and `error` frames trigger `disconnectClient()` directly — -your `handle()` does not see them. Errors are logged as warnings; -malformed/unmasked frames are surfaced as a synthetic error msg and -the connection is closed. - ---- - -## Sending - -```php -public function send(WSResource $resource, string $payload, string $type = 'text'): void -``` - -- Frames the payload via `WebSocketProtocol::encode()` and **queues** - it on the resource's `writeBuffer`. -- The main loop flushes queued bytes when the connection is writable - (`stream_select` returns it in `$write`). -- Sending to a disconnected client is a no-op + warning log. - -Types accepted by `encode()`: `text` (default), `binary`, `ping`, -`pong`, `close`. Use `text` / `binary` from `send()`; `close` is -emitted internally by `disconnectClient()`. - -To broadcast, loop `$this->connects` (a `[string => WSResource]` map -keyed by the stream resource ID). - ---- - -## The server loop - -`ThreadWebSocket::resolution()` is `final` — you don't override it. -It does: - -``` -bind: stream_socket_server("tcp://$ip:$port", STREAM_SERVER_{BIND,LISTEN}) -nonblock: stream_set_blocking(false) - -while true: - select($read = clients + listen-sock, $write = those with queued bytes, - timeout = $loopInterval µs) - - if listener has activity: - accept new connection - try handshake → on success register WSResource, fire handleConnect() - on failure send "HTTP/1.1 400 Bad Request" and close - - for each readable client: - fread(65535) - if EOF: disconnectClient() - append to readBuffer - while a complete frame can be decoded: - consume bytes - if close/error frame → disconnectClient() - else → fire handle($resource, $msg) - - for each writable client: - fwrite(writeBuffer) — track partial writes - - if $timeWorkLimit > 0 and elapsed > limit: break the loop - - pcntl_signal_dispatch() - loop() ← optional override hook -``` - ---- - -## Override hooks - -| Member / hook | Purpose | -|-------------------------------------|---------| -| `$ip` / `$port` | Default bind (overridable per call via `$data`) | -| `$loopInterval` (µs, default `200_000`) | `stream_select` timeout — keeps `loop()` ticking on quiet sockets | -| `$timeWorkLimit` (s, default `0`) | If > 0, the loop exits cleanly after that many seconds | -| `protected function loop(): void` | Called once per iteration after I/O — use it for timers, periodic broadcasts, GC; default no-op | -| `handleConnect()` / `handle()` / `handleDisconnect()` | The three required event hooks | -| `asInterrupt()` / `asTermination()` / `asClose()` | Signal log overrides | - -A `loop()` example — broadcast a heartbeat every 5 seconds: - -```php -private int $lastBeat = 0; - -protected function loop(): void -{ - if (time() - $this->lastBeat >= 5) { - foreach ($this->connects as $peer) { - $this->send($peer, json_encode(['t' => time()])); - } - $this->lastBeat = time(); - } -} -``` - ---- - -## `WSResource` and `Msg` - -`WSResource` (`src/Process/Socket/Web/PDU/WSResource.php`) wraps one -connection — exposes the underlying stream (`getConnect()`), the -handshake info (URI, headers, query params, ip/port), and the -`readBuffer` / `writeBuffer` strings the loop manipulates. Casting it -to `(string)` returns a unique connection ID — that's also the key in -`$this->connects`. - -`Msg` carries the decoded frame: `type`, `payload`, and (for synthetic -errors) an error message. - ---- - -## Signals - -`SocketWebServerHandler` is the per-WebSocket signal trait: - -| Signal | Internal handler | Override | Default log | -|----------|--------------------|-------------------|-------------| -| `SIGHUP` | `signClose()` | `asClose()` | `notice "CLOSE"` | -| `SIGINT` | `signInterrupt()` | `asInterrupt()` | `notice "INTERRUPTED"` | -| `SIGTERM`| `signTermination()`| `asTermination()` | `warning "TERMINATION"` | - -Each path calls `resolutionEnd()` first, which calls `socketClose()` — -that disconnects every client (sending a 1000 close frame) and shuts -the listener. Clean. - -`pcntl_signal_dispatch()` is invoked once per iteration, so signals -deliver promptly even on quiet sockets. - ---- - -## Errors - -- Bind failure or `stream_socket_server` returning `false` → critical - log + clean shutdown (`socketClose()`). -- Per-hook throws — caught at the call site (each of - `handleConnect`/`handle`/`handleDisconnect`), logged, the loop - continues. -- Frame decode errors — synthetic `'error'` `Msg` produced; the loop - warns and closes the offending connection (it does not propagate to - `handle()`). - ---- - -## Examples - -```php -// Echo server -class Echo extends WebSocket -{ - protected function handleConnect(WSResource $r): void {} - protected function handle(WSResource $r, Msg $m): void - { - $this->send($r, $m->payload); - } - protected function handleDisconnect(WSResource $r): void {} -} - -Echo::dispatch(['port' => 9001]); -``` - -```php -// Path-aware routing — split traffic by URI -protected function handleConnect(WSResource $r): void -{ - $path = $r->info['path'] ?? '/'; - if ($path !== '/chat') { - $this->disconnectClient($r); // close non-/chat clients - } -} -``` - -```php -// Time-limited test server -class StressTest extends WebSocket -{ - protected int $timeWorkLimit = 60; // shuts itself down after 60s - // ... -} -``` - ---- - -## When NOT to use this - -- For high-throughput production WebSocket workloads, prefer Swoole's - built-in WebSocket server (it's coroutine-aware and works with the - HTTP server you already run via `call run`). The kernel ships - `ThreadWebSocket` as a self-contained, dependency-free alternative — - great for internal services, tests, sidecar protocols. It is **not** - designed to compete with Swoole / Workerman at scale. -- For request/response semantics use a normal `Controller`. - ---- - -## Source - -- `src/Stereotype/WebSocket.php` -- `src/Process/Socket/Web/ThreadWebSocket.php` -- `src/Process/Socket/Web/WebSocketProtocol.php` -- `src/Process/Socket/Web/SocketWebServerHandler.php` -- `src/Process/Socket/Web/PDU/*` (`WSResource`, `Msg`, `DecodedFrame`) -- `src/Process/Core/Dispatch.php`, `Dispatchable.php` - -## See also - -- [00-overview.md](00-overview.md) — `Dispatch` lifecycle and DI -- [03-daemon.md](03-daemon.md) — to make the server a supervised singleton -- [`../console/09-thread.md`](../console/09-thread.md) — `call thread ` -- [`../console/02-make.md`](../console/02-make.md) — scaffold with `-W` diff --git a/docs/winter-application-redesign.md b/docs/winter-application-redesign.md deleted file mode 100644 index 84b156e..0000000 --- a/docs/winter-application-redesign.md +++ /dev/null @@ -1,572 +0,0 @@ -# WinterApplication — редизайн загрузчика (proposal) - -> Цель: убрать «бог-класс» `Boot`, где один класс держит `components()`, -> `configure()`, `providers()`, `channels()`, `httpCors()`, `health()`, -> `plugins()`, `swooleConfig()`. Приходим к Spring-модели: **тонкий entry-класс -> + конфигурация, разъехавшаяся по классам, которые находит сканер.** -> -> Это разбор дизайна, а не финальный код. Каждый раздел показывает: (1) Spring- -> аналог, (2) что даёт фреймворк, (3) что пишет кодер. - ---- - -## 0. Как это выглядит «до» и «после» - -### Сейчас (один класс на всё) - -```php -class Boot extends Application -{ - protected static function components(): array { /* http, process... */ } - protected static function configure(): void { Kernel::init(...); } - protected static function providers(Container $c): void { /* биндинги */ } - protected static function channels(): void { /* каналы логов */ } - protected static function httpCors(): void { Cors::configure(...); } - protected static function health(): void { Health::configure(...); } - protected static function plugins(): void { Plugin::registry(...); } - public static function swooleConfig(): array { return [...]; } -} -``` - -### После (тонкий вход + разнесённая конфигурация) - -```php -#[EnableWeb(port: 8000)] -#[EnableScheduling] -#[EnableDaemon(Emails::class)] -#[EnablePlugin('acme/auth-plugin', '/auth')] -final class App extends WinterApplication -{ - public static function main(array $args): never - { - return self::run(App::class, $args); - } -} -``` - -Всё остальное (беды/beans, CORS, health, каналы, настройки сервера) — **обычные -классы в проекте**, которые сканер сам находит. `App` больше не знает про них. - ---- - -## 1. Точка входа: `WinterApplication` + `main(array $args)` - -### Spring-аналог - -```java -@SpringBootApplication -public class MyApp { - public static void main(String[] args) { - SpringApplication.run(MyApp.class, args); // единственный вход - } -} -``` - -`SpringApplication.run(...)` делает всё: читает `application.properties`, сканирует -`@Component`, поднимает встроенный сервер. Аргументы `--server.port=8081` -перекрывают свойства (Spring это зовёт *relaxed binding*). - -### Что даёт фреймворк - -Новый абстрактный класс `WinterApplication` (заменяет `BaseBoot`/`Application`): - -```php -namespace Flytachi\Winter\K2; - -abstract class WinterApplication -{ - /** - * Единственный вход приложения. Парсит аргументы, поднимает ядро, сканирует - * проект, применяет конфигураторы и либо поднимает компоненты, либо выполняет - * console-команду (make/daemon/...). - * - * @param class-string $appClass - * @param array $args сырой $argv (имя скрипта в [0]) - */ - final public static function run(string $appClass, array $args): never - { - $arguments = ApplicationArguments::parse($args); // --port=8080 --profile=prod ... - // ... bootstrap ядра + скан + применение конфигураторов + запуск ... - } - - /** - * Опциональный override путей ядра — нужен только если каталоги проекта - * нестандартные. По умолчанию pathRoot выводится из расположения App. - */ - protected static function configure(ApplicationArguments $args): void - { - Kernel::init(pathRoot: static::rootPath()); - } -} -``` - -### Что пишет кодер - -Файл `App.php` (класс приложения): - -```php -#[EnableWeb(port: 8000)] -final class App extends WinterApplication -{ - public static function main(array $args): never - { - return self::run(App::class, $args); - } -} -``` - -Файл `call` (единственный launcher, как `java -jar`): - -```php -#!/usr/bin/env php -` парсятся в типизированный объект. -Они кладутся **поверх** `.env`/атрибутов как override: - -```bash -php call --port=8080 # перебить порт web-компонента -php call --profile=prod # выбрать профиль (аналог Spring profiles) -php call make -c UserController # console-команда — тоже через App::main -``` - -`--port=8080` побеждает `#[EnableWeb(port: 8000)]`. Приоритет: -**аргумент CLI > .env > атрибут/дефолт.** - ---- - -## 2. Beans / DI-биндинги: `#[Configuration]` + `#[Bean]` - -> Заменяет `providers(Container $c)`. - -### Spring-аналог - -```java -@Configuration -public class AppConfig { - @Bean - public MailerInterface mailer(@Value("${mail.host}") String host) { - return new SmtpMailer(host); - } -} -``` - -Класс с `@Configuration`, методы с `@Bean` возвращают объекты — Spring кладёт их в -контейнер. Тип возврата = ключ бина. Аргументы метода — автоинжектятся. - -### Что даёт фреймворк - -- Атрибут `#[Configuration]` (маркер класса-конфигурации). -- Атрибут `#[Bean]` (маркер фабричного метода). -- Атрибут `#[Value('ENV_KEY')]` — инжект значения из `.env` (аналог `@Value`). -- Новый коллектор `ConfigurationCollector`: на скане находит `#[Configuration]`- - классы, для каждого `#[Bean]`-метода регистрирует фабрику в `Container` - (ключ = тип возврата метода, аргументы = autowire). - -### Что пишет кодер - -```php -namespace Main\Config; - -use Flytachi\Winter\K2\App\Attribute\Configuration; -use Flytachi\Winter\K2\App\Attribute\Bean; -use Flytachi\Winter\K2\App\Attribute\Value; - -#[Configuration] -final class AppConfig -{ - #[Bean] - public function mailer(#[Value('MAIL_HOST')] string $host): MailerInterface - { - return new SmtpMailer($host); - } - - // Аргументы бинов автоинжектятся из контейнера — как в конструкторах. - #[Bean] - public function cache(LoggerInterface $logger): CacheInterface - { - return new RedisCache(env('REDIS_URL'), $logger); - } -} -``` - -Никаких `$c->bind(...)` в entry-классе. Хочешь новый сервис — создаёшь метод в -любом `#[Configuration]`-классе, сканер подхватит. Простые `#[Singleton]`/ -`#[Service]`-классы (авто-DI по атрибуту) работают как раньше — `#[Bean]` нужен -только когда сборку объекта нельзя выразить атрибутом (интерфейс→реализация, -фабрика, скаляр из env). - ---- - -## 3. CORS: интерфейс `WebConfigurer` - -> Заменяет `httpCors()`. - -### Spring-аналог - -```java -@Configuration -public class WebConfig implements WebMvcConfigurer { - @Override - public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/api/**") - .allowedOrigins("https://app.example.com") - .allowCredentials(true); - } -} -``` - -Реализуешь интерфейс `WebMvcConfigurer`, Spring находит его и вызывает -`addCorsMappings(...)` при старте. - -### Что даёт фреймворк - -- Интерфейс `WebConfigurer` с методом `configureCors(CorsRegistry $cors): void`. -- Класс `CorsRegistry` — fluent-обёртка, которая внутри зовёт существующий - `Cors::configure(...)`. -- На boot: `ImplementorCollector(WebConfigurer::class)` находит все реализации и - вызывает их (аналог того, как сейчас находятся Controller'ы). - -```php -interface WebConfigurer -{ - public function configureCors(CorsRegistry $cors): void; -} -``` - -### Что пишет кодер - -```php -namespace Main\Config; - -use Flytachi\Winter\K2\Http\Cors\WebConfigurer; -use Flytachi\Winter\K2\Http\Cors\CorsRegistry; - -final class WebConfig implements WebConfigurer -{ - public function configureCors(CorsRegistry $cors): void - { - $cors->allowedOrigins('https://app.example.com') - ->allowedHeaders('Content-Type', 'Authorization', 'X-Request-Id') - ->exposeHeaders('X-Request-Id') - ->allowCredentials(true) - ->maxAge(3600); - } -} -``` - -Нет CORS-класса → политика дефолтная (wildcard). Per-route по-прежнему через -`#[CrossOrigin]` на контроллере — этот механизм не трогаем. - ---- - -## 4. Health / Actuator: авто-discovery `HealthIndicator` - -> Заменяет `health()`. - -### Spring-аналог - -```java -@Component -public class DatabaseHealthIndicator implements HealthIndicator { - @Override - public Health health() { - return db.ping() ? Health.up().build() : Health.down().build(); - } -} -``` - -Просто объявляешь `@Component`, реализующий `HealthIndicator`. Actuator сам его -находит и агрегирует в `/actuator/health`. Никакой регистрации. - -### Что даёт фреймворк - -- Интерфейс `HealthIndicator` с методом `health(): Health`. -- Коллектор находит все реализации и регистрирует в агрегаторе `/actuator/health`. -- Сам actuator включается атрибутом `#[EnableActuator]` на App (либо по умолчанию - для web-компонента). Защита middleware — параметр атрибута. - -### Что пишет кодер - -Включить actuator (на App-классе): - -```php -#[EnableActuator(middleware: InternalOnlyMiddleware::class)] -final class App extends WinterApplication { /* ... */ } -``` - -Добавить свою проверку (обычный класс, сканер найдёт): - -```php -namespace Main\Health; - -use Flytachi\Winter\K2\Http\Health\HealthIndicator; -use Flytachi\Winter\K2\Http\Health\Health; - -final class DatabaseHealth implements HealthIndicator -{ - public function __construct(private Db $db) {} // автоинжект - - public function health(): Health - { - return $this->db->ping() - ? Health::up()->withDetail('latency_ms', $this->db->latency()) - : Health::down()->withDetail('reason', 'connection failed'); - } -} -``` - ---- - -## 5. Каналы логов: `.env` + опциональный `LoggingConfigurer` - -> Заменяет `channels()`. - -### Spring-аналог - -В Spring каналы/аппендеры настраиваются в `logback-spring.xml` или через -`application.properties` — почти никогда в коде. Только сложные случаи — через код. - -### Что даёт фреймворк - -- Базовые каналы (`http`, `sys`) уже регистрируются в `Kernel::init`. -- Кастомные каналы — из `.env` (как сейчас, `LOG_{NAME}_*`), плюс объявление имён. -- Для кода — интерфейс `LoggingConfigurer` с `configureChannels(ChannelRegistry)`. - -### Что пишет кодер - -Чаще всего — только `.env`: - -```dotenv -LOG_JOB_LEVEL=debug -LOG_JOB_OUTPUT=file -LOG_JOB_FILE=/var/log/app/job.log -``` - -Если нужен код (динамические каналы): - -```php -namespace Main\Config; - -use Flytachi\Winter\K2\Logging\LoggingConfigurer; -use Flytachi\Winter\K2\Logging\ChannelRegistry; - -final class LoggingConfig implements LoggingConfigurer -{ - public function configureChannels(ChannelRegistry $channels): void - { - $channels->add('job'); - $channels->add('audit'); - } -} -``` - -Использование в коде не меняется: -`LoggerFactory::getLogger(MyJob::class, 'job')->info('started')`. - ---- - -## 6. Плагины: атрибуты `#[EnablePlugin]` - -> Заменяет `plugins()`. - -### Spring-аналог - -Модульность в Spring — это `@Import` и стартеры (`spring-boot-starter-*`). Подключил -зависимость → авто-конфигурация подхватилась. Ближайшая калька — декларативные -`@Enable*`/`@Import` на главном классе. - -### Что даёт фреймворк - -- Атрибут `#[EnablePlugin(package, prefix, required)]` — повторяемый. -- На boot читаются атрибуты App-класса → вызывается существующий - `Plugin::registry(...)`. - -### Что пишет кодер - -```php -#[EnablePlugin('acme/auth-plugin', '/auth')] -#[EnablePlugin('acme/billing-plugin', '/billing')] -#[EnablePlugin('acme/experimental', '/x', required: false)] -final class App extends WinterApplication { /* ... */ } -``` - -Читается сверху класса, декларативно. `src/` каждого плагина сканируется -автоматически — как сейчас. - ---- - -## 7. Настройки сервера (Swoole): `ServerConfigurer` / `.env` - -> Заменяет `swooleConfig()`. - -### Spring-аналог - -```properties -server.port=8080 -server.tomcat.threads.max=200 -``` - -Настройки встроенного сервера — свойства `server.*`. Для кода — -`WebServerFactoryCustomizer`. - -### Что даёт фреймворк - -- `.env`-свойства `SERVER_*` (workers, max_request, ...). -- Опциональный `ServerConfigurer` с `configure(ServerSettings $s)` для тонкой - настройки в коде (проброс в `\Swoole\Http\Server::set()`). - -### Что пишет кодер - -`.env`: - -```dotenv -SERVER_WORKERS=8 -SERVER_MAX_REQUEST=5000 -``` - -или код: - -```php -final class ServerConfig implements ServerConfigurer -{ - public function configure(ServerSettings $s): void - { - $s->workers(swoole_cpu_num() * 2) - ->maxRequest(5000) - ->maxRequestGrace(500); - } -} -``` - ---- - -## 8. Что запускается: атрибуты `#[Enable*]` - -> Заменяет `components()`. - -### Spring-аналог - -```java -@EnableScheduling // включить планировщик -@EnableAsync // включить async -@SpringBootApplication -public class MyApp { ... } -``` - -В Spring «что умеет приложение» — это набор `@Enable*` + наличие сервера в classpath. - -### Что даёт фреймворк - -Атрибуты на App-классе, читаются на boot и превращаются в `Component`-манифест: - -| Атрибут | Аналог сейчас | -|---|---| -| `#[EnableWeb(host, port)]` | `Component::http(...)` | -| `#[EnableScheduling]` | `Component::scheduler()` | -| `#[EnableProcess(Class::class)]` | `Component::process(...)` | -| `#[EnableDaemon(Class::class)]` | `Component::daemon(...)` | - -### Что пишет кодер - -```php -#[EnableWeb(port: 8000)] -#[EnableScheduling] -#[EnableProcess(KernelSys::class)] -#[EnableDaemon(Emails::class)] -final class App extends WinterApplication -{ - public static function main(array $args): never - { - return self::run(App::class, $args); - } -} -``` - -- Есть `#[EnableWeb]` → поднимается Swoole HTTP + компаньоны рядом (addProcess). -- Нет `#[EnableWeb]` → headless (только фоновые). -- `--port=8080` из CLI перебивает порт. - -> Развилка, которую надо решить: `#[Enable*]`-атрибуты **или** оставить метод -> `components(): array` (гибче для условной сборки — `if (env(...))`), **или** оба -> (атрибуты для типового, метод как escape-hatch). - ---- - -## 9. Порядок boot (что за чем) - -``` -App::main($argv) - → WinterApplication::run(App::class, $argv) - 1. ApplicationArguments::parse($argv) — --port, --profile, ... - 2. configure(args) → Kernel::init(...) — пути, .env, логгер (РАНО, до скана) - 3. DI-скан проекта (существующий Scanner), коллекторы: - • DICollector — #[Singleton]/#[Service]/... (как сейчас) - • AsyncCollector — #[Async]-прокси (как сейчас) - • ConfigurationCollector— #[Configuration]/#[Bean] (НОВОЕ) - • ImplementorCollector — WebConfigurer / LoggingConfigurer / - ServerConfigurer / HealthIndicator (НОВОЕ) - 4. Применить конфигураторы: logging → cors → health - 5. Прочитать атрибуты App: #[Enable*], #[EnablePlugin], #[EnableActuator] - 6. Диспетчеризация: - • есть console-команда в args (make/daemon/schedule/...) → выполнить её - • иначе → поднять компоненты (serve): Swoole + компаньоны / headless -``` - -Ключевой нюанс (курица-яйцо): шаг 2 (`Kernel::init` — пути/env/лог) **обязан** -отработать до скана, поэтому он остаётся на App-классе/конвенции и **не** может быть -discovered-классом. Всё остальное — находится сканом. - ---- - -## 10. Итог: какие классы появляются - -### Даёт фреймворк (winter-kernel) - -| Класс / атрибут | Роль | -|---|---| -| `WinterApplication` | базовый entry-класс, `run()` / `main()` | -| `ApplicationArguments` | парсинг `--key=value` из argv | -| `#[Configuration]`, `#[Bean]`, `#[Value]` | beans вместо `providers()` | -| `ConfigurationCollector` | сбор `#[Bean]`-фабрик на скане | -| `WebConfigurer` + `CorsRegistry` | CORS вместо `httpCors()` | -| `HealthIndicator` (+ авто-агрегатор) | health вместо `health()` | -| `LoggingConfigurer` + `ChannelRegistry` | каналы вместо `channels()` | -| `ServerConfigurer` + `ServerSettings` | сервер вместо `swooleConfig()` | -| `#[EnableWeb]`, `#[EnableScheduling]`, `#[EnableProcess]`, `#[EnableDaemon]`, `#[EnablePlugin]`, `#[EnableActuator]` | манифест вместо `components()`/`plugins()` | - -### Пишет кодер (в своём проекте) - -| Файл | Что это | -|---|---| -| `App.php` | тонкий класс с `main()` + `#[Enable*]` | -| `call` | `App::main($argv)` | -| `Config/AppConfig.php` | `#[Configuration]` с `#[Bean]`-методами (опц.) | -| `Config/WebConfig.php` | `implements WebConfigurer` (опц., CORS) | -| `Health/DatabaseHealth.php` | `implements HealthIndicator` (опц.) | -| `Config/LoggingConfig.php` | `implements LoggingConfigurer` (опц.) | -| `Config/ServerConfig.php` | `implements ServerConfigurer` (опц.) | - -**Всё «опц.» — реально опционально**: нет класса → дефолт фреймворка. App-класс -худеет до `main()` + атрибутов; конфиг перестаёт торчать protected-методами в API -наследника (это отдельно важно по твоему принципу инкапсуляции). - ---- - -## 11. Открытые развилки (решить до кода) - -1. **Config-механизм** — full Spring (Configuration/Bean + Configurer-интерфейсы) - / лёгкий (один `AppConfig` с перенесёнными хуками) / гибрид. -2. **Components** — `#[Enable*]`-атрибуты / метод `components()` / оба. -3. **Console vs serve** — `App::main` диспетчеризует и команды, и подъём приложения - (нужно решить: `call run` остаётся отдельным словом, или подъём = дефолт без - команды). -4. **Back-compat** — оставляем ли старый `BaseBoot`/`Application` как deprecated - слой на переходный период, или рубим сразу (Swoole-only приоритет уже задан). diff --git a/tests/Route/Fixtures/ServerProcess.php b/tests/Route/Fixtures/ServerProcess.php new file mode 100644 index 0000000..a6fe06f --- /dev/null +++ b/tests/Route/Fixtures/ServerProcess.php @@ -0,0 +1,192 @@ +port; + } + + public function log(): string + { + return (string) @file_get_contents($this->logFile); + } + + public function url(string $path): string + { + return 'http://127.0.0.1:' . $this->port . $path; + } + + /** Boots the server and returns once it accepts connections, or false on timeout. */ + public function start(float $timeout = 15.0): bool + { + $this->port = self::freePort(); + $this->storage = sys_get_temp_dir() . '/wk_serve_' . getmypid() . '_' . bin2hex(random_bytes(4)); + $this->runner = $this->storage . '.php'; + $this->logFile = $this->storage . '.log'; + + // The entry a project would write by hand: load the autoloader, run the app. + $autoload = dirname(__DIR__, 3) . '/vendor/autoload.php'; + file_put_contents($this->runner, sprintf( + "port, + )); + + $this->pid = (int) trim((string) shell_exec(sprintf( + 'WK_SERVE_STORAGE=%s %s %s >> %s 2>&1 & echo $!', + escapeshellarg($this->storage), + escapeshellarg(PHP_BINARY), + escapeshellarg($this->runner), + escapeshellarg($this->logFile), + ))); + + return $this->awaitReady($timeout); + } + + public function signal(int $signal): void + { + if ($this->pid > 0) { + @posix_kill($this->pid, $signal); + } + } + + public function isAlive(): bool + { + return $this->pid > 0 && @posix_getpgid($this->pid) !== false; + } + + /** Waits for the process to leave on its own; false when it outstays the timeout. */ + public function awaitExit(float $timeout): bool + { + $deadline = microtime(true) + $timeout; + while (microtime(true) < $deadline) { + if (!$this->isAlive()) { + return true; + } + usleep(100_000); + } + + return false; + } + + /** Stops the server if it is still up, then removes everything it left behind. */ + public function stop(): void + { + if ($this->pid > 0) { + @exec(sprintf('kill -TERM %d 2>/dev/null', $this->pid)); + $this->awaitExit(4.0); + @exec(sprintf('kill -KILL %d 2>/dev/null', $this->pid)); + $this->pid = 0; + } + + @unlink($this->runner); + @unlink($this->logFile); + self::removeTree($this->storage); + } + + /** The storage tree nests (storage/runnable//…), so it has to go depth-first. */ + private static function removeTree(string $path): void + { + if ($path === '' || !is_dir($path)) { + return; + } + + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($items as $item) { + $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); + } + @rmdir($path); + } + + /** @return array{status: int, body: string, headers: list} */ + public function request(string $method, string $path): array + { + $context = stream_context_create(['http' => [ + 'method' => $method, + 'timeout' => 5, + 'ignore_errors' => true, // 4xx/5xx must come back as a response, not a warning + ]]); + + $body = @file_get_contents($this->url($path), false, $context); + $headers = $http_response_header ?? []; + + return [ + 'status' => self::statusOf($headers), + 'body' => $body === false ? '' : $body, + 'headers' => $headers, + ]; + } + + /** @param list $headers */ + public static function statusOf(array $headers): int + { + foreach ($headers as $line) { + if (preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $m) === 1) { + return (int) $m[1]; + } + } + + return 0; + } + + /** @param list $headers */ + public static function headerOf(array $headers, string $name): string + { + foreach ($headers as $line) { + $parts = explode(':', $line, 2); + if (count($parts) === 2 && strcasecmp(trim($parts[0]), $name) === 0) { + return trim($parts[1]); + } + } + + return ''; + } + + private function awaitReady(float $timeout): bool + { + $deadline = microtime(true) + $timeout; + while (microtime(true) < $deadline) { + $socket = @fsockopen('127.0.0.1', $this->port, $errno, $errstr, 0.3); + if ($socket !== false) { + fclose($socket); + return true; + } + usleep(200_000); + } + + return false; + } + + /** Asks the OS for an unused port, so parallel runs do not collide. */ + private static function freePort(): int + { + $socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); + $name = stream_socket_get_name($socket, false); + fclose($socket); + + return (int) substr((string) $name, strrpos((string) $name, ':') + 1); + } +} From 9836c2dfef21a75a91633606ac2690dac673df69 Mon Sep 17 00:00:00 2001 From: flytachi Date: Sun, 2 Aug 2026 02:38:50 +0500 Subject: [PATCH 49/71] sqlite --- doc/STATUS.md | 104 ++++++ doc/actuator-plan.md | 124 ------- doc/redes-check.md | 349 ------------------ doc/winter-application-flow.md | 255 ------------- docs/configuration/07-di.md | 4 +- phpunit.xml | 3 + src/Concurrent/Async/Async.php | 4 +- src/Ppa/Mapping/Attributes/Primal/Decimal.php | 6 +- src/Ppa/Mapping/Attributes/Primal/Double.php | 4 +- .../Mapping/Attributes/Primal/FloatType.php | 3 +- .../Mapping/Attributes/Sub/AutoIncrement.php | 10 +- src/Ppa/Mapping/Structure/Index.php | 12 + src/Unit/Pagination/CursorToken.php | 12 + tests/Concurrent/Async/AsyncContractTest.php | 207 +++++++++++ tests/Ppa/Mapping/SqliteDdlTest.php | 150 ++++++++ tests/Ppa/Mapping/Structure/IndexTest.php | 33 +- tests/Unit/Pagination/CursorTokenTest.php | 92 +++++ 17 files changed, 635 insertions(+), 737 deletions(-) create mode 100644 doc/STATUS.md delete mode 100644 doc/actuator-plan.md delete mode 100644 doc/redes-check.md delete mode 100644 doc/winter-application-flow.md create mode 100644 tests/Concurrent/Async/AsyncContractTest.php create mode 100644 tests/Ppa/Mapping/SqliteDdlTest.php create mode 100644 tests/Unit/Pagination/CursorTokenTest.php diff --git a/doc/STATUS.md b/doc/STATUS.md new file mode 100644 index 0000000..c356935 --- /dev/null +++ b/doc/STATUS.md @@ -0,0 +1,104 @@ +# Статус переделок — что сделано, что висит + +> Замена трём рабочим журналам (`redes-check.md`, `actuator-plan.md`, +> `winter-application-flow.md`): их решения приняты и перенесены в `CLAUDE.md` §15, +> поток загрузки описан там же. Здесь остаётся только сухой срез. +> +> Обновлено: 2026-08-02. + +--- + +## ✅ Сделано + +**Стартер `WinterApplication`** +- «Бог-класс» `Boot`/`BaseBoot`/`Application` удалён вместе со всеми семью хуками, кроме + `configure()` (он обязан остаться методом — решает, где искать скан). +- Манифест `#[Enable*]`; пустой манифест = ошибка загрузки, а не тихо простаивающее + приложение. +- Конфигурация — сканируемые классы: `#[Configuration]`/`#[Bean]`, `WebConfigurer`, + `LoggingConfigurer`, `HealthContributor`, `#[Import]`. +- Один проход сканера на всё. + +**Actuator** — `#[EnableActuator]` + `HealthContributor`; в компоненте `db` теперь +вложена утилизация пула. + +**Пул соединений** (`src/ConnectionPool/`) — idle-gate + `maxLifetime`, фоновый +housekeeper (keepalive / idleTimeout / minimumIdle, opt-in), evict при потере соединения +**без retry**, телеметрия + `call db pool`. Проверено на живых PostgreSQL и MariaDB. + +**Раскладка** — `public/` удалён из ядра; статику отдаёт Swoole (`staticPath()`); +`resources/{static,views}`; ядро не ссылается на внешние ассеты. + +**Back-compat** — вопрос закрыт: старый вход удалён, сосуществования нет. + +**Swoole-валидация `serveHttp`** — раньше числилась непроверенной («нет swoole на +боксе»); теперь есть живые прогоны и `ServeHttpTest` + `GracefulShutdownTest`. + +**Документация** — `docs/` вычищен от мёртвого API, `README.md` переписан, `doc-new/` +удалён. + +**Аудит непроверенных слоёв** — `Concurrent/Async`, `Unit/Pagination`, `Stereotype`, +миграции `Ppa` открыты и покрыты тестами (`AsyncContractTest`, `CursorTokenTest`, +`SqliteDdlTest`). В `CursorToken::decode()` добавлен отказ на нескалярное значение +позиции: подделанный курсор с массивом внутри доходил до билдера запроса и падал +`TypeError` — 500 на кривой вход вместо 400. + +**SQLite в миграциях `Ppa`** — пять мест роняли `UnhandledMatchError` на любом диалекте +кроме mysql/pgsql: `Primal\{Decimal,Double,FloatType}`, `Sub\AutoIncrement`, +`Structure\Index`. Везде добавлена ветка `sqlite`, старая первая ветка переведена в +`default` без смены значения. **pg/mysql не тронуты — сверено побайтово** с эталоном DDL +широкой сущности (20 колонок: все типы, индексы, дефолты, nullable), плюс тот же DDL +заново исполнен на живых PostgreSQL 16 и MariaDB 11. + +> Тонкость, найденная замером: rowid-алиасом в SQLite колонка становится, только если её +> тип записан **ровно `INTEGER`** — `INT`/`BIGINT`/`SMALLINT` дают отказ NOT NULL на +> первом `INSERT`, уже после успешного создания схемы. Поэтому `AutoIncrement` для sqlite +> поднимает любой целочисленный тип до `INTEGER`. `AUTOINCREMENT` намеренно не пишется: он +> лишь запрещает переиспользование id и стоит служебной таблицы. Отдельная строка +> `PRIMARY KEY (id)` (как генерит движок) rowid-алиасу не мешает — проверено, ломать +> структуру не пришлось. + +--- + +## 🟡 Висит + +| Пункт | Состояние | +|---|---| +| **`call health`** | команды нет; актуатор доступен только по HTTP | +| **`#[EnableActuator(port)]`** | атрибут принимает `middleware` и `indicator`, отдельного порта нет | +| **WebSocket** | `Component::websocket()` существует, движка за ним нет | +| **Starter-autoconfig** | только явный `#[Import]`; авто-подключение через `composer.json extra.winter` не делалось | +| **FPM** | из ядра не обслуживается; адаптеры (`FpmRequest`/`FpmResponse`) на месте и покрыты тестами — основа для отдельного `winter-fpm` | + +### SQLite: что осталось (следующий заход) + +Схема создаётся и работает, но добито не всё: + +| Пробел | Суть | +|---|---| +| `Json` / `TextArray` | отдают `JSON`; SQLite тип принимает, но даёт affinity NUMERIC вместо TEXT — работает, семантически неверно | +| Хранимые процедуры | `Structure\StoredProcedure` в SQLite смысла не имеет — нужен явный отказ, а не молчаливая генерация | +| `ALTER TABLE` | в SQLite сильно урезан (нет DROP/MODIFY COLUMN до 3.35) — на первый `CREATE` не влияет, всплывёт на последующих миграциях | +| Внешние ключи | требуют `PRAGMA foreign_keys=ON` на **каждое** соединение, иначе FK молча не действуют | + +## ✅ Починено вне ядра + +**`ping()` в winter-cdo врал `true` на мёртвом соединении** — исправлен в +`BaseDbConfig::ping()` и `pingDetail()`: ловится `Throwable` (а не `CDOException`, который +`PDOException` не родня), и `return` убран из `finally`, где он глотал исключение. +Проверено на живых PostgreSQL и MariaDB: живое → `true`, убитое → `false`, недоступный +порт → `false`. Ядро на него всё равно не опирается — `CdoConnectionFactory::probe()` +делает пробу сам, чтобы не зависеть от версии пакета. + +**Автоскобки в `Qb` (winter-cdo)** — группа условий склеивалась без скобок, поэтому +`OR` внутри группы разрывал внешний `AND`. На реальном проде это давало **обход +контроля доступа**: фильтр по складу отваливался, когда рядом стоял `OR`-блок поиска. +Исправлены `logicalPrepare()` (оборачивает группу из >1 части) и `add()` (пропускает +пустое условие, оборачивает результат). Планы запросов побайтово те же — сверено через +`EXPLAIN` (cost 91.32..599.18 в обоих вариантах), замедления нет. + +--- + +## Не покрыто проверкой + +OpenApi (отложен сознательно). Нагрузочных и суточных прогонов не было. diff --git a/doc/actuator-plan.md b/doc/actuator-plan.md deleted file mode 100644 index 0a78167..0000000 --- a/doc/actuator-plan.md +++ /dev/null @@ -1,124 +0,0 @@ -# Actuator / Health — план (handoff, продолжить отсюда) - -> Что хотим сделать с actuator в рамках редизайна `WinterApplication`. Часть решена, -> часть требует решения ПЕРЕД кодом. Контекст обсуждения: `doc/redes-check.md` §3. -> Health в новом `WinterApplication` ПОКА НЕ подключён — это следующий шаг. - ---- - -## 0. Цель одной фразой - -Дать диагностику приложения (`/actuator/*`: health/info/metrics/env/loggers/mappings) -так, чтобы она работала **и с web-сервером, и в headless** (приложение из только -Daemon+Schedule), и убрать хук `Health::configure()` с «бог-класса» → в атрибут. - ---- - -## 1. Ключевое разделение (не путать) - -- **Health-проверка (логика)** — транспорт-независима, работает всегда, в т.ч. без web. -- **`/actuator/*`** — это HTTP-**роуты** → без слушателя по HTTP их не прочитать. -- **НЕ путать** с Process/Daemon-статусом: `DaemonStatus`/`WorkerStatus` (`call daemon X - status`) = process-level (жив ли воркер, рестарты). Actuator = app-level (БД, память, - диск). Разные вещи, не сливать. - ---- - -## 2. ✅ Решено — способ отдачи = ОБА - -1. **`call health` (CLI)** — новая консольная команда. Зовёт индикатор, печатает JSON, - код возврата 0/1. Работает всегда (headless тоже). Для k8s `exec`-проб / cron. - ```yaml - livenessProbe: { exec: { command: ["php","call","health"] } } - ``` -2. **`#[EnableActuator(port: 9000)]`** — отдельный management-компонент (крошечный - сервер), поднимается **даже без web-компонента**. Калька Spring `management.server.port`. - ```yaml - livenessProbe: { httpGet: { path: /actuator/health, port: 9000 } } - ``` -3. **Если есть `Component::http()`** → `/actuator/*` на основном сервере (как сейчас). -4. **`#[EnableActuator(...)]` заменяет `Health::configure()`** — параметры (`port`, - `middleware`, `indicator`) переезжают в атрибут на App-классе. - -Пример: -```php -#[EnableActuator(port: 9000, middleware: InternalOnlyMiddleware::class)] -final class App extends WinterApplication { /* ... */ } -``` - ---- - -## 3. 🟡 РЕШИТЬ ПЕРЕД КОДОМ — форма индикатора - -Текущая модель: **ОДИН** `HealthIndicator` с 6 методами (см. §5), кастом через -наследование + `Health::configure(indicator:)`. Это НЕ спринговский «много маленьких». - -- **B1** — оставить один индикатор; только перенести конфиг в `#[EnableActuator]`. - Минимум изменений. Кодер наследует весь индикатор и переопределяет метод. -- **B2 (тяготеем сюда)** — разбить на много маленьких `HealthContributor` (каждый - чекает одно: БД, redis, диск); фреймворк сам находит их через `ImplementorCollector` - и агрегирует в `/actuator/health`. Системные секции (info/metrics/env/loggers/ - mappings) остаются встроенными. Drop-in, как понравилось в §2 redes-check. - ```php - final class RedisHealth implements HealthContributor { - public function check(): Health { return $redis->ping() ? Health::up() : Health::down(); } - } - ``` - Минус: переделка интерфейса Health + агрегатор. - -> **Решение оставлено на завтра. Пользователь склоняется к B2.** Начать с этого выбора. - ---- - -## 4. Куда встроить в WinterApplication (точки интеграции) - -1. **Чтение атрибута** — в `WinterApplication::bootstrap()`, рядом с `applyImports()`: - прочитать `#[EnableActuator]` на `static::class` → сохранить конфиг (port/middleware/ - indicator). (См. как сделан `applyImports()` — тот же приём с рефлексией атрибутов.) -2. **Web-путь** — если есть `Component::http()` и actuator включён → зарегистрировать - `/actuator/*` роуты (эквивалент нынешнего `Health::configure`), чтобы `Router::fromScan` - в `serveHttp()` их подхватил. Проверить, КАК сейчас `/actuator` попадает в роутер - (`src/Http/Health/Health.php` + где Router читает `Health::getConfig()`). -3. **Management-порт** — если задан `port` → поднять отдельный маленький сервер только - на `/actuator/*`. Варианты: отдельный `addProcess` в `serveHttp`, либо отдельный - companion в headless. Дизайн уточнить (Swoole `Http\Server` на своём порту, только - actuator-роуты). -4. **CLI** — новая команда `console/Command/Health.php`: boot → собрать секции индикатора - (health/info/metrics/...) → `echo json` → `exit(up?0:1)`. Не зависит от web. - Учесть: `WinterApplication::run()` уже отдаёт неизвестные глаголы в `Core`, значит - `call health` дойдёт до команды после boot автоматически. - ---- - -## 5. Текущий код Health (карта — что есть сейчас) - -- `src/Http/Health/HealthIndicatorInterface.php` — интерфейс, **6 методов**: - `health(): array`, `info(): array`, `metrics(): array`, `env(): array`, - `loggers(): array`, `mappings(): array`. -- `src/Http/Health/HealthIndicator.php` — дефолтная реализация (класс implements - interface); есть helper `dbHealth(string $rootDir)`, системные метрики. -- `src/Http/Health/Health.php` — статический реестр: - `configure(indicator = HealthIndicator::class, middleware = null)` → пишет `self::$config`; - `getConfig()`, `setMappings()/getMappings()`, `setRootDir()/getRootDir()`, - статические хелперы (`cpu()` и др.). Регистрирует `/actuator/*` эндпоинты. -- Порог статуса: degraded ≥80% ресурсов, down ≥90% или отказ соединения. - ---- - -## 6. Resume-чеклист (с чего начать завтра) - -1. [ ] Выбрать **B1 или B2** (форма индикатора). Пользователь → скорее B2. -2. [ ] Если B2: спроектировать `HealthContributor` (интерфейс `check(): Health`) + - агрегатор + `Health` value-объект (up/down/withDetail). Системные секции — - оставить во встроенном индикаторе. -3. [ ] Атрибут `#[EnableActuator(port?, middleware?, indicator?)]` + - `src/App/...` + чтение в `bootstrap()`. -4. [ ] Web-путь: регистрация `/actuator/*` при наличии `Component::http()`. -5. [ ] Management-порт: отдельный сервер при заданном `port` (в т.ч. headless). -6. [ ] CLI `console/Command/Health.php` (`call health`, JSON + exit-код). -7. [ ] Тесты в `tests/App` (агрегация contributors, exit-код CLI, attribute-config). -8. [ ] Обновить `doc/redes-check.md` §3 (🟡 → ✅) и `doc/winter-application-flow.md`. - -> Не трогать `Boot`/`Application` (правило редизайна). `WinterApplication` — -> отдельный вход. Полный контекст редизайна: `doc/redes-check.md`, -> `doc/winter-application-flow.md`. Память: `winter-application-redesign`. diff --git a/doc/redes-check.md b/doc/redes-check.md deleted file mode 100644 index 5a1e87c..0000000 --- a/doc/redes-check.md +++ /dev/null @@ -1,349 +0,0 @@ -# Redesign speak — живой лог решений (WinterApplication) - -> Рабочий журнал обсуждения редизайна загрузчика. Сюда фиксируем **согласованные** -> куски по мере разбора. Статусы: ✅ принято · 🟡 обсуждается · ⬜ ещё не трогали. -> -> Общая цель: убрать «бог-класс» `Boot` (где один класс держит `components()`, -> `configure()`, `providers()`, `channels()`, `httpCors()`, `health()`, `plugins()`, -> `swooleConfig()`) → перейти к Spring-модели: **тонкий entry-класс + -> конфигурация, разъехавшаяся по классам, которые находит сканер.** - ---- - -## ✅ 1. Beans — `#[Configuration]` + `#[Bean]` (замена `providers()`) - -**Принято.** Нравится, потенциально закрывает много кейсов конфигурации. - -### Суть -`#[Bean]` — это **сахар над существующим контейнером**, не отдельная система. -Коллектор под капотом дёргает ровно `$c->singleton()/->transient()/->request()`. - -### Как работает -- Класс с `#[Configuration]` — контейнер бинов. -- Метод с `#[Bean]` — фабрика. **Ключ бина = тип возврата метода.** -- Тело метода = та же фабрика-замыкание, что раньше писали в `providers()`. -- Аргументы `#[Bean]`-метода автоинжектятся (в т.ч. `#[Value('ENV_KEY')]` из `.env`). - -### Scope (важно!) -`#[Bean]` по умолчанию = **singleton** (метод вызывается 1 раз, объект кэшируется и -переиспользуется везде) — как `@Bean` в Spring. - -| Запись | Под капотом | -|---|---| -| `#[Bean]` | `$c->singleton(ReturnType, factory)` — 1 объект на процесс | -| `#[Bean(scope: Scope::Transient)]` | `$c->transient(...)` — новый каждый раз | -| `#[Bean(scope: Scope::Request)]` | `$c->request(...)` — один на запрос/корутину | - -> Асимметрия, держать в голове: `#[Bean]` по умолчанию singleton, а обычный -> сканируемый класс **без атрибута** — transient. (Тоже как в Spring.) - -### Пример (в нашем стиле — `#[Autowired]`, без конструктора) - -```php -#[Configuration] -final class AppConfig -{ - #[Bean] // = $c->singleton(CacheInterface::class, ...) - public function cache(): CacheInterface - { - return new RedisCache(env('REDIS_URL')); - } - - #[Bean] // аргументы автоинжектятся - public function mailer(#[Value('MAIL_HOST')] string $host): MailerInterface - { - return new SmtpMailer($host); - } -} -``` - -Потребитель — как обычно: - -```php -class ReportService extends Service -{ - #[Autowired] - private CacheInterface $cache; // придёт бин из cache(), тот же объект везде -} -``` - -### Про `#[Service]` — НЕ добавляем -- У нас `Service` — это **базовый класс** (`Stereotype\Service`), его наследуют; - зависимости через `#[Autowired]`-свойства. Атрибута `#[Service]` нет и не нужен. -- `extends Service` сам по себе в DI ничего не регистрирует — за scope отвечает - атрибут `#[Singleton]/#[Request]/#[Transient]` (см. `DICollector`). Без атрибута - класс автоварится по рефлексии как transient. -- Spring `@Service` = просто спец-`@Component` (метка + семантика). **Прироста - скорости нет** — атрибут vs базовый класс в рантайме равны. Единственный - реальный плюс атрибута — освобождает слот наследования PHP; нам сейчас не жмёт. -- Итог: простые сервисы — `extends Service` + `#[Autowired]` (+ `#[Singleton]` при - нужде кэша). Сложную сборку (интерфейс→реализация, фабрика, скаляр из env) берёт - `#[Bean]`. - ---- - -## ✅ 2. Конфигураторы — интерфейс + авто-discovery (на примере CORS `WebConfigurer`) - -**Принято.** Ключевой вывод: **этим паттерном можно добавлять сколько угодно -конфигов** — универсальный «drop-in» механизм. - -### Суть -Кодер пишет класс, `implements` нужный интерфейс — и **приложение само его -находит** на скане. Никакой регистрации, никаких ссылок из App-класса. - -### Как «оно само находит» -Механизм не новый — у нас **уже есть** `ImplementorCollector` -(`src/Collector/ImplementorCollector.php`): на скане собирает все не-абстрактные -классы, реализующие заданный интерфейс (так сейчас находятся `DbConfigInterface`). - -```php -// framework side — в тот же скан, что уже идёт в boot(): -$web = new ImplementorCollector(WebConfigurer::class); - -Scanner::run(rootDir: Kernel::$pathRoot, cache: ...) - ->collect(new DICollector($c)) - ->collect($async) - ->collect($web) // ← одна строка = «ищи реализации WebConfigurer» - ->execute(); - -// после скана — вызвать найденное: -$registry = new CorsRegistry(); -foreach ($web->getResult() as $ref) { - $configurer = $c->make($ref->getName()); // через контейнер → #[Autowired] внутри тоже работает - $configurer->configureCors($registry); -} -Cors::configure(...$registry->build()); // применяем в существующий Cors::configure() -``` - -### Что делает кодер — только создать файл - -```php -namespace Main\Config; - -use Flytachi\Winter\K2\Http\Cors\WebConfigurer; -use Flytachi\Winter\K2\Http\Cors\CorsRegistry; - -final class WebConfig implements WebConfigurer // ← достаточно `implements` -{ - public function configureCors(CorsRegistry $cors): void - { - $cors->allowedOrigins('https://app.example.com') - ->allowCredentials(true); - } -} -``` - -Положил класс → сканер увидел `implements` → фреймворк вызвал. Удалил → дефолт. - -### Нюансы -- Конфигураторов может быть **несколько** — вызовутся все (обычно хватает одного). -- Создаётся через `$c->make(...)` → внутри можно `#[Autowired]`-зависимости. - -### Обобщение -Тот же паттерн (`implements интерфейс` → сканер находит → фреймворк вызывает) -переиспользуется для всех «конфигураторов» редизайна: - -| Интерфейс | Заменяет | Что делает | -|---|---|---| -| `WebConfigurer` | `httpCors()` | настраивает CORS | -| `LoggingConfigurer` | `channels()` | регистрирует лог-каналы | -| `ServerConfigurer` | `swooleConfig()` | тюнит Swoole-сервер | -| `HealthIndicator` | `health()` | тот же discovery, но **собирается в список** проверок, а не «настраивает» | - ---- - -## 🟡 3. Health / Actuator — способ отдачи РЕШЁН, индикатор ПЕРЕПИШЕМ - -**Способ отдачи — принято (оба). Форма индикатора — будем переписывать, обсудим -отдельно** (не фиксируем реализацию сейчас). - -### Важное разделение (зафиксировать в голове) -- **Health-проверка (логика)** — транспорт-независима, работает и в headless - (например, приложение из только Daemon + Schedule). -- **`/actuator/*`** — это HTTP-**роуты** (`Health::configure()` регистрирует - мэппинги, читает `Router`) → без слушателя по HTTP их прочитать некому. - -### ✅ Способ отдачи health — ОБА -- **`call health` (CLI)** — команда зовёт индикатор, печатает JSON, exit 0/1. - Работает всегда, в т.ч. headless. Для k8s `exec`-проб / cron. -- **`#[EnableActuator(port: 9000)]`** — отдельный management-компонент (крошечный - сервер), поднимается **даже без `#[EnableWeb]`** (калька Spring - `management.server.port`). -- Если есть `#[EnableWeb]` → `/actuator/*` на основном сервере, как сейчас. -- `#[EnableActuator(...)]` заменяет `Health::configure()` — параметры (`port`, - `middleware`, `indicator`) переезжают в атрибут на App-классе. - -### 🟡 Форма индикатора — ПЕРЕПИШЕМ (обсудить позже) -Текущая модель: **один** `HealthIndicator` с 6 методами (`health/info/metrics/env/ -loggers/mappings`), кастом через наследование + `Health::configure(indicator:)`. -Это НЕ спринговский «много маленьких проверок». Обсудили направления: -- **B1** — оставить один индикатор (минимум изменений). -- **B2** — разбить на маленькие `HealthContributor` (drop-in, фреймворк сам находит - через `ImplementorCollector` и агрегирует; системные секции остаются встроенными). - -> Решение: **скорее всего перепишем** (тяготеем к B2 / drop-in), но детали — -> отдельным обсуждением. Пока НЕ реализуем. - -### Не путать с Process/Daemon-статусом -`DaemonStatus`/`WorkerStatus` (через store, `call daemon status`) — это -**process-level** liveness (жив ли воркер, рестарты). Health/Actuator — **app-level** -(БД, память, диск). Разные вещи, не сливаем. - ---- - -## ✅ 4. Import + Starter (замена `plugins()`) — переименовано - -**Принято.** `plugins()` → `import`. Два уровня, как в Java (не путать): - -### A. `#[Import(...)]` — явная форма (делаем сразу) -Переименованный `Plugin::registry()` в атрибут. Ты контролируешь prefix / `required`. - -```php -#[Import('acme/auth-plugin', '/auth')] -#[Import('acme/billing', '/billing', required: false)] -final class App extends WinterApplication { /* ... */ } -``` - -Под капотом — существующий механизм (`Composer\InstalledVersions::getInstallPath`, -скан `src/` пакета). Просто хук `plugins()` → атрибуты на App. - -### B. True starter — авто-конфиг (фича поверх, позже) -Пакет **сам объявляет себя** winter-стартером через `composer.json`, ядро сканит -установленные пакеты и подключает **без строк в App** (= Spring Boot starter: -`composer require` → включилось): - -```jsonc -// composer.json пакета acme/billing-starter -"extra": { - "winter": { - "starter": true, - "prefix": "/billing", - "config": "Acme\\Billing\\BillingConfiguration" // его #[Configuration] - } -} -``` - -- Новый механизм: читать `extra.winter` из `composer.json` установленных пакетов - (composer-идиома вместо спринговского `AutoConfiguration.imports`). -- `#[Import('acme/billing')]` остаётся как **override** авто-старта (сменить prefix, - отключить, `required:false`). -- **Порядок:** `#[Import]` сейчас, starter-autoconfig — отдельной фичей. - ---- - -## ✅ 5. Web / Server — `ServerConfigurer` УБИРАЕМ, сливаем в web - -**Принято.** Тюнинг сервера — это web-tier concern, отдельная сущность не нужна. -`swooleConfig()` уходит. Два уровня: - -> ⚠️ ПОПРАВКА (после разбора): `#[EnableWeb]` **отменён** — такой аннотации в Java -> нет (web в Spring включается наличием зависимости, не аннотацией). Ручки сервера -> переезжают на существующий `Component::http(host, port)` + `.env` + `WebConfigurer`. -> См. §6 «Компоненты». - -### Частые ручки — на `Component::http()` + `.env` - -```php -Component::http(host: '0.0.0.0', port: 8000) // host/port — как сейчас -// SERVER_WORKERS=8, SERVER_MAX_REQUEST=5000 — в .env -``` - -### Глубокий тюнинг — метод в `WebConfigurer` (тот же класс, что CORS) -Чтобы не заставлять реализовывать оба метода — **абстрактный адаптер с пустыми -дефолтами** (= спринговский `WebMvcConfigurerAdapter`): наследуешь, переопределяешь -только нужное. - -```php -final class WebConfig extends WebConfigurerAdapter // пустые дефолты configureCors + configureServer -{ - public function configureServer(ServerSettings $s): void // override только это - { - $s->set('ssl_cert_file', '/etc/ssl/app.pem'); - } -} -``` - -> Итог: `ServerConfigurer` из §2-таблицы **удалён**. Сервер = часть web-поверхности: -> `#[EnableWeb]` (ручки) + `WebConfigurer::configureServer()` (редкий тюнинг). - ---- - -## ✅ 6. Компоненты (что запускается) — оставляем метод `components()` - -**Принято.** Никаких `#[EnableWeb]` (в Java такой аннотации нет — web включается -зависимостью, не аннотацией). Явный сигнал «из чего собрано приложение» = метод. - -```php -protected static function components(): array -{ - return [ - Component::http(port: 8000), // есть #[EnableWeb]? — НЕТ, только это - Component::process(KernelSys::class), - Component::daemon(Emails::class), - Component::scheduler(), - ]; -} -``` - -- Честно, гибко (можно `if (env(...))`), без фейковых аннотаций. -- Параметры web-сервера: `Component::http(host, port)` + `.env` (`SERVER_*`) + - `WebConfigurer::configureServer()` (редкий тюнинг). См. §5. -- `#[Import]` (§4) остаётся отдельно — `@Import` это **реальная** Spring-аннотация. -- Реальные Java-тоглы (`@EnableScheduling`/`@EnableAsync`) НЕ вводим — scheduler это - просто `Component::scheduler()`, один механизм. - ---- - -## ✅ 7. Логи (каналы) — как есть, норм - -**Принято, не усложняем.** Базовые каналы (`http`, `sys`) + кастомные через `.env` -(`LOG_{NAME}_*`). Редкий код (динамические каналы) — интерфейс `LoggingConfigurer` -(тот же discovery-паттерн, что §2). Ничего не переделываем. - ---- - -## ✅ 8. Аргументы `main(array $args)` — минимальные, без `--profile` - -**Принято.** Только реальные ручки: `--port`, `--host` и т.п. Спринговские профили -(`--profile`) НЕ нужны — выкинули. Приоритет: **CLI-аргумент > .env > дефолт**. - ---- - -## ✅ 9. Точка входа + Boot order — РЕАЛИЗОВАНО - -**Принято и написано кодом.** `Boot`/`Application` НЕ тронуты — `WinterApplication` -это параллельный самостоятельный вход. - -- `App::main($argv)` → `WinterApplication::run($argv)`. -- **Диспетчеризация:** `call run`/`run dev` → поднять приложение (`serve`); голый - `call` → help, любой другой глагол (`make/daemon/cfg/...`) → консольный `Core`. - `run` перехватывается ДО `Core` (старый `Run`-command завязан на `Application`). -- **Аргументы:** `ApplicationArguments` (`--port/--host/-w`), приоритет CLI > .env > - дефолт. `--profile` выкинут (§8). -- **Boot order:** `Kernel::init` рано (курица-яйцо); затем ОДИН скан с коллекторами - `DICollector` + `ConfigurationCollector` + `AsyncCollector` + - `ImplementorCollector(WebConfigurer/LoggingConfigurer)`; после скана — apply - logging → cors → imports. Кэш `Scanner` хранит только список FQCN → добавление - коллекторов безопасно. - ---- - -## 📦 Статус реализации (готово в коде) - -Файлы: `src/WinterApplication.php`, `src/App/{ApplicationArguments,Scope}.php`, -`src/App/Attribute/{Configuration,Bean,Value,Import}.php`, -`src/Collector/ConfigurationCollector.php`, -`src/App/Config/{WebConfigurer,WebConfigurerAdapter,CorsRegistry,ServerSettings,LoggingConfigurer,ChannelRegistry}.php`. -Тесты: `tests/App/*` (11). **Весь сьют: 1416 зелёных.** - -Схема потока: `doc/winter-application-flow.md`. - -## ⬜ Осталось (отдельными шагами) -- 🟡 **Health/Actuator** — переписать индикатор + `call health` + `#[EnableActuator(port)]`. -- 🟡 **WebSocket** — порт движка (`Component::websocket()` пока throw). -- 🟡 **Starter-autoconfig** — `composer.json extra.winter` (сейчас только `#[Import]`). -- ⬜ **Back-compat** — сосуществование/удаление `BaseBoot`/`Application`; демо-`bootstrap` - на `WinterApplication`. -- ⬜ **Swoole-валидация** — `serveHttp` локально не проверялся (нет swoole на dev-боксе). - -> Полный черновой обзор со всеми примерами: `docs/winter-application-redesign.md`. diff --git a/doc/winter-application-flow.md b/doc/winter-application-flow.md deleted file mode 100644 index 2a7db13..0000000 --- a/doc/winter-application-flow.md +++ /dev/null @@ -1,255 +0,0 @@ -# WinterApplication — схема работы (как что движется) - -> Карта нового загрузчика: файлы, поток управления, поток данных. Читать при -> тестах и детальном разборе. `Boot`/`Application` НЕ тронуты — это параллельный, -> самостоятельный вход. Всё покрыто тестами (`tests/App`, суммарно 1416 зелёных). - ---- - -## 1. Карта файлов (что появилось) - -| Файл | Роль | -|---|---| -| `src/WinterApplication.php` | **точка входа**: `main()`/`run()` → boot → dispatch → serve | -| `src/App/ApplicationArguments.php` | парсинг `argv` (`--port`, `-w`, command/sub) | -| `src/App/Scope.php` | enum scope бина: Singleton / Transient / Request | -| `src/App/Attribute/Configuration.php` | метка класса-конфигурации (Spring `@Configuration`) | -| `src/App/Attribute/Bean.php` | метка фабричного метода (Spring `@Bean`) | -| `src/App/Attribute/Value.php` | инжект значения из `.env` в параметр бина (`@Value`) | -| `src/App/Attribute/Import.php` | подключение пакета-плагина (`@Import`), repeatable | -| `src/Collector/ConfigurationCollector.php` | регистрирует `#[Bean]`-методы в контейнер | -| `src/App/Config/WebConfigurer.php` | контракт: CORS + тюнинг сервера (`WebMvcConfigurer`) | -| `src/App/Config/WebConfigurerAdapter.php` | пустые дефолты обоих методов (адаптер) | -| `src/App/Config/CorsRegistry.php` | fluent-билдер CORS → `Cors::configure()` | -| `src/App/Config/ServerSettings.php` | опции Swoole из `.env` + тюнинг | -| `src/App/Config/LoggingConfigurer.php` | контракт: доп. лог-каналы | -| `src/App/Config/ChannelRegistry.php` | билдер каналов → `Kernel::channel()` | -| `tests/App/*` | тесты: аргументы + Beans-коллектор | - -Переиспользуется как есть: `Kernel`, `Container`, `Scanner`, `DICollector`, -`AsyncCollector`, `ImplementorCollector`, `Cors`, `Plugin`, `Router`, `DevWatcher`, -`ForkReset`, `Component`/`ComponentKind`, консольный `Core`. - ---- - -## 2. Общий поток (сверху вниз) - -``` - call → App::main($argv) (dev/call — единственный вход) - │ - ▼ - WinterApplication::run($argv) - │ - ┌──────────┴───────────────────────────────────────────────┐ - │ 1. ApplicationArguments::parse($argv) │ argv → объект args - │ command / sub / --port / -w / raw │ - ├───────────────────────────────────────────────────────────┤ - │ 2. bootstrap($args) ← СЕРДЦЕ (см. §3) │ ядро + скан + конфиг - ├───────────────────────────────────────────────────────────┤ - │ 3. DISPATCH по command: │ - │ 'run' | 'run dev' → serve($watch,$args) (§5) │ поднять приложение - │ пусто | make|cfg|... → new Core($argv)->run() │ консоль (пусто → Help) - └───────────────────────────────────────────────────────────┘ -``` - -Решение по диспетчеризации: **поднять приложение только по `call run`/`call run -dev`**; голый `call` и любой другой глагол = консольная команда (тот же `Core`, что -и в старом `cli()`; голый `call` → `Help`). `run` перехватывается ДО `Core` (старый -`Run`-command завязан на `Application`, его не трогаем). - ---- - -## 3. Фаза boot — `bootstrap($args)` (откуда что берётся) - -``` -bootstrap($args) - │ - ├─ configure($args) → Kernel::init(pathRoot: rootPath()) - │ .env, логгер (sys/http), timezone, thread - │ [РАНО: до скана — курица-яйцо] - │ - ├─ $c = Container::init() - │ - └─ ОДИН скан проекта (Scanner::run(pathRoot, cache di.php)): - collect(DICollector) → #[Singleton]/#[Request]/#[Transient] - collect(ConfigurationCollector) → #[Configuration] + #[Bean] → фабрики в $c - collect(AsyncCollector) → #[Async]-прокси - collect(ImplementorCollector(WebConfigurer)) → список классов - collect(ImplementorCollector(LoggingConfigurer)) → список классов - execute() - │ - ▼ после скана — ПРИМЕНИТЬ найденное: - applyLogging($c, найденные LoggingConfigurer) → ChannelRegistry → Kernel::channel() - applyCors($c, найденные WebConfigurer) → CorsRegistry → Cors::configure() - (+ запомнить классы для §5) - applyImports() → читает #[Import] на App-классе → Plugin::registry() -``` - -Ключевое: **конфигурация не вызывается как хуки на App-классе — она НАХОДИТСЯ -сканером** (обычные классы в проекте) и применяется после скана. App-класс знает -только `components()` + `configure()` + свои `#[Import]`-атрибуты. - -### Как `#[Bean]` попадает в контейнер (ConfigurationCollector) - -``` -#[Configuration] class AppConfig - ├─ #[Bean] cache(): CacheInterface → $c->singleton(CacheInterface, factory) - ├─ #[Bean(scope: Transient)] q(): Query → $c->transient(Query, factory) - └─ сам AppConfig → $c->singleton(AppConfig) (общий инстанс) - -factory(при resolve): - $config = $c->make(AppConfig) ← общий инстанс конфигурации - аргументы метода: - #[Value('KEY', def)] → env('KEY', def) ← скаляр из .env - иной тип → $c->make(тип) ← автowire - return $config->method(...аргументы) -``` -> Бин ОБЯЗАН возвращать объект (контейнер инжектит свойства в результат) — скаляры -> только через `.env`/`#[Value]`. Иначе коллектор кидает понятную ошибку. - ---- - -## 4. Как находятся конфигураторы (discovery) - -Один и тот же механизм для CORS/сервера/каналов — существующий `ImplementorCollector`: - -``` -Кодер кладёт класс: Фреймворк на скане: - class WebConfig ImplementorCollector(WebConfigurer) - implements WebConfigurer ──────► ->getResult() = [WebConfig, ...] - { configureCors(...) } после скана: $c->make(WebConfig) - ->configureCors($registry) - $registry->apply() → Cors::configure() -``` - -Ноль регистрации. Положил файл → нашёлся → применился. Удалил → дефолт. - ---- - -## 5. Фаза serve — `serve($watch, $args)` - -``` -serve() - ├─ components() → классификация: - │ Http → $http (максимум один) - │ WebSocket → ⛔ throw (порт легаси-движка ещё не готов) - │ Process/Daemon/Scheduler → companions[] - │ - ├─ есть $http? ──ДА──► serveHttp() (нужен ext-swoole) - │ host/port: args --port/--host > Component::http > дефолт - │ server->set( ServerSettings::fromEnv() + WebConfigurer::configureServer ) - │ Router::fromScan(pathRoot) + static(public) - │ companions → $server->addProcess(...) (супервизор) - │ workerStart → канал 'http' + CoroutineContext - │ companions-child → канал 'sys' + ProcessContext + ForkReset - │ $watch → DevWatcher (память + hot-reload через reexec) - │ $server->start() - │ - └─ нет $http? ──► serveHeadless() - 1 компонент → foreground $class::start() - несколько → pcntl fork на каждый + waitpid + форвард SIGTERM/SIGINT - (работает и без swoole) -``` - ---- - -## 6. Что пишет кодер (полный пример) - -```php -// App.php — тонкий класс приложения -#[Import('acme/auth-plugin', '/auth')] // плагин (опц.) -final class App extends WinterApplication -{ - protected static function configure(ApplicationArguments $args): void - { - Kernel::init(pathRoot: __DIR__); // или убрать → rootPath() сам выведет - } - - protected static function components(): array - { - return [ - Component::http(port: 8000), // web (опц.) - Component::daemon(Emails::class), - Component::scheduler(), - ]; - } -} -``` - -```php -// call — единственный launcher -require __DIR__ . '/vendor/autoload.php'; -require __DIR__ . '/App.php'; -App::main($argv); -``` - -Опциональные конфиг-классы (кладёшь — находятся сами): - -```php -#[Configuration] -final class AppConfig -{ - #[Bean] - public function mailer(#[Value('MAIL_HOST')] string $host): MailerInterface - { - return new SmtpMailer($host); - } -} - -final class WebConfig extends WebConfigurerAdapter -{ - public function configureCors(CorsRegistry $cors): void - { - $cors->allowedOrigins('https://app.example.com')->allowCredentials(); - } - public function configureServer(ServerSettings $s): void - { - $s->workers(8)->maxRequest(5000); - } -} -``` - -Как запускать: - -```bash -php call # help (консоль, Core → Help) -php call run # поднять приложение, DevWatcher off -php call run dev # + DevWatcher (память + hot-reload) -php call run --port=8080 # override порта -php call make -c UserController # консольная команда (через Core) -php call daemon main.Emails start # standalone-компонент -``` - ---- - -## 7. Каналы логов (куда что пишется) - -| Контекст | Канал | Где ставится | -|---|---|---| -| HTTP-запрос (worker) | `http` | `serveHttp` on('workerStart') + CoroutineContext | -| master / companions / console / framework | `sys` | run() перед Core; child-замыкания addProcess/headless | - -Кастомные каналы — `.env` (`LOG_{NAME}_*`) или `LoggingConfigurer`. - ---- - -## 8. Что ОТЛОЖЕНО (не в этом коде) - -- 🟡 **WebSocket** — `Component::websocket()` пока `throw` (порт легаси-движка). -- 🟡 **Health/Actuator** — индикатор переписываем; `call health` + `#[EnableActuator(port)]` - ещё не реализованы. -- 🟡 **Starter-autoconfig** — авто-подключение пакетов через `composer.json extra.winter` - (сейчас только явный `#[Import]`). -- ⬜ **Back-compat** — сосуществование со старым `Boot`/`Application` (решить). - ---- - -## 9. Чем проверять - -```bash -vendor/bin/phpunit tests/App # тесты нового кода (11) -vendor/bin/phpunit # весь сьют (1416, App включён в phpunit.xml) -``` -> Swoole на dev-боксе не загружен → HTTP-путь `serveHttp` локально не проверяется -> (как и раньше для `Application`); валидируется на timeline. Консоль/headless/ -> коллекторы/аргументы — проверяемы и покрыты. diff --git a/docs/configuration/07-di.md b/docs/configuration/07-di.md index 0b08f25..85f4724 100644 --- a/docs/configuration/07-di.md +++ b/docs/configuration/07-di.md @@ -163,9 +163,9 @@ you rarely call it directly, but it's worth knowing where injection happens: | Site | How dependencies are supplied | |------|-------------------------------| -| Controllers | Resolved through the container before the route method runs (constructor + `#[Autowired]`). | +| Controllers | Resolved through the container before the route method runs. Dependencies arrive as `#[Autowired]` properties — `Stereotype\Controller` declares a `final` constructor, so constructor injection is not available (declaring one is a fatal error). | | Middleware | Same container; `#[Autowired]` fields populated. | -| Threads / Jobs / Daemons | `Container::make(static::class)` builds a **fresh** DI instance inside the child process. | +| Processes / Daemons | `Container::make(static::class)` builds a **fresh** DI instance inside the child process. | | Console commands | Resolved via the container when dispatched. | Because the same container backs all of these, a `#[Singleton]` is shared diff --git a/phpunit.xml b/phpunit.xml index 6390cce..e0de501 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -40,6 +40,9 @@ tests/App + + tests/Unit + tests/ConnectionPool diff --git a/src/Concurrent/Async/Async.php b/src/Concurrent/Async/Async.php index cf14662..f775ffd 100644 --- a/src/Concurrent/Async/Async.php +++ b/src/Concurrent/Async/Async.php @@ -18,7 +18,9 @@ * --- * ### Contract * - * - the method is `public`, not `static` and not `final`; + * - the method is `public` or `protected` (never `private` — a private method is + * resolved statically inside its own class, so no subclass can intercept it), + * not `static` and not `final`; * - the declaring class is not `final`; * - the return type is `Future` or `void`; * - a `Future`-returning body returns {@see \Flytachi\Winter\K2\Concurrent\CompletableFuture::completedFuture()}; diff --git a/src/Ppa/Mapping/Attributes/Primal/Decimal.php b/src/Ppa/Mapping/Attributes/Primal/Decimal.php index 8d79d99..347409f 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Decimal.php +++ b/src/Ppa/Mapping/Attributes/Primal/Decimal.php @@ -44,8 +44,12 @@ final public function supports(array $phpTypes): bool public function toSql(string $dialect = 'mysql'): string { return match ($dialect) { - 'mysql' => "DECIMAL({$this->precision}, {$this->scale})", 'pgsql' => "NUMERIC({$this->precision}, {$this->scale})", + // SQLite has no fixed-point type; NUMERIC affinity keeps the value exact + // for integers and falls back to REAL otherwise, which is the closest it + // offers. Precision is accepted and ignored. + 'sqlite' => "NUMERIC({$this->precision}, {$this->scale})", + default => "DECIMAL({$this->precision}, {$this->scale})", }; } } diff --git a/src/Ppa/Mapping/Attributes/Primal/Double.php b/src/Ppa/Mapping/Attributes/Primal/Double.php index 5a55117..15cf390 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Double.php +++ b/src/Ppa/Mapping/Attributes/Primal/Double.php @@ -12,8 +12,10 @@ public function toSql(string $dialect = 'mysql'): string { return match ($dialect) { - 'mysql' => "DOUBLE", 'pgsql' => "DOUBLE PRECISION", + // Every SQLite float is an 8-byte IEEE double; REAL is the only spelling. + 'sqlite' => "REAL", + default => "DOUBLE", }; } } diff --git a/src/Ppa/Mapping/Attributes/Primal/FloatType.php b/src/Ppa/Mapping/Attributes/Primal/FloatType.php index 8d1df14..425d450 100644 --- a/src/Ppa/Mapping/Attributes/Primal/FloatType.php +++ b/src/Ppa/Mapping/Attributes/Primal/FloatType.php @@ -29,8 +29,9 @@ public function supports(array $phpTypes): bool public function toSql(string $dialect = 'mysql'): string { return match ($dialect) { - 'mysql' => "FLOAT", 'pgsql' => "REAL", + 'sqlite' => "REAL", + default => "FLOAT", }; } } diff --git a/src/Ppa/Mapping/Attributes/Sub/AutoIncrement.php b/src/Ppa/Mapping/Attributes/Sub/AutoIncrement.php index f49c475..6ce5b72 100644 --- a/src/Ppa/Mapping/Attributes/Sub/AutoIncrement.php +++ b/src/Ppa/Mapping/Attributes/Sub/AutoIncrement.php @@ -46,7 +46,15 @@ public function toSql(string $type, string $dialect = 'mysql'): string ? ' GENERATED ALWAYS AS IDENTITY' : ' GENERATED BY DEFAULT AS IDENTITY' ), - 'mysql' => $type . ' AUTO_INCREMENT', + // SQLite auto-increments through the rowid alias, and a column only + // becomes one when its type is spelled exactly INTEGER — INT, BIGINT and + // SMALLINT are ordinary columns that would reject the omitted value with + // a NOT NULL violation. There is one integer width anyway (64-bit), so + // widening the declared type loses nothing. The AUTOINCREMENT keyword is + // deliberately not emitted: it only forbids reusing ids after a delete, + // at the cost of an extra bookkeeping table. + 'sqlite' => 'INTEGER', + default => $type . ' AUTO_INCREMENT', }; } } diff --git a/src/Ppa/Mapping/Structure/Index.php b/src/Ppa/Mapping/Structure/Index.php index f244633..258f8f0 100644 --- a/src/Ppa/Mapping/Structure/Index.php +++ b/src/Ppa/Mapping/Structure/Index.php @@ -80,6 +80,18 @@ public function toSql(string $tableName, string $dialect = 'mysql'): string }; } + if ($dialect === 'sqlite') { + // SQLite has one index implementation, so there is no USING clause; partial + // indexes (WHERE) are supported, covering indexes (INCLUDE) are not. + $whereSql = $this->where ? " WHERE {$this->where}" : ''; + + return match ($this->type) { + IndexType::PRIMARY => "PRIMARY KEY" . $columnsSql, + IndexType::UNIQUE => "CREATE UNIQUE INDEX {$nameSql} ON {$tableName}{$columnsSql}{$whereSql}", + IndexType::INDEX => "CREATE INDEX {$nameSql} ON {$tableName}{$columnsSql}{$whereSql}", + }; + } + throw new \InvalidArgumentException("Unsupported dialect: {$dialect}"); } } diff --git a/src/Unit/Pagination/CursorToken.php b/src/Unit/Pagination/CursorToken.php index 7283cb7..341c9c4 100644 --- a/src/Unit/Pagination/CursorToken.php +++ b/src/Unit/Pagination/CursorToken.php @@ -78,6 +78,18 @@ public static function decode(string $token, string $expectedSignature): array ); } + // The token is client-supplied and JSON allows arrays and objects, while a + // cursor position is always scalar. Rejecting them here keeps a forged cursor a + // 400 through InvalidCursorException; left through, it reaches the query builder + // and surfaces as an uncaught TypeError — a 500 for bad input. + foreach ($payload['v'] as $value) { + if ($value !== null && !is_scalar($value)) { + throw new InvalidCursorException( + 'Cursor holds a non-scalar position value of type ' . get_debug_type($value) . '.' + ); + } + } + $direction = CursorDirection::tryFrom($payload['d']); if ($direction === null) { throw new InvalidCursorException("Cursor has unknown direction '{$payload['d']}'."); diff --git a/tests/Concurrent/Async/AsyncContractTest.php b/tests/Concurrent/Async/AsyncContractTest.php new file mode 100644 index 0000000..27a5b12 --- /dev/null +++ b/tests/Concurrent/Async/AsyncContractTest.php @@ -0,0 +1,207 @@ +originalVolatile = $prop->isInitialized() ? $prop->getValue() : null; + + $this->volatile = sys_get_temp_dir() . '/wk_async_' . getmypid() . '_' . bin2hex(random_bytes(4)); + KernelConfig::$pathStorageVolatile = $this->volatile; + } + + protected function tearDown(): void + { + foreach (glob($this->volatile . '/*/*') ?: [] as $file) { + @unlink($file); + } + foreach (glob($this->volatile . '/*') ?: [] as $dir) { + is_dir($dir) ? @rmdir($dir) : @unlink($dir); + } + @rmdir($this->volatile); + + if ($this->originalVolatile !== null) { + KernelConfig::$pathStorageVolatile = $this->originalVolatile; + } + } + + private function proxyFor(string $class): string + { + return ProxyFactory::forKernel(true)->proxyFor(new ReflectionClass($class)); + } + + // ── Accepted ─────────────────────────────────────────────────────────────── + + public function test_a_valid_class_is_proxied(): void + { + $proxy = $this->proxyFor(AsyncFixture::class); + + self::assertTrue(is_subclass_of($proxy, AsyncFixture::class), 'the proxy extends the original'); + self::assertSame(AsyncFixture::class, $proxy::proxyTarget()); + } + + public function test_only_annotated_methods_are_overridden(): void + { + $proxy = new ReflectionClass($this->proxyFor(AsyncFixture::class)); + + self::assertTrue($proxy->hasMethod('fire')); + self::assertSame($proxy->getName(), $proxy->getMethod('fire')->getDeclaringClass()->getName()); + self::assertNotSame( + $proxy->getName(), + $proxy->getMethod('plain')->getDeclaringClass()->getName(), + 'a method without #[Async] must stay the original', + ); + } + + public function test_a_protected_method_is_allowed(): void + { + // Self-calls still go through the proxy, so protected is legitimate — the + // contract is "not private", not "public only". + $proxy = new ReflectionClass($this->proxyFor(ProtectedAsyncFixture::class)); + + self::assertSame($proxy->getName(), $proxy->getMethod('step')->getDeclaringClass()->getName()); + } + + // ── Rejected ─────────────────────────────────────────────────────────────── + + /** @return list */ + public static function violations(): array + { + return [ + 'final class' => [FinalClassFixture::class, 'final'], + 'static method' => [StaticMethodFixture::class, 'static'], + 'final method' => [FinalMethodFixture::class, 'final'], + 'private method' => [PrivateMethodFixture::class, 'private'], + 'bad return type' => [BadReturnFixture::class, 'return type'], + 'no return type' => [NoReturnTypeFixture::class, 'return type'], + 'by-reference arg' => [ByRefFixture::class, 'by reference'], + ]; + } + + /** @param class-string $class */ + #[DataProvider('violations')] + public function test_a_violation_is_reported_at_generation(string $class, string $needle): void + { + $this->expectException(AsyncException::class); + $this->expectExceptionMessageMatches('/' . preg_quote($needle, '/') . '/i'); + + $this->proxyFor($class); + } +} + +// ── Fixtures ──────────────────────────────────────────────────────────────────── + +class AsyncFixture +{ + #[Async] + public function fire(string $to): void + { + } + + #[Async] + public function compute(int $a, int $b): Future + { + return CompletableFuture::completedFuture($a + $b); + } + + public function plain(int $x): int + { + return $x * 2; + } +} + +class ProtectedAsyncFixture +{ + #[Async] + protected function step(): void + { + } +} + +final class FinalClassFixture +{ + #[Async] + public function go(): void + { + } +} + +class StaticMethodFixture +{ + #[Async] + public static function go(): void + { + } +} + +class FinalMethodFixture +{ + #[Async] + final public function go(): void + { + } +} + +class PrivateMethodFixture +{ + public function trigger(): void + { + $this->go(); + } + + #[Async] + private function go(): void + { + } +} + +class BadReturnFixture +{ + #[Async] + public function go(): int + { + return 1; + } +} + +class NoReturnTypeFixture +{ + #[Async] + public function go() + { + } +} + +class ByRefFixture +{ + #[Async] + public function go(array &$rows): void + { + } +} diff --git a/tests/Ppa/Mapping/SqliteDdlTest.php b/tests/Ppa/Mapping/SqliteDdlTest.php new file mode 100644 index 0000000..673e959 --- /dev/null +++ b/tests/Ppa/Mapping/SqliteDdlTest.php @@ -0,0 +1,150 @@ +getProperties() as $property) { + $map->push($property); + } + + $sql = (array) new Table('products', $map->getColumns())->toSql($dialect); + + return array_values(array_filter(array_map('trim', explode(';', implode(";\n", $sql))))); + } + + private function migrated(): PDO + { + $db = new PDO('sqlite::memory:', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + foreach ($this->ddl() as $statement) { + $db->exec($statement); + } + + return $db; + } + + public function test_the_generated_schema_is_accepted(): void + { + // Any syntax the generator got wrong fails right here. + $this->migrated(); + + $this->addToAssertionCount(1); + } + + public function test_the_identity_column_assigns_ids(): void + { + $db = $this->migrated(); + + $db->exec("INSERT INTO products (sku, price, ratio) VALUES ('A', 1.5, 2.5)"); + $db->exec("INSERT INTO products (sku, price, ratio) VALUES ('B', 2.5, 3.5)"); + + self::assertSame( + ['1', '2'], + array_map('strval', $db->query('SELECT id FROM products ORDER BY id')->fetchAll(PDO::FETCH_COLUMN)), + 'the column must be a rowid alias, which requires the type to be exactly INTEGER', + ); + } + + public function test_the_identity_column_is_declared_as_plain_integer(): void + { + $create = implode("\n", $this->ddl()); + + self::assertMatchesRegularExpression('/\bid\s+INTEGER\b/', $create); + self::assertStringNotContainsString('AUTO_INCREMENT', $create, 'that is MySQL syntax'); + self::assertStringNotContainsString('IDENTITY', $create, 'that is PostgreSQL syntax'); + } + + public function test_defaults_and_nullability_survive(): void + { + $db = $this->migrated(); + $db->exec("INSERT INTO products (sku, price, ratio) VALUES ('A', 1.5, 2.5)"); + + $row = $db->query('SELECT stock, active, description FROM products')->fetch(PDO::FETCH_ASSOC); + + self::assertSame(0, (int) $row['stock']); + self::assertSame(1, (int) $row['active']); + self::assertNull($row['description']); + } + + public function test_a_unique_index_is_enforced(): void + { + $db = $this->migrated(); + $db->exec("INSERT INTO products (sku, price, ratio) VALUES ('A', 1.5, 2.5)"); + + $this->expectException(\PDOException::class); + $db->exec("INSERT INTO products (sku, price, ratio) VALUES ('A', 9.9, 9.9)"); + } + + public function test_floating_types_no_longer_break_generation(): void + { + // Decimal, Double and Float used to raise UnhandledMatchError for any dialect + // beyond mysql/pgsql, which stopped the migration before it reached the database. + $create = implode("\n", $this->ddl()); + + self::assertStringContainsString('NUMERIC(12, 2)', $create); + self::assertStringContainsString('REAL', $create); + } +} + +/** Entity under test — kept next to the test since only this file uses it. */ +final class SqliteProduct +{ + #[Id] + public int $id; + + #[P\Varchar(120)] + #[Unique] + public string $sku; + + #[P\Text] + #[NullableIs] + public ?string $description; + + #[P\Decimal(12, 2)] + public float $price; + + #[P\Double] + public float $ratio; + + #[P\Integer] + #[DefaultVal('0')] + #[Index] + public int $stock; + + #[P\Boolean] + #[DefaultVal('TRUE')] + public bool $active; +} diff --git a/tests/Ppa/Mapping/Structure/IndexTest.php b/tests/Ppa/Mapping/Structure/IndexTest.php index e1a53f8..5000afa 100644 --- a/tests/Ppa/Mapping/Structure/IndexTest.php +++ b/tests/Ppa/Mapping/Structure/IndexTest.php @@ -227,8 +227,37 @@ public function test_pg_and_mysql_share_same_hash_suffix_on_long_names(): void public function test_unsupported_dialect_throws(): void { + // sqlite used to stand in for "unsupported" here; it is a supported dialect now, + // so the guard is proven with one the mapper genuinely does not know. $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Unsupported dialect: sqlite'); - (new Index(columns: ['id']))->toSql('users', 'sqlite'); + $this->expectExceptionMessage('Unsupported dialect: oci'); + (new Index(columns: ['id']))->toSql('users', 'oci'); + } + + // ── SQLite ─────────────────────────────────────────────────────────────── + + public function test_sqlite_index_has_no_using_clause(): void + { + // SQLite has a single index implementation, so USING would be a syntax error. + $sql = (new Index(columns: ['email'], type: IndexType::UNIQUE))->toSql('users', 'sqlite'); + + self::assertStringContainsString('CREATE UNIQUE INDEX', $sql); + self::assertStringContainsString('ON users (email)', $sql); + self::assertStringNotContainsString('USING', $sql); + } + + public function test_sqlite_primary_key_is_a_table_constraint(): void + { + $sql = (new Index(columns: ['id'], type: IndexType::PRIMARY))->toSql('users', 'sqlite'); + + self::assertSame('PRIMARY KEY (id)', $sql); + } + + public function test_sqlite_supports_a_partial_index(): void + { + $sql = (new Index(columns: ['email'], type: IndexType::INDEX, where: 'deleted_at IS NULL')) + ->toSql('users', 'sqlite'); + + self::assertStringContainsString('WHERE deleted_at IS NULL', $sql); } } diff --git a/tests/Unit/Pagination/CursorTokenTest.php b/tests/Unit/Pagination/CursorTokenTest.php new file mode 100644 index 0000000..2092372 --- /dev/null +++ b/tests/Unit/Pagination/CursorTokenTest.php @@ -0,0 +1,92 @@ +expectException(InvalidCursorException::class); + $this->expectExceptionMessage('signature mismatch'); + + CursorToken::decode($token, self::SIGNATURE); + } + + /** @return array */ + public static function forgeries(): array + { + $wrap = static fn(array $payload): string => base64_encode((string) json_encode($payload)); + + return [ + 'not base64' => ['!!! not base64 !!!'], + 'not json' => [base64_encode('plain text')], + 'json but not object' => [base64_encode('[1,2,3]')], + 'missing fields' => [$wrap(['s' => self::SIGNATURE])], + 'values not a list' => [$wrap(['s' => self::SIGNATURE, 'v' => 'nope', 'd' => 'f'])], + 'unknown direction' => [$wrap(['s' => self::SIGNATURE, 'v' => [1], 'd' => 'sideways'])], + 'nested array value' => [$wrap(['s' => self::SIGNATURE, 'v' => [[1, 2]], 'd' => 'f'])], + 'object value' => [$wrap(['s' => self::SIGNATURE, 'v' => [['a' => 1]], 'd' => 'f'])], + ]; + } + + #[DataProvider('forgeries')] + public function test_a_forged_token_is_refused_as_an_invalid_cursor(string $token): void + { + // The type matters as much as the rejection: callers catch InvalidCursorException + // to answer 400. Anything else escapes as a 500. + $this->expectException(InvalidCursorException::class); + + CursorToken::decode($token, self::SIGNATURE); + } +} From 42049daea09a1adebe39a6487e861a3656d23f18 Mon Sep 17 00:00:00 2001 From: flytachi Date: Sun, 2 Aug 2026 19:58:58 +0500 Subject: [PATCH 50/71] full redisign --- CLAUDE.md | 83 ++++++++-- README.md | 8 +- comparison_20260802_k5.md | 141 +++++++++++++++++ composer.json | 4 +- console/Command/Cfg.php | 4 +- console/Command/Complete.php | 13 +- console/Command/Daemon.php | 16 +- console/Command/Db.php | 12 +- console/Command/Di.php | 16 +- console/Command/Help.php | 4 +- console/Command/Make.php | 32 +--- console/Command/Mapping.php | 6 +- console/Command/Process.php | 12 +- console/Command/Run.php | 2 +- console/Command/Schedule.php | 12 +- console/Command/Script.php | 8 +- console/Command/Storage.php | 4 +- console/Core.php | 2 +- console/{Inc => Stereotype}/CmdCustom.php | 3 +- .../CmdCustomInterface.php | 2 +- console/Template/Make/CmdTemplate | 2 +- console/Template/Make/ControllerTemplate | 6 +- console/Template/Make/DaemonTemplate | 2 +- console/Template/Make/DbConfigTemplate | 6 +- console/Template/Make/EntityTemplate | 6 +- console/Template/Make/MiddlewareTemplate | 6 +- console/Template/Make/ProcessTemplate | 2 +- console/Template/Make/RepositoryTemplate | 2 +- console/Template/Make/ResponseTemplate | 19 --- console/Template/Make/ServiceTemplate | 4 +- dev/bootstrap.php | 6 +- dev/main/AuthMiddleware.php | 8 +- dev/main/ListOrder.php | 4 +- dev/main/MainController.php | 22 +-- dev/main/Order.php | 6 +- dev/main/Process/AutoscaleDaemon.php | 4 +- dev/main/Process/ConsumerDemo.php | 4 +- dev/main/Process/CrashDaemon.php | 6 +- dev/main/Process/DemoProcess.php | 2 +- dev/main/Process/FleetDaemon.php | 2 +- dev/main/Process/HungDaemon.php | 6 +- dev/main/Process/LongDemo.php | 2 +- dev/main/Process/NeverDaemon.php | 6 +- dev/main/Process/SendProc.php | 2 +- dev/main/Process/SignalDemo.php | 4 +- dev/main/Process/StableDaemon.php | 2 +- dev/main/Schedule/DemoTasks.php | 2 +- dev/main/Services/FakeSendService.php | 3 +- dev/main/Services/SmsSendService.php | 3 +- dev/main/Te1.php | 4 +- dev/main/WebConfig.php | 6 +- dev/wDevRunner | 2 +- doc/STATUS.md | 13 ++ docs/architecture/01-routing.md | 10 +- docs/architecture/02-middleware.md | 12 +- docs/architecture/03-response.md | 14 +- docs/architecture/04-request/00-overview.md | 2 +- .../04-request/01-path-variable.md | 2 +- .../04-request/02-request-param.md | 2 +- .../04-request/03-request-body.md | 12 +- .../04-request/04-request-header.md | 2 +- .../04-request/05-request-file.md | 2 +- .../04-request/06-request-query.md | 2 +- .../04-request/07-request-json-form-xml.md | 6 +- docs/architecture/04-request/08-validation.md | 10 +- docs/architecture/05-localization.md | 2 +- docs/architecture/06-exception.md | 14 +- docs/concurrent/01-executors.md | 4 +- docs/concurrent/02-future.md | 4 +- docs/concurrent/04-build.md | 4 +- docs/concurrent/05-pools.md | 6 +- docs/configuration/01-kernel.md | 10 +- docs/configuration/03-cors.md | 12 +- docs/configuration/04-health.md | 12 +- docs/configuration/05-plugins.md | 4 +- docs/configuration/06-db.md | 6 +- docs/configuration/07-di.md | 2 +- docs/configuration/08-runtime.md | 2 +- docs/file/00-overview.md | 14 +- docs/pagination/00-overview.md | 2 +- docs/pagination/04-result-types.md | 4 +- docs/ppa/01-stereotypes.md | 8 +- docs/ppa/02-configuration.md | 8 +- docs/ppa/13-static-finders.md | 4 +- docs/ppa/16-advanced-examples.md | 2 +- docs/ppa/17-pool.md | 4 +- docs/ppa/18-migration.md | 12 +- docs/schedule/01-usage.md | 8 +- docs/starter/00-quickstart.md | 22 +-- function/dependencies.php | 4 +- phpunit.xml | 6 + src/App/ApplicationArguments.php | 4 +- src/App/ApplicationConfigException.php | 4 +- src/App/Attribute/Bean.php | 4 +- src/App/Attribute/Configuration.php | 2 +- src/App/Attribute/EnableActuator.php | 10 +- src/App/Attribute/EnableAsync.php | 6 +- src/App/Attribute/EnableDaemon.php | 8 +- src/App/Attribute/EnableProcess.php | 8 +- src/App/Attribute/EnableScheduler.php | 8 +- src/App/Attribute/EnableWeb.php | 8 +- src/App/Attribute/Import.php | 4 +- src/App/Attribute/Value.php | 2 +- src/App/Banner.php | 4 +- src/App/Component.php | 14 +- src/App/ComponentKind.php | 4 +- src/App/Config/ChannelRegistry.php | 4 +- src/App/Config/CorsRegistry.php | 4 +- src/App/Config/LoggingConfigurer.php | 2 +- src/App/Config/ServerSettings.php | 6 +- src/App/Config/WebConfigurer.php | 4 +- src/App/Config/WebConfigurerAdapter.php | 4 +- src/App/Scope.php | 2 +- src/Collector/ConfigurationCollector.php | 10 +- src/Collector/ImplementorCollector.php | 2 +- src/Collector/SubclassCollector.php | 2 +- src/Concurrent/Async/Async.php | 8 +- src/Concurrent/Async/AsyncCollector.php | 8 +- src/Concurrent/Async/AsyncException.php | 2 +- src/Concurrent/Async/AsyncSupport.php | 6 +- src/Concurrent/Async/Proxy/BypassScanner.php | 4 +- src/Concurrent/Async/Proxy/ProxyFactory.php | 6 +- src/Concurrent/Async/Proxy/ProxyGenerator.php | 14 +- .../Async/Proxy/SignatureWriter.php | 4 +- src/Concurrent/BoundedExecutorService.php | 2 +- src/Concurrent/CancellationException.php | 2 +- src/Concurrent/CompletableFuture.php | 2 +- src/Concurrent/ExecutionException.php | 2 +- .../Executor/CoroutineExecutorService.php | 14 +- .../Executor/DeferredExecutorService.php | 12 +- .../Executor/FixedExecutorService.php | 14 +- src/Concurrent/ExecutorService.php | 2 +- src/Concurrent/Executors.php | 8 +- src/Concurrent/Future.php | 2 +- src/Concurrent/RejectPolicy.php | 2 +- src/Concurrent/RejectedExecutionException.php | 2 +- src/Concurrent/TimeoutException.php | 2 +- src/ConnectionPool/ConnectionFactory.php | 2 +- src/ConnectionPool/ConnectionPool.php | 4 +- src/ConnectionPool/PoolEntry.php | 2 +- src/ConnectionPool/PoolException.php | 2 +- src/ConnectionPool/PoolPolicy.php | 2 +- src/ConnectionPool/SingleConnection.php | 2 +- src/Core/ClassScanner.php | 6 +- src/Core/KernelConfig.php | 2 +- src/Core/KernelStore.php | 2 +- src/Exception/ClientError.php | 2 +- src/Exception/Error.php | 2 +- src/Exception/ExceptionHeaderTrait.php | 2 +- src/Exception/KernelError.php | 2 +- src/Exception/ServerError.php | 2 +- src/File/CSV.php | 2 +- src/File/FileException.php | 2 +- src/File/JSON.php | 2 +- src/File/XML.php | 2 +- src/Http/Adapter/FpmRequest.php | 4 +- src/Http/Adapter/FpmResponse.php | 4 +- src/Http/Adapter/SwooleRequest.php | 4 +- src/Http/Adapter/SwooleResponse.php | 4 +- src/Http/Contracts/HttpRequest.php | 4 +- src/Http/Contracts/HttpResponse.php | 2 +- src/Http/Cors.php | 2 +- src/Http/Header.php | 4 +- src/Http/Health/Health.php | 4 +- src/Http/Health/HealthContributor.php | 6 +- src/Http/Health/HealthIndicator.php | 12 +- src/Http/Health/HealthIndicatorInterface.php | 2 +- src/Http/Health/HealthStatus.php | 2 +- src/Http/Health/Status.php | 2 +- .../Middleware/ClientTimezoneMiddleware.php | 8 +- src/Http/Middleware/MiddlewareException.php | 4 +- src/Http/Middleware/MiddlewareInterface.php | 8 +- src/Http/ParameterResolver.php | 38 ++--- src/Http/Request/Annotation/PathVariable.php | 4 +- src/Http/Request/Annotation/RequestBody.php | 4 +- src/Http/Request/Annotation/RequestFile.php | 4 +- src/Http/Request/Annotation/RequestForm.php | 4 +- src/Http/Request/Annotation/RequestHeader.php | 4 +- src/Http/Request/Annotation/RequestJson.php | 4 +- src/Http/Request/Annotation/RequestParam.php | 4 +- src/Http/Request/Annotation/RequestQuery.php | 4 +- src/Http/Request/Annotation/RequestXml.php | 4 +- src/Http/Request/K1ValidationTrait.php | 6 +- src/Http/Request/RequestException.php | 4 +- src/Http/Request/Validation/Assert.php | 4 +- src/Http/Request/Validation/Constraint.php | 2 +- src/Http/Request/Validation/Date.php | 4 +- src/Http/Request/Validation/Datetime.php | 4 +- src/Http/Request/Validation/Digits.php | 4 +- src/Http/Request/Validation/Email.php | 4 +- src/Http/Request/Validation/In.php | 4 +- src/Http/Request/Validation/Ip.php | 4 +- src/Http/Request/Validation/Ipv4.php | 4 +- src/Http/Request/Validation/Ipv6.php | 4 +- src/Http/Request/Validation/ListOf.php | 4 +- src/Http/Request/Validation/Max.php | 4 +- src/Http/Request/Validation/Min.php | 4 +- src/Http/Request/Validation/Msisdn.php | 4 +- src/Http/Request/Validation/Negative.php | 4 +- .../Request/Validation/NegativeOrZero.php | 4 +- src/Http/Request/Validation/NotBlank.php | 4 +- src/Http/Request/Validation/Phone.php | 4 +- src/Http/Request/Validation/Positive.php | 4 +- .../Request/Validation/PositiveOrZero.php | 4 +- src/Http/Request/Validation/Regex.php | 4 +- src/Http/Request/Validation/Required.php | 4 +- src/Http/Request/Validation/Size.php | 4 +- src/Http/Request/Validation/Time.php | 4 +- src/Http/Request/Validation/Url.php | 4 +- src/Http/Request/Validation/Uuid.php | 4 +- src/Http/Request/Validation/Valid.php | 4 +- .../Validation/ValidationException.php | 4 +- src/Http/Response/AcceptHeaderParser.php | 2 +- src/Http/Response/AdviceException.php | 6 +- .../Response/Collector/ExceptionCollector.php | 6 +- src/Http/Response/ContentType.php | 4 +- src/Http/Response/ExceptionWrapper.php | 5 +- src/Http/Response/FileResponseHeaders.php | 4 +- src/Http/Response/RenderContext.php | 2 +- src/Http/Response/ResponseEntity.php | 10 +- src/Http/Response/ResponseException.php | 4 +- .../Response/ResponseExceptionInterface.php | 2 +- src/Http/Response/ResponseFile.php | 10 +- src/Http/Response/ResponseStreamFile.php | 6 +- src/Http/Response/ResponseTrait.php | 2 +- src/Http/Response/ResponseView.php | 10 +- src/Http/Response/Sendable.php | 8 +- src/{ => Http}/Stereotype/Controller.php | 2 +- .../Stereotype/ControllerInterface.php | 2 +- .../ExceptionResponseBase.php | 10 +- src/{ => Http}/Stereotype/Middleware.php | 8 +- src/Kernel.php | 8 +- src/Localization/LanguageNegotiator.php | 2 +- src/Localization/Locale.php | 4 +- src/Localization/LocaleService.php | 4 +- src/Plugin.php | 4 +- src/Ppa/Declaration.php | 4 +- src/Ppa/DeclarationItem.php | 12 +- src/Ppa/Entity/EntityException.php | 2 +- src/Ppa/Entity/EntityInterface.php | 2 +- src/Ppa/Entity/RepositoryCrudInterface.php | 10 +- src/Ppa/Entity/RepositoryInterface.php | 6 +- src/Ppa/Entity/RepositoryViewInterface.php | 10 +- .../Additive/AttributeDbAdditive.php | 4 +- .../Attributes/Additive/DefaultVal.php | 4 +- .../Attributes/Additive/NullableIs.php | 4 +- src/Ppa/Mapping/Attributes/AttributeDb.php | 2 +- .../Mapping/Attributes/AttributeDbConfig.php | 2 +- .../Mapping/Attributes/AttributeDbEntity.php | 2 +- .../Mapping/Attributes/Config/Extension.php | 8 +- .../Mapping/Attributes/Config/Migratable.php | 8 +- .../Constraint/AttributeDbConstraint.php | 6 +- .../Constraint/AttributeDbConstraintCheck.php | 4 +- .../AttributeDbConstraintForeign.php | 4 +- .../Mapping/Attributes/Constraint/Check.php | 6 +- .../Attributes/Constraint/CheckEnum.php | 6 +- .../Attributes/Constraint/ForeignKey.php | 10 +- .../Attributes/Constraint/ForeignRepo.php | 12 +- src/Ppa/Mapping/Attributes/Entity/Table.php | 4 +- .../Attributes/Hybrid/AttributeDbHybrid.php | 12 +- src/Ppa/Mapping/Attributes/Hybrid/BigId.php | 12 +- src/Ppa/Mapping/Attributes/Hybrid/Id.php | 12 +- src/Ppa/Mapping/Attributes/Hybrid/SmallId.php | 12 +- src/Ppa/Mapping/Attributes/Hybrid/UuidPk.php | 12 +- .../Mapping/Attributes/Idx/AttributeDbIdx.php | 6 +- src/Ppa/Mapping/Attributes/Idx/Index.php | 12 +- src/Ppa/Mapping/Attributes/Idx/Primary.php | 12 +- src/Ppa/Mapping/Attributes/Idx/Unique.php | 12 +- .../Attributes/Primal/AttributeDbType.php | 4 +- .../Mapping/Attributes/Primal/BigInteger.php | 4 +- src/Ppa/Mapping/Attributes/Primal/Binary.php | 4 +- src/Ppa/Mapping/Attributes/Primal/Blob.php | 4 +- src/Ppa/Mapping/Attributes/Primal/Boolean.php | 4 +- src/Ppa/Mapping/Attributes/Primal/Char.php | 4 +- src/Ppa/Mapping/Attributes/Primal/Date.php | 4 +- .../Mapping/Attributes/Primal/DateTime.php | 2 +- src/Ppa/Mapping/Attributes/Primal/Decimal.php | 4 +- src/Ppa/Mapping/Attributes/Primal/Double.php | 4 +- .../Mapping/Attributes/Primal/FloatType.php | 2 +- src/Ppa/Mapping/Attributes/Primal/Integer.php | 4 +- src/Ppa/Mapping/Attributes/Primal/Json.php | 4 +- .../Attributes/Primal/SmallInteger.php | 4 +- src/Ppa/Mapping/Attributes/Primal/Text.php | 4 +- .../Mapping/Attributes/Primal/TextArray.php | 4 +- src/Ppa/Mapping/Attributes/Primal/Time.php | 4 +- .../Mapping/Attributes/Primal/Timestamp.php | 4 +- src/Ppa/Mapping/Attributes/Primal/Type.php | 4 +- src/Ppa/Mapping/Attributes/Primal/Uuid.php | 4 +- src/Ppa/Mapping/Attributes/Primal/Varchar.php | 4 +- .../Attributes/Sub/AttributeDbSubType.php | 4 +- .../Mapping/Attributes/Sub/AutoIncrement.php | 4 +- src/Ppa/Mapping/ColumnMapping.php | 30 ++-- src/Ppa/Mapping/Constants/FKAction.php | 2 +- src/Ppa/Mapping/Constants/IndexMethod.php | 2 +- src/Ppa/Mapping/Constants/IndexType.php | 2 +- .../Mapping/Constants/MigratablePriority.php | 4 +- .../Mapping/RepositoryMappingInterface.php | 2 +- src/Ppa/Mapping/Structure/CheckConstraint.php | 4 +- src/Ppa/Mapping/Structure/Column.php | 4 +- src/Ppa/Mapping/Structure/Extension.php | 6 +- src/Ppa/Mapping/Structure/ForeignKey.php | 6 +- src/Ppa/Mapping/Structure/Index.php | 8 +- src/Ppa/Mapping/Structure/NameValidator.php | 4 +- src/Ppa/Mapping/Structure/StoredProcedure.php | 4 +- .../Mapping/Structure/StructureInterface.php | 2 +- src/Ppa/Mapping/Structure/Table.php | 4 +- src/Ppa/Mapping/Structure/Trigger.php | 4 +- src/Ppa/Mapping/Structure/View.php | 4 +- src/Ppa/PPAMapping.php | 16 +- src/Ppa/Pool/BorrowedConnection.php | 4 +- src/Ppa/Pool/CdoConnectionFactory.php | 6 +- src/Ppa/Pool/ConnectionLoss.php | 2 +- src/Ppa/Pool/PoolTelemetry.php | 6 +- src/Ppa/Pool/PpaConnectionPool.php | 16 +- src/Ppa/Pool/PpaPoolConfigInterface.php | 2 +- src/Ppa/Pool/PpaPoolException.php | 2 +- src/Ppa/Pool/PpaPoolTrait.php | 2 +- src/Ppa/PpaCallTrait.php | 8 +- src/Ppa/Repository/RepositoryCore.php | 12 +- src/Ppa/Repository/RepositoryCrudTrait.php | 6 +- src/Ppa/Repository/RepositoryException.php | 2 +- src/Ppa/Repository/RepositoryViewTrait.php | 8 +- src/Ppa/Stereotype/CteRepo.php | 8 +- src/Ppa/Stereotype/Repository.php | 12 +- src/Ppa/Stereotype/RepositoryCrud.php | 8 +- src/Ppa/Stereotype/RepositoryView.php | 8 +- src/Process/Activity.php | 6 +- src/Process/Daemon/DaemonConfigException.php | 11 +- src/Process/Daemon/DaemonStatus.php | 12 +- src/Process/Daemon/RestartMode.php | 2 +- src/Process/Daemon/RestartPolicy.php | 4 +- src/Process/Daemon/ScalingPolicy.php | 6 +- src/Process/Daemon/Slot.php | 6 +- src/Process/Daemon/SlotState.php | 4 +- src/Process/Daemon/SupervisesFleet.php | 10 +- src/Process/Daemon/WorkerStatus.php | 4 +- src/Process/Engine/Engines.php | 4 +- src/Process/Engine/ProcessEngine.php | 10 +- src/Process/Engine/SwooleEngine.php | 8 +- src/Process/Engine/SyncEngine.php | 8 +- src/Process/ForkReset.php | 4 +- src/Process/Internal/SingletonLock.php | 8 +- src/Process/InterruptedException.php | 7 +- .../ProcessAlreadyRunningException.php | 10 +- src/Process/ProcessRunnable.php | 4 +- src/Process/ProcessState.php | 6 +- src/Process/ProcessStatus.php | 9 +- src/Process/ProcessStore.php | 4 +- src/Process/ResourceUsage.php | 2 +- src/Process/{Daemon => Stereotype}/Daemon.php | 16 +- src/Process/{ => Stereotype}/Process.php | 26 ++-- src/Route/Annotation/AbstractMapping.php | 2 +- src/Route/Annotation/CrossOrigin.php | 4 +- src/Route/Annotation/DeleteMapping.php | 4 +- src/Route/Annotation/GetMapping.php | 4 +- src/Route/Annotation/PatchMapping.php | 4 +- src/Route/Annotation/PostMapping.php | 4 +- src/Route/Annotation/PutMapping.php | 4 +- src/Route/Annotation/RequestMapping.php | 4 +- src/Route/Collector/MappingCollector.php | 14 +- src/Route/DevWatcher.php | 2 +- src/Route/Dispatcher.php | 4 +- src/Route/Route.php | 4 +- src/Route/RouteResult.php | 4 +- src/Route/Router.php | 44 +++--- src/Schedule/ScheduleConfigException.php | 2 +- src/Schedule/Scheduled.php | 6 +- src/Schedule/ScheduledCollector.php | 10 +- src/Schedule/ScheduledTask.php | 4 +- src/Schedule/{ => Stereotype}/Scheduler.php | 14 +- src/Schedule/Trigger/CronTrigger.php | 2 +- src/Schedule/Trigger/FixedDelayTrigger.php | 2 +- src/Schedule/Trigger/FixedRateTrigger.php | 2 +- src/Schedule/Trigger/Trigger.php | 2 +- src/Stereotype/Service.php | 9 -- src/Unit/Pagination/CursorDirection.php | 2 +- src/Unit/Pagination/CursorKey.php | 2 +- src/Unit/Pagination/CursorToken.php | 2 +- .../Pagination/InvalidCursorException.php | 2 +- src/Unit/Pagination/PaginationMeta.php | 2 +- src/Unit/Pagination/PaginationMetaCursor.php | 2 +- src/Unit/Pagination/PaginationResult.php | 2 +- src/Unit/Pagination/Paginator.php | 6 +- src/Unit/Pagination/Sort.php | 2 +- src/Unit/Pagination/WrapMeta.php | 4 +- src/Unit/Pagination/WrapResult.php | 4 +- src/Unit/Wrapper.php | 10 +- src/WinterApplication.php | 64 ++++---- tests/App/ApplicationArgumentsTest.php | 4 +- tests/App/ConfigurationCollectorTest.php | 12 +- tests/App/EnableManifestTest.php | 22 +-- tests/App/ServerSettingsTest.php | 4 +- tests/App/StaticPathTest.php | 8 +- tests/Architecture/ExtensionSurfaceTest.php | 143 ++++++++++++++++++ tests/Architecture/NamespaceTest.php | 69 +++++++++ tests/Architecture/StereotypeLayoutTest.php | 76 ++++++++++ tests/Concurrent/Async/AsyncContractTest.php | 16 +- .../Executor/FixedExecutorConcurrencyTest.php | 8 +- .../Executor/FixedExecutorServiceTest.php | 12 +- tests/Concurrent/RejectPolicyTest.php | 4 +- tests/Configuration/CorsTest.php | 4 +- tests/Configuration/HealthTest.php | 10 +- tests/Configuration/KernelConfigTest.php | 6 +- tests/Configuration/KernelStoreTest.php | 8 +- tests/Configuration/PluginTest.php | 6 +- tests/ConnectionPool/ConnectionPoolTest.php | 10 +- tests/ConnectionPool/HousekeeperTest.php | 6 +- tests/ConnectionPool/MockFactory.php | 4 +- tests/ConnectionPool/PoolPolicyTest.php | 4 +- tests/ConnectionPool/SingleConnectionTest.php | 8 +- tests/Console/CommandSurfaceTest.php | 124 +++++++++++++++ tests/Console/MakeTemplateTest.php | 73 +++++++++ tests/Http/ActuatorTest.php | 16 +- tests/Http/Request/FpmRequestBaseUrlTest.php | 4 +- tests/Http/Request/HeaderOriginTest.php | 6 +- tests/Http/Request/K1ValidationTraitTest.php | 8 +- tests/Http/Request/ListOfTest.php | 30 ++-- tests/Http/Request/PathVariableTest.php | 18 +-- tests/Http/Request/RequestBodyTest.php | 32 ++-- tests/Http/Request/RequestFileTest.php | 12 +- tests/Http/Request/RequestFormTest.php | 28 ++-- tests/Http/Request/RequestHeaderTest.php | 18 +-- tests/Http/Request/RequestJsonTest.php | 32 ++-- tests/Http/Request/RequestParamTest.php | 22 +-- tests/Http/Request/RequestQueryTest.php | 22 +-- tests/Http/Request/RequestXmlTest.php | 26 ++-- .../Http/Request/SwooleRequestBaseUrlTest.php | 4 +- tests/Http/Request/Validation/AssertTest.php | 4 +- .../Request/Validation/CustomMessageTest.php | 50 +++--- .../Http/Request/Validation/DateTimeTest.php | 8 +- tests/Http/Request/Validation/DigitsTest.php | 4 +- tests/Http/Request/Validation/EmailTest.php | 4 +- tests/Http/Request/Validation/InTest.php | 4 +- tests/Http/Request/Validation/IpTest.php | 8 +- tests/Http/Request/Validation/MaxTest.php | 4 +- tests/Http/Request/Validation/MinTest.php | 4 +- .../Request/Validation/MsisdnPhoneTest.php | 6 +- .../Request/Validation/NegativeOrZeroTest.php | 4 +- .../Http/Request/Validation/NegativeTest.php | 4 +- .../Http/Request/Validation/NotBlankTest.php | 4 +- .../Request/Validation/PositiveOrZeroTest.php | 4 +- .../Http/Request/Validation/PositiveTest.php | 4 +- tests/Http/Request/Validation/RegexTest.php | 4 +- .../Http/Request/Validation/RequiredTest.php | 4 +- tests/Http/Request/Validation/SizeTest.php | 4 +- tests/Http/Request/Validation/UrlTest.php | 4 +- tests/Http/Request/Validation/UuidTest.php | 4 +- .../Validation/ValidationExceptionTest.php | 6 +- .../Http/Response/FpmResponseSendfileTest.php | 4 +- .../Http/Response/ResponseStreamFileTest.php | 8 +- tests/Http/Response/ResponseViewPathTest.php | 6 +- tests/Integration/Cli/DbMigrateTestCase.php | 6 +- .../Cli/Fixtures/Mariadb/MigrMariadbRepo.php | 8 +- tests/Integration/Cli/Fixtures/MigrEntity.php | 14 +- .../Cli/Fixtures/Mysql/MigrMysqlRepo.php | 8 +- .../Cli/Fixtures/Pg/MigrPgRepo.php | 8 +- .../Integration/Cli/MariadbDbMigrateTest.php | 2 +- tests/Integration/Cli/MysqlDbMigrateTest.php | 2 +- tests/Integration/Cli/PgDbMigrateTest.php | 2 +- .../Crud/CrudIntegrationTestCase.php | 2 +- tests/Integration/Crud/MariadbCrudTest.php | 4 +- tests/Integration/Crud/MysqlCrudTest.php | 4 +- tests/Integration/Crud/PgCrudTest.php | 4 +- .../Crud/ProductsTableTestCase.php | 8 +- .../Fixtures/IntegrationTestCase.php | 4 +- .../Fixtures/MariadbTestDbConfig.php | 4 +- .../Fixtures/MysqlTestDbConfig.php | 4 +- tests/Integration/Fixtures/PgTestDbConfig.php | 6 +- tests/Integration/Fixtures/ProductEntity.php | 2 +- .../Fixtures/ProductMariadbRepo.php | 4 +- .../Integration/Fixtures/ProductMysqlRepo.php | 4 +- tests/Integration/Fixtures/ProductPgRepo.php | 4 +- tests/Integration/Fixtures/SpecimenEntity.php | 2 +- .../Fixtures/SpecimenMariadbRepo.php | 4 +- .../Fixtures/SpecimenMysqlRepo.php | 4 +- tests/Integration/Fixtures/SpecimenPgRepo.php | 4 +- .../Migration/MariadbMigrationE2ETest.php | 2 +- .../Migration/MysqlMigrationE2ETest.php | 20 +-- .../Migration/PgMigrationE2ETest.php | 22 +-- .../Pool/CdoMariadbSegfaultDiagnosticTest.php | 4 +- .../Pool/CdoMysqlSegfaultDiagnosticTest.php | 4 +- .../Pool/CdoSegfaultDiagnosticTestCase.php | 6 +- .../Pool/MariadbPoolConnectionTest.php | 4 +- .../Pool/MysqlPoolConnectionTest.php | 8 +- .../Integration/Pool/PgPoolConnectionTest.php | 8 +- .../Smoke/MariadbConnectivitySmokeTest.php | 4 +- .../Smoke/MysqlConnectivitySmokeTest.php | 4 +- .../Smoke/PgConnectivitySmokeTest.php | 4 +- tests/Integration/Types/MariadbTypesTest.php | 4 +- tests/Integration/Types/MysqlTypesTest.php | 4 +- tests/Integration/Types/PgTypesTest.php | 4 +- .../Types/TypesIntegrationTestCase.php | 8 +- tests/Integration/View/MariadbViewTest.php | 4 +- tests/Integration/View/MysqlViewTest.php | 4 +- tests/Integration/View/PgViewTest.php | 4 +- .../View/ViewIntegrationTestCase.php | 10 +- tests/Localization/LanguageNegotiatorTest.php | 4 +- tests/Localization/LocaleServiceTest.php | 4 +- tests/Localization/LocaleTest.php | 6 +- tests/Ppa/DeclarationItemTest.php | 18 +-- tests/Ppa/DeclarationTest.php | 12 +- tests/Ppa/Fixtures/StubDbConfig.php | 2 +- .../Additive/AdditiveAttributesTest.php | 6 +- .../Attributes/Config/ExtensionTest.php | 6 +- .../Attributes/Config/MigratableTest.php | 8 +- .../Constraint/ConstraintAttributesTest.php | 20 +-- .../Attributes/Hybrid/HybridTypesTest.php | 26 ++-- .../Attributes/Idx/IdxAttributesTest.php | 16 +- .../Attributes/Primal/PrimalTypesTest.php | 46 +++--- .../Attributes/Sub/AutoIncrementTest.php | 4 +- tests/Ppa/Mapping/ColumnMappingTest.php | 28 ++-- tests/Ppa/Mapping/Constants/FKActionTest.php | 4 +- .../Ppa/Mapping/Constants/IndexMethodTest.php | 4 +- tests/Ppa/Mapping/Constants/IndexTypeTest.php | 4 +- .../Constants/MigratablePriorityTest.php | 4 +- tests/Ppa/Mapping/SqliteDdlTest.php | 20 +-- .../Mapping/Structure/CheckConstraintTest.php | 6 +- tests/Ppa/Mapping/Structure/ColumnTest.php | 16 +- tests/Ppa/Mapping/Structure/ExtensionTest.php | 6 +- .../Ppa/Mapping/Structure/ForeignKeyTest.php | 8 +- tests/Ppa/Mapping/Structure/IndexTest.php | 10 +- .../Mapping/Structure/NameValidatorTest.php | 4 +- .../Mapping/Structure/StoredProcedureTest.php | 6 +- tests/Ppa/Mapping/Structure/TableTest.php | 18 +-- tests/Ppa/Mapping/Structure/TriggerTest.php | 6 +- tests/Ppa/Mapping/Structure/ViewTest.php | 6 +- tests/Ppa/Pool/ConnectionLossTest.php | 4 +- tests/Ppa/Pool/PoolTelemetryTest.php | 10 +- tests/Ppa/Pool/PpaConnectionPoolStatsTest.php | 12 +- tests/Ppa/Pool/PpaPoolTraitTest.php | 4 +- tests/Ppa/Pool/ReportFailureTest.php | 8 +- tests/Ppa/Repository/BindsAndCacheTest.php | 4 +- tests/Ppa/Repository/BuildSqlOrderTest.php | 6 +- tests/Ppa/Repository/Fixtures/OrdersRepo.php | 4 +- .../Repository/Fixtures/RepoTestDbConfig.php | 4 +- .../Fixtures/SelectionMappedRepo.php | 4 +- .../Repository/Fixtures/TypedUsersRepo.php | 4 +- tests/Ppa/Repository/Fixtures/UserEntity.php | 2 +- .../Fixtures/UserWithSelectionEntity.php | 4 +- tests/Ppa/Repository/Fixtures/UsersRepo.php | 4 +- tests/Ppa/Repository/GroupOrderLimitTest.php | 4 +- tests/Ppa/Repository/JoinBuilderTest.php | 6 +- tests/Ppa/Repository/PrepareSelectTest.php | 8 +- tests/Ppa/Repository/SelectFromAsTest.php | 10 +- tests/Ppa/Repository/UnionCteTest.php | 6 +- tests/Ppa/Repository/WhereBuilderTest.php | 4 +- tests/Process/ActivityTest.php | 4 +- tests/Process/Daemon/DaemonStatusTest.php | 14 +- tests/Process/Daemon/DaemonTest.php | 24 +-- tests/Process/Daemon/RestartModeTest.php | 4 +- tests/Process/Daemon/RestartPolicyTest.php | 6 +- tests/Process/Daemon/ScalingPolicyTest.php | 4 +- tests/Process/Daemon/SlotStateTest.php | 4 +- tests/Process/Daemon/SlotTest.php | 8 +- tests/Process/Daemon/SupervisesFleetTest.php | 16 +- tests/Process/Daemon/WorkerStatusTest.php | 8 +- tests/Process/ExceptionsTest.php | 8 +- .../Process/Fixtures/AutoscaleLoopDaemon.php | 6 +- tests/Process/Fixtures/BlankDaemon.php | 4 +- tests/Process/Fixtures/BusyIdleDaemon.php | 4 +- tests/Process/Fixtures/ClampDaemon.php | 4 +- tests/Process/Fixtures/CrashCapDaemon.php | 8 +- tests/Process/Fixtures/CrashLoopDaemon.php | 8 +- tests/Process/Fixtures/DefaultDaemon.php | 4 +- tests/Process/Fixtures/ExternalDaemon.php | 4 +- tests/Process/Fixtures/HungLoopDaemon.php | 8 +- tests/Process/Fixtures/InlineDaemon.php | 10 +- tests/Process/Fixtures/LoopDaemon.php | 8 +- tests/Process/Fixtures/LoopWorker.php | 4 +- tests/Process/Fixtures/NeverCrashDaemon.php | 8 +- tests/Process/Fixtures/SampleProcess.php | 4 +- tests/Process/Fixtures/SignalProcess.php | 4 +- tests/Process/Fixtures/StubDaemon.php | 4 +- tests/Process/Fixtures/StuckStopDaemon.php | 4 +- tests/Process/Fixtures/TitledProcess.php | 4 +- tests/Process/Fixtures/WorkerClassDaemon.php | 4 +- tests/Process/ForkResetTest.php | 4 +- .../Integration/DaemonIntegrationTest.php | 30 ++-- .../Integration/DispatchRunnerTest.php | 12 +- .../Fixtures/DispatchMarkerProcess.php | 6 +- tests/Process/Integration/IntegrationCase.php | 6 +- .../ProcessSignalIntegrationTest.php | 4 +- tests/Process/ProcessStateTest.php | 4 +- tests/Process/ProcessStatusTest.php | 8 +- tests/Process/ProcessTest.php | 10 +- tests/Process/ResourceUsageTest.php | 4 +- tests/Route/ApplicationBootTest.php | 12 +- tests/Route/Fixtures/App/DemoController.php | 16 +- tests/Route/Fixtures/App/GreetingService.php | 2 +- tests/Route/Fixtures/App/ServeApp.php | 12 +- tests/Route/Fixtures/FakeRequest.php | 4 +- tests/Route/Fixtures/FakeResponse.php | 4 +- tests/Route/Fixtures/RecordingMiddleware.php | 8 +- tests/Route/Fixtures/ServerProcess.php | 4 +- tests/Route/GracefulShutdownTest.php | 4 +- tests/Route/RouterDispatchTest.php | 10 +- tests/Route/RouterMiddlewareTest.php | 14 +- tests/Route/ServeHttpTest.php | 4 +- tests/Schedule/Fixtures/AbstractScheduled.php | 4 +- tests/Schedule/Fixtures/ArgScheduled.php | 4 +- tests/Schedule/Fixtures/BadCronScheduled.php | 4 +- .../Fixtures/CronInitialDelayScheduled.php | 4 +- tests/Schedule/Fixtures/CronScheduled.php | 4 +- tests/Schedule/Fixtures/MarkerScheduler.php | 8 +- tests/Schedule/Fixtures/MarkerTask.php | 2 +- .../Schedule/Fixtures/NoTriggerScheduled.php | 4 +- .../Fixtures/NonPositiveScheduled.php | 4 +- tests/Schedule/Fixtures/SampleScheduled.php | 4 +- tests/Schedule/Fixtures/StaticScheduled.php | 4 +- .../Schedule/Fixtures/TwoTriggerScheduled.php | 4 +- .../Integration/SchedulerIntegrationTest.php | 8 +- tests/Schedule/ScheduledCollectorTest.php | 34 ++--- tests/Schedule/ScheduledTaskTest.php | 6 +- tests/Schedule/ScheduledTest.php | 6 +- .../Schedule/SchedulerExtensionPointTest.php | 6 +- tests/Schedule/SchedulerTest.php | 10 +- tests/Schedule/Trigger/CronTriggerTest.php | 4 +- .../Trigger/FixedDelayTriggerTest.php | 4 +- .../Schedule/Trigger/FixedRateTriggerTest.php | 4 +- tests/Unit/Pagination/CursorTokenTest.php | 8 +- tests/function/TransTest.php | 4 +- wKernelRunner | 2 +- 622 files changed, 2734 insertions(+), 2058 deletions(-) create mode 100644 comparison_20260802_k5.md rename console/{Inc => Stereotype}/CmdCustom.php (88%) rename console/{Inc => Stereotype}/CmdCustomInterface.php (69%) delete mode 100644 console/Template/Make/ResponseTemplate rename src/{ => Http}/Stereotype/Controller.php (74%) rename src/{ => Http}/Stereotype/ControllerInterface.php (67%) rename src/Http/{Response => Stereotype}/ExceptionResponseBase.php (96%) rename src/{ => Http}/Stereotype/Middleware.php (68%) rename src/Process/{Daemon => Stereotype}/Daemon.php (96%) rename src/Process/{ => Stereotype}/Process.php (95%) rename src/Schedule/{ => Stereotype}/Scheduler.php (94%) delete mode 100644 src/Stereotype/Service.php create mode 100644 tests/Architecture/ExtensionSurfaceTest.php create mode 100644 tests/Architecture/NamespaceTest.php create mode 100644 tests/Architecture/StereotypeLayoutTest.php create mode 100644 tests/Console/CommandSurfaceTest.php create mode 100644 tests/Console/MakeTemplateTest.php diff --git a/CLAUDE.md b/CLAUDE.md index c32e38b..e97b89c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ works, why, and the rules to keep. > **winter-kernel** is a PHP 8.4+ framework kernel (a library, not an app). It runs > under two runtimes: **Swoole** (coroutines) and **FPM/CLI** (plain processes). -> Namespace root: `Flytachi\Winter\K2\` → `src/`. Tests: `Flytachi\Winter\K2\Tests\` → `tests/`. +> Namespace root: `Flytachi\Winter\Kernel\` → `src/`. Tests: `Flytachi\Winter\Kernel\Tests\` → `tests/`. --- @@ -49,15 +49,20 @@ back-off, graceful drain, autoscaling with damping, and a liveness watchdog. | Path | Namespace | What | |---|---|---| -| `src/Process/` | `Flytachi\Winter\K2\Process` | **the canonical layer (this work)** | -| `src/Process/Daemon/` | `…\Process\Daemon` | Daemon + supervision + policies + slot model | +| `src/Process/Stereotype/` | `…\Process\Stereotype` | **`Process` and `Daemon` — the two classes an application extends** | +| `src/Process/` | `Flytachi\Winter\Kernel\Process` | shared model: `Activity`, `ProcessStatus`, `ProcessState`, `ProcessStore`, `ForkReset` | +| `src/Process/Daemon/` | `…\Process\Daemon` | supervision + policies + slot model (`SupervisesFleet`, `Slot`, `ScalingPolicy`, …) | | `src/Process/Engine/` | `…\Process\Engine` | runtime backends (Swoole/Sync) | | `src/Process/Internal/` | `…\Process\Internal` | private-method traits (encapsulation) | -| `src/Old/Process/` | `…\Old\Process` | **OLD ThreadDaemon system, archived; the user will delete it.** Do not build on it. | -> History: this layer was developed in `src/Dev/Process/` (`…\Dev\Process`), then -> promoted to canonical `src/Process/`; the old `src/Process/` moved to `src/Old/Process/`. -> If you see `Dev\Process` anywhere it is stale — it should be `Process`. +> The split is the point: `Process/Stereotype/` holds what you extend, everything beside +> it is machinery. Before the 2026-08-02 restructure `Daemon` sat inside +> `Process/Daemon/` next to `SupervisesFleet`, and telling the extension point from the +> internals meant reading the source. See `doc/2026-08-02-restructure-design.md`. +> +> History: this layer was developed in `src/Dev/Process/`, then promoted to +> `src/Process/`; the archived `src/Old/Process/` has since been deleted. If you see +> `Dev\Process` or `Old\Process` anywhere it is stale. --- @@ -307,6 +312,60 @@ no package-private, so: If you add anything the supervision trait needs from Daemon, make it **private** and call it via `$this->` from the trait. +### The extension surface (added 2026-08-02) + +Encapsulation above governs *members*; this governs *classes*. Two rules, both enforced +by tests — `tests/Architecture/{StereotypeLayoutTest,ExtensionSurfaceTest}.php` and +`tests/Console/CommandSurfaceTest.php`. + +**1. What an application extends lives in `/Stereotype/`.** + +| | | +|---|---| +| `src/Http/Stereotype/` | `Controller`, `ControllerInterface`, `Middleware`, `ExceptionResponseBase` | +| `src/Ppa/Stereotype/` | `Repository`, `RepositoryCrud`, `RepositoryView` | +| `src/Process/Stereotype/` | `Process`, `Daemon` | +| `src/Schedule/Stereotype/` | `Scheduler` | +| `console/Stereotype/` | `CmdCustom`, `CmdCustomInterface` | + +Every `Stereotype/` belongs to a layer — there is no orphan one at the root. The layer +keeps its machinery beside, not inside, that directory, so a layer can be extracted into +its own package **together with its extension point**. + +The test for `implements`-only contracts is that they stay put: `MiddlewareInterface` +lives in `Http/Middleware/` and `HealthContributor` in `Http/Health/`, because +**`Stereotype/` is for `extends`, not for `implements`.** + +> **There is no `Service` base class, and do not reintroduce one.** It was an empty +> `abstract class` — Spring's `@Service` **annotation** transliterated into inheritance. +> Java made it an annotation precisely because Java, like PHP, allows a single parent: +> spending that slot on a semantic marker is a bad trade. Nothing in the kernel ever read +> it (the container resolves by class name, lifetime comes from `#[Singleton]` and +> friends), and in practice it forced `class X extends Service implements XInterface` — +> the real contract pushed into an interface because the parent slot was already gone. +> A service is a plain class. If a role marker is ever wanted, it must arrive as an +> attribute **with a mechanism behind it** (implying `#[Singleton]`, or serving as an AOP +> pointcut target) — a marker nothing reads is policy, not mechanism. + +**2. A class is `final` unless someone wrote down why not.** + +`final` is the only thing that removes a class from an IDE's `extends` completion, so an +open class is public API whether or not that was intended. `ExtensionSurfaceTest::OPEN` +is the single place openness is declared, and each entry carries its reason. Adding a +non-final class without an entry fails the suite. + +Exceptions are open **as a category** (extending `ClientError` in an application is +normal) and are skipped by the test via `Throwable`. + +> **Do not decide this by grep.** `ExceptionResponseBase` has no subclass in this +> repository and still must stay open — `#[AdviceException]` handlers extend it, and they +> live in applications. Check documented contracts (`#[Enable*]`, `#[Advice*]`, PHPDoc +> examples), not usage counts. + +Also: built-in `console/Command/*` are `final` because two of them (`Process`, `Daemon`) +share a short name with a real stereotype, and an open command made both appear in +completion. That was the original complaint; `final` fixed it without renaming anything. + --- ## 8. CLI @@ -327,15 +386,19 @@ in `console/Command/Complete.php`. Commands: `console/Command/{Process,Daemon}.p config resolution/clamping, `ForkReset`, titles, and the supervision **decision algorithms** (`SupervisesFleetTest` — backoff, damping/`windowExtreme`, `pickVictims` IDLE-first, slot counts) via reflection, deterministic, no forks. -- **Integration** (`tests/Process/Integration/`, 17 tests, `#[Group('integration')]` → +- **Integration** (`tests/Process/Integration/`, 18 tests, `#[Group('integration')]` → excluded from the default run): real fork/swoole/signals. `IntegrationCase` boots a temp-storage kernel, forks a real Process/Daemon child, observes via the shared store + a `WK_MARKER` file, sends real signals. Covers: fork N replicas, graceful stop (no orphans), restart-in-slot, maxRestarts→FAILED, NEVER→retired, watchdog, autoscale up→down, singleton, 2nd-signal force, per-worker activity, SIGHUP=reload, external `$workerClass` path, and all Process signal hooks. +- **Architecture** (`tests/Architecture/`, `tests/Console/`): guards that hold the + structure in place rather than testing behaviour — stereotype addresses, the absent + generation suffix in the namespace, the `final` surface, and that every generator + template imports a class that exists. They fail on drift, which is their whole job. -**Run:** `vendor/bin/phpunit` (full suite, **1346 tests** — integration excluded). +**Run:** `vendor/bin/phpunit` (full suite, **1605 tests** — integration excluded). Integration: `vendor/bin/phpunit --group integration tests/Process/Integration` (needs `pcntl`+`posix`; runs under Swoole here; ~20s). @@ -386,7 +449,7 @@ coroutine (fixed in the `winter-thread` package: `AdaptiveLauncher`/`SwooleLaunc **Not done (future phases):** phase 3 — templates (worker-pool, SMPP, WebSocket); phase 4 — web status (a controller reading the same store; `DaemonStatus` is already -JSON-serializable). `src/Old/Process` is to be **deleted by the user**. +JSON-serializable). **Known minor limitations:** `maxRestarts` is cumulative (not "consecutive"); a STARTING worker that hangs before its first heartbeat relies on the watchdog with a diff --git a/README.md b/README.md index 88e5547..5f9cf92 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,8 @@ composer require flytachi/winter-kernel K5 — тот же рантайм Swoole, что и K4-Swoole (persistent workers + coroutines), поэтому сравнение прямое «код vs код». Разница здесь — это чистая работа оптимизатора K5, а не смена модели исполнения (как было при FPM→Swoole). + +--- + +## Server State + +| state | K4-Sw ping | K4-Sw MEM | K5 ping | K5 MEM | Δ ping | Δ MEM | +|--------|------------|-----------|---------|-----------|--------|-------| +| cold | 0.62 ms | 18.71 MiB | 0.64 ms | 18.97 MiB | +0.02 | +1.4% | +| warm | 0.61 ms | 18.96 MiB | 0.63 ms | 19.12 MiB | +0.02 | +0.8% | +| cool | 0.70 ms | 27.22 MiB | 0.55 ms | 27.07 MiB | −21% | −0.6% | + +Профиль покоя **идентичен** K4-Swoole — те же ~19 MiB в покое, тот же под-миллисекундный ping. opcache delta −0.0 ms (код живёт в persistent-воркерах). Единственный сдвиг: cool-ping у K5 быстрее (0.55 vs 0.70 ms). Расширенный набор dev-инструментов **не утяжелил старт** — важный результат. + +--- + +## Throughput RPS — K5 vs K4-Swoole + +| endpoint | load | K4-Sw RPS | K5 RPS | K5 vs K4-Sw | +|----------|---------------|-----------|----------|-------------| +| ping | light c=10 | 16592.75 | 18130.36 | +9.3% | +| ping | medium c=25 | 16813.42 | 18253.05 | +8.6% | +| ping | heavy c=50 | 16767.10 | 18112.48 | +8.0% | +| ping | extreme c=100 | 16906.59 | 17913.91 | +6.0% | +| hello | light c=10 | 16836.38 | 17876.95 | +6.2% | +| hello | medium c=25 | 16844.54 | 18074.61 | +7.3% | +| hello | heavy c=50 | 16832.40 | 17951.40 | +6.6% | +| hello | extreme c=100 | 16770.39 | 17939.00 | +7.0% | +| compute | light c=10 | 15628.99 | 16693.67 | +6.8% | +| compute | medium c=25 | 15604.08 | 16612.39 | +6.5% | +| compute | heavy c=50 | 15651.99 | 16699.72 | +6.7% | +| compute | extreme c=100 | 15611.49 | 16645.40 | +6.6% | +| **io** | light c=10 | 158.90 | 158.35 | −0.3% | +| **io** | medium c=25 | 474.92 | 474.90 | 0.0% | +| **io** | heavy c=50 | 949.30 | 950.52 | +0.1% | +| **io** | extreme c=100 | 1975.64 | 1977.28 | +0.1% | +| memory | light c=10 | 16296.67 | 17879.65 | +9.7% | +| memory | medium c=25 | 16286.95 | 17698.14 | +8.7% | +| memory | heavy c=50 | 16232.91 | 17213.38 | +6.0% | +| memory | extreme c=100 | 16060.51 | 17635.33 | +9.8% | + +Средние дельты (без io): +- **K5 vs K4-Swoole: +7.5% RPS** на CPU-эндпоинтах (~16360 → ~17580). Ровно та же архитектура исполнения — прирост целиком от оптимизаций кода/бутстрапа/роутинга в K5. +- Прирост равномерный на ping/hello/compute/memory (+6…+10%), то есть это снижение фиксированной per-request стоимости, а не выигрыш на конкретном сценарии. +- **io — идентичен** (потолок корутин ~1977 RPS @ c=100 сохранён). Оптимизация не касалась io-модели — она и так упёрлась в саму I/O-задержку 50 ms, а не в рантайм. + +--- + +## Latency p99 at extreme load (c=100) + +| endpoint | K4-Sw p99 | K5 p99 | Δ | +|----------|-----------|---------|---------| +| ping | 7.21 ms | 7.25 ms | +0.6% | +| hello | 7.40 ms | 6.97 ms | −5.8% | +| compute | 7.69 ms | 7.53 ms | −2.1% | +| io | 51.72 ms | 51.64 ms| −0.2% | +| memory | 7.93 ms | 10.99 ms| **+38.6%** | + +Латентность в том же классе (~7 ms на CPU, ~52 ms на io — io-p99 = сама задержка I/O). Hello/compute чуть лучше. **Единственная точка внимания — p99 memory-эндпоинта: 10.99 vs 7.93 ms** (хвост шире при том, что p90 идентичен 5.96 ms). Это локальный tail-спайк на самом «тяжёлом» по аллокациям сценарии, а не систематическая регрессия — стоит перепроверить на повторном прогоне. + +--- + +## Stress Test (ping, auto escalation) + +| conns | K4-Sw RPS | K4-Sw lat | K5 RPS | K5 lat | zone (K5) | +|--------|-----------|-----------|----------|---------|-----------| +| c=10 | 16311.54 | 0.97 ms | 18253.45 | 0.83 ms | ✓ ok* | +| c=25 | 16470.92 | 2.89 ms | 18216.48 | 2.56 ms | ✓ ok | +| c=50 | 16340.77 | 5.66 ms | 18200.34 | 5.10 ms | ✓ ok | +| c=100 | 16146.72 | 7.70 ms | 18083.21 | 7.02 ms | ✓ ok | +| c=200 | 15995.83 | 14.39 ms | 18055.76 |12.89 ms | ✓ ok | +| c=300 | 15708.65 | 22.15 ms | 18011.50 |19.53 ms | ✓ ok | +| c=500 | 15649.30 | 36.40 ms | 17765.47 |32.58 ms | ✓ ok | +| c=750 | 15754.89 | 54.96 ms | 17642.78 |47.46 ms | ✓ ok | +| c=1000 | 15603.88 | 69.43 ms | 17643.11 |64.68 ms | ✓ ok | + +\* zone-эвристика пометила самый первый бурст (c=10) как `degraded` — это артефакт первого замера (RPS там максимальный, ошибок 0), с c=25 всё `✓ ok`. + +- **K5 держит ~17600–18250 RPS на всём диапазоне** против ~15600–16500 у K4-Swoole — стабильно выше на ~2000 RPS (+13% на хвосте c=1000). +- **Деградации нет ни у одной** до c=1000 включительно; 0 ошибок у обоих. +- Латентность у K5 **ниже на каждой ступени** (например, c=1000: 64.68 vs 69.43 ms, −6.8%; c=500: 32.58 vs 36.40 ms, −10.5%). Больше RPS *и* ниже latency одновременно. + +--- + +## Resources Under Load + +| metric | K4-Swoole | K5 | Δ | +|----------|------------|------------|----------| +| CPU peak | 94.8% | 94.6% | −0.2pp | +| CPU avg | 70.6% | 70.3% | −0.3pp | +| MEM peak | 100.70 MiB | 100.6 MiB | −0.1% | +| MEM cold | 18.71 MiB | 18.97 MiB | +1.4% | +| samples | 384 | 384 | — | + +**Главный итог всего сравнения:** ресурсы **идентичны** (CPU peak/avg и MEM peak совпадают до десятых), а K5 при этом выдаёт **на +7.5% больше RPS и с меньшей латентностью**. То есть K5 делает больше работы на тех же тактах и той же памяти — это чистый прирост эффективности (RPS на единицу CPU), а не размен «скорость за ресурсы». + +--- + +## Summary + +| metric | K4-Swoole | K5 | winner | +|---------------------------------|-----------|-----------|--------------| +| Avg RPS, CPU-эндпоинты | ~16360 | ~17580 | **K5 +7.5%** | +| io RPS @ c=100 (потолок корутин)| 1976 | 1977 | tie | +| p99 @ extreme (CPU eps) | ~7.4 ms | ~7.3 ms | K5 ≈ | +| p99 memory @ extreme | 7.93 ms | 10.99 ms | K4-Swoole | +| p99 io @ extreme | 51.7 ms | 51.6 ms | tie | +| Порог деградации | не достигнут | не достигнут | tie | +| RPS @ c=1000 (stress) | 15604 | 17643 | **K5 +13%** | +| Latency @ c=1000 (stress) | 69.43 ms | 64.68 ms | **K5** | +| Память в покое (cold) | 18.71 MiB | 18.97 MiB | K4-Sw ≈ | +| Память peak под нагрузкой | 100.7 MiB | 100.6 MiB | tie | +| CPU avg под нагрузкой | 70.6% | 70.3% | K5 ≈ | +| Cold-start ping | 0.62 ms | 0.64 ms | K4-Sw ≈ | + +### Key conclusions + +1. **K5 — чистая оптимизация K4, тот же рантайм.** В отличие от скачка FPM→Swoole (там менялась модель исполнения), здесь код на том же Swoole. Весь прирост +7.5% RPS — заслуга оптимизаций внутри K5. +2. **Больше throughput при неизменных ресурсах.** CPU peak/avg и MEM peak совпадают с K4-Swoole до десятых долей, а RPS выше на 7.5% и latency ниже на всех ступенях стресса. Эффективность (RPS/CPU) выросла — это лучший вид апгрейда. +3. **Плюс инструменты для разработчика — бесплатно.** Расширенный набор dev-фич не стоил ни памяти в покое (~19 MiB, как у K4-Sw), ни старта (cold-ping 0.64 ms), ни CPU. Обычно DX-обвязка что-то отъедает — здесь нет. +4. **io-потолок не тронут (и не нужно).** Корутинный io так же выходит на ~1977 RPS @ c=100 — упор в саму задержку 50 ms, рантайм не при чём. +5. **Единственная точка внимания — p99 memory-эндпоинта (10.99 vs 7.93 ms).** p90 идентичен, так что это хвостовой спайк на самом аллокационно-тяжёлом сценарии. Не блокер, но кандидат на перепроверку повторным прогоном. + +### Verdict + +**K5 — это K4-Swoole, ставший быстрее без всякой платы.** +7.5% RPS на CPU-эндпоинтах, +13% на стресс-хвосте, ниже latency на каждой ступени — **при идентичных CPU и памяти** и с дополнительным набором инструментов для разработчика в придачу. Никаких «потерь» за фичи, как это было на переходах K2→K3 (−5.4%) и K3→K4 (−2.7%): здесь оптимизатор не только вернул стоимость новых фич, но и ушёл в плюс. Единственное, за чем стоит присмотреть, — расширенный p99-хвост memory-эндпоинта. В остальном K5 доминирует над K4-Swoole по всем метрикам. **Чистое улучшение, брать однозначно.** diff --git a/composer.json b/composer.json index 8a9b965..2e2c7a1 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ "autoload": { "psr-4": { "Flytachi\\Winter\\Console\\": "console/", - "Flytachi\\Winter\\K2\\": "src/" + "Flytachi\\Winter\\Kernel\\": "src/" }, "files": [ "function/dependencies.php" @@ -23,7 +23,7 @@ }, "autoload-dev": { "psr-4": { - "Flytachi\\Winter\\K2\\Tests\\": "tests" + "Flytachi\\Winter\\Kernel\\Tests\\": "tests" } }, "bin": ["wKernelRunner"], diff --git a/console/Command/Cfg.php b/console/Command/Cfg.php index cd4c935..88229c0 100644 --- a/console/Command/Cfg.php +++ b/console/Command/Cfg.php @@ -5,9 +5,9 @@ namespace Flytachi\Winter\Console\Command; use Flytachi\Winter\Console\Inc\Cmd; -use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\Kernel\Kernel; -class Cfg extends Cmd +final class Cfg extends Cmd { public static string $title = "manage project configuration, environment and keys"; private string $templatePath; diff --git a/console/Command/Complete.php b/console/Command/Complete.php index ab1e1e3..f20fa0e 100644 --- a/console/Command/Complete.php +++ b/console/Command/Complete.php @@ -6,13 +6,13 @@ use Flytachi\Winter\Console\Core; use Flytachi\Winter\Console\Inc\Cmd; -use Flytachi\Winter\Console\Inc\CmdCustom; -use Flytachi\Winter\K2\Collector\SubclassCollector; -use Flytachi\Winter\K2\Core\ClassScanner; -use Flytachi\Winter\K2\Process\Daemon\Daemon as DaemonUnit; -use Flytachi\Winter\K2\Process\Process as ProcessUnit; +use Flytachi\Winter\Console\Stereotype\CmdCustom; +use Flytachi\Winter\Kernel\Collector\SubclassCollector; +use Flytachi\Winter\Kernel\Core\ClassScanner; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon as DaemonUnit; +use Flytachi\Winter\Kernel\Process\Stereotype\Process as ProcessUnit; -class Complete extends Cmd +final class Complete extends Cmd { public static string $title = "shell completion endpoint (internal)"; @@ -30,7 +30,6 @@ class Complete extends Cmd '-e:Entity — ORM entity / model', '-d:Dto — Data Transfer Object', '-q:Request — validated request object', - '-p:Response — custom HTTP response', '-P:Process — long-running process', '-N:Daemon — background daemon', '-D:DbConfig — database configuration', diff --git a/console/Command/Daemon.php b/console/Command/Daemon.php index 74c1880..83563e5 100644 --- a/console/Command/Daemon.php +++ b/console/Command/Daemon.php @@ -5,19 +5,19 @@ namespace Flytachi\Winter\Console\Command; use Flytachi\Winter\Console\Inc\Cmd; -use Flytachi\Winter\K2\Collector\SubclassCollector; -use Flytachi\Winter\K2\Core\ClassScanner; -use Flytachi\Winter\K2\Process\Activity; -use Flytachi\Winter\K2\Process\Daemon\Daemon as DaemonUnit; -use Flytachi\Winter\K2\Process\Daemon\DaemonStatus; -use Flytachi\Winter\K2\Process\Daemon\SlotState; -use Flytachi\Winter\K2\Process\Daemon\WorkerStatus; +use Flytachi\Winter\Kernel\Collector\SubclassCollector; +use Flytachi\Winter\Kernel\Core\ClassScanner; +use Flytachi\Winter\Kernel\Process\Activity; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon as DaemonUnit; +use Flytachi\Winter\Kernel\Process\Daemon\DaemonStatus; +use Flytachi\Winter\Kernel\Process\Daemon\SlotState; +use Flytachi\Winter\Kernel\Process\Daemon\WorkerStatus; /** * Manages supervised {@see DaemonUnit} fleets (start/stop/status), including the * per-worker view. Bare processes are managed by `call process`. */ -class Daemon extends Cmd +final class Daemon extends Cmd { public static string $title = "manage Daemon fleets (start/stop/status)"; diff --git a/console/Command/Db.php b/console/Command/Db.php index 54e78e9..40fc4a7 100644 --- a/console/Command/Db.php +++ b/console/Command/Db.php @@ -5,13 +5,13 @@ namespace Flytachi\Winter\Console\Command; use Flytachi\Winter\Console\Inc\Cmd; -use Flytachi\Winter\K2\Ppa\DeclarationItem; -use Flytachi\Winter\K2\Ppa\PPAMapping; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Table; -use Flytachi\Winter\K2\Ppa\Pool\PoolTelemetry; -use Flytachi\Winter\K2\Plugin; +use Flytachi\Winter\Kernel\Ppa\DeclarationItem; +use Flytachi\Winter\Kernel\Ppa\PPAMapping; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Table; +use Flytachi\Winter\Kernel\Ppa\Pool\PoolTelemetry; +use Flytachi\Winter\Kernel\Plugin; -class Db extends Cmd +final class Db extends Cmd { public static string $title = "manage database migrations and SQL preview"; diff --git a/console/Command/Di.php b/console/Command/Di.php index 12041c5..dce2337 100644 --- a/console/Command/Di.php +++ b/console/Command/Di.php @@ -9,17 +9,17 @@ use Flytachi\Winter\DI\Collector\DICollector; use Flytachi\Winter\DI\Contract\CollectorInterface; use Flytachi\Winter\DI\Scanner; -use Flytachi\Winter\K2\App\Attribute\EnableAsync; -use Flytachi\Winter\K2\Concurrent\Async\AsyncCollector; -use Flytachi\Winter\K2\Concurrent\Async\Proxy\BypassScanner; -use Flytachi\Winter\K2\Concurrent\Async\Proxy\ProxyFactory; -use Flytachi\Winter\K2\Concurrent\Async\Proxy\ProxyGenerator; -use Flytachi\Winter\K2\Kernel; -use Flytachi\Winter\K2\WinterApplication; +use Flytachi\Winter\Kernel\App\Attribute\EnableAsync; +use Flytachi\Winter\Kernel\Concurrent\Async\AsyncCollector; +use Flytachi\Winter\Kernel\Concurrent\Async\Proxy\BypassScanner; +use Flytachi\Winter\Kernel\Concurrent\Async\Proxy\ProxyFactory; +use Flytachi\Winter\Kernel\Concurrent\Async\Proxy\ProxyGenerator; +use Flytachi\Winter\Kernel\Kernel; +use Flytachi\Winter\Kernel\WinterApplication; use ReflectionClass; use ReflectionMethod; -class Di extends Cmd +final class Di extends Cmd { public static string $title = "manage and inspect DI scanner cache (build, clean, show, async)"; diff --git a/console/Command/Help.php b/console/Command/Help.php index 31bf51d..af6b56a 100644 --- a/console/Command/Help.php +++ b/console/Command/Help.php @@ -6,9 +6,9 @@ use Composer\InstalledVersions; use Flytachi\Winter\Console\Inc\Cmd; -use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\Kernel\Kernel; -class Help extends Cmd +final class Help extends Cmd { public static string $title = "list commands and show usage information"; diff --git a/console/Command/Make.php b/console/Command/Make.php index 827b1c7..29046e5 100644 --- a/console/Command/Make.php +++ b/console/Command/Make.php @@ -6,9 +6,9 @@ use Composer\Autoload\ClassLoader; use Flytachi\Winter\Console\Inc\Cmd; -use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\Kernel\Kernel; -class Make extends Cmd +final class Make extends Cmd { public static string $title = "generate framework component templates"; private string $createPath; @@ -70,9 +70,6 @@ private function resolution(): void if (in_array('d', $this->args['flags'])) { $this->createDto($templateName); } - if (in_array('p', $this->args['flags'])) { - $this->createResponse($templateName); - } if (in_array('P', $this->args['flags'])) { $this->createProcess($templateName); } @@ -195,30 +192,6 @@ private function createDto(string $name): void $this->createFile($info['className'], $info['path'], $code, 'dto'); } - private function createResponse(string $name): void - { - $info = $this->getInfo($name, '', 'ResponseTemplate'); - $this->smartInfo( - $info, - 'Controllers', - 'Controller', - 'Utilities/Responses', - 'Utilities/Response', - 'Utility/Responses', - 'Utility/Response', - 'Utils/Responses', - 'Utils/Response', - 'Util/Responses', - 'Util/Response', - 'Responses', - 'Response' - ); - $code = file_get_contents($info['template']); - $code = str_replace("__namespace__", $info['namespace'], $code); - $code = str_replace("__className__", $info['className'], $code); - $this->createFile($info['className'], $info['path'], $code, 'response'); - } - private function createProcess(string $name): void { $info = $this->getInfo($name, 'Process', 'ProcessTemplate'); @@ -462,7 +435,6 @@ public static function help(): void self::printLabel("Flags — Data", $cl); self::printBadge('-e', 'Entity (no suffix)', $cl, 36); self::printBadge('-d', 'Dto (suffix: Dto)', $cl, 36); - self::printBadge('-p', 'Response (no suffix)', $cl, 36); self::printLabel("Flags — Data", $cl); self::printLabel("Flags — Business", $cl); diff --git a/console/Command/Mapping.php b/console/Command/Mapping.php index 0aaf38e..7d74f60 100644 --- a/console/Command/Mapping.php +++ b/console/Command/Mapping.php @@ -5,10 +5,10 @@ namespace Flytachi\Winter\Console\Command; use Flytachi\Winter\Console\Inc\Cmd; -use Flytachi\Winter\K2\Kernel; -use Flytachi\Winter\K2\Route\Router; +use Flytachi\Winter\Kernel\Kernel; +use Flytachi\Winter\Kernel\Route\Router; -class Mapping extends Cmd +final class Mapping extends Cmd { public static string $title = "manage and inspect route mapping cache (build, clean, show)"; diff --git a/console/Command/Process.php b/console/Command/Process.php index fde077c..b3de133 100644 --- a/console/Command/Process.php +++ b/console/Command/Process.php @@ -5,16 +5,16 @@ namespace Flytachi\Winter\Console\Command; use Flytachi\Winter\Console\Inc\Cmd; -use Flytachi\Winter\K2\Collector\SubclassCollector; -use Flytachi\Winter\K2\Core\ClassScanner; -use Flytachi\Winter\K2\Process\Activity; -use Flytachi\Winter\K2\Process\Daemon\Daemon as DaemonUnit; -use Flytachi\Winter\K2\Process\Process as ProcessUnit; +use Flytachi\Winter\Kernel\Collector\SubclassCollector; +use Flytachi\Winter\Kernel\Core\ClassScanner; +use Flytachi\Winter\Kernel\Process\Activity; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon as DaemonUnit; +use Flytachi\Winter\Kernel\Process\Stereotype\Process as ProcessUnit; /** * Manages bare {@see ProcessUnit} units. Daemons are managed by `call daemon`. */ -class Process extends Cmd +final class Process extends Cmd { public static string $title = "manage Process units (start/stop/status)"; diff --git a/console/Command/Run.php b/console/Command/Run.php index 7b4a031..c996b0b 100644 --- a/console/Command/Run.php +++ b/console/Command/Run.php @@ -6,7 +6,7 @@ use Flytachi\Winter\Console\Inc\Cmd; -class Run extends Cmd +final class Run extends Cmd { public static string $title = "run the application: web + declared components (Swoole)"; diff --git a/console/Command/Schedule.php b/console/Command/Schedule.php index 2f845c5..f4fe991 100644 --- a/console/Command/Schedule.php +++ b/console/Command/Schedule.php @@ -5,11 +5,11 @@ namespace Flytachi\Winter\Console\Command; use Flytachi\Winter\Console\Inc\Cmd; -use Flytachi\Winter\K2\Core\ClassScanner; -use Flytachi\Winter\K2\Process\Activity; -use Flytachi\Winter\K2\Schedule\Scheduler; -use Flytachi\Winter\K2\Schedule\ScheduledCollector; -use Flytachi\Winter\K2\Schedule\ScheduledTask; +use Flytachi\Winter\Kernel\Core\ClassScanner; +use Flytachi\Winter\Kernel\Process\Activity; +use Flytachi\Winter\Kernel\Schedule\Stereotype\Scheduler; +use Flytachi\Winter\Kernel\Schedule\ScheduledCollector; +use Flytachi\Winter\Kernel\Schedule\ScheduledTask; /** * Runs and inspects the {@see Scheduler} — the single process that fires every @@ -17,7 +17,7 @@ * to name: the scheduler is one fixed runtime, and the schedule is the set of * annotated methods it discovers. */ -class Schedule extends Cmd +final class Schedule extends Cmd { private const int CL = 36; diff --git a/console/Command/Script.php b/console/Command/Script.php index c4d31ac..9609a1f 100644 --- a/console/Command/Script.php +++ b/console/Command/Script.php @@ -5,11 +5,11 @@ namespace Flytachi\Winter\Console\Command; use Flytachi\Winter\Console\Inc\Cmd; -use Flytachi\Winter\Console\Inc\CmdCustom; -use Flytachi\Winter\K2\Collector\SubclassCollector; -use Flytachi\Winter\K2\Core\ClassScanner; +use Flytachi\Winter\Console\Stereotype\CmdCustom; +use Flytachi\Winter\Kernel\Collector\SubclassCollector; +use Flytachi\Winter\Kernel\Core\ClassScanner; -class Script extends Cmd +final class Script extends Cmd { public static string $title = "run or list custom Cmd scripts"; diff --git a/console/Command/Storage.php b/console/Command/Storage.php index 47c513d..53e810a 100644 --- a/console/Command/Storage.php +++ b/console/Command/Storage.php @@ -5,9 +5,9 @@ namespace Flytachi\Winter\Console\Command; use Flytachi\Winter\Console\Inc\Cmd; -use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\Kernel\Kernel; -class Storage extends Cmd +final class Storage extends Cmd { public static string $title = "manage storage folders (init, clean)"; private string $templatePath; diff --git a/console/Core.php b/console/Core.php index 837583b..ca541c6 100644 --- a/console/Core.php +++ b/console/Core.php @@ -6,7 +6,7 @@ use Flytachi\Winter\Console\Inc\CoreHandle; -class Core extends CoreHandle +final class Core extends CoreHandle { /** Short aliases → Command class name */ protected static array $aliases = [ diff --git a/console/Inc/CmdCustom.php b/console/Stereotype/CmdCustom.php similarity index 88% rename from console/Inc/CmdCustom.php rename to console/Stereotype/CmdCustom.php index 3db9a85..f54f14d 100644 --- a/console/Inc/CmdCustom.php +++ b/console/Stereotype/CmdCustom.php @@ -2,8 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\Console\Inc; +namespace Flytachi\Winter\Console\Stereotype; +use Flytachi\Winter\Console\Inc\Printer; use Flytachi\Winter\DI\Container; abstract class CmdCustom extends Printer implements CmdCustomInterface diff --git a/console/Inc/CmdCustomInterface.php b/console/Stereotype/CmdCustomInterface.php similarity index 69% rename from console/Inc/CmdCustomInterface.php rename to console/Stereotype/CmdCustomInterface.php index 303c717..ffd59fb 100644 --- a/console/Inc/CmdCustomInterface.php +++ b/console/Stereotype/CmdCustomInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\Console\Inc; +namespace Flytachi\Winter\Console\Stereotype; interface CmdCustomInterface { diff --git a/console/Template/Make/CmdTemplate b/console/Template/Make/CmdTemplate index 5f0bd77..96d4001 100644 --- a/console/Template/Make/CmdTemplate +++ b/console/Template/Make/CmdTemplate @@ -2,7 +2,7 @@ namespace __namespace__; -use Flytachi\Winter\Console\Inc\CmdCustom; +use Flytachi\Winter\Console\Stereotype\CmdCustom; class __className__ extends CmdCustom { diff --git a/console/Template/Make/ControllerTemplate b/console/Template/Make/ControllerTemplate index a5f640e..577266f 100644 --- a/console/Template/Make/ControllerTemplate +++ b/console/Template/Make/ControllerTemplate @@ -2,9 +2,9 @@ namespace __namespace__; -use Flytachi\Winter\K2\Http\Response\ResponseEntity; -use Flytachi\Winter\K2\Route\Annotation\RequestMapping; -use Flytachi\Winter\K2\Stereotype\Controller; +use Flytachi\Winter\Kernel\Http\Response\ResponseEntity; +use Flytachi\Winter\Kernel\Route\Annotation\RequestMapping; +use Flytachi\Winter\Kernel\Http\Stereotype\Controller; class __className__ extends Controller { diff --git a/console/Template/Make/DaemonTemplate b/console/Template/Make/DaemonTemplate index 9ea9ac7..d683f76 100644 --- a/console/Template/Make/DaemonTemplate +++ b/console/Template/Make/DaemonTemplate @@ -2,7 +2,7 @@ namespace __namespace__; -use Flytachi\Winter\K2\Process\Daemon\Daemon; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; final class __className__ extends Daemon { diff --git a/console/Template/Make/DbConfigTemplate b/console/Template/Make/DbConfigTemplate index 0059294..f88967e 100644 --- a/console/Template/Make/DbConfigTemplate +++ b/console/Template/Make/DbConfigTemplate @@ -3,9 +3,9 @@ namespace __namespace__; use Flytachi\Winter\Cdo\Config\DbConfig; -use Flytachi\Winter\K2\Ppa\PpaCallTrait; -use Flytachi\Winter\K2\Ppa\Pool\PpaPoolConfigInterface; -use Flytachi\Winter\K2\Ppa\Pool\PpaPoolTrait; +use Flytachi\Winter\Kernel\Ppa\PpaCallTrait; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaPoolConfigInterface; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaPoolTrait; class __className__ extends DbConfig implements PpaPoolConfigInterface { diff --git a/console/Template/Make/EntityTemplate b/console/Template/Make/EntityTemplate index 66a6db6..bc41aac 100644 --- a/console/Template/Make/EntityTemplate +++ b/console/Template/Make/EntityTemplate @@ -2,9 +2,9 @@ namespace __namespace__; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Entity\Table; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid\Id; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Varchar; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Entity\Table; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid\Id; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Varchar; #[Table] class __className__ diff --git a/console/Template/Make/MiddlewareTemplate b/console/Template/Make/MiddlewareTemplate index 67421e9..f79f248 100644 --- a/console/Template/Make/MiddlewareTemplate +++ b/console/Template/Make/MiddlewareTemplate @@ -2,9 +2,9 @@ namespace __namespace__; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Stereotype\Middleware; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Stereotype\Middleware; #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] class __className__ extends Middleware diff --git a/console/Template/Make/ProcessTemplate b/console/Template/Make/ProcessTemplate index 59ba4f9..68c68e2 100644 --- a/console/Template/Make/ProcessTemplate +++ b/console/Template/Make/ProcessTemplate @@ -2,7 +2,7 @@ namespace __namespace__; -use Flytachi\Winter\K2\Process\Process; +use Flytachi\Winter\Kernel\Process\Stereotype\Process; final class __className__ extends Process { diff --git a/console/Template/Make/RepositoryTemplate b/console/Template/Make/RepositoryTemplate index 2d16a11..1a86e69 100644 --- a/console/Template/Make/RepositoryTemplate +++ b/console/Template/Make/RepositoryTemplate @@ -2,7 +2,7 @@ namespace __namespace__; -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; class __className__ extends Repository { diff --git a/console/Template/Make/ResponseTemplate b/console/Template/Make/ResponseTemplate deleted file mode 100644 index 0693177..0000000 --- a/console/Template/Make/ResponseTemplate +++ /dev/null @@ -1,19 +0,0 @@ -content = [ - 'code' => $this->httpCode->value, - 'result' => $this->content, - ...$this->debugger() - ]; - return parent::getBody(); - } -} diff --git a/console/Template/Make/ServiceTemplate b/console/Template/Make/ServiceTemplate index 272a031..0535142 100644 --- a/console/Template/Make/ServiceTemplate +++ b/console/Template/Make/ServiceTemplate @@ -2,8 +2,6 @@ namespace __namespace__; -use Flytachi\Winter\K2\Stereotype\Service; - -class __className__ extends Service +class __className__ { } diff --git a/dev/bootstrap.php b/dev/bootstrap.php index 77c3cea..a40e0b3 100644 --- a/dev/bootstrap.php +++ b/dev/bootstrap.php @@ -2,9 +2,9 @@ declare(strict_types=1); -use Flytachi\Winter\K2\App\Attribute\EnableActuator; -use Flytachi\Winter\K2\App\Attribute\EnableWeb; -use Flytachi\Winter\K2\WinterApplication; +use Flytachi\Winter\Kernel\App\Attribute\EnableActuator; +use Flytachi\Winter\Kernel\App\Attribute\EnableWeb; +use Flytachi\Winter\Kernel\WinterApplication; require __DIR__ . '/vendor/autoload.php'; diff --git a/dev/main/AuthMiddleware.php b/dev/main/AuthMiddleware.php index af50d26..c0c6302 100644 --- a/dev/main/AuthMiddleware.php +++ b/dev/main/AuthMiddleware.php @@ -2,10 +2,10 @@ namespace Main; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\Middleware\MiddlewareException; -use Flytachi\Winter\K2\Stereotype\Middleware; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Middleware\MiddlewareException; +use Flytachi\Winter\Kernel\Http\Stereotype\Middleware; #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] class AuthMiddleware extends Middleware diff --git a/dev/main/ListOrder.php b/dev/main/ListOrder.php index 29f403b..e251b94 100644 --- a/dev/main/ListOrder.php +++ b/dev/main/ListOrder.php @@ -2,8 +2,8 @@ namespace Main; -use Flytachi\Winter\K2\Http\Request\Validation\ListOf; -use Flytachi\Winter\K2\Http\Request\Validation\Size; +use Flytachi\Winter\Kernel\Http\Request\Validation\ListOf; +use Flytachi\Winter\Kernel\Http\Request\Validation\Size; readonly class ListOrder { diff --git a/dev/main/MainController.php b/dev/main/MainController.php index e9d2baf..44bb553 100644 --- a/dev/main/MainController.php +++ b/dev/main/MainController.php @@ -3,17 +3,17 @@ namespace Main; use Flytachi\Winter\DI\Attribute\Inject; -use Flytachi\Winter\K2\Concurrent\Executors; -use Flytachi\Winter\K2\Http\Request\Annotation\PathVariable; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestBody; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestParam; -use Flytachi\Winter\K2\Http\Request\Validation\Positive; -use Flytachi\Winter\K2\Http\Request\Validation\Valid; -use Flytachi\Winter\K2\Http\Response\ResponseEntity; -use Flytachi\Winter\K2\Route\Annotation\GetMapping; -use Flytachi\Winter\K2\Route\Annotation\PostMapping; -use Flytachi\Winter\K2\Route\Annotation\RequestMapping; -use Flytachi\Winter\K2\Stereotype\Controller; +use Flytachi\Winter\Kernel\Concurrent\Executors; +use Flytachi\Winter\Kernel\Http\Request\Annotation\PathVariable; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestBody; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestParam; +use Flytachi\Winter\Kernel\Http\Request\Validation\Positive; +use Flytachi\Winter\Kernel\Http\Request\Validation\Valid; +use Flytachi\Winter\Kernel\Http\Response\ResponseEntity; +use Flytachi\Winter\Kernel\Route\Annotation\GetMapping; +use Flytachi\Winter\Kernel\Route\Annotation\PostMapping; +use Flytachi\Winter\Kernel\Route\Annotation\RequestMapping; +use Flytachi\Winter\Kernel\Http\Stereotype\Controller; use Flytachi\Winter\Logger\Log; use Main\Services\SendInterface; use Main\Services\SmsSendService; diff --git a/dev/main/Order.php b/dev/main/Order.php index 09c56a9..ae67bb6 100644 --- a/dev/main/Order.php +++ b/dev/main/Order.php @@ -2,9 +2,9 @@ namespace Main; -use Flytachi\Winter\K2\Http\Request\K1ValidationTrait; -use Flytachi\Winter\K2\Http\Request\Validation\Size; -use Flytachi\Winter\K2\Localization\Locale; +use Flytachi\Winter\Kernel\Http\Request\K1ValidationTrait; +use Flytachi\Winter\Kernel\Http\Request\Validation\Size; +use Flytachi\Winter\Kernel\Localization\Locale; readonly class Order { diff --git a/dev/main/Process/AutoscaleDaemon.php b/dev/main/Process/AutoscaleDaemon.php index bf5c493..98dc51e 100644 --- a/dev/main/Process/AutoscaleDaemon.php +++ b/dev/main/Process/AutoscaleDaemon.php @@ -4,8 +4,8 @@ namespace Main\Process; -use Flytachi\Winter\K2\Process\Daemon\Daemon; -use Flytachi\Winter\K2\Process\Daemon\ScalingPolicy; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; +use Flytachi\Winter\Kernel\Process\Daemon\ScalingPolicy; /** * Autoscaling demo: desiredReplicas() ramps 1 → 5 → 2 over time so the reconcile diff --git a/dev/main/Process/ConsumerDemo.php b/dev/main/Process/ConsumerDemo.php index e2f8818..edaac74 100644 --- a/dev/main/Process/ConsumerDemo.php +++ b/dev/main/Process/ConsumerDemo.php @@ -4,8 +4,8 @@ namespace Main\Process; -use Flytachi\Winter\K2\Process\InterruptedException; -use Flytachi\Winter\K2\Process\Process; +use Flytachi\Winter\Kernel\Process\InterruptedException; +use Flytachi\Winter\Kernel\Process\Stereotype\Process; /** * Consumer-style demo: IDLE wait, then a BUSY unit. Proves drain-to-idle — a diff --git a/dev/main/Process/CrashDaemon.php b/dev/main/Process/CrashDaemon.php index 8942ec1..7abff94 100644 --- a/dev/main/Process/CrashDaemon.php +++ b/dev/main/Process/CrashDaemon.php @@ -4,9 +4,9 @@ namespace Main\Process; -use Flytachi\Winter\K2\Process\Daemon\Daemon; -use Flytachi\Winter\K2\Process\Daemon\RestartMode; -use Flytachi\Winter\K2\Process\Daemon\RestartPolicy; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; +use Flytachi\Winter\Kernel\Process\Daemon\RestartMode; +use Flytachi\Winter\Kernel\Process\Daemon\RestartPolicy; /** * Worker crashes after a couple of ticks. Exercises ON_FAILURE restart with diff --git a/dev/main/Process/DemoProcess.php b/dev/main/Process/DemoProcess.php index 78c7124..22bb427 100644 --- a/dev/main/Process/DemoProcess.php +++ b/dev/main/Process/DemoProcess.php @@ -4,7 +4,7 @@ namespace Main\Process; -use Flytachi\Winter\K2\Process\Process; +use Flytachi\Winter\Kernel\Process\Stereotype\Process; /** * One-shot demo: dispatch 6 tasks with a concurrency cap of 2 and exit. diff --git a/dev/main/Process/FleetDaemon.php b/dev/main/Process/FleetDaemon.php index e76e94f..d624c7a 100644 --- a/dev/main/Process/FleetDaemon.php +++ b/dev/main/Process/FleetDaemon.php @@ -4,7 +4,7 @@ namespace Main\Process; -use Flytachi\Winter\K2\Process\Daemon\Daemon; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; /** * Supervises an external worker class ({@see SendProc}) rather than an inline diff --git a/dev/main/Process/HungDaemon.php b/dev/main/Process/HungDaemon.php index ffd2a62..c6f30dc 100644 --- a/dev/main/Process/HungDaemon.php +++ b/dev/main/Process/HungDaemon.php @@ -4,9 +4,9 @@ namespace Main\Process; -use Flytachi\Winter\K2\Process\Daemon\Daemon; -use Flytachi\Winter\K2\Process\Daemon\RestartMode; -use Flytachi\Winter\K2\Process\Daemon\RestartPolicy; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; +use Flytachi\Winter\Kernel\Process\Daemon\RestartMode; +use Flytachi\Winter\Kernel\Process\Daemon\RestartPolicy; /** * Worker wedges in a tight loop after one healthy beat — alive by PID but making diff --git a/dev/main/Process/LongDemo.php b/dev/main/Process/LongDemo.php index 64888db..82802ed 100644 --- a/dev/main/Process/LongDemo.php +++ b/dev/main/Process/LongDemo.php @@ -4,7 +4,7 @@ namespace Main\Process; -use Flytachi\Winter\K2\Process\Process; +use Flytachi\Winter\Kernel\Process\Stereotype\Process; /** * Long-lived demo: ticks until stopped. Exercises running()/sleep() and the diff --git a/dev/main/Process/NeverDaemon.php b/dev/main/Process/NeverDaemon.php index 046740c..1dc7395 100644 --- a/dev/main/Process/NeverDaemon.php +++ b/dev/main/Process/NeverDaemon.php @@ -4,9 +4,9 @@ namespace Main\Process; -use Flytachi\Winter\K2\Process\Daemon\Daemon; -use Flytachi\Winter\K2\Process\Daemon\RestartMode; -use Flytachi\Winter\K2\Process\Daemon\RestartPolicy; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; +use Flytachi\Winter\Kernel\Process\Daemon\RestartMode; +use Flytachi\Winter\Kernel\Process\Daemon\RestartPolicy; /** * NEVER restart: a crashed worker must NOT be replaced. Verifies the slot is diff --git a/dev/main/Process/SendProc.php b/dev/main/Process/SendProc.php index 0ddd3bd..e35499a 100644 --- a/dev/main/Process/SendProc.php +++ b/dev/main/Process/SendProc.php @@ -4,7 +4,7 @@ namespace Main\Process; -use Flytachi\Winter\K2\Process\Process; +use Flytachi\Winter\Kernel\Process\Stereotype\Process; use Flytachi\Winter\Logger\LoggerFactory; /** diff --git a/dev/main/Process/SignalDemo.php b/dev/main/Process/SignalDemo.php index 6856215..c66e834 100644 --- a/dev/main/Process/SignalDemo.php +++ b/dev/main/Process/SignalDemo.php @@ -4,8 +4,8 @@ namespace Main\Process; -use Flytachi\Winter\K2\Process\InterruptedException; -use Flytachi\Winter\K2\Process\Process; +use Flytachi\Winter\Kernel\Process\InterruptedException; +use Flytachi\Winter\Kernel\Process\Stereotype\Process; /** * Reference of the signal contract with canonical PSR-3 log levels. diff --git a/dev/main/Process/StableDaemon.php b/dev/main/Process/StableDaemon.php index fb78604..500bc3c 100644 --- a/dev/main/Process/StableDaemon.php +++ b/dev/main/Process/StableDaemon.php @@ -4,7 +4,7 @@ namespace Main\Process; -use Flytachi\Winter\K2\Process\Daemon\Daemon; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; /** * Long-lived worker that loops until stopped. Exercises the graceful stop of a diff --git a/dev/main/Schedule/DemoTasks.php b/dev/main/Schedule/DemoTasks.php index e00c96b..62da2dc 100644 --- a/dev/main/Schedule/DemoTasks.php +++ b/dev/main/Schedule/DemoTasks.php @@ -4,7 +4,7 @@ namespace Main\Schedule; -use Flytachi\Winter\K2\Schedule\Scheduled; +use Flytachi\Winter\Kernel\Schedule\Scheduled; use Flytachi\Winter\Logger\LoggerFactory; /** diff --git a/dev/main/Services/FakeSendService.php b/dev/main/Services/FakeSendService.php index cd4b255..84c549c 100644 --- a/dev/main/Services/FakeSendService.php +++ b/dev/main/Services/FakeSendService.php @@ -2,10 +2,9 @@ namespace Main\Services; -use Flytachi\Winter\K2\Stereotype\Service; use Flytachi\Winter\Logger\Log; -class FakeSendService extends Service implements SendInterface +class FakeSendService implements SendInterface { public function list(): array { diff --git a/dev/main/Services/SmsSendService.php b/dev/main/Services/SmsSendService.php index 7b54a7d..401da2e 100644 --- a/dev/main/Services/SmsSendService.php +++ b/dev/main/Services/SmsSendService.php @@ -2,10 +2,9 @@ namespace Main\Services; -use Flytachi\Winter\K2\Stereotype\Service; use Flytachi\Winter\Logger\Log; -class SmsSendService extends Service implements SendInterface +class SmsSendService implements SendInterface { public function list(): array { diff --git a/dev/main/Te1.php b/dev/main/Te1.php index 531a8ff..a73a1a6 100644 --- a/dev/main/Te1.php +++ b/dev/main/Te1.php @@ -2,8 +2,8 @@ namespace Main; -use Flytachi\Winter\K2\Process\Daemon\Daemon; -use Flytachi\Winter\K2\Process\Process; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; +use Flytachi\Winter\Kernel\Process\Stereotype\Process; class Te1 extends Daemon { diff --git a/dev/main/WebConfig.php b/dev/main/WebConfig.php index eed0e86..c61034a 100644 --- a/dev/main/WebConfig.php +++ b/dev/main/WebConfig.php @@ -4,9 +4,9 @@ namespace Main; -use Flytachi\Winter\K2\App\ApplicationArguments; -use Flytachi\Winter\K2\App\Config\ServerSettings; -use Flytachi\Winter\K2\App\Config\WebConfigurerAdapter; +use Flytachi\Winter\Kernel\App\ApplicationArguments; +use Flytachi\Winter\Kernel\App\Config\ServerSettings; +use Flytachi\Winter\Kernel\App\Config\WebConfigurerAdapter; /** * Web configuration, found by the scan — no registration needed. diff --git a/dev/wDevRunner b/dev/wDevRunner index 3719616..69fd59b 100755 --- a/dev/wDevRunner +++ b/dev/wDevRunner @@ -16,7 +16,7 @@ declare(strict_types=1); payload runs. */ -use Flytachi\Winter\K2\WinterApplication; +use Flytachi\Winter\Kernel\WinterApplication; if (PHP_VERSION_ID < 80400) { fwrite(STDERR, "Please use PHP version 8.4 or higher.\n"); diff --git a/doc/STATUS.md b/doc/STATUS.md index c356935..d053457 100644 --- a/doc/STATUS.md +++ b/doc/STATUS.md @@ -37,6 +37,19 @@ housekeeper (keepalive / idleTimeout / minimumIdle, opt-in), evict при пот **Документация** — `docs/` вычищен от мёртвого API, `README.md` переписан, `doc-new/` удалён. +**Редизайн: namespace, стереотипы, поверхность расширения** (2026-08-02) — корень +`Flytachi\Winter\K2\` → `Flytachi\Winter\Kernel\` (версия ушла из адреса; `Console` +остался отдельным корнем); точки расширения собраны в `<Слой>/Stereotype/`; закрыты +`final` 96 классов в `src/` плюс 13 консольных команд и `Console\Core`, открытыми +осознанно остались 25 (17 исключений категорией + 8 поимённо с причинами). Держится +четырьмя архитектурными тестами. Спека — `doc/2026-08-02-restructure-design.md`, +план — `-plan.md`. + +> Для проектов: после обновления нужен **`composer update flytachi/winter-kernel`**, а не +> только `dump-autoload` — карта автозагрузки берётся из `vendor/composer/installed.json`, +> и без `update` там останется старый корень. Симптом — «Class not found» при формально +> свежем ядре. + **Аудит непроверенных слоёв** — `Concurrent/Async`, `Unit/Pagination`, `Stereotype`, миграции `Ppa` открыты и покрыты тестами (`AsyncContractTest`, `CursorTokenTest`, `SqliteDdlTest`). В `CursorToken::decode()` добавлен отказ на нескалярное значение diff --git a/docs/architecture/01-routing.md b/docs/architecture/01-routing.md index fcc8238..2321188 100644 --- a/docs/architecture/01-routing.md +++ b/docs/architecture/01-routing.md @@ -1,6 +1,6 @@ # Routing -Winter K2 uses a Spring Boot-style dual-mode router that works identically in Swoole coroutine mode and PHP-FPM. +Winter uses a Spring Boot-style dual-mode router that works identically in Swoole coroutine mode and PHP-FPM. --- @@ -9,8 +9,8 @@ Winter K2 uses a Spring Boot-style dual-mode router that works identically in Sw Define routes via PHP attributes on controller classes and methods. ```php -use Flytachi\Winter\K2\Route\Annotation\{RequestMapping, GetMapping, PostMapping, PutMapping, PatchMapping, DeleteMapping}; -use Flytachi\Winter\K2\Stereotype\Controller; +use Flytachi\Winter\Kernel\Route\Annotation\{RequestMapping, GetMapping, PostMapping, PutMapping, PatchMapping, DeleteMapping}; +use Flytachi\Winter\Kernel\Http\Stereotype\Controller; #[RequestMapping('users')] // class-level prefix → /users class UserController extends Controller @@ -168,7 +168,7 @@ request logging do not apply to them. See Call once in bootstrap **before** `Router::resolve()`: ```php -use Flytachi\Winter\K2\Http\Cors; +use Flytachi\Winter\Kernel\Http\Cors; Cors::configure( origins: ['https://app.example.com'], @@ -195,7 +195,7 @@ Global CORS headers are written **before** route dispatch — they appear on 404 Placed on a controller class or method, `#[CrossOrigin]` **overrides** (does not merge with) the global config: ```php -use Flytachi\Winter\K2\Route\Annotation\CrossOrigin; +use Flytachi\Winter\Kernel\Route\Annotation\CrossOrigin; // Entire controller #[CrossOrigin(origins: ['https://admin.example.com'], credentials: true)] diff --git a/docs/architecture/02-middleware.md b/docs/architecture/02-middleware.md index 2504fbe..d1f73ed 100644 --- a/docs/architecture/02-middleware.md +++ b/docs/architecture/02-middleware.md @@ -6,13 +6,13 @@ Middleware intercepts requests before and after the controller method runs. ## Creating a middleware -Extend `Flytachi\Winter\K2\Stereotype\Middleware` and override `before()`, `after()`, or both. +Extend `Flytachi\Winter\Kernel\Http\Stereotype\Middleware` and override `before()`, `after()`, or both. Both methods have default no-op implementations — override only what you need. ```php -use Flytachi\Winter\K2\Stereotype\Middleware; -use Flytachi\Winter\K2\Http\Contracts\{HttpRequest, HttpResponse}; -use Flytachi\Winter\K2\Http\Middleware\MiddlewareException; +use Flytachi\Winter\Kernel\Http\Stereotype\Middleware; +use Flytachi\Winter\Kernel\Http\Contracts\{HttpRequest, HttpResponse}; +use Flytachi\Winter\Kernel\Http\Middleware\MiddlewareException; class AuthMiddleware extends Middleware { @@ -123,7 +123,7 @@ class TimingMiddleware extends Middleware Shorthand for aborting a request with a specific HTTP status from inside middleware: ```php -use Flytachi\Winter\K2\Http\Middleware\MiddlewareException; +use Flytachi\Winter\Kernel\Http\Middleware\MiddlewareException; use Flytachi\Winter\Base\HttpCode; throw new MiddlewareException('Token expired'); // 401 Unauthorized @@ -146,7 +146,7 @@ throw (new MiddlewareException('Rate limited', HttpCode::TOO_MANY_REQUESTS)) Applies the client's IANA timezone to `date_default_timezone_set()` for the duration of the request. The value is read from `HttpRequest::getClientTimezone()`, which parses the `Timezone` or `X-Timezone` header and validates it against `timezone_identifiers_list()`. When no valid timezone is supplied, `before()` falls back to `env('TIME_ZONE', 'UTC')`; `after()` restores the same canonical default. ```php -use Flytachi\Winter\K2\Http\Middleware\ClientTimezoneMiddleware; +use Flytachi\Winter\Kernel\Http\Middleware\ClientTimezoneMiddleware; #[ClientTimezoneMiddleware] class ReportController extends Controller { ... } diff --git a/docs/architecture/03-response.md b/docs/architecture/03-response.md index 0fde628..8b2dc0c 100644 --- a/docs/architecture/03-response.md +++ b/docs/architecture/03-response.md @@ -9,7 +9,7 @@ Router serializes it to the underlying `HttpResponse` — controllers never call `$response->end()` themselves. ```php -use Flytachi\Winter\K2\Http\Response\ResponseEntity; +use Flytachi\Winter\Kernel\Http\Response\ResponseEntity; // Static factory shortcuts return ResponseEntity::ok($data); // 200 @@ -68,9 +68,9 @@ Prefer `ResponseEntity::noContent()` for an explicit 204. `Sendable` is the common contract for all response objects. Implement it for fully custom responses: ```php -use Flytachi\Winter\K2\Http\Response\Sendable; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Response\Sendable; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; class CsvResponse implements Sendable { @@ -101,7 +101,7 @@ File and download responses. All formats set `Content-Encoding: identity` and `C ### Factory methods ```php -use Flytachi\Winter\K2\Http\Response\ResponseFile; +use Flytachi\Winter\Kernel\Http\Response\ResponseFile; // Raw bytes — any MIME type ResponseFile::binary($data, 'report.bin'); @@ -152,7 +152,7 @@ memory-light `fpassthru` stream on FPM). Use it for large files (video, audio, archives, database dumps) where `ResponseFile::file()` would waste worker memory. ```php -use Flytachi\Winter\K2\Http\Response\ResponseStreamFile; +use Flytachi\Winter\Kernel\Http\Response\ResponseStreamFile; return ResponseStreamFile::open('/var/media/video.mp4'); // inline return ResponseStreamFile::open('/var/export/report.pdf')->attachment(); // download @@ -244,7 +244,7 @@ ResponseView::setBasePath(__DIR__ . '/resources/views'); ### Factory methods ```php -use Flytachi\Winter\K2\Http\Response\ResponseView; +use Flytachi\Winter\Kernel\Http\Response\ResponseView; // Render a single template return ResponseView::view('user/profile', ['user' => $user]); diff --git a/docs/architecture/04-request/00-overview.md b/docs/architecture/04-request/00-overview.md index 4593415..7d946d9 100644 --- a/docs/architecture/04-request/00-overview.md +++ b/docs/architecture/04-request/00-overview.md @@ -1,6 +1,6 @@ # Request Parameter Binding — Overview -Winter K2 resolves controller method parameters automatically at request time. +Winter resolves controller method parameters automatically at request time. You declare what you need via PHP type hints and attributes — the framework reads the request, validates the value, casts it to the declared type, and injects it into your method. No manual `$_GET`, `$_POST`, or `json_decode` in controller code. diff --git a/docs/architecture/04-request/01-path-variable.md b/docs/architecture/04-request/01-path-variable.md index b154c99..db2d359 100644 --- a/docs/architecture/04-request/01-path-variable.md +++ b/docs/architecture/04-request/01-path-variable.md @@ -5,7 +5,7 @@ placeholder embedded inside the route URL pattern — it captures a dynamic port the request path and delivers it, cast to the declared PHP type, as a method argument. ```php -use Flytachi\Winter\K2\Http\Request\Annotation\PathVariable; +use Flytachi\Winter\Kernel\Http\Request\Annotation\PathVariable; ``` --- diff --git a/docs/architecture/04-request/02-request-param.md b/docs/architecture/04-request/02-request-param.md index e485303..05b7ab3 100644 --- a/docs/architecture/04-request/02-request-param.md +++ b/docs/architecture/04-request/02-request-param.md @@ -5,7 +5,7 @@ key-value pair that follows the `?` separator in a URL — it is part of the req not part of the path that the router uses to select the route. ```php -use Flytachi\Winter\K2\Http\Request\Annotation\RequestParam; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestParam; ``` --- diff --git a/docs/architecture/04-request/03-request-body.md b/docs/architecture/04-request/03-request-body.md index ee7b58b..d712d98 100644 --- a/docs/architecture/04-request/03-request-body.md +++ b/docs/architecture/04-request/03-request-body.md @@ -4,7 +4,7 @@ Binds the raw request body to a controller method parameter. The format is auto-detected from the `Content-Type` header unless the type is `string`. ```php -use Flytachi\Winter\K2\Http\Request\Annotation\RequestBody; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestBody; ``` --- @@ -154,11 +154,11 @@ declared on DTO constructor parameters after hydration. Failed constraints throw `ValidationException (422)`. ```php -use Flytachi\Winter\K2\Http\Request\Validation\Valid; -use Flytachi\Winter\K2\Http\Request\Validation\Required; -use Flytachi\Winter\K2\Http\Request\Validation\NotBlank; -use Flytachi\Winter\K2\Http\Request\Validation\Min; -use Flytachi\Winter\K2\Http\Request\Validation\Max; +use Flytachi\Winter\Kernel\Http\Request\Validation\Valid; +use Flytachi\Winter\Kernel\Http\Request\Validation\Required; +use Flytachi\Winter\Kernel\Http\Request\Validation\NotBlank; +use Flytachi\Winter\Kernel\Http\Request\Validation\Min; +use Flytachi\Winter\Kernel\Http\Request\Validation\Max; class CreateOrderDto { diff --git a/docs/architecture/04-request/04-request-header.md b/docs/architecture/04-request/04-request-header.md index 2cee855..9273fe5 100644 --- a/docs/architecture/04-request/04-request-header.md +++ b/docs/architecture/04-request/04-request-header.md @@ -5,7 +5,7 @@ are case-insensitive by spec, so `Authorization`, `authorization`, and `AUTHORIZ refer to the same header — the framework handles this transparently. ```php -use Flytachi\Winter\K2\Http\Request\Annotation\RequestHeader; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestHeader; ``` --- diff --git a/docs/architecture/04-request/05-request-file.md b/docs/architecture/04-request/05-request-file.md index 887433c..a959ff6 100644 --- a/docs/architecture/04-request/05-request-file.md +++ b/docs/architecture/04-request/05-request-file.md @@ -5,7 +5,7 @@ controller pre-validated and ready to use — transfer errors, size limits, and type restrictions are all enforced by `ParameterResolver` before the method is called. ```php -use Flytachi\Winter\K2\Http\Request\Annotation\RequestFile; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestFile; ``` --- diff --git a/docs/architecture/04-request/06-request-query.md b/docs/architecture/04-request/06-request-query.md index 87fd2ff..152759e 100644 --- a/docs/architecture/04-request/06-request-query.md +++ b/docs/architecture/04-request/06-request-query.md @@ -4,7 +4,7 @@ Binds the entire query string as a typed DTO or raw array. Use when multiple query parameters form a logical group (filters, pagination, search). ```php -use Flytachi\Winter\K2\Http\Request\Annotation\RequestQuery; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestQuery; ``` --- diff --git a/docs/architecture/04-request/07-request-json-form-xml.md b/docs/architecture/04-request/07-request-json-form-xml.md index 0da5819..c944b97 100644 --- a/docs/architecture/04-request/07-request-json-form-xml.md +++ b/docs/architecture/04-request/07-request-json-form-xml.md @@ -4,9 +4,9 @@ Force a specific body format regardless of the `Content-Type` header. Use when you need strict format enforcement instead of auto-detection. ```php -use Flytachi\Winter\K2\Http\Request\Annotation\RequestJson; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestForm; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestXml; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestJson; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestForm; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestXml; ``` --- diff --git a/docs/architecture/04-request/08-validation.md b/docs/architecture/04-request/08-validation.md index b820119..cd0f158 100644 --- a/docs/architecture/04-request/08-validation.md +++ b/docs/architecture/04-request/08-validation.md @@ -1,11 +1,11 @@ # Request Validation — `#[Valid]` + Constraints -Winter K2 provides an attribute-based validation system for request DTOs. +Winter provides an attribute-based validation system for request DTOs. Constraints are PHP attributes placed on DTO constructor parameters. Validation is triggered by adding `#[Valid]` to the controller method parameter. ```php -use Flytachi\Winter\K2\Http\Request\Validation\Valid; +use Flytachi\Winter\Kernel\Http\Request\Validation\Valid; ``` --- @@ -39,7 +39,7 @@ use Flytachi\Winter\K2\Http\Request\Validation\Valid; ## Usage ```php -use Flytachi\Winter\K2\Http\Request\Validation\{Valid, Required, NotBlank, Min, Max, Email}; +use Flytachi\Winter\Kernel\Http\Request\Validation\{Valid, Required, NotBlank, Min, Max, Email}; class CreateUserDto { @@ -339,7 +339,7 @@ Return `null` to pass, return a string to fail with that message. ## Legacy: `K1ValidationTrait` -`Flytachi\Winter\K2\Http\Request\K1ValidationTrait` is the older, string-rule API. It is kept for backwards compatibility — **prefer the attribute-based system above for any new code**. +`Flytachi\Winter\Kernel\Http\Request\K1ValidationTrait` is the older, string-rule API. It is kept for backwards compatibility — **prefer the attribute-based system above for any new code**. Use it on any DTO of your own; there is no base class to extend. @@ -378,7 +378,7 @@ Key differences from the attribute system: ### Usage ```php -use Flytachi\Winter\K2\Http\Request\K1ValidationTrait; +use Flytachi\Winter\Kernel\Http\Request\K1ValidationTrait; final class CreateUserRequest { diff --git a/docs/architecture/05-localization.md b/docs/architecture/05-localization.md index 3937d89..35e6491 100644 --- a/docs/architecture/05-localization.md +++ b/docs/architecture/05-localization.md @@ -11,7 +11,7 @@ Coroutine-safe: each Swoole coroutine carries its own locale state. In FPM mode **1. Bootstrap (once, in your Boot class):** ```php -use Flytachi\Winter\K2\Localization\Locale; +use Flytachi\Winter\Kernel\Localization\Locale; Locale::setBasePath(__DIR__ . '/lang'); Locale::setDefault('en'); diff --git a/docs/architecture/06-exception.md b/docs/architecture/06-exception.md index cc765b4..4f4c197 100644 --- a/docs/architecture/06-exception.md +++ b/docs/architecture/06-exception.md @@ -29,7 +29,7 @@ Without any custom handler, every exception is handled by `ExceptionResponseBase Throw from anywhere — controllers, services, middleware. The Router catches it and sends an HTTP response. ```php -use Flytachi\Winter\K2\Http\Response\ResponseException; +use Flytachi\Winter\Kernel\Http\Response\ResponseException; use Flytachi\Winter\Base\HttpCode; throw new ResponseException('User not found', HttpCode::NOT_FOUND); @@ -45,7 +45,7 @@ Default code: **400 Bad Request**. Logged at `warning` level. ### `ClientError` — business / domain errors caused by the caller ```php -use Flytachi\Winter\K2\Exception\ClientError; +use Flytachi\Winter\Kernel\Exception\ClientError; throw new ClientError('Email already taken'); ClientError::throw('Email already taken', HttpCode::UNPROCESSABLE_ENTITY); @@ -56,7 +56,7 @@ Default code: **409 Conflict**. Logged at `warning` level. ### `ServerError` — unexpected infrastructure or application failures ```php -use Flytachi\Winter\K2\Exception\ServerError; +use Flytachi\Winter\Kernel\Exception\ServerError; throw new ServerError('Payment gateway timeout'); ServerError::throw('Database connection failed'); @@ -76,7 +76,7 @@ Maps the HTTP code to a log level automatically: | Other | `notice` | ```php -use Flytachi\Winter\K2\Exception\Error; +use Flytachi\Winter\Kernel\Exception\Error; throw new Error('Not implemented', HttpCode::NOT_IMPLEMENTED); Error::throw('Method not allowed', HttpCode::METHOD_NOT_ALLOWED); @@ -89,7 +89,7 @@ Default code: **520** (Unknown Error). Useful when callers do not know whether a Reserved for bugs in the kernel itself (misconfiguration, impossible state): ```php -use Flytachi\Winter\K2\Exception\KernelError; +use Flytachi\Winter\Kernel\Exception\KernelError; throw new KernelError('Router not initialized before handle()'); ``` @@ -109,7 +109,7 @@ See the [Middleware docs](02-middleware.md) for details. Default code: **401**. - Implements `ExceptionLogLevel` → calls `$e->getLogLevel()` — the exception declares its own level - Anything else → `error` (including plain `\RuntimeException`, `\LogicException`, etc.) -All K2 exceptions (`ResponseException`, `ClientError`, `ServerError`, `Error`, `KernelError`, `MiddlewareException`) implement `ExceptionLogLevel`. +All kernel exceptions (`ResponseException`, `ClientError`, `ServerError`, `Error`, `KernelError`, `MiddlewareException`) implement `ExceptionLogLevel`. --- @@ -121,7 +121,7 @@ Create a class that: 3. Carries the `#[AdviceException]` attribute ```php -use Flytachi\Winter\K2\Http\Response\{AdviceException, ExceptionResponseBase}; +use Flytachi\Winter\Kernel\Http\Response\{AdviceException, ExceptionResponseBase}; #[AdviceException(MyDomainException::class)] class MyDomainExceptionHandler extends ExceptionResponseBase diff --git a/docs/concurrent/01-executors.md b/docs/concurrent/01-executors.md index 30ca91b..34e67c1 100644 --- a/docs/concurrent/01-executors.md +++ b/docs/concurrent/01-executors.md @@ -10,7 +10,7 @@ behave correctly under Swoole and under PHP-FPM, and there is no `if (Runtime::isSwoole())` anywhere in your project. ```php -use Flytachi\Winter\K2\Concurrent\Executors; +use Flytachi\Winter\Kernel\Concurrent\Executors; Executors::common()->execute(fn() => $mixpanel->track($userId, 'signup')); ``` @@ -20,7 +20,7 @@ Executors::common()->execute(fn() => $mixpanel->track($userId, 'signup')); ## `Executors` ```php -use Flytachi\Winter\K2\Concurrent\Executors; +use Flytachi\Winter\Kernel\Concurrent\Executors; ``` ### `common(): ExecutorService` diff --git a/docs/concurrent/02-future.md b/docs/concurrent/02-future.md index 42d5936..24a96ff 100644 --- a/docs/concurrent/02-future.md +++ b/docs/concurrent/02-future.md @@ -26,7 +26,7 @@ under FPM — without the future knowing which happened. ## `Future` ```php -use Flytachi\Winter\K2\Concurrent\Future; +use Flytachi\Winter\Kernel\Concurrent\Future; ``` ### `get(?float $timeout = null): mixed` @@ -79,7 +79,7 @@ progress — under Swoole that means cancelling the coroutine. ## `CompletableFuture` ```php -use Flytachi\Winter\K2\Concurrent\CompletableFuture; +use Flytachi\Winter\Kernel\Concurrent\CompletableFuture; ``` Everything `Future` has, plus the ability to settle it yourself. diff --git a/docs/concurrent/04-build.md b/docs/concurrent/04-build.md index e285882..f32f97f 100644 --- a/docs/concurrent/04-build.md +++ b/docs/concurrent/04-build.md @@ -116,9 +116,9 @@ $ php call di async | [ Async methods (2 classes) ] | App\Services\NotificationService ......... [BUILT] | track() → void - | send() → Flytachi\Winter\K2\Concurrent\Future + | send() → Flytachi\Winter\Kernel\Concurrent\Future | App\Services\ReportService ............... [PENDING] - | build() → Flytachi\Winter\K2\Concurrent\Future + | build() → Flytachi\Winter\Kernel\Concurrent\Future ``` `PENDING` means the proxy has not been generated yet; it will be on first use. diff --git a/docs/concurrent/05-pools.md b/docs/concurrent/05-pools.md index 7916f00..6e8f0df 100644 --- a/docs/concurrent/05-pools.md +++ b/docs/concurrent/05-pools.md @@ -12,7 +12,7 @@ rest wait for a slot. It is `Executors.newFixedThreadPool(n)` from Java, adapted to coroutines. ```php -use Flytachi\Winter\K2\Concurrent\Executors; +use Flytachi\Winter\Kernel\Concurrent\Executors; $pool = Executors::newFixedExecutor(5); // at most 5 running at a time @@ -59,7 +59,7 @@ without a line of executor code at the call site. **Register it as a singleton** in your Boot's `providers()`: ```php -use Flytachi\Winter\K2\Concurrent\Executors; +use Flytachi\Winter\Kernel\Concurrent\Executors; protected static function providers(Container $c): void { @@ -121,7 +121,7 @@ need a hard limit, set `queue` and pick a `RejectPolicy` for the moment both the slots and the queue are full: ```php -use Flytachi\Winter\K2\Concurrent\RejectPolicy; +use Flytachi\Winter\Kernel\Concurrent\RejectPolicy; Executors::newFixedExecutor( concurrency: 5, diff --git a/docs/configuration/01-kernel.md b/docs/configuration/01-kernel.md index 96aad9e..74af0a5 100644 --- a/docs/configuration/01-kernel.md +++ b/docs/configuration/01-kernel.md @@ -27,8 +27,8 @@ Override `configure()` only for a non-standard layout — for instance to keep r files outside the project: ```php -use Flytachi\Winter\K2\App\ApplicationArguments; -use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\Kernel\App\ApplicationArguments; +use Flytachi\Winter\Kernel\Kernel; #[EnableWeb] final class Application extends WinterApplication @@ -101,7 +101,7 @@ Kernel::$pathStorageVolatile Use `true` for ephemeral containers (Docker, Kubernetes) where `/tmp` is fast and disposable. Use `false` for long-lived deployments where you want the route cache to persist with the rest of your storage. -`K2\Kernel::init()` passes `isTmpVolatile: false` by default; `KernelConfig::init()` defaults to `true` (the `K2\Kernel` wrapper flips it). Pass it explicitly if you want the other behaviour. +`Kernel::init()` passes `isTmpVolatile: false` by default; `KernelConfig::init()` defaults to `true` (the `Kernel` wrapper flips it). Pass it explicitly if you want the other behaviour. The directory is auto-created (`mkdir 0777 recursive`) on first call. @@ -172,7 +172,7 @@ $now = new DateTime('now', $tz); For applications that want the timezone applied globally for the duration of the request, attach `ClientTimezoneMiddleware` to a controller or method: ```php -use Flytachi\Winter\K2\Http\Middleware\ClientTimezoneMiddleware; +use Flytachi\Winter\Kernel\Http\Middleware\ClientTimezoneMiddleware; #[ClientTimezoneMiddleware] class ReportController extends Controller { ... } @@ -225,7 +225,7 @@ runs as the first step of the request pipeline and snapshots the origin alongsid headers, so these getters need no `HttpRequest` argument: ```php -use Flytachi\Winter\K2\Http\Header; +use Flytachi\Winter\Kernel\Http\Header; Header::getBaseUrl(); // "https://example.com:8443" Header::getScheme(); // "https" diff --git a/docs/configuration/03-cors.md b/docs/configuration/03-cors.md index 1959ae2..229fd32 100644 --- a/docs/configuration/03-cors.md +++ b/docs/configuration/03-cors.md @@ -1,6 +1,6 @@ # CORS -Winter K2 has two layers of CORS configuration, both modelled after Spring's `@CrossOrigin`: +Winter has two layers of CORS configuration, both modelled after Spring's `@CrossOrigin`: 1. **Global** — a `WebConfigurer` the scan finds. Applied to every response, including 404, 405, and error responses. 2. **Per-route** — `#[CrossOrigin(...)]` attribute on a controller class or method. **Overrides** the global config (does not merge with it) for that specific route. @@ -15,8 +15,8 @@ Global CORS is declared by any class extending `WebConfigurerAdapter` — there to override on the application class, the scan finds the configurer wherever it lives: ```php -use Flytachi\Winter\K2\App\Config\CorsRegistry; -use Flytachi\Winter\K2\App\Config\WebConfigurerAdapter; +use Flytachi\Winter\Kernel\App\Config\CorsRegistry; +use Flytachi\Winter\Kernel\App\Config\WebConfigurerAdapter; final class WebConfig extends WebConfigurerAdapter { @@ -84,9 +84,9 @@ Application code never sees an `OPTIONS` request that has a registered handler `#[CrossOrigin]` overrides the global config for the routes it covers. Method-level wins over class-level; class-level wins over global. ```php -use Flytachi\Winter\K2\Route\Annotation\CrossOrigin; -use Flytachi\Winter\K2\Route\Annotation\{RequestMapping, GetMapping}; -use Flytachi\Winter\K2\Stereotype\Controller; +use Flytachi\Winter\Kernel\Route\Annotation\CrossOrigin; +use Flytachi\Winter\Kernel\Route\Annotation\{RequestMapping, GetMapping}; +use Flytachi\Winter\Kernel\Http\Stereotype\Controller; #[RequestMapping('admin')] #[CrossOrigin( diff --git a/docs/configuration/04-health.md b/docs/configuration/04-health.md index dcf4d1f..1699509 100644 --- a/docs/configuration/04-health.md +++ b/docs/configuration/04-health.md @@ -15,8 +15,8 @@ The endpoints are useful for: Override `health()` in your `Boot` class and call `Health::configure()`: ```php -use Flytachi\Winter\K2\App\Attribute\EnableActuator; -use Flytachi\Winter\K2\WinterApplication; +use Flytachi\Winter\Kernel\App\Attribute\EnableActuator; +use Flytachi\Winter\Kernel\WinterApplication; #[EnableWeb] #[EnableActuator] // default built-in indicator, open access @@ -158,7 +158,7 @@ Subclass `HealthIndicator` to add custom checks without losing the built-in disk ```php namespace App\Health; -use Flytachi\Winter\K2\Http\Health\HealthIndicator; +use Flytachi\Winter\Kernel\Http\Health\HealthIndicator; final class AppHealthIndicator extends HealthIndicator { @@ -215,9 +215,9 @@ Production deployments usually want `/actuator/*` reachable only from inside the ```php namespace App\Http\Middleware; -use Flytachi\Winter\K2\Stereotype\Middleware; -use Flytachi\Winter\K2\Http\Contracts\{HttpRequest, HttpResponse}; -use Flytachi\Winter\K2\Http\Middleware\MiddlewareException; +use Flytachi\Winter\Kernel\Http\Stereotype\Middleware; +use Flytachi\Winter\Kernel\Http\Contracts\{HttpRequest, HttpResponse}; +use Flytachi\Winter\Kernel\Http\Middleware\MiddlewareException; use Flytachi\Winter\Base\HttpCode; final class InternalOnlyMiddleware extends Middleware diff --git a/docs/configuration/05-plugins.md b/docs/configuration/05-plugins.md index f1c34df..e32ff04 100644 --- a/docs/configuration/05-plugins.md +++ b/docs/configuration/05-plugins.md @@ -15,8 +15,8 @@ After that, every `#[Controller]` discovered under `vendor/acme/billing-plugin/s Override `plugins()` in your `Boot` class: ```php -use Flytachi\Winter\K2\App\Attribute\Import; -use Flytachi\Winter\K2\WinterApplication; +use Flytachi\Winter\Kernel\App\Attribute\Import; +use Flytachi\Winter\Kernel\WinterApplication; #[EnableWeb] #[Import('acme/auth-plugin', '/auth')] diff --git a/docs/configuration/06-db.md b/docs/configuration/06-db.md index 1c28a6b..7b20eb6 100644 --- a/docs/configuration/06-db.md +++ b/docs/configuration/06-db.md @@ -61,9 +61,9 @@ call db sql -e # preview extension statements only `db sql` and `db migrate` are opt-in at the **DbConfig** level. A `DbConfig` class participates in migrations only if it carries `#[Migratable]`. This is intentional — `DbConfig` classes that point at legacy databases, read replicas, or vendor schemas are excluded from migration tooling by default. ```php -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Migratable; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Extension; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\MigratablePriority; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Migratable; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Extension; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\MigratablePriority; #[Migratable] // default priority: Normal #[Extension('uuid-ossp')] diff --git a/docs/configuration/07-di.md b/docs/configuration/07-di.md index 85f4724..945dd9a 100644 --- a/docs/configuration/07-di.md +++ b/docs/configuration/07-di.md @@ -163,7 +163,7 @@ you rarely call it directly, but it's worth knowing where injection happens: | Site | How dependencies are supplied | |------|-------------------------------| -| Controllers | Resolved through the container before the route method runs. Dependencies arrive as `#[Autowired]` properties — `Stereotype\Controller` declares a `final` constructor, so constructor injection is not available (declaring one is a fatal error). | +| Controllers | Resolved through the container before the route method runs. Dependencies arrive as `#[Autowired]` properties — `Http\Stereotype\Controller` declares a `final` constructor, so constructor injection is not available (declaring one is a fatal error). | | Middleware | Same container; `#[Autowired]` fields populated. | | Processes / Daemons | `Container::make(static::class)` builds a **fresh** DI instance inside the child process. | | Console commands | Resolved via the container when dispatched. | diff --git a/docs/configuration/08-runtime.md b/docs/configuration/08-runtime.md index f0a449e..3835164 100644 --- a/docs/configuration/08-runtime.md +++ b/docs/configuration/08-runtime.md @@ -21,7 +21,7 @@ Every internal — `Router`, `ParameterResolver`, middleware, controllers — de interfaces, never on a concrete transport: ```php -namespace Flytachi\Winter\K2\Http\Contracts; +namespace Flytachi\Winter\Kernel\Http\Contracts; interface HttpRequest { /* getMethod(), getUri(), getHeader(), getRawBody(), … */ } interface HttpResponse { /* status(), header(), end(), sendfile() */ } diff --git a/docs/file/00-overview.md b/docs/file/00-overview.md index 036b63a..3cc2066 100644 --- a/docs/file/00-overview.md +++ b/docs/file/00-overview.md @@ -4,7 +4,7 @@ Three static helper classes for moving plain arrays in and out of the most common flat-file formats. No instances, no state — call the static methods directly. On any I/O or parse problem they throw `FileException`. -Namespace: `Flytachi\Winter\K2\File`. +Namespace: `Flytachi\Winter\Kernel\File`. | Class | Reads | Writes | Extra | |--------|---------------------|---------------------|-------| @@ -35,7 +35,7 @@ public static function write(string $path, array $data, ?array $head = null): vo associative rows keyed by that header: ```php -use Flytachi\Winter\K2\File\CSV; +use Flytachi\Winter\Kernel\File\CSV; // users.csv: // id,name @@ -81,7 +81,7 @@ public static function write(string $path, array $data): void ``` ```php -use Flytachi\Winter\K2\File\JSON; +use Flytachi\Winter\Kernel\File\JSON; $config = JSON::read('config.json'); // decoded as an associative array JSON::write('config.json', $config); // written with JSON_PRETTY_PRINT @@ -104,7 +104,7 @@ public static function isAvailable(): bool ``` ```php -use Flytachi\Winter\K2\File\XML; +use Flytachi\Winter\Kernel\File\XML; $tree = XML::read('feed.xml'); // element tree → nested array XML::write('feed.xml', $tree, 'feed'); // array → @@ -135,11 +135,11 @@ if (XML::isAvailable()) { ## Errors -Every failure path raises `Flytachi\Winter\K2\File\FileException`: +Every failure path raises `Flytachi\Winter\Kernel\File\FileException`: ```php -use Flytachi\Winter\K2\File\CSV; -use Flytachi\Winter\K2\File\FileException; +use Flytachi\Winter\Kernel\File\CSV; +use Flytachi\Winter\Kernel\File\FileException; try { $rows = CSV::read('missing.csv'); diff --git a/docs/pagination/00-overview.md b/docs/pagination/00-overview.md index 2025b71..94b8cf6 100644 --- a/docs/pagination/00-overview.md +++ b/docs/pagination/00-overview.md @@ -64,7 +64,7 @@ Wrapper (final, static) — page-centric, for numbere ## Quick start ```php -use Flytachi\Winter\K2\Unit\Pagination\Paginator; +use Flytachi\Winter\Kernel\Unit\Pagination\Paginator; // 1. Repository-backed offset pagination (most common) $result = Paginator::repo( diff --git a/docs/pagination/04-result-types.md b/docs/pagination/04-result-types.md index f98cf1b..111daae 100644 --- a/docs/pagination/04-result-types.md +++ b/docs/pagination/04-result-types.md @@ -150,7 +150,7 @@ See [03-cursor.md](03-cursor.md). ## Example — full round trip ```php -use Flytachi\Winter\K2\Unit\Pagination\Paginator; +use Flytachi\Winter\Kernel\Unit\Pagination\Paginator; // Controller public function index(Request $req): JsonResponse @@ -205,7 +205,7 @@ Generic over ``: */ ``` -Returned by {@see \Flytachi\Winter\K2\Unit\Wrapper::paginator()}. Pairs with +Returned by {@see \Flytachi\Winter\Kernel\Unit\Wrapper::paginator()}. Pairs with {@see WrapMeta}. See [05-wrapper.md](05-wrapper.md) for the full Wrapper contract. diff --git a/docs/ppa/01-stereotypes.md b/docs/ppa/01-stereotypes.md index 1ef644e..d74aff8 100644 --- a/docs/ppa/01-stereotypes.md +++ b/docs/ppa/01-stereotypes.md @@ -19,7 +19,7 @@ Each one wires together `RepositoryCore` with the appropriate traits. ## Repository — Full Access ```php -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; class UserRepository extends Repository { @@ -56,7 +56,7 @@ $repo->update(['status' => 'inactive'], Qb::lt('last_login', '2024-01-01')); ## RepositoryView — Read-Only ```php -use Flytachi\Winter\K2\Ppa\Stereotype\RepositoryView; +use Flytachi\Winter\Kernel\Ppa\Stereotype\RepositoryView; class ReportRepository extends RepositoryView { @@ -73,7 +73,7 @@ Ideal for database views or projections where writes are not allowed. ## RepositoryCrud — Write-Only ```php -use Flytachi\Winter\K2\Ppa\Stereotype\RepositoryCrud; +use Flytachi\Winter\Kernel\Ppa\Stereotype\RepositoryCrud; class EventLogRepository extends RepositoryCrud { @@ -95,7 +95,7 @@ not require subclassing — use it for one-off queries that don't belong to a dedicated repository. ```php -use Flytachi\Winter\K2\Ppa\Stereotype\CteRepo; +use Flytachi\Winter\Kernel\Ppa\Stereotype\CteRepo; // Ad-hoc query against any table: $repo = new CteRepo(DbConfig::class); diff --git a/docs/ppa/02-configuration.md b/docs/ppa/02-configuration.md index 3de1cc3..c78c947 100644 --- a/docs/ppa/02-configuration.md +++ b/docs/ppa/02-configuration.md @@ -111,7 +111,7 @@ property. Return a `[propertyName => 'sql_expression']` array from `selection()` Properties absent from the map are selected by plain name (with alias prefix if set). ```php -use Flytachi\Winter\K2\Ppa\Entity\EntityInterface; +use Flytachi\Winter\Kernel\Ppa\Entity\EntityInterface; class UserEntity implements EntityInterface { @@ -190,8 +190,8 @@ every repository pointing at it) is **silently skipped** by `db sql` and `db migrate` — even if entities have `#[Table]`. ```php -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Migratable; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\MigratablePriority; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Migratable; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\MigratablePriority; #[Migratable] // priority: Normal final class AppDbConfig extends PgDbConfig { /* … */ } @@ -206,7 +206,7 @@ final class AuthDbConfig extends PgDbConfig { /* … */ } ignored on mysql/mariadb at the SQL-generation step. ```php -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Extension; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Extension; #[Migratable] #[Extension('uuid-ossp')] diff --git a/docs/ppa/13-static-finders.md b/docs/ppa/13-static-finders.md index 90ee028..cddadd4 100644 --- a/docs/ppa/13-static-finders.md +++ b/docs/ppa/13-static-finders.md @@ -143,11 +143,11 @@ $user = UserRepository::findByOrThrow( ## EntityException `findByIdOrThrow()` and `findByOrThrow()` throw -`Flytachi\Winter\K2\Ppa\Entity\EntityException` on miss. +`Flytachi\Winter\Kernel\Ppa\Entity\EntityException` on miss. It extends the Winter framework exception and is logged at `WARNING` level. ```php -use Flytachi\Winter\K2\Ppa\Entity\EntityException; +use Flytachi\Winter\Kernel\Ppa\Entity\EntityException; try { $user = UserRepository::findByIdOrThrow($id); diff --git a/docs/ppa/16-advanced-examples.md b/docs/ppa/16-advanced-examples.md index c8db48c..1ff0e23 100644 --- a/docs/ppa/16-advanced-examples.md +++ b/docs/ppa/16-advanced-examples.md @@ -220,7 +220,7 @@ $repo->upsertGroup( ## 9. Ad-hoc query with CteRepo ```php -use Flytachi\Winter\K2\Ppa\Stereotype\CteRepo; +use Flytachi\Winter\Kernel\Ppa\Stereotype\CteRepo; // Raw aggregation across multiple tables — no dedicated repository needed. $stats = (new CteRepo(AppDbConfig::class)) diff --git a/docs/ppa/17-pool.md b/docs/ppa/17-pool.md index ca4a592..4501571 100644 --- a/docs/ppa/17-pool.md +++ b/docs/ppa/17-pool.md @@ -42,8 +42,8 @@ implement `PpaPoolConfigInterface` via `PpaPoolTrait`: ```php use Flytachi\Winter\Cdo\Config\PgDbConfig; -use Flytachi\Winter\K2\Ppa\Pool\PpaPoolConfigInterface; -use Flytachi\Winter\K2\Ppa\Pool\PpaPoolTrait; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaPoolConfigInterface; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaPoolTrait; class AppDb extends PgDbConfig implements PpaPoolConfigInterface { diff --git a/docs/ppa/18-migration.md b/docs/ppa/18-migration.md index 84a729d..120deaa 100644 --- a/docs/ppa/18-migration.md +++ b/docs/ppa/18-migration.md @@ -61,7 +61,7 @@ migration scan silently. under `Kernel::$pathRoot` (or under a registered plugin's root). The scanner only walks paths it's been pointed at. 2. **The entity class must carry `#[Table]`** — the marker attribute - from `Flytachi\Winter\K2\Ppa\Mapping\Attributes\Entity\Table`. Without + from `Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Entity\Table`. Without it `PPAMapping::scanDeclarationFilter()` short-circuits and the repo is ignored. 3. **The DbConfig class must carry `#[Migratable]`** — without it @@ -69,9 +69,9 @@ migration scan silently. filters the item out before any SQL is emitted. ```php -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Migratable; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Entity\Table; -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Migratable; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Entity\Table; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; // (1) discoverable — lives under your app's pathRoot @@ -95,7 +95,7 @@ final class UserRepository extends Repository ## `#[Migratable]` — opting a DbConfig into migration -`Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Migratable` +`Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Migratable` | Parameter | Type | Default | Effect | |---|---|---|---| @@ -132,7 +132,7 @@ Within the same priority, items run in the order returned by ## `#[Extension]` — PostgreSQL extension declarations -`Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Extension` +`Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Extension` PostgreSQL-only. Stack the attribute multiple times on the same config: diff --git a/docs/schedule/01-usage.md b/docs/schedule/01-usage.md index 8497b0c..9d53245 100644 --- a/docs/schedule/01-usage.md +++ b/docs/schedule/01-usage.md @@ -7,7 +7,7 @@ Three steps, no wiring. **1. Annotate a method** on any class the container can build: ```php -use Flytachi\Winter\K2\Schedule\Scheduled; +use Flytachi\Winter\Kernel\Schedule\Scheduled; use Psr\Log\LoggerInterface; class ReportService @@ -47,7 +47,7 @@ model, and running it in production. method may carry several triggers. ```php -use Flytachi\Winter\K2\Schedule\Scheduled; +use Flytachi\Winter\Kernel\Schedule\Scheduled; #[Scheduled(fixedDelay: 5.0)] public function flush(): void { /* ... */ } @@ -223,7 +223,7 @@ runs and API-triggered runs share one bounded set of workers. Register the pool once (a fixed-size executor) in your Boot's `providers()`: ```php -use Flytachi\Winter\K2\Concurrent\Executors; +use Flytachi\Winter\Kernel\Concurrent\Executors; protected static function providers(Container $c): void { @@ -267,7 +267,7 @@ The pool enforces its cap only under Swoole (coroutines). Without coroutines the size is a no-op. For cost control set a bounded queue and a reject policy: ```php -use Flytachi\Winter\K2\Concurrent\RejectPolicy; +use Flytachi\Winter\Kernel\Concurrent\RejectPolicy; $c->singleton('mailPool', fn() => Executors::newFixedExecutor( concurrency: 5, queue: 50, onReject: RejectPolicy::DISCARD, diff --git a/docs/starter/00-quickstart.md b/docs/starter/00-quickstart.md index 9755706..c4e847e 100644 --- a/docs/starter/00-quickstart.md +++ b/docs/starter/00-quickstart.md @@ -63,9 +63,9 @@ declares the application: tests/Route tests/Route/Fixtures + + tests/Architecture + + + tests/Console + diff --git a/src/App/ApplicationArguments.php b/src/App/ApplicationArguments.php index b2f63e2..44ed38b 100644 --- a/src/App/ApplicationArguments.php +++ b/src/App/ApplicationArguments.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\App; +namespace Flytachi\Winter\Kernel\App; /** - * Parsed CLI arguments passed to {@see \Flytachi\Winter\K2\WinterApplication::run()}. + * Parsed CLI arguments passed to {@see \Flytachi\Winter\Kernel\WinterApplication::run()}. * * Splits a raw `$argv` into three buckets: * - positionals — bare words: `command` (argv[1]) and `sub` (argv[2]); diff --git a/src/App/ApplicationConfigException.php b/src/App/ApplicationConfigException.php index dd191c9..cb9c5dd 100644 --- a/src/App/ApplicationConfigException.php +++ b/src/App/ApplicationConfigException.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\App; +namespace Flytachi\Winter\Kernel\App; /** - * Thrown when {@see \Flytachi\Winter\K2\Application::components()} is malformed — + * Thrown when {@see \Flytachi\Winter\Kernel\Application::components()} is malformed — * a non-{@see Component} entry, a missing Http host for a bundle, or a component * kind the current runtime cannot host. */ diff --git a/src/App/Attribute/Bean.php b/src/App/Attribute/Bean.php index 1894c95..2cda941 100644 --- a/src/App/Attribute/Bean.php +++ b/src/App/Attribute/Bean.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\App\Attribute; +namespace Flytachi\Winter\Kernel\App\Attribute; -use Flytachi\Winter\K2\App\Scope; +use Flytachi\Winter\Kernel\App\Scope; /** * Marks a method of a {@see Configuration} class as a container factory — the diff --git a/src/App/Attribute/Configuration.php b/src/App/Attribute/Configuration.php index d8d0231..a5368ed 100644 --- a/src/App/Attribute/Configuration.php +++ b/src/App/Attribute/Configuration.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\App\Attribute; +namespace Flytachi\Winter\Kernel\App\Attribute; /** * Marks a class as a bean container — the analogue of Spring's @Configuration. diff --git a/src/App/Attribute/EnableActuator.php b/src/App/Attribute/EnableActuator.php index 254a2f2..0a19165 100644 --- a/src/App/Attribute/EnableActuator.php +++ b/src/App/Attribute/EnableActuator.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\App\Attribute; +namespace Flytachi\Winter\Kernel\App\Attribute; -use Flytachi\Winter\K2\Http\Health\HealthIndicatorInterface; -use Flytachi\Winter\K2\Stereotype\Middleware; +use Flytachi\Winter\Kernel\Http\Health\HealthIndicatorInterface; +use Flytachi\Winter\Kernel\Http\Stereotype\Middleware; /** * Enables the `/actuator/*` diagnostic endpoints — the winter analogue of Spring - * Boot Actuator. Declared on the {@see \Flytachi\Winter\K2\WinterApplication} class. + * Boot Actuator. Declared on the {@see \Flytachi\Winter\Kernel\WinterApplication} class. * * Without it the actuator is off. With it, the endpoints are registered and every - * discovered {@see \Flytachi\Winter\K2\Http\Health\HealthContributor} is merged into + * discovered {@see \Flytachi\Winter\Kernel\Http\Health\HealthContributor} is merged into * `/actuator/health`. * * ``` diff --git a/src/App/Attribute/EnableAsync.php b/src/App/Attribute/EnableAsync.php index b575ccb..cb69349 100644 --- a/src/App/Attribute/EnableAsync.php +++ b/src/App/Attribute/EnableAsync.php @@ -2,18 +2,18 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\App\Attribute; +namespace Flytachi\Winter\Kernel\App\Attribute; /** * Enables #[Async] proxying — the analogue of Spring's `@EnableAsync`. Declared on - * the {@see \Flytachi\Winter\K2\WinterApplication} class. + * the {@see \Flytachi\Winter\Kernel\WinterApplication} class. * * Without it the async collector is never wired: classes carrying #[Async] are not * proxied and their methods run synchronously (exactly like Spring without * `@EnableAsync`). With it, the proxies are generated during the boot scan. * * This is the one #[Enable*] attribute that changes the boot sequence rather than - * contributing a {@see \Flytachi\Winter\K2\App\Component}. + * contributing a {@see \Flytachi\Winter\Kernel\App\Component}. * * ``` * #[EnableAsync] diff --git a/src/App/Attribute/EnableDaemon.php b/src/App/Attribute/EnableDaemon.php index 56c3d48..87d7449 100644 --- a/src/App/Attribute/EnableDaemon.php +++ b/src/App/Attribute/EnableDaemon.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\App\Attribute; +namespace Flytachi\Winter\Kernel\App\Attribute; -use Flytachi\Winter\K2\Process\Daemon\Daemon; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; /** * Declares a supervised {@see Daemon} fleet as part of the application — produces - * one {@see \Flytachi\Winter\K2\App\Component::daemon()} in the manifest. Declared - * on the {@see \Flytachi\Winter\K2\WinterApplication} class; repeatable. + * one {@see \Flytachi\Winter\Kernel\App\Component::daemon()} in the manifest. Declared + * on the {@see \Flytachi\Winter\Kernel\WinterApplication} class; repeatable. * * ``` * #[EnableDaemon(Emails::class)] diff --git a/src/App/Attribute/EnableProcess.php b/src/App/Attribute/EnableProcess.php index 5503953..b8f221f 100644 --- a/src/App/Attribute/EnableProcess.php +++ b/src/App/Attribute/EnableProcess.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\App\Attribute; +namespace Flytachi\Winter\Kernel\App\Attribute; -use Flytachi\Winter\K2\Process\Process; +use Flytachi\Winter\Kernel\Process\Stereotype\Process; /** * Declares a single managed {@see Process} worker as part of the application — - * produces one {@see \Flytachi\Winter\K2\App\Component::process()} in the manifest. - * Declared on the {@see \Flytachi\Winter\K2\WinterApplication} class; repeatable. + * produces one {@see \Flytachi\Winter\Kernel\App\Component::process()} in the manifest. + * Declared on the {@see \Flytachi\Winter\Kernel\WinterApplication} class; repeatable. * * ``` * #[EnableProcess(SnmpProc::class)] diff --git a/src/App/Attribute/EnableScheduler.php b/src/App/Attribute/EnableScheduler.php index d5d48ba..81f14af 100644 --- a/src/App/Attribute/EnableScheduler.php +++ b/src/App/Attribute/EnableScheduler.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\App\Attribute; +namespace Flytachi\Winter\Kernel\App\Attribute; -use Flytachi\Winter\K2\Schedule\Scheduler; +use Flytachi\Winter\Kernel\Schedule\Stereotype\Scheduler; /** * Enables the scheduler that runs #[Scheduled] tasks — the analogue of Spring's - * `@EnableScheduling`. Declared on the {@see \Flytachi\Winter\K2\WinterApplication} - * class; produces one {@see \Flytachi\Winter\K2\App\Component::scheduler()} in the + * `@EnableScheduling`. Declared on the {@see \Flytachi\Winter\Kernel\WinterApplication} + * class; produces one {@see \Flytachi\Winter\Kernel\App\Component::scheduler()} in the * manifest. * * ``` diff --git a/src/App/Attribute/EnableWeb.php b/src/App/Attribute/EnableWeb.php index 6ebc552..c04ef49 100644 --- a/src/App/Attribute/EnableWeb.php +++ b/src/App/Attribute/EnableWeb.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\App\Attribute; +namespace Flytachi\Winter\Kernel\App\Attribute; /** * Enables the HTTP web tier — the analogue of Spring's `@EnableWebMvc`. Declared - * on the {@see \Flytachi\Winter\K2\WinterApplication} class; produces one - * {@see \Flytachi\Winter\K2\App\Component::http()} in the manifest. + * on the {@see \Flytachi\Winter\Kernel\WinterApplication} class; produces one + * {@see \Flytachi\Winter\Kernel\App\Component::http()} in the manifest. * * A pure capability toggle: it carries no host/port. The bind address is * deployment configuration, not a property of the application, so it lives in - * `.env` / a {@see \Flytachi\Winter\K2\App\Config\WebConfigurer} and defaults to + * `.env` / a {@see \Flytachi\Winter\Kernel\App\Config\WebConfigurer} and defaults to * `--host`/`--port` (fallback `0.0.0.0:8000`). * * ``` diff --git a/src/App/Attribute/Import.php b/src/App/Attribute/Import.php index 8a10634..78036fc 100644 --- a/src/App/Attribute/Import.php +++ b/src/App/Attribute/Import.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\App\Attribute; +namespace Flytachi\Winter\Kernel\App\Attribute; /** * Imports a Composer package as a route-prefixed sub-application — the analogue - * of Spring's @Import. Declared on the {@see \Flytachi\Winter\K2\WinterApplication} + * of Spring's @Import. Declared on the {@see \Flytachi\Winter\Kernel\WinterApplication} * class; repeatable. * * The package's install path is resolved via Composer and its `src/` is scanned diff --git a/src/App/Attribute/Value.php b/src/App/Attribute/Value.php index 7bcc2cf..54b7612 100644 --- a/src/App/Attribute/Value.php +++ b/src/App/Attribute/Value.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\App\Attribute; +namespace Flytachi\Winter\Kernel\App\Attribute; /** * Injects a value read from the environment (.env) into a {@see Bean} method diff --git a/src/App/Banner.php b/src/App/Banner.php index 871bb61..8e59f42 100644 --- a/src/App/Banner.php +++ b/src/App/Banner.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\App; +namespace Flytachi\Winter\Kernel\App; use Composer\InstalledVersions; /** * The Winter startup banner — a Spring-Boot-style splash printed to the terminal - * when the application comes up ({@see \Flytachi\Winter\K2\WinterApplication::serve()}). + * when the application comes up ({@see \Flytachi\Winter\Kernel\WinterApplication::serve()}). * * It is human-facing: it writes ANSI to STDOUT and is shown only on an interactive * terminal. The structured "application up" line still goes to the log channel, so diff --git a/src/App/Component.php b/src/App/Component.php index 145dbff..c19bad0 100644 --- a/src/App/Component.php +++ b/src/App/Component.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\App; +namespace Flytachi\Winter\Kernel\App; -use Flytachi\Winter\K2\Schedule\Scheduler; +use Flytachi\Winter\Kernel\Schedule\Stereotype\Scheduler; /** - * A declared unit of a {@see \Flytachi\Winter\K2\WinterApplication} — the "what my app + * A declared unit of a {@see \Flytachi\Winter\Kernel\WinterApplication} — the "what my app * contains" manifest, in the spirit of a Spring bean / @Enable* switch. * * Build entries with the named factories, never the constructor: @@ -61,9 +61,9 @@ public static function websocket(string $path, string $handler): self } /** - * A single managed {@see \Flytachi\Winter\K2\Process\Process} worker. + * A single managed {@see \Flytachi\Winter\Kernel\Process\Stereotype\Process} worker. * - * @param class-string<\Flytachi\Winter\K2\Process\Process> $class + * @param class-string<\Flytachi\Winter\Kernel\Process\Stereotype\Process> $class */ public static function process(string $class): self { @@ -71,9 +71,9 @@ public static function process(string $class): self } /** - * A supervised {@see \Flytachi\Winter\K2\Process\Daemon\Daemon} fleet. + * A supervised {@see \Flytachi\Winter\Kernel\Process\Stereotype\Daemon} fleet. * - * @param class-string<\Flytachi\Winter\K2\Process\Daemon\Daemon> $class + * @param class-string<\Flytachi\Winter\Kernel\Process\Stereotype\Daemon> $class */ public static function daemon(string $class): self { diff --git a/src/App/ComponentKind.php b/src/App/ComponentKind.php index 070a0dd..a3deb7d 100644 --- a/src/App/ComponentKind.php +++ b/src/App/ComponentKind.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\App; +namespace Flytachi\Winter\Kernel\App; /** - * The kind of a long-lived {@see Component} a {@see \Flytachi\Winter\K2\WinterApplication} + * The kind of a long-lived {@see Component} a {@see \Flytachi\Winter\Kernel\WinterApplication} * hosts. Http is the main server; the rest are supervised companions. */ enum ComponentKind diff --git a/src/App/Config/ChannelRegistry.php b/src/App/Config/ChannelRegistry.php index 0c09fe7..68f3b2f 100644 --- a/src/App/Config/ChannelRegistry.php +++ b/src/App/Config/ChannelRegistry.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\App\Config; +namespace Flytachi\Winter\Kernel\App\Config; -use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\Kernel\Kernel; /** * Fluent handle passed to {@see LoggingConfigurer::configureChannels()}. Declares diff --git a/src/App/Config/CorsRegistry.php b/src/App/Config/CorsRegistry.php index 9e78971..ee684a6 100644 --- a/src/App/Config/CorsRegistry.php +++ b/src/App/Config/CorsRegistry.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\App\Config; +namespace Flytachi\Winter\Kernel\App\Config; -use Flytachi\Winter\K2\Http\Cors; +use Flytachi\Winter\Kernel\Http\Cors; /** * Fluent builder handed to {@see WebConfigurer::configureCors()}. Collects the diff --git a/src/App/Config/LoggingConfigurer.php b/src/App/Config/LoggingConfigurer.php index 857475c..18acde9 100644 --- a/src/App/Config/LoggingConfigurer.php +++ b/src/App/Config/LoggingConfigurer.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\App\Config; +namespace Flytachi\Winter\Kernel\App\Config; /** * Logging configuration contract — declares extra log channels in code (the rare diff --git a/src/App/Config/ServerSettings.php b/src/App/Config/ServerSettings.php index 67ae25e..2a38216 100644 --- a/src/App/Config/ServerSettings.php +++ b/src/App/Config/ServerSettings.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\App\Config; +namespace Flytachi\Winter\Kernel\App\Config; -use Flytachi\Winter\K2\App\ApplicationConfigException; -use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\Kernel\App\ApplicationConfigException; +use Flytachi\Winter\Kernel\Kernel; /** * Fluent builder for the Swoole HTTP server options — the replacement for the old diff --git a/src/App/Config/WebConfigurer.php b/src/App/Config/WebConfigurer.php index 68e6424..dc0221d 100644 --- a/src/App/Config/WebConfigurer.php +++ b/src/App/Config/WebConfigurer.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\App\Config; +namespace Flytachi\Winter\Kernel\App\Config; -use Flytachi\Winter\K2\App\ApplicationArguments; +use Flytachi\Winter\Kernel\App\ApplicationArguments; /** * Web-tier configuration contract — the winter analogue of Spring's diff --git a/src/App/Config/WebConfigurerAdapter.php b/src/App/Config/WebConfigurerAdapter.php index f58fb9e..a5a629d 100644 --- a/src/App/Config/WebConfigurerAdapter.php +++ b/src/App/Config/WebConfigurerAdapter.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\App\Config; +namespace Flytachi\Winter\Kernel\App\Config; -use Flytachi\Winter\K2\App\ApplicationArguments; +use Flytachi\Winter\Kernel\App\ApplicationArguments; /** * Empty-default base for {@see WebConfigurer} — the winter analogue of Spring's diff --git a/src/App/Scope.php b/src/App/Scope.php index e2cad89..28ed0e1 100644 --- a/src/App/Scope.php +++ b/src/App/Scope.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\App; +namespace Flytachi\Winter\Kernel\App; /** * Lifetime of a {@see Attribute\Bean} in the container. diff --git a/src/Collector/ConfigurationCollector.php b/src/Collector/ConfigurationCollector.php index 459d204..29850d9 100644 --- a/src/Collector/ConfigurationCollector.php +++ b/src/Collector/ConfigurationCollector.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Collector; +namespace Flytachi\Winter\Kernel\Collector; use Flytachi\Winter\DI\Container; use Flytachi\Winter\DI\Contract\CollectorInterface; -use Flytachi\Winter\K2\App\Attribute\Bean; -use Flytachi\Winter\K2\App\Attribute\Configuration; -use Flytachi\Winter\K2\App\Attribute\Value; -use Flytachi\Winter\K2\App\Scope; +use Flytachi\Winter\Kernel\App\Attribute\Bean; +use Flytachi\Winter\Kernel\App\Attribute\Configuration; +use Flytachi\Winter\Kernel\App\Attribute\Value; +use Flytachi\Winter\Kernel\App\Scope; use ReflectionClass; use ReflectionMethod; use ReflectionNamedType; diff --git a/src/Collector/ImplementorCollector.php b/src/Collector/ImplementorCollector.php index 0bb33ce..533a1bc 100644 --- a/src/Collector/ImplementorCollector.php +++ b/src/Collector/ImplementorCollector.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Collector; +namespace Flytachi\Winter\Kernel\Collector; use Flytachi\Winter\DI\Contract\CollectorInterface; use ReflectionClass; diff --git a/src/Collector/SubclassCollector.php b/src/Collector/SubclassCollector.php index e9e5f4f..4b93b54 100644 --- a/src/Collector/SubclassCollector.php +++ b/src/Collector/SubclassCollector.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Collector; +namespace Flytachi\Winter\Kernel\Collector; use Flytachi\Winter\DI\Contract\CollectorInterface; use ReflectionClass; diff --git a/src/Concurrent/Async/Async.php b/src/Concurrent/Async/Async.php index f775ffd..9259143 100644 --- a/src/Concurrent/Async/Async.php +++ b/src/Concurrent/Async/Async.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Concurrent\Async; +namespace Flytachi\Winter\Kernel\Concurrent\Async; /** * Marks a method to be executed asynchronously. * * Mirrors Spring's `Async`. The call returns immediately; the body runs on an - * {@see \Flytachi\Winter\K2\Concurrent\ExecutorService} — a coroutine under + * {@see \Flytachi\Winter\Kernel\Concurrent\ExecutorService} — a coroutine under * Swoole, a deferred task under FPM. * * The framework replaces the container binding of the declaring class with a @@ -23,7 +23,7 @@ * not `static` and not `final`; * - the declaring class is not `final`; * - the return type is `Future` or `void`; - * - a `Future`-returning body returns {@see \Flytachi\Winter\K2\Concurrent\CompletableFuture::completedFuture()}; + * - a `Future`-returning body returns {@see \Flytachi\Winter\Kernel\Concurrent\CompletableFuture::completedFuture()}; * - parameters are not passed by reference — a background task cannot write back. * * Violations are reported when proxies are generated, not at runtime. @@ -61,7 +61,7 @@ * result is still correct; only the "runs later" intuition does not hold for * purely computational bodies. * - * @see \Flytachi\Winter\K2\Concurrent\Future + * @see \Flytachi\Winter\Kernel\Concurrent\Future */ #[\Attribute(\Attribute::TARGET_METHOD)] final class Async diff --git a/src/Concurrent/Async/AsyncCollector.php b/src/Concurrent/Async/AsyncCollector.php index 8623eb6..427015e 100644 --- a/src/Concurrent/Async/AsyncCollector.php +++ b/src/Concurrent/Async/AsyncCollector.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Concurrent\Async; +namespace Flytachi\Winter\Kernel\Concurrent\Async; use Flytachi\Winter\DI\Attribute\Request; use Flytachi\Winter\DI\Attribute\Singleton; use Flytachi\Winter\DI\Attribute\Transient; use Flytachi\Winter\DI\Container; use Flytachi\Winter\DI\Contract\CollectorInterface; -use Flytachi\Winter\K2\Concurrent\Async\Proxy\ProxyFactory; -use Flytachi\Winter\K2\Concurrent\Async\Proxy\ProxyGenerator; -use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\Kernel\Concurrent\Async\Proxy\ProxyFactory; +use Flytachi\Winter\Kernel\Concurrent\Async\Proxy\ProxyGenerator; +use Flytachi\Winter\Kernel\Kernel; /** * Scanner collector that swaps classes carrying {@see Async} methods for their diff --git a/src/Concurrent/Async/AsyncException.php b/src/Concurrent/Async/AsyncException.php index 241ede2..6e8191f 100644 --- a/src/Concurrent/Async/AsyncException.php +++ b/src/Concurrent/Async/AsyncException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Concurrent\Async; +namespace Flytachi\Winter\Kernel\Concurrent\Async; /** * Thrown when an {@see Async} method cannot be proxied. diff --git a/src/Concurrent/Async/AsyncSupport.php b/src/Concurrent/Async/AsyncSupport.php index db3071c..6cf7f40 100644 --- a/src/Concurrent/Async/AsyncSupport.php +++ b/src/Concurrent/Async/AsyncSupport.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Concurrent\Async; +namespace Flytachi\Winter\Kernel\Concurrent\Async; -use Flytachi\Winter\K2\Concurrent\ExecutorService; -use Flytachi\Winter\K2\Concurrent\Future; +use Flytachi\Winter\Kernel\Concurrent\ExecutorService; +use Flytachi\Winter\Kernel\Concurrent\Future; /** * Runtime helper called by generated proxies. diff --git a/src/Concurrent/Async/Proxy/BypassScanner.php b/src/Concurrent/Async/Proxy/BypassScanner.php index 6e45d9e..b18bb5b 100644 --- a/src/Concurrent/Async/Proxy/BypassScanner.php +++ b/src/Concurrent/Async/Proxy/BypassScanner.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Concurrent\Async\Proxy; +namespace Flytachi\Winter\Kernel\Concurrent\Async\Proxy; /** - * Finds places where an {@see \Flytachi\Winter\K2\Concurrent\Async\Async} service is + * Finds places where an {@see \Flytachi\Winter\Kernel\Concurrent\Async\Async} service is * built with `new` instead of being taken from the container. * * Such a call gets the original class, not the proxy, so the annotated method diff --git a/src/Concurrent/Async/Proxy/ProxyFactory.php b/src/Concurrent/Async/Proxy/ProxyFactory.php index 8b0fb97..f685ef8 100644 --- a/src/Concurrent/Async/Proxy/ProxyFactory.php +++ b/src/Concurrent/Async/Proxy/ProxyFactory.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Concurrent\Async\Proxy; +namespace Flytachi\Winter\Kernel\Concurrent\Async\Proxy; -use Flytachi\Winter\K2\Concurrent\Async\AsyncException; -use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\Kernel\Concurrent\Async\AsyncException; +use Flytachi\Winter\Kernel\Kernel; /** * Materialises generated proxies as files and loads them. diff --git a/src/Concurrent/Async/Proxy/ProxyGenerator.php b/src/Concurrent/Async/Proxy/ProxyGenerator.php index a42e2c2..5d065c4 100644 --- a/src/Concurrent/Async/Proxy/ProxyGenerator.php +++ b/src/Concurrent/Async/Proxy/ProxyGenerator.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Concurrent\Async\Proxy; +namespace Flytachi\Winter\Kernel\Concurrent\Async\Proxy; -use Flytachi\Winter\K2\Concurrent\Async\Async; -use Flytachi\Winter\K2\Concurrent\Async\AsyncException; -use Flytachi\Winter\K2\Concurrent\Future; +use Flytachi\Winter\Kernel\Concurrent\Async\Async; +use Flytachi\Winter\Kernel\Concurrent\Async\AsyncException; +use Flytachi\Winter\Kernel\Concurrent\Future; /** * Turns a class carrying {@see Async} methods into the source of a subclass @@ -32,11 +32,11 @@ final class ProxyGenerator { /** Namespace generated classes are placed in. */ - public const string PROXY_NAMESPACE = 'Flytachi\\Winter\\K2\\Concurrent\\Async\\Proxy\\Generated'; + public const string PROXY_NAMESPACE = 'Flytachi\\Winter\\Kernel\\Concurrent\\Async\\Proxy\\Generated'; private const string PROXY_SUFFIX = '__Async'; - private const string SUPPORT = '\\Flytachi\\Winter\\K2\\Concurrent\\Async\\AsyncSupport'; - private const string EXECUTORS = '\\Flytachi\\Winter\\K2\\Concurrent\\Executors'; + private const string SUPPORT = '\\Flytachi\\Winter\\Kernel\\Concurrent\\Async\\AsyncSupport'; + private const string EXECUTORS = '\\Flytachi\\Winter\\Kernel\\Concurrent\\Executors'; private const string CONTAINER = '\\Flytachi\\Winter\\DI\\Container'; private const string PROXY_CONTRACT = '\\Flytachi\\Winter\\DI\\Contract\\ProxyInterface'; diff --git a/src/Concurrent/Async/Proxy/SignatureWriter.php b/src/Concurrent/Async/Proxy/SignatureWriter.php index f1f21d8..c0c50e1 100644 --- a/src/Concurrent/Async/Proxy/SignatureWriter.php +++ b/src/Concurrent/Async/Proxy/SignatureWriter.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Concurrent\Async\Proxy; +namespace Flytachi\Winter\Kernel\Concurrent\Async\Proxy; -use Flytachi\Winter\K2\Concurrent\Async\AsyncException; +use Flytachi\Winter\Kernel\Concurrent\Async\AsyncException; /** * Renders reflected method signatures back into PHP source. diff --git a/src/Concurrent/BoundedExecutorService.php b/src/Concurrent/BoundedExecutorService.php index 6bcc213..4f13de8 100644 --- a/src/Concurrent/BoundedExecutorService.php +++ b/src/Concurrent/BoundedExecutorService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Concurrent; +namespace Flytachi\Winter\Kernel\Concurrent; /** * An {@see ExecutorService} that caps how many tasks run at once, and can report diff --git a/src/Concurrent/CancellationException.php b/src/Concurrent/CancellationException.php index 53e463f..6589f56 100644 --- a/src/Concurrent/CancellationException.php +++ b/src/Concurrent/CancellationException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Concurrent; +namespace Flytachi\Winter\Kernel\Concurrent; /** * Thrown by {@see Future::get()} when the task was cancelled before it produced a result. diff --git a/src/Concurrent/CompletableFuture.php b/src/Concurrent/CompletableFuture.php index 7505c33..9943611 100644 --- a/src/Concurrent/CompletableFuture.php +++ b/src/Concurrent/CompletableFuture.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Concurrent; +namespace Flytachi\Winter\Kernel\Concurrent; /** * A {@see Future} whose completion can be driven explicitly. diff --git a/src/Concurrent/ExecutionException.php b/src/Concurrent/ExecutionException.php index 41ef427..3e7840d 100644 --- a/src/Concurrent/ExecutionException.php +++ b/src/Concurrent/ExecutionException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Concurrent; +namespace Flytachi\Winter\Kernel\Concurrent; /** * Thrown by {@see Future::get()} when the task terminated with a throwable. diff --git a/src/Concurrent/Executor/CoroutineExecutorService.php b/src/Concurrent/Executor/CoroutineExecutorService.php index c0c86d2..459a1d6 100644 --- a/src/Concurrent/Executor/CoroutineExecutorService.php +++ b/src/Concurrent/Executor/CoroutineExecutorService.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Concurrent\Executor; +namespace Flytachi\Winter\Kernel\Concurrent\Executor; use Flytachi\Winter\Base\Runtime; -use Flytachi\Winter\K2\Concurrent\CompletableFuture; -use Flytachi\Winter\K2\Concurrent\ExecutorService; -use Flytachi\Winter\K2\Concurrent\Future; -use Flytachi\Winter\K2\Concurrent\RejectedExecutionException; +use Flytachi\Winter\Kernel\Concurrent\CompletableFuture; +use Flytachi\Winter\Kernel\Concurrent\ExecutorService; +use Flytachi\Winter\Kernel\Concurrent\Future; +use Flytachi\Winter\Kernel\Concurrent\RejectedExecutionException; use Flytachi\Winter\Logger\LoggerFactory; /** @@ -25,10 +25,10 @@ * repository query state) is deliberately **not** inherited: everything a task * needs must be passed through its arguments. * - * Requires an active coroutine; use {@see \Flytachi\Winter\K2\Concurrent\Executors::common()} + * Requires an active coroutine; use {@see \Flytachi\Winter\Kernel\Concurrent\Executors::common()} * to get the right backend for the current runtime. * - * @see \Flytachi\Winter\K2\Concurrent\Executors + * @see \Flytachi\Winter\Kernel\Concurrent\Executors */ final class CoroutineExecutorService implements ExecutorService { diff --git a/src/Concurrent/Executor/DeferredExecutorService.php b/src/Concurrent/Executor/DeferredExecutorService.php index 073ede1..93e4474 100644 --- a/src/Concurrent/Executor/DeferredExecutorService.php +++ b/src/Concurrent/Executor/DeferredExecutorService.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Concurrent\Executor; +namespace Flytachi\Winter\Kernel\Concurrent\Executor; -use Flytachi\Winter\K2\Concurrent\CompletableFuture; -use Flytachi\Winter\K2\Concurrent\ExecutorService; -use Flytachi\Winter\K2\Concurrent\Future; -use Flytachi\Winter\K2\Concurrent\RejectedExecutionException; +use Flytachi\Winter\Kernel\Concurrent\CompletableFuture; +use Flytachi\Winter\Kernel\Concurrent\ExecutorService; +use Flytachi\Winter\Kernel\Concurrent\Future; +use Flytachi\Winter\Kernel\Concurrent\RejectedExecutionException; use Flytachi\Winter\Logger\LoggerFactory; /** @@ -32,7 +32,7 @@ * Note that `max_execution_time` and FPM's `request_terminate_timeout` keep * counting during the drain: a long deferred task can still be killed. * - * @see \Flytachi\Winter\K2\Concurrent\Executors + * @see \Flytachi\Winter\Kernel\Concurrent\Executors */ final class DeferredExecutorService implements ExecutorService { diff --git a/src/Concurrent/Executor/FixedExecutorService.php b/src/Concurrent/Executor/FixedExecutorService.php index c688e26..eb5d9a2 100644 --- a/src/Concurrent/Executor/FixedExecutorService.php +++ b/src/Concurrent/Executor/FixedExecutorService.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Concurrent\Executor; +namespace Flytachi\Winter\Kernel\Concurrent\Executor; use Flytachi\Winter\Base\Runtime; -use Flytachi\Winter\K2\Concurrent\BoundedExecutorService; -use Flytachi\Winter\K2\Concurrent\CompletableFuture; -use Flytachi\Winter\K2\Concurrent\Future; -use Flytachi\Winter\K2\Concurrent\RejectedExecutionException; -use Flytachi\Winter\K2\Concurrent\RejectPolicy; +use Flytachi\Winter\Kernel\Concurrent\BoundedExecutorService; +use Flytachi\Winter\Kernel\Concurrent\CompletableFuture; +use Flytachi\Winter\Kernel\Concurrent\Future; +use Flytachi\Winter\Kernel\Concurrent\RejectedExecutionException; +use Flytachi\Winter\Kernel\Concurrent\RejectPolicy; /** * A fixed-size pool: at most N tasks run concurrently, the rest wait for a slot. @@ -25,7 +25,7 @@ * When a bounded wait queue (`queue > 0`) is full, {@see RejectPolicy} decides the * outcome. An unbounded pool (`queue = 0`, the default) never rejects. * - * Obtain one through {@see \Flytachi\Winter\K2\Concurrent\Executors::newFixedExecutor()}; + * Obtain one through {@see \Flytachi\Winter\Kernel\Concurrent\Executors::newFixedExecutor()}; * register it in the container to give an `#[Async('id')]` method a dedicated pool. */ final class FixedExecutorService implements BoundedExecutorService diff --git a/src/Concurrent/ExecutorService.php b/src/Concurrent/ExecutorService.php index 7be50e2..ac75d0c 100644 --- a/src/Concurrent/ExecutorService.php +++ b/src/Concurrent/ExecutorService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Concurrent; +namespace Flytachi\Winter\Kernel\Concurrent; /** * Runs tasks asynchronously and hands back {@see Future} handles. diff --git a/src/Concurrent/Executors.php b/src/Concurrent/Executors.php index 0fe457e..a3071e0 100644 --- a/src/Concurrent/Executors.php +++ b/src/Concurrent/Executors.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Concurrent; +namespace Flytachi\Winter\Kernel\Concurrent; use Flytachi\Winter\Base\Runtime; -use Flytachi\Winter\K2\Concurrent\Executor\CoroutineExecutorService; -use Flytachi\Winter\K2\Concurrent\Executor\DeferredExecutorService; -use Flytachi\Winter\K2\Concurrent\Executor\FixedExecutorService; +use Flytachi\Winter\Kernel\Concurrent\Executor\CoroutineExecutorService; +use Flytachi\Winter\Kernel\Concurrent\Executor\DeferredExecutorService; +use Flytachi\Winter\Kernel\Concurrent\Executor\FixedExecutorService; /** * Factory for {@see ExecutorService} instances. diff --git a/src/Concurrent/Future.php b/src/Concurrent/Future.php index f54e011..716b71b 100644 --- a/src/Concurrent/Future.php +++ b/src/Concurrent/Future.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Concurrent; +namespace Flytachi\Winter\Kernel\Concurrent; /** * Handle to the result of an asynchronous computation. diff --git a/src/Concurrent/RejectPolicy.php b/src/Concurrent/RejectPolicy.php index e6c2c03..686fed1 100644 --- a/src/Concurrent/RejectPolicy.php +++ b/src/Concurrent/RejectPolicy.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Concurrent; +namespace Flytachi\Winter\Kernel\Concurrent; /** * What a bounded executor does with a task it cannot accept — when every worker diff --git a/src/Concurrent/RejectedExecutionException.php b/src/Concurrent/RejectedExecutionException.php index a51e93e..61ef500 100644 --- a/src/Concurrent/RejectedExecutionException.php +++ b/src/Concurrent/RejectedExecutionException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Concurrent; +namespace Flytachi\Winter\Kernel\Concurrent; /** * Thrown when a task cannot be accepted for execution. diff --git a/src/Concurrent/TimeoutException.php b/src/Concurrent/TimeoutException.php index 8f56173..47f7e82 100644 --- a/src/Concurrent/TimeoutException.php +++ b/src/Concurrent/TimeoutException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Concurrent; +namespace Flytachi\Winter\Kernel\Concurrent; /** * Thrown by {@see Future::get()} when the given timeout elapsed before the task completed. diff --git a/src/ConnectionPool/ConnectionFactory.php b/src/ConnectionPool/ConnectionFactory.php index 76ab7bf..a47da27 100644 --- a/src/ConnectionPool/ConnectionFactory.php +++ b/src/ConnectionPool/ConnectionFactory.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\ConnectionPool; +namespace Flytachi\Winter\Kernel\ConnectionPool; /** * The adapter the pool drives to open, probe and close the pooled resource. diff --git a/src/ConnectionPool/ConnectionPool.php b/src/ConnectionPool/ConnectionPool.php index c784c56..2829f18 100644 --- a/src/ConnectionPool/ConnectionPool.php +++ b/src/ConnectionPool/ConnectionPool.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\ConnectionPool; +namespace Flytachi\Winter\Kernel\ConnectionPool; use Closure; use Swoole\Coroutine\Channel; @@ -129,7 +129,7 @@ public function stats(): array * forget it and open its own. Clearing the timer is the part {@see close()} and * this share: a `Timer::tick` callback holds a reference to the pool, so a pool * merely dereferenced would stay alive and keep maintaining connections nobody - * uses. See {@see \Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool::reset()}. + * uses. See {@see \Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool::reset()}. */ public function abandon(): void { diff --git a/src/ConnectionPool/PoolEntry.php b/src/ConnectionPool/PoolEntry.php index dce0498..8dfc971 100644 --- a/src/ConnectionPool/PoolEntry.php +++ b/src/ConnectionPool/PoolEntry.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\ConnectionPool; +namespace Flytachi\Winter\Kernel\ConnectionPool; /** * A pooled connection plus its lifecycle metadata. `lastUsedAt` (mutable) drives diff --git a/src/ConnectionPool/PoolException.php b/src/ConnectionPool/PoolException.php index df35f23..972b01b 100644 --- a/src/ConnectionPool/PoolException.php +++ b/src/ConnectionPool/PoolException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\ConnectionPool; +namespace Flytachi\Winter\Kernel\ConnectionPool; use RuntimeException; use Throwable; diff --git a/src/ConnectionPool/PoolPolicy.php b/src/ConnectionPool/PoolPolicy.php index d235a18..602d24c 100644 --- a/src/ConnectionPool/PoolPolicy.php +++ b/src/ConnectionPool/PoolPolicy.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\ConnectionPool; +namespace Flytachi\Winter\Kernel\ConnectionPool; /** * Immutable pool tuning — the HikariCP-style knobs. A connection pool trades diff --git a/src/ConnectionPool/SingleConnection.php b/src/ConnectionPool/SingleConnection.php index dafcab5..212ef4c 100644 --- a/src/ConnectionPool/SingleConnection.php +++ b/src/ConnectionPool/SingleConnection.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\ConnectionPool; +namespace Flytachi\Winter\Kernel\ConnectionPool; use Closure; use Throwable; diff --git a/src/Core/ClassScanner.php b/src/Core/ClassScanner.php index 78813b6..bbfbf58 100644 --- a/src/Core/ClassScanner.php +++ b/src/Core/ClassScanner.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Core; +namespace Flytachi\Winter\Kernel\Core; use Flytachi\Winter\DI\Contract\CollectorInterface; use Flytachi\Winter\DI\Scanner; -use Flytachi\Winter\K2\Kernel; -use Flytachi\Winter\K2\Plugin; +use Flytachi\Winter\Kernel\Kernel; +use Flytachi\Winter\Kernel\Plugin; /** * Project-wide class discovery — runs a {@see Scanner} pass over the project diff --git a/src/Core/KernelConfig.php b/src/Core/KernelConfig.php index f93115f..9a71268 100644 --- a/src/Core/KernelConfig.php +++ b/src/Core/KernelConfig.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Core; +namespace Flytachi\Winter\Kernel\Core; abstract class KernelConfig { diff --git a/src/Core/KernelStore.php b/src/Core/KernelStore.php index c85fe87..5098603 100644 --- a/src/Core/KernelStore.php +++ b/src/Core/KernelStore.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Core; +namespace Flytachi\Winter\Kernel\Core; use Flytachi\FileStore\FileStorage; use Flytachi\FileStore\FileStorageException; diff --git a/src/Exception/ClientError.php b/src/Exception/ClientError.php index e0a654a..11fe575 100644 --- a/src/Exception/ClientError.php +++ b/src/Exception/ClientError.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Exception; +namespace Flytachi\Winter\Kernel\Exception; use Flytachi\Winter\Base\Exception\ExceptionLogLevel; use Flytachi\Winter\Base\Exception\ExceptionTrait; diff --git a/src/Exception/Error.php b/src/Exception/Error.php index 58acd87..f0af262 100644 --- a/src/Exception/Error.php +++ b/src/Exception/Error.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Exception; +namespace Flytachi\Winter\Kernel\Exception; use Flytachi\Winter\Base\Exception\ExceptionLogLevel; use Flytachi\Winter\Base\Exception\ExceptionTrait; diff --git a/src/Exception/ExceptionHeaderTrait.php b/src/Exception/ExceptionHeaderTrait.php index 22af804..20edfc4 100644 --- a/src/Exception/ExceptionHeaderTrait.php +++ b/src/Exception/ExceptionHeaderTrait.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Exception; +namespace Flytachi\Winter\Kernel\Exception; trait ExceptionHeaderTrait { diff --git a/src/Exception/KernelError.php b/src/Exception/KernelError.php index 15c0620..2a37946 100644 --- a/src/Exception/KernelError.php +++ b/src/Exception/KernelError.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Exception; +namespace Flytachi\Winter\Kernel\Exception; use Flytachi\Winter\Base\Exception\ExceptionLogLevel; use Flytachi\Winter\Base\Exception\ExceptionTrait; diff --git a/src/Exception/ServerError.php b/src/Exception/ServerError.php index aaa7d1a..adeba03 100644 --- a/src/Exception/ServerError.php +++ b/src/Exception/ServerError.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Exception; +namespace Flytachi\Winter\Kernel\Exception; use Flytachi\Winter\Base\Exception\ExceptionLogLevel; use Flytachi\Winter\Base\Exception\ExceptionTrait; diff --git a/src/File/CSV.php b/src/File/CSV.php index e4bb7a4..4193c3c 100644 --- a/src/File/CSV.php +++ b/src/File/CSV.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\File; +namespace Flytachi\Winter\Kernel\File; abstract class CSV { diff --git a/src/File/FileException.php b/src/File/FileException.php index 4e899a2..3d88c9e 100644 --- a/src/File/FileException.php +++ b/src/File/FileException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\File; +namespace Flytachi\Winter\Kernel\File; use Flytachi\Winter\Base\Exception\ExceptionLogLevel; use Flytachi\Winter\Base\Exception\ExceptionTrait; diff --git a/src/File/JSON.php b/src/File/JSON.php index dd3a842..793785e 100644 --- a/src/File/JSON.php +++ b/src/File/JSON.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\File; +namespace Flytachi\Winter\Kernel\File; abstract class JSON { diff --git a/src/File/XML.php b/src/File/XML.php index aa97f6e..a6263e8 100644 --- a/src/File/XML.php +++ b/src/File/XML.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\File; +namespace Flytachi\Winter\Kernel\File; abstract class XML { diff --git a/src/Http/Adapter/FpmRequest.php b/src/Http/Adapter/FpmRequest.php index 1f40a4e..d760596 100644 --- a/src/Http/Adapter/FpmRequest.php +++ b/src/Http/Adapter/FpmRequest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Adapter; +namespace Flytachi\Winter\Kernel\Http\Adapter; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; /** * HttpRequest adapter for PHP-FPM / Apache (CGI model). diff --git a/src/Http/Adapter/FpmResponse.php b/src/Http/Adapter/FpmResponse.php index 2b44ff1..7c93d69 100644 --- a/src/Http/Adapter/FpmResponse.php +++ b/src/Http/Adapter/FpmResponse.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Adapter; +namespace Flytachi\Winter\Kernel\Http\Adapter; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; /** * HttpResponse adapter for PHP-FPM / Apache (CGI model). diff --git a/src/Http/Adapter/SwooleRequest.php b/src/Http/Adapter/SwooleRequest.php index 36e25aa..50c4dca 100644 --- a/src/Http/Adapter/SwooleRequest.php +++ b/src/Http/Adapter/SwooleRequest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Adapter; +namespace Flytachi\Winter\Kernel\Http\Adapter; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; use Swoole\Http\Request; /** diff --git a/src/Http/Adapter/SwooleResponse.php b/src/Http/Adapter/SwooleResponse.php index 898e310..3707d13 100644 --- a/src/Http/Adapter/SwooleResponse.php +++ b/src/Http/Adapter/SwooleResponse.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Adapter; +namespace Flytachi\Winter\Kernel\Http\Adapter; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; use Swoole\Http\Response; /** diff --git a/src/Http/Contracts/HttpRequest.php b/src/Http/Contracts/HttpRequest.php index aa9f79d..c04fdc0 100644 --- a/src/Http/Contracts/HttpRequest.php +++ b/src/Http/Contracts/HttpRequest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Contracts; +namespace Flytachi\Winter\Kernel\Http\Contracts; /** * Unified HTTP request abstraction. @@ -11,7 +11,7 @@ * - SwooleRequest — wraps Swoole\Http\Request (coroutine-safe) * - FpmRequest — wraps $_SERVER / $_GET / $_POST / php://input * - * All K2 internals (Router, ParameterResolver, Middleware) + * All kernel internals (Router, ParameterResolver, Middleware) * depend only on this interface — never on a concrete transport. */ interface HttpRequest diff --git a/src/Http/Contracts/HttpResponse.php b/src/Http/Contracts/HttpResponse.php index 77ad678..8ebb71b 100644 --- a/src/Http/Contracts/HttpResponse.php +++ b/src/Http/Contracts/HttpResponse.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Contracts; +namespace Flytachi\Winter\Kernel\Http\Contracts; /** * Unified HTTP response abstraction. diff --git a/src/Http/Cors.php b/src/Http/Cors.php index f8bba15..57280cc 100644 --- a/src/Http/Cors.php +++ b/src/Http/Cors.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http; +namespace Flytachi\Winter\Kernel\Http; final class Cors { diff --git a/src/Http/Header.php b/src/Http/Header.php index a54a9c4..93d04db 100644 --- a/src/Http/Header.php +++ b/src/Http/Header.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http; +namespace Flytachi\Winter\Kernel\Http; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; use Flytachi\Winter\Base\Runtime; /** diff --git a/src/Http/Health/Health.php b/src/Http/Health/Health.php index 096001c..bc4497c 100644 --- a/src/Http/Health/Health.php +++ b/src/Http/Health/Health.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Health; +namespace Flytachi\Winter\Kernel\Http\Health; -use Flytachi\Winter\K2\Stereotype\Middleware; +use Flytachi\Winter\Kernel\Http\Stereotype\Middleware; final class Health { diff --git a/src/Http/Health/HealthContributor.php b/src/Http/Health/HealthContributor.php index 260e060..81ff8bf 100644 --- a/src/Http/Health/HealthContributor.php +++ b/src/Http/Health/HealthContributor.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Health; +namespace Flytachi\Winter\Kernel\Http\Health; /** * A single health check contributed to `/actuator/health` — the winter analogue of * Spring's `HealthContributor`. Any class implementing it is discovered on the boot - * scan (like {@see \Flytachi\Winter\K2\App\Config\WebConfigurer}) and, when the - * actuator is enabled via {@see \Flytachi\Winter\K2\App\Attribute\EnableActuator}, + * scan (like {@see \Flytachi\Winter\Kernel\App\Config\WebConfigurer}) and, when the + * actuator is enabled via {@see \Flytachi\Winter\Kernel\App\Attribute\EnableActuator}, * merged into the aggregated report under {@see name()}. * * Contributors are resolved from the container (constructor autowiring works) and diff --git a/src/Http/Health/HealthIndicator.php b/src/Http/Health/HealthIndicator.php index 3f33252..daeb220 100644 --- a/src/Http/Health/HealthIndicator.php +++ b/src/Http/Health/HealthIndicator.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Health; +namespace Flytachi\Winter\Kernel\Http\Health; use Composer\InstalledVersions; use Flytachi\Winter\Base\Runtime; use Flytachi\Winter\DI\Container; use Flytachi\Winter\DI\Scanner; -use Flytachi\Winter\K2\Collector\ImplementorCollector; -use Flytachi\Winter\K2\Http\Header; -use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; +use Flytachi\Winter\Kernel\Collector\ImplementorCollector; +use Flytachi\Winter\Kernel\Http\Header; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; class HealthIndicator implements HealthIndicatorInterface { @@ -132,8 +132,8 @@ public function loggers(): array ]; if ($output === 'file' || $file) { - $root = \Flytachi\Winter\K2\Kernel::$pathRoot; - $logDir = \Flytachi\Winter\K2\Kernel::$pathStorageLog; + $root = \Flytachi\Winter\Kernel\Kernel::$pathRoot; + $logDir = \Flytachi\Winter\Kernel\Kernel::$pathStorageLog; $entry['file'] = [ 'path' => $file ?? (str_starts_with($logDir, $root) ? ltrim(substr($logDir, strlen($root)), DIRECTORY_SEPARATOR) diff --git a/src/Http/Health/HealthIndicatorInterface.php b/src/Http/Health/HealthIndicatorInterface.php index dbbc113..4a10960 100644 --- a/src/Http/Health/HealthIndicatorInterface.php +++ b/src/Http/Health/HealthIndicatorInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Health; +namespace Flytachi\Winter\Kernel\Http\Health; interface HealthIndicatorInterface { diff --git a/src/Http/Health/HealthStatus.php b/src/Http/Health/HealthStatus.php index 6082883..5d021fa 100644 --- a/src/Http/Health/HealthStatus.php +++ b/src/Http/Health/HealthStatus.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Health; +namespace Flytachi\Winter\Kernel\Http\Health; /** * The result a {@see HealthContributor} returns — a {@see Status} plus optional diff --git a/src/Http/Health/Status.php b/src/Http/Health/Status.php index f2eb2e0..2a05928 100644 --- a/src/Http/Health/Status.php +++ b/src/Http/Health/Status.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Health; +namespace Flytachi\Winter\Kernel\Http\Health; /** * Health status of a single {@see HealthContributor}, aggregated into the overall diff --git a/src/Http/Middleware/ClientTimezoneMiddleware.php b/src/Http/Middleware/ClientTimezoneMiddleware.php index ed1fd47..a5dc50d 100644 --- a/src/Http/Middleware/ClientTimezoneMiddleware.php +++ b/src/Http/Middleware/ClientTimezoneMiddleware.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Middleware; +namespace Flytachi\Winter\Kernel\Http\Middleware; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Stereotype\Middleware; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Stereotype\Middleware; /** * Sets date_default_timezone_set() from the request's client timezone diff --git a/src/Http/Middleware/MiddlewareException.php b/src/Http/Middleware/MiddlewareException.php index a64f251..1dfb66f 100644 --- a/src/Http/Middleware/MiddlewareException.php +++ b/src/Http/Middleware/MiddlewareException.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Middleware; +namespace Flytachi\Winter\Kernel\Http\Middleware; use Flytachi\Winter\Base\HttpCode; -use Flytachi\Winter\K2\Http\Response\ResponseException; +use Flytachi\Winter\Kernel\Http\Response\ResponseException; /** * Throw from any middleware to abort the request with a specific HTTP status. diff --git a/src/Http/Middleware/MiddlewareInterface.php b/src/Http/Middleware/MiddlewareInterface.php index d1b7162..3cb67ad 100644 --- a/src/Http/Middleware/MiddlewareInterface.php +++ b/src/Http/Middleware/MiddlewareInterface.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Middleware; +namespace Flytachi\Winter\Kernel\Http\Middleware; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; /** - * K2 Middleware contract. + * Middleware contract. * * Implement in application middleware: * class AuthMiddleware extends Middleware { ... } diff --git a/src/Http/ParameterResolver.php b/src/Http/ParameterResolver.php index a8f3d01..e8bf361 100644 --- a/src/Http/ParameterResolver.php +++ b/src/Http/ParameterResolver.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http; +namespace Flytachi\Winter\Kernel\Http; use BcMath\Number as BcNumber; use BackedEnum; @@ -13,23 +13,23 @@ use finfo; use Flytachi\Winter\Base\Tool; use Flytachi\Winter\DI\ReflectionCache; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\Request\Annotation\PathVariable; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestBody; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestFile; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestForm; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestHeader; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestJson; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestParam; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestQuery; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestXml; -use Flytachi\Winter\K2\Http\Request\RequestException; -use Flytachi\Winter\K2\Http\Request\Validation\ListOf; -use Flytachi\Winter\K2\Http\Request\Validation\Constraint; -use Flytachi\Winter\K2\Http\Request\Validation\Valid; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; -use Flytachi\Winter\K2\Localization\Locale; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Request\Annotation\PathVariable; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestBody; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestFile; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestForm; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestHeader; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestJson; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestParam; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestQuery; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestXml; +use Flytachi\Winter\Kernel\Http\Request\RequestException; +use Flytachi\Winter\Kernel\Http\Request\Validation\ListOf; +use Flytachi\Winter\Kernel\Http\Request\Validation\Constraint; +use Flytachi\Winter\Kernel\Http\Request\Validation\Valid; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; +use Flytachi\Winter\Kernel\Localization\Locale; use LogicException; use ReflectionAttribute; use ReflectionMethod; @@ -69,7 +69,7 @@ * - #[ListOf] collections cascade constraints when #[Valid] is on the outer param. * - Variadic params: #[Valid] validates each element; all errors collected with [i].field keys. */ -class ParameterResolver +final class ParameterResolver { // ── Public API ──────────────────────────────────────────────────────────── diff --git a/src/Http/Request/Annotation/PathVariable.php b/src/Http/Request/Annotation/PathVariable.php index a0a484a..342f476 100644 --- a/src/Http/Request/Annotation/PathVariable.php +++ b/src/Http/Request/Annotation/PathVariable.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Annotation; +namespace Flytachi\Winter\Kernel\Http\Request\Annotation; use Attribute; @@ -18,7 +18,7 @@ * If $name is omitted the PHP parameter name is used. */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class PathVariable +final readonly class PathVariable { /** * @param string|null $name Path segment name as declared in the route pattern (e.g. {id:\d+} → 'id'). diff --git a/src/Http/Request/Annotation/RequestBody.php b/src/Http/Request/Annotation/RequestBody.php index 661805e..9a3ff8a 100644 --- a/src/Http/Request/Annotation/RequestBody.php +++ b/src/Http/Request/Annotation/RequestBody.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Annotation; +namespace Flytachi\Winter\Kernel\Http\Request\Annotation; use Attribute; @@ -40,7 +40,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class RequestBody +final readonly class RequestBody { /** * @param string|null $field Extract a single value from the parsed body by key diff --git a/src/Http/Request/Annotation/RequestFile.php b/src/Http/Request/Annotation/RequestFile.php index d59ea3a..3a7a1e4 100644 --- a/src/Http/Request/Annotation/RequestFile.php +++ b/src/Http/Request/Annotation/RequestFile.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Annotation; +namespace Flytachi\Winter\Kernel\Http\Request\Annotation; use Attribute; @@ -39,7 +39,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class RequestFile +final readonly class RequestFile { /** * @param string|null $name Form field name from the multipart request. diff --git a/src/Http/Request/Annotation/RequestForm.php b/src/Http/Request/Annotation/RequestForm.php index 6c9a7dd..75e16f2 100644 --- a/src/Http/Request/Annotation/RequestForm.php +++ b/src/Http/Request/Annotation/RequestForm.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Annotation; +namespace Flytachi\Winter\Kernel\Http\Request\Annotation; use Attribute; @@ -31,7 +31,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class RequestForm +final readonly class RequestForm { /** * @param string|null $field Extract a single value from the merged form/query data diff --git a/src/Http/Request/Annotation/RequestHeader.php b/src/Http/Request/Annotation/RequestHeader.php index 6543da7..380c2fb 100644 --- a/src/Http/Request/Annotation/RequestHeader.php +++ b/src/Http/Request/Annotation/RequestHeader.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Annotation; +namespace Flytachi\Winter\Kernel\Http\Request\Annotation; use Attribute; @@ -18,7 +18,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class RequestHeader +final readonly class RequestHeader { /** * @param string|null $name Exact HTTP header name (e.g. 'X-Trace-Id', 'Authorization'). diff --git a/src/Http/Request/Annotation/RequestJson.php b/src/Http/Request/Annotation/RequestJson.php index 62d34cb..46591da 100644 --- a/src/Http/Request/Annotation/RequestJson.php +++ b/src/Http/Request/Annotation/RequestJson.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Annotation; +namespace Flytachi\Winter\Kernel\Http\Request\Annotation; use Attribute; @@ -38,7 +38,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class RequestJson +final readonly class RequestJson { /** * @param string|null $field Extract a single value from the JSON body by key diff --git a/src/Http/Request/Annotation/RequestParam.php b/src/Http/Request/Annotation/RequestParam.php index e5322fc..a93bd12 100644 --- a/src/Http/Request/Annotation/RequestParam.php +++ b/src/Http/Request/Annotation/RequestParam.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Annotation; +namespace Flytachi\Winter\Kernel\Http\Request\Annotation; use Attribute; @@ -21,7 +21,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class RequestParam +final readonly class RequestParam { /** * @param string|null $name Exact query string key (e.g. 'page_size' for ?page_size=25). diff --git a/src/Http/Request/Annotation/RequestQuery.php b/src/Http/Request/Annotation/RequestQuery.php index bcff219..d836d97 100644 --- a/src/Http/Request/Annotation/RequestQuery.php +++ b/src/Http/Request/Annotation/RequestQuery.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Annotation; +namespace Flytachi\Winter\Kernel\Http\Request\Annotation; use Attribute; @@ -35,6 +35,6 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class RequestQuery +final readonly class RequestQuery { } diff --git a/src/Http/Request/Annotation/RequestXml.php b/src/Http/Request/Annotation/RequestXml.php index 49497a3..be2ea9b 100644 --- a/src/Http/Request/Annotation/RequestXml.php +++ b/src/Http/Request/Annotation/RequestXml.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Annotation; +namespace Flytachi\Winter\Kernel\Http\Request\Annotation; use Attribute; @@ -35,7 +35,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class RequestXml +final readonly class RequestXml { /** * @param string|null $field Extract a single value from the parsed XML by key diff --git a/src/Http/Request/K1ValidationTrait.php b/src/Http/Request/K1ValidationTrait.php index fb1c828..8a603d2 100644 --- a/src/Http/Request/K1ValidationTrait.php +++ b/src/Http/Request/K1ValidationTrait.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request; +namespace Flytachi\Winter\Kernel\Http\Request; use DateTime; -use Flytachi\Winter\K2\Localization\Locale; +use Flytachi\Winter\Kernel\Localization\Locale; /** * Legacy string-rule validation helpers, usable on any request DTO. * * Kept for backwards compatibility — new code should prefer the attribute-based - * system (#[Valid] + #[Constraint] under Flytachi\Winter\K2\Http\Request\Validation\*), + * system (#[Valid] + #[Constraint] under Flytachi\Winter\Kernel\Http\Request\Validation\*), * which collects all errors at once and integrates with i18n natively. * * This trait still supports the same {key} translation-key syntax in the optional diff --git a/src/Http/Request/RequestException.php b/src/Http/Request/RequestException.php index ac4eb77..ac945b4 100644 --- a/src/Http/Request/RequestException.php +++ b/src/Http/Request/RequestException.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request; +namespace Flytachi\Winter\Kernel\Http\Request; use Flytachi\Winter\Base\HttpCode; -use Flytachi\Winter\K2\Http\Response\ResponseException; +use Flytachi\Winter\Kernel\Http\Response\ResponseException; /** * Thrown when request data is invalid or required fields are missing. diff --git a/src/Http/Request/Validation/Assert.php b/src/Http/Request/Validation/Assert.php index 189dc54..6b264a1 100644 --- a/src/Http/Request/Validation/Assert.php +++ b/src/Http/Request/Validation/Assert.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -31,7 +31,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER | Attribute::IS_REPEATABLE)] -readonly class Assert implements Constraint +final readonly class Assert implements Constraint { /** * @param string $callable Callable string reference: 'ClassName::method' or 'functionName'. diff --git a/src/Http/Request/Validation/Constraint.php b/src/Http/Request/Validation/Constraint.php index c921858..7bc13d7 100644 --- a/src/Http/Request/Validation/Constraint.php +++ b/src/Http/Request/Validation/Constraint.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; /** * Contract for all parameter-level validation attributes. diff --git a/src/Http/Request/Validation/Date.php b/src/Http/Request/Validation/Date.php index c402ef9..5230230 100644 --- a/src/Http/Request/Validation/Date.php +++ b/src/Http/Request/Validation/Date.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -17,7 +17,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Date implements Constraint +final readonly class Date implements Constraint { /** * @param string $format PHP date format string. Defaults to 'Y-m-d' (e.g. "2024-01-31"). diff --git a/src/Http/Request/Validation/Datetime.php b/src/Http/Request/Validation/Datetime.php index 13a42b5..f17fa83 100644 --- a/src/Http/Request/Validation/Datetime.php +++ b/src/Http/Request/Validation/Datetime.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -19,7 +19,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Datetime implements Constraint +final readonly class Datetime implements Constraint { /** * @param string|null $format PHP datetime format string. null = flexible ISO 8601 via DateTimeImmutable. diff --git a/src/Http/Request/Validation/Digits.php b/src/Http/Request/Validation/Digits.php index 799f78a..a2e622d 100644 --- a/src/Http/Request/Validation/Digits.php +++ b/src/Http/Request/Validation/Digits.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -22,7 +22,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Digits implements Constraint +final readonly class Digits implements Constraint { /** * @param int $integer Max allowed digits in the integer part (before decimal point). diff --git a/src/Http/Request/Validation/Email.php b/src/Http/Request/Validation/Email.php index 418bfc4..bedf505 100644 --- a/src/Http/Request/Validation/Email.php +++ b/src/Http/Request/Validation/Email.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -16,7 +16,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Email implements Constraint +final readonly class Email implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/In.php b/src/Http/Request/Validation/In.php index c3659f7..246212f 100644 --- a/src/Http/Request/Validation/In.php +++ b/src/Http/Request/Validation/In.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -17,7 +17,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class In implements Constraint +final readonly class In implements Constraint { /** * @param array $values Allowed values to check against. diff --git a/src/Http/Request/Validation/Ip.php b/src/Http/Request/Validation/Ip.php index 467f777..161d6e8 100644 --- a/src/Http/Request/Validation/Ip.php +++ b/src/Http/Request/Validation/Ip.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -15,7 +15,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Ip implements Constraint +final readonly class Ip implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/Ipv4.php b/src/Http/Request/Validation/Ipv4.php index f4b1c7a..8d3ad31 100644 --- a/src/Http/Request/Validation/Ipv4.php +++ b/src/Http/Request/Validation/Ipv4.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -15,7 +15,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Ipv4 implements Constraint +final readonly class Ipv4 implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/Ipv6.php b/src/Http/Request/Validation/Ipv6.php index bd65892..160451e 100644 --- a/src/Http/Request/Validation/Ipv6.php +++ b/src/Http/Request/Validation/Ipv6.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -15,7 +15,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Ipv6 implements Constraint +final readonly class Ipv6 implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/ListOf.php b/src/Http/Request/Validation/ListOf.php index b7015b8..830b90e 100644 --- a/src/Http/Request/Validation/ListOf.php +++ b/src/Http/Request/Validation/ListOf.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -24,7 +24,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class ListOf +final readonly class ListOf { /** @param string $class Fully-qualified class name of the element DTO. */ public function __construct(public string $class) diff --git a/src/Http/Request/Validation/Max.php b/src/Http/Request/Validation/Max.php index 722b7f5..4876e84 100644 --- a/src/Http/Request/Validation/Max.php +++ b/src/Http/Request/Validation/Max.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -18,7 +18,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Max implements Constraint +final readonly class Max implements Constraint { /** * @param int|float $value Upper bound (inclusive). Value must be ≤ this. diff --git a/src/Http/Request/Validation/Min.php b/src/Http/Request/Validation/Min.php index 8590184..a415c8c 100644 --- a/src/Http/Request/Validation/Min.php +++ b/src/Http/Request/Validation/Min.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -18,7 +18,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Min implements Constraint +final readonly class Min implements Constraint { /** * @param int|float $value Lower bound (inclusive). Value must be ≥ this. diff --git a/src/Http/Request/Validation/Msisdn.php b/src/Http/Request/Validation/Msisdn.php index a560d9e..1a045a6 100644 --- a/src/Http/Request/Validation/Msisdn.php +++ b/src/Http/Request/Validation/Msisdn.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -16,7 +16,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Msisdn implements Constraint +final readonly class Msisdn implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/Negative.php b/src/Http/Request/Validation/Negative.php index d665817..5e945ea 100644 --- a/src/Http/Request/Validation/Negative.php +++ b/src/Http/Request/Validation/Negative.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -17,7 +17,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Negative implements Constraint +final readonly class Negative implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/NegativeOrZero.php b/src/Http/Request/Validation/NegativeOrZero.php index 64f4751..3b8ad83 100644 --- a/src/Http/Request/Validation/NegativeOrZero.php +++ b/src/Http/Request/Validation/NegativeOrZero.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -17,7 +17,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class NegativeOrZero implements Constraint +final readonly class NegativeOrZero implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/NotBlank.php b/src/Http/Request/Validation/NotBlank.php index dad0dd3..8b96422 100644 --- a/src/Http/Request/Validation/NotBlank.php +++ b/src/Http/Request/Validation/NotBlank.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -17,7 +17,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class NotBlank implements Constraint +final readonly class NotBlank implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/Phone.php b/src/Http/Request/Validation/Phone.php index 22c4efb..47032d9 100644 --- a/src/Http/Request/Validation/Phone.php +++ b/src/Http/Request/Validation/Phone.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -16,7 +16,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Phone implements Constraint +final readonly class Phone implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/Positive.php b/src/Http/Request/Validation/Positive.php index 9f8244d..a8516d8 100644 --- a/src/Http/Request/Validation/Positive.php +++ b/src/Http/Request/Validation/Positive.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -17,7 +17,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Positive implements Constraint +final readonly class Positive implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/PositiveOrZero.php b/src/Http/Request/Validation/PositiveOrZero.php index fa64151..cc53d49 100644 --- a/src/Http/Request/Validation/PositiveOrZero.php +++ b/src/Http/Request/Validation/PositiveOrZero.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -17,7 +17,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class PositiveOrZero implements Constraint +final readonly class PositiveOrZero implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/Regex.php b/src/Http/Request/Validation/Regex.php index cf922c0..f28f6a0 100644 --- a/src/Http/Request/Validation/Regex.php +++ b/src/Http/Request/Validation/Regex.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -20,7 +20,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Regex implements Constraint +final readonly class Regex implements Constraint { /** * @param string $pattern Full PHP regex with delimiters, e.g. '/^\d{4}$/'. diff --git a/src/Http/Request/Validation/Required.php b/src/Http/Request/Validation/Required.php index eabf595..89c18b5 100644 --- a/src/Http/Request/Validation/Required.php +++ b/src/Http/Request/Validation/Required.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -21,7 +21,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Required implements Constraint +final readonly class Required implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/Size.php b/src/Http/Request/Validation/Size.php index e099ac2..e8f1f83 100644 --- a/src/Http/Request/Validation/Size.php +++ b/src/Http/Request/Validation/Size.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -24,7 +24,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Size implements Constraint +final readonly class Size implements Constraint { /** * @param int $min Required. Lower bound (inclusive). When `$max` is omitted, also acts as the exact required size. diff --git a/src/Http/Request/Validation/Time.php b/src/Http/Request/Validation/Time.php index e117bec..38580d4 100644 --- a/src/Http/Request/Validation/Time.php +++ b/src/Http/Request/Validation/Time.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -18,7 +18,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Time implements Constraint +final readonly class Time implements Constraint { /** * @param string|null $format PHP time format string. null = accept 'H:i' or 'H:i:s'. diff --git a/src/Http/Request/Validation/Url.php b/src/Http/Request/Validation/Url.php index 4853a03..38ff4ee 100644 --- a/src/Http/Request/Validation/Url.php +++ b/src/Http/Request/Validation/Url.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -16,7 +16,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Url implements Constraint +final readonly class Url implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/Uuid.php b/src/Http/Request/Validation/Uuid.php index 33d74cc..1c02164 100644 --- a/src/Http/Request/Validation/Uuid.php +++ b/src/Http/Request/Validation/Uuid.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -18,7 +18,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Uuid implements Constraint +final readonly class Uuid implements Constraint { private const string PATTERN = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i'; private const string PATTERN_VER = '/^[0-9a-f]{8}-[0-9a-f]{4}-%d[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i'; diff --git a/src/Http/Request/Validation/Valid.php b/src/Http/Request/Validation/Valid.php index 6351889..25004e3 100644 --- a/src/Http/Request/Validation/Valid.php +++ b/src/Http/Request/Validation/Valid.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -34,6 +34,6 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Valid +final readonly class Valid { } diff --git a/src/Http/Request/Validation/ValidationException.php b/src/Http/Request/Validation/ValidationException.php index 9ed36eb..8fad828 100644 --- a/src/Http/Request/Validation/ValidationException.php +++ b/src/Http/Request/Validation/ValidationException.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Flytachi\Winter\Base\HttpCode; -use Flytachi\Winter\K2\Http\Response\ResponseException; +use Flytachi\Winter\Kernel\Http\Response\ResponseException; /** * Thrown when #[Valid] constraint validation fails on a DTO parameter. diff --git a/src/Http/Response/AcceptHeaderParser.php b/src/Http/Response/AcceptHeaderParser.php index 5c67682..9f44116 100644 --- a/src/Http/Response/AcceptHeaderParser.php +++ b/src/Http/Response/AcceptHeaderParser.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; final class AcceptHeaderParser { diff --git a/src/Http/Response/AdviceException.php b/src/Http/Response/AdviceException.php index 805ce7b..dd5324c 100644 --- a/src/Http/Response/AdviceException.php +++ b/src/Http/Response/AdviceException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; use Attribute; use Throwable; @@ -17,6 +17,8 @@ * ExceptionWrapper scans the project for these at startup and routes * Throwables to the most specific matching handler. * + * The base class to extend is {@see \Flytachi\Winter\Kernel\Http\Stereotype\ExceptionResponseBase}. + * * Example — catch specific exception: * #[AdviceException(NotFoundException::class)] * class NotFoundResponse extends ExceptionResponseBase { ... } @@ -26,7 +28,7 @@ * class GlobalErrorResponse extends ExceptionResponseBase { ... } */ #[Attribute(Attribute::TARGET_CLASS)] -readonly class AdviceException +final readonly class AdviceException { /** @var class-string[] */ public array $exceptionClassNames; diff --git a/src/Http/Response/Collector/ExceptionCollector.php b/src/Http/Response/Collector/ExceptionCollector.php index 35b1042..bbc2b93 100644 --- a/src/Http/Response/Collector/ExceptionCollector.php +++ b/src/Http/Response/Collector/ExceptionCollector.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response\Collector; +namespace Flytachi\Winter\Kernel\Http\Response\Collector; use Flytachi\Winter\DI\Contract\CollectorInterface; -use Flytachi\Winter\K2\Http\Response\AdviceException; -use Flytachi\Winter\K2\Http\Response\ResponseExceptionInterface; +use Flytachi\Winter\Kernel\Http\Response\AdviceException; +use Flytachi\Winter\Kernel\Http\Response\ResponseExceptionInterface; use ReflectionClass; final class ExceptionCollector implements CollectorInterface diff --git a/src/Http/Response/ContentType.php b/src/Http/Response/ContentType.php index e86b834..f4ab8d6 100644 --- a/src/Http/Response/ContentType.php +++ b/src/Http/Response/ContentType.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; -use Flytachi\Winter\K2\File\XML; +use Flytachi\Winter\Kernel\File\XML; enum ContentType: string { diff --git a/src/Http/Response/ExceptionWrapper.php b/src/Http/Response/ExceptionWrapper.php index 394f816..3a4caf8 100644 --- a/src/Http/Response/ExceptionWrapper.php +++ b/src/Http/Response/ExceptionWrapper.php @@ -2,11 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; use Composer\Autoload\ClassLoader; use Flytachi\Winter\DI\ReflectionCache; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryException; +use Flytachi\Winter\Kernel\Http\Stereotype\ExceptionResponseBase; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryException; use ReflectionClass; use ReflectionException; diff --git a/src/Http/Response/FileResponseHeaders.php b/src/Http/Response/FileResponseHeaders.php index ab8624e..b1c251a 100644 --- a/src/Http/Response/FileResponseHeaders.php +++ b/src/Http/Response/FileResponseHeaders.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; /** * Shared builder + header logic for file-style responses. diff --git a/src/Http/Response/RenderContext.php b/src/Http/Response/RenderContext.php index 6ee6cd3..979113c 100644 --- a/src/Http/Response/RenderContext.php +++ b/src/Http/Response/RenderContext.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; use Flytachi\Winter\Base\Runtime; diff --git a/src/Http/Response/ResponseEntity.php b/src/Http/Response/ResponseEntity.php index 1243b9f..15c4644 100644 --- a/src/Http/Response/ResponseEntity.php +++ b/src/Http/Response/ResponseEntity.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; use Flytachi\Winter\Base\HttpCode; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\Header; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Header; /** * Spring-Boot-style response wrapper — works in both Swoole and FPM modes. @@ -31,7 +31,7 @@ * Custom headers: * ResponseEntity::ok($data)->header('X-Request-Id', $id) */ -class ResponseEntity implements Sendable +final class ResponseEntity implements Sendable { private mixed $body = null; private array $headers = []; diff --git a/src/Http/Response/ResponseException.php b/src/Http/Response/ResponseException.php index 89a53cd..275748f 100644 --- a/src/Http/Response/ResponseException.php +++ b/src/Http/Response/ResponseException.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; use Flytachi\Winter\Base\Exception\ExceptionHeader; use Flytachi\Winter\Base\Exception\ExceptionLogLevel; use Flytachi\Winter\Base\Exception\ExceptionTrait; use Flytachi\Winter\Base\HttpCode; -use Flytachi\Winter\K2\Exception\ExceptionHeaderTrait; +use Flytachi\Winter\Kernel\Exception\ExceptionHeaderTrait; use Psr\Log\LogLevel; /** diff --git a/src/Http/Response/ResponseExceptionInterface.php b/src/Http/Response/ResponseExceptionInterface.php index b572e56..5243ebf 100644 --- a/src/Http/Response/ResponseExceptionInterface.php +++ b/src/Http/Response/ResponseExceptionInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; use Flytachi\Winter\Base\HttpCode; diff --git a/src/Http/Response/ResponseFile.php b/src/Http/Response/ResponseFile.php index c565ea7..6c90d93 100644 --- a/src/Http/Response/ResponseFile.php +++ b/src/Http/Response/ResponseFile.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; use Flytachi\Winter\Base\HttpCode; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\File\XML; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\File\XML; use SimpleXMLElement; /** @@ -26,7 +26,7 @@ * ->inline() — Content-Disposition: inline (render in browser) * ->maxAge(3600) — Cache-Control: public, max-age=3600 */ -class ResponseFile implements Sendable +final class ResponseFile implements Sendable { use FileResponseHeaders; diff --git a/src/Http/Response/ResponseStreamFile.php b/src/Http/Response/ResponseStreamFile.php index b45175a..32ce1b9 100644 --- a/src/Http/Response/ResponseStreamFile.php +++ b/src/Http/Response/ResponseStreamFile.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; use Flytachi\Winter\Base\HttpCode; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; /** * Streams a file from disk via HttpResponse::sendfile() (zero-copy on Swoole). diff --git a/src/Http/Response/ResponseTrait.php b/src/Http/Response/ResponseTrait.php index a820cd5..8accbeb 100644 --- a/src/Http/Response/ResponseTrait.php +++ b/src/Http/Response/ResponseTrait.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; trait ResponseTrait { diff --git a/src/Http/Response/ResponseView.php b/src/Http/Response/ResponseView.php index 6c0d258..c4f1147 100644 --- a/src/Http/Response/ResponseView.php +++ b/src/Http/Response/ResponseView.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; use Flytachi\Winter\Base\HttpCode; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Kernel; /** * PHP template response — port of ViewBase + View. @@ -26,7 +26,7 @@ * the directory is named after neither — `views` covers both, with layouts * conventionally under `views/layouts`. */ -class ResponseView implements Sendable +final class ResponseView implements Sendable { /** Directory under {@see Kernel::$pathResource} holding the views. */ private const string DEFAULT_DIR = 'views'; diff --git a/src/Http/Response/Sendable.php b/src/Http/Response/Sendable.php index f9bc628..aeb2da3 100644 --- a/src/Http/Response/Sendable.php +++ b/src/Http/Response/Sendable.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; /** - * Common contract for all K2 response objects. + * Common contract for all kernel response objects. * * Any value returned from a controller method that implements this interface * will be serialized to the HttpResponse by the Router automatically. diff --git a/src/Stereotype/Controller.php b/src/Http/Stereotype/Controller.php similarity index 74% rename from src/Stereotype/Controller.php rename to src/Http/Stereotype/Controller.php index a54c3ab..aa08f97 100644 --- a/src/Stereotype/Controller.php +++ b/src/Http/Stereotype/Controller.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Stereotype; +namespace Flytachi\Winter\Kernel\Http\Stereotype; abstract class Controller implements ControllerInterface { diff --git a/src/Stereotype/ControllerInterface.php b/src/Http/Stereotype/ControllerInterface.php similarity index 67% rename from src/Stereotype/ControllerInterface.php rename to src/Http/Stereotype/ControllerInterface.php index 35a72bc..760adc2 100644 --- a/src/Stereotype/ControllerInterface.php +++ b/src/Http/Stereotype/ControllerInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Stereotype; +namespace Flytachi\Winter\Kernel\Http\Stereotype; interface ControllerInterface { diff --git a/src/Http/Response/ExceptionResponseBase.php b/src/Http/Stereotype/ExceptionResponseBase.php similarity index 96% rename from src/Http/Response/ExceptionResponseBase.php rename to src/Http/Stereotype/ExceptionResponseBase.php index f984c10..cbd6434 100644 --- a/src/Http/Response/ExceptionResponseBase.php +++ b/src/Http/Stereotype/ExceptionResponseBase.php @@ -2,12 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Stereotype; use Flytachi\Winter\Base\Exception\ExceptionHeader; use Flytachi\Winter\Base\HttpCode; -use Flytachi\Winter\K2\Http\Header; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; +use Flytachi\Winter\Kernel\Http\Header; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; +use Flytachi\Winter\Kernel\Http\Response\AcceptHeaderParser; +use Flytachi\Winter\Kernel\Http\Response\ContentType; +use Flytachi\Winter\Kernel\Http\Response\ResponseExceptionInterface; +use Flytachi\Winter\Kernel\Http\Response\ResponseTrait; /** * Default exception response — implements ResponseExceptionInterface. diff --git a/src/Stereotype/Middleware.php b/src/Http/Stereotype/Middleware.php similarity index 68% rename from src/Stereotype/Middleware.php rename to src/Http/Stereotype/Middleware.php index d3fb143..ee489f6 100644 --- a/src/Stereotype/Middleware.php +++ b/src/Http/Stereotype/Middleware.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Stereotype; +namespace Flytachi\Winter\Kernel\Http\Stereotype; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\Middleware\MiddlewareInterface; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Middleware\MiddlewareInterface; /** * Base middleware — extend and override before() / after() as needed. diff --git a/src/Kernel.php b/src/Kernel.php index 7b98234..36e594a 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2; +namespace Flytachi\Winter\Kernel; -use Flytachi\Winter\K2\Core\KernelStore; -use Flytachi\Winter\K2\Process\ForkReset; -use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; +use Flytachi\Winter\Kernel\Core\KernelStore; +use Flytachi\Winter\Kernel\Process\ForkReset; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; use Flytachi\Winter\Thread\Launch\AdaptiveLauncher; use Flytachi\Winter\Thread\Thread; use Flytachi\Winter\Logger\Context\ProcessContext; diff --git a/src/Localization/LanguageNegotiator.php b/src/Localization/LanguageNegotiator.php index 1310159..eeb7ba4 100644 --- a/src/Localization/LanguageNegotiator.php +++ b/src/Localization/LanguageNegotiator.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Localization; +namespace Flytachi\Winter\Kernel\Localization; /** * Parses Accept-Language header and picks the best available locale. diff --git a/src/Localization/Locale.php b/src/Localization/Locale.php index 9f79a0a..7ad218d 100644 --- a/src/Localization/Locale.php +++ b/src/Localization/Locale.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Localization; +namespace Flytachi\Winter\Kernel\Localization; -use Flytachi\Winter\K2\Http\Header; +use Flytachi\Winter\Kernel\Http\Header; use Flytachi\Winter\Base\Runtime; /** diff --git a/src/Localization/LocaleService.php b/src/Localization/LocaleService.php index df3bafc..df0f0c1 100644 --- a/src/Localization/LocaleService.php +++ b/src/Localization/LocaleService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Localization; +namespace Flytachi\Winter\Kernel\Localization; use Flytachi\Winter\Base\Tool; @@ -23,7 +23,7 @@ * $svc->translate('user.greet', ['name' => 'Alice']) → ':name' placeholder via strtr (assoc params) * $svc->translate('unknown.key') → 'unknown.key' */ -class LocaleService +final class LocaleService { private array $dictionary = []; private bool $loaded = false; diff --git a/src/Plugin.php b/src/Plugin.php index 6d2bfc2..2257317 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2; +namespace Flytachi\Winter\Kernel; use Composer\InstalledVersions; -use Flytachi\Winter\K2\Exception\Error; +use Flytachi\Winter\Kernel\Exception\Error; final class Plugin { diff --git a/src/Ppa/Declaration.php b/src/Ppa/Declaration.php index 2cc9b23..52f913c 100644 --- a/src/Ppa/Declaration.php +++ b/src/Ppa/Declaration.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa; +namespace Flytachi\Winter\Kernel\Ppa; use Flytachi\Winter\Cdo\Config\Common\DbConfigInterface; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Table; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Table; /** * Registry of database structure declarations grouped by configuration. diff --git a/src/Ppa/DeclarationItem.php b/src/Ppa/DeclarationItem.php index 7a21bfc..8641ad6 100644 --- a/src/Ppa/DeclarationItem.php +++ b/src/Ppa/DeclarationItem.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa; +namespace Flytachi\Winter\Kernel\Ppa; use Flytachi\Winter\Cdo\Config\Common\DbConfigInterface; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Extension as ExtensionAttribute; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Migratable; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\MigratablePriority; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Extension as ExtensionStructure; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Table; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Extension as ExtensionAttribute; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Migratable; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\MigratablePriority; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Extension as ExtensionStructure; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Table; use ReflectionAttribute; use ReflectionClass; diff --git a/src/Ppa/Entity/EntityException.php b/src/Ppa/Entity/EntityException.php index 2588f6e..f3318d0 100644 --- a/src/Ppa/Entity/EntityException.php +++ b/src/Ppa/Entity/EntityException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Entity; +namespace Flytachi\Winter\Kernel\Ppa\Entity; use Flytachi\Winter\Base\Exception\ExceptionLogLevel; use Flytachi\Winter\Base\Exception\ExceptionTrait; diff --git a/src/Ppa/Entity/EntityInterface.php b/src/Ppa/Entity/EntityInterface.php index 618b4d7..3436c56 100644 --- a/src/Ppa/Entity/EntityInterface.php +++ b/src/Ppa/Entity/EntityInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Entity; +namespace Flytachi\Winter\Kernel\Ppa\Entity; /** * Marker interface for entity classes with custom column selection mapping. diff --git a/src/Ppa/Entity/RepositoryCrudInterface.php b/src/Ppa/Entity/RepositoryCrudInterface.php index 623bc9e..709bad0 100644 --- a/src/Ppa/Entity/RepositoryCrudInterface.php +++ b/src/Ppa/Entity/RepositoryCrudInterface.php @@ -2,18 +2,18 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Entity; +namespace Flytachi\Winter\Kernel\Ppa\Entity; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryException; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryException; /** * Contract for repository classes that support write operations. * * Extends {@see RepositoryInterface} with INSERT, UPDATE, DELETE, and UPSERT - * capabilities. Implemented by {@see \Flytachi\Winter\K2\Ppa\Repository\RepositoryCrudTrait} - * and exposed via {@see \Flytachi\Winter\K2\Ppa\Stereotype\RepositoryCrud} and - * {@see \Flytachi\Winter\K2\Ppa\Stereotype\Repository}. + * capabilities. Implemented by {@see \Flytachi\Winter\Kernel\Ppa\Repository\RepositoryCrudTrait} + * and exposed via {@see \Flytachi\Winter\Kernel\Ppa\Stereotype\RepositoryCrud} and + * {@see \Flytachi\Winter\Kernel\Ppa\Stereotype\Repository}. */ interface RepositoryCrudInterface extends RepositoryInterface { diff --git a/src/Ppa/Entity/RepositoryInterface.php b/src/Ppa/Entity/RepositoryInterface.php index dfef5a0..18c8489 100644 --- a/src/Ppa/Entity/RepositoryInterface.php +++ b/src/Ppa/Entity/RepositoryInterface.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Entity; +namespace Flytachi\Winter\Kernel\Ppa\Entity; use Flytachi\Winter\Cdo\CDOBind; use Flytachi\Winter\Cdo\Connection\CDO; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryException; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryException; use ValueError; /** @@ -19,7 +19,7 @@ * * Extended by {@see RepositoryCrudInterface} (write operations) and * {@see RepositoryViewInterface} (read operations). Implemented by - * {@see \Flytachi\Winter\K2\Ppa\Repository\RepositoryCore}. + * {@see \Flytachi\Winter\Kernel\Ppa\Repository\RepositoryCore}. */ interface RepositoryInterface { diff --git a/src/Ppa/Entity/RepositoryViewInterface.php b/src/Ppa/Entity/RepositoryViewInterface.php index a0da7a3..908a1dd 100644 --- a/src/Ppa/Entity/RepositoryViewInterface.php +++ b/src/Ppa/Entity/RepositoryViewInterface.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Entity; +namespace Flytachi\Winter\Kernel\Ppa\Entity; use Flytachi\Winter\Base\HttpCode; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryException; -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryException; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; /** * Contract for repository classes that support read operations. @@ -16,8 +16,8 @@ * raw SQL execution, single/collection fetch, count, exists, and * static convenience finders with optional throw-on-miss variants. * - * Implemented by {@see \Flytachi\Winter\K2\Ppa\Repository\RepositoryViewTrait} - * and exposed via {@see \Flytachi\Winter\K2\Ppa\Stereotype\RepositoryView} and + * Implemented by {@see \Flytachi\Winter\Kernel\Ppa\Repository\RepositoryViewTrait} + * and exposed via {@see \Flytachi\Winter\Kernel\Ppa\Stereotype\RepositoryView} and * {@see Repository}. * * `TEntity` is the entity class declared by a concrete repository via diff --git a/src/Ppa/Mapping/Attributes/Additive/AttributeDbAdditive.php b/src/Ppa/Mapping/Attributes/Additive/AttributeDbAdditive.php index 238dc86..49e4f92 100644 --- a/src/Ppa/Mapping/Attributes/Additive/AttributeDbAdditive.php +++ b/src/Ppa/Mapping/Attributes/Additive/AttributeDbAdditive.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDb; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDb; interface AttributeDbAdditive extends AttributeDb { diff --git a/src/Ppa/Mapping/Attributes/Additive/DefaultVal.php b/src/Ppa/Mapping/Attributes/Additive/DefaultVal.php index a95c2aa..fef9028 100644 --- a/src/Ppa/Mapping/Attributes/Additive/DefaultVal.php +++ b/src/Ppa/Mapping/Attributes/Additive/DefaultVal.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class DefaultVal implements AttributeDbAdditive +final readonly class DefaultVal implements AttributeDbAdditive { public function __construct( private string $definition, diff --git a/src/Ppa/Mapping/Attributes/Additive/NullableIs.php b/src/Ppa/Mapping/Attributes/Additive/NullableIs.php index 87d0fb5..1abac9a 100644 --- a/src/Ppa/Mapping/Attributes/Additive/NullableIs.php +++ b/src/Ppa/Mapping/Attributes/Additive/NullableIs.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class NullableIs implements AttributeDbAdditive +final readonly class NullableIs implements AttributeDbAdditive { public function __construct( private bool $isNullable = true, diff --git a/src/Ppa/Mapping/Attributes/AttributeDb.php b/src/Ppa/Mapping/Attributes/AttributeDb.php index 69a63e4..ec39c03 100644 --- a/src/Ppa/Mapping/Attributes/AttributeDb.php +++ b/src/Ppa/Mapping/Attributes/AttributeDb.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes; interface AttributeDb { diff --git a/src/Ppa/Mapping/Attributes/AttributeDbConfig.php b/src/Ppa/Mapping/Attributes/AttributeDbConfig.php index caf3e86..0762438 100644 --- a/src/Ppa/Mapping/Attributes/AttributeDbConfig.php +++ b/src/Ppa/Mapping/Attributes/AttributeDbConfig.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes; interface AttributeDbConfig { diff --git a/src/Ppa/Mapping/Attributes/AttributeDbEntity.php b/src/Ppa/Mapping/Attributes/AttributeDbEntity.php index b0c3d45..02cf853 100644 --- a/src/Ppa/Mapping/Attributes/AttributeDbEntity.php +++ b/src/Ppa/Mapping/Attributes/AttributeDbEntity.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes; interface AttributeDbEntity { diff --git a/src/Ppa/Mapping/Attributes/Config/Extension.php b/src/Ppa/Mapping/Attributes/Config/Extension.php index 3282134..ab3f76b 100644 --- a/src/Ppa/Mapping/Attributes/Config/Extension.php +++ b/src/Ppa/Mapping/Attributes/Config/Extension.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDbConfig; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDbConfig; /** * Declares a database extension required by the configuration. * * Stack one attribute per extension on a DbConfig class. Aggregated by - * {@see \Flytachi\Winter\K2\Ppa\DeclarationItem} and emitted as + * {@see \Flytachi\Winter\Kernel\Ppa\DeclarationItem} and emitted as * `CREATE EXTENSION IF NOT EXISTS …` at migration time. * * Driver support: PostgreSQL only. Putting this attribute on a non-pgsql @@ -26,7 +26,7 @@ * ``` */ #[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] -readonly class Extension implements AttributeDbConfig +final readonly class Extension implements AttributeDbConfig { public function __construct( public string $name, diff --git a/src/Ppa/Mapping/Attributes/Config/Migratable.php b/src/Ppa/Mapping/Attributes/Config/Migratable.php index d4321b8..0fd4b36 100644 --- a/src/Ppa/Mapping/Attributes/Config/Migratable.php +++ b/src/Ppa/Mapping/Attributes/Config/Migratable.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDbConfig; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\MigratablePriority; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDbConfig; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\MigratablePriority; /** * Opts a DbConfig into `db migrate` / `db sql` tooling. @@ -31,7 +31,7 @@ * ``` */ #[Attribute(Attribute::TARGET_CLASS)] -readonly class Migratable implements AttributeDbConfig +final readonly class Migratable implements AttributeDbConfig { public function __construct( public MigratablePriority $priority = MigratablePriority::Normal, diff --git a/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraint.php b/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraint.php index 0070953..dfb842a 100644 --- a/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraint.php +++ b/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraint.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDb; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\StructureInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDb; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\StructureInterface; interface AttributeDbConstraint extends AttributeDb { diff --git a/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraintCheck.php b/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraintCheck.php index c52c34d..4892fc3 100644 --- a/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraintCheck.php +++ b/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraintCheck.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\CheckConstraint; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\CheckConstraint; interface AttributeDbConstraintCheck extends AttributeDbConstraint { diff --git a/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraintForeign.php b/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraintForeign.php index 24392f8..4320376 100644 --- a/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraintForeign.php +++ b/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraintForeign.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey; interface AttributeDbConstraintForeign extends AttributeDbConstraint { diff --git a/src/Ppa/Mapping/Attributes/Constraint/Check.php b/src/Ppa/Mapping/Attributes/Constraint/Check.php index c008d2b..e4994c5 100644 --- a/src/Ppa/Mapping/Attributes/Constraint/Check.php +++ b/src/Ppa/Mapping/Attributes/Constraint/Check.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\CheckConstraint; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\CheckConstraint; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)] -readonly class Check implements AttributeDbConstraintCheck +final readonly class Check implements AttributeDbConstraintCheck { public function __construct( public string $expression, diff --git a/src/Ppa/Mapping/Attributes/Constraint/CheckEnum.php b/src/Ppa/Mapping/Attributes/Constraint/CheckEnum.php index c8c7b07..7290d50 100644 --- a/src/Ppa/Mapping/Attributes/Constraint/CheckEnum.php +++ b/src/Ppa/Mapping/Attributes/Constraint/CheckEnum.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint; use Attribute; use BackedEnum; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\CheckConstraint; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\CheckConstraint; use InvalidArgumentException; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class CheckEnum implements AttributeDbConstraintCheck +final readonly class CheckEnum implements AttributeDbConstraintCheck { /** * @param class-string $enumClassName diff --git a/src/Ppa/Mapping/Attributes/Constraint/ForeignKey.php b/src/Ppa/Mapping/Attributes/Constraint/ForeignKey.php index a316b49..06cbc5c 100644 --- a/src/Ppa/Mapping/Attributes/Constraint/ForeignKey.php +++ b/src/Ppa/Mapping/Attributes/Constraint/ForeignKey.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\FKAction; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\FKAction; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class ForeignKey implements AttributeDbConstraintForeign +final readonly class ForeignKey implements AttributeDbConstraintForeign { public function __construct( public string $referencedTable, @@ -22,8 +22,8 @@ public function __construct( public function toObject( string $columnName, string $dialect = 'mysql' - ): \Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey { - return new \Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey( + ): \Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey { + return new \Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey( referencedTable: $this->referencedTable, referencedColumn: $this->referencedColumn, onUpdate: $this->onUpdate, diff --git a/src/Ppa/Mapping/Attributes/Constraint/ForeignRepo.php b/src/Ppa/Mapping/Attributes/Constraint/ForeignRepo.php index 148448e..a71dd50 100644 --- a/src/Ppa/Mapping/Attributes/Constraint/ForeignRepo.php +++ b/src/Ppa/Mapping/Attributes/Constraint/ForeignRepo.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\FKAction; -use Flytachi\Winter\K2\Ppa\Mapping\RepositoryMappingInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\FKAction; +use Flytachi\Winter\Kernel\Ppa\Mapping\RepositoryMappingInterface; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class ForeignRepo implements AttributeDbConstraintForeign +final readonly class ForeignRepo implements AttributeDbConstraintForeign { /** * @param class-string $referencedRepoClass @@ -28,7 +28,7 @@ public function __construct( public function toObject( string $columnName, string $dialect = 'mysql' - ): \Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey { + ): \Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey { $referencedRepoInstance = new $this->referencedRepoClass(); if (!($referencedRepoInstance instanceof RepositoryMappingInterface)) { @@ -38,7 +38,7 @@ public function toObject( )); } - return new \Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey( + return new \Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey( referencedTable: $referencedRepoInstance->originTable(), referencedColumn: $referencedRepoInstance->mapIdentifierColumnName(), onUpdate: $this->onUpdate, diff --git a/src/Ppa/Mapping/Attributes/Entity/Table.php b/src/Ppa/Mapping/Attributes/Entity/Table.php index 30cadb3..4ba9e48 100644 --- a/src/Ppa/Mapping/Attributes/Entity/Table.php +++ b/src/Ppa/Mapping/Attributes/Entity/Table.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Entity; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Entity; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDbEntity; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDbEntity; #[Attribute(Attribute::TARGET_CLASS)] final class Table implements AttributeDbEntity diff --git a/src/Ppa/Mapping/Attributes/Hybrid/AttributeDbHybrid.php b/src/Ppa/Mapping/Attributes/Hybrid/AttributeDbHybrid.php index 9a6b2db..0650502 100644 --- a/src/Ppa/Mapping/Attributes/Hybrid/AttributeDbHybrid.php +++ b/src/Ppa/Mapping/Attributes/Hybrid/AttributeDbHybrid.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\AttributeDbAdditive; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDb; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\AttributeDbIdx; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\AttributeDbType; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Sub\AttributeDbSubType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\AttributeDbAdditive; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDb; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\AttributeDbIdx; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\AttributeDbType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Sub\AttributeDbSubType; interface AttributeDbHybrid extends AttributeDb { diff --git a/src/Ppa/Mapping/Attributes/Hybrid/BigId.php b/src/Ppa/Mapping/Attributes/Hybrid/BigId.php index e06b6ec..273d470 100644 --- a/src/Ppa/Mapping/Attributes/Hybrid/BigId.php +++ b/src/Ppa/Mapping/Attributes/Hybrid/BigId.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\NullableIs; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Primary; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\BigInteger; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Sub\AutoIncrement; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\NullableIs; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Primary; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\BigInteger; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Sub\AutoIncrement; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class BigId implements AttributeDbHybrid +final readonly class BigId implements AttributeDbHybrid { /** * AutoIncrement attribute for marking a property as an auto-incrementing column. diff --git a/src/Ppa/Mapping/Attributes/Hybrid/Id.php b/src/Ppa/Mapping/Attributes/Hybrid/Id.php index 4c3c747..8edba3c 100644 --- a/src/Ppa/Mapping/Attributes/Hybrid/Id.php +++ b/src/Ppa/Mapping/Attributes/Hybrid/Id.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\NullableIs; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Primary; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Integer; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Sub\AutoIncrement; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\NullableIs; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Primary; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Integer; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Sub\AutoIncrement; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Id implements AttributeDbHybrid +final readonly class Id implements AttributeDbHybrid { /** * AutoIncrement attribute for marking a property as an auto-incrementing column. diff --git a/src/Ppa/Mapping/Attributes/Hybrid/SmallId.php b/src/Ppa/Mapping/Attributes/Hybrid/SmallId.php index 70888a9..1c520c4 100644 --- a/src/Ppa/Mapping/Attributes/Hybrid/SmallId.php +++ b/src/Ppa/Mapping/Attributes/Hybrid/SmallId.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\NullableIs; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Primary; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\SmallInteger; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Sub\AutoIncrement; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\NullableIs; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Primary; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\SmallInteger; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Sub\AutoIncrement; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class SmallId implements AttributeDbHybrid +final readonly class SmallId implements AttributeDbHybrid { /** * AutoIncrement attribute for marking a property as an auto-incrementing column. diff --git a/src/Ppa/Mapping/Attributes/Hybrid/UuidPk.php b/src/Ppa/Mapping/Attributes/Hybrid/UuidPk.php index 5d59345..0bccd85 100644 --- a/src/Ppa/Mapping/Attributes/Hybrid/UuidPk.php +++ b/src/Ppa/Mapping/Attributes/Hybrid/UuidPk.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\DefaultVal; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\NullableIs; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Primary; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Uuid; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\DefaultVal; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\NullableIs; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Primary; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Uuid; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class UuidPk implements AttributeDbHybrid +final readonly class UuidPk implements AttributeDbHybrid { public function getInstances(string $dialect = 'mysql'): array { diff --git a/src/Ppa/Mapping/Attributes/Idx/AttributeDbIdx.php b/src/Ppa/Mapping/Attributes/Idx/AttributeDbIdx.php index a573226..3638b89 100644 --- a/src/Ppa/Mapping/Attributes/Idx/AttributeDbIdx.php +++ b/src/Ppa/Mapping/Attributes/Idx/AttributeDbIdx.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDb; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Index; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDb; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index; interface AttributeDbIdx extends AttributeDb { diff --git a/src/Ppa/Mapping/Attributes/Idx/Index.php b/src/Ppa/Mapping/Attributes/Idx/Index.php index cd30cda..478d6e6 100644 --- a/src/Ppa/Mapping/Attributes/Idx/Index.php +++ b/src/Ppa/Mapping/Attributes/Idx/Index.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexMethod; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexMethod; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] -class Index implements AttributeDbIdx +final class Index implements AttributeDbIdx { public function __construct( private array $columns = [], @@ -27,9 +27,9 @@ public function columnPreparation(string $columnMain): void } } - public function toObject(string $dialect = 'mysql'): \Flytachi\Winter\K2\Ppa\Mapping\Structure\Index + public function toObject(string $dialect = 'mysql'): \Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index { - return new \Flytachi\Winter\K2\Ppa\Mapping\Structure\Index( + return new \Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index( columns: $this->columns, name: $this->name, type: IndexType::INDEX, diff --git a/src/Ppa/Mapping/Attributes/Idx/Primary.php b/src/Ppa/Mapping/Attributes/Idx/Primary.php index fdc2754..10813be 100644 --- a/src/Ppa/Mapping/Attributes/Idx/Primary.php +++ b/src/Ppa/Mapping/Attributes/Idx/Primary.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexMethod; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexMethod; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; #[Attribute(Attribute::TARGET_PROPERTY)] -class Primary implements AttributeDbIdx +final class Primary implements AttributeDbIdx { private array $columns = []; @@ -20,9 +20,9 @@ public function columnPreparation(string $columnMain): void } } - public function toObject(string $dialect = 'mysql'): \Flytachi\Winter\K2\Ppa\Mapping\Structure\Index + public function toObject(string $dialect = 'mysql'): \Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index { - return new \Flytachi\Winter\K2\Ppa\Mapping\Structure\Index( + return new \Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index( columns: $this->columns, type: IndexType::PRIMARY, method: IndexMethod::BTREE, diff --git a/src/Ppa/Mapping/Attributes/Idx/Unique.php b/src/Ppa/Mapping/Attributes/Idx/Unique.php index 364cec1..c81553b 100644 --- a/src/Ppa/Mapping/Attributes/Idx/Unique.php +++ b/src/Ppa/Mapping/Attributes/Idx/Unique.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexMethod; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexMethod; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] -class Unique implements AttributeDbIdx +final class Unique implements AttributeDbIdx { public function __construct( private array $columns = [], @@ -27,9 +27,9 @@ public function columnPreparation(string $columnMain): void } } - public function toObject(string $dialect = 'mysql'): \Flytachi\Winter\K2\Ppa\Mapping\Structure\Index + public function toObject(string $dialect = 'mysql'): \Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index { - return new \Flytachi\Winter\K2\Ppa\Mapping\Structure\Index( + return new \Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index( columns: $this->columns, name: $this->name, type: IndexType::UNIQUE, diff --git a/src/Ppa/Mapping/Attributes/Primal/AttributeDbType.php b/src/Ppa/Mapping/Attributes/Primal/AttributeDbType.php index 51a1ff8..7ddb4ef 100644 --- a/src/Ppa/Mapping/Attributes/Primal/AttributeDbType.php +++ b/src/Ppa/Mapping/Attributes/Primal/AttributeDbType.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDb; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDb; interface AttributeDbType extends AttributeDb { diff --git a/src/Ppa/Mapping/Attributes/Primal/BigInteger.php b/src/Ppa/Mapping/Attributes/Primal/BigInteger.php index f1c6f71..a65d7ae 100644 --- a/src/Ppa/Mapping/Attributes/Primal/BigInteger.php +++ b/src/Ppa/Mapping/Attributes/Primal/BigInteger.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class BigInteger implements AttributeDbType +final readonly class BigInteger implements AttributeDbType { public function supports(array $phpTypes): bool { diff --git a/src/Ppa/Mapping/Attributes/Primal/Binary.php b/src/Ppa/Mapping/Attributes/Primal/Binary.php index 5a7cc50..d391b6d 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Binary.php +++ b/src/Ppa/Mapping/Attributes/Primal/Binary.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; use InvalidArgumentException; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Binary implements AttributeDbType +final readonly class Binary implements AttributeDbType { public function __construct( private int $length = 255 diff --git a/src/Ppa/Mapping/Attributes/Primal/Blob.php b/src/Ppa/Mapping/Attributes/Primal/Blob.php index b40fa66..2ee32cb 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Blob.php +++ b/src/Ppa/Mapping/Attributes/Primal/Blob.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; use InvalidArgumentException; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Blob implements AttributeDbType +final readonly class Blob implements AttributeDbType { /** * @param 'default'|'tiny'|'medium'|'long' $size 'tiny', 'medium', 'long' diff --git a/src/Ppa/Mapping/Attributes/Primal/Boolean.php b/src/Ppa/Mapping/Attributes/Primal/Boolean.php index c3e8d63..2661f61 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Boolean.php +++ b/src/Ppa/Mapping/Attributes/Primal/Boolean.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Boolean implements AttributeDbType +final readonly class Boolean implements AttributeDbType { public function supports(array $phpTypes): bool { diff --git a/src/Ppa/Mapping/Attributes/Primal/Char.php b/src/Ppa/Mapping/Attributes/Primal/Char.php index c2be4f2..f829360 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Char.php +++ b/src/Ppa/Mapping/Attributes/Primal/Char.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; use InvalidArgumentException; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Char implements AttributeDbType +final readonly class Char implements AttributeDbType { /** * Defines a fixed-length character string column. diff --git a/src/Ppa/Mapping/Attributes/Primal/Date.php b/src/Ppa/Mapping/Attributes/Primal/Date.php index fec4766..11f0867 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Date.php +++ b/src/Ppa/Mapping/Attributes/Primal/Date.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Date extends DateTime implements AttributeDbType +final readonly class Date extends DateTime implements AttributeDbType { public function toSql(string $dialect = 'mysql'): string { diff --git a/src/Ppa/Mapping/Attributes/Primal/DateTime.php b/src/Ppa/Mapping/Attributes/Primal/DateTime.php index ebb6d12..57de017 100644 --- a/src/Ppa/Mapping/Attributes/Primal/DateTime.php +++ b/src/Ppa/Mapping/Attributes/Primal/DateTime.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; diff --git a/src/Ppa/Mapping/Attributes/Primal/Decimal.php b/src/Ppa/Mapping/Attributes/Primal/Decimal.php index 347409f..5bad8ff 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Decimal.php +++ b/src/Ppa/Mapping/Attributes/Primal/Decimal.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Decimal extends FloatType implements AttributeDbType +final readonly class Decimal extends FloatType implements AttributeDbType { /** * @param int $precision The total number of digits that can be stored diff --git a/src/Ppa/Mapping/Attributes/Primal/Double.php b/src/Ppa/Mapping/Attributes/Primal/Double.php index 15cf390..96961a6 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Double.php +++ b/src/Ppa/Mapping/Attributes/Primal/Double.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Double extends FloatType implements AttributeDbType +final readonly class Double extends FloatType implements AttributeDbType { public function toSql(string $dialect = 'mysql'): string { diff --git a/src/Ppa/Mapping/Attributes/Primal/FloatType.php b/src/Ppa/Mapping/Attributes/Primal/FloatType.php index 425d450..104c6a8 100644 --- a/src/Ppa/Mapping/Attributes/Primal/FloatType.php +++ b/src/Ppa/Mapping/Attributes/Primal/FloatType.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; diff --git a/src/Ppa/Mapping/Attributes/Primal/Integer.php b/src/Ppa/Mapping/Attributes/Primal/Integer.php index b4f37d1..72396cd 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Integer.php +++ b/src/Ppa/Mapping/Attributes/Primal/Integer.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Integer implements AttributeDbType +final readonly class Integer implements AttributeDbType { public function supports(array $phpTypes): bool { diff --git a/src/Ppa/Mapping/Attributes/Primal/Json.php b/src/Ppa/Mapping/Attributes/Primal/Json.php index bc8f897..f2f139a 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Json.php +++ b/src/Ppa/Mapping/Attributes/Primal/Json.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Json implements AttributeDbType +final readonly class Json implements AttributeDbType { public function supports(array $phpTypes): bool { diff --git a/src/Ppa/Mapping/Attributes/Primal/SmallInteger.php b/src/Ppa/Mapping/Attributes/Primal/SmallInteger.php index 7e42707..c44fca3 100644 --- a/src/Ppa/Mapping/Attributes/Primal/SmallInteger.php +++ b/src/Ppa/Mapping/Attributes/Primal/SmallInteger.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class SmallInteger implements AttributeDbType +final readonly class SmallInteger implements AttributeDbType { public function supports(array $phpTypes): bool { diff --git a/src/Ppa/Mapping/Attributes/Primal/Text.php b/src/Ppa/Mapping/Attributes/Primal/Text.php index ef7a8a2..07dc683 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Text.php +++ b/src/Ppa/Mapping/Attributes/Primal/Text.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Text implements AttributeDbType +final readonly class Text implements AttributeDbType { public function supports(array $phpTypes): bool { diff --git a/src/Ppa/Mapping/Attributes/Primal/TextArray.php b/src/Ppa/Mapping/Attributes/Primal/TextArray.php index 980a899..fe7f112 100644 --- a/src/Ppa/Mapping/Attributes/Primal/TextArray.php +++ b/src/Ppa/Mapping/Attributes/Primal/TextArray.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class TextArray implements AttributeDbType +final readonly class TextArray implements AttributeDbType { public function supports(array $phpTypes): bool { diff --git a/src/Ppa/Mapping/Attributes/Primal/Time.php b/src/Ppa/Mapping/Attributes/Primal/Time.php index 42a75db..2f2fddd 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Time.php +++ b/src/Ppa/Mapping/Attributes/Primal/Time.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Time extends DateTime implements AttributeDbType +final readonly class Time extends DateTime implements AttributeDbType { public function toSql(string $dialect = 'mysql'): string { diff --git a/src/Ppa/Mapping/Attributes/Primal/Timestamp.php b/src/Ppa/Mapping/Attributes/Primal/Timestamp.php index c9fceb8..5935595 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Timestamp.php +++ b/src/Ppa/Mapping/Attributes/Primal/Timestamp.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Timestamp extends DateTime implements AttributeDbType +final readonly class Timestamp extends DateTime implements AttributeDbType { /** * @param bool $withTimeZone If true, the timestamp will diff --git a/src/Ppa/Mapping/Attributes/Primal/Type.php b/src/Ppa/Mapping/Attributes/Primal/Type.php index cc1936b..256e416 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Type.php +++ b/src/Ppa/Mapping/Attributes/Primal/Type.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Type implements AttributeDbType +final readonly class Type implements AttributeDbType { /** * @param string $definition The SQL type definition string (e.g., 'VARCHAR(255)', 'INT', 'TEXT'). diff --git a/src/Ppa/Mapping/Attributes/Primal/Uuid.php b/src/Ppa/Mapping/Attributes/Primal/Uuid.php index 1700835..9c5c467 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Uuid.php +++ b/src/Ppa/Mapping/Attributes/Primal/Uuid.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Uuid implements AttributeDbType +final readonly class Uuid implements AttributeDbType { public function __construct( private bool $asBinary = false diff --git a/src/Ppa/Mapping/Attributes/Primal/Varchar.php b/src/Ppa/Mapping/Attributes/Primal/Varchar.php index d25d8f5..a56b5dc 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Varchar.php +++ b/src/Ppa/Mapping/Attributes/Primal/Varchar.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Varchar implements AttributeDbType +final readonly class Varchar implements AttributeDbType { /** * @param int $length The maximum length of the VARCHAR string. Defaults to 255. diff --git a/src/Ppa/Mapping/Attributes/Sub/AttributeDbSubType.php b/src/Ppa/Mapping/Attributes/Sub/AttributeDbSubType.php index e97fe69..9b012cb 100644 --- a/src/Ppa/Mapping/Attributes/Sub/AttributeDbSubType.php +++ b/src/Ppa/Mapping/Attributes/Sub/AttributeDbSubType.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Sub; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Sub; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDb; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDb; interface AttributeDbSubType extends AttributeDb { diff --git a/src/Ppa/Mapping/Attributes/Sub/AutoIncrement.php b/src/Ppa/Mapping/Attributes/Sub/AutoIncrement.php index 6ce5b72..4ffb655 100644 --- a/src/Ppa/Mapping/Attributes/Sub/AutoIncrement.php +++ b/src/Ppa/Mapping/Attributes/Sub/AutoIncrement.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Sub; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Sub; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class AutoIncrement implements AttributeDbSubType +final readonly class AutoIncrement implements AttributeDbSubType { /** * AutoIncrement attribute for marking a property as an auto-incrementing column. diff --git a/src/Ppa/Mapping/ColumnMapping.php b/src/Ppa/Mapping/ColumnMapping.php index fc34c0f..d355165 100644 --- a/src/Ppa/Mapping/ColumnMapping.php +++ b/src/Ppa/Mapping/ColumnMapping.php @@ -2,21 +2,21 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping; - -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\AttributeDbAdditive; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDb; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint\AttributeDbConstraint; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint\AttributeDbConstraintCheck; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint\AttributeDbConstraintForeign; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid\AttributeDbHybrid; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\AttributeDbIdx; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Index; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\AttributeDbType; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Sub\AttributeDbSubType; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\CheckConstraint; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Column; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey; +namespace Flytachi\Winter\Kernel\Ppa\Mapping; + +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\AttributeDbAdditive; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDb; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint\AttributeDbConstraint; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint\AttributeDbConstraintCheck; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint\AttributeDbConstraintForeign; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid\AttributeDbHybrid; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\AttributeDbIdx; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Index; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\AttributeDbType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Sub\AttributeDbSubType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\CheckConstraint; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Column; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey; use ReflectionAttribute; use ReflectionProperty; diff --git a/src/Ppa/Mapping/Constants/FKAction.php b/src/Ppa/Mapping/Constants/FKAction.php index 2f2fb2d..6b78bb0 100644 --- a/src/Ppa/Mapping/Constants/FKAction.php +++ b/src/Ppa/Mapping/Constants/FKAction.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Constants; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Constants; enum FKAction: string { diff --git a/src/Ppa/Mapping/Constants/IndexMethod.php b/src/Ppa/Mapping/Constants/IndexMethod.php index 3d81a34..d712592 100644 --- a/src/Ppa/Mapping/Constants/IndexMethod.php +++ b/src/Ppa/Mapping/Constants/IndexMethod.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Constants; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Constants; enum IndexMethod: string { diff --git a/src/Ppa/Mapping/Constants/IndexType.php b/src/Ppa/Mapping/Constants/IndexType.php index 79a4ba9..80b9d55 100644 --- a/src/Ppa/Mapping/Constants/IndexType.php +++ b/src/Ppa/Mapping/Constants/IndexType.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Constants; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Constants; enum IndexType: string { diff --git a/src/Ppa/Mapping/Constants/MigratablePriority.php b/src/Ppa/Mapping/Constants/MigratablePriority.php index a40dc5e..dc89acc 100644 --- a/src/Ppa/Mapping/Constants/MigratablePriority.php +++ b/src/Ppa/Mapping/Constants/MigratablePriority.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Constants; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Constants; /** - * Migration ordering priority for {@see \Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Migratable}. + * Migration ordering priority for {@see \Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Migratable}. * * Lower value = earlier in the migration order. Sort ascending. */ diff --git a/src/Ppa/Mapping/RepositoryMappingInterface.php b/src/Ppa/Mapping/RepositoryMappingInterface.php index 8cceb12..db6a7ce 100644 --- a/src/Ppa/Mapping/RepositoryMappingInterface.php +++ b/src/Ppa/Mapping/RepositoryMappingInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping; +namespace Flytachi\Winter\Kernel\Ppa\Mapping; interface RepositoryMappingInterface { diff --git a/src/Ppa/Mapping/Structure/CheckConstraint.php b/src/Ppa/Mapping/Structure/CheckConstraint.php index ddfb079..140965f 100644 --- a/src/Ppa/Mapping/Structure/CheckConstraint.php +++ b/src/Ppa/Mapping/Structure/CheckConstraint.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Structure; -class CheckConstraint implements StructureInterface +final class CheckConstraint implements StructureInterface { public function __construct( public string $expression, diff --git a/src/Ppa/Mapping/Structure/Column.php b/src/Ppa/Mapping/Structure/Column.php index d5405f5..88e966f 100644 --- a/src/Ppa/Mapping/Structure/Column.php +++ b/src/Ppa/Mapping/Structure/Column.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Structure; -class Column implements StructureInterface +final class Column implements StructureInterface { public function __construct( public string $name, diff --git a/src/Ppa/Mapping/Structure/Extension.php b/src/Ppa/Mapping/Structure/Extension.php index 2f1a048..47aba8a 100644 --- a/src/Ppa/Mapping/Structure/Extension.php +++ b/src/Ppa/Mapping/Structure/Extension.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Structure; /** * SQL emitter for a PostgreSQL extension declaration. * - * Built from {@see \Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Extension} - * attributes during {@see \Flytachi\Winter\K2\Ppa\DeclarationItem} construction. + * Built from {@see \Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Extension} + * attributes during {@see \Flytachi\Winter\Kernel\Ppa\DeclarationItem} construction. */ final class Extension implements StructureInterface { diff --git a/src/Ppa/Mapping/Structure/ForeignKey.php b/src/Ppa/Mapping/Structure/ForeignKey.php index 16d705c..ca00e06 100644 --- a/src/Ppa/Mapping/Structure/ForeignKey.php +++ b/src/Ppa/Mapping/Structure/ForeignKey.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Structure; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\FKAction; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\FKAction; -class ForeignKey implements StructureInterface +final class ForeignKey implements StructureInterface { public function __construct( public string $referencedTable, diff --git a/src/Ppa/Mapping/Structure/Index.php b/src/Ppa/Mapping/Structure/Index.php index 258f8f0..ea3d510 100644 --- a/src/Ppa/Mapping/Structure/Index.php +++ b/src/Ppa/Mapping/Structure/Index.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Structure; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexMethod; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexMethod; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; -class Index implements StructureInterface +final class Index implements StructureInterface { public function __construct( public array $columns, diff --git a/src/Ppa/Mapping/Structure/NameValidator.php b/src/Ppa/Mapping/Structure/NameValidator.php index a4a3793..e2abcd6 100644 --- a/src/Ppa/Mapping/Structure/NameValidator.php +++ b/src/Ppa/Mapping/Structure/NameValidator.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Structure; -class NameValidator +final class NameValidator { public static function validate(string $name, int $maxLength = 63): void { diff --git a/src/Ppa/Mapping/Structure/StoredProcedure.php b/src/Ppa/Mapping/Structure/StoredProcedure.php index 74a4249..d5b3219 100644 --- a/src/Ppa/Mapping/Structure/StoredProcedure.php +++ b/src/Ppa/Mapping/Structure/StoredProcedure.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Structure; -class StoredProcedure implements StructureInterface +final class StoredProcedure implements StructureInterface { public function __construct( public string $name, diff --git a/src/Ppa/Mapping/Structure/StructureInterface.php b/src/Ppa/Mapping/Structure/StructureInterface.php index 5cdeea6..1dc430f 100644 --- a/src/Ppa/Mapping/Structure/StructureInterface.php +++ b/src/Ppa/Mapping/Structure/StructureInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Structure; interface StructureInterface { diff --git a/src/Ppa/Mapping/Structure/Table.php b/src/Ppa/Mapping/Structure/Table.php index aeba8e4..0f88e88 100644 --- a/src/Ppa/Mapping/Structure/Table.php +++ b/src/Ppa/Mapping/Structure/Table.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Structure; -class Table implements StructureInterface +final class Table implements StructureInterface { /** @var Column[] */ public array $columns; diff --git a/src/Ppa/Mapping/Structure/Trigger.php b/src/Ppa/Mapping/Structure/Trigger.php index 7a06520..d6ad417 100644 --- a/src/Ppa/Mapping/Structure/Trigger.php +++ b/src/Ppa/Mapping/Structure/Trigger.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Structure; -class Trigger implements StructureInterface +final class Trigger implements StructureInterface { public function __construct( public string $name, diff --git a/src/Ppa/Mapping/Structure/View.php b/src/Ppa/Mapping/Structure/View.php index 19851dd..87ef4d1 100644 --- a/src/Ppa/Mapping/Structure/View.php +++ b/src/Ppa/Mapping/Structure/View.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Structure; -class View implements StructureInterface +final class View implements StructureInterface { public function __construct( public string $name, diff --git a/src/Ppa/PPAMapping.php b/src/Ppa/PPAMapping.php index 50028ef..58dd524 100644 --- a/src/Ppa/PPAMapping.php +++ b/src/Ppa/PPAMapping.php @@ -2,21 +2,21 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa; +namespace Flytachi\Winter\Kernel\Ppa; use Flytachi\Winter\Cdo\Config\Common\DbConfigInterface; use Flytachi\Winter\DI\Scanner; -use Flytachi\Winter\K2\Collector\ImplementorCollector; -use Flytachi\Winter\K2\Kernel; -use Flytachi\Winter\K2\Ppa\Entity\RepositoryInterface; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Entity\Table as EntityTable; -use Flytachi\Winter\K2\Ppa\Mapping\ColumnMapping; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Table; +use Flytachi\Winter\Kernel\Collector\ImplementorCollector; +use Flytachi\Winter\Kernel\Kernel; +use Flytachi\Winter\Kernel\Ppa\Entity\RepositoryInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Entity\Table as EntityTable; +use Flytachi\Winter\Kernel\Ppa\Mapping\ColumnMapping; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Table; use ReflectionAttribute; use ReflectionClass; use ReflectionException; -class PPAMapping +final class PPAMapping { /** * @return DbConfigInterface[] diff --git a/src/Ppa/Pool/BorrowedConnection.php b/src/Ppa/Pool/BorrowedConnection.php index a228f22..1d0de13 100644 --- a/src/Ppa/Pool/BorrowedConnection.php +++ b/src/Ppa/Pool/BorrowedConnection.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Pool; +namespace Flytachi\Winter\Kernel\Ppa\Pool; -use Flytachi\Winter\K2\ConnectionPool\PoolEntry; +use Flytachi\Winter\Kernel\ConnectionPool\PoolEntry; /** * The connection a coroutine currently holds, plus whether it was found dead while in diff --git a/src/Ppa/Pool/CdoConnectionFactory.php b/src/Ppa/Pool/CdoConnectionFactory.php index 48e1dab..974f907 100644 --- a/src/Ppa/Pool/CdoConnectionFactory.php +++ b/src/Ppa/Pool/CdoConnectionFactory.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Pool; +namespace Flytachi\Winter\Kernel\Ppa\Pool; use Flytachi\Winter\Cdo\Config\Common\DbConfigInterface; -use Flytachi\Winter\K2\ConnectionPool\ConnectionFactory; +use Flytachi\Winter\Kernel\ConnectionPool\ConnectionFactory; use Psr\Log\LoggerInterface; /** * Adapts a CDO {@see DbConfigInterface} to the driver-agnostic - * {@see ConnectionFactory} the {@see \Flytachi\Winter\K2\ConnectionPool\ConnectionPool} + * {@see ConnectionFactory} the {@see \Flytachi\Winter\Kernel\ConnectionPool\ConnectionPool} * drives. * * The pooled resource is the **config instance**, not the raw CDO — winter-cdo's diff --git a/src/Ppa/Pool/ConnectionLoss.php b/src/Ppa/Pool/ConnectionLoss.php index 4d540da..1f89748 100644 --- a/src/Ppa/Pool/ConnectionLoss.php +++ b/src/Ppa/Pool/ConnectionLoss.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Pool; +namespace Flytachi\Winter\Kernel\Ppa\Pool; use PDOException; use Throwable; diff --git a/src/Ppa/Pool/PoolTelemetry.php b/src/Ppa/Pool/PoolTelemetry.php index 3aa94ef..00c6df0 100644 --- a/src/Ppa/Pool/PoolTelemetry.php +++ b/src/Ppa/Pool/PoolTelemetry.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Pool; +namespace Flytachi\Winter\Kernel\Ppa\Pool; use Flytachi\FileStore\FileStorage; -use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\Kernel\Kernel; /** * Publishes each worker's pool utilisation to the shared runnable store so the CLI - * can read it — the same pattern {@see \Flytachi\Winter\K2\Process\Process} uses for + * can read it — the same pattern {@see \Flytachi\Winter\Kernel\Process\Stereotype\Process} uses for * `call process status`. * * A connection pool lives in **one worker's memory**. The CLI is a separate process, diff --git a/src/Ppa/Pool/PpaConnectionPool.php b/src/Ppa/Pool/PpaConnectionPool.php index 3addfe6..de5b38d 100644 --- a/src/Ppa/Pool/PpaConnectionPool.php +++ b/src/Ppa/Pool/PpaConnectionPool.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Pool; +namespace Flytachi\Winter\Kernel\Ppa\Pool; use Flytachi\Winter\Logger\LoggerFactory; use Flytachi\Winter\Cdo\Config\Common\DbConfigInterface; use Flytachi\Winter\Cdo\Connection\CDO; use Flytachi\Winter\Base\Runtime; -use Flytachi\Winter\K2\ConnectionPool\ConnectionPool; -use Flytachi\Winter\K2\ConnectionPool\PoolEntry; -use Flytachi\Winter\K2\ConnectionPool\PoolException; -use Flytachi\Winter\K2\ConnectionPool\PoolPolicy; -use Flytachi\Winter\K2\ConnectionPool\SingleConnection; +use Flytachi\Winter\Kernel\ConnectionPool\ConnectionPool; +use Flytachi\Winter\Kernel\ConnectionPool\PoolEntry; +use Flytachi\Winter\Kernel\ConnectionPool\PoolException; +use Flytachi\Winter\Kernel\ConnectionPool\PoolPolicy; +use Flytachi\Winter\Kernel\ConnectionPool\SingleConnection; use Psr\Log\LoggerInterface; use Throwable; @@ -229,8 +229,8 @@ public static function stats(): array * * A fork copies file descriptors, so any connection cached before the fork * would be shared with the parent and corrupt the wire protocol. A forked - * daemon worker runs this via {@see \Flytachi\Winter\K2\Process\ForkReset} - * (registered in {@see \Flytachi\Winter\K2\Kernel::init()}), then re-opens + * daemon worker runs this via {@see \Flytachi\Winter\Kernel\Process\ForkReset} + * (registered in {@see \Flytachi\Winter\Kernel\Kernel::init()}), then re-opens * lazily in the child. Because access is static — repositories call * `PpaConnectionPool::db()`, never an injected instance — clearing the caches * is a complete "reconnect": nothing holds a stale reference. diff --git a/src/Ppa/Pool/PpaPoolConfigInterface.php b/src/Ppa/Pool/PpaPoolConfigInterface.php index 30652ff..e72fd5c 100644 --- a/src/Ppa/Pool/PpaPoolConfigInterface.php +++ b/src/Ppa/Pool/PpaPoolConfigInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Pool; +namespace Flytachi\Winter\Kernel\Ppa\Pool; /** * Marks a DbConfig as pool-aware. diff --git a/src/Ppa/Pool/PpaPoolException.php b/src/Ppa/Pool/PpaPoolException.php index 9c56e9f..33f9c16 100644 --- a/src/Ppa/Pool/PpaPoolException.php +++ b/src/Ppa/Pool/PpaPoolException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Pool; +namespace Flytachi\Winter\Kernel\Ppa\Pool; class PpaPoolException extends \RuntimeException { diff --git a/src/Ppa/Pool/PpaPoolTrait.php b/src/Ppa/Pool/PpaPoolTrait.php index 1f53d58..4a0fcda 100644 --- a/src/Ppa/Pool/PpaPoolTrait.php +++ b/src/Ppa/Pool/PpaPoolTrait.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Pool; +namespace Flytachi\Winter\Kernel\Ppa\Pool; /** * Default pool-settings implementation for {@see PpaPoolConfigInterface}. diff --git a/src/Ppa/PpaCallTrait.php b/src/Ppa/PpaCallTrait.php index 070dd40..ada421b 100644 --- a/src/Ppa/PpaCallTrait.php +++ b/src/Ppa/PpaCallTrait.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa; +namespace Flytachi\Winter\Kernel\Ppa; use Flytachi\Winter\Cdo\Connection\CDO; -use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; -use Flytachi\Winter\K2\Ppa\Stereotype\CteRepo; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; +use Flytachi\Winter\Kernel\Ppa\Stereotype\CteRepo; /** * PpaCallTrait — PPA-aware static shortcuts for config classes. @@ -34,7 +34,7 @@ * DbConfig::cte()->from('orders o')->where(Qb::eq('status', 'new'))->findAll(); * ``` * - * @package Flytachi\Winter\K2\Ppa + * @package Flytachi\Winter\Kernel\Ppa */ trait PpaCallTrait { diff --git a/src/Ppa/Repository/RepositoryCore.php b/src/Ppa/Repository/RepositoryCore.php index f6a2d4d..d816f88 100644 --- a/src/Ppa/Repository/RepositoryCore.php +++ b/src/Ppa/Repository/RepositoryCore.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Ppa\Repository; use Flytachi\Winter\Cdo\CDOBind; use Flytachi\Winter\Cdo\Connection\CDO; use Flytachi\Winter\Cdo\Connection\CDOStatement; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Ppa\Entity\EntityInterface; -use Flytachi\Winter\K2\Ppa\Entity\RepositoryInterface; -use Flytachi\Winter\K2\Ppa\Mapping\RepositoryMappingInterface; -use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; +use Flytachi\Winter\Kernel\Ppa\Entity\EntityInterface; +use Flytachi\Winter\Kernel\Ppa\Entity\RepositoryInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\RepositoryMappingInterface; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; use Flytachi\Winter\Base\Runtime; use PDOStatement; use stdClass; @@ -49,7 +49,7 @@ * * `TEntity` is the entity class declared by a concrete repository via * {@see $entityClassName}. Subclasses bind it through an `@extends` PHPDoc tag - * pinning the template parameter — see {@see \Flytachi\Winter\K2\Ppa\Stereotype\Repository} + * pinning the template parameter — see {@see \Flytachi\Winter\Kernel\Ppa\Stereotype\Repository} * for details. When unbound, `TEntity` defaults to {@see stdClass}. * * @template TEntity of object diff --git a/src/Ppa/Repository/RepositoryCrudTrait.php b/src/Ppa/Repository/RepositoryCrudTrait.php index 5380328..6b1b8ee 100644 --- a/src/Ppa/Repository/RepositoryCrudTrait.php +++ b/src/Ppa/Repository/RepositoryCrudTrait.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Ppa\Repository; use Flytachi\Winter\Cdo\Connection\CDO; use Flytachi\Winter\Cdo\Connection\CDOException; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Ppa\Entity\RepositoryCrudInterface; -use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; +use Flytachi\Winter\Kernel\Ppa\Entity\RepositoryCrudInterface; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; /** * Provides concrete write-operation implementations for repository classes. diff --git a/src/Ppa/Repository/RepositoryException.php b/src/Ppa/Repository/RepositoryException.php index 62cdaa2..111ced6 100644 --- a/src/Ppa/Repository/RepositoryException.php +++ b/src/Ppa/Repository/RepositoryException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Ppa\Repository; use Flytachi\Winter\Base\Exception\ExceptionLogLevel; use Flytachi\Winter\Base\Exception\ExceptionTrait; diff --git a/src/Ppa/Repository/RepositoryViewTrait.php b/src/Ppa/Repository/RepositoryViewTrait.php index ab6d3a7..2d6ba0a 100644 --- a/src/Ppa/Repository/RepositoryViewTrait.php +++ b/src/Ppa/Repository/RepositoryViewTrait.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Ppa\Repository; use Flytachi\Winter\Base\HttpCode; use Flytachi\Winter\Cdo\CDOBind; use Flytachi\Winter\Cdo\Connection\CDOStatement; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Ppa\Entity\EntityException; -use Flytachi\Winter\K2\Ppa\Entity\RepositoryViewInterface; -use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; +use Flytachi\Winter\Kernel\Ppa\Entity\EntityException; +use Flytachi\Winter\Kernel\Ppa\Entity\RepositoryViewInterface; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; use PDO; use Throwable; diff --git a/src/Ppa/Stereotype/CteRepo.php b/src/Ppa/Stereotype/CteRepo.php index 1215532..8ecd933 100644 --- a/src/Ppa/Stereotype/CteRepo.php +++ b/src/Ppa/Stereotype/CteRepo.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Stereotype; +namespace Flytachi\Winter\Kernel\Ppa\Stereotype; -use Flytachi\Winter\K2\Ppa\Entity\RepositoryViewInterface; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryCore; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryViewTrait; +use Flytachi\Winter\Kernel\Ppa\Entity\RepositoryViewInterface; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryCore; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryViewTrait; use stdClass; /** diff --git a/src/Ppa/Stereotype/Repository.php b/src/Ppa/Stereotype/Repository.php index d974d5c..53ec6a0 100644 --- a/src/Ppa/Stereotype/Repository.php +++ b/src/Ppa/Stereotype/Repository.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Stereotype; +namespace Flytachi\Winter\Kernel\Ppa\Stereotype; -use Flytachi\Winter\K2\Ppa\Entity\RepositoryCrudInterface; -use Flytachi\Winter\K2\Ppa\Entity\RepositoryViewInterface; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryCore; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryCrudTrait; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryViewTrait; +use Flytachi\Winter\Kernel\Ppa\Entity\RepositoryCrudInterface; +use Flytachi\Winter\Kernel\Ppa\Entity\RepositoryViewInterface; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryCore; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryCrudTrait; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryViewTrait; /** * Base class for full-access repository implementations (CRUD and View). diff --git a/src/Ppa/Stereotype/RepositoryCrud.php b/src/Ppa/Stereotype/RepositoryCrud.php index a781fd9..a214b72 100644 --- a/src/Ppa/Stereotype/RepositoryCrud.php +++ b/src/Ppa/Stereotype/RepositoryCrud.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Stereotype; +namespace Flytachi\Winter\Kernel\Ppa\Stereotype; -use Flytachi\Winter\K2\Ppa\Entity\RepositoryCrudInterface; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryCore; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryCrudTrait; +use Flytachi\Winter\Kernel\Ppa\Entity\RepositoryCrudInterface; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryCore; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryCrudTrait; /** * Base class for write-only repository implementations. diff --git a/src/Ppa/Stereotype/RepositoryView.php b/src/Ppa/Stereotype/RepositoryView.php index cafa98d..61184cc 100644 --- a/src/Ppa/Stereotype/RepositoryView.php +++ b/src/Ppa/Stereotype/RepositoryView.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Stereotype; +namespace Flytachi\Winter\Kernel\Ppa\Stereotype; -use Flytachi\Winter\K2\Ppa\Entity\RepositoryViewInterface; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryCore; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryViewTrait; +use Flytachi\Winter\Kernel\Ppa\Entity\RepositoryViewInterface; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryCore; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryViewTrait; /** * Base class for read-only repository implementations. diff --git a/src/Process/Activity.php b/src/Process/Activity.php index ede3dfb..f0c99b0 100644 --- a/src/Process/Activity.php +++ b/src/Process/Activity.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process; +namespace Flytachi\Winter\Kernel\Process; /** * Whether the process is doing work right now. * * Orthogonal to {@see ProcessState} (which tracks the lifecycle). Activity is - * BUSY while an inline unit is marked ({@see Process::markBusy()}) or any - * {@see Process::spawn()} task is in flight; IDLE otherwise. It drives + * BUSY while an inline unit is marked ({@see \Flytachi\Winter\Kernel\Process\Stereotype\Process::markBusy()}) or any + * {@see \Flytachi\Winter\Kernel\Process\Stereotype\Process::spawn()} task is in flight; IDLE otherwise. It drives * drain-to-idle on stop, the status view, and (later) a daemon's scale-down * decision — never stop a BUSY worker. * diff --git a/src/Process/Daemon/DaemonConfigException.php b/src/Process/Daemon/DaemonConfigException.php index 6b05ec5..a17c74e 100644 --- a/src/Process/Daemon/DaemonConfigException.php +++ b/src/Process/Daemon/DaemonConfigException.php @@ -2,12 +2,15 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Daemon; +namespace Flytachi\Winter\Kernel\Process\Daemon; /** - * Thrown when a {@see Daemon} has no worker body — neither {@see Daemon::workerRun()} - * is defined nor {@see Daemon::$workerClass} is set — or the configured worker - * class does not extend {@see \Flytachi\Winter\K2\Process\Process}. + * Thrown when a {@see \Flytachi\Winter\Kernel\Process\Stereotype\Daemon} has no + * worker body — neither + * {@see \Flytachi\Winter\Kernel\Process\Stereotype\Daemon::workerRun()} is defined + * nor {@see \Flytachi\Winter\Kernel\Process\Stereotype\Daemon::$workerClass} is set — + * or the configured worker class does not extend + * {@see \Flytachi\Winter\Kernel\Process\Stereotype\Process}. */ final class DaemonConfigException extends \RuntimeException { diff --git a/src/Process/Daemon/DaemonStatus.php b/src/Process/Daemon/DaemonStatus.php index 931cd7f..4ac4fcd 100644 --- a/src/Process/Daemon/DaemonStatus.php +++ b/src/Process/Daemon/DaemonStatus.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Daemon; +namespace Flytachi\Winter\Kernel\Process\Daemon; -use Flytachi\Winter\K2\Process\Activity; -use Flytachi\Winter\K2\Process\ProcessState; -use Flytachi\Winter\K2\Process\ProcessStatus; -use Flytachi\Winter\K2\Process\ResourceUsage; +use Flytachi\Winter\Kernel\Process\Activity; +use Flytachi\Winter\Kernel\Process\ProcessState; +use Flytachi\Winter\Kernel\Process\ProcessStatus; +use Flytachi\Winter\Kernel\Process\ResourceUsage; /** - * Status record of a supervised {@see Daemon}. + * Status record of a supervised {@see \Flytachi\Winter\Kernel\Process\Stereotype\Daemon}. * * A daemon is a {@see ProcessStatus} that runs several worker processes under a * supervisor, so its record adds what only a supervisor has: the per-worker diff --git a/src/Process/Daemon/RestartMode.php b/src/Process/Daemon/RestartMode.php index d567dd8..93b61f1 100644 --- a/src/Process/Daemon/RestartMode.php +++ b/src/Process/Daemon/RestartMode.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Daemon; +namespace Flytachi\Winter\Kernel\Process\Daemon; /** * When a supervised worker should be restarted after it exits. diff --git a/src/Process/Daemon/RestartPolicy.php b/src/Process/Daemon/RestartPolicy.php index 797b2d1..ea69553 100644 --- a/src/Process/Daemon/RestartPolicy.php +++ b/src/Process/Daemon/RestartPolicy.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Daemon; +namespace Flytachi\Winter\Kernel\Process\Daemon; /** - * How a {@see Daemon} recovers a worker that died unexpectedly. + * How a {@see \Flytachi\Winter\Kernel\Process\Stereotype\Daemon} recovers a worker that died unexpectedly. * * Groups the three restart knobs into one overridable policy object (paired with * {@see ScalingPolicy}). Immutable, and non-final so an application can define a diff --git a/src/Process/Daemon/ScalingPolicy.php b/src/Process/Daemon/ScalingPolicy.php index 3fb672e..3c63875 100644 --- a/src/Process/Daemon/ScalingPolicy.php +++ b/src/Process/Daemon/ScalingPolicy.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Daemon; +namespace Flytachi\Winter\Kernel\Process\Daemon; /** - * How a {@see Daemon} smooths fleet-size changes — stability over speed. + * How a {@see \Flytachi\Winter\Kernel\Process\Stereotype\Daemon} smooths fleet-size changes — stability over speed. * - * {@see Daemon::desiredReplicas()} is a signal, not a command: the supervisor + * {@see \Flytachi\Winter\Kernel\Process\Stereotype\Daemon::desiredReplicas()} is a signal, not a command: the supervisor * damps it so a noisy or naive value never thrashes the fleet. The model is * asymmetric (like Kubernetes HPA): scale up quickly, scale down only when low * demand is sustained. diff --git a/src/Process/Daemon/Slot.php b/src/Process/Daemon/Slot.php index 17dcfce..0136d20 100644 --- a/src/Process/Daemon/Slot.php +++ b/src/Process/Daemon/Slot.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Daemon; +namespace Flytachi\Winter\Kernel\Process\Daemon; -use Flytachi\Winter\K2\Process\Activity; +use Flytachi\Winter\Kernel\Process\Activity; /** - * One worker slot in a {@see Daemon}'s fleet. + * One worker slot in a {@see \Flytachi\Winter\Kernel\Process\Stereotype\Daemon}'s fleet. * * Mutable — the {@see SupervisesFleet} loop advances its {@see SlotState} machine in place. * The slot {@see $index} is stable: a restart reuses the same slot, so a worker's diff --git a/src/Process/Daemon/SlotState.php b/src/Process/Daemon/SlotState.php index b04a8f1..1859ee9 100644 --- a/src/Process/Daemon/SlotState.php +++ b/src/Process/Daemon/SlotState.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Daemon; +namespace Flytachi\Winter\Kernel\Process\Daemon; /** - * Lifecycle state of one worker slot in a {@see Daemon}'s fleet. + * Lifecycle state of one worker slot in a {@see \Flytachi\Winter\Kernel\Process\Stereotype\Daemon}'s fleet. * * The slot number is stable (a restart reuses the same slot), and the state is * the reconcile loop's marker of intent — it lets the supervisor tell a worker diff --git a/src/Process/Daemon/SupervisesFleet.php b/src/Process/Daemon/SupervisesFleet.php index dd58040..9c9437a 100644 --- a/src/Process/Daemon/SupervisesFleet.php +++ b/src/Process/Daemon/SupervisesFleet.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Daemon; +namespace Flytachi\Winter\Kernel\Process\Daemon; -use Flytachi\Winter\K2\Process\Activity; -use Flytachi\Winter\K2\Process\ProcessState; +use Flytachi\Winter\Kernel\Process\Activity; +use Flytachi\Winter\Kernel\Process\ProcessState; /** - * Fleet supervision for a {@see Daemon} — the master's behaviour, mixed in as + * Fleet supervision for a {@see \Flytachi\Winter\Kernel\Process\Stereotype\Daemon} — the master's behaviour, mixed in as * all-private methods so none of it leaks into the application-facing API. * * The master is a plain `pcntl` process with no event loop of its own: it forks @@ -431,7 +431,7 @@ private function forceStop(): void } /** - * SIGHUP handling: runs the master's {@see Daemon::onReload()} hook and + * SIGHUP handling: runs the master's {@see \Flytachi\Winter\Kernel\Process\Stereotype\Daemon::onReload()} hook and * forwards the signal to every worker. Reload, not stop. */ private function reload(): void diff --git a/src/Process/Daemon/WorkerStatus.php b/src/Process/Daemon/WorkerStatus.php index a40aa47..3c8e17f 100644 --- a/src/Process/Daemon/WorkerStatus.php +++ b/src/Process/Daemon/WorkerStatus.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Daemon; +namespace Flytachi\Winter\Kernel\Process\Daemon; -use Flytachi\Winter\K2\Process\Activity; +use Flytachi\Winter\Kernel\Process\Activity; /** * One worker's line in a {@see DaemonStatus}. diff --git a/src/Process/Engine/Engines.php b/src/Process/Engine/Engines.php index a55fa48..ce5924d 100644 --- a/src/Process/Engine/Engines.php +++ b/src/Process/Engine/Engines.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Engine; +namespace Flytachi\Winter\Kernel\Process\Engine; /** * Picks the {@see ProcessEngine} matching the current runtime. @@ -12,7 +12,7 @@ * launched from the CLI creates its own coroutine scheduler via * {@see \Swoole\Coroutine\run()} and is not a Swoole server worker. * - * @see \Flytachi\Winter\K2\Concurrent\Executors + * @see \Flytachi\Winter\Kernel\Concurrent\Executors */ final class Engines { diff --git a/src/Process/Engine/ProcessEngine.php b/src/Process/Engine/ProcessEngine.php index 3024cb4..f28427b 100644 --- a/src/Process/Engine/ProcessEngine.php +++ b/src/Process/Engine/ProcessEngine.php @@ -2,19 +2,19 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Engine; +namespace Flytachi\Winter\Kernel\Process\Engine; -use Flytachi\Winter\K2\Concurrent\Future; +use Flytachi\Winter\Kernel\Concurrent\Future; /** - * Runtime backend that carries a {@see \Flytachi\Winter\K2\Process\Process} + * Runtime backend that carries a {@see \Flytachi\Winter\Kernel\Process\Stereotype\Process} * body. * * The engine hides the difference between runtimes so the process body is * written once: under Swoole tasks become coroutines and pauses are * non-blocking; without Swoole tasks become forked children and pauses block * the single process. The contract stays identical either way — mirroring how - * {@see \Flytachi\Winter\K2\Concurrent\ExecutorService} keeps one surface over + * {@see \Flytachi\Winter\Kernel\Concurrent\ExecutorService} keeps one surface over * several backends. */ interface ProcessEngine @@ -47,7 +47,7 @@ public function spawn(callable $task): Future; /** * Pauses the body without blocking sibling tasks under Swoole. Throws - * {@see \Flytachi\Winter\K2\Process\InterruptedException} once if the body + * {@see \Flytachi\Winter\Kernel\Process\InterruptedException} once if the body * was interrupted (an IDLE wait woken by a stop request). * * @param float $seconds Seconds to pause. diff --git a/src/Process/Engine/SwooleEngine.php b/src/Process/Engine/SwooleEngine.php index 502e36f..2a15591 100644 --- a/src/Process/Engine/SwooleEngine.php +++ b/src/Process/Engine/SwooleEngine.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Engine; +namespace Flytachi\Winter\Kernel\Process\Engine; -use Flytachi\Winter\K2\Concurrent\Executors; -use Flytachi\Winter\K2\Concurrent\Future; -use Flytachi\Winter\K2\Process\InterruptedException; +use Flytachi\Winter\Kernel\Concurrent\Executors; +use Flytachi\Winter\Kernel\Concurrent\Future; +use Flytachi\Winter\Kernel\Process\InterruptedException; /** * Coroutine backend. diff --git a/src/Process/Engine/SyncEngine.php b/src/Process/Engine/SyncEngine.php index 78d2ec1..8374457 100644 --- a/src/Process/Engine/SyncEngine.php +++ b/src/Process/Engine/SyncEngine.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Engine; +namespace Flytachi\Winter\Kernel\Process\Engine; -use Flytachi\Winter\K2\Concurrent\CompletableFuture; -use Flytachi\Winter\K2\Concurrent\Future; -use Flytachi\Winter\K2\Process\InterruptedException; +use Flytachi\Winter\Kernel\Concurrent\CompletableFuture; +use Flytachi\Winter\Kernel\Concurrent\Future; +use Flytachi\Winter\Kernel\Process\InterruptedException; /** * Fork backend for runtimes without Swoole. diff --git a/src/Process/ForkReset.php b/src/Process/ForkReset.php index 761612b..ff792cd 100644 --- a/src/Process/ForkReset.php +++ b/src/Process/ForkReset.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process; +namespace Flytachi\Winter\Kernel\Process; /** * Registry of resets run in a freshly forked worker, before its body. @@ -11,7 +11,7 @@ * descriptor — a DB connection, a pool, a socket — is shared across processes * and corrupts if used from more than one. Framework packages register a reset * here at bootstrap (e.g. a connection pool registers a reconnect); the process - * runtime runs them in the child via {@see Process::afterFork()}. + * runtime runs them in the child via {@see \Flytachi\Winter\Kernel\Process\Stereotype\Process::afterFork()}. * * A reset MUST reconnect **in place** (close the old fd, open a new one on the * same object) rather than replace the object — otherwise already-injected diff --git a/src/Process/Internal/SingletonLock.php b/src/Process/Internal/SingletonLock.php index ccedd5c..4161679 100644 --- a/src/Process/Internal/SingletonLock.php +++ b/src/Process/Internal/SingletonLock.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Internal; +namespace Flytachi\Winter\Kernel\Process\Internal; -use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\Kernel\Kernel; /** - * The per-class singleton guard shared by {@see \Flytachi\Winter\K2\Process\Process} - * and {@see \Flytachi\Winter\K2\Process\Daemon\Daemon}. + * The per-class singleton guard shared by {@see \Flytachi\Winter\Kernel\Process\Stereotype\Process} + * and {@see \Flytachi\Winter\Kernel\Process\Stereotype\Daemon}. * * A crash-safe advisory `flock` — held for the process lifetime and released * automatically by the OS on death, so it never goes stale like a PID file. It is diff --git a/src/Process/InterruptedException.php b/src/Process/InterruptedException.php index a06b209..a348fa9 100644 --- a/src/Process/InterruptedException.php +++ b/src/Process/InterruptedException.php @@ -2,11 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process; +namespace Flytachi\Winter\Kernel\Process; /** - * Thrown from an interruptible blocking point (e.g. {@see Process::sleep()}) when - * a stop has been requested while the body was blocked. + * Thrown from an interruptible blocking point — for example + * {@see \Flytachi\Winter\Kernel\Process\Stereotype\Process::sleep()} — when a stop + * has been requested while the body was blocked. * * Mirrors Java's `InterruptedException`: a blocked body wakes immediately instead * of running the blocking call to completion. Leave it uncaught for a graceful diff --git a/src/Process/ProcessAlreadyRunningException.php b/src/Process/ProcessAlreadyRunningException.php index c95b709..19da88b 100644 --- a/src/Process/ProcessAlreadyRunningException.php +++ b/src/Process/ProcessAlreadyRunningException.php @@ -2,14 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process; +namespace Flytachi\Winter\Kernel\Process; /** - * Thrown by {@see Process::start()} / {@see Process::dispatch()} when an instance - * of the same class is already running. + * Thrown by {@see \Flytachi\Winter\Kernel\Process\Stereotype\Process::start()} or + * {@see \Flytachi\Winter\Kernel\Process\Stereotype\Process::dispatch()} when an + * instance of the same class is already running. * * A process is a singleton per class: one class means one running instance. To - * run several workers of the same logic, use a {@see Daemon} with replicas, or + * run several workers of the same logic, use a + * {@see \Flytachi\Winter\Kernel\Process\Stereotype\Daemon} with replicas, or * distinct classes. */ final class ProcessAlreadyRunningException extends \RuntimeException diff --git a/src/Process/ProcessRunnable.php b/src/Process/ProcessRunnable.php index d35bac9..78727a2 100644 --- a/src/Process/ProcessRunnable.php +++ b/src/Process/ProcessRunnable.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process; +namespace Flytachi\Winter\Kernel\Process; use Flytachi\Winter\Thread\Runnable; /** - * Serializable entry point that runs a {@see Process} in a detached background + * Serializable entry point that runs a {@see \Flytachi\Winter\Kernel\Process\Stereotype\Process} in a detached background * process. * * The launcher (via {@see \Flytachi\Winter\Thread\Thread}) re-execs the runner diff --git a/src/Process/ProcessState.php b/src/Process/ProcessState.php index 5092e05..2483bd5 100644 --- a/src/Process/ProcessState.php +++ b/src/Process/ProcessState.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process; +namespace Flytachi\Winter\Kernel\Process; /** - * Lifecycle state of a {@see Process}. + * Lifecycle state of a {@see \Flytachi\Winter\Kernel\Process\Stereotype\Process}. * * Mirrors the spirit of `java.lang.Thread.State`: a small, closed set of states * a managed unit moves through. {@see RESTARTING} is reserved for the supervised - * {@see Daemon} layer (phase 2) and is never set by a bare process. + * {@see \Flytachi\Winter\Kernel\Process\Stereotype\Daemon} layer (phase 2) and is never set by a bare process. */ enum ProcessState: int { diff --git a/src/Process/ProcessStatus.php b/src/Process/ProcessStatus.php index d2e17f3..1bbfb5e 100644 --- a/src/Process/ProcessStatus.php +++ b/src/Process/ProcessStatus.php @@ -2,17 +2,18 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process; +namespace Flytachi\Winter\Kernel\Process; /** - * Persisted status record of a {@see Process}. + * Persisted status record of a {@see \Flytachi\Winter\Kernel\Process\Stereotype\Process}. * * Written to the runnable store while the process lives, read back by the CLI * and the web layer. {@see ResourceUsage} is live and never persisted — it is - * attached on read via {@see Process::status()}. + * attached on read via {@see \Flytachi\Winter\Kernel\Process\Stereotype\Process::status()}. * * Serialises to a stable JSON shape so a controller can return it directly. A - * supervised {@see Daemon} records the richer {@see DaemonStatus} subclass. + * supervised {@see \Flytachi\Winter\Kernel\Process\Stereotype\Daemon} records the + * richer {@see \Flytachi\Winter\Kernel\Process\Daemon\DaemonStatus} subclass. */ class ProcessStatus implements \JsonSerializable { diff --git a/src/Process/ProcessStore.php b/src/Process/ProcessStore.php index 60da9a3..62b298e 100644 --- a/src/Process/ProcessStore.php +++ b/src/Process/ProcessStore.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process; +namespace Flytachi\Winter\Kernel\Process; use Flytachi\FileStore\FileStorage; -use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\Kernel\Kernel; /** * Locates the runnable store for a process class. diff --git a/src/Process/ResourceUsage.php b/src/Process/ResourceUsage.php index 9437f4b..d3a7bd9 100644 --- a/src/Process/ResourceUsage.php +++ b/src/Process/ResourceUsage.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process; +namespace Flytachi\Winter\Kernel\Process; /** * A resource snapshot of a running process, taken from `ps`. diff --git a/src/Process/Daemon/Daemon.php b/src/Process/Stereotype/Daemon.php similarity index 96% rename from src/Process/Daemon/Daemon.php rename to src/Process/Stereotype/Daemon.php index eb1bdf8..3c14836 100644 --- a/src/Process/Daemon/Daemon.php +++ b/src/Process/Stereotype/Daemon.php @@ -2,14 +2,18 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process\Daemon; +namespace Flytachi\Winter\Kernel\Process\Stereotype; use Flytachi\Winter\DI\Container; -use Flytachi\Winter\K2\Process\Activity; -use Flytachi\Winter\K2\Process\Internal\SingletonLock; -use Flytachi\Winter\K2\Process\Process; -use Flytachi\Winter\K2\Process\ProcessState; -use Flytachi\Winter\K2\Process\ProcessStatus; +use Flytachi\Winter\Kernel\Process\Activity; +use Flytachi\Winter\Kernel\Process\Daemon\DaemonConfigException; +use Flytachi\Winter\Kernel\Process\Daemon\DaemonStatus; +use Flytachi\Winter\Kernel\Process\Daemon\RestartPolicy; +use Flytachi\Winter\Kernel\Process\Daemon\ScalingPolicy; +use Flytachi\Winter\Kernel\Process\Daemon\SupervisesFleet; +use Flytachi\Winter\Kernel\Process\Internal\SingletonLock; +use Flytachi\Winter\Kernel\Process\ProcessState; +use Flytachi\Winter\Kernel\Process\ProcessStatus; use Flytachi\Winter\Logger\LoggerFactory; /** diff --git a/src/Process/Process.php b/src/Process/Stereotype/Process.php similarity index 95% rename from src/Process/Process.php rename to src/Process/Stereotype/Process.php index b29e4bf..72610c2 100644 --- a/src/Process/Process.php +++ b/src/Process/Stereotype/Process.php @@ -2,14 +2,22 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Process; +namespace Flytachi\Winter\Kernel\Process\Stereotype; use Flytachi\FileStore\FileStorage; use Flytachi\Winter\DI\Container; -use Flytachi\Winter\K2\Concurrent\Future; -use Flytachi\Winter\K2\Process\Engine\Engines; -use Flytachi\Winter\K2\Process\Engine\ProcessEngine; -use Flytachi\Winter\K2\Process\Internal\SingletonLock; +use Flytachi\Winter\Kernel\Concurrent\Future; +use Flytachi\Winter\Kernel\Process\Activity; +use Flytachi\Winter\Kernel\Process\Engine\Engines; +use Flytachi\Winter\Kernel\Process\Engine\ProcessEngine; +use Flytachi\Winter\Kernel\Process\ForkReset; +use Flytachi\Winter\Kernel\Process\Internal\SingletonLock; +use Flytachi\Winter\Kernel\Process\ProcessAlreadyRunningException; +use Flytachi\Winter\Kernel\Process\ProcessRunnable; +use Flytachi\Winter\Kernel\Process\ProcessState; +use Flytachi\Winter\Kernel\Process\ProcessStatus; +use Flytachi\Winter\Kernel\Process\ProcessStore; +use Flytachi\Winter\Kernel\Process\ResourceUsage; use Flytachi\Winter\Logger\LoggerFactory; use Flytachi\Winter\Thread\Thread; use Psr\Log\LoggerInterface; @@ -20,7 +28,7 @@ * * You write {@see run()}; the framework supplies the runtime (coroutines under * Swoole, forks otherwise), concurrency ({@see spawn()}), cooperative - * cancellation ({@see isRunning()} / {@see sleep()} + {@see InterruptedException}), + * cancellation ({@see isRunning()} / {@see sleep()} + {@see \Flytachi\Winter\Kernel\Process\InterruptedException}), * a signal contract (5 hooks), an activity state ({@see Activity}), guaranteed * teardown ({@see onShutdown()}) and lifecycle control from CLI/web. * @@ -117,7 +125,7 @@ final protected function isRunning(): bool /** * Interruptible pause — non-blocking under Swoole. Throws - * {@see InterruptedException} if an IDLE wait is woken by a stop. + * {@see \Flytachi\Winter\Kernel\Process\InterruptedException} if an IDLE wait is woken by a stop. */ final protected function sleep(float $seconds): void { @@ -376,7 +384,7 @@ private function boot(): void * @param string|null $ownerClass Owning daemon class whose store holds the per-slot record. * @internal Not for application code — calling it re-enters the engine. * Protected (not public) so a daemon can boot an external - * {@see \Flytachi\Winter\K2\Process\Daemon\Daemon::$workerClass} + * {@see \Flytachi\Winter\Kernel\Process\Stereotype\Daemon::$workerClass} * worker (a sibling Process) without exposing it to the outside. */ protected function runWorker(?int $slot = null, ?string $title = null, ?string $ownerClass = null): void @@ -563,7 +571,7 @@ className: static::class, /** * Writes the per-slot heartbeat a daemon worker reports to the supervisor. * Keyed by the owner daemon's class + slot, so the supervisor aggregates all - * workers into its {@see \Flytachi\Winter\K2\Process\Daemon\DaemonStatus}. + * workers into its {@see \Flytachi\Winter\Kernel\Process\Daemon\DaemonStatus}. */ private function writeWorkerRecord(): void { diff --git a/src/Route/Annotation/AbstractMapping.php b/src/Route/Annotation/AbstractMapping.php index d668faa..96f3258 100644 --- a/src/Route/Annotation/AbstractMapping.php +++ b/src/Route/Annotation/AbstractMapping.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route\Annotation; +namespace Flytachi\Winter\Kernel\Route\Annotation; abstract class AbstractMapping { diff --git a/src/Route/Annotation/CrossOrigin.php b/src/Route/Annotation/CrossOrigin.php index b262648..a8e0db1 100644 --- a/src/Route/Annotation/CrossOrigin.php +++ b/src/Route/Annotation/CrossOrigin.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route\Annotation; +namespace Flytachi\Winter\Kernel\Route\Annotation; use Attribute; @@ -23,7 +23,7 @@ * public function stats(): ResponseEntity { ... } */ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] -readonly class CrossOrigin +final readonly class CrossOrigin { /** * @param string[] $origins Allowed origins. Empty = '*'. diff --git a/src/Route/Annotation/DeleteMapping.php b/src/Route/Annotation/DeleteMapping.php index d9918b4..a37a3b0 100644 --- a/src/Route/Annotation/DeleteMapping.php +++ b/src/Route/Annotation/DeleteMapping.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route\Annotation; +namespace Flytachi\Winter\Kernel\Route\Annotation; use Attribute; #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] -class DeleteMapping extends AbstractMapping +final class DeleteMapping extends AbstractMapping { public function getMethod(): string { diff --git a/src/Route/Annotation/GetMapping.php b/src/Route/Annotation/GetMapping.php index 49033ce..6753bb1 100644 --- a/src/Route/Annotation/GetMapping.php +++ b/src/Route/Annotation/GetMapping.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route\Annotation; +namespace Flytachi\Winter\Kernel\Route\Annotation; use Attribute; #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] -class GetMapping extends AbstractMapping +final class GetMapping extends AbstractMapping { public function getMethod(): string { diff --git a/src/Route/Annotation/PatchMapping.php b/src/Route/Annotation/PatchMapping.php index 57beff7..d887423 100644 --- a/src/Route/Annotation/PatchMapping.php +++ b/src/Route/Annotation/PatchMapping.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route\Annotation; +namespace Flytachi\Winter\Kernel\Route\Annotation; use Attribute; #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] -class PatchMapping extends AbstractMapping +final class PatchMapping extends AbstractMapping { public function getMethod(): string { diff --git a/src/Route/Annotation/PostMapping.php b/src/Route/Annotation/PostMapping.php index 1da3425..7a41083 100644 --- a/src/Route/Annotation/PostMapping.php +++ b/src/Route/Annotation/PostMapping.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route\Annotation; +namespace Flytachi\Winter\Kernel\Route\Annotation; use Attribute; #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] -class PostMapping extends AbstractMapping +final class PostMapping extends AbstractMapping { public function getMethod(): string { diff --git a/src/Route/Annotation/PutMapping.php b/src/Route/Annotation/PutMapping.php index 85ac9ac..3f3b114 100644 --- a/src/Route/Annotation/PutMapping.php +++ b/src/Route/Annotation/PutMapping.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route\Annotation; +namespace Flytachi\Winter\Kernel\Route\Annotation; use Attribute; #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] -class PutMapping extends AbstractMapping +final class PutMapping extends AbstractMapping { public function getMethod(): string { diff --git a/src/Route/Annotation/RequestMapping.php b/src/Route/Annotation/RequestMapping.php index d48201d..8aaca83 100644 --- a/src/Route/Annotation/RequestMapping.php +++ b/src/Route/Annotation/RequestMapping.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route\Annotation; +namespace Flytachi\Winter\Kernel\Route\Annotation; use Attribute; /** Class-level route prefix or a method-level route with no specific HTTP method. */ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] -class RequestMapping extends AbstractMapping +final class RequestMapping extends AbstractMapping { } diff --git a/src/Route/Collector/MappingCollector.php b/src/Route/Collector/MappingCollector.php index cba1fd0..57a24b3 100644 --- a/src/Route/Collector/MappingCollector.php +++ b/src/Route/Collector/MappingCollector.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route\Collector; +namespace Flytachi\Winter\Kernel\Route\Collector; use Flytachi\Winter\DI\Contract\CollectorInterface; -use Flytachi\Winter\K2\Route\Annotation\AbstractMapping; -use Flytachi\Winter\K2\Route\Annotation\CrossOrigin; -use Flytachi\Winter\K2\Route\Annotation\RequestMapping; -use Flytachi\Winter\K2\Route\Router; -use Flytachi\Winter\K2\Stereotype\ControllerInterface; -use Flytachi\Winter\K2\Stereotype\Middleware; +use Flytachi\Winter\Kernel\Route\Annotation\AbstractMapping; +use Flytachi\Winter\Kernel\Route\Annotation\CrossOrigin; +use Flytachi\Winter\Kernel\Route\Annotation\RequestMapping; +use Flytachi\Winter\Kernel\Route\Router; +use Flytachi\Winter\Kernel\Http\Stereotype\ControllerInterface; +use Flytachi\Winter\Kernel\Http\Stereotype\Middleware; use ReflectionAttribute; use ReflectionClass; use ReflectionMethod; diff --git a/src/Route/DevWatcher.php b/src/Route/DevWatcher.php index f3541ff..1344701 100644 --- a/src/Route/DevWatcher.php +++ b/src/Route/DevWatcher.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route; +namespace Flytachi\Winter\Kernel\Route; use Swoole\Http\Request; use Swoole\Http\Response; diff --git a/src/Route/Dispatcher.php b/src/Route/Dispatcher.php index 4fbf3fa..936a449 100644 --- a/src/Route/Dispatcher.php +++ b/src/Route/Dispatcher.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route; +namespace Flytachi\Winter\Kernel\Route; /** * Fast dispatcher — O(1) static lookup + grouped-regex dynamic matching. @@ -10,7 +10,7 @@ * Routes sharing the same URI regex (e.g. GET /users/{id} and DELETE /users/{id}) * are merged into one URI group so alternation branches never shadow each other. */ -class Dispatcher +final class Dispatcher { private const int CHUNK_SIZE = 30; diff --git a/src/Route/Route.php b/src/Route/Route.php index d2bc508..4a371d3 100644 --- a/src/Route/Route.php +++ b/src/Route/Route.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route; +namespace Flytachi\Winter\Kernel\Route; -readonly class Route +final readonly class Route { public string $regex; /** @var list */ diff --git a/src/Route/RouteResult.php b/src/Route/RouteResult.php index c58a3b8..569ea7e 100644 --- a/src/Route/RouteResult.php +++ b/src/Route/RouteResult.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route; +namespace Flytachi\Winter\Kernel\Route; -class RouteResult +final class RouteResult { public const NOT_FOUND = 0; public const FOUND = 1; diff --git a/src/Route/Router.php b/src/Route/Router.php index a6b52c3..1b4828a 100644 --- a/src/Route/Router.php +++ b/src/Route/Router.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route; +namespace Flytachi\Winter\Kernel\Route; use Flytachi\Winter\Base\Exception\DebugDumpException; use Flytachi\Winter\Base\Exception\ExceptionLogLevel; @@ -11,28 +11,28 @@ use Flytachi\Winter\Base\Runtime; use Flytachi\Winter\DI\Container; use Flytachi\Winter\DI\Scanner; -use Flytachi\Winter\K2\Core\KernelStore; -use Flytachi\Winter\K2\Kernel; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\Header; -use Flytachi\Winter\K2\Http\ParameterResolver; -use Flytachi\Winter\K2\Http\Response\Collector\ExceptionCollector; -use Flytachi\Winter\K2\Localization\Locale; -use Flytachi\Winter\K2\Http\Response\ExceptionWrapper; -use Flytachi\Winter\K2\Http\Response\ResponseEntity; -use Flytachi\Winter\K2\Http\Response\ResponseException; -use Flytachi\Winter\K2\Http\Response\Sendable; -use Flytachi\Winter\K2\Http\Cors; -use Flytachi\Winter\K2\Http\Health\Health; -use Flytachi\Winter\K2\Http\Health\HealthIndicatorInterface; -use Flytachi\Winter\K2\Plugin; -use Flytachi\Winter\K2\Route\Collector\MappingCollector; -use Flytachi\Winter\K2\Stereotype\Middleware; +use Flytachi\Winter\Kernel\Core\KernelStore; +use Flytachi\Winter\Kernel\Kernel; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Header; +use Flytachi\Winter\Kernel\Http\ParameterResolver; +use Flytachi\Winter\Kernel\Http\Response\Collector\ExceptionCollector; +use Flytachi\Winter\Kernel\Localization\Locale; +use Flytachi\Winter\Kernel\Http\Response\ExceptionWrapper; +use Flytachi\Winter\Kernel\Http\Response\ResponseEntity; +use Flytachi\Winter\Kernel\Http\Response\ResponseException; +use Flytachi\Winter\Kernel\Http\Response\Sendable; +use Flytachi\Winter\Kernel\Http\Cors; +use Flytachi\Winter\Kernel\Http\Health\Health; +use Flytachi\Winter\Kernel\Http\Health\HealthIndicatorInterface; +use Flytachi\Winter\Kernel\Plugin; +use Flytachi\Winter\Kernel\Route\Collector\MappingCollector; +use Flytachi\Winter\Kernel\Http\Stereotype\Middleware; use Flytachi\Winter\Base\HttpCode; /** - * K2 Router — dual-mode (Swoole + FPM), Spring Boot-style. + * Router — dual-mode (Swoole + FPM), Spring Boot-style. * * ── Route registration (manual) ───────────────────────────────────────────── * $router->get('/users', [UserController::class, 'index']); @@ -51,13 +51,13 @@ * * ── Static files ───────────────────────────────────────────────────────────── * Not the router's job. Swoole serves them itself, in C, before PHP is reached — - * declare the directory with {@see \Flytachi\Winter\K2\App\Config\ServerSettings::staticPath()}. + * declare the directory with {@see \Flytachi\Winter\Kernel\App\Config\ServerSettings::staticPath()}. * * ── Dispatch ───────────────────────────────────────────────────────────────── * $router->handle(new SwooleRequest($req), new SwooleResponse($res)); * $router->handle(new FpmRequest(), new FpmResponse()); */ -class Router +final class Router { /** @var array> [METHOD][path] => handler */ private array $staticRoutes = []; diff --git a/src/Schedule/ScheduleConfigException.php b/src/Schedule/ScheduleConfigException.php index 37c2122..15b22d0 100644 --- a/src/Schedule/ScheduleConfigException.php +++ b/src/Schedule/ScheduleConfigException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Schedule; +namespace Flytachi\Winter\Kernel\Schedule; /** * Thrown when a {@see Scheduled} method is misconfigured — no trigger set, more diff --git a/src/Schedule/Scheduled.php b/src/Schedule/Scheduled.php index 6e8c1b2..2c3fc4b 100644 --- a/src/Schedule/Scheduled.php +++ b/src/Schedule/Scheduled.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Schedule; +namespace Flytachi\Winter\Kernel\Schedule; use Attribute; @@ -17,8 +17,8 @@ * * Exactly one trigger must be set: {@see $fixedDelay}, {@see $fixedRate} or * {@see $cron}. The period timings are in seconds (float), matching the rest of - * the kernel ({@see \Flytachi\Winter\K2\Process\Process::sleep()} / grace); the - * cron expression is clock-aligned (see {@see \Flytachi\Winter\K2\Schedule\Trigger\CronTrigger}). + * the kernel ({@see \Flytachi\Winter\Kernel\Process\Stereotype\Process::sleep()} / grace); the + * cron expression is clock-aligned (see {@see \Flytachi\Winter\Kernel\Schedule\Trigger\CronTrigger}). * A misconfigured attribute is rejected at discovery with a {@see ScheduleConfigException}. * * ``` diff --git a/src/Schedule/ScheduledCollector.php b/src/Schedule/ScheduledCollector.php index 156514d..fb040f3 100644 --- a/src/Schedule/ScheduledCollector.php +++ b/src/Schedule/ScheduledCollector.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Schedule; +namespace Flytachi\Winter\Kernel\Schedule; use Flytachi\Winter\DI\Contract\CollectorInterface; -use Flytachi\Winter\K2\Schedule\Trigger\CronTrigger; -use Flytachi\Winter\K2\Schedule\Trigger\FixedDelayTrigger; -use Flytachi\Winter\K2\Schedule\Trigger\FixedRateTrigger; -use Flytachi\Winter\K2\Schedule\Trigger\Trigger; +use Flytachi\Winter\Kernel\Schedule\Trigger\CronTrigger; +use Flytachi\Winter\Kernel\Schedule\Trigger\FixedDelayTrigger; +use Flytachi\Winter\Kernel\Schedule\Trigger\FixedRateTrigger; +use Flytachi\Winter\Kernel\Schedule\Trigger\Trigger; use InvalidArgumentException; use ReflectionClass; use ReflectionMethod; diff --git a/src/Schedule/ScheduledTask.php b/src/Schedule/ScheduledTask.php index 7f0338a..ed6c50b 100644 --- a/src/Schedule/ScheduledTask.php +++ b/src/Schedule/ScheduledTask.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Schedule; +namespace Flytachi\Winter\Kernel\Schedule; -use Flytachi\Winter\K2\Schedule\Trigger\Trigger; +use Flytachi\Winter\Kernel\Schedule\Trigger\Trigger; /** * One discovered {@see Scheduled} method and its live scheduling state. diff --git a/src/Schedule/Scheduler.php b/src/Schedule/Stereotype/Scheduler.php similarity index 94% rename from src/Schedule/Scheduler.php rename to src/Schedule/Stereotype/Scheduler.php index 03cb094..bc73c1d 100644 --- a/src/Schedule/Scheduler.php +++ b/src/Schedule/Stereotype/Scheduler.php @@ -2,15 +2,17 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Schedule; +namespace Flytachi\Winter\Kernel\Schedule\Stereotype; use Flytachi\Winter\DI\Container; -use Flytachi\Winter\K2\Concurrent\Future; -use Flytachi\Winter\K2\Core\ClassScanner; -use Flytachi\Winter\K2\Process\Process; +use Flytachi\Winter\Kernel\Concurrent\Future; +use Flytachi\Winter\Kernel\Core\ClassScanner; +use Flytachi\Winter\Kernel\Process\Stereotype\Process; +use Flytachi\Winter\Kernel\Schedule\ScheduledCollector; +use Flytachi\Winter\Kernel\Schedule\ScheduledTask; /** - * The scheduling runtime — one system process that runs every {@see Scheduled} + * The scheduling runtime — one system process that runs every {@see \Flytachi\Winter\Kernel\Schedule\Scheduled} * method on its trigger. * * On boot it scans the project (and plugins) for annotated methods, then loops: @@ -147,7 +149,7 @@ private function reap(): void } /** - * Scans the project and plugins for {@see Scheduled} methods. + * Scans the project and plugins for {@see \Flytachi\Winter\Kernel\Schedule\Scheduled} methods. * * Override to source tasks another way — e.g. from a database of cron rows — * instead of (or in addition to) annotation scanning. diff --git a/src/Schedule/Trigger/CronTrigger.php b/src/Schedule/Trigger/CronTrigger.php index 5119c09..3da7c09 100644 --- a/src/Schedule/Trigger/CronTrigger.php +++ b/src/Schedule/Trigger/CronTrigger.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Schedule\Trigger; +namespace Flytachi\Winter\Kernel\Schedule\Trigger; use DateTimeImmutable; use InvalidArgumentException; diff --git a/src/Schedule/Trigger/FixedDelayTrigger.php b/src/Schedule/Trigger/FixedDelayTrigger.php index 73b1ab5..575f939 100644 --- a/src/Schedule/Trigger/FixedDelayTrigger.php +++ b/src/Schedule/Trigger/FixedDelayTrigger.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Schedule\Trigger; +namespace Flytachi\Winter\Kernel\Schedule\Trigger; /** * Fires a fixed delay after the previous run finished — the next start is measured diff --git a/src/Schedule/Trigger/FixedRateTrigger.php b/src/Schedule/Trigger/FixedRateTrigger.php index 1e08c5a..f409a6a 100644 --- a/src/Schedule/Trigger/FixedRateTrigger.php +++ b/src/Schedule/Trigger/FixedRateTrigger.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Schedule\Trigger; +namespace Flytachi\Winter\Kernel\Schedule\Trigger; /** * Fires at a fixed rate — the next start is measured from the previous START, so diff --git a/src/Schedule/Trigger/Trigger.php b/src/Schedule/Trigger/Trigger.php index d49d3f1..ae30090 100644 --- a/src/Schedule/Trigger/Trigger.php +++ b/src/Schedule/Trigger/Trigger.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Schedule\Trigger; +namespace Flytachi\Winter\Kernel\Schedule\Trigger; /** * Computes when a scheduled task should next fire. diff --git a/src/Stereotype/Service.php b/src/Stereotype/Service.php deleted file mode 100644 index 08eb64f..0000000 --- a/src/Stereotype/Service.php +++ /dev/null @@ -1,9 +0,0 @@ - + */ + private const array OPEN = [ + 'Flytachi\Winter\Kernel\Schedule\Stereotype\Scheduler' + => 'stereotype: #[EnableScheduler(MyScheduler::class)] extends it', + 'Flytachi\Winter\Kernel\Http\Stereotype\ExceptionResponseBase' + => 'stereotype: #[AdviceException] handlers extend it', + 'Flytachi\Winter\Kernel\Process\ProcessStatus' + => 'extended by DaemonStatus inside the kernel', + 'Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\DateTime' + => 'extended by Date, Time and Timestamp inside PPA', + 'Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\FloatType' + => 'extended by Double and Decimal inside PPA', + 'Flytachi\Winter\Kernel\Http\Health\HealthIndicator' + => 'replaceable through #[EnableActuator(indicator: ...)]', + 'Flytachi\Winter\Kernel\Process\Daemon\ScalingPolicy' + => 'policy object, documented as non-final so it can be refined', + 'Flytachi\Winter\Kernel\Process\Daemon\RestartPolicy' + => 'policy object, documented as non-final so it can be refined', + ]; + + public function test_every_concrete_class_is_final_or_listed_as_open(): void + { + $unlisted = []; + + foreach ($this->classesInSrc() as $fqcn) { + $reflection = new ReflectionClass($fqcn); + + if ($reflection->isInterface() || $reflection->isEnum() || $reflection->isTrait()) { + continue; + } + if ($reflection->isAbstract() || $reflection->isFinal()) { + continue; + } + if ($reflection->implementsInterface(Throwable::class)) { + continue; + } + if (array_key_exists($fqcn, self::OPEN)) { + continue; + } + + $unlisted[] = $fqcn; + } + + sort($unlisted); + + self::assertSame( + [], + $unlisted, + 'These classes are open for extension by accident. Mark each final, or add it ' + . 'to self::OPEN with the reason it must stay open.', + ); + } + + /** + * A list entry outliving its class is worse than no list: it reads as a decision + * someone made, while guarding nothing. + */ + public function test_the_open_list_has_no_stale_entries(): void + { + $stale = array_values(array_filter( + array_keys(self::OPEN), + static fn (string $fqcn): bool => !class_exists($fqcn), + )); + + self::assertSame([], $stale, 'These entries name classes that no longer exist.'); + } + + /** + * The listed classes must actually be open, or the entry is a leftover that hides a + * class someone has since closed. + */ + public function test_every_listed_class_is_really_open(): void + { + $closed = []; + + foreach (array_keys(self::OPEN) as $fqcn) { + if (class_exists($fqcn) && new ReflectionClass($fqcn)->isFinal()) { + $closed[] = $fqcn; + } + } + + self::assertSame([], $closed, 'These are final already — drop them from self::OPEN.'); + } + + /** @return list */ + private function classesInSrc(): array + { + $root = dirname(__DIR__, 2) . '/src'; + $classes = []; + + /** @var SplFileInfo $file */ + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root)) as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $relative = substr($file->getPathname(), strlen($root) + 1, -4); + $fqcn = 'Flytachi\Winter\Kernel\\' . str_replace('/', '\\', $relative); + + if (class_exists($fqcn) || interface_exists($fqcn) || trait_exists($fqcn) || enum_exists($fqcn)) { + $classes[] = $fqcn; + } + } + + return $classes; + } +} diff --git a/tests/Architecture/NamespaceTest.php b/tests/Architecture/NamespaceTest.php new file mode 100644 index 0000000..8731ba0 --- /dev/null +++ b/tests/Architecture/NamespaceTest.php @@ -0,0 +1,69 @@ +getPathname(); + + if (!$file->isFile()) { + continue; + } + foreach (self::SKIP as $skip) { + if (str_contains($path, $skip)) { + continue 2; + } + } + if (str_contains((string) file_get_contents($path), $stale)) { + $found[] = substr($path, strlen($root) + 1); + } + } + + sort($found); + + self::assertSame([], $found, 'These files still address the kernel by its old generation name.'); + } +} diff --git a/tests/Architecture/StereotypeLayoutTest.php b/tests/Architecture/StereotypeLayoutTest.php new file mode 100644 index 0000000..d449463 --- /dev/null +++ b/tests/Architecture/StereotypeLayoutTest.php @@ -0,0 +1,76 @@ + */ + public static function stereotypes(): array + { + return [ + 'controller' => ['Flytachi\Winter\Kernel\Http\Stereotype\Controller'], + 'controller interface' => ['Flytachi\Winter\Kernel\Http\Stereotype\ControllerInterface'], + 'middleware' => ['Flytachi\Winter\Kernel\Http\Stereotype\Middleware'], + 'exception response' => ['Flytachi\Winter\Kernel\Http\Stereotype\ExceptionResponseBase'], + 'process' => ['Flytachi\Winter\Kernel\Process\Stereotype\Process'], + 'daemon' => ['Flytachi\Winter\Kernel\Process\Stereotype\Daemon'], + 'scheduler' => ['Flytachi\Winter\Kernel\Schedule\Stereotype\Scheduler'], + 'cmd custom' => ['Flytachi\Winter\Console\Stereotype\CmdCustom'], + 'cmd custom interface' => ['Flytachi\Winter\Console\Stereotype\CmdCustomInterface'], + ]; + } + + #[DataProvider('stereotypes')] + public function test_the_stereotype_is_addressable(string $fqcn): void + { + self::assertTrue( + class_exists($fqcn) || interface_exists($fqcn), + "{$fqcn} must exist — an application extends it, so its address is public API.", + ); + } + + /** + * Every Stereotype directory belongs to a layer — there is no orphan one at the root. + * + * The root directory existed for a single empty `Service` base class, a transliteration + * of Spring's `@Service` **annotation** into inheritance. PHP allows one parent, so + * extending it spent that slot on nothing: the container resolves by class name and + * lifetime comes from `#[Singleton]`, so no part of the kernel ever read the base + * class. Removing it leaves the rule without an exception. + */ + public function test_no_stereotype_directory_sits_outside_a_layer(): void + { + self::assertDirectoryDoesNotExist( + dirname(__DIR__, 2) . '/src/Stereotype', + 'A Stereotype directory belongs to its layer; a root one has no layer to speak for.', + ); + } + + /** + * The machinery a stereotype relies on is not itself a stereotype, so it must not + * follow the extension point into the Stereotype directory. + */ + public function test_the_middleware_contract_stays_out_of_the_stereotype_directory(): void + { + self::assertTrue( + interface_exists('Flytachi\Winter\Kernel\Http\Middleware\MiddlewareInterface'), + 'MiddlewareInterface is a contract to implement, not a base class to extend.', + ); + self::assertFalse( + interface_exists('Flytachi\Winter\Kernel\Http\Stereotype\MiddlewareInterface'), + ); + } +} diff --git a/tests/Concurrent/Async/AsyncContractTest.php b/tests/Concurrent/Async/AsyncContractTest.php index 27a5b12..b79b056 100644 --- a/tests/Concurrent/Async/AsyncContractTest.php +++ b/tests/Concurrent/Async/AsyncContractTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Concurrent\Async; - -use Flytachi\Winter\K2\Concurrent\Async\Async; -use Flytachi\Winter\K2\Concurrent\Async\AsyncException; -use Flytachi\Winter\K2\Concurrent\Async\Proxy\ProxyFactory; -use Flytachi\Winter\K2\Concurrent\CompletableFuture; -use Flytachi\Winter\K2\Concurrent\Future; -use Flytachi\Winter\K2\Core\KernelConfig; +namespace Flytachi\Winter\Kernel\Tests\Concurrent\Async; + +use Flytachi\Winter\Kernel\Concurrent\Async\Async; +use Flytachi\Winter\Kernel\Concurrent\Async\AsyncException; +use Flytachi\Winter\Kernel\Concurrent\Async\Proxy\ProxyFactory; +use Flytachi\Winter\Kernel\Concurrent\CompletableFuture; +use Flytachi\Winter\Kernel\Concurrent\Future; +use Flytachi\Winter\Kernel\Core\KernelConfig; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use ReflectionClass; diff --git a/tests/Concurrent/Executor/FixedExecutorConcurrencyTest.php b/tests/Concurrent/Executor/FixedExecutorConcurrencyTest.php index 5835157..c098287 100644 --- a/tests/Concurrent/Executor/FixedExecutorConcurrencyTest.php +++ b/tests/Concurrent/Executor/FixedExecutorConcurrencyTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Concurrent\Executor; +namespace Flytachi\Winter\Kernel\Tests\Concurrent\Executor; -use Flytachi\Winter\K2\Concurrent\Executor\FixedExecutorService; -use Flytachi\Winter\K2\Concurrent\RejectedExecutionException; -use Flytachi\Winter\K2\Concurrent\RejectPolicy; +use Flytachi\Winter\Kernel\Concurrent\Executor\FixedExecutorService; +use Flytachi\Winter\Kernel\Concurrent\RejectedExecutionException; +use Flytachi\Winter\Kernel\Concurrent\RejectPolicy; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; diff --git a/tests/Concurrent/Executor/FixedExecutorServiceTest.php b/tests/Concurrent/Executor/FixedExecutorServiceTest.php index 5c1e711..8ceb83c 100644 --- a/tests/Concurrent/Executor/FixedExecutorServiceTest.php +++ b/tests/Concurrent/Executor/FixedExecutorServiceTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Concurrent\Executor; +namespace Flytachi\Winter\Kernel\Tests\Concurrent\Executor; use ArrayObject; -use Flytachi\Winter\K2\Concurrent\BoundedExecutorService; -use Flytachi\Winter\K2\Concurrent\Executor\FixedExecutorService; -use Flytachi\Winter\K2\Concurrent\Executors; -use Flytachi\Winter\K2\Concurrent\RejectedExecutionException; -use Flytachi\Winter\K2\Concurrent\RejectPolicy; +use Flytachi\Winter\Kernel\Concurrent\BoundedExecutorService; +use Flytachi\Winter\Kernel\Concurrent\Executor\FixedExecutorService; +use Flytachi\Winter\Kernel\Concurrent\Executors; +use Flytachi\Winter\Kernel\Concurrent\RejectedExecutionException; +use Flytachi\Winter\Kernel\Concurrent\RejectPolicy; use InvalidArgumentException; use PHPUnit\Framework\TestCase; diff --git a/tests/Concurrent/RejectPolicyTest.php b/tests/Concurrent/RejectPolicyTest.php index 9c0d07b..8889f91 100644 --- a/tests/Concurrent/RejectPolicyTest.php +++ b/tests/Concurrent/RejectPolicyTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Concurrent; +namespace Flytachi\Winter\Kernel\Tests\Concurrent; -use Flytachi\Winter\K2\Concurrent\RejectPolicy; +use Flytachi\Winter\Kernel\Concurrent\RejectPolicy; use PHPUnit\Framework\TestCase; final class RejectPolicyTest extends TestCase diff --git a/tests/Configuration/CorsTest.php b/tests/Configuration/CorsTest.php index 0752f67..f48ce1b 100644 --- a/tests/Configuration/CorsTest.php +++ b/tests/Configuration/CorsTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Configuration; +namespace Flytachi\Winter\Kernel\Tests\Configuration; -use Flytachi\Winter\K2\Http\Cors; +use Flytachi\Winter\Kernel\Http\Cors; use PHPUnit\Framework\TestCase; use ReflectionClass; diff --git a/tests/Configuration/HealthTest.php b/tests/Configuration/HealthTest.php index 0bb8b6e..940fd6e 100644 --- a/tests/Configuration/HealthTest.php +++ b/tests/Configuration/HealthTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Configuration; +namespace Flytachi\Winter\Kernel\Tests\Configuration; -use Flytachi\Winter\K2\Http\Health\Health; -use Flytachi\Winter\K2\Http\Health\HealthIndicator; -use Flytachi\Winter\K2\Http\Health\HealthIndicatorInterface; -use Flytachi\Winter\K2\Stereotype\Middleware; +use Flytachi\Winter\Kernel\Http\Health\Health; +use Flytachi\Winter\Kernel\Http\Health\HealthIndicator; +use Flytachi\Winter\Kernel\Http\Health\HealthIndicatorInterface; +use Flytachi\Winter\Kernel\Http\Stereotype\Middleware; use PHPUnit\Framework\TestCase; use ReflectionClass; diff --git a/tests/Configuration/KernelConfigTest.php b/tests/Configuration/KernelConfigTest.php index 513e3a6..7404dde 100644 --- a/tests/Configuration/KernelConfigTest.php +++ b/tests/Configuration/KernelConfigTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Configuration; +namespace Flytachi\Winter\Kernel\Tests\Configuration; -use Flytachi\Winter\K2\Core\KernelConfig; +use Flytachi\Winter\Kernel\Core\KernelConfig; use PHPUnit\Framework\TestCase; use ReflectionClass; @@ -52,7 +52,7 @@ public function test_init_does_not_create_volatile_directory(): void KernelConfig::init( pathRoot: $this->tmpDir, pathStorage: $this->tmpDir . '/storage', - // isTmpVolatile defaults to true on KernelConfig, but the K2\Kernel + // isTmpVolatile defaults to true on KernelConfig, but the Kernel // wrapper passes false. We explicitly pass false here to mimic the // production-FPM scenario where the bug bit hardest. isTmpVolatile: false, diff --git a/tests/Configuration/KernelStoreTest.php b/tests/Configuration/KernelStoreTest.php index b825bfc..caebf45 100644 --- a/tests/Configuration/KernelStoreTest.php +++ b/tests/Configuration/KernelStoreTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Configuration; +namespace Flytachi\Winter\Kernel\Tests\Configuration; use Flytachi\FileStore\FileStorage; -use Flytachi\Winter\K2\Core\KernelStore; -use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\Kernel\Core\KernelStore; +use Flytachi\Winter\Kernel\Kernel; use PHPUnit\Framework\TestCase; use ReflectionClass; @@ -23,7 +23,7 @@ protected function setUp(): void mkdir($this->tmpDir . '/volatile'); // KernelConfig holds path state in static properties — point them at our tempdir. - $cfg = new ReflectionClass(\Flytachi\Winter\K2\Core\KernelConfig::class); + $cfg = new ReflectionClass(\Flytachi\Winter\Kernel\Core\KernelConfig::class); $cfg->getProperty('pathStorageCache')->setValue(null, $this->tmpDir . '/cache'); $cfg->getProperty('pathStorageRunnable')->setValue(null, $this->tmpDir . '/runnable'); $cfg->getProperty('pathStorageVolatile')->setValue(null, $this->tmpDir . '/volatile'); diff --git a/tests/Configuration/PluginTest.php b/tests/Configuration/PluginTest.php index 271f5db..8995b37 100644 --- a/tests/Configuration/PluginTest.php +++ b/tests/Configuration/PluginTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Configuration; +namespace Flytachi\Winter\Kernel\Tests\Configuration; -use Flytachi\Winter\K2\Exception\Error; -use Flytachi\Winter\K2\Plugin; +use Flytachi\Winter\Kernel\Exception\Error; +use Flytachi\Winter\Kernel\Plugin; use PHPUnit\Framework\TestCase; use ReflectionClass; diff --git a/tests/ConnectionPool/ConnectionPoolTest.php b/tests/ConnectionPool/ConnectionPoolTest.php index aca871e..05110a9 100644 --- a/tests/ConnectionPool/ConnectionPoolTest.php +++ b/tests/ConnectionPool/ConnectionPoolTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\ConnectionPool; +namespace Flytachi\Winter\Kernel\Tests\ConnectionPool; -use Flytachi\Winter\K2\ConnectionPool\ConnectionPool; -use Flytachi\Winter\K2\ConnectionPool\PoolEntry; -use Flytachi\Winter\K2\ConnectionPool\PoolException; -use Flytachi\Winter\K2\ConnectionPool\PoolPolicy; +use Flytachi\Winter\Kernel\ConnectionPool\ConnectionPool; +use Flytachi\Winter\Kernel\ConnectionPool\PoolEntry; +use Flytachi\Winter\Kernel\ConnectionPool\PoolException; +use Flytachi\Winter\Kernel\ConnectionPool\PoolPolicy; use PHPUnit\Framework\TestCase; /** diff --git a/tests/ConnectionPool/HousekeeperTest.php b/tests/ConnectionPool/HousekeeperTest.php index a72f7af..1dd4b74 100644 --- a/tests/ConnectionPool/HousekeeperTest.php +++ b/tests/ConnectionPool/HousekeeperTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\ConnectionPool; +namespace Flytachi\Winter\Kernel\Tests\ConnectionPool; -use Flytachi\Winter\K2\ConnectionPool\ConnectionPool; -use Flytachi\Winter\K2\ConnectionPool\PoolPolicy; +use Flytachi\Winter\Kernel\ConnectionPool\ConnectionPool; +use Flytachi\Winter\Kernel\ConnectionPool\PoolPolicy; use PHPUnit\Framework\TestCase; use ReflectionMethod; diff --git a/tests/ConnectionPool/MockFactory.php b/tests/ConnectionPool/MockFactory.php index 3c97cee..dfacbf1 100644 --- a/tests/ConnectionPool/MockFactory.php +++ b/tests/ConnectionPool/MockFactory.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\ConnectionPool; +namespace Flytachi\Winter\Kernel\Tests\ConnectionPool; -use Flytachi\Winter\K2\ConnectionPool\ConnectionFactory; +use Flytachi\Winter\Kernel\ConnectionPool\ConnectionFactory; /** * A scriptable {@see ConnectionFactory} for pool tests: it counts create/validate/close diff --git a/tests/ConnectionPool/PoolPolicyTest.php b/tests/ConnectionPool/PoolPolicyTest.php index f968700..9d8d009 100644 --- a/tests/ConnectionPool/PoolPolicyTest.php +++ b/tests/ConnectionPool/PoolPolicyTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\ConnectionPool; +namespace Flytachi\Winter\Kernel\Tests\ConnectionPool; -use Flytachi\Winter\K2\ConnectionPool\PoolPolicy; +use Flytachi\Winter\Kernel\ConnectionPool\PoolPolicy; use PHPUnit\Framework\TestCase; final class PoolPolicyTest extends TestCase diff --git a/tests/ConnectionPool/SingleConnectionTest.php b/tests/ConnectionPool/SingleConnectionTest.php index 578c67b..34fb2cc 100644 --- a/tests/ConnectionPool/SingleConnectionTest.php +++ b/tests/ConnectionPool/SingleConnectionTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\ConnectionPool; +namespace Flytachi\Winter\Kernel\Tests\ConnectionPool; -use Flytachi\Winter\K2\ConnectionPool\PoolException; -use Flytachi\Winter\K2\ConnectionPool\PoolPolicy; -use Flytachi\Winter\K2\ConnectionPool\SingleConnection; +use Flytachi\Winter\Kernel\ConnectionPool\PoolException; +use Flytachi\Winter\Kernel\ConnectionPool\PoolPolicy; +use Flytachi\Winter\Kernel\ConnectionPool\SingleConnection; use PHPUnit\Framework\TestCase; /** diff --git a/tests/Console/CommandSurfaceTest.php b/tests/Console/CommandSurfaceTest.php new file mode 100644 index 0000000..d01c389 --- /dev/null +++ b/tests/Console/CommandSurfaceTest.php @@ -0,0 +1,124 @@ +commands() as $fqcn) { + if (!new ReflectionClass($fqcn)->isFinal()) { + $open[] = $fqcn; + } + } + + sort($open); + + self::assertSame([], $open, 'Built-in commands must be final — they are not extension points.'); + } + + /** + * The names that collide with a stereotype are the reason this test exists, so they + * are asserted by name: a future command reusing a stereotype's short name would + * reintroduce the ambiguity even while every command is final. + */ + public function test_the_commands_shadowing_a_stereotype_stay_closed(): void + { + foreach (['Process', 'Daemon'] as $shadowed) { + $command = 'Flytachi\Winter\Console\Command\\' . $shadowed; + $stereotype = 'Flytachi\Winter\Kernel\Process\Stereotype\\' . $shadowed; + + self::assertTrue(class_exists($command), "{$command} is expected to exist."); + self::assertTrue(class_exists($stereotype), "{$stereotype} is expected to exist."); + self::assertTrue( + new ReflectionClass($command)->isFinal(), + "{$command} shares a short name with {$stereotype}; leaving it open makes " + . 'both appear in `extends` completion.', + ); + } + } + + /** + * The rest of the console is closed too. + * + * Only Command/ was the reported problem, but leaving the surrounding classes open + * would let the next one drift back in unnoticed. The single extension point here is + * CmdCustom, which is abstract and therefore never counted. + */ + public function test_the_rest_of_the_console_is_closed(): void + { + $open = []; + + foreach ($this->consoleClasses() as $fqcn) { + $reflection = new ReflectionClass($fqcn); + + if ($reflection->isInterface() || $reflection->isEnum() || $reflection->isTrait()) { + continue; + } + if ($reflection->isAbstract() || $reflection->isFinal()) { + continue; + } + + $open[] = $fqcn; + } + + sort($open); + + self::assertSame([], $open, 'The CLI is wiring, not API — close these or make them abstract.'); + } + + /** @return list */ + private function commands(): array + { + $classes = []; + + foreach (glob(dirname(__DIR__, 2) . '/console/Command/*.php') ?: [] as $file) { + $fqcn = 'Flytachi\Winter\Console\Command\\' . basename($file, '.php'); + + if (class_exists($fqcn)) { + $classes[] = $fqcn; + } + } + + return $classes; + } + + /** @return list */ + private function consoleClasses(): array + { + $root = dirname(__DIR__, 2) . '/console'; + $classes = []; + + /** @var \SplFileInfo $file */ + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root)) as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $relative = substr($file->getPathname(), strlen($root) + 1, -4); + $fqcn = 'Flytachi\Winter\Console\\' . str_replace('/', '\\', $relative); + + if (class_exists($fqcn) || interface_exists($fqcn) || trait_exists($fqcn) || enum_exists($fqcn)) { + $classes[] = $fqcn; + } + } + + return $classes; + } +} diff --git a/tests/Console/MakeTemplateTest.php b/tests/Console/MakeTemplateTest.php new file mode 100644 index 0000000..02fcad0 --- /dev/null +++ b/tests/Console/MakeTemplateTest.php @@ -0,0 +1,73 @@ + */ + public static function templates(): array + { + $root = dirname(__DIR__, 2) . '/console/Template'; + $cases = []; + + /** @var SplFileInfo $file */ + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root)) as $file) { + if (!$file->isFile() || in_array($file->getFilename(), self::PARKED, true)) { + continue; + } + + // Only PHP templates carry imports; Docker, shell and completion files do not. + $contents = (string) file_get_contents($file->getPathname()); + if (!str_starts_with($contents, 'getFilename()] = [$file->getPathname()]; + } + + ksort($cases); + + return $cases; + } + + #[DataProvider('templates')] + public function test_every_imported_class_exists(string $path): void + { + preg_match_all('/^use ([^;]+);/m', (string) file_get_contents($path), $matches); + + // A template may legitimately import nothing — a DTO is a plain class, and the + // PhpStorm meta file is not a class at all. + $this->addToAssertionCount(1); + + foreach ($matches[1] as $import) { + $fqcn = trim(explode(' as ', $import)[0]); + + self::assertTrue( + class_exists($fqcn) || interface_exists($fqcn) || trait_exists($fqcn) || enum_exists($fqcn), + sprintf('%s imports %s, which does not exist.', basename($path), $fqcn), + ); + } + } +} diff --git a/tests/Http/ActuatorTest.php b/tests/Http/ActuatorTest.php index a07a5cf..c94829a 100644 --- a/tests/Http/ActuatorTest.php +++ b/tests/Http/ActuatorTest.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http; +namespace Flytachi\Winter\Kernel\Tests\Http; use Flytachi\Winter\DI\Container; -use Flytachi\Winter\K2\App\Attribute\EnableActuator; -use Flytachi\Winter\K2\Http\Health\Health; -use Flytachi\Winter\K2\Http\Health\HealthContributor; -use Flytachi\Winter\K2\Http\Health\HealthIndicator; -use Flytachi\Winter\K2\Http\Health\HealthStatus; -use Flytachi\Winter\K2\Http\Health\Status; -use Flytachi\Winter\K2\WinterApplication; +use Flytachi\Winter\Kernel\App\Attribute\EnableActuator; +use Flytachi\Winter\Kernel\Http\Health\Health; +use Flytachi\Winter\Kernel\Http\Health\HealthContributor; +use Flytachi\Winter\Kernel\Http\Health\HealthIndicator; +use Flytachi\Winter\Kernel\Http\Health\HealthStatus; +use Flytachi\Winter\Kernel\Http\Health\Status; +use Flytachi\Winter\Kernel\WinterApplication; use PHPUnit\Framework\TestCase; use ReflectionClass; use ReflectionMethod; diff --git a/tests/Http/Request/FpmRequestBaseUrlTest.php b/tests/Http/Request/FpmRequestBaseUrlTest.php index ee0aee2..024b390 100644 --- a/tests/Http/Request/FpmRequestBaseUrlTest.php +++ b/tests/Http/Request/FpmRequestBaseUrlTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; -use Flytachi\Winter\K2\Http\Adapter\FpmRequest; +use Flytachi\Winter\Kernel\Http\Adapter\FpmRequest; use PHPUnit\Framework\TestCase; /** diff --git a/tests/Http/Request/HeaderOriginTest.php b/tests/Http/Request/HeaderOriginTest.php index e559bb9..ea9c47b 100644 --- a/tests/Http/Request/HeaderOriginTest.php +++ b/tests/Http/Request/HeaderOriginTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; -use Flytachi\Winter\K2\Http\Adapter\FpmRequest; -use Flytachi\Winter\K2\Http\Header; +use Flytachi\Winter\Kernel\Http\Adapter\FpmRequest; +use Flytachi\Winter\Kernel\Http\Header; use PHPUnit\Framework\TestCase; /** diff --git a/tests/Http/Request/K1ValidationTraitTest.php b/tests/Http/Request/K1ValidationTraitTest.php index efbd82b..5d799e1 100644 --- a/tests/Http/Request/K1ValidationTraitTest.php +++ b/tests/Http/Request/K1ValidationTraitTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; -use Flytachi\Winter\K2\Http\Request\K1ValidationTrait; -use Flytachi\Winter\K2\Http\Request\RequestException; -use Flytachi\Winter\K2\Localization\Locale; +use Flytachi\Winter\Kernel\Http\Request\K1ValidationTrait; +use Flytachi\Winter\Kernel\Http\Request\RequestException; +use Flytachi\Winter\Kernel\Localization\Locale; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Http/Request/ListOfTest.php b/tests/Http/Request/ListOfTest.php index 23ab282..4c266f9 100644 --- a/tests/Http/Request/ListOfTest.php +++ b/tests/Http/Request/ListOfTest.php @@ -2,21 +2,21 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; - -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\ParameterResolver; -use Flytachi\Winter\K2\Http\Request\Validation\ListOf; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestForm; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestJson; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestXml; -use Flytachi\Winter\K2\Http\Request\Validation\Min; -use Flytachi\Winter\K2\Http\Request\Validation\NotBlank; -use Flytachi\Winter\K2\Http\Request\Validation\Required; -use Flytachi\Winter\K2\Http\Request\Validation\Size; -use Flytachi\Winter\K2\Http\Request\Validation\Valid; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; + +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\ParameterResolver; +use Flytachi\Winter\Kernel\Http\Request\Validation\ListOf; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestForm; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestJson; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestXml; +use Flytachi\Winter\Kernel\Http\Request\Validation\Min; +use Flytachi\Winter\Kernel\Http\Request\Validation\NotBlank; +use Flytachi\Winter\Kernel\Http\Request\Validation\Required; +use Flytachi\Winter\Kernel\Http\Request\Validation\Size; +use Flytachi\Winter\Kernel\Http\Request\Validation\Valid; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; use PHPUnit\Framework\TestCase; use ReflectionMethod; diff --git a/tests/Http/Request/PathVariableTest.php b/tests/Http/Request/PathVariableTest.php index ded57cd..c8170dd 100644 --- a/tests/Http/Request/PathVariableTest.php +++ b/tests/Http/Request/PathVariableTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; - -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\ParameterResolver; -use Flytachi\Winter\K2\Http\Request\Annotation\PathVariable; -use Flytachi\Winter\K2\Http\Request\RequestException; -use Flytachi\Winter\K2\Http\Request\Validation\Positive; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; + +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\ParameterResolver; +use Flytachi\Winter\Kernel\Http\Request\Annotation\PathVariable; +use Flytachi\Winter\Kernel\Http\Request\RequestException; +use Flytachi\Winter\Kernel\Http\Request\Validation\Positive; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; use PHPUnit\Framework\TestCase; use ReflectionMethod; diff --git a/tests/Http/Request/RequestBodyTest.php b/tests/Http/Request/RequestBodyTest.php index a151634..e1caac6 100644 --- a/tests/Http/Request/RequestBodyTest.php +++ b/tests/Http/Request/RequestBodyTest.php @@ -2,21 +2,21 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; - -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\ParameterResolver; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestBody; -use Flytachi\Winter\K2\Http\Request\Validation\Constraint; -use Flytachi\Winter\K2\Http\Request\Validation\Min; -use Flytachi\Winter\K2\Http\Request\Validation\NotBlank; -use Flytachi\Winter\K2\Http\Request\Validation\Positive; -use Flytachi\Winter\K2\Http\Request\Validation\Required; -use Flytachi\Winter\K2\Http\Request\Validation\Size; -use Flytachi\Winter\K2\Http\Request\Validation\Valid; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; -use Flytachi\Winter\K2\Http\Request\RequestException; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; + +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\ParameterResolver; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestBody; +use Flytachi\Winter\Kernel\Http\Request\Validation\Constraint; +use Flytachi\Winter\Kernel\Http\Request\Validation\Min; +use Flytachi\Winter\Kernel\Http\Request\Validation\NotBlank; +use Flytachi\Winter\Kernel\Http\Request\Validation\Positive; +use Flytachi\Winter\Kernel\Http\Request\Validation\Required; +use Flytachi\Winter\Kernel\Http\Request\Validation\Size; +use Flytachi\Winter\Kernel\Http\Request\Validation\Valid; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; +use Flytachi\Winter\Kernel\Http\Request\RequestException; use PHPUnit\Framework\TestCase; use ReflectionMethod; @@ -375,7 +375,7 @@ public function test_variadic_multiple_items_hydrated(): void public function test_variadic_non_array_json_throws(): void { - $this->expectException(\Flytachi\Winter\K2\Http\Request\RequestException::class); + $this->expectException(\Flytachi\Winter\Kernel\Http\Request\RequestException::class); $this->resolve('asVariadic', $this->makeRequest('{"title":"X","amount":1}')); } diff --git a/tests/Http/Request/RequestFileTest.php b/tests/Http/Request/RequestFileTest.php index e508442..0fca644 100644 --- a/tests/Http/Request/RequestFileTest.php +++ b/tests/Http/Request/RequestFileTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\ParameterResolver; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestFile; -use Flytachi\Winter\K2\Http\Request\RequestException; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\ParameterResolver; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestFile; +use Flytachi\Winter\Kernel\Http\Request\RequestException; use PHPUnit\Framework\TestCase; use ReflectionMethod; diff --git a/tests/Http/Request/RequestFormTest.php b/tests/Http/Request/RequestFormTest.php index 720d8f7..589427b 100644 --- a/tests/Http/Request/RequestFormTest.php +++ b/tests/Http/Request/RequestFormTest.php @@ -2,20 +2,20 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; - -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\ParameterResolver; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestForm; -use Flytachi\Winter\K2\Http\Request\Validation\Min; -use Flytachi\Winter\K2\Http\Request\Validation\NotBlank; -use Flytachi\Winter\K2\Http\Request\Validation\Positive; -use Flytachi\Winter\K2\Http\Request\Validation\Required; -use Flytachi\Winter\K2\Http\Request\Validation\Size; -use Flytachi\Winter\K2\Http\Request\Validation\Valid; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; -use Flytachi\Winter\K2\Http\Request\RequestException; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; + +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\ParameterResolver; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestForm; +use Flytachi\Winter\Kernel\Http\Request\Validation\Min; +use Flytachi\Winter\Kernel\Http\Request\Validation\NotBlank; +use Flytachi\Winter\Kernel\Http\Request\Validation\Positive; +use Flytachi\Winter\Kernel\Http\Request\Validation\Required; +use Flytachi\Winter\Kernel\Http\Request\Validation\Size; +use Flytachi\Winter\Kernel\Http\Request\Validation\Valid; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; +use Flytachi\Winter\Kernel\Http\Request\RequestException; use PHPUnit\Framework\TestCase; use ReflectionMethod; diff --git a/tests/Http/Request/RequestHeaderTest.php b/tests/Http/Request/RequestHeaderTest.php index 6f99f32..f5b7bc6 100644 --- a/tests/Http/Request/RequestHeaderTest.php +++ b/tests/Http/Request/RequestHeaderTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; - -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\ParameterResolver; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestHeader; -use Flytachi\Winter\K2\Http\Request\RequestException; -use Flytachi\Winter\K2\Http\Request\Validation\Positive; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; + +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\ParameterResolver; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestHeader; +use Flytachi\Winter\Kernel\Http\Request\RequestException; +use Flytachi\Winter\Kernel\Http\Request\Validation\Positive; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; use PHPUnit\Framework\TestCase; use ReflectionMethod; diff --git a/tests/Http/Request/RequestJsonTest.php b/tests/Http/Request/RequestJsonTest.php index ce5b7aa..7d71fc1 100644 --- a/tests/Http/Request/RequestJsonTest.php +++ b/tests/Http/Request/RequestJsonTest.php @@ -2,21 +2,21 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; - -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\ParameterResolver; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestJson; -use Flytachi\Winter\K2\Http\Request\Validation\In; -use Flytachi\Winter\K2\Http\Request\Validation\Min; -use Flytachi\Winter\K2\Http\Request\Validation\NotBlank; -use Flytachi\Winter\K2\Http\Request\Validation\Positive; -use Flytachi\Winter\K2\Http\Request\Validation\Required; -use Flytachi\Winter\K2\Http\Request\Validation\Size; -use Flytachi\Winter\K2\Http\Request\Validation\Valid; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; -use Flytachi\Winter\K2\Http\Request\RequestException; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; + +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\ParameterResolver; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestJson; +use Flytachi\Winter\Kernel\Http\Request\Validation\In; +use Flytachi\Winter\Kernel\Http\Request\Validation\Min; +use Flytachi\Winter\Kernel\Http\Request\Validation\NotBlank; +use Flytachi\Winter\Kernel\Http\Request\Validation\Positive; +use Flytachi\Winter\Kernel\Http\Request\Validation\Required; +use Flytachi\Winter\Kernel\Http\Request\Validation\Size; +use Flytachi\Winter\Kernel\Http\Request\Validation\Valid; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; +use Flytachi\Winter\Kernel\Http\Request\RequestException; use PHPUnit\Framework\TestCase; use ReflectionMethod; @@ -290,7 +290,7 @@ public function test_variadic_multiple_items_hydrated(): void public function test_variadic_non_array_json_throws(): void { - $this->expectException(\Flytachi\Winter\K2\Http\Request\RequestException::class); + $this->expectException(\Flytachi\Winter\Kernel\Http\Request\RequestException::class); $this->resolve('asVariadic', '{"name":"Widget","qty":1}'); } diff --git a/tests/Http/Request/RequestParamTest.php b/tests/Http/Request/RequestParamTest.php index 0f50685..5c6c669 100644 --- a/tests/Http/Request/RequestParamTest.php +++ b/tests/Http/Request/RequestParamTest.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; - -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\ParameterResolver; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestParam; -use Flytachi\Winter\K2\Http\Request\RequestException; -use Flytachi\Winter\K2\Http\Request\Validation\Positive; -use Flytachi\Winter\K2\Http\Request\Validation\Size; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; -use Flytachi\Winter\K2\Localization\Locale; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; + +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\ParameterResolver; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestParam; +use Flytachi\Winter\Kernel\Http\Request\RequestException; +use Flytachi\Winter\Kernel\Http\Request\Validation\Positive; +use Flytachi\Winter\Kernel\Http\Request\Validation\Size; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; +use Flytachi\Winter\Kernel\Localization\Locale; use PHPUnit\Framework\TestCase; use ReflectionMethod; diff --git a/tests/Http/Request/RequestQueryTest.php b/tests/Http/Request/RequestQueryTest.php index f281a5d..9d0401c 100644 --- a/tests/Http/Request/RequestQueryTest.php +++ b/tests/Http/Request/RequestQueryTest.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; - -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\ParameterResolver; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestQuery; -use Flytachi\Winter\K2\Http\Request\RequestException; -use Flytachi\Winter\K2\Http\Request\Validation\Min; -use Flytachi\Winter\K2\Http\Request\Validation\NotBlank; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; -use Flytachi\Winter\K2\Http\Request\Validation\Valid; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; + +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\ParameterResolver; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestQuery; +use Flytachi\Winter\Kernel\Http\Request\RequestException; +use Flytachi\Winter\Kernel\Http\Request\Validation\Min; +use Flytachi\Winter\Kernel\Http\Request\Validation\NotBlank; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; +use Flytachi\Winter\Kernel\Http\Request\Validation\Valid; use PHPUnit\Framework\TestCase; use ReflectionMethod; diff --git a/tests/Http/Request/RequestXmlTest.php b/tests/Http/Request/RequestXmlTest.php index 501d624..0921947 100644 --- a/tests/Http/Request/RequestXmlTest.php +++ b/tests/Http/Request/RequestXmlTest.php @@ -2,19 +2,19 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; - -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\ParameterResolver; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestXml; -use Flytachi\Winter\K2\Http\Request\Validation\NotBlank; -use Flytachi\Winter\K2\Http\Request\Validation\Positive; -use Flytachi\Winter\K2\Http\Request\Validation\Required; -use Flytachi\Winter\K2\Http\Request\Validation\Size; -use Flytachi\Winter\K2\Http\Request\Validation\Valid; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; -use Flytachi\Winter\K2\Http\Request\RequestException; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; + +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\ParameterResolver; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestXml; +use Flytachi\Winter\Kernel\Http\Request\Validation\NotBlank; +use Flytachi\Winter\Kernel\Http\Request\Validation\Positive; +use Flytachi\Winter\Kernel\Http\Request\Validation\Required; +use Flytachi\Winter\Kernel\Http\Request\Validation\Size; +use Flytachi\Winter\Kernel\Http\Request\Validation\Valid; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; +use Flytachi\Winter\Kernel\Http\Request\RequestException; use PHPUnit\Framework\TestCase; use ReflectionMethod; diff --git a/tests/Http/Request/SwooleRequestBaseUrlTest.php b/tests/Http/Request/SwooleRequestBaseUrlTest.php index a879309..9f609b6 100644 --- a/tests/Http/Request/SwooleRequestBaseUrlTest.php +++ b/tests/Http/Request/SwooleRequestBaseUrlTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; -use Flytachi\Winter\K2\Http\Adapter\SwooleRequest; +use Flytachi\Winter\Kernel\Http\Adapter\SwooleRequest; use PHPUnit\Framework\TestCase; use Swoole\Http\Request; diff --git a/tests/Http/Request/Validation/AssertTest.php b/tests/Http/Request/Validation/AssertTest.php index 5de5136..3bc1aa4 100644 --- a/tests/Http/Request/Validation/AssertTest.php +++ b/tests/Http/Request/Validation/AssertTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Assert; +use Flytachi\Winter\Kernel\Http\Request\Validation\Assert; use PHPUnit\Framework\TestCase; function assert_must_be_even(mixed $value, string $field): ?string diff --git a/tests/Http/Request/Validation/CustomMessageTest.php b/tests/Http/Request/Validation/CustomMessageTest.php index 1f998bf..dea3cae 100644 --- a/tests/Http/Request/Validation/CustomMessageTest.php +++ b/tests/Http/Request/Validation/CustomMessageTest.php @@ -2,32 +2,32 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Constraint; -use Flytachi\Winter\K2\Http\Request\Validation\Date; -use Flytachi\Winter\K2\Http\Request\Validation\Datetime; -use Flytachi\Winter\K2\Http\Request\Validation\Digits; -use Flytachi\Winter\K2\Http\Request\Validation\Email; -use Flytachi\Winter\K2\Http\Request\Validation\In; -use Flytachi\Winter\K2\Http\Request\Validation\Ip; -use Flytachi\Winter\K2\Http\Request\Validation\Ipv4; -use Flytachi\Winter\K2\Http\Request\Validation\Ipv6; -use Flytachi\Winter\K2\Http\Request\Validation\Max; -use Flytachi\Winter\K2\Http\Request\Validation\Min; -use Flytachi\Winter\K2\Http\Request\Validation\Msisdn; -use Flytachi\Winter\K2\Http\Request\Validation\Negative; -use Flytachi\Winter\K2\Http\Request\Validation\NegativeOrZero; -use Flytachi\Winter\K2\Http\Request\Validation\NotBlank; -use Flytachi\Winter\K2\Http\Request\Validation\Phone; -use Flytachi\Winter\K2\Http\Request\Validation\Positive; -use Flytachi\Winter\K2\Http\Request\Validation\PositiveOrZero; -use Flytachi\Winter\K2\Http\Request\Validation\Regex; -use Flytachi\Winter\K2\Http\Request\Validation\Required; -use Flytachi\Winter\K2\Http\Request\Validation\Size; -use Flytachi\Winter\K2\Http\Request\Validation\Time; -use Flytachi\Winter\K2\Http\Request\Validation\Url; -use Flytachi\Winter\K2\Http\Request\Validation\Uuid; +use Flytachi\Winter\Kernel\Http\Request\Validation\Constraint; +use Flytachi\Winter\Kernel\Http\Request\Validation\Date; +use Flytachi\Winter\Kernel\Http\Request\Validation\Datetime; +use Flytachi\Winter\Kernel\Http\Request\Validation\Digits; +use Flytachi\Winter\Kernel\Http\Request\Validation\Email; +use Flytachi\Winter\Kernel\Http\Request\Validation\In; +use Flytachi\Winter\Kernel\Http\Request\Validation\Ip; +use Flytachi\Winter\Kernel\Http\Request\Validation\Ipv4; +use Flytachi\Winter\Kernel\Http\Request\Validation\Ipv6; +use Flytachi\Winter\Kernel\Http\Request\Validation\Max; +use Flytachi\Winter\Kernel\Http\Request\Validation\Min; +use Flytachi\Winter\Kernel\Http\Request\Validation\Msisdn; +use Flytachi\Winter\Kernel\Http\Request\Validation\Negative; +use Flytachi\Winter\Kernel\Http\Request\Validation\NegativeOrZero; +use Flytachi\Winter\Kernel\Http\Request\Validation\NotBlank; +use Flytachi\Winter\Kernel\Http\Request\Validation\Phone; +use Flytachi\Winter\Kernel\Http\Request\Validation\Positive; +use Flytachi\Winter\Kernel\Http\Request\Validation\PositiveOrZero; +use Flytachi\Winter\Kernel\Http\Request\Validation\Regex; +use Flytachi\Winter\Kernel\Http\Request\Validation\Required; +use Flytachi\Winter\Kernel\Http\Request\Validation\Size; +use Flytachi\Winter\Kernel\Http\Request\Validation\Time; +use Flytachi\Winter\Kernel\Http\Request\Validation\Url; +use Flytachi\Winter\Kernel\Http\Request\Validation\Uuid; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Http/Request/Validation/DateTimeTest.php b/tests/Http/Request/Validation/DateTimeTest.php index 3bbaa2d..76b4012 100644 --- a/tests/Http/Request/Validation/DateTimeTest.php +++ b/tests/Http/Request/Validation/DateTimeTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Date; -use Flytachi\Winter\K2\Http\Request\Validation\Datetime; -use Flytachi\Winter\K2\Http\Request\Validation\Time; +use Flytachi\Winter\Kernel\Http\Request\Validation\Date; +use Flytachi\Winter\Kernel\Http\Request\Validation\Datetime; +use Flytachi\Winter\Kernel\Http\Request\Validation\Time; use PHPUnit\Framework\TestCase; class DateTimeTest extends TestCase diff --git a/tests/Http/Request/Validation/DigitsTest.php b/tests/Http/Request/Validation/DigitsTest.php index ea67ad2..e79d921 100644 --- a/tests/Http/Request/Validation/DigitsTest.php +++ b/tests/Http/Request/Validation/DigitsTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Digits; +use Flytachi\Winter\Kernel\Http\Request\Validation\Digits; use PHPUnit\Framework\TestCase; class DigitsTest extends TestCase diff --git a/tests/Http/Request/Validation/EmailTest.php b/tests/Http/Request/Validation/EmailTest.php index 346eb44..d90837f 100644 --- a/tests/Http/Request/Validation/EmailTest.php +++ b/tests/Http/Request/Validation/EmailTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Email; +use Flytachi\Winter\Kernel\Http\Request\Validation\Email; use PHPUnit\Framework\TestCase; class EmailTest extends TestCase diff --git a/tests/Http/Request/Validation/InTest.php b/tests/Http/Request/Validation/InTest.php index 1a5ebed..74c878a 100644 --- a/tests/Http/Request/Validation/InTest.php +++ b/tests/Http/Request/Validation/InTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\In; +use Flytachi\Winter\Kernel\Http\Request\Validation\In; use PHPUnit\Framework\TestCase; class InTest extends TestCase diff --git a/tests/Http/Request/Validation/IpTest.php b/tests/Http/Request/Validation/IpTest.php index fcc3f6e..430ca70 100644 --- a/tests/Http/Request/Validation/IpTest.php +++ b/tests/Http/Request/Validation/IpTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Ip; -use Flytachi\Winter\K2\Http\Request\Validation\Ipv4; -use Flytachi\Winter\K2\Http\Request\Validation\Ipv6; +use Flytachi\Winter\Kernel\Http\Request\Validation\Ip; +use Flytachi\Winter\Kernel\Http\Request\Validation\Ipv4; +use Flytachi\Winter\Kernel\Http\Request\Validation\Ipv6; use PHPUnit\Framework\TestCase; class IpTest extends TestCase diff --git a/tests/Http/Request/Validation/MaxTest.php b/tests/Http/Request/Validation/MaxTest.php index 917fbe3..8d48d11 100644 --- a/tests/Http/Request/Validation/MaxTest.php +++ b/tests/Http/Request/Validation/MaxTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Max; +use Flytachi\Winter\Kernel\Http\Request\Validation\Max; use PHPUnit\Framework\TestCase; class MaxTest extends TestCase diff --git a/tests/Http/Request/Validation/MinTest.php b/tests/Http/Request/Validation/MinTest.php index 3065b8f..53ca7cf 100644 --- a/tests/Http/Request/Validation/MinTest.php +++ b/tests/Http/Request/Validation/MinTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Min; +use Flytachi\Winter\Kernel\Http\Request\Validation\Min; use PHPUnit\Framework\TestCase; class MinTest extends TestCase diff --git a/tests/Http/Request/Validation/MsisdnPhoneTest.php b/tests/Http/Request/Validation/MsisdnPhoneTest.php index 8a62029..a0c372e 100644 --- a/tests/Http/Request/Validation/MsisdnPhoneTest.php +++ b/tests/Http/Request/Validation/MsisdnPhoneTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Msisdn; -use Flytachi\Winter\K2\Http\Request\Validation\Phone; +use Flytachi\Winter\Kernel\Http\Request\Validation\Msisdn; +use Flytachi\Winter\Kernel\Http\Request\Validation\Phone; use PHPUnit\Framework\TestCase; class MsisdnPhoneTest extends TestCase diff --git a/tests/Http/Request/Validation/NegativeOrZeroTest.php b/tests/Http/Request/Validation/NegativeOrZeroTest.php index c7b5806..d8b3681 100644 --- a/tests/Http/Request/Validation/NegativeOrZeroTest.php +++ b/tests/Http/Request/Validation/NegativeOrZeroTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\NegativeOrZero; +use Flytachi\Winter\Kernel\Http\Request\Validation\NegativeOrZero; use PHPUnit\Framework\TestCase; class NegativeOrZeroTest extends TestCase diff --git a/tests/Http/Request/Validation/NegativeTest.php b/tests/Http/Request/Validation/NegativeTest.php index 59e9710..a09e10f 100644 --- a/tests/Http/Request/Validation/NegativeTest.php +++ b/tests/Http/Request/Validation/NegativeTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Negative; +use Flytachi\Winter\Kernel\Http\Request\Validation\Negative; use PHPUnit\Framework\TestCase; class NegativeTest extends TestCase diff --git a/tests/Http/Request/Validation/NotBlankTest.php b/tests/Http/Request/Validation/NotBlankTest.php index 8f84d35..dfd2054 100644 --- a/tests/Http/Request/Validation/NotBlankTest.php +++ b/tests/Http/Request/Validation/NotBlankTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\NotBlank; +use Flytachi\Winter\Kernel\Http\Request\Validation\NotBlank; use PHPUnit\Framework\TestCase; class NotBlankTest extends TestCase diff --git a/tests/Http/Request/Validation/PositiveOrZeroTest.php b/tests/Http/Request/Validation/PositiveOrZeroTest.php index 69892e5..631bf52 100644 --- a/tests/Http/Request/Validation/PositiveOrZeroTest.php +++ b/tests/Http/Request/Validation/PositiveOrZeroTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\PositiveOrZero; +use Flytachi\Winter\Kernel\Http\Request\Validation\PositiveOrZero; use PHPUnit\Framework\TestCase; class PositiveOrZeroTest extends TestCase diff --git a/tests/Http/Request/Validation/PositiveTest.php b/tests/Http/Request/Validation/PositiveTest.php index 62b712e..fd7de27 100644 --- a/tests/Http/Request/Validation/PositiveTest.php +++ b/tests/Http/Request/Validation/PositiveTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Positive; +use Flytachi\Winter\Kernel\Http\Request\Validation\Positive; use PHPUnit\Framework\TestCase; class PositiveTest extends TestCase diff --git a/tests/Http/Request/Validation/RegexTest.php b/tests/Http/Request/Validation/RegexTest.php index bc5cb75..89bbfd3 100644 --- a/tests/Http/Request/Validation/RegexTest.php +++ b/tests/Http/Request/Validation/RegexTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Regex; +use Flytachi\Winter\Kernel\Http\Request\Validation\Regex; use PHPUnit\Framework\TestCase; class RegexTest extends TestCase diff --git a/tests/Http/Request/Validation/RequiredTest.php b/tests/Http/Request/Validation/RequiredTest.php index f918100..eb545f7 100644 --- a/tests/Http/Request/Validation/RequiredTest.php +++ b/tests/Http/Request/Validation/RequiredTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Required; +use Flytachi\Winter\Kernel\Http\Request\Validation\Required; use PHPUnit\Framework\TestCase; class RequiredTest extends TestCase diff --git a/tests/Http/Request/Validation/SizeTest.php b/tests/Http/Request/Validation/SizeTest.php index 6ce6917..6672756 100644 --- a/tests/Http/Request/Validation/SizeTest.php +++ b/tests/Http/Request/Validation/SizeTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Size; +use Flytachi\Winter\Kernel\Http\Request\Validation\Size; use PHPUnit\Framework\TestCase; class SizeTest extends TestCase diff --git a/tests/Http/Request/Validation/UrlTest.php b/tests/Http/Request/Validation/UrlTest.php index 40c1149..e9283c5 100644 --- a/tests/Http/Request/Validation/UrlTest.php +++ b/tests/Http/Request/Validation/UrlTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Url; +use Flytachi\Winter\Kernel\Http\Request\Validation\Url; use PHPUnit\Framework\TestCase; class UrlTest extends TestCase diff --git a/tests/Http/Request/Validation/UuidTest.php b/tests/Http/Request/Validation/UuidTest.php index 7212ce7..fe04720 100644 --- a/tests/Http/Request/Validation/UuidTest.php +++ b/tests/Http/Request/Validation/UuidTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Uuid; +use Flytachi\Winter\Kernel\Http\Request\Validation\Uuid; use PHPUnit\Framework\TestCase; class UuidTest extends TestCase diff --git a/tests/Http/Request/Validation/ValidationExceptionTest.php b/tests/Http/Request/Validation/ValidationExceptionTest.php index cda3403..af140e6 100644 --- a/tests/Http/Request/Validation/ValidationExceptionTest.php +++ b/tests/Http/Request/Validation/ValidationExceptionTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; use PHPUnit\Framework\TestCase; class ValidationExceptionTest extends TestCase @@ -44,6 +44,6 @@ public function testNestedPathErrors(): void public function testIsInstanceOfResponseException(): void { $ex = new ValidationException([]); - self::assertInstanceOf(\Flytachi\Winter\K2\Http\Response\ResponseException::class, $ex); + self::assertInstanceOf(\Flytachi\Winter\Kernel\Http\Response\ResponseException::class, $ex); } } diff --git a/tests/Http/Response/FpmResponseSendfileTest.php b/tests/Http/Response/FpmResponseSendfileTest.php index 2aaffab..7a946a2 100644 --- a/tests/Http/Response/FpmResponseSendfileTest.php +++ b/tests/Http/Response/FpmResponseSendfileTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Response; +namespace Flytachi\Winter\Kernel\Tests\Http\Response; -use Flytachi\Winter\K2\Http\Adapter\FpmResponse; +use Flytachi\Winter\Kernel\Http\Adapter\FpmResponse; use PHPUnit\Framework\TestCase; final class FpmResponseSendfileTest extends TestCase diff --git a/tests/Http/Response/ResponseStreamFileTest.php b/tests/Http/Response/ResponseStreamFileTest.php index 7673533..0c0fa77 100644 --- a/tests/Http/Response/ResponseStreamFileTest.php +++ b/tests/Http/Response/ResponseStreamFileTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Response; +namespace Flytachi\Winter\Kernel\Tests\Http\Response; use Flytachi\Winter\Base\HttpCode; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\Response\ResponseStreamFile; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Response\ResponseStreamFile; use PHPUnit\Framework\TestCase; // ── Spy HttpResponse — records everything send() does ───────────────────────── diff --git a/tests/Http/Response/ResponseViewPathTest.php b/tests/Http/Response/ResponseViewPathTest.php index 80d2b1b..96b5a50 100644 --- a/tests/Http/Response/ResponseViewPathTest.php +++ b/tests/Http/Response/ResponseViewPathTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Response; +namespace Flytachi\Winter\Kernel\Tests\Http\Response; -use Flytachi\Winter\K2\Core\KernelConfig; -use Flytachi\Winter\K2\Http\Response\ResponseView; +use Flytachi\Winter\Kernel\Core\KernelConfig; +use Flytachi\Winter\Kernel\Http\Response\ResponseView; use PHPUnit\Framework\TestCase; use ReflectionProperty; use RuntimeException; diff --git a/tests/Integration/Cli/DbMigrateTestCase.php b/tests/Integration/Cli/DbMigrateTestCase.php index f985d44..95bbb5d 100644 --- a/tests/Integration/Cli/DbMigrateTestCase.php +++ b/tests/Integration/Cli/DbMigrateTestCase.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Cli; +namespace Flytachi\Winter\Kernel\Tests\Integration\Cli; use Flytachi\Winter\Console\Command\Db; -use Flytachi\Winter\K2\Kernel; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\IntegrationTestCase; +use Flytachi\Winter\Kernel\Kernel; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\IntegrationTestCase; /** * End-to-end test of the `Db` console command's migrate flow. diff --git a/tests/Integration/Cli/Fixtures/Mariadb/MigrMariadbRepo.php b/tests/Integration/Cli/Fixtures/Mariadb/MigrMariadbRepo.php index a8778cb..6796a08 100644 --- a/tests/Integration/Cli/Fixtures/Mariadb/MigrMariadbRepo.php +++ b/tests/Integration/Cli/Fixtures/Mariadb/MigrMariadbRepo.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Cli\Fixtures\Mariadb; +namespace Flytachi\Winter\Kernel\Tests\Integration\Cli\Fixtures\Mariadb; -use Flytachi\Winter\K2\Ppa\Stereotype\RepositoryView; -use Flytachi\Winter\K2\Tests\Integration\Cli\Fixtures\MigrEntity; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\MariadbTestDbConfig; +use Flytachi\Winter\Kernel\Ppa\Stereotype\RepositoryView; +use Flytachi\Winter\Kernel\Tests\Integration\Cli\Fixtures\MigrEntity; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\MariadbTestDbConfig; final class MigrMariadbRepo extends RepositoryView { diff --git a/tests/Integration/Cli/Fixtures/MigrEntity.php b/tests/Integration/Cli/Fixtures/MigrEntity.php index 9e09335..3c54f64 100644 --- a/tests/Integration/Cli/Fixtures/MigrEntity.php +++ b/tests/Integration/Cli/Fixtures/MigrEntity.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Cli\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Cli\Fixtures; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\NullableIs; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Entity\Table; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid\Id; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Unique; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Boolean; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Varchar; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\NullableIs; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Entity\Table; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid\Id; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Unique; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Boolean; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Varchar; /** * Shared entity for Phase C.5 (Cli `db migrate` E2E tests). diff --git a/tests/Integration/Cli/Fixtures/Mysql/MigrMysqlRepo.php b/tests/Integration/Cli/Fixtures/Mysql/MigrMysqlRepo.php index 6a6c833..c810b27 100644 --- a/tests/Integration/Cli/Fixtures/Mysql/MigrMysqlRepo.php +++ b/tests/Integration/Cli/Fixtures/Mysql/MigrMysqlRepo.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Cli\Fixtures\Mysql; +namespace Flytachi\Winter\Kernel\Tests\Integration\Cli\Fixtures\Mysql; -use Flytachi\Winter\K2\Ppa\Stereotype\RepositoryView; -use Flytachi\Winter\K2\Tests\Integration\Cli\Fixtures\MigrEntity; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\MysqlTestDbConfig; +use Flytachi\Winter\Kernel\Ppa\Stereotype\RepositoryView; +use Flytachi\Winter\Kernel\Tests\Integration\Cli\Fixtures\MigrEntity; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\MysqlTestDbConfig; final class MigrMysqlRepo extends RepositoryView { diff --git a/tests/Integration/Cli/Fixtures/Pg/MigrPgRepo.php b/tests/Integration/Cli/Fixtures/Pg/MigrPgRepo.php index c968f4f..f265c61 100644 --- a/tests/Integration/Cli/Fixtures/Pg/MigrPgRepo.php +++ b/tests/Integration/Cli/Fixtures/Pg/MigrPgRepo.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Cli\Fixtures\Pg; +namespace Flytachi\Winter\Kernel\Tests\Integration\Cli\Fixtures\Pg; -use Flytachi\Winter\K2\Ppa\Stereotype\RepositoryView; -use Flytachi\Winter\K2\Tests\Integration\Cli\Fixtures\MigrEntity; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\PgTestDbConfig; +use Flytachi\Winter\Kernel\Ppa\Stereotype\RepositoryView; +use Flytachi\Winter\Kernel\Tests\Integration\Cli\Fixtures\MigrEntity; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\PgTestDbConfig; final class MigrPgRepo extends RepositoryView { diff --git a/tests/Integration/Cli/MariadbDbMigrateTest.php b/tests/Integration/Cli/MariadbDbMigrateTest.php index e59ee91..62fe81a 100644 --- a/tests/Integration/Cli/MariadbDbMigrateTest.php +++ b/tests/Integration/Cli/MariadbDbMigrateTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Cli; +namespace Flytachi\Winter\Kernel\Tests\Integration\Cli; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/Integration/Cli/MysqlDbMigrateTest.php b/tests/Integration/Cli/MysqlDbMigrateTest.php index a6f2351..69a44af 100644 --- a/tests/Integration/Cli/MysqlDbMigrateTest.php +++ b/tests/Integration/Cli/MysqlDbMigrateTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Cli; +namespace Flytachi\Winter\Kernel\Tests\Integration\Cli; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/Integration/Cli/PgDbMigrateTest.php b/tests/Integration/Cli/PgDbMigrateTest.php index 5d8ea78..9367252 100644 --- a/tests/Integration/Cli/PgDbMigrateTest.php +++ b/tests/Integration/Cli/PgDbMigrateTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Cli; +namespace Flytachi\Winter\Kernel\Tests\Integration\Cli; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/Integration/Crud/CrudIntegrationTestCase.php b/tests/Integration/Crud/CrudIntegrationTestCase.php index 23805e6..d81fd1c 100644 --- a/tests/Integration/Crud/CrudIntegrationTestCase.php +++ b/tests/Integration/Crud/CrudIntegrationTestCase.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Crud; +namespace Flytachi\Winter\Kernel\Tests\Integration\Crud; use Flytachi\Winter\Cdo\CDOBind; use Flytachi\Winter\Cdo\Qb; diff --git a/tests/Integration/Crud/MariadbCrudTest.php b/tests/Integration/Crud/MariadbCrudTest.php index c710628..545e241 100644 --- a/tests/Integration/Crud/MariadbCrudTest.php +++ b/tests/Integration/Crud/MariadbCrudTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Crud; +namespace Flytachi\Winter\Kernel\Tests\Integration\Crud; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\ProductMariadbRepo; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\ProductMariadbRepo; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] diff --git a/tests/Integration/Crud/MysqlCrudTest.php b/tests/Integration/Crud/MysqlCrudTest.php index acb9245..1cd47a4 100644 --- a/tests/Integration/Crud/MysqlCrudTest.php +++ b/tests/Integration/Crud/MysqlCrudTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Crud; +namespace Flytachi\Winter\Kernel\Tests\Integration\Crud; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\ProductMysqlRepo; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\ProductMysqlRepo; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] diff --git a/tests/Integration/Crud/PgCrudTest.php b/tests/Integration/Crud/PgCrudTest.php index 9875583..8f4e66a 100644 --- a/tests/Integration/Crud/PgCrudTest.php +++ b/tests/Integration/Crud/PgCrudTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Crud; +namespace Flytachi\Winter\Kernel\Tests\Integration\Crud; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\ProductPgRepo; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\ProductPgRepo; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] diff --git a/tests/Integration/Crud/ProductsTableTestCase.php b/tests/Integration/Crud/ProductsTableTestCase.php index 8196478..2355f10 100644 --- a/tests/Integration/Crud/ProductsTableTestCase.php +++ b/tests/Integration/Crud/ProductsTableTestCase.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Crud; +namespace Flytachi\Winter\Kernel\Tests\Integration\Crud; -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\IntegrationTestCase; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\IntegrationTestCase; /** * Shared infrastructure for any integration test that operates on the @@ -14,7 +14,7 @@ * * Concrete behaviour test bodies live in subclasses: * - {@see CrudIntegrationTestCase} — insert / update / delete / upsert - * - {@see \Flytachi\Winter\K2\Tests\Integration\View\ViewIntegrationTestCase} + * - {@see \Flytachi\Winter\Kernel\Tests\Integration\View\ViewIntegrationTestCase} * — find / findAll / count / exists / *OrThrow * * Schema uses an explicit (non-auto-increment) integer PK; tests insert diff --git a/tests/Integration/Fixtures/IntegrationTestCase.php b/tests/Integration/Fixtures/IntegrationTestCase.php index 1c8e4b1..ea216c3 100644 --- a/tests/Integration/Fixtures/IntegrationTestCase.php +++ b/tests/Integration/Fixtures/IntegrationTestCase.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; -use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; use PHPUnit\Framework\TestCase; use ReflectionClass; diff --git a/tests/Integration/Fixtures/MariadbTestDbConfig.php b/tests/Integration/Fixtures/MariadbTestDbConfig.php index 16bd843..0af9c7c 100644 --- a/tests/Integration/Fixtures/MariadbTestDbConfig.php +++ b/tests/Integration/Fixtures/MariadbTestDbConfig.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; use Flytachi\Winter\Cdo\Config\MySqlDbConfig; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Migratable; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Migratable; /** * MariaDB configuration — separate from MysqlTestDbConfig only so the pool diff --git a/tests/Integration/Fixtures/MysqlTestDbConfig.php b/tests/Integration/Fixtures/MysqlTestDbConfig.php index bfaace1..7741a12 100644 --- a/tests/Integration/Fixtures/MysqlTestDbConfig.php +++ b/tests/Integration/Fixtures/MysqlTestDbConfig.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; use Flytachi\Winter\Cdo\Config\MySqlDbConfig; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Migratable; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Migratable; /** * MySQL configuration driven by env vars. diff --git a/tests/Integration/Fixtures/PgTestDbConfig.php b/tests/Integration/Fixtures/PgTestDbConfig.php index fc45616..a7c1c5a 100644 --- a/tests/Integration/Fixtures/PgTestDbConfig.php +++ b/tests/Integration/Fixtures/PgTestDbConfig.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; use Flytachi\Winter\Cdo\Config\PgDbConfig; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Extension; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Migratable; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Extension; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Migratable; /** * PostgreSQL configuration driven entirely by env vars. diff --git a/tests/Integration/Fixtures/ProductEntity.php b/tests/Integration/Fixtures/ProductEntity.php index 58546b7..deeead2 100644 --- a/tests/Integration/Fixtures/ProductEntity.php +++ b/tests/Integration/Fixtures/ProductEntity.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; /** * Test entity used by all CRUD integration tests. Three primitive types diff --git a/tests/Integration/Fixtures/ProductMariadbRepo.php b/tests/Integration/Fixtures/ProductMariadbRepo.php index 87c4412..83616d4 100644 --- a/tests/Integration/Fixtures/ProductMariadbRepo.php +++ b/tests/Integration/Fixtures/ProductMariadbRepo.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; final class ProductMariadbRepo extends Repository { diff --git a/tests/Integration/Fixtures/ProductMysqlRepo.php b/tests/Integration/Fixtures/ProductMysqlRepo.php index 26449c8..f8c0e5a 100644 --- a/tests/Integration/Fixtures/ProductMysqlRepo.php +++ b/tests/Integration/Fixtures/ProductMysqlRepo.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; final class ProductMysqlRepo extends Repository { diff --git a/tests/Integration/Fixtures/ProductPgRepo.php b/tests/Integration/Fixtures/ProductPgRepo.php index c56b748..1df9bb5 100644 --- a/tests/Integration/Fixtures/ProductPgRepo.php +++ b/tests/Integration/Fixtures/ProductPgRepo.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; final class ProductPgRepo extends Repository { diff --git a/tests/Integration/Fixtures/SpecimenEntity.php b/tests/Integration/Fixtures/SpecimenEntity.php index dab318f..4fd03a9 100644 --- a/tests/Integration/Fixtures/SpecimenEntity.php +++ b/tests/Integration/Fixtures/SpecimenEntity.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; /** * Typed entity used by the Types integration tests. diff --git a/tests/Integration/Fixtures/SpecimenMariadbRepo.php b/tests/Integration/Fixtures/SpecimenMariadbRepo.php index 8f8734e..edfe9bd 100644 --- a/tests/Integration/Fixtures/SpecimenMariadbRepo.php +++ b/tests/Integration/Fixtures/SpecimenMariadbRepo.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; final class SpecimenMariadbRepo extends Repository { diff --git a/tests/Integration/Fixtures/SpecimenMysqlRepo.php b/tests/Integration/Fixtures/SpecimenMysqlRepo.php index 3ec7001..d25980f 100644 --- a/tests/Integration/Fixtures/SpecimenMysqlRepo.php +++ b/tests/Integration/Fixtures/SpecimenMysqlRepo.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; final class SpecimenMysqlRepo extends Repository { diff --git a/tests/Integration/Fixtures/SpecimenPgRepo.php b/tests/Integration/Fixtures/SpecimenPgRepo.php index da6f46d..0bf968f 100644 --- a/tests/Integration/Fixtures/SpecimenPgRepo.php +++ b/tests/Integration/Fixtures/SpecimenPgRepo.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; final class SpecimenPgRepo extends Repository { diff --git a/tests/Integration/Migration/MariadbMigrationE2ETest.php b/tests/Integration/Migration/MariadbMigrationE2ETest.php index 68474eb..cf84335 100644 --- a/tests/Integration/Migration/MariadbMigrationE2ETest.php +++ b/tests/Integration/Migration/MariadbMigrationE2ETest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Migration; +namespace Flytachi\Winter\Kernel\Tests\Integration\Migration; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/Integration/Migration/MysqlMigrationE2ETest.php b/tests/Integration/Migration/MysqlMigrationE2ETest.php index 4e1ecc4..fc00a85 100644 --- a/tests/Integration/Migration/MysqlMigrationE2ETest.php +++ b/tests/Integration/Migration/MysqlMigrationE2ETest.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Migration; - -use Flytachi\Winter\K2\Ppa\Mapping\Constants\FKAction; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\CheckConstraint; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Column; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Index; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Table; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\IntegrationTestCase; +namespace Flytachi\Winter\Kernel\Tests\Integration\Migration; + +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\FKAction; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\CheckConstraint; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Column; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Table; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\IntegrationTestCase; use PHPUnit\Framework\Attributes\Group; /** diff --git a/tests/Integration/Migration/PgMigrationE2ETest.php b/tests/Integration/Migration/PgMigrationE2ETest.php index 4afceb7..e09a92c 100644 --- a/tests/Integration/Migration/PgMigrationE2ETest.php +++ b/tests/Integration/Migration/PgMigrationE2ETest.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Migration; - -use Flytachi\Winter\K2\Ppa\Mapping\Constants\FKAction; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\CheckConstraint; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Column; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Extension; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Index; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Table; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\IntegrationTestCase; +namespace Flytachi\Winter\Kernel\Tests\Integration\Migration; + +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\FKAction; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\CheckConstraint; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Column; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Extension; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Table; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\IntegrationTestCase; use PHPUnit\Framework\Attributes\Group; /** diff --git a/tests/Integration/Pool/CdoMariadbSegfaultDiagnosticTest.php b/tests/Integration/Pool/CdoMariadbSegfaultDiagnosticTest.php index 53d6192..1a73e94 100644 --- a/tests/Integration/Pool/CdoMariadbSegfaultDiagnosticTest.php +++ b/tests/Integration/Pool/CdoMariadbSegfaultDiagnosticTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Pool; +namespace Flytachi\Winter\Kernel\Tests\Integration\Pool; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\MariadbTestDbConfig; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\MariadbTestDbConfig; use PHPUnit\Framework\Attributes\Group; #[Group('cdo-diagnostic')] diff --git a/tests/Integration/Pool/CdoMysqlSegfaultDiagnosticTest.php b/tests/Integration/Pool/CdoMysqlSegfaultDiagnosticTest.php index f95ddf1..bca72e1 100644 --- a/tests/Integration/Pool/CdoMysqlSegfaultDiagnosticTest.php +++ b/tests/Integration/Pool/CdoMysqlSegfaultDiagnosticTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Pool; +namespace Flytachi\Winter\Kernel\Tests\Integration\Pool; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\MysqlTestDbConfig; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\MysqlTestDbConfig; use PHPUnit\Framework\Attributes\Group; #[Group('cdo-diagnostic')] diff --git a/tests/Integration/Pool/CdoSegfaultDiagnosticTestCase.php b/tests/Integration/Pool/CdoSegfaultDiagnosticTestCase.php index 457b92f..7bfe0e1 100644 --- a/tests/Integration/Pool/CdoSegfaultDiagnosticTestCase.php +++ b/tests/Integration/Pool/CdoSegfaultDiagnosticTestCase.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Pool; +namespace Flytachi\Winter\Kernel\Tests\Integration\Pool; -use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\IntegrationTestCase; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\IntegrationTestCase; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\RunInSeparateProcess; diff --git a/tests/Integration/Pool/MariadbPoolConnectionTest.php b/tests/Integration/Pool/MariadbPoolConnectionTest.php index 29e470e..ce6ab07 100644 --- a/tests/Integration/Pool/MariadbPoolConnectionTest.php +++ b/tests/Integration/Pool/MariadbPoolConnectionTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Pool; +namespace Flytachi\Winter\Kernel\Tests\Integration\Pool; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\MariadbTestDbConfig; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\MariadbTestDbConfig; use PHPUnit\Framework\Attributes\Group; #[Group('pool')] diff --git a/tests/Integration/Pool/MysqlPoolConnectionTest.php b/tests/Integration/Pool/MysqlPoolConnectionTest.php index eae548e..fe3d076 100644 --- a/tests/Integration/Pool/MysqlPoolConnectionTest.php +++ b/tests/Integration/Pool/MysqlPoolConnectionTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Pool; +namespace Flytachi\Winter\Kernel\Tests\Integration\Pool; -use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\IntegrationTestCase; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\MysqlTestDbConfig; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\IntegrationTestCase; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\MysqlTestDbConfig; use PHPUnit\Framework\Attributes\Group; #[Group('pool')] diff --git a/tests/Integration/Pool/PgPoolConnectionTest.php b/tests/Integration/Pool/PgPoolConnectionTest.php index 68843fc..4270107 100644 --- a/tests/Integration/Pool/PgPoolConnectionTest.php +++ b/tests/Integration/Pool/PgPoolConnectionTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Pool; +namespace Flytachi\Winter\Kernel\Tests\Integration\Pool; -use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\IntegrationTestCase; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\PgTestDbConfig; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\IntegrationTestCase; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\PgTestDbConfig; use PHPUnit\Framework\Attributes\Group; /** diff --git a/tests/Integration/Smoke/MariadbConnectivitySmokeTest.php b/tests/Integration/Smoke/MariadbConnectivitySmokeTest.php index 65830af..9ee68ed 100644 --- a/tests/Integration/Smoke/MariadbConnectivitySmokeTest.php +++ b/tests/Integration/Smoke/MariadbConnectivitySmokeTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Smoke; +namespace Flytachi\Winter\Kernel\Tests\Integration\Smoke; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\IntegrationTestCase; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\IntegrationTestCase; use PHPUnit\Framework\Attributes\Group; /** diff --git a/tests/Integration/Smoke/MysqlConnectivitySmokeTest.php b/tests/Integration/Smoke/MysqlConnectivitySmokeTest.php index b8b1479..d6c1b0e 100644 --- a/tests/Integration/Smoke/MysqlConnectivitySmokeTest.php +++ b/tests/Integration/Smoke/MysqlConnectivitySmokeTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Smoke; +namespace Flytachi\Winter\Kernel\Tests\Integration\Smoke; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\IntegrationTestCase; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\IntegrationTestCase; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] diff --git a/tests/Integration/Smoke/PgConnectivitySmokeTest.php b/tests/Integration/Smoke/PgConnectivitySmokeTest.php index 92b8ae7..a9437ff 100644 --- a/tests/Integration/Smoke/PgConnectivitySmokeTest.php +++ b/tests/Integration/Smoke/PgConnectivitySmokeTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Smoke; +namespace Flytachi\Winter\Kernel\Tests\Integration\Smoke; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\IntegrationTestCase; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\IntegrationTestCase; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] diff --git a/tests/Integration/Types/MariadbTypesTest.php b/tests/Integration/Types/MariadbTypesTest.php index 1c27272..c45c3c6 100644 --- a/tests/Integration/Types/MariadbTypesTest.php +++ b/tests/Integration/Types/MariadbTypesTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Types; +namespace Flytachi\Winter\Kernel\Tests\Integration\Types; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\SpecimenMariadbRepo; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\SpecimenMariadbRepo; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] diff --git a/tests/Integration/Types/MysqlTypesTest.php b/tests/Integration/Types/MysqlTypesTest.php index 3afc07d..adf1b3c 100644 --- a/tests/Integration/Types/MysqlTypesTest.php +++ b/tests/Integration/Types/MysqlTypesTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Types; +namespace Flytachi\Winter\Kernel\Tests\Integration\Types; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\SpecimenMysqlRepo; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\SpecimenMysqlRepo; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] diff --git a/tests/Integration/Types/PgTypesTest.php b/tests/Integration/Types/PgTypesTest.php index c78ccb8..a6732d6 100644 --- a/tests/Integration/Types/PgTypesTest.php +++ b/tests/Integration/Types/PgTypesTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Types; +namespace Flytachi\Winter\Kernel\Tests\Integration\Types; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\SpecimenPgRepo; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\SpecimenPgRepo; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] diff --git a/tests/Integration/Types/TypesIntegrationTestCase.php b/tests/Integration/Types/TypesIntegrationTestCase.php index 213d486..2b9294e 100644 --- a/tests/Integration/Types/TypesIntegrationTestCase.php +++ b/tests/Integration/Types/TypesIntegrationTestCase.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Types; +namespace Flytachi\Winter\Kernel\Tests\Integration\Types; -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\IntegrationTestCase; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\SpecimenEntity; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\IntegrationTestCase; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\SpecimenEntity; /** * Verifies the framework's Mapping layer end-to-end across pgsql / mysql / mariadb: diff --git a/tests/Integration/View/MariadbViewTest.php b/tests/Integration/View/MariadbViewTest.php index e6e0117..31fbc6c 100644 --- a/tests/Integration/View/MariadbViewTest.php +++ b/tests/Integration/View/MariadbViewTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\View; +namespace Flytachi\Winter\Kernel\Tests\Integration\View; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\ProductMariadbRepo; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\ProductMariadbRepo; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] diff --git a/tests/Integration/View/MysqlViewTest.php b/tests/Integration/View/MysqlViewTest.php index 66ba008..252e43e 100644 --- a/tests/Integration/View/MysqlViewTest.php +++ b/tests/Integration/View/MysqlViewTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\View; +namespace Flytachi\Winter\Kernel\Tests\Integration\View; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\ProductMysqlRepo; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\ProductMysqlRepo; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] diff --git a/tests/Integration/View/PgViewTest.php b/tests/Integration/View/PgViewTest.php index fbef01f..96c45e7 100644 --- a/tests/Integration/View/PgViewTest.php +++ b/tests/Integration/View/PgViewTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\View; +namespace Flytachi\Winter\Kernel\Tests\Integration\View; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\ProductPgRepo; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\ProductPgRepo; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] diff --git a/tests/Integration/View/ViewIntegrationTestCase.php b/tests/Integration/View/ViewIntegrationTestCase.php index 3039f00..9ffeb50 100644 --- a/tests/Integration/View/ViewIntegrationTestCase.php +++ b/tests/Integration/View/ViewIntegrationTestCase.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\View; +namespace Flytachi\Winter\Kernel\Tests\Integration\View; use Flytachi\Winter\Cdo\CDOBind; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Ppa\Entity\EntityException; -use Flytachi\Winter\K2\Tests\Integration\Crud\ProductsTableTestCase; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\ProductEntity; +use Flytachi\Winter\Kernel\Ppa\Entity\EntityException; +use Flytachi\Winter\Kernel\Tests\Integration\Crud\ProductsTableTestCase; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\ProductEntity; /** * Shared base for read-side integration tests across pgsql / mysql / mariadb. @@ -22,7 +22,7 @@ * 3 | gamma | NULL * 4 | delta | 4.00 * - * Tests exercise the full {@see \Flytachi\Winter\K2\Ppa\Repository\RepositoryViewTrait} + * Tests exercise the full {@see \Flytachi\Winter\Kernel\Ppa\Repository\RepositoryViewTrait} * surface — find / findAll / findColumn / count / exists / static finders * / rawFetch / hydration. */ diff --git a/tests/Localization/LanguageNegotiatorTest.php b/tests/Localization/LanguageNegotiatorTest.php index 4943637..933cee7 100644 --- a/tests/Localization/LanguageNegotiatorTest.php +++ b/tests/Localization/LanguageNegotiatorTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Localization; +namespace Flytachi\Winter\Kernel\Tests\Localization; -use Flytachi\Winter\K2\Localization\LanguageNegotiator; +use Flytachi\Winter\Kernel\Localization\LanguageNegotiator; use PHPUnit\Framework\TestCase; final class LanguageNegotiatorTest extends TestCase diff --git a/tests/Localization/LocaleServiceTest.php b/tests/Localization/LocaleServiceTest.php index 2cb0e27..32cfe7e 100644 --- a/tests/Localization/LocaleServiceTest.php +++ b/tests/Localization/LocaleServiceTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Localization; +namespace Flytachi\Winter\Kernel\Tests\Localization; -use Flytachi\Winter\K2\Localization\LocaleService; +use Flytachi\Winter\Kernel\Localization\LocaleService; use PHPUnit\Framework\TestCase; final class LocaleServiceTest extends TestCase diff --git a/tests/Localization/LocaleTest.php b/tests/Localization/LocaleTest.php index a6ca4fa..c32605c 100644 --- a/tests/Localization/LocaleTest.php +++ b/tests/Localization/LocaleTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Localization; +namespace Flytachi\Winter\Kernel\Tests\Localization; -use Flytachi\Winter\K2\Localization\Locale; -use Flytachi\Winter\K2\Localization\LocaleService; +use Flytachi\Winter\Kernel\Localization\Locale; +use Flytachi\Winter\Kernel\Localization\LocaleService; use PHPUnit\Framework\TestCase; final class LocaleTest extends TestCase diff --git a/tests/Ppa/DeclarationItemTest.php b/tests/Ppa/DeclarationItemTest.php index 987ad8d..7d39b4e 100644 --- a/tests/Ppa/DeclarationItemTest.php +++ b/tests/Ppa/DeclarationItemTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa; - -use Flytachi\Winter\K2\Ppa\DeclarationItem; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Extension; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Migratable; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\MigratablePriority; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Extension as ExtensionStructure; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Table; -use Flytachi\Winter\K2\Tests\Ppa\Fixtures\StubDbConfig; +namespace Flytachi\Winter\Kernel\Tests\Ppa; + +use Flytachi\Winter\Kernel\Ppa\DeclarationItem; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Extension; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Migratable; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\MigratablePriority; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Extension as ExtensionStructure; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Table; +use Flytachi\Winter\Kernel\Tests\Ppa\Fixtures\StubDbConfig; use PHPUnit\Framework\TestCase; // ── Fixture configs ────────────────────────────────────────────────────────── diff --git a/tests/Ppa/DeclarationTest.php b/tests/Ppa/DeclarationTest.php index 88540bb..9f84a61 100644 --- a/tests/Ppa/DeclarationTest.php +++ b/tests/Ppa/DeclarationTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa; +namespace Flytachi\Winter\Kernel\Tests\Ppa; -use Flytachi\Winter\K2\Ppa\Declaration; -use Flytachi\Winter\K2\Ppa\DeclarationItem; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Column; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Table; -use Flytachi\Winter\K2\Tests\Ppa\Fixtures\StubDbConfig; +use Flytachi\Winter\Kernel\Ppa\Declaration; +use Flytachi\Winter\Kernel\Ppa\DeclarationItem; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Column; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Table; +use Flytachi\Winter\Kernel\Tests\Ppa\Fixtures\StubDbConfig; use PHPUnit\Framework\TestCase; final class DeclConfigAlpha extends StubDbConfig diff --git a/tests/Ppa/Fixtures/StubDbConfig.php b/tests/Ppa/Fixtures/StubDbConfig.php index eff27fc..6e634aa 100644 --- a/tests/Ppa/Fixtures/StubDbConfig.php +++ b/tests/Ppa/Fixtures/StubDbConfig.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Fixtures; use Flytachi\Winter\Cdo\Config\Common\DbConfigInterface; use Flytachi\Winter\Cdo\Connection\CDO; diff --git a/tests/Ppa/Mapping/Attributes/Additive/AdditiveAttributesTest.php b/tests/Ppa/Mapping/Attributes/Additive/AdditiveAttributesTest.php index ddff8be..f3b3e39 100644 --- a/tests/Ppa/Mapping/Attributes/Additive/AdditiveAttributesTest.php +++ b/tests/Ppa/Mapping/Attributes/Additive/AdditiveAttributesTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Attributes\Additive; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Attributes\Additive; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\DefaultVal; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\NullableIs; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\DefaultVal; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\NullableIs; use PHPUnit\Framework\TestCase; final class AdditiveAttributesTest extends TestCase diff --git a/tests/Ppa/Mapping/Attributes/Config/ExtensionTest.php b/tests/Ppa/Mapping/Attributes/Config/ExtensionTest.php index 3cf3dbd..7baa9a3 100644 --- a/tests/Ppa/Mapping/Attributes/Config/ExtensionTest.php +++ b/tests/Ppa/Mapping/Attributes/Config/ExtensionTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Attributes\Config; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Attributes\Config; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDbConfig; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Extension; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDbConfig; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Extension; use PHPUnit\Framework\TestCase; use ReflectionAttribute; use ReflectionClass; diff --git a/tests/Ppa/Mapping/Attributes/Config/MigratableTest.php b/tests/Ppa/Mapping/Attributes/Config/MigratableTest.php index 1c93207..204b2de 100644 --- a/tests/Ppa/Mapping/Attributes/Config/MigratableTest.php +++ b/tests/Ppa/Mapping/Attributes/Config/MigratableTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Attributes\Config; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Attributes\Config; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDbConfig; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Migratable; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\MigratablePriority; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDbConfig; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Migratable; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\MigratablePriority; use PHPUnit\Framework\TestCase; use ReflectionAttribute; use ReflectionClass; diff --git a/tests/Ppa/Mapping/Attributes/Constraint/ConstraintAttributesTest.php b/tests/Ppa/Mapping/Attributes/Constraint/ConstraintAttributesTest.php index 3af0b6b..47a3c15 100644 --- a/tests/Ppa/Mapping/Attributes/Constraint/ConstraintAttributesTest.php +++ b/tests/Ppa/Mapping/Attributes/Constraint/ConstraintAttributesTest.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Attributes\Constraint; - -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint\Check; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint\CheckEnum; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint\ForeignKey; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint\ForeignRepo; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\FKAction; -use Flytachi\Winter\K2\Ppa\Mapping\RepositoryMappingInterface; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\CheckConstraint; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey as ForeignKeyStructure; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Attributes\Constraint; + +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint\Check; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint\CheckEnum; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint\ForeignKey; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint\ForeignRepo; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\FKAction; +use Flytachi\Winter\Kernel\Ppa\Mapping\RepositoryMappingInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\CheckConstraint; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey as ForeignKeyStructure; use PHPUnit\Framework\TestCase; // ── Enums used by CheckEnum tests ──────────────────────────────────────────── diff --git a/tests/Ppa/Mapping/Attributes/Hybrid/HybridTypesTest.php b/tests/Ppa/Mapping/Attributes/Hybrid/HybridTypesTest.php index 204d524..c504471 100644 --- a/tests/Ppa/Mapping/Attributes/Hybrid/HybridTypesTest.php +++ b/tests/Ppa/Mapping/Attributes/Hybrid/HybridTypesTest.php @@ -2,20 +2,20 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Attributes\Hybrid; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Attributes\Hybrid; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\DefaultVal; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\NullableIs; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid\BigId; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid\Id; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid\SmallId; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid\UuidPk; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Primary; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\BigInteger; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Integer; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\SmallInteger; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Uuid; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Sub\AutoIncrement; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\DefaultVal; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\NullableIs; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid\BigId; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid\Id; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid\SmallId; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid\UuidPk; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Primary; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\BigInteger; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Integer; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\SmallInteger; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Uuid; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Sub\AutoIncrement; use PHPUnit\Framework\TestCase; final class HybridTypesTest extends TestCase diff --git a/tests/Ppa/Mapping/Attributes/Idx/IdxAttributesTest.php b/tests/Ppa/Mapping/Attributes/Idx/IdxAttributesTest.php index 7d5be6a..11d5592 100644 --- a/tests/Ppa/Mapping/Attributes/Idx/IdxAttributesTest.php +++ b/tests/Ppa/Mapping/Attributes/Idx/IdxAttributesTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Attributes\Idx; - -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Index; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Primary; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Unique; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexMethod; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Index as IndexStructure; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Attributes\Idx; + +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Index; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Primary; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Unique; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexMethod; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index as IndexStructure; use PHPUnit\Framework\TestCase; final class IdxAttributesTest extends TestCase diff --git a/tests/Ppa/Mapping/Attributes/Primal/PrimalTypesTest.php b/tests/Ppa/Mapping/Attributes/Primal/PrimalTypesTest.php index 9d59456..17c3c44 100644 --- a/tests/Ppa/Mapping/Attributes/Primal/PrimalTypesTest.php +++ b/tests/Ppa/Mapping/Attributes/Primal/PrimalTypesTest.php @@ -2,29 +2,29 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Attributes\Primal; - -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\AttributeDbType; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\BigInteger; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Binary; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Blob; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Boolean; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Char; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Date; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\DateTime; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Decimal; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Double; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\FloatType; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Integer; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Json; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\SmallInteger; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Text; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\TextArray; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Time; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Timestamp; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Type; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Uuid; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Varchar; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Attributes\Primal; + +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\AttributeDbType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\BigInteger; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Binary; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Blob; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Boolean; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Char; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Date; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\DateTime; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Decimal; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Double; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\FloatType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Integer; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Json; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\SmallInteger; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Text; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\TextArray; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Time; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Timestamp; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Type; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Uuid; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Varchar; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Ppa/Mapping/Attributes/Sub/AutoIncrementTest.php b/tests/Ppa/Mapping/Attributes/Sub/AutoIncrementTest.php index f16de55..952388f 100644 --- a/tests/Ppa/Mapping/Attributes/Sub/AutoIncrementTest.php +++ b/tests/Ppa/Mapping/Attributes/Sub/AutoIncrementTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Attributes\Sub; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Attributes\Sub; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Sub\AutoIncrement; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Sub\AutoIncrement; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Ppa/Mapping/ColumnMappingTest.php b/tests/Ppa/Mapping/ColumnMappingTest.php index 818e3d6..bb995ec 100644 --- a/tests/Ppa/Mapping/ColumnMappingTest.php +++ b/tests/Ppa/Mapping/ColumnMappingTest.php @@ -2,20 +2,20 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping; - -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\DefaultVal; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\NullableIs; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint\Check; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint\ForeignKey as ForeignKeyAttr; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid\Id; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Index as IndexAttr; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Unique; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Integer; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Varchar; -use Flytachi\Winter\K2\Ppa\Mapping\ColumnMapping; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Column; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping; + +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\DefaultVal; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\NullableIs; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint\Check; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint\ForeignKey as ForeignKeyAttr; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid\Id; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Index as IndexAttr; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Unique; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Integer; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Varchar; +use Flytachi\Winter\Kernel\Ppa\Mapping\ColumnMapping; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Column; use PHPUnit\Framework\TestCase; use ReflectionProperty; diff --git a/tests/Ppa/Mapping/Constants/FKActionTest.php b/tests/Ppa/Mapping/Constants/FKActionTest.php index b02669e..20f794f 100644 --- a/tests/Ppa/Mapping/Constants/FKActionTest.php +++ b/tests/Ppa/Mapping/Constants/FKActionTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Constants; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Constants; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\FKAction; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\FKAction; use PHPUnit\Framework\TestCase; final class FKActionTest extends TestCase diff --git a/tests/Ppa/Mapping/Constants/IndexMethodTest.php b/tests/Ppa/Mapping/Constants/IndexMethodTest.php index c13cd76..04409b2 100644 --- a/tests/Ppa/Mapping/Constants/IndexMethodTest.php +++ b/tests/Ppa/Mapping/Constants/IndexMethodTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Constants; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Constants; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexMethod; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexMethod; use PHPUnit\Framework\TestCase; final class IndexMethodTest extends TestCase diff --git a/tests/Ppa/Mapping/Constants/IndexTypeTest.php b/tests/Ppa/Mapping/Constants/IndexTypeTest.php index c2d1faa..84c7232 100644 --- a/tests/Ppa/Mapping/Constants/IndexTypeTest.php +++ b/tests/Ppa/Mapping/Constants/IndexTypeTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Constants; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Constants; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; use PHPUnit\Framework\TestCase; final class IndexTypeTest extends TestCase diff --git a/tests/Ppa/Mapping/Constants/MigratablePriorityTest.php b/tests/Ppa/Mapping/Constants/MigratablePriorityTest.php index d40d58e..618579c 100644 --- a/tests/Ppa/Mapping/Constants/MigratablePriorityTest.php +++ b/tests/Ppa/Mapping/Constants/MigratablePriorityTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Constants; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Constants; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\MigratablePriority; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\MigratablePriority; use PHPUnit\Framework\TestCase; final class MigratablePriorityTest extends TestCase diff --git a/tests/Ppa/Mapping/SqliteDdlTest.php b/tests/Ppa/Mapping/SqliteDdlTest.php index 673e959..9132b24 100644 --- a/tests/Ppa/Mapping/SqliteDdlTest.php +++ b/tests/Ppa/Mapping/SqliteDdlTest.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping; - -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\DefaultVal; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\NullableIs; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid\Id; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Index; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Unique; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal as P; -use Flytachi\Winter\K2\Ppa\Mapping\ColumnMapping; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Table; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping; + +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\DefaultVal; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\NullableIs; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid\Id; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Index; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Unique; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal as P; +use Flytachi\Winter\Kernel\Ppa\Mapping\ColumnMapping; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Table; use PDO; use PHPUnit\Framework\TestCase; use ReflectionClass; diff --git a/tests/Ppa/Mapping/Structure/CheckConstraintTest.php b/tests/Ppa/Mapping/Structure/CheckConstraintTest.php index 95a7403..dc89e53 100644 --- a/tests/Ppa/Mapping/Structure/CheckConstraintTest.php +++ b/tests/Ppa/Mapping/Structure/CheckConstraintTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Structure; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\CheckConstraint; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\StructureInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\CheckConstraint; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\StructureInterface; use PHPUnit\Framework\TestCase; final class CheckConstraintTest extends TestCase diff --git a/tests/Ppa/Mapping/Structure/ColumnTest.php b/tests/Ppa/Mapping/Structure/ColumnTest.php index 4605a9d..6706e8a 100644 --- a/tests/Ppa/Mapping/Structure/ColumnTest.php +++ b/tests/Ppa/Mapping/Structure/ColumnTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Structure; - -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\CheckConstraint; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Column; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Index; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\StructureInterface; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Structure; + +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\CheckConstraint; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Column; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\StructureInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Ppa/Mapping/Structure/ExtensionTest.php b/tests/Ppa/Mapping/Structure/ExtensionTest.php index 6ff66eb..50a6ff7 100644 --- a/tests/Ppa/Mapping/Structure/ExtensionTest.php +++ b/tests/Ppa/Mapping/Structure/ExtensionTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Structure; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Extension; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\StructureInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Extension; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\StructureInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Ppa/Mapping/Structure/ForeignKeyTest.php b/tests/Ppa/Mapping/Structure/ForeignKeyTest.php index e54862c..c12623a 100644 --- a/tests/Ppa/Mapping/Structure/ForeignKeyTest.php +++ b/tests/Ppa/Mapping/Structure/ForeignKeyTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Structure; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\FKAction; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\StructureInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\FKAction; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\StructureInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Ppa/Mapping/Structure/IndexTest.php b/tests/Ppa/Mapping/Structure/IndexTest.php index 5000afa..a29c7dd 100644 --- a/tests/Ppa/Mapping/Structure/IndexTest.php +++ b/tests/Ppa/Mapping/Structure/IndexTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Structure; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexMethod; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Index; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\StructureInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexMethod; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\StructureInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Ppa/Mapping/Structure/NameValidatorTest.php b/tests/Ppa/Mapping/Structure/NameValidatorTest.php index 7023fbe..cb4454e 100644 --- a/tests/Ppa/Mapping/Structure/NameValidatorTest.php +++ b/tests/Ppa/Mapping/Structure/NameValidatorTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Structure; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\NameValidator; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\NameValidator; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Ppa/Mapping/Structure/StoredProcedureTest.php b/tests/Ppa/Mapping/Structure/StoredProcedureTest.php index 036c23a..2ca9eab 100644 --- a/tests/Ppa/Mapping/Structure/StoredProcedureTest.php +++ b/tests/Ppa/Mapping/Structure/StoredProcedureTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Structure; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\StoredProcedure; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\StructureInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\StoredProcedure; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\StructureInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Ppa/Mapping/Structure/TableTest.php b/tests/Ppa/Mapping/Structure/TableTest.php index bb78abb..58a96fa 100644 --- a/tests/Ppa/Mapping/Structure/TableTest.php +++ b/tests/Ppa/Mapping/Structure/TableTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Structure; - -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\CheckConstraint; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Column; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Index; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\StructureInterface; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Table; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Structure; + +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\CheckConstraint; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Column; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\StructureInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Table; use PHPUnit\Framework\TestCase; final class TableTest extends TestCase diff --git a/tests/Ppa/Mapping/Structure/TriggerTest.php b/tests/Ppa/Mapping/Structure/TriggerTest.php index e29212f..ceeac69 100644 --- a/tests/Ppa/Mapping/Structure/TriggerTest.php +++ b/tests/Ppa/Mapping/Structure/TriggerTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Structure; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\StructureInterface; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Trigger; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\StructureInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Trigger; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Ppa/Mapping/Structure/ViewTest.php b/tests/Ppa/Mapping/Structure/ViewTest.php index ee2284a..a2860e2 100644 --- a/tests/Ppa/Mapping/Structure/ViewTest.php +++ b/tests/Ppa/Mapping/Structure/ViewTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Structure; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\StructureInterface; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\View; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\StructureInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\View; use PHPUnit\Framework\TestCase; final class ViewTest extends TestCase diff --git a/tests/Ppa/Pool/ConnectionLossTest.php b/tests/Ppa/Pool/ConnectionLossTest.php index 7c037ba..cb3b5b9 100644 --- a/tests/Ppa/Pool/ConnectionLossTest.php +++ b/tests/Ppa/Pool/ConnectionLossTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Pool; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Pool; use Flytachi\Winter\Cdo\Connection\CDOException; -use Flytachi\Winter\K2\Ppa\Pool\ConnectionLoss; +use Flytachi\Winter\Kernel\Ppa\Pool\ConnectionLoss; use PDOException; use PHPUnit\Framework\TestCase; use RuntimeException; diff --git a/tests/Ppa/Pool/PoolTelemetryTest.php b/tests/Ppa/Pool/PoolTelemetryTest.php index 8031bf8..df27c4e 100644 --- a/tests/Ppa/Pool/PoolTelemetryTest.php +++ b/tests/Ppa/Pool/PoolTelemetryTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Pool; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Pool; -use Flytachi\Winter\K2\Core\KernelConfig; -use Flytachi\Winter\K2\Core\KernelStore; -use Flytachi\Winter\K2\Kernel; -use Flytachi\Winter\K2\Ppa\Pool\PoolTelemetry; +use Flytachi\Winter\Kernel\Core\KernelConfig; +use Flytachi\Winter\Kernel\Core\KernelStore; +use Flytachi\Winter\Kernel\Kernel; +use Flytachi\Winter\Kernel\Ppa\Pool\PoolTelemetry; use PHPUnit\Framework\TestCase; use ReflectionMethod; use ReflectionProperty; diff --git a/tests/Ppa/Pool/PpaConnectionPoolStatsTest.php b/tests/Ppa/Pool/PpaConnectionPoolStatsTest.php index 9a7945a..12b9bac 100644 --- a/tests/Ppa/Pool/PpaConnectionPoolStatsTest.php +++ b/tests/Ppa/Pool/PpaConnectionPoolStatsTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Pool; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Pool; -use Flytachi\Winter\K2\ConnectionPool\ConnectionPool; -use Flytachi\Winter\K2\ConnectionPool\PoolPolicy; -use Flytachi\Winter\K2\Http\Health\HealthIndicator; -use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; -use Flytachi\Winter\K2\Tests\ConnectionPool\MockFactory; +use Flytachi\Winter\Kernel\ConnectionPool\ConnectionPool; +use Flytachi\Winter\Kernel\ConnectionPool\PoolPolicy; +use Flytachi\Winter\Kernel\Http\Health\HealthIndicator; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; +use Flytachi\Winter\Kernel\Tests\ConnectionPool\MockFactory; use PHPUnit\Framework\TestCase; use ReflectionMethod; use ReflectionProperty; diff --git a/tests/Ppa/Pool/PpaPoolTraitTest.php b/tests/Ppa/Pool/PpaPoolTraitTest.php index 91c3c3a..cdaa113 100644 --- a/tests/Ppa/Pool/PpaPoolTraitTest.php +++ b/tests/Ppa/Pool/PpaPoolTraitTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Pool; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Pool; -use Flytachi\Winter\K2\Ppa\Pool\PpaPoolTrait; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaPoolTrait; use PHPUnit\Framework\TestCase; /** diff --git a/tests/Ppa/Pool/ReportFailureTest.php b/tests/Ppa/Pool/ReportFailureTest.php index 2dfcb38..e41a8ef 100644 --- a/tests/Ppa/Pool/ReportFailureTest.php +++ b/tests/Ppa/Pool/ReportFailureTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Pool; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Pool; use Flytachi\Winter\Cdo\Connection\CDOException; -use Flytachi\Winter\K2\ConnectionPool\PoolEntry; -use Flytachi\Winter\K2\Ppa\Pool\BorrowedConnection; -use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; +use Flytachi\Winter\Kernel\ConnectionPool\PoolEntry; +use Flytachi\Winter\Kernel\Ppa\Pool\BorrowedConnection; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; use PDOException; use PHPUnit\Framework\TestCase; diff --git a/tests/Ppa/Repository/BindsAndCacheTest.php b/tests/Ppa/Repository/BindsAndCacheTest.php index 25dd842..21e9a1e 100644 --- a/tests/Ppa/Repository/BindsAndCacheTest.php +++ b/tests/Ppa/Repository/BindsAndCacheTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository; use Flytachi\Winter\Cdo\CDOBind; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\UsersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\UsersRepo; use PHPUnit\Framework\TestCase; final class BindsAndCacheTest extends TestCase diff --git a/tests/Ppa/Repository/BuildSqlOrderTest.php b/tests/Ppa/Repository/BuildSqlOrderTest.php index cc127fc..44d9539 100644 --- a/tests/Ppa/Repository/BuildSqlOrderTest.php +++ b/tests/Ppa/Repository/BuildSqlOrderTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository; use Flytachi\Winter\Cdo\CDOBind; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\OrdersRepo; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\UsersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\OrdersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\UsersRepo; use PHPUnit\Framework\TestCase; /** diff --git a/tests/Ppa/Repository/Fixtures/OrdersRepo.php b/tests/Ppa/Repository/Fixtures/OrdersRepo.php index 828d3bb..34c5602 100644 --- a/tests/Ppa/Repository/Fixtures/OrdersRepo.php +++ b/tests/Ppa/Repository/Fixtures/OrdersRepo.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures; -use Flytachi\Winter\K2\Ppa\Stereotype\RepositoryView; +use Flytachi\Winter\Kernel\Ppa\Stereotype\RepositoryView; final class OrdersRepo extends RepositoryView { diff --git a/tests/Ppa/Repository/Fixtures/RepoTestDbConfig.php b/tests/Ppa/Repository/Fixtures/RepoTestDbConfig.php index dc9b3fd..aa9f3ee 100644 --- a/tests/Ppa/Repository/Fixtures/RepoTestDbConfig.php +++ b/tests/Ppa/Repository/Fixtures/RepoTestDbConfig.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures; -use Flytachi\Winter\K2\Tests\Ppa\Fixtures\StubDbConfig; +use Flytachi\Winter\Kernel\Tests\Ppa\Fixtures\StubDbConfig; /** * Shared no-op DbConfig used by every Repository test. PpaConnectionPool diff --git a/tests/Ppa/Repository/Fixtures/SelectionMappedRepo.php b/tests/Ppa/Repository/Fixtures/SelectionMappedRepo.php index 7051a45..291129b 100644 --- a/tests/Ppa/Repository/Fixtures/SelectionMappedRepo.php +++ b/tests/Ppa/Repository/Fixtures/SelectionMappedRepo.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures; -use Flytachi\Winter\K2\Ppa\Stereotype\RepositoryView; +use Flytachi\Winter\Kernel\Ppa\Stereotype\RepositoryView; final class SelectionMappedRepo extends RepositoryView { diff --git a/tests/Ppa/Repository/Fixtures/TypedUsersRepo.php b/tests/Ppa/Repository/Fixtures/TypedUsersRepo.php index b3e353c..681ee2f 100644 --- a/tests/Ppa/Repository/Fixtures/TypedUsersRepo.php +++ b/tests/Ppa/Repository/Fixtures/TypedUsersRepo.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures; -use Flytachi\Winter\K2\Ppa\Stereotype\RepositoryView; +use Flytachi\Winter\Kernel\Ppa\Stereotype\RepositoryView; final class TypedUsersRepo extends RepositoryView { diff --git a/tests/Ppa/Repository/Fixtures/UserEntity.php b/tests/Ppa/Repository/Fixtures/UserEntity.php index b962253..076d585 100644 --- a/tests/Ppa/Repository/Fixtures/UserEntity.php +++ b/tests/Ppa/Repository/Fixtures/UserEntity.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures; /** * Typed entity — buildSql() with this entity class will emit column list diff --git a/tests/Ppa/Repository/Fixtures/UserWithSelectionEntity.php b/tests/Ppa/Repository/Fixtures/UserWithSelectionEntity.php index 1320aaa..f949c4d 100644 --- a/tests/Ppa/Repository/Fixtures/UserWithSelectionEntity.php +++ b/tests/Ppa/Repository/Fixtures/UserWithSelectionEntity.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures; -use Flytachi\Winter\K2\Ppa\Entity\EntityInterface; +use Flytachi\Winter\Kernel\Ppa\Entity\EntityInterface; /** * Entity that overrides one column via the EntityInterface::selection() map. diff --git a/tests/Ppa/Repository/Fixtures/UsersRepo.php b/tests/Ppa/Repository/Fixtures/UsersRepo.php index a3949d1..9af93be 100644 --- a/tests/Ppa/Repository/Fixtures/UsersRepo.php +++ b/tests/Ppa/Repository/Fixtures/UsersRepo.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures; -use Flytachi\Winter\K2\Ppa\Stereotype\RepositoryView; +use Flytachi\Winter\Kernel\Ppa\Stereotype\RepositoryView; /** * Plain repo with no typed entity — buildSql() will emit `SELECT *`. diff --git a/tests/Ppa/Repository/GroupOrderLimitTest.php b/tests/Ppa/Repository/GroupOrderLimitTest.php index 9a616d0..ea3a8f2 100644 --- a/tests/Ppa/Repository/GroupOrderLimitTest.php +++ b/tests/Ppa/Repository/GroupOrderLimitTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository; use Dotenv\Parser\Value; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\UsersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\UsersRepo; use PHPUnit\Framework\TestCase; use TypeError; use ValueError; diff --git a/tests/Ppa/Repository/JoinBuilderTest.php b/tests/Ppa/Repository/JoinBuilderTest.php index a5dd138..6e312d9 100644 --- a/tests/Ppa/Repository/JoinBuilderTest.php +++ b/tests/Ppa/Repository/JoinBuilderTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\OrdersRepo; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\UsersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\OrdersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\UsersRepo; use PHPUnit\Framework\TestCase; final class JoinBuilderTest extends TestCase diff --git a/tests/Ppa/Repository/PrepareSelectTest.php b/tests/Ppa/Repository/PrepareSelectTest.php index 0dd139c..410b265 100644 --- a/tests/Ppa/Repository/PrepareSelectTest.php +++ b/tests/Ppa/Repository/PrepareSelectTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\SelectionMappedRepo; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\TypedUsersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\SelectionMappedRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\TypedUsersRepo; use PHPUnit\Framework\TestCase; final class PrepareSelectTest extends TestCase @@ -15,7 +15,7 @@ final class PrepareSelectTest extends TestCase public function test_stdClass_entity_emits_select_star(): void { // UsersRepo uses default stdClass entity → '*' in SELECT. - $r = \Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\UsersRepo::instance(); + $r = \Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\UsersRepo::instance(); self::assertStringStartsWith('SELECT * FROM ', $r->buildSql()); } diff --git a/tests/Ppa/Repository/SelectFromAsTest.php b/tests/Ppa/Repository/SelectFromAsTest.php index f5b6adc..cb5b889 100644 --- a/tests/Ppa/Repository/SelectFromAsTest.php +++ b/tests/Ppa/Repository/SelectFromAsTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryException; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\OrdersRepo; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\TypedUsersRepo; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\UsersRepo; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryException; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\OrdersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\TypedUsersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\UsersRepo; use PHPUnit\Framework\TestCase; final class SelectFromAsTest extends TestCase diff --git a/tests/Ppa/Repository/UnionCteTest.php b/tests/Ppa/Repository/UnionCteTest.php index bc823f6..1ead149 100644 --- a/tests/Ppa/Repository/UnionCteTest.php +++ b/tests/Ppa/Repository/UnionCteTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository; use Flytachi\Winter\Cdo\CDOBind; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\OrdersRepo; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\UsersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\OrdersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\UsersRepo; use PHPUnit\Framework\TestCase; final class UnionCteTest extends TestCase diff --git a/tests/Ppa/Repository/WhereBuilderTest.php b/tests/Ppa/Repository/WhereBuilderTest.php index 339f871..c6b9cc4 100644 --- a/tests/Ppa/Repository/WhereBuilderTest.php +++ b/tests/Ppa/Repository/WhereBuilderTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository; use Flytachi\Winter\Cdo\CDOBind; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\UsersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\UsersRepo; use PHPUnit\Framework\TestCase; final class WhereBuilderTest extends TestCase diff --git a/tests/Process/ActivityTest.php b/tests/Process/ActivityTest.php index 4a26e77..36a7dc3 100644 --- a/tests/Process/ActivityTest.php +++ b/tests/Process/ActivityTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process; +namespace Flytachi\Winter\Kernel\Tests\Process; -use Flytachi\Winter\K2\Process\Activity; +use Flytachi\Winter\Kernel\Process\Activity; use PHPUnit\Framework\TestCase; final class ActivityTest extends TestCase diff --git a/tests/Process/Daemon/DaemonStatusTest.php b/tests/Process/Daemon/DaemonStatusTest.php index 1beb983..082030f 100644 --- a/tests/Process/Daemon/DaemonStatusTest.php +++ b/tests/Process/Daemon/DaemonStatusTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Daemon; +namespace Flytachi\Winter\Kernel\Tests\Process\Daemon; -use Flytachi\Winter\K2\Process\Activity; -use Flytachi\Winter\K2\Process\Daemon\DaemonStatus; -use Flytachi\Winter\K2\Process\Daemon\SlotState; -use Flytachi\Winter\K2\Process\Daemon\WorkerStatus; -use Flytachi\Winter\K2\Process\ProcessState; -use Flytachi\Winter\K2\Process\ProcessStatus; +use Flytachi\Winter\Kernel\Process\Activity; +use Flytachi\Winter\Kernel\Process\Daemon\DaemonStatus; +use Flytachi\Winter\Kernel\Process\Daemon\SlotState; +use Flytachi\Winter\Kernel\Process\Daemon\WorkerStatus; +use Flytachi\Winter\Kernel\Process\ProcessState; +use Flytachi\Winter\Kernel\Process\ProcessStatus; use PHPUnit\Framework\TestCase; final class DaemonStatusTest extends TestCase diff --git a/tests/Process/Daemon/DaemonTest.php b/tests/Process/Daemon/DaemonTest.php index c4c5b8d..ee56515 100644 --- a/tests/Process/Daemon/DaemonTest.php +++ b/tests/Process/Daemon/DaemonTest.php @@ -2,18 +2,18 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Daemon; - -use Flytachi\Winter\K2\Process\Daemon\Daemon; -use Flytachi\Winter\K2\Process\Daemon\DaemonConfigException; -use Flytachi\Winter\K2\Process\Daemon\RestartMode; -use Flytachi\Winter\K2\Process\Daemon\RestartPolicy; -use Flytachi\Winter\K2\Process\Daemon\ScalingPolicy; -use Flytachi\Winter\K2\Tests\Process\Fixtures\BlankDaemon; -use Flytachi\Winter\K2\Tests\Process\Fixtures\ClampDaemon; -use Flytachi\Winter\K2\Tests\Process\Fixtures\DefaultDaemon; -use Flytachi\Winter\K2\Tests\Process\Fixtures\ExternalDaemon; -use Flytachi\Winter\K2\Tests\Process\Fixtures\InlineDaemon; +namespace Flytachi\Winter\Kernel\Tests\Process\Daemon; + +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; +use Flytachi\Winter\Kernel\Process\Daemon\DaemonConfigException; +use Flytachi\Winter\Kernel\Process\Daemon\RestartMode; +use Flytachi\Winter\Kernel\Process\Daemon\RestartPolicy; +use Flytachi\Winter\Kernel\Process\Daemon\ScalingPolicy; +use Flytachi\Winter\Kernel\Tests\Process\Fixtures\BlankDaemon; +use Flytachi\Winter\Kernel\Tests\Process\Fixtures\ClampDaemon; +use Flytachi\Winter\Kernel\Tests\Process\Fixtures\DefaultDaemon; +use Flytachi\Winter\Kernel\Tests\Process\Fixtures\ExternalDaemon; +use Flytachi\Winter\Kernel\Tests\Process\Fixtures\InlineDaemon; use PHPUnit\Framework\TestCase; final class DaemonTest extends TestCase diff --git a/tests/Process/Daemon/RestartModeTest.php b/tests/Process/Daemon/RestartModeTest.php index 498f9c8..c32472e 100644 --- a/tests/Process/Daemon/RestartModeTest.php +++ b/tests/Process/Daemon/RestartModeTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Daemon; +namespace Flytachi\Winter\Kernel\Tests\Process\Daemon; -use Flytachi\Winter\K2\Process\Daemon\RestartMode; +use Flytachi\Winter\Kernel\Process\Daemon\RestartMode; use PHPUnit\Framework\TestCase; final class RestartModeTest extends TestCase diff --git a/tests/Process/Daemon/RestartPolicyTest.php b/tests/Process/Daemon/RestartPolicyTest.php index 90eb126..1fcc917 100644 --- a/tests/Process/Daemon/RestartPolicyTest.php +++ b/tests/Process/Daemon/RestartPolicyTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Daemon; +namespace Flytachi\Winter\Kernel\Tests\Process\Daemon; -use Flytachi\Winter\K2\Process\Daemon\RestartMode; -use Flytachi\Winter\K2\Process\Daemon\RestartPolicy; +use Flytachi\Winter\Kernel\Process\Daemon\RestartMode; +use Flytachi\Winter\Kernel\Process\Daemon\RestartPolicy; use PHPUnit\Framework\TestCase; final class RestartPolicyTest extends TestCase diff --git a/tests/Process/Daemon/ScalingPolicyTest.php b/tests/Process/Daemon/ScalingPolicyTest.php index 46c3a89..28afda3 100644 --- a/tests/Process/Daemon/ScalingPolicyTest.php +++ b/tests/Process/Daemon/ScalingPolicyTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Daemon; +namespace Flytachi\Winter\Kernel\Tests\Process\Daemon; -use Flytachi\Winter\K2\Process\Daemon\ScalingPolicy; +use Flytachi\Winter\Kernel\Process\Daemon\ScalingPolicy; use PHPUnit\Framework\TestCase; final class ScalingPolicyTest extends TestCase diff --git a/tests/Process/Daemon/SlotStateTest.php b/tests/Process/Daemon/SlotStateTest.php index 5cc3b91..7bee272 100644 --- a/tests/Process/Daemon/SlotStateTest.php +++ b/tests/Process/Daemon/SlotStateTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Daemon; +namespace Flytachi\Winter\Kernel\Tests\Process\Daemon; -use Flytachi\Winter\K2\Process\Daemon\SlotState; +use Flytachi\Winter\Kernel\Process\Daemon\SlotState; use PHPUnit\Framework\TestCase; final class SlotStateTest extends TestCase diff --git a/tests/Process/Daemon/SlotTest.php b/tests/Process/Daemon/SlotTest.php index 2e2610d..7f3fa98 100644 --- a/tests/Process/Daemon/SlotTest.php +++ b/tests/Process/Daemon/SlotTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Daemon; +namespace Flytachi\Winter\Kernel\Tests\Process\Daemon; -use Flytachi\Winter\K2\Process\Activity; -use Flytachi\Winter\K2\Process\Daemon\Slot; -use Flytachi\Winter\K2\Process\Daemon\SlotState; +use Flytachi\Winter\Kernel\Process\Activity; +use Flytachi\Winter\Kernel\Process\Daemon\Slot; +use Flytachi\Winter\Kernel\Process\Daemon\SlotState; use PHPUnit\Framework\TestCase; final class SlotTest extends TestCase diff --git a/tests/Process/Daemon/SupervisesFleetTest.php b/tests/Process/Daemon/SupervisesFleetTest.php index 43ebce0..34a4641 100644 --- a/tests/Process/Daemon/SupervisesFleetTest.php +++ b/tests/Process/Daemon/SupervisesFleetTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Daemon; - -use Flytachi\Winter\K2\Process\Activity; -use Flytachi\Winter\K2\Process\Daemon\Daemon; -use Flytachi\Winter\K2\Process\Daemon\ScalingPolicy; -use Flytachi\Winter\K2\Process\Daemon\Slot; -use Flytachi\Winter\K2\Process\Daemon\SlotState; -use Flytachi\Winter\K2\Tests\Process\Fixtures\StubDaemon; +namespace Flytachi\Winter\Kernel\Tests\Process\Daemon; + +use Flytachi\Winter\Kernel\Process\Activity; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; +use Flytachi\Winter\Kernel\Process\Daemon\ScalingPolicy; +use Flytachi\Winter\Kernel\Process\Daemon\Slot; +use Flytachi\Winter\Kernel\Process\Daemon\SlotState; +use Flytachi\Winter\Kernel\Tests\Process\Fixtures\StubDaemon; use PHPUnit\Framework\TestCase; /** diff --git a/tests/Process/Daemon/WorkerStatusTest.php b/tests/Process/Daemon/WorkerStatusTest.php index 004cef6..dbb4e37 100644 --- a/tests/Process/Daemon/WorkerStatusTest.php +++ b/tests/Process/Daemon/WorkerStatusTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Daemon; +namespace Flytachi\Winter\Kernel\Tests\Process\Daemon; -use Flytachi\Winter\K2\Process\Activity; -use Flytachi\Winter\K2\Process\Daemon\SlotState; -use Flytachi\Winter\K2\Process\Daemon\WorkerStatus; +use Flytachi\Winter\Kernel\Process\Activity; +use Flytachi\Winter\Kernel\Process\Daemon\SlotState; +use Flytachi\Winter\Kernel\Process\Daemon\WorkerStatus; use PHPUnit\Framework\TestCase; final class WorkerStatusTest extends TestCase diff --git a/tests/Process/ExceptionsTest.php b/tests/Process/ExceptionsTest.php index 8c1eb89..e65a2b8 100644 --- a/tests/Process/ExceptionsTest.php +++ b/tests/Process/ExceptionsTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process; +namespace Flytachi\Winter\Kernel\Tests\Process; -use Flytachi\Winter\K2\Process\Daemon\DaemonConfigException; -use Flytachi\Winter\K2\Process\InterruptedException; -use Flytachi\Winter\K2\Process\ProcessAlreadyRunningException; +use Flytachi\Winter\Kernel\Process\Daemon\DaemonConfigException; +use Flytachi\Winter\Kernel\Process\InterruptedException; +use Flytachi\Winter\Kernel\Process\ProcessAlreadyRunningException; use PHPUnit\Framework\TestCase; final class ExceptionsTest extends TestCase diff --git a/tests/Process/Fixtures/AutoscaleLoopDaemon.php b/tests/Process/Fixtures/AutoscaleLoopDaemon.php index 284f437..85cb4ae 100644 --- a/tests/Process/Fixtures/AutoscaleLoopDaemon.php +++ b/tests/Process/Fixtures/AutoscaleLoopDaemon.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Process\Fixtures; -use Flytachi\Winter\K2\Process\Daemon\Daemon; -use Flytachi\Winter\K2\Process\Daemon\ScalingPolicy; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; +use Flytachi\Winter\Kernel\Process\Daemon\ScalingPolicy; /** * Integration fixture: desiredReplicas() ramps 1 → 4 → 2 with a fast scaling diff --git a/tests/Process/Fixtures/BlankDaemon.php b/tests/Process/Fixtures/BlankDaemon.php index 7124d4f..4682042 100644 --- a/tests/Process/Fixtures/BlankDaemon.php +++ b/tests/Process/Fixtures/BlankDaemon.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Process\Fixtures; -use Flytachi\Winter\K2\Process\Daemon\Daemon; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; /** * Misconfigured daemon: neither workerRun() nor $workerClass — bootWorker() must diff --git a/tests/Process/Fixtures/BusyIdleDaemon.php b/tests/Process/Fixtures/BusyIdleDaemon.php index 5a60ebd..217c567 100644 --- a/tests/Process/Fixtures/BusyIdleDaemon.php +++ b/tests/Process/Fixtures/BusyIdleDaemon.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Process\Fixtures; -use Flytachi\Winter\K2\Process\Daemon\Daemon; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; /** * Integration fixture: worker alternates BUSY / IDLE, so the supervisor's diff --git a/tests/Process/Fixtures/ClampDaemon.php b/tests/Process/Fixtures/ClampDaemon.php index d65807b..28dcf27 100644 --- a/tests/Process/Fixtures/ClampDaemon.php +++ b/tests/Process/Fixtures/ClampDaemon.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Process\Fixtures; -use Flytachi\Winter\K2\Process\Daemon\Daemon; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; /** * Daemon configured with out-of-range values, to verify the introspection diff --git a/tests/Process/Fixtures/CrashCapDaemon.php b/tests/Process/Fixtures/CrashCapDaemon.php index 7a63d34..e865cd1 100644 --- a/tests/Process/Fixtures/CrashCapDaemon.php +++ b/tests/Process/Fixtures/CrashCapDaemon.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Process\Fixtures; -use Flytachi\Winter\K2\Process\Daemon\Daemon; -use Flytachi\Winter\K2\Process\Daemon\RestartMode; -use Flytachi\Winter\K2\Process\Daemon\RestartPolicy; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; +use Flytachi\Winter\Kernel\Process\Daemon\RestartMode; +use Flytachi\Winter\Kernel\Process\Daemon\RestartPolicy; /** * Integration fixture: worker always crashes. Exercises restart-into-the-same-slot diff --git a/tests/Process/Fixtures/CrashLoopDaemon.php b/tests/Process/Fixtures/CrashLoopDaemon.php index 356bca3..0f1e98b 100644 --- a/tests/Process/Fixtures/CrashLoopDaemon.php +++ b/tests/Process/Fixtures/CrashLoopDaemon.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Process\Fixtures; -use Flytachi\Winter\K2\Process\Daemon\Daemon; -use Flytachi\Winter\K2\Process\Daemon\RestartMode; -use Flytachi\Winter\K2\Process\Daemon\RestartPolicy; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; +use Flytachi\Winter\Kernel\Process\Daemon\RestartMode; +use Flytachi\Winter\Kernel\Process\Daemon\RestartPolicy; /** * Integration fixture: worker crashes on a loop, restarted forever (no ceiling), diff --git a/tests/Process/Fixtures/DefaultDaemon.php b/tests/Process/Fixtures/DefaultDaemon.php index 103f3ff..cda8d22 100644 --- a/tests/Process/Fixtures/DefaultDaemon.php +++ b/tests/Process/Fixtures/DefaultDaemon.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Process\Fixtures; -use Flytachi\Winter\K2\Process\Daemon\Daemon; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; /** * Daemon that overrides nothing but the body — used to assert the shipped diff --git a/tests/Process/Fixtures/ExternalDaemon.php b/tests/Process/Fixtures/ExternalDaemon.php index b7745e5..a1b8670 100644 --- a/tests/Process/Fixtures/ExternalDaemon.php +++ b/tests/Process/Fixtures/ExternalDaemon.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Process\Fixtures; -use Flytachi\Winter\K2\Process\Daemon\Daemon; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; /** * Worker-typed daemon: no workerRun(), supervises an external Process class. diff --git a/tests/Process/Fixtures/HungLoopDaemon.php b/tests/Process/Fixtures/HungLoopDaemon.php index a8b2381..d60c246 100644 --- a/tests/Process/Fixtures/HungLoopDaemon.php +++ b/tests/Process/Fixtures/HungLoopDaemon.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Process\Fixtures; -use Flytachi\Winter\K2\Process\Daemon\Daemon; -use Flytachi\Winter\K2\Process\Daemon\RestartMode; -use Flytachi\Winter\K2\Process\Daemon\RestartPolicy; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; +use Flytachi\Winter\Kernel\Process\Daemon\RestartMode; +use Flytachi\Winter\Kernel\Process\Daemon\RestartPolicy; /** * Integration fixture: worker wedges in a tight loop after one beat — alive by diff --git a/tests/Process/Fixtures/InlineDaemon.php b/tests/Process/Fixtures/InlineDaemon.php index 4e18da8..5e9577e 100644 --- a/tests/Process/Fixtures/InlineDaemon.php +++ b/tests/Process/Fixtures/InlineDaemon.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Process\Fixtures; -use Flytachi\Winter\K2\Process\Daemon\Daemon; -use Flytachi\Winter\K2\Process\Daemon\RestartMode; -use Flytachi\Winter\K2\Process\Daemon\RestartPolicy; -use Flytachi\Winter\K2\Process\Daemon\ScalingPolicy; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; +use Flytachi\Winter\Kernel\Process\Daemon\RestartMode; +use Flytachi\Winter\Kernel\Process\Daemon\RestartPolicy; +use Flytachi\Winter\Kernel\Process\Daemon\ScalingPolicy; /** * Inline-body daemon (workerRun defined) with every policy overridden and the diff --git a/tests/Process/Fixtures/LoopDaemon.php b/tests/Process/Fixtures/LoopDaemon.php index 0fcfd3b..204cf7c 100644 --- a/tests/Process/Fixtures/LoopDaemon.php +++ b/tests/Process/Fixtures/LoopDaemon.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Process\Fixtures; -use Flytachi\Winter\K2\Process\Daemon\Daemon; -use Flytachi\Winter\K2\Process\Daemon\RestartMode; -use Flytachi\Winter\K2\Process\Daemon\RestartPolicy; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; +use Flytachi\Winter\Kernel\Process\Daemon\RestartMode; +use Flytachi\Winter\Kernel\Process\Daemon\RestartPolicy; /** * Integration fixture: a long-lived worker fleet that loops until stopped. diff --git a/tests/Process/Fixtures/LoopWorker.php b/tests/Process/Fixtures/LoopWorker.php index 7d00035..4669821 100644 --- a/tests/Process/Fixtures/LoopWorker.php +++ b/tests/Process/Fixtures/LoopWorker.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Process\Fixtures; -use Flytachi\Winter\K2\Process\Process; +use Flytachi\Winter\Kernel\Process\Stereotype\Process; /** * Integration fixture: a standalone worker Process that loops until stopped — diff --git a/tests/Process/Fixtures/NeverCrashDaemon.php b/tests/Process/Fixtures/NeverCrashDaemon.php index cec8f32..201b88d 100644 --- a/tests/Process/Fixtures/NeverCrashDaemon.php +++ b/tests/Process/Fixtures/NeverCrashDaemon.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Process\Fixtures; -use Flytachi\Winter\K2\Process\Daemon\Daemon; -use Flytachi\Winter\K2\Process\Daemon\RestartMode; -use Flytachi\Winter\K2\Process\Daemon\RestartPolicy; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; +use Flytachi\Winter\Kernel\Process\Daemon\RestartMode; +use Flytachi\Winter\Kernel\Process\Daemon\RestartPolicy; /** * Integration fixture: NEVER restart + a crashing worker. Each dead worker must diff --git a/tests/Process/Fixtures/SampleProcess.php b/tests/Process/Fixtures/SampleProcess.php index 3fb171c..2fe6a5f 100644 --- a/tests/Process/Fixtures/SampleProcess.php +++ b/tests/Process/Fixtures/SampleProcess.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Process\Fixtures; -use Flytachi\Winter\K2\Process\Process; +use Flytachi\Winter\Kernel\Process\Stereotype\Process; /** * Minimal bare process for title / afterFork tests. Never actually run. diff --git a/tests/Process/Fixtures/SignalProcess.php b/tests/Process/Fixtures/SignalProcess.php index aa5c803..6a06579 100644 --- a/tests/Process/Fixtures/SignalProcess.php +++ b/tests/Process/Fixtures/SignalProcess.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Process\Fixtures; -use Flytachi\Winter\K2\Process\Process; +use Flytachi\Winter\Kernel\Process\Stereotype\Process; /** * Integration fixture: a bare process that records every signal hook to the file diff --git a/tests/Process/Fixtures/StubDaemon.php b/tests/Process/Fixtures/StubDaemon.php index a1abbb2..f67a2f3 100644 --- a/tests/Process/Fixtures/StubDaemon.php +++ b/tests/Process/Fixtures/StubDaemon.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Process\Fixtures; -use Flytachi\Winter\K2\Process\Daemon\Daemon; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; /** * Daemon whose desiredReplicas() is settable, for driving Supervisor damping diff --git a/tests/Process/Fixtures/StuckStopDaemon.php b/tests/Process/Fixtures/StuckStopDaemon.php index 6ec8315..196e925 100644 --- a/tests/Process/Fixtures/StuckStopDaemon.php +++ b/tests/Process/Fixtures/StuckStopDaemon.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Process\Fixtures; -use Flytachi\Winter\K2\Process\Daemon\Daemon; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; /** * Integration fixture: worker wedges and never drains, with a long grace. A first diff --git a/tests/Process/Fixtures/TitledProcess.php b/tests/Process/Fixtures/TitledProcess.php index cfbe53a..0a17567 100644 --- a/tests/Process/Fixtures/TitledProcess.php +++ b/tests/Process/Fixtures/TitledProcess.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Process\Fixtures; -use Flytachi\Winter\K2\Process\Process; +use Flytachi\Winter\Kernel\Process\Stereotype\Process; /** * Bare process with an explicit title, to check titleName() precedence. diff --git a/tests/Process/Fixtures/WorkerClassDaemon.php b/tests/Process/Fixtures/WorkerClassDaemon.php index 9ed628f..51a533e 100644 --- a/tests/Process/Fixtures/WorkerClassDaemon.php +++ b/tests/Process/Fixtures/WorkerClassDaemon.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Process\Fixtures; -use Flytachi\Winter\K2\Process\Daemon\Daemon; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; /** * Integration fixture: worker-typed daemon (no workerRun) that supervises an diff --git a/tests/Process/ForkResetTest.php b/tests/Process/ForkResetTest.php index 40ff2d1..cbbc045 100644 --- a/tests/Process/ForkResetTest.php +++ b/tests/Process/ForkResetTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process; +namespace Flytachi\Winter\Kernel\Tests\Process; -use Flytachi\Winter\K2\Process\ForkReset; +use Flytachi\Winter\Kernel\Process\ForkReset; use PHPUnit\Framework\TestCase; final class ForkResetTest extends TestCase diff --git a/tests/Process/Integration/DaemonIntegrationTest.php b/tests/Process/Integration/DaemonIntegrationTest.php index cd7ed84..ca6dd5b 100644 --- a/tests/Process/Integration/DaemonIntegrationTest.php +++ b/tests/Process/Integration/DaemonIntegrationTest.php @@ -2,26 +2,26 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Integration; - -use Flytachi\Winter\K2\Process\Activity; -use Flytachi\Winter\K2\Process\Daemon\DaemonStatus; -use Flytachi\Winter\K2\Process\Daemon\SlotState; -use Flytachi\Winter\K2\Tests\Process\Fixtures\AutoscaleLoopDaemon; -use Flytachi\Winter\K2\Tests\Process\Fixtures\BusyIdleDaemon; -use Flytachi\Winter\K2\Tests\Process\Fixtures\CrashCapDaemon; -use Flytachi\Winter\K2\Tests\Process\Fixtures\CrashLoopDaemon; -use Flytachi\Winter\K2\Tests\Process\Fixtures\HungLoopDaemon; -use Flytachi\Winter\K2\Tests\Process\Fixtures\LoopDaemon; -use Flytachi\Winter\K2\Tests\Process\Fixtures\NeverCrashDaemon; -use Flytachi\Winter\K2\Tests\Process\Fixtures\StuckStopDaemon; -use Flytachi\Winter\K2\Tests\Process\Fixtures\WorkerClassDaemon; +namespace Flytachi\Winter\Kernel\Tests\Process\Integration; + +use Flytachi\Winter\Kernel\Process\Activity; +use Flytachi\Winter\Kernel\Process\Daemon\DaemonStatus; +use Flytachi\Winter\Kernel\Process\Daemon\SlotState; +use Flytachi\Winter\Kernel\Tests\Process\Fixtures\AutoscaleLoopDaemon; +use Flytachi\Winter\Kernel\Tests\Process\Fixtures\BusyIdleDaemon; +use Flytachi\Winter\Kernel\Tests\Process\Fixtures\CrashCapDaemon; +use Flytachi\Winter\Kernel\Tests\Process\Fixtures\CrashLoopDaemon; +use Flytachi\Winter\Kernel\Tests\Process\Fixtures\HungLoopDaemon; +use Flytachi\Winter\Kernel\Tests\Process\Fixtures\LoopDaemon; +use Flytachi\Winter\Kernel\Tests\Process\Fixtures\NeverCrashDaemon; +use Flytachi\Winter\Kernel\Tests\Process\Fixtures\StuckStopDaemon; +use Flytachi\Winter\Kernel\Tests\Process\Fixtures\WorkerClassDaemon; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] final class DaemonIntegrationTest extends IntegrationCase { - /** @param class-string<\Flytachi\Winter\K2\Process\Daemon\Daemon> $class */ + /** @param class-string<\Flytachi\Winter\Kernel\Process\Stereotype\Daemon> $class */ private function daemonStatus(string $class): ?DaemonStatus { $s = $class::status(); diff --git a/tests/Process/Integration/DispatchRunnerTest.php b/tests/Process/Integration/DispatchRunnerTest.php index 0e59b9d..9963d04 100644 --- a/tests/Process/Integration/DispatchRunnerTest.php +++ b/tests/Process/Integration/DispatchRunnerTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Integration; +namespace Flytachi\Winter\Kernel\Tests\Process\Integration; -use Flytachi\Winter\K2\Core\KernelStore; -use Flytachi\Winter\K2\Kernel; -use Flytachi\Winter\K2\Tests\Process\Integration\Fixtures\DispatchMarkerProcess; +use Flytachi\Winter\Kernel\Core\KernelStore; +use Flytachi\Winter\Kernel\Kernel; +use Flytachi\Winter\Kernel\Tests\Process\Integration\Fixtures\DispatchMarkerProcess; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -135,8 +135,8 @@ private function buildProject(): void declare(strict_types=1); require '{$repo}/vendor/autoload.php'; - #[\Flytachi\Winter\K2\App\Attribute\EnableWeb] - final class WkDispatchFixtureApp extends \Flytachi\Winter\K2\WinterApplication + #[\Flytachi\Winter\Kernel\App\Attribute\EnableWeb] + final class WkDispatchFixtureApp extends \Flytachi\Winter\Kernel\WinterApplication { public static function main(array \$argv): never { parent::run(\$argv); } } diff --git a/tests/Process/Integration/Fixtures/DispatchMarkerProcess.php b/tests/Process/Integration/Fixtures/DispatchMarkerProcess.php index 6bfbab9..3ad7f6b 100644 --- a/tests/Process/Integration/Fixtures/DispatchMarkerProcess.php +++ b/tests/Process/Integration/Fixtures/DispatchMarkerProcess.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Process\Integration\Fixtures; -use Flytachi\Winter\K2\Process\Process; +use Flytachi\Winter\Kernel\Process\Stereotype\Process; /** - * Dispatched by {@see \Flytachi\Winter\K2\Tests\Process\Integration\DispatchRunnerTest} + * Dispatched by {@see \Flytachi\Winter\Kernel\Tests\Process\Integration\DispatchRunnerTest} * into a detached process. It writes its PID to a marker file — the only evidence the * test process can observe, since the child is a separate PHP process with its own * kernel — and then idles until stopped. diff --git a/tests/Process/Integration/IntegrationCase.php b/tests/Process/Integration/IntegrationCase.php index 662ef12..976e066 100644 --- a/tests/Process/Integration/IntegrationCase.php +++ b/tests/Process/Integration/IntegrationCase.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Integration; +namespace Flytachi\Winter\Kernel\Tests\Process\Integration; use Flytachi\Winter\DI\Container; -use Flytachi\Winter\K2\Core\KernelStore; -use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\Kernel\Core\KernelStore; +use Flytachi\Winter\Kernel\Kernel; use PHPUnit\Framework\TestCase; /** diff --git a/tests/Process/Integration/ProcessSignalIntegrationTest.php b/tests/Process/Integration/ProcessSignalIntegrationTest.php index 573fa35..b9a3c4b 100644 --- a/tests/Process/Integration/ProcessSignalIntegrationTest.php +++ b/tests/Process/Integration/ProcessSignalIntegrationTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process\Integration; +namespace Flytachi\Winter\Kernel\Tests\Process\Integration; -use Flytachi\Winter\K2\Tests\Process\Fixtures\SignalProcess; +use Flytachi\Winter\Kernel\Tests\Process\Fixtures\SignalProcess; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] diff --git a/tests/Process/ProcessStateTest.php b/tests/Process/ProcessStateTest.php index 5b9f3f0..c336ac0 100644 --- a/tests/Process/ProcessStateTest.php +++ b/tests/Process/ProcessStateTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process; +namespace Flytachi\Winter\Kernel\Tests\Process; -use Flytachi\Winter\K2\Process\ProcessState; +use Flytachi\Winter\Kernel\Process\ProcessState; use PHPUnit\Framework\TestCase; final class ProcessStateTest extends TestCase diff --git a/tests/Process/ProcessStatusTest.php b/tests/Process/ProcessStatusTest.php index 7a94d26..1d00001 100644 --- a/tests/Process/ProcessStatusTest.php +++ b/tests/Process/ProcessStatusTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process; +namespace Flytachi\Winter\Kernel\Tests\Process; -use Flytachi\Winter\K2\Process\Activity; -use Flytachi\Winter\K2\Process\ProcessState; -use Flytachi\Winter\K2\Process\ProcessStatus; +use Flytachi\Winter\Kernel\Process\Activity; +use Flytachi\Winter\Kernel\Process\ProcessState; +use Flytachi\Winter\Kernel\Process\ProcessStatus; use PHPUnit\Framework\TestCase; final class ProcessStatusTest extends TestCase diff --git a/tests/Process/ProcessTest.php b/tests/Process/ProcessTest.php index 3726270..0c8e537 100644 --- a/tests/Process/ProcessTest.php +++ b/tests/Process/ProcessTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process; +namespace Flytachi\Winter\Kernel\Tests\Process; -use Flytachi\Winter\K2\Process\ForkReset; -use Flytachi\Winter\K2\Process\Process; -use Flytachi\Winter\K2\Tests\Process\Fixtures\SampleProcess; -use Flytachi\Winter\K2\Tests\Process\Fixtures\TitledProcess; +use Flytachi\Winter\Kernel\Process\ForkReset; +use Flytachi\Winter\Kernel\Process\Stereotype\Process; +use Flytachi\Winter\Kernel\Tests\Process\Fixtures\SampleProcess; +use Flytachi\Winter\Kernel\Tests\Process\Fixtures\TitledProcess; use PHPUnit\Framework\TestCase; final class ProcessTest extends TestCase diff --git a/tests/Process/ResourceUsageTest.php b/tests/Process/ResourceUsageTest.php index cfb8802..bf70290 100644 --- a/tests/Process/ResourceUsageTest.php +++ b/tests/Process/ResourceUsageTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Process; +namespace Flytachi\Winter\Kernel\Tests\Process; -use Flytachi\Winter\K2\Process\ResourceUsage; +use Flytachi\Winter\Kernel\Process\ResourceUsage; use PHPUnit\Framework\TestCase; final class ResourceUsageTest extends TestCase diff --git a/tests/Route/ApplicationBootTest.php b/tests/Route/ApplicationBootTest.php index df37e6a..5b7f131 100644 --- a/tests/Route/ApplicationBootTest.php +++ b/tests/Route/ApplicationBootTest.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Route; +namespace Flytachi\Winter\Kernel\Tests\Route; use Flytachi\Winter\DI\Collector\DICollector; use Flytachi\Winter\DI\Container; use Flytachi\Winter\DI\Scanner; -use Flytachi\Winter\K2\Core\KernelConfig; -use Flytachi\Winter\K2\Kernel; -use Flytachi\Winter\K2\Route\Router; -use Flytachi\Winter\K2\Tests\Route\Fixtures\FakeRequest; -use Flytachi\Winter\K2\Tests\Route\Fixtures\FakeResponse; +use Flytachi\Winter\Kernel\Core\KernelConfig; +use Flytachi\Winter\Kernel\Kernel; +use Flytachi\Winter\Kernel\Route\Router; +use Flytachi\Winter\Kernel\Tests\Route\Fixtures\FakeRequest; +use Flytachi\Winter\Kernel\Tests\Route\Fixtures\FakeResponse; use PHPUnit\Framework\TestCase; use ReflectionProperty; diff --git a/tests/Route/Fixtures/App/DemoController.php b/tests/Route/Fixtures/App/DemoController.php index 9af00fe..0403f09 100644 --- a/tests/Route/Fixtures/App/DemoController.php +++ b/tests/Route/Fixtures/App/DemoController.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Route\Fixtures\App; +namespace Flytachi\Winter\Kernel\Tests\Route\Fixtures\App; use Flytachi\Winter\DI\Attribute\Autowired; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Request\Annotation\PathVariable; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestParam; -use Flytachi\Winter\K2\Route\Annotation\GetMapping; -use Flytachi\Winter\K2\Route\Annotation\PostMapping; -use Flytachi\Winter\K2\Route\Annotation\RequestMapping; -use Flytachi\Winter\K2\Stereotype\Controller; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Request\Annotation\PathVariable; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestParam; +use Flytachi\Winter\Kernel\Route\Annotation\GetMapping; +use Flytachi\Winter\Kernel\Route\Annotation\PostMapping; +use Flytachi\Winter\Kernel\Route\Annotation\RequestMapping; +use Flytachi\Winter\Kernel\Http\Stereotype\Controller; /** * The application under test: an ordinary controller, declared the way a coder would. diff --git a/tests/Route/Fixtures/App/GreetingService.php b/tests/Route/Fixtures/App/GreetingService.php index 186e932..40d979c 100644 --- a/tests/Route/Fixtures/App/GreetingService.php +++ b/tests/Route/Fixtures/App/GreetingService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Route\Fixtures\App; +namespace Flytachi\Winter\Kernel\Tests\Route\Fixtures\App; use Flytachi\Winter\DI\Attribute\Singleton; diff --git a/tests/Route/Fixtures/App/ServeApp.php b/tests/Route/Fixtures/App/ServeApp.php index 5c63c3d..5b49aef 100644 --- a/tests/Route/Fixtures/App/ServeApp.php +++ b/tests/Route/Fixtures/App/ServeApp.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Route\Fixtures\App; +namespace Flytachi\Winter\Kernel\Tests\Route\Fixtures\App; -use Flytachi\Winter\K2\App\ApplicationArguments; -use Flytachi\Winter\K2\App\Attribute\EnableWeb; -use Flytachi\Winter\K2\Kernel; -use Flytachi\Winter\K2\WinterApplication; +use Flytachi\Winter\Kernel\App\ApplicationArguments; +use Flytachi\Winter\Kernel\App\Attribute\EnableWeb; +use Flytachi\Winter\Kernel\Kernel; +use Flytachi\Winter\Kernel\WinterApplication; /** - * The fixture application {@see \Flytachi\Winter\K2\Tests\Route\ServeHttpTest} boots + * The fixture application {@see \Flytachi\Winter\Kernel\Tests\Route\ServeHttpTest} boots * for real — the same class shape a project writes, sharing this directory with the * controller so the scan finds it exactly as it would in an application. * diff --git a/tests/Route/Fixtures/FakeRequest.php b/tests/Route/Fixtures/FakeRequest.php index 9e3b0c4..a4e49b0 100644 --- a/tests/Route/Fixtures/FakeRequest.php +++ b/tests/Route/Fixtures/FakeRequest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Route\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Route\Fixtures; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; /** * A request the router can dispatch without a live SAPI. Only method, URI and headers diff --git a/tests/Route/Fixtures/FakeResponse.php b/tests/Route/Fixtures/FakeResponse.php index d600b71..1bf1c9b 100644 --- a/tests/Route/Fixtures/FakeResponse.php +++ b/tests/Route/Fixtures/FakeResponse.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Route\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Route\Fixtures; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; /** * Captures what the router wrote instead of sending it, so a test can assert on the diff --git a/tests/Route/Fixtures/RecordingMiddleware.php b/tests/Route/Fixtures/RecordingMiddleware.php index f8eb353..3577f9c 100644 --- a/tests/Route/Fixtures/RecordingMiddleware.php +++ b/tests/Route/Fixtures/RecordingMiddleware.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Route\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Route\Fixtures; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Stereotype\Middleware; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Stereotype\Middleware; /** * Writes its own name into a shared trace on each hook, so a test can assert the order diff --git a/tests/Route/Fixtures/ServerProcess.php b/tests/Route/Fixtures/ServerProcess.php index a6fe06f..c16f320 100644 --- a/tests/Route/Fixtures/ServerProcess.php +++ b/tests/Route/Fixtures/ServerProcess.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Route\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Route\Fixtures; /** * Runs the fixture application as a real server in its own process. @@ -46,7 +46,7 @@ public function start(float $timeout = 15.0): bool // The entry a project would write by hand: load the autoloader, run the app. $autoload = dirname(__DIR__, 3) . '/vendor/autoload.php'; file_put_contents($this->runner, sprintf( - "port, diff --git a/tests/Route/GracefulShutdownTest.php b/tests/Route/GracefulShutdownTest.php index c603d65..b95f096 100644 --- a/tests/Route/GracefulShutdownTest.php +++ b/tests/Route/GracefulShutdownTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Route; +namespace Flytachi\Winter\Kernel\Tests\Route; -use Flytachi\Winter\K2\Tests\Route\Fixtures\ServerProcess; +use Flytachi\Winter\Kernel\Tests\Route\Fixtures\ServerProcess; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; diff --git a/tests/Route/RouterDispatchTest.php b/tests/Route/RouterDispatchTest.php index eee56b0..88ac7a4 100644 --- a/tests/Route/RouterDispatchTest.php +++ b/tests/Route/RouterDispatchTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Route; +namespace Flytachi\Winter\Kernel\Tests\Route; use Flytachi\Winter\Base\HttpCode; -use Flytachi\Winter\K2\Http\Response\ResponseException; -use Flytachi\Winter\K2\Route\Router; -use Flytachi\Winter\K2\Tests\Route\Fixtures\FakeRequest; -use Flytachi\Winter\K2\Tests\Route\Fixtures\FakeResponse; +use Flytachi\Winter\Kernel\Http\Response\ResponseException; +use Flytachi\Winter\Kernel\Route\Router; +use Flytachi\Winter\Kernel\Tests\Route\Fixtures\FakeRequest; +use Flytachi\Winter\Kernel\Tests\Route\Fixtures\FakeResponse; use PHPUnit\Framework\TestCase; use RuntimeException; diff --git a/tests/Route/RouterMiddlewareTest.php b/tests/Route/RouterMiddlewareTest.php index 8845de1..755b2d6 100644 --- a/tests/Route/RouterMiddlewareTest.php +++ b/tests/Route/RouterMiddlewareTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Route; +namespace Flytachi\Winter\Kernel\Tests\Route; use Flytachi\Winter\DI\Container; -use Flytachi\Winter\K2\Route\Router; -use Flytachi\Winter\K2\Tests\Route\Fixtures\FakeRequest; -use Flytachi\Winter\K2\Tests\Route\Fixtures\FakeResponse; -use Flytachi\Winter\K2\Tests\Route\Fixtures\FirstMiddleware; -use Flytachi\Winter\K2\Tests\Route\Fixtures\RecordingMiddleware; -use Flytachi\Winter\K2\Tests\Route\Fixtures\SecondMiddleware; +use Flytachi\Winter\Kernel\Route\Router; +use Flytachi\Winter\Kernel\Tests\Route\Fixtures\FakeRequest; +use Flytachi\Winter\Kernel\Tests\Route\Fixtures\FakeResponse; +use Flytachi\Winter\Kernel\Tests\Route\Fixtures\FirstMiddleware; +use Flytachi\Winter\Kernel\Tests\Route\Fixtures\RecordingMiddleware; +use Flytachi\Winter\Kernel\Tests\Route\Fixtures\SecondMiddleware; use PHPUnit\Framework\TestCase; use RuntimeException; diff --git a/tests/Route/ServeHttpTest.php b/tests/Route/ServeHttpTest.php index d7307c0..cff8c7a 100644 --- a/tests/Route/ServeHttpTest.php +++ b/tests/Route/ServeHttpTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Route; +namespace Flytachi\Winter\Kernel\Tests\Route; -use Flytachi\Winter\K2\Tests\Route\Fixtures\ServerProcess; +use Flytachi\Winter\Kernel\Tests\Route\Fixtures\ServerProcess; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; diff --git a/tests/Schedule/Fixtures/AbstractScheduled.php b/tests/Schedule/Fixtures/AbstractScheduled.php index 3c4f798..05bf806 100644 --- a/tests/Schedule/Fixtures/AbstractScheduled.php +++ b/tests/Schedule/Fixtures/AbstractScheduled.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Schedule\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Schedule\Fixtures; -use Flytachi\Winter\K2\Schedule\Scheduled; +use Flytachi\Winter\Kernel\Schedule\Scheduled; /** A #[Scheduled] method on a non-instantiable (abstract) class. */ abstract class AbstractScheduled diff --git a/tests/Schedule/Fixtures/ArgScheduled.php b/tests/Schedule/Fixtures/ArgScheduled.php index 2b8e433..ce5eed8 100644 --- a/tests/Schedule/Fixtures/ArgScheduled.php +++ b/tests/Schedule/Fixtures/ArgScheduled.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Schedule\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Schedule\Fixtures; -use Flytachi\Winter\K2\Schedule\Scheduled; +use Flytachi\Winter\Kernel\Schedule\Scheduled; /** A #[Scheduled] method that requires an argument. */ final class ArgScheduled diff --git a/tests/Schedule/Fixtures/BadCronScheduled.php b/tests/Schedule/Fixtures/BadCronScheduled.php index b23f077..ac285c4 100644 --- a/tests/Schedule/Fixtures/BadCronScheduled.php +++ b/tests/Schedule/Fixtures/BadCronScheduled.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Schedule\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Schedule\Fixtures; -use Flytachi\Winter\K2\Schedule\Scheduled; +use Flytachi\Winter\Kernel\Schedule\Scheduled; /** A #[Scheduled] method with a malformed cron expression. */ final class BadCronScheduled diff --git a/tests/Schedule/Fixtures/CronInitialDelayScheduled.php b/tests/Schedule/Fixtures/CronInitialDelayScheduled.php index 7782a0c..4436f25 100644 --- a/tests/Schedule/Fixtures/CronInitialDelayScheduled.php +++ b/tests/Schedule/Fixtures/CronInitialDelayScheduled.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Schedule\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Schedule\Fixtures; -use Flytachi\Winter\K2\Schedule\Scheduled; +use Flytachi\Winter\Kernel\Schedule\Scheduled; /** A #[Scheduled] cron method that also (illegally) sets an initial delay. */ final class CronInitialDelayScheduled diff --git a/tests/Schedule/Fixtures/CronScheduled.php b/tests/Schedule/Fixtures/CronScheduled.php index d311177..b6555ce 100644 --- a/tests/Schedule/Fixtures/CronScheduled.php +++ b/tests/Schedule/Fixtures/CronScheduled.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Schedule\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Schedule\Fixtures; -use Flytachi\Winter\K2\Schedule\Scheduled; +use Flytachi\Winter\Kernel\Schedule\Scheduled; /** A valid #[Scheduled] cron method — every day at 02:00. */ final class CronScheduled diff --git a/tests/Schedule/Fixtures/MarkerScheduler.php b/tests/Schedule/Fixtures/MarkerScheduler.php index d2de44f..918ac3f 100644 --- a/tests/Schedule/Fixtures/MarkerScheduler.php +++ b/tests/Schedule/Fixtures/MarkerScheduler.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Schedule\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Schedule\Fixtures; -use Flytachi\Winter\K2\Schedule\ScheduledTask; -use Flytachi\Winter\K2\Schedule\Scheduler; -use Flytachi\Winter\K2\Schedule\Trigger\FixedRateTrigger; +use Flytachi\Winter\Kernel\Schedule\ScheduledTask; +use Flytachi\Winter\Kernel\Schedule\Stereotype\Scheduler; +use Flytachi\Winter\Kernel\Schedule\Trigger\FixedRateTrigger; /** * Integration fixture: a real scheduler whose task registry is injected (via the diff --git a/tests/Schedule/Fixtures/MarkerTask.php b/tests/Schedule/Fixtures/MarkerTask.php index c036b8b..d6ed88c 100644 --- a/tests/Schedule/Fixtures/MarkerTask.php +++ b/tests/Schedule/Fixtures/MarkerTask.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Schedule\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Schedule\Fixtures; /** * Integration bean: its scheduled method appends a line to the file named by the diff --git a/tests/Schedule/Fixtures/NoTriggerScheduled.php b/tests/Schedule/Fixtures/NoTriggerScheduled.php index 479faa2..3453948 100644 --- a/tests/Schedule/Fixtures/NoTriggerScheduled.php +++ b/tests/Schedule/Fixtures/NoTriggerScheduled.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Schedule\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Schedule\Fixtures; -use Flytachi\Winter\K2\Schedule\Scheduled; +use Flytachi\Winter\Kernel\Schedule\Scheduled; /** A #[Scheduled] method with no trigger set. */ final class NoTriggerScheduled diff --git a/tests/Schedule/Fixtures/NonPositiveScheduled.php b/tests/Schedule/Fixtures/NonPositiveScheduled.php index 32164b6..21dadda 100644 --- a/tests/Schedule/Fixtures/NonPositiveScheduled.php +++ b/tests/Schedule/Fixtures/NonPositiveScheduled.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Schedule\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Schedule\Fixtures; -use Flytachi\Winter\K2\Schedule\Scheduled; +use Flytachi\Winter\Kernel\Schedule\Scheduled; /** A #[Scheduled] method with a non-positive period. */ final class NonPositiveScheduled diff --git a/tests/Schedule/Fixtures/SampleScheduled.php b/tests/Schedule/Fixtures/SampleScheduled.php index 3b2fd64..8ecc4e0 100644 --- a/tests/Schedule/Fixtures/SampleScheduled.php +++ b/tests/Schedule/Fixtures/SampleScheduled.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Schedule\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Schedule\Fixtures; -use Flytachi\Winter\K2\Schedule\Scheduled; +use Flytachi\Winter\Kernel\Schedule\Scheduled; /** * A well-formed target: two triggered methods plus a plain one the collector must diff --git a/tests/Schedule/Fixtures/StaticScheduled.php b/tests/Schedule/Fixtures/StaticScheduled.php index e4e631c..d6ba3d9 100644 --- a/tests/Schedule/Fixtures/StaticScheduled.php +++ b/tests/Schedule/Fixtures/StaticScheduled.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Schedule\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Schedule\Fixtures; -use Flytachi\Winter\K2\Schedule\Scheduled; +use Flytachi\Winter\Kernel\Schedule\Scheduled; /** A #[Scheduled] static method (not invocable on a resolved instance). */ final class StaticScheduled diff --git a/tests/Schedule/Fixtures/TwoTriggerScheduled.php b/tests/Schedule/Fixtures/TwoTriggerScheduled.php index 0e49c7b..bf3e6cd 100644 --- a/tests/Schedule/Fixtures/TwoTriggerScheduled.php +++ b/tests/Schedule/Fixtures/TwoTriggerScheduled.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Schedule\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Schedule\Fixtures; -use Flytachi\Winter\K2\Schedule\Scheduled; +use Flytachi\Winter\Kernel\Schedule\Scheduled; /** A #[Scheduled] method with two triggers set at once. */ final class TwoTriggerScheduled diff --git a/tests/Schedule/Integration/SchedulerIntegrationTest.php b/tests/Schedule/Integration/SchedulerIntegrationTest.php index c7b831b..20c38cf 100644 --- a/tests/Schedule/Integration/SchedulerIntegrationTest.php +++ b/tests/Schedule/Integration/SchedulerIntegrationTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Schedule\Integration; +namespace Flytachi\Winter\Kernel\Tests\Schedule\Integration; -use Flytachi\Winter\K2\Tests\Process\Integration\IntegrationCase; -use Flytachi\Winter\K2\Tests\Schedule\Fixtures\MarkerScheduler; +use Flytachi\Winter\Kernel\Tests\Process\Integration\IntegrationCase; +use Flytachi\Winter\Kernel\Tests\Schedule\Fixtures\MarkerScheduler; use PHPUnit\Framework\Attributes\Group; /** * Live scheduler run: forks a real {@see MarkerScheduler}, which fires its task - * through the actual engine ({@see \Flytachi\Winter\K2\Process\Process::spawn()}), + * through the actual engine ({@see \Flytachi\Winter\Kernel\Process\Stereotype\Process::spawn()}), * and observes the firing through the WK_MARKER file — then a real SIGTERM stops * it. Exercises the true boot, coroutine/fork, and graceful-stop machinery. */ diff --git a/tests/Schedule/ScheduledCollectorTest.php b/tests/Schedule/ScheduledCollectorTest.php index f331d90..080391a 100644 --- a/tests/Schedule/ScheduledCollectorTest.php +++ b/tests/Schedule/ScheduledCollectorTest.php @@ -2,23 +2,23 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Schedule; - -use Flytachi\Winter\K2\Schedule\ScheduledCollector; -use Flytachi\Winter\K2\Schedule\ScheduleConfigException; -use Flytachi\Winter\K2\Schedule\Trigger\CronTrigger; -use Flytachi\Winter\K2\Schedule\Trigger\FixedDelayTrigger; -use Flytachi\Winter\K2\Schedule\Trigger\FixedRateTrigger; -use Flytachi\Winter\K2\Tests\Schedule\Fixtures\AbstractScheduled; -use Flytachi\Winter\K2\Tests\Schedule\Fixtures\ArgScheduled; -use Flytachi\Winter\K2\Tests\Schedule\Fixtures\BadCronScheduled; -use Flytachi\Winter\K2\Tests\Schedule\Fixtures\CronInitialDelayScheduled; -use Flytachi\Winter\K2\Tests\Schedule\Fixtures\CronScheduled; -use Flytachi\Winter\K2\Tests\Schedule\Fixtures\NonPositiveScheduled; -use Flytachi\Winter\K2\Tests\Schedule\Fixtures\NoTriggerScheduled; -use Flytachi\Winter\K2\Tests\Schedule\Fixtures\SampleScheduled; -use Flytachi\Winter\K2\Tests\Schedule\Fixtures\StaticScheduled; -use Flytachi\Winter\K2\Tests\Schedule\Fixtures\TwoTriggerScheduled; +namespace Flytachi\Winter\Kernel\Tests\Schedule; + +use Flytachi\Winter\Kernel\Schedule\ScheduledCollector; +use Flytachi\Winter\Kernel\Schedule\ScheduleConfigException; +use Flytachi\Winter\Kernel\Schedule\Trigger\CronTrigger; +use Flytachi\Winter\Kernel\Schedule\Trigger\FixedDelayTrigger; +use Flytachi\Winter\Kernel\Schedule\Trigger\FixedRateTrigger; +use Flytachi\Winter\Kernel\Tests\Schedule\Fixtures\AbstractScheduled; +use Flytachi\Winter\Kernel\Tests\Schedule\Fixtures\ArgScheduled; +use Flytachi\Winter\Kernel\Tests\Schedule\Fixtures\BadCronScheduled; +use Flytachi\Winter\Kernel\Tests\Schedule\Fixtures\CronInitialDelayScheduled; +use Flytachi\Winter\Kernel\Tests\Schedule\Fixtures\CronScheduled; +use Flytachi\Winter\Kernel\Tests\Schedule\Fixtures\NonPositiveScheduled; +use Flytachi\Winter\Kernel\Tests\Schedule\Fixtures\NoTriggerScheduled; +use Flytachi\Winter\Kernel\Tests\Schedule\Fixtures\SampleScheduled; +use Flytachi\Winter\Kernel\Tests\Schedule\Fixtures\StaticScheduled; +use Flytachi\Winter\Kernel\Tests\Schedule\Fixtures\TwoTriggerScheduled; use PHPUnit\Framework\TestCase; use ReflectionClass; diff --git a/tests/Schedule/ScheduledTaskTest.php b/tests/Schedule/ScheduledTaskTest.php index 7a1386d..a5a1033 100644 --- a/tests/Schedule/ScheduledTaskTest.php +++ b/tests/Schedule/ScheduledTaskTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Schedule; +namespace Flytachi\Winter\Kernel\Tests\Schedule; -use Flytachi\Winter\K2\Schedule\ScheduledTask; -use Flytachi\Winter\K2\Schedule\Trigger\FixedDelayTrigger; +use Flytachi\Winter\Kernel\Schedule\ScheduledTask; +use Flytachi\Winter\Kernel\Schedule\Trigger\FixedDelayTrigger; use PHPUnit\Framework\TestCase; final class ScheduledTaskTest extends TestCase diff --git a/tests/Schedule/ScheduledTest.php b/tests/Schedule/ScheduledTest.php index ba7f4f4..6580ae1 100644 --- a/tests/Schedule/ScheduledTest.php +++ b/tests/Schedule/ScheduledTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Schedule; +namespace Flytachi\Winter\Kernel\Tests\Schedule; -use Flytachi\Winter\K2\Schedule\Scheduled; -use Flytachi\Winter\K2\Tests\Schedule\Fixtures\SampleScheduled; +use Flytachi\Winter\Kernel\Schedule\Scheduled; +use Flytachi\Winter\Kernel\Tests\Schedule\Fixtures\SampleScheduled; use PHPUnit\Framework\TestCase; use ReflectionMethod; diff --git a/tests/Schedule/SchedulerExtensionPointTest.php b/tests/Schedule/SchedulerExtensionPointTest.php index 321d125..2154f17 100644 --- a/tests/Schedule/SchedulerExtensionPointTest.php +++ b/tests/Schedule/SchedulerExtensionPointTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Schedule; +namespace Flytachi\Winter\Kernel\Tests\Schedule; -use Flytachi\Winter\K2\App\Attribute\EnableScheduler; -use Flytachi\Winter\K2\Schedule\Scheduler; +use Flytachi\Winter\Kernel\App\Attribute\EnableScheduler; +use Flytachi\Winter\Kernel\Schedule\Stereotype\Scheduler; use PHPUnit\Framework\TestCase; use ReflectionClass; use ReflectionMethod; diff --git a/tests/Schedule/SchedulerTest.php b/tests/Schedule/SchedulerTest.php index 74a55e9..1cf7237 100644 --- a/tests/Schedule/SchedulerTest.php +++ b/tests/Schedule/SchedulerTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Schedule; +namespace Flytachi\Winter\Kernel\Tests\Schedule; -use Flytachi\Winter\K2\Concurrent\CompletableFuture; -use Flytachi\Winter\K2\Schedule\Scheduler; -use Flytachi\Winter\K2\Schedule\ScheduledTask; -use Flytachi\Winter\K2\Schedule\Trigger\FixedDelayTrigger; +use Flytachi\Winter\Kernel\Concurrent\CompletableFuture; +use Flytachi\Winter\Kernel\Schedule\Stereotype\Scheduler; +use Flytachi\Winter\Kernel\Schedule\ScheduledTask; +use Flytachi\Winter\Kernel\Schedule\Trigger\FixedDelayTrigger; use PHPUnit\Framework\TestCase; use ReflectionMethod; use ReflectionProperty; diff --git a/tests/Schedule/Trigger/CronTriggerTest.php b/tests/Schedule/Trigger/CronTriggerTest.php index 6cd6763..345d2e5 100644 --- a/tests/Schedule/Trigger/CronTriggerTest.php +++ b/tests/Schedule/Trigger/CronTriggerTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Schedule\Trigger; +namespace Flytachi\Winter\Kernel\Tests\Schedule\Trigger; -use Flytachi\Winter\K2\Schedule\Trigger\CronTrigger; +use Flytachi\Winter\Kernel\Schedule\Trigger\CronTrigger; use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Schedule/Trigger/FixedDelayTriggerTest.php b/tests/Schedule/Trigger/FixedDelayTriggerTest.php index 0a1b1fe..0a271d9 100644 --- a/tests/Schedule/Trigger/FixedDelayTriggerTest.php +++ b/tests/Schedule/Trigger/FixedDelayTriggerTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Schedule\Trigger; +namespace Flytachi\Winter\Kernel\Tests\Schedule\Trigger; -use Flytachi\Winter\K2\Schedule\Trigger\FixedDelayTrigger; +use Flytachi\Winter\Kernel\Schedule\Trigger\FixedDelayTrigger; use PHPUnit\Framework\TestCase; final class FixedDelayTriggerTest extends TestCase diff --git a/tests/Schedule/Trigger/FixedRateTriggerTest.php b/tests/Schedule/Trigger/FixedRateTriggerTest.php index 7ec88c2..1a90463 100644 --- a/tests/Schedule/Trigger/FixedRateTriggerTest.php +++ b/tests/Schedule/Trigger/FixedRateTriggerTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Schedule\Trigger; +namespace Flytachi\Winter\Kernel\Tests\Schedule\Trigger; -use Flytachi\Winter\K2\Schedule\Trigger\FixedRateTrigger; +use Flytachi\Winter\Kernel\Schedule\Trigger\FixedRateTrigger; use PHPUnit\Framework\TestCase; final class FixedRateTriggerTest extends TestCase diff --git a/tests/Unit/Pagination/CursorTokenTest.php b/tests/Unit/Pagination/CursorTokenTest.php index 2092372..d57ca3c 100644 --- a/tests/Unit/Pagination/CursorTokenTest.php +++ b/tests/Unit/Pagination/CursorTokenTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Unit\Pagination; +namespace Flytachi\Winter\Kernel\Tests\Unit\Pagination; -use Flytachi\Winter\K2\Unit\Pagination\CursorDirection; -use Flytachi\Winter\K2\Unit\Pagination\CursorToken; -use Flytachi\Winter\K2\Unit\Pagination\InvalidCursorException; +use Flytachi\Winter\Kernel\Unit\Pagination\CursorDirection; +use Flytachi\Winter\Kernel\Unit\Pagination\CursorToken; +use Flytachi\Winter\Kernel\Unit\Pagination\InvalidCursorException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/function/TransTest.php b/tests/function/TransTest.php index 7bc4591..75cfc96 100644 --- a/tests/function/TransTest.php +++ b/tests/function/TransTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Func; +namespace Flytachi\Winter\Kernel\Tests\Func; -use Flytachi\Winter\K2\Localization\Locale; +use Flytachi\Winter\Kernel\Localization\Locale; use PHPUnit\Framework\TestCase; final class TransTest extends TestCase diff --git a/wKernelRunner b/wKernelRunner index 88c3225..b16b534 100755 --- a/wKernelRunner +++ b/wKernelRunner @@ -21,7 +21,7 @@ declare(strict_types=1); same convention and then hands off to WinterApplication::executor(). */ -use Flytachi\Winter\K2\WinterApplication; +use Flytachi\Winter\Kernel\WinterApplication; if (PHP_VERSION_ID < 80400) { fwrite(STDERR, "Please use PHP version 8.4 or higher.\n"); From 9fa8833f29a5afd2780a3ab3aa146cfada33e2d4 Mon Sep 17 00:00:00 2001 From: flytachi Date: Mon, 3 Aug 2026 14:50:28 +0500 Subject: [PATCH 51/71] full redisign --- console/Command/Di.php | 8 +- console/Template/Make/RepositoryTemplate | 6 ++ docs/configuration/01-kernel.md | 6 +- docs/configuration/08-runtime.md | 28 +++++ phpunit.xml | 4 + src/Core/ClassScanner.php | 45 +++++++- src/Http/Header.php | 109 +++++++++++++++---- src/Http/Health/HealthIndicator.php | 6 +- src/Ppa/PPAMapping.php | 6 +- src/Ppa/Repository/RepositoryCore.php | 42 ++++++++ src/Route/Router.php | 8 +- src/WinterApplication.php | 4 +- tests/Core/ClassScannerExclusionTest.php | 79 ++++++++++++++ tests/Http/Fixtures/OriginProbeRequest.php | 103 ++++++++++++++++++ tests/Http/HeaderNormalizeTest.php | 95 +++++++++++++++++ tests/Http/HeaderOriginTest.php | 115 +++++++++++++++++++++ 16 files changed, 625 insertions(+), 39 deletions(-) create mode 100644 tests/Core/ClassScannerExclusionTest.php create mode 100644 tests/Http/Fixtures/OriginProbeRequest.php create mode 100644 tests/Http/HeaderNormalizeTest.php create mode 100644 tests/Http/HeaderOriginTest.php diff --git a/console/Command/Di.php b/console/Command/Di.php index dce2337..846081b 100644 --- a/console/Command/Di.php +++ b/console/Command/Di.php @@ -8,7 +8,7 @@ use Flytachi\Winter\DI\Container; use Flytachi\Winter\DI\Collector\DICollector; use Flytachi\Winter\DI\Contract\CollectorInterface; -use Flytachi\Winter\DI\Scanner; +use Flytachi\Winter\Kernel\Core\ClassScanner; use Flytachi\Winter\Kernel\App\Attribute\EnableAsync; use Flytachi\Winter\Kernel\Concurrent\Async\AsyncCollector; use Flytachi\Winter\Kernel\Concurrent\Async\Proxy\BypassScanner; @@ -188,7 +188,7 @@ private function buildArg(): bool // Container and writes the FQCN list to $cachePath as a side effect. The // class list and the #[Async] proxies come from the same scan on purpose: // two commands would leave a window where one is stale. - $scan = Scanner::run(rootDir: Kernel::$pathRoot, cache: $cachePath) + $scan = ClassScanner::scanner(rootDir: Kernel::$pathRoot, cache: $cachePath) ->collect(new DICollector($container)); if ($async !== null) { $scan->collect($async); @@ -441,7 +441,7 @@ public function collect(string $class, ReflectionClass $ref): void } }; - Scanner::run(rootDir: Kernel::$pathRoot) + ClassScanner::scanner(rootDir: Kernel::$pathRoot) ->collect($sink) ->execute(); @@ -466,7 +466,7 @@ public function collect(string $class, ReflectionClass $ref): void } }; - Scanner::run(rootDir: Kernel::$pathRoot) + ClassScanner::scanner(rootDir: Kernel::$pathRoot) ->collect($sink) ->execute(); diff --git a/console/Template/Make/RepositoryTemplate b/console/Template/Make/RepositoryTemplate index 1a86e69..5b5b9b2 100644 --- a/console/Template/Make/RepositoryTemplate +++ b/console/Template/Make/RepositoryTemplate @@ -2,8 +2,14 @@ namespace __namespace__; +use Flytachi\Winter\DI\Attribute\Singleton; use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; +// A repository is safe to share: its query state (sqlParts, alias, entity class) lives +// in the coroutine context, not in the object, so concurrent requests never see each +// other's builder. Sharing it skips a constructor that reaches into the connection pool +// on every request. Drop #[Singleton] only if you add mutable fields of your own. +#[Singleton] class __className__ extends Repository { protected string $dbConfigClassName; diff --git a/docs/configuration/01-kernel.md b/docs/configuration/01-kernel.md index 74af0a5..e4aac09 100644 --- a/docs/configuration/01-kernel.md +++ b/docs/configuration/01-kernel.md @@ -58,7 +58,7 @@ Kernel::init( pathStorageLog: __DIR__ . '/storage/logs', pathStorageCache: __DIR__ . '/storage/cache', pathStorageRunnable: __DIR__ . '/storage/runnable', - isTmpVolatile: false, // see "Volatile storage" below + isTmpVolatile: true, // the default; see "Volatile storage" below ); ``` @@ -101,7 +101,9 @@ Kernel::$pathStorageVolatile Use `true` for ephemeral containers (Docker, Kubernetes) where `/tmp` is fast and disposable. Use `false` for long-lived deployments where you want the route cache to persist with the rest of your storage. -`Kernel::init()` passes `isTmpVolatile: false` by default; `KernelConfig::init()` defaults to `true` (the `Kernel` wrapper flips it). Pass it explicitly if you want the other behaviour. +Both `Kernel::init()` and `KernelConfig::init()` default to `true` — the temp directory. Pass `false` explicitly if you want volatile artefacts to live inside your storage tree. + +Note one consequence of `false`: volatile storage then sits **inside the project root**, where class discovery runs. The scan excludes it (see `ClassScanner::scanner()`), because that directory holds generated code — the DI cache and the `#[Async]` proxies — and scanning what a previous scan produced is self-referential. The directory is auto-created (`mkdir 0777 recursive`) on first call. diff --git a/docs/configuration/08-runtime.md b/docs/configuration/08-runtime.md index 3835164..6a6e7bd 100644 --- a/docs/configuration/08-runtime.md +++ b/docs/configuration/08-runtime.md @@ -90,6 +90,34 @@ The `.env` shorthands `SERVER_WORKERS`, `SERVER_TASKS`, `SERVER_MAX_REQUEST` and --- +## Set `opcache.enable_cli=1` + +A Swoole server runs under the **CLI SAPI**, and `opcache.enable_cli` is `0` by default. +Leave it off and the server gets no opcache at all — every worker keeps its own copy of +every compiled class instead of sharing one in opcache's shared memory. + +Measured on a synthetic 400-class application (560 lines per class), loading all of them: + +| | memory held by the process | +|---|---:| +| `opcache.enable_cli=0` | 52.3 MiB | +| `opcache.enable_cli=1` | **7.7 MiB** | + +Seven times less, per worker, for one ini line. Time is roughly unchanged — opcache saves +the parse, not the class linking, which every process still does for itself. + +```ini +opcache.enable=1 +opcache.enable_cli=1 ; required: Swoole is a CLI process +opcache.validate_timestamps=0 ; production — no stat() per file per request +opcache.jit=0 ; Swoole registers opcode handlers; JIT is auto-disabled anyway +``` + +The shipped Docker template (`call docker`) already sets these; the values above matter +when you build your own image or run outside a container. + +--- + ## The one thing to watch: shared state A Swoole worker is long-lived, so a `#[Singleton]` is created once and **reused across diff --git a/phpunit.xml b/phpunit.xml index f311b98..f587547 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -8,6 +8,7 @@ tests/Http + tests/Http/Fixtures tests/Localization @@ -53,6 +54,9 @@ tests/Architecture + + tests/Core + tests/Console diff --git a/src/Core/ClassScanner.php b/src/Core/ClassScanner.php index bbfbf58..d963fb2 100644 --- a/src/Core/ClassScanner.php +++ b/src/Core/ClassScanner.php @@ -41,12 +41,55 @@ public static function scan(CollectorInterface ...$collectors): void } } + /** + * A {@see Scanner} carrying the project's standard exclusions. + * + * Use this instead of {@see Scanner::run()} anywhere the scan starts at the project + * root. `Scanner` drops `vendor/` on its own; the two directories named here hold + * PHP that is not application code, and the scanner cannot tell the difference — it + * reads every `.php` file looking for a class declaration, then `require_once`s + * whatever it finds. + * + * - **storage** holds *generated* code: the DI cache and the `#[Async]` proxies are + * written there, so scanning it is at best wasted work and at worst + * self-referential — a scan reading what the previous scan produced. Under the + * default `isTmpVolatile: true` it lives in the system temp directory and the scan + * never reaches it; under `false` it sits inside the project root. + * - **resources** holds *views*, which are PHP files by nature and classes by + * accident. A template that happens to declare a helper class matches the + * scanner's regex, gets required at boot, and **executes** — echoing into the + * output and running whatever else sits at its top level. Verified, not theorised. + * Even without a class declaration every template is read from disk on each cold + * scan for nothing. + * + * @param string|null $cache Cache file path, or null to always walk the filesystem. + */ + public static function scanner(string $rootDir, ?string $cache = null): Scanner + { + return Scanner::run($rootDir, $cache)->exclude(self::excluded()); + } + + /** + * Directories excluded from every project scan, in addition to `vendor/`. + * + * @return list + */ + private static function excluded(): array + { + // Kernel::init() may not have run yet — a bare Scanner is still correct then, + // it simply has fewer exclusions. + return array_values(array_filter([ + isset(Kernel::$pathStorage) ? Kernel::$pathStorage : null, + isset(Kernel::$pathResource) ? Kernel::$pathResource : null, + ])); + } + /** * @param CollectorInterface[] $collectors */ private static function run(string $rootDir, array $collectors): void { - $scanner = Scanner::run($rootDir); + $scanner = self::scanner($rootDir); foreach ($collectors as $collector) { $scanner->collect($collector); } diff --git a/src/Http/Header.php b/src/Http/Header.php index 93d04db..551efd9 100644 --- a/src/Http/Header.php +++ b/src/Http/Header.php @@ -24,8 +24,17 @@ */ final class Header { - private const CTX_KEY = '__k2_headers__'; - private const CTX_ORIGIN = '__k2_origin__'; + private const CTX_KEY = '__k2_headers__'; + private const CTX_ORIGIN = '__k2_origin__'; + private const CTX_REQUEST = '__k2_request__'; + + /** + * Upper bound on the header-name normalisation memo. + * + * Real traffic uses a few dozen distinct names, so this is never reached in practice; + * it exists because the names are client-controlled. See {@see normalizeKey()}. + */ + private const int NORMALIZE_MEMO_LIMIT = 256; /** FPM fallback storage */ private static array $bag = []; @@ -33,6 +42,9 @@ final class Header /** FPM fallback storage for the request origin (scheme/host/port/baseUrl). */ private static array $origin = []; + /** FPM fallback storage for the request the origin is derived from, on demand. */ + private static ?HttpRequest $request = null; + private function __construct() { } @@ -45,22 +57,20 @@ public static function init(HttpRequest $request): void $headers = self::normalizeMap($request->getHeaders()); $headers['Ip-Address'] = $request->getClientIp(); - // Snapshot the request origin separately — `host` must not clobber the - // raw `Host` header (which may carry a port) in the header bag. - $origin = [ - 'scheme' => $request->getScheme(), - 'host' => $request->getHost(), - 'port' => $request->getPort(), - 'baseUrl' => $request->getBaseUrl(), - ]; - + // The origin (scheme/host/port/baseUrl) is NOT snapshotted here — it is derived + // on first read by origin(). Nothing in the kernel asks for it, so computing it + // eagerly charged every request for something only some applications use, and + // charged it twice over: getBaseUrl() re-derives scheme, host and port itself. + // The request is kept instead, and the derived values are memoised beside it. if (Runtime::isSwooleCoroutine()) { $ctx = \Swoole\Coroutine::getContext(); - $ctx[self::CTX_KEY] = $headers; - $ctx[self::CTX_ORIGIN] = $origin; + $ctx[self::CTX_KEY] = $headers; + $ctx[self::CTX_REQUEST] = $request; + unset($ctx[self::CTX_ORIGIN]); } else { - self::$bag = $headers; - self::$origin = $origin; + self::$bag = $headers; + self::$request = $request; + self::$origin = []; } } @@ -169,18 +179,77 @@ private static function storage(): array return self::$bag; } + /** + * The request origin, derived on first read and memoised for the rest of the request. + * + * `baseUrl` is assembled from the parts already read rather than through + * {@see HttpRequest::getBaseUrl()}, which would derive scheme, host and port a + * second time — the reason the eager version cost twice what it looked like. + * + * @return array{scheme?: string, host?: string, port?: int, baseUrl?: string} + */ private static function origin(): array { - if (Runtime::isSwooleCoroutine()) { - return \Swoole\Coroutine::getContext()[self::CTX_ORIGIN] ?? []; + $swoole = Runtime::isSwooleCoroutine(); + $ctx = $swoole ? \Swoole\Coroutine::getContext() : null; + + $cached = $swoole ? ($ctx[self::CTX_ORIGIN] ?? null) : (self::$origin ?: null); + if ($cached !== null) { + return $cached; + } + + $request = $swoole ? ($ctx[self::CTX_REQUEST] ?? null) : self::$request; + if (!$request instanceof HttpRequest) { + return []; + } + + $scheme = $request->getScheme(); + $host = $request->getHost(); + $port = $request->getPort(); + $standard = ($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443); + + $origin = [ + 'scheme' => $scheme, + 'host' => $host, + 'port' => $port, + 'baseUrl' => $standard ? "{$scheme}://{$host}" : "{$scheme}://{$host}:{$port}", + ]; + + if ($swoole) { + $ctx[self::CTX_ORIGIN] = $origin; + } else { + self::$origin = $origin; } - return self::$origin; + + return $origin; } - /** Normalize a single header key to Title-Case. */ + /** + * Normalize a single header key to Title-Case. + * + * Memoised: the four string operations below run for every key of every request, + * and real traffic reuses the same few dozen names, so the memo hits almost always. + * + * The cap is not a tuning knob — it is the safety property. Header names come from + * the client, so an uncapped map would let one caller grow a long-lived worker's + * memory until it dies. Past the cap normalisation simply runs as it did before, + * which is correct, only not memoised. + */ private static function normalizeKey(string $key): string { - return str_replace(' ', '-', ucwords(str_replace('-', ' ', strtolower($key)))); + static $memo = []; + + if (isset($memo[$key])) { + return $memo[$key]; + } + + $normalized = str_replace(' ', '-', ucwords(str_replace('-', ' ', strtolower($key)))); + + if (count($memo) < self::NORMALIZE_MEMO_LIMIT) { + $memo[$key] = $normalized; + } + + return $normalized; } /** Normalize all keys in a header map to Title-Case. */ diff --git a/src/Http/Health/HealthIndicator.php b/src/Http/Health/HealthIndicator.php index daeb220..04b8ef8 100644 --- a/src/Http/Health/HealthIndicator.php +++ b/src/Http/Health/HealthIndicator.php @@ -7,7 +7,7 @@ use Composer\InstalledVersions; use Flytachi\Winter\Base\Runtime; use Flytachi\Winter\DI\Container; -use Flytachi\Winter\DI\Scanner; +use Flytachi\Winter\Kernel\Core\ClassScanner; use Flytachi\Winter\Kernel\Collector\ImplementorCollector; use Flytachi\Winter\Kernel\Http\Header; use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; @@ -162,7 +162,7 @@ final protected function dbHealth(string $rootDir): array if ($rootDir !== '' && interface_exists($interface)) { $collector = new ImplementorCollector($interface); - Scanner::run($rootDir)->collect($collector)->execute(); + ClassScanner::scanner($rootDir)->collect($collector)->execute(); foreach ($collector->getResult() as $ref) { /** @var \Flytachi\Winter\Cdo\Config\Common\DbConfigInterface $config */ @@ -262,7 +262,7 @@ final protected function cacheHealth(string $rootDir): array } $collector = new ImplementorCollector($interface); - Scanner::run($rootDir)->collect($collector)->execute(); + ClassScanner::scanner($rootDir)->collect($collector)->execute(); $details = []; $worstStatus = 'up'; diff --git a/src/Ppa/PPAMapping.php b/src/Ppa/PPAMapping.php index 58dd524..d7644f7 100644 --- a/src/Ppa/PPAMapping.php +++ b/src/Ppa/PPAMapping.php @@ -5,7 +5,7 @@ namespace Flytachi\Winter\Kernel\Ppa; use Flytachi\Winter\Cdo\Config\Common\DbConfigInterface; -use Flytachi\Winter\DI\Scanner; +use Flytachi\Winter\Kernel\Core\ClassScanner; use Flytachi\Winter\Kernel\Collector\ImplementorCollector; use Flytachi\Winter\Kernel\Kernel; use Flytachi\Winter\Kernel\Ppa\Entity\RepositoryInterface; @@ -24,7 +24,7 @@ final class PPAMapping public static function scanningConfigs(?string $rootDir = null): array { $collector = new ImplementorCollector(DbConfigInterface::class); - Scanner::run($rootDir ?? Kernel::$pathRoot)->collect($collector)->execute(); + ClassScanner::scanner($rootDir ?? Kernel::$pathRoot)->collect($collector)->execute(); $configs = []; foreach ($collector->getResult() as $ref) { @@ -39,7 +39,7 @@ public static function scanningConfigs(?string $rootDir = null): array public static function scanningDeclaration(?string $rootDir = null): Declaration { $collector = new ImplementorCollector(RepositoryInterface::class); - Scanner::run($rootDir ?? Kernel::$pathRoot)->collect($collector)->execute(); + ClassScanner::scanner($rootDir ?? Kernel::$pathRoot)->collect($collector)->execute(); return self::scanDeclarationFilter($collector->getResult()); } diff --git a/src/Ppa/Repository/RepositoryCore.php b/src/Ppa/Repository/RepositoryCore.php index d816f88..76484b8 100644 --- a/src/Ppa/Repository/RepositoryCore.php +++ b/src/Ppa/Repository/RepositoryCore.php @@ -8,12 +8,17 @@ use Flytachi\Winter\Cdo\Connection\CDO; use Flytachi\Winter\Cdo\Connection\CDOStatement; use Flytachi\Winter\Cdo\Qb; +use Flytachi\Winter\DI\Attribute\Autowired; +use Flytachi\Winter\DI\Attribute\Inject; +use Flytachi\Winter\DI\Container; use Flytachi\Winter\Kernel\Ppa\Entity\EntityInterface; use Flytachi\Winter\Kernel\Ppa\Entity\RepositoryInterface; use Flytachi\Winter\Kernel\Ppa\Mapping\RepositoryMappingInterface; use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; use Flytachi\Winter\Base\Runtime; use PDOStatement; +use ReflectionClass; +use ReflectionProperty; use stdClass; use Swoole\Coroutine; use Throwable; @@ -87,18 +92,55 @@ public function __construct() /** * Creates and returns a new repository instance, optionally with a table alias. * + * Deliberately a fresh object rather than a container lookup: the alias lives in + * per-object state, so joining one table twice needs two distinct handles. Resolving + * this through the container would return the shared instance for a `#[Singleton]` + * repository, and the second alias would silently overwrite the first. + * + * The container still fills `#[Autowired]` / `#[Inject]` properties, so a repository + * with dependencies behaves the same however it was obtained. Injection is skipped + * when no container exists — PPA is usable from a bare script, and that path simply + * has nothing to inject, exactly as before this was added. + * * @param string|null $as Optional table alias — calls {@see as()} before returning * @return static */ public static function instance(?string $as = null): static { $repository = new static(); + + if (self::hasInjectableProperties(static::class) && Container::isInitialized()) { + Container::getInstance()->inject($repository); + } + if (!empty($as)) { $repository->as($as); } return $repository; } + /** + * Whether a repository class declares anything for the container to fill. + * + * Answered once per class and remembered, because {@see instance()} runs on every + * join of every request while the answer is almost always no — a repository normally + * carries a config class name and nothing else. Asking the container regardless cost + * roughly 2 µs per call, which is the wrong price for a rarely used capability. + * + * @param class-string $class + */ + private static function hasInjectableProperties(string $class): bool + { + static $known = []; + + return $known[$class] ??= array_any( + new ReflectionClass($class)->getProperties(), + static fn(ReflectionProperty $property): bool => + $property->getAttributes(Autowired::class) !== [] + || $property->getAttributes(Inject::class) !== [], + ); + } + // ------------------------------------------------------------------------- // Coroutine-safe state // ------------------------------------------------------------------------- diff --git a/src/Route/Router.php b/src/Route/Router.php index 1b4828a..2209c10 100644 --- a/src/Route/Router.php +++ b/src/Route/Router.php @@ -10,7 +10,7 @@ use Flytachi\Winter\DI\ReflectionCache; use Flytachi\Winter\Base\Runtime; use Flytachi\Winter\DI\Container; -use Flytachi\Winter\DI\Scanner; +use Flytachi\Winter\Kernel\Core\ClassScanner; use Flytachi\Winter\Kernel\Core\KernelStore; use Flytachi\Winter\Kernel\Kernel; use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; @@ -165,7 +165,7 @@ public static function fromScan(string $rootDir, array $exclude = []): static $mappingCollector = new MappingCollector($router); $exceptionCollector = new ExceptionCollector(); - Scanner::run($rootDir) + ClassScanner::scanner($rootDir) ->exclude($exclude) ->collect($mappingCollector) ->collect($exceptionCollector) @@ -174,7 +174,7 @@ public static function fromScan(string $rootDir, array $exclude = []): static foreach (Plugin::getPlugins() as $prefix => $path) { $pluginSrc = $path . '/src'; if (is_dir($pluginSrc)) { - Scanner::run($pluginSrc) + ClassScanner::scanner($pluginSrc) ->collect(new MappingCollector($router, $prefix)) ->execute(); } @@ -193,7 +193,7 @@ public static function fromScan(string $rootDir, array $exclude = []): static /** Add attribute-scanned routes from $rootDir to this Router instance. */ public function scan(string $rootDir, array $exclude = []): static { - Scanner::run($rootDir) + ClassScanner::scanner($rootDir) ->exclude($exclude) ->collect(new MappingCollector($this)) ->execute(); diff --git a/src/WinterApplication.php b/src/WinterApplication.php index 4cf9e78..4fdc820 100644 --- a/src/WinterApplication.php +++ b/src/WinterApplication.php @@ -8,7 +8,7 @@ use Flytachi\Winter\Base\RuntimeMode; use Flytachi\Winter\Console\Core; use Flytachi\Winter\DI\Container; -use Flytachi\Winter\DI\Scanner; +use Flytachi\Winter\Kernel\Core\ClassScanner; use Flytachi\Winter\Kernel\App\ApplicationArguments; use Flytachi\Winter\Kernel\App\ApplicationConfigException; use Flytachi\Winter\Kernel\App\Attribute\EnableActuator; @@ -250,7 +250,7 @@ protected static function bootstrap(ApplicationArguments $args): void ) : null; - $scan = Scanner::run( + $scan = ClassScanner::scanner( rootDir: Kernel::$pathRoot, cache: $debug ? null : Kernel::$pathStorageVolatile . '/di.php', ) diff --git a/tests/Core/ClassScannerExclusionTest.php b/tests/Core/ClassScannerExclusionTest.php new file mode 100644 index 0000000..3a9584d --- /dev/null +++ b/tests/Core/ClassScannerExclusionTest.php @@ -0,0 +1,79 @@ +root = sys_get_temp_dir() . '/wk-scan-' . getmypid(); + @mkdir($this->root . '/storage/volatile', 0777, true); + Kernel::init(pathRoot: $this->root, isTmpVolatile: false); + } + + protected function tearDown(): void + { + @unlink($this->root . '/storage/volatile/di.php'); + @rmdir($this->root . '/storage/volatile'); + @rmdir($this->root . '/storage'); + @rmdir($this->root); + } + + public function test_the_storage_directory_is_excluded(): void + { + self::assertContains( + rtrim(Kernel::$pathStorage, '/\\'), + $this->exclusionsOf(ClassScanner::scanner($this->root)), + 'Storage holds the DI cache and generated proxies — walking it is self-referential.', + ); + } + + /** + * Views are PHP files that are not classes. The scanner reads every `.php` file + * looking for a class declaration and `require_once`s what it finds, so a template + * declaring a helper class would be **executed** at boot — echoing into the output + * and running whatever else sits at its top level. Excluding the directory is the + * only place that distinction can be made, since the scanner cannot see it. + */ + public function test_the_resource_directory_is_excluded(): void + { + self::assertContains( + rtrim(Kernel::$pathResource, '/\\'), + $this->exclusionsOf(ClassScanner::scanner($this->root)), + 'resources/views holds templates — reading, let alone requiring them, is wrong.', + ); + } + + public function test_vendor_stays_excluded_as_well(): void + { + self::assertContains( + $this->root . '/vendor', + $this->exclusionsOf(ClassScanner::scanner($this->root)), + 'Scanner excludes vendor itself; adding our own must not replace that.', + ); + } + + /** @return list */ + private function exclusionsOf(Scanner $scanner): array + { + return array_values(new ReflectionProperty(Scanner::class, 'exclude')->getValue($scanner)); + } +} diff --git a/tests/Http/Fixtures/OriginProbeRequest.php b/tests/Http/Fixtures/OriginProbeRequest.php new file mode 100644 index 0000000..c7a5f61 --- /dev/null +++ b/tests/Http/Fixtures/OriginProbeRequest.php @@ -0,0 +1,103 @@ + $headers */ + public function __construct( + private readonly string $scheme = 'http', + private readonly string $host = 'localhost', + private readonly int $port = 80, + private readonly string $baseUrl = 'http://localhost', + private readonly array $headers = [], + ) { + } + + public function getScheme(): string + { + $this->originCalls++; + return $this->scheme; + } + + public function getHost(): string + { + return $this->host; + } + + public function getPort(): int + { + return $this->port; + } + + public function getBaseUrl(): string + { + return $this->baseUrl; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function getHeader(string $name): ?string + { + return $this->headers[$name] ?? null; + } + + public function getClientIp(): string + { + return '127.0.0.1'; + } + + public function getMethod(): string + { + return 'GET'; + } + + public function getUri(): string + { + return '/'; + } + + public function getQueryParams(): array + { + return []; + } + + public function getParsedBody(): array + { + return []; + } + + public function getRawBody(): string + { + return ''; + } + + public function getUploadedFiles(): array + { + return []; + } + + public function getServerParam(string $key): ?string + { + return null; + } + + public function getClientTimezone(): ?string + { + return null; + } +} diff --git a/tests/Http/HeaderNormalizeTest.php b/tests/Http/HeaderNormalizeTest.php new file mode 100644 index 0000000..27dc7d9 --- /dev/null +++ b/tests/Http/HeaderNormalizeTest.php @@ -0,0 +1,95 @@ + */ + public static function keys(): array + { + return [ + 'lower case' => ['content-type', 'Content-Type'], + 'upper case' => ['CONTENT-TYPE', 'Content-Type'], + 'mixed case' => ['CoNtEnT-TyPe', 'Content-Type'], + 'already normal' => ['Content-Type', 'Content-Type'], + 'single word' => ['host', 'Host'], + 'three parts' => ['x-forwarded-for', 'X-Forwarded-For'], + 'no separator' => ['authorization', 'Authorization'], + ]; + } + + #[DataProvider('keys')] + public function test_a_key_normalises_to_title_case(string $raw, string $expected): void + { + self::assertSame($expected, self::normalize($raw)); + } + + public function test_repeated_normalisation_is_stable(): void + { + for ($i = 0; $i < 3; $i++) { + self::assertSame('Accept-Language', self::normalize('accept-language')); + } + } + + /** + * The safety property: a flood of distinct names must not grow the memo past its cap, + * and every one of them must still normalise correctly. + */ + public function test_a_flood_of_distinct_names_stays_bounded_and_correct(): void + { + $limit = new \ReflectionClassConstant(Header::class, 'NORMALIZE_MEMO_LIMIT')->getValue(); + + for ($i = 0; $i < $limit * 4; $i++) { + self::assertSame("X-Flood-{$i}", self::normalize("x-flood-{$i}")); + } + + self::assertLessThanOrEqual( + $limit, + self::memoSize(), + 'The memo must stop growing at its cap — its keys are client-controlled.', + ); + } + + /** + * Reading a header goes through the same normalisation, so the cap must not change + * what a lookup finds. + */ + public function test_lookup_still_works_for_a_key_beyond_the_cap(): void + { + Header::init(new OriginProbeRequest('http', 'localhost', 80, 'http://localhost', [ + 'x-late-header' => 'value', + ])); + + self::assertSame('value', Header::get('X-Late-Header')); + self::assertSame('value', Header::get('x-late-header')); + } + + private static function normalize(string $key): string + { + return new ReflectionMethod(Header::class, 'normalizeKey')->invoke(null, $key); + } + + private static function memoSize(): int + { + $statics = new ReflectionMethod(Header::class, 'normalizeKey')->getStaticVariables(); + + return count($statics['memo'] ?? []); + } +} diff --git a/tests/Http/HeaderOriginTest.php b/tests/Http/HeaderOriginTest.php new file mode 100644 index 0000000..edc5107 --- /dev/null +++ b/tests/Http/HeaderOriginTest.php @@ -0,0 +1,115 @@ +setValue(null, []); + new ReflectionProperty(Header::class, 'origin')->setValue(null, []); + new ReflectionProperty(Header::class, 'request')->setValue(null, null); + } + + public function test_the_origin_is_reported_as_the_request_states_it(): void + { + Header::init(new OriginProbeRequest('https', 'example.test', 8443, 'https://example.test:8443')); + + self::assertSame('https', Header::getScheme()); + self::assertSame('example.test', Header::getHost()); + self::assertSame(8443, Header::getPort()); + self::assertSame('https://example.test:8443', Header::getBaseUrl()); + } + + /** + * The point of the change: a request that never reads the origin never computes it. + */ + public function test_a_request_that_never_asks_pays_nothing(): void + { + $request = new OriginProbeRequest(); + + Header::init($request); + Header::get('Content-Type'); + Header::getIpAddress(); + + self::assertSame( + 0, + $request->originCalls, + 'Nothing read the origin, so the request should not have been asked for it.', + ); + } + + /** + * Reading repeatedly must not recompute: the four getters together used to cost + * six calls into the request, because getBaseUrl() re-derives scheme, host and port. + */ + public function test_the_origin_is_computed_once_however_often_it_is_read(): void + { + $request = new OriginProbeRequest(); + Header::init($request); + + for ($i = 0; $i < 5; $i++) { + Header::getScheme(); + Header::getHost(); + Header::getPort(); + Header::getBaseUrl(); + } + + self::assertSame(1, $request->originCalls, 'The origin should be derived exactly once.'); + } + + /** + * A later request must not inherit the previous one's origin — the failure a + * per-request cache introduces when it is never invalidated. + */ + public function test_a_later_request_does_not_inherit_the_previous_origin(): void + { + Header::init(new OriginProbeRequest('http', 'first.test', 80, 'http://first.test')); + self::assertSame('http://first.test', Header::getBaseUrl()); + + Header::init(new OriginProbeRequest('https', 'second.test', 8443, 'https://second.test:8443')); + self::assertSame('http://second.test:8443', str_replace('https', 'http', Header::getBaseUrl() ?? '')); + self::assertSame(8443, Header::getPort()); + self::assertSame('second.test', Header::getHost()); + } + + /** + * The raw `Host` header keeps its port; the origin's host is stripped. The two + * live side by side and must not be confused for one another. + */ + public function test_the_raw_host_header_survives_beside_the_stripped_origin(): void + { + Header::init(new OriginProbeRequest('http', 'example.test', 8080, 'http://example.test:8080', [ + 'Host' => 'example.test:8080', + ])); + + self::assertSame('example.test:8080', Header::get('Host')); + self::assertSame('example.test', Header::getHost()); + } +} From a406599c8f34bf1d079e51ded8d95ed09fe0f8e2 Mon Sep 17 00:00:00 2001 From: flytachi Date: Mon, 3 Aug 2026 15:48:57 +0500 Subject: [PATCH 52/71] di - fixes --- CLAUDE.md | 37 ++++ doc/STATUS.md | 7 + docs/configuration/07-di.md | 84 +++++++++ docs/process/02-concurrency.md | 36 +++- phpunit.xml | 3 + src/Collector/ScopeConflictException.php | 45 +++++ src/Collector/ScopeGraphCollector.php | 143 ++++++++++++++++ src/Process/Stereotype/Process.php | 16 ++ src/WinterApplication.php | 9 +- tests/Collector/ScopeGraphCollectorTest.php | 179 ++++++++++++++++++++ tests/Process/UnitOfWorkScopeTest.php | 120 +++++++++++++ 11 files changed, 677 insertions(+), 2 deletions(-) create mode 100644 src/Collector/ScopeConflictException.php create mode 100644 src/Collector/ScopeGraphCollector.php create mode 100644 tests/Collector/ScopeGraphCollectorTest.php create mode 100644 tests/Process/UnitOfWorkScopeTest.php diff --git a/CLAUDE.md b/CLAUDE.md index e97b89c..0160fb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -366,6 +366,43 @@ Also: built-in `console/Command/*` are `final` because two of them (`Process`, ` share a short name with a real stereotype, and an open command made both appear in completion. That was the original complaint; `final` fixed it without renaming anything. +### DI scopes — one rule, enforced at boot (added 2026-08-03) + +> **A class may hold a reference to a shorter-lived object only if it does not outlive it.** + +Injected properties resolve **once, when the holder is built**. So a `#[Singleton]` +holding a `#[Request]` bean freezes the first request's instance for the worker's +lifetime — every later request keeps seeing it, with no error and nothing in the log. +Measured live: three users, all three served as the first one. + +The reach is **transitive** — a singleton freezes its whole dependency subtree, so +`#[Singleton] → plain service → #[Request]` leaks identically. Also measured, not reasoned. + +`Collector\ScopeGraphCollector` gathers the dependency graph during the single scan pass; +`assertNoFrozenRequestScope()` walks it in `bootstrap()` and throws `ScopeConflictException` +naming the whole path. Cost: ~0.01 ms at boot, nothing per request. Cycle-guarded. + +This mirrors Spring's *safe* branch: there, `@RequestScope` injects a scoped proxy, and a +raw `@Scope("request")` without one fails at startup. Spring never leaks silently because +its singletons are built eagerly, before any request exists, so the resolution simply has +nothing to capture. Ours are built lazily — during the first request — which is exactly +why the capture succeeds and then rots. The boot check restores the guarantee. + +`#[Request]` outside HTTP: a worker's body is **one** coroutine, so a request-scoped bean +resolved there would outlive every job. `Process::markBusy()` — which already marks where a +unit of work starts — therefore also ends the request scope, via +`Container::flushRequestScope()`. Singletons are untouched, and a body that never marks a +unit resets nothing. Measured before the fix: four iterations, one object, each seeing what +the one before it wrote. + +Consequences worth remembering: +- `#[Singleton]` is **per worker process**, not per application: 4 workers = 4 instances. + Counters and caches in singleton fields disagree with themselves. +- Repositories are safe to share because their query state lives in the coroutine context, + not the object — hence `#[Singleton]` in the generated template (§13-adjacent, `call make -r`). +- `Http\Stereotype\Controller::__construct()` is `final`: no constructor means no natural + place to stash request state, which removes half the trap before it appears. + --- ## 8. CLI diff --git a/doc/STATUS.md b/doc/STATUS.md index d053457..044086c 100644 --- a/doc/STATUS.md +++ b/doc/STATUS.md @@ -50,6 +50,13 @@ housekeeper (keepalive / idleTimeout / minimumIdle, opt-in), evict при пот > и без `update` там останется старый корень. Симптом — «Class not found» при формально > свежем ядре. +**Скоупы DI: отказ на загрузке** (2026-08-03) — `#[Singleton]`, держащий `#[Request]` +(напрямую или через цепочку), больше не поднимает приложение. Раньше первый запрос +замораживался в синглтоне на всю жизнь воркера, и каждый следующий пользователь +обслуживался под чужой личностью — молча, без ошибки и без записи в лог. Проверено живьём, +включая транзитивный случай через два уровня. `Collector\ScopeGraphCollector` + +`ScopeConflictException`, ~0.01 мс при старте, ноль на запрос. + **Аудит непроверенных слоёв** — `Concurrent/Async`, `Unit/Pagination`, `Stereotype`, миграции `Ppa` открыты и покрыты тестами (`AsyncContractTest`, `CursorTokenTest`, `SqliteDdlTest`). В `CursorToken::decode()` добавлен отказ на нескалярное значение diff --git a/docs/configuration/07-di.md b/docs/configuration/07-di.md index 945dd9a..c5fd921 100644 --- a/docs/configuration/07-di.md +++ b/docs/configuration/07-di.md @@ -174,6 +174,90 @@ rebuilds its own graph. See [`../process/00-overview.md`](../process/00-overview --- +## Choosing a scope + +One rule decides every case: + +> **A class may hold a reference to a shorter-lived object only if it does not outlive it.** + +Injected properties are resolved **once, when the holder is built**. A `#[Singleton]` is +built once per worker, so whatever it captured at that moment is what it keeps — for +every later request. + +| scope | one instance per | safe to hold | +|---|---|---| +| `#[Transient]` (default) | resolution | anything | +| `#[Request]` | request (coroutine) | `#[Request]`, `#[Singleton]` | +| `#[Singleton]` | **worker process**, not application | `#[Singleton]` only | + +Note the middle column for `#[Singleton]`: with four Swoole workers you have four +instances, not one. A counter or cache in a singleton field will disagree with itself +depending on which worker answered. + +### What makes a class safe to share + +A `#[Singleton]` is exactly as safe as its least careful field. Fields holding request +data — the current user, an accumulated result, a scratch value — break under +concurrency, because a Swoole worker serves requests in interleaved coroutines and they +all see the same object. + +Repositories are safe by construction: their query state (`sqlParts`, alias, entity class) +lives in the coroutine context rather than in the object, so concurrent requests never +share a builder. That is why `call make -r` marks them `#[Singleton]` out of the box. + +### The boot refuses a singleton that holds a request bean + +```php +#[Request] +class AuthContext { /* ... */ } + +#[Singleton] +class OrderService +{ + #[Autowired] private AuthContext $context; // ← the application will not start +} +``` + +The first request's `AuthContext` would be frozen into `OrderService` for the worker's +lifetime, and every later request would keep seeing it — silently, with no error and +nothing in the log. With an authentication context in that position, every user after the +first is served under the first user's identity. + +The check is **transitive**, because a singleton freezes its whole dependency subtree: +`#[Singleton] → plain service → #[Request]` leaks exactly as badly. The error names the +full path, since the mistake usually hides in the middle — each link looks correct alone. + +Fixes, in order of preference: + +- drop `#[Singleton]` from the holder, so it is built per request (the default); +- resolve the request-scoped bean where it is used, not as a property; +- make the dependency stateless and give it `#[Singleton]` too. + +The canonical way to carry request data is the reverse of holding it: a `#[Request]` bean +written by middleware and read by whatever needs it, where **every reader is transient**. + +```php +#[Request] +class AuthContext { /* ... */ } + +class StaffMiddleware extends Middleware // transient — outlives nothing +{ + #[Autowired] public AuthContext $context; + + public function before(HttpRequest $request, HttpResponse $response): void + { + $this->context->init(/* ... */); // ← written once per request + } +} + +class CategoryController extends Controller // transient +{ + #[Autowired] private AuthContext $context; // ← same instance, same request +} +``` + +--- + ## Plugins Classes under a registered plugin's `src/` are scanned by the **same** DI diff --git a/docs/process/02-concurrency.md b/docs/process/02-concurrency.md index c655c89..eefe670 100644 --- a/docs/process/02-concurrency.md +++ b/docs/process/02-concurrency.md @@ -236,7 +236,7 @@ final class ReportBuilder extends Process } ``` -`markBusy()` and `markIdle()` are pure in-memory flags — they cost nothing and are +`markBusy()` and `markIdle()` are in-memory flags — they cost nothing and are safe to call on every iteration. They combine with the spawn count by OR, so a process that both marks itself busy and has spawns in flight stays `BUSY` until both are clear; it can never report a false `IDLE` while work is genuinely @@ -244,6 +244,40 @@ outstanding. The value is persisted to the status record on a roughly one-second heartbeat, and only when it actually changes, so a worker flipping between busy and idle on every message never touches the disk for it. +### `markBusy()` also opens a new request scope + +A `#[Request]` binding means *one instance per unit of work*. Over HTTP the framework +knows what a unit is — a request is a coroutine, and the scope dies with it. **A worker +has no such boundary**: its whole body runs inside one coroutine, so a request-scoped +bean resolved there would live for the entire run and hand each job the previous job's +state: + +``` +итерация 1: объект #61, at entry saw "unset" +итерация 2: объект #61, at entry saw "job-1" ← the last job's data +итерация 3: объект #61, at entry saw "job-2" +``` + +Only the body knows where a unit ends, and `markBusy()` already says so. So it doubles as +the scope boundary: request-scoped bindings resolved after it are new. + +```php +while ($this->isRunning()) { + $job = $this->queue->pop(timeout: 1.0); + if ($job === null) { continue; } + + $this->markBusy(); // ← unit starts; request scope reset + $ctx = Container::getInstance()->make(JobContext::class); // fresh every job + // ... work ... + $this->markIdle(); +} +``` + +Two things this does **not** do. Singletons are untouched — a pool, a warm cache or a +counter is not scoped to a unit and keeps both identity and state. And a body that never +calls `markBusy()` has declared no units, so nothing is reset; a process that simply runs +behaves as it always did. + --- ## How the FPM backend reproduces this diff --git a/phpunit.xml b/phpunit.xml index f587547..38d1c4b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -54,6 +54,9 @@ tests/Architecture + + tests/Collector + tests/Core diff --git a/src/Collector/ScopeConflictException.php b/src/Collector/ScopeConflictException.php new file mode 100644 index 0000000..b4d7dfe --- /dev/null +++ b/src/Collector/ScopeConflictException.php @@ -0,0 +1,45 @@ + $conflicts */ + public static function of(array $conflicts): self + { + $paths = implode("\n ", $conflicts); + + return new self(<< Classes carrying `#[Singleton]`. */ + private array $singletons = []; + + /** @var array Classes carrying `#[Request]`. */ + private array $requestScoped = []; + + /** @var array> */ + private array $edges = []; + + public function collect(string $class, ReflectionClass $ref): void + { + if ($ref->getAttributes(Singleton::class) !== []) { + $this->singletons[$class] = true; + } + if ($ref->getAttributes(Request::class) !== []) { + $this->requestScoped[$class] = true; + } + + foreach ($ref->getProperties() as $property) { + if ($property->getAttributes(Autowired::class) === [] && $property->getAttributes(Inject::class) === []) { + continue; + } + + $type = $this->targetOf($property->getAttributes(Inject::class), $property->getType()); + if ($type !== null) { + $this->edges[$class][] = ['property' => $property->getName(), 'type' => $type]; + } + } + } + + /** + * Fails the boot when a singleton can reach a request-scoped bean. + * + * The message names the whole path rather than the endpoints, because the middle of + * the chain is where the mistake usually hides — each link looks correct on its own. + * + * @throws ScopeConflictException + */ + public function assertNoFrozenRequestScope(): void + { + $conflicts = []; + + foreach (array_keys($this->singletons) as $singleton) { + $path = $this->pathToRequestScope($singleton, [$singleton => true]); + if ($path !== null) { + $conflicts[] = $singleton . ' → ' . implode(' → ', $path); + } + } + + if ($conflicts !== []) { + throw ScopeConflictException::of($conflicts); + } + } + + /** + * Depth-first walk to the first request-scoped class reachable from $class. + * + * @param class-string $class + * @param array $seen Guards against a dependency cycle. + * @return list|null The remaining hops, or null when nothing is reachable. + */ + private function pathToRequestScope(string $class, array $seen): ?array + { + foreach ($this->edges[$class] ?? [] as $edge) { + $target = $edge['type']; + $hop = "\${$edge['property']}: {$target}"; + + if (isset($this->requestScoped[$target])) { + return [$hop]; + } + if (isset($seen[$target])) { + continue; + } + + $deeper = $this->pathToRequestScope($target, $seen + [$target => true]); + if ($deeper !== null) { + return [$hop, ...$deeper]; + } + } + + return null; + } + + /** + * The class a property resolves to: an explicit `#[Inject(Foo::class)]` wins over the + * declared type, mirroring how the container itself decides. + * + * @param list<\ReflectionAttribute> $injectAttributes + * @return class-string|null Null for scalars, unions and untyped properties — the + * container cannot resolve those either. + */ + private function targetOf(array $injectAttributes, ?\ReflectionType $declared): ?string + { + if ($injectAttributes !== []) { + $id = $injectAttributes[0]->newInstance()->id; + if (is_string($id) && $id !== '' && class_exists($id)) { + return $id; + } + } + + if ($declared instanceof ReflectionNamedType && !$declared->isBuiltin()) { + return $declared->getName(); + } + + return null; + } +} diff --git a/src/Process/Stereotype/Process.php b/src/Process/Stereotype/Process.php index 72610c2..d70b51b 100644 --- a/src/Process/Stereotype/Process.php +++ b/src/Process/Stereotype/Process.php @@ -155,10 +155,26 @@ final protected function requestStop(): void /** * Marks the start of an inline unit of work (no {@see spawn()}). Keeps the * process BUSY so it is not interrupted mid-unit and not scaled down. + * + * This is also where the **request scope ends and a new one begins**. Under HTTP a + * request is a coroutine and its scope dies with it; a worker has no such boundary — + * its whole body runs in one coroutine, so a `#[Request]` bean resolved inside would + * survive every iteration and carry the previous job's state into the next. Verified: + * four iterations, one object, each seeing what the one before it wrote. + * + * Only the body knows where a unit ends, and this call already says so — the activity + * flag and the scope boundary are two readings of the same event, so the developer + * gets correct scoping without having to know the scope exists. + * + * A body that never calls this has declared no units, and nothing is reset. */ final protected function markBusy(): void { $this->inlineBusy = true; + + if (Container::isInitialized()) { + Container::getInstance()->flushRequestScope(); + } } /** diff --git a/src/WinterApplication.php b/src/WinterApplication.php index 4fdc820..237b2af 100644 --- a/src/WinterApplication.php +++ b/src/WinterApplication.php @@ -28,6 +28,7 @@ use Flytachi\Winter\Kernel\App\Config\WebConfigurer; use Flytachi\Winter\Kernel\Collector\ConfigurationCollector; use Flytachi\Winter\Kernel\Collector\ImplementorCollector; +use Flytachi\Winter\Kernel\Collector\ScopeGraphCollector; use Flytachi\Winter\Kernel\Concurrent\Async\AsyncCollector; use Flytachi\Winter\Kernel\Concurrent\Async\Proxy\ProxyFactory; use Flytachi\Winter\Kernel\Http\Health\Health; @@ -236,6 +237,7 @@ protected static function bootstrap(ApplicationArguments $args): void $webCollector = new ImplementorCollector(WebConfigurer::class); $logCollector = new ImplementorCollector(LoggingConfigurer::class); $actuatorCollector = new ImplementorCollector(HealthContributor::class); + $scopeGraph = new ScopeGraphCollector(); // #[Async] proxying is opt-in, like Spring's @EnableAsync: the collector is // created and wired only when #[EnableAsync] is present. Without it, classes @@ -258,13 +260,18 @@ protected static function bootstrap(ApplicationArguments $args): void ->collect($config) ->collect($webCollector) ->collect($logCollector) - ->collect($actuatorCollector); + ->collect($actuatorCollector) + ->collect($scopeGraph); if ($async !== null) { $scan->collect($async); } $scan->execute(); + // Before anything is resolved: a #[Singleton] holding a #[Request] bean would + // freeze the first request's instance for the worker's lifetime, and say nothing. + $scopeGraph->assertNoFrozenRequestScope(); + $async?->flush(); // Default contextual logger: an injected LoggerInterface is named after the diff --git a/tests/Collector/ScopeGraphCollectorTest.php b/tests/Collector/ScopeGraphCollectorTest.php new file mode 100644 index 0000000..4c10335 --- /dev/null +++ b/tests/Collector/ScopeGraphCollectorTest.php @@ -0,0 +1,179 @@ +collect($class, new ReflectionClass($class)); + } + + return $collector; + } + + public function test_a_singleton_holding_a_request_bean_is_refused(): void + { + $graph = $this->graphOf(SgAuthContext::class, SgDirectHolder::class); + + $this->expectException(ScopeConflictException::class); + $graph->assertNoFrozenRequestScope(); + } + + /** + * The transitive case is the one that surprises people: every link looks correct on + * its own, and only the chain is wrong. + */ + public function test_it_follows_the_chain_through_an_innocent_middleman(): void + { + $graph = $this->graphOf(SgAuthContext::class, SgPlainService::class, SgIndirectHolder::class); + + $this->expectException(ScopeConflictException::class); + $graph->assertNoFrozenRequestScope(); + } + + /** + * The message has to name the middle of the chain, since that is where the mistake + * hides — pointing only at the endpoints leaves the reader to find the link. + */ + public function test_the_message_names_the_whole_path(): void + { + $graph = $this->graphOf(SgAuthContext::class, SgPlainService::class, SgIndirectHolder::class); + + try { + $graph->assertNoFrozenRequestScope(); + self::fail('Expected the boot to be refused.'); + } catch (ScopeConflictException $e) { + self::assertStringContainsString(SgIndirectHolder::class, $e->getMessage()); + self::assertStringContainsString(SgPlainService::class, $e->getMessage(), 'The middle link.'); + self::assertStringContainsString(SgAuthContext::class, $e->getMessage()); + self::assertStringContainsString('service', $e->getMessage(), 'The property name.'); + } + } + + public function test_an_explicit_inject_target_counts_too(): void + { + $graph = $this->graphOf(SgAuthContext::class, SgNamedHolder::class); + + $this->expectException(ScopeConflictException::class); + $graph->assertNoFrozenRequestScope(); + } + + public function test_a_singleton_with_only_shareable_dependencies_passes(): void + { + $graph = $this->graphOf(SgPlainRepository::class, SgHarmless::class); + + $graph->assertNoFrozenRequestScope(); + + $this->addToAssertionCount(1); + } + + /** + * A request-scoped bean held by something that does not outlive the request is the + * correct pattern, and must not be flagged. + */ + public function test_a_transient_holder_is_fine(): void + { + $graph = $this->graphOf(SgAuthContext::class, SgPlainService::class); + + $graph->assertNoFrozenRequestScope(); + + $this->addToAssertionCount(1); + } + + /** + * A dependency cycle must not hang the walk — it is a separate problem the container + * reports on its own, and this check has no business turning it into an infinite loop. + */ + public function test_a_dependency_cycle_terminates(): void + { + $graph = $this->graphOf(SgCycleA::class, SgCycleB::class); + + $graph->assertNoFrozenRequestScope(); + + $this->addToAssertionCount(1); + } +} diff --git a/tests/Process/UnitOfWorkScopeTest.php b/tests/Process/UnitOfWorkScopeTest.php new file mode 100644 index 0000000..5451bcc --- /dev/null +++ b/tests/Process/UnitOfWorkScopeTest.php @@ -0,0 +1,120 @@ +invoke($this); + } +} + +/** + * A worker's unit of work is also its request scope. + * + * Under HTTP the scope ends with the coroutine carrying the request. A worker has no + * such boundary — its whole body runs in one coroutine, so a `#[Request]` bean resolved + * inside it would survive every iteration and hand the previous job's state to the next. + * Measured before the fix: four iterations, one object, each seeing what the one before + * it wrote. + * + * `markBusy()` already marks where a unit begins, so it is the boundary. That keeps the + * developer from having to know the scope exists in order to get it right. + */ +final class UnitOfWorkScopeTest extends TestCase +{ + protected function setUp(): void + { + Container::init(); + } + + public function test_each_unit_of_work_gets_a_fresh_request_bean(): void + { + $worker = new UowWorker(); + $c = Container::getInstance(); + + $worker->beginUnit(); + $first = $c->make(UowJobContext::class); + $first->job = 'job-1'; + + $worker->beginUnit(); + $second = $c->make(UowJobContext::class); + + self::assertNotSame($first, $second); + self::assertSame('unset', $second->job, "The next job must not inherit the last one's state."); + } + + public function test_within_one_unit_the_bean_is_the_same(): void + { + $worker = new UowWorker(); + $c = Container::getInstance(); + + $worker->beginUnit(); + + self::assertSame( + $c->make(UowJobContext::class), + $c->make(UowJobContext::class), + 'A unit of work is one scope — that is the whole point of #[Request].', + ); + } + + /** + * Ending a scope is not resetting the container: a worker's singletons carry state + * across units on purpose — a connection pool, a counter, a warm cache. + */ + public function test_singletons_survive_the_unit_boundary(): void + { + $worker = new UowWorker(); + $c = Container::getInstance(); + + $worker->beginUnit(); + $c->make(UowSharedCounter::class)->count = 7; + + $worker->beginUnit(); + + self::assertSame(7, $c->make(UowSharedCounter::class)->count); + } + + /** + * A body that never marks a unit has declared no boundary, so nothing is reset — + * the previous behaviour, kept for processes that simply run. + */ + public function test_a_body_that_never_marks_a_unit_keeps_its_bean(): void + { + $c = Container::getInstance(); + + $first = $c->make(UowJobContext::class); + $first->job = 'still here'; + + self::assertSame('still here', $c->make(UowJobContext::class)->job); + } +} From 198358b17622e94e1c94b411825da4b9388f4df5 Mon Sep 17 00:00:00 2001 From: flytachi Date: Mon, 3 Aug 2026 18:44:38 +0500 Subject: [PATCH 53/71] di - fixes --- composer.json | 2 +- console/Template/Make/RepositoryTemplate | 4 - dev/composer.json | 6 +- src/Http/ParameterResolver.php | 17 ++- tests/Http/Request/MixedTypeBindingTest.php | 150 ++++++++++++++++++++ 5 files changed, 170 insertions(+), 9 deletions(-) create mode 100644 tests/Http/Request/MixedTypeBindingTest.php diff --git a/composer.json b/composer.json index 2e2c7a1..73f6a2f 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ "bin": ["wKernelRunner"], "require": { "php": ">=8.4", - "flytachi/winter-di": "^1.0", + "flytachi/winter-di": "^2.0", "flytachi/winter-base": "^3.0", "flytachi/winter-logger": "^1.0", "flytachi/winter-thread": "^3.0", diff --git a/console/Template/Make/RepositoryTemplate b/console/Template/Make/RepositoryTemplate index 5b5b9b2..18986e0 100644 --- a/console/Template/Make/RepositoryTemplate +++ b/console/Template/Make/RepositoryTemplate @@ -5,10 +5,6 @@ namespace __namespace__; use Flytachi\Winter\DI\Attribute\Singleton; use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; -// A repository is safe to share: its query state (sqlParts, alias, entity class) lives -// in the coroutine context, not in the object, so concurrent requests never see each -// other's builder. Sharing it skips a constructor that reaches into the connection pool -// on every request. Drop #[Singleton] only if you add mutable fields of your own. #[Singleton] class __className__ extends Repository { diff --git a/dev/composer.json b/dev/composer.json index 4d6b4cb..a32ca99 100644 --- a/dev/composer.json +++ b/dev/composer.json @@ -1,6 +1,6 @@ { - "name": "project/winter-k2", - "description": "My Project winter-k2", + "name": "project/winter", + "description": "My Project winter", "type": "project", "license": "MIT", "scripts": { @@ -13,7 +13,7 @@ }, "require": { "php": ">=8.3", - "flytachi/winter-kernel": "dev-k2" + "flytachi/winter-kernel": "dev-alpha" }, "require-dev": { "phpunit/phpunit": "@stable", diff --git a/src/Http/ParameterResolver.php b/src/Http/ParameterResolver.php index e8bf361..15f35db 100644 --- a/src/Http/ParameterResolver.php +++ b/src/Http/ParameterResolver.php @@ -71,6 +71,15 @@ */ final class ParameterResolver { + /** + * Declared types that accept an array, so binding one to them is not an error. + * + * `mixed` and `iterable` are here because PHP itself accepts an array for both; the + * absence of `object` is deliberate, since an array bound to it would only fail later + * at construction, and failing here says why. + */ + private const array ARRAY_COMPATIBLE = ['array', 'mixed', 'iterable']; + // ── Public API ──────────────────────────────────────────────────────────── public static function resolve( @@ -870,7 +879,13 @@ private static function resolveMessage(string $message, string $field, Constrain private static function cast(mixed $value, ?string $typeName, string $label = 'Parameter'): mixed { - if (is_array($value) && $typeName !== null && $typeName !== 'array') { + // An array reaching a scalar is a client error worth reporting — `?id[]=1` bound + // to an int would otherwise cast to the string "Array". But the check has to name + // the types that genuinely reject an array: `mixed` and `iterable` accept one, and + // comparing against 'array' alone refused them. A DataGrid filter is where this + // surfaced — its `value` is `mixed` because `isAnyOf` sends a list where + // `contains` sends a string. + if (is_array($value) && $typeName !== null && !in_array($typeName, self::ARRAY_COMPATIBLE, true)) { RequestException::throw("$label must be $typeName, got array"); } diff --git a/tests/Http/Request/MixedTypeBindingTest.php b/tests/Http/Request/MixedTypeBindingTest.php new file mode 100644 index 0000000..6cb23d9 --- /dev/null +++ b/tests/Http/Request/MixedTypeBindingTest.php @@ -0,0 +1,150 @@ +response = $this->createStub(HttpResponse::class); + } + + private function resolve(string $method, string $rawBody): array + { + $request = $this->createStub(HttpRequest::class); + $request->method('getRawBody')->willReturn($rawBody); + $request->method('getQueryParams')->willReturn([]); + + return ParameterResolver::resolve( + new ReflectionMethod(MixedTypeFixture::class, $method), + $request, + $this->response, + [], + ); + } + + public function test_a_mixed_property_accepts_an_array(): void + { + $args = $this->resolve('filter', '{"field":"status","value":["draft","sent"]}'); + + self::assertSame(['draft', 'sent'], $args[0]->value); + } + + public function test_a_mixed_property_accepts_a_nested_object(): void + { + $args = $this->resolve('filter', '{"field":"range","value":{"from":1,"to":9}}'); + + self::assertSame(['from' => 1, 'to' => 9], $args[0]->value); + } + + /** + * `mixed` accepts every kind and converts none of them: the value must arrive with + * the type JSON gave it. Casting here would be a guess — the whole point of the + * declaration is that the shape is decided elsewhere. + * + * @return array + */ + public static function everyKind(): array + { + return [ + 'string' => ['"john"', 'john'], + 'int' => ['42', 42], + 'negative int' => ['-7', -7], + 'float' => ['3.14', 3.14], + 'bool true' => ['true', true], + 'bool false' => ['false', false], + 'null' => ['null', null], + 'empty string' => ['""', ''], + 'zero' => ['0', 0], + 'numeric string stays a string' => ['"42"', '42'], + 'list' => ['[1,2]', [1, 2]], + 'nested object' => ['{"a":1}', ['a' => 1]], + 'empty list' => ['[]', []], + ]; + } + + #[DataProvider('everyKind')] + public function test_a_mixed_property_takes_every_kind_unchanged(string $json, mixed $expected): void + { + $args = $this->resolve('filter', '{"field":"f","value":' . $json . '}'); + + self::assertSame($expected, $args[0]->value); + } + + public function test_an_iterable_property_accepts_an_array(): void + { + $args = $this->resolve('iterableDto', '{"items":[1,2,3]}'); + + self::assertSame([1, 2, 3], $args[0]->items); + } + + /** + * The guard must keep doing its job: an array reaching a scalar is still a client + * error, and reporting it beats casting the value to the string "Array". + * + * Hydrating a DTO collects such refusals into a validation report rather than + * failing on the first one, so the caller sees every bad field at once — hence + * ValidationException here and RequestException on a bare parameter. + */ + public function test_an_array_reaching_a_string_is_still_refused(): void + { + $this->expectException(ValidationException::class); + + $this->resolve('filter', '{"field":["a","b"],"value":null}'); + } +} From 6adfa28c2f46b8357c5a6145e67bf1bcd09a4186 Mon Sep 17 00:00:00 2001 From: flytachi Date: Tue, 4 Aug 2026 12:49:38 +0500 Subject: [PATCH 54/71] env --- console/Template/Docker/Dockerfile | 7 +- console/Template/Docker/docker-compose.yml | 6 +- docs/configuration/08-runtime.md | 23 ++++++ src/WinterApplication.php | 8 +- tests/App/ServerBindingTest.php | 96 ++++++++++++++++++++++ 5 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 tests/App/ServerBindingTest.php diff --git a/console/Template/Docker/Dockerfile b/console/Template/Docker/Dockerfile index 374e7ca..951b130 100644 --- a/console/Template/Docker/Dockerfile +++ b/console/Template/Docker/Dockerfile @@ -70,5 +70,10 @@ RUN php call cfg completion -if || true COPY docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh -EXPOSE 8000 +# Documentation only — EXPOSE publishes nothing. The host binding is done by `ports:` +# in docker-compose.yml, and the app binds where SERVER_PORT says. Kept in step with +# both so `docker image inspect` and `docker run -P` report the truth. +ARG SERVER_PORT=8000 +EXPOSE ${SERVER_PORT} + ENTRYPOINT ["/entrypoint.sh"] diff --git a/console/Template/Docker/docker-compose.yml b/console/Template/Docker/docker-compose.yml index f74b56e..22e0ebe 100644 --- a/console/Template/Docker/docker-compose.yml +++ b/console/Template/Docker/docker-compose.yml @@ -5,12 +5,12 @@ services: build: context: . dockerfile: Dockerfile + args: + SERVER_PORT: ${SERVER_PORT:-8000} environment: - COMPOSE_BAKE=true - # Default (no DEV) → prod: `call run`, tuned opcache on. Opt into dev per run - # (no rebuild): DEV=true docker compose up → `call run dev`, opcache off. - DEV=${DEV:-false} ports: - - "8000:8000" + - "${SERVER_PORT:-8000}:${SERVER_PORT:-8000}" volumes: - ./:/var/www/html/ diff --git a/docs/configuration/08-runtime.md b/docs/configuration/08-runtime.md index 6a6e7bd..2a83d2b 100644 --- a/docs/configuration/08-runtime.md +++ b/docs/configuration/08-runtime.md @@ -88,6 +88,29 @@ final class WebConfig extends WebConfigurerAdapter The `.env` shorthands `SERVER_WORKERS`, `SERVER_TASKS`, `SERVER_MAX_REQUEST` and `SERVER_MAX_REQUEST_GRACE` seed the same settings before the configurer runs. +### Where it binds + +`SERVER_HOST` and `SERVER_PORT` do the same for the bind address, with the flag winning: + +``` +--host / --port ▸ .env ▸ 0.0.0.0:8000 +``` + +```dotenv +SERVER_HOST=0.0.0.0 +SERVER_PORT=8003 +``` + +The flag stays on top because a one-off `--port=9501` is an override by intent. The +generated `docker-compose.yml` reads `SERVER_PORT` from the same `.env`, so the published +port and the bound port cannot drift apart. + +> Do not also put `SERVER_PORT` under `environment:` in compose. A real environment +> variable shows up in `$_SERVER`, which makes Dotenv consider the name already set and +> skip the `.env` value — while `env()` reads `$_ENV` only and never sees the environment +> one. Set in both places, the application ends up seeing neither and falls back to the +> default. + --- ## Set `opcache.enable_cli=1` diff --git a/src/WinterApplication.php b/src/WinterApplication.php index 237b2af..ba2f390 100644 --- a/src/WinterApplication.php +++ b/src/WinterApplication.php @@ -593,9 +593,13 @@ private static function serveHeadless( */ private static function buildServerSettings(ApplicationArguments $args): ServerSettings { + // Precedence: --host/--port ▸ .env ▸ built-in default. The flag wins because a + // one-off override is what a flag is for; `.env` carries the environment's own + // answer, which is what every other SERVER_* setting already does. Passing the + // environment value as the argument default gives exactly that order. $settings = ServerSettings::fromEnv( - $args->option('host', '0.0.0.0') ?? '0.0.0.0', - $args->int('port', 8000), + $args->option('host', (string) env('SERVER_HOST', '0.0.0.0')) ?? '0.0.0.0', + $args->int('port', (int) env('SERVER_PORT', 8000)), ); $c = self::$container; if ($c !== null) { diff --git a/tests/App/ServerBindingTest.php b/tests/App/ServerBindingTest.php new file mode 100644 index 0000000..670a111 --- /dev/null +++ b/tests/App/ServerBindingTest.php @@ -0,0 +1,96 @@ + */ + private array $envBackup = []; + + protected function setUp(): void + { + $this->envBackup = $_ENV; + unset($_ENV['SERVER_HOST'], $_ENV['SERVER_PORT']); + } + + protected function tearDown(): void + { + $_ENV = $this->envBackup; + } + + /** @param list $argv */ + private function bind(array $argv): array + { + $settings = new ReflectionMethod(WinterApplication::class, 'buildServerSettings') + ->invoke(null, ApplicationArguments::parse(['call', ...$argv])); + + return [$settings->getHost(), $settings->getPort()]; + } + + public function test_it_falls_back_to_the_built_in_default(): void + { + self::assertSame(['0.0.0.0', 8000], $this->bind(['run'])); + } + + public function test_the_environment_supplies_the_port(): void + { + $_ENV['SERVER_PORT'] = '8003'; + + self::assertSame(['0.0.0.0', 8003], $this->bind(['run'])); + } + + public function test_the_environment_supplies_the_host(): void + { + $_ENV['SERVER_HOST'] = '127.0.0.1'; + + self::assertSame(['127.0.0.1', 8000], $this->bind(['run'])); + } + + public function test_the_flag_wins_over_the_environment(): void + { + $_ENV['SERVER_HOST'] = '127.0.0.1'; + $_ENV['SERVER_PORT'] = '8003'; + + self::assertSame(['0.0.0.0', 9501], $this->bind(['run', '--host=0.0.0.0', '--port=9501'])); + } + + /** + * One of the two may be overridden without disturbing the other. + */ + public function test_the_flag_overrides_only_what_it_names(): void + { + $_ENV['SERVER_HOST'] = '127.0.0.1'; + $_ENV['SERVER_PORT'] = '8003'; + + self::assertSame(['127.0.0.1', 9501], $this->bind(['run', '--port=9501'])); + } + + /** + * `env()` turns a numeric string into an int on its own; the port must survive that + * without being re-parsed into something else. + */ + public function test_a_numeric_environment_value_is_read_as_a_port(): void + { + $_ENV['SERVER_PORT'] = 8003; + + self::assertSame(8003, $this->bind(['run'])[1]); + } +} From e518f7d9bbb1781c9bb93e390ae52d514e193703 Mon Sep 17 00:00:00 2001 From: flytachi Date: Tue, 4 Aug 2026 13:23:38 +0500 Subject: [PATCH 55/71] env --- console/Template/Docker/docker-compose.yml | 8 ++ console/Template/Docker/docker/entrypoint.sh | 9 +- docs/configuration/08-runtime.md | 23 ----- src/WinterApplication.php | 8 +- tests/App/ServerBindingTest.php | 96 -------------------- 5 files changed, 17 insertions(+), 127 deletions(-) delete mode 100644 tests/App/ServerBindingTest.php diff --git a/console/Template/Docker/docker-compose.yml b/console/Template/Docker/docker-compose.yml index 22e0ebe..7bce5cc 100644 --- a/console/Template/Docker/docker-compose.yml +++ b/console/Template/Docker/docker-compose.yml @@ -6,10 +6,18 @@ services: context: . dockerfile: Dockerfile args: + # Only feeds EXPOSE, which is metadata. The binding below is what publishes. SERVER_PORT: ${SERVER_PORT:-8000} environment: - COMPOSE_BAKE=true + # Default (no DEV) → prod: `call run`, tuned opcache on. Opt into dev per run + # (no rebuild): DEV=true docker compose up → `call run dev`, opcache off. - DEV=${DEV:-false} + # Read by entrypoint.sh, which passes it as `call run --port=…`. + - SERVER_PORT=${SERVER_PORT:-8000} + # One source of truth: SERVER_PORT in .env. Compose reads it here and hands the + # same value to the entrypoint above, so the published port and the bound port + # cannot drift apart. Change it in one place. ports: - "${SERVER_PORT:-8000}:${SERVER_PORT:-8000}" volumes: diff --git a/console/Template/Docker/docker/entrypoint.sh b/console/Template/Docker/docker/entrypoint.sh index 638b36f..5b1114a 100644 --- a/console/Template/Docker/docker/entrypoint.sh +++ b/console/Template/Docker/docker/entrypoint.sh @@ -7,6 +7,11 @@ # If you set up a crontab in a docker/dependencies/ script, start crond here: # crond -l 8 +# The port comes from compose, which read it from .env — the same value it published +# with `ports:`. Passing it as a flag keeps the two in step without the application +# needing to know it is running in a container. +PORT="${SERVER_PORT:-8000}" + # Opcache is toggled here (runtime, as root before su-exec), so switching dev/prod # needs no rebuild. Idempotent: safe on a fresh or a restarted container. OPCACHE_CONF=/usr/local/etc/php/conf.d/10-opcache.ini @@ -14,9 +19,9 @@ OPCACHE_CONF=/usr/local/etc/php/conf.d/10-opcache.ini if [ "${DEV:-false}" = "true" ]; then # Development: opcache off (mounted code always live) + DevWatcher hot-reload. rm -f "$OPCACHE_CONF" - exec su-exec winter php /var/www/html/call run dev + exec su-exec winter php /var/www/html/call run dev --port="$PORT" else # Production: tuned opcache on. cp /opt/winter/php-opcache.ini "$OPCACHE_CONF" - exec su-exec winter php /var/www/html/call run + exec su-exec winter php /var/www/html/call run --port="$PORT" fi diff --git a/docs/configuration/08-runtime.md b/docs/configuration/08-runtime.md index 2a83d2b..6a6e7bd 100644 --- a/docs/configuration/08-runtime.md +++ b/docs/configuration/08-runtime.md @@ -88,29 +88,6 @@ final class WebConfig extends WebConfigurerAdapter The `.env` shorthands `SERVER_WORKERS`, `SERVER_TASKS`, `SERVER_MAX_REQUEST` and `SERVER_MAX_REQUEST_GRACE` seed the same settings before the configurer runs. -### Where it binds - -`SERVER_HOST` and `SERVER_PORT` do the same for the bind address, with the flag winning: - -``` ---host / --port ▸ .env ▸ 0.0.0.0:8000 -``` - -```dotenv -SERVER_HOST=0.0.0.0 -SERVER_PORT=8003 -``` - -The flag stays on top because a one-off `--port=9501` is an override by intent. The -generated `docker-compose.yml` reads `SERVER_PORT` from the same `.env`, so the published -port and the bound port cannot drift apart. - -> Do not also put `SERVER_PORT` under `environment:` in compose. A real environment -> variable shows up in `$_SERVER`, which makes Dotenv consider the name already set and -> skip the `.env` value — while `env()` reads `$_ENV` only and never sees the environment -> one. Set in both places, the application ends up seeing neither and falls back to the -> default. - --- ## Set `opcache.enable_cli=1` diff --git a/src/WinterApplication.php b/src/WinterApplication.php index ba2f390..237b2af 100644 --- a/src/WinterApplication.php +++ b/src/WinterApplication.php @@ -593,13 +593,9 @@ private static function serveHeadless( */ private static function buildServerSettings(ApplicationArguments $args): ServerSettings { - // Precedence: --host/--port ▸ .env ▸ built-in default. The flag wins because a - // one-off override is what a flag is for; `.env` carries the environment's own - // answer, which is what every other SERVER_* setting already does. Passing the - // environment value as the argument default gives exactly that order. $settings = ServerSettings::fromEnv( - $args->option('host', (string) env('SERVER_HOST', '0.0.0.0')) ?? '0.0.0.0', - $args->int('port', (int) env('SERVER_PORT', 8000)), + $args->option('host', '0.0.0.0') ?? '0.0.0.0', + $args->int('port', 8000), ); $c = self::$container; if ($c !== null) { diff --git a/tests/App/ServerBindingTest.php b/tests/App/ServerBindingTest.php deleted file mode 100644 index 670a111..0000000 --- a/tests/App/ServerBindingTest.php +++ /dev/null @@ -1,96 +0,0 @@ - */ - private array $envBackup = []; - - protected function setUp(): void - { - $this->envBackup = $_ENV; - unset($_ENV['SERVER_HOST'], $_ENV['SERVER_PORT']); - } - - protected function tearDown(): void - { - $_ENV = $this->envBackup; - } - - /** @param list $argv */ - private function bind(array $argv): array - { - $settings = new ReflectionMethod(WinterApplication::class, 'buildServerSettings') - ->invoke(null, ApplicationArguments::parse(['call', ...$argv])); - - return [$settings->getHost(), $settings->getPort()]; - } - - public function test_it_falls_back_to_the_built_in_default(): void - { - self::assertSame(['0.0.0.0', 8000], $this->bind(['run'])); - } - - public function test_the_environment_supplies_the_port(): void - { - $_ENV['SERVER_PORT'] = '8003'; - - self::assertSame(['0.0.0.0', 8003], $this->bind(['run'])); - } - - public function test_the_environment_supplies_the_host(): void - { - $_ENV['SERVER_HOST'] = '127.0.0.1'; - - self::assertSame(['127.0.0.1', 8000], $this->bind(['run'])); - } - - public function test_the_flag_wins_over_the_environment(): void - { - $_ENV['SERVER_HOST'] = '127.0.0.1'; - $_ENV['SERVER_PORT'] = '8003'; - - self::assertSame(['0.0.0.0', 9501], $this->bind(['run', '--host=0.0.0.0', '--port=9501'])); - } - - /** - * One of the two may be overridden without disturbing the other. - */ - public function test_the_flag_overrides_only_what_it_names(): void - { - $_ENV['SERVER_HOST'] = '127.0.0.1'; - $_ENV['SERVER_PORT'] = '8003'; - - self::assertSame(['127.0.0.1', 9501], $this->bind(['run', '--port=9501'])); - } - - /** - * `env()` turns a numeric string into an int on its own; the port must survive that - * without being re-parsed into something else. - */ - public function test_a_numeric_environment_value_is_read_as_a_port(): void - { - $_ENV['SERVER_PORT'] = 8003; - - self::assertSame(8003, $this->bind(['run'])[1]); - } -} From 4197df17ff7f26655fe4f8ab4afd85c00e195d9e Mon Sep 17 00:00:00 2001 From: flytachi Date: Tue, 4 Aug 2026 13:41:20 +0500 Subject: [PATCH 56/71] env --- console/Template/Docker/Dockerfile | 11 +++++----- console/Template/Docker/docker-compose.yml | 23 +++++++++++--------- console/Template/Docker/docker/entrypoint.sh | 6 ++--- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/console/Template/Docker/Dockerfile b/console/Template/Docker/Dockerfile index 951b130..cf155cc 100644 --- a/console/Template/Docker/Dockerfile +++ b/console/Template/Docker/Dockerfile @@ -70,10 +70,9 @@ RUN php call cfg completion -if || true COPY docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh -# Documentation only — EXPOSE publishes nothing. The host binding is done by `ports:` -# in docker-compose.yml, and the app binds where SERVER_PORT says. Kept in step with -# both so `docker image inspect` and `docker run -P` report the truth. -ARG SERVER_PORT=8000 -EXPOSE ${SERVER_PORT} - +# No EXPOSE on purpose. It publishes nothing — `ports:` in docker-compose.yml does that, +# and the port itself is decided there too. Baking a number in here would only add one +# that can disagree: `docker compose up` without `--build` reuses the existing image, so +# the recorded port would be whatever the last build happened to use. A metadata line +# that quietly lies is worse than no metadata line. ENTRYPOINT ["/entrypoint.sh"] diff --git a/console/Template/Docker/docker-compose.yml b/console/Template/Docker/docker-compose.yml index 7bce5cc..0572914 100644 --- a/console/Template/Docker/docker-compose.yml +++ b/console/Template/Docker/docker-compose.yml @@ -1,3 +1,11 @@ +# The port lives here and nowhere else. Change this one number and both the published +# port and the port the application binds follow — they are the same anchor below. +# +# The `${SERVER_PORT:-…}` form keeps an escape hatch: set SERVER_PORT in the environment +# (or in .env) and it wins without this file being touched, which is usually what a +# production host wants. +x-port: &port ${SERVER_PORT:-8000} + services: server: container_name: ${COMPOSE_PROJECT_NAME:-app} @@ -5,20 +13,15 @@ services: build: context: . dockerfile: Dockerfile - args: - # Only feeds EXPOSE, which is metadata. The binding below is what publishes. - SERVER_PORT: ${SERVER_PORT:-8000} environment: - - COMPOSE_BAKE=true + COMPOSE_BAKE: "true" # Default (no DEV) → prod: `call run`, tuned opcache on. Opt into dev per run # (no rebuild): DEV=true docker compose up → `call run dev`, opcache off. - - DEV=${DEV:-false} + DEV: ${DEV:-false} # Read by entrypoint.sh, which passes it as `call run --port=…`. - - SERVER_PORT=${SERVER_PORT:-8000} - # One source of truth: SERVER_PORT in .env. Compose reads it here and hands the - # same value to the entrypoint above, so the published port and the bound port - # cannot drift apart. Change it in one place. + SERVER_PORT: *port ports: - - "${SERVER_PORT:-8000}:${SERVER_PORT:-8000}" + - target: *port + published: *port volumes: - ./:/var/www/html/ diff --git a/console/Template/Docker/docker/entrypoint.sh b/console/Template/Docker/docker/entrypoint.sh index 5b1114a..f0ff48a 100644 --- a/console/Template/Docker/docker/entrypoint.sh +++ b/console/Template/Docker/docker/entrypoint.sh @@ -7,9 +7,9 @@ # If you set up a crontab in a docker/dependencies/ script, start crond here: # crond -l 8 -# The port comes from compose, which read it from .env — the same value it published -# with `ports:`. Passing it as a flag keeps the two in step without the application -# needing to know it is running in a container. +# The port arrives from compose's `environment:`, holding the same anchor it published +# with `ports:`. Passing it as a flag keeps the two in step and leaves the application +# unaware it is running in a container — the framework has no Docker-specific setting. PORT="${SERVER_PORT:-8000}" # Opcache is toggled here (runtime, as root before su-exec), so switching dev/prod From 3affd0dea720d9a9b43e8f8029b1f94ce344c984 Mon Sep 17 00:00:00 2001 From: flytachi Date: Tue, 4 Aug 2026 14:15:35 +0500 Subject: [PATCH 57/71] env --- src/ConnectionPool/ConnectionPool.php | 32 ++++- .../CloseOutsideCoroutineTest.php | 133 ++++++++++++++++++ 2 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 tests/ConnectionPool/CloseOutsideCoroutineTest.php diff --git a/src/ConnectionPool/ConnectionPool.php b/src/ConnectionPool/ConnectionPool.php index 2829f18..060ac61 100644 --- a/src/ConnectionPool/ConnectionPool.php +++ b/src/ConnectionPool/ConnectionPool.php @@ -5,6 +5,7 @@ namespace Flytachi\Winter\Kernel\ConnectionPool; use Closure; +use Swoole\Coroutine; use Swoole\Coroutine\Channel; use Throwable; @@ -138,19 +139,40 @@ public function abandon(): void $this->total = 0; } - /** Closes every idle connection and the pool itself (also stops the housekeeper). */ + /** + * Stops the housekeeper, closes every idle connection and the pool itself. + * + * Called outside a coroutine (worker shutdown) only the housekeeper is stopped — + * see the comment in the body for why the connections are left to the kernel. + */ public function close(): void { $this->clearHousekeeper(); if ($this->idle === null) { return; } - while ($this->idle->length() > 0) { - $entry = $this->idle->pop(0.001); - if ($entry instanceof PoolEntry) { - $this->safeClose($entry->resource); + + // Draining needs a coroutine: Channel::pop() is a coroutine API and raises a + // *fatal* outside one — not catchable, the process dies. The caller that closes + // outside a coroutine is Swoole's `workerExit`, which fires while the reactor is + // already winding down; the worker then dies mid-shutdown and, under `run dev`, + // never comes back. + // + // So the drain is skipped there rather than forced. The point of closing at that + // moment is the housekeeping timer above — a live repeating timer keeps the + // reactor from draining at all. The sockets need no ceremony: the process is + // ending, the kernel closes them, and a dropped client is routine for a database. + // Starting a scheduler just to say goodbye politely would risk hanging the very + // exit this is meant to keep clean. + if (Coroutine::getCid() > 0) { + while ($this->idle->length() > 0) { + $entry = $this->idle->pop(0.001); + if ($entry instanceof PoolEntry) { + $this->safeClose($entry->resource); + } } } + $this->idle->close(); $this->idle = null; $this->total = 0; diff --git a/tests/ConnectionPool/CloseOutsideCoroutineTest.php b/tests/ConnectionPool/CloseOutsideCoroutineTest.php new file mode 100644 index 0000000..58c122c --- /dev/null +++ b/tests/ConnectionPool/CloseOutsideCoroutineTest.php @@ -0,0 +1,133 @@ +pop(0.001) + * PpaConnectionPool::shutdown() + * WinterApplication::{closure:serveHttp()} ← workerExit + * + * It only fires when the pool still holds an idle connection, which is why an + * application that never queries never sees it — and why a real one always does. + * + * Draining is skipped there rather than made to work: the process is terminating, so the + * kernel closes the sockets either way, and every database treats a dropped client as + * routine. What must not be skipped is the timer. + */ +final class CloseOutsideCoroutineTest extends TestCase +{ + protected function setUp(): void + { + if (!extension_loaded('swoole')) { + self::markTestSkipped('ConnectionPool needs Swoole.'); + } + } + + /** + * A leaked repeating timer would hang PHP's own shutdown (the reactor waits for it), + * taking the whole suite with it. The assertions run first, so this only decides how + * a failure is reported, not whether it is caught. + */ + protected function tearDown(): void + { + Timer::clearAll(); + } + + /** Fills the pool inside a coroutine, then hands it back for the caller to close. */ + private function pooledWithIdleConnection(MockFactory $factory): ConnectionPool + { + $pool = null; + + \Swoole\Coroutine\run(static function () use ($factory, &$pool): void { + $pool = new ConnectionPool($factory, new PoolPolicy(maximumPoolSize: 4)); + $pool->release($pool->borrow()); + }); + + return $pool; + } + + public function test_closing_a_populated_pool_outside_a_coroutine_does_not_throw(): void + { + $factory = new MockFactory(); + $pool = $this->pooledWithIdleConnection($factory); + + self::assertSame(1, $pool->stats()['idle'], 'The pool must hold something to drain.'); + + $pool->close(); + + $this->addToAssertionCount(1); + } + + /** + * The timer is the whole reason `workerExit` calls this, so it has to be gone even + * when the drain is skipped. + */ + public function test_the_housekeeper_is_released_even_when_the_drain_is_skipped(): void + { + $pool = new ConnectionPool( + new MockFactory(), + new PoolPolicy(maximumPoolSize: 4, housekeepingInterval: 1.0, keepaliveTime: 1.0), + ); + + // Armed the way the first borrow arms it. It cannot be armed inside a coroutine + // here: a repeating timer keeps the reactor alive, so `Coroutine\run()` would + // never return — which is the same property that makes clearing it the whole + // point of closing on worker exit. + new ReflectionMethod(ConnectionPool::class, 'ensureHousekeeper')->invoke($pool); + + $timer = new ReflectionProperty(ConnectionPool::class, 'timerId'); + $timerId = $timer->getValue($pool); + self::assertNotNull($timerId, 'A housekeeping timer is expected.'); + + $pool->close(); + + self::assertNull($timer->getValue($pool)); + self::assertFalse(Timer::exists($timerId), 'The Swoole timer itself must be gone.'); + } + + /** + * Inside a coroutine the drain still happens — skipping it is the exit path's + * concession, not the pool's normal behaviour. + */ + public function test_inside_a_coroutine_the_connections_are_still_closed(): void + { + $factory = new MockFactory(); + + \Swoole\Coroutine\run(static function () use ($factory): void { + $pool = new ConnectionPool($factory, new PoolPolicy(maximumPoolSize: 4)); + $pool->release($pool->borrow()); + $pool->release($pool->borrow()); + $pool->close(); + }); + + self::assertSame($factory->created, $factory->closed, 'Every connection made was closed.'); + } + + public function test_closing_an_empty_pool_outside_a_coroutine_is_harmless(): void + { + $factory = new MockFactory(); + $pool = new ConnectionPool($factory, new PoolPolicy(maximumPoolSize: 4)); + + $pool->close(); + $pool->close(); + + $this->addToAssertionCount(1); + } +} From b6bce558837a496060fe48b2d825038164361ad4 Mon Sep 17 00:00:00 2001 From: flytachi Date: Tue, 4 Aug 2026 17:07:05 +0500 Subject: [PATCH 58/71] env --- CLAUDE.md | 18 ++- docs/configuration/04-health.md | 21 ++++ docs/ppa/17-pool.md | 6 +- src/Ppa/Pool/PoolTelemetry.php | 78 ++++++++++--- src/Ppa/Pool/PpaConnectionPool.php | 7 ++ src/Route/Router.php | 31 +++++- src/WinterApplication.php | 7 +- tests/Ppa/Pool/PoolTelemetryTest.php | 105 ++++++++++++++++++ tests/Route/ActuatorHealthCodeTest.php | 145 +++++++++++++++++++++++++ 9 files changed, 398 insertions(+), 20 deletions(-) create mode 100644 tests/Route/ActuatorHealthCodeTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 0160fb8..0edd4d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -620,8 +620,22 @@ of an interrupted transaction is meaningless. One request fails; the connection `Kernel::runnable('ppa.pool', false)`, the same store indirection `call process status` uses (the CLI is a different process and can never see a worker's memory). Interval via `PPA_POOL_TELEMETRY` (default 5s, `0` = off); records carry a TTL of - three intervals so a dead worker's record expires by itself; a worker holding no pool - writes nothing. + three intervals so a dead worker's record expires by itself. + + **Nothing is armed until there is something to report.** `workerStart` only calls + `PoolTelemetry::enable($workerId)` (marks the worker eligible); the timer starts from + `PpaConnectionPool::pool()` on the **first pool**, via `arm()` — the same lazy shape + the pool uses for its own housekeeper. `arm()` is a no-op where `enable()` never ran, + which is what keeps a daemon worker or a CLI process from publishing. `stop()` reaches + for the store only if something was actually written (`$published`), and + `PpaConnectionPool::reset()` calls `forget()` so a forked child cannot publish under + its parent's worker id. + + That last pair is a fix, not decoration: `stop()` used to guard on "is the timer + armed", which was true in *every* worker, so the shutdown path called + `store()->del()` — and `new FileStorage(...)` mkdirs its folder — leaving an empty + `storage/runnable/ppa.pool/` in applications with no datasource at all. Do not + re-guard on the timer. Numbers are **per worker** (each has its own pool, like HikariCP per-JVM). Saturation is therefore counted per worker, never derived from fleet sums — a summed pool can look diff --git a/docs/configuration/04-health.md b/docs/configuration/04-health.md index 1699509..f97e982 100644 --- a/docs/configuration/04-health.md +++ b/docs/configuration/04-health.md @@ -104,6 +104,27 @@ Source: `src/Route/Router.php` (`registerHealth()`). Aggregation rule: any `down` → overall `down`; otherwise any `degraded` → overall `degraded`; otherwise `up`. +### Response code + +The overall status is also the response code, so a probe that reads the code rather than the body reaches the same verdict: + +| Overall status | Code | +|---|---| +| `up` | `200` | +| `degraded` | `200` | +| `down` | `503 Service Unavailable` | + +`degraded` stays `200` on purpose: it means working worse, not not working, and a readiness probe that pulled the instance out of rotation over it would turn a partial outage into a full one. The body is the same in either case — the code carries no information the report does not. + +Only `/actuator/health` is affected. The other endpoints answer `200`, including one whose payload happens to contain a `status` key of its own. + +This makes the endpoint usable directly as a container health check or a k8s probe: + +```yaml +readinessProbe: + httpGet: { path: /actuator/health, port: 8000 } +``` + ### Disk / memory thresholds Built into `HealthIndicator`: diff --git a/docs/ppa/17-pool.md b/docs/ppa/17-pool.md index 4501571..dfe5dea 100644 --- a/docs/ppa/17-pool.md +++ b/docs/ppa/17-pool.md @@ -140,7 +140,11 @@ per worker The CLI is a separate process and cannot read a running server's memory, so each worker publishes its stats to the shared store on a timer — `PPA_POOL_TELEMETRY` (seconds, default `5`, `0` disables). Records carry a TTL of three intervals, so a worker that -stops simply expires; a worker holding no pool writes nothing at all. +stops simply expires. + +The publisher starts on the worker's **first pool**, not at worker start. An application +with no datasource runs no timer, writes no record and creates no store directory — it +pays nothing for a subsystem it never uses. The same numbers appear in the `db` component of `/actuator/health`, nested under the datasource they belong to, where a saturated pool marks it `degraded`. diff --git a/src/Ppa/Pool/PoolTelemetry.php b/src/Ppa/Pool/PoolTelemetry.php index 00c6df0..45bee11 100644 --- a/src/Ppa/Pool/PoolTelemetry.php +++ b/src/Ppa/Pool/PoolTelemetry.php @@ -27,6 +27,11 @@ * holding no pool writes nothing at all, so an application that never touches PPA * pays exactly zero. * + * Nothing is armed until there is something to report: {@see enable()} only marks the + * worker as eligible, and the timer starts on the first pool ({@see arm()}, called by + * {@see PpaConnectionPool}) — the same lazy shape the pool uses for its own housekeeper. + * An application with no datasource therefore has no timer and leaves no directory. + * * Set `PPA_POOL_TELEMETRY` to the publish interval in seconds, or `0` to disable. */ final class PoolTelemetry @@ -40,6 +45,16 @@ final class PoolTelemetry /** Swoole timer id of the publisher in this worker, or null when not running. */ private static ?int $timerId = null; + /** The worker this process publishes as, or null where telemetry does not apply. */ + private static ?int $workerId = null; + + /** + * Whether a record was ever written from this process — the only thing that + * justifies touching the store on the way out. Reaching for it otherwise would + * create the store directory in an application that has no pool at all. + */ + private static bool $published = false; + /** * Publish interval in seconds from `PPA_POOL_TELEMETRY`; `0.0` disables telemetry. * Values below one second are raised to one — this is telemetry, not a heartbeat. @@ -53,18 +68,39 @@ public static function interval(): float } /** - * Starts publishing this worker's pool utilisation. Call once per worker (from the - * server's `workerStart`); a no-op when telemetry is disabled, Swoole is absent, or - * this worker already publishes. + * Marks this worker as eligible to publish. Call once per worker (from the server's + * `workerStart`); a no-op when telemetry is disabled or Swoole is absent. + * + * This arms nothing. Only a process that goes on to open a pool has anything to + * report, and only that process should own a timer — see {@see arm()}. + */ + public static function enable(int $workerId): void + { + if (self::interval() <= 0.0 || !extension_loaded('swoole')) { + return; + } + + self::$workerId = $workerId; + } + + /** + * Starts the publisher, if this process is an eligible worker and is not already + * publishing. Called by {@see PpaConnectionPool} when it opens its first pool — + * the first moment there is anything to report. */ - public static function start(int $workerId): void + public static function arm(): void { $interval = self::interval(); - if (self::$timerId !== null || $interval <= 0.0 || !extension_loaded('swoole')) { + if (self::$timerId !== null + || self::$workerId === null + || $interval <= 0.0 + || !extension_loaded('swoole') + ) { return; } - $ttl = (int) ceil($interval * 3); + $workerId = self::$workerId; + $ttl = (int) ceil($interval * 3); self::$timerId = \Swoole\Timer::tick( (int) ($interval * 1000), static fn() => self::publish($workerId, $ttl), @@ -74,16 +110,16 @@ public static function start(int $workerId): void /** Stops publishing and drops this worker's record. */ public static function stop(int $workerId): void { - if (self::$timerId === null) { - // Never published, so there is no record to drop — and asking for the store - // would create its directory in an application that has no pool at all. - return; + if (self::$timerId !== null && extension_loaded('swoole')) { + \Swoole\Timer::clear(self::$timerId); } + self::$timerId = null; + self::$workerId = null; - if (extension_loaded('swoole')) { - \Swoole\Timer::clear(self::$timerId); + if (!self::$published) { + return; } - self::$timerId = null; + self::$published = false; try { self::store()->del(self::recordKey($workerId)); @@ -92,6 +128,21 @@ public static function stop(int $workerId): void } } + /** + * Drops this process's publishing identity without touching the store — the + * fork-safe counterpart of {@see stop()}, called from + * {@see PpaConnectionPool::reset()}. + * + * A forked child inherits these statics, so without this it would publish under its + * parent's worker id and overwrite the parent's record with its own numbers. + */ + public static function forget(): void + { + self::$timerId = null; + self::$workerId = null; + self::$published = false; + } + /** * Reads every worker record still alive, newest state as each worker last published * it. Expired records (dead workers) are skipped by the store's TTL. @@ -172,6 +223,7 @@ private static function publish(int $workerId, int $ttl): void ['worker' => $workerId, 'at' => time(), 'pools' => $pools], time() + $ttl, ); + self::$published = true; } catch (\Throwable) { // Telemetry is best-effort: a failed write must never disturb the worker. } diff --git a/src/Ppa/Pool/PpaConnectionPool.php b/src/Ppa/Pool/PpaConnectionPool.php index de5b38d..aca56ef 100644 --- a/src/Ppa/Pool/PpaConnectionPool.php +++ b/src/Ppa/Pool/PpaConnectionPool.php @@ -273,6 +273,10 @@ public static function reset(): void self::$pools = []; self::$static = []; self::$configs = []; + + // The child inherited the parent's publishing identity along with everything + // else; keeping it would let the child overwrite the parent's telemetry record. + PoolTelemetry::forget(); } // ------------------------------------------------------------------------- @@ -391,6 +395,9 @@ private static function pool(string $configClass): ConnectionPool new CdoConnectionFactory($configClass, self::logger()), $policy, ); + + // First pool in this worker — from here on there is something to report. + PoolTelemetry::arm(); } return self::$pools[$key]; } diff --git a/src/Route/Router.php b/src/Route/Router.php index 2209c10..93718f2 100644 --- a/src/Route/Router.php +++ b/src/Route/Router.php @@ -26,6 +26,7 @@ use Flytachi\Winter\Kernel\Http\Cors; use Flytachi\Winter\Kernel\Http\Health\Health; use Flytachi\Winter\Kernel\Http\Health\HealthIndicatorInterface; +use Flytachi\Winter\Kernel\Http\Health\Status; use Flytachi\Winter\Kernel\Plugin; use Flytachi\Winter\Kernel\Route\Collector\MappingCollector; use Flytachi\Winter\Kernel\Http\Stereotype\Middleware; @@ -352,13 +353,41 @@ private function registerHealth(string $indicatorClass, ?string $middlewareClass throw new ResponseException('Actuator endpoint not found', HttpCode::NOT_FOUND); } - return ResponseEntity::ok($indicator->{$method}()); + $body = $indicator->{$method}(); + + return ResponseEntity::status(self::healthCode($method, $body))->body($body); }; $this->add('GET', '/actuator', $handler, $middlewares); $this->add('GET', '/actuator/{method}', $handler, $middlewares); } + /** + * The response code carrying the health verdict: `down` → 503, everything else → 200. + * + * Without this the endpoint answered 200 while reporting `status: down` inside, so + * every consumer that reads the code rather than the body — a container health check, + * a k8s liveness/readiness probe, a load balancer — saw a dead application as healthy. + * + * `degraded` deliberately stays 200: it means working worse, not not working, and a + * probe that pulls the instance out of rotation over it would turn a partial outage + * into a full one. + * + * Only `health` reports a status; `info`, `metrics` and the rest are plain reads. + */ + private static function healthCode(string $method, mixed $body): HttpCode + { + if ($method !== 'health' || !is_array($body)) { + return HttpCode::OK; + } + $status = $body['status'] ?? null; + $status = $status instanceof Status ? $status->value : $status; + + return is_string($status) && strtolower($status) === Status::Down->value + ? HttpCode::SERVICE_UNAVAILABLE + : HttpCode::OK; + } + // ── Dispatch ────────────────────────────────────────────────────────────── /** diff --git a/src/WinterApplication.php b/src/WinterApplication.php index 237b2af..57a515a 100644 --- a/src/WinterApplication.php +++ b/src/WinterApplication.php @@ -459,12 +459,13 @@ static function () use ($class): void { $router->handle($request, new SwooleResponse($res, $isHead)); }; - // Request workers log on 'http' with per-request coroutine isolation, and - // publish their connection-pool utilisation so `call db pool` can read it. + // Request workers log on 'http' with per-request coroutine isolation, and are + // marked eligible to publish their connection-pool utilisation for + // `call db pool` — the publisher itself only starts if a pool is ever opened. $workerStart = static function (\Swoole\Http\Server $server, int $workerId): void { LoggerFactory::setContextStorage(new CoroutineContext()); LoggerFactory::setDefaultChannel('http'); - PoolTelemetry::start($workerId); + PoolTelemetry::enable($workerId); }; // A worker cannot leave while its reactor still holds a repeating timer, so a diff --git a/tests/Ppa/Pool/PoolTelemetryTest.php b/tests/Ppa/Pool/PoolTelemetryTest.php index df27c4e..dac1be0 100644 --- a/tests/Ppa/Pool/PoolTelemetryTest.php +++ b/tests/Ppa/Pool/PoolTelemetryTest.php @@ -36,10 +36,18 @@ protected function setUp(): void // Kernel caches FileStorage by name against the path it was first built with. $this->clearRunnableCache(); + PoolTelemetry::forget(); } protected function tearDown(): void { + // An armed repeating timer would keep the reactor — and PHP's own shutdown — + // from ever draining, hanging the suite. Assertions have already run. + if (extension_loaded('swoole')) { + \Swoole\Timer::clearAll(); + } + PoolTelemetry::forget(); + if ($this->originalEnv === null) { unset($_ENV['PPA_POOL_TELEMETRY']); } else { @@ -185,4 +193,101 @@ public function test_publish_writes_nothing_when_the_worker_holds_no_pool(): voi self::assertSame([], PoolTelemetry::snapshot(), 'an app that never touches PPA leaves no records'); } + + // ── Lifecycle: nothing is paid for until a pool exists ────────────────────── + + private function timerId(): ?int + { + return new ReflectionProperty(PoolTelemetry::class, 'timerId')->getValue(); + } + + /** The store directory is created by the mere act of asking for the store. */ + private function storeDirExists(): bool + { + return is_dir($this->storage . '/ppa.pool'); + } + + public function test_enable_arms_no_timer_on_its_own(): void + { + $this->requireSwoole(); + + PoolTelemetry::enable(0); + + self::assertNull($this->timerId(), 'a worker with no datasource must not run a publisher'); + } + + public function test_the_publisher_starts_when_a_pool_appears(): void + { + $this->requireSwoole(); + PoolTelemetry::enable(0); + $armed = null; + + // Inside a coroutine, where a worker really opens its first pool. The timer is + // released before the closure ends: a live repeating timer holds the reactor + // open and `Coroutine\run()` would never return. + \Swoole\Coroutine\run(function () use (&$armed): void { + PoolTelemetry::arm(); + $armed = $this->timerId(); + PoolTelemetry::stop(0); + }); + + self::assertNotNull($armed, 'the first pool is the first thing worth reporting'); + } + + /** A daemon worker or a CLI process opens pools too, and publishes for nobody. */ + public function test_arming_without_an_eligible_worker_does_nothing(): void + { + $this->requireSwoole(); + + PoolTelemetry::arm(); + + self::assertNull($this->timerId()); + } + + public function test_a_forked_child_does_not_inherit_the_publishing_identity(): void + { + $this->requireSwoole(); + PoolTelemetry::enable(0); + + PoolTelemetry::forget(); + PoolTelemetry::arm(); + + self::assertNull($this->timerId(), 'the child would otherwise overwrite its parent record'); + } + + /** + * The regression this pair of fixes is really about: a worker that never published + * must not reach for the store on the way out. Asking for it creates the directory, + * and an empty `runnable/ppa.pool/` reads as "this application uses PPA". + */ + public function test_a_worker_that_never_published_leaves_no_directory_behind(): void + { + $this->requireSwoole(); + PoolTelemetry::enable(0); + + \Swoole\Coroutine\run(static function (): void { + PoolTelemetry::arm(); + PoolTelemetry::stop(0); + }); + + self::assertNull($this->timerId(), 'the timer is released either way'); + self::assertFalse($this->storeDirExists(), 'nothing was written, so nothing is asked for'); + } + + public function test_stop_drops_the_record_of_a_worker_that_did_publish(): void + { + $this->publishRecord(0, ['App\\Config\\MainDb' => ['total' => 1, 'idle' => 1, 'active' => 0, 'maximum' => 5]]); + new ReflectionProperty(PoolTelemetry::class, 'published')->setValue(null, true); + + PoolTelemetry::stop(0); + + self::assertSame([], PoolTelemetry::snapshot(), 'a worker leaving takes its record with it'); + } + + private function requireSwoole(): void + { + if (!extension_loaded('swoole')) { + self::markTestSkipped('The publisher is a Swoole timer.'); + } + } } diff --git a/tests/Route/ActuatorHealthCodeTest.php b/tests/Route/ActuatorHealthCodeTest.php new file mode 100644 index 0000000..9c8eb9d --- /dev/null +++ b/tests/Route/ActuatorHealthCodeTest.php @@ -0,0 +1,145 @@ +invoke($router, StubIndicator::class, null); + + return $router; + } + + private function get(string $uri): FakeResponse + { + $response = new FakeResponse(); + $this->actuator()->handle(new FakeRequest('GET', $uri), $response); + + return $response; + } + + protected function tearDown(): void + { + StubIndicator::$report = ['status' => 'up', 'components' => []]; + } + + public function test_down_answers_503(): void + { + StubIndicator::$report = ['status' => 'down', 'components' => []]; + + $response = $this->get('/actuator/health'); + + self::assertSame(503, $response->status); + self::assertSame('down', $response->json()['status'], 'the body still carries the report'); + } + + public function test_degraded_answers_200(): void + { + StubIndicator::$report = ['status' => 'degraded', 'components' => []]; + + self::assertSame(200, $this->get('/actuator/health')->status); + } + + public function test_up_answers_200(): void + { + self::assertSame(200, $this->get('/actuator/health')->status); + } + + /** `/actuator` falls through to the health method, so it must carry the verdict too. */ + public function test_the_bare_actuator_route_answers_503_when_down(): void + { + StubIndicator::$report = ['status' => 'down', 'components' => []]; + + self::assertSame(503, $this->get('/actuator')->status); + } + + /** + * Only health reports a verdict. Another endpoint may use the word `status` for + * something of its own — a deployment state, a licence, a queue — and that must not + * be read as "the application is down". + */ + public function test_a_status_key_on_another_endpoint_is_not_a_verdict(): void + { + self::assertSame('down', new StubIndicator()->info()['status'], 'the fixture must bait it'); + + self::assertSame(200, $this->get('/actuator/info')->status); + } + + /** A custom indicator may hand back the enum rather than its value. */ + public function test_a_status_enum_is_understood(): void + { + StubIndicator::$report = ['status' => Status::Down, 'components' => []]; + + self::assertSame(503, $this->get('/actuator/health')->status); + } + + public function test_a_report_without_a_status_stays_200(): void + { + StubIndicator::$report = ['components' => []]; + + self::assertSame(200, $this->get('/actuator/health')->status); + } +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +/** An indicator whose health report the test dictates. */ +final class StubIndicator implements HealthIndicatorInterface +{ + public static array $report = ['status' => 'up', 'components' => []]; + + public function health(): array + { + return self::$report; + } + + /** Carries a `status` of its own — the bait for the health-only check. */ + public function info(): array + { + return ['framework' => 'winter', 'status' => 'down']; + } + + public function metrics(): array + { + return []; + } + + public function env(): array + { + return []; + } + + public function loggers(): array + { + return []; + } + + public function mappings(): array + { + return []; + } +} From 02b8df960fa500ac5869b4543c7cc4ca257ecc1d Mon Sep 17 00:00:00 2001 From: flytachi Date: Wed, 5 Aug 2026 01:14:43 +0500 Subject: [PATCH 59/71] context req --- console/Template/Docker/docker-compose.yml | 6 +- doc/STATUS.md | 225 +++++++++++++++++- docs/architecture/02-middleware.md | 23 +- docs/configuration/01-kernel.md | 14 +- docs/configuration/08-runtime.md | 17 ++ src/Core/RequestLocal.php | 101 ++++++++ .../Middleware/ClientTimezoneMiddleware.php | 41 +++- src/Localization/Timezone.php | 71 ++++++ src/Ppa/Pool/PpaConnectionPool.php | 56 ++++- tests/Core/RequestLocalTest.php | 137 +++++++++++ tests/Localization/TimezoneTest.php | 134 +++++++++++ tests/Ppa/Pool/PoolTimezoneTest.php | 180 ++++++++++++++ 12 files changed, 972 insertions(+), 33 deletions(-) create mode 100644 src/Core/RequestLocal.php create mode 100644 src/Localization/Timezone.php create mode 100644 tests/Core/RequestLocalTest.php create mode 100644 tests/Localization/TimezoneTest.php create mode 100644 tests/Ppa/Pool/PoolTimezoneTest.php diff --git a/console/Template/Docker/docker-compose.yml b/console/Template/Docker/docker-compose.yml index 0572914..94b0252 100644 --- a/console/Template/Docker/docker-compose.yml +++ b/console/Template/Docker/docker-compose.yml @@ -8,6 +8,7 @@ x-port: &port ${SERVER_PORT:-8000} services: server: + tty: true container_name: ${COMPOSE_PROJECT_NAME:-app} network_mode: bridge build: @@ -15,11 +16,8 @@ services: dockerfile: Dockerfile environment: COMPOSE_BAKE: "true" - # Default (no DEV) → prod: `call run`, tuned opcache on. Opt into dev per run - # (no rebuild): DEV=true docker compose up → `call run dev`, opcache off. - DEV: ${DEV:-false} - # Read by entrypoint.sh, which passes it as `call run --port=…`. SERVER_PORT: *port + DEV: ${DEV:-false} ports: - target: *port published: *port diff --git a/doc/STATUS.md b/doc/STATUS.md index 044086c..2ea220d 100644 --- a/doc/STATUS.md +++ b/doc/STATUS.md @@ -4,7 +4,7 @@ > `winter-application-flow.md`): их решения приняты и перенесены в `CLAUDE.md` §15, > поток загрузки описан там же. Здесь остаётся только сухой срез. > -> Обновлено: 2026-08-02. +> Обновлено: 2026-08-04. --- @@ -22,10 +22,50 @@ **Actuator** — `#[EnableActuator]` + `HealthContributor`; в компоненте `db` теперь вложена утилизация пула. +> Вердикт доехал до кода ответа (2026-08-04): `down` → **503**, `up`/`degraded` → 200. +> Раньше `/actuator/health` отдавал 200 всегда, и всё, что смотрит на код, а не на тело — +> HEALTHCHECK контейнера, liveness/readiness в k8s, балансировщик — считало мёртвое +> приложение здоровым; под с недоступной базой не выводился из ротации никогда. +> `degraded` намеренно остаётся 200: это «работает хуже», а не «не работает», и проба, +> убирающая инстанс из ротации по нему, превращает частичную деградацию в полную. +> Затрагивает только `health`: у прочих эндпоинтов `status` может значить своё. +> `Router::healthCode()`, `tests/Route/ActuatorHealthCodeTest.php`. +> +> **Ломающее** для того, кто парсит тело при 200: клиент с `throwOnError` теперь получит +> исключение вместо отчёта. +> +> Решения по актуатору: `call health` **не делаем** — CLI это другой процесс, компонент +> `db` открыл бы своё соединение, а пул показал бы нули (память воркера), то есть команда +> отвечала бы не на тот вопрос; живые числа по пулу уже даёт `call db pool` через стор. +> Отдельный порт в `#[EnableActuator(port)]` — не планируется. + **Пул соединений** (`src/ConnectionPool/`) — idle-gate + `maxLifetime`, фоновый housekeeper (keepalive / idleTimeout / minimumIdle, opt-in), evict при потере соединения **без retry**, телеметрия + `call db pool`. Проверено на живых PostgreSQL и MariaDB. +> Закрытие пула на выходе воркера (2026-08-04): `ConnectionPool::close()` дренировал +> канал через `Channel::pop()` — корутинный API, который **вне корутины поднимает фатал, +> не ловящийся `try/catch`**. Вызывает его `workerExit`, то есть уже сворачивающийся +> реактор, и воркер умирал посреди выключения; под `run dev` watcher после правки кода не +> дожидался перезапуска. Срабатывало при любом непустом пуле, но в проде выход воркера — +> это остановка контейнера, поэтому в глаза не бросалось. Теперь дренаж вне корутины +> пропускается, а таймер housekeeper снимается всегда — ради него закрытие и делается +> (живой повторяющийся тик не даёт реактору слиться). Сокеты закрывает ядро при выходе +> процесса. `tests/ConnectionPool/CloseOutsideCoroutineTest.php`. + +> Телеметрия пула стала ленивой (2026-08-04): `workerStart` теперь только помечает +> воркер как имеющий право публиковать (`PoolTelemetry::enable()`), а таймер заводится на +> **первом пуле** из `PpaConnectionPool::pool()` (`arm()`) — той же формой, что и +> housekeeper пула. Раньше таймер армировался в каждом воркере независимо от наличия БД, +> и `stop()` на выходе проверял «армирован ли таймер» вместо «писали ли мы» — доходил до +> `store()->del()`, а конструктор `FileStorage` делает `mkdir`. В итоге приложение вообще +> без датасорса оставляло после себя пустой `storage/runnable/ppa.pool/`. Замечено на +> bench'е, где не было ни репозиториев, ни конфигов БД. Цена самого таймера при этом +> мизерная (0.054 мкс на тик, 0.93 мс CPU в сутки на воркер) — чинилось не ради неё, а +> ради того, что PHPDoc обещал «pays exactly zero», и это было неправдой. +> `PpaConnectionPool::reset()` дополнительно зовёт `forget()`, иначе форкнутый ребёнок +> публиковал бы под worker id родителя. + **Раскладка** — `public/` удалён из ядра; статику отдаёт Swoole (`staticPath()`); `resources/{static,views}`; ядро не ссылается на внешние ассеты. @@ -42,8 +82,7 @@ housekeeper (keepalive / idleTimeout / minimumIdle, opt-in), evict при пот остался отдельным корнем); точки расширения собраны в `<Слой>/Stereotype/`; закрыты `final` 96 классов в `src/` плюс 13 консольных команд и `Console\Core`, открытыми осознанно остались 25 (17 исключений категорией + 8 поимённо с причинами). Держится -четырьмя архитектурными тестами. Спека — `doc/2026-08-02-restructure-design.md`, -план — `-plan.md`. +четырьмя архитектурными тестами. Спека — `doc/2026-08-02-restructure-design.md`. > Для проектов: после обновления нужен **`composer update flytachi/winter-kernel`**, а не > только `dump-autoload` — карта автозагрузки берётся из `vendor/composer/installed.json`, @@ -78,28 +117,192 @@ housekeeper (keepalive / idleTimeout / minimumIdle, opt-in), evict при пот > `PRIMARY KEY (id)` (как генерит движок) rowid-алиасу не мешает — проверено, ломать > структуру не пришлось. +**Кеш загрузки: разобрано и отклонено** (2026-08-02) — идея кешировать результаты +коллекторов и грузить классы лениво **не делается** до смены профиля исполнения. +Замеры на синтетике под худший случай (400 классов по 560 строк), медиана из 9 прогонов, +профиль прода (`opcache.enable_cli=1`, `validate_timestamps=0`): + +| | без opcache | с opcache | +|---|---:|---:| +| загрузка + рефлексия 400 классов | 73.2 мс | 86.3 мс | +| память процесса | 52.3 MiB | 7.7 MiB | + +Время с opcache не падает, а слегка растёт: опкоды берутся из разделяемой памяти, но +связывание классов выполняется в каждом процессе и не кешируется. Память падает в 7 раз — +самая дешёвая оптимизация во всём разборе, и она уже в шаблоне Docker. + +Мотивацию ломает то, что воркеры **форкаются от мастера**: `bootstrap()` отрабатывает до +`$server->start()`, и рестарт воркера по `max_request` загрузку не повторяет (мастер +35.9 MiB, воркер 8.8 MiB через COW). То есть 86 мс платятся один раз за старт сервера, а +не за воркер. Ленивая загрузка при этом не убирает работу, а переносит её в первые +запросы после деплоя — ухудшение p99 ровно на холодном воркере. Прогрев к тому же уже +есть и документирован как шаг деплоя: `call di build` / `clean` / `show`. + +Пересчитать имеет смысл, если вернётся FPM (`winter-fpm`: там загрузка на каждый запрос), +появится частый холодный старт (serverless), или старт сервера перевалит за ~1 с с +загрузкой классов больше половины этого времени. + +Три находки из того же разбора **сделаны**: `Scanner` исключает `Kernel::$pathStorage` +(иначе он обходил `storage/volatile/` с `di.php` и сгенерированными `#[Async]`-прокси); +исправлено `docs/configuration/01-kernel.md` про умолчание `isTmpVolatile` (в коде +`true`, документация утверждала обратное); `opcache.enable_cli=1` описан в `docs/`, а не +только в комментарии к ini. + --- ## 🟡 Висит | Пункт | Состояние | |---|---| -| **`call health`** | команды нет; актуатор доступен только по HTTP | -| **`#[EnableActuator(port)]`** | атрибут принимает `middleware` и `indicator`, отдельного порта нет | | **WebSocket** | `Component::websocket()` существует, движка за ним нет | | **Starter-autoconfig** | только явный `#[Import]`; авто-подключение через `composer.json extra.winter` не делалось | | **FPM** | из ядра не обслуживается; адаптеры (`FpmRequest`/`FpmResponse`) на месте и покрыты тестами — основа для отдельного `winter-fpm` | -### SQLite: что осталось (следующий заход) +### Границы ресурсов запроса (решено делать, 2026-08-04) + +Поводом стал `Fatal error: Allowed memory size of 134217728 bytes exhausted` под стрессом. +Разбор показал: приложение строило 500 000 сущностей в памяти и отдавало их +`insertGroup(...$entities)`. Замеры одного такого запроса: + +| Шаг | Память | +|---|---:| +| 500k объектов в массиве | 72.0 MiB | +| распаковка в вариадик `...$entities` | +15.8 MiB | +| `CDO::groupRowsBySignature()` — объект → hash-массив на строку | **+352.3 MiB** | +| **удерживается одновременно** | **440 MiB** (пик процесса 444 MiB) | + +Тот же объём батчами по 1000 — **пик 4.0 MiB**. Ключевое: `CDO::insertGroup()` уже +чанкует по 1000, но чанкует **SQL, а не память** — `groupRowsBySignature()` +материализует все строки до первого запроса к базе. + +**1. `winter-cdo`: `iterable`/`yield` в `insertGroup`.** Сигнатура +`insertGroup(array|object ...$entities)` — вариадик, генератор в неё не передать, то есть +она *вынуждает* держать всё в памяти. Приём `iterable` с батчингом внутри сделает пик +O(батч) вместо O(всего) без переписывания прикладного кода. Правка в соседнем репозитории +плюс метод в `RepositoryCrudTrait`. + +**2. Ядро: границы запроса** — контроль памяти, RPS и максимального времени запроса +(в FPM это `max_execution_time=60` из коробки, под Swoole такого нет). Что уже выяснено, +чтобы не начинать с тупика: + +- **`max_execution_time` под Swoole не работает**: CLI SAPI даёт `0`, ограничения нет + вообще. Запрос может висеть вечно. +- **`max_request_execution_time` в Swoole 6.2 — `unsupported option`** (проверено, сервер + печатает warning). Так что таймаут запроса придётся делать самим: сторожевой таймер на + корутину запроса с `Coroutine::cancel`, а не настройкой сервера. +- **Что сервер принимает** (проверено на живом `Swoole\Http\Server` 6.2.0): + `worker_max_concurrency` (потолок одновременных запросов в воркере — от пика памяти), + `max_conn`, `max_request` / `max_request_grace` (уже есть в `ServerSettings`), + `package_max_length`. +- **`memory_limit` ядро не выставляет и не должно** — он на воркер, а память контейнера + это `worker_num × memory_limit` + мастер + разделяемая память opcache; ядро не знает ни + того, ни другого. Отдельный вопрос — вписать его явно в шаблон Docker с этой формулой в + комментарии, чтобы ручка перестала быть невидимым дефолтом PHP (сейчас 128M именно так и + обнаруживается — по фаталу). + +### ✅ Часовой пояс запроса: корутино-безопасен (сделано 2026-08-05) -Схема создаётся и работает, но добито не всё: +**Было.** `ClientTimezoneMiddleware::before()` звал `date_default_timezone_set()` — +**глобальную переменную процесса**. Под Swoole все одновременные запросы воркера её делят, +поэтому пояс одного пользователя утекал в другой. Замерено на живом рантайме: + +``` +запрос B (Europe/London): выставил Europe/London +запрос A (Asia/Tashkent): до I/O Asia/Tashkent → после I/O Europe/London ← чужой пояс +``` + +Запрос A выставил свой пояс, ушёл на I/O, запрос B за это время выставил свой — A после +возобновления читает чужой. Дальше это уезжает в два места: `date()`/`DateTime` в хендлере +после первого yield и **сессия БД** — `PpaConnectionPool::coroutineDb()` читает +`date_default_timezone_get()` в момент запроса (`src/Ppa/Pool/PpaConnectionPool.php:360-363`). + +Тот же класс, что `#[Singleton]`, держащий `#[Request]`: молча, без ошибки, видно только +когда у одновременных пользователей **разные** пояса. В приложении с единым поясом невидимо. + +**Стало.** Три новых/изменённых места: + +- **`src/Core/RequestLocal.php`** — примитив хранения «значение на единицу работы», аналог + `ThreadLocal` из JDK. Внутри корутины кладёт в её контекст, вне — в статику (один запрос + на процесс), и вызывающему не нужно знать, где он исполняется. Это **механизм**; поверх + него полагается делать типизированные фасады, а не разбрасывать строковые ключи. + Имя выбрано осознанно: `RequestScope` уже занят DI-скоупом (`#[Request]`, + `flushRequestScope()`), а слово `Context` в кодовой базе занято пять раз + (`CoroutineContext`, `ProcessContext`, `RenderContext`, `AuthContext`, `ContextStorage` + в логгере). +- **`src/Localization/Timezone.php`** — фасад поверх него: `set()`, `current()` с падением + на `env('TIME_ZONE', 'UTC')`, `isSet()`, `reset()`. +- **`ClientTimezoneMiddleware`** кладёт пояс в `Timezone` (источник правды) **и** по-прежнему + зовёт `date_default_timezone_set()` — как удобство для неадаптированного кода, с явным + предупреждением в PHPDoc и документации. Убирать глобаль не стали: это молча изменило бы + то, что возвращает `date()` в существующих хендлерах. +- **`PpaConnectionPool::syncTimezone()`** читает `Timezone::current()` вместо глобали. + +**Что сделать корутино-локальным нельзя.** `date()` и `new DateTime()` без явной зоны +читают глобаль движка PHP. Это устройство языка, ядро тут бессильно — поэтому в документации +прямо сказано: где ответ должен принадлежать запросившему пользователю, зону передавать +явно через `Timezone::current()`. + +**Заодно сделана дешёвая оптимизация.** `SET TIMEZONE` шлётся на **каждый** вызов `db()`, и +это **не избыточность**: вызов намеренно вынесен из блока заимствования, потому что +соединение из пула переходит между пользователями и пояс предыдущего нельзя оставлять +следующему. Сокращение сделано иначе — `\WeakMap`, ключом которого выступает сам пулируемый +объект конфига, помнит последний применённый пояс, и команда уходит только при отличии. +Корректно всегда, включая смену пояса в середине запроса; убирает 2 обращения к базе из 6, +когда пояс у всех один. + +**Тесты** (+20, всего 1688): `tests/Core/RequestLocalTest.php` — изоляция корутин, включая +исходный сценарий Ташкент/Лондон, и отдельность статики от контекста; +`tests/Localization/TimezoneTest.php` — умолчания, плюс тест, фиксирующий, что глобаль PHP +**по-прежнему** общая (если он однажды упадёт, значит PHP сделал пояс корутино-локальным и +фасад можно упростить); `tests/Ppa/Pool/PoolTimezoneTest.php` — в сессию уходит зона запроса, +а не глобали, memo не шлёт дважды одно и то же и не проглатывает смену. Проверено пятью +мутациями, каждая ловится. + +> Замер на `stress-rp` (`POOL-REPORT-SMALL-1W.md`): на один HTTP-запрос база выполняет +> ~5.9 транзакций — 2 × `SET TIMEZONE`, `SELECT COUNT(*)`, `SELECT` страницы, изредка +> `SELECT 1` (проба пула), плюс повторные `PREPARE`. + +**Кеш подготовленных выражений — разобрано и отклонено (2026-08-05).** Подготовленные +выражения не переиспользуются: `ATTR_EMULATE_PREPARES = false` для pgsql, а +`RepositoryViewTrait` зовёт `prepare()` на каждый запрос, поэтому один и тот же SQL база +разбирает заново. Проверено на пуле в одно соединение: 10 запросов → 21 имя `pdo_stmt_`, +номера растут монотонно, каждое использовано ровно один раз. + +**Утечки при этом нет** — RSS backend'а PostgreSQL после 20 000 подготовок: 27 044 → +27 172 → 27 172 kB. PDO освобождает их корректно. + +Цена — постоянные **0.25–0.28 мс на обращение к базе** (замер: `SELECT 1` 0.402 → 0.121 мс, +точечный SELECT 0.365 → 0.121, тяжёлый `COUNT(*)` 1.954 → 1.667). То есть на аналитике это +15 %, на точечном CRUD-запросе — две трети времени. + +**Решено не делать до релиза:** кеширование такого рода — задача прикладного разработчика, +а не ядра. Если возвращаться, условие обязательное: кеш ключуется текстом SQL, а +`RepositoryCore.php:262,265` вклеивает `LIMIT`/`OFFSET` **в текст** — такой запрос в кеш не +попадёт никогда и будет только его засорять. Значит сначала биндинг `LIMIT`/`OFFSET` +параметрами, и только потом кеш. + +### SQLite: что осталось (отложено 2026-08-04) + +**Отложено сознательно** — SQLite в winter не основной диалект, а объём работы великоват +для «дополнения». Разбор проведён до конца, всё ниже **проверено исполнением** живого +DDL, а не чтением кода. Прошлая редакция этого раздела в двух пунктах врала; исправлено. + +Работает: типы (после августовской правки), identity/rowid, дефолты, nullable, индексы, +включая `UNIQUE` и partial `WHERE` — `CREATE INDEX` у SQLite отдельный statement. | Пробел | Суть | |---|---| -| `Json` / `TextArray` | отдают `JSON`; SQLite тип принимает, но даёт affinity NUMERIC вместо TEXT — работает, семантически неверно | -| Хранимые процедуры | `Structure\StoredProcedure` в SQLite смысла не имеет — нужен явный отказ, а не молчаливая генерация | -| `ALTER TABLE` | в SQLite сильно урезан (нет DROP/MODIFY COLUMN до 3.35) — на первый `CREATE` не влияет, всплывёт на последующих миграциях | -| Внешние ключи | требуют `PRAGMA foreign_keys=ON` на **каждое** соединение, иначе FK молча не действуют | +| **Внешние ключи — DDL падает** | `ForeignKey::toSql()` всегда даёт `ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY …`; SQLite отвечает `near "FOREIGN": syntax error` и на 3.51, и на 3.53. Любая сущность с `#[ForeignKey]`/`#[ForeignRepo]` не мигрируется. Прошлая формулировка «молча не действуют» неверна — до PRAGMA дело не доходит. Лечится инлайном внутри `CREATE TABLE` (проверено, принимается) | +| **CHECK — зависит от версии SQLite** | Та же форма `ALTER TABLE … ADD CONSTRAINT … CHECK (…)`: 3.53.4 (бандл в pdo_sqlite у PHP 8.5.8) принимает и констрейнт работает; системный 3.51.0 — `near "CONSTRAINT": syntax error`. Поэтому «живой» прогон здесь зелёный, а на alpine/debian со старым sqlite упадёт. Инлайн-форма работает на обеих — заодно уходит версионная зависимость | +| **FK не действуют без PRAGMA** | `PRAGMA foreign_keys` по умолчанию `0`, ставится **на каждое соединение**. Замерено: без него вставка несуществующего ключа проходит, с ним — `FOREIGN KEY constraint failed`. Место для правки — `CdoConnectionFactory::create()` после `connect()`, только для драйвера sqlite; **не решено, ядро это делает или конфиг** | +| `Json` / `TextArray` | отдают `JSON` → affinity NUMERIC. Замерено: JSON-скаляр `123` читается из такой колонки как **integer**, из `TEXT`-колонки — как text; документы `{...}` не страдают. Узко, но неверно — одна строка на тип | +| Отказы неинформативны | `StoredProcedure`, `Trigger`, `Extension`, `Table::dropIndex/dropForeignKey/dropCheckConstraint` **уже бросают** `\InvalidArgumentException` для sqlite. Прошлая формулировка «нужен явный отказ вместо молчаливой генерации» неверна: отказ есть, плохи только тип исключения и текст | +| `ALTER TABLE` | отдельная фаза: `addColumn` в SQLite можно, но только с константным дефолтом и без `PRIMARY KEY`/`UNIQUE`; `dropColumn` — с 3.35. На первый `CREATE` не влияет | + +Тест `SqliteDdlTest` покрывает только типы, identity, дефолты и уникальный индекс — ни FK, +ни CHECK в его сущности нет, поэтому оба пробела и дожили. Когда дойдут руки: сущность с +FK и CHECK, генерировать **и исполнять**, enforcement проверять под PRAGMA, а перед +правками снять golden baseline DDL для mysql/pgsql и сверить побайтово. ## ✅ Починено вне ядра diff --git a/docs/architecture/02-middleware.md b/docs/architecture/02-middleware.md index d1f73ed..749320f 100644 --- a/docs/architecture/02-middleware.md +++ b/docs/architecture/02-middleware.md @@ -143,7 +143,7 @@ throw (new MiddlewareException('Rate limited', HttpCode::TOO_MANY_REQUESTS)) ### `ClientTimezoneMiddleware` -Applies the client's IANA timezone to `date_default_timezone_set()` for the duration of the request. The value is read from `HttpRequest::getClientTimezone()`, which parses the `Timezone` or `X-Timezone` header and validates it against `timezone_identifiers_list()`. When no valid timezone is supplied, `before()` falls back to `env('TIME_ZONE', 'UTC')`; `after()` restores the same canonical default. +Applies the client's IANA timezone for the duration of the request. The value is read from `HttpRequest::getClientTimezone()`, which parses the `Timezone` or `X-Timezone` header and validates it against `timezone_identifiers_list()`. When no valid timezone is supplied, `before()` falls back to `env('TIME_ZONE', 'UTC')`. ```php use Flytachi\Winter\Kernel\Http\Middleware\ClientTimezoneMiddleware; @@ -152,7 +152,26 @@ use Flytachi\Winter\Kernel\Http\Middleware\ClientTimezoneMiddleware; class ReportController extends Controller { ... } ``` -**Swoole caveat.** `after()` is not invoked when the handler throws — the `catch (\Throwable)` in `Router::dispatch` sits outside the after-loop. In a long-running worker, an unhandled exception leaves the global TZ at the client's value until the next request that passes through this middleware overwrites it. Apply uniformly across routes, or skip the middleware and read `$request->getClientTimezone()` explicitly inside handlers. +It stores the timezone in two places, and the difference decides which of your code is safe: + +| | Where | Safe under concurrency | +|---|---|---| +| `Timezone::current()` | coroutine-local (`RequestLocal`) | **yes** | +| `date_default_timezone_set()` | PHP engine global | only while concurrent requests share one zone | + +`Timezone` is the source of truth. Everything the framework does on the request's behalf reads it — including the timezone of the database session, so a pooled connection never carries the previous user's zone into your query. + +The engine global is set as a convenience for code that has not been adapted, and it is genuinely shared: a Swoole worker runs many requests as coroutines in one process, so a request that sets its zone and then waits on I/O can resume to find another request's value in place. Measured, not reasoned — a request from `Asia/Tashkent` yielded, a request from `Europe/London` set its own, and the first read `Europe/London` on resume. No library can fix that; it is where PHP keeps its default. + +So pass the zone explicitly wherever the answer must belong to the requesting user: + +```php +use Flytachi\Winter\Kernel\Localization\Timezone; + +$when = new \DateTimeImmutable('now', new \DateTimeZone(Timezone::current())); +``` + +**Swoole caveat.** `after()` is not invoked when the handler throws — the `catch (\Throwable)` in `Router::dispatch` sits outside the after-loop. The coroutine-local value dies with the coroutine either way, but the engine global keeps the client's value until the next request through this middleware resets it — one more reason not to lean on it. See [Client timezone detection](../configuration/01-kernel.md#client-timezone-detection) for the request-side contract and the no-mutation usage pattern. diff --git a/docs/configuration/01-kernel.md b/docs/configuration/01-kernel.md index e4aac09..2f70cc0 100644 --- a/docs/configuration/01-kernel.md +++ b/docs/configuration/01-kernel.md @@ -180,9 +180,19 @@ use Flytachi\Winter\Kernel\Http\Middleware\ClientTimezoneMiddleware; class ReportController extends Controller { ... } ``` -The middleware calls `date_default_timezone_set()` in `before()` with the client value (or `env('TIME_ZONE', 'UTC')` as fallback) and restores the canonical default in `after()`. +The middleware stores the client value (or `env('TIME_ZONE', 'UTC')`) in `Timezone`, and additionally sets PHP's own default so unadapted code keeps working: -**Swoole caveat.** `after()` does not run when the handler throws — `Router::dispatch` catches `Throwable` outside the after-loop. In a long-running worker, an unhandled exception leaves the global TZ at the client's value until the next request that passes through the middleware overwrites it. Apply the middleware uniformly across routes, or skip it and call `getClientTimezone()` explicitly inside handlers. +```php +use Flytachi\Winter\Kernel\Localization\Timezone; + +Timezone::current(); // 'Asia/Tashkent' — this request's zone, coroutine-local +``` + +**`Timezone::current()` is the safe read; `date()` is not.** PHP keeps its default timezone in an engine global, and a Swoole worker runs many requests as coroutines in one process — so a request that sets its zone and then waits on I/O can resume to find a concurrent request's value in place. That is measured behaviour, not a theoretical risk, and no library can change it. `Timezone` lives in `RequestLocal` instead, so concurrent requests cannot see each other's. + +The framework reads `Timezone` for everything it does on the request's behalf, including the timezone of the database session — a pooled connection is handed from one user to the next, and its session zone is corrected on every query rather than left as the previous user found it. + +**Swoole caveat.** `after()` does not run when the handler throws — `Router::dispatch` catches `Throwable` outside the after-loop. The coroutine-local value disappears with the coroutine regardless, but the engine global keeps the client's value until the next request through the middleware resets it. --- diff --git a/docs/configuration/08-runtime.md b/docs/configuration/08-runtime.md index 6a6e7bd..b33764d 100644 --- a/docs/configuration/08-runtime.md +++ b/docs/configuration/08-runtime.md @@ -88,6 +88,23 @@ final class WebConfig extends WebConfigurerAdapter The `.env` shorthands `SERVER_WORKERS`, `SERVER_TASKS`, `SERVER_MAX_REQUEST` and `SERVER_MAX_REQUEST_GRACE` seed the same settings before the configurer runs. +### One worker by default + +Say nothing and the server runs **a single worker process** — the framework sets no +`worker_num`, and Swoole's own default in `SWOOLE_BASE` mode is one. Concurrency still +works: requests are coroutines inside that process, and a blocking call yields rather +than stalling the others. + +What one worker does not give you is more than one CPU core. Measured on a 12-core box, +a single worker saturates one core at roughly 2 400 req/s against a database and 8 500 +req/s without one. `->workers(swoole_cpu_num())` is what spreads the load; until then, +extra cores sit idle. + +The setting also changes what a pool size means. `maximumPoolSize` is **per worker**, so +one worker makes it the whole server's connection budget, while `workers(12)` multiplies +it by twelve — and a database with `max_connections = 100` will refuse the difference. +Size it as `worker_num × maximumPoolSize` ≤ what the server allows. + --- ## Set `opcache.enable_cli=1` diff --git a/src/Core/RequestLocal.php b/src/Core/RequestLocal.php new file mode 100644 index 0000000..8a037a0 --- /dev/null +++ b/src/Core/RequestLocal.php @@ -0,0 +1,101 @@ +getClientTimezone() explicitly in handlers. + * - {@see Timezone} — **the source of truth.** Coroutine-local, so concurrent requests + * cannot overwrite each other. Everything the framework does on the request's behalf + * reads this, including the timezone of the database session + * ({@see \Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool}). + * - `date_default_timezone_set()` — **a convenience, safe under one condition only.** + * It is a PHP engine global shared by every coroutine in the worker, so a bare + * `date()` or `new DateTime()` reflects whichever request wrote last. That is correct + * while concurrent requests share a timezone and wrong the moment they do not; no + * library can fix it, that is how PHP stores the default. + * + * So read {@see Timezone::current()} wherever the answer must belong to the requesting + * user, and treat the global as a best-effort default for code not yet adapted: + * + * ``` + * $when = new \DateTimeImmutable('now', new \DateTimeZone(Timezone::current())); + * ``` + * + * Swoole note: if the handler throws, `after()` does not run. The coroutine-local value + * dies with the coroutine either way, but the engine global keeps the client's value + * until the next request through this middleware resets it — one more reason not to + * lean on it. * * Usage: * #[ClientTimezoneMiddleware] @@ -30,8 +47,10 @@ final class ClientTimezoneMiddleware extends Middleware { public function before(HttpRequest $request, HttpResponse $response): void { - $tz = $request->getClientTimezone() ?? env('TIME_ZONE', 'UTC'); - date_default_timezone_set((string) $tz); + $tz = (string) ($request->getClientTimezone() ?? env('TIME_ZONE', 'UTC')); + + Timezone::set($tz); + date_default_timezone_set($tz); } public function after(mixed $result): mixed diff --git a/src/Localization/Timezone.php b/src/Localization/Timezone.php new file mode 100644 index 0000000..6d1487e --- /dev/null +++ b/src/Localization/Timezone.php @@ -0,0 +1,71 @@ +|null + */ + private static ?\WeakMap $appliedTimezone = null; + private static function logger(): LoggerInterface { return LoggerFactory::getLogger('PPA'); @@ -357,12 +373,46 @@ private static function coroutineDb(string $configClass): CDO $config = $held->entry->resource; $cdo = $config->connection(); + self::syncTimezone($config, $cdo); + + return $cdo; + } + + /** + * Makes the connection's session timezone match the request's. + * + * This runs on **every** `db()` call, not once per borrow, and that is deliberate: + * a pooled connection passes from one user to the next, so the previous user's + * timezone must never be left in place — a client in London would receive dates in + * Tashkent's zone. See {@see \Flytachi\Winter\Kernel\Http\Middleware\ClientTimezoneMiddleware}. + * + * The zone comes from {@see Timezone}, which is coroutine-local. Reading PHP's + * `date_default_timezone_get()` here — as this did — meant reading an engine global + * shared by every request in the worker: a request that yielded on I/O could resume + * after a concurrent request had overwritten it, and then hand *that* zone to its + * own database session. Measured, not theorised. + * + * The command itself is skipped when the connection already carries the right zone. + * That is not the same as hoisting it out of the hot path: the check is per + * connection, so a connection arriving from a user in another timezone is still + * corrected, including mid-request. It removes two round-trips per request in the + * ordinary case where everyone shares one zone. + */ + private static function syncTimezone(object $config, CDO $cdo): void + { $driver = $cdo->getAttribute(\PDO::ATTR_DRIVER_NAME); - if (!empty($driver)) { - $cdo->applyDatabaseTimezone($driver, date_default_timezone_get()); + if (empty($driver)) { + return; } - return $cdo; + $applied = self::$appliedTimezone ??= new \WeakMap(); + $tz = Timezone::current(); + if (($applied[$config] ?? null) === $tz) { + return; + } + + $cdo->applyDatabaseTimezone($driver, $tz); + $applied[$config] = $tz; } /** diff --git a/tests/Core/RequestLocalTest.php b/tests/Core/RequestLocalTest.php new file mode 100644 index 0000000..1e3ab59 --- /dev/null +++ b/tests/Core/RequestLocalTest.php @@ -0,0 +1,137 @@ +requireSwoole(); + $seen = []; + + \Swoole\Coroutine\run(static function () use (&$seen): void { + \Swoole\Coroutine::create(static function () use (&$seen): void { + RequestLocal::set('tz', 'Asia/Tashkent'); + \Swoole\Coroutine::sleep(0.05); // wait on I/O + $seen['A'] = RequestLocal::get('tz'); // resume + }); + \Swoole\Coroutine::create(static function () use (&$seen): void { + \Swoole\Coroutine::sleep(0.01); // lands while A waits + RequestLocal::set('tz', 'Europe/London'); + $seen['B'] = RequestLocal::get('tz'); + }); + }); + + self::assertSame('Asia/Tashkent', $seen['A'], 'request A must not read B\'s value'); + self::assertSame('Europe/London', $seen['B']); + } + + public function test_a_coroutine_starts_without_the_previous_ones_values(): void + { + $this->requireSwoole(); + $second = 'unset'; + + \Swoole\Coroutine\run(static function () use (&$second): void { + \Swoole\Coroutine::create(static function (): void { + RequestLocal::set('leftover', 'first request'); + }); + \Swoole\Coroutine::create(static function () use (&$second): void { + $second = RequestLocal::get('leftover', 'clean'); + }); + }); + + self::assertSame('clean', $second, 'a finished request leaves nothing behind'); + } + + /** The static fallback must not leak into the coroutine path, or vice versa. */ + public function test_the_two_runtimes_keep_separate_stores(): void + { + $this->requireSwoole(); + RequestLocal::set('where', 'outside'); + $inside = 'unset'; + + \Swoole\Coroutine\run(static function () use (&$inside): void { + $inside = RequestLocal::get('where', 'nothing here'); + }); + + self::assertSame('nothing here', $inside); + self::assertSame('outside', RequestLocal::get('where'), 'the outer store is untouched'); + } + + private function requireSwoole(): void + { + if (!extension_loaded('swoole')) { + self::markTestSkipped('Coroutine isolation needs Swoole.'); + } + } +} diff --git a/tests/Localization/TimezoneTest.php b/tests/Localization/TimezoneTest.php new file mode 100644 index 0000000..08f70f4 --- /dev/null +++ b/tests/Localization/TimezoneTest.php @@ -0,0 +1,134 @@ +originalEnv = $_ENV['TIME_ZONE'] ?? null; + RequestLocal::clear(); + } + + protected function tearDown(): void + { + if ($this->originalEnv === null) { + unset($_ENV['TIME_ZONE']); + } else { + $_ENV['TIME_ZONE'] = $this->originalEnv; + } + RequestLocal::clear(); + } + + public function test_the_stored_zone_is_returned(): void + { + Timezone::set('Asia/Tashkent'); + + self::assertSame('Asia/Tashkent', Timezone::current()); + self::assertTrue(Timezone::isSet()); + } + + public function test_without_a_stored_zone_the_environment_answers(): void + { + $_ENV['TIME_ZONE'] = 'Europe/Berlin'; + + self::assertSame('Europe/Berlin', Timezone::current()); + self::assertFalse(Timezone::isSet(), 'a default is not the request\'s own zone'); + } + + public function test_with_no_environment_either_it_falls_back_to_utc(): void + { + unset($_ENV['TIME_ZONE']); + + self::assertSame('UTC', Timezone::current()); + } + + public function test_reset_returns_to_the_environment_default(): void + { + $_ENV['TIME_ZONE'] = 'Europe/Berlin'; + Timezone::set('Asia/Tashkent'); + + Timezone::reset(); + + self::assertSame('Europe/Berlin', Timezone::current()); + } + + /** + * The failure that motivated the class. Request A must read its own zone after an + * I/O wait, not the zone of a request that arrived while it waited. + */ + public function test_a_concurrent_request_cannot_overwrite_the_zone(): void + { + if (!extension_loaded('swoole')) { + self::markTestSkipped('Coroutine isolation needs Swoole.'); + } + $seen = []; + + \Swoole\Coroutine\run(static function () use (&$seen): void { + \Swoole\Coroutine::create(static function () use (&$seen): void { + Timezone::set('Asia/Tashkent'); + \Swoole\Coroutine::sleep(0.05); + $seen['A'] = Timezone::current(); + }); + \Swoole\Coroutine::create(static function () use (&$seen): void { + \Swoole\Coroutine::sleep(0.01); + Timezone::set('Europe/London'); + $seen['B'] = Timezone::current(); + }); + }); + + self::assertSame('Asia/Tashkent', $seen['A']); + self::assertSame('Europe/London', $seen['B']); + } + + /** + * PHP's own global is what this replaces — and the contrast is the point: the same + * two coroutines mixed up their zones through `date_default_timezone_set()`. + */ + public function test_the_php_global_is_still_shared_which_is_why_this_exists(): void + { + if (!extension_loaded('swoole')) { + self::markTestSkipped('Coroutine isolation needs Swoole.'); + } + $original = date_default_timezone_get(); + $seen = []; + + \Swoole\Coroutine\run(static function () use (&$seen): void { + \Swoole\Coroutine::create(static function () use (&$seen): void { + date_default_timezone_set('Asia/Tashkent'); + \Swoole\Coroutine::sleep(0.05); + $seen['A'] = date_default_timezone_get(); + }); + \Swoole\Coroutine::create(static function (): void { + \Swoole\Coroutine::sleep(0.01); + date_default_timezone_set('Europe/London'); + }); + }); + + date_default_timezone_set($original); + + self::assertSame( + 'Europe/London', + $seen['A'], + 'if this ever fails, PHP made its default timezone coroutine-local and ' + . 'Timezone could delegate to it', + ); + } +} diff --git a/tests/Ppa/Pool/PoolTimezoneTest.php b/tests/Ppa/Pool/PoolTimezoneTest.php new file mode 100644 index 0000000..de0e56c --- /dev/null +++ b/tests/Ppa/Pool/PoolTimezoneTest.php @@ -0,0 +1,180 @@ +originalEnv = $_ENV['TIME_ZONE'] ?? null; + RequestLocal::clear(); + new ReflectionProperty(PpaConnectionPool::class, 'appliedTimezone')->setValue(null, null); + } + + protected function tearDown(): void + { + if ($this->originalEnv === null) { + unset($_ENV['TIME_ZONE']); + } else { + $_ENV['TIME_ZONE'] = $this->originalEnv; + } + RequestLocal::clear(); + new ReflectionProperty(PpaConnectionPool::class, 'appliedTimezone')->setValue(null, null); + } + + /** Calls the private `syncTimezone($config, $cdo)` the way `coroutineDb()` does. */ + private function sync(object $config, RecordingCdo $cdo): void + { + new ReflectionMethod(PpaConnectionPool::class, 'syncTimezone')->invoke(null, $config, $cdo); + } + + private function cdo(): RecordingCdo + { + return new ReflectionClass(RecordingCdo::class)->newInstanceWithoutConstructor(); + } + + public function test_the_session_gets_the_requests_zone(): void + { + Timezone::set('Asia/Tashkent'); + $cdo = $this->cdo(); + + $this->sync(new \stdClass(), $cdo); + + self::assertSame(['Asia/Tashkent'], $cdo->applied); + } + + public function test_without_a_request_zone_the_environment_default_is_used(): void + { + $_ENV['TIME_ZONE'] = 'Europe/Berlin'; + $cdo = $this->cdo(); + + $this->sync(new \stdClass(), $cdo); + + self::assertSame(['Europe/Berlin'], $cdo->applied); + } + + /** The engine global is not the source — that is the whole point of the change. */ + public function test_the_php_global_is_ignored(): void + { + $original = date_default_timezone_get(); + date_default_timezone_set('America/New_York'); // another request's leftover + $_ENV['TIME_ZONE'] = 'UTC'; + $cdo = $this->cdo(); + + try { + $this->sync(new \stdClass(), $cdo); + } finally { + date_default_timezone_set($original); + } + + self::assertSame(['UTC'], $cdo->applied, 'the global must not reach the session'); + } + + public function test_the_same_zone_is_not_sent_twice_to_one_connection(): void + { + Timezone::set('Asia/Tashkent'); + $config = new \stdClass(); + $cdo = $this->cdo(); + + $this->sync($config, $cdo); + $this->sync($config, $cdo); + $this->sync($config, $cdo); + + self::assertSame(['Asia/Tashkent'], $cdo->applied, 'one command, not three'); + } + + /** + * The case the memo must not swallow: the connection comes back from the pool while + * a different user holds it. + */ + public function test_a_changed_zone_is_applied_again(): void + { + $config = new \stdClass(); + $cdo = $this->cdo(); + + Timezone::set('Asia/Tashkent'); + $this->sync($config, $cdo); + Timezone::set('Europe/London'); + $this->sync($config, $cdo); + + self::assertSame(['Asia/Tashkent', 'Europe/London'], $cdo->applied); + } + + /** The memo is per connection: one connection's state says nothing about another's. */ + public function test_each_connection_is_tracked_separately(): void + { + Timezone::set('Asia/Tashkent'); + $first = $this->cdo(); + $second = $this->cdo(); + + $this->sync($configA = new \stdClass(), $first); + $this->sync($configB = new \stdClass(), $second); + + self::assertSame(['Asia/Tashkent'], $first->applied); + self::assertSame(['Asia/Tashkent'], $second->applied, 'a fresh connection needs its own SET'); + self::assertNotSame($configA, $configB); + } + + public function test_a_driverless_connection_is_left_alone(): void + { + Timezone::set('Asia/Tashkent'); + $cdo = $this->cdo(); + $cdo->driver = ''; + + $this->sync(new \stdClass(), $cdo); + + self::assertSame([], $cdo->applied); + } +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +/** + * A CDO that records the timezones asked of it instead of talking to a server. + * Built via `newInstanceWithoutConstructor()`, so no connection is ever opened. + */ +final class RecordingCdo extends CDO +{ + /** @var list */ + public array $applied = []; + public string $driver = 'pgsql'; + + public function getAttribute(int $attribute): mixed + { + return $this->driver; + } + + public function applyDatabaseTimezone(mixed $driver, string $tz): void + { + $this->applied[] = $tz; + } +} From 0e089602aba44ff9ef016d2127be13b5c4ccb9c5 Mon Sep 17 00:00:00 2001 From: flytachi Date: Wed, 5 Aug 2026 15:07:43 +0500 Subject: [PATCH 60/71] memory and ppa --- composer.json | 2 +- console/Template/Docker/Dockerfile | 5 + console/Template/Docker/docker/php-memory.ini | 33 +++ doc/STATUS.md | 47 +++- docs/configuration/08-runtime.md | 47 +++- docs/ppa/00-overview.md | 4 +- docs/ppa/14-crud.md | 85 ++++++- docs/ppa/16-advanced-examples.md | 2 +- src/App/Config/ServerSettings.php | 61 ++++- src/App/Config/WorkerMemory.php | 177 +++++++++++++ src/Ppa/Entity/RepositoryCrudInterface.php | 21 +- src/Ppa/Repository/RepositoryCrudTrait.php | 80 +++++- src/WinterApplication.php | 17 +- tests/App/WorkerMemoryTest.php | 238 ++++++++++++++++++ .../Crud/CrudIntegrationTestCase.php | 8 +- .../Ppa/Repository/InsertBatchFlattenTest.php | 162 ++++++++++++ 16 files changed, 941 insertions(+), 48 deletions(-) create mode 100644 console/Template/Docker/docker/php-memory.ini create mode 100644 src/App/Config/WorkerMemory.php create mode 100644 tests/App/WorkerMemoryTest.php create mode 100644 tests/Ppa/Repository/InsertBatchFlattenTest.php diff --git a/composer.json b/composer.json index 73f6a2f..3592264 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ "flytachi/winter-base": "^3.0", "flytachi/winter-logger": "^1.0", "flytachi/winter-thread": "^3.0", - "flytachi/winter-cdo": "^3.0", + "flytachi/winter-cdo": "^4.0", "flytachi/winter-cache": "^1.0", "flytachi/file-store": "^2.0", "vlucas/phpdotenv": "^5.6", diff --git a/console/Template/Docker/Dockerfile b/console/Template/Docker/Dockerfile index cf155cc..1f6c80d 100644 --- a/console/Template/Docker/Dockerfile +++ b/console/Template/Docker/Dockerfile @@ -41,6 +41,11 @@ RUN docker-php-ext-install -j"$(nproc)" pcntl # code is always live. COPY docker/php-opcache.ini /opt/winter/php-opcache.ini +# Memory ceiling per worker — active in both modes, so it is not staged like opcache. +# PHP's 128M default is invisible until a worker dies of it; this puts the dial in +# sight. Read the file before changing it: the box must hold worker_num × this value. +COPY docker/php-memory.ini /usr/local/etc/php/conf.d/20-memory.ini + # User hook: DB drivers / PHP extensions / cron. Modular scripts in # docker/dependencies/ — delete what you don't need; they run in filename order # (numeric prefixes). Placed BEFORE the app code copy so this rarely-changing diff --git a/console/Template/Docker/docker/php-memory.ini b/console/Template/Docker/docker/php-memory.ini new file mode 100644 index 0000000..2b3280e --- /dev/null +++ b/console/Template/Docker/docker/php-memory.ini @@ -0,0 +1,33 @@ +; -------------------------- +; Memory +; -------------------------- +; +; This file is always active — dev and prod alike — unlike php-opcache.ini, which the +; entrypoint switches on only outside dev mode. + +; Memory ceiling for ONE worker process. +; +; PHP's own default is 128M, and it is invisible: nothing announces it, so the first +; time most projects learn the number is when a worker dies under load. 256M is set +; here explicitly so the dial is where you can see it. +; +; Two things about this limit are easy to get wrong: +; +; 1. It is per PROCESS, and a Swoole worker serves many requests at once as +; coroutines out of one heap. So it bounds their SUM, not any single request. +; Measured on a small API: roughly 90 KB of heap per in-flight request, i.e. +; ~1400 concurrent requests fit in 128M and ~2800 in 256M. +; +; 2. The box must hold worker_num × this value, plus opcache's shared memory +; (see php-opcache.ini). Four workers at 256M want 1 GiB before a single row of +; data is read. The framework warns at startup when that product exceeds the +; container's memory limit. +; +; Raising it moves the threshold; it does not remove it. What keeps a worker alive +; under a spike is bounding concurrency, not the ceiling. +; +; Do NOT set -1 here. Unlimited does not remove the ceiling, it moves it into the +; kernel: PHP never stops the process, so the OOM killer eventually sends SIGKILL — +; no shutdown functions, no log line, and the whole container when the server is PID 1. +; A real limit at least fails through PHP, and the manager restarts that one worker. +memory_limit = 256M diff --git a/doc/STATUS.md b/doc/STATUS.md index 2ea220d..c51138c 100644 --- a/doc/STATUS.md +++ b/doc/STATUS.md @@ -158,6 +158,51 @@ housekeeper (keepalive / idleTimeout / minimumIdle, opt-in), evict при пот | **Starter-autoconfig** | только явный `#[Import]`; авто-подключение через `composer.json extra.winter` не делалось | | **FPM** | из ядра не обслуживается; адаптеры (`FpmRequest`/`FpmResponse`) на месте и покрыты тестами — основа для отдельного `winter-fpm` | +### ✅ Пакетная запись стала потоковой (сделано 2026-08-05) + +**`winter-cdo` v4.0.0** — `insertGroup`/`upsertGroup` переименованы в +**`insertBatch`/`upsertBatch`**, параметр `array $entities` расширен до `iterable`, +внутри вместо материализации всех строк — буферы по форме строки с флашем по +заполнении. Алиасов не оставляли: два имени одного метода пережили бы миграцию. +«Batch» — слово из обоих канонов (JDBC `addBatch`/`executeBatch`, Spring +`batchUpdate`, Yii `batchInsert`) и совпадает с тем, что делает параметр `chunkSize`. + +**Ядро** — `RepositoryCrudTrait` и `RepositoryCrudInterface` под новые имена, плюс +правило разбора вариадика: **массив — это строка, `Traversable` — это поток строк**. +Различение достоверное (массив здесь законная сущность, `Traversable` — никогда), так +что одна сигнатура принимает всё сразу: + +```php +$repo->insertBatch($user1, $user2); // сущности +$repo->insertBatch(['name' => 'John']); // массив — одна строка +$repo->insertBatch(...$entities); // распаковка +$repo->insertBatch($generator); // поток +$repo->insertBatch($fromCsv, $extraRow); // вперемешку +``` + +Раскладка идёт **генератором** (`RepositoryCrudTrait::flatten()`), иначе поток снова +собрался бы в массив на границе слоёв и смысл потерялся. + +**Замеры.** Сквозь всю цепочку репозиторий → CDO, 200 000 строк генератором — пик +**1.4 MiB**. Для сравнения, старый путь на тех же данных: 180.9 MiB, из которых +147 MiB — преобразование строк в массивы внутри CDO (объект 175 B, его же массив +773 B — ×4.4), и всё это до первой вставки. На 500 000 строк было 440 MiB против +~4 MiB, что и убивало воркер со штатным `memory_limit = 128M`. + +**Изменения семантики**, записаны в CHANGELOG CDO: частичная запись при падении +(батчи уходят по мере заполнения, а не после проверки всех строк — лечится +транзакцией на стороне вызывающего); `upsertBatch` проверяет `conflictColumns` до +данных, а не после; `insertBatch` не имеет раннего выхода на пустом входе. + +**Плюс защита от ошибки, о которую спотыкались разработчики**: `$updateColumns` +принимает только карту `колонка => выражение`. Список `['qty', 'created_at']` — форма +из Laravel — раньше доезжал до базы как `SET 0 = qty` и возвращался как +`no such column: 0`, то есть указывал на схему вместо вызова. Теперь отказ с +сообщением, называющим колонку, показывающим исправленный вызов и упоминающим +`:current`. Список **не** принят как сокращение осознанно: он покрывает только +тривиальный `:new`, карту всё равно пришлось бы выучить при первом выражении, а +платить пришлось бы двумя формами навсегда. + ### Границы ресурсов запроса (решено делать, 2026-08-04) Поводом стал `Fatal error: Allowed memory size of 134217728 bytes exhausted` под стрессом. @@ -175,7 +220,7 @@ housekeeper (keepalive / idleTimeout / minimumIdle, opt-in), evict при пот чанкует по 1000, но чанкует **SQL, а не память** — `groupRowsBySignature()` материализует все строки до первого запроса к базе. -**1. `winter-cdo`: `iterable`/`yield` в `insertGroup`.** Сигнатура +**1. ~~`winter-cdo`: `iterable`/`yield`~~ — сделано, см. выше.** Сигнатура `insertGroup(array|object ...$entities)` — вариадик, генератор в неё не передать, то есть она *вынуждает* держать всё в памяти. Приём `iterable` с батчингом внутри сделает пик O(батч) вместо O(всего) без переписывания прикладного кода. Правка в соседнем репозитории diff --git a/docs/configuration/08-runtime.md b/docs/configuration/08-runtime.md index b33764d..2a456e3 100644 --- a/docs/configuration/08-runtime.md +++ b/docs/configuration/08-runtime.md @@ -85,8 +85,51 @@ final class WebConfig extends WebConfigurerAdapter } ``` -The `.env` shorthands `SERVER_WORKERS`, `SERVER_TASKS`, `SERVER_MAX_REQUEST` and -`SERVER_MAX_REQUEST_GRACE` seed the same settings before the configurer runs. +The `.env` shorthands `SERVER_WORKERS`, `SERVER_TASKS`, `SERVER_MAX_REQUEST`, +`SERVER_MAX_REQUEST_GRACE` and `SERVER_MEMORY_LIMIT` seed the same settings before the +configurer runs. + +### Memory per worker + +```php +$server->workers(4)->memoryLimit('256M'); +``` + +```dotenv +SERVER_MEMORY_LIMIT=256M +``` + +Say nothing and **the framework does not touch the setting at all** — PHP's own value +stands (128M compiled in, unless a `php.ini` raises it). Configure it and the limit is +applied on worker start. + +`memoryLimit()` is a PHP ini, not a Swoole option, so it never reaches +`Swoole\Server::set()`. It lives on `ServerSettings` because it is one half of an +arithmetic whose other half — `worker_num` — is already there: + +> The box must hold **`worker_num × memory_limit`**, plus opcache's shared memory. + +Four workers at 256M want 1 GiB before a row of data is read. The framework says this +out loud at startup and **warns** when the product exceeds the container's memory +limit (read from cgroup). It warns rather than refuses: every worker peaking together +is a worst case, and over-committing memory is a legitimate choice. + +Two properties of this limit are easy to get wrong: + +- **It is per process, and a Swoole worker runs many requests at once out of one + heap.** So it bounds their *sum*, not any single request. Measured on a small API: + roughly 90 KB of heap per in-flight request — about 1 400 concurrent requests fit in + 128M. When the sum is reached the worker dies and takes every request it was holding + (measured: 1 193 coroutines discarded at once). +- **Raising it moves the threshold, it does not remove it.** At high enough concurrency + any ceiling is reached. What keeps a worker alive under a spike is bounding + concurrency, not the ceiling. + +**Do not set `-1`.** Unlimited does not remove the ceiling — it moves it into the +kernel. PHP never stops the process, so the OOM killer eventually sends `SIGKILL`: no +shutdown functions, no log entry, and the whole container when the server is PID 1. A +real limit at least fails through PHP, and the manager restarts that one worker. The +framework warns at startup when it finds `-1`. ### One worker by default diff --git a/docs/ppa/00-overview.md b/docs/ppa/00-overview.md index be49e68..a4c9c0e 100644 --- a/docs/ppa/00-overview.md +++ b/docs/ppa/00-overview.md @@ -16,7 +16,7 @@ RepositoryCore (abstract) ├── implements RepositoryMappingInterface — originTable, mapIdentifierColumnName └── extends Stereotype (PSR-3 logger) - ├── + RepositoryCrudTrait → insert, insertGroup, update, delete, upsert, upsertGroup + ├── + RepositoryCrudTrait → insert, insertBatch, update, delete, upsert, upsertBatch └── + RepositoryViewTrait → find, findAll, findColumn, count, exists, rawFetch findById, findBy, findAllBy, *OrThrow @@ -109,7 +109,7 @@ $repo->update(['status' => 'inactive'], Qb::lt('last_login', '2024-01-01')); |---|------|----------| | 12 | [12-view-fetch.md](12-view-fetch.md) | `find`, `findAll`, `findColumn`, `count`, `exists`, `rawFetch` | | 13 | [13-static-finders.md](13-static-finders.md) | `findById`, `findBy`, `findAllBy`, `findByIdOrThrow`, `findByOrThrow` | -| 14 | [14-crud.md](14-crud.md) | `insert`, `insertGroup`, `update`, `delete`, `upsert`, `upsertGroup` | +| 14 | [14-crud.md](14-crud.md) | `insert`, `insertBatch`, `update`, `delete`, `upsert`, `upsertBatch` | ### Utilities diff --git a/docs/ppa/14-crud.md b/docs/ppa/14-crud.md index dc06d52..e36c9bd 100644 --- a/docs/ppa/14-crud.md +++ b/docs/ppa/14-crud.md @@ -56,28 +56,74 @@ $id = $repo->insert($user); --- -## insertGroup() +## insertBatch() ```php -public function insertGroup(array|object ...$entities): void +public function insertBatch(iterable|object ...$entities): void ``` -Batch-inserts multiple records efficiently. Rows are chunked to avoid -exceeding database placeholder limits. +Inserts many records, sent to the database in batches so no single statement +exceeds the driver's placeholder limit. ```php $repo = new UserRepository(); -$repo->insertGroup( +$repo->insertBatch( ['name' => 'Alice', 'email' => 'a@example.com', 'status' => 'active'], ['name' => 'Bob', 'email' => 'b@example.com', 'status' => 'trial'], ['name' => 'Carol', 'email' => 'c@example.com', 'status' => 'active'], ); // Or spread an array: -$repo->insertGroup(...$usersArray); +$repo->insertBatch(...$usersArray); ``` +### One rule: an array is a row, a stream is many + +Every argument is read the same way — **an array is one row, anything traversable +is a stream of rows** — so the forms combine freely: + +```php +$repo->insertBatch($user1, $user2); // entities +$repo->insertBatch(['name' => 'John']); // an array is one row +$repo->insertBatch(...$entities); // an unpacked array +$repo->insertBatch($generator); // a stream +$repo->insertBatch($fromCsv, $extraRow); // mixed, one call +``` + +Nothing is ambiguous: an array is a valid entity here, and a `Traversable` never is. + +### Streaming: why the shape matters + +Rows reach the driver lazily and each batch is flushed as it fills, so **peak +memory follows the batch size, not the size of the job**: + +```php +// 500 000 rows, ~4 MiB peak — the collection never exists at once +$repo->insertBatch((function () { + for ($i = 1; $i <= 500_000; $i++) { + $row = new Bench(); + $row->field = "Record #{$i}"; + $row->created_at = TimeTool::now()->format('Y-m-d H:i:s'); + yield $row; + } +})()); +``` + +Building the collection first costs whatever that collection costs. Measured on +500 000 entities: **440 MiB** as an array against **4 MiB** streamed — and most of +that 440 was not the entities but their conversion to rows, which used to happen +for all of them before the first statement was sent. + +That measurement is also how a worker dies: PHP's default `memory_limit` is 128 MiB, +per worker, and the array form crosses it around 150 000 rows. + +### Failure + +Batches are sent as they fill, so a failure part-way leaves the earlier batches +committed. Wrap the call in a transaction when the whole job must be +all-or-nothing. + --- ## update() @@ -174,22 +220,24 @@ $repo->upsert( --- -## upsertGroup() +## upsertBatch() ```php -public function upsertGroup( - array $entities, - array $conflictColumns, - ?array $updateColumns = null +public function upsertBatch( + iterable $entities, + array $conflictColumns, + ?array $updateColumns = null ): void ``` -Batch version of `upsert()`. Rows are chunked to avoid database limits. +Batch version of `upsert()`. `$entities` is any `iterable` — an array, a generator, +any `Traversable` — and a generator keeps peak memory at one batch whatever the +total, exactly as in [`insertBatch()`](#insertbatch). ```php $repo = new ProductRepository(); -$repo->upsertGroup( +$repo->upsertBatch( $stockItems, ['sku'], [ @@ -200,6 +248,17 @@ $repo->upsertGroup( ); ``` +`$updateColumns` maps **column => expression**, not a list of column names: + +```php +['price' => ':new'] // ✅ replace price with the incoming value +['price', 'stock'] // ❌ RepositoryException — this is Laravel's shape +[] or null // ✅ ignore conflicts (DO NOTHING / INSERT IGNORE) +``` + +The list form is refused with a message naming the column and showing the corrected +call, rather than reaching the database and coming back as `no such column: 0`. + --- ## Exception handling diff --git a/docs/ppa/16-advanced-examples.md b/docs/ppa/16-advanced-examples.md index 1ff0e23..1ac82c2 100644 --- a/docs/ppa/16-advanced-examples.md +++ b/docs/ppa/16-advanced-examples.md @@ -204,7 +204,7 @@ try { // Sync stock levels — accumulate quantity, always overwrite cost. $repo = new ProductRepository(); -$repo->upsertGroup( +$repo->upsertBatch( $incomingStock, // array of ['sku', 'quantity', 'cost'] ['sku'], [ diff --git a/src/App/Config/ServerSettings.php b/src/App/Config/ServerSettings.php index 2a38216..29440af 100644 --- a/src/App/Config/ServerSettings.php +++ b/src/App/Config/ServerSettings.php @@ -26,6 +26,7 @@ private function __construct( private string $host, private int $port, private array $options = [], + private ?string $memoryLimit = null, ) { } @@ -44,13 +45,20 @@ public static function fromEnv(string $host = '0.0.0.0', int $port = 8000): self 'SERVER_MAX_REQUEST' => 'max_request', 'SERVER_MAX_REQUEST_GRACE' => 'max_request_grace', ]; + // `SERVER_MEMORY_LIMIT` is handled below — it is a PHP ini, not a Swoole key. foreach ($map as $envKey => $swooleKey) { $raw = env($envKey); if ($raw !== null && is_numeric($raw)) { $options[$swooleKey] = (int) $raw; } } - return new self($host, $port, $options); + + // Not a Swoole option — a PHP ini, so it is carried separately and never + // reaches Swoole\Server::set(). Kept as written ('256M', '1G', '-1'). + $memoryLimit = env('SERVER_MEMORY_LIMIT'); + $memoryLimit = is_string($memoryLimit) && $memoryLimit !== '' ? $memoryLimit : null; + + return new self($host, $port, $options, $memoryLimit); } /** Bind host (e.g. '0.0.0.0', '127.0.0.1'). */ @@ -140,6 +148,48 @@ public function staticPath(string $path): self ->set('enable_static_handler', true); } + /** + * Memory ceiling for each worker process, applied on worker start. + * + * ``` + * $server->workers(4)->memoryLimit('256M'); + * ``` + * + * Say nothing and the framework does not touch the setting at all — PHP's own + * value stands (128M compiled in, unless a php.ini raises it). Nothing existing + * changes by upgrading. + * + * **This is a PHP ini value, not a Swoole option**, so it never reaches + * `Swoole\Server::set()` — see {@see toArray()}. It lives here because the limit + * is only half of an arithmetic whose other half, `worker_num`, is already set + * on this object: a box has to hold `worker_num × memoryLimit` plus opcache's + * shared memory, and keeping the two apart is how that product goes unnoticed + * until a worker dies. + * + * The limit is **per worker, shared by every coroutine in it** — a Swoole worker + * serves many requests at once out of one heap, so this bounds their sum, not any + * single request. Raising it moves the threshold; it does not remove it. What + * stops a worker dying is bounding concurrency, not the ceiling. + * + * `-1` (unlimited) is accepted but warned about at boot: without a limit PHP never + * stops, so the process grows until the kernel's OOM killer sends SIGKILL — no + * shutdown functions, no log line, and the whole container when the server is + * PID 1. A limit at least fails through PHP, which the manager can recover from. + * + * @param string $limit A PHP memory value: '256M', '1G', or '-1' for unlimited. + */ + public function memoryLimit(string $limit): self + { + $this->memoryLimit = $limit; + return $this; + } + + /** The configured per-worker memory limit, or null when the ini is left alone. */ + public function getMemoryLimit(): ?string + { + return $this->memoryLimit; + } + /** Set any raw Swoole option. */ public function set(string $key, mixed $value): self { @@ -147,7 +197,14 @@ public function set(string $key, mixed $value): self return $this; } - /** @return array */ + /** + * The Swoole options only. + * + * `memoryLimit` is deliberately absent: it is a PHP ini value, and Swoole answers + * an option it does not know with `unsupported option` on every start. + * + * @return array + */ public function toArray(): array { return $this->options; diff --git a/src/App/Config/WorkerMemory.php b/src/App/Config/WorkerMemory.php new file mode 100644 index 0000000..bd255ee --- /dev/null +++ b/src/App/Config/WorkerMemory.php @@ -0,0 +1,177 @@ +warning( + 'memory_limit is -1 (unlimited) — under Swoole this does not remove the ' + . 'ceiling, it moves it to the kernel. PHP will never stop the process, so ' + . 'a runaway request grows until the OOM killer sends SIGKILL: no shutdown ' + . 'functions, no log entry, and the whole container when the server is PID 1. ' + . 'A real limit at least fails through PHP, which the manager recovers from.', + ); + return; + } + + if ($perWorker === 0) { + return; // unparseable value — PHP will complain about it far better than we can + } + + $fleet = $perWorker * max(1, $workers); + $opcache = self::opcacheBytes(); + $needed = $fleet + $opcache; + $box = $boxBytes ?? self::containerLimitBytes(); + + if ($box === null || $needed <= $box) { + return; + } + + $logger->warning(sprintf( + 'Memory over-commit: %d worker(s) × %s = %s, plus %s of opcache, needs %s — ' + . 'the container is limited to %s. Every worker peaking at once would exceed it, ' + . 'and the kernel kills the process rather than PHP failing the request. ' + . 'Lower memory_limit, run fewer workers, or give the container more memory.', + max(1, $workers), + self::format($perWorker), + self::format($fleet), + self::format($opcache), + self::format($needed), + self::format($box), + )); + } + + /** The container's memory ceiling in bytes, or null when it is unlimited/unknown. */ + private static function containerLimitBytes(): ?int + { + foreach ([self::CGROUP_V2, self::CGROUP_V1] as $path) { + if (!is_readable($path)) { + continue; + } + $raw = trim((string) @file_get_contents($path)); + + // cgroup v2 writes the literal "max" when unbounded; v1 writes a number so + // large it means the same thing (typically PHP_INT_MAX rounded to page size). + if ($raw === '' || $raw === 'max' || !ctype_digit($raw)) { + return null; + } + $bytes = (int) $raw; + + return $bytes > 0 && $bytes < PHP_INT_MAX / 2 ? $bytes : null; + } + + return null; + } + + /** Opcache's shared memory, which is charged to the box once, not per worker. */ + private static function opcacheBytes(): int + { + if (!function_exists('opcache_get_status') || ini_get('opcache.enable') === false) { + return 0; + } + $mb = (int) ini_get('opcache.memory_consumption'); + + return $mb > 0 ? $mb * 1024 * 1024 : 0; + } + + /** + * PHP shorthand ('256M', '1G') to bytes. Returns -1 for unlimited, 0 when the value + * makes no sense — PHP itself reports that better than a second opinion would. + */ + private static function toBytes(string $value): int + { + $value = trim($value); + if ($value === '-1') { + return -1; + } + if (!preg_match('/^(\d+)\s*([KMG])?$/i', $value, $m)) { + return 0; + } + + return (int) $m[1] * match (strtoupper($m[2] ?? '')) { + 'K' => 1024, + 'M' => 1024 ** 2, + 'G' => 1024 ** 3, + default => 1, + }; + } + + private static function format(int $bytes): string + { + if ($bytes >= 1024 ** 3) { + return round($bytes / 1024 ** 3, 1) . ' GiB'; + } + + return round($bytes / 1024 ** 2) . ' MiB'; + } +} diff --git a/src/Ppa/Entity/RepositoryCrudInterface.php b/src/Ppa/Entity/RepositoryCrudInterface.php index 709bad0..1ed4acf 100644 --- a/src/Ppa/Entity/RepositoryCrudInterface.php +++ b/src/Ppa/Entity/RepositoryCrudInterface.php @@ -27,13 +27,18 @@ interface RepositoryCrudInterface extends RepositoryInterface public function insert(object|array $entity): mixed; /** - * Inserts multiple entities in a single batch statement. + * Inserts many entities, sent to the database in batches. * - * @param array|object ...$entities One or more entities to insert + * One rule, applied per argument: **an array is one row, anything traversable is + * a stream of rows.** So entities, unpacked arrays and generators all work, in + * any combination — and a generator keeps peak memory at one batch whatever the + * total. + * + * @param iterable|object ...$entities Entities, streams of entities, or both * @return void * @throws RepositoryException */ - public function insertGroup(array|object ...$entities): void; + public function insertBatch(iterable|object ...$entities): void; /** * Updates rows matching the given condition. @@ -66,13 +71,13 @@ public function delete(Qb $qb): int|string; public function upsert(object|array $entity, array $conflictColumns, ?array $updateColumns = null): mixed; /** - * Batch-inserts multiple entities, updating specified columns on conflict. + * Upserts many entities, sent to the database in batches. * - * @param array $entities Array of entities to upsert - * @param array $conflictColumns Columns that define the conflict target - * @param array|null $updateColumns Columns to update on conflict; null updates all non-conflict columns + * @param iterable $entities Entities to upsert — array, generator, any Traversable + * @param array $conflictColumns Columns that define the conflict target + * @param array|null $updateColumns Column => expression map; null or [] ignores conflicts * @return void * @throws RepositoryException */ - public function upsertGroup(array $entities, array $conflictColumns, ?array $updateColumns = null): void; + public function upsertBatch(iterable $entities, array $conflictColumns, ?array $updateColumns = null): void; } diff --git a/src/Ppa/Repository/RepositoryCrudTrait.php b/src/Ppa/Repository/RepositoryCrudTrait.php index 6b1b8ee..f8a4ab5 100644 --- a/src/Ppa/Repository/RepositoryCrudTrait.php +++ b/src/Ppa/Repository/RepositoryCrudTrait.php @@ -47,17 +47,37 @@ public function insert(object|array $entity): mixed } /** - * Inserts multiple entities in a single batch statement. + * Inserts many entities, sent to the database in batches. * - * @see CDO::insertGroup() - * @param array|object ...$entities One or more entities to insert + * Takes entities directly, or a stream of them, or both: + * + * ``` + * $repo->insertBatch($user1, $user2); // entities + * $repo->insertBatch(['name' => 'John']); // an array is one row + * $repo->insertBatch(...$entities); // an unpacked array + * $repo->insertBatch($generator); // a stream — nothing is held + * $repo->insertBatch($fromCsv, $extraRow); // mixed, in one call + * ``` + * + * The rule is per argument: **an array is one row, anything traversable is a + * stream of rows.** There is no ambiguity — an array is a valid entity here and + * a `Traversable` never is. + * + * Streaming is the reason this shape exists. Rows reach {@see CDO::insertBatch()} + * lazily, and it flushes each batch as it fills, so peak memory follows the batch + * size rather than the size of the job. Building the collection eagerly first — + * `insertBatch(...$halfAMillionEntities)` — costs whatever that array costs; + * handing over a generator costs a batch. + * + * @see CDO::insertBatch() + * @param iterable|object ...$entities Entities, streams of entities, or both. * @return void * @throws RepositoryException */ - public function insertGroup(array|object ...$entities): void + public function insertBatch(iterable|object ...$entities): void { try { - $this->db()->insertGroup($this->originTable(), $entities); + $this->db()->insertBatch($this->originTable(), self::flatten($entities)); } catch (CDOException $exception) { PpaConnectionPool::reportFailure($this->dbConfigClassName, $exception); throw new RepositoryException($exception->getMessage(), $exception->getCode(), $exception); @@ -125,25 +145,59 @@ public function upsert( } /** - * Batch-inserts multiple entities, updating specified columns on conflict. + * Upserts many entities, sent to the database in batches. * - * @see CDO::upsertGroup() - * @param array $entities Array of entities to upsert - * @param array $conflictColumns Columns that define the conflict target - * @param array|null $updateColumns Columns to update on conflict; null updates all non-conflict columns + * `$entities` is any `iterable` — an array of entities, a generator, any + * `Traversable`. A generator keeps peak memory at one batch whatever the total; + * see {@see insertBatch()}. + * + * `$updateColumns` maps **column => expression** (`':new'` for the incoming + * value, `':current'` for the stored one). A plain list of column names is + * refused by CDO with a message showing the corrected call; pass `[]` or `null` + * to ignore conflicts entirely. + * + * @see CDO::upsertBatch() + * @param iterable $entities Entities to upsert. + * @param array $conflictColumns Columns that define the conflict target. + * @param array|null $updateColumns Column => expression map; null or [] ignores conflicts. * @return void * @throws RepositoryException */ - public function upsertGroup( - array $entities, + public function upsertBatch( + iterable $entities, array $conflictColumns, ?array $updateColumns = null ): void { try { - $this->db()->upsertGroup($this->originTable(), $entities, $conflictColumns, $updateColumns); + $this->db()->upsertBatch($this->originTable(), $entities, $conflictColumns, $updateColumns); } catch (CDOException $exception) { PpaConnectionPool::reportFailure($this->dbConfigClassName, $exception); throw new RepositoryException($exception->getMessage(), $exception->getCode(), $exception); } } + + /** + * Turns the variadic arguments into one lazy stream of rows. + * + * An array is a row (a column-value map — the form {@see insert()} takes), while + * anything traversable is a stream of rows to be drained. That asymmetry is not a + * heuristic: an array is a legal entity in this API and a `Traversable` never is, + * so no call is ambiguous. + * + * A generator, so a stream stays a stream all the way into the driver. Collecting + * into an array here would put the whole job back in memory and undo the point. + * + * @param array $entities + * @return \Generator> + */ + private static function flatten(array $entities): \Generator + { + foreach ($entities as $entity) { + if ($entity instanceof \Traversable) { + yield from $entity; + continue; + } + yield $entity; + } + } } diff --git a/src/WinterApplication.php b/src/WinterApplication.php index 57a515a..c7717ba 100644 --- a/src/WinterApplication.php +++ b/src/WinterApplication.php @@ -26,6 +26,7 @@ use Flytachi\Winter\Kernel\App\Config\LoggingConfigurer; use Flytachi\Winter\Kernel\App\Config\ServerSettings; use Flytachi\Winter\Kernel\App\Config\WebConfigurer; +use Flytachi\Winter\Kernel\App\Config\WorkerMemory; use Flytachi\Winter\Kernel\Collector\ConfigurationCollector; use Flytachi\Winter\Kernel\Collector\ImplementorCollector; use Flytachi\Winter\Kernel\Collector\ScopeGraphCollector; @@ -436,6 +437,16 @@ private static function serveHttp( $server = new \Swoole\Http\Server($host, $port); $server->set($settings->toArray()); + // Says the arithmetic out loud before any worker exists: the memory limit is per + // worker and shared by every coroutine in it, so what the box must hold is + // worker_num × limit plus opcache. Warns, never refuses — over-committing is a + // legitimate choice and this is a worst-case estimate. + WorkerMemory::check( + $settings->getMemoryLimit(), + (int) ($settings->toArray()['worker_num'] ?? 1), + LoggerFactory::getLogger('sys'), + ); + $names = []; foreach ($companions as $companion) { $class = (string) $companion->class; @@ -462,7 +473,11 @@ static function () use ($class): void { // Request workers log on 'http' with per-request coroutine isolation, and are // marked eligible to publish their connection-pool utilisation for // `call db pool` — the publisher itself only starts if a pool is ever opened. - $workerStart = static function (\Swoole\Http\Server $server, int $workerId): void { + $memoryLimit = $settings->getMemoryLimit(); + $workerStart = static function (\Swoole\Http\Server $server, int $workerId) use ($memoryLimit): void { + // Per worker, because the limit is a property of this process — and a no-op + // when nothing was configured, so PHP's own value stands. + WorkerMemory::apply($memoryLimit); LoggerFactory::setContextStorage(new CoroutineContext()); LoggerFactory::setDefaultChannel('http'); PoolTelemetry::enable($workerId); diff --git a/tests/App/WorkerMemoryTest.php b/tests/App/WorkerMemoryTest.php new file mode 100644 index 0000000..c9ae339 --- /dev/null +++ b/tests/App/WorkerMemoryTest.php @@ -0,0 +1,238 @@ +invoke(null, $value); + } + + // ── Parsing PHP's shorthand ──────────────────────────────────────────────── + + public function test_php_memory_shorthand_is_understood(): void + { + self::assertSame(256 * 1024 ** 2, $this->toBytes('256M')); + self::assertSame(1024 ** 3, $this->toBytes('1G')); + self::assertSame(512 * 1024, $this->toBytes('512K')); + self::assertSame(1000, $this->toBytes('1000'), 'a bare number is bytes'); + self::assertSame(256 * 1024 ** 2, $this->toBytes(' 256m '), 'case and spacing are PHP-tolerant'); + } + + public function test_unlimited_is_distinguished_from_unparseable(): void + { + self::assertSame(-1, $this->toBytes('-1'), 'unlimited'); + self::assertSame(0, $this->toBytes('nonsense'), 'unparseable — PHP reports it better than we can'); + self::assertSame(0, $this->toBytes('')); + } + + // ── The one configuration that is worse than a limit ─────────────────────── + + /** + * Unlimited does not remove the ceiling, it moves it into the kernel: PHP never + * stops the process, so the OOM killer does — with no shutdown functions, no log + * entry, and the whole container when the server is PID 1. + */ + public function test_unlimited_is_warned_about(): void + { + $logger = $this->check('-1', 4); + + self::assertCount(1, $logger->warnings()); + self::assertStringContainsString('OOM killer', $logger->warnings()[0]); + } + + public function test_a_real_limit_is_not_warned_about(): void + { + self::assertSame([], $this->check('64M', 1)->warnings()); + } + + public function test_an_unparseable_limit_is_left_to_php(): void + { + self::assertSame([], $this->check('not-a-size', 4)->warnings(), 'no second opinion'); + } + + // ── The arithmetic ───────────────────────────────────────────────────────── + + /** The case this exists for: four workers at 512M cannot live in a 1 GiB box. */ + public function test_a_fleet_larger_than_the_box_is_reported(): void + { + $warnings = $this->check('512M', 4, boxBytes: self::GIB)->warnings(); + + self::assertCount(1, $warnings); + self::assertStringContainsString('4 worker(s) × 512 MiB', $warnings[0]); + self::assertStringContainsString('2 GiB', $warnings[0], 'the product is spelled out'); + self::assertStringContainsString('1 GiB', $warnings[0], 'against what the box has'); + } + + public function test_a_fleet_that_fits_is_silent(): void + { + self::assertSame([], $this->check('128M', 4, boxBytes: 4 * self::GIB)->warnings()); + } + + /** The same limit is fine alone and over-committed multiplied — that is the point. */ + public function test_the_worker_count_is_what_turns_a_fit_into_an_over_commit(): void + { + self::assertSame([], $this->check('512M', 1, boxBytes: self::GIB)->warnings()); + self::assertCount(1, $this->check('512M', 8, boxBytes: self::GIB)->warnings()); + } + + /** + * Worst-case estimate, so it warns and never throws: every worker peaking together + * is rare, and over-committing is a legitimate choice. Refusing would break working + * deployments over a guess — unlike the DI scope check, where the condition is + * certainly wrong. + */ + public function test_over_commit_never_throws(): void + { + $this->check('4G', 128, boxBytes: 256 * 1024 ** 2); + + $this->addToAssertionCount(1); // reaching this line is the assertion + } + + public function test_nothing_is_reported_when_the_box_has_no_limit(): void + { + // An unlimited (or unreadable) ceiling cannot be judged, and silence is the + // honest answer — no cgroup limit is set on the test machine either. + self::assertSame([], $this->check('4G', 128)->warnings()); + } + + // ── Applying it ──────────────────────────────────────────────────────────── + + public function test_apply_sets_the_ini_for_this_process(): void + { + $original = ini_get('memory_limit'); + + try { + WorkerMemory::apply('321M'); + self::assertSame('321M', ini_get('memory_limit')); + } finally { + ini_set('memory_limit', $original); + } + } + + /** + * Configure nothing and the framework must not touch the ini at all. + * + * Reaching `ini_set()` with an empty value does not change the limit — it fails and + * leaves it alone — but it does raise `Failed to set memory limit to 0 bytes`, and a + * PHP warning on every worker start reads like a bug. So what is asserted is the + * silence, not just the value. + */ + public function test_apply_without_a_limit_touches_nothing_and_says_nothing(): void + { + $original = ini_get('memory_limit'); + $diagnostics = []; + set_error_handler(static function (int $no, string $message) use (&$diagnostics): bool { + $diagnostics[] = $message; + return true; + }); + + try { + WorkerMemory::apply(null); + WorkerMemory::apply(''); + } finally { + restore_error_handler(); + } + + self::assertSame($original, ini_get('memory_limit')); + self::assertSame([], $diagnostics, 'the ini was never touched'); + } + + // ── Wiring through ServerSettings ────────────────────────────────────────── + + public function test_the_setting_is_absent_until_asked_for(): void + { + self::assertNull(ServerSettings::fromEnv()->getMemoryLimit()); + } + + public function test_the_setting_round_trips(): void + { + self::assertSame('256M', ServerSettings::fromEnv()->memoryLimit('256M')->getMemoryLimit()); + } + + public function test_the_env_shorthand_seeds_it(): void + { + $original = $_ENV['SERVER_MEMORY_LIMIT'] ?? null; + $_ENV['SERVER_MEMORY_LIMIT'] = '512M'; + + try { + self::assertSame('512M', ServerSettings::fromEnv()->getMemoryLimit()); + } finally { + if ($original === null) { + unset($_ENV['SERVER_MEMORY_LIMIT']); + } else { + $_ENV['SERVER_MEMORY_LIMIT'] = $original; + } + } + } + + /** + * It is a PHP ini, not a Swoole option — and Swoole answers an option it does not + * know with `unsupported option` on every start, as it does for + * `max_request_execution_time`. + */ + public function test_it_never_reaches_the_swoole_options(): void + { + $options = ServerSettings::fromEnv()->workers(4)->memoryLimit('256M')->toArray(); + + self::assertArrayHasKey('worker_num', $options); + self::assertArrayNotHasKey('memory_limit', $options); + self::assertArrayNotHasKey('memoryLimit', $options); + } +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +/** A PSR-3 logger that keeps the warnings, so a test can read what was said. */ +final class RecordingLogger extends AbstractLogger +{ + /** @var list */ + private array $lines = []; + + public function log($level, Stringable|string $message, array $context = []): void + { + $this->lines[] = [$level, (string) $message]; + } + + /** @return list */ + public function warnings(): array + { + return array_values(array_map( + static fn(array $line): string => $line[1], + array_filter($this->lines, static fn(array $line): bool => $line[0] === 'warning'), + )); + } +} diff --git a/tests/Integration/Crud/CrudIntegrationTestCase.php b/tests/Integration/Crud/CrudIntegrationTestCase.php index d81fd1c..a6dd247 100644 --- a/tests/Integration/Crud/CrudIntegrationTestCase.php +++ b/tests/Integration/Crud/CrudIntegrationTestCase.php @@ -8,7 +8,7 @@ use Flytachi\Winter\Cdo\Qb; /** - * CRUD test bodies — insert / insertGroup / update / delete / upsert. + * CRUD test bodies — insert / insertBatch / update / delete / upsert. * * Schema/lifecycle are owned by {@see ProductsTableTestCase}. Tests start * with an empty `products` table (setUp truncates) and insert rows with @@ -61,7 +61,7 @@ public function test_insert_omits_null_columns(): void public function test_insert_group_creates_all_rows(): void { - $this->repo()->insertGroup( + $this->repo()->insertBatch( ['id' => 1, 'name' => 'a', 'price' => 1.0], ['id' => 2, 'name' => 'b', 'price' => 2.0], ['id' => 3, 'name' => 'c', 'price' => 3.0], @@ -74,7 +74,7 @@ public function test_insert_group_creates_all_rows(): void public function test_update_changes_matched_rows_only(): void { - $this->repo()->insertGroup( + $this->repo()->insertBatch( ['id' => 1, 'name' => 'old', 'price' => 1.0], ['id' => 2, 'name' => 'keep', 'price' => 2.0], ); @@ -91,7 +91,7 @@ public function test_update_changes_matched_rows_only(): void public function test_delete_removes_only_matched_rows(): void { - $this->repo()->insertGroup( + $this->repo()->insertBatch( ['id' => 1, 'name' => 'a', 'price' => 1.0], ['id' => 2, 'name' => 'b', 'price' => 2.0], ['id' => 3, 'name' => 'c', 'price' => 3.0], diff --git a/tests/Ppa/Repository/InsertBatchFlattenTest.php b/tests/Ppa/Repository/InsertBatchFlattenTest.php new file mode 100644 index 0000000..e127df7 --- /dev/null +++ b/tests/Ppa/Repository/InsertBatchFlattenTest.php @@ -0,0 +1,162 @@ + */ + private function flatten(mixed ...$args): array + { + $flatten = new ReflectionMethod(RepositoryCrudTrait::class, 'flatten'); + + return iterator_to_array($flatten->invoke(null, $args), false); + } + + // ── The rule ─────────────────────────────────────────────────────────────── + + public function test_objects_pass_through_one_by_one(): void + { + $a = new \stdClass(); + $b = new \stdClass(); + + self::assertSame([$a, $b], $this->flatten($a, $b)); + } + + /** The half that could not be guessed: an array is a row, not a collection. */ + public function test_an_array_is_one_row(): void + { + $row = ['name' => 'John', 'email' => 'j@x']; + + self::assertSame([$row], $this->flatten($row), 'an array must not be drained'); + } + + public function test_several_arrays_are_several_rows(): void + { + $first = ['name' => 'John']; + $second = ['name' => 'Jane']; + + self::assertSame([$first, $second], $this->flatten($first, $second)); + } + + public function test_a_generator_is_drained(): void + { + $stream = (static function (): Generator { + yield ['n' => 1]; + yield ['n' => 2]; + yield ['n' => 3]; + })(); + + self::assertSame([['n' => 1], ['n' => 2], ['n' => 3]], $this->flatten($stream)); + } + + public function test_any_traversable_is_drained(): void + { + self::assertSame( + [['n' => 1], ['n' => 2]], + $this->flatten(new ArrayIterator([['n' => 1], ['n' => 2]])), + ); + } + + public function test_the_forms_combine_in_one_call(): void + { + $entity = new \stdClass(); + $stream = (static function (): Generator { + yield ['n' => 1]; + yield ['n' => 2]; + })(); + + self::assertSame( + [$entity, ['n' => 1], ['n' => 2], ['extra' => true]], + $this->flatten($entity, $stream, ['extra' => true]), + ); + } + + public function test_two_streams_are_drained_in_order(): void + { + $first = (static function (): Generator { + yield 'a'; + yield 'b'; + })(); + $second = (static function (): Generator { + yield 'c'; + })(); + + self::assertSame(['a', 'b', 'c'], $this->flatten($first, $second)); + } + + public function test_no_arguments_yield_nothing(): void + { + self::assertSame([], $this->flatten()); + } + + public function test_an_empty_stream_contributes_nothing(): void + { + $empty = (static function (): Generator { + if (false) { + yield 1; + } + })(); + + self::assertSame([['kept' => true]], $this->flatten($empty, ['kept' => true])); + } + + // ── Laziness: the reason the shape exists ────────────────────────────────── + + /** Nothing may be pulled from the source until the driver asks for it. */ + public function test_the_source_is_untouched_until_iterated(): void + { + $produced = 0; + $stream = (static function () use (&$produced): Generator { + for ($i = 0; $i < 5; $i++) { + $produced++; + yield ['n' => $i]; + } + })(); + + $flatten = new ReflectionMethod(RepositoryCrudTrait::class, 'flatten'); + $rows = $flatten->invoke(null, [$stream]); + + self::assertSame(0, $produced, 'building the stream must not consume the source'); + + $rows->current(); + self::assertSame(1, $produced, 'exactly one row is produced to satisfy one read'); + } + + /** …and each row is produced exactly once, however many streams are involved. */ + public function test_each_row_is_produced_once(): void + { + $produced = 0; + $stream = (static function () use (&$produced): Generator { + for ($i = 0; $i < 50; $i++) { + $produced++; + yield ['n' => $i]; + } + })(); + + $rows = $this->flatten($stream, ['tail' => true]); + + self::assertCount(51, $rows); + self::assertSame(50, $produced); + } +} From c30dba12b7ef940048328d1d64ab15b7558ca8da Mon Sep 17 00:00:00 2001 From: flytachi Date: Wed, 5 Aug 2026 17:55:01 +0500 Subject: [PATCH 61/71] timeout control --- doc/STATUS.md | 118 +++++++++ docs/configuration/08-runtime.md | 70 +++++ src/App/Config/ServerSettings.php | 55 +++- src/Route/Annotation/Timeout.php | 60 +++++ src/Route/Collector/MappingCollector.php | 28 +- src/Route/RequestWatchdog.php | 195 ++++++++++++++ src/Route/Router.php | 67 ++++- src/WinterApplication.php | 13 +- tests/Route/RequestWatchdogTest.php | 311 +++++++++++++++++++++++ 9 files changed, 909 insertions(+), 8 deletions(-) create mode 100644 src/Route/Annotation/Timeout.php create mode 100644 src/Route/RequestWatchdog.php create mode 100644 tests/Route/RequestWatchdogTest.php diff --git a/doc/STATUS.md b/doc/STATUS.md index c51138c..2befd8b 100644 --- a/doc/STATUS.md +++ b/doc/STATUS.md @@ -203,6 +203,124 @@ $repo->insertBatch($fromCsv, $extraRow); // вперемешку тривиальный `:new`, карту всё равно пришлось бы выучить при первом выражении, а платить пришлось бы двумя формами навсегда. +### ✅ Границы ресурсов: память воркера (сделано 2026-08-05) + +Первый из трёх пунктов «границ ресурсов запроса». Настраивается двумя способами, как +остальные настройки сервера: + +```dotenv +SERVER_MEMORY_LIMIT=256M +``` +```php +$server->workers(4)->memoryLimit('256M'); +``` + +**Умолчания в ядре нет** — не задал, и `ini` не трогается вообще: остаются PHP-шные +128M (вкомпилированный дефолт, проверено `php -n`). Ни одно существующее приложение от +обновления не меняется. Дефолт живёт в шаблоне Docker — `docker/php-memory.ini`, +**256M**, активен в обоих режимах в отличие от opcache-конфига. + +`memoryLimit()` — не опция Swoole, а PHP-шный `ini`, поэтому в `toArray()` не попадает +(иначе Swoole ответит `unsupported option`, как на `max_request_execution_time`). +Применяется в `workerStart`, то есть на воркер. + +**Проверка на старте предупреждает, не отказывает** (`App\Config\WorkerMemory`): +`worker_num × memory_limit + opcache` против лимита cgroup, плюс отдельно про `-1`. +Не отказ — потому что это оценка по худшему случаю, а переподписка памяти законна; +в отличие от проверки скоупов DI, где условие заведомо неверно. + +**Почему `-1` хуже лимита:** PHP не остановит процесс, его остановит OOM-killer — +`SIGKILL` без shutdown-функций, без записи в лог и вместе со всем контейнером, если +сервер PID 1. Фатал PHP хотя бы называет файл и строку, и мастер поднимает воркер. + +**Замеры, на которых стоит выбор 256M:** + +| | | +|---|---| +| обычный запрос (SQL → JSON) | ~90 КБ кучи в полёте | +| 128M / 256M / 512M | ~1400 / ~2800 / ~5600 одновременных | +| реально в полёте у stress-rp | 200–400 при 2250 rps | +| `worker_num × лимит` на 12 ядрах | 3 ГБ при 256M, 12 ГБ при 1G | + +То есть для обычного трафика запас семи-десятикратный, а толстые эндпоинты потолком +не лечатся вовсе: 150 МБ на запрос — это один такой запрос при 256M и два при 512M. +Лечится стримингом (`insertBatch` с генератором: 100 000 сущностей — 54 MiB массивом +против 1.4 MiB генератором). + +**Известная неточность проверки, оставлена сознательно.** Она сравнивает **учёт PHP** +с лимитом контейнера, а контейнеру нужен **RSS**, который выше на базу процесса. +Замерено на живом воркере: пик кучи PHP 87.69 MB, `top` RSS 114 MB при базе процесса +33 MB — то есть `RSS ≈ база + пик кучи`. Проверка оптимистична примерно на 30 МБ на +воркер. Чинится прибавлением базы (её видно на старте по RSS мастера), но это уточнение, +а не ошибка направления. + +> Тонкость, которую стоит помнить при чтении `top`: PHP освобождает память, но процесс +> **не возвращает её ядру** — аллокатор держит куски для переиспользования. Поэтому RSS +> после тяжёлого запроса остаётся высоким до конца жизни воркера, а +> `memory_get_peak_usage()` не убывает вовсе. Это не утечка. + +### ✅ Границы ресурсов: время жизни запроса (сделано 2026-08-05) + +Второй из трёх пунктов. **Дефолт 30 секунд**, `0` выключает: + +```dotenv +SERVER_REQUEST_TIMEOUT=60 +``` +```php +$server->requestTimeout(60); +``` +```php +#[Timeout(120)] class ReportController // на контроллере +#[Timeout(600)] public function export() // на методе — побеждает метод +#[Timeout(0)] public function stream() // маршрут без дедлайна +``` + +Атрибут разбирается **на скане** и ложится в скомпилированную таблицу маршрутов рядом с +`__cors`/`__middlewares` — на запрос ничего не рефлексируется. + +**Почему 30, а не 60 как у FPM:** под Swoole зависший запрос держит не только себя — +соединение из пула занято на всё время запроса, а пул общий на воркер. У .NET тот же 30. + +**Своего таймаута у Swoole нет — проверено тремя способами.** В списках допустимых опций +расширения (67 серверных + 50 портовых + 23 глобальных) нет ни одной со словом +`execution`; `strings swoole.so` даёт **0** вхождений `max_request_execution_time` (для +контроля: `max_request_grace`, `worker_max_concurrency`, `max_wait_time` — по одному); +живой сервер с `max_request_execution_time => 1` и обработчиком на 3 секунды отвечает +200 через 3 секунды в **обоих** режимах, плюс печатает `unsupported option` на каждый +старт. `set()` значение сохраняет — читать его некому. + +**Как сделано** (`Route\RequestWatchdog`): реестр `cid => дедлайн` + один тик на воркер. +Не таймер на запрос — тот стоит записи в куче реактора, а запись в реестре стоит одной +вставки в массив и `defer` на снятие. И не обход `Coroutine::list()` — тот возвращает +**все** корутины воркера, включая housekeeper пула и телеметрию, а отменить их по +возрасту значит сломать то, чему они принадлежат. + +**Две измеренные вещи определили дизайн:** + +1. **Отмена не липкая.** После `catch (Throwable)` в прикладном коде корутина снова + полностью работоспособна — следующий `sleep(2)` честно спит две секунды, хотя + `isCanceled()` остаётся `true`. Один `cancel` гасится одним `catch`. Поэтому + просроченный запрос отменяется на **каждом** проходе, и его результат отбрасывается + (`hasExpired()` → 504), иначе клиент получил бы 200 с отчётом из невыполненных + запросов. +2. **CPU-запрос не прерывается вовсе** — событийный цикл один, и пока обработчик крутит + цикл без I/O, сам сторож не может проснуться. Замер: тик, назначенный на 0.10 с, + проснулся на 1.91 с, за циклом на 1.8 с. У FPM ограничение зеркальное: он убивает + цикл, но не висящий запрос к базе. + +`finally` и `defer` при отмене отрабатывают — проверено: транзакция закрывается, +соединение возвращается в пул. Живой сервер: `/fast` → 200 за 0.05 с, `/slow` (спит 5 с) +→ 504 за 1.07 с при дедлайне 1 с, `/swallow` (глотает всё) → 504 за 1.98 с. + +**Ломающее при обновлении:** дефолт 30 с включается сам. Маршрут, который легитимно +работает дольше (выгрузка, импорт), начнёт получать 504, пока ему не проставят +`#[Timeout]`. Это осознанный выбор — защита по умолчанию, — но при обновлении стоит +пройтись по долгим эндпоинтам. + +> Тонкость, найденная мутацией: `release()` обязан чистить пометку `expired`, потому что +> **Swoole переиспользует номера корутин**. Оставленная пометка досталась бы следующему +> запросу с тем же id, и он получил бы 504 ни за что. + ### Границы ресурсов запроса (решено делать, 2026-08-04) Поводом стал `Fatal error: Allowed memory size of 134217728 bytes exhausted` под стрессом. diff --git a/docs/configuration/08-runtime.md b/docs/configuration/08-runtime.md index 2a456e3..65bc1b4 100644 --- a/docs/configuration/08-runtime.md +++ b/docs/configuration/08-runtime.md @@ -131,6 +131,76 @@ shutdown functions, no log entry, and the whole container when the server is PID real limit at least fails through PHP, and the manager restarts that one worker. The framework warns at startup when it finds `-1`. +### Request timeout + +A request that never finishes holds more than itself: its pooled database connection is +borrowed for the whole request, and the pool is shared by every request in the worker. +So requests have a deadline — **30 seconds by default**. + +```php +$server->requestTimeout(60); // globally +$server->requestTimeout(0); // no deadline at all +``` + +```dotenv +SERVER_REQUEST_TIMEOUT=60 +``` + +Individual routes override it with `#[Timeout]`, on the controller or on the method — +the method wins: + +```php +use Flytachi\Winter\Kernel\Route\Annotation\Timeout; + +#[RequestMapping('reports')] +#[Timeout(120)] // everything here gets two minutes +class ReportController extends Controller +{ + #[GetMapping('export')] + #[Timeout(600)] // …but the export gets ten + public function export(): ResponseEntity { ... } + + #[GetMapping('stream')] + #[Timeout(0)] // …and this one is never timed out + public function stream(): ResponseEntity { ... } +} +``` + +The attribute is read once, during the scan, and stored in the compiled route table — +nothing is reflected per request. + +When the deadline passes the request's coroutine is cancelled: `finally` and `defer` run +(so transactions close and pooled connections go back), and the client receives +**504 Gateway Timeout**. + +**Swoole has no request timeout of its own.** Verified against the extension: none of its +67 server options concerns execution time, `max_request_execution_time` appears nowhere in +the binary, and setting it changes nothing — a handler sleeping three seconds under a +one-second "limit" still answers 200 after three, in both server modes. What it does +produce is `unsupported option` on every start. + +#### What it can and cannot interrupt + +| The handler | What happens at the deadline | +|---|---| +| waits on I/O and does not swallow errors | interrupted, `finally`/`defer` run, client gets 504 | +| waits on I/O but catches `Throwable` | cannot complete any further I/O; its result is discarded and the client still gets 504 | +| burns CPU without yielding | **not interrupted** — it runs to completion | + +The last row is a property of a single-threaded event loop, not a gap in the +implementation: while a handler loops without touching I/O nothing else in the worker +runs, the watchdog included. Measured — a sweep scheduled for 0.10 s woke at 1.91 s, +behind the 1.8 s loop it was waiting on. + +PHP-FPM has the mirror-image limitation: its `max_execution_time` kills a runaway loop +but not a hung query. Neither model covers both. + +The second row exists because cancellation is **not sticky**: after application code +catches the `CanceledException` the coroutine is fully functional again — a following +`sleep(2)` really sleeps two seconds. So an overdue request is cancelled on every sweep, +and the framework answers 504 rather than sending a report built from queries that never +ran. + ### One worker by default Say nothing and the server runs **a single worker process** — the framework sets no diff --git a/src/App/Config/ServerSettings.php b/src/App/Config/ServerSettings.php index 29440af..aede7ad 100644 --- a/src/App/Config/ServerSettings.php +++ b/src/App/Config/ServerSettings.php @@ -21,12 +21,25 @@ */ final class ServerSettings { + /** + * Seconds a request may run before the watchdog cancels it. + * + * Chosen at 30 rather than PHP-FPM's 60: under Swoole a stuck request holds more + * than itself — a pooled connection is borrowed for the whole request, and the pool + * is shared by every request in the worker. .NET settles on the same 30. + * + * A route that legitimately runs longer says so with #[Timeout]; the global value is + * there to stop the ones that hang by accident. + */ + private const float DEFAULT_REQUEST_TIMEOUT = 30.0; + /** @param array $options */ private function __construct( private string $host, private int $port, private array $options = [], private ?string $memoryLimit = null, + private float $requestTimeout = self::DEFAULT_REQUEST_TIMEOUT, ) { } @@ -58,7 +71,10 @@ public static function fromEnv(string $host = '0.0.0.0', int $port = 8000): self $memoryLimit = env('SERVER_MEMORY_LIMIT'); $memoryLimit = is_string($memoryLimit) && $memoryLimit !== '' ? $memoryLimit : null; - return new self($host, $port, $options, $memoryLimit); + $timeout = env('SERVER_REQUEST_TIMEOUT'); + $timeout = is_numeric($timeout) ? max(0.0, (float) $timeout) : self::DEFAULT_REQUEST_TIMEOUT; + + return new self($host, $port, $options, $memoryLimit, $timeout); } /** Bind host (e.g. '0.0.0.0', '127.0.0.1'). */ @@ -190,6 +206,43 @@ public function getMemoryLimit(): ?string return $this->memoryLimit; } + /** + * How long a request may run before the server stops waiting for it, in seconds. + * `0` disables the deadline. Default: 30. + * + * ``` + * $server->requestTimeout(60); // globally + * $server->requestTimeout(0); // no deadline at all + * ``` + * + * Individual routes override it with + * {@see \Flytachi\Winter\Kernel\Route\Annotation\Timeout} — a report that legitimately + * takes ten minutes carries `#[Timeout(600)]`, and the global value protects + * everything else. + * + * Swoole has no request timeout of its own — verified against the extension: no + * option in its lists concerns execution time, and `max_request_execution_time` is + * absent from the binary and does nothing when set. The deadline is enforced by + * {@see \Flytachi\Winter\Kernel\Route\RequestWatchdog}, which cancels the request's + * coroutine; `finally` and `defer` run, so transactions close and pooled connections + * return, and the client receives `504`. + * + * It interrupts a request that **waits**. A request burning CPU is not interrupted — + * the event loop is single-threaded, so nothing else in the worker runs, the watchdog + * included. See the watchdog's docblock for the full picture. + */ + public function requestTimeout(float $seconds): self + { + $this->requestTimeout = max(0.0, $seconds); + return $this; + } + + /** The configured request deadline in seconds; `0.0` when disabled. */ + public function getRequestTimeout(): float + { + return $this->requestTimeout; + } + /** Set any raw Swoole option. */ public function set(string $key, mixed $value): self { diff --git a/src/Route/Annotation/Timeout.php b/src/Route/Annotation/Timeout.php new file mode 100644 index 0000000..59a4be3 --- /dev/null +++ b/src/Route/Annotation/Timeout.php @@ -0,0 +1,60 @@ +getAttributes(Middleware::class, ReflectionAttribute::IS_INSTANCEOF) ); $classCors = $this->collectCrossOrigin($ref->getAttributes(CrossOrigin::class)); + $classTimeout = $this->collectTimeout($ref->getAttributes(Timeout::class)); foreach ($ref->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { if ($method->name === '__construct') { @@ -66,12 +68,15 @@ public function collect(string $class, ReflectionClass $ref): void $middlewares = array_merge($classMiddlewares, $methodMiddlewares); $cors = $this->collectCrossOrigin($method->getAttributes(CrossOrigin::class)) ?? $classCors; + // The method's own #[Timeout] wins over the controller's; neither + // present leaves the global deadline in force (null). + $timeout = $this->collectTimeout($method->getAttributes(Timeout::class)) ?? $classTimeout; if ($httpMethod !== null) { - $this->router->add($httpMethod, $url, $handler, $middlewares, $cors); + $this->router->add($httpMethod, $url, $handler, $middlewares, $cors, $timeout); } else { foreach (['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as $m) { - $this->router->add($m, $url, $handler, $middlewares, $cors); + $this->router->add($m, $url, $handler, $middlewares, $cors, $timeout); } } } @@ -88,6 +93,25 @@ private function collectMiddlewares(array $attrs): array return $result; } + /** + * The route's own deadline in seconds, or null when it carries no #[Timeout]. + * + * Resolved here, at scan time, so it lands in the compiled route table and the + * mapping cache — nothing is reflected per request. + * + * @param ReflectionAttribute[] $attrs + */ + private function collectTimeout(array $attrs): ?int + { + if (empty($attrs)) { + return null; + } + /** @var Timeout $inst */ + $inst = $attrs[0]->newInstance(); + + return max(0, $inst->seconds); + } + /** @param ReflectionAttribute[] $attrs */ private function collectCrossOrigin(array $attrs): ?array { diff --git a/src/Route/RequestWatchdog.php b/src/Route/RequestWatchdog.php new file mode 100644 index 0000000..07cf108 --- /dev/null +++ b/src/Route/RequestWatchdog.php @@ -0,0 +1,195 @@ + deadline. */ + private static array $deadlines = []; + + /** Requests already cancelled, whose result must not be sent: cid => true. */ + private static array $expired = []; + + /** Swoole timer id of this worker's sweep, or null when not armed. */ + private static ?int $timerId = null; + + /** Default deadline in seconds for requests that do not carry their own; 0 = off. */ + private static float $default = 0.0; + + private function __construct() + { + } + + /** + * Arms the sweep for this worker. Call once from `workerStart`; a no-op when the + * timeout is disabled, so an application that wants none runs no timer at all. + */ + public static function enable(float $seconds): void + { + self::$default = max(0.0, $seconds); + + if (self::$timerId !== null || self::$default <= 0.0 || !extension_loaded('swoole')) { + return; + } + + $interval = min(self::MAX_INTERVAL, max(self::MIN_INTERVAL, self::$default / 4)); + self::$timerId = Timer::tick((int) ($interval * 1000), static fn() => self::sweep()); + } + + /** Disarms the sweep and forgets everything — for worker shutdown and for tests. */ + public static function disable(): void + { + if (self::$timerId !== null && extension_loaded('swoole')) { + Timer::clear(self::$timerId); + } + self::$timerId = null; + self::$default = 0.0; + self::$deadlines = []; + self::$expired = []; + } + + /** + * Registers the current request and returns its coroutine id, or null when there is + * no deadline to enforce (timeout disabled, or not running in a coroutine). + * + * The caller must {@see release()} the id when the request ends — via `defer`, so it + * happens however the request finishes. + */ + public static function register(?float $seconds = null): ?int + { + $seconds ??= self::$default; + if ($seconds <= 0.0 || !Runtime::isSwooleCoroutine()) { + return null; + } + + $cid = Coroutine::getCid(); + self::$deadlines[$cid] = self::now() + $seconds; + + return $cid; + } + + /** + * Replaces the deadline of a registered request — for a route whose own + * {@see \Flytachi\Winter\Kernel\Route\Annotation\Timeout} differs from the global + * one, which is only known after the route has been matched. + */ + public static function extend(?int $cid, float $seconds): void + { + if ($cid === null) { + return; + } + if ($seconds <= 0.0) { + unset(self::$deadlines[$cid]); // the route opts out of the deadline + return; + } + + self::$deadlines[$cid] = self::now() + $seconds; + } + + /** Whether this request was cancelled by the watchdog, so its result must be dropped. */ + public static function hasExpired(?int $cid): bool + { + return $cid !== null && isset(self::$expired[$cid]); + } + + /** Forgets a finished request. Safe to call for an id that was never registered. */ + public static function release(?int $cid): void + { + if ($cid === null) { + return; + } + unset(self::$deadlines[$cid], self::$expired[$cid]); + } + + /** In-flight requests currently being watched — for diagnostics. */ + public static function watching(): int + { + return count(self::$deadlines); + } + + /** + * One pass over the registry: cancel everything past its deadline. + * + * Cancels again on every pass, not only the first, because cancellation is not + * sticky — see the class docblock. + */ + private static function sweep(): void + { + if (self::$deadlines === []) { + return; + } + $now = self::now(); + + foreach (self::$deadlines as $cid => $deadline) { + if ($now < $deadline) { + continue; + } + if (!Coroutine::exists($cid)) { + // Finished between the deadline and this pass; its own defer will clean up. + continue; + } + + self::$expired[$cid] = true; + Coroutine::cancel($cid, true); + } + } + + private static function now(): float + { + return hrtime(true) / 1e9; + } +} diff --git a/src/Route/Router.php b/src/Route/Router.php index 93718f2..98e9efb 100644 --- a/src/Route/Router.php +++ b/src/Route/Router.php @@ -85,19 +85,22 @@ final class Router * maxAge:int, * vary:string[] * }|null $cors + * @param int|null $timeout Per-route deadline in seconds from #[Timeout]; 0 opts + * the route out, null leaves the global deadline in force. */ public function add( string $method, string $path, mixed $handler, array $middlewares = [], - ?array $cors = null + ?array $cors = null, + ?int $timeout = null ): static { $this->dispatcher = null; $method = strtoupper($method); - if ($middlewares !== [] || $cors !== null) { + if ($middlewares !== [] || $cors !== null || $timeout !== null) { $stored = ['__handler' => $handler]; if ($middlewares !== []) { $stored['__middlewares'] = $middlewares; @@ -105,6 +108,9 @@ public function add( if ($cors !== null) { $stored['__cors'] = $cors; } + if ($timeout !== null) { + $stored['__timeout'] = $timeout; + } } else { $stored = $handler; } @@ -435,6 +441,15 @@ public function handle(HttpRequest $request, HttpResponse $response): void $ctx['__request_uri'] = $request->getUri(); } + // Watched from here, with the global deadline; a route carrying its own + // #[Timeout] adjusts it below, once dispatch has said which route this is. + // Released via defer so it happens however the request ends — including the + // cancellation the watchdog itself raises. + $watched = RequestWatchdog::register(); + if ($watched !== null) { + \Swoole\Coroutine::defer(static fn() => RequestWatchdog::release($watched)); + } + try { $method = $request->getMethod(); @@ -465,6 +480,14 @@ public function handle(HttpRequest $request, HttpResponse $response): void } } + // ── Per-route #[Timeout] overrides the global deadline ──────────── + if ($result->status === RouteResult::FOUND) { + $routeTimeout = $this->extractRouteTimeout($result->handler); + if ($routeTimeout !== null) { + RequestWatchdog::extend($watched, (float) $routeTimeout); + } + } + match ($result->status) { RouteResult::FOUND => $this->invoke($result->handler, $request, $response, $result->params), RouteResult::METHOD_NOT_ALLOWED => throw new ResponseException( @@ -473,8 +496,15 @@ public function handle(HttpRequest $request, HttpResponse $response): void )->withHeader('Allow', implode(', ', $result->allowedMethods)), default => throw new ResponseException('Not Found', HttpCode::NOT_FOUND), }; + + // A handler that swallowed the cancellation reaches this line having + // completed no I/O — every wait was cut short. Its answer is built from + // queries that never ran, so it is not the answer to send. + if (RequestWatchdog::hasExpired($watched)) { + throw new ResponseException('Gateway Timeout', HttpCode::GATEWAY_TIMEOUT); + } } catch (\Throwable $e) { - $this->sendError($e, $response); + $this->sendError($this->asTimeout($e, $watched), $response); } } @@ -698,6 +728,37 @@ private function extractRouteCors(mixed $stored): ?array } return null; } + + /** + * Extract the per-route deadline stored by {@see Collector\MappingCollector} under + * '__timeout' — the seconds from a route's own `#[Timeout]`, or null when it has + * none and the global deadline stands. `0` there means the route opts out. + */ + private function extractRouteTimeout(mixed $stored): ?int + { + if (is_array($stored) && array_key_exists('__timeout', $stored)) { + return $stored['__timeout']; + } + return null; + } + + /** + * Presents a watchdog cancellation as `504 Gateway Timeout`. + * + * What surfaces when the deadline passes is `Swoole\Coroutine\CanceledException`, + * thrown wherever the request happened to be waiting — a message about coroutines + * that says nothing to whoever made the request, and would be logged as a server + * fault rather than a deadline. Anything else is left exactly as it was: a request + * that timed out *and* had a real bug should still report the bug. + */ + private function asTimeout(\Throwable $e, ?int $watched): \Throwable + { + if (!RequestWatchdog::hasExpired($watched)) { + return $e; + } + + return new ResponseException('Gateway Timeout', HttpCode::GATEWAY_TIMEOUT, $e); + } // ── Debug helpers ───────────────────────────────────────────────────────── /** @return list */ diff --git a/src/WinterApplication.php b/src/WinterApplication.php index c7717ba..93e6e6d 100644 --- a/src/WinterApplication.php +++ b/src/WinterApplication.php @@ -42,6 +42,7 @@ use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; use Flytachi\Winter\Kernel\Process\ForkReset; use Flytachi\Winter\Kernel\Route\DevWatcher; +use Flytachi\Winter\Kernel\Route\RequestWatchdog; use Flytachi\Winter\Kernel\Route\Router; use Flytachi\Winter\Logger\Context\CoroutineContext; use Flytachi\Winter\Logger\Context\ProcessContext; @@ -473,11 +474,18 @@ static function () use ($class): void { // Request workers log on 'http' with per-request coroutine isolation, and are // marked eligible to publish their connection-pool utilisation for // `call db pool` — the publisher itself only starts if a pool is ever opened. - $memoryLimit = $settings->getMemoryLimit(); - $workerStart = static function (\Swoole\Http\Server $server, int $workerId) use ($memoryLimit): void { + $memoryLimit = $settings->getMemoryLimit(); + $requestTimeout = $settings->getRequestTimeout(); + $workerStart = static function ( + \Swoole\Http\Server $server, + int $workerId + ) use ($memoryLimit, $requestTimeout): void { // Per worker, because the limit is a property of this process — and a no-op // when nothing was configured, so PHP's own value stands. WorkerMemory::apply($memoryLimit); + // One sweep per worker rather than a timer per request; a no-op at 0, so an + // application that wants no deadline runs no timer at all. + RequestWatchdog::enable($requestTimeout); LoggerFactory::setContextStorage(new CoroutineContext()); LoggerFactory::setDefaultChannel('http'); PoolTelemetry::enable($workerId); @@ -488,6 +496,7 @@ static function () use ($class): void { // `workerExit` fires exactly while the reactor is trying to drain, which is // where those timers have to be released. $workerExit = static function (\Swoole\Http\Server $server, int $workerId): void { + RequestWatchdog::disable(); PoolTelemetry::stop($workerId); PpaConnectionPool::shutdown(); }; diff --git a/tests/Route/RequestWatchdogTest.php b/tests/Route/RequestWatchdogTest.php new file mode 100644 index 0000000..dc1eb30 --- /dev/null +++ b/tests/Route/RequestWatchdogTest.php @@ -0,0 +1,311 @@ +addToAssertionCount(1); + } + + /** + * Releasing must also clear the expiry mark, because **Swoole reuses coroutine ids**. + * A mark left behind on a finished request would be found by the next request handed + * the same id, which would then be answered 504 having done nothing wrong. + */ + public function test_releasing_clears_the_expiry_mark_so_a_reused_id_starts_clean(): void + { + $expiredBefore = $expiredAfterReuse = null; + + \Swoole\Coroutine\run(static function () use (&$expiredBefore, &$expiredAfterReuse): void { + RequestWatchdog::enable(0.05); + $cid = null; + + Coroutine::create(static function () use (&$cid, &$expiredBefore): void { + $cid = RequestWatchdog::register(); + try { + Coroutine::sleep(1); + } catch (\Throwable) { + // timed out, as intended + } + $expiredBefore = RequestWatchdog::hasExpired($cid); + RequestWatchdog::release($cid); + }); + + Coroutine::sleep(0.4); + // Whatever id that request had, a later one may be given it again. + $expiredAfterReuse = RequestWatchdog::hasExpired($cid); + RequestWatchdog::disable(); + }); + + self::assertTrue($expiredBefore, 'the request really did time out'); + self::assertFalse($expiredAfterReuse, 'the mark must not outlive the request that earned it'); + } + + // ── The deadline ─────────────────────────────────────────────────────────── + + /** The case the class exists for: a waiting request is interrupted, cleanly. */ + public function test_a_waiting_request_is_cancelled_at_its_deadline(): void + { + $outcome = null; + $finallyRan = false; + $deferRan = false; + + \Swoole\Coroutine\run(static function () use (&$outcome, &$finallyRan, &$deferRan): void { + RequestWatchdog::enable(0.15); + + Coroutine::create(static function () use (&$outcome, &$finallyRan, &$deferRan): void { + $cid = RequestWatchdog::register(); + Coroutine::defer(static function () use (&$deferRan, $cid): void { + $deferRan = true; // the pool returns its connection here + RequestWatchdog::release($cid); + }); + try { + Coroutine::sleep(5); + $outcome = 'finished'; + } catch (\Throwable $e) { + $outcome = $e::class; + } finally { + $finallyRan = true; // …and a transaction closes here + } + }); + + Coroutine::sleep(0.4); + RequestWatchdog::disable(); + }); + + self::assertSame('Swoole\Coroutine\CanceledException', $outcome); + self::assertTrue($finallyRan, 'finally must run — transactions have to close'); + self::assertTrue($deferRan, 'defer must run — pooled connections have to come back'); + } + + public function test_a_request_inside_its_deadline_is_untouched(): void + { + $outcome = null; + + \Swoole\Coroutine\run(static function () use (&$outcome): void { + RequestWatchdog::enable(1.0); + + Coroutine::create(static function () use (&$outcome): void { + $cid = RequestWatchdog::register(); + try { + Coroutine::sleep(0.05); + $outcome = RequestWatchdog::hasExpired($cid) ? 'expired' : 'ok'; + } catch (\Throwable $e) { + $outcome = $e::class; + } + RequestWatchdog::release($cid); + }); + + Coroutine::sleep(0.3); + RequestWatchdog::disable(); + }); + + self::assertSame('ok', $outcome); + } + + /** + * A handler that catches the cancellation must not get away with it: every further + * wait is cut short, and the request stays marked so the framework answers 504 + * instead of sending a result built from queries that never ran. + */ + public function test_swallowing_the_cancellation_does_not_defeat_the_deadline(): void + { + $completedWaits = 0; + $expired = null; + + \Swoole\Coroutine\run(static function () use (&$completedWaits, &$expired): void { + RequestWatchdog::enable(0.1); + + Coroutine::create(static function () use (&$completedWaits, &$expired): void { + $cid = RequestWatchdog::register(); + for ($i = 0; $i < 4; $i++) { + try { + Coroutine::sleep(0.5); + $completedWaits++; // never, once the deadline passed + } catch (\Throwable) { + // swallowed on purpose + } + } + $expired = RequestWatchdog::hasExpired($cid); + RequestWatchdog::release($cid); + }); + + Coroutine::sleep(1.0); + RequestWatchdog::disable(); + }); + + self::assertSame(0, $completedWaits, 'no wait of an overdue request may complete'); + self::assertTrue($expired, 'the framework must know to answer 504'); + } + + // ── Per-route override ───────────────────────────────────────────────────── + + public function test_a_route_may_extend_its_deadline(): void + { + $outcome = null; + + \Swoole\Coroutine\run(static function () use (&$outcome): void { + RequestWatchdog::enable(0.1); + + Coroutine::create(static function () use (&$outcome): void { + $cid = RequestWatchdog::register(); + RequestWatchdog::extend($cid, 5.0); // #[Timeout(5)] on this route + try { + Coroutine::sleep(0.3); // longer than the global deadline + $outcome = 'finished'; + } catch (\Throwable $e) { + $outcome = $e::class; + } + RequestWatchdog::release($cid); + }); + + Coroutine::sleep(0.6); + RequestWatchdog::disable(); + }); + + self::assertSame('finished', $outcome, 'the route\'s own deadline must win'); + } + + /** `#[Timeout(0)]` opts a route out entirely. */ + public function test_a_route_may_opt_out_of_the_deadline(): void + { + $outcome = null; + + \Swoole\Coroutine\run(static function () use (&$outcome): void { + RequestWatchdog::enable(0.1); + + Coroutine::create(static function () use (&$outcome): void { + $cid = RequestWatchdog::register(); + RequestWatchdog::extend($cid, 0.0); + try { + Coroutine::sleep(0.3); + $outcome = 'finished'; + } catch (\Throwable $e) { + $outcome = $e::class; + } + RequestWatchdog::release($cid); + }); + + Coroutine::sleep(0.6); + RequestWatchdog::disable(); + }); + + self::assertSame('finished', $outcome); + self::assertSame(0, RequestWatchdog::watching(), 'an opted-out route is not watched'); + } + + public function test_extending_something_never_registered_is_harmless(): void + { + RequestWatchdog::extend(null, 10.0); + + $this->addToAssertionCount(1); + } + + // ── The limit, stated rather than hidden ─────────────────────────────────── + + /** + * A handler that never yields cannot be interrupted — and the sweep cannot even run + * while it holds the loop. This is a property of a single-threaded event loop, not + * something the watchdog could fix; PHP-FPM has the mirror-image limitation, killing + * a runaway loop but not a hung query. + */ + public function test_a_cpu_bound_request_is_not_interrupted(): void + { + $finished = false; + + \Swoole\Coroutine\run(static function () use (&$finished): void { + RequestWatchdog::enable(0.05); + + Coroutine::create(static function () use (&$finished): void { + $cid = RequestWatchdog::register(); + $x = 0; + for ($i = 0; $i < 20_000_000; $i++) { // no yield point anywhere + $x += $i; + } + $finished = true; + RequestWatchdog::release($cid); + }); + + Coroutine::sleep(0.3); + RequestWatchdog::disable(); + }); + + self::assertTrue($finished, 'a CPU-bound handler runs to completion — documented, not a bug'); + } +} From 93ce50281463897b391dd1edf7df50412d1ee15b Mon Sep 17 00:00:00 2001 From: flytachi Date: Wed, 5 Aug 2026 18:05:17 +0500 Subject: [PATCH 62/71] timeout control --- src/Route/RequestWatchdog.php | 15 +++++++++++++ src/Route/Router.php | 42 ++++++++++++++++++++++------------- 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/src/Route/RequestWatchdog.php b/src/Route/RequestWatchdog.php index 07cf108..560ccb7 100644 --- a/src/Route/RequestWatchdog.php +++ b/src/Route/RequestWatchdog.php @@ -146,6 +146,21 @@ public static function hasExpired(?int $cid): bool return $cid !== null && isset(self::$expired[$cid]); } + /** + * The same question about the request running right now. + * + * Error handling deep in the pipeline has no reference to the id — it only has the + * exception — so it asks about the coroutine it is already in. + */ + public static function isCurrentExpired(): bool + { + if (self::$expired === [] || !Runtime::isSwooleCoroutine()) { + return false; + } + + return isset(self::$expired[Coroutine::getCid()]); + } + /** Forgets a finished request. Safe to call for an id that was never registered. */ public static function release(?int $cid): void { diff --git a/src/Route/Router.php b/src/Route/Router.php index 98e9efb..1239e0d 100644 --- a/src/Route/Router.php +++ b/src/Route/Router.php @@ -496,15 +496,8 @@ public function handle(HttpRequest $request, HttpResponse $response): void )->withHeader('Allow', implode(', ', $result->allowedMethods)), default => throw new ResponseException('Not Found', HttpCode::NOT_FOUND), }; - - // A handler that swallowed the cancellation reaches this line having - // completed no I/O — every wait was cut short. Its answer is built from - // queries that never ran, so it is not the answer to send. - if (RequestWatchdog::hasExpired($watched)) { - throw new ResponseException('Gateway Timeout', HttpCode::GATEWAY_TIMEOUT); - } } catch (\Throwable $e) { - $this->sendError($this->asTimeout($e, $watched), $response); + $this->sendError($e, $response); } } @@ -548,6 +541,13 @@ private function invoke(mixed $stored, HttpRequest $req, HttpResponse $res, arra $result = $mw->after($result); } + // A handler that swallowed the watchdog's cancellation arrives here having + // completed no I/O — every wait was cut short — so its result was built from + // queries that never ran. Answer the deadline instead of that. + if (RequestWatchdog::isCurrentExpired()) { + throw new ResponseException('Gateway Timeout', HttpCode::GATEWAY_TIMEOUT); + } + // ── Serialize return value ──────────────────────────────────────── if ($result instanceof Sendable) { $result->send($res, $req); @@ -561,6 +561,12 @@ private function invoke(mixed $stored, HttpRequest $req, HttpResponse $res, arra private function sendError(\Throwable $e, HttpResponse $res): void { + // Past the deadline, whatever surfaced is a consequence of the cancellation — + // typically Swoole\Coroutine\CanceledException, raised wherever the request + // happened to be waiting. On its own that is an empty message with code 0, which + // would be logged as a server fault and answered 500. Say what actually happened. + $e = $this->asTimeout($e); + if ($e instanceof DebugDumpException) { $res->status(200); $res->header('Content-Type', 'text/html; charset=utf-8'); @@ -743,17 +749,21 @@ private function extractRouteTimeout(mixed $stored): ?int } /** - * Presents a watchdog cancellation as `504 Gateway Timeout`. + * Presents anything raised after the deadline as `504 Gateway Timeout`. + * + * Applied where the response is built rather than where the request is dispatched, + * because {@see invoke()} has a `catch` of its own: it answered the raw + * `CanceledException` — code 0, empty message, logged as a server fault and sent as + * 500 — before the outer handler ever saw it, and the outer 504 then arrived too late + * to change the response and only added a second log line. * - * What surfaces when the deadline passes is `Swoole\Coroutine\CanceledException`, - * thrown wherever the request happened to be waiting — a message about coroutines - * that says nothing to whoever made the request, and would be logged as a server - * fault rather than a deadline. Anything else is left exactly as it was: a request - * that timed out *and* had a real bug should still report the bug. + * A `ResponseException` already carrying 504 is left alone, so re-wrapping cannot + * stack. Everything else on a request that did not time out passes through untouched: + * a request that timed out *and* had a real bug still reports the bug as the cause. */ - private function asTimeout(\Throwable $e, ?int $watched): \Throwable + private function asTimeout(\Throwable $e): \Throwable { - if (!RequestWatchdog::hasExpired($watched)) { + if ($e instanceof ResponseException && $e->getCode() === HttpCode::GATEWAY_TIMEOUT->value) { return $e; } From 7875e07ef1c011556e797e83b7cc7008010011cb Mon Sep 17 00:00:00 2001 From: flytachi Date: Wed, 5 Aug 2026 18:06:31 +0500 Subject: [PATCH 63/71] timeout control --- src/Route/Router.php | 3 + tests/Route/RouterTimeoutResponseTest.php | 144 ++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 tests/Route/RouterTimeoutResponseTest.php diff --git a/src/Route/Router.php b/src/Route/Router.php index 1239e0d..75dd144 100644 --- a/src/Route/Router.php +++ b/src/Route/Router.php @@ -766,6 +766,9 @@ private function asTimeout(\Throwable $e): \Throwable if ($e instanceof ResponseException && $e->getCode() === HttpCode::GATEWAY_TIMEOUT->value) { return $e; } + if (!RequestWatchdog::isCurrentExpired()) { + return $e; + } return new ResponseException('Gateway Timeout', HttpCode::GATEWAY_TIMEOUT, $e); } diff --git a/tests/Route/RouterTimeoutResponseTest.php b/tests/Route/RouterTimeoutResponseTest.php new file mode 100644 index 0000000..4919043 --- /dev/null +++ b/tests/Route/RouterTimeoutResponseTest.php @@ -0,0 +1,144 @@ +handle(new FakeRequest('GET', $uri), $response); + }); + + Coroutine::sleep($deadline + 0.5); + RequestWatchdog::disable(); + }); + + return $response; + } + + /** A handler that waits too long: cancelled, and the client is told why. */ + public function test_a_timed_out_request_answers_504(): void + { + $router = new Router()->get('/', static function () { + Coroutine::sleep(5); + return ResponseEntity::ok('never reached'); + }); + + $response = $this->send($router, 0.15); + + self::assertSame(504, $response->status); + self::assertSame( + ['code' => 504, 'message' => 'Gateway Timeout'], + $response->json(), + 'not the coroutine exception\'s empty message and code 0', + ); + } + + /** + * A handler that catches everything reaches its end with a result built from queries + * that never ran. That result must not be what the client receives. + */ + public function test_a_handler_that_swallows_the_cancellation_still_answers_504(): void + { + $router = new Router()->get('/', static function () { + for ($i = 0; $i < 4; $i++) { + try { + Coroutine::sleep(0.5); + } catch (\Throwable) { + // swallowed on purpose + } + } + return ResponseEntity::ok(['report' => 'built from nothing']); + }); + + $response = $this->send($router, 0.15); + + self::assertSame(504, $response->status); + self::assertSame(['code' => 504, 'message' => 'Gateway Timeout'], $response->json()); + } + + /** The response is written once — a second write would be the old double-send. */ + public function test_the_response_is_sent_exactly_once(): void + { + $router = new Router()->get('/', static function () { + Coroutine::sleep(5); + return ResponseEntity::ok('never reached'); + }); + + $response = $this->send($router, 0.15); + + self::assertTrue($response->ended); + self::assertSame(504, $response->status, 'a later write would have overwritten this'); + } + + public function test_a_request_within_its_deadline_is_untouched(): void + { + $router = new Router()->get('/', static function () { + Coroutine::sleep(0.02); + return ResponseEntity::ok('done'); + }); + + $response = $this->send($router, 1.0); + + self::assertSame(200, $response->status); + self::assertSame('done', $response->body, 'a plain string is sent as-is, not wrapped'); + } + + /** + * A real failure on a request that also timed out must still name the real failure — + * the timeout wraps it as the cause rather than replacing it. + */ + public function test_an_error_that_is_not_a_timeout_keeps_its_own_status(): void + { + $router = new Router()->get('/', static function () { + throw new \RuntimeException('something else went wrong'); + }); + + $response = $this->send($router, 1.0); + + self::assertNotSame(504, $response->status); + } +} From 0bcc89bfe93b5a70a38c8b1aee11d8ba69cfe237 Mon Sep 17 00:00:00 2001 From: flytachi Date: Wed, 5 Aug 2026 18:23:11 +0500 Subject: [PATCH 64/71] timeout control --- doc/STATUS.md | 32 +++++++++++++++++++++++++ src/Http/Response/ResponseException.php | 15 +++++++++++- src/Route/RequestWatchdog.php | 29 +++++++++++++++++++--- tests/Route/RequestWatchdogTest.php | 28 ++++++++++++++++++++++ 4 files changed, 100 insertions(+), 4 deletions(-) diff --git a/doc/STATUS.md b/doc/STATUS.md index 2befd8b..7facd36 100644 --- a/doc/STATUS.md +++ b/doc/STATUS.md @@ -321,6 +321,38 @@ $server->requestTimeout(60); > **Swoole переиспользует номера корутин**. Оставленная пометка досталась бы следующему > запросу с тем же id, и он получил бы 504 ни за что. +> **Две правки после проверки на живом приложении.** +> +> **1. Ответ приходил 500 вместо 504.** У `Router::invoke()` есть свой +> `catch (\Throwable) { sendError() }` — он перехватывал `CanceledException` раньше +> внешнего контроля, логировал её как ERROR и **уже отправлял ответ**. А у этого +> исключения код 0 и пустое сообщение, отсюда `500 {"code":0,"message":""}`; внешний 504 +> логировался второй строкой, но менять было уже нечего. Перевод перенесён в +> `sendError()`, то есть туда, где формируется ответ: один путь, один ответ, одна запись. +> Проверка «обработчик проглотил отмену» переехала внутрь `invoke()`, до сериализации +> результата. Закреплено `tests/Route/RouterTimeoutResponseTest.php`. +> +> **2. `ResponseException` логировался `WARNING` при любом коде.** Для 404 верно, для 504 +> нет — в ядре уже есть разделение `ClientError` → WARNING, `ServerError` → ERROR, и +> Java-канон тот же (4xx тихо, 5xx громко). Теперь уровень зависит от кода: `>= 500` → +> ERROR. Затрагивает любой 5xx, брошенный через `ResponseException`. +> +> Код ответа оставлен **504**, хотя Spring на своём таймауте асинхронного запроса отдаёт +> 503: у нас 503 уже занят актуатором под «здоровье плохое», и смешивать два разных +> состояния одним кодом не стоит. + +> **Точность дедлайна.** Шаг сторожа и есть точность: запрос, ставший просроченным сразу +> после прохода, ждёт следующего. При первом варианте (потолок 1 с, шаг `таймаут / 4`) +> трёхсекундный таймаут отвечал за **3.37 с** — это средний перелёт 750-миллисекундного +> шага, а не сеть. Потолок опущен до **100 мс**: замер после правки — 3.081 / 3.081 / +> 3.087 с. +> +> Цена измерена: проход по реестру — 0.3 мкс при 10 запросах в полёте, 7.4 мкс при 1000, +> 37.4 мкс при 5000. Десять проходов в секунду при тысяче запросов это 74 мкс/с, то есть +> **0.007 % ядра**. Простаивающий воркер со взведённым сторожем: 0.02 с процессорного +> времени за 20 секунд, и это вместе с холостым ходом самого реактора — на пустом реестре +> проход выходит сразу. + ### Границы ресурсов запроса (решено делать, 2026-08-04) Поводом стал `Fatal error: Allowed memory size of 134217728 bytes exhausted` под стрессом. diff --git a/src/Http/Response/ResponseException.php b/src/Http/Response/ResponseException.php index 275748f..932f6d5 100644 --- a/src/Http/Response/ResponseException.php +++ b/src/Http/Response/ResponseException.php @@ -28,8 +28,21 @@ class ResponseException extends \RuntimeException implements ExceptionLogLevel, protected $code = HttpCode::BAD_REQUEST->value; + /** + * `WARNING` for a client error, `ERROR` for a server one — the same split the rest + * of the kernel uses ({@see \Flytachi\Winter\Kernel\Exception\ClientError} is + * `WARNING`, {@see \Flytachi\Winter\Kernel\Exception\ServerError} is `ERROR`). + * + * This class is thrown with whatever status the caller passes, so a flat level was + * right for the 4xx it mostly carries and wrong for the 5xx it also can: a request + * that timed out or hit an upstream failure was filed next to a plain 404, in the + * stream nobody pages on. + * + * A status below 400 keeps `WARNING` too — an exception is not the normal way to + * return one, and saying so quietly is enough. + */ public function getLogLevel(): string { - return LogLevel::WARNING; + return $this->getCode() >= 500 ? LogLevel::ERROR : LogLevel::WARNING; } } diff --git a/src/Route/RequestWatchdog.php b/src/Route/RequestWatchdog.php index 560ccb7..65b9133 100644 --- a/src/Route/RequestWatchdog.php +++ b/src/Route/RequestWatchdog.php @@ -55,8 +55,19 @@ final class RequestWatchdog /** Sweep at most this often, however short the deadline. */ private const float MIN_INTERVAL = 0.05; - /** …and at least this often, however long it is. */ - private const float MAX_INTERVAL = 1.0; + /** + * …and at least this often, however long it is. + * + * The interval is the accuracy: a request that becomes overdue just after a sweep + * waits for the next one, so the deadline overshoots by up to this much and by half + * of it on average. At one second a three-second timeout answered in 3.37 s. + * + * 100 ms costs nothing worth counting. Measured: a sweep over 1 000 in-flight + * requests takes 7.4 µs, so ten a second is 74 µs — 0.007 % of a core; at 5 000 it + * is 0.037 %. An idle worker pays nothing at all, the sweep returning immediately on + * an empty registry. + */ + private const float MAX_INTERVAL = 0.1; /** Deadline (monotonic seconds) per in-flight request coroutine: cid => deadline. */ private static array $deadlines = []; @@ -86,7 +97,7 @@ public static function enable(float $seconds): void return; } - $interval = min(self::MAX_INTERVAL, max(self::MIN_INTERVAL, self::$default / 4)); + $interval = self::sweepInterval(self::$default); self::$timerId = Timer::tick((int) ($interval * 1000), static fn() => self::sweep()); } @@ -203,6 +214,18 @@ private static function sweep(): void } } + /** + * How often to sweep for a given deadline — the accuracy of the deadline itself. + * + * A quarter of the deadline, clamped: never rarer than {@see MAX_INTERVAL}, so a + * long timeout is still measured to 100 ms, and never more often than + * {@see MIN_INTERVAL}, so a sub-second one does not spin the reactor. + */ + private static function sweepInterval(float $seconds): float + { + return min(self::MAX_INTERVAL, max(self::MIN_INTERVAL, $seconds / 4)); + } + private static function now(): float { return hrtime(true) / 1e9; diff --git a/tests/Route/RequestWatchdogTest.php b/tests/Route/RequestWatchdogTest.php index dc1eb30..8f76c82 100644 --- a/tests/Route/RequestWatchdogTest.php +++ b/tests/Route/RequestWatchdogTest.php @@ -42,6 +42,34 @@ protected function tearDown(): void \Swoole\Timer::clearAll(); } + // ── Accuracy of the deadline ─────────────────────────────────────────────── + + private function interval(float $seconds): float + { + return new \ReflectionMethod(RequestWatchdog::class, 'sweepInterval')->invoke(null, $seconds); + } + + /** + * The sweep interval *is* the accuracy: a request that goes overdue right after one + * pass waits for the next, so the deadline overshoots by up to an interval. At one + * second — the first choice here — a three-second timeout answered in 3.37 s, which + * is the average overshoot of a 750 ms sweep, not network noise. + */ + public function test_a_long_deadline_is_still_measured_to_a_tenth_of_a_second(): void + { + self::assertSame(0.1, $this->interval(3.0), 'a 3 s timeout must not sweep every 750 ms'); + self::assertSame(0.1, $this->interval(30.0)); + self::assertSame(0.1, $this->interval(600.0), 'even a ten-minute deadline stays precise'); + } + + /** A short deadline gets a proportionally short sweep, down to a floor. */ + public function test_a_short_deadline_sweeps_proportionally(): void + { + self::assertSame(0.075, $this->interval(0.3), 'a quarter of the deadline'); + self::assertSame(0.05, $this->interval(0.2), '…until the floor'); + self::assertSame(0.05, $this->interval(0.01), 'a tiny deadline must not spin the reactor'); + } + // ── Registration ─────────────────────────────────────────────────────────── public function test_nothing_is_watched_while_the_deadline_is_disabled(): void From a99bd99ea0f2c497ea95b4de9a221d00ec198401 Mon Sep 17 00:00:00 2001 From: flytachi Date: Wed, 5 Aug 2026 18:52:27 +0500 Subject: [PATCH 65/71] timeout control --- src/Route/RequestWatchdog.php | 6 ++- tests/Route/RequestWatchdogTest.php | 72 +++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/Route/RequestWatchdog.php b/src/Route/RequestWatchdog.php index 65b9133..4f33eb5 100644 --- a/src/Route/RequestWatchdog.php +++ b/src/Route/RequestWatchdog.php @@ -205,7 +205,11 @@ private static function sweep(): void continue; } if (!Coroutine::exists($cid)) { - // Finished between the deadline and this pass; its own defer will clean up. + // Finished between its deadline and this pass. Its own defer normally + // clears the entry; dropping it here as well costs nothing and keeps the + // registry from growing for the worker's whole life if a defer is ever + // missed. Unsetting during foreach is safe — the loop walks a copy. + self::release($cid); continue; } diff --git a/tests/Route/RequestWatchdogTest.php b/tests/Route/RequestWatchdogTest.php index 8f76c82..8604498 100644 --- a/tests/Route/RequestWatchdogTest.php +++ b/tests/Route/RequestWatchdogTest.php @@ -305,6 +305,78 @@ public function test_extending_something_never_registered_is_harmless(): void $this->addToAssertionCount(1); } + // ── Nothing accumulates ──────────────────────────────────────────────────── + + /** @return array{deadlines: int, expired: int} */ + private function registrySize(): array + { + return [ + 'deadlines' => count(new \ReflectionProperty(RequestWatchdog::class, 'deadlines')->getValue()), + 'expired' => count(new \ReflectionProperty(RequestWatchdog::class, 'expired')->getValue()), + ]; + } + + /** + * The registry lives as long as the worker, so anything left in it is left for hours. + * Two hundred requests — half of them timing out — must leave it exactly as they + * found it. + */ + public function test_the_registry_returns_to_empty_after_many_requests(): void + { + \Swoole\Coroutine\run(static function (): void { + RequestWatchdog::enable(0.08); + + for ($i = 0; $i < 200; $i++) { + $slow = $i % 2 === 0; + Coroutine::create(static function () use ($slow): void { + $cid = RequestWatchdog::register(); + Coroutine::defer(static fn() => RequestWatchdog::release($cid)); + try { + Coroutine::sleep($slow ? 1.0 : 0.001); + } catch (\Throwable) { + // half of them are cancelled, on purpose + } + }); + } + + Coroutine::sleep(0.6); + RequestWatchdog::disable(); + }); + + self::assertSame(['deadlines' => 0, 'expired' => 0], $this->registrySize()); + } + + /** + * A request that ends between its deadline and the next sweep is never cancelled, so + * only its `defer` would clear it. The sweep drops it too rather than trusting that — + * an entry kept for a coroutine that no longer exists would never leave. + */ + public function test_a_request_gone_before_the_sweep_leaves_nothing_behind(): void + { + $sweep = new \ReflectionMethod(RequestWatchdog::class, 'sweep'); + $deadlines = new \ReflectionProperty(RequestWatchdog::class, 'deadlines'); + + // A coroutine id that has certainly finished, already past its deadline. + $deadlines->setValue(null, [999_999 => hrtime(true) / 1e9 - 1.0]); + + \Swoole\Coroutine\run(static fn() => $sweep->invoke(null)); + + self::assertSame(['deadlines' => 0, 'expired' => 0], $this->registrySize()); + } + + /** Shutting the worker down forgets everything — nothing survives into the next one. */ + public function test_disable_clears_the_registry(): void + { + \Swoole\Coroutine\run(static function (): void { + RequestWatchdog::enable(5.0); + RequestWatchdog::register(); + RequestWatchdog::disable(); + }); + + self::assertSame(['deadlines' => 0, 'expired' => 0], $this->registrySize()); + self::assertSame(0, RequestWatchdog::watching()); + } + // ── The limit, stated rather than hidden ─────────────────────────────────── /** From a0ef188b235c6c8f324f1c442c2d2ed70f3feaf0 Mon Sep 17 00:00:00 2001 From: flytachi Date: Wed, 5 Aug 2026 19:08:43 +0500 Subject: [PATCH 66/71] timeout control --- doc/STATUS.md | 69 +++++++++++ src/App/Config/ServerSettings.php | 53 ++++++++- src/App/Config/WorkerMemory.php | 67 +++++++++++ src/WinterApplication.php | 11 +- tests/App/WorkerMemoryTest.php | 183 +++++++++++++++++++++++++++++- 5 files changed, 380 insertions(+), 3 deletions(-) diff --git a/doc/STATUS.md b/doc/STATUS.md index 7facd36..40d5482 100644 --- a/doc/STATUS.md +++ b/doc/STATUS.md @@ -353,6 +353,75 @@ $server->requestTimeout(60); > времени за 20 секунд, и это вместе с холостым ходом самого реактора — на пустом реестре > проход выходит сразу. +### 🔍 Память воркера не возвращается ядру после тяжёлого запроса (найдено 2026-08-05) + +Замечено на живом приложении: после запроса, построившего 100 000 сущностей, `top` +показывает RSS 114 МБ, а PHP считает занятыми 6.38 МБ. Память освобождена, но **ядру не +отдана** — и держится до конца жизни воркера. На инстансе с несколькими контейнерами это +«кто успел, тот и съел»: один всплеск закрепляет за контейнером память навсегда. + +**Разобрано, причина найдена — и она лечится.** Дело в том, **как** аллокатор PHP получал +память: + +| Что аллоцировали | RSS после освобождения | +|---|---| +| одна строка 200 МБ | **возвращается сразу** — большие блоки идут прямым `mmap`/`munmap` | +| 600 000 мелких объектов | **остаётся 303 МБ** при 10 МБ по счётчику PHP | + +`gc_collect_cycles()` не помогает вовсе (собирает 0). А **`gc_mem_caches()` возвращает**: + +``` +600k объектов: RSS 312 912 KB +освободили: RSS 303 584 KB ← PHP считает 10 МБ +после gc_collect_cycles: RSS 303 584 KB ← без изменений +gc_mem_caches() вернул: 267 444 KB +после: RSS 37 472 KB +``` + +**Цена вызова измерена:** + +| | | +|---|---| +| на разогретом аллокаторе | 80.7 мс (вернул 128 МБ) | +| вхолостую, когда отдавать нечего | 5 мкс | +| повторный разогрев после сброса | +10 % к следующей крупной аллокации | + +То есть звать **после каждого** запроса нельзя — 80 мс это дороже самого запроса. Но звать +**после запроса, чей пик превысил порог**, — дёшево и решает проблему целиком: холостой +вызов стоит 5 мкс, а разогрев отбивается один раз. + +**Предложение (не реализовано):** после завершения запроса сравнивать +`memory_get_peak_usage()` с порогом и, если превышен, звать `gc_mem_caches()` и сбрасывать +пик (`memory_reset_peak_usage()`). Порог — настройкой рядом с `memoryLimit()`, по умолчанию +что-то вроде четверти лимита. Это ровно тот случай, когда «отдал память соседям» стоит +десятых долей процента пропускной способности. + +### 🔍 Линейный рост памяти на запрос — есть и без ядра (найдено 2026-08-05) + +Попутная находка при проверке сторожа на утечки. **Не связана с сегодняшней работой** — +воспроизводится на сервере из десяти строк без единой строки фреймворка. + +| Стенд | Рост | +|---|---| +| голый `Swoole\Http\Server`, обработчик в две строки | ~500 KB на 3000 запросов (**~170 Б/запрос**) | +| он же через `Router::handle()` | ~1633 KB на 3000 запросов (**~557 Б/запрос**) | + +Восемь кругов подряд, прирост ровный до килобайта — это **линейный рост, а не разрастание +пулов с выходом на полку**. + +Что проверено, чтобы исключить ложный след: + +- `gc_collect_cycles()` собирает **0** и не освобождает ничего — не циклы; +- keep-alive (`ab -k`) картину не меняет — не структуры на соединение; +- со сторожем и без него (`enable(0)`, таймер не заводится) скорость роста одинакова — + **не наш сторож**. + +Расходится с наблюдением на `stress-rp`, где RAM осела на 44 МБ и между прогонами не +росла. Возможно, в приложении с полным `bootstrap` и opcache картина другая; возможно, +полка выше достигнутого. **Требует отдельного разбора.** Если рост подтвердится как +линейный, `max_request` (перерождение воркера после N запросов) из «настройки на всякий +случай» становится обязательной. + ### Границы ресурсов запроса (решено делать, 2026-08-04) Поводом стал `Fatal error: Allowed memory size of 134217728 bytes exhausted` под стрессом. diff --git a/src/App/Config/ServerSettings.php b/src/App/Config/ServerSettings.php index aede7ad..2687959 100644 --- a/src/App/Config/ServerSettings.php +++ b/src/App/Config/ServerSettings.php @@ -33,6 +33,15 @@ final class ServerSettings */ private const float DEFAULT_REQUEST_TIMEOUT = 30.0; + /** + * Idle memory a worker may hold before giving it back to the kernel. + * + * An ordinary worker carries two or three megabytes of reserve — the allocator's + * chunk is 2 MB — so 32M is unmistakably "a large request happened here", while + * being small enough that the memory is worth reclaiming. + */ + private const string DEFAULT_MEMORY_TRIM = '32M'; + /** @param array $options */ private function __construct( private string $host, @@ -40,6 +49,7 @@ private function __construct( private array $options = [], private ?string $memoryLimit = null, private float $requestTimeout = self::DEFAULT_REQUEST_TIMEOUT, + private string $memoryTrimThreshold = self::DEFAULT_MEMORY_TRIM, ) { } @@ -74,7 +84,10 @@ public static function fromEnv(string $host = '0.0.0.0', int $port = 8000): self $timeout = env('SERVER_REQUEST_TIMEOUT'); $timeout = is_numeric($timeout) ? max(0.0, (float) $timeout) : self::DEFAULT_REQUEST_TIMEOUT; - return new self($host, $port, $options, $memoryLimit, $timeout); + $trim = env('SERVER_MEMORY_TRIM'); + $trim = is_string($trim) && $trim !== '' ? $trim : self::DEFAULT_MEMORY_TRIM; + + return new self($host, $port, $options, $memoryLimit, $timeout, $trim); } /** Bind host (e.g. '0.0.0.0', '127.0.0.1'). */ @@ -243,6 +256,44 @@ public function getRequestTimeout(): float return $this->requestTimeout; } + /** + * How much idle memory a worker may hold before handing it back to the operating + * system, checked after each request. Default: 32M. `0` never hands anything back. + * + * ``` + * $server->memoryLimit('256M')->memoryTrimThreshold('64M'); + * ``` + * + * PHP releases memory to its own allocator, not to the kernel: many small objects + * live in chunks that are kept for reuse, so a worker that once built a large result + * goes on holding that memory for the rest of its life. On a host running several + * containers that is first-come-first-served — measured, a single request that built + * 600 000 objects left 258 MB reserved and unused. + * + * The threshold is compared against the **reserve** — what the allocator has taken + * from the kernel minus what is actually in use — so a busy worker is never trimmed + * (its memory is in use, the reserve is small) and one trim is enough (releasing + * closes the gap). 32M is far above the two or three megabytes an ordinary worker + * carries, and far below anything worth keeping. + * + * Handing memory back costs about 80 ms when there is a lot of it, and 5 µs when + * there is none — which is what an ordinary request pays. The next large allocation + * pays roughly 10 % more, having to take fresh chunks. + * + * @param string $bytes A PHP memory value: '32M', '512K', or '0' to disable. + */ + public function memoryTrimThreshold(string $bytes): self + { + $this->memoryTrimThreshold = $bytes; + return $this; + } + + /** The configured idle-memory threshold, as written. */ + public function getMemoryTrimThreshold(): string + { + return $this->memoryTrimThreshold; + } + /** Set any raw Swoole option. */ public function set(string $key, mixed $value): self { diff --git a/src/App/Config/WorkerMemory.php b/src/App/Config/WorkerMemory.php index bd255ee..5523cae 100644 --- a/src/App/Config/WorkerMemory.php +++ b/src/App/Config/WorkerMemory.php @@ -111,6 +111,73 @@ public static function check( )); } + /** + * Hands the allocator's idle reserve back to the operating system, if it has grown + * past `$threshold` bytes. Returns the number of bytes released. + * + * PHP frees memory to **its own allocator**, not to the kernel. Whether the kernel + * ever sees it back depends on how it was taken: a single large block is mapped + * directly and unmapped on release, but many small objects live in the allocator's + * chunks, and those chunks are kept for reuse. Measured — 600 000 small objects: + * + * ``` + * in work : used 282 MB, taken from the OS 284 MB → reserve 1.7 MB + * request finished : used 10 MB, taken from the OS 268 MB → reserve 258.0 MB + * after this call : used 10 MB, taken from the OS 12 MB → reserve 2.0 MB + * ``` + * + * That middle line is the problem: the worker holds a quarter of a gigabyte it does + * not use, for the rest of its life. On a host running several containers it is + * first-come-first-served — one spike and the memory is spoken for. + * + * The trigger is the **reserve**, `memory_get_usage(true) - memory_get_usage()`, not + * the peak. Two reasons. While a request is genuinely working the reserve stays small + * (the memory is in use), so sustained load does not trip it — where the peak, which + * never decreases, would trip on every request after the first heavy one. And it is + * self-correcting: releasing closes the gap, so the next call finds nothing to do. + * + * Cost, measured: 80.7 ms when there is 128 MB to return, **5 µs** when there is + * nothing — so the ordinary request pays microseconds — and about 10 % on the next + * large allocation, which has to take fresh chunks. Worth it once after a spike; + * ruinous on every request, which is what the threshold prevents. + * + * `gc_collect_cycles()` does none of this — measured, it collects nothing here and + * frees nothing. Cycles are not what is being held. + */ + public static function trimIfIdle(int $threshold): int + { + if ($threshold <= 0) { + return 0; + } + if (self::idleReserve() < $threshold) { + return 0; + } + + return gc_mem_caches(); + } + + /** + * Memory the allocator has taken from the kernel and is not using — what + * {@see trimIfIdle()} decides on, and worth reading on its own when asking where a + * worker's resident size went. + * + * Its virtue over `memory_get_peak_usage()` is that it **falls again**. A worker in + * the middle of a large request has a high peak and almost no reserve, because the + * memory is in use; once the request ends the reserve is what stayed behind. The peak + * never decreases at all, so it says "something large happened here once" forever and + * cannot tell whether anything is being held now. + */ + public static function idleReserve(): int + { + return memory_get_usage(true) - memory_get_usage(); + } + + /** Parses a PHP memory value ('32M', '512K', '0') into bytes. */ + public static function bytes(string $value): int + { + return self::toBytes($value); + } + /** The container's memory ceiling in bytes, or null when it is unlimited/unknown. */ private static function containerLimitBytes(): ?int { diff --git a/src/WinterApplication.php b/src/WinterApplication.php index 93e6e6d..4f65f43 100644 --- a/src/WinterApplication.php +++ b/src/WinterApplication.php @@ -465,10 +465,19 @@ static function () use ($class): void { )); } - $handler = static function (\Swoole\Http\Request $req, \Swoole\Http\Response $res) use ($router): void { + $trimThreshold = WorkerMemory::bytes($settings->getMemoryTrimThreshold()); + $handler = static function ( + \Swoole\Http\Request $req, + \Swoole\Http\Response $res + ) use ($router, $trimThreshold): void { $request = new SwooleRequest($req); $isHead = strtoupper($request->getMethod()) === 'HEAD'; $router->handle($request, new SwooleResponse($res, $isHead)); + + // After the response, not before it: giving memory back can take tens of + // milliseconds when there is a lot of it, and the client should not wait for + // that. A request that reserved nothing unusual pays 5 µs to find out. + WorkerMemory::trimIfIdle($trimThreshold); }; // Request workers log on 'http' with per-request coroutine isolation, and are diff --git a/tests/App/WorkerMemoryTest.php b/tests/App/WorkerMemoryTest.php index c9ae339..fa01279 100644 --- a/tests/App/WorkerMemoryTest.php +++ b/tests/App/WorkerMemoryTest.php @@ -171,6 +171,149 @@ public function test_apply_without_a_limit_touches_nothing_and_says_nothing(): v self::assertSame([], $diagnostics, 'the ini was never touched'); } + // ── Handing idle memory back ─────────────────────────────────────────────── + + /** + * Leaves the allocator holding roughly 30 MB it no longer needs — the shape of a + * request that built a large result, at a size the 128M test limit tolerates. The + * mechanism does not care about the magnitude; the live case was 258 MB. + */ + private function buildAndDropALargeResult(): void + { + $many = []; + for ($i = 0; $i < 60_000; $i++) { + $many[] = (object) ['a' => "value {$i}", 'b' => $i]; + } + unset($many); + } + + /** + * PHP frees memory to its own allocator, not to the kernel: many small objects live + * in chunks that are kept for reuse, so a worker that once built a large result goes + * on holding that memory for the rest of its life. Measured on a live worker — a + * request building 100 000 entities left `top` reading 114 MB against 6 MB in use. + */ + public function test_a_large_reserve_is_handed_back(): void + { + $this->buildAndDropALargeResult(); + + $reserveBefore = memory_get_usage(true) - memory_get_usage(); + self::assertGreaterThan( + 8 * 1024 ** 2, + $reserveBefore, + 'the fixture must actually leave a reserve, or the test proves nothing', + ); + + $freed = WorkerMemory::trimIfIdle(8 * 1024 ** 2); + + self::assertGreaterThan(0, $freed); + self::assertLessThan( + $reserveBefore, + memory_get_usage(true) - memory_get_usage(), + 'the reserve must actually shrink', + ); + } + + /** An ordinary worker carries a few megabytes of reserve and must be left alone. */ + public function test_a_small_reserve_is_left_alone(): void + { + WorkerMemory::trimIfIdle(1024); // clear whatever this process holds + + self::assertSame(0, WorkerMemory::trimIfIdle(512 * 1024 ** 2), 'nothing near half a gig'); + } + + /** + * The threshold has to be the deciding factor, not a formality: with a reserve worth + * releasing but a threshold above it, nothing may happen. Otherwise every request + * would pay the release — 80 ms when there is a lot to give back. + */ + public function test_a_reserve_below_the_threshold_is_kept(): void + { + $this->buildAndDropALargeResult(); + + $reserve = memory_get_usage(true) - memory_get_usage(); + self::assertGreaterThan(8 * 1024 ** 2, $reserve, 'there is something to release'); + + self::assertSame( + 0, + WorkerMemory::trimIfIdle(512 * 1024 ** 2), + '…but the threshold says it is not worth it', + ); + + WorkerMemory::trimIfIdle(8 * 1024 ** 2); // tidy up for the tests that follow + } + + /** + * Why the decision is made on the reserve and not on the process peak. + * + * A worker in the middle of a large request has a **high peak and almost no + * reserve** — the memory is in use, none of it is idle. Once the request ends the + * reserve is exactly what stayed behind. The peak, by contrast, never decreases: it + * says "something large happened here once" for the rest of the worker's life and + * cannot tell whether anything is being held *now*. + * + * Note what this test does and does not prove. The two triggers differ in **cost**, + * not in outcome: with memory in use, releasing would find nothing to release either + * way, so both return 0. What is provable — and what this asserts — is that the + * reserve tracks what is idle while the peak does not. + */ + public function test_the_reserve_falls_again_while_the_peak_never_does(): void + { + WorkerMemory::trimIfIdle(1024); + + $held = []; + for ($i = 0; $i < 60_000; $i++) { + $held[] = (object) ['a' => "value {$i}", 'b' => $i]; + } + + self::assertGreaterThan(8 * 1024 ** 2, memory_get_peak_usage(), 'the peak is high…'); + self::assertLessThan(8 * 1024 ** 2, WorkerMemory::idleReserve(), '…while nothing is idle'); + self::assertSame(0, WorkerMemory::trimIfIdle(8 * 1024 ** 2), 'a busy worker is left alone'); + + unset($held); + + self::assertGreaterThan( + 8 * 1024 ** 2, + WorkerMemory::idleReserve(), + 'the request ended and its memory is now idle — this is what the peak cannot say', + ); + + WorkerMemory::trimIfIdle(8 * 1024 ** 2); + + self::assertLessThan( + 8 * 1024 ** 2, + WorkerMemory::idleReserve(), + 'and the reserve falls again, which is why one release is enough', + ); + self::assertGreaterThan( + 8 * 1024 ** 2, + memory_get_peak_usage(), + 'while the peak still reads high, and would go on triggering forever', + ); + } + + /** + * Releasing closes the gap, so the next check finds nothing to do. That is what keeps + * a busy worker from paying the cost on every request — unlike the process peak, + * which never decreases and would trip forever after one heavy request. + */ + public function test_the_trigger_is_self_correcting(): void + { + $this->buildAndDropALargeResult(); + + self::assertGreaterThan(0, WorkerMemory::trimIfIdle(8 * 1024 ** 2), 'first call releases'); + self::assertSame(0, WorkerMemory::trimIfIdle(8 * 1024 ** 2), 'second finds nothing'); + } + + public function test_a_zero_threshold_disables_it(): void + { + $this->buildAndDropALargeResult(); + + self::assertSame(0, WorkerMemory::trimIfIdle(0), 'opted out, whatever is held'); + + WorkerMemory::trimIfIdle(8 * 1024 ** 2); // tidy up for the tests that follow + } + // ── Wiring through ServerSettings ────────────────────────────────────────── public function test_the_setting_is_absent_until_asked_for(): void @@ -206,11 +349,49 @@ public function test_the_env_shorthand_seeds_it(): void */ public function test_it_never_reaches_the_swoole_options(): void { - $options = ServerSettings::fromEnv()->workers(4)->memoryLimit('256M')->toArray(); + $options = ServerSettings::fromEnv() + ->workers(4) + ->memoryLimit('256M') + ->memoryTrimThreshold('64M') + ->toArray(); self::assertArrayHasKey('worker_num', $options); self::assertArrayNotHasKey('memory_limit', $options); self::assertArrayNotHasKey('memoryLimit', $options); + self::assertArrayNotHasKey('memoryTrimThreshold', $options); + } + + public function test_the_trim_threshold_defaults_to_32m(): void + { + $original = $_ENV['SERVER_MEMORY_TRIM'] ?? null; + unset($_ENV['SERVER_MEMORY_TRIM']); + + try { + self::assertSame('32M', ServerSettings::fromEnv()->getMemoryTrimThreshold()); + self::assertSame(32 * 1024 ** 2, WorkerMemory::bytes('32M')); + } finally { + if ($original !== null) { + $_ENV['SERVER_MEMORY_TRIM'] = $original; + } + } + } + + public function test_the_trim_threshold_is_configurable(): void + { + self::assertSame('64M', ServerSettings::fromEnv()->memoryTrimThreshold('64M')->getMemoryTrimThreshold()); + + $original = $_ENV['SERVER_MEMORY_TRIM'] ?? null; + $_ENV['SERVER_MEMORY_TRIM'] = '128M'; + + try { + self::assertSame('128M', ServerSettings::fromEnv()->getMemoryTrimThreshold()); + } finally { + if ($original === null) { + unset($_ENV['SERVER_MEMORY_TRIM']); + } else { + $_ENV['SERVER_MEMORY_TRIM'] = $original; + } + } } } From 9d624d3bb4f9e10b69969ebd4d0261bead37df8f Mon Sep 17 00:00:00 2001 From: flytachi Date: Wed, 5 Aug 2026 19:31:04 +0500 Subject: [PATCH 67/71] timeout control --- doc/STATUS.md | 81 ++++++++++++++++++++++--- docs/configuration/08-runtime.md | 99 ++++++++++++++++++++++++++++++- src/App/Config/ServerSettings.php | 41 ++++++++++++- tests/App/WorkerMemoryTest.php | 42 +++++++++++++ 4 files changed, 252 insertions(+), 11 deletions(-) diff --git a/doc/STATUS.md b/doc/STATUS.md index 40d5482..65bd23d 100644 --- a/doc/STATUS.md +++ b/doc/STATUS.md @@ -353,7 +353,7 @@ $server->requestTimeout(60); > времени за 20 секунд, и это вместе с холостым ходом самого реактора — на пустом реестре > проход выходит сразу. -### 🔍 Память воркера не возвращается ядру после тяжёлого запроса (найдено 2026-08-05) +### ✅ Память воркера возвращается ядру после тяжёлого запроса (сделано 2026-08-05) Замечено на живом приложении: после запроса, построившего 100 000 сущностей, `top` показывает RSS 114 МБ, а PHP считает занятыми 6.38 МБ. Память освобождена, но **ядру не @@ -390,13 +390,80 @@ gc_mem_caches() вернул: 267 444 KB **после запроса, чей пик превысил порог**, — дёшево и решает проблему целиком: холостой вызов стоит 5 мкс, а разогрев отбивается один раз. -**Предложение (не реализовано):** после завершения запроса сравнивать -`memory_get_peak_usage()` с порогом и, если превышен, звать `gc_mem_caches()` и сбрасывать -пик (`memory_reset_peak_usage()`). Порог — настройкой рядом с `memoryLimit()`, по умолчанию -что-то вроде четверти лимита. Это ровно тот случай, когда «отдал память соседям» стоит -десятых долей процента пропускной способности. +**Сделано:** `WorkerMemory::trimIfIdle()` вызывается после отправки ответа (после — чтобы +клиент не ждал восьмидесяти миллисекунд). Настройка `memoryTrimThreshold()` / +`SERVER_MEMORY_TRIM`, **по умолчанию 32M**, `0` выключает. Имя от glibc `malloc_trim()` и +Go `debug.FreeOSMemory()` — устоявшееся слово именно для «отдать ядру». -### 🔍 Линейный рост памяти на запрос — есть и без ядра (найдено 2026-08-05) +Почему 32M: обычный воркер несёт 2–3 МБ резерва (чанк аллокатора — 2 МБ), так что это +безошибочно «здесь был тяжёлый запрос», и при этом достаточно мало, чтобы память стоило +забирать. + +**Триггер — резерв, а не пик, и это оказалось важнее выбора порога.** Я собирался считать +по `memory_get_peak_usage()` и отказался: он **не убывает никогда**, поэтому после первого +же тяжёлого запроса срабатывал бы на каждом следующем — 80 мс на запрос до конца жизни +воркера. Резерв `memory_get_usage(true) − memory_get_usage()` мал, пока память **в +работе** (нагруженный воркер не трогаем), и **сам падает** после сброса (одного раза +достаточно). Вынесен публично как `WorkerMemory::idleReserve()` — он же полезен для +диагностики «куда делся RSS». + +Живой замер после правки: RSS 300 МБ → 40 МБ, холостой вызов на обычном запросе 0.1 мкс. + +> Про тесты, честно: мутация «считать по пику вместо резерва» **не ловится в принципе** — +> при памяти в работе `gc_mem_caches()` и так ничего не возвращает, обе версии дают ноль. +> Разница между пиком и резервом — в цене под нагрузкой, а не в результате. Поэтому +> проверяется сам сигнал: резерв мал при работе, велик после завершения, снова мал после +> сброса, а пик всё это время держится высоко. В докблоке теста написано, что он +> доказывает, а что нет. + +### ✅ Утечка Swoole на каждой приостановке корутины — найдена и обойдена (2026-08-05) + +Продолжение находки ниже: линейный рост оказался утечкой **в самом Swoole 6.2.0**, +локализован до строчки, и обойдён перерождением воркеров. + +**Что течёт — ровно 56.0 байта на приостановку корутины.** Пять кругов по 10 000, до +байта одинаково. Переживает и `gc_collect_cycles()`, и `gc_mem_caches()`. + +| Что делаем 10 000 раз | Утечка | +|---|---:| +| корутина, которая **приостанавливается** (`sleep`) | **56.0 B** | +| корутина без приостановки — сразу выходит | 0 | +| `Timer::after` без корутины | 0 | +| просто замыкание | 0 | + +Ни корутина сама по себе, ни таймер по отдельности не текут — только пара. Фреймворка в +воспроизведении нет вовсе: сервер из десяти строк и цикл создания корутин. + +HTTP-запрос **всегда** приостанавливается (база, вышестоящий вызов, запись ответа), +поэтому платит как минимум эти 56 байт; через полный конвейер выходит ближе к 170. + +| Нагрузка | Утечка | Воркеру с 256M хватит на | +|---:|---:|---:| +| 500 rps | 96 MB/ч | 2.7 часа | +| 2 000 rps | 385 MB/ч | **42 минуты** | +| 5 000 rps | 961 MB/ч | 16 минут | + +**Обход — умолчания в ядре:** `max_request = 100 000`, `max_request_grace = 10 000`. +Раньше оба не задавались, то есть воркер жил вечно. + +- **100 000** держит утечку около 17 МБ и делает перерождения редкими. Перерождение не + бесплатно: новый воркер стартует с **пустым пулом** и наполняет его заново (~30 мс), + что на 100 000 запросов даёт 0.0003 мс на запрос. У Laravel Octane дефолт 500 — они + могут, у них нет пула, который надо греть. +- **grace прибавляется, а не вычитается** — проверено: при `max_request = 20, grace = 15` + экземпляры обслужили 30, 27, 34 и 26 запросов, при `grace = 0` — ровно по 20. То есть + реальный предел `max_request + rand(0, grace)`, и это разброс, чтобы воркеры не гасили + пулы одновременно. + +**Ломающее при обновлении:** воркеры начнут перерождаться. Сбрасывается всё, что живёт в +их памяти — `#[Singleton]`, локальные кеши, их доля пула. Формально это могло случиться и +раньше (воркер мог упасть), но теперь происходит по расписанию. + +> Перепроверить после обновления Swoole — воспроизведение занимает одну команду: цикл +> `Coroutine::create(fn() => Coroutine::sleep(0.001))` партиями, с замером +> `memory_get_usage()` после `gc_mem_caches()`. Если станет 0 — умолчания можно поднимать. + +### 🔍 Остаток роста сверх утечки Swoole (открыто 2026-08-05) Попутная находка при проверке сторожа на утечки. **Не связана с сегодняшней работой** — воспроизводится на сервере из десяти строк без единой строки фреймворка. diff --git a/docs/configuration/08-runtime.md b/docs/configuration/08-runtime.md index 65bc1b4..6ef8e55 100644 --- a/docs/configuration/08-runtime.md +++ b/docs/configuration/08-runtime.md @@ -85,9 +85,17 @@ final class WebConfig extends WebConfigurerAdapter } ``` -The `.env` shorthands `SERVER_WORKERS`, `SERVER_TASKS`, `SERVER_MAX_REQUEST`, -`SERVER_MAX_REQUEST_GRACE` and `SERVER_MEMORY_LIMIT` seed the same settings before the -configurer runs. +The `.env` shorthands seed the same settings before the configurer runs: + +| Variable | Method | Default | +|---|---|---| +| `SERVER_WORKERS` | `workers()` | 1 — see below | +| `SERVER_TASKS` | `taskWorkers()` | Swoole's | +| `SERVER_MAX_REQUEST` | `maxRequest()` | `100000` | +| `SERVER_MAX_REQUEST_GRACE` | `maxRequestGrace()` | `10000` | +| `SERVER_MEMORY_LIMIT` | `memoryLimit()` | untouched — PHP's own 128M | +| `SERVER_MEMORY_TRIM` | `memoryTrimThreshold()` | `32M` | +| `SERVER_REQUEST_TIMEOUT` | `requestTimeout()` | `30` seconds | ### Memory per worker @@ -131,6 +139,91 @@ shutdown functions, no log entry, and the whole container when the server is PID real limit at least fails through PHP, and the manager restarts that one worker. The framework warns at startup when it finds `-1`. +### Workers are replaced, and have to be + +A worker does not live forever: after **100 000 requests** it is replaced by a fresh one. +This is a default, not a precaution. + +**Swoole leaks 56 bytes every time a coroutine suspends and resumes.** Measured to the +byte across five runs of ten thousand, and it survives both `gc_collect_cycles()` and +`gc_mem_caches()`. Neither a coroutine on its own nor a timer on its own leaks — only the +pair. An HTTP request always suspends: on the database, on an upstream call, on writing +the response. Through the full pipeline it measures nearer 170 bytes a request. + +| Load | Leaked | A 256M worker lasts | +|---:|---:|---:| +| 500 req/s | 96 MB/h | 2.7 hours | +| 2 000 req/s | 385 MB/h | 42 minutes | +| 5 000 req/s | 961 MB/h | 16 minutes | + +So the choice is not whether to recycle but how often. 100 000 keeps the leak near 17 MB +— comfortably inside any sane limit — while being rare enough that the cost of +replacement disappears. Replacement is not free: the new worker starts with an empty +connection pool and has to fill it, about 30 ms, which spread over 100 000 requests is +0.0003 ms each. + +`SERVER_MAX_REQUEST_GRACE` (default `10000`) is what keeps workers from recycling +together. Swoole **adds** a random amount up to the grace, so the real limit is +`max_request + rand(0, grace)` — verified: at `max_request = 20, grace = 15` worker +instances served 30, 27, 34 and 26 requests, while at `grace = 0` every one served exactly +20. Without it, workers counting to the same number under even traffic go cold almost +together and the survivors take the load. + +> **What a replacement resets.** Everything living in that worker's memory: `#[Singleton]` +> instances, in-process caches, its share of the connection pool. This was always possible +> — a worker could die at any moment — but it now happens on a schedule. State that must +> outlive a request belongs in a database or a cache, not in a singleton's property. + +### Giving memory back + +PHP frees memory to **its own allocator**, not to the kernel. Whether the kernel ever +sees it again depends on how it was taken: one large block is mapped directly and +unmapped on release, but many small objects live in the allocator's chunks, and those +are kept for reuse. So a worker that once built a large result goes on holding that +memory for the rest of its life — measured on a live worker, `top` reading 114 MiB +against 6 MiB actually in use. + +On a host running several containers that is first-come-first-served: one spike and the +memory is spoken for. + +After each request the framework checks how much the allocator is holding idle and hands +it back when that passes a threshold — **32M by default**: + +```php +$server->memoryLimit('256M')->memoryTrimThreshold('64M'); +``` + +```dotenv +SERVER_MEMORY_TRIM=64M +``` + +`0` never hands anything back. + +Measured on 600 000 small objects: + +| | in use | taken from the OS | idle reserve | +|---|---:|---:|---:| +| request working | 282 MB | 284 MB | 1.7 MB | +| request finished | 10 MB | 268 MB | **258 MB** | +| after the release | 10 MB | 12 MB | 2 MB | + +The decision is made on that **reserve** — what the allocator took minus what is in use — +rather than on the process peak, for two reasons. A worker in the middle of a large +request has a high peak and almost no reserve, so sustained load never trips it; and the +reserve falls again once released, so one release is enough. `memory_get_peak_usage()` +never decreases, and would trigger on every request for the rest of the worker's life. + +Cost, measured: **80 ms** when there is 128 MB to give back, **5 µs** when there is +nothing — which is what an ordinary request pays — and about 10 % on the next large +allocation, which has to take fresh chunks. The release runs after the response is sent, +so no client waits for it. + +`gc_collect_cycles()` does none of this: measured, it collects nothing here and frees +nothing. What is being held are not cycles. + +`WorkerMemory::idleReserve()` returns the same number, if you want to see where a +worker's resident size went. + ### Request timeout A request that never finishes holds more than itself: its pooled database connection is diff --git a/src/App/Config/ServerSettings.php b/src/App/Config/ServerSettings.php index 2687959..5e9249e 100644 --- a/src/App/Config/ServerSettings.php +++ b/src/App/Config/ServerSettings.php @@ -42,6 +42,40 @@ final class ServerSettings */ private const string DEFAULT_MEMORY_TRIM = '32M'; + /** + * Requests a worker serves before it is replaced by a fresh one. + * + * Not a precaution — a necessity. Swoole leaks **56 bytes every time a coroutine + * suspends and resumes**, measured to the byte across five runs of ten thousand, and + * surviving both `gc_collect_cycles()` and `gc_mem_caches()`. Neither a coroutine on + * its own nor a timer on its own leaks; only the pair. An HTTP request always + * suspends — on the database, on an upstream call, on writing the response — so every + * request leaves at least that behind, and through the full pipeline it measures + * nearer 170 bytes. At 2 000 req/s that is roughly 385 MB an hour: a worker with a + * 256M limit would die in well under an hour of ordinary traffic. + * + * 100 000 keeps the leak near 17 MB, comfortably inside any sane limit, while being + * rare enough that the cost of replacement disappears. Replacement is not free: the + * new worker starts with an empty connection pool and has to fill it — about 30 ms — + * which spread over 100 000 requests is 0.0003 ms each. At Laravel Octane's default + * of 500 that same cost would be felt. + */ + private const int DEFAULT_MAX_REQUEST = 100_000; + + /** + * How far apart workers are allowed to drift before recycling. + * + * Swoole **adds** a random amount up to this to `max_request`, so the real limit is + * `max_request + rand(0, grace)` — verified: at `max_request = 20, grace = 15` the + * worker instances served 30, 27, 34 and 26 requests, while at `grace = 0` every one + * of them served exactly 20. + * + * Without it, workers counting to the same number under even traffic recycle almost + * together: several connection pools go cold at once and the survivors take the load. + * Ten per cent spreads them by about a minute at 2 000 req/s. + */ + private const int DEFAULT_MAX_REQUEST_GRACE = 10_000; + /** @param array $options */ private function __construct( private string $host, @@ -61,7 +95,12 @@ private function __construct( */ public static function fromEnv(string $host = '0.0.0.0', int $port = 8000): self { - $options = []; + // Framework defaults, before the environment gets a say. Both exist because + // Swoole leaks on every coroutine suspension — see the constants. + $options = [ + 'max_request' => self::DEFAULT_MAX_REQUEST, + 'max_request_grace' => self::DEFAULT_MAX_REQUEST_GRACE, + ]; $map = [ 'SERVER_WORKERS' => 'worker_num', 'SERVER_TASKS' => 'task_worker_num', diff --git a/tests/App/WorkerMemoryTest.php b/tests/App/WorkerMemoryTest.php index fa01279..c3b5bd5 100644 --- a/tests/App/WorkerMemoryTest.php +++ b/tests/App/WorkerMemoryTest.php @@ -361,6 +361,48 @@ public function test_it_never_reaches_the_swoole_options(): void self::assertArrayNotHasKey('memoryTrimThreshold', $options); } + /** + * Worker recycling is on by default, and has to be: Swoole leaks 56 bytes every time + * a coroutine suspends — measured to the byte — and an HTTP request always suspends. + * At 2 000 req/s that is ~385 MB an hour, so a worker that never recycles dies of it. + */ + public function test_worker_recycling_is_configured_by_default(): void + { + $options = ServerSettings::fromEnv()->toArray(); + + self::assertSame(100_000, $options['max_request'], 'a worker must not live forever'); + self::assertSame(10_000, $options['max_request_grace'], 'and they must not all recycle at once'); + } + + /** + * Swoole **adds** the grace to the limit — verified live: at `max_request = 20, + * grace = 15` worker instances served 30, 27, 34 and 26 requests, while at `grace = 0` + * every one served exactly 20. So the default spread is 100 000 – 110 000. + */ + public function test_recycling_can_be_overridden(): void + { + $options = ServerSettings::fromEnv()->maxRequest(500)->maxRequestGrace(50)->toArray(); + + self::assertSame(500, $options['max_request']); + self::assertSame(50, $options['max_request_grace']); + } + + public function test_the_env_shorthand_overrides_the_recycling_default(): void + { + $original = $_ENV['SERVER_MAX_REQUEST'] ?? null; + $_ENV['SERVER_MAX_REQUEST'] = '250000'; + + try { + self::assertSame(250_000, ServerSettings::fromEnv()->toArray()['max_request']); + } finally { + if ($original === null) { + unset($_ENV['SERVER_MAX_REQUEST']); + } else { + $_ENV['SERVER_MAX_REQUEST'] = $original; + } + } + } + public function test_the_trim_threshold_defaults_to_32m(): void { $original = $_ENV['SERVER_MEMORY_TRIM'] ?? null; From ee7f93f0e9b451c36e45709f192f9eae183c5cad Mon Sep 17 00:00:00 2001 From: flytachi Date: Thu, 6 Aug 2026 03:53:57 +0500 Subject: [PATCH 68/71] profile --- doc/STATUS.md | 141 ++++++ docs/configuration/08-runtime.md | 59 ++- docs/configuration/09-web-server.md | 509 +++++++++++++++++++++ docs/console/04-run.md | 3 +- docs/starter/00-quickstart.md | 2 +- src/App/Config/Profile.php | 193 ++++++++ src/App/Config/ServerSettings.php | 342 ++++++++++++-- src/Http/Adapter/FpmRequest.php | 2 +- src/Http/Adapter/SwooleRequest.php | 2 +- src/Http/Contracts/HttpRequest.php | 12 +- src/Route/RequestWatchdog.php | 8 +- src/Route/Router.php | 27 +- src/WinterApplication.php | 31 +- tests/App/WorkerMemoryTest.php | 219 ++++++++- tests/Http/Request/ServerParamTypeTest.php | 79 ++++ tests/Route/RequestWatchdogTest.php | 91 ++++ 16 files changed, 1640 insertions(+), 80 deletions(-) create mode 100644 docs/configuration/09-web-server.md create mode 100644 src/App/Config/Profile.php create mode 100644 tests/Http/Request/ServerParamTypeTest.php diff --git a/doc/STATUS.md b/doc/STATUS.md index 65bd23d..ab64f7a 100644 --- a/doc/STATUS.md +++ b/doc/STATUS.md @@ -612,6 +612,147 @@ O(батч) вместо O(всего) без переписывания при попадёт никогда и будет только его засорять. Значит сначала биндинг `LIMIT`/`OFFSET` параметрами, и только потом кеш. +### ✅ Границы ресурсов: конкурентность запросов (сделано 2026-08-06) + +Третий и последний пункт «границ ресурсов запроса». `maxConcurrency()` → +`worker_max_concurrency`, `SERVER_MAX_CONCURRENCY`. + +**Swoole ставит в очередь, а не отклоняет.** Замер: 20 одновременных запросов по 0.3 с при +пределе 2 — все 20 успешны, `Failed requests: 0`, суммарно 3.3 с вместо 0.3. То есть +перегрузка превращается в задержку, а не в ошибки; ни 503, ни своей очереди не нужно. + +**Умолчание выводится из `memory_limit`:** `limit × 0.5 / 64 КБ` → 128M даёт 1024, 256M — +2048, 512M — 4096. Одним числом не обойтись, и это признано вслух: цена запроса в полёте +меряется десятикратным разбросом — + +| Что делает запрос | Память | +|---|---:| +| ждёт ответа стороннего сервиса | **~10 КБ** (10 000 ждущих = 60 MiB) | +| запрос к базе + сериализация | ~90 КБ | + +64 КБ — середина, и для обоих краёв она неверна: прокси может позволить себе на порядок +больше, сервис отчётов — на порядок меньше. Оба должны сказать это явно. + +Выводится **в момент чтения** (`getMaxConcurrency()`), а не в `fromEnv()` — иначе +`->memoryLimit('512M')` в `WebConfigurer` поднял бы память, а потолок молча остался бы от +128M. Проверено живьём: 128M → 1024, после `memoryLimit('512M')` → 4096. + +**Дедлайн теперь покрывает ожидание в очереди.** Корутина запроса не создаётся, пока воркер +не пропустит его: замер при пределе 1 и обработчике 0.3 с — пять запросов ждали 0.000, +0.301, 0.603, 0.904 и 1.206 с, и обработчик каждого видел только свои 0.3 с. Без правки +запрос, прождавший три секунды, получал бы свежие тридцать. `request_time_float` ставится +до очереди, поэтому `Router` считает истёкшее и передаёт в +`RequestWatchdog::register(elapsed:)`. Передаётся именно **потраченное**, а не момент +старта: сторож живёт на монотонных часах, а метка — на стенных, смешивать нельзя. + +**Это не rate limit и не может им быть.** Не знает, кто звонит, — значит не даст одному +партнёру 50 rps, а другому 20; задерживает, а не отказывает, где квота обязана ответить +`429`; и счётчик на воркер умножается на число воркеров. Отдельная задача, см. ниже. + +### ✅ Профили сервера (сделано 2026-08-06) + +`Profile` — enum из четырёх значений, `$server->profile(...)`, `SERVER_PROFILE`. +По умолчанию `Balance`. Задаёт **пять** настроек: конкурентность, соединения, порог +очистки, `maxRequest` и `maxRequestGrace`. Не задаёт `requestTimeout`, `maxRequestSize`, +`staticPath`, `workers` — это свойства приложения, а не способа тратить память. + +**Ось — форма запроса, а не смелость.** Профиль объявляет одно число: сколько памяти +отводится запросу на собственную работу (Stable 256 КБ, Balance 128, Performance 64). +`Performance` даёт сервису с лёгкими запросами **больше** конкурентности, а не меньше. +Проверяется разработчиком в одну строку — `memory_get_usage()` вокруг обработчика. + +Три константы вывода — замеры, не допущения: + +| Константа | Замер | +|---|---| +| 78 КБ — запрос в полёте | 600 запросов, висящих в тривиальном обработчике: 78.2 КБ каждый | +| 68 КБ — простаивающее соединение | линейно на 401 / 801 / 1201, возвращается при закрытии | +| 170 Б — утечка на запрос | Swoole теряет 56 Б на приостановку корутины, запрос приостанавливается несколько раз | + +База воркера **меряется** (`memory_get_usage(true)` в `fromEnv()`, после bootstrap и до +воркеров), а не предполагается: приложение с сотней маршрутов и тремя пулами само получит +меньший потолок. Зафиксирована один раз при создании — иначе два чтения давали бы +несогласованные между собой пределы. + +Выводится **в момент чтения**, поэтому `->memoryLimit('512M')` в `WebConfigurer` поднимает +всё производное независимо от порядка вызовов. Явное значение и `SERVER_*` побеждают всегда. + +Живая проверка — Swoole принял все четыре: Stable 636/1272, Balance 934/1868, +Performance 1219/2438, Stress — ни одного ключа (умолчания Swoole). + +`Stress` оставлен под своим именем. Он не «Performance, только сильнее»: пропускная +способность упирается задолго до памяти (2 250 rps при c=500, дальше рост встал), поэтому +снятие пределов потолок не поднимает. Он убирает **периодические помехи, портящие замер** — +паузы очистки в p99, замену воркера с холодным пулом посреди прогона, таймер сторожа. +Баннер печатает профиль и во что он развернулся; под `stress` — предупреждение. + +**Попутно исправлено:** прежний вывод `maxConcurrency` считал по 64 КБ на запрос и не +учитывал соединение под ним — при 128M давал 1024 там, где замеренный пол требует ≥78 КБ +на запрос. Доли `0.25/0.4/0.6`, которые я предлагал первым заходом, были выдуманы; заменены +на объявленное число памяти запросу. + +**Найдено и не сделано:** `swoole_cpu_num()` возвращает ядра **хоста**, а не контейнера — +под `--cpus=1` отвечает 12 на 12-ядерной машине (cgroup при этом честно пишет +`cpu.max = 100000 100000`). Поэтому профиль не трогает `workers`, а из примеров в +документации этот вызов убран. Читать cgroup-квоту — отдельная задача. + +### 🐞 `heartbeat_idle_time` рвёт выполняющиеся запросы — умолчание убрано (2026-08-06) + +Введённое накануне умолчание `heartbeat_idle_time = 300` **откачено**. Swoole меряет время +с последней **присылки данных клиентом**, а клиент, ждущий медленный ответ, не шлёт ничего +— значит запрос в работе для него неотличим от брошенного соединения. + +| `heartbeat_idle_time` | Обработчик 5 с | Итог | +|---:|---|---| +| 2 | оборван на **2.07 с** | ответа нет | +| 8 | ответ за 5.00 с | цел | + +Обрыв **молчаливый** — в лог сервера не попадает ни строки. То есть умолчание было бы +скрытым потолком длительности запроса и молча перекрывало бы `#[Timeout(600)]`, который +описан как способ разрешить долгий отчёт. Покупалось этим немного: таблица соединений +между 1 024 и 1 000 000 стоит 160 КБ RSS, а дескрипторов в контейнере ~1 048 576. + +Метод `idleConnectionTimeout()` остался как явная настройка с этим замером в PHPDoc. + +Попутно проверены два заявления из внешнего совета: **`heartbeat_check_interval` для работы +не требуется** (закрывает и в одиночку — замер), а рекомендованные вместо heartbeat +`keepalive_timeout` и `request_timeout` **в Swoole 6.2.0 не существуют** — обе отклоняются +как `unsupported option`, то есть совет не сработал бы вовсе. + +### 🐞 `getServerParam()` падал на числовых ключах (починено 2026-08-06) + +Объявлен `?string`, а оба рантайма хранят часть значений числами. Под `strict_types` это +`TypeError` на **пяти ключах из одиннадцати**, что публикует Swoole: `request_time`, +`request_time_float`, `server_port`, `remote_port`, `master_time`. Спросить порт клиента +было достаточно, чтобы уронить запрос. Под FPM то же с `REQUEST_TIME`/`REQUEST_TIME_FLOAT`. + +Тип расширен до `string|int|float|null` — совместимое направление: приложение, реализующее +`HttpRequest` с `?string`, по-прежнему удовлетворяет контракту (возвращаемый тип разрешено +сужать). Найдено при попытке дотянуться до времени прибытия для дедлайна; в ядре метод не +вызывался ниоткуда, поэтому баг и жил незамеченным. + +### Ограничение частоты по клиентам (задача, поставлена 2026-08-06) + +Отдельно от `maxConcurrency` — это про политику, а не про выживание воркера. Сценарий +пользователя: `integration-bridge-service`, партнёрам обещаны разные квоты (50 rps, 20, 100). + +Чего нет: ни `RateLimit`, ни `Throttle`, ни квот. Строка `RateLimitMiddleware::class` в +`docs/architecture/02-middleware.md` — **пример синтаксиса, класса за ней нет** (легко +принять за существующий). Есть только `HttpCode::TOO_MANY_REQUESTS` и рабочая +`Swoole\Table`. + +Главная ловушка — где живёт счётчик: + +| Где | Чем | Верно ли | +|---|---|---| +| воркер | обычное свойство | **нет** — умножается на `worker_num` | +| контейнер | `Swoole\Table` | да, пока инстанс один | +| несколько контейнеров | Redis | всегда | + +Обсудить перед реализацией: алгоритм (скользящее окно / token bucket), ключ (заголовок, +API-ключ, mTLS), содержимое `Retry-After`, поведение при недоступном Redis — отказывать +или пропускать. + ### SQLite: что осталось (отложено 2026-08-04) **Отложено сознательно** — SQLite в winter не основной диалект, а объём работы великоват diff --git a/docs/configuration/08-runtime.md b/docs/configuration/08-runtime.md index 6ef8e55..0d72074 100644 --- a/docs/configuration/08-runtime.md +++ b/docs/configuration/08-runtime.md @@ -77,7 +77,7 @@ final class WebConfig extends WebConfigurerAdapter { public function configureServer(ServerSettings $server, ApplicationArguments $args): void { - $server->workers(swoole_cpu_num() * 2) + $server->workers(4) ->maxRequest(5000) ->maxRequestGrace(500) ->set('ssl_cert_file', '/etc/ssl/app.pem'); // any raw Swoole option @@ -91,12 +91,52 @@ The `.env` shorthands seed the same settings before the configurer runs: |---|---|---| | `SERVER_WORKERS` | `workers()` | 1 — see below | | `SERVER_TASKS` | `taskWorkers()` | Swoole's | -| `SERVER_MAX_REQUEST` | `maxRequest()` | `100000` | -| `SERVER_MAX_REQUEST_GRACE` | `maxRequestGrace()` | `10000` | +| `SERVER_MAX_REQUEST` | `maxRequest()` | from the profile | +| `SERVER_MAX_REQUEST_GRACE` | `maxRequestGrace()` | from the profile | +| `SERVER_PROFILE` | `profile()` | `balance` | +| `SERVER_MAX_REQUEST_SIZE` | `maxRequestSize()` | `8388608` (8 MB) | +| `SERVER_MAX_CONCURRENCY` | `maxConcurrency()` | from the profile | +| `SERVER_IDLE_TIMEOUT` | `idleConnectionTimeout()` | off | +| `SERVER_MAX_CONNECTIONS` | `maxConnections()` | from the profile | | `SERVER_MEMORY_LIMIT` | `memoryLimit()` | untouched — PHP's own 128M | -| `SERVER_MEMORY_TRIM` | `memoryTrimThreshold()` | `32M` | +| `SERVER_MEMORY_TRIM` | `memoryTrimThreshold()` | from the profile | | `SERVER_REQUEST_TIMEOUT` | `requestTimeout()` | `30` seconds | +### Limits, and the profile behind them + +Most of what bounds a request — how many run at once, how many clients may be connected, +how large a request may be, when a worker is replaced — follows from a single profile: + +```php +$server->profile(Profile::Performance); +``` + +| Profile | One request may use | Suits | +|---|---:|---| +| `Stable` | 256 KB | reports, exports, a monolith with wide joins | +| **`Balance`** (default) | 128 KB | ordinary CRUD | +| `Performance` | 64 KB | a thin API, a proxy, an integration bridge | +| `Stress` | — | benchmarks only | + +The axis is the **shape of a request, not caution**: `Performance` gives a service with +small requests more concurrency, not less. Everything else is derived from measured +constants — 78 KB per in-flight request, 68 KB per open connection, 170 B leaked per +request — against the heap left after the application's own baseline, which is measured at +startup rather than assumed. + +Swoole **queues** what exceeds the concurrency cap rather than refusing it, so overload +becomes latency, not errors — measured, twenty concurrent 0.3-second requests against a +cap of 2 all succeeded, taking 3.3 seconds instead of 0.3. Time spent queueing counts +against the request deadline, since a request's coroutine is not created until the worker +lets it through. + +**None of it is a rate limit.** It has no idea who is calling, so it cannot give one +partner 50 requests a second and another 20, and it delays rather than rejects where a +quota has to answer `429`. + +Every setting, its measurement, and how to override it: +[`09-web-server.md`](09-web-server.md). + ### Memory per worker ```php @@ -141,8 +181,9 @@ framework warns at startup when it finds `-1`. ### Workers are replaced, and have to be -A worker does not live forever: after **100 000 requests** it is replaced by a fresh one. -This is a default, not a precaution. +A worker does not live forever: after a number of requests derived from the profile — +157 903 under `Balance` at 256M — it is replaced by a fresh one. This is a default, not a +precaution. **Swoole leaks 56 bytes every time a coroutine suspends and resumes.** Measured to the byte across five runs of ten thousand, and it survives both `gc_collect_cycles()` and @@ -303,8 +344,9 @@ than stalling the others. What one worker does not give you is more than one CPU core. Measured on a 12-core box, a single worker saturates one core at roughly 2 400 req/s against a database and 8 500 -req/s without one. `->workers(swoole_cpu_num())` is what spreads the load; until then, -extra cores sit idle. +req/s without one. `->workers(n)` is what spreads the load; until then, extra +cores sit idle. Set `n` from the cores the **container** was given — `swoole_cpu_num()` +reports the host's, and answers 12 under `--cpus=1` on a 12-core machine. The setting also changes what a pool size means. `maximumPoolSize` is **per worker**, so one worker makes it the whole server's connection budget, while `workers(12)` multiplies @@ -396,6 +438,7 @@ self-maintaining connection. ## See also +- [`09-web-server.md`](09-web-server.md) — `WebConfig`, and every server setting in one reference - [`01-kernel.md`](01-kernel.md) — paths, `.env`, and the boot order - [`07-di.md`](07-di.md) — singleton lifetime and the shared-state caveat - [`../architecture/01-routing.md`](../architecture/01-routing.md) — the dispatch pipeline behind `handle()` diff --git a/docs/configuration/09-web-server.md b/docs/configuration/09-web-server.md new file mode 100644 index 0000000..ba67d84 --- /dev/null +++ b/docs/configuration/09-web-server.md @@ -0,0 +1,509 @@ +# Web server configuration + +Everything the HTTP server is told — where it binds, how many workers it runs, what a +request is allowed to cost — is set in one place: a class implementing `WebConfigurer`. + +This page is the reference for that class and for every setting on it. For what the +runtime *is* — worker lifetime, shared state, why your code does not change between +transports — see [`08-runtime.md`](08-runtime.md). + +--- + +## The class + +```php +namespace Main\Config; + +use Flytachi\Winter\Kernel\App\ApplicationArguments; +use Flytachi\Winter\Kernel\App\Config\ServerSettings; +use Flytachi\Winter\Kernel\App\Config\WebConfigurerAdapter; + +final class WebConfig extends WebConfigurerAdapter +{ + public function configureServer(ServerSettings $server, ApplicationArguments $args): void + { + $server->port($args->int('port', 8000)) + ->workers(4) + ->memoryLimit('256M'); + } +} +``` + +There is no registration step. The class is found by the boot scan, like every other +`#[Configuration]` or `HealthContributor` — adding configuration is adding a class. + +`WebConfigurerAdapter` supplies empty defaults for both methods of the contract, so you +override only the one you need. Implement `WebConfigurer` directly when you want both: + +| Method | Concern | When it runs | +|---|---|---| +| `configureServer()` | bind address and server tuning | in the master, **before workers fork** | +| `configureCors()` | the global CORS policy | per request — see [`03-cors.md`](03-cors.md) | + +`configureServer()` receives the parsed CLI arguments so *you* decide where the bind +address comes from — a `--port` flag, a flag of your own, `.env`, or a literal. The +handle arrives pre-seeded with the framework default (`--host` / `--port`, falling back +to `0.0.0.0:8000`), so leaving it untouched keeps that. + +Several configurers may exist; each is invoked in turn on the same object. The last +write wins, which is worth knowing if two of them touch the same setting. + +### Where a value comes from + +``` +framework defaults → .env (SERVER_*) → configureServer() → Swoole +``` + +Each stage overrides the one before it, so a value set in code cannot be overridden by +the environment. If you want a setting to stay operator-tunable, read it yourself: + +```php +$server->workers((int) env('APP_WORKERS', 1)); +``` + +Only environment variables that are actually set contribute anything — an unset one +leaves the framework default (or Swoole's) in place. + +--- + +## Profiles + +Five of the settings below are not numbers you have to choose. They follow from one +question — **how much memory does a single request use?** — and a profile is how you +answer it. + +```php +$server->profile(Profile::Performance); +``` + +| Profile | One request may use | Suits | +|---|---:|---| +| `Stable` | 256 KB | reports, exports, a monolith with wide joins | +| **`Balance`** (default) | 128 KB | ordinary CRUD | +| `Performance` | 64 KB | a thin API, a proxy, an integration bridge | +| `Stress` | — | benchmarks only: every cap and every periodic task off | + +**The axis is the shape of a request, not caution.** `Performance` is not "faster but +riskier" — it is for services whose requests are *small*, and it gives such a service +**more** concurrency than `Stable` would, not less. The question is measurable: + +```php +$before = memory_get_usage(); +// ... your handler ... +$after = memory_get_usage(); // under 64 KB → Performance; over 200 KB → Stable +``` + +### What a profile decides + +Everything else follows arithmetically, from measurements rather than preference: + +| Setting | Derivation | +|---|---| +| `maxConcurrency` | available heap ÷ (78 KB + the profile's number + 68 KB) | +| `maxConnections` | twice that — the working ones and one idle client apiece | +| `memoryTrimThreshold` | `memory_limit` ÷ 16 · 8 · 4 | +| `maxRequest` | `memory_limit` × 5% · 10% · 20% ÷ 170 B leaked per request | +| `maxRequestGrace` | a tenth of `maxRequest` | + +The three constants are measured, not assumed: **78 KB** is what an in-flight request +holds before allocating anything of its own (600 requests suspended in a trivial handler +cost 78.2 KB each), **68 KB** is an idle connection (measured linearly at 401, 801 and +1 201), and **170 B** is what Swoole leaks per request. + +"Available heap" is the limit less what the application already holds at boot — and that +baseline is **measured**, not guessed, so an application with a hundred routes and three +connection pools gets a correspondingly smaller ceiling without being asked. + +At 256M, with a booted application carrying ~16M: + +| Profile | In flight | Connections | Trim at | Recycle at | +|---|---:|---:|---:|---:| +| Stable | 611 | 1 222 | 16M | 78 951 | +| Balance | 896 | 1 792 | 32M | 157 903 | +| Performance | 1 170 | 2 340 | 64M | 315 806 | + +Double the memory limit and every number roughly doubles. The profile stays the same +decision, so it does not have to be revisited when the container is resized. + +### Overriding + +A profile supplies **defaults**, resolved when each value is read. Anything set +explicitly wins, whatever order the calls are made in: + +```php +$server->profile(Profile::Performance) + ->maxConnections(10_000) // browsers hold connections open between requests + ->requestTimeout(120); // never part of a profile — see below +``` + +An operator can switch without touching code: + +```dotenv +SERVER_PROFILE=stress +``` + +### `Stress` + +Not "Performance, more so". Throughput plateaus long before the memory ceiling — measured +on a live application, 2 250 req/s at 500 concurrent, with more concurrency adding nothing +— so removing the caps does not raise it. What it removes is **periodic interference that +distorts a measurement**: handing memory back pauses for tens of milliseconds and shows in +p99, replacing a worker empties its connection pool mid-run, and the request watchdog runs +a timer of its own. + +A run under it can exhaust the worker's memory, and over a long one Swoole's per-request +leak accumulates with no replacement to clear it. Both are fine for a bounded benchmark +and for nothing else. The banner says so on startup. + +### What a profile does not decide + +`requestTimeout()`, `maxRequestSize()`, `staticPath()`, `workers()` and the bind address. +Those are properties of the application or the deployment, not of how memory is spent — a +report endpoint needs its ten minutes under every profile. + +`workers()` in particular is left alone because there is nothing to derive it from: +`swoole_cpu_num()` reports the **host's** cores, not the container's. Measured — under +`--cpus=1` it still answers 12 on a 12-core machine, so deriving from it would start twelve +workers on one core. + +--- + +## Reference + +Everything below is a method on `ServerSettings`. Defaults are what you get when nothing +is said. + +| Method | Controls | Default | Environment | +|---|---|---|---| +| `profile()` | the memory stance, and the five settings below it | `Balance` | `SERVER_PROFILE` | +| `host()` | bind address | `0.0.0.0` | `--host` | +| `port()` | bind port | `8000` | `--port` | +| `workers()` | worker processes | `1` | `SERVER_WORKERS` | +| `taskWorkers()` | task worker processes | none | `SERVER_TASKS` | +| `maxRequest()` | requests before a worker is replaced | from profile | `SERVER_MAX_REQUEST` | +| `maxRequestGrace()` | random spread on that number | from profile | `SERVER_MAX_REQUEST_GRACE` | +| `maxRequestSize()` | largest accepted request | `8 MB` | `SERVER_MAX_REQUEST_SIZE` | +| `maxConcurrency()` | requests in flight per worker | from profile | `SERVER_MAX_CONCURRENCY` | +| `requestTimeout()` | seconds a request may run | `30` | `SERVER_REQUEST_TIMEOUT` | +| `maxConnections()` | simultaneous TCP connections | from profile | `SERVER_MAX_CONNECTIONS` | +| `idleConnectionTimeout()` | seconds before a quiet connection is closed | off | `SERVER_IDLE_TIMEOUT` | +| `memoryLimit()` | PHP heap per worker | PHP's own (128M) | `SERVER_MEMORY_LIMIT` | +| `memoryTrimThreshold()` | idle memory before it is returned to the OS | from profile | `SERVER_MEMORY_TRIM` | +| `staticPath()` | directory served as static files | none | — | +| `set()` | any raw Swoole option | — | — | + +--- + +### Bind address + +```php +$server->host('127.0.0.1')->port(8080); +``` + +`host()` and `port()` decide where the server listens. Binding to `127.0.0.1` makes the +server unreachable from outside the machine, which is what you want when a reverse proxy +sits in front of it on the same host; `0.0.0.0` accepts from anywhere, which is what a +container needs. + +Read them back with `getHost()` and `getPort()` — the banner does. + +--- + +### Processes + +```php +$server->workers(4)->taskWorkers(2); +``` + +**`workers()`** — how many worker processes serve requests. One by default, and nothing +derives it, deliberately: a worker handles many requests at once through coroutines, so one worker is +already concurrent, and the first thing extra workers cost is memory (each has its own +heap, its own connection pool, its own singletons). Add them when a profile says the +worker is CPU-bound, not on principle. + +Scaling is close to linear when the bottleneck is in PHP — measured on an endpoint with +no database, 8 551 → 16 265 rps going from one worker to two. It is not linear when the +bottleneck is elsewhere: the same jump on a database-backed endpoint gave 2 049 → 2 331 +rps, because PostgreSQL was already using eleven of the machine's twelve cores. + +**`taskWorkers()`** — processes for Swoole's task queue. Unset by default. The kernel +does not use them; they are here for applications that call `$server->task()` directly. + +--- + +### Worker replacement + +```php +$server->maxRequest(100_000)->maxRequestGrace(10_000); +``` + +**`maxRequest()`** — requests a worker serves before it is replaced by a fresh one. Not +a precaution but a necessity: Swoole leaks **56 bytes every time a coroutine suspends and +resumes**, measured to the byte across five runs of ten thousand, and surviving both +`gc_collect_cycles()` and `gc_mem_caches()`. An HTTP request always suspends — on the +database, on an upstream call, on writing the response — so every request leaves at least +that behind, and through the full pipeline it measures nearer 170 bytes. At 2 000 req/s +that is roughly 385 MB an hour. + +100 000 keeps the leak near 17 MB while making replacement rare enough to be free: the +new worker starts with an empty connection pool and has to fill it, about 30 ms, which +spread over 100 000 requests is 0.0003 ms each. + +**`maxRequestGrace()`** — Swoole **adds** a random amount up to this to `max_request`, so +the real limit is `max_request + rand(0, grace)`. Verified: at `max_request = 20, +grace = 15` worker instances served 30, 27, 34 and 26 requests, while at `grace = 0` +every one served exactly 20. + +Without it, workers counting to the same number under even traffic recycle almost +together — several connection pools go cold at once and the survivors take the load. + +Replacement also closes that worker's connections, which is worth knowing: it is what +reclaims connections abandoned by clients that vanished without closing. + +--- + +### Request size + +```php +$server->maxRequestSize(32 * 1024 * 1024); +``` + +The largest request accepted, **headers included**. Swoole's own limit is 2 MB — measured, +2 000 KB passes and 2 048 KB comes back `413` — which is tight for anything accepting a +document or an image, and invisible when it bites: the client gets a bare status with +nothing explaining it. The default of 8 MB matches PHP's `post_max_size`. + +The limit covers the whole packet, not the body alone: at the default a body of +8 388 400 bytes passes and 8 388 608 does not, the difference being the request's own +headers. Leave room for them when sizing an upload endpoint. + +Raise it knowing the cost: the request sits in the worker's heap for its whole life, and +that heap is shared by every request in flight. It does **not** cost anything per +connection — measured, per-connection memory is identical at 2 MB, 8 MB and 32 MB. + +--- + +### Concurrency + +```php +$server->maxConcurrency(10_000); +``` + +How many requests one worker processes at the same time. This is the setting that +protects the worker: `memoryLimit()` decides when it dies, this decides whether it gets +there. + +Swoole **queues** what exceeds the cap rather than refusing it — measured, twenty +concurrent 0.3-second requests against a cap of 2 all succeeded, taking 3.3 seconds in +total instead of 0.3. Overload becomes latency, not errors. + +The default comes from the profile — see [Profiles](#profiles) above. +`getMaxConcurrency()` returns what will be applied. + +Whether raising it (or the memory limit behind it) buys anything depends on whether it was +what bound. `worker_concurrency` in `Swoole\Server::stats()` answers that: sitting at the +ceiling means requests are queueing, well below it means the bottleneck is elsewhere. + +Time spent queueing counts against the request deadline: a request's coroutine is not +created until the worker lets it through, so without that a client that waited three +seconds would then be granted a fresh thirty. + +**It is not a rate limit.** It does not know who is calling, so it cannot give one partner +50 requests a second and another 20, and it delays rather than rejects where a quota has +to answer `429`. + +--- + +### Request timeout + +```php +$server->requestTimeout(60); // globally +$server->requestTimeout(0); // no deadline at all +``` + +Seconds a request may run before the server stops waiting for it. Thirty by default, +rather than PHP-FPM's sixty, because under Swoole a stuck request holds more than itself: +a pooled connection is borrowed for the whole request, and the pool is shared by every +request in the worker. + +Swoole has no request timeout of its own — verified against the extension, and +`max_request_execution_time` is rejected as an unsupported option. The deadline is +enforced by the framework's own watchdog, which cancels the request's coroutine: `finally` +and `defer` run, so transactions close and pooled connections return, and the client +receives `504`. + +Individual routes override it with `#[Timeout]`: + +```php +#[RequestMapping('reports')] +#[Timeout(600)] // ten minutes for everything in this controller +final class ReportController extends Controller +{ + #[GetMapping('quick')] + #[Timeout(5)] // …except this one + public function quick(): array { /* … */ } +} +``` + +It interrupts a request that **waits**. A request burning CPU is not interrupted, because +the event loop is single-threaded — nothing else in the worker runs while it holds the +CPU, the watchdog included. + +--- + +### Connections + +```php +$server->maxConnections(2000)->idleConnectionTimeout(600); +``` + +**`maxConnections()`** — the largest number of simultaneous TCP connections, itself +clamped by the process's file-descriptor limit. Left at Swoole's 100 000. + +Connections are not requests, though it is easy to read them that way: a keep-alive +connection serves many requests one after another, and an idle one serves none. Measured +— `max_connection = 2` did not slow six concurrent requests at all, while +`worker_max_concurrency = 2` doubled their total time by queueing them. + +They are not free, however. A held keep-alive connection costs about **68 KB of PHP heap** +— measured linearly at 401, 801 and 1 201 connections — so it counts against +`memoryLimit()`, and a worker with the default 128M dies at roughly 1 900 held +connections. The memory returns when they close, and connections spread across workers +(two workers holding 1 200 between them used ~43 MB each rather than 84 MB on one). + +**`idleConnectionTimeout()`** — closes a connection that has gone quiet. Off by default, +and understand it before turning it on: Swoole measures the time since the client last +*sent* something, and a client waiting for a slow response sends nothing, so **a request +still being worked on looks exactly like an abandoned connection**. Measured — with the +timeout at 2 seconds a 5-second request was cut at 2.07 s and the client got no response; +at 8 seconds the same request returned normally at 5.00 s. Neither wrote anything to the +log. + +So it is a ceiling on request duration as much as on idleness, and it would silently +overrule `#[Timeout]`. Set it above the longest request the application permits. What it +buys is reclaiming connections from clients that vanished without closing — and worker +replacement already does that, which is why it is not on by default. + +(`heartbeat_check_interval` is not required for it to work, a common claim and verified +false here.) + +--- + +### Memory + +```php +$server->memoryLimit('256M')->memoryTrimThreshold('64M'); +``` + +**`memoryLimit()`** — the PHP heap ceiling for each worker. Untouched by default, so PHP's +own value stands (128M compiled in, unless a php.ini raises it). + +It is **per worker and shared by every coroutine in it** — a Swoole worker serves many +requests out of one heap, so this bounds their sum, not any single request. Raising it +moves the threshold; it does not remove it. What stops a worker dying is bounding +concurrency. + +At boot the framework says the arithmetic out loud — `worker_num × memoryLimit` plus +opcache's shared memory against the container's limit — and warns when the fleet will not +fit. It warns rather than refuses: every worker peaking at once is rare, and +over-committing is a legitimate choice. + +`-1` is accepted but warned about: without a limit PHP never stops, so a runaway request +grows until the kernel's OOM killer sends `SIGKILL` — no shutdown functions, no log line, +and the whole container when the server is PID 1. A real limit at least fails through PHP, +which the manager recovers from. + +**`memoryTrimThreshold()`** — how much idle memory a worker may hold before handing it +back to the operating system, checked after each request. `0` never hands anything back. + +PHP releases memory to its own allocator, not to the kernel: many small objects live in +chunks that are kept for reuse, so a worker that once built a large result goes on holding +that memory for the rest of its life. Measured — one request that built 600 000 objects +left 258 MB reserved and unused. + +The threshold is compared against the **reserve** (what the allocator took from the kernel +minus what is in use), so a busy worker is never trimmed and one trim is enough. It costs +about 80 ms when there is a lot to return and 5 µs when there is nothing, which is what an +ordinary request pays. + +--- + +### Static files + +```php +$server->staticPath('resources/static'); // resources/static/app.css → /app.css +``` + +Serves files from a directory using Swoole's own handler, which answers in C before PHP is +involved: it streams the file rather than reading it into the worker, honours `Range`, and +cannot be walked out of the directory with `..`. + +Opt-in — say nothing and no file is ever served, which is what an API-only service wants. +Relative paths resolve against the project root, and a missing directory throws +`ApplicationConfigException` rather than becoming silent 404s at runtime. + +Three consequences: + +- **The directory is the URL root.** Swoole appends the whole request path to it, so the + layout on disk mirrors the layout in URLs. Point it at a directory holding assets and + nothing else — everything under it becomes downloadable. +- **Static responses never reach PHP**, so middleware, CORS and request logging do not + apply to them. +- **One directory only.** `document_root` is a single value; a plugin's assets have to be + collected into the one root rather than mounted from a second. + +--- + +### Raw options + +```php +$server->set('ssl_cert_file', '/etc/ssl/app.pem') + ->set('static_handler_locations', ['/assets']); +``` + +`set()` writes any Swoole option directly, for the ones the framework does not wrap. It is +the escape hatch, not a fallback — a wrapped setting should be set through its method, so +the reasoning above stays attached to it. + +An option Swoole does not recognise is **not** an error that stops the server: it prints +`unsupported option` as a warning and carries on. Watch for that line when a setting from +a blog post appears to do nothing — `keepalive_timeout` and `request_timeout`, for +instance, do not exist in Swoole 6.2. + +--- + +## Reading the result + +| | | +|---|---| +| `getHost()`, `getPort()` | the bind address | +| `getProfile()` | the profile in force, configured or default | +| `getMaxConcurrency()` | the ceiling that will apply, derived or configured | +| `getMaxConnections()` | connections allowed at once | +| `getMaxRequest()`, `getMaxRequestGrace()` | when a worker is replaced; `0` = never | +| `getMemoryLimit()` | the configured limit, or `null` when the ini is left alone | +| `getRequestTimeout()` | the deadline in seconds; `0.0` when disabled | +| `getMemoryTrimThreshold()` | the threshold, as written | +| `toArray()` | the Swoole options only | + +`toArray()` is what reaches `Swoole\Http\Server::set()`. Three of the settings above are +deliberately absent from it — `memoryLimit`, `requestTimeout` and `memoryTrimThreshold` +are a PHP ini value and two framework mechanisms, and Swoole would answer each with +`unsupported option` on every start. + +--- + +## Source + +- `src/App/Config/WebConfigurer.php`, `WebConfigurerAdapter.php` — the contract +- `src/App/Config/ServerSettings.php` — every setting on this page +- `src/App/Config/Profile.php` — the four profiles and the arithmetic behind them +- `src/App/Config/WorkerMemory.php` — the boot check and the idle trim +- `src/Route/RequestWatchdog.php` — the request deadline +- `src/WinterApplication.php` — `serve()`, and the `workerStart` / `workerExit` handlers + +## See also + +- [`08-runtime.md`](08-runtime.md) — what the runtime is, and the caveats that come with a long-lived worker +- [`03-cors.md`](03-cors.md) — the other half of `WebConfigurer` +- [`01-kernel.md`](01-kernel.md) — paths, `.env`, and the boot order diff --git a/docs/console/04-run.md b/docs/console/04-run.md index 453aa28..5fbfbfe 100644 --- a/docs/console/04-run.md +++ b/docs/console/04-run.md @@ -122,5 +122,6 @@ Use this only for local development — there is no concurrency. - [`../architecture/01-routing.md`](../architecture/01-routing.md) — how routes are scanned - [07-mapping.md](07-mapping.md) — `Router` cache management -- [`../configuration/08-runtime.md`](../configuration/08-runtime.md) — server settings and the runtime +- [`../configuration/09-web-server.md`](../configuration/09-web-server.md) — every server setting +- [`../configuration/08-runtime.md`](../configuration/08-runtime.md) — the runtime and its caveats - [`../configuration/02-logging.md`](../configuration/02-logging.md) — per-coroutine log context diff --git a/docs/starter/00-quickstart.md b/docs/starter/00-quickstart.md index c4e847e..05b6f8b 100644 --- a/docs/starter/00-quickstart.md +++ b/docs/starter/00-quickstart.md @@ -185,7 +185,7 @@ final class WebConfig extends WebConfigurerAdapter public function configureServer(ServerSettings $server, ApplicationArguments $args): void { $server->port(8000) - ->workers(swoole_cpu_num() * 2) + ->profile(Profile::Performance) // small requests — see 09-web-server.md ->staticPath('resources/static'); // resources/static/app.css → /app.css } } diff --git a/src/App/Config/Profile.php b/src/App/Config/Profile.php new file mode 100644 index 0000000..24c8011 --- /dev/null +++ b/src/App/Config/Profile.php @@ -0,0 +1,193 @@ +profile(Profile::Performance); + * ``` + * + * **The axis is the shape of a request, not caution.** `Performance` is not "faster at + * the cost of safety" — it is the profile for services whose requests are *small*, and it + * gives such a service more concurrency than `Stable` would, not less. Picking one is + * answering "how much memory does one of my requests use?", which is measurable: + * + * ``` + * $before = memory_get_usage(); + * // ... the handler ... + * $after = memory_get_usage(); // under 64 KB → Performance; over 200 KB → Stable + * ``` + * + * Everything a profile decides is a **default**, resolved when the value is read. An + * explicit `maxConcurrency()`, `maxRequest()` or `SERVER_*` variable always wins, whatever + * order it was set in. + */ +enum Profile: string +{ + /** Heavy requests — reports, exports, a monolith with wide joins. 256 KB each. */ + case Stable = 'stable'; + + /** Ordinary CRUD. 128 KB per request, and the default when nothing is said. */ + case Balance = 'balance'; + + /** Small requests — a thin API, a proxy, an integration bridge. 64 KB each. */ + case Performance = 'performance'; + + /** + * Guards off — for benchmarks, not for production. + * + * Not "Performance, more so": throughput plateaus long before the memory ceiling + * (measured on a live application, 2 250 req/s at 500 concurrent, with more + * concurrency adding nothing), so removing the caps does not raise it. What it removes + * is **periodic interference that distorts a measurement**: handing memory back pauses + * for tens of milliseconds and shows up in p99, replacing a worker empties its + * connection pool mid-run, and the request watchdog runs a timer of its own. + * + * A run under this profile can exhaust the worker's memory, and over a long one + * Swoole's per-request leak accumulates with no replacement to clear it. Both are + * acceptable for a bounded benchmark and for nothing else. + */ + case Stress = 'stress'; + + /** + * Heap an in-flight request holds before it has allocated anything of its own — + * measured, not assumed: 600 requests suspended in a trivial handler cost 78.2 KB + * each, against 68.0 KB for the same connections idle. The difference is the + * coroutine and the request/response pair; the rest is the connection underneath. + */ + private const int REQUEST_FLOOR = 78 * 1024; + + /** + * Heap one open connection holds while its client is connected but asking nothing — + * measured linearly at 401, 801 and 1 201 connections, and released in full when they + * close. It is charged to PHP's own limit, not merely to RSS, which is why a worker + * with the default 128M dies at roughly 1 900 idle keep-alive connections. + */ + private const int CONNECTION_FLOOR = 68 * 1024; + + /** + * Bytes leaked per request through the whole pipeline. Swoole loses 56 bytes on every + * coroutine suspend/resume — measured to the byte over five runs of ten thousand, and + * surviving both `gc_collect_cycles()` and `gc_mem_caches()` — and a request suspends + * several times. {@see maxRequest()} turns this into a replacement interval. + */ + private const int LEAK_PER_REQUEST = 170; + + /** Assumed heap when the limit cannot be read (`-1`, or a value PHP cannot parse). */ + private const int ASSUMED_LIMIT = 128 * 1024 * 1024; + + /** True while the profile imposes limits at all — false only for {@see Stress}. */ + public function guards(): bool + { + return $this !== self::Stress; + } + + /** + * Heap this profile reserves for one in-flight request's own work, on top of + * {@see REQUEST_FLOOR}. Zero for {@see Stress}, which reserves nothing because it + * caps nothing. + */ + public function requestHeadroom(): int + { + return match ($this) { + self::Stable => 256 * 1024, + self::Balance => 128 * 1024, + self::Performance => 64 * 1024, + self::Stress => 0, + }; + } + + /** + * Requests this profile allows in flight at once, given the heap left after the + * worker's own baseline. `0` means no cap. + * + * Each one is budgeted at floor + headroom, plus a second connection at + * {@see CONNECTION_FLOOR} for the client that is connected and *not* currently asking + * — the ordinary keep-alive case. That one-to-one assumption is what + * {@see connections()} spends; a service whose clients hold connections open far + * longer says so with `maxConnections()`. + */ + public function concurrency(int $availableBytes): int + { + if (!$this->guards()) { + return 0; + } + + $perRequest = self::REQUEST_FLOOR + $this->requestHeadroom() + self::CONNECTION_FLOOR; + + return max(1, intdiv(max(0, $availableBytes), $perRequest)); + } + + /** Connections allowed at once: the working ones and an idle one apiece. `0` = no cap. */ + public function connections(int $availableBytes): int + { + return $this->guards() ? $this->concurrency($availableBytes) * 2 : 0; + } + + /** + * Idle heap a worker may hold before handing it back to the operating system. + * `0` never hands anything back. + * + * Scaled to the limit rather than fixed, because what counts as "suspiciously + * unused" depends on how much there is: an ordinary worker carries two or three + * megabytes of reserve whatever its ceiling. Handing memory back costs about 80 ms + * when there is a lot of it, so the profile that avoids pauses waits longer. + */ + public function trimThreshold(int $limitBytes): int + { + $limit = $limitBytes > 0 ? $limitBytes : self::ASSUMED_LIMIT; + + return match ($this) { + self::Stable => intdiv($limit, 16), + self::Balance => intdiv($limit, 8), + self::Performance => intdiv($limit, 4), + self::Stress => 0, + }; + } + + /** + * Requests a worker serves before it is replaced. `0` never replaces it. + * + * Derived from what the leak is allowed to reach: a bigger heap tolerates more of it + * before replacement is worth its cost — the new worker starts with an empty + * connection pool and has to fill it, about 30 ms. + */ + public function maxRequest(int $limitBytes): int + { + $limit = $limitBytes > 0 ? $limitBytes : self::ASSUMED_LIMIT; + $share = match ($this) { + self::Stable => 0.05, + self::Balance => 0.10, + self::Performance => 0.20, + self::Stress => 0.0, + }; + + return (int) ($limit * $share / self::LEAK_PER_REQUEST); + } + + /** + * How far apart workers may drift before recycling — a tenth of {@see maxRequest()}. + * + * Swoole **adds** a random amount up to this to the limit, so the real one is + * `max_request + rand(0, grace)`: verified, at `max_request = 20, grace = 15` workers + * served 30, 27, 34 and 26 requests, against exactly 20 apiece at `grace = 0`. + * Without it, workers counting to the same number under even traffic recycle almost + * together, and several connection pools go cold at once. + */ + public function maxRequestGrace(int $limitBytes): int + { + return intdiv($this->maxRequest($limitBytes), 10); + } +} diff --git a/src/App/Config/ServerSettings.php b/src/App/Config/ServerSettings.php index 5e9249e..6d00804 100644 --- a/src/App/Config/ServerSettings.php +++ b/src/App/Config/ServerSettings.php @@ -34,47 +34,22 @@ final class ServerSettings private const float DEFAULT_REQUEST_TIMEOUT = 30.0; /** - * Idle memory a worker may hold before giving it back to the kernel. + * Largest request accepted, in bytes — the whole packet, headers included. * - * An ordinary worker carries two or three megabytes of reserve — the allocator's - * chunk is 2 MB — so 32M is unmistakably "a large request happened here", while - * being small enough that the memory is worth reclaiming. - */ - private const string DEFAULT_MEMORY_TRIM = '32M'; - - /** - * Requests a worker serves before it is replaced by a fresh one. - * - * Not a precaution — a necessity. Swoole leaks **56 bytes every time a coroutine - * suspends and resumes**, measured to the byte across five runs of ten thousand, and - * surviving both `gc_collect_cycles()` and `gc_mem_caches()`. Neither a coroutine on - * its own nor a timer on its own leaks; only the pair. An HTTP request always - * suspends — on the database, on an upstream call, on writing the response — so every - * request leaves at least that behind, and through the full pipeline it measures - * nearer 170 bytes. At 2 000 req/s that is roughly 385 MB an hour: a worker with a - * 256M limit would die in well under an hour of ordinary traffic. - * - * 100 000 keeps the leak near 17 MB, comfortably inside any sane limit, while being - * rare enough that the cost of replacement disappears. Replacement is not free: the - * new worker starts with an empty connection pool and has to fill it — about 30 ms — - * which spread over 100 000 requests is 0.0003 ms each. At Laravel Octane's default - * of 500 that same cost would be felt. - */ - private const int DEFAULT_MAX_REQUEST = 100_000; - - /** - * How far apart workers are allowed to drift before recycling. - * - * Swoole **adds** a random amount up to this to `max_request`, so the real limit is - * `max_request + rand(0, grace)` — verified: at `max_request = 20, grace = 15` the - * worker instances served 30, 27, 34 and 26 requests, while at `grace = 0` every one - * of them served exactly 20. + * Swoole's own limit is **2 MB** — measured, 2 000 KB passes and 2 048 KB comes back + * `413` — which is tight for anything that accepts a document or an image, and + * invisible: the client gets a bare status with nothing explaining it. 8 MB matches + * PHP's own `post_max_size` default, so a PHP developer meets the number they expect. * - * Without it, workers counting to the same number under even traffic recycle almost - * together: several connection pools go cold at once and the survivors take the load. - * Ten per cent spreads them by about a minute at 2 000 req/s. + * It is not raised further on purpose. The body is held in the worker's heap for the + * duration of the request, and the heap is shared by every request in flight — 64 MB + * bodies at a hundred concurrent uploads is 6.4 GB, and the worker dies before the + * upload does. */ - private const int DEFAULT_MAX_REQUEST_GRACE = 10_000; + private const int DEFAULT_MAX_REQUEST_SIZE = 8 * 1024 * 1024; + + /** Assumed heap when the limit cannot be read — `-1`, or a value PHP cannot parse. */ + private const int ASSUMED_MEMORY_LIMIT = 128 * 1024 * 1024; /** @param array $options */ private function __construct( @@ -83,7 +58,9 @@ private function __construct( private array $options = [], private ?string $memoryLimit = null, private float $requestTimeout = self::DEFAULT_REQUEST_TIMEOUT, - private string $memoryTrimThreshold = self::DEFAULT_MEMORY_TRIM, + private ?string $memoryTrimThreshold = null, + private ?Profile $profile = null, + private int $baselineBytes = 0, ) { } @@ -92,20 +69,25 @@ private function __construct( * framework's default policy is `--host`/`--port`); tuning options come from the * environment — only variables that are actually set contribute a key (so Swoole * defaults apply otherwise). + * + * Nothing the profile decides is seeded here. Those are resolved when read, so a + * {@see WebConfigurer} that raises `memoryLimit()` raises everything derived from it + * no matter which order the two calls are made in. */ public static function fromEnv(string $host = '0.0.0.0', int $port = 8000): self { - // Framework defaults, before the environment gets a say. Both exist because - // Swoole leaks on every coroutine suspension — see the constants. $options = [ - 'max_request' => self::DEFAULT_MAX_REQUEST, - 'max_request_grace' => self::DEFAULT_MAX_REQUEST_GRACE, + 'package_max_length' => self::DEFAULT_MAX_REQUEST_SIZE, ]; $map = [ 'SERVER_WORKERS' => 'worker_num', 'SERVER_TASKS' => 'task_worker_num', 'SERVER_MAX_REQUEST' => 'max_request', 'SERVER_MAX_REQUEST_GRACE' => 'max_request_grace', + 'SERVER_MAX_REQUEST_SIZE' => 'package_max_length', + 'SERVER_MAX_CONNECTIONS' => 'max_connection', + 'SERVER_MAX_CONCURRENCY' => 'worker_max_concurrency', + 'SERVER_IDLE_TIMEOUT' => 'heartbeat_idle_time', ]; // `SERVER_MEMORY_LIMIT` is handled below — it is a PHP ini, not a Swoole key. foreach ($map as $envKey => $swooleKey) { @@ -124,9 +106,16 @@ public static function fromEnv(string $host = '0.0.0.0', int $port = 8000): self $timeout = is_numeric($timeout) ? max(0.0, (float) $timeout) : self::DEFAULT_REQUEST_TIMEOUT; $trim = env('SERVER_MEMORY_TRIM'); - $trim = is_string($trim) && $trim !== '' ? $trim : self::DEFAULT_MEMORY_TRIM; + $trim = is_string($trim) && $trim !== '' ? $trim : null; - return new self($host, $port, $options, $memoryLimit, $timeout, $trim); + $profile = env('SERVER_PROFILE'); + $profile = is_string($profile) ? Profile::tryFrom(strtolower(trim($profile))) : null; + + // Captured once, here, rather than on every read: this runs after bootstrap and + // before any worker exists, which is exactly the heap a worker starts from, and a + // value that changed between two reads would make the derived limits disagree + // with each other. + return new self($host, $port, $options, $memoryLimit, $timeout, $trim, $profile, memory_get_usage(true)); } /** Bind host (e.g. '0.0.0.0', '127.0.0.1'). */ @@ -163,16 +152,252 @@ public function taskWorkers(int $count): self return $this->set('task_worker_num', $count); } + /** + * Requests a worker serves before it is replaced by a fresh one. `0` never replaces + * it. Derived from the profile when not set — see {@see Profile::maxRequest()}. + * + * Replacement is not a precaution but a necessity: Swoole leaks 56 bytes on every + * coroutine suspend/resume, and an HTTP request always suspends. + */ public function maxRequest(int $count): self { return $this->set('max_request', $count); } + /** + * How far apart workers may drift before recycling. Derived from the profile when not + * set — a tenth of {@see maxRequest()}. + * + * Swoole **adds** a random amount up to this to `max_request`, so the real limit is + * `max_request + rand(0, grace)`. + */ public function maxRequestGrace(int $count): self { return $this->set('max_request_grace', $count); } + /** + * The stance this server takes on its own memory — and, through it, every limit that + * keeps a worker alive. {@see Profile::Balance} when nothing is said. + * + * ``` + * $server->profile(Profile::Performance); + * ``` + * + * It supplies {@see maxConcurrency()}, {@see maxConnections()}, {@see maxRequest()}, + * {@see maxRequestGrace()} and {@see memoryTrimThreshold()} as **defaults**, resolved + * when each is read. Anything set explicitly — here, or through a `SERVER_*` variable + * — wins regardless of the order the calls are made in: + * + * ``` + * $server->profile(Profile::Performance) + * ->maxConnections(10_000); // browsers hold connections open; the rest stands + * ``` + * + * Choosing one is answering how much memory a single request uses, which is + * measurable — see {@see Profile}. + */ + public function profile(Profile $profile): self + { + $this->profile = $profile; + return $this; + } + + /** The profile in force: the configured one, `SERVER_PROFILE`, or {@see Profile::Balance}. */ + public function getProfile(): Profile + { + return $this->profile ?? Profile::Balance; + } + + /** Requests a worker will serve before replacement; `0` when it is never replaced. */ + public function getMaxRequest(): int + { + $configured = $this->options['max_request'] ?? null; + + return is_int($configured) ? $configured : $this->getProfile()->maxRequest($this->limitBytes()); + } + + /** The random spread added to {@see getMaxRequest()}. */ + public function getMaxRequestGrace(): int + { + $configured = $this->options['max_request_grace'] ?? null; + + return is_int($configured) ? $configured : $this->getProfile()->maxRequestGrace($this->limitBytes()); + } + + /** + * Largest request accepted, in bytes — **headers included**. Default: 8 MB. + * + * ``` + * $server->maxRequestSize(32 * 1024 * 1024); // room for 32 MB uploads + * ``` + * + * The limit is on the whole packet, not the body alone: at the default, a body of + * 8 388 400 bytes passes and 8 388 608 does not — the difference is the request's own + * headers. Leave room for them when sizing an upload endpoint. + * + * Swoole's own limit is 2 MB and says nothing when it is hit — the client gets a bare + * `413`, which sends the reader looking anywhere but here. The default matches PHP's + * `post_max_size`, so a PHP developer meets the number they expect. + * + * Raise it knowing what it costs: the request sits in the worker's heap for its whole + * life, and that heap is shared by every request in flight. Large bodies and high + * concurrency multiply. + */ + public function maxRequestSize(int $bytes): self + { + return $this->set('package_max_length', $bytes); + } + + /** + * Largest number of simultaneous TCP connections. Derived from the profile when not + * set — twice {@see getMaxConcurrency()}, the working ones and an idle one apiece. + * + * Not the same thing as concurrent requests, though it is easy to read it that way: + * a keep-alive connection serves many requests one after another, and an idle one + * serves none. Measured — `max_connection = 2` did not slow six concurrent requests + * at all, while `worker_max_concurrency = 2` doubled their total time by queueing. + * + * It is not free, though. A held connection costs about **68 KB of PHP heap** — + * measured linearly at 401, 801 and 1 201 connections, and charged to `memory_limit`, + * not merely to RSS — so a worker with the default 128M dies at roughly 1 900 idle + * keep-alive connections. Swoole's own 100 000 is therefore no limit at all: it would + * take 6.8 GB to reach. + * + * Raise it for a service whose clients hold connections open while asking nothing — + * browsers with open tabs, mobile clients polling rarely. The profile assumes one such + * client per working request, which suits a service behind nginx or one called by + * other services. + */ + public function maxConnections(int $count): self + { + return $this->set('max_connection', $count); + } + + /** Connections allowed at once: the configured value, or the profile's derivation. */ + public function getMaxConnections(): int + { + $configured = $this->options['max_connection'] ?? null; + if (is_int($configured) && $configured > 0) { + return $configured; + } + + return $this->getProfile()->connections($this->availableBytes()); + } + + /** + * Seconds a connection may go without sending data before the server closes it. + * Off by default, which is Swoole's own behaviour. + * + * ``` + * $server->idleConnectionTimeout(300); // must exceed the longest request + * ``` + * + * **It also cuts requests that are still running**, and this is the whole reason it is + * off. Swoole measures the time since the client last *sent* something, and a client + * waiting for a slow response sends nothing — so a request being worked on looks + * exactly like an abandoned connection. Measured: with the timeout at 2 seconds a + * 5-second request was cut at 2.07 s and the client got no response; at 8 seconds the + * same request returned normally at 5.00 s. Nothing was written to the log either + * time — the connection simply ends. + * + * So it is a ceiling on request duration as much as on idleness, and it would silently + * overrule {@see \Flytachi\Winter\Kernel\Route\Annotation\Timeout} — a route allowed + * ten minutes would still die here. Set it above the longest request the application + * permits, and prefer {@see requestTimeout()} for bounding request duration: that one + * cancels the coroutine, so `finally` runs, connections return to the pool, and the + * client is told what happened with a `504`. + * + * What it does buy: a client that opens a connection and vanishes otherwise holds a + * file descriptor for the life of the worker. That is a small prize — the connection + * table costs nothing measurable (1 024 vs 1 000 000 connections differ by 160 KB of + * RSS) and containers here allow about a million descriptors — which is why it does + * not pay for the hazard by default. + * + * A related detail worth knowing, since the two are usually mentioned together: + * `heartbeat_check_interval` is **not** required for this to work — verified, the + * timeout closes idle connections on its own. + */ + public function idleConnectionTimeout(int $seconds): self + { + return $this->set('heartbeat_idle_time', $seconds); + } + + /** + * Largest number of requests one worker processes at the same time. Derived from the + * profile when not set — see {@see getMaxConcurrency()}. + * + * ``` + * $server->maxConcurrency(10000); // a proxy: requests are cheap, mostly waiting + * ``` + * + * Swoole **queues** what exceeds it rather than refusing — measured: twenty concurrent + * 0.3-second requests against a limit of 2 all succeeded, taking 3.3 seconds in total + * instead of 0.3. So overload turns into latency, not errors, and no client is turned + * away because the server is briefly busy. + * + * This is the setting that actually protects the worker. `memory_limit` decides when + * it dies; this decides whether it gets there. Not to be confused with + * {@see maxConnections()}, which counts sockets: a keep-alive connection serves many + * requests in turn and an idle one serves none. + * + * It is not a rate limit, and cannot be used as one. It has no idea who is calling, so + * it cannot give one client 50 requests a second and another 20 — and it delays rather + * than rejects, where a quota has to answer `429`. A per-client quota also has to be + * shared between workers, which a per-worker number never is. + */ + public function maxConcurrency(int $count): self + { + return $this->set('worker_max_concurrency', $count); + } + + /** + * The concurrency ceiling that will be applied: the configured value, or the + * profile's derivation from the heap left after the worker's own baseline. + * + * At 256M with {@see Profile::Balance}, 896 in flight; at 512M, 1 853. Doubling the + * limit doubles the ceiling — but only turns into throughput if the ceiling was what + * bound. `worker_concurrency` in `Swoole\Server::stats()` says whether it was: + * sitting at the ceiling means requests are queueing, well below it means the + * bottleneck is somewhere else. + */ + public function getMaxConcurrency(): int + { + $configured = $this->options['worker_max_concurrency'] ?? null; + if (is_int($configured) && $configured > 0) { + return $configured; + } + + return $this->getProfile()->concurrency($this->availableBytes()); + } + + /** + * The worker's memory ceiling in bytes, or {@see ASSUMED_MEMORY_LIMIT} when it cannot + * be read (`-1`, or a value PHP cannot parse) — an application that opts out of + * memory limits still gets limits derived, rather than none. + */ + private function limitBytes(): int + { + $bytes = WorkerMemory::bytes($this->memoryLimit ?? (string) ini_get('memory_limit')); + + return $bytes > 0 ? $bytes : self::ASSUMED_MEMORY_LIMIT; + } + + /** + * Heap the requests may actually share: the limit less what the application already + * holds before serving anything. + * + * The baseline is **measured, not assumed** — an application with a hundred routes, + * three connection pools and a wide dependency graph starts heavier than an empty + * one, and gets a correspondingly smaller ceiling without being asked. It is taken in + * the master, which has run the same bootstrap the workers inherit; a worker allocates + * a little more of its own (its pool fills lazily), so this errs slightly generous. + */ + private function availableBytes(): int + { + return max(0, $this->limitBytes() - $this->baselineBytes); + } + /** * Serves static files from `$path` using Swoole's own handler. * @@ -319,6 +544,9 @@ public function getRequestTimeout(): float * there is none — which is what an ordinary request pays. The next large allocation * pays roughly 10 % more, having to take fresh chunks. * + * Derived from the profile when not set, as a fraction of the memory limit — what + * counts as "suspiciously unused" depends on how much there is. + * * @param string $bytes A PHP memory value: '32M', '512K', or '0' to disable. */ public function memoryTrimThreshold(string $bytes): self @@ -327,10 +555,14 @@ public function memoryTrimThreshold(string $bytes): self return $this; } - /** The configured idle-memory threshold, as written. */ - public function getMemoryTrimThreshold(): string + /** The idle-memory threshold in bytes: the configured value, or the profile's. */ + public function getMemoryTrimThreshold(): int { - return $this->memoryTrimThreshold; + if ($this->memoryTrimThreshold !== null) { + return max(0, WorkerMemory::bytes($this->memoryTrimThreshold)); + } + + return $this->getProfile()->trimThreshold($this->limitBytes()); } /** Set any raw Swoole option. */ @@ -346,10 +578,22 @@ public function set(string $key, mixed $value): self * `memoryLimit` is deliberately absent: it is a PHP ini value, and Swoole answers * an option it does not know with `unsupported option` on every start. * + * The profile's four Swoole settings are filled in here rather than in + * {@see fromEnv()} because they derive from the memory limit, which a + * {@see WebConfigurer} may still change. A profile that caps nothing + * ({@see Profile::Stress}) contributes no key, leaving Swoole's own behaviour. + * * @return array */ public function toArray(): array { - return $this->options; + $derived = array_filter([ + 'worker_max_concurrency' => $this->getMaxConcurrency(), + 'max_connection' => $this->getMaxConnections(), + 'max_request' => $this->getMaxRequest(), + 'max_request_grace' => $this->getMaxRequestGrace(), + ], static fn(int $value): bool => $value > 0); + + return $this->options + $derived; } } diff --git a/src/Http/Adapter/FpmRequest.php b/src/Http/Adapter/FpmRequest.php index d760596..24a3a5c 100644 --- a/src/Http/Adapter/FpmRequest.php +++ b/src/Http/Adapter/FpmRequest.php @@ -79,7 +79,7 @@ public function getUploadedFiles(): array return $result; } - public function getServerParam(string $key): ?string + public function getServerParam(string $key): string|int|float|null { return $_SERVER[$key] ?? null; } diff --git a/src/Http/Adapter/SwooleRequest.php b/src/Http/Adapter/SwooleRequest.php index 50c4dca..4a85a43 100644 --- a/src/Http/Adapter/SwooleRequest.php +++ b/src/Http/Adapter/SwooleRequest.php @@ -61,7 +61,7 @@ public function getUploadedFiles(): array return $this->request->files ?? []; } - public function getServerParam(string $key): ?string + public function getServerParam(string $key): string|int|float|null { return $this->request->server[$key] ?? null; } diff --git a/src/Http/Contracts/HttpRequest.php b/src/Http/Contracts/HttpRequest.php index c04fdc0..35c154c 100644 --- a/src/Http/Contracts/HttpRequest.php +++ b/src/Http/Contracts/HttpRequest.php @@ -46,8 +46,16 @@ public function getHeaders(): array; /** Uploaded files ($_FILES equivalent). */ public function getUploadedFiles(): array; - /** Server / environment variable (e.g. 'remote_addr', 'request_method'). */ - public function getServerParam(string $key): ?string; + /** + * Server / environment variable (e.g. 'remote_addr', 'request_method'). + * + * Not every one of them is a string, which is why the return type is not `?string`: + * Swoole stores `server_port`, `remote_port`, `request_time` and `master_time` as + * integers and `request_time_float` as a float, and PHP does the same for + * `REQUEST_TIME` and `REQUEST_TIME_FLOAT` under FPM. Asking for a port used to fail + * with a `TypeError` under `strict_types` rather than answer. + */ + public function getServerParam(string $key): string|int|float|null; /** Resolved client IP address (respects X-Forwarded-For / Forwarded). */ public function getClientIp(): string; diff --git a/src/Route/RequestWatchdog.php b/src/Route/RequestWatchdog.php index 4f33eb5..acbe18f 100644 --- a/src/Route/RequestWatchdog.php +++ b/src/Route/RequestWatchdog.php @@ -120,7 +120,7 @@ public static function disable(): void * The caller must {@see release()} the id when the request ends — via `defer`, so it * happens however the request finishes. */ - public static function register(?float $seconds = null): ?int + public static function register(?float $seconds = null, float $elapsed = 0.0): ?int { $seconds ??= self::$default; if ($seconds <= 0.0 || !Runtime::isSwooleCoroutine()) { @@ -128,7 +128,11 @@ public static function register(?float $seconds = null): ?int } $cid = Coroutine::getCid(); - self::$deadlines[$cid] = self::now() + $seconds; + // `$elapsed` is spent budget, not a start time: the deadline runs on the monotonic + // clock, and the caller measures the wait on the wall clock Swoole stamps arrivals + // with. Subtracting keeps the two apart. A request that used its whole budget + // queueing gets a deadline in the past and is cancelled by the next sweep. + self::$deadlines[$cid] = self::now() + $seconds - max(0.0, $elapsed); return $cid; } diff --git a/src/Route/Router.php b/src/Route/Router.php index 75dd144..847ace5 100644 --- a/src/Route/Router.php +++ b/src/Route/Router.php @@ -445,7 +445,12 @@ public function handle(HttpRequest $request, HttpResponse $response): void // #[Timeout] adjusts it below, once dispatch has said which route this is. // Released via defer so it happens however the request ends — including the // cancellation the watchdog itself raises. - $watched = RequestWatchdog::register(); + // + // The time already spent queueing counts against the deadline. Under + // worker_max_concurrency a request's coroutine is not created until the worker + // lets it through, so without this a request that waited three seconds would + // start a fresh thirty — while the client has been waiting the whole time. + $watched = RequestWatchdog::register(elapsed: self::waitedInQueue($request)); if ($watched !== null) { \Swoole\Coroutine::defer(static fn() => RequestWatchdog::release($watched)); } @@ -772,6 +777,26 @@ private function asTimeout(\Throwable $e): \Throwable return new ResponseException('Gateway Timeout', HttpCode::GATEWAY_TIMEOUT, $e); } + + /** + * Seconds this request spent waiting to be picked up, before any of it ran. + * + * Swoole stamps `request_time_float` when the packet arrives, which is *before* + * `worker_max_concurrency` decides whether there is room to run it — verified with a + * limit of one and a 0.3-second handler: five simultaneous requests reported 0.000, + * 0.301, 0.603, 0.904 and 1.206 seconds of waiting. + * + * Returns 0.0 when the stamp is missing or nonsensical (a clock adjustment between + * arrival and now would otherwise charge the request for it), so the deadline then + * behaves exactly as it did before. + */ + private static function waitedInQueue(HttpRequest $request): float + { + $arrived = $request->getServerParam('request_time_float'); + + return is_numeric($arrived) ? max(0.0, microtime(true) - (float) $arrived) : 0.0; + } + // ── Debug helpers ───────────────────────────────────────────────────────── /** @return list */ diff --git a/src/WinterApplication.php b/src/WinterApplication.php index 4f65f43..fb4a3bc 100644 --- a/src/WinterApplication.php +++ b/src/WinterApplication.php @@ -465,7 +465,7 @@ static function () use ($class): void { )); } - $trimThreshold = WorkerMemory::bytes($settings->getMemoryTrimThreshold()); + $trimThreshold = $settings->getMemoryTrimThreshold(); $handler = static function ( \Swoole\Http\Request $req, \Swoole\Http\Response $res @@ -521,7 +521,11 @@ static function () use ($class): void { $server->on('workerExit', $workerExit); if (Banner::isEnabled($args)) { - Banner::print(static::bannerRows($companions, $host, $port), self::elapsedMs()); + $rows = static::bannerRows($companions, $host, $port); + // What the profile resolved to, because a limit nobody can see is a limit + // nobody thinks of when a request starts queueing or a connection is refused. + $rows[] = ['profile', self::profileSummary($settings)]; + Banner::print($rows, self::elapsedMs()); } $logger->info(sprintf( @@ -689,6 +693,29 @@ private static function hasAttribute(string $attribute): bool * @param list $companions * @return list */ + /** + * The profile and the numbers it resolved to, for the startup banner. + * + * `Profile::Stress` prints a warning instead of numbers: it removes the caps and the + * periodic work that would distort a measurement, which is right for a benchmark and + * wrong for anything else, and a banner is the last place it can still be noticed. + */ + private static function profileSummary(ServerSettings $settings): string + { + $profile = $settings->getProfile(); + if (!$profile->guards()) { + return $profile->value . ' — guards off, benchmarks only'; + } + + return sprintf( + '%s · %d in flight · %d connections · recycle at %s', + $profile->value, + $settings->getMaxConcurrency(), + $settings->getMaxConnections(), + number_format($settings->getMaxRequest()), + ); + } + private static function bannerRows(array $companions, ?string $host, ?int $port): array { $rows = []; diff --git a/tests/App/WorkerMemoryTest.php b/tests/App/WorkerMemoryTest.php index c3b5bd5..314d3b2 100644 --- a/tests/App/WorkerMemoryTest.php +++ b/tests/App/WorkerMemoryTest.php @@ -4,6 +4,7 @@ namespace Flytachi\Winter\Kernel\Tests\App; +use Flytachi\Winter\Kernel\App\Config\Profile; use Flytachi\Winter\Kernel\App\Config\ServerSettings; use Flytachi\Winter\Kernel\App\Config\WorkerMemory; use PHPUnit\Framework\TestCase; @@ -368,10 +369,10 @@ public function test_it_never_reaches_the_swoole_options(): void */ public function test_worker_recycling_is_configured_by_default(): void { - $options = ServerSettings::fromEnv()->toArray(); + $options = self::settings('256M')->toArray(); - self::assertSame(100_000, $options['max_request'], 'a worker must not live forever'); - self::assertSame(10_000, $options['max_request_grace'], 'and they must not all recycle at once'); + self::assertSame(157_903, $options['max_request'], 'a worker must not live forever'); + self::assertSame(15_790, $options['max_request_grace'], 'and they must not all recycle at once'); } /** @@ -403,30 +404,210 @@ public function test_the_env_shorthand_overrides_the_recycling_default(): void } } - public function test_the_trim_threshold_defaults_to_32m(): void + /** + * Swoole's own limit is 2 MB — measured, 2 000 KB passes and 2 048 KB comes back 413 + * — which is tight for anything accepting a document, and invisible when it bites. + * 8 MB matches PHP's `post_max_size`, the number a PHP developer expects. + */ + public function test_the_request_size_limit_is_raised_from_swooles_2mb(): void { - $original = $_ENV['SERVER_MEMORY_TRIM'] ?? null; - unset($_ENV['SERVER_MEMORY_TRIM']); + self::assertSame( + 8 * 1024 ** 2, + ServerSettings::fromEnv()->toArray()['package_max_length'], + ); + self::assertSame( + 32 * 1024 ** 2, + ServerSettings::fromEnv()->maxRequestSize(32 * 1024 ** 2)->toArray()['package_max_length'], + ); + } + + /** + * The idle timeout stays off, because Swoole applies it to requests that are still + * running: it measures the time since the client last *sent* something, and a client + * waiting for a slow response sends nothing. Measured — with the timeout at 2 seconds + * a 5-second request was cut at 2.07 s with no response and no log line; at 8 seconds + * the same request returned normally. + * + * A default here would therefore be a silent ceiling on request duration, overruling + * #[Timeout] on the routes that legitimately take longer. What it would buy is small: + * the connection table costs about 160 KB between 1 024 and 1 000 000 connections. + */ + public function test_no_idle_timeout_is_imposed_on_running_requests(): void + { + self::assertArrayNotHasKey('heartbeat_idle_time', ServerSettings::fromEnv()->toArray()); + self::assertSame( + 600, + ServerSettings::fromEnv()->idleConnectionTimeout(600)->toArray()['heartbeat_idle_time'], + ); + } + + /** + * Connections are not requests. A keep-alive connection serves many in turn, and idle + * ones hold none at all — measured: `max_connection = 2` did not slow six concurrent + * requests, while `worker_max_concurrency = 2` doubled their time by queueing. So this + * bounds file descriptors, not work, and Swoole's own 100 000 is left alone: lowering + * it only refuses clients earlier, and memory runs out first. + */ + public function test_the_connection_ceiling_is_derived_rather_than_left_to_swoole(): void + { + // Swoole's own 100 000 is no limit at all: at the measured 68 KB apiece it would + // take 6.8 GB to reach, and the worker dies at ~1 900 with the default 128M. + self::assertSame(1792, self::settings('256M')->toArray()['max_connection']); + self::assertSame( + 5000, + self::settings('256M')->maxConnections(5000)->toArray()['max_connection'], + ); + } + + /** + * Every in-flight request is budgeted at the measured floor (78 KB) plus what the + * profile grants it, plus one idle connection (68 KB) for the keep-alive client that + * is connected and not currently asking. + */ + public function test_each_profile_derives_its_own_concurrency(): void + { + $expected = [ + // profile, 256M, 128M + [Profile::Stable, 611, 285], + [Profile::Balance, 896, 418], + [Profile::Performance, 1170, 546], + ]; + + foreach ($expected as [$profile, $at256, $at128]) { + self::assertSame( + $at256, + self::settings('256M')->profile($profile)->getMaxConcurrency(), + $profile->value . ' at 256M', + ); + self::assertSame( + $at128, + self::settings('128M')->profile($profile)->getMaxConcurrency(), + $profile->value . ' at 128M', + ); + } + } + + /** Connections are the working ones plus an idle one apiece. */ + public function test_connections_leave_room_for_idle_keep_alive_clients(): void + { + $settings = self::settings('256M')->profile(Profile::Balance); + + self::assertSame($settings->getMaxConcurrency() * 2, $settings->getMaxConnections()); + } + + /** Balance is what an application that says nothing gets. */ + public function test_balance_is_the_default_profile(): void + { + self::assertSame(Profile::Balance, ServerSettings::fromEnv()->getProfile()); + self::assertSame( + self::settings('256M')->profile(Profile::Balance)->getMaxConcurrency(), + self::settings('256M')->getMaxConcurrency(), + ); + } + + /** The bench profile caps nothing, so it contributes no key and Swoole's own stands. */ + public function test_stress_removes_the_caps_entirely(): void + { + $options = self::settings('256M')->profile(Profile::Stress)->toArray(); + + foreach (['worker_max_concurrency', 'max_connection', 'max_request', 'max_request_grace'] as $key) { + self::assertArrayNotHasKey($key, $options, "{$key} must be left to Swoole under stress"); + } + self::assertSame(0, self::settings('256M')->profile(Profile::Stress)->getMemoryTrimThreshold()); + } + + /** + * Resolved on read, so the order of calls in a WebConfigurer cannot matter — deriving + * in fromEnv() would freeze the ini's value before ->memoryLimit() is reached. + */ + public function test_raising_the_memory_limit_raises_everything_derived_from_it(): void + { + $settings = self::settings('256M'); + $before = $settings->getMaxConcurrency(); + + $settings->memoryLimit('512M'); + self::assertSame(1853, $settings->getMaxConcurrency()); + self::assertGreaterThan($before, $settings->getMaxConcurrency()); + self::assertSame(1853, $settings->toArray()['worker_max_concurrency']); + self::assertSame(3706, $settings->toArray()['max_connection']); + } + + /** An explicit value wins over the profile, whichever way it was given. */ + public function test_a_configured_value_is_not_second_guessed(): void + { + self::assertSame( + 10000, + self::settings('128M')->maxConcurrency(10000)->getMaxConcurrency(), + ); + + $original = $_ENV['SERVER_MAX_CONCURRENCY'] ?? null; + $_ENV['SERVER_MAX_CONCURRENCY'] = '7777'; try { - self::assertSame('32M', ServerSettings::fromEnv()->getMemoryTrimThreshold()); - self::assertSame(32 * 1024 ** 2, WorkerMemory::bytes('32M')); + self::assertSame(7777, ServerSettings::fromEnv()->getMaxConcurrency()); } finally { - if ($original !== null) { - $_ENV['SERVER_MEMORY_TRIM'] = $original; + if ($original === null) { + unset($_ENV['SERVER_MAX_CONCURRENCY']); + } else { + $_ENV['SERVER_MAX_CONCURRENCY'] = $original; + } + } + } + + /** The profile can be chosen by an operator without touching code. */ + public function test_the_profile_can_come_from_the_environment(): void + { + $original = $_ENV['SERVER_PROFILE'] ?? null; + + try { + $_ENV['SERVER_PROFILE'] = 'performance'; + self::assertSame(Profile::Performance, ServerSettings::fromEnv()->getProfile()); + + $_ENV['SERVER_PROFILE'] = ' STRESS '; + self::assertSame(Profile::Stress, ServerSettings::fromEnv()->getProfile()); + + $_ENV['SERVER_PROFILE'] = 'nonsense'; + self::assertSame(Profile::Balance, ServerSettings::fromEnv()->getProfile(), 'an unknown name is not fatal'); + } finally { + if ($original === null) { + unset($_ENV['SERVER_PROFILE']); + } else { + $_ENV['SERVER_PROFILE'] = $original; } } } + /** + * With no limit to derive from there is still a ceiling. And it never derives zero, + * which Swoole stores as given and reads as "no limit" — verified — so a tiny budget + * would become an unlimited one. + */ + public function test_an_unreadable_memory_limit_still_yields_a_ceiling(): void + { + self::assertSame(418, self::settings('-1')->getMaxConcurrency(), 'falls back to a 128M budget'); + self::assertSame(418, self::settings('lots')->getMaxConcurrency()); + self::assertSame(1, self::settings('1K')->getMaxConcurrency(), 'never zero, which would mean no limit'); + } + + /** The threshold scales with the limit: what counts as "unused" depends on how much there is. */ + public function test_the_trim_threshold_follows_the_profile(): void + { + $of = static fn(Profile $p): int => self::settings('256M')->profile($p)->getMemoryTrimThreshold(); + + self::assertSame(32 * 1024 ** 2, self::settings('256M')->getMemoryTrimThreshold()); + self::assertSame(16 * 1024 ** 2, $of(Profile::Stable)); + self::assertSame(64 * 1024 ** 2, $of(Profile::Performance)); + } + public function test_the_trim_threshold_is_configurable(): void { - self::assertSame('64M', ServerSettings::fromEnv()->memoryTrimThreshold('64M')->getMemoryTrimThreshold()); + self::assertSame(64 * 1024 ** 2, self::settings('256M')->memoryTrimThreshold('64M')->getMemoryTrimThreshold()); $original = $_ENV['SERVER_MEMORY_TRIM'] ?? null; $_ENV['SERVER_MEMORY_TRIM'] = '128M'; try { - self::assertSame('128M', ServerSettings::fromEnv()->getMemoryTrimThreshold()); + self::assertSame(128 * 1024 ** 2, ServerSettings::fromEnv()->getMemoryTrimThreshold()); } finally { if ($original === null) { unset($_ENV['SERVER_MEMORY_TRIM']); @@ -435,6 +616,20 @@ public function test_the_trim_threshold_is_configurable(): void } } } + + /** + * Settings with a known worker baseline, so the derived numbers are exact rather than + * a function of whatever the test process happens to hold. Sixteen megabytes is what a + * booted application carries; the framework measures it for real at startup. + */ + private static function settings(string $limit): ServerSettings + { + $settings = ServerSettings::fromEnv()->memoryLimit($limit); + new \ReflectionProperty(ServerSettings::class, 'baselineBytes') + ->setValue($settings, 16 * 1024 ** 2); + + return $settings; + } } // ── Fixtures ────────────────────────────────────────────────────────────────── diff --git a/tests/Http/Request/ServerParamTypeTest.php b/tests/Http/Request/ServerParamTypeTest.php new file mode 100644 index 0000000..07b9e9b --- /dev/null +++ b/tests/Http/Request/ServerParamTypeTest.php @@ -0,0 +1,79 @@ +server = [ + 'request_method' => 'GET', + 'server_port' => 9501, + 'remote_port' => 54321, + 'request_time' => 1_754_500_000, + 'request_time_float' => 1_754_500_000.123456, + ]; + $request = new SwooleRequest($raw); + + self::assertSame('GET', $request->getServerParam('request_method')); + self::assertSame(9501, $request->getServerParam('server_port')); + self::assertSame(54321, $request->getServerParam('remote_port')); + self::assertSame(1_754_500_000, $request->getServerParam('request_time')); + self::assertSame(1_754_500_000.123456, $request->getServerParam('request_time_float')); + self::assertNull($request->getServerParam('absent')); + } + + public function test_fpm_numeric_server_values_are_returned_not_thrown(): void + { + $original = $_SERVER; + $_SERVER['REQUEST_TIME'] = 1_754_500_000; + $_SERVER['REQUEST_TIME_FLOAT'] = 1_754_500_000.123456; + $_SERVER['SERVER_PORT'] = 8080; + + try { + $request = new FpmRequest(); + + self::assertSame(1_754_500_000, $request->getServerParam('REQUEST_TIME')); + self::assertSame(1_754_500_000.123456, $request->getServerParam('REQUEST_TIME_FLOAT')); + self::assertSame(8080, $request->getServerParam('SERVER_PORT')); + } finally { + $_SERVER = $original; + } + } + + /** + * The float must survive intact, because the request deadline is computed from it: + * casting through a string would round it at PHP's 14-digit `precision`. + */ + public function test_the_arrival_stamp_keeps_its_precision(): void + { + $raw = new \Swoole\Http\Request(); + $raw->server = ['request_time_float' => 1_754_500_000.123456]; + + $value = new SwooleRequest($raw)->getServerParam('request_time_float'); + + self::assertIsFloat($value); + self::assertSame(0.0, $value - 1_754_500_000.123456); + } +} diff --git a/tests/Route/RequestWatchdogTest.php b/tests/Route/RequestWatchdogTest.php index 8604498..d6c49ff 100644 --- a/tests/Route/RequestWatchdogTest.php +++ b/tests/Route/RequestWatchdogTest.php @@ -182,6 +182,97 @@ public function test_a_waiting_request_is_cancelled_at_its_deadline(): void self::assertTrue($deferRan, 'defer must run — pooled connections have to come back'); } + /** + * Time spent queueing counts against the deadline. + * + * Under `worker_max_concurrency` a request's coroutine is not created until the worker + * lets it through, so the watchdog never sees the wait — measured with a limit of one + * and a 0.3-second handler, five simultaneous requests waited 0.000 to 1.206 seconds + * before any of their code ran. Without this, each would then start a fresh full + * budget while the client had already been waiting. + */ + public function test_time_spent_queueing_is_charged_to_the_deadline(): void + { + $outcome = null; + + \Swoole\Coroutine\run(static function () use (&$outcome): void { + RequestWatchdog::enable(1.0); + + Coroutine::create(static function () use (&$outcome): void { + // 0.9 s of the one-second budget was spent waiting to be picked up. + $cid = RequestWatchdog::register(elapsed: 0.9); + try { + Coroutine::sleep(0.3); + $outcome = 'finished'; + } catch (\Throwable $e) { + $outcome = $e::class; + } + RequestWatchdog::release($cid); + }); + + Coroutine::sleep(0.6); + RequestWatchdog::disable(); + }); + + self::assertSame( + 'Swoole\Coroutine\CanceledException', + $outcome, + 'a 0.3 s body must not survive on a 1 s budget already 0.9 s spent', + ); + } + + /** A budget wholly spent queueing is cancelled at the first sweep, not granted anew. */ + public function test_a_request_that_queued_past_its_budget_does_not_start_afresh(): void + { + $outcome = null; + + \Swoole\Coroutine\run(static function () use (&$outcome): void { + RequestWatchdog::enable(0.5); + + Coroutine::create(static function () use (&$outcome): void { + $cid = RequestWatchdog::register(elapsed: 2.0); + try { + Coroutine::sleep(5); + $outcome = 'finished'; + } catch (\Throwable $e) { + $outcome = $e::class; + } + RequestWatchdog::release($cid); + }); + + Coroutine::sleep(0.4); + RequestWatchdog::disable(); + }); + + self::assertSame('Swoole\Coroutine\CanceledException', $outcome); + } + + /** Nothing queued, nothing charged — the ordinary request is unaffected. */ + public function test_a_request_that_did_not_queue_keeps_its_whole_budget(): void + { + $outcome = null; + + \Swoole\Coroutine\run(static function () use (&$outcome): void { + RequestWatchdog::enable(1.0); + + Coroutine::create(static function () use (&$outcome): void { + $cid = RequestWatchdog::register(elapsed: 0.0); + try { + Coroutine::sleep(0.3); + $outcome = 'finished'; + } catch (\Throwable $e) { + $outcome = $e::class; + } + RequestWatchdog::release($cid); + }); + + Coroutine::sleep(0.6); + RequestWatchdog::disable(); + }); + + self::assertSame('finished', $outcome); + } + public function test_a_request_inside_its_deadline_is_untouched(): void { $outcome = null; From cc7a4e5d512a53e0d89982b07f4d785ae5e04b4b Mon Sep 17 00:00:00 2001 From: flytachi Date: Thu, 6 Aug 2026 13:59:58 +0500 Subject: [PATCH 69/71] profile --- src/App/Config/Profile.php | 51 ++++++++++++++++++++++++------- src/App/Config/ServerSettings.php | 24 +++++++++++++-- tests/App/WorkerMemoryTest.php | 37 +++++++++++++++++++++- 3 files changed, 98 insertions(+), 14 deletions(-) diff --git a/src/App/Config/Profile.php b/src/App/Config/Profile.php index 24c8011..846b19b 100644 --- a/src/App/Config/Profile.php +++ b/src/App/Config/Profile.php @@ -36,7 +36,7 @@ */ enum Profile: string { - /** Heavy requests — reports, exports, a monolith with wide joins. 256 KB each. */ + /** Heavy requests — reports, exports, a monolith with wide joins. 512 KB each. */ case Stable = 'stable'; /** Ordinary CRUD. 128 KB per request, and the default when nothing is said. */ @@ -95,14 +95,20 @@ public function guards(): bool } /** - * Heap this profile reserves for one in-flight request's own work, on top of - * {@see REQUEST_FLOOR}. Zero for {@see Stress}, which reserves nothing because it - * caps nothing. + * Heap one in-flight request may use for **its own work** — the entities it loads, + * the string it serialises — on top of {@see REQUEST_FLOOR}, which is the framework's + * and which no application can influence. Zero for {@see Stress}, which budgets + * nothing because it caps nothing. + * + * It is an assumption about the application, however the profile is described: a + * ceiling of 1 170 in flight is the same statement as "a request here fits in 64 KB". + * Choose the profile knowing that, and an application whose requests are heavier is + * not made safe by the framework — it is made to die later. */ - public function requestHeadroom(): int + public function requestBudget(): int { return match ($this) { - self::Stable => 256 * 1024, + self::Stable => 512 * 1024, self::Balance => 128 * 1024, self::Performance => 64 * 1024, self::Stress => 0, @@ -113,11 +119,17 @@ public function requestHeadroom(): int * Requests this profile allows in flight at once, given the heap left after the * worker's own baseline. `0` means no cap. * - * Each one is budgeted at floor + headroom, plus a second connection at + * Each is budgeted at floor + budget, plus a second connection at * {@see CONNECTION_FLOOR} for the client that is connected and *not* currently asking * — the ordinary keep-alive case. That one-to-one assumption is what * {@see connections()} spends; a service whose clients hold connections open far * longer says so with `maxConnections()`. + * + * How far this can be pushed is bounded by the floor, not by the profile: measured at + * 256M, cutting the budget to **zero** — an application allowed to allocate nothing at + * all — raises the ceiling from 1 170 to 1 683, and no further. At `Performance` the + * framework's own 146 KB is already 70 % of what a request costs. What multiplies the + * ceiling is memory: the same profile gives 2 418 at 512M and 4 915 at 1G. */ public function concurrency(int $availableBytes): int { @@ -125,15 +137,32 @@ public function concurrency(int $availableBytes): int return 0; } - $perRequest = self::REQUEST_FLOOR + $this->requestHeadroom() + self::CONNECTION_FLOOR; + $perRequest = self::REQUEST_FLOOR + $this->requestBudget() + self::CONNECTION_FLOOR; return max(1, intdiv(max(0, $availableBytes), $perRequest)); } - /** Connections allowed at once: the working ones and an idle one apiece. `0` = no cap. */ - public function connections(int $availableBytes): int + /** + * Connections allowed at once: the working ones and an idle one apiece, but never + * more than the process has file descriptors for. `0` = no cap. + * + * A socket is a descriptor, so `ulimit -n` is a second ceiling entirely independent of + * memory — and the tighter one on a stingy host. Swoole enforces it either way: asked + * for more it prints `max_connection is exceed the maximum value, it's reset to N` and + * silently uses N. Clamping here means the number the framework reports is the number + * that will apply, and the warning never appears. + * + * Only the derived value is clamped. An explicit `maxConnections()` above the limit is + * left to Swoole, so its warning still reaches the operator who asked for it — that + * warning is the one thing telling them to raise `ulimit -n`. + */ + public function connections(int $availableBytes, int $descriptorLimit): int { - return $this->guards() ? $this->concurrency($availableBytes) * 2 : 0; + if (!$this->guards()) { + return 0; + } + + return min($this->concurrency($availableBytes) * 2, max(1, $descriptorLimit)); } /** diff --git a/src/App/Config/ServerSettings.php b/src/App/Config/ServerSettings.php index 6d00804..20153aa 100644 --- a/src/App/Config/ServerSettings.php +++ b/src/App/Config/ServerSettings.php @@ -282,7 +282,25 @@ public function getMaxConnections(): int return $configured; } - return $this->getProfile()->connections($this->availableBytes()); + return $this->getProfile()->connections($this->availableBytes(), $this->descriptorLimit()); + } + + /** + * The process's file-descriptor ceiling, or {@see PHP_INT_MAX} when there is none to + * read — a socket is a descriptor, so this bounds connections independently of memory. + * + * The soft limit, because that is the one in force and the one Swoole clamps to. It is + * often the tighter of the two ceilings on a developer's own machine: measured, a + * container from the same image allows 1 048 576, while a macOS shell allows 1 024. + */ + private function descriptorLimit(): int + { + if (!function_exists('posix_getrlimit')) { + return PHP_INT_MAX; + } + $limit = posix_getrlimit()['soft openfiles'] ?? null; + + return is_numeric($limit) && (int) $limit > 0 ? (int) $limit : PHP_INT_MAX; } /** @@ -368,7 +386,9 @@ public function getMaxConcurrency(): int return $configured; } - return $this->getProfile()->concurrency($this->availableBytes()); + // Never more than there are connections to carry them: a request in flight holds + // a socket, so a descriptor ceiling below the memory one binds here too. + return min($this->getProfile()->concurrency($this->availableBytes()), $this->getMaxConnections()); } /** diff --git a/tests/App/WorkerMemoryTest.php b/tests/App/WorkerMemoryTest.php index 314d3b2..2b4c512 100644 --- a/tests/App/WorkerMemoryTest.php +++ b/tests/App/WorkerMemoryTest.php @@ -468,7 +468,7 @@ public function test_each_profile_derives_its_own_concurrency(): void { $expected = [ // profile, 256M, 128M - [Profile::Stable, 611, 285], + [Profile::Stable, 373, 174], [Profile::Balance, 896, 418], [Profile::Performance, 1170, 546], ]; @@ -495,6 +495,41 @@ public function test_connections_leave_room_for_idle_keep_alive_clients(): void self::assertSame($settings->getMaxConcurrency() * 2, $settings->getMaxConnections()); } + /** + * A socket is a file descriptor, so `ulimit -n` bounds connections independently of + * memory — and is the tighter ceiling on a stingy host. Asked for more than it allows, + * Swoole prints `max_connection is exceed the maximum value, it's reset to N` and uses + * N, so a derived number above it would be a number the framework reports and Swoole + * ignores. + */ + public function test_derived_connections_never_exceed_the_descriptor_limit(): void + { + $available = 240 * 1024 ** 2; + + self::assertSame(1792, Profile::Balance->connections($available, PHP_INT_MAX)); + self::assertSame(1024, Profile::Balance->connections($available, 1024), 'clamped by descriptors'); + self::assertSame(1, Profile::Balance->connections($available, 0), 'never zero, which means "no cap"'); + } + + /** A request in flight holds a socket, so the descriptor ceiling binds concurrency too. */ + public function test_concurrency_never_exceeds_the_connections_that_carry_it(): void + { + $settings = self::settings('256M')->maxConnections(100); + + self::assertSame(100, $settings->getMaxConnections()); + self::assertSame(100, $settings->getMaxConcurrency(), 'cannot work more requests than sockets'); + } + + /** + * An explicit value above the limit is left alone. Swoole's warning is then the only + * thing telling the operator to raise `ulimit -n`, and swallowing it would leave them + * wondering why a number they set is not the number in force. + */ + public function test_an_explicit_connection_ceiling_is_left_for_swoole_to_complain_about(): void + { + self::assertSame(999_999, self::settings('256M')->maxConnections(999_999)->getMaxConnections()); + } + /** Balance is what an application that says nothing gets. */ public function test_balance_is_the_default_profile(): void { From 9a545673dcbd163b733bbb95851003297d8f4c7f Mon Sep 17 00:00:00 2001 From: flytachi Date: Thu, 6 Aug 2026 20:02:36 +0500 Subject: [PATCH 70/71] profile --- doc/STATUS.md | 93 +++++++++++++++++++++++++++++ docs/configuration/08-runtime.md | 10 ++-- docs/configuration/09-web-server.md | 27 ++++++--- src/App/Config/Profile.php | 58 +++++++++++++----- tests/App/WorkerMemoryTest.php | 20 ++++++- 5 files changed, 180 insertions(+), 28 deletions(-) diff --git a/doc/STATUS.md b/doc/STATUS.md index ab64f7a..2e4e3ae 100644 --- a/doc/STATUS.md +++ b/doc/STATUS.md @@ -696,6 +696,99 @@ Performance 1219/2438, Stress — ни одного ключа (умолчани `cpu.max = 100000 100000`). Поэтому профиль не трогает `workers`, а из примеров в документации этот вызов убран. Читать cgroup-квоту — отдельная задача. +### ✅ Профили проверены на стенде, `max_request` исправлен (2026-08-06) + +Шесть прогонов `benchKit --full` на `stand-k6` (контейнер 1 CPU / 512M, `memory_limit` +256M). Карта «профиль → папка» в `benchKit/storage/PROFILE-MAP.txt`. + +**Профили работают.** Каждая точка поломки совпала с нашим пределом до цифры: Stable +ломался на `/io` при c=500 (предел 392) и на `/compute` при c=750 (предел соединений 784), +Balance — на `/io` при c=1000 (предел 941), Performance не сломался нигде. Пропускная +способность у всех одинакова (26–29 тыс. rps, CPU 71 %) — **предел ничего не стоит, пока +не упирается**. + +**Память ни разу не была узким местом:** пик 109–158 МиБ из 512. Даже Stress без единого +предела не приблизился к фаталу. + +#### 🐞 `max_request` был привязан к профилю — исправлено + +Замена воркера **убивает выполняющиеся запросы**. Замерено изолированно: 200 запросов при +`max_request=30` → 125 потеряно. И данные прогонов легли на это точно: + +| Профиль | Замена через | Обрывов | +|---|---:|---:| +| Stable | 78 951 | 6 805 | +| Balance | 157 903 | 1 000 | +| Performance | 315 806 | 48 | + +То есть **самый осторожный профиль рвал клиентов чаще всех** — прямая инверсия смысла. +Ошибка была в рассуждении: я привязал долю утечки к осторожности профиля, хотя утечка — +фиксированное число байт на запрос и о весе запроса ничего не знает. + +Стало: **20 % кучи под утечку у всех** → 298 261 при 256M. Проверено повторными прогонами: + +| Профиль | Падений до → после | Обрывов до → после | `/io` rps до → после | +|---|---|---|---| +| Stable | 751 → **95** | 6 805 → **2 564** | 5 752 → **7 439** | +| Balance | 15 → **0** | 1 000 → **197** | 13 746 → **17 866** | + +#### 🔬 Утечка Swoole оказалась условной + +Прежняя запись «170 Б на запрос через весь конвейер» неточна. Замерено на живом сервере с +выключенной заменой воркера: + +| Что делает запрос | Прирост кучи | +|---|---:| +| обычный JSON без приостановки | **0** (4.6 млн запросов) | +| `Coroutine::defer()` | 0 | +| канал + корутина | 0 | +| **пул с таймаутом** `Channel::pop($t)`, даже когда реально ждёт | **0** | +| `Coroutine::sleep()` — **сработавший таймер** | **180 Б** | + +Течёт только сработавший таймер. Значит обычное приложение (контроллер → база → JSON) не +течёт вовсе, а платит за замену воркера наравне со всеми. Это и есть довод за редкую +замену. + +#### Что такое DROP и REJECT в отчётах benchKit + +Классы взаимоисключающие (`bench.sh:505`): `DROP` — только обрывы сокетов, `TIMEOUT` — +только клиентские таймауты, `REJECT` — только не-2xx (у нас 503). + +Оба сводятся к одной причине — замене воркера: запросы **в работе** теряют соединение +(DROP), запросы **в очереди** получают 503 (REJECT). Контрольный опыт: очередь 50 при 500 +клиентах **без** замены — ноль ошибок; с заменой каждые 2000 — 965 обрывов и 700 ответов +503. Превышение `max_connection` даёт только обрывы, без 503. + +**Поправка к сказанному ранее:** я утверждал, что Swoole об этом молчит. Неверно — я сам +глушил вывод через `log_level => SWOOLE_LOG_ERROR` в тестовых серверах. При уровне по +умолчанию он пишет `Too many connections [now: N]` (предел соединений) и +`ReactorEpoll::del() ERRNO 800` при смене воркера — обе строки воспроизводятся на голом +Swoole без нашего кода. Верно осталось одно: **о самих потерянных запросах он не сообщает**. + +#### Разброс инструмента + +Stress прогнан дважды в одинаковой конфигурации: rps расходится на ~5 %, обрывы — вдвое +(171 против 78). Значит приросты Balance (+46 % на `/compute`, +30 % на `/io`) настоящие, а +отметки «поломки» на `/ping` при c=10 (8 и 24 ошибки из сотен тысяч) — шум инструмента: +`err_tol = 0.5 %` применяется, судя по всему, не ко всем классам ошибок. + +**Performance ≈ Stress в пределах шума** (18 057 против 19 121 на `/io`). Снятие всех +пределов не даёт ничего — подтверждает, что «Performance в разы больше» невозможен: пол в +146 КБ на запрос неустраним и при `Performance` составляет уже 70 % бюджета. + +#### Открыто, замерено, не сделано + +- **Соединения выведены из конкурентности (`×2`)** — вес запроса протекает в число + соединений, хотя соединение стоит 68 КБ при любом весе. Последствие: Stable отказал на + 784 соединениях, израсходовав 109 МиБ из 256M (влезло бы ~2 900). Развязка дала бы + 1 055 вместо 746, но ввела бы новую невымеряемую долю взамен спрятанной в `×2`. +- **`SWOOLE_PROCESS`** убирает обрывы при замене полностью — замерено 60 из 60 против + 23 из 60, и то же для `stop($workerId)`: 200 из 200 против 75 из 200. Цена: −6 % rps на + одном воркере, −20 % на четырёх. Раз замена стала вчетверо реже, платить незачем. +- **Замена по росту кучи вместо счётчика** — упирается в то, что под насыщением куча + законно занята запросами в полёте (у Balance до 184 МБ из 256M), и отличить это от + утечки по размеру кучи нельзя. Нужен признак, не путающий «занято работой» с «утекло». + ### 🐞 `heartbeat_idle_time` рвёт выполняющиеся запросы — умолчание убрано (2026-08-06) Введённое накануне умолчание `heartbeat_idle_time = 300` **откачено**. Swoole меряет время diff --git a/docs/configuration/08-runtime.md b/docs/configuration/08-runtime.md index 0d72074..34ecb0d 100644 --- a/docs/configuration/08-runtime.md +++ b/docs/configuration/08-runtime.md @@ -113,16 +113,18 @@ $server->profile(Profile::Performance); | Profile | One request may use | Suits | |---|---:|---| -| `Stable` | 256 KB | reports, exports, a monolith with wide joins | +| `Stable` | 512 KB | reports, exports, a monolith with wide joins | | **`Balance`** (default) | 128 KB | ordinary CRUD | | `Performance` | 64 KB | a thin API, a proxy, an integration bridge | | `Stress` | — | benchmarks only | The axis is the **shape of a request, not caution**: `Performance` gives a service with small requests more concurrency, not less. Everything else is derived from measured -constants — 78 KB per in-flight request, 68 KB per open connection, 170 B leaked per -request — against the heap left after the application's own baseline, which is measured at -startup rather than assumed. +constants — 78 KB per in-flight request, 68 KB per open connection, 180 B leaked by a +request that arms a timer — against the heap left after the application's own baseline, +which is measured at startup rather than assumed. Worker replacement is the one derived +setting that does *not* vary with the profile: what it guards against is the same for all +of them, and what it costs — the requests the worker was serving — is too. Swoole **queues** what exceeds the concurrency cap rather than refusing it, so overload becomes latency, not errors — measured, twenty concurrent 0.3-second requests against a diff --git a/docs/configuration/09-web-server.md b/docs/configuration/09-web-server.md index ba67d84..9f2d933 100644 --- a/docs/configuration/09-web-server.md +++ b/docs/configuration/09-web-server.md @@ -78,7 +78,7 @@ $server->profile(Profile::Performance); | Profile | One request may use | Suits | |---|---:|---| -| `Stable` | 256 KB | reports, exports, a monolith with wide joins | +| `Stable` | 512 KB | reports, exports, a monolith with wide joins | | **`Balance`** (default) | 128 KB | ordinary CRUD | | `Performance` | 64 KB | a thin API, a proxy, an integration bridge | | `Stress` | — | benchmarks only: every cap and every periodic task off | @@ -100,15 +100,28 @@ Everything else follows arithmetically, from measurements rather than preference | Setting | Derivation | |---|---| | `maxConcurrency` | available heap ÷ (78 KB + the profile's number + 68 KB) | -| `maxConnections` | twice that — the working ones and one idle client apiece | +| `maxConnections` | twice that — the working ones and one idle client apiece, capped by `ulimit -n` | | `memoryTrimThreshold` | `memory_limit` ÷ 16 · 8 · 4 | -| `maxRequest` | `memory_limit` × 5% · 10% · 20% ÷ 170 B leaked per request | +| `maxRequest` | `memory_limit` × 20% ÷ 180 B — **the same for every profile** | | `maxRequestGrace` | a tenth of `maxRequest` | +Worker replacement is the one setting that does **not** vary with the profile, and that is +deliberate. What it guards against is a fixed number of bytes per request, which knows +nothing about how large a request is; what it costs is the requests the worker was +serving, which it kills. Tying it to the profile made the most cautious one replace four +times as often as the least, and drop clients accordingly — measured on a live stand, +6 805 connections lost against 48. Making it uniform took a Stable run from 751 failures +to 95, and a Balance run from 15 to none. + +The leak it guards against is also **conditional**, which is worth knowing before raising +it further: 4.6 million ordinary requests grew the heap by nothing at all, and neither did +`defer`, a channel round-trip, or a pooled borrow that waits. Only a timer that fires — +`Coroutine::sleep()` and what builds on it — leaks, at about 180 bytes a request. + The three constants are measured, not assumed: **78 KB** is what an in-flight request holds before allocating anything of its own (600 requests suspended in a trivial handler cost 78.2 KB each), **68 KB** is an idle connection (measured linearly at 401, 801 and -1 201), and **170 B** is what Swoole leaks per request. +1 201), and **180 B** is what a request that arms a timer leaks. "Available heap" is the limit less what the application already holds at boot — and that baseline is **measured**, not guessed, so an application with a hundred routes and three @@ -118,9 +131,9 @@ At 256M, with a booted application carrying ~16M: | Profile | In flight | Connections | Trim at | Recycle at | |---|---:|---:|---:|---:| -| Stable | 611 | 1 222 | 16M | 78 951 | -| Balance | 896 | 1 792 | 32M | 157 903 | -| Performance | 1 170 | 2 340 | 64M | 315 806 | +| Stable | 373 | 746 | 16M | 298 261 | +| Balance | 896 | 1 792 | 32M | 298 261 | +| Performance | 1 170 | 2 340 | 64M | 298 261 | Double the memory limit and every number roughly doubles. The profile stays the same decision, so it does not have to be revisited when the container is resized. diff --git a/src/App/Config/Profile.php b/src/App/Config/Profile.php index 846b19b..5ca7db9 100644 --- a/src/App/Config/Profile.php +++ b/src/App/Config/Profile.php @@ -78,12 +78,25 @@ enum Profile: string private const int CONNECTION_FLOOR = 68 * 1024; /** - * Bytes leaked per request through the whole pipeline. Swoole loses 56 bytes on every - * coroutine suspend/resume — measured to the byte over five runs of ten thousand, and - * surviving both `gc_collect_cycles()` and `gc_mem_caches()` — and a request suspends - * several times. {@see maxRequest()} turns this into a replacement interval. + * Bytes leaked by a request that **arms a timer** — `Coroutine::sleep()` and anything + * built on it. Measured against a live server with replacement disabled: 248 723 such + * requests grew the heap from 2.16 MB to 44.89 MB, or 180 bytes each. + * + * It is not a cost every request pays, and the distinction matters because + * {@see maxRequest()} is what the framework charges everyone for it. Measured on the + * same server: 4.6 million ordinary requests grew the heap by **zero** bytes, and so + * did `Coroutine::defer()`, a channel round-trip, and a pooled borrow that actually + * waits (`Channel::pop($timeout)` — the pool's own path). Only the fired timer leaks. + */ + private const int LEAK_PER_REQUEST = 180; + + /** + * Fraction of the heap the leak may reach before a worker is replaced. + * + * One value for every profile: the leak is per request and has nothing to do with how + * much memory a request uses, so there was never anything for it to vary with. */ - private const int LEAK_PER_REQUEST = 170; + private const float LEAK_BUDGET_SHARE = 0.20; /** Assumed heap when the limit cannot be read (`-1`, or a value PHP cannot parse). */ private const int ASSUMED_LIMIT = 128 * 1024 * 1024; @@ -189,21 +202,36 @@ public function trimThreshold(int $limitBytes): int /** * Requests a worker serves before it is replaced. `0` never replaces it. * - * Derived from what the leak is allowed to reach: a bigger heap tolerates more of it - * before replacement is worth its cost — the new worker starts with an empty - * connection pool and has to fill it, about 30 ms. + * The same for every profile, because what it guards against does not vary with them: + * the leak is a fixed number of bytes per request and knows nothing about how large a + * request is. Tying it to the profile — which is what this did at first — made the + * most cautious profile replace its worker four times as often as the least, and + * replacement is the one thing here with a **certain** cost. + * + * That cost, measured on a live stand: replacing a worker kills the requests it was + * serving. Under load the three profiles differed only in how often they did it, and + * the dropped connections followed exactly — 6 805 for a worker replaced every 78 951 + * requests, 48 for one replaced every 315 806. Nothing was written to any log; only + * the client sees it. + * + * The benefit, by contrast, is **conditional**. Measured: 4.6 million ordinary + * requests grew the heap by nothing at all, and neither did `defer`, a channel, or a + * pooled borrow that waits. Only a **timer that fires** leaks — `Coroutine::sleep()` + * costs about 180 bytes a request. So an application that never sleeps in a handler + * does not leak, and pays for this in dropped requests without receiving anything. + * + * Hence one share for everyone, sized so a leaking application stays well inside its + * limit: at 20 %, a worker at 256M is replaced after ~315 000 requests, having leaked + * about 51 MB if every request slept. */ public function maxRequest(int $limitBytes): int { + if (!$this->guards()) { + return 0; + } $limit = $limitBytes > 0 ? $limitBytes : self::ASSUMED_LIMIT; - $share = match ($this) { - self::Stable => 0.05, - self::Balance => 0.10, - self::Performance => 0.20, - self::Stress => 0.0, - }; - return (int) ($limit * $share / self::LEAK_PER_REQUEST); + return (int) ($limit * self::LEAK_BUDGET_SHARE / self::LEAK_PER_REQUEST); } /** diff --git a/tests/App/WorkerMemoryTest.php b/tests/App/WorkerMemoryTest.php index 2b4c512..53bb7c1 100644 --- a/tests/App/WorkerMemoryTest.php +++ b/tests/App/WorkerMemoryTest.php @@ -371,8 +371,24 @@ public function test_worker_recycling_is_configured_by_default(): void { $options = self::settings('256M')->toArray(); - self::assertSame(157_903, $options['max_request'], 'a worker must not live forever'); - self::assertSame(15_790, $options['max_request_grace'], 'and they must not all recycle at once'); + self::assertSame(298_261, $options['max_request'], 'a worker must not live forever'); + self::assertSame(29_826, $options['max_request_grace'], 'and they must not all recycle at once'); + } + + /** + * Replacement costs the same whatever the profile — it kills the requests the worker + * was serving — while what it guards against, a per-request leak, has nothing to do + * with how large a request is. Tying it to the profile made the most cautious one + * replace four times as often as the least, and drop clients accordingly: measured on + * a live stand, 6 805 connections lost against 48. + */ + public function test_recycling_does_not_vary_with_the_profile(): void + { + $of = static fn(Profile $p): int => self::settings('256M')->profile($p)->getMaxRequest(); + + self::assertSame($of(Profile::Balance), $of(Profile::Stable)); + self::assertSame($of(Profile::Balance), $of(Profile::Performance)); + self::assertSame(0, $of(Profile::Stress), 'the bench profile never replaces its worker'); } /** From 0f1a6e057065e3b8687bd697ada663cf88ea4828 Mon Sep 17 00:00:00 2001 From: flytachi Date: Fri, 7 Aug 2026 13:37:55 +0500 Subject: [PATCH 71/71] profile --- CLAUDE.md | 835 ------------------------- comparison_20260802_k5.md | 141 ----- doc/STATUS.md | 892 --------------------------- tests/Architecture/NamespaceTest.php | 69 --- 4 files changed, 1937 deletions(-) delete mode 100644 CLAUDE.md delete mode 100644 comparison_20260802_k5.md delete mode 100644 doc/STATUS.md delete mode 100644 tests/Architecture/NamespaceTest.php diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 0edd4d7..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,835 +0,0 @@ -# CLAUDE.md — winter-kernel: Process/Daemon + ConnectionPool handoff - -This file orients you (Claude) to the work done on `winter-kernel`: the -**Process/Daemon** layer (§1–§12), the **ConnectionPool** layer (§13), the -**project layout / static files** (§14), and the **`WinterApplication` starter** (§15). -Read the relevant part fully before touching it. It describes what was built, how it -works, why, and the rules to keep. - -> **winter-kernel** is a PHP 8.4+ framework kernel (a library, not an app). It runs -> under two runtimes: **Swoole** (coroutines) and **FPM/CLI** (plain processes). -> Namespace root: `Flytachi\Winter\Kernel\` → `src/`. Tests: `Flytachi\Winter\Kernel\Tests\` → `tests/`. - ---- - -## 0. Standing rules (do not violate) - -- **NEVER touch git.** The user commits/pushes. Plans contain code only — no commit steps. -- **Comments & PHPDoc: English only.** (Discussion with the user can be Russian.) -- **PHPDoc style:** one space between tag/type/name (no column alignment). In code - examples, use **only real methods** — never invent a method like `$this->handle()`; - use a comment placeholder (`// ... work ...`) for domain logic. -- **Framework philosophy: mechanism, not policy.** The kernel gives pipeline/hooks; - the coder decides policy (masking, sanitization, etc.). -- **Encapsulation matters to the user.** Internal machinery must not leak into the - application-facing API (see §7). This was a repeated, explicit concern. -- Sibling packages (`winter-thread`, `winter-logger`, `winter-di`, `winter-cdo`) are - **separate repos** pulled via composer. Don't edit them from here unless asked. -- The **dev playground** is `dev/` — a runnable app. Demos: `php dev/call daemon|process ...`. - ---- - -## 1. What this layer is - -A **runtime-agnostic managed-process abstraction**, Java-canonical in feel, mirroring -the existing `src/Concurrent` layer. Two levels: - -- **`Process`** — one managed worker: one body, one PID, alive until stopped. For a - single queue consumer, a single scheduled loop, a leader-elected singleton. -- **`Daemon extends Process`** — a **supervised fleet** of identical worker replicas - kept alive by a master supervisor (like `nginx`/`php-fpm` master+workers, or a k8s - Deployment). For a consumer pool, autoscaled workers, crash-isolated fan-out. - -The developer writes config + a body; the framework supplies the runtime (coroutines -or forks), concurrency, cooperative cancellation, a signal contract, guaranteed -teardown, a crash-safe singleton, and (for Daemon) forking/reaping, restart with -back-off, graceful drain, autoscaling with damping, and a liveness watchdog. - -### Location & namespaces - -| Path | Namespace | What | -|---|---|---| -| `src/Process/Stereotype/` | `…\Process\Stereotype` | **`Process` and `Daemon` — the two classes an application extends** | -| `src/Process/` | `Flytachi\Winter\Kernel\Process` | shared model: `Activity`, `ProcessStatus`, `ProcessState`, `ProcessStore`, `ForkReset` | -| `src/Process/Daemon/` | `…\Process\Daemon` | supervision + policies + slot model (`SupervisesFleet`, `Slot`, `ScalingPolicy`, …) | -| `src/Process/Engine/` | `…\Process\Engine` | runtime backends (Swoole/Sync) | -| `src/Process/Internal/` | `…\Process\Internal` | private-method traits (encapsulation) | - -> The split is the point: `Process/Stereotype/` holds what you extend, everything beside -> it is machinery. Before the 2026-08-02 restructure `Daemon` sat inside -> `Process/Daemon/` next to `SupervisesFleet`, and telling the extension point from the -> internals meant reading the source. See `doc/2026-08-02-restructure-design.md`. -> -> History: this layer was developed in `src/Dev/Process/`, then promoted to -> `src/Process/`; the archived `src/Old/Process/` has since been deleted. If you see -> `Dev\Process` or `Old\Process` anywhere it is stale. - ---- - -## 2. `Process` — developer API - -```php -final class EmailConsumer extends Process -{ - #[Autowired] private MailQueue $queue; - - protected int $concurrency = 50; // cap on spawn() (0 = unlimited) - protected float $grace = 0.0; // drain deadline on stop (0 = wait forever) - protected ?string $processTitle = null; // ps title (default = short class name) - - public function run(): void // the body — loop on isRunning() - { - while ($this->isRunning()) { - $job = $this->queue->pop(timeout: 1.0); - if ($job === null) { continue; } - $this->markBusy(); - // ... process $job ... - $this->markIdle(); - } - } -} -``` - -**Primitives** (use inside `run()`; `final protected`): `isRunning(): bool`, -`sleep(float)` (interruptible — throws `InterruptedException` if an IDLE wait is -woken by a stop), `spawn(callable): Future` (bounded by `$concurrency`; -structured-concurrency drain on exit), `requestStop()`, `markBusy()/markIdle()`, -`activity(): Activity`, `touch()` (explicit liveness beat — for a daemon watchdog). - -**Hooks** (`protected`, override to react; the framework calls them): -`onTerminate` (SIGTERM), `onInterrupt` (SIGINT), `onReload` (SIGHUP — does NOT stop), -`onUser1`/`onUser2` (SIGUSR1/2), `onShutdown` (guaranteed teardown, runs once), -`afterFork` (reset fork-unsafe resources in a forked worker — see §5), -`buildProcessTitle`/`titleName` (override to customise the `ps` title). - -**Control** (`final public static`; usually from CLI): `start(): void` (foreground, -blocks), `dispatch(?string $output = '/dev/null'): int` (detached background, returns -PID), `status(bool $usage = false): ?ProcessStatus`, `stop(): bool` (sends SIGTERM). - -**Singleton per class** — one running instance per class, guarded by a crash-safe -`flock` (never a PID file). A second `start()`/`dispatch()` throws -`ProcessAlreadyRunningException`. Liveness via `posix_getpgid` (needs no permission → -cross-user safe). `status()` is a **pure read** — it never deletes the record. - -**Stop semantics:** SIGTERM/SIGINT → `isRunning()` flips false (cooperative). An IDLE -`sleep()` is interrupted at once (coroutine cancel / flag). A BUSY unit drains -(finishes its current unit). Past `grace` (>0) → force exit. `grace = 0` = wait -forever. A **repeated** stop on a bare process is **ignored** (only `grace` timer or -external SIGKILL forces) — the "second signal forces" behaviour belongs to Daemon. - ---- - -## 3. `Daemon` — developer API - -```php -final class Emails extends Daemon -{ - protected int $replicas = 3; // baseline fleet size - protected float $grace = 30.0; // master drain deadline (k8s parity; 0 = forever) - protected float $livenessTimeout = 0.0; // watchdog off by default - - // Body — ONE of two (priority: workerRun ▸ $workerClass): - protected function workerRun(): void // inline: the daemon IS the worker - { - while ($this->isRunning()) { $this->markBusy(); /* ... */ $this->markIdle(); } - } - // protected ?string $workerClass = SendProcess::class; // OR supervise an external Process class - - // Optional policy (all have sane defaults): - protected function desiredReplicas(): int { return min(16, intdiv($this->queue->depth(), 100)); } - protected function scaling(): ScalingPolicy { return ScalingPolicy::default(); } - protected function restart(): RestartPolicy { return RestartPolicy::default(); } - - // Optional master hooks: - protected function onWorkerStart(int $slot, int $pid): void {} - protected function onWorkerExit(int $slot, int $pid, bool $crashed): void {} - protected function onScale(int $from, int $to): void {} - protected function tick(): void {} // periodic on master (~scaleInterval) -} -``` - -The **manager and the unit of work are separate concerns** (like an executor and its -task). `workerRun()` = self-typed (daemon is also the worker). `$workerClass` = a -standalone `Process` you can also run solo (`SendProcess::start()`) and supervise. If -neither is set, each worker fails on fork with `DaemonConfigException`. - -**Control** = same 4 verbs as Process (`start`/`dispatch`/`status`/`stop`); `status()` -returns a **`DaemonStatus`** (a `ProcessStatus` + `restarts` + `workers[]`, one -`WorkerStatus` per non-empty slot). - -`ScalingPolicy` (readonly, non-final, `::default()`): `scaleInterval=1.0`, -`scaleUpDelay=0.0`, `scaleDownStabilization=60.0`, `cooldown=3.0`, `scaleStep=0`. -`RestartPolicy` (readonly, non-final, `::default()`): `mode` (`RestartMode` enum: -`ALWAYS`/`ON_FAILURE`(default)/`NEVER`), `maxRestarts=0` (0=∞), `backoff=1.0`. - ---- - -## 4. How the Daemon works internally (the hard part) - -The master is a **plain `pcntl` process with no event loop of its own** — it forks -workers and reaps them with `pcntl_waitpid`. Forking before any reactor starts is -safe; each worker child then boots its own clean `Coroutine\run` (Swoole) or plain -runtime. Workers are created with **`pcntl_fork`, not the Thread launcher** — -supervision needs the direct parent↔child `waitpid` relationship. (The Thread -launcher is used one level up, in `dispatch()`, to send the whole master to the -background.) - -### Slot state machine (`SlotState` enum, `Slot` mutable model) - -``` - EMPTY ─fork─► STARTING ─heartbeat─► RUNNING ─exit─► RESTARTING ─backoff─► (fork) - │ │ ▲ │ - retire │ retire │ │ un-retire │ give up - ▼ ▼ │ ▼ - RETIRING ◄───────┘ │ RETIRED / EMPTY - │ deadline/force - ▼ - KILLING ─reaped─► EMPTY -``` - -- `isCommitted()` = STARTING/RUNNING/RESTARTING/**RETIRED** (the size reconcile drives - to desired). `isAlive()` = STARTING/RUNNING/RETIRING/KILLING (has a live PID). -- **The slot state is the intent marker** that keeps the restart policy and the - autoscaler from fighting: a worker retired on purpose (scale-down/stop) is `RETIRING` - → never restarted; one that died on its own is handled by the restart policy. - -### The reconcile loop (single authority over fleet size) - -Each `scaleInterval`: `tick()` → compute damped `desiredReplicas()` → drive the -committed fleet toward it (up: fork; down: retire) by at most `scaleStep`, gated by -`cooldown`. - -- **Restart:** an unexpected death (`RUNNING`→exit) → `RestartPolicy`. If it restarts, - the slot goes `RESTARTING` (back-off `base·2^(n-1)`, cap 30s) then re-forks into the - **same slot** (so `worker#{n}` is stable). `maxRestarts` exceeded → daemon `FAILED`, - stops. If policy declines (`NEVER`, or a clean exit under `ON_FAILURE`) → **`RETIRED` - terminal state** (NOT freed — else reconcile would refill it instantly, bypassing - back-off → crash-loop). This RETIRED behaviour is a deliberate fix; do not "simplify" - it back to `free()`. -- **Scale-down:** mark victims `RETIRING` (graceful SIGTERM drain), **IDLE-first** - (best-effort from the heartbeat; a BUSY victim still drains). RETIRED/RESTARTING - slots are shed first (free). Anti-flap: a scale-up first un-retires still-draining - workers before forking new ones. -- **Scaling damping (`ScalingPolicy`):** up = react to the sustained floor over - `scaleUpDelay`; down = shrink only to the **high-water demand over - `scaleDownStabilization`** (a transient dip sheds nothing). `scaleStep` caps the - magnitude per action; `cooldown` the frequency. Signal, not command. - -### Stop sequence - -SIGTERM to master → **freeze reconcile** (state STOPPING) FIRST → retire the whole -fleet (parallel SIGTERM) → each drains by its deadline (`grace`; 0=∞) → SIGKILL -stragglers past deadline → after the fleet is empty run `onShutdown()`, delete the -record, release the flock, exit `TERMINATED`. A **second** stop signal collapses all -deadlines → immediate SIGKILL (operator's "stop now"). - -### Watchdog (liveness) - -Every worker writes a **monotonic heartbeat** (`ProcessStatus.heartbeatAt`) each engine -tick (~1s) to a per-slot store record keyed `#`. The master reads it for -(a) the fleet view and (b) IDLE-first victim selection. If `livenessTimeout > 0` and a -RUNNING/STARTING worker's heartbeat goes silent past it → SIGKILL → reap → restart -(crash path). Catches a wedged worker (deadlock, hung I/O) that a plain PID check -misses. Under FPM, a long BUSY unit that never yields won't heartbeat → either raise -the timeout or call `touch()` inside it. - -### Titles - -`winter-process: ` · `winter-daemon: master` · `winter-daemon: -worker#{n}` where **n is one-based** (`worker#1` = slot #0). Shared `winter-daemon:` -prefix → `pkill -f 'winter-daemon: '` kills the whole family. - ---- - -## 5. Fork-safety (`afterFork` / `ForkReset`) - -A fork copies the parent's memory **including open fds** (DB connections, pools, -sockets). Shared fds corrupt the wire protocol. Rule: **open connections in the child**. - -- `Process::afterFork()` runs in a forked worker before `run()`. Default: - `ForkReset::runAll()` — runs every framework-registered reset. Override to reset your - own resources (call `parent::afterFork()` first). -- Framework packages register a reset at bootstrap. **`Kernel::init()` registers** - `ForkReset::register(fn() => PpaConnectionPool::reset())` — so daemon workers get - fresh DB connections automatically. A reset must **reconnect in place** (not replace - the object — an injected reference would go stale) or the pool must be lazy. - ---- - -## 6. Runtimes (engines) - -`Engines::common($concurrency, $grace)` picks by `extension_loaded('swoole')`: - -- **`SwooleEngine`** — body runs in `Coroutine\run`; `spawn()` = real coroutines - (shared memory, coroutine semaphore); `sleep()` non-blocking; stop cancels the body - coroutine → interruptible. -- **`SyncEngine`** — FPM/plain; `spawn()` = `pcntl_fork` per task (isolated, - fire-and-forget, `Future` = settled placeholder); `sleep()` interruptible in chunks; - `SIGALRM` force-exit at grace. - -Both implement `ProcessEngine`. **Two bugs fixed here — do not reintroduce:** -1. **Reactor hang:** `SwooleEngine::enter()` MUST unregister signals - (`\Swoole\Process::signal($signo, null)`) + clear timers in its `finally`, else - `Coroutine\run` never returns after the body ends (hangs forever with `grace=0`). -2. **grace>0 drain freeze:** in `SwooleEngine::requestStop()`, arm the grace - `Timer::after` **BEFORE** `Coroutine::cancel($bodyCid)`. Arming the timer after the - cancel swallows the pending resume → the body freezes until the timer fires (drain - would always wait the full grace). Order matters. - ---- - -## 7. Encapsulation design (important — the user cares deeply) - -Internal machinery must not appear in the application-facing API of a subclass. PHP has -no package-private, so: - -- **`Internal\SingletonLock` trait** (private `acquireLock`/`releaseLock`/`lockPath` + - `$lockHandle`) — `use`d by BOTH `Process` and `Daemon`. Private trait methods are - invisible to app subclasses (`class Foo extends Process` can't call them) yet each - framework class gets its own copy. -- **`Daemon\SupervisesFleet` trait** — the ENTIRE supervision loop (reconcile, - reap, watchdog, scaling, stop, slot transitions, `superviseFleet()`, `snapshot()`, - `backoff()`, …), all **`private`**, `use`d by `Daemon`. There is **no `Supervisor` - class** — it was merged into this trait so nothing leaks. The daemon IS the supervisor - in the master process. -- Daemon's internal accessors (`replicas`, `computeDesired`, `scalingPolicy`, - `restartPolicy`, `graceSeconds`, `livenessTimeout`, `bootWorker`, `workerRecord`, - `clearWorkerRecord`, `fire*`) are all **`private`** — the trait calls them via `$this`. -- `applyProcessTitle` and `Daemon::workerTitle` are **`private`**. -- `Process::runWorker` is **`protected`** (not public): a daemon boots an external - `$workerClass` worker (a sibling Process) via cross-instance protected access - (allowed in PHP because it's declared in the common base `Process`). -- **Kept `protected @internal` by necessity** (not leaks): `key()`, `store()`, - `ensureNotRunning()` — required protected because `status()`/`stop()` reach them via - late-static-binding (`static::key()`) and Daemon reuses them; they are benign. -- `run()` is public — it is the contract method the developer implements (like - `Runnable::run`), not internal machinery. - -**Override points that stay `protected` (intended API):** `run`, all `on*` hooks, -`afterFork`, `buildProcessTitle`, `titleName` (Process); `workerRun`, `desiredReplicas`, -`scaling`, `restart`, `onWorkerStart/Exit`, `onScale`, `tick` (Daemon). - -If you add anything the supervision trait needs from Daemon, make it **private** and -call it via `$this->` from the trait. - -### The extension surface (added 2026-08-02) - -Encapsulation above governs *members*; this governs *classes*. Two rules, both enforced -by tests — `tests/Architecture/{StereotypeLayoutTest,ExtensionSurfaceTest}.php` and -`tests/Console/CommandSurfaceTest.php`. - -**1. What an application extends lives in `/Stereotype/`.** - -| | | -|---|---| -| `src/Http/Stereotype/` | `Controller`, `ControllerInterface`, `Middleware`, `ExceptionResponseBase` | -| `src/Ppa/Stereotype/` | `Repository`, `RepositoryCrud`, `RepositoryView` | -| `src/Process/Stereotype/` | `Process`, `Daemon` | -| `src/Schedule/Stereotype/` | `Scheduler` | -| `console/Stereotype/` | `CmdCustom`, `CmdCustomInterface` | - -Every `Stereotype/` belongs to a layer — there is no orphan one at the root. The layer -keeps its machinery beside, not inside, that directory, so a layer can be extracted into -its own package **together with its extension point**. - -The test for `implements`-only contracts is that they stay put: `MiddlewareInterface` -lives in `Http/Middleware/` and `HealthContributor` in `Http/Health/`, because -**`Stereotype/` is for `extends`, not for `implements`.** - -> **There is no `Service` base class, and do not reintroduce one.** It was an empty -> `abstract class` — Spring's `@Service` **annotation** transliterated into inheritance. -> Java made it an annotation precisely because Java, like PHP, allows a single parent: -> spending that slot on a semantic marker is a bad trade. Nothing in the kernel ever read -> it (the container resolves by class name, lifetime comes from `#[Singleton]` and -> friends), and in practice it forced `class X extends Service implements XInterface` — -> the real contract pushed into an interface because the parent slot was already gone. -> A service is a plain class. If a role marker is ever wanted, it must arrive as an -> attribute **with a mechanism behind it** (implying `#[Singleton]`, or serving as an AOP -> pointcut target) — a marker nothing reads is policy, not mechanism. - -**2. A class is `final` unless someone wrote down why not.** - -`final` is the only thing that removes a class from an IDE's `extends` completion, so an -open class is public API whether or not that was intended. `ExtensionSurfaceTest::OPEN` -is the single place openness is declared, and each entry carries its reason. Adding a -non-final class without an entry fails the suite. - -Exceptions are open **as a category** (extending `ClientError` in an application is -normal) and are skipped by the test via `Throwable`. - -> **Do not decide this by grep.** `ExceptionResponseBase` has no subclass in this -> repository and still must stay open — `#[AdviceException]` handlers extend it, and they -> live in applications. Check documented contracts (`#[Enable*]`, `#[Advice*]`, PHPDoc -> examples), not usage counts. - -Also: built-in `console/Command/*` are `final` because two of them (`Process`, `Daemon`) -share a short name with a real stereotype, and an open command made both appear in -completion. That was the original complaint; `final` fixed it without renaming anything. - -### DI scopes — one rule, enforced at boot (added 2026-08-03) - -> **A class may hold a reference to a shorter-lived object only if it does not outlive it.** - -Injected properties resolve **once, when the holder is built**. So a `#[Singleton]` -holding a `#[Request]` bean freezes the first request's instance for the worker's -lifetime — every later request keeps seeing it, with no error and nothing in the log. -Measured live: three users, all three served as the first one. - -The reach is **transitive** — a singleton freezes its whole dependency subtree, so -`#[Singleton] → plain service → #[Request]` leaks identically. Also measured, not reasoned. - -`Collector\ScopeGraphCollector` gathers the dependency graph during the single scan pass; -`assertNoFrozenRequestScope()` walks it in `bootstrap()` and throws `ScopeConflictException` -naming the whole path. Cost: ~0.01 ms at boot, nothing per request. Cycle-guarded. - -This mirrors Spring's *safe* branch: there, `@RequestScope` injects a scoped proxy, and a -raw `@Scope("request")` without one fails at startup. Spring never leaks silently because -its singletons are built eagerly, before any request exists, so the resolution simply has -nothing to capture. Ours are built lazily — during the first request — which is exactly -why the capture succeeds and then rots. The boot check restores the guarantee. - -`#[Request]` outside HTTP: a worker's body is **one** coroutine, so a request-scoped bean -resolved there would outlive every job. `Process::markBusy()` — which already marks where a -unit of work starts — therefore also ends the request scope, via -`Container::flushRequestScope()`. Singletons are untouched, and a body that never marks a -unit resets nothing. Measured before the fix: four iterations, one object, each seeing what -the one before it wrote. - -Consequences worth remembering: -- `#[Singleton]` is **per worker process**, not per application: 4 workers = 4 instances. - Counters and caches in singleton fields disagree with themselves. -- Repositories are safe to share because their query state lives in the coroutine context, - not the object — hence `#[Singleton]` in the generated template (§13-adjacent, `call make -r`). -- `Http\Stereotype\Controller::__construct()` is `final`: no constructor means no natural - place to stash request state, which removes half the trap before it appears. - ---- - -## 8. CLI - -- `call process [start [-d] | stop | status [-v]]` (alias `proc`). Lists - only bare processes. -- `call daemon [start [-d] | stop | status [-v]]` (alias `dmn`). Lists only - daemons; `status` shows the per-worker fleet table (SLOT/PID/STATE/ACT/UPTIME/RESTARTS). - -Dot notation → FQCN: `main.process.Emails` → `Main\Process\Emails`. Tab-completion is -in `console/Command/Complete.php`. Commands: `console/Command/{Process,Daemon}.php`. - ---- - -## 9. Tests - -- **Unit** (`tests/Process/`, ~96 tests): enums, value objects, JSON serialization, - config resolution/clamping, `ForkReset`, titles, and the supervision **decision - algorithms** (`SupervisesFleetTest` — backoff, damping/`windowExtreme`, `pickVictims` - IDLE-first, slot counts) via reflection, deterministic, no forks. -- **Integration** (`tests/Process/Integration/`, 18 tests, `#[Group('integration')]` → - excluded from the default run): real fork/swoole/signals. `IntegrationCase` boots a - temp-storage kernel, forks a real Process/Daemon child, observes via the shared store - + a `WK_MARKER` file, sends real signals. Covers: fork N replicas, graceful stop (no - orphans), restart-in-slot, maxRestarts→FAILED, NEVER→retired, watchdog, autoscale - up→down, singleton, 2nd-signal force, per-worker activity, SIGHUP=reload, external - `$workerClass` path, and all Process signal hooks. -- **Architecture** (`tests/Architecture/`, `tests/Console/`): guards that hold the - structure in place rather than testing behaviour — stereotype addresses, the absent - generation suffix in the namespace, the `final` surface, and that every generator - template imports a class that exists. They fail on drift, which is their whole job. - -**Run:** `vendor/bin/phpunit` (full suite, **1605 tests** — integration excluded). -Integration: `vendor/bin/phpunit --group integration tests/Process/Integration` (needs -`pcntl`+`posix`; runs under Swoole here; ~20s). - -**Test conventions:** -- Method names are `test_snake_case` (project convention; PSR-12 flags camelCase — ignore). -- Reflection needs no `setAccessible` (PHP 8.1+). For a **private property declared in a - trait**, reflect via the **declaring class** (`new ReflectionProperty(Daemon::class, - 'slots')`), NOT the subclass (private parent props aren't visible via a child class). - Private methods reflect fine via the instance. -- Forked children in tests end with `posix_kill(getmypid(), SIGKILL)` to skip PHPUnit's - shutdown (avoids polluting output). -- `IntegrationCase::setUp` resets `KernelStore`'s `$runnable`/`$storages`/`$volatiles` - caches (via reflection) — `Kernel::runnable()` caches `FileStorage` by name against - the path it was first built with, so a reused fixture class would otherwise read a - previous test's deleted temp dir. - -Dev demos (runnable): `dev/main/Process/*.php` (StableDaemon, CrashDaemon, FleetDaemon -[`$workerClass`], AutoscaleDaemon, NeverDaemon, HungDaemon, SendProc, …). - ---- - -## 10. Docs - -`docs/` is the in-repo reference, English, behaviour-focused, verified against the code — -routing and request binding, responses, PPA, processes and daemons, scheduling, console, -configuration, plus `starter/00-quickstart.md`. `README.md` is the install-and-run entry -point. **Keep both accurate when you change an API**: they were audited class-by-class -via reflection, and every framework symbol they name resolves. - -The user's public documentation site lives in a separate repository and is his own -concern — do not try to keep it in sync from here. - -`doc-new/` no longer exists. It held working design notes while the ConnectionPool, -layout and starter work was in flight; those are now §13, §14 and §15 of this file. -Do not recreate it — design rationale belongs here, user-facing prose in `docs/`. - ---- - -## 11. Status - -**Done & verified (live under Swoole + full test suite):** Process (lifecycle, dual -runtime, signals, drain, grace/force, spawn/concurrency, singleton, JSON/security), -Daemon (worker-typed + inline, fork+DI+`afterFork`, reconcile+slot-state, retire/ -scale-down IDLE-first, scaling damping, restart+backoff+maxRestarts→FAILED, full stop -sequence, watchdog, per-worker status, hooks, titles), full encapsulation (traits), -PHPDoc, docs, unit + integration tests. `dispatch()` works from inside a Swoole -coroutine (fixed in the `winter-thread` package: `AdaptiveLauncher`/`SwooleLauncher`). - -**Not done (future phases):** phase 3 — templates (worker-pool, SMPP, WebSocket); -phase 4 — web status (a controller reading the same store; `DaemonStatus` is already -JSON-serializable). - -**Known minor limitations:** `maxRestarts` is cumulative (not "consecutive"); a -STARTING worker that hangs before its first heartbeat relies on the watchdog with a -timeout; SIGKILL of an FPM worker can orphan its `spawn` grandchildren (only on the -past-grace force path). - ---- - -## 12. Gotchas — things already solved, don't reintroduce - -- Force-exit must run cleanup: a `register_shutdown_function` backstop deletes the store - record even on `exit()` (finally alone is skipped on force-exit). -- `status()` uses `posix_getpgid` (not `posix_kill($pid,0)` — which needs permission and - fails cross-user) and is a **pure read** (no delete) — else a cross-user status check - could evict a live process's record. -- Activity is a **backed** enum (`Activity: string`) and status objects are - `JsonSerializable` — a pure enum made `json_encode` return `false`. -- The two SwooleEngine bugs in §6. -- The maxRestarts→FAILED slot-free ordering and the NEVER→RETIRED terminal state in §4. -- Daemon status must heartbeat-persist (~1s) in the loop, not only on fleet-size changes, - or activity/STARTING→RUNNING never reach the store. -- **`wKernelRunner` is load-bearing — do not delete it.** It looks like a leftover (it is - a bare file in the repo root, shipped as composer `"bin"`), but it is the child side of - `dispatch()`: `Process::dispatch()` → `Thread` launcher → `php vendor/bin/wKernelRunner - --detach` → composer bin proxy → the project's `bootstrap.php` → - `WinterApplication::discoverAppClass()` → `::executor($argv)` → `bootstrap()` → - `AdaptiveRunner`. Detaching cannot be a fork (the parent may be a Swoole worker whose - reactor must not be duplicated), so a **fresh PHP process** must boot the app again. - Two traps: the runner resolves the project root as `dirname(__DIR__, 3)`, so it must be - **copied, never symlinked** (PHP resolves `__DIR__` through symlinks); and `WINTER_KEY` - must reach `$_ENV` (`env()` reads `$_ENV` only, never `getenv()`) or the child rejects - the signed payload. Covered by `tests/Process/Integration/DispatchRunnerTest.php` — - every other Process test forks directly and never executes the runner, which is why a - broken runner once went unnoticed. -- Both thread launchers spawn the same `php ` child; only the shell call - differs (`Coroutine\System::exec` inside a coroutine, `proc_open` elsewhere). There is - no `Swoole\Process` path — Swoole refuses one while its async-io threads are up. - ---- - -## 13. `ConnectionPool` — the "HikariCP-lite" layer - -### The problem it solves - -Under FPM every request got a **fresh** connection, so a database outage healed itself: -the process died, the next one reconnected. A long-lived Swoole worker keeps connections -in memory, and a plain `Swoole\ConnectionPool` is a **dumb channel** (`get`/`put`, zero -maintenance): after the DB comes back, the dead sockets are still in the pool and -`put($cdo)` returns each corpse for the next borrower. This layer restores FPM-level -self-healing without paying per-borrow. - -> **Do NOT "fix" this with a `SELECT 1` on every borrow.** That was tried and reverted — -> it adds a round-trip to every query and churns healthy connections. HikariCP does not -> do it either. The mechanism below is the idle-gate; keep it. - -### Location & shape - -| Path | What | -|---|---| -| `src/ConnectionPool/` | the generic, **self-contained** module (no PPA/CDO inside — it can be `git mv`d into its own package) | -| `src/Ppa/Pool/` | the PPA adapter that wires CDO into it | - -Module: `ConnectionPool` (coroutine, `Swoole\Coroutine\Channel`) · `SingleConnection` -(FPM/non-coroutine, **no** Channel) · `ConnectionFactory` (create/validate/close) · -`PoolEntry` · `PoolPolicy` · `PoolException`. Both take an injectable `Closure $clock` -— that is the seam that makes idle/lifetime logic unit-testable without a live DB. - -`Runtime::isSwooleCoroutine()` picks the path: coroutine → `ConnectionPool`, -everything else → `SingleConnection`. **`SingleConnection` is not coroutine-safe**; it -is only ever reached off the coroutine path, and that invariant is what keeps it simple. - -### `PoolPolicy` knobs - -| Knob | Default | Meaning | -|---|---|---| -| `maximumPoolSize` | 10 | upper bound | -| `connectionTimeout` | 15.0 | wait for a free connection, then fail fast | -| `maxLifetime` | 1800.0 | rotate by age (`0` = never); jittered by `maxLifetimeJitter` (0.1) | -| `aliveBypassWindow` | 0.5 | **idle-gate**: idle less than this ⇒ skip the probe | -| `housekeepingInterval` | 30.0 | background pass period (clamped to ≥1s) | -| `keepaliveTime` | 0.0 (off) | probe long-idle connections in the background | -| `idleTimeout` | 0.0 (off) | close idle connections down to `minimumIdle` | -| `minimumIdle` | 0 (lazy) | warm floor | - -The last three are **off by default**, and `housekeepingEnabled()` gates the timer — an -unconfigured pool never arms one. Housekeeping is **Swoole-only** (it needs a timer); -`SingleConnection` gets idle-gate + `maxLifetime` on `get()` and nothing else. - -### `aliveBypassWindow` — the point of the whole design - -Bigger window = *fewer* probes = **weaker** healing (the intuition runs backwards, so -read it twice). At 0.5s a hot connection reused within the window pays nothing, while a -connection that sat through an outage is always probed before it is handed out. Do not -raise the default to "reduce overhead" — hot connections already pay zero. - -### PPA wiring (`src/Ppa/Pool/`) - -- `CdoConnectionFactory` — pools the **config instance**, not the raw CDO (winter-cdo's - config owns the connection): `create` = `new $configClass` + `connect()`, - `validate` = `ping()`, `close` = `disconnect()`. -- `PpaConnectionPool` keeps the public API it always had (`db`/`getConfigDb`/ - `showDbConfigs`/`reset`) — repositories were not touched. -- Knobs are per config via `PpaPoolConfigInterface` + defaults in `PpaPoolTrait`, so a - config using the trait never breaks when knobs are added. **Add new knobs the same - way** (interface method + trait default), never as a bare interface method. - -### Evict on connection loss (no retry — deliberate) - -`ConnectionLoss` separates a **dead socket** (SQLSTATE class `08`, PG `57P01/02/03`, -MySQL driver codes 2006/2013/2055) from a **rejected query** (`23xxx`, `42xxx`, -deadlock) by walking the `previous` chain — CDO wraps the original `PDOException`. -Only the first evicts; churning the pool on every constraint violation would be a bug. - -`PpaConnectionPool::reportFailure($configClass, $e)` is called from the 12 catch blocks -that already existed in `RepositoryCrudTrait`/`RepositoryViewTrait`. On a loss it flips -`BorrowedConnection::$dead` (so the coroutine's `defer` evicts instead of releasing) and -drops the entry from the coroutine context, so the next query — including the next one -in the same request — borrows a fresh connection. - -**Never add a retry here.** The pool cannot know what ran: the break may have happened -*after* the server applied the write (replay ⇒ duplicate), and replaying one statement -of an interrupted transaction is meaningless. One request fails; the connection dies. - -### Observability - -`PpaConnectionPool::stats()` → per-config `{total, idle, active, maximum}`. Two consumers: - -1. **Actuator** — folded into the `db` component of `/actuator/health` (one entry per - datasource carrying both reachability and `pool`), not a separate component. -2. **`call db pool`** — reads per-worker records published by `PoolTelemetry` to - `Kernel::runnable('ppa.pool', false)`, the same store indirection `call process - status` uses (the CLI is a different process and can never see a worker's memory). - Interval via `PPA_POOL_TELEMETRY` (default 5s, `0` = off); records carry a TTL of - three intervals so a dead worker's record expires by itself. - - **Nothing is armed until there is something to report.** `workerStart` only calls - `PoolTelemetry::enable($workerId)` (marks the worker eligible); the timer starts from - `PpaConnectionPool::pool()` on the **first pool**, via `arm()` — the same lazy shape - the pool uses for its own housekeeper. `arm()` is a no-op where `enable()` never ran, - which is what keeps a daemon worker or a CLI process from publishing. `stop()` reaches - for the store only if something was actually written (`$published`), and - `PpaConnectionPool::reset()` calls `forget()` so a forked child cannot publish under - its parent's worker id. - - That last pair is a fix, not decoration: `stop()` used to guard on "is the timer - armed", which was true in *every* worker, so the shutdown path called - `store()->del()` — and `new FileStorage(...)` mkdirs its folder — leaving an empty - `storage/runnable/ppa.pool/` in applications with no datasource at all. Do not - re-guard on the timer. - -Numbers are **per worker** (each has its own pool, like HikariCP per-JVM). Saturation is -therefore counted per worker, never derived from fleet sums — a summed pool can look -roomy while one worker is fully blocked. - -### Gotchas — already solved, don't reintroduce - -- **`reset()` must `abandon()` each pool.** A `Timer::tick` callback holds a reference to - its pool, so a merely dereferenced pool stays alive and keeps maintaining connections - the process no longer owns. `abandon()` clears the timer and drops references - **without closing sockets** — a forked child must never close an inherited fd. -- **Keepalive must not touch `lastUsedAt`.** It measures application idleness; resetting - it would make `idleTimeout` never fire. -- **`make()` reserves the slot before connecting** (`++$total` then `create()`, rollback - in `catch`) so concurrent borrows cannot over-provision past `maximumPoolSize`. -- **`PoolTelemetry` uses `Kernel::runnable($name, false)`** — non-hashed keys, because - the CLI enumerates records with `keys()` and feeds them back to `read()`; with hashing - those are HMACs and would be hashed a second time. -- SQLite: the pool runs but an embedded DB has no connection to lose; use - `poolMaxConnections: 1` (and `maxLifetime: 0` for `:memory:`, where every connection is - a *separate* database and rotation would destroy the data). - -### Tests - -`tests/ConnectionPool/` (module: pool, `SingleConnection`, housekeeper decisions via -reflection under a controllable clock) and `tests/Ppa/Pool/` (classifier, `reportFailure` -in a real coroutine, telemetry store round-trip, actuator merge). All deterministic, no -live DB. Live drivers: `tests/Integration/Pool/` under `#[Group('pool')]`, enabled by -`PG_TEST_DSN` / `MYSQL_TEST_DSN` / `MARIADB_TEST_DSN`. - -Design history: this section is the record; the working notes it came from are gone. - ---- - -## 14. Project layout & static files - -### Layout - -``` -resources/ - static/ web assets — served by Swoole, see below - views/ view files — ResponseView's default root -storage/ logs, cache, runnable records -``` - -`views`, not `templates`, on purpose: inside {@see ResponseView} a *template* is the -**layout** (it receives `$content`) and a *resource* is the **page**. Both live under -the same root, so naming that root after either role would be wrong — `views` covers -both, with layouts conventionally under `views/layouts`. (Spring uses `templates/`, -but it has no such split, so the name does not collide there.) - -**There is no `public/`, and the kernel has no notion of one.** It existed for the FPM -document-root model — nginx needs a directory to aim at, and `index.php` must live -inside it so sources are not web-reachable. Swoole has no document root: the server -process decides what it serves. FPM is moving to a separate `winter-fpm` project, which -will own its own document root. - -`Kernel::init()` therefore takes `pathResource`, `pathStorage*` and no `pathPublic`. - -### Static files — Swoole serves them, the framework does not - -Opt-in, declared where the rest of the web config lives: - -```php -public function configureServer(ServerSettings $server, ApplicationArguments $args): void -{ - $server->port(8000) - ->staticPath('resources/static'); // resources/static/app.css → /app.css -} -``` - -`staticPath()` resolves a relative path against `Kernel::$pathRoot`, **throws -`ApplicationConfigException` if the directory is missing** (a typo would otherwise be -silent 404s at runtime), and sets Swoole's `document_root` + `enable_static_handler`. -Say nothing and no file is ever served — which is what an API-only service wants. - -Three consequences worth knowing: - -- **The directory is the URL root.** Swoole appends the whole request path to it, so - the layout on disk mirrors the layout in URLs. Point it at a directory holding assets - and nothing else: everything under it becomes downloadable. (Pointing it at - `resources` would expose `resources/views` — executable PHP.) -- **Static responses never reach PHP**, so middleware, CORS and request logging do not - apply to them. (No regression: the old PHP implementation also served before the - CORS block.) -- **One directory only.** `document_root` is a single value — a second `staticPath()` - call cannot mount a plugin's assets from another directory. If that is ever needed, - the answer is collecting them into the one root (a `call assets link`-style step), - not a second root. - -Swoole checks the filesystem on each request to decide whether it is a static one. -Narrowing that to certain prefixes is a tuning knob rather than part of the API — -`->set('static_handler_locations', ['/assets'])` when a profile says it matters. - -### The framework ships no assets - -Its pages are self-contained: the error page inlines its `` mark rather than -linking one. That is deliberate — the error page is what a visitor sees when the -application is already failing, so it must not depend on the application being -configured correctly. **Do not reintroduce a `/static/...` URL into kernel output**: -static serving is opt-in, so a linked asset simply 404s in a project that never -enabled it. - -### Do not reimplement static serving in PHP — it was removed for cause - -`Router` used to do it (`static()`, `$publicDir`, a branch in `handle()`, -`serveStaticFile()`). All of it is gone. Verified against a live Swoole server before -removing: - -- **Path traversal.** `$file = $this->publicDir . $path` joined the *raw* `request_uri`; - Swoole does not canonicalise it. `GET /../../../etc/passwd` returned the file — - arbitrary read, bounded only by the worker's permissions. -- **Memory.** `serveStaticFile()` used `file_get_contents()`, so a 50 MB download meant - +50 MB RSS per concurrent request. Swoole streams instead. -- **Cost on every request.** `pathPublic` was always derived and always wired, so every - project paid an `is_file()` syscall on every GET — including those with no static - files at all. -- No `Range` (no seeking/resume), no `ETag`/`304`, `mime_content_type()` per request. - -Swoole's handler covers all of it in C, including refusing to escape `document_root` -(verified: the traversal requests above return 404 there). - -Design history: this section is the record; the working notes it came from are gone. - - ---- - -## 15. `WinterApplication` — the starter - -### What replaced what - -There is no god bootstrap class any more. The old `BaseBoot`/`Application` exposed seven -hooks (`configure`, `providers`, `channels`, `httpCors`, `health`, `plugins`, -`swooleConfig`) that every project had to override; all of them are gone except -`configure()`. - -```php -#[EnableWeb] -#[EnableActuator] -final class Application extends WinterApplication -{ - public static function main(array $argv): never { parent::run($argv); } -} -``` - -Two rules carry the design: - -- **The manifest is declarative.** `#[Enable*]` on the application class says what the - application *is made of*. Each attribute maps to one `Component` - (`EnableWeb` → http, `EnableProcess`/`EnableDaemon` → workers, `EnableScheduler` → - scheduler), except `EnableAsync`, which only toggles `#[Async]` proxying during boot. -- **Configuration is discovered, not hooked.** `#[Configuration]`/`#[Bean]`, - `WebConfigurer`, `LoggingConfigurer`, `HealthContributor`, `#[Import]` — all found by - the single scan pass. Adding configuration is adding a class. - -An empty manifest is an error (`ApplicationConfigException`), not a silently idle -application. - -### Entries - -| Entry | Who calls it | -|---|---| -| `main($argv)` → `run($argv)` | the project's `call` | -| `serve()` | `run` when the verb is `run` | -| `executor($argv)` | **only** `vendor/bin/wKernelRunner` — the child side of `dispatch()` (see §12) | -| `discoverAppClass()` | that runner, to find the app class after requiring `bootstrap.php` | - -`configure()` is the one hook that survived, and it must stay a method: it runs -`Kernel::init()`, which decides *where the scan looks*, so it cannot itself be a -discovered class. Its default derives the project root from the application class's own -file. - -### Boot order (`bootstrap()`) - -``` -1. $appClass = static::class -2. configure() ← Kernel::init: paths, .env, logging -3. Container::init() -4. ONE Scanner pass ← DICollector + ConfigurationCollector + WebConfigurer - + LoggingConfigurer + HealthContributor (+ AsyncCollector - only when #[EnableAsync] is present) -5. contextual LoggerInterface binding -6. applyLogging → applyCors → applyActuator → applyImports -``` - -One pass, not one per concern — adding a collector means adding it to that pass, never a -second `Scanner::run()`. - -### Gotchas - -- **`main()` must not declare a default for `$argv`.** It once did (`array $argv = []`) - and broke every subclass that overrode `main(array $args)`; PHP rejects the narrower - signature. Caught only by a smoke run. -- **`#[EnableAsync]` gates the collector itself**, not just a flag: without it the - `AsyncCollector` is never created, so `#[Async]` methods run synchronously. It is - collected last, after `DICollector` rebinds a class to itself. -- The banner is suppressed by `--no-banner`, `WINTER_BANNER=off`, or a non-TTY stdout. diff --git a/comparison_20260802_k5.md b/comparison_20260802_k5.md deleted file mode 100644 index 232a37a..0000000 --- a/comparison_20260802_k5.md +++ /dev/null @@ -1,141 +0,0 @@ -# Benchmark Comparison — 2026-08-02 — K4-Swoole vs K5 - -**K4-Swoole** → stand-k4-swoole-1 http://localhost:8005 (winter-k4, полнофункциональный + production, **Swoole**) -**K5** → stand-k5 http://localhost:8006 (winter-k5 = **оптимизированный K4** + расширенный набор фич и инструментов для разработчика, **Swoole**) - -Run dates: - K4-Swoole — 2026-07-08 00:09 - K5 — 2026-08-02 16:18 - -Duration: 30s per scenario, 10s per stress level -Tiers: light(c=10) / medium(c=25) / heavy(c=50) / extreme(c=100) -Raw results: - storage/stand-k4-swoole-1_20260707_235644/ - storage/stand-k5_20260802_160538/ - -> K5 — тот же рантайм Swoole, что и K4-Swoole (persistent workers + coroutines), поэтому сравнение прямое «код vs код». Разница здесь — это чистая работа оптимизатора K5, а не смена модели исполнения (как было при FPM→Swoole). - ---- - -## Server State - -| state | K4-Sw ping | K4-Sw MEM | K5 ping | K5 MEM | Δ ping | Δ MEM | -|--------|------------|-----------|---------|-----------|--------|-------| -| cold | 0.62 ms | 18.71 MiB | 0.64 ms | 18.97 MiB | +0.02 | +1.4% | -| warm | 0.61 ms | 18.96 MiB | 0.63 ms | 19.12 MiB | +0.02 | +0.8% | -| cool | 0.70 ms | 27.22 MiB | 0.55 ms | 27.07 MiB | −21% | −0.6% | - -Профиль покоя **идентичен** K4-Swoole — те же ~19 MiB в покое, тот же под-миллисекундный ping. opcache delta −0.0 ms (код живёт в persistent-воркерах). Единственный сдвиг: cool-ping у K5 быстрее (0.55 vs 0.70 ms). Расширенный набор dev-инструментов **не утяжелил старт** — важный результат. - ---- - -## Throughput RPS — K5 vs K4-Swoole - -| endpoint | load | K4-Sw RPS | K5 RPS | K5 vs K4-Sw | -|----------|---------------|-----------|----------|-------------| -| ping | light c=10 | 16592.75 | 18130.36 | +9.3% | -| ping | medium c=25 | 16813.42 | 18253.05 | +8.6% | -| ping | heavy c=50 | 16767.10 | 18112.48 | +8.0% | -| ping | extreme c=100 | 16906.59 | 17913.91 | +6.0% | -| hello | light c=10 | 16836.38 | 17876.95 | +6.2% | -| hello | medium c=25 | 16844.54 | 18074.61 | +7.3% | -| hello | heavy c=50 | 16832.40 | 17951.40 | +6.6% | -| hello | extreme c=100 | 16770.39 | 17939.00 | +7.0% | -| compute | light c=10 | 15628.99 | 16693.67 | +6.8% | -| compute | medium c=25 | 15604.08 | 16612.39 | +6.5% | -| compute | heavy c=50 | 15651.99 | 16699.72 | +6.7% | -| compute | extreme c=100 | 15611.49 | 16645.40 | +6.6% | -| **io** | light c=10 | 158.90 | 158.35 | −0.3% | -| **io** | medium c=25 | 474.92 | 474.90 | 0.0% | -| **io** | heavy c=50 | 949.30 | 950.52 | +0.1% | -| **io** | extreme c=100 | 1975.64 | 1977.28 | +0.1% | -| memory | light c=10 | 16296.67 | 17879.65 | +9.7% | -| memory | medium c=25 | 16286.95 | 17698.14 | +8.7% | -| memory | heavy c=50 | 16232.91 | 17213.38 | +6.0% | -| memory | extreme c=100 | 16060.51 | 17635.33 | +9.8% | - -Средние дельты (без io): -- **K5 vs K4-Swoole: +7.5% RPS** на CPU-эндпоинтах (~16360 → ~17580). Ровно та же архитектура исполнения — прирост целиком от оптимизаций кода/бутстрапа/роутинга в K5. -- Прирост равномерный на ping/hello/compute/memory (+6…+10%), то есть это снижение фиксированной per-request стоимости, а не выигрыш на конкретном сценарии. -- **io — идентичен** (потолок корутин ~1977 RPS @ c=100 сохранён). Оптимизация не касалась io-модели — она и так упёрлась в саму I/O-задержку 50 ms, а не в рантайм. - ---- - -## Latency p99 at extreme load (c=100) - -| endpoint | K4-Sw p99 | K5 p99 | Δ | -|----------|-----------|---------|---------| -| ping | 7.21 ms | 7.25 ms | +0.6% | -| hello | 7.40 ms | 6.97 ms | −5.8% | -| compute | 7.69 ms | 7.53 ms | −2.1% | -| io | 51.72 ms | 51.64 ms| −0.2% | -| memory | 7.93 ms | 10.99 ms| **+38.6%** | - -Латентность в том же классе (~7 ms на CPU, ~52 ms на io — io-p99 = сама задержка I/O). Hello/compute чуть лучше. **Единственная точка внимания — p99 memory-эндпоинта: 10.99 vs 7.93 ms** (хвост шире при том, что p90 идентичен 5.96 ms). Это локальный tail-спайк на самом «тяжёлом» по аллокациям сценарии, а не систематическая регрессия — стоит перепроверить на повторном прогоне. - ---- - -## Stress Test (ping, auto escalation) - -| conns | K4-Sw RPS | K4-Sw lat | K5 RPS | K5 lat | zone (K5) | -|--------|-----------|-----------|----------|---------|-----------| -| c=10 | 16311.54 | 0.97 ms | 18253.45 | 0.83 ms | ✓ ok* | -| c=25 | 16470.92 | 2.89 ms | 18216.48 | 2.56 ms | ✓ ok | -| c=50 | 16340.77 | 5.66 ms | 18200.34 | 5.10 ms | ✓ ok | -| c=100 | 16146.72 | 7.70 ms | 18083.21 | 7.02 ms | ✓ ok | -| c=200 | 15995.83 | 14.39 ms | 18055.76 |12.89 ms | ✓ ok | -| c=300 | 15708.65 | 22.15 ms | 18011.50 |19.53 ms | ✓ ok | -| c=500 | 15649.30 | 36.40 ms | 17765.47 |32.58 ms | ✓ ok | -| c=750 | 15754.89 | 54.96 ms | 17642.78 |47.46 ms | ✓ ok | -| c=1000 | 15603.88 | 69.43 ms | 17643.11 |64.68 ms | ✓ ok | - -\* zone-эвристика пометила самый первый бурст (c=10) как `degraded` — это артефакт первого замера (RPS там максимальный, ошибок 0), с c=25 всё `✓ ok`. - -- **K5 держит ~17600–18250 RPS на всём диапазоне** против ~15600–16500 у K4-Swoole — стабильно выше на ~2000 RPS (+13% на хвосте c=1000). -- **Деградации нет ни у одной** до c=1000 включительно; 0 ошибок у обоих. -- Латентность у K5 **ниже на каждой ступени** (например, c=1000: 64.68 vs 69.43 ms, −6.8%; c=500: 32.58 vs 36.40 ms, −10.5%). Больше RPS *и* ниже latency одновременно. - ---- - -## Resources Under Load - -| metric | K4-Swoole | K5 | Δ | -|----------|------------|------------|----------| -| CPU peak | 94.8% | 94.6% | −0.2pp | -| CPU avg | 70.6% | 70.3% | −0.3pp | -| MEM peak | 100.70 MiB | 100.6 MiB | −0.1% | -| MEM cold | 18.71 MiB | 18.97 MiB | +1.4% | -| samples | 384 | 384 | — | - -**Главный итог всего сравнения:** ресурсы **идентичны** (CPU peak/avg и MEM peak совпадают до десятых), а K5 при этом выдаёт **на +7.5% больше RPS и с меньшей латентностью**. То есть K5 делает больше работы на тех же тактах и той же памяти — это чистый прирост эффективности (RPS на единицу CPU), а не размен «скорость за ресурсы». - ---- - -## Summary - -| metric | K4-Swoole | K5 | winner | -|---------------------------------|-----------|-----------|--------------| -| Avg RPS, CPU-эндпоинты | ~16360 | ~17580 | **K5 +7.5%** | -| io RPS @ c=100 (потолок корутин)| 1976 | 1977 | tie | -| p99 @ extreme (CPU eps) | ~7.4 ms | ~7.3 ms | K5 ≈ | -| p99 memory @ extreme | 7.93 ms | 10.99 ms | K4-Swoole | -| p99 io @ extreme | 51.7 ms | 51.6 ms | tie | -| Порог деградации | не достигнут | не достигнут | tie | -| RPS @ c=1000 (stress) | 15604 | 17643 | **K5 +13%** | -| Latency @ c=1000 (stress) | 69.43 ms | 64.68 ms | **K5** | -| Память в покое (cold) | 18.71 MiB | 18.97 MiB | K4-Sw ≈ | -| Память peak под нагрузкой | 100.7 MiB | 100.6 MiB | tie | -| CPU avg под нагрузкой | 70.6% | 70.3% | K5 ≈ | -| Cold-start ping | 0.62 ms | 0.64 ms | K4-Sw ≈ | - -### Key conclusions - -1. **K5 — чистая оптимизация K4, тот же рантайм.** В отличие от скачка FPM→Swoole (там менялась модель исполнения), здесь код на том же Swoole. Весь прирост +7.5% RPS — заслуга оптимизаций внутри K5. -2. **Больше throughput при неизменных ресурсах.** CPU peak/avg и MEM peak совпадают с K4-Swoole до десятых долей, а RPS выше на 7.5% и latency ниже на всех ступенях стресса. Эффективность (RPS/CPU) выросла — это лучший вид апгрейда. -3. **Плюс инструменты для разработчика — бесплатно.** Расширенный набор dev-фич не стоил ни памяти в покое (~19 MiB, как у K4-Sw), ни старта (cold-ping 0.64 ms), ни CPU. Обычно DX-обвязка что-то отъедает — здесь нет. -4. **io-потолок не тронут (и не нужно).** Корутинный io так же выходит на ~1977 RPS @ c=100 — упор в саму задержку 50 ms, рантайм не при чём. -5. **Единственная точка внимания — p99 memory-эндпоинта (10.99 vs 7.93 ms).** p90 идентичен, так что это хвостовой спайк на самом аллокационно-тяжёлом сценарии. Не блокер, но кандидат на перепроверку повторным прогоном. - -### Verdict - -**K5 — это K4-Swoole, ставший быстрее без всякой платы.** +7.5% RPS на CPU-эндпоинтах, +13% на стресс-хвосте, ниже latency на каждой ступени — **при идентичных CPU и памяти** и с дополнительным набором инструментов для разработчика в придачу. Никаких «потерь» за фичи, как это было на переходах K2→K3 (−5.4%) и K3→K4 (−2.7%): здесь оптимизатор не только вернул стоимость новых фич, но и ушёл в плюс. Единственное, за чем стоит присмотреть, — расширенный p99-хвост memory-эндпоинта. В остальном K5 доминирует над K4-Swoole по всем метрикам. **Чистое улучшение, брать однозначно.** diff --git a/doc/STATUS.md b/doc/STATUS.md deleted file mode 100644 index 2e4e3ae..0000000 --- a/doc/STATUS.md +++ /dev/null @@ -1,892 +0,0 @@ -# Статус переделок — что сделано, что висит - -> Замена трём рабочим журналам (`redes-check.md`, `actuator-plan.md`, -> `winter-application-flow.md`): их решения приняты и перенесены в `CLAUDE.md` §15, -> поток загрузки описан там же. Здесь остаётся только сухой срез. -> -> Обновлено: 2026-08-04. - ---- - -## ✅ Сделано - -**Стартер `WinterApplication`** -- «Бог-класс» `Boot`/`BaseBoot`/`Application` удалён вместе со всеми семью хуками, кроме - `configure()` (он обязан остаться методом — решает, где искать скан). -- Манифест `#[Enable*]`; пустой манифест = ошибка загрузки, а не тихо простаивающее - приложение. -- Конфигурация — сканируемые классы: `#[Configuration]`/`#[Bean]`, `WebConfigurer`, - `LoggingConfigurer`, `HealthContributor`, `#[Import]`. -- Один проход сканера на всё. - -**Actuator** — `#[EnableActuator]` + `HealthContributor`; в компоненте `db` теперь -вложена утилизация пула. - -> Вердикт доехал до кода ответа (2026-08-04): `down` → **503**, `up`/`degraded` → 200. -> Раньше `/actuator/health` отдавал 200 всегда, и всё, что смотрит на код, а не на тело — -> HEALTHCHECK контейнера, liveness/readiness в k8s, балансировщик — считало мёртвое -> приложение здоровым; под с недоступной базой не выводился из ротации никогда. -> `degraded` намеренно остаётся 200: это «работает хуже», а не «не работает», и проба, -> убирающая инстанс из ротации по нему, превращает частичную деградацию в полную. -> Затрагивает только `health`: у прочих эндпоинтов `status` может значить своё. -> `Router::healthCode()`, `tests/Route/ActuatorHealthCodeTest.php`. -> -> **Ломающее** для того, кто парсит тело при 200: клиент с `throwOnError` теперь получит -> исключение вместо отчёта. -> -> Решения по актуатору: `call health` **не делаем** — CLI это другой процесс, компонент -> `db` открыл бы своё соединение, а пул показал бы нули (память воркера), то есть команда -> отвечала бы не на тот вопрос; живые числа по пулу уже даёт `call db pool` через стор. -> Отдельный порт в `#[EnableActuator(port)]` — не планируется. - -**Пул соединений** (`src/ConnectionPool/`) — idle-gate + `maxLifetime`, фоновый -housekeeper (keepalive / idleTimeout / minimumIdle, opt-in), evict при потере соединения -**без retry**, телеметрия + `call db pool`. Проверено на живых PostgreSQL и MariaDB. - -> Закрытие пула на выходе воркера (2026-08-04): `ConnectionPool::close()` дренировал -> канал через `Channel::pop()` — корутинный API, который **вне корутины поднимает фатал, -> не ловящийся `try/catch`**. Вызывает его `workerExit`, то есть уже сворачивающийся -> реактор, и воркер умирал посреди выключения; под `run dev` watcher после правки кода не -> дожидался перезапуска. Срабатывало при любом непустом пуле, но в проде выход воркера — -> это остановка контейнера, поэтому в глаза не бросалось. Теперь дренаж вне корутины -> пропускается, а таймер housekeeper снимается всегда — ради него закрытие и делается -> (живой повторяющийся тик не даёт реактору слиться). Сокеты закрывает ядро при выходе -> процесса. `tests/ConnectionPool/CloseOutsideCoroutineTest.php`. - -> Телеметрия пула стала ленивой (2026-08-04): `workerStart` теперь только помечает -> воркер как имеющий право публиковать (`PoolTelemetry::enable()`), а таймер заводится на -> **первом пуле** из `PpaConnectionPool::pool()` (`arm()`) — той же формой, что и -> housekeeper пула. Раньше таймер армировался в каждом воркере независимо от наличия БД, -> и `stop()` на выходе проверял «армирован ли таймер» вместо «писали ли мы» — доходил до -> `store()->del()`, а конструктор `FileStorage` делает `mkdir`. В итоге приложение вообще -> без датасорса оставляло после себя пустой `storage/runnable/ppa.pool/`. Замечено на -> bench'е, где не было ни репозиториев, ни конфигов БД. Цена самого таймера при этом -> мизерная (0.054 мкс на тик, 0.93 мс CPU в сутки на воркер) — чинилось не ради неё, а -> ради того, что PHPDoc обещал «pays exactly zero», и это было неправдой. -> `PpaConnectionPool::reset()` дополнительно зовёт `forget()`, иначе форкнутый ребёнок -> публиковал бы под worker id родителя. - -**Раскладка** — `public/` удалён из ядра; статику отдаёт Swoole (`staticPath()`); -`resources/{static,views}`; ядро не ссылается на внешние ассеты. - -**Back-compat** — вопрос закрыт: старый вход удалён, сосуществования нет. - -**Swoole-валидация `serveHttp`** — раньше числилась непроверенной («нет swoole на -боксе»); теперь есть живые прогоны и `ServeHttpTest` + `GracefulShutdownTest`. - -**Документация** — `docs/` вычищен от мёртвого API, `README.md` переписан, `doc-new/` -удалён. - -**Редизайн: namespace, стереотипы, поверхность расширения** (2026-08-02) — корень -`Flytachi\Winter\K2\` → `Flytachi\Winter\Kernel\` (версия ушла из адреса; `Console` -остался отдельным корнем); точки расширения собраны в `<Слой>/Stereotype/`; закрыты -`final` 96 классов в `src/` плюс 13 консольных команд и `Console\Core`, открытыми -осознанно остались 25 (17 исключений категорией + 8 поимённо с причинами). Держится -четырьмя архитектурными тестами. Спека — `doc/2026-08-02-restructure-design.md`. - -> Для проектов: после обновления нужен **`composer update flytachi/winter-kernel`**, а не -> только `dump-autoload` — карта автозагрузки берётся из `vendor/composer/installed.json`, -> и без `update` там останется старый корень. Симптом — «Class not found» при формально -> свежем ядре. - -**Скоупы DI: отказ на загрузке** (2026-08-03) — `#[Singleton]`, держащий `#[Request]` -(напрямую или через цепочку), больше не поднимает приложение. Раньше первый запрос -замораживался в синглтоне на всю жизнь воркера, и каждый следующий пользователь -обслуживался под чужой личностью — молча, без ошибки и без записи в лог. Проверено живьём, -включая транзитивный случай через два уровня. `Collector\ScopeGraphCollector` + -`ScopeConflictException`, ~0.01 мс при старте, ноль на запрос. - -**Аудит непроверенных слоёв** — `Concurrent/Async`, `Unit/Pagination`, `Stereotype`, -миграции `Ppa` открыты и покрыты тестами (`AsyncContractTest`, `CursorTokenTest`, -`SqliteDdlTest`). В `CursorToken::decode()` добавлен отказ на нескалярное значение -позиции: подделанный курсор с массивом внутри доходил до билдера запроса и падал -`TypeError` — 500 на кривой вход вместо 400. - -**SQLite в миграциях `Ppa`** — пять мест роняли `UnhandledMatchError` на любом диалекте -кроме mysql/pgsql: `Primal\{Decimal,Double,FloatType}`, `Sub\AutoIncrement`, -`Structure\Index`. Везде добавлена ветка `sqlite`, старая первая ветка переведена в -`default` без смены значения. **pg/mysql не тронуты — сверено побайтово** с эталоном DDL -широкой сущности (20 колонок: все типы, индексы, дефолты, nullable), плюс тот же DDL -заново исполнен на живых PostgreSQL 16 и MariaDB 11. - -> Тонкость, найденная замером: rowid-алиасом в SQLite колонка становится, только если её -> тип записан **ровно `INTEGER`** — `INT`/`BIGINT`/`SMALLINT` дают отказ NOT NULL на -> первом `INSERT`, уже после успешного создания схемы. Поэтому `AutoIncrement` для sqlite -> поднимает любой целочисленный тип до `INTEGER`. `AUTOINCREMENT` намеренно не пишется: он -> лишь запрещает переиспользование id и стоит служебной таблицы. Отдельная строка -> `PRIMARY KEY (id)` (как генерит движок) rowid-алиасу не мешает — проверено, ломать -> структуру не пришлось. - -**Кеш загрузки: разобрано и отклонено** (2026-08-02) — идея кешировать результаты -коллекторов и грузить классы лениво **не делается** до смены профиля исполнения. -Замеры на синтетике под худший случай (400 классов по 560 строк), медиана из 9 прогонов, -профиль прода (`opcache.enable_cli=1`, `validate_timestamps=0`): - -| | без opcache | с opcache | -|---|---:|---:| -| загрузка + рефлексия 400 классов | 73.2 мс | 86.3 мс | -| память процесса | 52.3 MiB | 7.7 MiB | - -Время с opcache не падает, а слегка растёт: опкоды берутся из разделяемой памяти, но -связывание классов выполняется в каждом процессе и не кешируется. Память падает в 7 раз — -самая дешёвая оптимизация во всём разборе, и она уже в шаблоне Docker. - -Мотивацию ломает то, что воркеры **форкаются от мастера**: `bootstrap()` отрабатывает до -`$server->start()`, и рестарт воркера по `max_request` загрузку не повторяет (мастер -35.9 MiB, воркер 8.8 MiB через COW). То есть 86 мс платятся один раз за старт сервера, а -не за воркер. Ленивая загрузка при этом не убирает работу, а переносит её в первые -запросы после деплоя — ухудшение p99 ровно на холодном воркере. Прогрев к тому же уже -есть и документирован как шаг деплоя: `call di build` / `clean` / `show`. - -Пересчитать имеет смысл, если вернётся FPM (`winter-fpm`: там загрузка на каждый запрос), -появится частый холодный старт (serverless), или старт сервера перевалит за ~1 с с -загрузкой классов больше половины этого времени. - -Три находки из того же разбора **сделаны**: `Scanner` исключает `Kernel::$pathStorage` -(иначе он обходил `storage/volatile/` с `di.php` и сгенерированными `#[Async]`-прокси); -исправлено `docs/configuration/01-kernel.md` про умолчание `isTmpVolatile` (в коде -`true`, документация утверждала обратное); `opcache.enable_cli=1` описан в `docs/`, а не -только в комментарии к ini. - ---- - -## 🟡 Висит - -| Пункт | Состояние | -|---|---| -| **WebSocket** | `Component::websocket()` существует, движка за ним нет | -| **Starter-autoconfig** | только явный `#[Import]`; авто-подключение через `composer.json extra.winter` не делалось | -| **FPM** | из ядра не обслуживается; адаптеры (`FpmRequest`/`FpmResponse`) на месте и покрыты тестами — основа для отдельного `winter-fpm` | - -### ✅ Пакетная запись стала потоковой (сделано 2026-08-05) - -**`winter-cdo` v4.0.0** — `insertGroup`/`upsertGroup` переименованы в -**`insertBatch`/`upsertBatch`**, параметр `array $entities` расширен до `iterable`, -внутри вместо материализации всех строк — буферы по форме строки с флашем по -заполнении. Алиасов не оставляли: два имени одного метода пережили бы миграцию. -«Batch» — слово из обоих канонов (JDBC `addBatch`/`executeBatch`, Spring -`batchUpdate`, Yii `batchInsert`) и совпадает с тем, что делает параметр `chunkSize`. - -**Ядро** — `RepositoryCrudTrait` и `RepositoryCrudInterface` под новые имена, плюс -правило разбора вариадика: **массив — это строка, `Traversable` — это поток строк**. -Различение достоверное (массив здесь законная сущность, `Traversable` — никогда), так -что одна сигнатура принимает всё сразу: - -```php -$repo->insertBatch($user1, $user2); // сущности -$repo->insertBatch(['name' => 'John']); // массив — одна строка -$repo->insertBatch(...$entities); // распаковка -$repo->insertBatch($generator); // поток -$repo->insertBatch($fromCsv, $extraRow); // вперемешку -``` - -Раскладка идёт **генератором** (`RepositoryCrudTrait::flatten()`), иначе поток снова -собрался бы в массив на границе слоёв и смысл потерялся. - -**Замеры.** Сквозь всю цепочку репозиторий → CDO, 200 000 строк генератором — пик -**1.4 MiB**. Для сравнения, старый путь на тех же данных: 180.9 MiB, из которых -147 MiB — преобразование строк в массивы внутри CDO (объект 175 B, его же массив -773 B — ×4.4), и всё это до первой вставки. На 500 000 строк было 440 MiB против -~4 MiB, что и убивало воркер со штатным `memory_limit = 128M`. - -**Изменения семантики**, записаны в CHANGELOG CDO: частичная запись при падении -(батчи уходят по мере заполнения, а не после проверки всех строк — лечится -транзакцией на стороне вызывающего); `upsertBatch` проверяет `conflictColumns` до -данных, а не после; `insertBatch` не имеет раннего выхода на пустом входе. - -**Плюс защита от ошибки, о которую спотыкались разработчики**: `$updateColumns` -принимает только карту `колонка => выражение`. Список `['qty', 'created_at']` — форма -из Laravel — раньше доезжал до базы как `SET 0 = qty` и возвращался как -`no such column: 0`, то есть указывал на схему вместо вызова. Теперь отказ с -сообщением, называющим колонку, показывающим исправленный вызов и упоминающим -`:current`. Список **не** принят как сокращение осознанно: он покрывает только -тривиальный `:new`, карту всё равно пришлось бы выучить при первом выражении, а -платить пришлось бы двумя формами навсегда. - -### ✅ Границы ресурсов: память воркера (сделано 2026-08-05) - -Первый из трёх пунктов «границ ресурсов запроса». Настраивается двумя способами, как -остальные настройки сервера: - -```dotenv -SERVER_MEMORY_LIMIT=256M -``` -```php -$server->workers(4)->memoryLimit('256M'); -``` - -**Умолчания в ядре нет** — не задал, и `ini` не трогается вообще: остаются PHP-шные -128M (вкомпилированный дефолт, проверено `php -n`). Ни одно существующее приложение от -обновления не меняется. Дефолт живёт в шаблоне Docker — `docker/php-memory.ini`, -**256M**, активен в обоих режимах в отличие от opcache-конфига. - -`memoryLimit()` — не опция Swoole, а PHP-шный `ini`, поэтому в `toArray()` не попадает -(иначе Swoole ответит `unsupported option`, как на `max_request_execution_time`). -Применяется в `workerStart`, то есть на воркер. - -**Проверка на старте предупреждает, не отказывает** (`App\Config\WorkerMemory`): -`worker_num × memory_limit + opcache` против лимита cgroup, плюс отдельно про `-1`. -Не отказ — потому что это оценка по худшему случаю, а переподписка памяти законна; -в отличие от проверки скоупов DI, где условие заведомо неверно. - -**Почему `-1` хуже лимита:** PHP не остановит процесс, его остановит OOM-killer — -`SIGKILL` без shutdown-функций, без записи в лог и вместе со всем контейнером, если -сервер PID 1. Фатал PHP хотя бы называет файл и строку, и мастер поднимает воркер. - -**Замеры, на которых стоит выбор 256M:** - -| | | -|---|---| -| обычный запрос (SQL → JSON) | ~90 КБ кучи в полёте | -| 128M / 256M / 512M | ~1400 / ~2800 / ~5600 одновременных | -| реально в полёте у stress-rp | 200–400 при 2250 rps | -| `worker_num × лимит` на 12 ядрах | 3 ГБ при 256M, 12 ГБ при 1G | - -То есть для обычного трафика запас семи-десятикратный, а толстые эндпоинты потолком -не лечатся вовсе: 150 МБ на запрос — это один такой запрос при 256M и два при 512M. -Лечится стримингом (`insertBatch` с генератором: 100 000 сущностей — 54 MiB массивом -против 1.4 MiB генератором). - -**Известная неточность проверки, оставлена сознательно.** Она сравнивает **учёт PHP** -с лимитом контейнера, а контейнеру нужен **RSS**, который выше на базу процесса. -Замерено на живом воркере: пик кучи PHP 87.69 MB, `top` RSS 114 MB при базе процесса -33 MB — то есть `RSS ≈ база + пик кучи`. Проверка оптимистична примерно на 30 МБ на -воркер. Чинится прибавлением базы (её видно на старте по RSS мастера), но это уточнение, -а не ошибка направления. - -> Тонкость, которую стоит помнить при чтении `top`: PHP освобождает память, но процесс -> **не возвращает её ядру** — аллокатор держит куски для переиспользования. Поэтому RSS -> после тяжёлого запроса остаётся высоким до конца жизни воркера, а -> `memory_get_peak_usage()` не убывает вовсе. Это не утечка. - -### ✅ Границы ресурсов: время жизни запроса (сделано 2026-08-05) - -Второй из трёх пунктов. **Дефолт 30 секунд**, `0` выключает: - -```dotenv -SERVER_REQUEST_TIMEOUT=60 -``` -```php -$server->requestTimeout(60); -``` -```php -#[Timeout(120)] class ReportController // на контроллере -#[Timeout(600)] public function export() // на методе — побеждает метод -#[Timeout(0)] public function stream() // маршрут без дедлайна -``` - -Атрибут разбирается **на скане** и ложится в скомпилированную таблицу маршрутов рядом с -`__cors`/`__middlewares` — на запрос ничего не рефлексируется. - -**Почему 30, а не 60 как у FPM:** под Swoole зависший запрос держит не только себя — -соединение из пула занято на всё время запроса, а пул общий на воркер. У .NET тот же 30. - -**Своего таймаута у Swoole нет — проверено тремя способами.** В списках допустимых опций -расширения (67 серверных + 50 портовых + 23 глобальных) нет ни одной со словом -`execution`; `strings swoole.so` даёт **0** вхождений `max_request_execution_time` (для -контроля: `max_request_grace`, `worker_max_concurrency`, `max_wait_time` — по одному); -живой сервер с `max_request_execution_time => 1` и обработчиком на 3 секунды отвечает -200 через 3 секунды в **обоих** режимах, плюс печатает `unsupported option` на каждый -старт. `set()` значение сохраняет — читать его некому. - -**Как сделано** (`Route\RequestWatchdog`): реестр `cid => дедлайн` + один тик на воркер. -Не таймер на запрос — тот стоит записи в куче реактора, а запись в реестре стоит одной -вставки в массив и `defer` на снятие. И не обход `Coroutine::list()` — тот возвращает -**все** корутины воркера, включая housekeeper пула и телеметрию, а отменить их по -возрасту значит сломать то, чему они принадлежат. - -**Две измеренные вещи определили дизайн:** - -1. **Отмена не липкая.** После `catch (Throwable)` в прикладном коде корутина снова - полностью работоспособна — следующий `sleep(2)` честно спит две секунды, хотя - `isCanceled()` остаётся `true`. Один `cancel` гасится одним `catch`. Поэтому - просроченный запрос отменяется на **каждом** проходе, и его результат отбрасывается - (`hasExpired()` → 504), иначе клиент получил бы 200 с отчётом из невыполненных - запросов. -2. **CPU-запрос не прерывается вовсе** — событийный цикл один, и пока обработчик крутит - цикл без I/O, сам сторож не может проснуться. Замер: тик, назначенный на 0.10 с, - проснулся на 1.91 с, за циклом на 1.8 с. У FPM ограничение зеркальное: он убивает - цикл, но не висящий запрос к базе. - -`finally` и `defer` при отмене отрабатывают — проверено: транзакция закрывается, -соединение возвращается в пул. Живой сервер: `/fast` → 200 за 0.05 с, `/slow` (спит 5 с) -→ 504 за 1.07 с при дедлайне 1 с, `/swallow` (глотает всё) → 504 за 1.98 с. - -**Ломающее при обновлении:** дефолт 30 с включается сам. Маршрут, который легитимно -работает дольше (выгрузка, импорт), начнёт получать 504, пока ему не проставят -`#[Timeout]`. Это осознанный выбор — защита по умолчанию, — но при обновлении стоит -пройтись по долгим эндпоинтам. - -> Тонкость, найденная мутацией: `release()` обязан чистить пометку `expired`, потому что -> **Swoole переиспользует номера корутин**. Оставленная пометка досталась бы следующему -> запросу с тем же id, и он получил бы 504 ни за что. - -> **Две правки после проверки на живом приложении.** -> -> **1. Ответ приходил 500 вместо 504.** У `Router::invoke()` есть свой -> `catch (\Throwable) { sendError() }` — он перехватывал `CanceledException` раньше -> внешнего контроля, логировал её как ERROR и **уже отправлял ответ**. А у этого -> исключения код 0 и пустое сообщение, отсюда `500 {"code":0,"message":""}`; внешний 504 -> логировался второй строкой, но менять было уже нечего. Перевод перенесён в -> `sendError()`, то есть туда, где формируется ответ: один путь, один ответ, одна запись. -> Проверка «обработчик проглотил отмену» переехала внутрь `invoke()`, до сериализации -> результата. Закреплено `tests/Route/RouterTimeoutResponseTest.php`. -> -> **2. `ResponseException` логировался `WARNING` при любом коде.** Для 404 верно, для 504 -> нет — в ядре уже есть разделение `ClientError` → WARNING, `ServerError` → ERROR, и -> Java-канон тот же (4xx тихо, 5xx громко). Теперь уровень зависит от кода: `>= 500` → -> ERROR. Затрагивает любой 5xx, брошенный через `ResponseException`. -> -> Код ответа оставлен **504**, хотя Spring на своём таймауте асинхронного запроса отдаёт -> 503: у нас 503 уже занят актуатором под «здоровье плохое», и смешивать два разных -> состояния одним кодом не стоит. - -> **Точность дедлайна.** Шаг сторожа и есть точность: запрос, ставший просроченным сразу -> после прохода, ждёт следующего. При первом варианте (потолок 1 с, шаг `таймаут / 4`) -> трёхсекундный таймаут отвечал за **3.37 с** — это средний перелёт 750-миллисекундного -> шага, а не сеть. Потолок опущен до **100 мс**: замер после правки — 3.081 / 3.081 / -> 3.087 с. -> -> Цена измерена: проход по реестру — 0.3 мкс при 10 запросах в полёте, 7.4 мкс при 1000, -> 37.4 мкс при 5000. Десять проходов в секунду при тысяче запросов это 74 мкс/с, то есть -> **0.007 % ядра**. Простаивающий воркер со взведённым сторожем: 0.02 с процессорного -> времени за 20 секунд, и это вместе с холостым ходом самого реактора — на пустом реестре -> проход выходит сразу. - -### ✅ Память воркера возвращается ядру после тяжёлого запроса (сделано 2026-08-05) - -Замечено на живом приложении: после запроса, построившего 100 000 сущностей, `top` -показывает RSS 114 МБ, а PHP считает занятыми 6.38 МБ. Память освобождена, но **ядру не -отдана** — и держится до конца жизни воркера. На инстансе с несколькими контейнерами это -«кто успел, тот и съел»: один всплеск закрепляет за контейнером память навсегда. - -**Разобрано, причина найдена — и она лечится.** Дело в том, **как** аллокатор PHP получал -память: - -| Что аллоцировали | RSS после освобождения | -|---|---| -| одна строка 200 МБ | **возвращается сразу** — большие блоки идут прямым `mmap`/`munmap` | -| 600 000 мелких объектов | **остаётся 303 МБ** при 10 МБ по счётчику PHP | - -`gc_collect_cycles()` не помогает вовсе (собирает 0). А **`gc_mem_caches()` возвращает**: - -``` -600k объектов: RSS 312 912 KB -освободили: RSS 303 584 KB ← PHP считает 10 МБ -после gc_collect_cycles: RSS 303 584 KB ← без изменений -gc_mem_caches() вернул: 267 444 KB -после: RSS 37 472 KB -``` - -**Цена вызова измерена:** - -| | | -|---|---| -| на разогретом аллокаторе | 80.7 мс (вернул 128 МБ) | -| вхолостую, когда отдавать нечего | 5 мкс | -| повторный разогрев после сброса | +10 % к следующей крупной аллокации | - -То есть звать **после каждого** запроса нельзя — 80 мс это дороже самого запроса. Но звать -**после запроса, чей пик превысил порог**, — дёшево и решает проблему целиком: холостой -вызов стоит 5 мкс, а разогрев отбивается один раз. - -**Сделано:** `WorkerMemory::trimIfIdle()` вызывается после отправки ответа (после — чтобы -клиент не ждал восьмидесяти миллисекунд). Настройка `memoryTrimThreshold()` / -`SERVER_MEMORY_TRIM`, **по умолчанию 32M**, `0` выключает. Имя от glibc `malloc_trim()` и -Go `debug.FreeOSMemory()` — устоявшееся слово именно для «отдать ядру». - -Почему 32M: обычный воркер несёт 2–3 МБ резерва (чанк аллокатора — 2 МБ), так что это -безошибочно «здесь был тяжёлый запрос», и при этом достаточно мало, чтобы память стоило -забирать. - -**Триггер — резерв, а не пик, и это оказалось важнее выбора порога.** Я собирался считать -по `memory_get_peak_usage()` и отказался: он **не убывает никогда**, поэтому после первого -же тяжёлого запроса срабатывал бы на каждом следующем — 80 мс на запрос до конца жизни -воркера. Резерв `memory_get_usage(true) − memory_get_usage()` мал, пока память **в -работе** (нагруженный воркер не трогаем), и **сам падает** после сброса (одного раза -достаточно). Вынесен публично как `WorkerMemory::idleReserve()` — он же полезен для -диагностики «куда делся RSS». - -Живой замер после правки: RSS 300 МБ → 40 МБ, холостой вызов на обычном запросе 0.1 мкс. - -> Про тесты, честно: мутация «считать по пику вместо резерва» **не ловится в принципе** — -> при памяти в работе `gc_mem_caches()` и так ничего не возвращает, обе версии дают ноль. -> Разница между пиком и резервом — в цене под нагрузкой, а не в результате. Поэтому -> проверяется сам сигнал: резерв мал при работе, велик после завершения, снова мал после -> сброса, а пик всё это время держится высоко. В докблоке теста написано, что он -> доказывает, а что нет. - -### ✅ Утечка Swoole на каждой приостановке корутины — найдена и обойдена (2026-08-05) - -Продолжение находки ниже: линейный рост оказался утечкой **в самом Swoole 6.2.0**, -локализован до строчки, и обойдён перерождением воркеров. - -**Что течёт — ровно 56.0 байта на приостановку корутины.** Пять кругов по 10 000, до -байта одинаково. Переживает и `gc_collect_cycles()`, и `gc_mem_caches()`. - -| Что делаем 10 000 раз | Утечка | -|---|---:| -| корутина, которая **приостанавливается** (`sleep`) | **56.0 B** | -| корутина без приостановки — сразу выходит | 0 | -| `Timer::after` без корутины | 0 | -| просто замыкание | 0 | - -Ни корутина сама по себе, ни таймер по отдельности не текут — только пара. Фреймворка в -воспроизведении нет вовсе: сервер из десяти строк и цикл создания корутин. - -HTTP-запрос **всегда** приостанавливается (база, вышестоящий вызов, запись ответа), -поэтому платит как минимум эти 56 байт; через полный конвейер выходит ближе к 170. - -| Нагрузка | Утечка | Воркеру с 256M хватит на | -|---:|---:|---:| -| 500 rps | 96 MB/ч | 2.7 часа | -| 2 000 rps | 385 MB/ч | **42 минуты** | -| 5 000 rps | 961 MB/ч | 16 минут | - -**Обход — умолчания в ядре:** `max_request = 100 000`, `max_request_grace = 10 000`. -Раньше оба не задавались, то есть воркер жил вечно. - -- **100 000** держит утечку около 17 МБ и делает перерождения редкими. Перерождение не - бесплатно: новый воркер стартует с **пустым пулом** и наполняет его заново (~30 мс), - что на 100 000 запросов даёт 0.0003 мс на запрос. У Laravel Octane дефолт 500 — они - могут, у них нет пула, который надо греть. -- **grace прибавляется, а не вычитается** — проверено: при `max_request = 20, grace = 15` - экземпляры обслужили 30, 27, 34 и 26 запросов, при `grace = 0` — ровно по 20. То есть - реальный предел `max_request + rand(0, grace)`, и это разброс, чтобы воркеры не гасили - пулы одновременно. - -**Ломающее при обновлении:** воркеры начнут перерождаться. Сбрасывается всё, что живёт в -их памяти — `#[Singleton]`, локальные кеши, их доля пула. Формально это могло случиться и -раньше (воркер мог упасть), но теперь происходит по расписанию. - -> Перепроверить после обновления Swoole — воспроизведение занимает одну команду: цикл -> `Coroutine::create(fn() => Coroutine::sleep(0.001))` партиями, с замером -> `memory_get_usage()` после `gc_mem_caches()`. Если станет 0 — умолчания можно поднимать. - -### 🔍 Остаток роста сверх утечки Swoole (открыто 2026-08-05) - -Попутная находка при проверке сторожа на утечки. **Не связана с сегодняшней работой** — -воспроизводится на сервере из десяти строк без единой строки фреймворка. - -| Стенд | Рост | -|---|---| -| голый `Swoole\Http\Server`, обработчик в две строки | ~500 KB на 3000 запросов (**~170 Б/запрос**) | -| он же через `Router::handle()` | ~1633 KB на 3000 запросов (**~557 Б/запрос**) | - -Восемь кругов подряд, прирост ровный до килобайта — это **линейный рост, а не разрастание -пулов с выходом на полку**. - -Что проверено, чтобы исключить ложный след: - -- `gc_collect_cycles()` собирает **0** и не освобождает ничего — не циклы; -- keep-alive (`ab -k`) картину не меняет — не структуры на соединение; -- со сторожем и без него (`enable(0)`, таймер не заводится) скорость роста одинакова — - **не наш сторож**. - -Расходится с наблюдением на `stress-rp`, где RAM осела на 44 МБ и между прогонами не -росла. Возможно, в приложении с полным `bootstrap` и opcache картина другая; возможно, -полка выше достигнутого. **Требует отдельного разбора.** Если рост подтвердится как -линейный, `max_request` (перерождение воркера после N запросов) из «настройки на всякий -случай» становится обязательной. - -### Границы ресурсов запроса (решено делать, 2026-08-04) - -Поводом стал `Fatal error: Allowed memory size of 134217728 bytes exhausted` под стрессом. -Разбор показал: приложение строило 500 000 сущностей в памяти и отдавало их -`insertGroup(...$entities)`. Замеры одного такого запроса: - -| Шаг | Память | -|---|---:| -| 500k объектов в массиве | 72.0 MiB | -| распаковка в вариадик `...$entities` | +15.8 MiB | -| `CDO::groupRowsBySignature()` — объект → hash-массив на строку | **+352.3 MiB** | -| **удерживается одновременно** | **440 MiB** (пик процесса 444 MiB) | - -Тот же объём батчами по 1000 — **пик 4.0 MiB**. Ключевое: `CDO::insertGroup()` уже -чанкует по 1000, но чанкует **SQL, а не память** — `groupRowsBySignature()` -материализует все строки до первого запроса к базе. - -**1. ~~`winter-cdo`: `iterable`/`yield`~~ — сделано, см. выше.** Сигнатура -`insertGroup(array|object ...$entities)` — вариадик, генератор в неё не передать, то есть -она *вынуждает* держать всё в памяти. Приём `iterable` с батчингом внутри сделает пик -O(батч) вместо O(всего) без переписывания прикладного кода. Правка в соседнем репозитории -плюс метод в `RepositoryCrudTrait`. - -**2. Ядро: границы запроса** — контроль памяти, RPS и максимального времени запроса -(в FPM это `max_execution_time=60` из коробки, под Swoole такого нет). Что уже выяснено, -чтобы не начинать с тупика: - -- **`max_execution_time` под Swoole не работает**: CLI SAPI даёт `0`, ограничения нет - вообще. Запрос может висеть вечно. -- **`max_request_execution_time` в Swoole 6.2 — `unsupported option`** (проверено, сервер - печатает warning). Так что таймаут запроса придётся делать самим: сторожевой таймер на - корутину запроса с `Coroutine::cancel`, а не настройкой сервера. -- **Что сервер принимает** (проверено на живом `Swoole\Http\Server` 6.2.0): - `worker_max_concurrency` (потолок одновременных запросов в воркере — от пика памяти), - `max_conn`, `max_request` / `max_request_grace` (уже есть в `ServerSettings`), - `package_max_length`. -- **`memory_limit` ядро не выставляет и не должно** — он на воркер, а память контейнера - это `worker_num × memory_limit` + мастер + разделяемая память opcache; ядро не знает ни - того, ни другого. Отдельный вопрос — вписать его явно в шаблон Docker с этой формулой в - комментарии, чтобы ручка перестала быть невидимым дефолтом PHP (сейчас 128M именно так и - обнаруживается — по фаталу). - -### ✅ Часовой пояс запроса: корутино-безопасен (сделано 2026-08-05) - -**Было.** `ClientTimezoneMiddleware::before()` звал `date_default_timezone_set()` — -**глобальную переменную процесса**. Под Swoole все одновременные запросы воркера её делят, -поэтому пояс одного пользователя утекал в другой. Замерено на живом рантайме: - -``` -запрос B (Europe/London): выставил Europe/London -запрос A (Asia/Tashkent): до I/O Asia/Tashkent → после I/O Europe/London ← чужой пояс -``` - -Запрос A выставил свой пояс, ушёл на I/O, запрос B за это время выставил свой — A после -возобновления читает чужой. Дальше это уезжает в два места: `date()`/`DateTime` в хендлере -после первого yield и **сессия БД** — `PpaConnectionPool::coroutineDb()` читает -`date_default_timezone_get()` в момент запроса (`src/Ppa/Pool/PpaConnectionPool.php:360-363`). - -Тот же класс, что `#[Singleton]`, держащий `#[Request]`: молча, без ошибки, видно только -когда у одновременных пользователей **разные** пояса. В приложении с единым поясом невидимо. - -**Стало.** Три новых/изменённых места: - -- **`src/Core/RequestLocal.php`** — примитив хранения «значение на единицу работы», аналог - `ThreadLocal` из JDK. Внутри корутины кладёт в её контекст, вне — в статику (один запрос - на процесс), и вызывающему не нужно знать, где он исполняется. Это **механизм**; поверх - него полагается делать типизированные фасады, а не разбрасывать строковые ключи. - Имя выбрано осознанно: `RequestScope` уже занят DI-скоупом (`#[Request]`, - `flushRequestScope()`), а слово `Context` в кодовой базе занято пять раз - (`CoroutineContext`, `ProcessContext`, `RenderContext`, `AuthContext`, `ContextStorage` - в логгере). -- **`src/Localization/Timezone.php`** — фасад поверх него: `set()`, `current()` с падением - на `env('TIME_ZONE', 'UTC')`, `isSet()`, `reset()`. -- **`ClientTimezoneMiddleware`** кладёт пояс в `Timezone` (источник правды) **и** по-прежнему - зовёт `date_default_timezone_set()` — как удобство для неадаптированного кода, с явным - предупреждением в PHPDoc и документации. Убирать глобаль не стали: это молча изменило бы - то, что возвращает `date()` в существующих хендлерах. -- **`PpaConnectionPool::syncTimezone()`** читает `Timezone::current()` вместо глобали. - -**Что сделать корутино-локальным нельзя.** `date()` и `new DateTime()` без явной зоны -читают глобаль движка PHP. Это устройство языка, ядро тут бессильно — поэтому в документации -прямо сказано: где ответ должен принадлежать запросившему пользователю, зону передавать -явно через `Timezone::current()`. - -**Заодно сделана дешёвая оптимизация.** `SET TIMEZONE` шлётся на **каждый** вызов `db()`, и -это **не избыточность**: вызов намеренно вынесен из блока заимствования, потому что -соединение из пула переходит между пользователями и пояс предыдущего нельзя оставлять -следующему. Сокращение сделано иначе — `\WeakMap`, ключом которого выступает сам пулируемый -объект конфига, помнит последний применённый пояс, и команда уходит только при отличии. -Корректно всегда, включая смену пояса в середине запроса; убирает 2 обращения к базе из 6, -когда пояс у всех один. - -**Тесты** (+20, всего 1688): `tests/Core/RequestLocalTest.php` — изоляция корутин, включая -исходный сценарий Ташкент/Лондон, и отдельность статики от контекста; -`tests/Localization/TimezoneTest.php` — умолчания, плюс тест, фиксирующий, что глобаль PHP -**по-прежнему** общая (если он однажды упадёт, значит PHP сделал пояс корутино-локальным и -фасад можно упростить); `tests/Ppa/Pool/PoolTimezoneTest.php` — в сессию уходит зона запроса, -а не глобали, memo не шлёт дважды одно и то же и не проглатывает смену. Проверено пятью -мутациями, каждая ловится. - -> Замер на `stress-rp` (`POOL-REPORT-SMALL-1W.md`): на один HTTP-запрос база выполняет -> ~5.9 транзакций — 2 × `SET TIMEZONE`, `SELECT COUNT(*)`, `SELECT` страницы, изредка -> `SELECT 1` (проба пула), плюс повторные `PREPARE`. - -**Кеш подготовленных выражений — разобрано и отклонено (2026-08-05).** Подготовленные -выражения не переиспользуются: `ATTR_EMULATE_PREPARES = false` для pgsql, а -`RepositoryViewTrait` зовёт `prepare()` на каждый запрос, поэтому один и тот же SQL база -разбирает заново. Проверено на пуле в одно соединение: 10 запросов → 21 имя `pdo_stmt_`, -номера растут монотонно, каждое использовано ровно один раз. - -**Утечки при этом нет** — RSS backend'а PostgreSQL после 20 000 подготовок: 27 044 → -27 172 → 27 172 kB. PDO освобождает их корректно. - -Цена — постоянные **0.25–0.28 мс на обращение к базе** (замер: `SELECT 1` 0.402 → 0.121 мс, -точечный SELECT 0.365 → 0.121, тяжёлый `COUNT(*)` 1.954 → 1.667). То есть на аналитике это -15 %, на точечном CRUD-запросе — две трети времени. - -**Решено не делать до релиза:** кеширование такого рода — задача прикладного разработчика, -а не ядра. Если возвращаться, условие обязательное: кеш ключуется текстом SQL, а -`RepositoryCore.php:262,265` вклеивает `LIMIT`/`OFFSET` **в текст** — такой запрос в кеш не -попадёт никогда и будет только его засорять. Значит сначала биндинг `LIMIT`/`OFFSET` -параметрами, и только потом кеш. - -### ✅ Границы ресурсов: конкурентность запросов (сделано 2026-08-06) - -Третий и последний пункт «границ ресурсов запроса». `maxConcurrency()` → -`worker_max_concurrency`, `SERVER_MAX_CONCURRENCY`. - -**Swoole ставит в очередь, а не отклоняет.** Замер: 20 одновременных запросов по 0.3 с при -пределе 2 — все 20 успешны, `Failed requests: 0`, суммарно 3.3 с вместо 0.3. То есть -перегрузка превращается в задержку, а не в ошибки; ни 503, ни своей очереди не нужно. - -**Умолчание выводится из `memory_limit`:** `limit × 0.5 / 64 КБ` → 128M даёт 1024, 256M — -2048, 512M — 4096. Одним числом не обойтись, и это признано вслух: цена запроса в полёте -меряется десятикратным разбросом — - -| Что делает запрос | Память | -|---|---:| -| ждёт ответа стороннего сервиса | **~10 КБ** (10 000 ждущих = 60 MiB) | -| запрос к базе + сериализация | ~90 КБ | - -64 КБ — середина, и для обоих краёв она неверна: прокси может позволить себе на порядок -больше, сервис отчётов — на порядок меньше. Оба должны сказать это явно. - -Выводится **в момент чтения** (`getMaxConcurrency()`), а не в `fromEnv()` — иначе -`->memoryLimit('512M')` в `WebConfigurer` поднял бы память, а потолок молча остался бы от -128M. Проверено живьём: 128M → 1024, после `memoryLimit('512M')` → 4096. - -**Дедлайн теперь покрывает ожидание в очереди.** Корутина запроса не создаётся, пока воркер -не пропустит его: замер при пределе 1 и обработчике 0.3 с — пять запросов ждали 0.000, -0.301, 0.603, 0.904 и 1.206 с, и обработчик каждого видел только свои 0.3 с. Без правки -запрос, прождавший три секунды, получал бы свежие тридцать. `request_time_float` ставится -до очереди, поэтому `Router` считает истёкшее и передаёт в -`RequestWatchdog::register(elapsed:)`. Передаётся именно **потраченное**, а не момент -старта: сторож живёт на монотонных часах, а метка — на стенных, смешивать нельзя. - -**Это не rate limit и не может им быть.** Не знает, кто звонит, — значит не даст одному -партнёру 50 rps, а другому 20; задерживает, а не отказывает, где квота обязана ответить -`429`; и счётчик на воркер умножается на число воркеров. Отдельная задача, см. ниже. - -### ✅ Профили сервера (сделано 2026-08-06) - -`Profile` — enum из четырёх значений, `$server->profile(...)`, `SERVER_PROFILE`. -По умолчанию `Balance`. Задаёт **пять** настроек: конкурентность, соединения, порог -очистки, `maxRequest` и `maxRequestGrace`. Не задаёт `requestTimeout`, `maxRequestSize`, -`staticPath`, `workers` — это свойства приложения, а не способа тратить память. - -**Ось — форма запроса, а не смелость.** Профиль объявляет одно число: сколько памяти -отводится запросу на собственную работу (Stable 256 КБ, Balance 128, Performance 64). -`Performance` даёт сервису с лёгкими запросами **больше** конкурентности, а не меньше. -Проверяется разработчиком в одну строку — `memory_get_usage()` вокруг обработчика. - -Три константы вывода — замеры, не допущения: - -| Константа | Замер | -|---|---| -| 78 КБ — запрос в полёте | 600 запросов, висящих в тривиальном обработчике: 78.2 КБ каждый | -| 68 КБ — простаивающее соединение | линейно на 401 / 801 / 1201, возвращается при закрытии | -| 170 Б — утечка на запрос | Swoole теряет 56 Б на приостановку корутины, запрос приостанавливается несколько раз | - -База воркера **меряется** (`memory_get_usage(true)` в `fromEnv()`, после bootstrap и до -воркеров), а не предполагается: приложение с сотней маршрутов и тремя пулами само получит -меньший потолок. Зафиксирована один раз при создании — иначе два чтения давали бы -несогласованные между собой пределы. - -Выводится **в момент чтения**, поэтому `->memoryLimit('512M')` в `WebConfigurer` поднимает -всё производное независимо от порядка вызовов. Явное значение и `SERVER_*` побеждают всегда. - -Живая проверка — Swoole принял все четыре: Stable 636/1272, Balance 934/1868, -Performance 1219/2438, Stress — ни одного ключа (умолчания Swoole). - -`Stress` оставлен под своим именем. Он не «Performance, только сильнее»: пропускная -способность упирается задолго до памяти (2 250 rps при c=500, дальше рост встал), поэтому -снятие пределов потолок не поднимает. Он убирает **периодические помехи, портящие замер** — -паузы очистки в p99, замену воркера с холодным пулом посреди прогона, таймер сторожа. -Баннер печатает профиль и во что он развернулся; под `stress` — предупреждение. - -**Попутно исправлено:** прежний вывод `maxConcurrency` считал по 64 КБ на запрос и не -учитывал соединение под ним — при 128M давал 1024 там, где замеренный пол требует ≥78 КБ -на запрос. Доли `0.25/0.4/0.6`, которые я предлагал первым заходом, были выдуманы; заменены -на объявленное число памяти запросу. - -**Найдено и не сделано:** `swoole_cpu_num()` возвращает ядра **хоста**, а не контейнера — -под `--cpus=1` отвечает 12 на 12-ядерной машине (cgroup при этом честно пишет -`cpu.max = 100000 100000`). Поэтому профиль не трогает `workers`, а из примеров в -документации этот вызов убран. Читать cgroup-квоту — отдельная задача. - -### ✅ Профили проверены на стенде, `max_request` исправлен (2026-08-06) - -Шесть прогонов `benchKit --full` на `stand-k6` (контейнер 1 CPU / 512M, `memory_limit` -256M). Карта «профиль → папка» в `benchKit/storage/PROFILE-MAP.txt`. - -**Профили работают.** Каждая точка поломки совпала с нашим пределом до цифры: Stable -ломался на `/io` при c=500 (предел 392) и на `/compute` при c=750 (предел соединений 784), -Balance — на `/io` при c=1000 (предел 941), Performance не сломался нигде. Пропускная -способность у всех одинакова (26–29 тыс. rps, CPU 71 %) — **предел ничего не стоит, пока -не упирается**. - -**Память ни разу не была узким местом:** пик 109–158 МиБ из 512. Даже Stress без единого -предела не приблизился к фаталу. - -#### 🐞 `max_request` был привязан к профилю — исправлено - -Замена воркера **убивает выполняющиеся запросы**. Замерено изолированно: 200 запросов при -`max_request=30` → 125 потеряно. И данные прогонов легли на это точно: - -| Профиль | Замена через | Обрывов | -|---|---:|---:| -| Stable | 78 951 | 6 805 | -| Balance | 157 903 | 1 000 | -| Performance | 315 806 | 48 | - -То есть **самый осторожный профиль рвал клиентов чаще всех** — прямая инверсия смысла. -Ошибка была в рассуждении: я привязал долю утечки к осторожности профиля, хотя утечка — -фиксированное число байт на запрос и о весе запроса ничего не знает. - -Стало: **20 % кучи под утечку у всех** → 298 261 при 256M. Проверено повторными прогонами: - -| Профиль | Падений до → после | Обрывов до → после | `/io` rps до → после | -|---|---|---|---| -| Stable | 751 → **95** | 6 805 → **2 564** | 5 752 → **7 439** | -| Balance | 15 → **0** | 1 000 → **197** | 13 746 → **17 866** | - -#### 🔬 Утечка Swoole оказалась условной - -Прежняя запись «170 Б на запрос через весь конвейер» неточна. Замерено на живом сервере с -выключенной заменой воркера: - -| Что делает запрос | Прирост кучи | -|---|---:| -| обычный JSON без приостановки | **0** (4.6 млн запросов) | -| `Coroutine::defer()` | 0 | -| канал + корутина | 0 | -| **пул с таймаутом** `Channel::pop($t)`, даже когда реально ждёт | **0** | -| `Coroutine::sleep()` — **сработавший таймер** | **180 Б** | - -Течёт только сработавший таймер. Значит обычное приложение (контроллер → база → JSON) не -течёт вовсе, а платит за замену воркера наравне со всеми. Это и есть довод за редкую -замену. - -#### Что такое DROP и REJECT в отчётах benchKit - -Классы взаимоисключающие (`bench.sh:505`): `DROP` — только обрывы сокетов, `TIMEOUT` — -только клиентские таймауты, `REJECT` — только не-2xx (у нас 503). - -Оба сводятся к одной причине — замене воркера: запросы **в работе** теряют соединение -(DROP), запросы **в очереди** получают 503 (REJECT). Контрольный опыт: очередь 50 при 500 -клиентах **без** замены — ноль ошибок; с заменой каждые 2000 — 965 обрывов и 700 ответов -503. Превышение `max_connection` даёт только обрывы, без 503. - -**Поправка к сказанному ранее:** я утверждал, что Swoole об этом молчит. Неверно — я сам -глушил вывод через `log_level => SWOOLE_LOG_ERROR` в тестовых серверах. При уровне по -умолчанию он пишет `Too many connections [now: N]` (предел соединений) и -`ReactorEpoll::del() ERRNO 800` при смене воркера — обе строки воспроизводятся на голом -Swoole без нашего кода. Верно осталось одно: **о самих потерянных запросах он не сообщает**. - -#### Разброс инструмента - -Stress прогнан дважды в одинаковой конфигурации: rps расходится на ~5 %, обрывы — вдвое -(171 против 78). Значит приросты Balance (+46 % на `/compute`, +30 % на `/io`) настоящие, а -отметки «поломки» на `/ping` при c=10 (8 и 24 ошибки из сотен тысяч) — шум инструмента: -`err_tol = 0.5 %` применяется, судя по всему, не ко всем классам ошибок. - -**Performance ≈ Stress в пределах шума** (18 057 против 19 121 на `/io`). Снятие всех -пределов не даёт ничего — подтверждает, что «Performance в разы больше» невозможен: пол в -146 КБ на запрос неустраним и при `Performance` составляет уже 70 % бюджета. - -#### Открыто, замерено, не сделано - -- **Соединения выведены из конкурентности (`×2`)** — вес запроса протекает в число - соединений, хотя соединение стоит 68 КБ при любом весе. Последствие: Stable отказал на - 784 соединениях, израсходовав 109 МиБ из 256M (влезло бы ~2 900). Развязка дала бы - 1 055 вместо 746, но ввела бы новую невымеряемую долю взамен спрятанной в `×2`. -- **`SWOOLE_PROCESS`** убирает обрывы при замене полностью — замерено 60 из 60 против - 23 из 60, и то же для `stop($workerId)`: 200 из 200 против 75 из 200. Цена: −6 % rps на - одном воркере, −20 % на четырёх. Раз замена стала вчетверо реже, платить незачем. -- **Замена по росту кучи вместо счётчика** — упирается в то, что под насыщением куча - законно занята запросами в полёте (у Balance до 184 МБ из 256M), и отличить это от - утечки по размеру кучи нельзя. Нужен признак, не путающий «занято работой» с «утекло». - -### 🐞 `heartbeat_idle_time` рвёт выполняющиеся запросы — умолчание убрано (2026-08-06) - -Введённое накануне умолчание `heartbeat_idle_time = 300` **откачено**. Swoole меряет время -с последней **присылки данных клиентом**, а клиент, ждущий медленный ответ, не шлёт ничего -— значит запрос в работе для него неотличим от брошенного соединения. - -| `heartbeat_idle_time` | Обработчик 5 с | Итог | -|---:|---|---| -| 2 | оборван на **2.07 с** | ответа нет | -| 8 | ответ за 5.00 с | цел | - -Обрыв **молчаливый** — в лог сервера не попадает ни строки. То есть умолчание было бы -скрытым потолком длительности запроса и молча перекрывало бы `#[Timeout(600)]`, который -описан как способ разрешить долгий отчёт. Покупалось этим немного: таблица соединений -между 1 024 и 1 000 000 стоит 160 КБ RSS, а дескрипторов в контейнере ~1 048 576. - -Метод `idleConnectionTimeout()` остался как явная настройка с этим замером в PHPDoc. - -Попутно проверены два заявления из внешнего совета: **`heartbeat_check_interval` для работы -не требуется** (закрывает и в одиночку — замер), а рекомендованные вместо heartbeat -`keepalive_timeout` и `request_timeout` **в Swoole 6.2.0 не существуют** — обе отклоняются -как `unsupported option`, то есть совет не сработал бы вовсе. - -### 🐞 `getServerParam()` падал на числовых ключах (починено 2026-08-06) - -Объявлен `?string`, а оба рантайма хранят часть значений числами. Под `strict_types` это -`TypeError` на **пяти ключах из одиннадцати**, что публикует Swoole: `request_time`, -`request_time_float`, `server_port`, `remote_port`, `master_time`. Спросить порт клиента -было достаточно, чтобы уронить запрос. Под FPM то же с `REQUEST_TIME`/`REQUEST_TIME_FLOAT`. - -Тип расширен до `string|int|float|null` — совместимое направление: приложение, реализующее -`HttpRequest` с `?string`, по-прежнему удовлетворяет контракту (возвращаемый тип разрешено -сужать). Найдено при попытке дотянуться до времени прибытия для дедлайна; в ядре метод не -вызывался ниоткуда, поэтому баг и жил незамеченным. - -### Ограничение частоты по клиентам (задача, поставлена 2026-08-06) - -Отдельно от `maxConcurrency` — это про политику, а не про выживание воркера. Сценарий -пользователя: `integration-bridge-service`, партнёрам обещаны разные квоты (50 rps, 20, 100). - -Чего нет: ни `RateLimit`, ни `Throttle`, ни квот. Строка `RateLimitMiddleware::class` в -`docs/architecture/02-middleware.md` — **пример синтаксиса, класса за ней нет** (легко -принять за существующий). Есть только `HttpCode::TOO_MANY_REQUESTS` и рабочая -`Swoole\Table`. - -Главная ловушка — где живёт счётчик: - -| Где | Чем | Верно ли | -|---|---|---| -| воркер | обычное свойство | **нет** — умножается на `worker_num` | -| контейнер | `Swoole\Table` | да, пока инстанс один | -| несколько контейнеров | Redis | всегда | - -Обсудить перед реализацией: алгоритм (скользящее окно / token bucket), ключ (заголовок, -API-ключ, mTLS), содержимое `Retry-After`, поведение при недоступном Redis — отказывать -или пропускать. - -### SQLite: что осталось (отложено 2026-08-04) - -**Отложено сознательно** — SQLite в winter не основной диалект, а объём работы великоват -для «дополнения». Разбор проведён до конца, всё ниже **проверено исполнением** живого -DDL, а не чтением кода. Прошлая редакция этого раздела в двух пунктах врала; исправлено. - -Работает: типы (после августовской правки), identity/rowid, дефолты, nullable, индексы, -включая `UNIQUE` и partial `WHERE` — `CREATE INDEX` у SQLite отдельный statement. - -| Пробел | Суть | -|---|---| -| **Внешние ключи — DDL падает** | `ForeignKey::toSql()` всегда даёт `ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY …`; SQLite отвечает `near "FOREIGN": syntax error` и на 3.51, и на 3.53. Любая сущность с `#[ForeignKey]`/`#[ForeignRepo]` не мигрируется. Прошлая формулировка «молча не действуют» неверна — до PRAGMA дело не доходит. Лечится инлайном внутри `CREATE TABLE` (проверено, принимается) | -| **CHECK — зависит от версии SQLite** | Та же форма `ALTER TABLE … ADD CONSTRAINT … CHECK (…)`: 3.53.4 (бандл в pdo_sqlite у PHP 8.5.8) принимает и констрейнт работает; системный 3.51.0 — `near "CONSTRAINT": syntax error`. Поэтому «живой» прогон здесь зелёный, а на alpine/debian со старым sqlite упадёт. Инлайн-форма работает на обеих — заодно уходит версионная зависимость | -| **FK не действуют без PRAGMA** | `PRAGMA foreign_keys` по умолчанию `0`, ставится **на каждое соединение**. Замерено: без него вставка несуществующего ключа проходит, с ним — `FOREIGN KEY constraint failed`. Место для правки — `CdoConnectionFactory::create()` после `connect()`, только для драйвера sqlite; **не решено, ядро это делает или конфиг** | -| `Json` / `TextArray` | отдают `JSON` → affinity NUMERIC. Замерено: JSON-скаляр `123` читается из такой колонки как **integer**, из `TEXT`-колонки — как text; документы `{...}` не страдают. Узко, но неверно — одна строка на тип | -| Отказы неинформативны | `StoredProcedure`, `Trigger`, `Extension`, `Table::dropIndex/dropForeignKey/dropCheckConstraint` **уже бросают** `\InvalidArgumentException` для sqlite. Прошлая формулировка «нужен явный отказ вместо молчаливой генерации» неверна: отказ есть, плохи только тип исключения и текст | -| `ALTER TABLE` | отдельная фаза: `addColumn` в SQLite можно, но только с константным дефолтом и без `PRIMARY KEY`/`UNIQUE`; `dropColumn` — с 3.35. На первый `CREATE` не влияет | - -Тест `SqliteDdlTest` покрывает только типы, identity, дефолты и уникальный индекс — ни FK, -ни CHECK в его сущности нет, поэтому оба пробела и дожили. Когда дойдут руки: сущность с -FK и CHECK, генерировать **и исполнять**, enforcement проверять под PRAGMA, а перед -правками снять golden baseline DDL для mysql/pgsql и сверить побайтово. - -## ✅ Починено вне ядра - -**`ping()` в winter-cdo врал `true` на мёртвом соединении** — исправлен в -`BaseDbConfig::ping()` и `pingDetail()`: ловится `Throwable` (а не `CDOException`, который -`PDOException` не родня), и `return` убран из `finally`, где он глотал исключение. -Проверено на живых PostgreSQL и MariaDB: живое → `true`, убитое → `false`, недоступный -порт → `false`. Ядро на него всё равно не опирается — `CdoConnectionFactory::probe()` -делает пробу сам, чтобы не зависеть от версии пакета. - -**Автоскобки в `Qb` (winter-cdo)** — группа условий склеивалась без скобок, поэтому -`OR` внутри группы разрывал внешний `AND`. На реальном проде это давало **обход -контроля доступа**: фильтр по складу отваливался, когда рядом стоял `OR`-блок поиска. -Исправлены `logicalPrepare()` (оборачивает группу из >1 части) и `add()` (пропускает -пустое условие, оборачивает результат). Планы запросов побайтово те же — сверено через -`EXPLAIN` (cost 91.32..599.18 в обоих вариантах), замедления нет. - ---- - -## Не покрыто проверкой - -OpenApi (отложен сознательно). Нагрузочных и суточных прогонов не было. diff --git a/tests/Architecture/NamespaceTest.php b/tests/Architecture/NamespaceTest.php deleted file mode 100644 index 8731ba0..0000000 --- a/tests/Architecture/NamespaceTest.php +++ /dev/null @@ -1,69 +0,0 @@ -getPathname(); - - if (!$file->isFile()) { - continue; - } - foreach (self::SKIP as $skip) { - if (str_contains($path, $skip)) { - continue 2; - } - } - if (str_contains((string) file_get_contents($path), $stale)) { - $found[] = substr($path, strlen($root) + 1); - } - } - - sort($found); - - self::assertSame([], $found, 'These files still address the kernel by its old generation name.'); - } -}