ci / bun-nix (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m22s
ci / rust-arm64 (pull_request) Successful in 1m42s
ci / web (pull_request) Successful in 1m51s
apple / swift (pull_request) Successful in 1m50s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m28s
android / android (pull_request) Successful in 6m39s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 13m7s
ci / rust (pull_request) Successful in 14m4s
Every unrecoverable loss armed the client's freeze gate twice: instantly at frame-index-gap detection (which fires the RFI), and ~120 ms later when the reassembler ages the lost frame into frames_dropped and poll() re-armed unconditionally. An LTR-RFI recovery anchor lands in ~60 ms — between the two signals — so the stale climb re-froze a bit-exact-healed stream, the host swallowed the re-ask as an RFI echo, and the picture stayed frozen until the overdue backstop extracted a full IDR: the field 'H265 freezes on every loss, AV1 fine' signature on AMD hosts (AMF is the only LTR-RFI backend; the slower IDR path usually lands after the climb and dodged the race). The gap-arm now pre-credits the expected climb (ReanchorGate::arm_expecting_drops; credit expires after DROP_CREDIT_WINDOW so a straggler-filled gap can't mask a later real loss), and poll() consumes credited climbs instead of re-arming. Plumbed through every embedder: pf-client-core's session pump, Android's sync/async loops (note_frame_index now returns the gap width), and the Swift client via new ABI exports punktfunk_connection_note_frame_index_ex + punktfunk_reanchor_gate_arm_expecting_drops (additive; the bool ABI stays).
110 lines
5.4 KiB
Swift
110 lines
5.4 KiB
Swift
// Swift wrapper around the punktfunk-core C ABI's post-loss re-anchor gate
|
|
// (`punktfunk_reanchor_gate_*`, ABI v6). The shared Rust gate (crates/punktfunk-core/src/reanchor.rs)
|
|
// is what the Linux/Windows desktop pump and the Android client use directly; the Swift clients reach
|
|
// it across the C ABI so the freeze-until-reanchor policy is defined ONCE for every platform.
|
|
//
|
|
// Why a freeze at all: after unrecoverable loss the host keeps sending delta frames that reference a
|
|
// picture the client never got. Hardware decoders (VideoToolbox included) don't reliably error on
|
|
// that — they CONCEAL, returning a gray/garbage frame with a success status. Presenting those is the
|
|
// visible "gray flash with motion" of the loss reports. The gate withholds concealed frames and holds
|
|
// the last good picture on glass until a PROVEN clean re-anchor lands — an IDR (wire `FLAG_SOF`), an
|
|
// RFI recovery anchor (`USER_FLAG_RECOVERY_ANCHOR`), or the 2nd of two intra-refresh recovery marks
|
|
// (`USER_FLAG_RECOVERY_POINT`) — with a bounded backstop so a lost re-anchor can never freeze forever.
|
|
// See punktfunk-planning design/client-reanchor-freeze-parity.md.
|
|
//
|
|
// Threading: one gate per session. Its calls arrive from two threads — the pump thread (`arm` on a
|
|
// frame-index gap / a submit failure, `poll` per iteration) and a VideoToolbox decode thread
|
|
// (`onDecoded` per decoded frame, `onNoOutput` on a decode error). The raw Rust gate is a plain
|
|
// struct behind an opaque pointer with no internal synchronization, so every call is serialized under
|
|
// `lock` here — the calls are cheap field updates, so contention is negligible. `@unchecked Sendable`:
|
|
// the lock enforces the contract.
|
|
|
|
import Foundation
|
|
import PunktfunkCore
|
|
|
|
final class ReanchorGate: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
/// The opaque `ReanchorGate *`. `var` so `reseed` can swap it at session start. Never NULL
|
|
/// (`punktfunk_reanchor_gate_new` never returns NULL).
|
|
private var ptr: OpaquePointer
|
|
|
|
/// Seed the baseline with the connection's current `framesDropped` so the first `poll` doesn't
|
|
/// read the session's starting drop count as a fresh loss.
|
|
init(framesDropped: UInt64) {
|
|
ptr = punktfunk_reanchor_gate_new(framesDropped)
|
|
}
|
|
|
|
deinit { punktfunk_reanchor_gate_free(ptr) }
|
|
|
|
/// Re-anchor the drop-count baseline to `framesDropped` for a (re)started session. The gate is
|
|
/// created in the pipeline's init (before a connection exists, seeded 0); `start` calls this once
|
|
/// the live connection's count is known so a mid-life connection's non-zero baseline isn't
|
|
/// mistaken for loss on the first poll.
|
|
func reseed(framesDropped: UInt64) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
punktfunk_reanchor_gate_free(ptr)
|
|
ptr = punktfunk_reanchor_gate_new(framesDropped)
|
|
}
|
|
|
|
/// Arm the freeze: a loss was detected (a frame-index gap, or a decoder wedge). Zeroes the
|
|
/// recovery-mark count and (re)sets the backstop deadline.
|
|
func arm() {
|
|
lock.lock()
|
|
punktfunk_reanchor_gate_arm(ptr)
|
|
lock.unlock()
|
|
}
|
|
|
|
/// `arm()` for a loss detected as a frame-index gap of a known width
|
|
/// (`PunktfunkConnection.noteFrameIndexGapWidth`). Pre-credits the reassembler's later
|
|
/// `framesDropped` climb for the same lost frames, so `poll` doesn't re-freeze a stream an
|
|
/// RFI anchor already healed (the double-arm race — the Rust gate's docs tell the story).
|
|
func arm(expectingDrops: UInt64) {
|
|
lock.lock()
|
|
punktfunk_reanchor_gate_arm_expecting_drops(ptr, expectingDrops)
|
|
lock.unlock()
|
|
}
|
|
|
|
/// Fold one decoded frame. `flags` is the AU's wire `user_flags`. Returns true to PRESENT the
|
|
/// frame, false to WITHHOLD it as a post-loss concealment (hold the last good picture). Pass
|
|
/// `decoderKeyframe: false` — VideoToolbox doesn't flag IDRs, so the wire `FLAG_SOF` covers it.
|
|
func onDecoded(flags: UInt32, decoderKeyframe: Bool = false) -> Bool {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
var present = false
|
|
_ = punktfunk_reanchor_gate_on_decoded(ptr, flags, decoderKeyframe, &present)
|
|
return present
|
|
}
|
|
|
|
/// A received AU produced no decoded frame (a VideoToolbox decode error). Returns true when the
|
|
/// no-output streak has tripped (the gate armed the freeze) and the caller should — throttled —
|
|
/// request a keyframe.
|
|
func onNoOutput() -> Bool {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
var requestKf = false
|
|
_ = punktfunk_reanchor_gate_on_no_output(ptr, &requestKf)
|
|
return requestKf
|
|
}
|
|
|
|
/// Periodic fold of the session's `framesDropped` plus the overdue backstop. Returns true when the
|
|
/// caller should — throttled — request a keyframe (a drop-count climb armed a fresh freeze, or the
|
|
/// freeze is overdue and re-asks while it keeps holding).
|
|
func poll(framesDropped: UInt64) -> Bool {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
var requestKf = false
|
|
_ = punktfunk_reanchor_gate_poll(ptr, framesDropped, &requestKf)
|
|
return requestKf
|
|
}
|
|
|
|
/// Whether the gate is currently withholding concealed frames (frozen on the last good picture).
|
|
var isHolding: Bool {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
var holding = false
|
|
_ = punktfunk_reanchor_gate_is_holding(ptr, &holding)
|
|
return holding
|
|
}
|
|
}
|