Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/selkies/webrtc/pacer.py: 19%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

442 statements  

1# This Source Code Form is subject to the terms of the Mozilla Public 

2# License, v. 2.0. If a copy of the MPL was not distributed with this 

3# file, You can obtain one at https://mozilla.org/MPL/2.0/. 

4# 

5# Strict-priority packet pacer for the DTLS transport choke point. 

6# 

7# One RtpPacer per RTCDtlsTransport. Priority classes, highest first: 

8# RTCP + audio (which bypass the token bucket entirely: they are protocol 

9# rate-limited, tiny, and latency-critical), data-channel, video (incl. RTX and 

10# FEC). 

11# 

12# Scheduling is an event-driven token bucket. A packet goes straight out when 

13# nothing is queued and credit covers it; otherwise it queues and the drain 

14# task sleeps to the exact instant credit covers the highest-priority head. An 

15# enqueue that credit already covers pokes the drain awake, so neither sleep 

16# overshoot nor coalescing adds latency to another class. 

17# 

18# Invariants: 

19# * The burst budget always covers one max-size packet, otherwise a packet 

20# could never become affordable and its class would wedge forever. 

21# * The burst budget follows the link, never the pace alone: 5 ms of the 

22# pace until transport-cc feedback typically shows the wire delivering 

23# frames' leading bursts at twice the encoder's rate or more without loss, 

24# then what that link delivers in 10 ms until it typically falls below 1.5 

25# times the encoder's rate, loses a packet, delivers less than the encoder 

26# makes, or goes unmeasured (`_apply_pace`, `set_link_bps`). 

27# * Video is the only droppable class; every other class is bounded by 

28# backpressure on the sender instead. 

29# * The video queue budget is max(CAP_MIN_MS of wire time, IDR_FLOOR_FACTOR x 

30# the largest of the last IDR_WINDOW keyframes). A budget below one IDR 

31# truncates every keyframe under sustained congestion and breaks the 

32# reference chain permanently, hence the sliding-window floor. 

33# * Overflow drops the whole GOP and requests a throttled keyframe rather 

34# than thinning arbitrary packet tails; a timeout resurrects video if no 

35# keyframe arrives, so an unbound keyframe callback or a stuck encoder 

36# cannot kill the class permanently (natural IDR cadence can be minutes). 

37# Where the video sender can resync its peer without one (`resync`: the 

38# encoder names each frame's reference and the peer reads the dependency 

39# descriptor), the drop is handed to it instead: it holds back the frames 

40# the purge left undecodable and has the encoder predict past them, and 

41# video flows on, the shared stream carrying no keyframe for one peer. 

42# * Rate control is AIMD on wire evidence: an internal overflow only proves 

43# injection > drain, and with UDP sends the drain is the pace setting, not 

44# the wire — once one brake drops the pace below the encoder rate, every 

45# further overflow is self-inflicted. So an overflow resets the GOP but 

46# sizes a brake only from a fresh goodput estimate, and only one measured 

47# on a feedback window the wire cut in several places, since a window 

48# delivered whole reads back the pacer's own output and one cut in a 

49# single run is an outage (estimate - BYPASS_RESERVE_BPS, floored at 

50# AIMD_FLOOR_FACTOR x the encoder target), 

51# at most one brake per BRAKE_HOLD_S; recovery is +25%/s toward 

52# PACE_FACTOR x the encoder target, and only a brake resets its clock, so 

53# reset churn cannot hold the pace at the floor. 

54import asyncio 

55import logging 

56import os 

57import statistics 

58import time 

59from typing import Any, Awaitable, Callable, Dict, Deque, Optional 

60from collections import deque 

61 

62logger = logging.getLogger("selkies_webrtc_pacer") 

63 

64# Priority classes (lower value wins). RTCP + audio share a class: both are 

65# tiny absolute rates and must never queue behind anything else. 

66CLASS_RTCP = 0 

67CLASS_AUDIO = 0 

68# CLASS_DC carries data-channel traffic (input, status, clipboard); 

69# CLASS_VIDEO carries video RTP, RTX, and FEC. 

70CLASS_DC = 1 

71CLASS_VIDEO = 2 

72# Classes that own a queue: class 0 bypasses the bucket and is never enqueued. 

73_QUEUED_CLASSES = (CLASS_DC, CLASS_VIDEO) 

74 

75# Pace above the encoder target when the link allows. 

76PACE_FACTOR = 2.5 

77# Wire share reserved for bypass classes (audio + RTCP + DC) when braking 

78# from an estimate. 

79BYPASS_RESERVE_BPS = 500_000 

80# AIMD: multiplicative pace recovery (+25%/s) in the absence of internal 

81# overflow events. 

82OVERFLOW_RECOVERY_PER_S = 0.25 

83# Brake depth floor (x encoder target): chained overflow brakes must never 

84# crater the stream into a starvation valley that +25%/s takes seconds to 

85# climb out of. 

86AIMD_FLOOR_FACTOR = 0.35 

87# Burst credit window: how much wire time at the pace the bucket may hoard 

88# while the link's room is unknown or short. Wider windows let video bursts 

89# land in front of audio at a bottleneck. 

90DEBT_WINDOW_S = 0.005 

91# A link measured to have room takes the burst it delivers in BURST_LINK_S, 

92# never more than libwebrtc's send burst interval at the pace under its byte 

93# cap, which keeps a burst inside a socket's send buffer. 

94BURST_LINK_S = 0.010 

95BURST_WINDOW_S = 0.040 

96MAX_BURST_BYTES = 63_000 

97# Room is a link delivering a frame's leading burst at this many times the 

98# encoder's rate or faster, typically over the last LINK_SAMPLES feedback 

99# packets and measured within LINK_MAX_AGE_S; a link found to have room keeps 

100# it down to LINK_KEEP_FACTOR times that rate. 

101LINK_HEADROOM_FACTOR = 2.0 

102LINK_KEEP_FACTOR = 1.5 

103LINK_SAMPLES = 5 

104LINK_MAX_AGE_S = 2.0 

105# The share of the pace a still screen's cleanup may run at (`room_bps`): the pacer drains its 

106# frames with room for audio, retransmissions and FEC beside them. 

107CLEANUP_PACE_SHARE = 0.8 

108# Floor under the burst budget. Credit saturates at the budget, so a budget 

109# below one packet leaves that packet unaffordable forever; DTLS records here 

110# are MTU-sized and this keeps headroom above them. 

111BURST_FLOOR_BYTES = 2048 

112# Video queue budget floor, time-denominated. 

113CAP_MIN_MS = 120 

114# IDR-size floor for the video queue budget, over a rolling window of recent 

115# IDR sizes. 

116IDR_FLOOR_FACTOR = 2.2 

117IDR_WINDOW = 4 

118KEYREQ_MIN_INTERVAL_S = 0.5 

119# Sanity floor: a 0-goodput feedback window must never stall (or 

120# divide-by-zero) the scheduler. 

121MIN_PACE_BPS = 100_000 

122# Defensive cap on drain sleeps; pokes handle the rest. 

123DRAIN_MAX_SLEEP_S = 0.05 

124# Frames whose end `frame_end` waits on at once; more than are ever in flight. 

125FRAME_ENDS_MAX = 64 

126# Post-enable grace: ignore goodput braking so the first (audio-only/startup) 

127# feedback windows cannot slam the pace before video even ramps. 

128GOODPUT_WARMUP_S = 5.0 

129# At most one brake per hold: while the pace sits below the encoder rate the 

130# queue refills from injection alone, and those overflows carry no wire signal. 

131BRAKE_HOLD_S = 1.0 

132# A brake only trusts an estimate this fresh; one bad feedback window (an 

133# upload delaying feedback, a WiFi hiccup) must not size every later brake. 

134GOODPUT_MAX_AGE_S = 2.0 

135# If no keyframe resurrects a dead GOP within this of the last keyreq, 

136# resurrect on queue-drain anyway. 

137RESURRECT_TIMEOUT_S = 1.0 

138# Data-channel backpressure: senders block once the backlog passes the high 

139# water mark and are released again below the low water mark. 

140DC_HIGH_WATER_BYTES = 2_000_000 

141DC_LOW_WATER_BYTES = 1_000_000 

142# Cap on one backpressure wait: a wedged wire must slow senders, never park 

143# them forever. 

144DC_BLOCK_TIMEOUT_S = 1.0 

145 

146 

147def _stale_deadline_s() -> float: 

148 """Read SELKIES_WEBRTC_PACER_STALE_MS as seconds; a malformed value disables 

149 the deadline instead of killing the import.""" 

150 raw = os.environ.get("SELKIES_WEBRTC_PACER_STALE_MS", "0") 

151 try: 

152 return max(0.0, float(raw)) / 1000.0 

153 except ValueError: 

154 logger.warning("pacer: ignoring malformed " 

155 "SELKIES_WEBRTC_PACER_STALE_MS=%r", raw) 

156 return 0.0 

157 

158 

159# Whole-GOP deadline for queued video, never tighter than one IDR's wire time. 

160# Off by default: purging the backlog stalls the receiver's jitter buffer on the 

161# resulting gap, which costs more delivered-video latency than the freshness it 

162# buys on most links. Set SELKIES_WEBRTC_PACER_STALE_MS to enable. 

163VIDEO_STALE_S = _stale_deadline_s() 

164# Near-empty windows carry no rate signal. 

165MIN_GOODPUT_SAMPLE_BYTES = 2048 

166# What a feedback window must show for its delivery rate to size a brake: this 

167# share of it lost, in at least this many separate runs. Less says the window 

168# measured what the pacer sent; one run of any size is an outage. 

169BRAKE_LOSS_FRACTION = 0.1 

170BRAKE_LOSS_RUNS = 3 

171 

172SendNow = Callable[[bytes], Awaitable[None]] 

173 

174 

175class RtpPacer: 

176 """Token-bucket pacer with strict priority classes for one RTP/DTLS flow. 

177 

178 Owned by one RTCDtlsTransport. All classes share a single credit window. 

179 Video packets that cannot fit the queue budget trigger a GOP reset: queued 

180 video is purged, subsequent video is dropped until a fresh keyframe 

181 resurrects the stream, and a throttled keyframe request is emitted. 

182 Video is the only class ever dropped; data-channel senders are throttled by 

183 backpressure instead, since their traffic is reliable. 

184 """ 

185 

186 def __init__( 

187 self, 

188 encoder_bps: int, 

189 send_now: SendNow, 

190 send_now_data: Optional[SendNow] = None, 

191 request_keyframe: Optional[Callable[[], None]] = None, 

192 loop: Optional[asyncio.AbstractEventLoop] = None, 

193 on_dropped: Optional[Callable[[int], None]] = None, 

194 resync: Optional[Callable[[Optional[int]], bool]] = None, 

195 ) -> None: 

196 self._encoder_bps = max(int(encoder_bps), 100_000) 

197 self._goodput_bps: Optional[int] = None 

198 self._goodput_at = 0.0 

199 self._brake_hold_until = 0.0 

200 self._send_now = send_now 

201 self._send_now_data = send_now_data or send_now 

202 self._request_keyframe = request_keyframe 

203 # Told the tag of every video packet dropped, refused, or purged, so the 

204 # transport's loss accounting can leave them out. 

205 self._on_dropped = on_dropped 

206 # Offered a GOP reset's oldest dropped tag; True when the video sender 

207 # resyncs its peer itself, so no keyframe is asked for (module notes). 

208 self._resync = resync 

209 self._loop = loop or asyncio.get_running_loop() 

210 # Tag of a frame's last packet -> what to call, and with what, once it is 

211 # on the wire (`frame_end`); a dropped one is forgotten with its packet. 

212 self.frame_ends: Dict[int, tuple] = {} 

213 

214 self._queues: Dict[int, Deque[bytes]] = {c: deque() for c in _QUEUED_CLASSES} 

215 self._poke = asyncio.Event() 

216 # Set while the backlog is below the low-water mark: data-channel 

217 # senders wait on it once the backlog crosses the high-water mark. 

218 self._drained = asyncio.Event() 

219 self._drained.set() 

220 self._make_class_table() 

221 self._bytes_queued = 0 

222 self._video_bytes = 0 

223 # Enqueue-time mirror of the video queue: head age drives the 

224 # latency-first stale-GOP deadline. Kept in lockstep at every 

225 # mutation site (enqueue / trim / drain / wholesale clears). 

226 self._video_ts: Deque[float] = deque() 

227 self._video_tags: Deque[Optional[int]] = deque() 

228 self.credit = 0.0 

229 self._debt_cap = 0.0 

230 self._narrow_cap = 0.0 

231 self._link_bps: Optional[float] = None 

232 self._link_at = 0.0 

233 self._link_recent: Deque[float] = deque(maxlen=LINK_SAMPLES) 

234 self._link_room = False 

235 self._pace_bps = MIN_PACE_BPS 

236 # Sentinel for "AIMD owns the pace now"; distinct from the recovery 

237 # clock, which ticks on every pace update. 

238 self._ever_overflowed = False 

239 self._last_pace_update_at = 0.0 

240 self._refresh_windows() 

241 self._last = time.monotonic() 

242 self._drain_task: Optional[asyncio.Task] = None 

243 self._stopped = False 

244 self._idr_sizes: Deque[int] = deque(maxlen=IDR_WINDOW) 

245 self._idr_floor_bytes = 0 

246 self._frame_floor_bytes = 0 

247 self._gop_dead = False 

248 self._last_keyreq = 0.0 

249 self._keyreq_answered = True 

250 self._enabled_at = self._last 

251 self._gop_dead_at = 0.0 

252 self._purged_first: Optional[int] = None 

253 self._oversize_warned = False 

254 self.stats = { 

255 "video_dropped": 0, "keyreqs": 0, "gop_resets": 0, "resyncs": 0, 

256 "idr_resurrects": 0, "timeout_resurrects": 0, "stale_resets": 0, 

257 "paced_bytes": 0, "queue_max_bytes": 0, "fastpath_bytes": 0, 

258 } 

259 self._stats_prev: dict = {} 

260 self._stats_timer: Optional[asyncio.TimerHandle] = None 

261 self._arm_stats_log() 

262 

263 def _arm_stats_log(self) -> None: 

264 if self._stopped: 

265 return 

266 self._stats_timer = self._loop.call_later(10.0, self._maybe_log_stats) 

267 

268 def _maybe_log_stats(self) -> None: 

269 """Periodic counter deltas, at debug: a streaming link moves them every 

270 interval, so this is one line per pacer per interval for as long as the 

271 session lasts. The same numbers are exported as webrtc_pacer_* gauges, 

272 which is where a running deployment should read them.""" 

273 changed = { 

274 k: self.stats[k] - self._stats_prev.get(k, 0) 

275 for k in self.stats 

276 if self.stats[k] != self._stats_prev.get(k, 0) 

277 } 

278 self._stats_prev = dict(self.stats) 

279 if changed: 

280 logger.debug( 

281 "pacer: +%s | total %s", changed, self.snapshot()) 

282 self._arm_stats_log() 

283 

284 # ------------------------------------------------------------------ rates 

285 def set_encoder_bps(self, bps: int) -> None: 

286 bps = max(int(bps), 100_000) 

287 if bps != self._encoder_bps: 

288 self._encoder_bps = bps 

289 self._refresh_windows() 

290 

291 def set_goodput_bps(self, bps: Optional[float]) -> None: 

292 # The estimate is CONTROL INPUT ONLY AT OVERFLOW EVENTS: braking on 

293 # own-output measurement continuously is a feedback loop that always 

294 # drifts (saturated wire = link, not capacity; unsaturated wire = own 

295 # injection). AIMD: estimates merely size the step taken on overflow. 

296 if bps is None: 

297 return 

298 now = time.monotonic() 

299 if now - self._enabled_at < GOODPUT_WARMUP_S: 

300 # The first feedback windows after enabling carry audio-only (or 

301 # no) traffic: their throughput measures the offered load, not the 

302 # link, and would size an early brake far below capacity. 

303 return 

304 bps = int(bps) 

305 if bps > 0: 

306 self._goodput_bps = bps 

307 self._goodput_at = now 

308 

309 def set_link_bps(self, bps: Optional[float]) -> None: 

310 """Take the rate the wire delivered a frame's leading burst at. 

311 

312 Args: 

313 bps: The rate from the latest transport-cc feedback; 0 when that 

314 feedback showed the wire losing packets, None when it held no 

315 burst to measure, which leaves the last estimate to age out. 

316 """ 

317 if bps is None: 

318 return 

319 self._link_bps = float(bps) 

320 self._link_at = time.monotonic() 

321 if bps > 0: 

322 self._link_recent.append(float(bps)) 

323 else: 

324 self._link_recent.clear() 

325 self._apply_pace() 

326 

327 def room_bps(self) -> Optional[float]: 

328 """The rate a still screen's cleanup may take through this pacer: what the wire delivered 

329 frames' leading bursts at, typically (the median of the last LINK_SAMPLES estimates, 

330 measured within LINK_MAX_AGE_S), within CLEANUP_PACE_SHARE of the pace; 0 where the 

331 latest feedback showed the wire losing packets, None where none measured it.""" 

332 if self._link_bps is None or time.monotonic() - self._link_at > LINK_MAX_AGE_S: 

333 return None 

334 if not self._link_bps: 

335 return 0.0 

336 typical = statistics.median(self._link_recent) if self._link_recent else self._link_bps 

337 return min(typical, CLEANUP_PACE_SHARE * self._pace_bps) 

338 

339 def set_frame_floor(self, size: int) -> None: 

340 """Take a video frame of `size` bytes into the queue whole (`_video_cap_bytes`): a still 

341 screen's cleanup frame at the rate `room_bps` gave.""" 

342 self._frame_floor_bytes = max(0, int(size)) 

343 

344 @property 

345 def burst_probe_bytes(self) -> float: 

346 """How much of a frame leaves unpaced whatever the link's room: the 

347 leading bytes whose arrivals measure the link rather than the pace.""" 

348 return self._narrow_cap 

349 

350 def _on_overflow(self) -> None: 

351 """Brake on queue overflow, sized by wire evidence alone. 

352 

353 The overflow itself only proves injection > drain, and with UDP sends 

354 the drain is the pace setting, not the wire — after one brake drops 

355 the pace below the encoder rate, every further overflow is 

356 self-inflicted. So a brake needs a fresh goodput estimate and runs at 

357 most once per BRAKE_HOLD_S; without an estimate the GOP reset stands 

358 alone. The rejected alternative — halve on every overflow — re-braked 

359 on the self-inflicted ones faster than recovery could climb and 

360 latched the pace at the floor until the page reloaded. 

361 """ 

362 now = time.monotonic() 

363 if now < self._brake_hold_until: 

364 return 

365 est = (self._goodput_bps 

366 if now - self._goodput_at <= GOODPUT_MAX_AGE_S else None) 

367 if not est: 

368 return 

369 target = max(MIN_PACE_BPS, est - BYPASS_RESERVE_BPS) 

370 # Depth floor: never crater below a usable video share of the user's 

371 # encoder target (starvation valleys take seconds to recover from). 

372 target = max(target, int(AIMD_FLOOR_FACTOR * self._encoder_bps)) 

373 if target < self._pace_bps: 

374 self._ever_overflowed = True 

375 self._brake_hold_until = now + BRAKE_HOLD_S 

376 self._last_pace_update_at = now 

377 self._pace_bps = target 

378 self._apply_pace() 

379 

380 def _maybe_recover_pace(self) -> None: 

381 # +25%/s multiplicative recovery toward the encoder ceiling; only a 

382 # brake resets the clock, so reset churn cannot stall the climb. 

383 now = time.monotonic() 

384 dt = now - self._last_pace_update_at 

385 if dt < 0.25: 

386 return 

387 ceiling = PACE_FACTOR * self._encoder_bps 

388 self._last_pace_update_at = now 

389 if self._link_bps and now - self._link_at > LINK_MAX_AGE_S: 

390 self._link_bps = None 

391 self._link_recent.clear() 

392 self._apply_pace() 

393 if self._pace_bps >= ceiling: 

394 return 

395 grown = int(self._pace_bps * (1.0 + OVERFLOW_RECOVERY_PER_S) ** min(dt, 4.0)) 

396 self._pace_bps = min(int(ceiling), max(grown, MIN_PACE_BPS)) 

397 self._apply_pace() 

398 

399 def _make_class_table(self) -> None: 

400 """Precomputed per-class (class, queue, sender) triples in priority 

401 order: per-packet work must not re-derive what never changes.""" 

402 self._class_table = [ 

403 (CLASS_DC, self._queues[CLASS_DC], self._send_now_data), 

404 (CLASS_VIDEO, self._queues[CLASS_VIDEO], self._send_now), 

405 ] 

406 

407 def _apply_pace(self) -> None: 

408 """Re-derive the burst budget from the pace and the link, and clamp 

409 credit to it. 

410 

411 The budget is DEBT_WINDOW_S of the pace while the link's room is 

412 unknown or short. A link the latest feedback showed delivering a 

413 frame's leading burst at LINK_HEADROOM_FACTOR times the encoder's rate 

414 or faster, with nothing lost on the wire and the pace not braked, takes 

415 what it delivers in BURST_LINK_S, at most libwebrtc's burst, so a 

416 frame leaves whole instead of trickling out at the pace behind its 

417 first packets. Room is judged on the median of the last LINK_SAMPLES 

418 estimates and kept while that stays at LINK_KEEP_FACTOR times the 

419 encoder's rate or more: one burst's rate scatters with the receiver's 

420 own timing, and a single estimate against a single threshold flips the 

421 budget on every other feedback of a link close to it. The burst is 

422 sized by the lower of the latest estimate and that median, and a 

423 latest estimate below the encoder's rate closes the room at once, so 

424 a link that drops is never burst into at its old rate. Sizing the burst 

425 to the measured link rather than to the pace keeps the wide one off a 

426 link near capacity, where it only moves the queue from the pacer to 

427 the bottleneck, ahead of audio. 

428 Credit saturates at the budget, so it is floored at 

429 BURST_FLOOR_BYTES: below one packet's size, that packet could never 

430 become affordable and its class would wedge.""" 

431 pace_bytes = self._pace_bps / 8.0 

432 self._narrow_cap = cap = max(pace_bytes * DEBT_WINDOW_S, float(BURST_FLOOR_BYTES)) 

433 link = self._link_bps 

434 typical = statistics.median(self._link_recent) if self._link_recent else 0.0 

435 factor = LINK_KEEP_FACTOR if self._link_room else LINK_HEADROOM_FACTOR 

436 self._link_room = bool(link and link >= self._encoder_bps 

437 and typical >= factor * self._encoder_bps 

438 and time.monotonic() - self._link_at <= LINK_MAX_AGE_S) 

439 if self._link_room and self._pace_bps >= int(PACE_FACTOR * self._encoder_bps): 

440 cap = max(cap, min(min(link, typical) / 8.0 * BURST_LINK_S, pace_bytes * BURST_WINDOW_S, 

441 float(MAX_BURST_BYTES))) 

442 self._debt_cap = cap 

443 if self.credit > cap: 

444 self.credit = cap 

445 

446 def _refresh_windows(self) -> None: 

447 ceiling = PACE_FACTOR * self._encoder_bps 

448 # Until the first brake the encoder setting owns the pace (config 

449 # tracking); afterwards it can only CLAMP the AIMD-controlled pace. 

450 if not self._ever_overflowed: 

451 self._pace_bps = int(ceiling) 

452 else: 

453 self._pace_bps = min(self._pace_bps, int(ceiling)) 

454 self._pace_bps = max(self._pace_bps, MIN_PACE_BPS) 

455 self._apply_pace() 

456 

457 def _video_cap_bytes(self) -> int: 

458 base = int(self._pace_bps / 8.0 * CAP_MIN_MS / 1000) 

459 return max(base, self._idr_floor_bytes, self._frame_floor_bytes) 

460 

461 # ----------------------------------------------------------------- inputs 

462 def note_keyframe(self, total_payload_bytes: int, natural: bool = True) -> None: 

463 """Feed the sliding-window IDR floor and resurrect the stream after a 

464 GOP reset. 

465 

466 Only NATURAL keyframes enter the window: a forced keyframe is emitted at 

467 the collapsed bitrate that made us ask for it, so letting it evict a 

468 window entry would shrink the floor, shrink the cap, and trigger the next 

469 reset — a self-reinforcing keyframe-churn cycle. A forced keyframe may 

470 still RAISE the floor, so a cap too small to hold one IDR still grows out 

471 of the churn. 

472 

473 A keyframe arriving within KEYREQ_MIN_INTERVAL_S of our own request is 

474 treated as forced whatever the caller says: on the pre-encoded pack() 

475 path the sender packetizes frames it did not encode, so it cannot tell 

476 the two apart. 

477 """ 

478 size = int(total_payload_bytes) 

479 if natural and time.monotonic() - self._last_keyreq < KEYREQ_MIN_INTERVAL_S: 

480 natural = False 

481 if natural: 

482 self._idr_sizes.append(size) 

483 self._idr_floor_bytes = int(max(self._idr_sizes) * IDR_FLOOR_FACTOR) 

484 else: 

485 self._idr_floor_bytes = max(self._idr_floor_bytes, 

486 int(size * IDR_FLOOR_FACTOR)) 

487 self._keyreq_answered = True 

488 if self._gop_dead: 

489 self._gop_dead = False 

490 self.stats["idr_resurrects"] += 1 

491 logger.debug("pacer: keyframe of %d bytes resurrects video %.0f ms after the reset", 

492 size, (time.monotonic() - self._gop_dead_at) * 1000) 

493 

494 def request_keyframe_once(self) -> None: 

495 """Ask for a keyframe, unless one was asked for within 

496 KEYREQ_MIN_INTERVAL_S and has not arrived yet: that one resurrects the 

497 stream when it comes, while a reset after it landed needs its own, or 

498 video stays dead until the resurrect timeout.""" 

499 now = time.monotonic() 

500 if not self._keyreq_answered and now - self._last_keyreq < KEYREQ_MIN_INTERVAL_S: 

501 return 

502 self._last_keyreq = now 

503 self._keyreq_answered = False 

504 if self._request_keyframe is None: 

505 logger.warning("pacer: no keyframe callback bound; GOP reset " 

506 "relies on the %.1fs timeout resurrect", RESURRECT_TIMEOUT_S) 

507 return 

508 self.stats["keyreqs"] += 1 

509 try: 

510 self._request_keyframe() 

511 except Exception: 

512 logger.debug("pacer keyframe request failed", exc_info=True) 

513 

514 # ------------------------------------------------------------------- send 

515 def video_room(self) -> int: 

516 """Bytes the video queue takes before a packet overflows it (`_reset_gop`).""" 

517 return self._video_cap_bytes() - self._video_bytes 

518 

519 def drain_s(self) -> float: 

520 """Seconds until what is queued now has left, at the current pace.""" 

521 return self._bytes_queued * 8 / max(self._pace_bps, 1) 

522 

523 def _accrue(self) -> None: 

524 now = time.monotonic() 

525 self.credit = min(self._debt_cap, 

526 self.credit + (now - self._last) * self._pace_bps / 8.0) 

527 self._last = now 

528 

529 async def send(self, data: bytes, cls: int, tag: Optional[int] = None) -> bool: 

530 """Send `data` now, queue it, or drop it. 

531 

532 Args: 

533 data: The datagram. 

534 cls: Its priority class. 

535 tag: What to report through `on_dropped` if it is dropped. 

536 

537 Returns: 

538 True when the packet went out or queued; False when it was dropped, 

539 which only video is: refused while its GOP is abandoned, or the 

540 packet that overflowed the budget or found the queue stale and 

541 abandoned the GOP itself. 

542 """ 

543 if self._stopped: 

544 raise ConnectionError("pacer stopped") 

545 n = len(data) 

546 

547 # RTCP/audio bypass: protocol rate-limited and latency-critical, so 

548 # they never queue and never consume video's credit. 

549 if cls == CLASS_RTCP: 

550 self.stats["fastpath_bytes"] += n 

551 await self._send_now(data) 

552 return True 

553 

554 self._maybe_recover_pace() 

555 

556 if cls != CLASS_VIDEO and self._bytes_queued >= DC_HIGH_WATER_BYTES: 

557 await self._await_backlog_drain() 

558 if self._stopped: 

559 raise ConnectionError("pacer stopped") 

560 

561 if cls == CLASS_VIDEO and self._gop_dead: 

562 # Safety net: if no keyframe resurrected us within the timeout of 

563 # the last keyreq (broken/unbound callback, encoder stuck), let 

564 # video flow again anyway. The decoder recovers from the reference 

565 # break at the next keyframe; a permanently dead class does not. 

566 if time.monotonic() - self._gop_dead_at >= RESURRECT_TIMEOUT_S: 

567 self._gop_dead = False 

568 self.stats["timeout_resurrects"] += 1 

569 logger.info("pacer: no keyframe within %.1fs of reset; " 

570 "resurrecting video optimistically", RESURRECT_TIMEOUT_S) 

571 else: 

572 self.stats["video_dropped"] += 1 

573 self._drop(tag) 

574 return False 

575 

576 # Latency-first stale deadline: video sitting in queue longer than 

577 # the deadline is delivered-too-late by definition, so the whole GOP 

578 # is purged and a fresh keyframe requested (fewer stale frames > more 

579 # stale frames). The deadline is never tighter than one IDR's wire 

580 # time — otherwise reset churn would deliver zero video. 

581 if VIDEO_STALE_S > 0 and cls == CLASS_VIDEO and self._video_ts: 

582 deadline = VIDEO_STALE_S 

583 if self._idr_floor_bytes: 

584 idr_time = self._idr_floor_bytes * 8.0 / max(self._pace_bps, 1) 

585 if idr_time > deadline: 

586 deadline = idr_time 

587 now = time.monotonic() 

588 if now - self._video_ts[0] > deadline: 

589 self._stale_reset(deadline, tag) 

590 self.stats["video_dropped"] += 1 

591 self._drop(tag) 

592 return False 

593 

594 # Fast path when nothing is buffered: anything credit covers goes 

595 # straight out (~0 added delay below the rate). 

596 if not self._bytes_queued: 

597 self._accrue() 

598 if n <= self.credit: 

599 self.credit -= n 

600 self.stats["fastpath_bytes"] += n 

601 sender = self._send_now_data if cls == CLASS_DC else self._send_now 

602 await sender(data) 

603 if self.frame_ends and tag is not None: 

604 self._left(tag) 

605 return True 

606 

607 # Video queue budget: a packet the budget cannot hold abandons the GOP, 

608 # queue and all. The cap is video-only: audio/DC never push an IDR out. 

609 if cls == CLASS_VIDEO and self._video_bytes + n > self._video_cap_bytes(): 

610 self._reset_gop(tag=tag) 

611 self.stats["video_dropped"] += 1 

612 self._drop(tag) 

613 return False 

614 

615 self._queues[cls].append(data) 

616 if cls == CLASS_VIDEO: 

617 self._video_bytes += n 

618 self._video_ts.append(time.monotonic()) 

619 self._video_tags.append(tag) 

620 self._bytes_queued += n 

621 if self._bytes_queued > self.stats["queue_max_bytes"]: 

622 self.stats["queue_max_bytes"] = self._bytes_queued 

623 # Wake-on-affordable: if the drain is sleeping through a big head 

624 # packet but credit already covers THIS newcomer, don't make it wait 

625 # for the head's deadline. 

626 self._accrue() 

627 if n <= self.credit: 

628 self._poke.set() 

629 self._kick() 

630 return True 

631 

632 def _stale_reset(self, deadline_s: float, tag: Optional[int] = None) -> None: 

633 """Latency-first GOP reset for queued video that outlived its usefulness.""" 

634 self._reset_gop("video backlog stale (>%.0fms)" % (deadline_s * 1000), tag) 

635 self.stats["stale_resets"] += 1 

636 

637 def frame_end(self, tag: int, sink: Callable[..., None], *args: Any) -> None: 

638 """Call `sink(*args)` once the video packet tagged `tag`, the last of its 

639 frame, leaves for the wire: how long a frame took to get there includes the 

640 time it queued here. At most FRAME_ENDS_MAX are held; the oldest goes first.""" 

641 ends = self.frame_ends 

642 ends[tag] = (sink, args) 

643 if len(ends) > FRAME_ENDS_MAX: 

644 del ends[next(iter(ends))] 

645 

646 def _left(self, tag: Optional[int]) -> None: 

647 """A video packet went out: call what its frame's end was waiting on.""" 

648 end = self.frame_ends.pop(tag, None) 

649 if end is not None: 

650 end[0](*end[1]) 

651 

652 def _drop(self, tag: Optional[int]) -> None: 

653 if tag is not None: 

654 self.frame_ends.pop(tag, None) 

655 if self._on_dropped is not None: 

656 self._on_dropped(tag) 

657 

658 def _purge_video(self) -> int: 

659 """Drop the whole video queue, keeping the byte counters and the 

660 enqueue-time and tag mirrors in lockstep with it; the packets dropped. 

661 The oldest tag dropped is kept in `_purged_first`.""" 

662 dq = self._queues[CLASS_VIDEO] 

663 n = len(dq) 

664 if dq: 

665 self.stats["video_dropped"] += n 

666 dq.clear() 

667 self._purged_first = next((t for t in self._video_tags if t is not None), None) 

668 for tag in self._video_tags: 

669 self._drop(tag) 

670 self._video_tags.clear() 

671 self._video_ts.clear() 

672 self._bytes_queued -= self._video_bytes 

673 self._video_bytes = 0 

674 return n 

675 

676 def _reset_gop(self, reason: str = "video queue overflow", tag: Optional[int] = None) -> None: 

677 """Abandon the GOP: purge the queued video, which nothing behind the 

678 requested keyframe can use, refuse video until that keyframe, and 

679 brake if wire evidence sizes one. Where the video sender resyncs its 

680 peer itself (`_resync`), told the oldest packet lost (the queue's, else 

681 `tag`, the packet at hand), the purge and the brake are all: video 

682 flows on and no keyframe is asked for.""" 

683 purged = self._purge_video() 

684 first = self._purged_first if self._purged_first is not None else tag 

685 if not self._gop_dead and self._resync is not None and self._resync(first): 

686 self._on_overflow() 

687 self.stats["resyncs"] += 1 

688 logger.info("pacer: %s => %d queued packets purged; the sender resyncs its peer", 

689 reason, purged) 

690 return 

691 if not self._gop_dead: 

692 self._gop_dead = True 

693 self._gop_dead_at = time.monotonic() 

694 self._on_overflow() 

695 self.stats["gop_resets"] += 1 

696 logger.info("pacer: %s => GOP reset, %d queued packets purged, keyframe requested", 

697 reason, purged) 

698 self.request_keyframe_once() 

699 

700 # ------------------------------------------------------------------ drain 

701 def _kick(self) -> None: 

702 task = self._drain_task 

703 if self._stopped or (task is not None and not task.done()): 

704 return 

705 self._drain_task = self._loop.create_task(self._drain()) 

706 

707 async def _await_backlog_drain(self) -> None: 

708 """Backpressure for the reliable classes: hold the caller until the 

709 backlog falls back to the low-water mark. Bounded so a wedged wire can 

710 only slow a sender, never park it.""" 

711 self._drained.clear() 

712 self._kick() 

713 try: 

714 await asyncio.wait_for(self._drained.wait(), DC_BLOCK_TIMEOUT_S) 

715 except asyncio.TimeoutError: 

716 logger.warning("pacer: %d bytes still queued after %.1fs of " 

717 "backpressure; letting the sender through", 

718 self._bytes_queued, DC_BLOCK_TIMEOUT_S) 

719 

720 def _release_senders(self) -> None: 

721 if not self._drained.is_set(): 

722 self._drained.set() 

723 

724 async def _drain(self) -> None: 

725 try: 

726 while not self._stopped: 

727 # Clear the poke BEFORE the pass, not right before the wait: a 

728 # poke arriving mid-pass must survive into the wait below, 

729 # otherwise a newly affordable packet sleeps until the head's 

730 # deadline. 

731 self._poke.clear() 

732 self._maybe_recover_pace() 

733 self._accrue() 

734 for cls, dq, sender in self._class_table: 

735 while dq: 

736 size = len(dq[0]) 

737 if size > self.credit: 

738 # A packet wider than the whole burst budget can 

739 # never be covered by credit; release it once the 

740 # bucket is full so its class cannot wedge. 

741 if size <= self._debt_cap or self.credit < self._debt_cap: 

742 break 

743 if not self._oversize_warned: 

744 self._oversize_warned = True 

745 logger.warning( 

746 "pacer: %d-byte packet exceeds the %d-byte burst " 

747 "budget; releasing oversized packets on a full " 

748 "bucket", size, int(self._debt_cap)) 

749 data = dq.popleft() 

750 self._bytes_queued -= size 

751 tag = None 

752 if cls == CLASS_VIDEO: 

753 self._video_bytes -= size 

754 self._video_ts.popleft() 

755 tag = self._video_tags.popleft() 

756 self.credit -= size 

757 try: 

758 await sender(data) 

759 except Exception: 

760 logger.warning("pacer: send failed; dropping queue", 

761 exc_info=True) 

762 # The receiver's reference chain dies with the 

763 # purged packets: mark video dead so nothing that 

764 # depends on them is sent, and ask for a keyframe, 

765 # unless the sender resyncs its peer itself. 

766 self._reset_gop("send failed", tag) 

767 self._queues = {c: deque() for c in _QUEUED_CLASSES} 

768 self._make_class_table() 

769 self._bytes_queued = self._video_bytes = 0 

770 self._release_senders() 

771 return 

772 self.stats["paced_bytes"] += size 

773 if tag is not None and self.frame_ends: 

774 self._left(tag) 

775 if self._bytes_queued <= DC_LOW_WATER_BYTES: 

776 self._release_senders() 

777 if not self._bytes_queued: 

778 return 

779 head = 0 

780 for _, dq, _sender in self._class_table: 

781 if dq: 

782 head = len(dq[0]) 

783 break 

784 if not head: 

785 return 

786 # Nothing more is affordable: sleep to the exact instant 

787 # credit covers the highest-priority queued packet — but wake 

788 # early if an enqueue pokes us (newly affordable packet) and 

789 # never sleep beyond the defensive cap. 

790 delay = min( 

791 max((head - self.credit) * 8.0 / self._pace_bps, 0.0005), 

792 DRAIN_MAX_SLEEP_S, 

793 ) 

794 try: 

795 await asyncio.wait_for(self._poke.wait(), delay) 

796 except asyncio.TimeoutError: 

797 pass 

798 finally: 

799 self._drain_task = None 

800 

801 async def close(self) -> None: 

802 self._stopped = True 

803 # Waiters re-check _stopped and raise; nothing may stay parked here. 

804 self._release_senders() 

805 if self._stats_timer is not None: 

806 self._stats_timer.cancel() 

807 self._stats_timer = None 

808 task = self._drain_task 

809 if task is not None and not task.done(): 

810 task.cancel() 

811 try: 

812 await task 

813 except asyncio.CancelledError: 

814 pass 

815 except Exception: 

816 pass 

817 self._drain_task = None 

818 logger.info("pacer closed: %s", self.snapshot()) 

819 

820 def snapshot(self) -> Dict[str, int]: 

821 out = dict(self.stats) 

822 out["pace_bps"] = int(self._pace_bps) 

823 out["burst_bytes"] = int(self._debt_cap) 

824 out["queued_bytes"] = self._bytes_queued 

825 out["video_bytes"] = self._video_bytes 

826 out["idr_floor_bytes"] = int(self._idr_floor_bytes) 

827 out["frame_floor_bytes"] = int(self._frame_floor_bytes) 

828 out["gop_dead"] = int(self._gop_dead) 

829 return out