diff --git a/.github/.release-please-config.json b/.github/.release-please-config.json index 740af07..77d8757 100644 --- a/.github/.release-please-config.json +++ b/.github/.release-please-config.json @@ -112,6 +112,12 @@ "component": "bridge-rector", "include-component-in-tag": true, "changelog-path": "CHANGELOG.md" + }, + "bridge/revolt": { + "package-name": "testo/bridge-revolt", + "component": "bridge-revolt", + "include-component-in-tag": true, + "changelog-path": "CHANGELOG.md" } }, "bump-patch-for-minor-pre-major": true, diff --git a/.github/workflows/split-publish.yml b/.github/workflows/split-publish.yml index 914d4fc..49dc9ea 100644 --- a/.github/workflows/split-publish.yml +++ b/.github/workflows/split-publish.yml @@ -22,6 +22,7 @@ on: # yamllint disable-line rule:truthy - 'bridge-infection-[0-9]*' - 'bridge-mockery-[0-9]*' - 'bridge-rector-[0-9]*' + - 'bridge-revolt-[0-9]*' - 'bridge-symfony-console-[0-9]*' - 'codecov-[0-9]*' - 'convention-[0-9]*' diff --git a/bridge/mockery/src/Internal/MockeryInterceptor.php b/bridge/mockery/src/Internal/MockeryInterceptor.php index ff2a0d0..18ca1af 100644 --- a/bridge/mockery/src/Internal/MockeryInterceptor.php +++ b/bridge/mockery/src/Internal/MockeryInterceptor.php @@ -8,6 +8,7 @@ use Testo\Assert\Internal\StaticState; use Testo\Assert\State\Expectation\ExpectationFailed; use Testo\Assert\State\Expectation\ExpectationFulfilled; +use Testo\Bridge\Mockery\MockeryConcurrencyException; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Value\Status; @@ -22,6 +23,14 @@ * * Runs innermost so the teardown fires as close as possible to the test function. * + * Mockery's mock container is process-global static state, so — unlike Testo's own fiber-local guards — + * it cannot be isolated per fiber. This guard therefore serializes: it runs the test directly (no fiber + * trampoline, so it cooperates with a real event loop) and, if another Mockery test is already in flight, + * refuses to start with a {@see MockeryConcurrencyException}. That only happens under interleaving + * ({@see \Testo\Fiber\Schedule::RoundRobin}/`Random`, {@see \Testo\Bridge\Revolt\Strategy::PerCase}), + * where siblings would clobber each other's mocks. One-at-a-time execution — `Solo`/`PerTest` or no + * fibers — is fully supported, including across a real event-loop suspension. + * * @internal * @psalm-internal Testo\Bridge\Mockery */ @@ -29,15 +38,26 @@ order: InterceptorOptions::ORDER_CLOSE_TO_TEST, testType: TestType::Test, )] -final readonly class MockeryInterceptor implements TestRunInterceptor +final class MockeryInterceptor implements TestRunInterceptor { + /** + * Whether a Mockery-guarded test is currently running. The container is process-global, so a second + * test starting while this is set means two tests are interleaving over the same container. + */ + private static bool $running = false; + #[\Override] public function runTest(TestInfo $info, callable $next): TestResult { + self::$running and throw new MockeryConcurrencyException(); + self::$running = true; + $result = null; try { - $result = $this->run($info, $next); + $result = $next($info); } finally { + self::$running = false; + # close() clears the container, so read the count first. $verified = \Mockery::getContainer()->mockery_getExpectationCount(); try { @@ -100,42 +120,4 @@ private static function reportFailedExpectation(\Throwable $e): ?ExpectationFail return $failure; } - - /** - * Run the test, keeping the global Mockery container bound to it across fiber suspensions. - * - * The container is process-global static state, so under concurrent (fiber-based) execution - * sibling tests would clobber each other's mocks. On every suspension we save this test's - * container and reset the global one; on resumption we restore it — the same scope-guarding - * dance as {@see \Testo\Application\Internal\MessengerHub::scope()}. - * - * @param callable(TestInfo): TestResult $next - */ - private function run(TestInfo $info, callable $next): TestResult - { - if (\Fiber::getCurrent() === null) { - return $next($info); - } - - $fiber = new \Fiber(static fn(): TestResult => $next($info)); - $value = $fiber->start(); - while (!$fiber->isTerminated()) { - $container = \Mockery::getContainer(); - \Mockery::resetContainer(); - try { - $resume = \Fiber::suspend($value); - } catch (\Throwable $e) { - \Mockery::setContainer($container); - $value = $fiber->throw($e); - continue; - } - - \Mockery::setContainer($container); - $value = $fiber->resume($resume); - } - - /** @var TestResult $result */ - $result = $fiber->getReturn(); - return $result; - } } diff --git a/bridge/mockery/src/MockeryConcurrencyException.php b/bridge/mockery/src/MockeryConcurrencyException.php new file mode 100644 index 0000000..299d526 --- /dev/null +++ b/bridge/mockery/src/MockeryConcurrencyException.php @@ -0,0 +1,29 @@ +status, Status::Passed); } + public function interleavedMockeryTestsAreRejected(): void + { + // Under Schedule::RoundRobin the two scenario tests interleave: the first parks mid-mock, so the + // second starts while the global container is still the first's. That second test must abort with + // a MockeryConcurrencyException rather than silently clobbering the first's mocks; the first, + // running one at a time from its own point of view, still passes. + $second = TestRunner::runTest([MockeryConcurrencyScenarios::class, 'secondMockAcrossSuspend']); + Assert::same($second->status, Status::Aborted); + + $first = TestRunner::runTest([MockeryConcurrencyScenarios::class, 'firstMockAcrossSuspend']); + Assert::same($first->status, Status::Passed); + } + /** * Whether the test's assertion history holds a record with the given success flag — i.e. the * bridge reported the mock verification (fulfilled or failed) to the Assert plugin. diff --git a/bridge/mockery/tests/Self/MockeryUnderFibersTest.php b/bridge/mockery/tests/Self/MockeryUnderFibersTest.php new file mode 100644 index 0000000..b62f8b8 --- /dev/null +++ b/bridge/mockery/tests/Self/MockeryUnderFibersTest.php @@ -0,0 +1,48 @@ +expects('count')->once()->andReturn(4); + + Assert::same($mock->count(), 4); + } + + #[RunInRevolt] + public function mockVerifiesAcrossARevoltAwait(): void + { + /** @var \Mockery\MockInterface&\Countable $mock */ + $mock = \Mockery::mock(\Countable::class); + $mock->expects('count')->once()->andReturn(7); + + $suspension = EventLoop::getSuspension(); + EventLoop::delay(0.001, static fn() => $suspension->resume()); + $suspension->suspend(); + + Assert::same($mock->count(), 7); + } +} diff --git a/bridge/mockery/tests/Stub/MockeryConcurrencyScenarios.php b/bridge/mockery/tests/Stub/MockeryConcurrencyScenarios.php new file mode 100644 index 0000000..273b1f4 --- /dev/null +++ b/bridge/mockery/tests/Stub/MockeryConcurrencyScenarios.php @@ -0,0 +1,44 @@ +expects('count')->once()->andReturn(1); + + \Fiber::suspend(); + + $mock->count(); + } + + public function secondMockAcrossSuspend(): void + { + /** @var MockInterface&\Countable $mock */ + $mock = \Mockery::mock(\Countable::class); + $mock->expects('count')->once()->andReturn(2); + + \Fiber::suspend(); + + $mock->count(); + } +} diff --git a/bridge/revolt/README.md b/bridge/revolt/README.md new file mode 100644 index 0000000..f133d9b --- /dev/null +++ b/bridge/revolt/README.md @@ -0,0 +1,60 @@ +

+ TESTO +

+ +

Revolt event-loop bridge

+ +
+ +[![Documentation](https://img.shields.io/badge/Documentation-blue?style=for-the-badge&logo=gitbook&logoColor=white)](https://php-testo.github.io) +[![Support on Boosty](https://img.shields.io/static/v1?style=for-the-badge&label=&message=Sponsorship&logo=Boosty&logoColor=white&color=%23F15F2C)](https://boosty.to/roxblnfk) + +
+ +
+ +> [!IMPORTANT] +> ## 🪞 This is a read-only mirror. +> +> Active development of the Testo project lives in [**php-testo/testo**](https://github.com/php-testo/testo) under `bridge/revolt/`. This repository is **automatically synchronized** from there on every release. +> +> File issues and pull requests in the [main monorepo](https://github.com/php-testo/testo/issues), not here. + +## About + +Runs a test on the process-global [Revolt](https://revolt.run) event loop, so the test body may `await` real async work (timers, streams, `Future::await()`, amphp libraries) and resume without blocking the process. + +- `#[RunInRevolt]` — run a test on the Revolt event loop for real async I/O. Applied to a class, it drives every test in the case. + +Suspension must go through a Revolt `Suspension` bound to a watcher (I/O, timer) — a bare `\Fiber::suspend()` has no resumer on the loop (that is the plain-fiber `#[RunInFiber]` from `testo/fiber`, a different concern). + +### Strategy + +`#[RunInRevolt]` takes a `Strategy` that chooses when the loop is entered: + +- `Strategy::PerTest` (default) — each test's whole pipeline is placed on the loop individually, one test at a time, each run to completion before the next. No interleaving. +- `Strategy::PerCase` — launches the whole case's tests **concurrently** on one loop run via `CaseInfo`'s batch runner, interleaving at their await points. + +Either way the whole per-test pipeline runs inside the loop fiber. Testo's scoped-state guards (assertion collector, messenger) hold their state per fiber, so concurrent tests stay isolated — each reads its own assertion/messenger state across every switch. + +```php +use Testo\Bridge\Revolt\RunInRevolt; +use Testo\Bridge\Revolt\Strategy; + +#[RunInRevolt(Strategy::PerTest)] +final class TimersTest { /* ... */ } +``` + +## Install + +```bash +composer require --dev testo/bridge-revolt +``` + +[![PHP](https://img.shields.io/packagist/php-v/testo/bridge-revolt.svg?style=flat-square&logo=php)](https://packagist.org/packages/testo/bridge-revolt) +[![Latest Version on Packagist](https://img.shields.io/packagist/v/testo/bridge-revolt.svg?style=flat-square&logo=packagist)](https://packagist.org/packages/testo/bridge-revolt) +[![License](https://img.shields.io/packagist/l/testo/bridge-revolt.svg?style=flat-square)](https://github.com/php-testo/testo/blob/1.x/LICENSE.md) +[![Total Downloads](https://img.shields.io/packagist/dt/testo/bridge-revolt.svg?style=flat-square)](https://packagist.org/packages/testo/bridge-revolt/stats) diff --git a/bridge/revolt/composer.json b/bridge/revolt/composer.json new file mode 100644 index 0000000..eee6657 --- /dev/null +++ b/bridge/revolt/composer.json @@ -0,0 +1,36 @@ +{ + "name": "testo/bridge-revolt", + "description": "Revolt event-loop bridge for the Testo testing framework: run tests on the real event loop for async I/O.", + "license": "BSD-3-Clause", + "type": "library", + "keywords": [ + "testo", + "revolt", + "event-loop", + "async", + "amphp" + ], + "authors": [ + { + "name": "Aleksei Gagarin (roxblnfk)", + "homepage": "https://github.com/roxblnfk" + } + ], + "require": { + "php": ">=8.2", + "revolt/event-loop": "^1.0", + "testo/testo": "0.10.20 - 1" + }, + "autoload": { + "psr-4": { + "Testo\\Bridge\\Revolt\\": "src/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + } +} diff --git a/bridge/revolt/src/Internal/RevoltTestBatchRunner.php b/bridge/revolt/src/Internal/RevoltTestBatchRunner.php new file mode 100644 index 0000000..8f44770 --- /dev/null +++ b/bridge/revolt/src/Internal/RevoltTestBatchRunner.php @@ -0,0 +1,117 @@ + 0; + } + + /** + * Runs a single handler as a coroutine on the loop and blocks the current fiber until it returns. + * + * @param callable(): TestResult $handler + */ + public static function runOnLoop(callable $handler): TestResult + { + $suspension = EventLoop::getSuspension(); + + EventLoop::queue(static function () use ($suspension, $handler): void { + ++self::$depth; + try { + $suspension->resume($handler()); + } catch (\Throwable $e) { + $suspension->throw($e); + } finally { + --self::$depth; + } + }); + + /** @var TestResult */ + return $suspension->suspend(); + } + + /** + * Launches every handler as its own coroutine on the loop at once and blocks the current fiber + * until all of them finish, preserving input order in the result. + * + * @param list $handlers + * @return list + */ + public function __invoke(array $handlers): array + { + if ($handlers === []) { + return []; + } + + $suspension = EventLoop::getSuspension(); + $results = []; + $errors = []; + $pending = \count($handlers); + + foreach ($handlers as $i => $handler) { + EventLoop::queue(static function () use ($i, $handler, $suspension, &$results, &$errors, &$pending): void { + ++self::$depth; + try { + $results[$i] = $handler(); + } catch (\Throwable $e) { + $errors[$i] = $e; + } finally { + --self::$depth; + --$pending === 0 and $suspension->resume(); + } + }); + } + + $suspension->suspend(); + + // Safety net: the loop must not unwind before every test finished. If a handler ever strands its + // loop fiber (a bare suspend with no resumer), this suspension would return with tests missing — + // fail loudly rather than silently dropping tests (a green run that ran nothing is the worst + // outcome). With the fiber-local guards in place this never fires in practice. + $done = \count($results) + \count($errors); + $done === \count($handlers) or throw new \RuntimeException(\sprintf( + 'Revolt PerCase lost %d of %d test(s): the event loop unwound before they finished.', + \count($handlers) - $done, + \count($handlers), + )); + + $errors === [] or throw \reset($errors); + + \ksort($results); + + return \array_values($results); + } +} diff --git a/bridge/revolt/src/Internal/RunInRevoltInterceptor.php b/bridge/revolt/src/Internal/RunInRevoltInterceptor.php new file mode 100644 index 0000000..9f45515 --- /dev/null +++ b/bridge/revolt/src/Internal/RunInRevoltInterceptor.php @@ -0,0 +1,72 @@ +options->strategy === Strategy::PerCase + ? $next($info->withBatchRunner(new RevoltTestBatchRunner())) + : $next($info); + } + + #[\Override] + public function runTest(TestInfo $info, callable $next): TestResult + { + // A case-level runner already drove us onto the loop (Strategy::PerCase) — just run the pipeline. + if (RevoltTestBatchRunner::active()) { + return $next($info); + } + + // Strategy::PerTest (or a method-level attribute): put this test's whole pipeline on the loop, so + // the fiber-local guards inner to us run in the same fiber as the test body. + return RevoltTestBatchRunner::runOnLoop(static fn(): TestResult => $next($info)); + } +} diff --git a/bridge/revolt/src/RunInRevolt.php b/bridge/revolt/src/RunInRevolt.php new file mode 100644 index 0000000..c17ef64 --- /dev/null +++ b/bridge/revolt/src/RunInRevolt.php @@ -0,0 +1,48 @@ + $suspension->resume('ready')); + * Assert::same($suspension->suspend(), 'ready'); // the loop runs while we wait + * } + * ``` + * + * Unlike the plain-fiber `#[RunInFiber]` (the `testo/fiber` plugin, where Testo's own scheduler drives + * the fiber), here the **Revolt loop** owns the fiber, so suspension must go through a Revolt + * `Suspension` bound to a watcher (I/O, timer) — a bare `\Fiber::suspend()` has no resumer on the loop. + * + * The {@see Strategy} chooses when the loop is entered — {@see Strategy::PerTest} (default, one test on + * the loop at a time, each run to completion) or {@see Strategy::PerCase} (launches the whole case's tests + * concurrently on one loop run, interleaving at their await points). Either way the whole per-test + * pipeline runs inside the loop fiber; Testo's scoped-state guards hold their state per fiber (see + * {@see \Internal\Fiber\FiberLocal}), so concurrent tests stay isolated. + * + * @api + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] +#[FallbackInterceptor(RunInRevoltInterceptor::class)] +final readonly class RunInRevolt implements Interceptable +{ + public function __construct( + public Strategy $strategy = Strategy::PerTest, + ) {} +} diff --git a/bridge/revolt/src/Strategy.php b/bridge/revolt/src/Strategy.php new file mode 100644 index 0000000..4af5e60 --- /dev/null +++ b/bridge/revolt/src/Strategy.php @@ -0,0 +1,31 @@ + $suspension->resume()); + $suspension->suspend(); + + Assert::same(1, 1); + } + + public function secondAwaitsATimer(): void + { + $suspension = EventLoop::getSuspension(); + EventLoop::delay(0.001, static fn() => $suspension->resume()); + $suspension->suspend(); + + Assert::same(2, 2); + } +} diff --git a/bridge/revolt/tests/Self/RunInRevoltTest.php b/bridge/revolt/tests/Self/RunInRevoltTest.php new file mode 100644 index 0000000..a6c3aec --- /dev/null +++ b/bridge/revolt/tests/Self/RunInRevoltTest.php @@ -0,0 +1,36 @@ + $suspension->resume('resumed')); + + Assert::same($suspension->suspend(), 'resumed'); + } +} diff --git a/bridge/revolt/tests/Unit/RunInRevoltInterceptorTest.php b/bridge/revolt/tests/Unit/RunInRevoltInterceptorTest.php new file mode 100644 index 0000000..33ebb69 --- /dev/null +++ b/bridge/revolt/tests/Unit/RunInRevoltInterceptorTest.php @@ -0,0 +1,67 @@ +runTestCase($this->caseInfo(), $next); + + Assert::notNull($captured?->batchRunner); + } + + public function perTestStrategyLeavesTheCaseRunnerUnset(): void + { + $captured = null; + $next = static function (CaseInfo $info) use (&$captured): CaseResult { + $captured = $info; + return new CaseResult(results: [], status: Status::Passed); + }; + + (new RunInRevoltInterceptor(new RunInRevolt(Strategy::PerTest))) + ->runTestCase($this->caseInfo(), $next); + + Assert::same(null, $captured?->batchRunner); + } + + public function defaultStrategyIsPerTest(): void + { + Assert::same(Strategy::PerTest, (new RunInRevolt())->strategy); + } + + private function caseInfo(): CaseInfo + { + return new CaseInfo(definition: new CaseDefinition(name: 'RevoltCase', type: 'test')); + } +} diff --git a/bridge/revolt/tests/suites.php b/bridge/revolt/tests/suites.php new file mode 100644 index 0000000..8829cb6 --- /dev/null +++ b/bridge/revolt/tests/suites.php @@ -0,0 +1,24 @@ +dispatcher, $this, $holdEvents); + # A fork belongs to the same test as its parent, so it inherits the parent's identity. + return new self($this->dispatcher, $this, $holdEvents, $this->identity); } #[\Override] @@ -114,7 +117,7 @@ private function dispatch(Message $message): void { $this->suspended = true; try { - $this->dispatcher->dispatch(new MessageReceived($message)); + $this->dispatcher->dispatch(new MessageReceived($message, $this->identity)); } finally { $this->suspended = false; } diff --git a/core/Application/Internal/MessengerHub.php b/core/Application/Internal/MessengerHub.php index a752e40..35d4c4a 100644 --- a/core/Application/Internal/MessengerHub.php +++ b/core/Application/Internal/MessengerHub.php @@ -5,10 +5,11 @@ namespace Testo\Application\Internal; use Psr\EventDispatcher\EventDispatcherInterface; +use Internal\Fiber\FiberLocal; use Testo\Application\Internal\Messenger\State; -use Testo\Application\Internal\Messenger\MutableContainer; use Testo\Common\Messenger; use Testo\Common\Messenger\Channel; +use Testo\Core\Context\TestIdentity; use Testo\Core\Log\Level; use Testo\Core\Log\Message; use Testo\Core\Log\MessageLog; @@ -20,16 +21,21 @@ * the state/scope model of {@see \Internal\Container\ObjectContainer}: each scope owns an * isolated message buffer, while the {@see MessageReceived} event stream stays global. * + * The active {@see State} is kept in a {@see FiberLocal} so a scope entered inside a test fiber stays + * isolated to that fiber: while several tests interleave on one event loop, each reads its own buffer + * across every switch, with no swapping at the suspension boundary. + * * @internal */ final readonly class MessengerHub implements Messenger { - private MutableContainer $state; + /** @var FiberLocal */ + private FiberLocal $current; public function __construct( private EventDispatcherInterface $eventDispatcher, ) { - $this->state = new MutableContainer(new State($this->eventDispatcher)); + $this->current = new FiberLocal(new State($this->eventDispatcher)); } #[\Override] @@ -37,8 +43,7 @@ public function log(string $channel, string $content, Level $level = Level::Info { // The active state records the message and announces it (or holds the event, in a holdEvents // fork, until commit); it also guards against avalanche recursion during dispatch. - /** @psalm-suppress ArgumentTypeCoercion */ - $this->state->state->record(new Message(\microtime(true), $channel, $level, $content, $context)); + $this->current->get()->record(new Message(\microtime(true), $channel, $level, $content, $context)); } #[\Override] @@ -48,81 +53,27 @@ public function channel(string $name): Channel } #[\Override] - public function scope(\Closure $scope): mixed + public function scope(\Closure $scope, ?TestIdentity $identity = null): mixed { - $old = $this->state->state; - $new = new State($this->eventDispatcher); - try { - $this->state->state = $new; - if (\Fiber::getCurrent() === null) { - return $scope($this); - } - - // Wrap scope into a fiber so the parent state is restored across suspensions. - $self = $this; - $fiber = new \Fiber(static fn() => $scope($self)); - $value = $fiber->start(); - while (!$fiber->isTerminated()) { - $this->state->state = $old; - try { - $resume = \Fiber::suspend($value); - } catch (\Throwable $e) { - $this->state->state = $new; - $value = $fiber->throw($e); - continue; - } - - $this->state->state = $new; - $value = $fiber->resume($resume); - } - - return $fiber->getReturn(); - } finally { - $this->state->state = $old; - $new->destroy(); - } + // A test scope carries the test's identity, so every MessageReceived dispatched from within it + // is stamped with that test — the seam that keeps interleaving tests' output attributable. + $new = new State($this->eventDispatcher, identity: $identity); + return $this->current->scope($new, fn(): mixed => $scope($this), $new->destroy(...)); } #[\Override] public function fork(\Closure $fork, bool $holdEvents = false): mixed { - $old = $this->state->state; - $new = $old->fork($holdEvents); - try { - $this->state->state = $new; - if (\Fiber::getCurrent() === null) { - return $fork($new->commit(...)); - } - - // Wrap the fork into a fiber so the parent state is restored across suspensions. - $fiber = new \Fiber(static fn() => $fork($new->commit(...))); - $value = $fiber->start(); - while (!$fiber->isTerminated()) { - $this->state->state = $old; - try { - $resume = \Fiber::suspend($value); - } catch (\Throwable $e) { - $this->state->state = $new; - $value = $fiber->throw($e); - continue; - } - - $this->state->state = $new; - $value = $fiber->resume($resume); - } - - return $fiber->getReturn(); - } finally { - // Restore the parent, but do NOT destroy the fork: the `$commit` callable may be invoked - // after this returns (the caller decides to keep/drop the branch only once it sees the - // result). commit() clears the fork's own buffer; an abandoned fork is freed by GC. - $this->state->state = $old; - } + // A fork is a child branch on top of the active state. No destroy: the `$commit` callable may be + // invoked after this returns (the caller keeps/drops the branch once it sees the result); commit() + // clears the fork's own buffer, and an abandoned fork is freed by GC. + $new = $this->current->get()->fork($holdEvents); + return $this->current->scope($new, static fn(): mixed => $fork($new->commit(...))); } #[\Override] public function getMessages(): MessageLog { - return new MessageLog($this->state->state->getMessages()); + return new MessageLog($this->current->get()->getMessages()); } } diff --git a/core/Common/Messenger.php b/core/Common/Messenger.php index 58cd240..3ae90bd 100644 --- a/core/Common/Messenger.php +++ b/core/Common/Messenger.php @@ -4,6 +4,7 @@ namespace Testo\Common; +use Testo\Core\Context\TestIdentity; use Testo\Core\Log\Level; use Testo\Core\Log\Message; use Testo\Core\Log\MessageLog; @@ -80,11 +81,16 @@ public function channel(string $name): Channel; * inside the closure observes only what was written within it. Fiber-aware: the parent state * is restored across suspension points. * + * When an `$identity` is given, every {@see MessageReceived} dispatched from within the scope is + * stamped with it, so a consumer can attribute the message to that test even while other tests + * interleave their output on the same stream. + * * @template T * @param \Closure(self): T $scope + * @param TestIdentity|null $identity Test this scope's messages belong to, if any. * @return T */ - public function scope(\Closure $scope): mixed; + public function scope(\Closure $scope, ?TestIdentity $identity = null): mixed; /** * Run the given closure within a fork: a mergeable child branch of the active scope. diff --git a/core/Core/Context/TestIdentity.php b/core/Core/Context/TestIdentity.php new file mode 100644 index 0000000..4d9be9a --- /dev/null +++ b/core/Core/Context/TestIdentity.php @@ -0,0 +1,31 @@ + $arguments Arguments to pass to the test method. * @param array $attributes + * @param TestIdentity|null $identity Identity to carry; a fresh random one is minted when omitted. */ public function __construct( public string $name, @@ -27,7 +33,10 @@ public function __construct( public TestDefinition $testDefinition, public array $arguments = [], public array $attributes = [], - ) {} + ?TestIdentity $identity = null, + ) { + $this->identity = $identity ?? TestIdentity::generate(); + } public function with( ?array $arguments = null, @@ -38,6 +47,7 @@ public function with( testDefinition: $this->testDefinition, arguments: $arguments ?? $this->arguments, attributes: $this->attributes, + identity: $this->identity, ); } } diff --git a/core/Event/Message/MessageReceived.php b/core/Event/Message/MessageReceived.php index 1d885b4..1e21cd6 100644 --- a/core/Event/Message/MessageReceived.php +++ b/core/Event/Message/MessageReceived.php @@ -4,6 +4,7 @@ namespace Testo\Event\Message; +use Testo\Core\Context\TestIdentity; use Testo\Core\Log\Message; /** @@ -18,7 +19,13 @@ */ final readonly class MessageReceived { + /** + * @param TestIdentity|null $identity Identity of the test the message was recorded for, or `null` + * when it belongs to no test (suite/case setup, output between tests). Lets consumers + * attribute interleaved output to the right test instead of guessing from ordering. + */ public function __construct( public Message $message, + public ?TestIdentity $identity = null, ) {} } diff --git a/core/Output/Teamcity/Teamcity/Formatter.php b/core/Output/Teamcity/Teamcity/Formatter.php index b9af582..379c1f4 100644 --- a/core/Output/Teamcity/Teamcity/Formatter.php +++ b/core/Output/Teamcity/Teamcity/Formatter.php @@ -27,9 +27,11 @@ private function __construct() {} * @param \ReflectionClass|null $caseReflection Concrete (runtime) case class, used to * attribute a method-backed suite (a DataProvider batch) to the subclass rather than the * method's declaring class. Ignored when `$reflection` is a class. + * @param non-empty-string|null $flowId Flow this suite belongs to; groups its messages apart from + * other flows running concurrently on the same stream. * @return non-empty-string */ - public static function suiteStarted(string $name, null|\ReflectionClass|\ReflectionFunctionAbstract $reflection = null, ?\ReflectionClass $caseReflection = null): string + public static function suiteStarted(string $name, null|\ReflectionClass|\ReflectionFunctionAbstract $reflection = null, ?\ReflectionClass $caseReflection = null, ?string $flowId = null): string { $attributes = ['name' => $name]; @@ -40,6 +42,8 @@ public static function suiteStarted(string $name, null|\ReflectionClass|\Reflect $locationHint !== null and $attributes['locationHint'] = $locationHint; } + $flowId !== null and $attributes['flowId'] = $flowId; + return self::formatMessage('testSuiteStarted', $attributes); } @@ -47,11 +51,16 @@ public static function suiteStarted(string $name, null|\ReflectionClass|\Reflect * Formats a test suite finished message. * * @param non-empty-string $name Suite name + * @param non-empty-string|null $flowId Flow this suite belongs to. * @return non-empty-string */ - public static function suiteFinished(string $name): string + public static function suiteFinished(string $name, ?string $flowId = null): string { - return self::formatMessage('testSuiteFinished', ['name' => $name]); + $attributes = ['name' => $name]; + + $flowId !== null and $attributes['flowId'] = $flowId; + + return self::formatMessage('testSuiteFinished', $attributes); } /** @@ -65,9 +74,11 @@ public static function suiteFinished(string $name): string * the TeamCity `metainfo` attribute. Omitted when `null`. * @param \ReflectionClass|null $caseReflection Concrete (runtime) case class, used to * attribute an inherited test to the subclass rather than the method's declaring class. + * @param non-empty-string|null $flowId Flow this test belongs to; keeps its messages grouped apart + * from other tests running concurrently on the same stream. * @return non-empty-string */ - public static function testStarted(string $name, bool $captureStandardOutput = false, ?\ReflectionFunctionAbstract $reflection = null, ?string $locationSuffix = null, ?string $description = null, ?\ReflectionClass $caseReflection = null): string + public static function testStarted(string $name, bool $captureStandardOutput = false, ?\ReflectionFunctionAbstract $reflection = null, ?string $locationSuffix = null, ?string $description = null, ?\ReflectionClass $caseReflection = null, ?string $flowId = null): string { $attributes = ['name' => $name]; @@ -79,6 +90,7 @@ public static function testStarted(string $name, bool $captureStandardOutput = f } $description !== null and $attributes['metainfo'] = $description; + $flowId !== null and $attributes['flowId'] = $flowId; return self::formatMessage('testStarted', $attributes); } @@ -88,13 +100,15 @@ public static function testStarted(string $name, bool $captureStandardOutput = f * * @param non-empty-string $name Test name * @param int<0, max>|null $duration Duration in milliseconds + * @param non-empty-string|null $flowId Flow this test belongs to. * @return non-empty-string */ - public static function testFinished(string $name, ?int $duration = null): string + public static function testFinished(string $name, ?int $duration = null, ?string $flowId = null): string { $attributes = ['name' => $name]; $duration !== null and $attributes['duration'] = (string) $duration; + $flowId !== null and $attributes['flowId'] = $flowId; return self::formatMessage('testFinished', $attributes); } @@ -108,6 +122,7 @@ public static function testFinished(string $name, ?int $duration = null): string * @param non-empty-string|null $type Comparison type for diff display (e.g., 'comparisonFailure') * @param non-empty-string|null $expected Expected value for diff * @param non-empty-string|null $actual Actual value for diff + * @param non-empty-string|null $flowId Flow this test belongs to. * @return non-empty-string */ public static function testFailed( @@ -117,6 +132,7 @@ public static function testFailed( ?string $type = null, ?string $expected = null, ?string $actual = null, + ?string $flowId = null, ): string { $attributes = [ 'name' => $name, @@ -127,6 +143,7 @@ public static function testFailed( $type !== null and $attributes['type'] = $type; $expected !== null and $attributes['expected'] = $expected; $actual !== null and $attributes['actual'] = $actual; + $flowId !== null and $attributes['flowId'] = $flowId; return self::formatMessage('testFailed', $attributes); } @@ -136,13 +153,15 @@ public static function testFailed( * * @param non-empty-string $name Test name * @param non-empty-string $message Optional skip reason + * @param non-empty-string|null $flowId Flow this test belongs to. * @return non-empty-string */ - public static function testIgnored(string $name, string $message = ''): string + public static function testIgnored(string $name, string $message = '', ?string $flowId = null): string { $attributes = ['name' => $name]; $message !== '' and $attributes['message'] = $message; + $flowId !== null and $attributes['flowId'] = $flowId; return self::formatMessage('testIgnored', $attributes); } @@ -154,10 +173,13 @@ public static function testIgnored(string $name, string $message = ''): string * @param non-empty-string $output Standard output content * @param array $attributes Extra attributes (e.g. `channel`, `level`) * for consumers that understand them; standard TeamCity parsers ignore unknown ones. + * @param non-empty-string|null $flowId Flow the emitting test belongs to. * @return non-empty-string */ - public static function testStdOut(string $name, string $output, array $attributes = []): string + public static function testStdOut(string $name, string $output, array $attributes = [], ?string $flowId = null): string { + $flowId !== null and $attributes['flowId'] = $flowId; + return self::formatMessage('testStdOut', [ 'name' => $name, 'out' => $output, @@ -171,10 +193,13 @@ public static function testStdOut(string $name, string $output, array $attribute * @param non-empty-string $output Standard error content * @param array $attributes Extra attributes (e.g. `channel`, `level`) * for consumers that understand them; standard TeamCity parsers ignore unknown ones. + * @param non-empty-string|null $flowId Flow the emitting test belongs to. * @return non-empty-string */ - public static function testStdErr(string $name, string $output, array $attributes = []): string + public static function testStdErr(string $name, string $output, array $attributes = [], ?string $flowId = null): string { + $flowId !== null and $attributes['flowId'] = $flowId; + return self::formatMessage('testStdErr', [ 'name' => $name, 'out' => $output, diff --git a/core/Output/Teamcity/Teamcity/TeamcityLogger.php b/core/Output/Teamcity/Teamcity/TeamcityLogger.php index 527e991..ffe75be 100644 --- a/core/Output/Teamcity/Teamcity/TeamcityLogger.php +++ b/core/Output/Teamcity/Teamcity/TeamcityLogger.php @@ -12,6 +12,7 @@ use Testo\Core\Context\CaseResult; use Testo\Core\Context\SuiteInfo; use Testo\Core\Context\SuiteResult; +use Testo\Core\Context\TestIdentity; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Log\Message; @@ -45,6 +46,17 @@ public function __construct($output = null) $this->output = $output ?? \STDOUT; } + /** + * TeamCity `flowId` for a test: distinct concurrent tests get distinct flows, so their interleaved + * `testStarted`/output/`testFinished` messages stay grouped instead of overlapping on one stream. + * + * @return non-empty-string + */ + public static function flowId(TestIdentity $identity): string + { + return (string) $identity->id; + } + /** * Formats a throwable into a detailed string with class, message, file, line, and stack trace. */ @@ -125,7 +137,12 @@ public function suiteFinishedFromInfo(SuiteInfo $info): void */ public function batchStartedFromInfo(TestInfo $info): void { - $this->publish(Formatter::suiteStarted($info->name, $info->testDefinition->reflection, $info->caseInfo->definition->reflection)); + $this->publish(Formatter::suiteStarted( + $info->name, + $info->testDefinition->reflection, + $info->caseInfo->definition->reflection, + self::flowId($info->identity), + )); } /** @@ -133,7 +150,7 @@ public function batchStartedFromInfo(TestInfo $info): void */ public function batchFinishedFromInfo(TestInfo $info): void { - $this->publish(Formatter::suiteFinished($info->name)); + $this->publish(Formatter::suiteFinished($info->name, self::flowId($info->identity))); } /** @@ -217,6 +234,7 @@ public function testStartedFromInfo(TestInfo $info, bool $captureStandardOutput $locationSuffix, $description, $info->caseInfo->definition->reflection, + self::flowId($info->identity), )); } @@ -227,7 +245,7 @@ public function testStartedFromInfo(TestInfo $info, bool $captureStandardOutput */ public function testFinishedFromInfo(TestInfo $info, ?int $duration = null): void { - $this->publish(Formatter::testFinished($info->name, $duration)); + $this->publish(Formatter::testFinished($info->name, $duration, self::flowId($info->identity))); } /** @@ -252,6 +270,7 @@ public function testFailedFromResult(TestResult $result): void type: $isComparison ? 'comparisonFailure' : null, expected: $isComparison ? $failure->getExpectedAsString() : null, actual: $isComparison ? $failure->getActualAsString() : null, + flowId: self::flowId($result->info->identity), ), ); } @@ -276,8 +295,10 @@ public function testIgnoredFromInfo(TestInfo $info, string $message = ''): void * `testFinished` to nest correctly. * * @param non-empty-string $name Test name the message belongs to. + * @param non-empty-string|null $flowId Flow of the test the message belongs to, so interleaved + * output stays grouped with the right test. */ - public function logMessage(string $name, Message $message): void + public function logMessage(string $name, Message $message, ?string $flowId = null): void { if ($message->content === '') { return; @@ -291,8 +312,8 @@ public function logMessage(string $name, Message $message): void ]; $this->publish($message->channel === self::CHANNEL_STDERR - ? Formatter::testStdErr($name, $message->content, $attributes) - : Formatter::testStdOut($name, $message->content, $attributes)); + ? Formatter::testStdErr($name, $message->content, $attributes, $flowId) + : Formatter::testStdOut($name, $message->content, $attributes, $flowId)); } /** @@ -383,7 +404,7 @@ private function handlePassedTest(TestResult $result, ?int $duration, ?string $o { $name = $overrideName ?? $result->info->name; - $this->publish(Formatter::testFinished($name, $duration)); + $this->publish(Formatter::testFinished($name, $duration, self::flowId($result->info->identity))); } /** @@ -394,8 +415,9 @@ private function handlePassedTest(TestResult $result, ?int $duration, ?string $o private function handleSkippedTest(TestResult $result, ?int $duration, ?string $overrideName = null): void { $name = $overrideName ?? $result->info->name; - $this->publish(Formatter::testIgnored($name)); - $this->publish(Formatter::testFinished($name, $duration)); + $flowId = self::flowId($result->info->identity); + $this->publish(Formatter::testIgnored($name, flowId: $flowId)); + $this->publish(Formatter::testFinished($name, $duration, $flowId)); } /** @@ -406,8 +428,9 @@ private function handleSkippedTest(TestResult $result, ?int $duration, ?string $ private function handleCancelledTest(TestResult $result, ?int $duration, ?string $overrideName = null): void { $name = $overrideName ?? $result->info->name; - $this->publish(Formatter::testIgnored($name, 'Test cancelled')); - $this->publish(Formatter::testFinished($name, $duration)); + $flowId = self::flowId($result->info->identity); + $this->publish(Formatter::testIgnored($name, 'Test cancelled', $flowId)); + $this->publish(Formatter::testFinished($name, $duration, $flowId)); } /** @@ -426,6 +449,7 @@ private function handleFailedTest(TestResult $result, ?int $duration, ?string $o : ''; $isComparison = $failure instanceof ComparisonFailure; + $flowId = self::flowId($result->info->identity); $this->publish( Formatter::testFailed( @@ -435,9 +459,10 @@ private function handleFailedTest(TestResult $result, ?int $duration, ?string $o type: $isComparison ? 'comparisonFailure' : null, expected: $isComparison ? $failure->getExpectedAsString() : null, actual: $isComparison ? $failure->getActualAsString() : null, + flowId: $flowId, ), ); - $this->publish(Formatter::testFinished($name, $duration)); + $this->publish(Formatter::testFinished($name, $duration, $flowId)); } /** @@ -452,15 +477,17 @@ private function handleAbortedTest(TestResult $result, ?int $duration, ?string $ $details = $result->failure !== null ? self::formatThrowable($result->failure, $result->info->testDefinition->reflection) : ''; + $flowId = self::flowId($result->info->identity); $this->publish( Formatter::testFailed( $name, 'Test aborted', $details, + flowId: $flowId, ), ); - $this->publish(Formatter::testFinished($name, $duration)); + $this->publish(Formatter::testFinished($name, $duration, $flowId)); } /** @@ -471,14 +498,16 @@ private function handleAbortedTest(TestResult $result, ?int $duration, ?string $ private function handleRiskyTest(TestResult $result, ?int $duration, ?string $overrideName = null): void { $name = $overrideName ?? $result->info->name; + $flowId = self::flowId($result->info->identity); $this->publish( Formatter::testStdOut( $name, "\nWarning: This test has been marked as risky", + flowId: $flowId, ), ); - $this->publish(Formatter::testFinished($name, $duration)); + $this->publish(Formatter::testFinished($name, $duration, $flowId)); } /** diff --git a/core/Output/Teamcity/TeamcityPlugin.php b/core/Output/Teamcity/TeamcityPlugin.php index 8fa9b25..01c4980 100644 --- a/core/Output/Teamcity/TeamcityPlugin.php +++ b/core/Output/Teamcity/TeamcityPlugin.php @@ -27,27 +27,32 @@ final class TeamcityPlugin implements PluginConfigurator { /** - * Tracks whether we're inside a DataProvider batch. + * Ids of the tests currently inside a DataProvider batch. * - * @var array + * Keyed by {@see \Testo\Core\Context\TestIdentity::$id} so concurrently running tests are tracked + * independently — a single scalar would be clobbered the moment two tests interleave. + * + * @var array */ private array $isBatch = []; /** - * Name of the in-flight test/data set that streamed {@see MessageReceived} output is attributed - * to. `null` when no test is running (output between tests is dropped). + * Name of the in-flight test/data set that streamed {@see MessageReceived} output is attributed to, + * per running test id. A test with no entry is not running (its output is dropped). * - * @var non-empty-string|null + * @var array */ - private ?string $currentName = null; + private array $currentName = []; /** - * A regular (non-DataProvider) test whose `testStarted` has not been emitted yet. It is emitted - * lazily on the first message (so output can stream in real time), or at pipeline finish if the - * test produced no output. `null` once `testStarted` has been emitted (or for data sets, which - * emit it eagerly). + * Regular (non-DataProvider) tests whose `testStarted` has not been emitted yet, per test id. Emitted + * lazily on the first message (so output streams in real time), or at pipeline finish if the test + * produced no output. Dropped once `testStarted` has been emitted (or for data sets, which emit it + * eagerly). + * + * @var array */ - private ?TestInfo $pendingStart = null; + private array $pendingStart = []; private readonly TeamcityLogger $logger; @@ -89,18 +94,12 @@ public function configure(Container $container): void $listeners->addListener(TestSuiteFinished::class, $this->onTestSuiteFinished(...)); } - private static function getId(TestInfo $testInfo): string - { - return \spl_object_hash($testInfo->testDefinition); - } - /** - * Clears the current-test attribution so output emitted outside any test is dropped. + * Clears one test's attribution once it is done, so later output outside any test is dropped. */ - private function resetCurrent(): void + private function resetCurrent(int $id): void { - $this->currentName = null; - $this->pendingStart = null; + unset($this->currentName[$id], $this->pendingStart[$id]); } private function onSessionStarting(SessionStarting $event): void @@ -117,62 +116,68 @@ private function onSessionFinished(SessionFinished $event): void private function onMessageReceived(MessageReceived $event): void { - // No test in flight — output between tests is not attributable, so drop it. Internal errors on - // the dedicated stderr channel are the exception: surface them as a standalone message instead. - if ($this->currentName === null) { + $identity = $event->identity; + + // No attributable test — the message belongs to none (suite/case setup, output between tests), + // or its test is not tracked here. Drop it; internal errors on the dedicated stderr channel are + // the exception and are surfaced as a standalone message instead. + if ($identity === null || !isset($this->currentName[$identity->id])) { $event->message->channel === Messenger::CHANNEL_STDERR and $this->logger->logStandaloneMessage($event->message); return; } + $id = $identity->id; + // Lazily emit testStarted for a regular test on its first output, so it streams in real time. - if ($this->pendingStart !== null) { - $this->logger->testStartedFromInfo($this->pendingStart); - $this->pendingStart = null; + if (isset($this->pendingStart[$id])) { + $this->logger->testStartedFromInfo($this->pendingStart[$id]); + unset($this->pendingStart[$id]); } - $this->logger->logMessage($this->currentName, $event->message); + $this->logger->logMessage($this->currentName[$id], $event->message, TeamcityLogger::flowId($identity)); } private function onTestPipelineStarting(TestPipelineStarting $event): void { // Assume a regular test: attribute output to it and keep testStarted pending until output // arrives. If it turns out to be a DataProvider batch, onTestBatchStarting clears this. - $this->currentName = $event->testInfo->name; - $this->pendingStart = $event->testInfo; + $id = $event->testInfo->identity->id; + $this->currentName[$id] = $event->testInfo->name; + $this->pendingStart[$id] = $event->testInfo; } private function onTestPipelineFinished(TestPipelineFinished $event): void { // Check if this test was inside a DataProvider batch - $id = self::getId($event->testInfo); + $id = $event->testInfo->identity->id; if (isset($this->isBatch[$id])) { // DataProvider test - already handled in batch events unset($this->isBatch[$id]); - $this->resetCurrent(); + $this->resetCurrent($id); return; } // Regular test: testStarted was emitted lazily on first output; if there was none, emit now. - if ($this->pendingStart !== null) { - $this->logger->testStartedFromInfo($this->pendingStart); - $this->pendingStart = null; + if (isset($this->pendingStart[$id])) { + $this->logger->testStartedFromInfo($this->pendingStart[$id]); + unset($this->pendingStart[$id]); } $duration = (int) $event->testResult->getAttribute('duration'); $this->logger->handleSingleTestResult($event->testResult, $duration); - $this->resetCurrent(); + $this->resetCurrent($id); } private function onTestBatchStarting(TestBatchStarting $event): void { // Mark that we're inside a batch - $id = self::getId($event->testInfo); + $id = $event->testInfo->identity->id; $this->isBatch[$id] = true; // It's a DataProvider, not a single test: drop the pending single-test start; data sets // emit their own testStarted and own the current attribution. - $this->resetCurrent(); + $this->resetCurrent($id); // For DataProvider tests, start a test suite (wraps all data sets) $this->logger->batchStartedFromInfo($event->testInfo); @@ -198,9 +203,12 @@ private function onTestDataSetStarting(TestDataSetStarting $event): void locationSuffix: $locationSuffix, ); - // testStarted already emitted eagerly; stream this data set's output to it in real time. - $this->currentName = $name; - $this->pendingStart = null; + // testStarted already emitted eagerly; stream this data set's output to it in real time. Data + // sets of one provider share the batch's identity and run sequentially, so one current-name + // entry per id is unambiguous. + $id = $event->testInfo->identity->id; + $this->currentName[$id] = $name; + unset($this->pendingStart[$id]); } private function onTestDataSetFinished(TestDataSetFinished $event): void @@ -211,7 +219,7 @@ private function onTestDataSetFinished(TestDataSetFinished $event): void $name = "Dataset #{$prefix}{$event->datasetIndex} [$event->datasetKey]"; $this->logger->handleSingleTestResult($event->testResult, $duration, overrideName: $name); - $this->resetCurrent(); + $this->resetCurrent($event->testInfo->identity->id); } private function onTestCaseStarting(TestCaseStarting $event): void diff --git a/core/Pipeline/Internal/OutputInterceptor.php b/core/Pipeline/Internal/OutputInterceptor.php index c350089..9a293d0 100644 --- a/core/Pipeline/Internal/OutputInterceptor.php +++ b/core/Pipeline/Internal/OutputInterceptor.php @@ -45,7 +45,8 @@ public function __construct( #[\Override] public function runTest(TestInfo $info, callable $next): TestResult { - # Fork the messenger: everything written during this test lands in an isolated buffer. + # Fork the messenger: everything written during this test lands in an isolated buffer, and + # every MessageReceived it dispatches is stamped with this test's identity. return $this->messenger->scope(static function (Messenger $messenger) use ($info, $next): TestResult { $result = $next($info); @@ -54,6 +55,6 @@ public function runTest(TestInfo $info, callable $next): TestResult return $messages->isEmpty() ? $result : $result->withMessages($messages); - }); + }, $info->identity); } } diff --git a/internal/container/src/ObjectContainer.php b/internal/container/src/ObjectContainer.php index 3a23633..ea8ad36 100644 --- a/internal/container/src/ObjectContainer.php +++ b/internal/container/src/ObjectContainer.php @@ -5,6 +5,7 @@ namespace Internal\Container; use Internal\Container\Interanl\State; +use Internal\Fiber\FiberLocal; /** * Simple dependency injection container. @@ -12,98 +13,78 @@ * Provides service creation and caching with autowiring capabilities. * Automatically loads configuration for config classes. * + * The active {@see State} lives in a {@see FiberLocal} so a scope entered inside a fiber stays isolated to + * that fiber: when several fibers interleave on one event loop, each reads its own scoped bindings across + * every switch, with no swapping at the suspension boundary. + * * @internal * @psalm-internal Testo\Application */ final class ObjectContainer implements Container { - private State $state; + /** @var FiberLocal */ + private FiberLocal $current; public function __construct() { - $this->state = new State($this); + $this->current = new FiberLocal(new State($this)); } public function addInflector(Inflector $inflector): void { - $this->state->addInflector($inflector); + $this->current->get()->addInflector($inflector); } #[\Override] public function get(string $id, array $arguments = []): object { - return $this->state->get($id, $arguments); + return $this->current->get()->get($id, $arguments); } #[\Override] public function has(string $id): bool { - return $this->state->has($id); + return $this->current->get()->has($id); } #[\Override] public function set(object $service, ?string $id = null, bool $destroy = false): void { \assert($id === null || $service instanceof $id, "Service must be instance of {$id}."); - $this->state->set($service, $id, $destroy); + $this->current->get()->set($service, $id, $destroy); } #[\Override] public function make(string $class, array $arguments = []): object { - return $this->state->make($class, $arguments); + return $this->current->get()->make($class, $arguments); } #[\Override] public function bind(string $id, \Closure|string|array|null $binding = null): void { - $this->state->bind($id, $binding); + $this->current->get()->bind($id, $binding); } #[\Override] public function scope(\Closure $scope): mixed { - $oldState = $this->state; - $newState = $oldState->clone($this); - try { - $this->state = $newState; - if (\Fiber::getCurrent() === null) { - return $scope($this); - } - - // Wrap scope into fiber - $fiber = new \Fiber(static fn(ObjectContainer $c) => $scope($c)); - $value = $fiber->start($this); - while (!$fiber->isTerminated()) { - $this->state = $oldState; - try { - $resume = \Fiber::suspend($value); - } catch (\Throwable $e) { - $this->state = $newState; - $value = $fiber->throw($e); - continue; - } - - $this->state = $newState; - $value = $fiber->resume($resume); - } - - return $fiber->getReturn(); - } finally { - $this->state = $oldState; - $newState->destroy(); - } + // Clone the active state into a child scope; FiberLocal binds it to the current fiber only and + // restores the parent on exit — even across a real event-loop suspension inside $scope, where the + // loop resumes this exact fiber directly. The child state is destroyed after the parent is restored. + $new = $this->current->get()->clone($this); + return $this->current->scope($new, fn(): mixed => $scope($this), $new->destroy(...)); } #[\Override] public function destroy(): void { - $this->state->destroy(); - unset($this->state); + $this->current->get()->destroy(); + unset($this->current); } public function __clone(): void { - $this->state = clone $this->state; + $this->current = new FiberLocal(clone $this->current->get()); } } diff --git a/internal/container/tests/Testo/Fibers.php b/internal/container/tests/Testo/Fibers.php new file mode 100644 index 0000000..5bc550e --- /dev/null +++ b/internal/container/tests/Testo/Fibers.php @@ -0,0 +1,84 @@ +start(); + while (!$fiber->isTerminated()) { + if ($check !== null) { + try { + $value = $check($value); + } catch (\Throwable $e) { + $value = $fiber->throw($e); + continue; + } + } + $value = $fiber->resume($value); + } + + return $fiber->getReturn(); + } + + /** + * Start every callable in its own fiber, then round-robin resume them to completion so they + * interleave at each `\Fiber::suspend()`. Returns each fiber's return value, keyed by input order. + * + * @param callable(): mixed ...$callables + * @return array + */ + public static function runSequence(callable ...$callables): array + { + $fibers = []; + $results = []; + foreach ($callables as $key => $callable) { + $fibers[$key] = new \Fiber($callable); + $results[$key] = null; + } + + foreach ($fibers as $fiber) { + $fiber->start(); + } + + while ($fibers !== []) { + foreach ($fibers as $key => $fiber) { + if ($fiber->isTerminated()) { + $results[$key] = $fiber->getReturn(); + unset($fibers[$key]); + continue; + } + $fiber->resume(); + } + } + + return $results; + } +} diff --git a/internal/container/tests/Testo/Loop.php b/internal/container/tests/Testo/Loop.php new file mode 100644 index 0000000..3db1c20 --- /dev/null +++ b/internal/container/tests/Testo/Loop.php @@ -0,0 +1,59 @@ +resume($body()); + } catch (\Throwable $e) { + $suspension->throw($e); + } + }); + + return $suspension->suspend(); + } + + /** + * Inside a {@see run()} body: yield to the loop and resume on the next tick — a genuine + * suspension through the Revolt driver, i.e. the point where a guard that hand-drives the + * fiber collides with the driver resuming it. + */ + public static function tick(): void + { + $suspension = EventLoop::getSuspension(); + EventLoop::queue(static fn() => $suspension->resume()); + $suspension->suspend(); + } +} diff --git a/internal/container/tests/Unit/ConcurrentScopesRevoltTest.php b/internal/container/tests/Unit/ConcurrentScopesRevoltTest.php new file mode 100644 index 0000000..e07fc82 --- /dev/null +++ b/internal/container/tests/Unit/ConcurrentScopesRevoltTest.php @@ -0,0 +1,72 @@ +scope(static function (ObjectContainer $scoped) use ($tag): void { + $scoped->get(ContainerScopeService::class)->tag = $tag; + + for ($i = 0; $i < 3; $i++) { + $suspension = EventLoop::getSuspension(); + EventLoop::delay(0.001, static fn() => $suspension->resume()); + $suspension->suspend(); + + Assert::same($scoped->get(ContainerScopeService::class)->tag, $tag); + } + }); + } +} diff --git a/internal/container/tests/Unit/FiberScopeTest.php b/internal/container/tests/Unit/FiberScopeTest.php new file mode 100644 index 0000000..35c52e3 --- /dev/null +++ b/internal/container/tests/Unit/FiberScopeTest.php @@ -0,0 +1,93 @@ + $container->scope(static function (ObjectContainer $scoped): string { + $scoped->get(ContainerScopeService::class)->tag = 7; + + $out = (string) \Fiber::suspend('foo'); + try { + $out .= (string) \Fiber::suspend('error'); + } catch (\Throwable $e) { + $out .= $e->getMessage(); + } + $out .= (string) \Fiber::suspend('baz'); + + // The scoped instance survived the injected throw and the suspensions around it. + return $out . '/' . $scoped->get(ContainerScopeService::class)->tag; + }), + static fn(string $value): string => $value === 'error' + ? throw new \RuntimeException('X') + : $value, + ); + + Assert::same($result, 'fooXbaz/7'); + } + + public function anUncaughtInjectedExceptionPropagatesOutOfTheScope(): never + { + $container = new ObjectContainer(); + + Expect::exception(\RuntimeException::class)->withMessage('boom'); + + Fibers::runInFiber( + static fn(): mixed => $container->scope(static function (ObjectContainer $scoped): string { + $scoped->get(ContainerScopeService::class); + \Fiber::suspend('foo'); + return (string) \Fiber::suspend('error'); + }), + static fn(string $value): string => $value === 'error' + ? throw new \RuntimeException('boom') + : $value, + ); + } + + public function manyConcurrentFibersKeepScopedStateIsolated(): void + { + $root = new ObjectContainer(); + + $scopeTagging = static fn(int $tag): callable => static function () use ($root, $tag): int { + return $root->scope(static function (ObjectContainer $scoped) use ($tag): int { + $scoped->get(ContainerScopeService::class)->tag = $tag; + \Fiber::suspend(); + return $scoped->get(ContainerScopeService::class)->tag; + }); + }; + + $results = Fibers::runSequence( + $scopeTagging(1), + $scopeTagging(2), + $scopeTagging(3), + $scopeTagging(4), + $scopeTagging(5), + ); + + Assert::same($results, [1, 2, 3, 4, 5]); + } +} diff --git a/internal/container/tests/Unit/ResolutionTest.php b/internal/container/tests/Unit/ResolutionTest.php new file mode 100644 index 0000000..4b5d0c9 --- /dev/null +++ b/internal/container/tests/Unit/ResolutionTest.php @@ -0,0 +1,211 @@ +get(ContainerScopeService::class), + $container->get(ContainerScopeService::class), + ); + } + + public function makeCreatesAFreshInstanceEachTime(): void + { + $container = new ObjectContainer(); + + $first = $container->make(ContainerScopeService::class); + $second = $container->make(ContainerScopeService::class); + + Assert::notSame($first, $second); + Assert::notSame($container->get(ContainerScopeService::class), $first); + } + + public function hasIsFalseUntilResolvedOrBound(): void + { + $container = new ObjectContainer(); + + Assert::false($container->has(GreeterInterface::class)); + + $container->bind(GreeterInterface::class, Greeter::class); + Assert::true($container->has(GreeterInterface::class)); + } + + public function hasIsTrueAfterResolution(): void + { + $container = new ObjectContainer(); + + Assert::false($container->has(ContainerScopeService::class)); + $container->get(ContainerScopeService::class); + Assert::true($container->has(ContainerScopeService::class)); + } + + public function setStoresAServiceUnderItsClass(): void + { + $container = new ObjectContainer(); + $service = new ContainerScopeService(); + $service->tag = 5; + + $container->set($service); + + Assert::same($container->get(ContainerScopeService::class), $service); + } + + public function setStoresAServiceUnderAnExplicitId(): void + { + $container = new ObjectContainer(); + $greeter = new Greeter(); + + $container->set($greeter, GreeterInterface::class); + + Assert::same($container->get(GreeterInterface::class), $greeter); + } + + public function autowiresConstructorDependencies(): void + { + $container = new ObjectContainer(); + + $dependent = $container->get(DependentService::class); + + Assert::instanceOf($dependent->dependency, ContainerScopeService::class); + Assert::same($dependent->dependency, $container->get(ContainerScopeService::class)); + } + + public function bindUsesAClosureAsFactory(): void + { + $container = new ObjectContainer(); + $container->bind(ContainerScopeService::class, static function (): ContainerScopeService { + $service = new ContainerScopeService(); + $service->tag = 55; + return $service; + }); + + Assert::same($container->get(ContainerScopeService::class)->tag, 55); + } + + public function bindResolvesAClassAliasToItsImplementation(): void + { + $container = new ObjectContainer(); + $container->bind(GreeterInterface::class, Greeter::class); + + Assert::instanceOf($container->get(GreeterInterface::class), Greeter::class); + } + + public function bindSuppliesConstructorArgumentsFromAnArray(): void + { + $container = new ObjectContainer(); + $container->bind(UnresolvableService::class, ['count' => 5]); + + Assert::same($container->get(UnresolvableService::class)->count, 5); + } + + public function bindWithNullSelfBinds(): void + { + $container = new ObjectContainer(); + $container->bind(ContainerScopeService::class); + + Assert::true($container->has(ContainerScopeService::class)); + Assert::instanceOf($container->get(ContainerScopeService::class), ContainerScopeService::class); + } + + public function getPassesFirstTimeArgumentsToTheConstructor(): void + { + $container = new ObjectContainer(); + + Assert::same($container->get(UnresolvableService::class, ['count' => 9])->count, 9); + } + + public function buildsFactoriableServicesThroughTheirCreateMethod(): void + { + $container = new ObjectContainer(); + $container->bind(ConfigFactoriable::class); + + $config = $container->get(ConfigFactoriable::class); + + Assert::instanceOf($config, ConfigFactoriable::class); + Assert::instanceOf($config->service, ContainerScopeService::class); + } + + public function runsInflectorsOverResolvedServices(): void + { + $container = new ObjectContainer(); + $container->addInflector(new TagInflector()); + + Assert::same($container->make(ContainerScopeService::class)->tag, 100); + } + + public function resolvesTheContainerInterfaceToItself(): void + { + $container = new ObjectContainer(); + + Assert::same($container->get(ContainerInterface::class), $container); + Assert::same($container->get(Container::class), $container); + Assert::same($container->get(ObjectContainer::class), $container); + } + + public function destroyTearsDownManagedDestroyableServices(): void + { + $container = new ObjectContainer(); + $service = $container->get(DestroyableService::class); + + $container->destroy(); + + Assert::true($service->destroyed); + } + + public function makeThrowsNotFoundForAnUnresolvableService(): never + { + $container = new ObjectContainer(); + + Expect::exception(NotFoundExceptionInterface::class); + + $container->make(UnresolvableService::class); + } + + public function bindRejectsANonExistentAliasClass(): never + { + $container = new ObjectContainer(); + + Expect::exception(\InvalidArgumentException::class); + + $container->bind(GreeterInterface::class, 'No\\Such\\Class'); + } + + public function bindRejectsAnIncompatibleAlias(): never + { + $container = new ObjectContainer(); + + Expect::exception(\InvalidArgumentException::class); + + $container->bind(GreeterInterface::class, ContainerScopeService::class); + } +} diff --git a/internal/container/tests/Unit/ScopeIsolationTest.php b/internal/container/tests/Unit/ScopeIsolationTest.php new file mode 100644 index 0000000..4723f89 --- /dev/null +++ b/internal/container/tests/Unit/ScopeIsolationTest.php @@ -0,0 +1,193 @@ +get(ContainerScopeService::class)->tag = 10; + + $inside = $container->scope(static function (ObjectContainer $scoped): int { + $service = $scoped->get(ContainerScopeService::class); + $service->tag = 99; + return $service->tag; + }); + + Assert::same($inside, 99); + Assert::same($container->get(ContainerScopeService::class)->tag, 10); + } + + public function nestedSyncScopesRestoreTheParent(): void + { + $container = new ObjectContainer(); + $container->get(ContainerScopeService::class)->tag = 1; + + $container->scope(static function (ObjectContainer $outer): void { + $outer->get(ContainerScopeService::class)->tag = 2; + + $outer->scope(static function (ObjectContainer $inner): void { + $inner->get(ContainerScopeService::class)->tag = 3; + }); + + Assert::same($outer->get(ContainerScopeService::class)->tag, 2); + }); + + Assert::same($container->get(ContainerScopeService::class)->tag, 1); + } + + public function scopeReturnsTheClosureResult(): void + { + $container = new ObjectContainer(); + + $result = $container->scope(static fn(): string => 'result'); + + Assert::same($result, 'result'); + } + + public function scopeInheritsAClonedCopyOfParentCachedServices(): void + { + $container = new ObjectContainer(); + $parent = $container->get(ContainerScopeService::class); + $parent->tag = 7; + + $container->scope(static function (ObjectContainer $scoped) use ($parent): void { + $inScope = $scoped->get(ContainerScopeService::class); + Assert::notSame($inScope, $parent); + Assert::same($inScope->tag, 7); + $inScope->tag = 99; + }); + + Assert::same($parent->tag, 7); + } + + public function scopeSharesReadonlyServicesWithTheParent(): void + { + $container = new ObjectContainer(); + $parent = $container->get(ReadonlyTag::class); + + $inScope = $container->scope(static fn(ObjectContainer $scoped): ReadonlyTag => $scoped->get(ReadonlyTag::class)); + + Assert::same($inScope, $parent); + } + + public function scopeInheritsParentBindings(): void + { + $container = new ObjectContainer(); + $container->bind(GreeterInterface::class, Greeter::class); + + $inScope = $container->scope( + static fn(ObjectContainer $scoped): GreeterInterface => $scoped->get(GreeterInterface::class), + ); + + Assert::instanceOf($inScope, Greeter::class); + } + + public function bindingInsideAScopeDoesNotLeakToTheParent(): void + { + $container = new ObjectContainer(); + + $container->scope(static function (ObjectContainer $scoped): void { + $scoped->bind(GreeterInterface::class, Greeter::class); + Assert::true($scoped->has(GreeterInterface::class)); + }); + + Assert::false($container->has(GreeterInterface::class)); + } + + public function scopeRestoresTheParentAfterAnException(): void + { + $container = new ObjectContainer(); + $container->get(ContainerScopeService::class)->tag = 5; + + try { + $container->scope(static function (ObjectContainer $scoped): never { + $scoped->get(ContainerScopeService::class)->tag = 42; + throw new \RuntimeException('boom'); + }); + } catch (\RuntimeException) { + // The throw is incidental — what we verify is that the parent scope is intact afterwards. + } + + Assert::same($container->get(ContainerScopeService::class)->tag, 5); + } + + public function deeplyNestedScopesEachRestoreTheirParent(): void + { + $container = new ObjectContainer(); + $container->get(ContainerScopeService::class)->tag = 0; + + $deepest = $container->scope(static function (ObjectContainer $l1): int { + $l1->get(ContainerScopeService::class)->tag = 1; + + $inner = $l1->scope(static function (ObjectContainer $l2): int { + $l2->get(ContainerScopeService::class)->tag = 2; + + $l3 = $l2->scope(static fn(ObjectContainer $c): int => $c->get(ContainerScopeService::class)->tag = 3); + + Assert::same($l2->get(ContainerScopeService::class)->tag, 2); + return $l3; + }); + + Assert::same($l1->get(ContainerScopeService::class)->tag, 1); + return $inner; + }); + + Assert::same($deepest, 3); + Assert::same($container->get(ContainerScopeService::class)->tag, 0); + } + + /** + * Two fibers each enter their own scope, park, then resume: each must still resolve its own scoped + * instance across the switch — the isolation invariant a real event loop relies on. + */ + public function interleavedScopesKeepSeparateInstances(): void + { + $container = new ObjectContainer(); + $seen = []; + + $fiberA = new \Fiber(static function () use ($container, &$seen): void { + $container->scope(static function (ObjectContainer $scoped) use (&$seen): void { + $scoped->get(ContainerScopeService::class)->tag = 1; + \Fiber::suspend(); + $seen['A'] = $scoped->get(ContainerScopeService::class)->tag; + }); + }); + $fiberB = new \Fiber(static function () use ($container, &$seen): void { + $container->scope(static function (ObjectContainer $scoped) use (&$seen): void { + $scoped->get(ContainerScopeService::class)->tag = 2; + \Fiber::suspend(); + $seen['B'] = $scoped->get(ContainerScopeService::class)->tag; + }); + }); + + $fiberA->start(); + $fiberB->start(); + $fiberA->resume(); + $fiberB->resume(); + + Assert::same($seen['A'], 1); + Assert::same($seen['B'], 2); + } +} diff --git a/internal/container/tests/Unit/ScopeUnderFiberTest.php b/internal/container/tests/Unit/ScopeUnderFiberTest.php new file mode 100644 index 0000000..85c63c9 --- /dev/null +++ b/internal/container/tests/Unit/ScopeUnderFiberTest.php @@ -0,0 +1,68 @@ +scope(static function (ObjectContainer $scoped) use ($tag): void { + $scoped->get(ContainerScopeService::class)->tag = $tag; + + for ($i = 0; $i < 3; $i++) { + \Fiber::suspend(); + Assert::same($scoped->get(ContainerScopeService::class)->tag, $tag); + } + }); + } +} diff --git a/internal/container/tests/Unit/ScopesRevoltTest.php b/internal/container/tests/Unit/ScopesRevoltTest.php new file mode 100644 index 0000000..a1dab15 --- /dev/null +++ b/internal/container/tests/Unit/ScopesRevoltTest.php @@ -0,0 +1,97 @@ + throw new \RuntimeException('boom')); + } catch (\RuntimeException $e) { + $caught = $e; + } + + Assert::notNull($caught); + Assert::same($caught?->getMessage(), 'boom'); + } + + public function containerResolvesInsideTheLoop(): void + { + $service = Loop::run(static function (): object { + $container = new ObjectContainer(); + return $container->get(ContainerScopeService::class); + }); + + Assert::instanceOf($service, ContainerScopeService::class); + } + + /** + * A real Revolt await **inside** a container `scope()`: the scope's state must survive the + * suspension. The loop resumes this exact fiber directly, and because the active {@see State} is held + * per fiber (see {@see \Internal\Fiber\FiberLocal}), `get()` after the await still reads the scope's + * own instance (`tag = 42`), not a fresh parent one. Before the fiber-local migration this leaked via + * the fiber trampoline (returned `tag = 0`) or deadlocked the Revolt driver. + */ + #[RunInRevolt] + public function scopeAcrossARevoltAwaitKeepsItsState(): void + { + $container = new ObjectContainer(); + + $container->scope(static function (ObjectContainer $scoped): void { + $scoped->get(ContainerScopeService::class)->tag = 42; + + $suspension = EventLoop::getSuspension(); + EventLoop::delay(0.001, static fn() => $suspension->resume()); + $suspension->suspend(); + + Assert::same($scoped->get(ContainerScopeService::class)->tag, 42); + }); + } +} diff --git a/internal/container/tests/Unit/Stub/ConfigFactoriable.php b/internal/container/tests/Unit/Stub/ConfigFactoriable.php new file mode 100644 index 0000000..70bce7f --- /dev/null +++ b/internal/container/tests/Unit/Stub/ConfigFactoriable.php @@ -0,0 +1,25 @@ +destroyed = true; + } +} diff --git a/internal/container/tests/Unit/Stub/Greeter.php b/internal/container/tests/Unit/Stub/Greeter.php new file mode 100644 index 0000000..4c80a57 --- /dev/null +++ b/internal/container/tests/Unit/Stub/Greeter.php @@ -0,0 +1,16 @@ +tag += 100; + return $object; + } +} diff --git a/internal/container/tests/Unit/Stub/UnresolvableService.php b/internal/container/tests/Unit/Stub/UnresolvableService.php new file mode 100644 index 0000000..bc2bd50 --- /dev/null +++ b/internal/container/tests/Unit/Stub/UnresolvableService.php @@ -0,0 +1,18 @@ +get(); // 'default' — nothing bound in this context yet + * + * // scope() binds a value for the duration of a callback, then restores the previous one: + * $local->scope('scoped', function () use ($local): void { + * $local->get(); // 'scoped' + * }); + * $local->get(); // 'default' — restored + * ``` + * + * @template T + */ +final class FiberLocal +{ + /** @var \WeakMap<\Fiber, T> */ + private \WeakMap $byFiber; + + /** @var T|null */ + private mixed $main = null; + + private bool $hasMain = false; + + /** + * @param T $default Value seen before anything is set in the current context. + */ + public function __construct( + private readonly mixed $default = null, + ) { + /** @psalm-suppress PropertyTypeCoercion an empty WeakMap is WeakMap until keyed */ + $this->byFiber = new \WeakMap(); + } + + /** + * @return T The value bound to the current fiber (or the main thread), or the default. + */ + public function get(): mixed + { + $fiber = \Fiber::getCurrent(); + + return $fiber === null + ? ($this->hasMain ? $this->main : $this->default) + : ($this->byFiber[$fiber] ?? $this->default); + } + + /** + * Bind $value to the current context, run $run, then restore the previous binding — even if $run + * throws. Re-entrant: nested scopes in the same fiber save and restore correctly. + * + * $destroy, if given, runs once after the binding is restored (a `finally` for the caller): it lets a + * scope tear its value down without an extra try/finally around the call. + * + * @template R + * @param T $value + * @param \Closure(): R $run + * @param \Closure(): void|null $destroy Cleanup for $value, run after the binding is restored. + * @return R + */ + public function scope(mixed $value, \Closure $run, ?\Closure $destroy = null): mixed + { + $fiber = \Fiber::getCurrent(); + + try { + if ($fiber === null) { + $hadOld = $this->hasMain; + $old = $this->main; + $this->main = $value; + $this->hasMain = true; + try { + return $run(); + } finally { + $this->hasMain = $hadOld; + $this->main = $hadOld ? $old : null; + } + } + + // Split on had/not-had so the restored value keeps its non-null type T. + if (isset($this->byFiber[$fiber])) { + $old = $this->byFiber[$fiber]; + $this->byFiber[$fiber] = $value; + try { + return $run(); + } finally { + /** @psalm-suppress PossiblyNullArgument $old came from an isset() slot, so it is a real T */ + $this->byFiber[$fiber] = $old; + } + } + + $this->byFiber[$fiber] = $value; + try { + return $run(); + } finally { + unset($this->byFiber[$fiber]); + } + } finally { + $destroy === null or $destroy(); + } + } +} diff --git a/internal/fiber/tests/Unit/FiberLocalTest.php b/internal/fiber/tests/Unit/FiberLocalTest.php new file mode 100644 index 0000000..ec1e47f --- /dev/null +++ b/internal/fiber/tests/Unit/FiberLocalTest.php @@ -0,0 +1,151 @@ +get(), 'default'); + + $inside = $local->scope('scoped', static fn(): string => $local->get()); + Assert::same($inside, 'scoped'); + Assert::same($local->get(), 'default'); + } + + public function nestedScopeRestoresOuterValue(): void + { + $local = new FiberLocal('base'); + $seen = []; + + $local->scope('outer', static function () use ($local, &$seen): void { + $seen[] = $local->get(); + $local->scope('inner', static function () use ($local, &$seen): void { + $seen[] = $local->get(); + }); + $seen[] = $local->get(); + }); + + Assert::same($seen, ['outer', 'inner', 'outer']); + Assert::same($local->get(), 'base'); + } + + /** + * The interleaving invariant every guard relies on: while two fibers are driven turn by turn, each + * reads its own scoped value across every switch, with no swapping at the suspension boundary. + */ + public function interleavedFibersEachReadOwnValue(): void + { + $local = new FiberLocal('base'); + $log = []; + + $fiberA = new \Fiber(static function () use ($local, &$log): void { + $local->scope('A', static function () use ($local, &$log): void { + $log[] = 'A:' . $local->get(); + \Fiber::suspend(); + $log[] = 'A:' . $local->get(); + }); + }); + $fiberB = new \Fiber(static function () use ($local, &$log): void { + $local->scope('B', static function () use ($local, &$log): void { + $log[] = 'B:' . $local->get(); + \Fiber::suspend(); + $log[] = 'B:' . $local->get(); + }); + }); + + $fiberA->start(); + $fiberB->start(); + $fiberA->resume(); + $fiberB->resume(); + + Assert::same($log, ['A:A', 'B:B', 'A:A', 'B:B']); + Assert::same($local->get(), 'base'); + } + + public function scopeRestoresAfterException(): void + { + $local = new FiberLocal('base'); + + try { + $local->scope('inner', static function (): never { + throw new \RuntimeException('boom'); + }); + } catch (\RuntimeException) { + // The throw is incidental machinery here — what we verify is the restore in scope()'s finally. + } + + Assert::same($local->get(), 'base'); + } + + public function scopeRunsDestroyOnceAfterRestore(): void + { + $local = new FiberLocal('base'); + $seenDuringDestroy = 'unset'; + $calls = 0; + + $local->scope( + 'scoped', + static fn(): int => 1, + static function () use ($local, &$seenDuringDestroy, &$calls): void { + ++$calls; + $seenDuringDestroy = $local->get(); + }, + ); + + Assert::same($calls, 1); + Assert::same($seenDuringDestroy, 'base'); + } + + public function scopeRunsDestroyOnException(): void + { + $local = new FiberLocal('base'); + $destroyed = false; + + try { + $local->scope( + 'scoped', + static function (): never { + throw new \RuntimeException('boom'); + }, + static function () use (&$destroyed): void { + $destroyed = true; + }, + ); + } catch (\RuntimeException) { + } + + Assert::true($destroyed); + } + + public function abandonedFiberSlotIsReleasedByGc(): void + { + $local = new FiberLocal('base'); + + // Park a fiber inside scope() so its restoring finally never runs — the slot lingers in the map. + $fiber = new \Fiber(static function () use ($local): void { + $local->scope('leaky', static fn() => \Fiber::suspend()); + }); + $fiber->start(); + + /** @var \WeakMap<\Fiber, mixed> $byFiber */ + $byFiber = (new \ReflectionProperty(FiberLocal::class, 'byFiber'))->getValue($local); + Assert::count($byFiber, 1); + + $fiber = null; + \gc_collect_cycles(); + + Assert::count($byFiber, 0); + } +} diff --git a/internal/fiber/tests/suites.php b/internal/fiber/tests/suites.php new file mode 100644 index 0000000..8c4d60f --- /dev/null +++ b/internal/fiber/tests/suites.php @@ -0,0 +1,18 @@ +history[] = $subParent; + $state = StaticState::current(); + $state === null or $state->history[] = $subParent; $callback(new self($resolved, $subParent)); diff --git a/plugin/assert/src/Internal/Middleware/AssertCollectorInterceptor.php b/plugin/assert/src/Internal/Middleware/AssertCollectorInterceptor.php index 6eea016..ba57219 100644 --- a/plugin/assert/src/Internal/Middleware/AssertCollectorInterceptor.php +++ b/plugin/assert/src/Internal/Middleware/AssertCollectorInterceptor.php @@ -38,45 +38,21 @@ public function __construct( public function runTest(TestInfo $info, callable $next): TestResult { $state = new TestState(); - try { - $previous = StaticState::swap($state); - if (\Fiber::getCurrent() === null) { - # No Fiber, run the test directly - $result = $next($info); - } else { - # Create a Fiber scope to run the test - $fiber = new \Fiber(static fn(): TestResult => $next($info)); - - $value = $fiber->start(); - while (!$fiber->isTerminated()) { - StaticState::swap($previous); - try { - $resume = \Fiber::suspend($value); - } catch (\Throwable $e) { - $previous = StaticState::swap($state); - $value = $fiber->throw($e); - continue; - } - - $previous = StaticState::swap($state); - $value = $fiber->resume($resume); - } - - /** @var TestResult $result */ - $result = $fiber->getReturn(); - } - - $this->messenger->log( - AssertPlugin::CHANNEL_HISTORY, - HistoryRenderer::render($state->history), - ); - - return $result - ->withAttribute(TestState::class, $state) - ->withSummary($result->summary->withAddedMetric('assertions', \count($state->history))); - } finally { - StaticState::swap($previous); - } + // The collector is installed for the current fiber only, so concurrent tests keep separate + // histories. StaticState::scope() restores the previous collector on exit — including across a + // real event-loop suspension inside $next, where the loop resumes this exact fiber directly. + $result = StaticState::scope($state, static fn(): TestResult => $next($info)); + + // Emit after $next returns: the tree is only final here (composite records are appended empty and + // filled in afterwards, so streaming per assertion would serialize half-built structures). + $this->messenger->log( + AssertPlugin::CHANNEL_HISTORY, + HistoryRenderer::render($state->history), + ); + + return $result + ->withAttribute(TestState::class, $state) + ->withSummary($result->summary->withAddedMetric('assertions', \count($state->history))); } } diff --git a/plugin/assert/src/Internal/StaticState.php b/plugin/assert/src/Internal/StaticState.php index a9bbeea..b71e1f3 100644 --- a/plugin/assert/src/Internal/StaticState.php +++ b/plugin/assert/src/Internal/StaticState.php @@ -4,6 +4,7 @@ namespace Testo\Assert\Internal; +use Internal\Fiber\FiberLocal; use Testo\Assert\Exception\StateNotFound; use Testo\Assert\Internal\Expectation\ExpectedFail; use Testo\Assert\Internal\Expectation\ExpectExceptionHandler; @@ -19,22 +20,29 @@ /** * Holds the current assertion collector. * + * The active collector lives in a {@see FiberLocal} so each test fiber sees its own {@see TestState}: + * while tests interleave on one event loop, {@see Assert}'s reads and writes stay isolated per fiber + * without swapping a process-global slot at every suspension boundary. + * * @internal * @psalm-internal Testo\Assert */ final class StaticState { - public static ?TestState $state = null; + /** @var FiberLocal|null */ + private static ?FiberLocal $active = null; /** - * Swap the current collector with the given one. + * Install $state as the current collector for the current fiber, run $run, then restore the previous + * collector. Replaces the former swap()/child-fiber trampoline. * - * @return TestState|null The previous collector. + * @template R + * @param \Closure(): R $run + * @return R */ - public static function swap(?TestState $collector): ?TestState + public static function scope(?TestState $state, \Closure $run): mixed { - [self::$state, $collector] = [$collector, self::$state]; - return $collector; + return self::store()->scope($state, $run); } /** @@ -42,7 +50,7 @@ public static function swap(?TestState $collector): ?TestState */ public static function current(): ?TestState { - return self::$state; + return self::store()->get(); } /** @@ -53,7 +61,8 @@ public static function current(): ?TestState public static function typeSuccess(string $type, mixed $actual, string $message = ''): AssertionComposite { $result = new AssertionComposite(Support::stringify($actual), "is $type", $message); - self::$state === null or self::$state->history[] = $result; + $state = self::current(); + $state === null or $state->history[] = $result; return $result; } @@ -71,7 +80,8 @@ public static function typeFail(string $type, mixed $actual, string $message = ' reason: "got " . Support::stringify($actual), details: '', ); - self::$state === null or self::$state->history[] = $result; + $state = self::current(); + $state === null or $state->history[] = $result; throw $result; } @@ -82,7 +92,8 @@ public static function typeFail(string $type, mixed $actual, string $message = ' */ public static function success(mixed $value, string $assertion, string $context = ''): void { - self::$state === null or self::$state->history[] = new AssertionSuccess( + $state = self::current(); + $state === null or $state->history[] = new AssertionSuccess( value: Support::stringify($value), assertion: $assertion, context: $context, @@ -98,7 +109,8 @@ public static function success(mixed $value, string $assertion, string $context */ public static function fail(Record&\Throwable $failure): never { - self::$state === null or self::$state->history[] = $failure; + $state = self::current(); + $state === null or $state->history[] = $failure; throw $failure; } @@ -118,17 +130,17 @@ public static function expectException( string|\Throwable $classOrObject, bool $same = false, ): ExpectExceptionHandler { - self::$state === null and throw new StateNotFound(); + $state = self::current() ?? throw new StateNotFound(); - return self::$state->expectations[] = $same + return $state->expectations[] = $same ? ExpectExceptionHandler::createSame($classOrObject) : ExpectExceptionHandler::createEquals($classOrObject); } public static function expectFail(Fail $exception): void { - self::$state === null and throw new StateNotFound(); - self::$state->expectations[] = new ExpectedFail($exception); + $state = self::current() ?? throw new StateNotFound(); + $state->expectations[] = new ExpectedFail($exception); } /** @@ -140,9 +152,17 @@ public static function expectFail(Fail $exception): void */ public static function trackObjectsLeak(bool $notLeaks, object ...$objects): Leaks|NotLeaks { - self::$state === null and throw new StateNotFound(); - return self::$state->expectations[] = $notLeaks + $state = self::current() ?? throw new StateNotFound(); + return $state->expectations[] = $notLeaks ? new NotLeaks(...$objects) : new Leaks(...$objects); } + + /** + * @return FiberLocal + */ + private static function store(): FiberLocal + { + return self::$active ??= new FiberLocal(null); + } } diff --git a/plugin/assert/tests/Feature/ConcurrentAssertIsolationTest.php b/plugin/assert/tests/Feature/ConcurrentAssertIsolationTest.php new file mode 100644 index 0000000..5710e5e --- /dev/null +++ b/plugin/assert/tests/Feature/ConcurrentAssertIsolationTest.php @@ -0,0 +1,94 @@ +status, Status::Passed); + + $state = $result->getAttribute(TestState::class); + Assert::instanceOf($state, TestState::class); + Assert::count($state->history, $expected); + } +} diff --git a/plugin/assert/tests/Stub/Concurrency/RandomAssertScenarios.php b/plugin/assert/tests/Stub/Concurrency/RandomAssertScenarios.php new file mode 100644 index 0000000..b69b0fb --- /dev/null +++ b/plugin/assert/tests/Stub/Concurrency/RandomAssertScenarios.php @@ -0,0 +1,47 @@ + $suspension->resume()); + $suspension->suspend(); + } + } +} diff --git a/plugin/assert/tests/Stub/Concurrency/RoundRobinAssertScenarios.php b/plugin/assert/tests/Stub/Concurrency/RoundRobinAssertScenarios.php new file mode 100644 index 0000000..84d1588 --- /dev/null +++ b/plugin/assert/tests/Stub/Concurrency/RoundRobinAssertScenarios.php @@ -0,0 +1,49 @@ +status, Status::Passed); + + $contents = \array_map( + static fn(Message $message): string => $message->content, + $result->messages->channel(MessengerConcurrency::CHANNEL), + ); + + $expected = []; + for ($i = 1; $i <= $count; $i++) { + $expected[] = $prefix . '-' . $i; + } + + Assert::same($contents, $expected); + } +} diff --git a/tests/Application/Stub/Messenger/Concurrency/MessengerConcurrency.php b/tests/Application/Stub/Messenger/Concurrency/MessengerConcurrency.php new file mode 100644 index 0000000..58490ef --- /dev/null +++ b/tests/Application/Stub/Messenger/Concurrency/MessengerConcurrency.php @@ -0,0 +1,15 @@ +logAndVerifyOwnMessages('alpha', 3); + } + + public function betaLogsFourMessages(): void + { + $this->logAndVerifyOwnMessages('beta', 4); + } + + public function gammaLogsFiveMessages(): void + { + $this->logAndVerifyOwnMessages('gamma', 5); + } + + /** + * Log `$count` messages tagged with `$prefix`, awaiting a real timer after each so the other tests + * interleave on the loop, then assert this test's own buffer holds exactly its own messages. + */ + private function logAndVerifyOwnMessages(string $prefix, int $count): void + { + $expected = []; + for ($i = 1; $i <= $count; $i++) { + $content = $prefix . '-' . $i; + $this->messenger->log(MessengerConcurrency::CHANNEL, $content); + $expected[] = $content; + + $suspension = EventLoop::getSuspension(); + EventLoop::delay(0.001, static fn() => $suspension->resume()); + $suspension->suspend(); + } + + $mine = \array_map( + static fn(Message $message): string => $message->content, + $this->messenger->getMessages()->channel(MessengerConcurrency::CHANNEL), + ); + + Assert::same($mine, $expected); + } +} diff --git a/tests/Application/Stub/Messenger/Concurrency/RoundRobinMessengerScenarios.php b/tests/Application/Stub/Messenger/Concurrency/RoundRobinMessengerScenarios.php new file mode 100644 index 0000000..46c6261 --- /dev/null +++ b/tests/Application/Stub/Messenger/Concurrency/RoundRobinMessengerScenarios.php @@ -0,0 +1,67 @@ +logAndVerifyOwnMessages('alpha', 3); + } + + public function betaLogsFourMessages(): void + { + $this->logAndVerifyOwnMessages('beta', 4); + } + + public function gammaLogsFiveMessages(): void + { + $this->logAndVerifyOwnMessages('gamma', 5); + } + + /** + * Log `$count` messages tagged with `$prefix` on the {@see MessengerConcurrency::CHANNEL} channel, + * suspending after each so the other tests interleave, then assert this test's own message buffer holds + * exactly its own messages — nothing a sibling logged in between. + */ + private function logAndVerifyOwnMessages(string $prefix, int $count): void + { + $expected = []; + for ($i = 1; $i <= $count; $i++) { + $content = $prefix . '-' . $i; + $this->messenger->log(MessengerConcurrency::CHANNEL, $content); + $expected[] = $content; + \Fiber::suspend(); + } + + $mine = \array_map( + static fn(Message $message): string => $message->content, + $this->messenger->getMessages()->channel(MessengerConcurrency::CHANNEL), + ); + + Assert::same($mine, $expected); + } +} diff --git a/tests/Core/Pipeline/OutputInterceptorTest.php b/tests/Core/Pipeline/OutputInterceptorTest.php index a395d2c..34274db 100644 --- a/tests/Core/Pipeline/OutputInterceptorTest.php +++ b/tests/Core/Pipeline/OutputInterceptorTest.php @@ -8,6 +8,7 @@ use Testo\Codecov\Covers; use Testo\Common\Messenger; use Testo\Core\Context\CaseInfo; +use Testo\Core\Context\TestIdentity; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Definition\CaseDefinition; @@ -31,7 +32,7 @@ public function runTestCallsNextWithInfoAndReturnsResult(): void $testResult = new TestResult($testInfo, Status::Passed); $received = null; - $result = $interceptor->runTest($testInfo, function(TestInfo $info) use (&$received, $testResult): TestResult { + $result = $interceptor->runTest($testInfo, static function (TestInfo $info) use (&$received, $testResult): TestResult { $received = $info; return $testResult; }); @@ -47,7 +48,7 @@ public function runTestReturnsOriginalResultWhenMessagesEmpty(): void $interceptor = new OutputInterceptor($messenger); $testResult = new TestResult($testInfo, Status::Passed); - $result = $interceptor->runTest($testInfo, fn() => $testResult); + $result = $interceptor->runTest($testInfo, static fn() => $testResult); Assert::same($result, $testResult); Assert::true($result->messages->isEmpty()); @@ -60,7 +61,7 @@ public function runTestAttachesMessagesToResultWhenMessagesPresent(): void $interceptor = new OutputInterceptor($messenger); $testResult = new TestResult($testInfo, Status::Passed); - $result = $interceptor->runTest($testInfo, function() use ($messenger, $testResult): TestResult { + $result = $interceptor->runTest($testInfo, static function () use ($messenger, $testResult): TestResult { $messenger->recordMessage(new Message( time: \microtime(true), channel: 'stdout', @@ -86,7 +87,7 @@ public function runTestIsolatesPreScopeMessagesFromResult(): void $interceptor = new OutputInterceptor($messenger); $testResult = new TestResult($testInfo, Status::Passed); - $result = $interceptor->runTest($testInfo, function() use ($messenger, $testResult): TestResult { + $result = $interceptor->runTest($testInfo, static function () use ($messenger, $testResult): TestResult { $messenger->recordMessage(new Message( time: \microtime(true), channel: 'stdout', @@ -107,7 +108,7 @@ public function runTestPreservesResultStatusWhenAttachingMessages(): void $interceptor = new OutputInterceptor($messenger); $testResult = new TestResult($testInfo, Status::Failed, failure: new \Exception('Test failed')); - $result = $interceptor->runTest($testInfo, function() use ($messenger, $testResult): TestResult { + $result = $interceptor->runTest($testInfo, static function () use ($messenger, $testResult): TestResult { $messenger->recordMessage(new Message( time: \microtime(true), channel: 'stdout', @@ -167,7 +168,7 @@ public function channel(string $name): Messenger\Channel * empty child buffer for the duration of the callback so {@see getMessages()} inside observes * only what was written within the scope, then restores the parent and discards the child. */ - public function scope(\Closure $scope): mixed + public function scope(\Closure $scope, ?TestIdentity $identity = null): mixed { $parent = $this->messages; $this->messages = []; diff --git a/tests/Output/Unit/Teamcity/TeamcityLoggerTest.php b/tests/Output/Unit/Teamcity/TeamcityLoggerTest.php index 1b488f1..d0e2aa8 100644 --- a/tests/Output/Unit/Teamcity/TeamcityLoggerTest.php +++ b/tests/Output/Unit/Teamcity/TeamcityLoggerTest.php @@ -12,6 +12,8 @@ use Testo\Core\Context\TestResult; use Testo\Core\Definition\CaseDefinition; use Testo\Core\Definition\TestDefinition; +use Testo\Core\Log\Level; +use Testo\Core\Log\Message; use Testo\Core\Value\Status; use Testo\Output\Teamcity\Teamcity\TeamcityLogger; use Testo\Test; @@ -124,6 +126,53 @@ public function logEmptyRunEmitsBuildProblem(): void Assert::string($output)->contains("description='No tests were executed'"); } + public function testStartedFromInfoStampsFlowIdFromIdentity(): void + { + $info = self::makeInfo('passingTest'); + + $output = self::capture(static fn(TeamcityLogger $logger) => $logger->testStartedFromInfo($info)); + + Assert::string($output)->contains("flowId='{$info->identity->id}'"); + } + + public function handleSingleTestResultStampsFlowIdFromIdentity(): void + { + $result = self::makeFailedResult(new \RuntimeException('boom')); + + $output = self::capture(static fn(TeamcityLogger $logger) => $logger->handleSingleTestResult($result)); + + // Both the failure and the finish message carry the test's flow, so a consumer keeps them together. + Assert::same(\substr_count($output, "flowId='{$result->info->identity->id}'"), 2); + } + + public function logMessageStampsTheGivenFlowId(): void + { + $message = new Message(time: 0.0, channel: 'stdout', level: Level::Info, content: 'streamed line'); + + $output = self::capture( + static fn(TeamcityLogger $logger) => $logger->logMessage('someTest', $message, '4242'), + ); + + Assert::string($output)->contains("flowId='4242'"); + Assert::string($output)->contains('streamed line'); + } + + public function distinctTestsGetDistinctFlowIds(): void + { + $first = self::makeInfo('passingTest'); + $second = self::makeInfo('passingTest'); + + // Two separate runs of the same method are distinct in-flight tests — their flows must differ so + // that, when interleaved, a consumer never merges their messages. + Assert::notSame($first->identity->id, $second->identity->id); + + $a = self::capture(static fn(TeamcityLogger $logger) => $logger->testStartedFromInfo($first)); + $b = self::capture(static fn(TeamcityLogger $logger) => $logger->testStartedFromInfo($second)); + + Assert::string($a)->contains("flowId='{$first->identity->id}'"); + Assert::string($b)->contains("flowId='{$second->identity->id}'"); + } + /** * Runs the callback against a logger writing to an in-memory stream and returns what it wrote. * diff --git a/tests/Sandbox/Self/AsyncTest.php b/tests/Sandbox/Self/AsyncTest.php new file mode 100644 index 0000000..3ab9916 --- /dev/null +++ b/tests/Sandbox/Self/AsyncTest.php @@ -0,0 +1,80 @@ + + */ + private const SLEEP_SECONDS = [ + 'quick' => 0.10, + 'steady' => 0.20, + 'slow' => 0.35, + ]; + + /** How many work-and-sleep steps each test performs. */ + private const STEPS = 5; + + public function quickBursts(): void + { + self::workThenSleep('quick'); + } + + public function steadyProgress(): void + { + self::workThenSleep('steady'); + } + + public function slowGrind(): void + { + self::workThenSleep('slow'); + } + + /** + * Do {@see STEPS} steps of "work" (an echoed progress line) separated by a real timer sleep of the + * `$label`'s duration, yielding the loop each time so the other tests run in between. + */ + private static function workThenSleep(string $label): void + { + $step = self::SLEEP_SECONDS[$label]; + + $done = 0; + for ($i = 1; $i <= self::STEPS; $i++) { + self::sleep($step); + ++$done; + echo \sprintf("[%s] step %d/%d (slept %.2fs)\n", $label, $i, self::STEPS, $step); + } + + Assert::same($done, self::STEPS); + } + + /** + * Suspend this test's fiber for `$seconds` on the Revolt loop, letting the sibling tests run while it + * naps, then resume once the timer fires. + */ + private static function sleep(float $seconds): void + { + $suspension = EventLoop::getSuspension(); + EventLoop::delay($seconds, static fn() => $suspension->resume()); + $suspension->suspend(); + } +}