From 1326f17eb64013eab0c44cdd80776f6b4e046faa Mon Sep 17 00:00:00 2001 From: Tobias Bachert Date: Tue, 24 May 2022 15:17:01 +0200 Subject: [PATCH] Support async php runtimes (#675) * Remove implicit root scope * Provide access to active scope and context of scope * Add local storage to scope * Trigger fiber error only when crossing fiber boundaries * Split ContextStorage into two interfaces * Add swoole context storage Implementation currently incompatible with fibers. * Fix cs * Make default context storage execution context aware * Add scope bound callable and promise for async user implementations * Resolve/suppress phan/psalm/phpstan issues * Improve ContextStorage test coverage Adds tests for newly added features and fixes covers annotations. * Apply feedback - move Swoole context storage to Contrib/Context/Swoole - use self instead of classname --- src/Context/Context.php | 14 ++- src/Context/ContextStorage.php | 24 +++-- src/Context/ContextStorageHead.php | 2 +- src/Context/ContextStorageInterface.php | 8 +- src/Context/ContextStorageNode.php | 43 ++++++-- src/Context/ContextStorageScopeInterface.php | 17 +++ .../ExecutionContextAwareInterface.php | 23 ++++ src/Context/FiberBoundContextStorage.php | 84 +++++++++++++++ src/Context/FiberBoundContextStorageScope.php | 67 ++++++++++++ .../FiberNotSupportedContextStorage.php | 64 ----------- src/Context/ScopeBound/ContextHolder.php | 20 ++++ src/Context/ScopeBound/ScopeBoundCallable.php | 31 ++++++ src/Context/ScopeBound/ScopeBoundPromise.php | 77 ++++++++++++++ .../fiber/initialize_fiber_handler.php | 2 +- .../Swoole/SwooleContextDestructor.php | 27 +++++ .../Context/Swoole/SwooleContextHandler.php | 61 +++++++++++ .../Context/Swoole/SwooleContextScope.php | 60 +++++++++++ .../Context/Swoole/SwooleContextStorage.php | 77 ++++++++++++++ .../test_context_switching_not_supported.phpt | 6 +- tests/Unit/Context/ContextStorageTest.php | 100 ++++++++++++++++++ tests/Unit/Context/ContextTest.php | 51 +-------- .../ScopeBound/ScopeBoundCallableTest.php | 33 ++++++ .../ScopeBound/ScopeBoundPromiseTest.php | 87 +++++++++++++++ tests/Unit/Context/ScopeTest.php | 55 +++++++++- 24 files changed, 887 insertions(+), 146 deletions(-) create mode 100644 src/Context/ContextStorageScopeInterface.php create mode 100644 src/Context/ExecutionContextAwareInterface.php create mode 100644 src/Context/FiberBoundContextStorage.php create mode 100644 src/Context/FiberBoundContextStorageScope.php delete mode 100644 src/Context/FiberNotSupportedContextStorage.php create mode 100644 src/Context/ScopeBound/ContextHolder.php create mode 100644 src/Context/ScopeBound/ScopeBoundCallable.php create mode 100644 src/Context/ScopeBound/ScopeBoundPromise.php create mode 100644 src/Contrib/Context/Swoole/SwooleContextDestructor.php create mode 100644 src/Contrib/Context/Swoole/SwooleContextHandler.php create mode 100644 src/Contrib/Context/Swoole/SwooleContextScope.php create mode 100644 src/Contrib/Context/Swoole/SwooleContextStorage.php create mode 100644 tests/Unit/Context/ContextStorageTest.php create mode 100644 tests/Unit/Context/ScopeBound/ScopeBoundCallableTest.php create mode 100644 tests/Unit/Context/ScopeBound/ScopeBoundPromiseTest.php diff --git a/src/Context/Context.php b/src/Context/Context.php index 5b0c75a65..3d6f82a8b 100644 --- a/src/Context/Context.php +++ b/src/Context/Context.php @@ -9,21 +9,31 @@ */ class Context { - private static ?ContextStorageInterface $storage = null; + + /** + * @var ContextStorageInterface&ExecutionContextAwareInterface + */ + private static ContextStorageInterface $storage; private static ?\OpenTelemetry\Context\Context $root = null; /** * @internal + * + * @param ContextStorageInterface&ExecutionContextAwareInterface $storage */ public static function setStorage(ContextStorageInterface $storage): void { self::$storage = $storage; } + /** + * @return ContextStorageInterface&ExecutionContextAwareInterface + */ public static function storage(): ContextStorageInterface { - return self::$storage ??= new ContextStorage(self::getRoot()); + /** @psalm-suppress RedundantPropertyInitializationCheck */ + return self::$storage ??= new ContextStorage(); } /** diff --git a/src/Context/ContextStorage.php b/src/Context/ContextStorage.php index d70011d3f..fbb453f2d 100644 --- a/src/Context/ContextStorage.php +++ b/src/Context/ContextStorage.php @@ -7,40 +7,46 @@ /** * @internal */ -final class ContextStorage implements ContextStorageInterface +final class ContextStorage implements ContextStorageInterface, ExecutionContextAwareInterface { public ContextStorageHead $current; private ContextStorageHead $main; - /** @var array */ + /** @var array */ private array $forks = []; - public function __construct(Context $context) + public function __construct() { $this->current = $this->main = new ContextStorageHead($this); - $this->current->node = new ContextStorageNode($context, $this->current); } - public function fork(int $id): void + public function fork($id): void { $this->forks[$id] = clone $this->current; } - public function switch(int $id): void + public function switch($id): void { $this->current = $this->forks[$id] ?? $this->main; } - public function destroy(int $id): void + public function destroy($id): void { unset($this->forks[$id]); } + public function scope(): ?ContextStorageScopeInterface + { + return ($this->current->node->head ?? null) === $this->current + ? $this->current->node + : null; + } + public function current(): Context { - return $this->current->node->context; + return $this->current->node->context ?? Context::getRoot(); } - public function attach(Context $context): ScopeInterface + public function attach(Context $context): ContextStorageScopeInterface { return $this->current->node = new ContextStorageNode($context, $this->current, $this->current->node); } diff --git a/src/Context/ContextStorageHead.php b/src/Context/ContextStorageHead.php index d7bd3b30d..3cc4d7181 100644 --- a/src/Context/ContextStorageHead.php +++ b/src/Context/ContextStorageHead.php @@ -10,7 +10,7 @@ final class ContextStorageHead { public ContextStorage $storage; - public ContextStorageNode $node; + public ?ContextStorageNode $node = null; public function __construct(ContextStorage $storage) { diff --git a/src/Context/ContextStorageInterface.php b/src/Context/ContextStorageInterface.php index a3d7158aa..4a8f299e2 100644 --- a/src/Context/ContextStorageInterface.php +++ b/src/Context/ContextStorageInterface.php @@ -6,13 +6,9 @@ interface ContextStorageInterface { - public function fork(int $id): void; - - public function switch(int $id): void; - - public function destroy(int $id): void; + public function scope(): ?ContextStorageScopeInterface; public function current(): Context; - public function attach(Context $context): ScopeInterface; + public function attach(Context $context): ContextStorageScopeInterface; } diff --git a/src/Context/ContextStorageNode.php b/src/Context/ContextStorageNode.php index ad7e0b4a2..fe9cb72b0 100644 --- a/src/Context/ContextStorageNode.php +++ b/src/Context/ContextStorageNode.php @@ -9,11 +9,12 @@ /** * @internal */ -final class ContextStorageNode implements ScopeInterface +final class ContextStorageNode implements ScopeInterface, ContextStorageScopeInterface { public Context $context; - private ContextStorageHead $head; + public ContextStorageHead $head; private ?ContextStorageNode $previous; + private array $localStorage = []; public function __construct( Context $context, @@ -25,6 +26,35 @@ public function __construct( $this->previous = $previous; } + public function offsetExists($offset): bool + { + return isset($this->localStorage[$offset]); + } + + /** + * @phan-suppress PhanUndeclaredClassAttribute + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->localStorage[$offset]; + } + + public function offsetSet($offset, $value): void + { + $this->localStorage[$offset] = $value; + } + + public function offsetUnset($offset): void + { + unset($this->localStorage[$offset]); + } + + public function context(): Context + { + return $this->context; + } + public function detach(): int { $flags = 0; @@ -33,24 +63,25 @@ public function detach(): int } if ($this === $this->head->node) { - assert($this->previous !== null); + assert($this->previous !== $this); $this->head->node = $this->previous; - $this->previous = null; + $this->previous = $this; return $flags; } - if (!$this->previous) { + if ($this->previous === $this) { return $flags | ScopeInterface::DETACHED; } + assert($this->head->node !== null); for ($n = $this->head->node, $depth = 1; $n->previous !== $this; $n = $n->previous, $depth++) { assert($n->previous !== null); } $n->previous = $this->previous; - $this->previous = null; + $this->previous = $this; return $flags | ScopeInterface::MISMATCH | $depth; } diff --git a/src/Context/ContextStorageScopeInterface.php b/src/Context/ContextStorageScopeInterface.php new file mode 100644 index 000000000..00c476712 --- /dev/null +++ b/src/Context/ContextStorageScopeInterface.php @@ -0,0 +1,17 @@ +storage = $storage; + } + + public function fork($id): void + { + $this->storage->fork($id); + } + + public function switch($id): void + { + $this->storage->switch($id); + } + + public function destroy($id): void + { + $this->storage->destroy($id); + } + + public function scope(): ?ContextStorageScopeInterface + { + $this->checkFiberMismatch(); + + if (!$scope = $this->storage->scope()) { + return null; + } + + return new FiberBoundContextStorageScope($scope); + } + + public function current(): Context + { + $this->checkFiberMismatch(); + + return $this->storage->current(); + } + + public function attach(Context $context): ContextStorageScopeInterface + { + $scope = $this->storage->attach($context); + assert(class_exists(Fiber::class, false)); + $scope[Fiber::class] = Fiber::getCurrent(); + + return new FiberBoundContextStorageScope($scope); + } + + private function checkFiberMismatch(): void + { + $scope = $this->storage->scope(); + assert(class_exists(Fiber::class, false)); + if ($scope && $scope[Fiber::class] !== Fiber::getCurrent()) { + trigger_error('Fiber context switching not supported', E_USER_WARNING); + } + } +} diff --git a/src/Context/FiberBoundContextStorageScope.php b/src/Context/FiberBoundContextStorageScope.php new file mode 100644 index 000000000..14236423f --- /dev/null +++ b/src/Context/FiberBoundContextStorageScope.php @@ -0,0 +1,67 @@ +scope = $scope; + } + + public function offsetExists($offset): bool + { + return $this->scope->offsetExists($offset); + } + + /** + * @phan-suppress PhanUndeclaredClassAttribute + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->scope->offsetGet($offset); + } + + public function offsetSet($offset, $value): void + { + $this->scope->offsetSet($offset, $value); + } + + public function offsetUnset($offset): void + { + $this->scope->offsetUnset($offset); + } + + public function context(): Context + { + return $this->scope->context(); + } + + public function detach(): int + { + $flags = $this->scope->detach(); + assert(class_exists(Fiber::class, false)); + if ($this->scope[Fiber::class] !== Fiber::getCurrent()) { + $flags |= ScopeInterface::INACTIVE; + } + + return $flags; + } +} diff --git a/src/Context/FiberNotSupportedContextStorage.php b/src/Context/FiberNotSupportedContextStorage.php deleted file mode 100644 index 26ef8ca51..000000000 --- a/src/Context/FiberNotSupportedContextStorage.php +++ /dev/null @@ -1,64 +0,0 @@ -storage = $storage; - } - - public function fork(int $id): void - { - $this->storage->fork($id); - } - - public function switch(int $id): void - { - $this->storage->switch($id); - } - - public function destroy(int $id): void - { - $this->storage->destroy($id); - } - - public function current(): Context - { - assert(class_exists(Fiber::class)); - if (Fiber::getCurrent()) { - trigger_error('Fiber context switching not supported', E_USER_WARNING); - } - - return $this->storage->current(); - } - - public function attach(Context $context): ScopeInterface - { - assert(class_exists(Fiber::class)); - if (Fiber::getCurrent()) { - trigger_error('Fiber context switching not supported', E_USER_WARNING); - } - - return $this->storage->attach($context); - } -} diff --git a/src/Context/ScopeBound/ContextHolder.php b/src/Context/ScopeBound/ContextHolder.php new file mode 100644 index 000000000..ff5780c13 --- /dev/null +++ b/src/Context/ScopeBound/ContextHolder.php @@ -0,0 +1,20 @@ +context = $context; + } +} diff --git a/src/Context/ScopeBound/ScopeBoundCallable.php b/src/Context/ScopeBound/ScopeBoundCallable.php new file mode 100644 index 000000000..969b14f51 --- /dev/null +++ b/src/Context/ScopeBound/ScopeBoundCallable.php @@ -0,0 +1,31 @@ +current(); + + return function (...$args) use ($callable, $context, $storage) { + $scope = $storage->attach($context); + + try { + return $callable(...$args); + } finally { + $scope->detach(); + } + }; + } +} diff --git a/src/Context/ScopeBound/ScopeBoundPromise.php b/src/Context/ScopeBound/ScopeBoundPromise.php new file mode 100644 index 000000000..cd26e99f9 --- /dev/null +++ b/src/Context/ScopeBound/ScopeBoundPromise.php @@ -0,0 +1,77 @@ +storage = $storage; + $this->contextHolder = $contextHolder; + $this->promise = $promise; + } + + public static function wrap(object $promise, ?ContextStorageInterface $storage = null): ScopeBoundPromise + { + if (!method_exists($promise, 'then')) { + throw new InvalidArgumentException(); + } + + $storage ??= Context::storage(); + + return $promise instanceof ScopeBoundPromise && $storage === $promise->storage + ? $promise + : new self($storage, new ContextHolder($storage->current()), $promise); + } + + public function then(callable $onFulfilled = null, callable $onRejected = null): ScopeBoundPromise + { + $contextHolder = new ContextHolder($this->storage->current()); + + return new self($this->storage, $contextHolder, $this->promise->then( + $this->wrapCallback($onFulfilled, $contextHolder), + $this->wrapCallback($onRejected, $contextHolder), + )); + } + + private function wrapCallback(?callable $callable, ContextHolder $child): ?callable + { + if (!$callable) { + return null; + } + + return function (...$args) use ($callable, $child) { + $scope = $this->storage->attach($this->contextHolder->context); + $scope[__CLASS__] = $sentinel = new stdClass(); + + try { + return $callable(...$args); + } finally { + $child->context = $this->storage->current(); + while (($s = $this->storage->scope()) && ($s[__CLASS__] ?? null) !== $sentinel) { + $s->detach(); + } + + $scope->detach(); + } + }; + } +} diff --git a/src/Context/fiber/initialize_fiber_handler.php b/src/Context/fiber/initialize_fiber_handler.php index 9487a8f96..7a23b181c 100644 --- a/src/Context/fiber/initialize_fiber_handler.php +++ b/src/Context/fiber/initialize_fiber_handler.php @@ -18,5 +18,5 @@ if ($observer->isEnabled() && $observer->init()) { // ffi fiber support enabled } else { - Context::setStorage(new FiberNotSupportedContextStorage(Context::storage())); + Context::setStorage(new FiberBoundContextStorage(Context::storage())); } diff --git a/src/Contrib/Context/Swoole/SwooleContextDestructor.php b/src/Contrib/Context/Swoole/SwooleContextDestructor.php new file mode 100644 index 000000000..ce1801d41 --- /dev/null +++ b/src/Contrib/Context/Swoole/SwooleContextDestructor.php @@ -0,0 +1,27 @@ +storage = $storage; + $this->cid = $cid; + } + + public function __destruct() + { + $this->storage->destroy($this->cid); + } +} diff --git a/src/Contrib/Context/Swoole/SwooleContextHandler.php b/src/Contrib/Context/Swoole/SwooleContextHandler.php new file mode 100644 index 000000000..4e3342829 --- /dev/null +++ b/src/Contrib/Context/Swoole/SwooleContextHandler.php @@ -0,0 +1,61 @@ +storage = $storage; + } + + public function switchToActiveCoroutine(): void + { + $cid = Coroutine::getCid(); + if ($cid !== -1 && !$this->isForked($cid)) { + for ($pcid = $cid; ($pcid = Coroutine::getPcid($pcid)) !== -1 && !$this->isForked($pcid);) { + } + + $this->storage->switch($pcid); + $this->forkCoroutine($cid); + } + + $this->storage->switch($cid); + } + + public function splitOffChildCoroutines(): void + { + $pcid = Coroutine::getCid(); + foreach (Coroutine::listCoroutines() as $cid) { + if ($pcid === Coroutine::getPcid($cid) && !$this->isForked($cid)) { + $this->forkCoroutine($cid); + } + } + } + + private function isForked(int $cid): bool + { + return isset(Coroutine::getContext($cid)[__CLASS__]); + } + + private function forkCoroutine(int $cid): void + { + $this->storage->fork($cid); + Coroutine::getContext($cid)[__CLASS__] = new SwooleContextDestructor($this->storage, $cid); + } +} diff --git a/src/Contrib/Context/Swoole/SwooleContextScope.php b/src/Contrib/Context/Swoole/SwooleContextScope.php new file mode 100644 index 000000000..3a5e89de4 --- /dev/null +++ b/src/Contrib/Context/Swoole/SwooleContextScope.php @@ -0,0 +1,60 @@ +scope = $scope; + $this->handler = $handler; + } + + public function offsetExists($offset): bool + { + return $this->scope->offsetExists($offset); + } + + /** + * @phan-suppress PhanUndeclaredClassAttribute + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->scope->offsetGet($offset); + } + + public function offsetSet($offset, $value): void + { + $this->scope->offsetSet($offset, $value); + } + + public function offsetUnset($offset): void + { + $this->scope->offsetUnset($offset); + } + + public function context(): Context + { + return $this->scope->context(); + } + + public function detach(): int + { + $this->handler->switchToActiveCoroutine(); + + return $this->scope->detach(); + } +} diff --git a/src/Contrib/Context/Swoole/SwooleContextStorage.php b/src/Contrib/Context/Swoole/SwooleContextStorage.php new file mode 100644 index 000000000..a7a0d3725 --- /dev/null +++ b/src/Contrib/Context/Swoole/SwooleContextStorage.php @@ -0,0 +1,77 @@ +storage = $storage; + $this->handler = new SwooleContextHandler($storage); + } + + public function fork($id): void + { + $this->handler->switchToActiveCoroutine(); + + $this->storage->fork($id); + } + + public function switch($id): void + { + $this->handler->switchToActiveCoroutine(); + + $this->storage->switch($id); + } + + public function destroy($id): void + { + $this->handler->switchToActiveCoroutine(); + + $this->storage->destroy($id); + } + + public function scope(): ?ContextStorageScopeInterface + { + $this->handler->switchToActiveCoroutine(); + + if (!$scope = $this->storage->scope()) { + return null; + } + + return new SwooleContextScope($scope, $this->handler); + } + + public function current(): Context + { + $this->handler->switchToActiveCoroutine(); + + return $this->storage->current(); + } + + public function attach(Context $context): ContextStorageScopeInterface + { + $this->handler->switchToActiveCoroutine(); + $this->handler->splitOffChildCoroutines(); + + $scope = $this->storage->attach($context); + + return new SwooleContextScope($scope, $this->handler); + } +} diff --git a/tests/Integration/Context/Fiber/test_context_switching_not_supported.phpt b/tests/Integration/Context/Fiber/test_context_switching_not_supported.phpt index 8fdf5a9e5..84aab7ff1 100644 --- a/tests/Integration/Context/Fiber/test_context_switching_not_supported.phpt +++ b/tests/Integration/Context/Fiber/test_context_switching_not_supported.phpt @@ -43,14 +43,10 @@ $scope->detach(); --EXPECTF-- main:main -Warning: Fiber context switching not supported in %s - -Warning: Fiber context switching not supported in %s - Warning: Fiber context switching not supported in %s fiber:fiber -main:fiber Warning: Fiber context switching not supported in %s +main:fiber fiber:fiber main:main diff --git a/tests/Unit/Context/ContextStorageTest.php b/tests/Unit/Context/ContextStorageTest.php new file mode 100644 index 000000000..87af5989e --- /dev/null +++ b/tests/Unit/Context/ContextStorageTest.php @@ -0,0 +1,100 @@ +assertNull($storage->scope()); + } + + public function test_scope_returns_non_null_after_attach(): void + { + $storage = new ContextStorage(); + $storage->attach($storage->current()); + $this->assertNotNull($storage->scope()); + } + + public function test_scope_returns_null_in_new_fork(): void + { + $storage = new ContextStorage(); + $storage->attach($storage->current()); + $storage->fork(1); + $storage->switch(1); + $this->assertNull($storage->scope()); + } + + public function test_storage_switch_treats_unknown_id_as_main(): void + { + $storage = new ContextStorage(); + + $storage->fork(1); + $storage->attach($storage->current()); + $storage->switch(1); + + $storage->switch(2); + $this->assertNotNull($storage->scope()); + } + + public function test_storage_switch_switches_context(): void + { + $storage = new ContextStorage(); + $main = new Context(); + $fork = new Context(); + + $scopeMain = $storage->attach($main); + + // Fiber start + $storage->fork(1); + $storage->switch(1); + $this->assertSame($main, $storage->current()); + + $scopeFork = $storage->attach($fork); + $this->assertSame($fork, $storage->current()); + + // Fiber suspend + $storage->switch(0); + $this->assertSame($main, $storage->current()); + + // Fiber resume + $storage->switch(1); + $this->assertSame($fork, $storage->current()); + + $scopeFork->detach(); + + // Fiber return + $storage->switch(0); + $storage->destroy(1); + + $scopeMain->detach(); + } + + public function test_storage_fork_keeps_forked_root(): void + { + $storage = new ContextStorage(); + $main = new Context(); + + $scopeMain = $storage->attach($main); + $storage->fork(1); + $scopeMain->detach(); + + $storage->switch(1); + $this->assertSame($main, $storage->current()); + + $storage->switch(0); + $storage->destroy(1); + } +} diff --git a/tests/Unit/Context/ContextTest.php b/tests/Unit/Context/ContextTest.php index 319a10cc0..ecc7d0c88 100644 --- a/tests/Unit/Context/ContextTest.php +++ b/tests/Unit/Context/ContextTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace OpenTelemetry\Tests\Context\Unit; +namespace OpenTelemetry\Tests\Unit\Context; use OpenTelemetry\Context\Context; use OpenTelemetry\Context\ContextKey; use PHPUnit\Framework\TestCase; /** - * @covers OpenTelemetry\Context\Context + * @covers \OpenTelemetry\Context\Context */ class ContextTest extends TestCase { @@ -210,51 +210,4 @@ public function test_static_with_passed_ctx_does_not_use_current(): void $otherCtx = Context::withValue($key2, '222', new Context()); $this->assertSame($currentCtx, Context::getCurrent()); } - - public function test_storage_switch_switches_context(): void - { - $main = new Context(); - $fork = new Context(); - - $scopeMain = Context::attach($main); - - // Fiber start - Context::storage()->fork(1); - Context::storage()->switch(1); - $this->assertSame($main, Context::getCurrent()); - - $scopeFork = Context::attach($fork); - $this->assertSame($fork, Context::getCurrent()); - - // Fiber suspend - Context::storage()->switch(0); - $this->assertSame($main, Context::getCurrent()); - - // Fiber resume - Context::storage()->switch(1); - $this->assertSame($fork, Context::getCurrent()); - - $scopeFork->detach(); - - // Fiber return - Context::storage()->switch(0); - Context::storage()->destroy(1); - - $scopeMain->detach(); - } - - public function test_storage_fork_keeps_forked_root(): void - { - $main = new Context(); - - $scopeMain = Context::attach($main); - Context::storage()->fork(1); - $scopeMain->detach(); - - Context::storage()->switch(1); - $this->assertSame($main, Context::getCurrent()); - - Context::storage()->switch(0); - Context::storage()->destroy(1); - } } diff --git a/tests/Unit/Context/ScopeBound/ScopeBoundCallableTest.php b/tests/Unit/Context/ScopeBound/ScopeBoundCallableTest.php new file mode 100644 index 000000000..090b9537f --- /dev/null +++ b/tests/Unit/Context/ScopeBound/ScopeBoundCallableTest.php @@ -0,0 +1,33 @@ +attach($storage->current()->with($contextKey, 'value')); + + $callable = fn (ContextKey $contextKey) => $storage->current()->get($contextKey); + $scopeBoundCallable = ScopeBoundCallable::wrap($callable, $storage); + + $scope->detach(); + + $this->assertNull($callable($contextKey)); + $this->assertSame('value', $scopeBoundCallable($contextKey)); + $this->assertNull($callable($contextKey)); + } +} diff --git a/tests/Unit/Context/ScopeBound/ScopeBoundPromiseTest.php b/tests/Unit/Context/ScopeBound/ScopeBoundPromiseTest.php new file mode 100644 index 000000000..9506d8b77 --- /dev/null +++ b/tests/Unit/Context/ScopeBound/ScopeBoundPromiseTest.php @@ -0,0 +1,87 @@ +then(fn ($value) => $value) + ->then(fn ($value) => $this->assertSame(5, $value)) + ; + } + + public function test_promise_then_propagates_context_in_promise() + { + $storage = new ContextStorage(); + + $contextKey = new ContextKey(); + $promise = self::getFulfilledPromise(); + + ScopeBoundPromise::wrap($promise, $storage) + ->then(fn () => $storage->attach($storage->current()->with($contextKey, 'value'))) + ->then(fn () => $this->assertSame('value', $storage->current()->get($contextKey))) + ; + } + + public function test_promise_then_preserves_scope_outside_of_promise() + { + $storage = new ContextStorage(); + + $contextKey = new ContextKey(); + $promise = self::getFulfilledPromise(); + + ScopeBoundPromise::wrap($promise, $storage) + ->then(fn () => $storage->attach($storage->current()->with($contextKey, 'value'))) + ; + + $this->assertNull($storage->current()->get($contextKey)); + } + + public function test_promise_invalid() + { + $this->expectException(InvalidArgumentException::class); + + ScopeBoundPromise::wrap(new stdClass()); + } + + private static function getFulfilledPromise($value = null): object + { + return new class($value) { + private $value; + + /** + * @param mixed $value + */ + public function __construct($value) + { + $this->value = $value; + } + + public function then(?callable $onFulfilled = null): self + { + return $onFulfilled + ? new self($onFulfilled($this->value)) + : $this; + } + }; + } +} diff --git a/tests/Unit/Context/ScopeTest.php b/tests/Unit/Context/ScopeTest.php index f90686db3..93bc1f20f 100644 --- a/tests/Unit/Context/ScopeTest.php +++ b/tests/Unit/Context/ScopeTest.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace OpenTelemetry\Tests\Context\Unit; +namespace OpenTelemetry\Tests\Unit\Context; use OpenTelemetry\Context\Context; use OpenTelemetry\Context\ContextKey; +use OpenTelemetry\Context\ContextStorage; use OpenTelemetry\Context\ScopeInterface; use PHPUnit\Framework\TestCase; /** - * @covers OpenTelemetry\Context\Context - * @todo check scope vs context - what should this class be testing? + * @covers \OpenTelemetry\Context\ContextStorageNode */ class ScopeTest extends TestCase { @@ -63,6 +63,21 @@ public function test_order_mismatch_scope_detach(): void $this->assertSame(0, $scope2->detach()); } + public function test_order_mismatch_scope_detach_depth(): void + { + $contextStorage = new ContextStorage(); + $context = $contextStorage->current(); + + $scope1 = $contextStorage->attach($context); + $scope2 = $contextStorage->attach($context); + $scope3 = $contextStorage->attach($context); + $scope4 = $contextStorage->attach($context); + + $this->assertSame(ScopeInterface::MISMATCH | 2, $scope2->detach()); + $this->assertSame(ScopeInterface::MISMATCH | 1, $scope3->detach()); + $this->assertSame(0, $scope4->detach()); + $this->assertSame(0, $scope1->detach()); + } public function test_inactive_scope_detach(): void { $scope1 = Context::attach(Context::getCurrent()); @@ -74,4 +89,38 @@ public function test_inactive_scope_detach(): void Context::storage()->switch(0); Context::storage()->destroy(1); } + + public function test_scope_context_returns_context_of_scope(): void + { + $storage = new ContextStorage(); + + $ctx1 = $storage->current()->with(new ContextKey(), 1); + $ctx2 = $storage->current()->with(new ContextKey(), 2); + + $scope1 = $storage->attach($ctx1); + $this->assertSame($ctx1, $scope1->context()); + + $scope2 = $storage->attach($ctx2); + $this->assertSame($ctx1, $scope1->context()); + $this->assertSame($ctx2, $scope2->context()); + + $scope2->detach(); + $this->assertSame($ctx2, $scope2->context()); + } + + public function test_scope_local_storage_is_preserved_between_attach_and_scope(): void + { + $storage = new ContextStorage(); + $scope = $storage->attach($storage->current()); + $scope['key'] = 'value'; + $scope = $storage->scope(); + $this->assertNotNull($scope); + $this->assertArrayHasKey('key', $scope); /** @phpstan-ignore-line */ + $this->assertSame('value', $scope['key']); + + unset($scope['key']); + $scope = $storage->scope(); + $this->assertNotNull($scope); + $this->assertArrayNotHasKey('key', $scope); + } }