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
19 changes: 18 additions & 1 deletion src/sentry/class_map/RuntimeContextManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ final class RuntimeContextManager
{
private const PROCESS_EXECUTION_CONTEXT_KEY = 'sentry.process.execution_context';

/**
* Default flush timeout (seconds) used when endContext() is called without an explicit
* timeout. 0 means "do not wait": the transport drains its channel asynchronously and
* endContext() returns as soon as possible, so a full channel can never block the
* coroutine from terminating.
*/
private const DEFAULT_FLUSH_TIMEOUT = 0;

/**
* @var HubInterface
*/
Expand Down Expand Up @@ -146,7 +154,9 @@ public function endContext(?int $timeout = null): void
$runtimeContextId = $this->executionContextToRuntimeContext[$executionContextKey];
unset($this->executionContextToRuntimeContext[$executionContextKey]);

$this->removeContextById($runtimeContextId, $timeout);
// Resolve the effective flush timeout here so that callers that omit it
// (e.g. SentrySdk::endContext()) can never block indefinitely; see DEFAULT_FLUSH_TIMEOUT.
$this->removeContextById($runtimeContextId, $timeout ?? self::DEFAULT_FLUSH_TIMEOUT);
Comment on lines +157 to +159

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Bound the channel push rather than the client flush

When CoHttpTransport's channel is full, this timeout does not prevent endContext() from hanging: the logs and metrics flushes run first and eventually call CoHttpTransport::send(), whose channel push uses the separate $this->timeout at src/sentry/src/Transport/CoHttpTransport.php:73. The new 0 only reaches $client->flush() afterward, and this component's CoHttpTransport::close() at lines 78-81 ignores that argument entirely. Consequently the deferred SentrySdk::endContext() calls can still block in the exact full-channel scenario this change targets; the enqueue timeout itself must be bounded or the aggregator flushes must avoid blocking.

Useful? React with 👍 / 👎.

}

private function createContextForExecutionContextKey(string $executionContextKey): void
Expand All @@ -165,6 +175,9 @@ private function removeContextById(string $runtimeContextId, ?int $timeout = nul
}

$runtimeContext = $this->activeContexts[$runtimeContextId];
// Release the context BEFORE flushing (intentional order): even when a flush segment
// below throws or times out, the context is already freed and the coroutine can
// terminate without leaking request state. Flushing is best-effort only.
unset($this->activeContexts[$runtimeContextId]);
// Remove any key mappings that may still reference this context.
$this->removeExecutionContextMappingsForRuntimeContext($runtimeContextId);
Expand All @@ -176,6 +189,10 @@ private function removeContextById(string $runtimeContextId, ?int $timeout = nul

private function flushRuntimeContextResources(RuntimeContext $runtimeContext, ?int $timeout, LoggerInterface $logger): void
{
// Resolve the effective timeout once for every flush segment below. The context has
// already been released by the caller, so any failing/timing-out segment does not
// affect context release; each segment stays isolated in its own try/catch.
$timeout = $timeout ?? self::DEFAULT_FLUSH_TIMEOUT;
$hub = $runtimeContext->getHub();

// captureEvent can throw before transport send (for example from scope event processors
Expand Down
88 changes: 88 additions & 0 deletions tests/Sentry/RuntimeContextManagerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?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 RuntimeException;
use Sentry\ClientInterface;
use Sentry\Options;
use Sentry\State\HubInterface;
use Sentry\State\RuntimeContextManager;
use Sentry\Transport\Result;
use Sentry\Transport\ResultStatus;

// Load the component's class_map replacement for the SDK class (the same file the
// ConfigProvider registers via the container class_map), so these tests exercise the
// coroutine-safe implementation instead of the raw SDK class.
require_once __DIR__ . '/../../src/sentry/class_map/RuntimeContextManager.php';

beforeEach(function () {
// Mock hub + client so the RuntimeContextManager constructor never resolves the container
// ($baseHub->getClient() returns a truthy mock client).
$this->client = $this->createMock(ClientInterface::class);
$this->client->method('getOptions')->willReturn(new Options());
$this->client->method('captureEvent')->willReturn(null);

$this->baseHub = $this->createMock(HubInterface::class);
$this->baseHub->method('getClient')->willReturn($this->client);
});

test('startContext creates an isolated hub and marks the context active', function () {
// Tests run inside a coroutine via FriendsOfHyperf\Tests\TestCase, so the
// CoArrayObject-backed manager has a clean per-coroutine context here.
$manager = new RuntimeContextManager($this->baseHub);
$manager->startContext();

expect($manager->hasActiveContext())->toBeTrue();
expect($manager->getCurrentContext()->getHub())->not->toBe($this->baseHub);
});

test('endContext forwards the flush timeout to the client', function () {
$received = [];
$this->client->method('flush')->willReturnCallback(static function (?int $timeout) use (&$received) {
$received[] = $timeout;

return new Result(ResultStatus::success());
});

$manager = new RuntimeContextManager($this->baseHub);

$manager->startContext();
$manager->endContext(1500);
expect($received)->toBe([1500]);

$manager->startContext();
$manager->endContext(null);
expect($received)->toBe([1500, 0]);
});

test('endContext does not throw when the client flush throws and still releases the context', function () {
$this->client->method('flush')->willThrowException(new RuntimeException('transport unavailable'));

$manager = new RuntimeContextManager($this->baseHub);
$manager->startContext();

$manager->endContext();

expect($manager->hasActiveContext())->toBeFalse();
});

test('startContext is idempotent for the current execution key', function () {
$manager = new RuntimeContextManager($this->baseHub);

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

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

expect($secondId)->toBe($firstId);
});
Loading