From 6806116b0112c3dc5089dc1cf58a85c45bd80692 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Thu, 27 Aug 2026 12:50:10 +0200 Subject: [PATCH] fix(stream): let congestion control back off below the bitrate target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebRTCBitratePolicy pinned the estimator's minimum at 90% of target, so on a path that could not carry ~2.7 Mbps the sender kept overshooting: standing queues, loss, recovery keyframes, and freezes instead of a quality dip. It also blocks raising the ceiling — with a 90% floor, a higher target forces a higher floor onto constrained links. The minimum is now 10% of target with a 250 kbps absolute floor, clamped to the target for tiny configs. The starting estimate stays at the target (applyBitrateSettings already passes the maximum as the current estimate), so ramp-up on healthy paths is unchanged. Measured on loopback (3 Mbps target, 60 fps, idle -> 8 s scroll burst -> idle): old policy held 2810 kbps and decayed onto its 2700 floor; new policy held flat at 2813 kbps through all three phases with fps at 59-60 — the estimate does not sag toward the lower floor on a clean path, it only gains room to shrink when the path is genuinely constrained. With the floor fixed, deployments can raise --video-bitrate (e.g. 3M -> 6M) to double the bits available to scrolls and transitions without risking overshoot freezes on bad networks. Co-Authored-By: Claude Fable 5 --- .../StreamingPolicy/WebRTCBitratePolicy.swift | 15 ++++++++++- .../WebRTCBitratePolicyTests.swift | 25 +++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/serve-sim/Sources/StreamingPolicy/WebRTCBitratePolicy.swift b/packages/serve-sim/Sources/StreamingPolicy/WebRTCBitratePolicy.swift index 386db654e..80ca5c0db 100644 --- a/packages/serve-sim/Sources/StreamingPolicy/WebRTCBitratePolicy.swift +++ b/packages/serve-sim/Sources/StreamingPolicy/WebRTCBitratePolicy.swift @@ -1,9 +1,22 @@ public struct WebRTCBitratePolicy: Equatable, Sendable { + /// Below this the stream is not worth keeping alive at any resolution. + public static let absoluteMinimumBitsPerSecond = 250_000 + public let minimumBitsPerSecond: Int public let maximumBitsPerSecond: Int + /// The estimator starts at the target (the owner passes the maximum as the + /// starting estimate), so ramp-up stays instant on healthy paths. The + /// minimum only bounds how far congestion control may back off: at 10% of + /// target it can shrink into what a constrained path actually carries. + /// The previous minimum was 90% of target, which pinned the estimator into + /// a band the path sometimes could not carry — sustained overshoot, loss, + /// recovery keyframes, and freezes instead of a quality dip. public init(targetBitsPerSecond: Int) { - minimumBitsPerSecond = targetBitsPerSecond * 9 / 10 + minimumBitsPerSecond = min( + targetBitsPerSecond, + max(Self.absoluteMinimumBitsPerSecond, targetBitsPerSecond / 10) + ) maximumBitsPerSecond = targetBitsPerSecond } } diff --git a/packages/serve-sim/Tests/StreamingPolicyTests/WebRTCBitratePolicyTests.swift b/packages/serve-sim/Tests/StreamingPolicyTests/WebRTCBitratePolicyTests.swift index a9e012267..83a147ba6 100644 --- a/packages/serve-sim/Tests/StreamingPolicyTests/WebRTCBitratePolicyTests.swift +++ b/packages/serve-sim/Tests/StreamingPolicyTests/WebRTCBitratePolicyTests.swift @@ -2,10 +2,31 @@ import XCTest @testable import StreamingPolicy final class WebRTCBitratePolicyTests: XCTestCase { - func testKeepsAdaptiveBandwidthBetweenNinetyPercentAndConfiguredTarget() { + func testLeavesCongestionControlRoomBelowTheConfiguredTarget() { let policy = WebRTCBitratePolicy(targetBitsPerSecond: 6_000_000) - XCTAssertEqual(policy.minimumBitsPerSecond, 5_400_000) + // 10% of target: under congestion the estimator may back off to a + // survivable rate instead of overrunning a path that cannot carry + // 90% of target — the old floor turned every real dip into loss, + // recovery keyframes, and freezes. + XCTAssertEqual(policy.minimumBitsPerSecond, 600_000) XCTAssertEqual(policy.maximumBitsPerSecond, 6_000_000) } + + func testMinimumNeverDropsBelowASurvivableAbsoluteFloor() { + let policy = WebRTCBitratePolicy(targetBitsPerSecond: 1_000_000) + + // 10% would be 100 kbps — below what keeps the stream alive. + XCTAssertEqual(policy.minimumBitsPerSecond, 250_000) + XCTAssertEqual(policy.maximumBitsPerSecond, 1_000_000) + } + + func testMinimumIsClampedToTinyConfiguredTargets() { + let policy = WebRTCBitratePolicy(targetBitsPerSecond: 100_000) + + // The CLI accepts targets down to 100 kbps; the minimum must never + // exceed the maximum. + XCTAssertEqual(policy.minimumBitsPerSecond, 100_000) + XCTAssertEqual(policy.maximumBitsPerSecond, 100_000) + } }