diff --git a/AGENTS.md b/AGENTS.md index 4fcc365..6b541a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ their descendants. 2. **No suppressions.** No `@psalm-suppress`, no baseline. Fix the root cause. 3. **Deadlines are monotonic.** All deadline checks use `TimeSource`; never replace it with wall-clock time or a sleep-based timer in the core. + `Context::sleep()` only waits through `Sleeper` and re-checks `TimeSource`. 4. **Preserve the public contract.** Update README + tests with any API change. ## Commands @@ -64,6 +65,20 @@ make release-check - **`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). +- **States form a DAG**: `joinedWith()` gives a state two parents. A + terminated state detaches from **every** parent, and `addChild()` ignores a + child that is already terminated — otherwise a long-lived worker context + retains every joined per-message node. +- **`requestCancel()` runs nothing.** It records a pending cause; every check + walks the ancestors (with a visited set, joins share ancestors) and applies + the topmost request. A process-wide pending counter skips the walk while it + is zero — keep it balanced (increment on record, decrement when the pending + cause is consumed), or every check pays the walk. +- **`withoutCancel()` has no state link and no parent link**: values are + copied, so a cleanup context keeps nothing of the request tree alive. +- **Seconds → nanoseconds uses `round()`, never a bare `(int)`**: `1.015 * 1e9` + is `1014999999.99…`; truncation once made `remainingMs()` report 1014 right + after `withTimeout(1.015)`. - 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e19a0d..4025bbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ 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.3.0 — 2026-10-15 + +- Added: `Testing\FakeTimeSource` and `Testing\SteppingTimeSource` (#8). +- Added: `Sleeper`, `UsleepSleeper` and `Context::sleep()`; `background()` + takes an optional `Sleeper` (#9). +- Added: `Context::withoutCancel()` (#10). +- Added: `Context::withReserve()` (#11). +- Added: `Context::iterate()` (#12). +- Added: `ContextController::requestCancel()` — deferred cancellation safe + from signal handlers; listener failures surface from the next check (#13). +- Added: `Context::joinedWith()` — the internal state is now a DAG; a + terminated node detaches from both parents (#14). +- Added: `WireCodec` and `WireValue` (#15). A missing or malformed incoming + budget falls back to `maxBudgetMs`; bad input never lifts the limit. +- Fixed: `withTimeout()` truncated the duration to whole nanoseconds, so + `withTimeout(1.015)` reported `remainingMs() === 1014.0` right away. +- Docs: "Writing an adapter" — read the budget before `assertActive()`. +- Docs: the agent skill (`resources/skills/rasuvaeff-context/SKILL.md`) is + rewritten for 0.3 — safety rules, decision table; it no longer mentions a + scheduler that does not exist. + ## 0.2.0 — 2026-10-11 - `Context::onTerminate()` returns an unsubscribe closure diff --git a/README.md b/README.md index 16f7c0f..5899627 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Deadline, cancellation and request-scoped values for PHP > Using an AI coding assistant? [llms.txt](llms.txt) contains a compact API reference you can share with the model. +> Projects using the [llm/skills](https://github.com/roxblnfk/skills) Composer plugin also get this package's agent skill synced into `.agents/skills/` automatically on install. ## Requirements @@ -49,7 +50,8 @@ never run. Check before every step, not once at the top. `cancel()` runs listeners synchronously in the code that calls it. A single-threaded PHP request blocked inside a call cannot cancel itself at the same time, so a listener helps only once control returns — after a transport -timeout, between steps, or under a fiber/event loop. +timeout, between steps, or under a fiber/event loop. From a signal handler use +`requestCancel()`, which runs nothing until the next check. ## Usage @@ -72,6 +74,10 @@ $root = Context::background(); // never terminates by itse $traced = $request->withValue(RequestId::class, new RequestId('r-42')); $traced->value(RequestId::class)?->value; // 'r-42' (nearest ancestor wins) + +[$work] = $request->withReserve(0.2); // the parent deadline minus 200 ms +$cleanup = $request->withoutCancel(); // values only: outlives the request +[$job, $jobController] = $message->joinedWith($worker); // terminates when either does ``` A child deadline is never later than its parent's: `withTimeout()` and @@ -79,6 +85,19 @@ A child deadline is never later than its parent's: `withTimeout()` and an immediate deadline; an oversized one saturates at `PHP_INT_MAX` instead of overflowing. +- **`withReserve($seconds)`** leaves time to act after lower layers return — + answer 504, NACK a message, save progress. Without a parent deadline it is + `withCancel()`; a reserve covering the whole budget is already expired. +- **`withoutCancel()`** keeps the values but drops the deadline and the + cancellation: for audit, compensation and error logging in `finally` after + the request ended. It has no budget — bound it: + `[$cleanup] = $request->withoutCancel()->withTimeout(2.0)`. +- **`joinedWith($other)`** obeys two independent sources: a worker's shutdown + context and a message context received from elsewhere. The first cause + wins, the deadline is the earlier one, values come from `$this` only. When + both belong to one tree, derive instead — `$worker->withTimeout($ttr)` + already obeys both. + ### Check ```php @@ -93,7 +112,22 @@ 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. +propagates to every descendant immediately. A check costs about 0.2 µs. + +```php +$request->sleep(0.5); // never past the deadline: throws when it comes first + +foreach ($request->iterate($rows) as $key => $row) { + process($row); // checked before pulling each element +} +``` + +`sleep()` throws `ContextExpiredException` when the deadline comes before the +end of the sleep and `ContextCancelledException` when a signal handler +requests cancellation meanwhile. `iterate()` keeps keys and checks the context +**before pulling** the next element, so a generator source (a paginated query) +is never advanced after the context terminates; pass `every: 100` for very +cheap elements. ### Cancel and clean up @@ -112,6 +146,23 @@ 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`. +A listener that throws does not stop the others or the cascade; its exception +is rethrown after the cascade, from `cancel()` or from the check that +discovered the termination. + +**From a signal handler, never call `cancel()`** — it would run listeners at +an arbitrary point of the interrupted code. `requestCancel()` only records the +reason; the next check of the context (or of a descendant) terminates it, so +listeners run, and their failures surface, in the normal flow: + +```php +pcntl_async_signals(true); +pcntl_signal(SIGTERM, static fn() => $workerController->requestCancel(new \RuntimeException('SIGTERM'))); +``` + +`rasuvaeff/context-cli` wraps this with handler restoration and a forced exit +on the second signal. + ### Queue workers Create a context per message with the job's TTR as the deadline, check it @@ -148,25 +199,75 @@ $headers['X-Request-Timeout-Ms'] = (string) (int) $context->remainingMs(); Validate the incoming value (an integer in a bounded range) before trusting it. `rasuvaeff/context-http` implements this for PSR-15/PSR-18. +To carry request-scoped values too, use a `WireCodec`. Only classes +implementing `WireValue` travel, under stable aliases you choose: + +```php +final readonly class TenantId implements WireValue +{ + public function __construct(public string $value) { /* validate */ } + public function toWire(): string { return $this->value; } + public static function fromWire(string $wire): static { return new static($wire); } +} + +$codec = new WireCodec(['tenant' => TenantId::class]); + +$message->attributes = $codec->encode($context); // ['budget_ms' => 790, 'values' => ['tenant' => 'acme']] + +[$context, $controller] = $codec->decode($message->attributes, Context::background(), maxBudgetMs: 30_000); +``` + +`decode()` treats its input as untrusted and never throws on it: the budget is +capped by `maxBudgetMs` and by the parent, a missing or malformed budget falls +back to `maxBudgetMs` (bad input never lifts the limit), unknown aliases are +dropped, and a value whose `fromWire()` throws is dropped +(pass `onInvalid` to log it). Cancellation is not carried — it is an event, +not state. + ### Testing -Inject a `TimeSource` to drive deadlines deterministically: +The package ships deterministic clocks in `Rasuvaeff\Context\Testing`: ```php -$clock = new FakeTimeSource(); // your TimeSource returning nowNs() +use Rasuvaeff\Context\Testing\FakeTimeSource; +use Rasuvaeff\Context\Testing\SteppingTimeSource; + +$clock = new FakeTimeSource(); // also the Sleeper: sleep() advances it [$context] = Context::background($clock)->withTimeout(1.0); -$clock->advanceNs(2_000_000_000); +$clock->advanceMs(2_000); $context->isExpired(); // true + +$racy = new SteppingTimeSource(600_000_000); // +0.6 s on every read ``` +`SteppingTimeSource` moves the clock between two consecutive checks inside one +call — the way to prove an adapter never acts on an expired budget. + +### Writing an adapter + +- **Read `remainingMs()` before `assertActive()`.** Read after the check, a + deadline passing in between yields `0.0` — and `0` means *no timeout* to + curl, to ClickHouse's `max_execution_time` and to MySQL's + `MAX_EXECUTION_TIME`. Read first, a passing check guarantees at least 1 ms. +- Map the transport's timeout error to `ContextExpiredException` with the + original as `previous` when the context has expired by then; rethrow it + untouched otherwise (a timeout you did not set). +- Test the race with `SteppingTimeSource`. + ### Public API | Class | Description | |---|---| -| `Context` | Immutable view: `background()`, `withCancel()`, `withTimeout()`, `withDeadline()`, `withValue()`, `value()`, `assertActive()`, `is*()`, `remainingMs()`, `deadlineNs()`, `onTerminate()` | -| `ContextController` | Owner handle: `cancel(?Throwable $reason = null)` | +| `Context` | Immutable view: `background(?TimeSource, ?Sleeper)`, `withCancel()`, `withTimeout()`, `withDeadline()`, `withReserve()`, `withoutCancel()`, `joinedWith()`, `withValue()`, `value()`, `assertActive()`, `is*()`, `remainingMs()`, `deadlineNs()`, `sleep()`, `iterate()`, `onTerminate()` | +| `ContextController` | Owner handle: `cancel(?Throwable $reason = null)`, `requestCancel(?Throwable $reason = null)` | | `TimeSource` | Monotonic clock seam (`nowNs(): int`) for deterministic tests | | `HrTimeSource` | Production `hrtime()` implementation | +| `Sleeper` | Sleep seam (`sleepNs(int)`) behind `sleep()`; may return early | +| `UsleepSleeper` | Production `usleep()` implementation | +| `WireCodec` | Budget and allow-listed values across a process boundary: `encode()`, `decode()` | +| `WireValue` | A value that may cross the boundary: `toWire()`, `fromWire()` | +| `Testing\FakeTimeSource` | Deterministic clock and sleeper: `advanceNs()`, `advanceMs()` | +| `Testing\SteppingTimeSource` | A clock that moves on every read, for race tests | | `TerminationCause` | Cancellation or deadline cause passed to listeners | | `ContextException` | Base context exception (`RuntimeException`) | | `ContextCancelledException` | Explicit cancellation detected by `assertActive()`; the reason is `previous` | @@ -179,7 +280,10 @@ 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. +validate and cap it before building a context from it. `WireCodec::decode()` +does so and accepts values only through `WireValue::fromWire()` on an +allow-list of classes, never `unserialize()`: validate in `fromWire()` and +throw on anything unexpected. ## Examples diff --git a/README.ru.md b/README.ru.md index 05edd67..5473bdf 100644 --- a/README.ru.md +++ b/README.ru.md @@ -11,6 +11,9 @@ Дедлайн, отмена и значения в рамках запроса для PHP > Пользуетесь AI-ассистентом? В [llms.txt](llms.txt) — компактный справочник по API, который можно передать модели. +> Проекты с Composer-плагином [llm/skills](https://github.com/roxblnfk/skills) +> дополнительно получают agent-скилл этого пакета в `.agents/skills/` +> автоматически при установке. ## Требования @@ -49,7 +52,9 @@ composer require rasuvaeff/context `cancel()` запускает listener'ы синхронно, в вызывающем коде. Однопоточный PHP-запрос, заблокированный внутри вызова, не может одновременно отменить сам себя, поэтому listener полезен только после возврата управления — -по таймауту транспорта, между шагами или под fiber/event loop. +по таймауту транспорта, между шагами или под fiber/event loop. Из обработчика +сигнала используйте `requestCancel()`: он ничего не исполняет до следующей +проверки. ## Использование @@ -72,6 +77,10 @@ $root = Context::background(); // сам по себе н $traced = $request->withValue(RequestId::class, new RequestId('r-42')); $traced->value(RequestId::class)?->value; // 'r-42' (выигрывает ближайший предок) + +[$work] = $request->withReserve(0.2); // дедлайн родителя минус 200 мс +$cleanup = $request->withoutCancel(); // только значения: переживает запрос +[$job, $jobController] = $message->joinedWith($worker); // завершается вместе с любым из двух ``` Дедлайн потомка никогда не позже родительского: `withTimeout()` и @@ -79,6 +88,20 @@ $traced->value(RequestId::class)?->value; // 'r-42' (выиг даёт немедленный дедлайн; слишком большой насыщается до `PHP_INT_MAX`, а не переполняется. +- **`withReserve($seconds)`** оставляет время на действия после возврата + нижних слоёв — ответить 504, сделать NACK сообщению, сохранить прогресс. Без + дедлайна у родителя это `withCancel()`; резерв, покрывающий весь бюджет, + даёт уже истёкший контекст. +- **`withoutCancel()`** сохраняет значения, но отбрасывает дедлайн и отмену: + для аудита, компенсаций и логирования ошибок в `finally` после окончания + запроса. Бюджета у него нет — ограничьте его: + `[$cleanup] = $request->withoutCancel()->withTimeout(2.0)`. +- **`joinedWith($other)`** подчиняется двум независимым источникам: + shutdown-контексту воркера и контексту сообщения, пришедшему извне. + Выигрывает первая причина, дедлайн — более ранний, значения берутся только + из `$this`. Если оба из одного дерева — деривируйте: + `$worker->withTimeout($ttr)` уже подчиняется обоим. + ### Проверки ```php @@ -93,7 +116,22 @@ $request->remainingMs(); // null без дедлайна; >= 1.0 пок истёкший дедлайн замечает следующий вызов `assertActive()`, `remainingMs()`, `isTerminated()` или `isExpired()`. Если дедлайн принадлежит предку, завершаются предок и всё его поддерево. Отмена через контроллер сразу -распространяется на всех потомков. +распространяется на всех потомков. Одна проверка стоит около 0,2 мкс. + +```php +$request->sleep(0.5); // не дольше дедлайна: бросает, если он наступил раньше + +foreach ($request->iterate($rows) as $key => $row) { + process($row); // проверка перед взятием каждого элемента +} +``` + +`sleep()` бросает `ContextExpiredException`, если дедлайн наступил раньше +конца сна, и `ContextCancelledException`, если за это время обработчик +сигнала запросил отмену. `iterate()` сохраняет ключи и проверяет контекст +**до взятия** следующего элемента, поэтому генератор-источник (постраничный +запрос) не продвигается после завершения контекста; для очень дешёвых +элементов передайте `every: 100`. ### Отмена и освобождение ресурсов @@ -112,6 +150,23 @@ $requestController->cancel($reason); // $reason станет previous у ис так что долгоживущий родитель не копит потомков по одному на запрос. Контроллер, которым владеете, всегда отменяйте в `finally`. +Бросающий слушатель не останавливает остальных и каскад; его исключение +пробрасывается после каскада — из `cancel()` или из проверки, обнаружившей +завершение. + +**Из обработчика сигнала никогда не вызывайте `cancel()`** — он запустит +слушателей в произвольной точке прерванного кода. `requestCancel()` только +записывает причину; следующая проверка контекста (или потомка) завершает его, +так что слушатели работают, а их ошибки всплывают, в обычном потоке кода: + +```php +pcntl_async_signals(true); +pcntl_signal(SIGTERM, static fn() => $workerController->requestCancel(new \RuntimeException('SIGTERM'))); +``` + +`rasuvaeff/context-cli` оборачивает это с восстановлением обработчиков и +принудительным выходом по второму сигналу. + ### Воркеры очередей Создавайте контекст на каждое сообщение с TTR задачи в роли дедлайна, @@ -150,25 +205,78 @@ $headers['X-Request-Timeout-Ms'] = (string) (int) $context->remainingMs(); Входящее значение проверяйте (целое число в ограниченном диапазоне), прежде чем ему доверять. Для PSR-15/PSR-18 это реализует `rasuvaeff/context-http`. +Чтобы передать и значения запроса, используйте `WireCodec`. Передаются только +классы, реализующие `WireValue`, под стабильными алиасами, которые выбираете +вы: + +```php +final readonly class TenantId implements WireValue +{ + public function __construct(public string $value) { /* валидация */ } + public function toWire(): string { return $this->value; } + public static function fromWire(string $wire): static { return new static($wire); } +} + +$codec = new WireCodec(['tenant' => TenantId::class]); + +$message->attributes = $codec->encode($context); // ['budget_ms' => 790, 'values' => ['tenant' => 'acme']] + +[$context, $controller] = $codec->decode($message->attributes, Context::background(), maxBudgetMs: 30_000); +``` + +`decode()` считает вход недоверенным и никогда на нём не бросает: бюджет +ограничен `maxBudgetMs` и родителем, отсутствующий или некорректный бюджет +заменяется на `maxBudgetMs` (плохой вход никогда не снимает лимит), +неизвестные алиасы отбрасываются, значение, чей `fromWire()` бросил, +отбрасывается (передайте `onInvalid`, чтобы это залогировать). Отмена не +передаётся — это событие, а не состояние. + ### Тестирование -Подставьте `TimeSource`, чтобы управлять дедлайнами детерминированно: +Пакет поставляет детерминированные часы в `Rasuvaeff\Context\Testing`: ```php -$clock = new FakeTimeSource(); // ваш TimeSource, возвращающий nowNs() +use Rasuvaeff\Context\Testing\FakeTimeSource; +use Rasuvaeff\Context\Testing\SteppingTimeSource; + +$clock = new FakeTimeSource(); // он же Sleeper: sleep() двигает часы [$context] = Context::background($clock)->withTimeout(1.0); -$clock->advanceNs(2_000_000_000); +$clock->advanceMs(2_000); $context->isExpired(); // true + +$racy = new SteppingTimeSource(600_000_000); // +0,6 с на каждое чтение ``` +`SteppingTimeSource` сдвигает часы между двумя последовательными проверками +внутри одного вызова — так доказывается, что адаптер никогда не действует по +истёкшему бюджету. + +### Как писать адаптер + +- **Читайте `remainingMs()` до `assertActive()`.** Если читать после проверки, + дедлайн, прошедший между ними, даёт `0.0` — а `0` для curl, для + `max_execution_time` в ClickHouse и для `MAX_EXECUTION_TIME` в MySQL + означает *без таймаута*. Если читать до, прошедшая проверка гарантирует + минимум 1 мс. +- Ошибку таймаута транспорта превращайте в `ContextExpiredException` с + исходной в `previous`, если к этому моменту контекст истёк; иначе + пробрасывайте как есть (таймаут, выставленный не вами). +- Гонку тестируйте через `SteppingTimeSource`. + ### Публичный API | Класс | Описание | |---|---| -| `Context` | Неизменяемое представление: `background()`, `withCancel()`, `withTimeout()`, `withDeadline()`, `withValue()`, `value()`, `assertActive()`, `is*()`, `remainingMs()`, `deadlineNs()`, `onTerminate()` | -| `ContextController` | Владелец ветки: `cancel(?Throwable $reason = null)` | +| `Context` | Неизменяемое представление: `background(?TimeSource, ?Sleeper)`, `withCancel()`, `withTimeout()`, `withDeadline()`, `withReserve()`, `withoutCancel()`, `joinedWith()`, `withValue()`, `value()`, `assertActive()`, `is*()`, `remainingMs()`, `deadlineNs()`, `sleep()`, `iterate()`, `onTerminate()` | +| `ContextController` | Владелец ветки: `cancel(?Throwable $reason = null)`, `requestCancel(?Throwable $reason = null)` | | `TimeSource` | Шов монотонных часов (`nowNs(): int`) для детерминированных тестов | | `HrTimeSource` | Продакшен-реализация на `hrtime()` | +| `Sleeper` | Шов сна (`sleepNs(int)`) за `sleep()`; может вернуться раньше | +| `UsleepSleeper` | Продакшен-реализация на `usleep()` | +| `WireCodec` | Бюджет и разрешённые значения через границу процесса: `encode()`, `decode()` | +| `WireValue` | Значение, которое может пересечь границу: `toWire()`, `fromWire()` | +| `Testing\FakeTimeSource` | Детерминированные часы и sleeper: `advanceNs()`, `advanceMs()` | +| `Testing\SteppingTimeSource` | Часы, сдвигающиеся на каждом чтении, для тестов гонок | | `TerminationCause` | Причина завершения (отмена или дедлайн), передаётся слушателям | | `ContextException` | Базовое исключение контекста (`RuntimeException`) | | `ContextCancelledException` | Явная отмена, обнаруженная `assertActive()`; причина — `previous` | @@ -182,7 +290,9 @@ $context->isExpired(); // true переводить оставшийся бюджет в собственные таймауты или регистрировать слушателя `onTerminate()` для очистки. Бюджет, пришедший из другого процесса, — недоверенный вход: проверьте и ограничьте его, прежде чем строить из него -контекст. +контекст. `WireCodec::decode()` делает это и принимает значения только через +`WireValue::fromWire()` из списка разрешённых классов, никогда не через +`unserialize()`: валидируйте в `fromWire()` и бросайте на всём неожиданном. ## Примеры diff --git a/benchmarks/ContextBench.php b/benchmarks/ContextBench.php new file mode 100644 index 0000000..3fba299 --- /dev/null +++ b/benchmarks/ContextBench.php @@ -0,0 +1,67 @@ + [self::class, 'checkDeep'], + 'joined at depth 10' => [self::class, 'checkJoined'], + ], + calls: 100_000, + iterations: 10, + )] + public static function checkShallow(): bool + { + return self::shallow()->isTerminated(); + } + + public static function checkDeep(): bool + { + return self::deep()->isTerminated(); + } + + public static function checkJoined(): bool + { + return self::joined()->isTerminated(); + } + + private static function shallow(): Context + { + return self::$shallow ??= Context::background()->withTimeout(3_600.0)[0]; + } + + private static function deep(): Context + { + if (self::$deep === null) { + $context = Context::background()->withTimeout(3_600.0)[0]; + for ($i = 0; $i < 9; $i++) { + [$context] = $context->withCancel(); + } + self::$deep = $context; + } + + return self::$deep; + } + + private static function joined(): Context + { + return self::$joined ??= self::deep()->joinedWith(self::deep())[0]; + } +} diff --git a/examples/README.md b/examples/README.md index dc00a03..97c1cde 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,3 +3,4 @@ | Script | Shows | Needs server? | |---|---|---| | `basic.php` | Derivation, request-scoped values and cancellation | no | +| `derivations.php` | `withReserve()`, `iterate()`, `sleep()`, `joinedWith()` with `requestCancel()`, `withoutCancel()`, `WireCodec` — on a fake clock | no | diff --git a/examples/derivations.php b/examples/derivations.php new file mode 100644 index 0000000..4b00ef9 --- /dev/null +++ b/examples/derivations.php @@ -0,0 +1,71 @@ +value; + } + + public static function fromWire(string $wire): static + { + return new static($wire); + } +} + +$clock = new FakeTimeSource(); +[$request, $requestController] = Context::background($clock)->withTimeout(1.0); +$request = $request->withValue(TenantId::class, new TenantId('acme')); + +// Leave 200 ms to answer after the work below returns. +[$work] = $request->withReserve(0.2); +echo 'work budget: ' . $work->remainingMs() . " ms\n"; // 800 + +// Process a batch, checking the context before pulling each element. +$processed = 0; + +try { + foreach ($work->iterate(range(1, 10)) as $item) { + $processed++; + $work->sleep(0.1); // 100 ms per item + } +} catch (ContextExpiredException) { + echo "batch stopped by the reserve after {$processed} items\n"; // 8 +} + +// The worker shuts down; a joined context obeys both. +[$worker, $workerController] = Context::background($clock)->withCancel(); +[$job] = $request->joinedWith($worker); +$workerController->requestCancel(new RuntimeException('SIGTERM')); // safe in a signal handler +echo 'job cancelled: ' . var_export($job->isCancelled(), true) . "\n"; // true + +// Cleanup that must run after the request ended, with its own budget. +$requestController->cancel(); +[$cleanup] = $request->withoutCancel()->withTimeout(2.0); +echo 'cleanup sees tenant: ' . $cleanup->value(TenantId::class)?->value . "\n"; + +// Hand the remaining budget and the tenant to another process. +[$outgoing] = Context::background($clock)->withTimeout(0.75); +$codec = new WireCodec(['tenant' => TenantId::class]); +$wire = $codec->encode($outgoing->withValue(TenantId::class, new TenantId('acme'))); +echo json_encode($wire, JSON_THROW_ON_ERROR) . "\n"; // {"budget_ms":750,"values":{"tenant":"acme"}} + +[$received] = $codec->decode($wire, Context::background(), maxBudgetMs: 30_000); +echo 'received tenant: ' . $received->value(TenantId::class)?->value . "\n"; diff --git a/infection.json5 b/infection.json5 index ae31635..1bb8b78 100644 --- a/infection.json5 +++ b/infection.json5 @@ -8,7 +8,7 @@ "summary": "build/infection-summary.log" }, "testFramework": "testo", - "minMsi": 85, + "minMsi": 90, "mutators": { "@default": true } diff --git a/llms.txt b/llms.txt index c36e9ff..e13304a 100644 --- a/llms.txt +++ b/llms.txt @@ -62,5 +62,36 @@ 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`. -Testing: implement `TimeSource::nowNs(): int` with a mutable counter and pass it -to `Context::background($clock)`. +Testing: `Rasuvaeff\Context\Testing\FakeTimeSource` (`advanceNs()`, +`advanceMs()`; also the `Sleeper`, so `sleep()` advances it) and +`SteppingTimeSource($stepNs)` (moves on every read — race tests). Pass to +`Context::background($clock)`. + +## 0.3.0 additions + +```php +[$work, $c] = $ctx->withReserve(0.2); // parent deadline − 200 ms; no parent deadline → withCancel() +$cleanup = $ctx->withoutCancel(); // values only: no deadline, not cancelled by the parent +[$job, $c] = $message->joinedWith($worker);// terminated when either is; earlier deadline; values from $message + // same TimeSource required (two default clocks are fine) +$ctx->sleep(0.5); // min(0.5 s, remaining); throws Expired/Cancelled +foreach ($ctx->iterate($items, every: 1) as $k => $v) {} // checks before pulling each element +$controller->requestCancel($reason); // signal-safe: applied at the next check of the subtree +``` + +- Never call `cancel()` from a signal handler; use `requestCancel()`. +- A check costs ~0.2 µs; requested cancellations are applied ancestors-first. +- Adapters: read `remainingMs()` BEFORE `assertActive()` — after it, an expiry + in between yields `0.0`, which curl/ClickHouse/MySQL read as "no timeout". + +```php +final readonly class TenantId implements WireValue { + public function toWire(): string; // what travels + public static function fromWire(string $w): static; // validate, throw on bad input +} +$codec = new WireCodec(['tenant' => TenantId::class], onInvalid: null); +$wire = $codec->encode($ctx); // ['budget_ms' => int|null, 'values' => ['tenant' => '...']]; throws if terminated +[$ctx, $c] = $codec->decode($wire, Context::background(), maxBudgetMs: 30_000); +// decode never throws on input: missing/bad budget → maxBudgetMs (never unlimited), +// unknown alias / failing fromWire() dropped +``` diff --git a/resources/skills/rasuvaeff-context/SKILL.md b/resources/skills/rasuvaeff-context/SKILL.md index 00f1b76..9cd7fa8 100644 --- a/resources/skills/rasuvaeff-context/SKILL.md +++ b/resources/skills/rasuvaeff-context/SKILL.md @@ -2,46 +2,110 @@ name: rasuvaeff-context description: >- Deadline, cancellation and request-scoped values for PHP with - rasuvaeff/context — Context, ContextController, deadlines, termination - listeners and cooperative checks. Use when propagating request or job - budgets, cancellation or correlation metadata through application code. + rasuvaeff/context — Context, ContextController, withTimeout/withReserve/ + withoutCancel/joinedWith, sleep(), iterate(), requestCancel() for signal + handlers, WireCodec for crossing process boundaries, and the Testing clocks. + Use when propagating a request or job budget, cancellation or correlation + metadata through code, writing a transport adapter that maps the remaining + budget to a timeout, stopping a CLI worker gracefully, or testing any of it. --- # rasuvaeff/context -Dependency-free cooperative context for PHP. Namespace `Rasuvaeff\Context\`. +Dependency-free cooperative context (deadline + cancellation + values), like +Go's `context.Context`. Namespace `Rasuvaeff\Context\`. -## Rules +## Safety rules — verify these on every change -1. The owner keeps `ContextController`; callees receive only `Context`. -2. Derivation methods return `[Context, ContextController]`: - `withCancel()`, `withTimeout(float $seconds)`, and `withDeadline(int $ns)`. -3. Call `assertActive()` before every retry, batch, queue step or external - operation. Deadline observation is lazy unless an optional scheduler is - installed. -4. `isCancelled()` means explicit cancellation; `isExpired()` means deadline; - `isTerminated()` is their union. `ContextException` subclasses distinguish - the causes. -5. `withValue(class-string, T)` is for request-scoped metadata, not service - lookup. Values must be objects matching their class-string key. -6. A context cannot interrupt an arbitrary blocking syscall. Map remaining - budget into a transport timeout or register an `onTerminate()` cleanup hook. +1. **Pass `Context` explicitly; never store it in a service or DI.** It is + request-scoped. The owner keeps the `ContextController` and cancels it in + `finally`; callees receive only the `Context`. + +2. **Nothing is interrupted — check between steps.** Call `assertActive()` + before each retry attempt, batch item, queue step or external call. Loops: + `foreach ($ctx->iterate($items) as $k => $v)` (checks before pulling each + element). Waits: `$ctx->sleep($s)`, never `usleep()`. A deadline is + discovered lazily — an unobserved context never runs its listeners. + +3. **Adapters read the budget BEFORE `assertActive()`.** + ```php + $remainingMs = $ctx->remainingMs(); // first + $ctx->assertActive(); // then: guarantees >= 1 ms + ``` + Read after the check, an expiry in between yields `0.0`, and `0` means + **no timeout** to curl, ClickHouse `max_execution_time` and MySQL + `MAX_EXECUTION_TIME`. Map the transport's timeout error to + `ContextExpiredException` (original as `previous`) only when the context + has expired; otherwise rethrow it. + +4. **Signal handlers call `requestCancel()`, never `cancel()`.** `cancel()` + runs listeners synchronously — inside a handler that is an arbitrary point + of the interrupted code. `requestCancel()` only records the reason; the + next check applies it. A real signal wakes `$ctx->sleep()` early. + +5. **Derive, don't join.** A job under a worker is `$worker->withTimeout($ttr)`. + `joinedWith()` is only for two *independent* sources (worker shutdown + + a message context received from elsewhere); values come from `$this` only, + both must share a `TimeSource`. + +6. **Cleanup after the request ended needs `withoutCancel()` + a budget.** + `[$cleanup] = $request->withoutCancel()->withTimeout(2.0);` — the bare + `withoutCancel()` has no deadline at all. + +7. **Budgets crossing a process boundary are untrusted.** Send the remaining + milliseconds, never `deadlineNs()` (monotonic, process-local). Use + `WireCodec`: values only via `WireValue::fromWire()` (validate, throw on bad + input — never `unserialize()`), aliases from an allow-list, and + `decode($wire, $parent, maxBudgetMs:)` falls back to `maxBudgetMs` when the + budget is missing or malformed. + +8. **Tests use the shipped clocks, never real time.** + `Testing\FakeTimeSource` (also the `Sleeper`: `sleep()` advances it) and + `Testing\SteppingTimeSource($stepNs)` — moves on every read, the way to + prove an adapter never acts on an expired budget (rule 3). + +## Decision table + +| Need | Use | +|---|---| +| Request/job budget | `[$ctx, $c] = Context::background()->withTimeout($seconds)` | +| Leave time to answer 504 / NACK | `[$work] = $ctx->withReserve(0.2)` | +| Cancellable sub-branch | `[$branch, $c] = $ctx->withCancel()` | +| Absolute monotonic deadline | `$ctx->withDeadline($ns)` (earlier of parent's and yours) | +| Request metadata | `$ctx->withValue(TenantId::class, new TenantId('t'))` / `->value(TenantId::class)` | +| Graceful CLI shutdown | `pcntl_signal(SIGTERM, fn () => $c->requestCancel(...))` (or `rasuvaeff/context-cli`) | +| Release a resource on termination | `$unsubscribe = $ctx->onTerminate(fn (TerminationCause $t) => ...)` | +| Hand budget + values to another process | `WireCodec::encode()` / `decode()` | +| HTTP (PSR-15/PSR-18/Guzzle) | `rasuvaeff/context-http` | ## Canonical usage ```php use Rasuvaeff\Context\Context; +use Rasuvaeff\Context\ContextExpiredException; -[$context, $controller] = Context::background()->withTimeout(3.0); +[$job, $controller] = $worker->withTimeout($ttrSeconds); try { - $context->assertActive(); - doWork($context); + foreach ($job->iterate($steps) as $step) { + $step($job); + } + $queue->ack($message); +} catch (ContextExpiredException) { + $queue->nack($message); } finally { $controller->cancel(); } ``` -For queues, derive one context per message from its TTR, check it between -steps, ACK only after completion, and NACK/dead-letter on -`ContextExpiredException`. Read `vendor/rasuvaeff/context/llms.txt` for the -complete API and edge-case semantics. +Predicates: `isCancelled()` (explicit), `isExpired()` (deadline), +`isTerminated()` (either); `assertActive()` throws `ContextCancelledException` +(reason as `previous`) or `ContextExpiredException`. A listener that throws +does not stop the cascade; its exception surfaces from `cancel()` or from the +check that discovered the termination. + +## Full API + +Every method, edge case (zero/negative/oversized timeouts, `every:` in +`iterate()`, joined deadlines) and the transport limitations matrix ship with +the package: read `vendor/rasuvaeff/context/llms.txt` and the README +"Limitations" section before guessing a method name. diff --git a/src/Context.php b/src/Context.php index 2c11d5d..6b53cb2 100644 --- a/src/Context.php +++ b/src/Context.php @@ -15,25 +15,26 @@ private function __construct( private ContextState $state, private ?self $parent, private TimeSource $timeSource, + private Sleeper $sleeper, private ?int $deadlineNs, private ?ContextState $deadlineOwner, /** @var array */ private array $values, - ) { - // 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 + /** + * A `$timeSource` that also implements {@see Sleeper} (such as + * {@see Testing\FakeTimeSource}) is used as the sleeper when none is given. + */ + public static function background(?TimeSource $timeSource = null, ?Sleeper $sleeper = null): self { + $timeSource ??= new HrTimeSource(); + return new self( state: new ContextState(), parent: null, - timeSource: $timeSource ?? new HrTimeSource(), + timeSource: $timeSource, + sleeper: $sleeper ?? ($timeSource instanceof Sleeper ? $timeSource : new UsleepSleeper()), deadlineNs: null, deadlineOwner: null, values: [], @@ -63,7 +64,9 @@ public function withTimeout(float $seconds): array } $nowNs = $this->timeSource->nowNs(); - $durationNs = max(0.0, $seconds) * 1_000_000_000.0; + // round(), not (int): 1.015 * 1e9 is 1014999999.99…, and truncating + // made remainingMs() report 1014 right after asking for 1015. + $durationNs = round(max(0.0, $seconds) * 1_000_000_000.0); $roomNs = \PHP_INT_MAX - $nowNs; $deadline = $durationNs >= (float) $roomNs ? \PHP_INT_MAX : $nowNs + (int) $durationNs; @@ -74,14 +77,81 @@ public function withTimeout(float $seconds): array public function withDeadline(int $absoluteNs): array { $state = new ContextState(); - $deadline = $this->deadlineNs === null ? $absoluteNs : min($this->deadlineNs, $absoluteNs); - // An equal deadline is already owned by the parent. Keeping that - // owner preserves the rule that observing a child discovers and - // terminates the ancestor's complete subtree. - $owner = $this->deadlineNs === null || $absoluteNs < $this->deadlineNs - ? $state - : $this->deadlineOwner; + [$deadline, $owner] = $this->earlierDeadline($absoluteNs, $state); + $context = $this->derive(state: $state, deadlineNs: $deadline, deadlineOwner: $owner); + + return [$context, new ContextController($state)]; + } + + /** + * The parent's deadline moved earlier by `$seconds`, leaving time to act + * after lower layers return (answer 504, NACK a message). Without a + * parent deadline this is {@see withCancel()}; a reserve that covers the + * whole remaining budget yields an already-expired context. + * + * @return array{self, ContextController} + */ + public function withReserve(float $seconds): array + { + if (!is_finite($seconds) || $seconds < 0.0) { + throw new \InvalidArgumentException('Reserve must be a finite non-negative number of seconds'); + } + + if ($this->deadlineNs === null) { + return $this->withCancel(); + } + + // round(), not (int): 0.892 * 1e9 is 891999999.99…, and truncating + // would leave the deadline a nanosecond later than asked. + $reserveNs = round($seconds * 1_000_000_000.0); + $deadline = $reserveNs >= (float) $this->deadlineNs ? 0 : $this->deadlineNs - (int) $reserveNs; + + return $this->withDeadline($deadline); + } + + /** + * Keeps this context's values but neither its deadline nor its + * cancellation: for work that must outlive the request (audit, + * compensation, error logging). Bound it with `withTimeout()`. + */ + public function withoutCancel(): self + { + // No parent link at all: values are copied, so a long-running cleanup + // does not keep the request's context tree alive. + return new self( + state: new ContextState(), + parent: null, + timeSource: $this->timeSource, + sleeper: $this->sleeper, + deadlineNs: null, + deadlineOwner: null, + values: $this->values, + ); + } + + /** + * Terminates when either this context or `$other` does (the first cause + * wins); the deadline is the earlier of the two. Values come from this + * context only. When both belong to one tree, derive instead. + * + * @return array{self, ContextController} + */ + public function joinedWith(self $other): array + { + if (!$this->sharesClockWith($other)) { + throw new \InvalidArgumentException('Joined contexts must share a time source'); + } + + $state = new ContextState(); + $deadline = $this->deadlineNs; + $owner = $this->deadlineOwner; + if ($other->deadlineNs !== null && ($deadline === null || $other->deadlineNs < $deadline)) { + $deadline = $other->deadlineNs; + $owner = $other->deadlineOwner; + } + $context = $this->derive(state: $state, deadlineNs: $deadline, deadlineOwner: $owner); + $other->state->addChild($state); return [$context, new ContextController($state)]; } @@ -106,7 +176,7 @@ public function withValue(string $key, object $value): self public function assertActive(): void { - $this->observeDeadline(); + $this->observe(); $cause = $this->state->cause(); if ($cause instanceof TerminationCause) { @@ -127,28 +197,28 @@ public function assertActive(): void public function isTerminated(): bool { - $this->observeDeadline(); + $this->observe(); return $this->state->cause() instanceof \Rasuvaeff\Context\TerminationCause; } public function isCancelled(): bool { - $this->observeDeadline(); + $this->observe(); return $this->state->cause()?->isCancelled() ?? false; } public function isExpired(): bool { - $this->observeDeadline(); + $this->observe(); return $this->state->cause()?->isExpired() ?? false; } public function remainingMs(): ?float { - $this->observeDeadline(); + $this->observe(); if ($this->deadlineNs === null) { return null; @@ -169,6 +239,53 @@ public function deadlineNs(): ?int return $this->deadlineNs; } + /** + * Sleeps for `$seconds`, but never past the deadline: throws + * {@see ContextExpiredException} when the deadline comes first and + * {@see ContextCancelledException} when the context is cancelled (a + * signal wakes the sleep early). Throws at once on a terminated context. + */ + public function sleep(float $seconds): void + { + if (!is_finite($seconds)) { + throw new \InvalidArgumentException('Sleep duration must be finite'); + } + + $this->assertActive(); + $nowNs = $this->timeSource->nowNs(); + $durationNs = round(max(0.0, $seconds) * 1_000_000_000.0); + $endNs = $durationNs >= (float) (\PHP_INT_MAX - $nowNs) ? \PHP_INT_MAX : $nowNs + (int) $durationNs; + $targetNs = $this->deadlineNs === null ? $endNs : min($endNs, $this->deadlineNs); + + // A sleeper may return early: re-check the clock until the target. + while ($nowNs < $targetNs) { + $this->sleeper->sleepNs($targetNs - $nowNs); + $this->assertActive(); + $nowNs = $this->timeSource->nowNs(); + } + } + + /** + * Yields `$items` with their keys, checking the context before pulling + * each element (every `$every` elements), so a generator source is never + * advanced after the context terminates. + * + * @template TKey + * @template TValue + * + * @param iterable $items + * + * @return \Generator + */ + public function iterate(iterable $items, int $every = 1): \Generator + { + if ($every < 1) { + throw new \InvalidArgumentException('Check interval must be positive'); + } + + return $this->iterateChecked($items, $every); + } + /** @param class-string $key */ public function value(string $key): ?object { @@ -190,21 +307,78 @@ public function onTerminate(callable $listener): \Closure return $this->state->addListener(\Closure::fromCallable($listener)); } - private function observeDeadline(): void + /** + * @template TKey + * @template TValue + * + * @param iterable $items + * @param positive-int $every + * + * @return \Generator + */ + private function iterateChecked(iterable $items, int $every): \Generator + { + $iterator = \is_array($items) ? new \ArrayIterator($items) : new \IteratorIterator($items); + + $this->assertActive(); + $iterator->rewind(); + $untilCheck = $every; + + while ($iterator->valid()) { + yield $iterator->key() => $iterator->current(); + + if (--$untilCheck === 0) { + $this->assertActive(); + $untilCheck = $every; + } + $iterator->next(); + } + } + + /** + * Requested cancellations first (an explicit request wins over a deadline + * discovered at the same check), then the lazy deadline. + */ + private function observe(): void { + $this->state->resolvePending(); + if ($this->deadlineNs === null || $this->timeSource->nowNs() < $this->deadlineNs) { return; } - $owner = $this->deadlineOwner; - if (!$owner instanceof \Rasuvaeff\Context\Internal\ContextState || $owner->cause() instanceof \Rasuvaeff\Context\TerminationCause) { - return; + // A deadline always has an owner; terminating an already terminated + // owner is a no-op. + $this->deadlineOwner?->terminate(TerminationCause::expired($this->deadlineNs)); + } + + /** + * An equal deadline is already owned by the parent: keeping that owner + * preserves the rule that observing a child discovers and terminates the + * ancestor's complete subtree. + * + * @return array{int, ContextState|null} + */ + private function earlierDeadline(int $absoluteNs, ContextState $state): array + { + if ($this->deadlineNs === null || $absoluteNs < $this->deadlineNs) { + return [$absoluteNs, $state]; } - $owner->terminate(TerminationCause::expired($this->deadlineNs)); + return [$this->deadlineNs, $this->deadlineOwner]; + } + + private function sharesClockWith(self $other): bool + { + return $this->timeSource === $other->timeSource + || ($this->timeSource instanceof HrTimeSource && $other->timeSource instanceof HrTimeSource); } /** + * 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(). + * * @param array|null $values */ private function derive( @@ -214,10 +388,15 @@ private function derive( /** @var array|null $values */ ?array $values = null, ): self { + if ($state !== $this->state) { + $this->state->addChild($state); + } + return new self( state: $state, parent: $this, timeSource: $this->timeSource, + sleeper: $this->sleeper, deadlineNs: $deadlineNs, deadlineOwner: $deadlineOwner, values: $values ?? $this->values, diff --git a/src/ContextController.php b/src/ContextController.php index 777ad6d..5756a22 100644 --- a/src/ContextController.php +++ b/src/ContextController.php @@ -13,8 +13,23 @@ { public function __construct(private ContextState $state) {} + /** + * Terminates now: listeners and descendants run synchronously, and a + * listener failure is rethrown from here once the cascade completes. + */ public function cancel(?\Throwable $reason = null): void { $this->state->terminate(TerminationCause::cancelled($reason)); } + + /** + * Records the cancellation without running anything; the next check of + * this context or a descendant terminates it, and listener failures + * surface from that check. Use it from signal handlers, where `cancel()` + * would run listeners at an arbitrary point of the interrupted code. + */ + public function requestCancel(?\Throwable $reason = null): void + { + $this->state->requestTermination(TerminationCause::cancelled($reason)); + } } diff --git a/src/Internal/ContextState.php b/src/Internal/ContextState.php index 56b03d5..d60f8b6 100644 --- a/src/Internal/ContextState.php +++ b/src/Internal/ContextState.php @@ -7,6 +7,8 @@ use Rasuvaeff\Context\TerminationCause; /** + * One cancellable node. States form a DAG: a joined context has two parents. + * * @internal */ final class ContextState @@ -17,20 +19,37 @@ final class ContextState /** @var array keyed by spl_object_id() */ private array $children = []; - private ?self $parent = null; + /** @var array keyed by spl_object_id() */ + private array $parents = []; private ?TerminationCause $cause = null; + private ?TerminationCause $pending = null; + + /** + * Requests not yet applied, process-wide. While it is zero a check skips + * the walk over its ancestors. A request left on a state that is garbage + * collected unobserved keeps it above zero — that costs speed, never + * correctness. + */ + private static int $pendingRequests = 0; + /** @throws \Throwable */ public function addChild(self $child): void { + // A child already terminated through its other parent must not be + // retained by this one. + if ($child->cause instanceof TerminationCause) { + return; + } + if ($this->cause instanceof TerminationCause) { $child->terminate($this->cause); return; } - $child->parent = $this; + $child->parents[spl_object_id($this)] = $this; $this->children[spl_object_id($child)] = $child; } @@ -39,6 +58,35 @@ public function cause(): ?TerminationCause return $this->cause; } + /** + * Records a termination without running anything: the next observation + * of this node or a descendant terminates it ({@see resolvePending()}). + * Safe from a signal handler. The first request wins. + */ + public function requestTermination(TerminationCause $cause): void + { + if ($this->cause instanceof TerminationCause || $this->pending instanceof TerminationCause) { + return; + } + + $this->pending = $cause; + self::$pendingRequests++; + } + + /** + * Applies requested terminations, ancestors first, so the topmost + * requesting node terminates and cascades over this one. + * + * @throws \Throwable + */ + public function resolvePending(): void + { + if (self::$pendingRequests > 0) { + $visited = []; + $this->resolvePendingFrom($visited); + } + } + /** * @param \Closure(TerminationCause): void $listener * @@ -70,12 +118,18 @@ public function terminate(TerminationCause $cause): void } $this->cause = $cause; + if ($this->pending instanceof TerminationCause) { + $this->pending = null; + self::$pendingRequests--; + } - // 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; + // A terminated state no longer needs its parents and they no longer + // need it: detaching keeps a long-lived parent from accumulating + // every per-request child it ever derived or joined. + foreach ($this->parents as $parent) { + $parent->removeChild($this); + } + $this->parents = []; $listeners = $this->listeners; $children = $this->children; @@ -104,6 +158,27 @@ public function terminate(TerminationCause $cause): void } } + /** + * @param array $visited states already walked: a joined + * context shares ancestors, and walking them twice is wasted work + * + * @throws \Throwable + */ + private function resolvePendingFrom(array &$visited): void + { + $visited[spl_object_id($this)] = true; + + foreach ($this->parents as $id => $parent) { + if (!$this->cause instanceof TerminationCause && !isset($visited[$id])) { + $parent->resolvePendingFrom($visited); + } + } + + if (!$this->cause instanceof TerminationCause && $this->pending instanceof TerminationCause) { + $this->terminate($this->pending); + } + } + private function removeChild(self $child): void { unset($this->children[spl_object_id($child)]); diff --git a/src/Sleeper.php b/src/Sleeper.php new file mode 100644 index 0000000..1f842c5 --- /dev/null +++ b/src/Sleeper.php @@ -0,0 +1,16 @@ +nowNs; + } + + #[\Override] + public function sleepNs(int $nanoseconds): void + { + $this->advanceNs($nanoseconds); + } + + public function advanceNs(int $nanoseconds): void + { + if ($nanoseconds > 0) { + $this->nowNs += $nanoseconds; + } + } + + public function advanceMs(int|float $milliseconds): void + { + $this->advanceNs((int) round((float) $milliseconds * 1_000_000.0)); + } +} diff --git a/src/Testing/SteppingTimeSource.php b/src/Testing/SteppingTimeSource.php new file mode 100644 index 0000000..b742155 --- /dev/null +++ b/src/Testing/SteppingTimeSource.php @@ -0,0 +1,31 @@ +nowNs; + $this->nowNs += $this->stepNs; + + return $now; + } +} diff --git a/src/UsleepSleeper.php b/src/UsleepSleeper.php new file mode 100644 index 0000000..3d9bf09 --- /dev/null +++ b/src/UsleepSleeper.php @@ -0,0 +1,19 @@ +} + * + * @api + */ +final readonly class WireCodec +{ + private const string ALIAS_PATTERN = '/^[A-Za-z0-9_.-]{1,64}\z/'; + + private const int MAX_BUDGET_MS = 999_999_999; + + /** @var array> */ + private array $aliases; + + /** @var (\Closure(string, \Throwable): void)|null */ + private ?\Closure $onInvalid; + + /** + * @param array $aliases alias => value class + * @param (callable(string, \Throwable): void)|null $onInvalid receives the + * alias and the error of a dropped incoming value + */ + public function __construct(array $aliases, ?callable $onInvalid = null) + { + $checked = []; + foreach ($aliases as $alias => $class) { + if (preg_match(self::ALIAS_PATTERN, $alias) !== 1) { + throw new \InvalidArgumentException(sprintf('Invalid wire alias "%s"', $alias)); + } + + if (!is_subclass_of($class, WireValue::class)) { + throw new \InvalidArgumentException(sprintf('Wire value class "%s" must implement %s', $class, WireValue::class)); + } + + $checked[$alias] = $class; + } + + $this->aliases = $checked; + $this->onInvalid = $onInvalid === null ? null : \Closure::fromCallable($onInvalid); + } + + /** + * @return WireFormat + * + * @throws ContextException when the context is terminated + */ + public function encode(Context $context): array + { + // Read before the check: once assertActive() passes, the budget is + // never the 0.0 of an expired context. + $remainingMs = $context->remainingMs(); + $context->assertActive(); + + $values = []; + foreach ($this->aliases as $alias => $class) { + $value = $context->value($class); + if ($value instanceof WireValue) { + $values[$alias] = $value->toWire(); + } + } + + return [ + 'budget_ms' => $remainingMs === null ? null : max(1, (int) $remainingMs), + 'values' => $values, + ]; + } + + /** + * Rebuilds a context under `$parent` from untrusted input. The incoming + * budget is capped by `$maxBudgetMs` and can only shorten the parent's; + * a missing or malformed budget falls back to `$maxBudgetMs` — bad input + * never lifts the limit. Unknown aliases and values whose `fromWire()` + * throws are dropped. Never throws on bad input. + * + * @param positive-int $maxBudgetMs + * + * @return array{Context, ContextController} + */ + public function decode(mixed $wire, Context $parent, int $maxBudgetMs): array + { + $wire = \is_array($wire) ? $wire : []; + $budgetMs = $this->budget($wire['budget_ms'] ?? null); + + [$context, $controller] = $parent->withTimeout((float) min($budgetMs ?? $maxBudgetMs, $maxBudgetMs) / 1000.0); + + $values = $this->map($wire['values'] ?? null); + foreach ($this->aliases as $alias => $class) { + $raw = $values[$alias] ?? null; + if (!\is_string($raw)) { + continue; + } + + try { + $context = $context->withValue($class, $class::fromWire($raw)); + } catch (\Throwable $error) { + if ($this->onInvalid instanceof \Closure) { + ($this->onInvalid)($alias, $error); + } + } + } + + return [$context, $controller]; + } + + /** @return array */ + private function map(mixed $raw): array + { + return \is_array($raw) ? $raw : []; + } + + /** @return positive-int|null */ + private function budget(mixed $raw): ?int + { + if (\is_string($raw) && preg_match('/^[0-9]{1,9}\z/', $raw) === 1) { + $raw = (int) $raw; + } + + if (!\is_int($raw) || $raw < 1 || $raw > self::MAX_BUDGET_MS) { + return null; + } + + return $raw; + } +} diff --git a/src/WireValue.php b/src/WireValue.php new file mode 100644 index 0000000..d273494 --- /dev/null +++ b/src/WireValue.php @@ -0,0 +1,19 @@ +withTimeout(3.0); + [$work] = $request->withReserve(0.5); + + Assert::same($work->deadlineNs(), 3_500_000_000); + Assert::same($work->remainingMs(), 2_500.0); + } + + public function aReserveWithoutAParentDeadlineIsACancellableBranch(): void + { + [$work, $controller] = Context::background()->withReserve(0.5); + + Assert::null($work->deadlineNs()); + + $controller->cancel(); + + Assert::true($work->isCancelled()); + } + + public function aReserveCoveringTheBudgetIsAlreadyExpired(): void + { + $clock = new FakeTimeSource(); + [$request] = Context::background($clock)->withTimeout(1.0); + [$work] = $request->withReserve(1.0); + [$huge] = $request->withReserve(1e12); + + Assert::true($work->isExpired()); + Assert::true($huge->isExpired()); + Assert::same($huge->deadlineNs(), 0); + Assert::false($request->isTerminated()); + } + + #[DataProvider('invalidReserveProvider')] + public function invalidReservesAreRejected(float $seconds): void + { + Expect::exception(\InvalidArgumentException::class); + Context::background()->withReserve($seconds); + } + + public static function invalidReserveProvider(): iterable + { + yield 'negative' => [-0.1]; + yield 'not a number' => [NAN]; + yield 'infinite' => [INF]; + } + + #[Property(runs: 300)] + public function aReserveIsExactAndNeverLaterThanTheParent(int $parentMs, int $reserveMs): void + { + $clock = new FakeTimeSource(); + [$parent] = Context::background($clock)->withTimeout($parentMs / 1000.0); + [$child] = $parent->withReserve($reserveMs / 1000.0); + $parentDeadline = $parent->deadlineNs() ?? 0; + $reserveNs = $reserveMs * 1_000_000; + $expected = $reserveNs >= $parentDeadline ? 0 : $parentDeadline - $reserveNs; + + Assert::same($child->deadlineNs(), $expected); + Assert::true($expected <= $parentDeadline); + Assert::same($child->isExpired(), $reserveMs >= $parentMs); + Assert::false($parent->isTerminated()); + + Classify::cover($reserveMs >= $parentMs, 'reserve covers the budget', 10); + Classify::cover($reserveMs < $parentMs, 'budget left', 10); + } + + /** @return array */ + public static function aReserveIsExactAndNeverLaterThanTheParentGenerators(): array + { + return [ + 'parentMs' => Gen::intBetween(1, 5_000), + 'reserveMs' => Gen::intBetween(0, 6_000), + ]; + } + + /** @return iterable */ + public static function aReserveIsExactAndNeverLaterThanTheParentExamples(): iterable + { + yield 'zero reserve keeps the parent deadline' => [1_000, 0]; + yield 'reserve equal to the budget' => [1_000, 1_000]; + yield 'reserve beyond the clock origin' => [1, 6_000]; + } + + public function aDetachedContextKeepsValuesButNotTheDeadline(): void + { + $clock = new FakeTimeSource(); + [$request, $controller] = Context::background($clock)->withTimeout(1.0); + $request = $request->withValue(RequestId::class, new RequestId('r-1')); + $cleanup = $request->withoutCancel(); + + $controller->cancel(); + $clock->advanceMs(5_000); + + Assert::true($request->isCancelled()); + Assert::false($cleanup->isTerminated()); + Assert::null($cleanup->deadlineNs()); + Assert::null($cleanup->remainingMs()); + Assert::same($cleanup->value(RequestId::class)?->value, 'r-1'); + } + + public function aDetachedContextIsNotRetainedByItsParent(): void + { + [$request] = Context::background()->withCancel(); + + for ($i = 0; $i < 10; $i++) { + $request->withoutCancel(); + } + + Assert::same(StateInspector::childCount($request), 0); + } + + public function aDetachedContextCanBeBoundedAgain(): void + { + $clock = new FakeTimeSource(); + [$request, $controller] = Context::background($clock)->withTimeout(0.1); + $controller->cancel(); + [$cleanup] = $request->withoutCancel()->withTimeout(2.0); + + $clock->advanceMs(1_000); + Assert::false($cleanup->isTerminated()); + + $clock->advanceMs(1_000); + Assert::true($cleanup->isExpired()); + } + + /** + * @param non-empty-list $parentPicks + */ + #[Property(runs: 200)] + public function aDetachedContextSurvivesAnyTerminationAbove(array $parentPicks, int $detachPick, int $cancelPick): void + { + $clock = new FakeTimeSource(); + [$root, $rootController] = Context::background($clock)->withTimeout(1.0); + $contexts = [$root->withValue(RequestId::class, new RequestId('root'))]; + $controllers = [$rootController]; + + foreach ($parentPicks as $i => $pick) { + [$context, $controller] = $contexts[$pick % ($i + 1)]->withCancel(); + $contexts[] = $context->withValue(RequestId::class, new RequestId('n' . $i)); + $controllers[] = $controller; + } + + $source = $contexts[$detachPick % \count($contexts)]; + $detached = $source->withoutCancel(); + $expired = $cancelPick % 2 === 0; + + if ($expired) { + $clock->advanceMs(2_000); + } else { + $controllers[$cancelPick % \count($controllers)]->cancel(); + } + foreach ($contexts as $context) { + $context->isTerminated(); + } + + Assert::false($detached->isTerminated()); + Assert::same($detached->value(RequestId::class), $source->value(RequestId::class)); + + Classify::cover($expired, 'the root expired', 20); + Classify::cover(!$expired, 'a node was cancelled', 20); + } + + /** @return array */ + public static function aDetachedContextSurvivesAnyTerminationAboveGenerators(): array + { + return [ + 'parentPicks' => Gen::nonEmptyArrayOf(Gen::intBetween(0, 1_000), 8), + 'detachPick' => Gen::intBetween(0, 1_000), + 'cancelPick' => Gen::intBetween(0, 1_000), + ]; + } + + public function aJoinedContextTerminatesWithEitherParent(): void + { + [$worker, $workerController] = Context::background()->withCancel(); + [$message, $messageController] = Context::background()->withCancel(); + [$first] = $message->joinedWith($worker); + [$second] = $message->joinedWith($worker); + $reason = new \RuntimeException('shutdown'); + + $workerController->cancel($reason); + + Assert::true($first->isCancelled()); + Assert::true($second->isCancelled()); + Assert::false($message->isTerminated()); + + [$third] = $message->joinedWith(Context::background()->withCancel()[0]); + $messageController->cancel(); + + Assert::true($third->isCancelled()); + } + + public function theFirstCauseOfAJoinedContextWins(): void + { + [$worker, $workerController] = Context::background()->withCancel(); + [$message, $messageController] = Context::background()->withCancel(); + [$joined] = $message->joinedWith($worker); + $first = new \RuntimeException('first'); + $listenerReasons = []; + $joined->onTerminate(static function ($cause) use (&$listenerReasons): void { + $listenerReasons[] = $cause->reason(); + }); + + $workerController->cancel($first); + $messageController->cancel(new \RuntimeException('second')); + + Assert::same($listenerReasons, [$first]); + } + + public function aJoinedContextTakesTheEarlierDeadline(): void + { + $clock = new FakeTimeSource(); + [$worker] = Context::background($clock)->withTimeout(10.0); + [$message] = Context::background($clock)->withTimeout(2.0); + [$sibling] = $message->withCancel(); + [$joined] = $worker->joinedWith($message); + + Assert::same($joined->deadlineNs(), $message->deadlineNs()); + + $clock->advanceMs(2_000); + + // The deadline is owned by $message: observing the joined node + // terminates the owner and its whole subtree, not the worker. + Assert::true($joined->isExpired()); + Assert::true($message->isExpired()); + Assert::true($sibling->isExpired()); + Assert::false($worker->isTerminated()); + } + + /** + * @param 'primary'|'other'|null $owner + */ + #[DataProvider('joinedDeadlineProvider')] + public function aJoinedDeadlineBelongsToTheEarlierParent(?float $primaryTimeout, ?float $otherTimeout, ?int $expectedNs, ?string $owner): void + { + $clock = new FakeTimeSource(); + $primary = Context::background($clock); + $other = Context::background($clock); + if ($primaryTimeout !== null) { + [$primary] = $primary->withTimeout($primaryTimeout); + } + if ($otherTimeout !== null) { + [$other] = $other->withTimeout($otherTimeout); + } + + [$joined] = $primary->joinedWith($other); + $clock->advanceMs(10_000); + $joined->isTerminated(); + + Assert::same($joined->deadlineNs(), $expectedNs); + // Observing the joined node terminates the owner of its deadline only. + Assert::same(StateInspector::state($primary)->cause() instanceof TerminationCause, $owner === 'primary'); + Assert::same(StateInspector::state($other)->cause() instanceof TerminationCause, $owner === 'other'); + } + + public static function joinedDeadlineProvider(): iterable + { + yield 'primary earlier' => [1.0, 2.0, 2_000_000_000, 'primary']; + yield 'other earlier' => [2.0, 1.0, 2_000_000_000, 'other']; + yield 'equal stays with the primary' => [1.0, 1.0, 2_000_000_000, 'primary']; + yield 'only the primary has one' => [1.0, null, 2_000_000_000, 'primary']; + yield 'only the other has one' => [null, 1.0, 2_000_000_000, 'other']; + yield 'neither has one' => [null, null, null, null]; + } + + public function joiningTheDefaultClockWithAnotherIsRejected(): void + { + Expect::exception(\InvalidArgumentException::class); + Context::background()->joinedWith(Context::background(new FakeTimeSource())); + } + + public function aJoinedContextTakesValuesFromThePrimaryOnly(): void + { + $primary = Context::background()->withValue(RequestId::class, new RequestId('primary')); + $other = Context::background()->withValue(RequestId::class, new RequestId('other')); + $bare = Context::background(); + + Assert::same($primary->joinedWith($other)[0]->value(RequestId::class)?->value, 'primary'); + Assert::null($bare->joinedWith($other)[0]->value(RequestId::class)); + } + + public function joiningATerminatedContextIsTerminatedAtOnce(): void + { + [$worker, $workerController] = Context::background()->withCancel(); + [$message] = Context::background()->withCancel(); + $workerController->cancel(); + + [$joined] = $message->joinedWith($worker); + [$reversed] = $worker->joinedWith($message); + + Assert::true($joined->isCancelled()); + Assert::true($reversed->isCancelled()); + Assert::same(StateInspector::childCount($message), 0); + } + + public function aTerminatedJoinedContextDetachesFromBothParents(): void + { + [$worker] = Context::background()->withCancel(); + [$message] = Context::background()->withCancel(); + + for ($i = 0; $i < 20; $i++) { + [, $controller] = $message->joinedWith($worker); + $controller->cancel(); + } + [$kept] = $message->joinedWith($worker); + + Assert::same(StateInspector::childCount($worker), 1); + Assert::same(StateInspector::childCount($message), 1); + Assert::same(StateInspector::parentCount($kept), 2); + } + + public function joiningContextsOnDifferentClocksIsRejected(): void + { + $first = Context::background(new FakeTimeSource()); + $second = Context::background(new FakeTimeSource()); + + Expect::exception(\InvalidArgumentException::class); + $first->joinedWith($second); + } + + public function contextsOnTheDefaultClockCanBeJoined(): void + { + [$joined, $controller] = Context::background()->joinedWith(Context::background()); + + $controller->cancel(); + + Assert::true($joined->isCancelled()); + } + + /** + * @param non-empty-list $nodes [first parent pick, second parent pick, is a join] + */ + #[Property(runs: 300)] + public function aJoinedNodeTerminatesExactlyWhenAParentDoes(array $nodes, int $cancelPick): void + { + [$left] = Context::background()->withCancel(); + [$right] = Context::background()->withCancel(); + $contexts = [$left, $right]; + $controllers = [null, null]; + $parents = [[], []]; + + foreach ($nodes as [$firstPick, $secondPick, $isJoin]) { + $count = \count($contexts); + $first = $firstPick % $count; + $second = $secondPick % $count; + + if ($isJoin && $first !== $second) { + [$context, $controller] = $contexts[$first]->joinedWith($contexts[$second]); + $parents[] = [$first, $second]; + } else { + [$context, $controller] = $contexts[$first]->withCancel(); + $parents[] = [$first]; + } + $contexts[] = $context; + $controllers[] = $controller; + } + + $target = 2 + $cancelPick % (\count($contexts) - 2); + $controllers[$target]?->cancel(); + + $terminated = []; + foreach ($parents as $node => $nodeParents) { + $terminated[$node] = $node === $target; + foreach ($nodeParents as $parent) { + $terminated[$node] = $terminated[$node] || $terminated[$parent]; + } + } + + $joins = 0; + $terminatedJoins = 0; + foreach ($contexts as $node => $context) { + Assert::same($context->isCancelled(), $terminated[$node]); + if (\count($parents[$node]) === 2) { + $joins++; + $terminatedJoins += $terminated[$node] ? 1 : 0; + } + } + Assert::false(StateInspector::retainsTerminatedChild($contexts)); + + Classify::cover($terminatedJoins > 0, 'a join node terminated', 10); + Classify::cover($joins > $terminatedJoins, 'a join node survived', 10); + } + + /** @return array */ + public static function aJoinedNodeTerminatesExactlyWhenAParentDoesGenerators(): array + { + return [ + 'nodes' => Gen::nonEmptyArrayOf( + Gen::tuple( + Gen::intBetween(0, 1_000), + Gen::intBetween(0, 1_000), + Gen::frequency([[1, Gen::constant(value: true)], [1, Gen::constant(value: false)]]), + ), + 10, + ), + 'cancelPick' => Gen::intBetween(0, 1_000), + ]; + } + + /** @return iterable, int}> */ + public static function aJoinedNodeTerminatesExactlyWhenAParentDoesExamples(): iterable + { + // nodes 0 and 1 are the two roots; generated nodes start at 2 + yield 'a diamond cancelled at its top' => [[[0, 0, false], [2, 2, false], [2, 3, true]], 0]; + yield 'a join of two roots cancelled itself' => [[[0, 1, true]], 0]; + yield 'one branch of a join cancelled' => [[[0, 0, false], [1, 1, false], [2, 3, true]], 1]; + } +} diff --git a/tests/ContextLifecycleTest.php b/tests/ContextLifecycleTest.php index 7c1f013..d412d75 100644 --- a/tests/ContextLifecycleTest.php +++ b/tests/ContextLifecycleTest.php @@ -11,7 +11,7 @@ use Rasuvaeff\Context\ContextExpiredException; use Rasuvaeff\Context\Internal\ContextState; use Rasuvaeff\Context\TerminationCause; -use Rasuvaeff\Context\Tests\Support\FakeTimeSource; +use Rasuvaeff\Context\Testing\FakeTimeSource; use Rasuvaeff\Context\Tests\Support\RequestId; use Rasuvaeff\PropertyTesting\ArbitraryInterface; use Rasuvaeff\PropertyTesting\Classify; @@ -156,6 +156,15 @@ public function anOrdinaryTimeoutIsExact(): void Assert::same($context->deadlineNs(), 2_500_000_000); } + public function aFractionalTimeoutIsNotTruncated(): void + { + // 1.015 * 1e9 is 1014999999.99… in floating point. + [$context] = Context::background(new FakeTimeSource())->withTimeout(1.015); + + Assert::same($context->deadlineNs(), 2_015_000_000); + Assert::same($context->remainingMs(), 1_015.0); + } + #[DataProvider('nonPositiveTimeoutProvider')] public function aNonPositiveTimeoutExpiresImmediately(float $seconds): void { diff --git a/tests/ContextRequestCancelTest.php b/tests/ContextRequestCancelTest.php new file mode 100644 index 0000000..6e39c50 --- /dev/null +++ b/tests/ContextRequestCancelTest.php @@ -0,0 +1,296 @@ +withCancel(); + $calls = 0; + $context->onTerminate(static function () use (&$calls): void { + $calls++; + }); + + $controller->requestCancel(); + + Assert::same($calls, 0); + Assert::true($context->isCancelled()); + Assert::same($calls, 1); + } + + public function theReasonOfARequestIsKept(): void + { + [$context, $controller] = Context::background()->withCancel(); + $reason = new \RuntimeException('SIGTERM'); + $controller->requestCancel($reason); + + Expect::exception(ContextCancelledException::class); + + try { + $context->assertActive(); + } catch (ContextCancelledException $exception) { + Assert::same($exception->getPrevious(), $reason); + + throw $exception; + } + } + + public function theFirstRequestWins(): void + { + [$context, $controller] = Context::background()->withCancel(); + $first = new \RuntimeException('first'); + $controller->requestCancel($first); + $controller->requestCancel(new \RuntimeException('second')); + $reasons = []; + $context->onTerminate(static function (TerminationCause $cause) use (&$reasons): void { + $reasons[] = $cause->reason(); + }); + + $context->isTerminated(); + + Assert::same($reasons, [$first]); + } + + public function anImmediateCancelOverridesAPendingRequest(): void + { + [$context, $controller] = Context::background()->withCancel(); + $immediate = new \RuntimeException('immediate'); + $controller->requestCancel(new \RuntimeException('pending')); + $reasons = []; + $context->onTerminate(static function (TerminationCause $cause) use (&$reasons): void { + $reasons[] = $cause->reason(); + }); + + $controller->cancel($immediate); + $context->isTerminated(); + + Assert::same($reasons, [$immediate]); + } + + public function aRequestOnATerminatedContextIsIgnored(): void + { + [$context, $controller] = Context::background()->withCancel(); + $first = new \RuntimeException('first'); + $controller->cancel($first); + $controller->requestCancel(new \RuntimeException('late')); + + try { + $context->assertActive(); + } catch (ContextCancelledException $exception) { + Assert::same($exception->getPrevious(), $first); + + return; + } + + throw new \LogicException('a cancellation was expected'); + } + + public function checkingADescendantAppliesTheRequest(): void + { + [$parent, $controller] = Context::background()->withCancel(); + [$child] = $parent->withCancel(); + [$sibling] = Context::background()->withCancel(); + $parentCalls = 0; + $parent->onTerminate(static function () use (&$parentCalls): void { + $parentCalls++; + }); + + $controller->requestCancel(); + $sibling->isTerminated(); + + Assert::same($parentCalls, 0); + Assert::true($child->isCancelled()); + Assert::same($parentCalls, 1); + } + + public function aRequestWinsOverADeadlineDiscoveredAtTheSameCheck(): void + { + $clock = new FakeTimeSource(); + [$context, $controller] = Context::background($clock)->withTimeout(1.0); + $controller->requestCancel(); + $clock->advanceMs(2_000); + + Assert::true($context->isCancelled()); + Assert::false($context->isExpired()); + } + + public function aFailingListenerSurfacesFromTheCheck(): void + { + [$context, $controller] = Context::background()->withCancel(); + [$child] = $context->withCancel(); + $context->onTerminate(static function (): void { + throw new \DomainException('listener failed'); + }); + + $controller->requestCancel(); + + try { + $child->assertActive(); + } catch (\DomainException $exception) { + Assert::same($exception->getMessage(), 'listener failed'); + Assert::true($child->isCancelled()); + Assert::true($context->isCancelled()); + + return; + } + + throw new \LogicException('the listener failure was expected to surface'); + } + + public function everyAppliedOrOverriddenRequestReleasesTheFastPath(): void + { + $before = $this->pendingRequests(); + [$first, $firstController] = Context::background()->withCancel(); + [$second, $secondController] = Context::background()->withCancel(); + [, $terminatedController] = Context::background()->withCancel(); + $terminatedController->cancel(); + + $firstController->requestCancel(); + $firstController->requestCancel(); + $secondController->requestCancel(); + $terminatedController->requestCancel(); + + Assert::same($this->pendingRequests(), $before + 2); + + $first->isTerminated(); + $secondController->cancel(); + + Assert::same($this->pendingRequests(), $before); + Assert::true($second->isCancelled()); + } + + public function aRequestReachesAJoinedContextThroughEitherParent(): void + { + [$worker, $workerController] = Context::background()->withCancel(); + [$message] = Context::background()->withCancel(); + [$joined] = $message->joinedWith($worker); + + $workerController->requestCancel(); + + Assert::true($joined->isCancelled()); + Assert::false($message->isTerminated()); + } + + /** + * @param non-empty-list $nodes [parent pick, requests cancellation] + */ + #[Property(runs: 300)] + public function aCheckAppliesTheTopmostRequestOnItsPath(array $nodes, int $observePick): void + { + [$root] = Context::background()->withCancel(); + $contexts = []; + $parents = []; + $requested = []; + $terminated = []; + + foreach ($nodes as $i => [$pick, $requests]) { + $parent = $i === 0 ? -1 : $pick % $i; + [$context, $controller] = ($parent === -1 ? $root : $contexts[$parent])->withCancel(); + $context->onTerminate(static function () use (&$terminated, $i): void { + $terminated[] = $i; + }); + if ($requests) { + $controller->requestCancel(); + $requested[] = $i; + } + $contexts[] = $context; + $parents[] = $parent; + } + + $observed = $observePick % \count($contexts); + $path = []; + for ($node = $observed; $node !== -1; $node = $parents[$node]) { + array_unshift($path, $node); + } + $topmost = null; + foreach ($path as $node) { + if (\in_array($node, $requested, strict: true)) { + $topmost = $node; + + break; + } + } + + $contexts[$observed]->isTerminated(); + sort($terminated); + + Assert::same($terminated, $topmost === null ? [] : $this->subtree($parents, $topmost)); + + Classify::cover($topmost !== null, 'a request on the path', 20); + Classify::cover($topmost === null, 'no request on the path', 10); + Classify::when($topmost !== null && $topmost !== $observed, 'an ancestor requested'); + } + + /** @return array */ + public static function aCheckAppliesTheTopmostRequestOnItsPathGenerators(): array + { + return [ + 'nodes' => Gen::nonEmptyArrayOf( + Gen::tuple( + Gen::intBetween(0, 1_000), + Gen::frequency([[3, Gen::constant(value: false)], [1, Gen::constant(value: true)]]), + ), + 10, + ), + 'observePick' => Gen::intBetween(0, 1_000), + ]; + } + + /** @return iterable, int}> */ + public static function aCheckAppliesTheTopmostRequestOnItsPathExamples(): iterable + { + yield 'two requests on one path' => [[[0, true], [0, true], [1, false]], 2]; + yield 'a request on a sibling branch only' => [[[0, false], [0, true], [0, false]], 2]; + yield 'the observed node requested' => [[[0, false], [0, true]], 1]; + } + + /** + * @param list $parents + * + * @return list + */ + private function subtree(array $parents, int $root): array + { + $nodes = []; + foreach (array_keys($parents) as $node) { + $ancestor = $node; + while ($ancestor !== -1 && $ancestor !== $root) { + $ancestor = $parents[$ancestor]; + } + if ($ancestor === $root) { + $nodes[] = $node; + } + } + + return $nodes; + } + + private function pendingRequests(): int + { + $count = (new \ReflectionProperty(ContextState::class, 'pendingRequests'))->getValue(); + + return \is_int($count) ? $count : -1; + } +} diff --git a/tests/ContextSleepAndIterateTest.php b/tests/ContextSleepAndIterateTest.php new file mode 100644 index 0000000..d060e2d --- /dev/null +++ b/tests/ContextSleepAndIterateTest.php @@ -0,0 +1,352 @@ +sleep(1.5); + + Assert::same($clock->nowNs(), 2_500_000_000); + } + + public function theDeadlineCutsASleepShort(): void + { + $clock = new FakeTimeSource(); + [$context] = Context::background($clock)->withTimeout(1.0); + + try { + $context->sleep(5.0); + } catch (ContextExpiredException) { + Assert::same($clock->nowNs(), 2_000_000_000); + + return; + } + + throw new \LogicException('an expiry was expected'); + } + + public function aTerminatedContextDoesNotSleep(): void + { + $clock = new FakeTimeSource(); + [$context, $controller] = Context::background($clock)->withCancel(); + $controller->cancel(); + + try { + $context->sleep(1.0); + } catch (ContextCancelledException) { + Assert::same($clock->nowNs(), 1_000_000_000); + + return; + } + + throw new \LogicException('a cancellation was expected'); + } + + public function anOversizedSleepSaturatesAndStopsAtTheDeadline(): void + { + $clock = new FakeTimeSource(); + [$context] = Context::background($clock)->withTimeout(1.0); + + try { + $context->sleep(1e12); + } catch (ContextExpiredException) { + Assert::same($clock->nowNs(), 2_000_000_000); + + return; + } + + throw new \LogicException('an expiry was expected'); + } + + #[DataProvider('nonPositiveSleepProvider')] + public function aNonPositiveSleepOnlyChecks(float $seconds): void + { + $clock = new FakeTimeSource(); + + Context::background($clock)->sleep($seconds); + + Assert::same($clock->nowNs(), 1_000_000_000); + } + + public static function nonPositiveSleepProvider(): iterable + { + yield 'zero' => [0.0]; + yield 'negative' => [-3.0]; + } + + #[DataProvider('invalidSleepProvider')] + public function invalidSleepsAreRejected(float $seconds): void + { + Expect::exception(\InvalidArgumentException::class); + Context::background(new FakeTimeSource())->sleep($seconds); + } + + public static function invalidSleepProvider(): iterable + { + yield 'not a number' => [NAN]; + yield 'infinite' => [INF]; + } + + public function aSleeperThatWakesEarlyIsResumed(): void + { + $clock = new FakeTimeSource(); + $sleeper = new class ($clock) implements Sleeper { + public int $calls = 0; + + public function __construct(private readonly FakeTimeSource $clock) {} + + #[\Override] + public function sleepNs(int $nanoseconds): void + { + $this->calls++; + $this->clock->advanceNs(intdiv($nanoseconds, 2) + 1); + } + }; + + Context::background($clock, $sleeper)->sleep(1.0); + + Assert::true($sleeper->calls > 1); + Assert::true($clock->nowNs() >= 2_000_000_000); + } + + public function aCancellationRequestedDuringSleepEndsIt(): void + { + $clock = new FakeTimeSource(); + // Stands in for a signal handler that wakes usleep() early. + $sleeper = new class ($clock) implements Sleeper { + public ?ContextController $controller = null; + + public function __construct(private readonly FakeTimeSource $clock) {} + + #[\Override] + public function sleepNs(int $nanoseconds): void + { + $this->clock->advanceNs(intdiv($nanoseconds, 4)); + $this->controller?->requestCancel(new \RuntimeException('SIGTERM')); + } + }; + [$context, $sleeper->controller] = Context::background($clock, $sleeper)->withCancel(); + + try { + $context->sleep(4.0); + } catch (ContextCancelledException $exception) { + Assert::same($exception->getPrevious()?->getMessage(), 'SIGTERM'); + Assert::same($clock->nowNs(), 2_000_000_000); + + return; + } + + throw new \LogicException('a cancellation was expected'); + } + + public function theRealSleeperWaitsAtLeastTheDuration(): void + { + $started = hrtime(as_number: true); + + Context::background()->sleep(0.002); + + Assert::true(hrtime(as_number: true) - $started >= 2_000_000); + } + + public function theRealSleeperBlocks(): void + { + $started = hrtime(as_number: true); + + (new UsleepSleeper())->sleepNs(3_000_000); + + Assert::true(hrtime(as_number: true) - $started >= 2_000_000); + } + + public function aSleepRoundsToTheNearestNanosecond(): void + { + $clock = new FakeTimeSource(); + + Context::background($clock)->sleep(1.3e-9); + + Assert::same($clock->nowNs(), 1_000_000_001); + } + + public function theRealSleeperIgnoresNonPositiveDurations(): void + { + $started = hrtime(as_number: true); + + (new UsleepSleeper())->sleepNs(0); + (new UsleepSleeper())->sleepNs(-1_000_000_000); + + Assert::true(hrtime(as_number: true) - $started < 500_000_000); + } + + #[Property(runs: 300)] + public function aSleepStopsAtWhicheverComesFirst(int $sleepMs, int $deadlineMs): void + { + $clock = new FakeTimeSource(); + [$context] = Context::background($clock)->withTimeout($deadlineMs / 1000.0); + $expired = false; + + try { + $context->sleep($sleepMs / 1000.0); + } catch (ContextExpiredException) { + $expired = true; + } + + Assert::same($clock->nowNs() - 1_000_000_000, min($sleepMs, $deadlineMs) * 1_000_000); + Assert::same($expired, $deadlineMs <= $sleepMs); + + Classify::cover($expired, 'the deadline came first', 20); + Classify::cover(!$expired, 'the sleep completed', 20); + } + + /** @return array */ + public static function aSleepStopsAtWhicheverComesFirstGenerators(): array + { + return [ + 'sleepMs' => Gen::intBetween(0, 3_000), + 'deadlineMs' => Gen::intBetween(1, 3_000), + ]; + } + + /** @return iterable */ + public static function aSleepStopsAtWhicheverComesFirstExamples(): iterable + { + yield 'the deadline exactly at the end of the sleep' => [1_000, 1_000]; + yield 'one millisecond to spare' => [999, 1_000]; + yield 'a zero sleep' => [0, 1]; + } + + public function iteratingKeepsKeysIncludingDuplicates(): void + { + $source = (static function (): \Generator { + yield 'a' => 1; + yield 'a' => 2; + yield 7 => 3; + })(); + $seen = []; + + foreach (Context::background()->iterate($source) as $key => $value) { + $seen[] = [$key, $value]; + } + + Assert::same($seen, [['a', 1], ['a', 2], [7, 3]]); + } + + public function iteratingAcceptsArraysAndAggregates(): void + { + $aggregate = new \ArrayObject(['x' => 1, 'y' => 2]); + + Assert::same(iterator_to_array(Context::background()->iterate(['p' => 1, 'q' => 2])), ['p' => 1, 'q' => 2]); + Assert::same(iterator_to_array(Context::background()->iterate($aggregate)), ['x' => 1, 'y' => 2]); + } + + public function anEmptyIterationStillChecks(): void + { + [$context, $controller] = Context::background()->withCancel(); + $controller->cancel(); + + Expect::exception(ContextCancelledException::class); + foreach ($context->iterate([]) as $unused) { + throw new \LogicException('nothing to iterate'); + } + } + + public function anInvalidCheckIntervalIsRejectedBeforeIterating(): void + { + Expect::exception(\InvalidArgumentException::class); + Context::background()->iterate([1], every: 0); + } + + public function aCheckIntervalSkipsChecksBetweenElements(): void + { + $clock = new FakeTimeSource(); + [$context] = Context::background($clock)->withTimeout(0.0025); + $processed = []; + + try { + foreach ($context->iterate([1, 2, 3, 4, 5, 6], every: 3) as $item) { + $processed[] = $item; + $clock->advanceMs(1); + } + } catch (ContextExpiredException) { + Assert::same($processed, [1, 2, 3]); + + return; + } + + throw new \LogicException('an expiry was expected'); + } + + #[Property(runs: 300)] + public function iterationStopsBeforePullingPastTheDeadline(int $items, int $deadlineMs): void + { + $clock = new FakeTimeSource(); + [$context] = Context::background($clock)->withTimeout($deadlineMs / 1000.0); + $pulled = 0; + $source = (static function () use ($items, &$pulled): \Generator { + for ($i = 1; $i <= $items; $i++) { + $pulled++; + + yield $i; + } + })(); + $processed = 0; + $expired = false; + + try { + foreach ($context->iterate($source) as $item) { + $processed++; + $clock->advanceMs(1); + } + } catch (ContextExpiredException) { + $expired = true; + } + + Assert::same($processed, min($items, $deadlineMs)); + Assert::same($pulled, $processed); + Assert::same($expired, $deadlineMs <= $items); + + Classify::cover($expired, 'stopped by the deadline', 20); + Classify::cover(!$expired, 'iterated everything', 20); + } + + /** @return array */ + public static function iterationStopsBeforePullingPastTheDeadlineGenerators(): array + { + return [ + 'items' => Gen::intBetween(0, 20), + 'deadlineMs' => Gen::intBetween(0, 25), + ]; + } + + /** @return iterable */ + public static function iterationStopsBeforePullingPastTheDeadlineExamples(): iterable + { + yield 'expired before the first element' => [5, 0]; + yield 'the deadline at the last element' => [5, 5]; + yield 'an empty source' => [0, 3]; + } +} diff --git a/tests/ContextTest.php b/tests/ContextTest.php index 41c7cee..f33fd89 100644 --- a/tests/ContextTest.php +++ b/tests/ContextTest.php @@ -7,7 +7,7 @@ use Rasuvaeff\Context\Context; use Rasuvaeff\Context\ContextCancelledException; use Rasuvaeff\Context\ContextExpiredException; -use Rasuvaeff\Context\Tests\Support\FakeTimeSource; +use Rasuvaeff\Context\Testing\FakeTimeSource; use Rasuvaeff\Context\Tests\Support\RequestId; use Rasuvaeff\PropertyTesting\ArbitraryInterface; use Rasuvaeff\PropertyTesting\Classify; diff --git a/tests/Support/FakeTimeSource.php b/tests/Support/FakeTimeSource.php deleted file mode 100644 index c670429..0000000 --- a/tests/Support/FakeTimeSource.php +++ /dev/null @@ -1,25 +0,0 @@ - $nowNs */ - public function __construct(private int $nowNs = 1_000_000_000) {} - - #[\Override] - public function nowNs(): int - { - return $this->nowNs; - } - - /** @param int<0, max> $nanoseconds */ - public function advanceNs(int $nanoseconds): void - { - $this->nowNs += $nanoseconds; - } -} diff --git a/tests/Support/Locale.php b/tests/Support/Locale.php new file mode 100644 index 0000000..5a8c417 --- /dev/null +++ b/tests/Support/Locale.php @@ -0,0 +1,29 @@ +value; + } + + #[\Override] + public static function fromWire(string $wire): static + { + return new self($wire); + } +} diff --git a/tests/Support/StateInspector.php b/tests/Support/StateInspector.php new file mode 100644 index 0000000..00c763d --- /dev/null +++ b/tests/Support/StateInspector.php @@ -0,0 +1,73 @@ +getValue($context); + if (!$state instanceof ContextState) { + throw new \LogicException('Context has no state'); + } + + return $state; + } + + public static function childCount(Context $context): int + { + return \count(self::children(self::state($context))); + } + + public static function parentCount(Context $context): int + { + $parents = (new \ReflectionProperty(ContextState::class, 'parents'))->getValue(self::state($context)); + + return \is_array($parents) ? \count($parents) : 0; + } + + /** + * True when an active node still references a terminated child. + * + * @param list $contexts + */ + public static function retainsTerminatedChild(array $contexts): bool + { + foreach ($contexts as $context) { + $state = self::state($context); + if ($state->cause() instanceof \Rasuvaeff\Context\TerminationCause) { + continue; + } + + foreach (self::children($state) as $child) { + if ($child->cause() !== null) { + return true; + } + } + } + + return false; + } + + /** @return list */ + private static function children(ContextState $state): array + { + $children = (new \ReflectionProperty(ContextState::class, 'children'))->getValue($state); + $result = []; + foreach (\is_array($children) ? $children : [] as $child) { + if ($child instanceof ContextState) { + $result[] = $child; + } + } + + return $result; + } +} diff --git a/tests/Support/TenantId.php b/tests/Support/TenantId.php new file mode 100644 index 0000000..a95fe25 --- /dev/null +++ b/tests/Support/TenantId.php @@ -0,0 +1,29 @@ +value; + } + + #[\Override] + public static function fromWire(string $wire): static + { + return new self($wire); + } +} diff --git a/tests/Testing/TestingClocksTest.php b/tests/Testing/TestingClocksTest.php new file mode 100644 index 0000000..680cb02 --- /dev/null +++ b/tests/Testing/TestingClocksTest.php @@ -0,0 +1,82 @@ +nowNs(), 5_000); + Assert::same($clock->nowNs(), 5_000); + + $clock->advanceNs(10); + $clock->advanceMs(1.5); + $clock->sleepNs(7); + + Assert::same($clock->nowNs(), 5_000 + 10 + 1_500_000 + 7); + } + + public function fractionalMillisecondsRoundToTheNearestNanosecond(): void + { + $clock = new FakeTimeSource(0); + + $clock->advanceMs(0.000_000_4); + Assert::same($clock->nowNs(), 0); + + $clock->advanceMs(1.000_000_6); + Assert::same($clock->nowNs(), 1_000_001); + } + + public function aFakeClockNeverMovesBackwards(): void + { + $clock = new FakeTimeSource(5_000); + + $clock->advanceNs(-10); + $clock->advanceMs(-1); + $clock->sleepNs(-1); + + Assert::same($clock->nowNs(), 5_000); + } + + public function aFakeClockDrivesBothDeadlinesAndSleep(): void + { + $clock = new FakeTimeSource(); + [$context] = Context::background($clock)->withTimeout(10.0); + + $context->sleep(2.5); + + Assert::same($clock->nowNs(), 3_500_000_000); + Assert::same($context->remainingMs(), 7_500.0); + } + + public function aSteppingClockMovesOnEveryRead(): void + { + $clock = new SteppingTimeSource(100, 1_000); + + Assert::same($clock->nowNs(), 1_000); + Assert::same($clock->nowNs(), 1_100); + Assert::same($clock->nowNs(), 1_200); + } + + public function aZeroStepKeepsTheClockStill(): void + { + $clock = new SteppingTimeSource(0); + + Assert::same($clock->nowNs(), 1_000_000_000); + Assert::same($clock->nowNs(), 1_000_000_000); + } +} diff --git a/tests/WireCodecTest.php b/tests/WireCodecTest.php new file mode 100644 index 0000000..45650c1 --- /dev/null +++ b/tests/WireCodecTest.php @@ -0,0 +1,259 @@ +withTimeout(2.0); + $context = $context + ->withValue(TenantId::class, new TenantId('acme')) + ->withValue(RequestId::class, new RequestId('not-on-the-wire')); + $clock->advanceMs(250); + + Assert::same($this->codec()->encode($context), [ + 'budget_ms' => 1_750, + 'values' => ['tenant' => 'acme'], + ]); + } + + public function aMissingBudgetNeverLiftsTheLimit(): void + { + [$context] = $this->codec()->decode(['values' => ['tenant' => 'acme']], Context::background(new FakeTimeSource()), maxBudgetMs: 2_000); + + Assert::same($context->remainingMs(), 2_000.0); + } + + public function encodesNoBudgetWithoutADeadline(): void + { + Assert::same($this->codec()->encode(Context::background()), ['budget_ms' => null, 'values' => []]); + } + + public function refusesToEncodeATerminatedContext(): void + { + [$context, $controller] = Context::background()->withCancel(); + $controller->cancel(); + + Expect::exception(ContextCancelledException::class); + $this->codec()->encode($context); + } + + public function decodesValuesAndAShorterBudget(): void + { + $clock = new FakeTimeSource(); + $wire = ['budget_ms' => 800, 'values' => ['tenant' => 'acme', 'locale' => 'de', 'unknown' => 'x']]; + + [$context, $controller] = $this->codec()->decode($wire, Context::background($clock), maxBudgetMs: 5_000); + + Assert::same($context->remainingMs(), 800.0); + Assert::same($context->value(TenantId::class)?->value, 'acme'); + Assert::same($context->value(Locale::class)?->value, 'de'); + + $controller->cancel(); + + Assert::true($context->isCancelled()); + } + + public function theIncomingBudgetNeverExceedsTheLimitOrTheParent(): void + { + $clock = new FakeTimeSource(); + [$parent] = Context::background($clock)->withTimeout(0.3); + + [$capped] = $this->codec()->decode(['budget_ms' => 900_000], Context::background($clock), maxBudgetMs: 2_000); + [$byParent] = $this->codec()->decode(['budget_ms' => 900_000], $parent, maxBudgetMs: 2_000); + + Assert::same($capped->remainingMs(), 2_000.0); + Assert::same($byParent->remainingMs(), 300.0); + } + + public function theLargestBudgetIsAccepted(): void + { + [$context] = $this->codec()->decode(['budget_ms' => 999_999_999], Context::background(new FakeTimeSource()), maxBudgetMs: 1_000); + + Assert::same($context->remainingMs(), 1_000.0); + } + + public function aMissingValueDoesNotStopTheOthers(): void + { + [$context] = $this->codec()->decode(['values' => ['locale' => 'de']], Context::background(), maxBudgetMs: 1_000); + + Assert::null($context->value(TenantId::class)); + Assert::same($context->value(Locale::class)?->value, 'de'); + } + + public function aDigitStringBudgetIsAccepted(): void + { + [$context] = $this->codec()->decode(['budget_ms' => '450'], Context::background(new FakeTimeSource()), maxBudgetMs: 5_000); + + Assert::same($context->remainingMs(), 450.0); + } + + #[DataProvider('malformedBudgetProvider')] + public function aMalformedBudgetFallsBackToTheLimit(mixed $budget): void + { + [$context] = $this->codec()->decode(['budget_ms' => $budget], Context::background(new FakeTimeSource()), maxBudgetMs: 5_000); + + Assert::same($context->remainingMs(), 5_000.0); + Assert::false($context->isTerminated()); + } + + public static function malformedBudgetProvider(): iterable + { + yield 'zero' => [0]; + yield 'negative' => [-5]; + yield 'too large' => [1_000_000_000]; + yield 'float' => [12.5]; + yield 'trailing newline' => ["12\n"]; + yield 'letters' => ['12a']; + yield 'leading space' => [' 12']; + yield 'too many digits' => ['1234567890']; + yield 'array' => [[1]]; + yield 'null' => [null]; + } + + public function anInvalidValueIsDroppedAndReported(): void + { + $reported = []; + $codec = new WireCodec( + ['tenant' => TenantId::class, 'locale' => Locale::class], + static function (string $alias, \Throwable $error) use (&$reported): void { + $reported[] = [$alias, $error->getMessage()]; + }, + ); + + [$context] = $codec->decode(['values' => ['tenant' => "acme\n", 'locale' => 'de']], Context::background(), maxBudgetMs: 1_000); + + Assert::null($context->value(TenantId::class)); + Assert::same($context->value(Locale::class)?->value, 'de'); + Assert::same($reported, [['tenant', "Invalid tenant id \"acme\n\""]]); + } + + #[DataProvider('malformedWireProvider')] + public function malformedInputDecodesToTheLimitWithoutValues(mixed $wire): void + { + [$context] = $this->codec()->decode($wire, Context::background(new FakeTimeSource()), maxBudgetMs: 1_000); + + Assert::same($context->remainingMs(), 1_000.0); + Assert::null($context->value(TenantId::class)); + } + + public static function malformedWireProvider(): iterable + { + yield 'not an array' => ['tenant=acme']; + yield 'values not an array' => [['values' => 'acme']]; + yield 'value not a string' => [['values' => ['tenant' => ['acme']]]]; + yield 'list instead of map' => [['values' => ['acme']]]; + } + + #[DataProvider('invalidAliasProvider')] + public function invalidAliasesAreRejected(string $alias): void + { + Expect::exception(\InvalidArgumentException::class); + new WireCodec([$alias => TenantId::class]); + } + + public static function invalidAliasProvider(): iterable + { + yield 'empty' => ['']; + yield 'space' => ['ten ant']; + yield 'trailing newline' => ["tenant\n"]; + yield 'too long' => [str_repeat('a', 65)]; + yield 'baggage separator' => ['tenant,x']; + } + + public function aClassThatIsNotAWireValueIsRejected(): void + { + Expect::exception(\InvalidArgumentException::class); + new WireCodec(['request' => RequestId::class]); + } + + #[Property(runs: 300)] + public function aRoundTripKeepsValuesAndNeverExtendsTheBudget(string $tenant, ?string $locale, ?int $budgetMs): void + { + $clock = new FakeTimeSource(); + $source = Context::background($clock); + if ($budgetMs !== null) { + [$source] = $source->withTimeout($budgetMs / 1000.0); + } + $source = $source->withValue(TenantId::class, new TenantId($tenant)); + if ($locale !== null) { + $source = $source->withValue(Locale::class, new Locale($locale)); + } + + $wire = json_decode(json_encode($this->codec()->encode($source), \JSON_THROW_ON_ERROR), associative: true, flags: \JSON_THROW_ON_ERROR); + [$decoded] = $this->codec()->decode($wire, Context::background($clock), maxBudgetMs: 60_000); + + Assert::same($decoded->value(TenantId::class)?->value, $tenant); + Assert::same($decoded->value(Locale::class)?->value, $locale); + Assert::same($decoded->remainingMs(), $source->remainingMs() ?? 60_000.0); + + Classify::cover($budgetMs !== null, 'with a budget', 20); + Classify::cover($locale === null, 'without a locale', 10); + } + + /** @return array */ + public static function aRoundTripKeepsValuesAndNeverExtendsTheBudgetGenerators(): array + { + return [ + 'tenant' => Gen::regex('[a-z0-9-]{1,32}'), + 'locale' => Gen::nullable(Gen::regex('[a-z]{2}')), + 'budgetMs' => Gen::nullable(Gen::intBetween(1, 60_000)), + ]; + } + + #[Property(runs: 300)] + public function decodingArbitraryInputNeverThrows(mixed $wire): void + { + [$context] = $this->codec()->decode($wire, Context::background(), maxBudgetMs: 1_000); + + Assert::false($context->isTerminated()); + } + + /** @return array */ + public static function decodingArbitraryInputNeverThrowsGenerators(): array + { + $scalar = Gen::frequency([ + [2, Gen::stringAscii()], + [1, Gen::int()], + [1, Gen::constant(value: null)], + ]); + + return [ + 'wire' => Gen::frequency([ + [1, Gen::json()], + [3, Gen::record([ + 'budget_ms' => $scalar, + 'values' => Gen::dictOf(Gen::elements(['tenant', 'locale', 'other']), $scalar), + ])], + ]), + ]; + } + + private function codec(): WireCodec + { + return new WireCodec(['tenant' => TenantId::class, 'locale' => Locale::class]); + } +}