From fcf3f8cd6e48777087cd31a2cec2b6c0189337b8 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 15 Oct 2026 15:38:45 +0200 Subject: [PATCH] feat(profiling): detect process forks --- analysis-baseline.toml | 2 +- src/Profiling/ContinuousProfiler.php | 65 +++++++- tests/Profiling/ContinuousProfilerTest.php | 174 +++++++++++++++++++++ 3 files changed, 237 insertions(+), 4 deletions(-) diff --git a/analysis-baseline.toml b/analysis-baseline.toml index b2f12f535..6a9bfdb0f 100644 --- a/analysis-baseline.toml +++ b/analysis-baseline.toml @@ -1066,7 +1066,7 @@ count = 2 file = "src/Profiling/ContinuousProfiler.php" code = "invalid-method-access" message = "Attempting to access a method on a non-object type (`unknown-ref(ExcimerProfiler)`)." -count = 4 +count = 5 [[issues]] file = "src/Profiling/ContinuousProfiler.php" diff --git a/src/Profiling/ContinuousProfiler.php b/src/Profiling/ContinuousProfiler.php index efa679287..8d7c7643a 100644 --- a/src/Profiling/ContinuousProfiler.php +++ b/src/Profiling/ContinuousProfiler.php @@ -56,6 +56,11 @@ final class ContinuousProfiler */ private $profilerId; + /** + * @var int|false|null The ID of the process the running profiler samples + */ + private $processId; + /** * @var float|null The start time of the running profiler as a Unix timestamp with microseconds */ @@ -109,6 +114,8 @@ public function __construct(int $maxSamplesPerChunk = self::MAX_SAMPLES_PER_CHUN public function start(): void { + $this->restartInForkedProcess(); + if ($this->profiler !== null) { return; } @@ -166,6 +173,7 @@ public function start(): void $this->profiler = $profiler; $this->profilerId = $profilerId; + $this->processId = getmypid(); $this->startTimestamp = $startTimestamp; $this->nextFlushTimestamp = $startTimestamp + $this->flushInterval; @@ -219,6 +227,8 @@ public function stopForRuntimeContext(string $runtimeContextId): void public function stop(): void { + $this->restartInForkedProcess(); + if ($this->profiler === null) { return; } @@ -230,14 +240,15 @@ public function stop(): void $this->capturePendingChunks(); - $this->profiler = null; - $this->profilerId = null; - $this->startTimestamp = null; + $this->clearProfiler(); + $this->activeRootSpans = []; } public function flush(): void { + $this->restartInForkedProcess(); + if ($this->profiler === null) { return; } @@ -249,6 +260,8 @@ public function flush(): void public function capturePendingChunks(): void { + $this->restartInForkedProcess(); + if ($this->profiler !== null && microtime(true) >= $this->nextFlushTimestamp) { $this->profiler->flush(); } @@ -274,8 +287,14 @@ public function isRunning(): bool return $this->profiler !== null; } + /** + * Gets the ID of the running profiler, which is restarted first if the process was forked, + * so that spans created in the forked process are linked to the profiler sampling it. + */ public function getProfilerId(): ?string { + $this->restartInForkedProcess(); + return $this->profilerId; } @@ -334,6 +353,46 @@ private function completeChunk(array $entries): void $this->pendingChunks->push(new ProfileChunk($this->profilerId, $this->startTimestamp, $profileData, $client->getOptions())); } + private function clearProfiler(): void + { + // Clear the flush callback so that the samples are discarded + if ($this->profiler !== null) { + $this->profiler->clearFlushCallback(); + } + + $this->profiler = null; + $this->profilerId = null; + $this->processId = null; + $this->startTimestamp = null; + + // A forked process inherits the completed chunks, which the parent process captures itself + $this->pendingChunks->drain(); + } + + /** + * Excimer does not sample in a forked process, so the profiler inherited from the parent process + * is replaced by a new one sampling this process, while the parent process keeps its own samples. + */ + private function restartInForkedProcess(): void + { + if ($this->profiler === null || $this->processId === getmypid()) { + return; + } + + // The timer of the inherited profiler does not exist in this process, so tearing it down emits warnings + set_error_handler(static function (): bool { + return true; + }); + + try { + $this->clearProfiler(); + } finally { + restore_error_handler(); + } + + $this->start(); + } + private function isSessionSampled(?float $sampleRate): bool { if ($sampleRate === null || $sampleRate <= 0.0) { diff --git a/tests/Profiling/ContinuousProfilerTest.php b/tests/Profiling/ContinuousProfilerTest.php index f69324123..368f59a57 100644 --- a/tests/Profiling/ContinuousProfilerTest.php +++ b/tests/Profiling/ContinuousProfilerTest.php @@ -330,11 +330,185 @@ public function testChunksAreSplitByDuration(): void } } + /** + * A forked process inherits the memory of the running profiler, but not the timer Excimer + * samples with, so it would not be profiled anymore. + * + * The profiler is started and the process forked. The forked process asks for the profiler ID, + * which replaces the inherited profiler with a new one without emitting any error, collects + * samples and stops the profiler. The test then asserts that the forked process sent a chunk + * with a new profiler ID, while the parent process keeps profiling with its own profiler. + * + * @requires extension excimer + * @requires extension pcntl + */ + public function testForkedProcessIsProfiledByANewProfiler(): void + { + $this->bindClient(['profile_session_sample_rate' => 1.0]); + + $profiler = new ContinuousProfiler(); + $profiler->start(); + + $parentProfilerId = $profiler->getProfilerId(); + + $result = $this->runInForkedProcess(static function () use ($profiler): array { + StubTransport::$events = []; + + error_clear_last(); + + $childProfilerId = $profiler->getProfilerId(); + + self::busyWait(0.2); + + $profiler->stop(); + + return [ + 'lastError' => error_get_last(), + 'childProfilerId' => $childProfilerId, + 'chunkProfilerIds' => self::getChunkProfilerIds(), + ]; + }); + + $this->assertNull($result['lastError']); + $this->assertNotNull($result['childProfilerId']); + $this->assertNotSame($parentProfilerId, $result['childProfilerId']); + $this->assertSame([$result['childProfilerId']], $result['chunkProfilerIds']); + + // The parent process keeps profiling with its own profiler + $this->assertSame($parentProfilerId, $profiler->getProfilerId()); + + self::busyWait(0.05); + + $profiler->stop(); + + $this->assertCount(1, StubTransport::$events); + $this->assertSame($parentProfilerId, StubTransport::$events[0]->getProfileChunk()->getProfilerId()); + } + + /** + * Destroying the profiler inherited by a forked process passes the samples it has not flushed yet + * to the flush callback. The parent process sends those samples itself, so the forked process must + * not send them under the profiler ID of the parent process. + * + * The samples are buffered in batches and the chunks are short, so the samples collected before the + * fork would fill several chunks while the inherited profiler is replaced. + * + * @requires extension excimer + * @requires extension pcntl + */ + public function testForkedProcessDoesNotSendTheSamplesOfTheParentProcess(): void + { + $this->bindClient(['profile_session_sample_rate' => 1.0]); + + $profiler = new ContinuousProfiler(1000, 0.1, 0.1); + $profiler->start(); + + self::busyWait(0.3); + + $result = $this->runInForkedProcess(static function () use ($profiler): array { + StubTransport::$events = []; + + $profiler->getProfilerId(); + $profiler->capturePendingChunks(); + + return ['chunkProfilerIds' => self::getChunkProfilerIds()]; + }); + + $this->assertSame([], $result['chunkProfilerIds']); + + $profiler->stop(); + } + + /** + * The chunks completed while sampling wait in the memory that a forked process inherits. The parent + * process captures them itself, so the forked process must not capture them under the profiler ID of + * the parent process. + * + * @requires extension excimer + * @requires extension pcntl + */ + public function testForkedProcessDoesNotCaptureThePendingChunksOfTheParentProcess(): void + { + $this->bindClient(['profile_session_sample_rate' => 1.0]); + + $profiler = new ContinuousProfiler(5); + $profiler->start(); + + $profilerId = $profiler->getProfilerId(); + + self::busyWait(0.2); + + $result = $this->runInForkedProcess(static function () use ($profiler): array { + StubTransport::$events = []; + + $profiler->capturePendingChunks(); + + return ['chunkProfilerIds' => self::getChunkProfilerIds()]; + }); + + $this->assertSame([], $result['chunkProfilerIds']); + + $profiler->capturePendingChunks(); + + $this->assertNotEmpty(StubTransport::$events); + $this->assertSame([$profilerId], array_values(array_unique(self::getChunkProfilerIds()))); + + $profiler->stop(); + } + private function bindClient(array $options): void { SentrySdk::init()->bindClient(new Client(new Options($options), StubTransport::getInstance())); } + /** + * Runs the callback in a forked process and returns its result. As the forked process cannot + * report to the test directly, it writes its result to a file and kills itself, so the test + * runner does not shut down a second time. + * + * @param callable(): array $callback + * + * @return array + */ + private function runInForkedProcess(callable $callback): array + { + $resultFile = tempnam(sys_get_temp_dir(), 'sentry-profiler-fork'); + + $pid = pcntl_fork(); + + if ($pid === 0) { + try { + $result = $callback(); + } catch (\Throwable $exception) { + $result = ['error' => (string) $exception]; + } + + file_put_contents($resultFile, json_encode($result)); + + // Exits without running the shutdown of the test runner in the forked process + posix_kill(getmypid(), \SIGKILL); + } + + pcntl_waitpid($pid, $status); + + $result = json_decode((string) file_get_contents($resultFile), true); + unlink($resultFile); + + $this->assertArrayNotHasKey('error', $result, $result['error'] ?? ''); + + return $result; + } + + /** + * @return array + */ + private static function getChunkProfilerIds(): array + { + return array_map(static function (Event $event): ?string { + return $event->getProfileChunk() !== null ? $event->getProfileChunk()->getProfilerId() : null; + }, StubTransport::$events); + } + private static function busyWait(float $seconds): void { $end = microtime(true) + $seconds;