Skip to content
Open
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
16 changes: 14 additions & 2 deletions src/sentry/class_map/RuntimeContextManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
* - The manager keeps a lazily initialized global context as fallback.
* - startContext() creates an isolated runtime context for the current
* execution key when no context is active yet.
* - startContext() only takes effect inside a coroutine; in non-coroutine
* environments it is a no-op and the global fallback context is used.
* - endContext() flushes context resources and removes that context.
*
* @internal
Expand Down Expand Up @@ -120,6 +122,13 @@ public function hasActiveContext(): bool
*/
public function startContext(): void
{
// The main coroutine (non-coroutine environment, Coroutine::id() <= 0)
// uses a process-level context store that is not reaped together with
// a coroutine, so falling back to the global context is safer there.
if (\Hyperf\Engine\Coroutine::id() <= 0) {
return;
Comment on lines +128 to +129

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the non-coroutine context lifecycle

When SentrySdk::startContext() is called from a console, CLI, or other path where Coroutine::id() is -1, this return prevents the promised isolated runtime context from being created. Subsequent hub or scope changes therefore mutate the global fallback, and endContext() becomes a no-op instead of flushing and removing that context. The process-level store can still be cleaned normally when callers pair startContext() with endContext(); callers omitting the latter should not cause all non-coroutine lifecycle calls to lose isolation.

Useful? React with 👍 / 👎.

}

$executionContextKey = $this->getExecutionContextKey();

if ($this->hasActiveContextForExecutionContextKey($executionContextKey)) {
Expand Down Expand Up @@ -279,8 +288,11 @@ private function generateRuntimeContextId(): string

private function getExecutionContextKey(): string
{
// All supported runtime modes currently use a process-local execution key.
return self::PROCESS_EXECUTION_CONTEXT_KEY;
// The key is scoped per coroutine so execution context mappings cannot
// leak across coroutines or linger on the main coroutine. CoArrayObject
// already stores values per current coroutine, so normal behavior is
// unchanged.
return \sprintf('%s.%d', self::PROCESS_EXECUTION_CONTEXT_KEY, \Hyperf\Engine\Coroutine::id());
}

private function getGlobalContext(): RuntimeContext
Expand Down
137 changes: 137 additions & 0 deletions tests/Sentry/RuntimeContextLifecycleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?php

declare(strict_types=1);
/**
* This file is part of friendsofhyperf/components.
*
* @link https://github.com/friendsofhyperf/components
* @document https://github.com/friendsofhyperf/components/blob/main/README.md
* @contact huangdijia@gmail.com
*/

namespace FriendsOfHyperf\Tests\Sentry;

use FriendsOfHyperf\CoPHPUnit\Attributes\NonCoroutine;
use FriendsOfHyperf\Tests\TestCase;
use Mockery;
use Sentry\ClientInterface;
use Sentry\Options;
use Sentry\State\HubInterface;
use Sentry\State\RuntimeContextManager;
use Sentry\Transport\Result;
use Sentry\Transport\ResultStatus;
use Swoole\Coroutine;
use Swoole\Coroutine\Channel;

// The SDK class is replaced via Hyperf's class_map injection at runtime, which
// is not active in the test process, so load the replacement file explicitly
// to exercise the coroutine-aware implementation under test.
require_once __DIR__ . '/../../src/sentry/class_map/RuntimeContextManager.php';

/**
* @internal
*/
class RuntimeContextLifecycleTest extends TestCase
{
public function testStartAndEndContextInsideCoroutine(): void
{
$manager = $this->createRuntimeContextManager();

$manager->startContext();

$this->assertTrue($manager->hasActiveContext());

$manager->endContext();

$this->assertFalse($manager->hasActiveContext());
}

#[NonCoroutine]
public function testStartContextIsIgnoredOnMainCoroutine(): void
{
$this->assertSame(-1, Coroutine::getCid());

$manager = $this->createRuntimeContextManager();

$manager->startContext();

// The main coroutine uses a process-level context store that is never
// reaped, so startContext() is a no-op and the global fallback is used.
$this->assertFalse($manager->hasActiveContext());
$this->assertSame('global', $manager->getCurrentContext()->getId());
}

public function testContextsAreIsolatedAcrossCoroutines(): void
{
$manager = $this->createRuntimeContextManager();
$channelA = new Channel(1);
$channelB = new Channel(1);
$result = [];

Coroutine::create(function () use ($manager, $channelA, &$result): void {
$manager->startContext();
$result['a_id'] = $manager->getCurrentContext()->getId();
$result['a_active'] = $manager->hasActiveContext();
$channelA->push('started');
$channelA->pop(); // Wait for the "end A" signal.
Comment on lines +75 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use separate channels for acknowledgements

Because each channel has capacity one, coroutine A immediately consumes its own started value in the following pop() instead of waiting for the parent, and coroutine B does the same. Both child coroutines can therefore finish before the parent reaches lines 92–93, so this test never has two active contexts concurrently and cannot detect the cross-coroutine regression it claims to cover. Use separate command/acknowledgement channels or otherwise ensure only the parent consumes the started signal.

AGENTS.md reference: AGENTS.md:L40-L42

Useful? React with 👍 / 👎.

$manager->endContext();
$result['a_active_after_end'] = $manager->hasActiveContext();
$channelA->push('done');
});

Coroutine::create(function () use ($manager, $channelB, &$result): void {
$manager->startContext();
$result['b_id'] = $manager->getCurrentContext()->getId();
$result['b_active'] = $manager->hasActiveContext();
$channelB->push('started');
$channelB->pop(); // Wait for the "A ended" signal.
$result['b_active_after_a_end'] = $manager->hasActiveContext();
$channelB->push('done');
});

$channelA->pop(); // A started.
$channelB->pop(); // B started.

$this->assertNotSame($result['a_id'], $result['b_id']);
$this->assertTrue($result['a_active']);
$this->assertTrue($result['b_active']);

$channelA->push('end');
$channelA->pop(); // A finished ending its context.

$channelB->push('check');
$channelB->pop(); // B verified its own context is still active.

$this->assertFalse($result['a_active_after_end']);
$this->assertTrue($result['b_active_after_a_end']);
}

public function testStartContextIsIdempotentWithinSameCoroutine(): void
{
$manager = $this->createRuntimeContextManager();

$manager->startContext();
$firstId = $manager->getCurrentContext()->getId();
$manager->startContext();

// A nested start for the same execution key is a no-op.
$this->assertSame($firstId, $manager->getCurrentContext()->getId());
$this->assertTrue($manager->hasActiveContext());

$manager->endContext();

$this->assertFalse($manager->hasActiveContext());
}

private function createRuntimeContextManager(): RuntimeContextManager
{
$client = Mockery::mock(ClientInterface::class);
$client->shouldReceive('getOptions')->andReturn(new Options());
$client->shouldReceive('flush')->andReturn(new Result(ResultStatus::success()));

$hub = Mockery::mock(HubInterface::class);
$hub->shouldReceive('getClient')->andReturn($client);

return new RuntimeContextManager($hub);
}
}
Loading