From 4db76e211cfd83496d85193d551dcc5c110805a1 Mon Sep 17 00:00:00 2001 From: Roman Sorochak Date: Sat, 26 Sep 2026 13:05:59 +0200 Subject: [PATCH 1/2] Fix unsigned underflow in AudioEntry.audioDataLengthBytes() A live stream can report a length smaller than the parsed header offset. UInt(source.length) - UInt(dataOffset) then underflows and Swift traps (SIGTRAP), reached through duration() once packets have arrived. Return 0 in that case, which is what duration() already returns for a stream of unknown length. Co-Authored-By: Claude Opus 5.5 --- .../Streaming/Audio Entry/AudioEntry.swift | 8 ++- .../Audio Entry/AudioEntryTests.swift | 70 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 AudioStreamingTests/Streaming/Audio Entry/AudioEntryTests.swift diff --git a/AudioStreaming/Streaming/Audio Entry/AudioEntry.swift b/AudioStreaming/Streaming/Audio Entry/AudioEntry.swift index 1ddb08a..c948801 100644 --- a/AudioStreaming/Streaming/Audio Entry/AudioEntry.swift +++ b/AudioStreaming/Streaming/Audio Entry/AudioEntry.swift @@ -155,8 +155,12 @@ class AudioEntry { if let byteCount = audioStreamState.dataByteCount { return UInt(byteCount) } - guard source.length > 0 else { return 0 } - return UInt(source.length) - UInt(audioStreamState.dataOffset) + let length = UInt64(max(source.length, 0)) + let dataOffset = audioStreamState.dataOffset + // A live stream can report a length smaller than the parsed header offset; + // an unsigned subtraction would trap there. + guard length > dataOffset else { return 0 } + return UInt(length - dataOffset) } } diff --git a/AudioStreamingTests/Streaming/Audio Entry/AudioEntryTests.swift b/AudioStreamingTests/Streaming/Audio Entry/AudioEntryTests.swift new file mode 100644 index 0000000..126b94e --- /dev/null +++ b/AudioStreamingTests/Streaming/Audio Entry/AudioEntryTests.swift @@ -0,0 +1,70 @@ +// +// AudioEntryTests.swift +// AudioStreamingTests +// + +import AVFoundation +import XCTest +@testable import AudioStreaming + +class AudioEntryTests: XCTestCase { + func test_AudioDataLengthBytes_Is_Zero_When_DataOffset_Exceeds_Length() { + // a live stream can report a length smaller than the parsed header offset + let entry = audioEntry(length: 100) + entry.audioStreamState.dataOffset = 200 + + XCTAssertEqual(entry.audioDataLengthBytes(), 0) + } + + func test_AudioDataLengthBytes_Is_Zero_When_DataOffset_Equals_Length() { + let entry = audioEntry(length: 100) + entry.audioStreamState.dataOffset = 100 + + XCTAssertEqual(entry.audioDataLengthBytes(), 0) + } + + func test_AudioDataLengthBytes_Subtracts_DataOffset_From_Length() { + let entry = audioEntry(length: 1000) + entry.audioStreamState.dataOffset = 200 + + XCTAssertEqual(entry.audioDataLengthBytes(), 800) + } + + func test_AudioDataLengthBytes_Prefers_Explicit_ByteCount() { + let entry = audioEntry(length: 100) + entry.audioStreamState.dataOffset = 200 + entry.audioStreamState.dataByteCount = 5000 + + XCTAssertEqual(entry.audioDataLengthBytes(), 5000) + } + + func test_Duration_Is_Zero_When_DataOffset_Exceeds_Length() { + let entry = audioEntry(length: 100) + entry.sampleRate = 44100 + entry.audioStreamState.bitRate = 128_000 + entry.audioStreamState.dataOffset = 200 + + XCTAssertEqual(entry.duration(), 0) + } +} + +private final class FixedLengthSource: CoreAudioStreamSource { + let position = 0 + let length: Int + weak var delegate: AudioStreamSourceDelegate? + let audioFileHint: AudioFileTypeID = kAudioFileMP3Type + let underlyingQueue = DispatchQueue(label: "audio-entry-tests") + + init(length: Int) { self.length = length } + + func close() {} + func suspend() {} + func resume() {} + func seek(at _: Int) {} +} + +private func audioEntry(length: Int) -> AudioEntry { + AudioEntry(source: FixedLengthSource(length: length), + entryId: AudioEntryId(id: "entry"), + outputAudioFormat: AVAudioFormat()) +} From a4465cd2eef25d49a5a5f599dfc211a2de33684d Mon Sep 17 00:00:00 2001 From: Roman Sorochak Date: Sun, 27 Sep 2026 00:54:50 +0200 Subject: [PATCH 2/2] Tolerate repeat fires in test_HandlerIsExecuted_On_The_Specified_Queue The timer source repeats every 100 ms. On a slow CI runner with --parallel it can fire a second time before wait(for:) returns, and the second fulfill() raises an API violation that fails the suite. Co-Authored-By: Claude Opus 5.5 --- AudioStreamingTests/Core/DispatchTimerSourceTests.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AudioStreamingTests/Core/DispatchTimerSourceTests.swift b/AudioStreamingTests/Core/DispatchTimerSourceTests.swift index 5d272e7..81553b2 100644 --- a/AudioStreamingTests/Core/DispatchTimerSourceTests.swift +++ b/AudioStreamingTests/Core/DispatchTimerSourceTests.swift @@ -96,6 +96,9 @@ class DispatchTimerSourceTests: XCTestCase { func test_HandlerIsExecuted_On_The_Specified_Queue() { let expectaction = expectation(description: "fired") + // The source repeats every 100 ms; on a slow runner it can fire again before + // `wait` returns, and a second `fulfill()` raises an API violation. + expectaction.assertForOverFulfill = false timerSource?.add { XCTAssertEqual(DispatchQueue.getSpecific(key: self.dispatchKey), 1)