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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/Phaseolies/DI/Concerns/InteractsWithExecutionContexts.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -306,6 +324,35 @@ private function resetExecutionContexts(): void
}
}

/**
* Find suspended contexts that were started inside any of the given contexts, deepest first
*
* @param array<string, true> $ancestorIds
* @return array<int, ExecutionContext>
*/
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
*
Expand Down
123 changes: 123 additions & 0 deletions tests/Application/ExecutionContextTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading