Skip to content

Commit 958bc7c

Browse files
test(io): aetherctl --throttle-kbps slow-CDN simulation
Reproducing the #92 open-GOP glitch needs AVPlayer to rebuffer and do a fresh decode at a mid-stream non-independent segment, which does not happen on a fast local network. This adds a source-IO bandwidth cap so the producer can be starved below real-time on demand. - SourceThrottle: pure virtual-clock leaky-bucket rate math (testable, no sleeping), 7 unit tests. - AVIOReader: captures the throttle once at init from a static test hook and holds delivered bytes to the target rate in read() (the single chokepoint for persistent/streaming/seekable), lock-guarded. - AetherEngine.setSourceThrottleKbpsForTesting: test-only static hook, mirrors setForceSoftwarePathForTesting. - aetherctl: --throttle-kbps N on serve / seektest / audio / extract. Verified end-to-end: a 0.06s unthrottled extract becomes 10.8s at 800 kbit/s over a local HTTP fixture. Full suite green (XCTest 227, Swift Testing 268), strict-concurrency build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XZTEfmztPE8hAdjHdBr9BH
1 parent 75341db commit 958bc7c

5 files changed

Lines changed: 144 additions & 3 deletions

File tree

Sources/AetherEngine/AetherEngine.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,16 @@ public final class AetherEngine: ObservableObject {
322322
forceSoftwarePathForTesting = on
323323
}
324324

325+
/// TEST-ONLY: throttle source IO to simulate a slow CDN/origin (kbit/s; 0 = unlimited). Read once by
326+
/// each `AVIOReader` at init, so set it before `load`/`start`. Used by `aetherctl --throttle-kbps` to
327+
/// starve the producer below real-time and provoke AVPlayer rebuffers (e.g. the #92 open-GOP repro).
328+
nonisolated(unsafe) static var sourceThrottleKbpsForTesting = 0
329+
330+
/// TEST-ONLY. Set the source-IO throttle for the `aetherctl --throttle-kbps` harness; not for app use.
331+
public nonisolated static func setSourceThrottleKbpsForTesting(_ kbps: Int) {
332+
sourceThrottleKbpsForTesting = max(0, kbps)
333+
}
334+
325335
/// Reads `AVPlayer.eligibleForHDRPlayback` and `AVPlayer.availableHDRModes` at call time.
326336
/// macOS reports the built-in display only and may under-report external displays.
327337
public static var displayCapabilities: DisplayCapabilities {

Sources/AetherEngine/Demuxer/AVIOReader.swift

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,11 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable {
264264
private let chunkRequestTimeout: TimeInterval
265265
private let chunkMaxRetries: Int
266266

267+
/// TEST-ONLY slow-CDN throttle (kbit/s, 0 = unlimited), captured once from the static hook at init.
268+
private let throttleKbps: Int
269+
private var throttleVClockNs: UInt64 = 0
270+
private let throttleLock = NSLock()
271+
267272
init(url: URL, extraHeaders: [String: String] = [:], chunkSize: Int = 4 * 1024 * 1024, prefetchEnabled: Bool = true, isLive: Bool = false, chunkRequestTimeout: TimeInterval = 35, chunkMaxRetries: Int = 3) {
268273
self.url = url
269274
self.extraHeaders = extraHeaders
@@ -272,6 +277,23 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable {
272277
self.isLive = isLive
273278
self.chunkRequestTimeout = chunkRequestTimeout
274279
self.chunkMaxRetries = max(1, chunkMaxRetries)
280+
self.throttleKbps = AetherEngine.sourceThrottleKbpsForTesting
281+
}
282+
283+
/// Slow-CDN simulation: hold delivered bytes to `throttleKbps` by sleeping the demux thread before the
284+
/// bytes reach the demuxer. No-op unless the test hook is set. Lock-guarded: prefetch and demux paths
285+
/// can both deliver. Sleeping here is consistent with the existing reconnect backoff on this thread.
286+
private func applyThrottle(deliveredBytes: Int) {
287+
guard throttleKbps > 0, deliveredBytes > 0 else { return }
288+
throttleLock.lock()
289+
let sleepNs = SourceThrottle.advance(
290+
vclockNs: &throttleVClockNs,
291+
nowNs: DispatchTime.now().uptimeNanoseconds,
292+
deliveredBytes: deliveredBytes,
293+
kbps: throttleKbps
294+
)
295+
throttleLock.unlock()
296+
if sleepNs > 0 { Thread.sleep(forTimeInterval: Double(sleepNs) / 1_000_000_000) }
275297
}
276298

277299
private func applyExtraHeaders(_ request: inout URLRequest) {
@@ -524,9 +546,12 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable {
524546
if isPastReadDeadline { readDeadlineFired = true; return -1 }
525547
// Check usePersistentReader before isStreaming: live feeds without
526548
// Content-Length must use the reconnect-capable persistent path.
527-
if usePersistentReader { return readPersistent(into: buf, size: size) }
528-
if isStreaming { return readStreaming(into: buf, size: size) }
529-
return readSeekable(into: buf, size: size)
549+
let n: Int32
550+
if usePersistentReader { n = readPersistent(into: buf, size: size) }
551+
else if isStreaming { n = readStreaming(into: buf, size: size) }
552+
else { n = readSeekable(into: buf, size: size) }
553+
if n > 0 { applyThrottle(deliveredBytes: Int(n)) }
554+
return n
530555
}
531556

532557
// MARK: - Seekable Read (Range-based)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import Foundation
2+
3+
/// Virtual-clock leaky-bucket rate limiter for the source IO read path (slow-CDN simulation).
4+
/// Pure value logic so the pacing can be unit-tested without sleeping. `AVIOReader` keeps one
5+
/// `vclockNs` per reader and calls `advance` after each delivered chunk; the returned nanoseconds
6+
/// are how long to `Thread.sleep` before returning the bytes to the demuxer.
7+
enum SourceThrottle {
8+
9+
/// Wall-clock nanoseconds it should take to deliver `bytes` at `kbps` kilobits/s. 0 when disabled.
10+
static func costNs(bytes: Int, kbps: Int) -> UInt64 {
11+
guard kbps > 0, bytes > 0 else { return 0 }
12+
let bytesPerSec = Double(kbps) * 125.0 // kbit/s -> byte/s (1000 bits / 8)
13+
return UInt64((Double(bytes) / bytesPerSec) * 1_000_000_000)
14+
}
15+
16+
/// Advance the virtual delivery clock by one chunk and return the sleep (ns) needed to hold the rate.
17+
/// `vclockNs` is the real time by which all bytes so far should have been delivered. An idle gap
18+
/// (now past the clock) resets the base to `now`, so a pause never banks burst credit.
19+
static func advance(vclockNs: inout UInt64, nowNs: UInt64, deliveredBytes: Int, kbps: Int) -> UInt64 {
20+
guard kbps > 0, deliveredBytes > 0 else { return 0 }
21+
let base = max(vclockNs, nowNs)
22+
let deadline = base &+ costNs(bytes: deliveredBytes, kbps: kbps)
23+
vclockNs = deadline
24+
return deadline > nowNs ? deadline - nowNs : 0
25+
}
26+
}

Sources/aetherctl/main.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ func printUsage() {
8989
non-DV TV / on macOS (where displayCapabilities
9090
reports supportsDolbyVision=false anyway).
9191
92+
Flags (serve / seektest / audio / extract):
93+
--throttle-kbps N
94+
TEST-ONLY slow-CDN simulation: cap source-IO
95+
delivery to N kbit/s. Set below the stream bitrate
96+
to starve the producer below real-time and provoke
97+
AVPlayer rebuffers (e.g. the #92 open-GOP repro).
98+
9299
Flags (swdecode only):
93100
--frames N Max packets to read / frames to wait for.
94101
Default 100.
@@ -231,12 +238,17 @@ if first == "seektest" {
231238
let seeks = takeIntFlag("--seeks", from: &rest) ?? 40
232239
let gapMs = takeIntFlag("--gap-ms", from: &rest) ?? 60
233240
let settle = takeDoubleFlag("--settle", from: &rest) ?? 5.0
241+
let throttleKbps = takeIntFlag("--throttle-kbps", from: &rest)
234242
guard let urlArg = rest.first(where: { !$0.hasPrefix("--") }) else {
235243
print("ERROR: seektest requires a <url> argument")
236244
exit(64)
237245
}
238246
rest.removeAll { $0 == urlArg }
239247
rejectStrayFlags(rest, subcommand: "seektest")
248+
if let throttleKbps {
249+
AetherEngine.setSourceThrottleKbpsForTesting(throttleKbps)
250+
print("[aetherctl] source throttle: \(throttleKbps) kbit/s (slow-CDN simulation)")
251+
}
240252
exit(runSeekTest(url: parseSourceURL(urlArg), seeks: seeks, gapMs: gapMs, settleSeconds: settle))
241253
}
242254

@@ -384,6 +396,8 @@ if ["probe", "serve", "validate", "swdecode", "extract", "audio", "customio"].co
384396
let audioSeconds = takeDoubleFlag("--seconds", from: &rest) ?? 10
385397
// --native-subs: diagnostics affordance for mov_text subtitle track (#55); serve only.
386398
let nativeSubsIndex = takeIntFlag("--native-subs", from: &rest)
399+
// --throttle-kbps: slow-CDN simulation; starves the producer below real-time to provoke rebuffers.
400+
let throttleKbps = takeIntFlag("--throttle-kbps", from: &rest)
387401
rejectStrayFlags(rest, subcommand: first)
388402
guard let urlArg = rest.first else {
389403
print("ERROR: \(first) requires a <url> argument")
@@ -393,6 +407,10 @@ if ["probe", "serve", "validate", "swdecode", "extract", "audio", "customio"].co
393407
}
394408
let url = parseSourceURL(urlArg)
395409
let dvModeAvailable = !noDV
410+
if let throttleKbps {
411+
AetherEngine.setSourceThrottleKbpsForTesting(throttleKbps)
412+
print("[aetherctl] source throttle: \(throttleKbps) kbit/s (slow-CDN simulation)")
413+
}
396414
switch first {
397415
case "probe":
398416
exit(runProbe(url: url))
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import XCTest
2+
@testable import AetherEngine
3+
4+
final class SourceThrottleTests: XCTestCase {
5+
6+
func testCostNsDisabledOrEmpty() {
7+
XCTAssertEqual(SourceThrottle.costNs(bytes: 125_000, kbps: 0), 0)
8+
XCTAssertEqual(SourceThrottle.costNs(bytes: 0, kbps: 1000), 0)
9+
}
10+
11+
func testCostNs1000kbpsDelivers125kBPerSecond() {
12+
// 1000 kbit/s = 125000 byte/s, so 125000 bytes should cost exactly 1 second.
13+
XCTAssertEqual(SourceThrottle.costNs(bytes: 125_000, kbps: 1000), 1_000_000_000)
14+
// Half the bytes -> half the time.
15+
XCTAssertEqual(SourceThrottle.costNs(bytes: 62_500, kbps: 1000), 500_000_000)
16+
}
17+
18+
func testCostNsScalesInverselyWithRate() {
19+
// Same payload at 2x the rate costs half the time.
20+
let slow = SourceThrottle.costNs(bytes: 1_000_000, kbps: 1000)
21+
let fast = SourceThrottle.costNs(bytes: 1_000_000, kbps: 2000)
22+
XCTAssertEqual(fast, slow / 2)
23+
}
24+
25+
func testAdvanceSteadyStateSleepsOneChunkCost() {
26+
// Caught up (vclock == now): a 125 kB chunk at 1000 kbps must sleep ~1s.
27+
var vclock: UInt64 = 1_000_000_000
28+
let sleep = SourceThrottle.advance(
29+
vclockNs: &vclock, nowNs: 1_000_000_000, deliveredBytes: 125_000, kbps: 1000)
30+
XCTAssertEqual(sleep, 1_000_000_000)
31+
XCTAssertEqual(vclock, 2_000_000_000) // virtual clock advanced by the chunk cost
32+
}
33+
34+
func testAdvanceBacklogAccumulates() {
35+
// Two back-to-back chunks at the same `now` (producer reading fast): the second must wait
36+
// behind the first's virtual deadline, not deliver immediately.
37+
var vclock: UInt64 = 0
38+
let now: UInt64 = 0
39+
let s1 = SourceThrottle.advance(vclockNs: &vclock, nowNs: now, deliveredBytes: 125_000, kbps: 1000)
40+
let s2 = SourceThrottle.advance(vclockNs: &vclock, nowNs: now, deliveredBytes: 125_000, kbps: 1000)
41+
XCTAssertEqual(s1, 1_000_000_000)
42+
XCTAssertEqual(s2, 2_000_000_000) // queued behind chunk 1
43+
XCTAssertEqual(vclock, 2_000_000_000)
44+
}
45+
46+
func testAdvanceIdleGapDoesNotBankCredit() {
47+
// After delivering one chunk (vclock at 1s), a long real gap (now jumps to 10s) must not let
48+
// the next chunk deliver "for free" or bank negative sleep; base resets to now.
49+
var vclock: UInt64 = 1_000_000_000
50+
let sleep = SourceThrottle.advance(
51+
vclockNs: &vclock, nowNs: 10_000_000_000, deliveredBytes: 125_000, kbps: 1000)
52+
XCTAssertEqual(sleep, 1_000_000_000) // just one chunk cost, no burst credit
53+
XCTAssertEqual(vclock, 11_000_000_000) // rebased to now + cost
54+
}
55+
56+
func testAdvanceDisabledReturnsZero() {
57+
var vclock: UInt64 = 0
58+
XCTAssertEqual(
59+
SourceThrottle.advance(vclockNs: &vclock, nowNs: 0, deliveredBytes: 125_000, kbps: 0), 0)
60+
XCTAssertEqual(vclock, 0)
61+
}
62+
}

0 commit comments

Comments
 (0)