From 6b59d12fdc1505cbea2937602d1cfc7962ac0812 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sat, 17 Oct 2026 15:10:15 +0600 Subject: [PATCH] Fix: Suspended child contexts survive after their parent ends --- .../InteractsWithExecutionContexts.php | 47 +++++++ tests/Application/ExecutionContextTest.php | 123 ++++++++++++++++++ 2 files changed, 170 insertions(+) diff --git a/src/Phaseolies/DI/Concerns/InteractsWithExecutionContexts.php b/src/Phaseolies/DI/Concerns/InteractsWithExecutionContexts.php index c4d50480..2ddf5dd1 100644 --- a/src/Phaseolies/DI/Concerns/InteractsWithExecutionContexts.php +++ b/src/Phaseolies/DI/Concerns/InteractsWithExecutionContexts.php @@ -84,6 +84,24 @@ public function endContext(ExecutionContext $context): void $failure = null; + $endingIds = [$context->id() => true]; + + for ($i = $index; $i < count($this->contextStack); $i++) { + $endingIds[$this->contextStack[$i]->id()] = true; + } + + foreach ($this->suspendedDescendantsOf($endingIds) as $descendant) { + if (!isset($this->liveContexts[$descendant->id()])) { + continue; + } + + try { + $this->endContext($descendant); + } catch (\Throwable $e) { + $failure ??= $e; + } + } + while (count($this->contextStack) > $index) { $ending = end($this->contextStack); @@ -306,6 +324,35 @@ private function resetExecutionContexts(): void } } + /** + * Find suspended contexts that were started inside any of the given contexts, deepest first + * + * @param array $ancestorIds + * @return array + */ + private function suspendedDescendantsOf(array $ancestorIds): array + { + $found = []; + + foreach ($this->liveContexts as $live) { + if ($this->contextIndex($live) !== null) { + continue; + } + + for ($parent = $live->parent(); $parent !== null; $parent = $parent->parent()) { + if (isset($ancestorIds[$parent->id()])) { + $found[] = $live; + + break; + } + } + } + + usort($found, fn (ExecutionContext $a, ExecutionContext $b) => $b->depth() <=> $a->depth()); + + return $found; + } + /** * Get the index of a context in the stack, or null if it is not active * diff --git a/tests/Application/ExecutionContextTest.php b/tests/Application/ExecutionContextTest.php index b9255b9f..1fd7b42f 100644 --- a/tests/Application/ExecutionContextTest.php +++ b/tests/Application/ExecutionContextTest.php @@ -217,6 +217,129 @@ public function testSuspendedContextWithoutScopedInstancesStillFiresEndListeners $this->assertSame(1, $ended); } + public function testEndingAParentEndsItsSuspendedChild(): void + { + $outer = $this->container->beginContext('outer'); + $inner = $this->container->beginContext('inner'); + $this->container->suspendContext($inner); + + $this->container->endContext($outer); + $this->container->resumeContext($inner); + + $this->assertNull($this->container->executionContext()); + $this->assertFalse($this->container->hasExecutionContext()); + } + + public function testSuspendedChildEndListenersRunBeforeTheParentsAndOnlyOnce(): void + { + $order = []; + $this->container->contextEnding(function (ExecutionContext $c) use (&$order) { + $order[] = $c->type(); + }); + + $outer = $this->container->beginContext('outer'); + $inner = $this->container->beginContext('inner'); + $this->container->suspendContext($inner); + + $this->container->endContext($outer); + $this->container->endContext($inner); // already ended, must not fire again + + $this->assertSame(['inner', 'outer'], $order); + } + + public function testEndingAParentEndsSuspendedGrandchildrenDeepestFirst(): void + { + $order = []; + $this->container->contextEnding(function (ExecutionContext $c) use (&$order) { + $order[] = $c->type(); + }); + + $outer = $this->container->beginContext('outer'); + $middle = $this->container->beginContext('middle'); + $inner = $this->container->beginContext('inner'); + $this->container->suspendContext($inner); + $this->container->suspendContext($middle); + + $this->container->endContext($outer); + + $this->assertSame(['inner', 'middle', 'outer'], $order); + $this->container->resumeContext($inner); + $this->container->resumeContext($middle); + $this->assertNull($this->container->executionContext()); + } + + public function testEndingASuspendedParentAlsoEndsItsSuspendedChild(): void + { + $order = []; + $this->container->contextEnding(function (ExecutionContext $c) use (&$order) { + $order[] = $c->type(); + }); + + $outer = $this->container->beginContext('outer'); + $inner = $this->container->beginContext('inner'); + $this->container->suspendContext($inner); + $this->container->suspendContext($outer); + + $this->container->endContext($outer); + + $this->assertSame(['inner', 'outer'], $order); + } + + public function testEndingAContextLeavesUnrelatedSuspendedContextsAlive(): void + { + $other = $this->container->beginContext('request'); + $this->container->suspendContext($other); + + $outer = $this->container->beginContext('outer'); + $this->container->endContext($outer); + + $this->container->resumeContext($other); + $this->assertSame($other->id(), $this->container->executionContext()?->id()); + } + + public function testSuspendedChildScopedInstancesAreDroppedWithTheParent(): void + { + $this->container->bindExecution('scoped', fn () => new \stdClass()); + + $outer = $this->container->beginContext('outer'); + $inner = $this->container->beginContext('inner'); + $first = $this->container->get('scoped'); + $this->container->suspendContext($inner); + $this->container->endContext($outer); + + $this->container->resumeContext($inner); // ended, so nothing to resume + + $this->container->runInContext('again', [], function () use ($first) { + $this->assertNotSame($first, $this->container->get('scoped')); + }); + } + + public function testAFailingListenerForASuspendedChildStillEndsEverythingAndIsRethrown(): void + { + $ended = []; + $this->container->contextEnding(function (ExecutionContext $c) use (&$ended) { + $ended[] = $c->type(); + + if ($c->type() === 'inner') { + throw new \RuntimeException('inner listener failed'); + } + }); + + $outer = $this->container->beginContext('outer'); + $inner = $this->container->beginContext('inner'); + $this->container->suspendContext($inner); + + try { + $this->container->endContext($outer); + $this->fail('Expected the listener failure'); + } catch (\RuntimeException $e) { + $this->assertSame('inner listener failed', $e->getMessage()); + } + + $this->assertSame(['inner', 'outer'], $ended); + $this->assertFalse($this->container->hasExecutionContext()); + } + // ---------------------------------------------------------------- scopes public function testExecutionScopedServiceIsSharedWithinAContextAndNotAcrossContexts(): void