From 78245c3244b93b78ecb6d7c0200aa7408e6fa1bb Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sun, 11 Oct 2026 12:11:28 +0300 Subject: [PATCH 1/2] Detach terminated children, unsubscribe from onTerminate(), saturate withTimeout(); real docs Fixes #1 Fixes #2 Fixes #3 Fixes #4 --- .github/workflows/build.yml | 16 ++ AGENTS.md | 43 ++-- CHANGELOG.md | 38 +++- README.md | 130 +++++++++---- README.ru.md | 197 +++++++++++++++++++ llms.txt | 65 +++++-- src/Context.php | 42 +++- src/ContextCancelledException.php | 4 +- src/ContextController.php | 4 +- src/ContextException.php | 4 +- src/ContextExpiredException.php | 8 +- src/HrTimeSource.php | 4 +- src/Internal/ContextState.php | 63 ++++-- src/TerminationCause.php | 4 +- src/TimeSource.php | 4 +- tests/ContextLifecycleTest.php | 312 ++++++++++++++++++++++++++++++ 16 files changed, 821 insertions(+), 117 deletions(-) create mode 100644 README.ru.md create mode 100644 tests/ContextLifecycleTest.php diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3c4b453..304f21a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -181,9 +181,25 @@ jobs: - name: Install dependencies run: composer install --no-interaction --no-progress --prefer-dist + - name: Restore property regression corpus + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: build/property-db + key: property-db-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: property-db- + - name: Test with coverage + env: + PROPERTY_DB: ${{ github.workspace }}/build/property-db run: composer test:coverage:ci + - name: Save property regression corpus + if: ${{ !cancelled() }} + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: build/property-db + key: property-db-${{ github.run_id }}-${{ github.run_attempt }} + - name: Mutation testing run: composer mutation diff --git a/AGENTS.md b/AGENTS.md index 15bc304..4fcc365 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,21 @@ make release-check `ContextController` at the owner of a cancellable branch. - Deadline observation is lazy. Observing a child whose effective deadline is owned by an ancestor terminates that ancestor and its complete subtree. -- `withValue()` accepts only an object matching its class-string key. +- `withValue()` accepts only an object matching its class-string key, and the + derived context **shares its parent's state** (no controller of its own) — + never give it a fresh `ContextState`, or a long-lived parent retains one + child per `withValue()`. +- **A terminated state detaches from its parent** and drops its listeners and + children. `onTerminate()` returns an unsubscribe closure. Contexts that are + never terminated (an uncancelled `withCancel()`, an unobserved expired + deadline) stay attached by design — owners cancel their controller in + `finally`. +- **`withTimeout()` saturates**: a non-positive timeout is an immediate + deadline (`now`), an oversized one is `PHP_INT_MAX`; never compute + `now + (int) ($seconds * 1e9)` unguarded (float → int overflow is undefined). +- Deadlines are monotonic nanoseconds and meaningless in another process: to + cross a boundary, send `remainingMs()` and rebuild with + `withTimeout($ms / 1000)` on the other side. - Code: `declare(strict_types=1)`, `final readonly class`, `#[\Override]`, explicit types. - **Validation patterns anchor with `\z`, never `$`.** In PCRE `$` also matches @@ -60,29 +74,6 @@ make release-check the value the whitelist was supposed to reject then travels on as if it were clean. `/D` does the same job. Cover it with a data-provider case carrying `"value\n"`, or the regression comes back unnoticed. - -- **Migrations live in `src/Migration/` under the package namespace**, never as - a global class in `migrations/`: a class outside PSR-4 is not autoloadable, so - any DI definition mentioning it makes `Yiisoft\Di\Container` fatal at build - time in every request. Register with - `MigrationService::setSourceNamespaces()`, not `setSourcePaths()`. -- **The table name is a typed value object, never a scalar.** - `yiisoft/db-migration` builds migrations through `Injector::make()`, which - resolves arguments by name or by type and never reads a container definition - keyed by the migration's own class — a `string $table` has no type to resolve, - so `M...::class => ['__construct()' => ['table' => …]]` silently yields the - default. `config/di.php` builds the VO from params and passes it to the - migration *and* the runtime class, so the two cannot disagree; index names are - derived from it (PostgreSQL scopes index names per schema, not per table). -- **Test the migration through the real `Injector`**, with and without a - container binding, and assert the created column set plus each index's - columns — migrations under `src/` are mutated by infection, and - `ArrayItemRemoval` in `createTable`/`createIndex` escapes otherwise. -- Changing a migration's FQCN is a **major** and needs `UPGRADE.md` - (`UPDATE migration SET name = …` before the first `migrate:up`). `bc-check` - stays silent — the old class was outside PSR-4, so roave never saw it. - `examples/` is part of the public contract: keep scripts runnable and update `examples/README.md` when example usage changes. - **CI workflows are SHA-pinned.** Every `uses:` in `.github/workflows/*.yml` @@ -96,7 +87,7 @@ make release-check ## When you finish -- Update `README.md` (and `examples/` if usage changed); update `CHANGELOG.md` - when releasing. +- Update `README.md` **and `README.ru.md`** (both languages, same commit; and + `examples/` if usage changed); update `CHANGELOG.md` when releasing. - Re-run `composer build`; if the change affects the public API or release process, also run `make release-check`. Paste the output. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cc8fab..1058be1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,39 @@ # Changelog -## 0.1.0 — 2026-10-10 - -- Clarified the per-message queue-worker recipe and cooperative deadline - semantics in the documentation. - All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 0.2.0 — 2026-10-11 + +- **BC:** `Context::onTerminate()` returns an unsubscribe closure + (`\Closure(): void`) instead of `void`; calling it removes the listener, + and it is a no-op once the listener ran. Callers that ignore the return + value are unaffected (#1). +- A terminated context state detaches from its parent and releases its + listeners and children, so a long-lived parent no longer accumulates every + cancelled or expired child it derived. `withValue()` now shares its + parent's state instead of attaching a new child per call (#1). +- `withTimeout()` no longer overflows: a timeout beyond the representable + range saturates at a `PHP_INT_MAX` deadline, and a zero or negative timeout + yields an immediate deadline (the context is expired on first + observation) (#2). +- `ContextExpiredException` accepts an optional `$previous` throwable, so a + transport adapter can keep the underlying timeout as the cause. +- Documented propagating a budget across a process boundary: send + `remainingMs()`, rebuild with `withTimeout($ms / 1000)` on the receiving + side (#4). +- README/llms.txt placeholders replaced with the real API reference, Russian + README added, property tests for subtree cancellation and nearest-ancestor + value lookup, regression-corpus caching in CI (#3). + +## 0.1.0 — 2026-10-10 + +- Initial release: `Context` (`background()`, `withCancel()`, + `withTimeout()`, `withDeadline()`, `withValue()`), `ContextController`, + monotonic `TimeSource`/`HrTimeSource`, `TerminationCause`, + `ContextException` with `ContextCancelledException` and + `ContextExpiredException`; lazy deadline observation that terminates the + owning ancestor's subtree; `onTerminate()` listeners with aggregated + listener failures; queue-worker recipe in the documentation. diff --git a/README.md b/README.md index 562ac51..69d8288 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,7 @@ Deadline, cancellation and request-scoped values for PHP ## Requirements - PHP 8.3+ - +- No runtime dependencies ## Installation @@ -27,27 +25,69 @@ composer require rasuvaeff/context ## Usage - +A `Context` is an immutable, read-only view passed down the call graph: a +deadline, a cancellation signal and request-scoped values. The owner of a +cancellable branch keeps the matching `ContextController`. Cancellation is +cooperative: code checks the context between steps; nothing is interrupted +mid-call. + +### Create and derive ```php use Rasuvaeff\Context\Context; -[$context, $controller] = Context::background()->withTimeout(3.0); -$context->assertActive(); -$context->onTerminate(static function (): void { +$root = Context::background(); // never terminates by itself + +[$request, $requestController] = $root->withTimeout(3.0); // seconds +[$step, $stepController] = $request->withCancel(); // cancellable branch +[$capped] = $request->withDeadline($absoluteNs); // monotonic hrtime() ns + +$traced = $request->withValue(RequestId::class, new RequestId('r-42')); +$traced->value(RequestId::class)?->value; // 'r-42' (nearest ancestor wins) +``` + +A child deadline is never later than its parent's: `withTimeout()` and +`withDeadline()` take the earlier of the two. A zero or negative timeout is +an immediate deadline; an oversized one saturates at `PHP_INT_MAX` instead of +overflowing. + +### Check + +```php +$request->assertActive(); // throws ContextCancelledException / ContextExpiredException +$request->isTerminated(); // cancelled or expired +$request->isCancelled(); // explicit cancellation +$request->isExpired(); // deadline passed +$request->remainingMs(); // null without a deadline; >= 1.0 while active, 0.0 once expired +``` + +Deadlines use monotonic `hrtime()` and are observed lazily: the next +`assertActive()`, `remainingMs()`, `isTerminated()` or `isExpired()` call +discovers an expired deadline. If the deadline belongs to an ancestor, the +ancestor and its whole subtree are terminated. A controller cancellation +propagates to every descendant immediately. + +### Cancel and clean up + +```php +$unsubscribe = $request->onTerminate(static function (TerminationCause $cause): void { // release a resource owned by this operation }); -$controller->cancel(); + +$unsubscribe(); // the resource was released early: drop the listener +$requestController->cancel($reason); // $reason becomes the exception's previous ``` -For queue workers, create a context for each message and use the job's TTR as -the deadline. Check it between external calls and acknowledge only when the -job completes; an expired context should be retried or dead-lettered according -to the queue's policy: +`onTerminate()` runs the listener once when the context terminates +(immediately if it already has) and returns a closure that unregisters it. +A terminated context detaches from its parent and releases its listeners, so +a long-lived parent does not accumulate per-request children. Always cancel +the controller you own in `finally`. + +### Queue workers + +Create a context per message with the job's TTR as the deadline, check it +between external calls and acknowledge only when the job completes: ```php [$jobContext, $jobController] = Context::background()->withTimeout($ttrSeconds); @@ -64,36 +104,54 @@ try { } ``` -Deadlines use monotonic `hrtime()`. The core discovers an expired deadline on -the next `assertActive()`, `remainingMs()`, `isTerminated()` or `isExpired()` -call. If that deadline belongs to an ancestor, the ancestor and all its -descendants are terminated. A controller cancellation propagates immediately. +### Crossing a process boundary + +Deadlines are monotonic nanoseconds of the current process and mean nothing +elsewhere. Send the remaining budget and rebuild it on the other side: + +```php +// sender: an HTTP header or a queue message attribute +$headers['X-Request-Timeout-Ms'] = (string) (int) $context->remainingMs(); + +// receiver: never longer than its own limit +[$context] = Context::background()->withTimeout(min($limitSeconds, $headerMs / 1000)); +``` + +Validate the incoming value (an integer in a bounded range) before trusting +it. `rasuvaeff/context-http` implements this for PSR-15/PSR-18. + +### Testing + +Inject a `TimeSource` to drive deadlines deterministically: + +```php +$clock = new FakeTimeSource(); // your TimeSource returning nowNs() +[$context] = Context::background($clock)->withTimeout(1.0); +$clock->advanceNs(2_000_000_000); +$context->isExpired(); // true +``` ### Public API | Class | Description | |---|---| -| `Context` | Immutable view with derivation, values, deadline and checks | -| `ContextController` | Owner handle for explicit cancellation | -| `TimeSource` | Monotonic clock seam for deterministic tests | +| `Context` | Immutable view: `background()`, `withCancel()`, `withTimeout()`, `withDeadline()`, `withValue()`, `value()`, `assertActive()`, `is*()`, `remainingMs()`, `deadlineNs()`, `onTerminate()` | +| `ContextController` | Owner handle: `cancel(?Throwable $reason = null)` | +| `TimeSource` | Monotonic clock seam (`nowNs(): int`) for deterministic tests | | `HrTimeSource` | Production `hrtime()` implementation | | `TerminationCause` | Cancellation or deadline cause passed to listeners | -| `ContextException` | Base context exception | -| `ContextCancelledException` | Explicit cancellation detected by `assertActive()` | -| `ContextExpiredException` | Deadline expiration detected by `assertActive()` | +| `ContextException` | Base context exception (`RuntimeException`) | +| `ContextCancelledException` | Explicit cancellation detected by `assertActive()`; the reason is `previous` | +| `ContextExpiredException` | Deadline expiration detected by `assertActive()`; `deadlineNs()`, optional `previous` | ## Security - - -Context values are request-scoped metadata, not a service locator. The package -does not interrupt a blocking syscall; transport adapters must map the -remaining budget or register an `onTerminate()` cleanup listener. +Context values are request-scoped metadata, not a service locator; keys are +class strings and a value must be an instance of its key. The package never +interrupts a blocking syscall: transport adapters must map the remaining +budget to their own timeouts or register an `onTerminate()` cleanup +listener. A budget received from another process is untrusted input — +validate and cap it before building a context from it. ## Examples diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 0000000..31d7c05 --- /dev/null +++ b/README.ru.md @@ -0,0 +1,197 @@ +# rasuvaeff/context + +[![Latest Stable Version](https://poser.pugx.org/rasuvaeff/context/v)](https://packagist.org/packages/rasuvaeff/context) +[![Total Downloads](https://poser.pugx.org/rasuvaeff/context/downloads)](https://packagist.org/packages/rasuvaeff/context) +[![Build](https://github.com/rasuvaeff/context/actions/workflows/build.yml/badge.svg)](https://github.com/rasuvaeff/context/actions/workflows/build.yml) +[![Static analysis](https://github.com/rasuvaeff/context/actions/workflows/static-analysis.yml/badge.svg)](https://github.com/rasuvaeff/context/actions/workflows/static-analysis.yml) +[![Psalm level](https://img.shields.io/badge/psalm-level_1-blue.svg)](https://github.com/rasuvaeff/context/actions/workflows/static-analysis.yml) +[![PHP](https://img.shields.io/packagist/dependency-v/rasuvaeff/context/php)](https://packagist.org/packages/rasuvaeff/context) +[![License](https://img.shields.io/badge/license-BSD--3--Clause-blue.svg)](LICENSE.md) + +Дедлайн, отмена и значения в рамках запроса для PHP + +> Пользуетесь AI-ассистентом? В [llms.txt](llms.txt) — компактный справочник по API, который можно передать модели. + +## Требования + +- PHP 8.3+ +- Без runtime-зависимостей + +## Установка + +```bash +composer require rasuvaeff/context +``` + +## Использование + +`Context` — неизменяемое представление только для чтения, которое передаётся +вниз по графу вызовов: дедлайн, сигнал отмены и значения в рамках запроса. +Владелец отменяемой ветки держит соответствующий `ContextController`. Отмена +кооперативная: код проверяет контекст между шагами, посреди вызова ничего не +прерывается. + +### Создание и производные контексты + +```php +use Rasuvaeff\Context\Context; + +$root = Context::background(); // сам по себе не завершается + +[$request, $requestController] = $root->withTimeout(3.0); // секунды +[$step, $stepController] = $request->withCancel(); // отменяемая ветка +[$capped] = $request->withDeadline($absoluteNs); // монотонные наносекунды hrtime() + +$traced = $request->withValue(RequestId::class, new RequestId('r-42')); +$traced->value(RequestId::class)?->value; // 'r-42' (выигрывает ближайший предок) +``` + +Дедлайн потомка никогда не позже родительского: `withTimeout()` и +`withDeadline()` берут более ранний из двух. Нулевой или отрицательный таймаут +даёт немедленный дедлайн; слишком большой насыщается до `PHP_INT_MAX`, а не +переполняется. + +### Проверки + +```php +$request->assertActive(); // бросает ContextCancelledException / ContextExpiredException +$request->isTerminated(); // отменён или истёк +$request->isCancelled(); // явная отмена +$request->isExpired(); // дедлайн прошёл +$request->remainingMs(); // null без дедлайна; >= 1.0 пока активен, 0.0 после истечения +``` + +Дедлайны считаются по монотонному `hrtime()` и обнаруживаются лениво: +истёкший дедлайн замечает следующий вызов `assertActive()`, `remainingMs()`, +`isTerminated()` или `isExpired()`. Если дедлайн принадлежит предку, +завершаются предок и всё его поддерево. Отмена через контроллер сразу +распространяется на всех потомков. + +### Отмена и освобождение ресурсов + +```php +$unsubscribe = $request->onTerminate(static function (TerminationCause $cause): void { + // освободить ресурс, принадлежащий операции +}); + +$unsubscribe(); // ресурс освобождён раньше — слушатель больше не нужен +$requestController->cancel($reason); // $reason станет previous у исключения +``` + +`onTerminate()` вызывает слушателя один раз при завершении контекста (сразу, +если он уже завершён) и возвращает замыкание, которое снимает регистрацию. +Завершённый контекст отсоединяется от родителя и отпускает своих слушателей, +так что долгоживущий родитель не копит потомков по одному на запрос. +Контроллер, которым владеете, всегда отменяйте в `finally`. + +### Воркеры очередей + +Создавайте контекст на каждое сообщение с TTR задачи в роли дедлайна, +проверяйте его между внешними вызовами и подтверждайте сообщение только после +завершения задачи: + +```php +[$jobContext, $jobController] = Context::background()->withTimeout($ttrSeconds); +try { + foreach ($steps as $step) { + $jobContext->assertActive(); + $step($jobContext); + } + $queue->ack($message); +} catch (ContextExpiredException) { + $queue->nack($message); // retry или dead-letter решает очередь +} finally { + $jobController->cancel(); +} +``` + +### Передача через границу процесса + +Дедлайн — это монотонные наносекунды текущего процесса, в другом процессе они +ничего не значат. Передавайте оставшийся бюджет и восстанавливайте его на +другой стороне: + +```php +// отправитель: HTTP-заголовок или атрибут сообщения очереди +$headers['X-Request-Timeout-Ms'] = (string) (int) $context->remainingMs(); + +// получатель: не дольше собственного лимита +[$context] = Context::background()->withTimeout(min($limitSeconds, $headerMs / 1000)); +``` + +Входящее значение проверяйте (целое число в ограниченном диапазоне), прежде +чем ему доверять. Для PSR-15/PSR-18 это реализует `rasuvaeff/context-http`. + +### Тестирование + +Подставьте `TimeSource`, чтобы управлять дедлайнами детерминированно: + +```php +$clock = new FakeTimeSource(); // ваш TimeSource, возвращающий nowNs() +[$context] = Context::background($clock)->withTimeout(1.0); +$clock->advanceNs(2_000_000_000); +$context->isExpired(); // true +``` + +### Публичный API + +| Класс | Описание | +|---|---| +| `Context` | Неизменяемое представление: `background()`, `withCancel()`, `withTimeout()`, `withDeadline()`, `withValue()`, `value()`, `assertActive()`, `is*()`, `remainingMs()`, `deadlineNs()`, `onTerminate()` | +| `ContextController` | Владелец ветки: `cancel(?Throwable $reason = null)` | +| `TimeSource` | Шов монотонных часов (`nowNs(): int`) для детерминированных тестов | +| `HrTimeSource` | Продакшен-реализация на `hrtime()` | +| `TerminationCause` | Причина завершения (отмена или дедлайн), передаётся слушателям | +| `ContextException` | Базовое исключение контекста (`RuntimeException`) | +| `ContextCancelledException` | Явная отмена, обнаруженная `assertActive()`; причина — `previous` | +| `ContextExpiredException` | Истёкший дедлайн, обнаруженный `assertActive()`; `deadlineNs()`, необязательный `previous` | + +## Безопасность + +Значения контекста — метаданные в рамках запроса, а не service locator: ключи +— имена классов, значение обязано быть экземпляром своего ключа. Пакет никогда +не прерывает блокирующий системный вызов: транспортные адаптеры должны +переводить оставшийся бюджет в собственные таймауты или регистрировать +слушателя `onTerminate()` для очистки. Бюджет, пришедший из другого процесса, +— недоверенный вход: проверьте и ограничьте его, прежде чем строить из него +контекст. + +## Примеры + +Исполняемые скрипты — в [examples/](examples/). Примеры должны выполняться без +фатальных ошибок и соответствовать документированному публичному API. + +| Скрипт | Что показывает | Нужен сервер? | +|---|---|---| +| `basic.php` | Производные контексты, значения в рамках запроса и отмена | нет | + +## Разработка + +На хосте нет PHP/Composer — всё через Docker-образ `composer:2`: + +```bash +docker run --rm -v "$PWD":/app -w /app composer:2 composer install +docker run --rm -v "$PWD":/app -w /app composer:2 composer build +docker run --rm -v "$PWD":/app -w /app composer:2 composer cs:fix +docker run --rm -v "$PWD":/app -w /app composer:2 composer test +docker run --rm -v "$PWD":/app -w /app composer:2 composer release-check +``` + +Или через Make: + +```bash +make install +make build +make cs-fix +make test +make test-coverage +make mutation +make release-check +``` + +`make test-coverage` и `make mutation` ставят `pcov` внутри контейнера +`composer:2`: в базовом образе нет драйвера покрытия. + +## Лицензия + +[BSD-3-Clause](LICENSE.md) diff --git a/llms.txt b/llms.txt index c78bf03..c36e9ff 100644 --- a/llms.txt +++ b/llms.txt @@ -8,30 +8,59 @@ composer require rasuvaeff/context ## Rules -- PHP 8.3+, `declare(strict_types=1)` -- Immutable by default (`final readonly class`) -- +- PHP 8.3+, `declare(strict_types=1)`, no runtime dependencies. +- `Context` is an immutable read-only view; the owner of a branch keeps the + `ContextController` and cancels it in `finally`. +- Cancellation is cooperative: call `assertActive()` between steps; nothing + interrupts a blocking call. Map `remainingMs()` to transport timeouts. +- Deadlines are monotonic `hrtime()` nanoseconds, observed lazily; observing a + child whose deadline is owned by an ancestor terminates the ancestor's whole + subtree. A child deadline is never later than the parent's. +- `withTimeout()`: zero/negative → immediate deadline; oversized → saturates + at `PHP_INT_MAX`. NaN/INF → `InvalidArgumentException`. +- `onTerminate()` returns an unsubscribe `\Closure(): void`; a terminated + context detaches from its parent and drops its listeners. +- `withValue(class-string, T)` shares the parent's state (no controller); + lookup returns the nearest ancestor's value or null. +- Deadlines do not cross processes: send `(int) remainingMs()`, rebuild with + `withTimeout($ms / 1000)` (capped by your own limit) on the receiver. ## API - - ```php use Rasuvaeff\Context\Context; +use Rasuvaeff\Context\ContextCancelledException; +use Rasuvaeff\Context\ContextExpiredException; +use Rasuvaeff\Context\TerminationCause; + +$root = Context::background(?TimeSource $timeSource = null); + +[$ctx, $controller] = $root->withTimeout(2.0); // [Context, ContextController] +[$child, $childController] = $ctx->withCancel(); +[$capped, $c] = $ctx->withDeadline(int $absoluteNs); +$traced = $ctx->withValue(RequestId::class, new RequestId('r-1')); // Context only +$traced->value(RequestId::class); // ?RequestId + +$ctx->assertActive(); // ContextCancelledException (reason = previous) | ContextExpiredException +$ctx->isTerminated(); // isCancelled() || isExpired() +$ctx->remainingMs(); // ?float: null = no deadline, >= 1.0 active, 0.0 expired +$ctx->deadlineNs(); // ?int -[$ctx, $controller] = Context::background()->withTimeout(2.0); -$ctx->assertActive(); -$controller->cancel(); +$unsubscribe = $ctx->onTerminate(static fn (TerminationCause $c): null => null); +$unsubscribe(); // idempotent; no-op once the listener ran + +$controller->cancel(?\Throwable $reason = null); + +new ContextExpiredException(int $deadlineNs, ?\Throwable $previous = null); ``` -`withCancel()`, `withTimeout(float $seconds)` and `withDeadline(int $ns)` -return `[Context, ContextController]`. `withValue(class-string, T)` returns -only a child `Context` and requires an object matching the key. `isCancelled()` -means explicit cancellation; `isExpired()` means deadline; `isTerminated()` is -their union. `remainingMs()` returns `null` without a deadline and at least -`1.0` for a still-active sub-millisecond budget. +Listener failures do not stop the cascade: every listener and child runs, then +one exception (or a `RuntimeException` chain for several) is rethrown from +`cancel()` / the observing call. + +Queue recipe: one context per message with the TTR as timeout, `assertActive()` +before every step, ACK only after completion, NACK/dead-letter on +`ContextExpiredException`, cancel the controller in `finally`. -Queue recipe: derive one context per message using its TTR, call -`assertActive()` before every step, ACK only after completion, and NACK or -dead-letter on `ContextExpiredException`. The core does not depend on a queue. +Testing: implement `TimeSource::nowNs(): int` with a mutable counter and pass it +to `Context::background($clock)`. diff --git a/src/Context.php b/src/Context.php index 8cf5b51..2c11d5d 100644 --- a/src/Context.php +++ b/src/Context.php @@ -6,7 +6,9 @@ use Rasuvaeff\Context\Internal\ContextState; -/** @api */ +/** + * @api + */ final readonly class Context { private function __construct( @@ -18,7 +20,12 @@ private function __construct( /** @var array */ private array $values, ) { - $parent?->state->addChild($state); + // A value-only derivation shares its parent's state: it has no + // controller of its own, so it terminates exactly when the parent + // does, and the parent does not retain one child per withValue(). + if ($parent instanceof self && $parent->state !== $state) { + $parent->state->addChild($state); + } } public static function background(?TimeSource $timeSource = null): self @@ -42,15 +49,23 @@ public function withCancel(): array return [$context, new ContextController($state)]; } - /** @return array{self, ContextController} */ + /** + * A zero or negative timeout yields an already-expired context; a + * timeout beyond the representable range saturates at `PHP_INT_MAX` + * nanoseconds instead of overflowing into an immediate deadline. + * + * @return array{self, ContextController} + */ public function withTimeout(float $seconds): array { if (!is_finite($seconds)) { throw new \InvalidArgumentException('Timeout must be finite'); } - $durationNs = (int) ($seconds * 1_000_000_000.0); - $deadline = $this->timeSource->nowNs() + $durationNs; + $nowNs = $this->timeSource->nowNs(); + $durationNs = max(0.0, $seconds) * 1_000_000_000.0; + $roomNs = \PHP_INT_MAX - $nowNs; + $deadline = $durationNs >= (float) $roomNs ? \PHP_INT_MAX : $nowNs + (int) $durationNs; return $this->withDeadline($deadline); } @@ -82,7 +97,7 @@ public function withValue(string $key, object $value): self $values[$key] = $value; return $this->derive( - state: new ContextState(), + state: $this->state, deadlineNs: $this->deadlineNs, deadlineOwner: $this->deadlineOwner, values: $values, @@ -160,10 +175,19 @@ public function value(string $key): ?object return $this->values[$key] ?? $this->parent?->value($key); } - /** @param callable(TerminationCause): void $listener */ - public function onTerminate(callable $listener): void + /** + * Runs `$listener` once when this context terminates (immediately if it + * already has). The returned closure unregisters it — call it when the + * guarded resource is released before the context ends, so a + * long-lived context does not keep every listener alive. + * + * @param callable(TerminationCause): void $listener + * + * @return \Closure(): void + */ + public function onTerminate(callable $listener): \Closure { - $this->state->addListener(\Closure::fromCallable($listener)); + return $this->state->addListener(\Closure::fromCallable($listener)); } private function observeDeadline(): void diff --git a/src/ContextCancelledException.php b/src/ContextCancelledException.php index 2867904..9a60d95 100644 --- a/src/ContextCancelledException.php +++ b/src/ContextCancelledException.php @@ -4,7 +4,9 @@ namespace Rasuvaeff\Context; -/** @api */ +/** + * @api + */ final class ContextCancelledException extends ContextException { public function __construct(?\Throwable $reason = null) diff --git a/src/ContextController.php b/src/ContextController.php index 1a08719..777ad6d 100644 --- a/src/ContextController.php +++ b/src/ContextController.php @@ -6,7 +6,9 @@ use Rasuvaeff\Context\Internal\ContextState; -/** @api */ +/** + * @api + */ final readonly class ContextController { public function __construct(private ContextState $state) {} diff --git a/src/ContextException.php b/src/ContextException.php index b2a3e6f..6dfa6c7 100644 --- a/src/ContextException.php +++ b/src/ContextException.php @@ -4,5 +4,7 @@ namespace Rasuvaeff\Context; -/** @api */ +/** + * @api + */ class ContextException extends \RuntimeException {} diff --git a/src/ContextExpiredException.php b/src/ContextExpiredException.php index 31bea56..be29f82 100644 --- a/src/ContextExpiredException.php +++ b/src/ContextExpiredException.php @@ -4,12 +4,14 @@ namespace Rasuvaeff\Context; -/** @api */ +/** + * @api + */ final class ContextExpiredException extends ContextException { - public function __construct(private readonly int $deadlineNs) + public function __construct(private readonly int $deadlineNs, ?\Throwable $previous = null) { - parent::__construct('Context deadline exceeded'); + parent::__construct('Context deadline exceeded', previous: $previous); } public function deadlineNs(): int diff --git a/src/HrTimeSource.php b/src/HrTimeSource.php index ce0509f..9323bb9 100644 --- a/src/HrTimeSource.php +++ b/src/HrTimeSource.php @@ -4,7 +4,9 @@ namespace Rasuvaeff\Context; -/** @api */ +/** + * @api + */ final readonly class HrTimeSource implements TimeSource { #[\Override] diff --git a/src/Internal/ContextState.php b/src/Internal/ContextState.php index 6901a6c..56b03d5 100644 --- a/src/Internal/ContextState.php +++ b/src/Internal/ContextState.php @@ -6,24 +6,32 @@ use Rasuvaeff\Context\TerminationCause; -/** @internal */ +/** + * @internal + */ final class ContextState { - /** @var list */ + /** @var array */ private array $listeners = []; - /** @var list */ + /** @var array keyed by spl_object_id() */ private array $children = []; + private ?self $parent = null; + private ?TerminationCause $cause = null; + /** @throws \Throwable */ public function addChild(self $child): void { - $this->children[] = $child; - - if ($this->cause instanceof \Rasuvaeff\Context\TerminationCause) { + if ($this->cause instanceof TerminationCause) { $child->terminate($this->cause); + + return; } + + $child->parent = $this; + $this->children[spl_object_id($child)] = $child; } public function cause(): ?TerminationCause @@ -31,29 +39,51 @@ public function cause(): ?TerminationCause return $this->cause; } - /** @throws \Throwable */ - public function addListener(\Closure $listener): void + /** + * @param \Closure(TerminationCause): void $listener + * + * @return \Closure(): void unregisters the listener; a no-op once it ran + * + * @throws \Throwable + */ + public function addListener(\Closure $listener): \Closure { - if ($this->cause instanceof \Rasuvaeff\Context\TerminationCause) { + if ($this->cause instanceof TerminationCause) { $listener($this->cause); - return; + return static function (): void {}; } $this->listeners[] = $listener; + $id = array_key_last($this->listeners); + + return function () use ($id): void { + unset($this->listeners[$id]); + }; } /** @throws \Throwable */ public function terminate(TerminationCause $cause): void { - if ($this->cause instanceof \Rasuvaeff\Context\TerminationCause) { + if ($this->cause instanceof TerminationCause) { return; } $this->cause = $cause; + + // A terminated state no longer needs its parent and the parent no + // longer needs it: detaching keeps a long-lived parent from + // accumulating every per-request child it ever derived. + $this->parent?->removeChild($this); + $this->parent = null; + + $listeners = $this->listeners; + $children = $this->children; + $this->listeners = []; + $this->children = []; $errors = []; - foreach ($this->listeners as $listener) { + foreach ($listeners as $listener) { try { $listener($cause); } catch (\Throwable $error) { @@ -61,7 +91,7 @@ public function terminate(TerminationCause $cause): void } } - foreach ($this->children as $child) { + foreach ($children as $child) { try { $child->terminate($cause); } catch (\Throwable $error) { @@ -74,7 +104,12 @@ public function terminate(TerminationCause $cause): void } } - /** @param list<\Throwable> $errors */ + private function removeChild(self $child): void + { + unset($this->children[spl_object_id($child)]); + } + + /** @param non-empty-list<\Throwable> $errors */ private function aggregate(array $errors): \Throwable { if (count($errors) === 1) { diff --git a/src/TerminationCause.php b/src/TerminationCause.php index 9a7f75a..850acf1 100644 --- a/src/TerminationCause.php +++ b/src/TerminationCause.php @@ -4,7 +4,9 @@ namespace Rasuvaeff\Context; -/** @api */ +/** + * @api + */ final readonly class TerminationCause { private function __construct( diff --git a/src/TimeSource.php b/src/TimeSource.php index 94271ee..ba75290 100644 --- a/src/TimeSource.php +++ b/src/TimeSource.php @@ -4,7 +4,9 @@ namespace Rasuvaeff\Context; -/** @api */ +/** + * @api + */ interface TimeSource { /** Returns monotonic time in nanoseconds. */ diff --git a/tests/ContextLifecycleTest.php b/tests/ContextLifecycleTest.php new file mode 100644 index 0000000..2805cd3 --- /dev/null +++ b/tests/ContextLifecycleTest.php @@ -0,0 +1,312 @@ +withCancel(); + + for ($i = 0; $i < 100; $i++) { + [, $controller] = $parent->withCancel(); + $controller->cancel(); + } + [$kept] = $parent->withCancel(); + + Assert::same($this->childCount($parent), 1); + Assert::false($kept->isTerminated()); + } + + public function anExpiredChildIsDetachedOnceObserved(): void + { + $clock = new FakeTimeSource(); + [$parent] = Context::background($clock)->withCancel(); + [$child] = $parent->withTimeout(0.01); + + $clock->advanceNs(20_000_000); + + Assert::true($child->isExpired()); + Assert::same($this->childCount($parent), 0); + } + + public function aChildOfATerminatedParentIsNotRetained(): void + { + [$parent, $controller] = Context::background()->withCancel(); + $controller->cancel(); + + [$child] = $parent->withCancel(); + + Assert::true($child->isCancelled()); + Assert::same($this->childCount($parent), 0); + } + + public function valueDerivationsShareTheParentState(): void + { + [$parent, $controller] = Context::background()->withCancel(); + $context = $parent; + + for ($i = 0; $i < 50; $i++) { + $context = $context->withValue(RequestId::class, new RequestId((string) $i)); + } + + Assert::same($this->childCount($parent), 0); + Assert::same($context->value(RequestId::class)?->value, '49'); + + $controller->cancel(); + + Assert::true($context->isCancelled()); + } + + public function anUnsubscribedListenerNeverRuns(): void + { + [$context, $controller] = Context::background()->withCancel(); + $calls = []; + $unsubscribe = $context->onTerminate(static function () use (&$calls): void { + $calls[] = 'removed'; + }); + $context->onTerminate(static function () use (&$calls): void { + $calls[] = 'kept'; + }); + + $unsubscribe(); + $unsubscribe(); + $controller->cancel(); + + Assert::same($calls, ['kept']); + } + + public function listenersAreReleasedOnTermination(): void + { + [$context, $controller] = Context::background()->withCancel(); + $context->onTerminate(static function (): void {}); + + Assert::same($this->listenerCount($context), 1); + + $controller->cancel(); + + Assert::same($this->listenerCount($context), 0); + } + + public function aListenerOnATerminatedContextRunsAtOnce(): void + { + [$context, $controller] = Context::background()->withCancel(); + $controller->cancel(); + $causes = []; + + $unsubscribe = $context->onTerminate(static function (TerminationCause $cause) use (&$causes): void { + $causes[] = $cause->isCancelled(); + }); + + Assert::same($causes, [true]); + Assert::same($this->listenerCount($context), 0); + + $unsubscribe(); + + Assert::same($causes, [true]); + } + + public function anOversizedTimeoutSaturatesInsteadOfOverflowing(): void + { + [$context] = Context::background(new FakeTimeSource())->withTimeout(1e12); + + Assert::same($context->deadlineNs(), \PHP_INT_MAX); + Assert::false($context->isExpired()); + Assert::true($context->remainingMs() > 1e9); + } + + public function aTimeoutNearTheClockCeilingSaturates(): void + { + [$context] = Context::background(new FakeTimeSource(\PHP_INT_MAX - 1_000))->withTimeout(1.0); + + Assert::same($context->deadlineNs(), \PHP_INT_MAX); + } + + public function anOrdinaryTimeoutIsExact(): void + { + [$context] = Context::background(new FakeTimeSource(1_000_000_000))->withTimeout(1.5); + + Assert::same($context->deadlineNs(), 2_500_000_000); + } + + #[DataProvider('nonPositiveTimeoutProvider')] + public function aNonPositiveTimeoutExpiresImmediately(float $seconds): void + { + [$context] = Context::background(new FakeTimeSource(1_000_000_000))->withTimeout($seconds); + + Assert::same($context->deadlineNs(), 1_000_000_000); + Assert::true($context->isExpired()); + } + + public static function nonPositiveTimeoutProvider(): iterable + { + yield 'zero' => [0.0]; + yield 'negative' => [-1.0]; + yield 'hugely negative' => [-1e20]; + } + + public function anExpiredExceptionCanCarryItsCause(): void + { + $cause = new \RuntimeException('transport timeout'); + $exception = new ContextExpiredException(123, $cause); + + Assert::same($exception->getPrevious(), $cause); + Assert::same($exception->deadlineNs(), 123); + } + + public function theRemainingBudgetCrossesAProcessBoundary(): void + { + $senderClock = new FakeTimeSource(5_000_000_000); + [$sender] = Context::background($senderClock)->withTimeout(2.0); + $senderClock->advanceNs(500_000_000); + + // The wire carries milliseconds; the receiver's monotonic clock is + // unrelated to the sender's, so only the remaining budget travels. + $header = (string) (int) $sender->remainingMs(); + + $receiverClock = new FakeTimeSource(70_000_000_000); + [$receiver] = Context::background($receiverClock)->withTimeout((int) $header / 1000.0); + [$capped] = $receiver->withTimeout(60.0); + + Assert::same($header, '1500'); + Assert::same($receiver->remainingMs(), 1500.0); + Assert::same($capped->remainingMs(), 1500.0); + } + + /** + * @param list $parentPicks + */ + #[Property(runs: 200)] + public function cancellingANodeTerminatesExactlyItsSubtree(array $parentPicks, int $cancelPick): void + { + [$root] = Context::background()->withCancel(); + $contexts = [$root]; + $controllers = [null]; + $parents = [-1]; + + foreach ($parentPicks as $i => $pick) { + $parent = $pick % ($i + 1); + [$context, $controller] = $contexts[$parent]->withCancel(); + $contexts[] = $context; + $controllers[] = $controller; + $parents[] = $parent; + } + + $target = 1 + $cancelPick % \count($parentPicks); + $controllers[$target]?->cancel(); + + foreach ($contexts as $node => $context) { + $ancestor = $node; + while ($ancestor !== -1 && $ancestor !== $target) { + $ancestor = $parents[$ancestor]; + } + + Assert::same($context->isCancelled(), $ancestor === $target); + } + + Classify::cover(\in_array($target, $parents, strict: true), 'cancelled an inner node', 20); + Classify::when(!\in_array($target, $parents, strict: true), 'cancelled a leaf'); + } + + /** @return array */ + public static function cancellingANodeTerminatesExactlyItsSubtreeGenerators(): array + { + return [ + 'parentPicks' => Gen::nonEmptyArrayOf(Gen::intBetween(0, 1_000), 12), + 'cancelPick' => Gen::intBetween(0, 1_000), + ]; + } + + /** @return iterable, int}> */ + public static function cancellingANodeTerminatesExactlyItsSubtreeExamples(): iterable + { + yield 'a single child' => [[0], 0]; + yield 'the middle of a chain' => [[0, 1, 2, 3], 1]; + yield 'one of two siblings' => [[0, 0], 1]; + } + + /** + * @param list $setAtLevel + */ + #[Property(runs: 200)] + public function aValueLookupFindsTheNearestAncestor(array $setAtLevel): void + { + $context = Context::background(); + $expected = null; + + foreach ($setAtLevel as $level => $set) { + [$context] = $context->withCancel(); + if ($set) { + $context = $context->withValue(RequestId::class, new RequestId((string) $level)); + $expected = (string) $level; + } + } + + Assert::same($context->value(RequestId::class)?->value, $expected); + + Classify::cover($expected !== null, 'found a value', 20); + Classify::cover($expected === null, 'no value set', 5); + } + + /** @return array */ + public static function aValueLookupFindsTheNearestAncestorGenerators(): array + { + return [ + 'setAtLevel' => Gen::arrayOf(Gen::frequency([[4, Gen::constant(value: false)], [1, Gen::constant(value: true)]]), 0, 8), + ]; + } + + /** @return iterable}> */ + public static function aValueLookupFindsTheNearestAncestorExamples(): iterable + { + yield 'no levels' => [[]]; + yield 'shadowed by a nearer value' => [[true, false, true, false]]; + yield 'only the root level' => [[true, false, false]]; + } + + private function childCount(Context $context): int + { + return \count($this->stateProperty($context, 'children')); + } + + private function listenerCount(Context $context): int + { + return \count($this->stateProperty($context, 'listeners')); + } + + /** + * @return array + */ + private function stateProperty(Context $context, string $name): array + { + $state = (new \ReflectionProperty(Context::class, 'state'))->getValue($context); + /** @var array $value */ + $value = (new \ReflectionProperty(ContextState::class, $name))->getValue($state); + + return $value; + } +} From 1ed0e041dac0183505196850c76218ebcfe0d7ef Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sun, 11 Oct 2026 12:13:59 +0300 Subject: [PATCH 2/2] Changelog: onTerminate() change is source-compatible --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1058be1..0e19a0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 0.2.0 — 2026-10-11 -- **BC:** `Context::onTerminate()` returns an unsubscribe closure +- `Context::onTerminate()` returns an unsubscribe closure (`\Closure(): void`) instead of `void`; calling it removes the listener, - and it is a no-op once the listener ran. Callers that ignore the return - value are unaffected (#1). + and it is a no-op once the listener ran. Source-compatible: callers that + ignore the return value are unaffected (#1). - A terminated context state detaches from its parent and releases its listeners and children, so a long-lived parent no longer accumulates every cancelled or expired child it derived. `withValue()` now shares its