6.12.1 - Frame-time epochs that order across a load, and a software throughput that never lies
Two host-facing diagnostics that reported something false with confidence, plus a build fix. Drop-in from 6.12.0, no source change on the consumer side; two published values change what they read.
Fixed: the producer epoch keeps rising across a load()
NativeVideoFrameTime.epoch and SoftwareVideoFrameTime.generation both document the same rule, that a higher value retires every entry a consumer recorded under a lower one. Both were counted per session, and a load() builds a new HLSVideoEngine and a new SampleBufferRenderer, so the counter restarted at exactly the seam the rule exists for.
The observer is engine-scoped and outlives a load, so the outgoing producer keeps reporting until load stops it. A host that retired its table at the start of the load could be handed a frame from the outgoing producer at epoch N, adopt N as newest, and then reject every frame of the new session as stale for the rest of the item. epoch is the only discriminator that could have separated them: source, item and segmentIndex all restart near zero on a new item.
Both counters now draw from one process-wide sequence with its own lock, so a superseded producer's last report always ranks below the next session's first. No public struct changed, and a host that only compares epochs relatively needs no change at all. What did change: successive values are strictly increasing but no longer consecutive, and the software path's initial generation is drawn at construction rather than starting at zero, so a fresh renderer cannot tie with the one it replaces.
stopInternal additionally detaches the outgoing session and the outgoing software host from the observer before stopping them. That is not a session fence, since a pump can have snapshotted the observer already, it only makes the ordinary case silence rather than a correctly-sorted straggler.
The witness is two HLSVideoEngine instances over one clip, standing in for the two sides of a load. On the old code it fails with epoch 1 against epoch 1, which is the single value ordering cannot separate.
Fixed: the software path stops publishing 0.0 Mbps while its reader is parked
networkThroughputMbps was a wall-clock mean over the 10 s byte window. The playback reader fetches a large range and then parks on backpressure until low water, so on a fast link most ticks of that window carry no bytes at all. Measured with aetherctl play against a 42 MB / 2.8 Mbps VP9 source over a local range origin:
t=01 rx=16.4MB ahead=16.0MB
t=02 net=0.00Mbps rx=16.4MB ahead=15.5MB cushion=0.44s
...
t=23 net=0.00Mbps rx=16.4MB ahead=8.5MB cushion=0.42s
t=25 net=6.92Mbps rx=24.6MB ahead=16.0MB <- the next refill
23 consecutive ticks of a confident zero on a session playing perfectly, while the reader's runway drained from 16.0 to 8.3 MB. That is the same false-with-confidence zero #306 was filed about, one field over, and the reporting host had already been forced to guard on > 0 rather than on non-nil because of it.
The rate is now measured over the seconds bytes actually arrived in, which is the quantity the native path reports through observedBitrate, and it is nil rather than zero when nothing arrived in the window at all. Publishing the gap keeps the choice on the host side: a host that wants a held reading keeps the last non-nil value, while a host handed a zero cannot tell a parked reader from a dead link.
The struggling arm is unchanged, and it is the arm the section was already legible on. With a 2 Mbps paced origin every tick carries bytes, so both formulas agree and a starving 2.8 Mbps session reads a steady 2.10 Mbps with ahead=0 and cushion=0 throughout.
instantBitrateMbps is deliberately left as a wall-clock mean on both paths: that is what makes it the stream rate rather than the link rate. The readerWindowAheadBytes doc comment is corrected too, since it told hosts to divide the runway by the throughput; that answers how long the runway took to fetch, not how long it lasts at playback rate, where the divisor is averageBitrateMbps.
Found while verifying @kskchaitanya1993's retest on #306.
Fixed: the render-metrics read builds across the toolchain range
loadRenderMetrics() read displayLayer.sampleBufferRenderer and then suspended on that renderer's async accessor, and no single await on it satisfies both ends of the range: an SDK that isolates AVSampleBufferDisplayLayer to the main actor refuses to hand the renderer to any other domain, while a toolchain that imports the async accessor as nonisolated refuses to take that non-Sendable renderer from the main actor. The read is main-actor isolated now and goes through the completion-handler accessor, which suspends without moving the renderer anywhere. Every caller was already main-actor isolated, so playback and telemetry are unchanged.
Reported and first fixed by @jihongboo in #313 / #312.
Verification
swift test: 1556 tests, 232 suites, green on macOS 26. CI green on all three jobs (swift test on macOS, xcodebuild on the tvOS and iOS simulators).
Both new behaviours were verified by falsification rather than by assertion: with the per-instance counters restored the frame-time witness fails on equal epochs across two sessions, and the throughput change was measured on two arms of the same source, one fast local origin and one paced at 2 Mbps with 30 ms of latency, so the arm that must not move is shown not moving.