fix: guard executor shutdown in BaseCaptureStrategy.stop()#5627
fix: guard executor shutdown in BaseCaptureStrategy.stop()#5627tsushanth wants to merge 8 commits into
Conversation
|
Thanks for contributing, could you run spotlessApply to fix the formatting failure? Then we'll review this more closely. |
romtsn
left a comment
There was a problem hiding this comment.
@tsushanth thanks for your contribution, that's very much appreciated! I'm thinking if there's potentially a better approach, which would be to pull persistingExecutor up to the ReplayIntegration level and then pass it as a ctor argument to the respective CaptureStrategy.
It could then have the same lifecycle as the replayExecutor (that is, shut it down inside ReplayIntegration.close()) and would survive multiple start/stop calls, potentially saving us the cost of creating a new executor every time we start a new recording.
|
Refactored per the feedback —
Tests updated to pass a mock |
|
Refactored as suggested @romtsn — Also ran |
d32fda0 to
25cec73
Compare
Each start/stop cycle leaked one SentryReplayPersister-* thread because stop() reset delegated properties (segmentTimestamp, currentReplayId) whose setters dispatch to persistingExecutor, initialising the lazy — but stop() never shut it down. Replace the lazy delegate with an explicit nullable holder so the executor is only created when actually needed and can be detected at stop() time. Call shutdownNow() (non-blocking) rather than the blocking shutdown() to avoid ANRs when stop() runs on the main thread. Fixes getsentry#5564
Move persistingExecutor out of BaseCaptureStrategy and into ReplayIntegration, passing it as a constructor argument to CaptureStrategy subclasses. Shut it down in ReplayIntegration.close() alongside replayExecutor so executor lifecycle is managed in one place.
…ut down Add the persistingExecutor argument to SessionCaptureStrategy and BufferCaptureStrategy constructor calls in tests, and add changelog entry. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
25cec73 to
6d4e33b
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 6d4e33b. Configure here.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The test used a mocked executor that never spawned threads, so the thread-count assertion was always true regardless of the fix. The executor lifecycle is now owned by ReplayIntegration, not SessionCaptureStrategy, so the test belonged at the wrong layer. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Uses real ScheduledThreadPoolExecutor threads so the test actually fails if the shutdown in close() is removed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6c60e00 to
7a3e33b
Compare
shutdown() calls awaitTermination() which blocks up to shutdownTimeoutMillis. Since close() can run on the main thread (via Sentry.close() from hybrid SDKs), this risks an ANR. shutdownNow() is non-blocking and sufficient at teardown. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
7a3e33b to
c14175f
Compare

Fixes #5564
Problem
Each
ReplayIntegration.start()constructs a freshSessionCaptureStrategyorBufferCaptureStrategy. Both inheritBaseCaptureStrategy, which owns apersistingExecutorthat was previously declared as a Kotlinlazydelegate.stop()resets the delegated propertiessegmentTimestampandcurrentReplayId. Their setters callrunInBackground, which — whenoptions.threadChecker.isMainThread()is true — accessespersistingExecutor, silently initialising the lazy.stop()never shuts the executor down, so oneSentryReplayPersister-*thread is abandoned on every cycle. Kiln benchmark data confirmed monotonically growing thread counts and a GC allocation rate roughly 9× baseline after the repeated start/stop pattern introduced in kiln#73.Fix
Replace the
lazydelegate with an explicit nullable holder (persistingExecutorHolder). The customget()mirrors the old lazy behaviour (create-on-first-access) while making initialisation detectable. At the end ofstop(), if the holder is non-null the executor is shut down viashutdownNow()— non-blocking, safe to call on the main thread — and the holder is cleared so a subsequentstart()gets a fresh executor without any residual state.ReplayExecutorService.shutdown()is intentionally not used here: it blocks foroptions.shutdownTimeoutMillisand risks an ANR when invoked on the main thread.shutdownNow()interrupts any in-flight persistence write and discards the queue, which is acceptable becausecache.close()is called immediately before in the samestop()body.Test
Added
stop shuts down persisting executor so no SentryReplayPersister threads leak across cyclestoSessionCaptureStrategyTest. The test stubsthreadChecker.isMainThread()totrue(forcing the persisting executor path), runs three start/stop cycles, and asserts that noSentryReplayPersister-*threads remain alive after a short drain window.