From cb8565aa268ce09bdf992a25781b4aa13b5f39cf Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 11 Jun 2026 13:52:31 +0200 Subject: [PATCH 01/14] fix(session-replay): Stabilize video assembly Extract the replay video-assembly fixes from the capture-backoff branch so they can land and ship independently: - Retain the last frame captured before a segment window so recovered and resumed segments start with the correct screen state instead of dropping it (this moves the recovered replay start timestamp to the retained frame's time). - Fill and bound fractional gaps between captured frames so video timing stays stable when captures are skipped under load. - Use half-open video windows to avoid duplicating boundary frames in consecutive segments. - Drop video segments that contain no frames instead of emitting empty videos. - Keep video timing stable when a cached frame image cannot be read. - Serialize oldestFrameDate reads on the processing queue. --- CHANGELOG.md | 6 + .../SessionReplay/SentryOnDemandReplay.swift | 112 +++++++-- .../SentryVideoFrameProcessor.swift | 216 +++++++++++++++--- .../SentryOnDemandReplayTests.swift | 152 ++++++++++++ .../SentrySessionReplayIntegrationTests.swift | 4 +- .../SentryVideoFrameProcessorTests.swift | 203 +++++++++++++++- 6 files changed, 640 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3ef22d73a8..aabfdf85e74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Session replay video assembly: drop empty video segments, avoid duplicating frames at segment boundaries, and keep video timing stable when captured frames are skipped or unreadable (#8041) + ## 9.17.0 ### Features diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index e3ffcf52e78..fb06e4f36bf 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -18,6 +18,10 @@ import UIKit private let processingQueue: SentryDispatchQueueWrapper private let assetWorkerQueue: SentryDispatchQueueWrapper private var _frames = [SentryReplayFrame]() + /// Guards `retainedFrameBeforeCurrentFrames`. All other accesses to it happen on + /// `processingQueue`; the lock only exists because `deinit` can run on any thread. + private let retainedFrameLock = NSLock() + private var retainedFrameBeforeCurrentFrames: SentryReplayFrame? #if SENTRY_TEST || SENTRY_TEST_CI || DEBUG //This is exposed only for tests, no need to make it thread safe. @@ -41,6 +45,17 @@ import UIKit self.processingQueue = processingQueue self.assetWorkerQueue = assetWorkerQueue } + + deinit { + let retainedFrame = retainedFrameLock.synchronized { + let frame = retainedFrameBeforeCurrentFrames + retainedFrameBeforeCurrentFrames = nil + return frame + } + if let retainedFrame = retainedFrame { + removeFrameFile(retainedFrame) + } + } public convenience init( withContentFrom outputPath: String, @@ -130,20 +145,21 @@ import UIKit SentrySDKLog.debug("[Session Replay] Releasing frames until date: \(date)") while let first = self._frames.first, first.time < date { self._frames.removeFirst() - let fileUrl = URL(fileURLWithPath: first.imagePath) - do { - try FileManager.default.removeItem(at: fileUrl) - SentrySDKLog.debug("[Session Replay] Removed frame at url: \(fileUrl.path)") - } catch { - SentrySDKLog.error("[Session Replay] Failed to remove frame at: \(fileUrl.path), reason: \(error), ignoring error") - } + self.replaceRetainedFrame(first) } SentrySDKLog.debug("[Session Replay] Frames released, remaining frames count: \(self._frames.count)") } } public var oldestFrameDate: Date? { - return _frames.first?.time + var oldestFrameDate: Date? + processingQueue.dispatchSync { + let retainedFrame = self.retainedFrameLock.synchronized { + self.retainedFrameBeforeCurrentFrames + } + oldestFrameDate = retainedFrame?.time ?? self._frames.first?.time + } + return oldestFrameDate } public func createVideoInBackgroundWith(beginning: Date, end: Date, completion: @escaping ([SentryVideoInfo]) -> Void) { @@ -162,7 +178,7 @@ import UIKit // Note: In previous implementations this method was wrapped by a sync call to the processing queue. // As this method is already called from the processing queue, we must remove the sync call. - let videoFrames = self._frames.filter { $0.time >= beginning && $0.time <= end } + let videoFrames = self.videoFramesForRendering(beginning: beginning, end: end) var frameCount = 0 var videos = [SentryVideoInfo]() @@ -177,7 +193,7 @@ import UIKit var currentError: Error? group.enter() - self.renderVideo(with: videoFrames, from: frameCount, at: outputFileURL) { result in + self.renderVideo(with: videoFrames, fromIndex: frameCount, until: end, at: outputFileURL) { result in switch result { case .success(let videoResult): // Set the frame count/offset to the new index that is returned by the completion block. @@ -221,16 +237,77 @@ import UIKit return videos } + private func videoFramesForRendering(beginning: Date, end: Date) -> [SentryReplayFrame] { + guard end > beginning else { return [] } + + var videoFrames = self._frames.filter { $0.time >= beginning && $0.time < end } + guard let firstFrame = videoFrames.first else { + guard let previousFrame = frameBefore(beginning) else { return [] } + return [frame(previousFrame, movedTo: beginning)] + } + + if firstFrame.time > beginning { + let frameToHold = frameBefore(beginning) ?? firstFrame + videoFrames.insert(frame(frameToHold, movedTo: beginning), at: 0) + } + + return videoFrames + } + + private func frameBefore(_ date: Date) -> SentryReplayFrame? { + let retainedFrame = retainedFrameLock.synchronized { + retainedFrameBeforeCurrentFrames + }.flatMap { $0.time < date ? $0 : nil } + let currentFrame = _frames.last(where: { $0.time < date }) + + guard let retained = retainedFrame else { return currentFrame } + guard let current = currentFrame else { return retained } + return retained.time > current.time ? retained : current + } + + private func frame(_ frame: SentryReplayFrame, movedTo time: Date) -> SentryReplayFrame { + return SentryReplayFrame(imagePath: frame.imagePath, time: time, screenName: frame.screenName) + } + + private func replaceRetainedFrame(_ frame: SentryReplayFrame) { + let frameToRemove = retainedFrameLock.synchronized { () -> SentryReplayFrame? in + let previousFrame = retainedFrameBeforeCurrentFrames + retainedFrameBeforeCurrentFrames = frame + guard previousFrame?.imagePath != frame.imagePath else { return nil } + return previousFrame + } + + if let frameToRemove = frameToRemove { + removeFrameFile(frameToRemove) + } + } + + private func removeFrameFile(_ frame: SentryReplayFrame) { + let fileUrl = URL(fileURLWithPath: frame.imagePath) + do { + try FileManager.default.removeItem(at: fileUrl) + SentrySDKLog.debug("[Session Replay] Removed frame at url: \(fileUrl.path)") + } catch { + SentrySDKLog.error("[Session Replay] Failed to remove frame at: \(fileUrl.path), reason: \(error), ignoring error") + } + } + // swiftlint:disable function_body_length cyclomatic_complexity - private func renderVideo(with videoFrames: [SentryReplayFrame], from: Int, at outputFileURL: URL, completion: @escaping (Result) -> Void) { - SentrySDKLog.debug("[Session Replay] Rendering video with \(videoFrames.count) frames, from index: \(from), to output url: \(outputFileURL)") + private func renderVideo( + with videoFrames: [SentryReplayFrame], + fromIndex: Int, + until videoEnd: Date, + at outputFileURL: URL, + completion: @escaping (Result) -> Void + ) { + SentrySDKLog.debug("[Session Replay] Rendering video with \(videoFrames.count) frames, from index: \(fromIndex), to output url: \(outputFileURL)") - guard from < videoFrames.count else { + guard fromIndex < videoFrames.count else { SentrySDKLog.error("[Session Replay] Failed to render video, reason: index out of bounds") return completion(.failure(SentryOnDemandReplayError.indexOutOfBounds)) } - guard let image = UIImage(contentsOfFile: videoFrames[from].imagePath) else { - SentrySDKLog.error("[Session Replay] Failed to render video, reason: can't read image at path: \(videoFrames[from].imagePath)") + guard let image = UIImage(contentsOfFile: videoFrames[fromIndex].imagePath) else { + SentrySDKLog.error("[Session Replay] Failed to render video, reason: can't read image at path: \(videoFrames[fromIndex].imagePath)") return completion(.failure(SentryOnDemandReplayError.cantReadImage)) } @@ -265,8 +342,9 @@ import UIKit videoHeight: videoHeight, videoWidth: videoWidth, frameRate: frameRate, - initialFrameIndex: from, - initialImageSize: image.size + initialFrameIndex: fromIndex, + initialImageSize: image.size, + videoEnd: videoEnd ) // Append frames to the video writer input in a pull-style manner when the input is ready to receive more media data. diff --git a/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift b/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift index d31c2e62a16..267d4fcb9be 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift @@ -7,6 +7,16 @@ import Foundation import UIKit class SentryVideoFrameProcessor { + private enum AppendFrameResult { + case success + case notReady + case failure + } + + /// Tolerance applied when mapping capture timestamps to presentation frame indices, to + /// absorb floating-point error at frame boundaries. + private static let frameIndexEpsilon = 0.000001 + let videoFrames: [SentryReplayFrame] let videoWriter: AVAssetWriter let currentPixelBuffer: SentryAppendablePixelBuffer @@ -14,11 +24,16 @@ class SentryVideoFrameProcessor { let videoHeight: CGFloat let videoWidth: CGFloat let frameRate: Int + let videoEnd: Date? var frameIndex: Int var lastImageSize: CGSize var usedFrames: [SentryReplayFrame] var isFinished: Bool + private var outputFrameIndex: Int + private var videoStart: Date? + private var lastAppendedImage: UIImage? + private var lastAppendedFrame: SentryReplayFrame? init( videoFrames: [SentryReplayFrame], @@ -29,7 +44,8 @@ class SentryVideoFrameProcessor { videoWidth: CGFloat, frameRate: Int, initialFrameIndex: Int, - initialImageSize: CGSize + initialImageSize: CGSize, + videoEnd: Date? = nil ) { self.videoFrames = videoFrames self.videoWriter = videoWriter @@ -38,8 +54,10 @@ class SentryVideoFrameProcessor { self.videoHeight = videoHeight self.videoWidth = videoWidth self.frameRate = frameRate + self.videoEnd = videoEnd self.frameIndex = initialFrameIndex + self.outputFrameIndex = 0 self.lastImageSize = initialImageSize self.usedFrames = [] self.isFinished = false @@ -58,40 +76,137 @@ class SentryVideoFrameProcessor { return onCompletion(.failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo)) } guard frameIndex < videoFrames.count else { + guard handleAppendResult( + appendLastFrameUntilVideoEnd(videoWriterInput: videoWriterInput), + onCompletion: onCompletion + ) else { return } + SentrySDKLog.debug("[Session Replay] No more frames available to process, finishing the video") return finishVideo(frameIndex: self.frameIndex, onCompletion: onCompletion) } - - let frame = videoFrames[frameIndex] - defer { - // Increment the frame index even if the image could not be appended to the pixel buffer. - // This is important to avoid an infinite loop. - frameIndex += 1 - } - guard let image = UIImage(contentsOfFile: frame.imagePath) else { - // Continue with the next frame - continue - } - - SentrySDKLog.debug("[Session Replay] Image at index \(frameIndex) is ready, size: \(image.size)") - guard lastImageSize == image.size else { - SentrySDKLog.debug("[Session Replay] Image size has changed, finishing video") - return finishVideo(frameIndex: self.frameIndex, onCompletion: onCompletion) - } - lastImageSize = image.size - - let presentTime = SentryOnDemandReplay.calculatePresentationTime( - forFrameAtIndex: frameIndex, - frameRate: frameRate - ).timeValue - guard currentPixelBuffer.append(image: image, presentationTime: presentTime) else { - SentrySDKLog.error("[Session Replay] Failed to append image to pixel buffer, cancelling the writing session, reason: \(String(describing: videoWriter.error))") - videoWriter.inputs.forEach { $0.markAsFinished() } - videoWriter.cancelWriting() - return onCompletion(.failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo)) + + guard processCurrentFrame(videoWriterInput: videoWriterInput, onCompletion: onCompletion) else { return } + } + } + + private func processCurrentFrame( + videoWriterInput: AVAssetWriterInput, + onCompletion: @escaping (Result) -> Void + ) -> Bool { + let frame = videoFrames[frameIndex] + guard let image = UIImage(contentsOfFile: frame.imagePath) else { + return processUnreadableFrame(frame, videoWriterInput: videoWriterInput, onCompletion: onCompletion) + } + + guard handleAppendResult( + appendLastFrame(until: frame.time, videoWriterInput: videoWriterInput), + onCompletion: onCompletion + ) else { return false } + + SentrySDKLog.debug("[Session Replay] Image at index \(frameIndex) is ready, size: \(image.size)") + guard lastImageSize == image.size else { + SentrySDKLog.debug("[Session Replay] Image size has changed, finishing video") + finishVideo(frameIndex: self.frameIndex, onCompletion: onCompletion) + return false + } + lastImageSize = image.size + + guard handleAppendResult( + append(image: image, forFrame: frame, videoWriterInput: videoWriterInput), + onCompletion: onCompletion + ) else { return false } + + frameIndex += 1 + return true + } + + private func handleAppendResult( + _ result: AppendFrameResult, + onCompletion: @escaping (Result) -> Void + ) -> Bool { + switch result { + case .success: + return true + case .notReady: + return false + case .failure: + cancelWriting(onCompletion: onCompletion) + return false + } + } + + private func cancelWriting(onCompletion completion: @escaping (Result) -> Void) { + SentrySDKLog.error("[Session Replay] Failed to append image to pixel buffer, cancelling the writing session, reason: \(String(describing: videoWriter.error))") + videoWriter.inputs.forEach { $0.markAsFinished() } + videoWriter.cancelWriting() + completion(.failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo)) + } + + private func appendLastFrameUntilVideoEnd(videoWriterInput: AVAssetWriterInput) -> AppendFrameResult { + guard let videoEnd = videoEnd, let videoStart = videoStart else { return .success } + return appendLastFrame( + untilFrameIndex: presentationFrameCount(until: videoEnd, from: videoStart), + videoWriterInput: videoWriterInput + ) + } + + private func appendLastFrame(until date: Date, videoWriterInput: AVAssetWriterInput) -> AppendFrameResult { + guard let videoStart = videoStart else { return .success } + return appendLastFrame( + untilFrameIndex: presentationFrameIndex(forCapturedFrameAt: date, from: videoStart), + videoWriterInput: videoWriterInput + ) + } + + private func appendLastFrame(untilFrameIndex targetFrameIndex: Int, videoWriterInput: AVAssetWriterInput) -> AppendFrameResult { + guard let lastAppendedImage = lastAppendedImage, + let lastAppendedFrame = lastAppendedFrame + else { return .success } + + while outputFrameIndex < targetFrameIndex { + switch append(image: lastAppendedImage, forFrame: lastAppendedFrame, videoWriterInput: videoWriterInput) { + case .success: + break + case .notReady: + return .notReady + case .failure: + return .failure } - usedFrames.append(frame) } + + return .success + } + + private func append(image: UIImage, forFrame frame: SentryReplayFrame, videoWriterInput: AVAssetWriterInput) -> AppendFrameResult { + guard videoWriterInput.isReadyForMoreMediaData else { return .notReady } + + if videoStart == nil { + videoStart = frame.time + } + + let presentTime = SentryOnDemandReplay.calculatePresentationTime( + forFrameAtIndex: outputFrameIndex, + frameRate: frameRate + ).timeValue + guard currentPixelBuffer.append(image: image, presentationTime: presentTime) else { return .failure } + + usedFrames.append(frame) + lastAppendedImage = image + lastAppendedFrame = frame + outputFrameIndex += 1 + return .success + } + + private func presentationFrameIndex(forCapturedFrameAt date: Date, from start: Date) -> Int { + let elapsed = max(0, date.timeIntervalSince(start)) + let index = floor(elapsed * Double(frameRate) + Self.frameIndexEpsilon) + return max(0, Int(index)) + } + + private func presentationFrameCount(until date: Date, from start: Date) -> Int { + let elapsed = max(0, date.timeIntervalSince(start)) + let frameCount = ceil(elapsed * Double(frameRate) - Self.frameIndexEpsilon) + return max(0, Int(frameCount)) } // swiftlint:enable function_body_length cyclomatic_complexity @@ -118,6 +233,13 @@ class SentryVideoFrameProcessor { return completion(.success(videoResult)) case .completed: SentrySDKLog.debug("[Session Replay] Finish writing video was completed, creating video info from file attributes.") + guard !self.usedFrames.isEmpty else { + SentrySDKLog.debug("[Session Replay] Finished video writing without appended frames, completing with no video info") + self.removeOutputFile() + let videoResult = SentryRenderVideoResult(info: nil, finalFrameIndex: frameIndex) + return completion(.success(videoResult)) + } + do { let videoInfo = try self.getVideoInfo( from: self.outputFileURL, @@ -158,7 +280,7 @@ class SentryVideoFrameProcessor { SentrySDKLog.warning("[Session Replay] Failed to read video start time from used frames, reason: no frames found") throw SentryOnDemandReplayError.cantReadVideoStartTime } - let duration = TimeInterval(usedFrames.count / self.frameRate) + let duration = TimeInterval(usedFrames.count) / TimeInterval(self.frameRate) return SentryVideoInfo( path: outputFileURL, height: videoHeight, @@ -174,5 +296,37 @@ class SentryVideoFrameProcessor { } } +private extension SentryVideoFrameProcessor { + func removeOutputFile() { + guard FileManager.default.fileExists(atPath: outputFileURL.path) else { return } + + do { + try FileManager.default.removeItem(at: outputFileURL) + SentrySDKLog.debug("[Session Replay] Removed empty replay video at url: \(outputFileURL.path)") + } catch { + SentrySDKLog.warning("[Session Replay] Could not delete empty replay video at url: \(outputFileURL.path), reason: \(error)") + } + } + + func processUnreadableFrame( + _ frame: SentryReplayFrame, + videoWriterInput: AVAssetWriterInput, + onCompletion: @escaping (Result) -> Void + ) -> Bool { + if lastAppendedImage != nil { + // Fill the gap left by the unreadable frame with the last appended image. + guard handleAppendResult( + appendLastFrame(until: frame.time, videoWriterInput: videoWriterInput), + onCompletion: onCompletion + ) else { return false } + } else { + SentrySDKLog.warning("[Session Replay] Could not load initial replay frame image, skipping frame.") + } + + frameIndex += 1 + return true + } +} + #endif // os(iOS) || os(tvOS) #endif // canImport(UIKit) && !SENTRY_NO_UI_FRAMEWORK diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift index 63cd9bd793f..de86c3f570a 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift @@ -64,6 +64,34 @@ class SentryOnDemandReplayTests: XCTestCase { XCTAssertEqual(frames.first?.time, start.addingTimeInterval(5)) XCTAssertEqual(frames.last?.time, start.addingTimeInterval(9)) } + + func testDeinit_whenFrameIsRetainedBeforeCurrentFrames_shouldRemoveRetainedFrameFile() throws { + let start = Date(timeIntervalSinceReferenceDate: 0) + let retainedFramePath = outputPath + .appendingPathComponent("\(start.timeIntervalSinceReferenceDate)") + .appendingPathExtension("png") + .path + + let processingQueue = SentryDispatchQueueWrapper() + let workerQueue = SentryDispatchQueueWrapper() + var sut: SentryOnDemandReplay? = SentryOnDemandReplay( + outputPath: outputPath.path, + processingQueue: processingQueue, + assetWorkerQueue: workerQueue + ) + + sut?.addFrameAsync(timestamp: start, maskedViewImage: UIImage.add) + sut?.addFrameAsync(timestamp: start.addingTimeInterval(1), maskedViewImage: UIImage.add) + processingQueue.queue.sync {} + sut?.releaseFramesUntil(start.addingTimeInterval(1)) + processingQueue.queue.sync {} + + XCTAssertTrue(FileManager.default.fileExists(atPath: retainedFramePath)) + + sut = nil + + XCTAssertFalse(FileManager.default.fileExists(atPath: retainedFramePath)) + } func testFramesWithScreenName() { let sut = getSut() @@ -113,6 +141,130 @@ class SentryOnDemandReplayTests: XCTestCase { try FileManager.default.removeItem(at: videoPath) wait(for: [videoExpectation], timeout: 1) } + + func testGenerateVideo_whenFramesHaveGap_shouldHoldPreviousFrame() throws { + // -- Arrange -- + let sut = getSut() + + let start = Date(timeIntervalSinceReferenceDate: 0) + sut.addFrameAsync(timestamp: start, maskedViewImage: UIImage.add) + sut.addFrameAsync(timestamp: start.addingTimeInterval(3), maskedViewImage: UIImage.add) + + // -- Act -- + let videos = sut.createVideoWith(beginning: start, end: start.addingTimeInterval(5)) + + // -- Assert -- + XCTAssertEqual(videos.count, 1) + let info = try XCTUnwrap(videos.first) + + XCTAssertEqual(info.duration, 5) + XCTAssertEqual(info.frameCount, 5) + XCTAssertEqual(info.start, start) + XCTAssertEqual(info.end, start.addingTimeInterval(5)) + + try FileManager.default.removeItem(at: info.path) + } + + func testGenerateVideo_whenFirstFrameIsAfterBeginning_shouldKeepSegmentDuration() throws { + // -- Arrange -- + let sut = getSut() + + let start = Date(timeIntervalSinceReferenceDate: 0) + sut.addFrameAsync(timestamp: start.addingTimeInterval(2), maskedViewImage: UIImage.add) + + // -- Act -- + let videos = sut.createVideoWith(beginning: start, end: start.addingTimeInterval(5)) + + // -- Assert -- + XCTAssertEqual(videos.count, 1) + let info = try XCTUnwrap(videos.first) + + XCTAssertEqual(info.duration, 5) + XCTAssertEqual(info.frameCount, 5) + XCTAssertEqual(info.start, start) + XCTAssertEqual(info.end, start.addingTimeInterval(5)) + + try FileManager.default.removeItem(at: info.path) + } + + func testGenerateVideo_whenFrameExistsAtEnd_shouldKeepSegmentDuration() throws { + // -- Arrange -- + let sut = getSut() + + let start = Date(timeIntervalSinceReferenceDate: 0) + for i in 0...5 { + sut.addFrameAsync(timestamp: start.addingTimeInterval(TimeInterval(i)), maskedViewImage: UIImage.add) + } + + // -- Act -- + let firstSegment = sut.createVideoWith(beginning: start, end: start.addingTimeInterval(5)) + let secondSegment = sut.createVideoWith(beginning: start.addingTimeInterval(5), end: start.addingTimeInterval(10)) + + // -- Assert -- + XCTAssertEqual(firstSegment.count, 1) + let firstInfo = try XCTUnwrap(firstSegment.first) + XCTAssertEqual(firstInfo.duration, 5) + XCTAssertEqual(firstInfo.frameCount, 5) + XCTAssertEqual(firstInfo.start, start) + XCTAssertEqual(firstInfo.end, start.addingTimeInterval(5)) + + XCTAssertEqual(secondSegment.count, 1) + let secondInfo = try XCTUnwrap(secondSegment.first) + XCTAssertEqual(secondInfo.duration, 5) + XCTAssertEqual(secondInfo.frameCount, 5) + XCTAssertEqual(secondInfo.start, start.addingTimeInterval(5)) + XCTAssertEqual(secondInfo.end, start.addingTimeInterval(10)) + + try FileManager.default.removeItem(at: firstInfo.path) + try FileManager.default.removeItem(at: secondInfo.path) + } + + func testGenerateVideo_whenOnlyPreviousFrameExists_shouldHoldPreviousFrameFromBeginning() throws { + // -- Arrange -- + let sut = getSut() + + let start = Date(timeIntervalSinceReferenceDate: 0) + sut.addFrameAsync(timestamp: start.addingTimeInterval(4), maskedViewImage: UIImage.add) + sut.releaseFramesUntil(start.addingTimeInterval(5)) + + // -- Act -- + let videos = sut.createVideoWith(beginning: start.addingTimeInterval(5), end: start.addingTimeInterval(10)) + + // -- Assert -- + XCTAssertEqual(videos.count, 1) + let info = try XCTUnwrap(videos.first) + + XCTAssertEqual(info.duration, 5) + XCTAssertEqual(info.frameCount, 5) + XCTAssertEqual(info.start, start.addingTimeInterval(5)) + XCTAssertEqual(info.end, start.addingTimeInterval(10)) + + try FileManager.default.removeItem(at: info.path) + } + + func testGenerateVideo_whenFirstFrameAfterBeginning_shouldHoldPreviousFrameUntilFirstFrame() throws { + // -- Arrange -- + let sut = getSut() + + let start = Date(timeIntervalSinceReferenceDate: 0) + sut.addFrameAsync(timestamp: start.addingTimeInterval(4), maskedViewImage: UIImage.add) + sut.addFrameAsync(timestamp: start.addingTimeInterval(7), maskedViewImage: UIImage.add) + sut.releaseFramesUntil(start.addingTimeInterval(5)) + + // -- Act -- + let videos = sut.createVideoWith(beginning: start.addingTimeInterval(5), end: start.addingTimeInterval(10)) + + // -- Assert -- + XCTAssertEqual(videos.count, 1) + let info = try XCTUnwrap(videos.first) + + XCTAssertEqual(info.duration, 5) + XCTAssertEqual(info.frameCount, 5) + XCTAssertEqual(info.start, start.addingTimeInterval(5)) + XCTAssertEqual(info.end, start.addingTimeInterval(10)) + + try FileManager.default.removeItem(at: info.path) + } func testAddFrameIsThreadSafe() { let processingQueue = SentryDispatchQueueWrapper() diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift index 165a2d5077d..6bcdc03978c 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift @@ -294,9 +294,9 @@ class SentrySessionReplayIntegrationTests: XCTestCase { let replayInfo = try XCTUnwrap(hub.capturedReplayRecordingVideo.first) XCTAssertEqual(replayInfo.replay.replayType, SentryReplayType.session) XCTAssertEqual(replayInfo.recording.segmentId, 2) - XCTAssertEqual(replayInfo.replay.replayStartTimestamp, Date(timeIntervalSinceReferenceDate: 5)) + XCTAssertEqual(replayInfo.replay.replayStartTimestamp, Date(timeIntervalSinceReferenceDate: 4)) } - + func testBufferReplayForCrash() throws { try createLastSessionReplay(writeSessionInfo: false) diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentryVideoFrameProcessorTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentryVideoFrameProcessorTests.swift index 806d1af32e8..c6fe338b742 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentryVideoFrameProcessorTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentryVideoFrameProcessorTests.swift @@ -213,6 +213,112 @@ class SentryVideoFrameProcessorTests: XCTestCase { XCTAssertEqual(sut.usedFrames.count, 3) } + func testProcessFrames_WhenFramesHaveGap_ShouldHoldPreviousFrame() { + let videoWriterInput = TestAVAssetWriterInput(mediaType: .video, outputSettings: nil) + fixture.videoWriter.add(videoWriterInput) + + let frames = [ + SentryReplayFrame( + imagePath: fixture.videoFrames[0].imagePath, + time: Date(timeIntervalSinceReferenceDate: 0), + screenName: "A" + ), + SentryReplayFrame( + imagePath: fixture.videoFrames[0].imagePath, + time: Date(timeIntervalSinceReferenceDate: 3), + screenName: "B" + ) + ] + let sut = SentryVideoFrameProcessor( + videoFrames: frames, + videoWriter: fixture.videoWriter, + currentPixelBuffer: fixture.currentPixelBuffer, + outputFileURL: fixture.outputFileURL, + videoHeight: fixture.videoHeight, + videoWidth: fixture.videoWidth, + frameRate: fixture.frameRate, + initialFrameIndex: 0, + initialImageSize: fixture.initialImageSize, + videoEnd: Date(timeIntervalSinceReferenceDate: 5) + ) + + sut.processFrames(videoWriterInput: videoWriterInput) { _ in } + + XCTAssertEqual(fixture.currentPixelBuffer.appendInvocations.count, 5) + let presentationTimes = fixture.currentPixelBuffer.appendInvocations.invocations.map { $0.presentationTime.seconds } + XCTAssertEqual(presentationTimes, [0, 1, 2, 3, 4]) + XCTAssertEqual(sut.usedFrames.compactMap(\.screenName), ["A", "A", "A", "B", "B"]) + } + + func testProcessFrames_WhenFramesHaveFractionalGap_ShouldNotOverExpandDuration() { + let videoWriterInput = TestAVAssetWriterInput(mediaType: .video, outputSettings: nil) + fixture.videoWriter.add(videoWriterInput) + + let frames = [ + SentryReplayFrame( + imagePath: fixture.videoFrames[0].imagePath, + time: Date(timeIntervalSinceReferenceDate: 0), + screenName: "A" + ), + SentryReplayFrame( + imagePath: fixture.videoFrames[0].imagePath, + time: Date(timeIntervalSinceReferenceDate: 2.4), + screenName: "B" + ) + ] + let sut = SentryVideoFrameProcessor( + videoFrames: frames, + videoWriter: fixture.videoWriter, + currentPixelBuffer: fixture.currentPixelBuffer, + outputFileURL: fixture.outputFileURL, + videoHeight: fixture.videoHeight, + videoWidth: fixture.videoWidth, + frameRate: fixture.frameRate, + initialFrameIndex: 0, + initialImageSize: fixture.initialImageSize, + videoEnd: Date(timeIntervalSinceReferenceDate: 2.4) + ) + + sut.processFrames(videoWriterInput: videoWriterInput) { _ in } + + XCTAssertEqual(fixture.currentPixelBuffer.appendInvocations.count, 3) + let presentationTimes = fixture.currentPixelBuffer.appendInvocations.invocations.map { $0.presentationTime.seconds } + XCTAssertEqual(presentationTimes, [0, 1, 2]) + XCTAssertEqual(sut.usedFrames.compactMap(\.screenName), ["A", "A", "B"]) + } + + func testProcessFrames_WhenVideoEndHasFractionalGap_ShouldNotCompressDuration() { + let videoWriterInput = TestAVAssetWriterInput(mediaType: .video, outputSettings: nil) + fixture.videoWriter.add(videoWriterInput) + + let frames = [ + SentryReplayFrame( + imagePath: fixture.videoFrames[0].imagePath, + time: Date(timeIntervalSinceReferenceDate: 0), + screenName: "A" + ) + ] + let sut = SentryVideoFrameProcessor( + videoFrames: frames, + videoWriter: fixture.videoWriter, + currentPixelBuffer: fixture.currentPixelBuffer, + outputFileURL: fixture.outputFileURL, + videoHeight: fixture.videoHeight, + videoWidth: fixture.videoWidth, + frameRate: fixture.frameRate, + initialFrameIndex: 0, + initialImageSize: fixture.initialImageSize, + videoEnd: Date(timeIntervalSinceReferenceDate: 2.4) + ) + + sut.processFrames(videoWriterInput: videoWriterInput) { _ in } + + XCTAssertEqual(fixture.currentPixelBuffer.appendInvocations.count, 3) + let presentationTimes = fixture.currentPixelBuffer.appendInvocations.invocations.map { $0.presentationTime.seconds } + XCTAssertEqual(presentationTimes, [0, 1, 2]) + XCTAssertEqual(sut.usedFrames.compactMap(\.screenName), ["A", "A", "A"]) + } + func testProcessFrames_WhenVideoWriterNotWriting_ShouldCancelWriting() { let sut = fixture.getSut() let videoWriterInput = TestAVAssetWriterInput(mediaType: .video, outputSettings: nil) @@ -299,13 +405,13 @@ class SentryVideoFrameProcessorTests: XCTestCase { } } - func testProcessFrames_WhenImageCannotBeLoaded_ShouldSkipFrame() { + func testProcessFrames_WhenInitialImageCannotBeLoaded_ShouldSkipFrame() { let videoWriterInput = TestAVAssetWriterInput(mediaType: .video, outputSettings: nil) let completionInvocations = Invocations>() // Create frames with non-existent image paths let nonExistentFrames = [ - SentryReplayFrame(imagePath: "/another/non/existent/path.png", time: Date(), screenName: "Screen2") + SentryReplayFrame(imagePath: "/another/non/existent/path.png", time: Date(timeIntervalSinceReferenceDate: 1), screenName: "Screen2") ] let sutWithNonExistentFrames = SentryVideoFrameProcessor( @@ -322,9 +428,45 @@ class SentryVideoFrameProcessorTests: XCTestCase { sutWithNonExistentFrames.processFrames(videoWriterInput: videoWriterInput) { completionInvocations.record($0) } - // Should still increment frame index even if image can't be loaded XCTAssertEqual(sutWithNonExistentFrames.frameIndex, 1) XCTAssertEqual(sutWithNonExistentFrames.usedFrames.count, 0) + XCTAssertEqual(fixture.currentPixelBuffer.appendInvocations.count, 0) + } + + func testProcessFrames_WhenTrailingImageCannotBeLoaded_ShouldHoldPreviousFrame() throws { + let videoWriterInput = TestAVAssetWriterInput(mediaType: .video, outputSettings: nil) + fixture.videoWriter.add(videoWriterInput) + + let frames = [ + SentryReplayFrame( + imagePath: try fixture.createTestImage(), + time: Date(timeIntervalSinceReferenceDate: 0), + screenName: "A" + ), + SentryReplayFrame( + imagePath: "/non/existent/path.png", + time: Date(timeIntervalSinceReferenceDate: 3), + screenName: "Missing" + ) + ] + let sut = SentryVideoFrameProcessor( + videoFrames: frames, + videoWriter: fixture.videoWriter, + currentPixelBuffer: fixture.currentPixelBuffer, + outputFileURL: fixture.outputFileURL, + videoHeight: fixture.videoHeight, + videoWidth: fixture.videoWidth, + frameRate: fixture.frameRate, + initialFrameIndex: 0, + initialImageSize: fixture.initialImageSize + ) + + sut.processFrames(videoWriterInput: videoWriterInput) { _ in } + + XCTAssertEqual(fixture.currentPixelBuffer.appendInvocations.count, 3) + let presentationTimes = fixture.currentPixelBuffer.appendInvocations.invocations.map { $0.presentationTime.seconds } + XCTAssertEqual(presentationTimes, [0, 1, 2]) + XCTAssertEqual(sut.usedFrames.compactMap(\.screenName), ["A", "A", "A"]) } // MARK: - Finish Video Tests @@ -369,6 +511,35 @@ class SentryVideoFrameProcessorTests: XCTestCase { } } + func testFinishVideo_WhenWriterCompletedWithoutUsedFrames_ShouldReturnNilVideoInfo() throws { + let sut = fixture.getSut() + let videoWriterInput = TestAVAssetWriterInput(mediaType: .video, outputSettings: nil) + fixture.videoWriter.add(videoWriterInput) + fixture.videoWriter.statusOverride = .completed + let completionInvocations = Invocations>() + + try Data("empty video data".utf8).write(to: fixture.outputFileURL) + + sut.finishVideo(frameIndex: 1) { result in + completionInvocations.record(result) + } + + XCTAssertEqual(videoWriterInput.markAsFinishedInvocations.count, 1) + XCTAssertEqual(completionInvocations.count, 1) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.outputFileURL.path)) + + let result = completionInvocations.invocations.first + XCTAssertNotNil(result) + + switch result { + case .success(let videoResult): + XCTAssertEqual(videoResult.finalFrameIndex, 1) + XCTAssertNil(videoResult.info) + default: + XCTFail("Expected success result with nil info") + } + } + func testFinishVideo_WhenWriterCancelled_ShouldReturnNilVideoInfo() { let sut = fixture.getSut() let videoWriterInput = TestAVAssetWriterInput(mediaType: .video, outputSettings: nil) @@ -540,6 +711,32 @@ class SentryVideoFrameProcessorTests: XCTestCase { XCTAssertEqual(videoInfo.screens, ["Screen1", "Screen2", "Screen3"]) } + func testGetVideoInfo_WithMoreThanOneFPS_ShouldUseFractionalDuration() throws { + let sut = SentryVideoFrameProcessor( + videoFrames: fixture.videoFrames, + videoWriter: fixture.videoWriter, + currentPixelBuffer: fixture.currentPixelBuffer, + outputFileURL: fixture.outputFileURL, + videoHeight: fixture.videoHeight, + videoWidth: fixture.videoWidth, + frameRate: 2, + initialFrameIndex: fixture.initialFrameIndex, + initialImageSize: fixture.initialImageSize + ) + let testData = Data("test video data".utf8) + try testData.write(to: fixture.outputFileURL) + + let videoInfo = try sut.getVideoInfo( + from: fixture.outputFileURL, + usedFrames: fixture.videoFrames, + videoWidth: 200, + videoHeight: 100 + ) + + XCTAssertEqual(videoInfo.duration, 1.5) + XCTAssertEqual(videoInfo.end, Date(timeIntervalSinceReferenceDate: 1.5)) + } + func testGetVideoInfo_WithNonExistentFile_ShouldThrowError() { let sut = fixture.getSut() From 75e639265329e044401e0ec5e2357594612cee1e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 11 Jun 2026 19:25:44 +0200 Subject: [PATCH 02/14] Fix changelog formatting and update for 9.17.1 Removed merge conflict markers and updated changelog for version 9.17.1. --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cedfa77ca88..8222fec9635 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,11 @@ # Changelog -<<<<<<< fix/replay-video-assembly ## Unreleased ### Fixes - Session replay video assembly: drop empty video segments, avoid duplicating frames at segment boundaries, and keep video timing stable when captured frames are skipped or unreadable (#8041) -======= + ## 9.17.1 ### Fixes @@ -15,7 +14,6 @@ - Fix missing `_OBJC_CLASS_$_` symbols in x86_64 slice of SentryObjC dynamic framework (#8037) - Mark feedback form aliases and conformances unavailable in app extensions (#8040) - Silence retroactive conformance warning for `SentryLevel: CustomStringConvertible` when building with SPM from source (#8032) ->>>>>>> main ## 9.17.0 From ebeaa32edefdb9b464181090093e711f235a8331 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 11 Jun 2026 23:19:42 +0200 Subject: [PATCH 03/14] ref(session-replay): Inline single-use video helpers Inline videoFramesForRendering and replaceRetainedFrame at their only call sites and drop the one-line frame(_:movedTo:) wrapper. No behavior change. --- .../SessionReplay/SentryOnDemandReplay.swift | 65 +++++++++---------- 1 file changed, 29 insertions(+), 36 deletions(-) diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index fb06e4f36bf..34908e8ee72 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -145,7 +145,18 @@ import UIKit SentrySDKLog.debug("[Session Replay] Releasing frames until date: \(date)") while let first = self._frames.first, first.time < date { self._frames.removeFirst() - self.replaceRetainedFrame(first) + // Retain the released frame so the next segment can still render the screen + // state at its window start; delete the previously retained frame's file once + // it is replaced. + let frameToRemove = self.retainedFrameLock.synchronized { () -> SentryReplayFrame? in + let previousFrame = self.retainedFrameBeforeCurrentFrames + self.retainedFrameBeforeCurrentFrames = first + guard previousFrame?.imagePath != first.imagePath else { return nil } + return previousFrame + } + if let frameToRemove = frameToRemove { + self.removeFrameFile(frameToRemove) + } } SentrySDKLog.debug("[Session Replay] Frames released, remaining frames count: \(self._frames.count)") } @@ -178,7 +189,23 @@ import UIKit // Note: In previous implementations this method was wrapped by a sync call to the processing queue. // As this method is already called from the processing queue, we must remove the sync call. - let videoFrames = self.videoFramesForRendering(beginning: beginning, end: end) + guard end > beginning else { return [] } + + // Select the frames in the half-open window [beginning, end). When captures were skipped, + // hold the last frame captured before the window at the window start so the segment still + // begins with the correct screen state. + var videoFrames = _frames.filter { $0.time >= beginning && $0.time < end } + if let firstFrame = videoFrames.first { + if firstFrame.time > beginning { + let frameToHold = frameBefore(beginning) ?? firstFrame + videoFrames.insert(SentryReplayFrame(imagePath: frameToHold.imagePath, time: beginning, screenName: frameToHold.screenName), at: 0) + } + } else if let previousFrame = frameBefore(beginning) { + videoFrames = [SentryReplayFrame(imagePath: previousFrame.imagePath, time: beginning, screenName: previousFrame.screenName)] + } else { + return [] + } + var frameCount = 0 var videos = [SentryVideoInfo]() @@ -237,23 +264,6 @@ import UIKit return videos } - private func videoFramesForRendering(beginning: Date, end: Date) -> [SentryReplayFrame] { - guard end > beginning else { return [] } - - var videoFrames = self._frames.filter { $0.time >= beginning && $0.time < end } - guard let firstFrame = videoFrames.first else { - guard let previousFrame = frameBefore(beginning) else { return [] } - return [frame(previousFrame, movedTo: beginning)] - } - - if firstFrame.time > beginning { - let frameToHold = frameBefore(beginning) ?? firstFrame - videoFrames.insert(frame(frameToHold, movedTo: beginning), at: 0) - } - - return videoFrames - } - private func frameBefore(_ date: Date) -> SentryReplayFrame? { let retainedFrame = retainedFrameLock.synchronized { retainedFrameBeforeCurrentFrames @@ -265,23 +275,6 @@ import UIKit return retained.time > current.time ? retained : current } - private func frame(_ frame: SentryReplayFrame, movedTo time: Date) -> SentryReplayFrame { - return SentryReplayFrame(imagePath: frame.imagePath, time: time, screenName: frame.screenName) - } - - private func replaceRetainedFrame(_ frame: SentryReplayFrame) { - let frameToRemove = retainedFrameLock.synchronized { () -> SentryReplayFrame? in - let previousFrame = retainedFrameBeforeCurrentFrames - retainedFrameBeforeCurrentFrames = frame - guard previousFrame?.imagePath != frame.imagePath else { return nil } - return previousFrame - } - - if let frameToRemove = frameToRemove { - removeFrameFile(frameToRemove) - } - } - private func removeFrameFile(_ frame: SentryReplayFrame) { let fileUrl = URL(fileURLWithPath: frame.imagePath) do { From 5538c957f97444eca7dfc6b2d7fdb51d60feed0b Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 12 Jun 2026 10:23:27 +0200 Subject: [PATCH 04/14] ref(session-replay): Inline single-use video processor helpers Inline appendLastFrameUntilVideoEnd, cancelWriting, presentationFrameCount, presentationFrameIndex, processCurrentFrame, processUnreadableFrame, and the appendLastFrame(date) overload into their single call sites. Keeps appendLastFrame(untilFrameIndex:), append(image:forFrame:), and handleAppendResult which have multiple callers. --- .../SentryVideoFrameProcessor.swift | 141 +++++++----------- 1 file changed, 52 insertions(+), 89 deletions(-) diff --git a/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift b/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift index 267d4fcb9be..626d3dcc10f 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift @@ -63,6 +63,7 @@ class SentryVideoFrameProcessor { self.isFinished = false } + // swiftlint:disable function_body_length cyclomatic_complexity func processFrames(videoWriterInput: AVAssetWriterInput, onCompletion: @escaping (Result) -> Void) { // Use the recommended loop pattern for AVAssetWriterInput // See https://developer.apple.com/documentation/avfoundation/avassetwriterinput/requestmediadatawhenready(on:using:)#Discussion @@ -76,49 +77,63 @@ class SentryVideoFrameProcessor { return onCompletion(.failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo)) } guard frameIndex < videoFrames.count else { - guard handleAppendResult( - appendLastFrameUntilVideoEnd(videoWriterInput: videoWriterInput), - onCompletion: onCompletion - ) else { return } + if let videoEnd = videoEnd, let videoStart = videoStart { + let elapsed = max(0, videoEnd.timeIntervalSince(videoStart)) + let targetFrameIndex = max(0, Int(ceil(elapsed * Double(frameRate) - Self.frameIndexEpsilon))) + guard handleAppendResult( + appendLastFrame(untilFrameIndex: targetFrameIndex, videoWriterInput: videoWriterInput), + onCompletion: onCompletion + ) else { return } + } SentrySDKLog.debug("[Session Replay] No more frames available to process, finishing the video") return finishVideo(frameIndex: self.frameIndex, onCompletion: onCompletion) } - guard processCurrentFrame(videoWriterInput: videoWriterInput, onCompletion: onCompletion) else { return } - } - } - - private func processCurrentFrame( - videoWriterInput: AVAssetWriterInput, - onCompletion: @escaping (Result) -> Void - ) -> Bool { - let frame = videoFrames[frameIndex] - guard let image = UIImage(contentsOfFile: frame.imagePath) else { - return processUnreadableFrame(frame, videoWriterInput: videoWriterInput, onCompletion: onCompletion) - } + let frame = videoFrames[frameIndex] + guard let image = UIImage(contentsOfFile: frame.imagePath) else { + if lastAppendedImage != nil { + if let videoStart = videoStart { + let elapsed = max(0, frame.time.timeIntervalSince(videoStart)) + let targetFrameIndex = max(0, Int(floor(elapsed * Double(frameRate) + Self.frameIndexEpsilon))) + guard handleAppendResult( + appendLastFrame(untilFrameIndex: targetFrameIndex, videoWriterInput: videoWriterInput), + onCompletion: onCompletion + ) else { return } + } + } else { + SentrySDKLog.warning("[Session Replay] Could not load initial replay frame image, skipping frame.") + } + frameIndex += 1 + continue + } - guard handleAppendResult( - appendLastFrame(until: frame.time, videoWriterInput: videoWriterInput), - onCompletion: onCompletion - ) else { return false } + if let videoStart = videoStart { + let elapsed = max(0, frame.time.timeIntervalSince(videoStart)) + let targetFrameIndex = max(0, Int(floor(elapsed * Double(frameRate) + Self.frameIndexEpsilon))) + guard handleAppendResult( + appendLastFrame(untilFrameIndex: targetFrameIndex, videoWriterInput: videoWriterInput), + onCompletion: onCompletion + ) else { return } + } - SentrySDKLog.debug("[Session Replay] Image at index \(frameIndex) is ready, size: \(image.size)") - guard lastImageSize == image.size else { - SentrySDKLog.debug("[Session Replay] Image size has changed, finishing video") - finishVideo(frameIndex: self.frameIndex, onCompletion: onCompletion) - return false - } - lastImageSize = image.size + SentrySDKLog.debug("[Session Replay] Image at index \(frameIndex) is ready, size: \(image.size)") + guard lastImageSize == image.size else { + SentrySDKLog.debug("[Session Replay] Image size has changed, finishing video") + finishVideo(frameIndex: self.frameIndex, onCompletion: onCompletion) + return + } + lastImageSize = image.size - guard handleAppendResult( - append(image: image, forFrame: frame, videoWriterInput: videoWriterInput), - onCompletion: onCompletion - ) else { return false } + guard handleAppendResult( + append(image: image, forFrame: frame, videoWriterInput: videoWriterInput), + onCompletion: onCompletion + ) else { return } - frameIndex += 1 - return true + frameIndex += 1 + } } + // swiftlint:enable function_body_length cyclomatic_complexity private func handleAppendResult( _ result: AppendFrameResult, @@ -130,34 +145,14 @@ class SentryVideoFrameProcessor { case .notReady: return false case .failure: - cancelWriting(onCompletion: onCompletion) + SentrySDKLog.error("[Session Replay] Failed to append image to pixel buffer, cancelling the writing session, reason: \(String(describing: videoWriter.error))") + videoWriter.inputs.forEach { $0.markAsFinished() } + videoWriter.cancelWriting() + onCompletion(.failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo)) return false } } - private func cancelWriting(onCompletion completion: @escaping (Result) -> Void) { - SentrySDKLog.error("[Session Replay] Failed to append image to pixel buffer, cancelling the writing session, reason: \(String(describing: videoWriter.error))") - videoWriter.inputs.forEach { $0.markAsFinished() } - videoWriter.cancelWriting() - completion(.failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo)) - } - - private func appendLastFrameUntilVideoEnd(videoWriterInput: AVAssetWriterInput) -> AppendFrameResult { - guard let videoEnd = videoEnd, let videoStart = videoStart else { return .success } - return appendLastFrame( - untilFrameIndex: presentationFrameCount(until: videoEnd, from: videoStart), - videoWriterInput: videoWriterInput - ) - } - - private func appendLastFrame(until date: Date, videoWriterInput: AVAssetWriterInput) -> AppendFrameResult { - guard let videoStart = videoStart else { return .success } - return appendLastFrame( - untilFrameIndex: presentationFrameIndex(forCapturedFrameAt: date, from: videoStart), - videoWriterInput: videoWriterInput - ) - } - private func appendLastFrame(untilFrameIndex targetFrameIndex: Int, videoWriterInput: AVAssetWriterInput) -> AppendFrameResult { guard let lastAppendedImage = lastAppendedImage, let lastAppendedFrame = lastAppendedFrame @@ -197,19 +192,6 @@ class SentryVideoFrameProcessor { return .success } - private func presentationFrameIndex(forCapturedFrameAt date: Date, from start: Date) -> Int { - let elapsed = max(0, date.timeIntervalSince(start)) - let index = floor(elapsed * Double(frameRate) + Self.frameIndexEpsilon) - return max(0, Int(index)) - } - - private func presentationFrameCount(until date: Date, from start: Date) -> Int { - let elapsed = max(0, date.timeIntervalSince(start)) - let frameCount = ceil(elapsed * Double(frameRate) - Self.frameIndexEpsilon) - return max(0, Int(frameCount)) - } - - // swiftlint:enable function_body_length cyclomatic_complexity func finishVideo(frameIndex: Int, onCompletion completion: @escaping (Result) -> Void) { // Note: This method is expected to be called from the asset worker queue and *not* the processing queue. SentrySDKLog.info("[Session Replay] Finishing video with output file URL: \(outputFileURL), used frames count: \(usedFrames.count), video height: \(videoHeight), video width: \(videoWidth)") @@ -307,25 +289,6 @@ private extension SentryVideoFrameProcessor { SentrySDKLog.warning("[Session Replay] Could not delete empty replay video at url: \(outputFileURL.path), reason: \(error)") } } - - func processUnreadableFrame( - _ frame: SentryReplayFrame, - videoWriterInput: AVAssetWriterInput, - onCompletion: @escaping (Result) -> Void - ) -> Bool { - if lastAppendedImage != nil { - // Fill the gap left by the unreadable frame with the last appended image. - guard handleAppendResult( - appendLastFrame(until: frame.time, videoWriterInput: videoWriterInput), - onCompletion: onCompletion - ) else { return false } - } else { - SentrySDKLog.warning("[Session Replay] Could not load initial replay frame image, skipping frame.") - } - - frameIndex += 1 - return true - } } #endif // os(iOS) || os(tvOS) From 9549af11fbf5d89ebc0d7fab42841e5cd830bf6a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 12 Jun 2026 12:15:24 +0200 Subject: [PATCH 05/14] fix(session-replay): Fix mixed-frames test using identical timestamps Use distinct timestamps so the gap-filling logic is actually exercised, and update assertions to expect the gap-fill frame. --- .../SentryVideoFrameProcessorTests.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentryVideoFrameProcessorTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentryVideoFrameProcessorTests.swift index c6fe338b742..7440419c640 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentryVideoFrameProcessorTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentryVideoFrameProcessorTests.swift @@ -812,11 +812,10 @@ class SentryVideoFrameProcessorTests: XCTestCase { let videoWriterInput = TestAVAssetWriterInput(mediaType: .video, outputSettings: nil) let completionInvocations = Invocations>() - // Create mixed frames (valid and invalid) let mixedFrames = [ - SentryReplayFrame(imagePath: try fixture.createTestImage(), time: Date(), screenName: "Valid1"), - SentryReplayFrame(imagePath: "/non/existent/path.png", time: Date(), screenName: "Invalid"), - SentryReplayFrame(imagePath: try fixture.createTestImage(), time: Date(), screenName: "Valid2") + SentryReplayFrame(imagePath: try fixture.createTestImage(), time: Date(timeIntervalSinceReferenceDate: 0), screenName: "Valid1"), + SentryReplayFrame(imagePath: "/non/existent/path.png", time: Date(timeIntervalSinceReferenceDate: 1), screenName: "Invalid"), + SentryReplayFrame(imagePath: try fixture.createTestImage(), time: Date(timeIntervalSinceReferenceDate: 2), screenName: "Valid2") ] let sutWithMixedFrames = SentryVideoFrameProcessor( @@ -833,9 +832,10 @@ class SentryVideoFrameProcessorTests: XCTestCase { sutWithMixedFrames.processFrames(videoWriterInput: videoWriterInput) { completionInvocations.record($0) } - // Should process valid frames and skip invalid ones XCTAssertEqual(sutWithMixedFrames.frameIndex, 3) - XCTAssertEqual(sutWithMixedFrames.usedFrames.count, 2) // Only valid frames + // 3 used frames: Valid1 at t=0, gap-fill (holding Valid1) at t=1, Valid2 at t=2 + XCTAssertEqual(sutWithMixedFrames.usedFrames.count, 3) + XCTAssertEqual(sutWithMixedFrames.usedFrames.compactMap(\.screenName), ["Valid1", "Valid1", "Valid2"]) } } From e10075d9ab44e12cd6539960c5681858f51d9c3c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 15 Jun 2026 13:03:20 +0200 Subject: [PATCH 06/14] docs(session-replay): Explain bounded video fill Document why bounded replay segments repeat the last captured frame until the requested video end. --- .../Integrations/SessionReplay/SentryVideoFrameProcessor.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift b/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift index 626d3dcc10f..a572e4d042a 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift @@ -77,6 +77,8 @@ class SentryVideoFrameProcessor { return onCompletion(.failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo)) } guard frameIndex < videoFrames.count else { + // When rendering a bounded segment, keep the last captured frame on screen + // until `videoEnd`. Without bounds, finish at the last captured frame. if let videoEnd = videoEnd, let videoStart = videoStart { let elapsed = max(0, videoEnd.timeIntervalSince(videoStart)) let targetFrameIndex = max(0, Int(ceil(elapsed * Double(frameRate) - Self.frameIndexEpsilon))) From 7212439c9a50910c2d5064658fe689b8c3ecec66 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 15 Jun 2026 13:04:14 +0200 Subject: [PATCH 07/14] ref(session-replay): Use throwing append handling Make append result handling return a boolean or throw, instead of taking the video completion callback. --- .../SentryVideoFrameProcessor.swift | 52 +++++++++++-------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift b/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift index a572e4d042a..ecf1f2f8748 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift @@ -82,10 +82,13 @@ class SentryVideoFrameProcessor { if let videoEnd = videoEnd, let videoStart = videoStart { let elapsed = max(0, videoEnd.timeIntervalSince(videoStart)) let targetFrameIndex = max(0, Int(ceil(elapsed * Double(frameRate) - Self.frameIndexEpsilon))) - guard handleAppendResult( - appendLastFrame(untilFrameIndex: targetFrameIndex, videoWriterInput: videoWriterInput), - onCompletion: onCompletion - ) else { return } + do { + guard try shouldContinueProcessing( + after: appendLastFrame(untilFrameIndex: targetFrameIndex, videoWriterInput: videoWriterInput) + ) else { return } + } catch { + return onCompletion(.failure(error)) + } } SentrySDKLog.debug("[Session Replay] No more frames available to process, finishing the video") @@ -98,10 +101,13 @@ class SentryVideoFrameProcessor { if let videoStart = videoStart { let elapsed = max(0, frame.time.timeIntervalSince(videoStart)) let targetFrameIndex = max(0, Int(floor(elapsed * Double(frameRate) + Self.frameIndexEpsilon))) - guard handleAppendResult( - appendLastFrame(untilFrameIndex: targetFrameIndex, videoWriterInput: videoWriterInput), - onCompletion: onCompletion - ) else { return } + do { + guard try shouldContinueProcessing( + after: appendLastFrame(untilFrameIndex: targetFrameIndex, videoWriterInput: videoWriterInput) + ) else { return } + } catch { + return onCompletion(.failure(error)) + } } } else { SentrySDKLog.warning("[Session Replay] Could not load initial replay frame image, skipping frame.") @@ -113,10 +119,13 @@ class SentryVideoFrameProcessor { if let videoStart = videoStart { let elapsed = max(0, frame.time.timeIntervalSince(videoStart)) let targetFrameIndex = max(0, Int(floor(elapsed * Double(frameRate) + Self.frameIndexEpsilon))) - guard handleAppendResult( - appendLastFrame(untilFrameIndex: targetFrameIndex, videoWriterInput: videoWriterInput), - onCompletion: onCompletion - ) else { return } + do { + guard try shouldContinueProcessing( + after: appendLastFrame(untilFrameIndex: targetFrameIndex, videoWriterInput: videoWriterInput) + ) else { return } + } catch { + return onCompletion(.failure(error)) + } } SentrySDKLog.debug("[Session Replay] Image at index \(frameIndex) is ready, size: \(image.size)") @@ -127,20 +136,20 @@ class SentryVideoFrameProcessor { } lastImageSize = image.size - guard handleAppendResult( - append(image: image, forFrame: frame, videoWriterInput: videoWriterInput), - onCompletion: onCompletion - ) else { return } + do { + guard try shouldContinueProcessing( + after: append(image: image, forFrame: frame, videoWriterInput: videoWriterInput) + ) else { return } + } catch { + return onCompletion(.failure(error)) + } frameIndex += 1 } } // swiftlint:enable function_body_length cyclomatic_complexity - private func handleAppendResult( - _ result: AppendFrameResult, - onCompletion: @escaping (Result) -> Void - ) -> Bool { + private func shouldContinueProcessing(after result: AppendFrameResult) throws -> Bool { switch result { case .success: return true @@ -150,8 +159,7 @@ class SentryVideoFrameProcessor { SentrySDKLog.error("[Session Replay] Failed to append image to pixel buffer, cancelling the writing session, reason: \(String(describing: videoWriter.error))") videoWriter.inputs.forEach { $0.markAsFinished() } videoWriter.cancelWriting() - onCompletion(.failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo)) - return false + throw videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo } } From f28011fd366f701a5fac16ad791e833b7cb2269a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 15 Jun 2026 13:05:20 +0200 Subject: [PATCH 08/14] ref(session-replay): Rename repeated frame helper Name the helper for what it does: repeat the last appended frame until the video reaches a target output index. --- .../SessionReplay/SentryVideoFrameProcessor.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift b/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift index ecf1f2f8748..06dd41ad679 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift @@ -84,7 +84,7 @@ class SentryVideoFrameProcessor { let targetFrameIndex = max(0, Int(ceil(elapsed * Double(frameRate) - Self.frameIndexEpsilon))) do { guard try shouldContinueProcessing( - after: appendLastFrame(untilFrameIndex: targetFrameIndex, videoWriterInput: videoWriterInput) + after: repeatLastFrame(untilFrameIndex: targetFrameIndex, videoWriterInput: videoWriterInput) ) else { return } } catch { return onCompletion(.failure(error)) @@ -103,7 +103,7 @@ class SentryVideoFrameProcessor { let targetFrameIndex = max(0, Int(floor(elapsed * Double(frameRate) + Self.frameIndexEpsilon))) do { guard try shouldContinueProcessing( - after: appendLastFrame(untilFrameIndex: targetFrameIndex, videoWriterInput: videoWriterInput) + after: repeatLastFrame(untilFrameIndex: targetFrameIndex, videoWriterInput: videoWriterInput) ) else { return } } catch { return onCompletion(.failure(error)) @@ -121,7 +121,7 @@ class SentryVideoFrameProcessor { let targetFrameIndex = max(0, Int(floor(elapsed * Double(frameRate) + Self.frameIndexEpsilon))) do { guard try shouldContinueProcessing( - after: appendLastFrame(untilFrameIndex: targetFrameIndex, videoWriterInput: videoWriterInput) + after: repeatLastFrame(untilFrameIndex: targetFrameIndex, videoWriterInput: videoWriterInput) ) else { return } } catch { return onCompletion(.failure(error)) @@ -163,7 +163,8 @@ class SentryVideoFrameProcessor { } } - private func appendLastFrame(untilFrameIndex targetFrameIndex: Int, videoWriterInput: AVAssetWriterInput) -> AppendFrameResult { + /// Repeats the most recently appended image until the output reaches `targetFrameIndex`. + private func repeatLastFrame(untilFrameIndex targetFrameIndex: Int, videoWriterInput: AVAssetWriterInput) -> AppendFrameResult { guard let lastAppendedImage = lastAppendedImage, let lastAppendedFrame = lastAppendedFrame else { return .success } From b36cc551e1c2d8b2ae75f9a7ea0a2ff9592b16b2 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 15 Jun 2026 13:06:00 +0200 Subject: [PATCH 09/14] ref(session-replay): Share replay file cleanup Replace duplicate replay frame and empty video deletion helpers with a single file removal helper. --- .../SessionReplay/SentryOnDemandReplay.swift | 25 ++++++++++--------- .../SentryVideoFrameProcessor.swift | 15 +---------- 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index 34908e8ee72..5a4bd22d7c2 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -9,6 +9,17 @@ import CoreMedia import Foundation import UIKit +func removeReplayFile(at fileURL: URL) { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return } + + do { + try FileManager.default.removeItem(at: fileURL) + SentrySDKLog.debug("[Session Replay] Removed replay file at: \(fileURL.path)") + } catch { + SentrySDKLog.warning("[Session Replay] Could not delete replay file at: \(fileURL.path), reason: \(error)") + } +} + // swiftlint:disable type_body_length @objcMembers @_spi(Private) public class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { @@ -53,7 +64,7 @@ import UIKit return frame } if let retainedFrame = retainedFrame { - removeFrameFile(retainedFrame) + removeReplayFile(at: URL(fileURLWithPath: retainedFrame.imagePath)) } } @@ -155,7 +166,7 @@ import UIKit return previousFrame } if let frameToRemove = frameToRemove { - self.removeFrameFile(frameToRemove) + removeReplayFile(at: URL(fileURLWithPath: frameToRemove.imagePath)) } } SentrySDKLog.debug("[Session Replay] Frames released, remaining frames count: \(self._frames.count)") @@ -275,16 +286,6 @@ import UIKit return retained.time > current.time ? retained : current } - private func removeFrameFile(_ frame: SentryReplayFrame) { - let fileUrl = URL(fileURLWithPath: frame.imagePath) - do { - try FileManager.default.removeItem(at: fileUrl) - SentrySDKLog.debug("[Session Replay] Removed frame at url: \(fileUrl.path)") - } catch { - SentrySDKLog.error("[Session Replay] Failed to remove frame at: \(fileUrl.path), reason: \(error), ignoring error") - } - } - // swiftlint:disable function_body_length cyclomatic_complexity private func renderVideo( with videoFrames: [SentryReplayFrame], diff --git a/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift b/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift index 06dd41ad679..24884217ed3 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift @@ -228,7 +228,7 @@ class SentryVideoFrameProcessor { SentrySDKLog.debug("[Session Replay] Finish writing video was completed, creating video info from file attributes.") guard !self.usedFrames.isEmpty else { SentrySDKLog.debug("[Session Replay] Finished video writing without appended frames, completing with no video info") - self.removeOutputFile() + removeReplayFile(at: self.outputFileURL) let videoResult = SentryRenderVideoResult(info: nil, finalFrameIndex: frameIndex) return completion(.success(videoResult)) } @@ -289,18 +289,5 @@ class SentryVideoFrameProcessor { } } -private extension SentryVideoFrameProcessor { - func removeOutputFile() { - guard FileManager.default.fileExists(atPath: outputFileURL.path) else { return } - - do { - try FileManager.default.removeItem(at: outputFileURL) - SentrySDKLog.debug("[Session Replay] Removed empty replay video at url: \(outputFileURL.path)") - } catch { - SentrySDKLog.warning("[Session Replay] Could not delete empty replay video at url: \(outputFileURL.path), reason: \(error)") - } - } -} - #endif // os(iOS) || os(tvOS) #endif // canImport(UIKit) && !SENTRY_NO_UI_FRAMEWORK From 48952c86d7e4acbaca0c06a28132a2ba04cc07ac Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 15 Jun 2026 13:06:27 +0200 Subject: [PATCH 10/14] docs(session-replay): Explain retained frame cleanup Document why clean shutdown removes the retained frame file while crash recovery leaves it for the next launch. --- .../Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index 5a4bd22d7c2..bbf885461a4 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -58,6 +58,8 @@ func removeReplayFile(at fileURL: URL) { } deinit { + // Clean shutdown removes the retained file. If the app crashes, the file stays + // in the replay folder and is loaded during crash recovery on the next launch. let retainedFrame = retainedFrameLock.synchronized { let frame = retainedFrameBeforeCurrentFrames retainedFrameBeforeCurrentFrames = nil From 20ff61cb41f1b9c0fafbd9a433fcc2d7d8f6ef85 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 15 Jun 2026 13:06:53 +0200 Subject: [PATCH 11/14] perf(session-replay): Avoid sync dispatch in recovery Read loaded replay frames directly during recovery because recording has not started yet. --- .../SessionReplay/SentryOnDemandReplay.swift | 12 +++--------- .../SessionReplay/SessionReplayRecovery.swift | 2 +- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index bbf885461a4..5e9116c5f0d 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -175,15 +175,9 @@ func removeReplayFile(at fileURL: URL) { } } - public var oldestFrameDate: Date? { - var oldestFrameDate: Date? - processingQueue.dispatchSync { - let retainedFrame = self.retainedFrameLock.synchronized { - self.retainedFrameBeforeCurrentFrames - } - oldestFrameDate = retainedFrame?.time ?? self._frames.first?.time - } - return oldestFrameDate + /// Used by replay recovery after `init(withContentFrom:)` loaded surviving frames from disk. + var oldestRecoveredFrameDate: Date? { + _frames.first?.time } public func createVideoInBackgroundWith(beginning: Date, end: Date, completion: @escaping ([SentryVideoInfo]) -> Void) { diff --git a/Sources/Swift/Integrations/SessionReplay/SessionReplayRecovery.swift b/Sources/Swift/Integrations/SessionReplay/SessionReplayRecovery.swift index a8a5fd4abb1..ebc2176c061 100644 --- a/Sources/Swift/Integrations/SessionReplay/SessionReplayRecovery.swift +++ b/Sources/Swift/Integrations/SessionReplay/SessionReplayRecovery.swift @@ -115,7 +115,7 @@ struct SessionReplayRecovery { if hasCrashInfo { beginning = Date(timeIntervalSinceReferenceDate: crashInfo.lastSegmentEnd) } else { - guard let oldestFrame = resumeReplayMaker.oldestFrameDate else { + guard let oldestFrame = resumeReplayMaker.oldestRecoveredFrameDate else { SentrySDKLog.debug("[Session Replay] No frames to send, dropping replay") return nil } From 5383273c948d47fd282ed5d4a41a9c7fada39d51 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 15 Jun 2026 13:55:40 +0200 Subject: [PATCH 12/14] fix(session-replay): Guard video finish reentry --- .../SentryVideoFrameProcessor.swift | 9 ++++-- .../SentryVideoFrameProcessorTests.swift | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift b/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift index 24884217ed3..a6c651aaa78 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryVideoFrameProcessor.swift @@ -65,6 +65,8 @@ class SentryVideoFrameProcessor { // swiftlint:disable function_body_length cyclomatic_complexity func processFrames(videoWriterInput: AVAssetWriterInput, onCompletion: @escaping (Result) -> Void) { + guard !isFinished else { return } + // Use the recommended loop pattern for AVAssetWriterInput // See https://developer.apple.com/documentation/avfoundation/avassetwriterinput/requestmediadatawhenready(on:using:)#Discussion // This could lead to an infinite loop if we don't make sure to mark the input as finished when the video is finished either by the end of the frames or by an error. @@ -72,6 +74,7 @@ class SentryVideoFrameProcessor { SentrySDKLog.debug("[Session Replay] Video writer input is ready, status: \(videoWriter.status)") guard videoWriter.status == .writing else { SentrySDKLog.error("[Session Replay] Video writer is not writing anymore, cancelling the writing session, reason: \(videoWriter.error?.localizedDescription ?? "Unknown error")") + isFinished = true videoWriter.inputs.forEach { $0.markAsFinished() } videoWriter.cancelWriting() return onCompletion(.failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo)) @@ -157,6 +160,7 @@ class SentryVideoFrameProcessor { return false case .failure: SentrySDKLog.error("[Session Replay] Failed to append image to pixel buffer, cancelling the writing session, reason: \(String(describing: videoWriter.error))") + isFinished = true videoWriter.inputs.forEach { $0.markAsFinished() } videoWriter.cancelWriting() throw videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo @@ -204,14 +208,15 @@ class SentryVideoFrameProcessor { } func finishVideo(frameIndex: Int, onCompletion completion: @escaping (Result) -> Void) { + guard !isFinished else { return }; isFinished = true + // Note: This method is expected to be called from the asset worker queue and *not* the processing queue. SentrySDKLog.info("[Session Replay] Finishing video with output file URL: \(outputFileURL), used frames count: \(usedFrames.count), video height: \(videoHeight), video width: \(videoWidth)") videoWriter.inputs.forEach { $0.markAsFinished() } videoWriter.finishWriting { [weak self] in guard let self = self else { SentrySDKLog.warning("[Session Replay] On-demand replay is deallocated, completing writing session without output video info") - let videoResult = SentryRenderVideoResult(info: nil, finalFrameIndex: frameIndex) - return completion(.success(videoResult)) + return completion(.success(SentryRenderVideoResult(info: nil, finalFrameIndex: frameIndex))) } SentrySDKLog.debug("[Session Replay] Finished video writing, status: \(self.videoWriter.status)") diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentryVideoFrameProcessorTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentryVideoFrameProcessorTests.swift index 7440419c640..8ab776e3da0 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentryVideoFrameProcessorTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentryVideoFrameProcessorTests.swift @@ -28,6 +28,7 @@ class SentryVideoFrameProcessorTests: XCTestCase { var errorOverride: Error? var cancelWritingCalled = false var finishWritingCalled = false + var finishWritingInvocations = Invocations() var trackedInputs: [AVAssetWriterInput] = [] override var status: AVAssetWriter.Status { @@ -44,6 +45,7 @@ class SentryVideoFrameProcessorTests: XCTestCase { override func finishWriting(completionHandler: @escaping () -> Void) { finishWritingCalled = true + finishWritingInvocations.record(Void()) completionHandler() } @@ -63,6 +65,7 @@ class SentryVideoFrameProcessorTests: XCTestCase { override func finishWriting(completionHandler: @escaping () -> Void) { finishWritingCalled = true + finishWritingInvocations.record(Void()) if shouldExecuteCompletionImmediately { completionHandler() } else { @@ -357,6 +360,31 @@ class SentryVideoFrameProcessorTests: XCTestCase { XCTAssertEqual(completionInvocations.count, 1) } + func testProcessFrames_WhenCalledAgainWhileFinishing_ShouldFinishOnce() throws { + let videoWriter = try XCTUnwrap(DelayedTestAVAssetWriter(url: fixture.outputFileURL, fileType: .mp4)) + let videoWriterInput = TestAVAssetWriterInput(mediaType: .video, outputSettings: nil) + videoWriter.add(videoWriterInput) + let completionInvocations = Invocations>() + let sut = SentryVideoFrameProcessor( + videoFrames: fixture.videoFrames, + videoWriter: videoWriter, + currentPixelBuffer: fixture.currentPixelBuffer, + outputFileURL: fixture.outputFileURL, + videoHeight: fixture.videoHeight, + videoWidth: fixture.videoWidth, + frameRate: fixture.frameRate, + initialFrameIndex: fixture.initialFrameIndex, + initialImageSize: fixture.initialImageSize + ) + + sut.processFrames(videoWriterInput: videoWriterInput) { completionInvocations.record($0) } + sut.processFrames(videoWriterInput: videoWriterInput) { completionInvocations.record($0) } + + XCTAssertEqual(videoWriterInput.markAsFinishedInvocations.count, 1) + XCTAssertEqual(videoWriter.finishWritingInvocations.count, 1) + XCTAssertEqual(completionInvocations.count, 0) + } + func testProcessFrames_WhenImageSizeChanges_ShouldFinishVideo() { let videoWriterInput = TestAVAssetWriterInput(mediaType: .video, outputSettings: nil) fixture.videoWriter.add(videoWriterInput) From 17bd953b3ef7885dae30bdc51fd0bd6ee70285ce Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 18 Jun 2026 14:09:10 +0200 Subject: [PATCH 13/14] changelog --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcb338ca785..8db3fb4c778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ > [!WARNING] > **The minimum macOS deployment target will be raised to macOS 12 (Monterey)** with the upcoming release that adopts Xcode 27. Xcode 27 no longer supports deployment targets below macOS 12. If your app must support macOS 11 or earlier, please stay on the last SDK version released before this change. See [#8113](https://github.com/getsentry/sentry-cocoa/issues/8113) for full details. +## Unreleased + +### Fixes + +- Session replay video assembly: drop empty video segments, avoid duplicating frames at segment boundaries, and keep video timing stable when captured frames are skipped or unreadable (#8041) + ## 9.18.0 ### Features @@ -11,7 +17,6 @@ ### Fixes -- Session replay video assembly: drop empty video segments, avoid duplicating frames at segment boundaries, and keep video timing stable when captured frames are skipped or unreadable (#8041) - Show feedback form from shake or screenshot without widget (#8050) ### Deprecations From 77bcb96c89ce4787026011b6a2f092cee8dfdd7c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 18 Jun 2026 14:13:03 +0200 Subject: [PATCH 14/14] changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8db3fb4c778..0e9f825ffd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,10 @@ # Changelog +## Unreleased + > [!WARNING] > **The minimum macOS deployment target will be raised to macOS 12 (Monterey)** with the upcoming release that adopts Xcode 27. Xcode 27 no longer supports deployment targets below macOS 12. If your app must support macOS 11 or earlier, please stay on the last SDK version released before this change. See [#8113](https://github.com/getsentry/sentry-cocoa/issues/8113) for full details. -## Unreleased - ### Fixes - Session replay video assembly: drop empty video segments, avoid duplicating frames at segment boundaries, and keep video timing stable when captured frames are skipped or unreadable (#8041)