diff --git a/src/sentry/class_map/RuntimeContextManager.php b/src/sentry/class_map/RuntimeContextManager.php index 640f35e89..c84ab29e2 100644 --- a/src/sentry/class_map/RuntimeContextManager.php +++ b/src/sentry/class_map/RuntimeContextManager.php @@ -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 */ @@ -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); } private function createContextForExecutionContextKey(string $executionContextKey): void @@ -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); @@ -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 diff --git a/tests/Sentry/RuntimeContextManagerTest.php b/tests/Sentry/RuntimeContextManagerTest.php new file mode 100644 index 000000000..58c24c4cb --- /dev/null +++ b/tests/Sentry/RuntimeContextManagerTest.php @@ -0,0 +1,88 @@ +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); +});