Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
120 changes: 112 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -72,13 +74,30 @@ $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
`withDeadline()` take the earlier of the two. A zero or negative timeout is
an immediate deadline; an oversized one saturates at `PHP_INT_MAX` instead of
overflowing.

- **`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
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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` |
Expand All @@ -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

Expand Down
Loading
Loading