Files
VoidLink/VoidLink/Utility/FrameQueue.m
T
AcakiandClaude Fable 5 6879bf6379 Allow switching rendering backend without restarting the app
The renderer setting previously forced an app quit (exit(0)) because
switching backends between sessions crashed or leaked. Fix the
underlying lifecycle bugs and remove the restart requirement:

- FrameQueue: really release frames in clear(), clear on session
  start/stop, and guard start/stop with an owner so a stale session's
  async cleanup can't pause a new session's queue
- MetalView: weak delegate (breaks retain cycle leaking the whole Metal
  stack), never start two render threads, bound the shutdown wait to
  avoid deadlocking against the render thread's dispatch_sync to main
- MetalViewController: explicit idempotent shutdown called from stream
  teardown (viewDidDisappear isn't guaranteed); snapshot the renderer
  in render-thread callbacks to avoid use-after-free during shutdown
- MetalVideoRenderer: make the shared colorspace name thread-safe and
  ARC-managed instead of a CFStringRef over-released in dealloc; guard
  NULL format description extensions
- Connection: drop the static renderer reference in DrCleanup
- VideoDecoderRenderer: cache the application background state via
  notifications instead of calling UIKit from the decode queue
  (Main Thread Checker violation)
- Frame: don't CFRetain(NULL) when a format desc has no extensions
- StreamFrameViewController: fix _streamView cleanup ordering
- Settings: apply the backend change in place (with the Metal caveats
  alert) and update localized tips; takes effect on next stream

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 17:14:01 +08:00

389 lines
13 KiB
Objective-C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@import AVFoundation;
@import VideoToolbox;
#import <os/lock.h>
#import <Limelight.h>
#import "Logger.h"
#import "FloatBuffer.h"
#import "FrameQueue.h"
#import "ImGuiPlots.h"
@implementation FrameQueue {
NSMutableArray<id> *_buffer;
int _capacity;
int _head;
int _tail;
int _count;
BOOL _droppedLast;
int _framesIn;
CMTime _ptsCorrection;
os_unfair_lock _lock;
FloatBuffer *_queueSizeHistory;
int _currentSoftCap;
dispatch_queue_t _sq;
dispatch_semaphore_t _frameSemaphore;
// The renderer session currently consuming from the queue. A stale session's
// async stop must not pause a newer session's queue.
__weak id _owner;
}
+ (instancetype)sharedInstance {
static FrameQueue *sharedInstance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sharedInstance = [[super allocWithZone:NULL] _initSingleton];
});
return sharedInstance;
}
// Prevent others from using alloc/init directly
+ (instancetype)allocWithZone:(struct _NSZone *)zone {
return [self sharedInstance];
}
// If someone tries to copy it, just return the same instance
- (id)copyWithZone:(NSZone *)zone {
return self;
}
- (id)mutableCopyWithZone:(NSZone *)zone {
return self;
}
- (instancetype)_initSingleton {
self = [super init];
if (self) {
_droppedLast = NO;
_frameDropMetrics = [[FloatBuffer alloc] initWithCapacity:512];
_framesIn = 0;
_highWaterMark = 2;
_maxCapacity = 15;
_ptsCorrection = CMTimeMake(0, 90000);
_queueSizeHistory = [[FloatBuffer alloc] initWithCapacity:64];
_lock = OS_UNFAIR_LOCK_INIT;
_paused = YES; // caller will call start()
// ring buffer
_capacity = _maxCapacity;
_buffer = [NSMutableArray arrayWithCapacity:_capacity];
for (int i = 0; i < _capacity; i++) {
[_buffer addObject:[NSNull null]];
}
_head = _tail = _count = 0;
_sq = dispatch_queue_create("com.moonlight.FrameQueue",
dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INTERACTIVE, 0));
_frameSemaphore = dispatch_semaphore_create(0);
// ping estimatedFramerate to set initial last value
[self estimatedFramerate];
}
return self;
}
// Push into buffer at _tail
- (void)_pushFrame:(Frame *)frame {
[_buffer replaceObjectAtIndex:_tail withObject:frame];
_tail = (_tail + 1) % _capacity;
_count++;
// I think it's ok to signal the cond from within the unfair lock, the render
// loop will just end up waiting on it in a call to dequeue.
dispatch_semaphore_signal(_frameSemaphore);
FQLog(LOG_I, @"[-> %@ %d / %f] enqueue frame, queue size %d / %d",
frame.frameType == FRAME_TYPE_IDR ? @"IDR" : @"P",
frame.frameNumber, frame.pts, _count, _highWaterMark);
}
// Pop oldest frame from _head
- (Frame *)_popFrame {
id obj = _buffer[_head];
Frame *frame = (obj == [NSNull null] ? nil : obj);
[_buffer replaceObjectAtIndex:_head withObject:[NSNull null]];
_head = (_head + 1) % _capacity;
_count--;
return frame;
}
// Peek next frame (without removing)
- (Frame *)_peekFrame {
id frame = (_count > 0) ? _buffer[_head] : nil;
return (frame == [NSNull null]) ? nil : frame;
}
// enumerate in‐buffer frames
- (void)_enumerateFrames:(void(^)(Frame *frame, NSUInteger idx, BOOL *stop))block {
BOOL stop = NO;
for (int i = 0; i < _count; i++) {
int idx = (_head + i) % _capacity;
block([_buffer objectAtIndex:idx], i, &stop);
if (stop) break;
}
}
- (void)_noteDroppedFrame:(Frame *)frame {
if ([frame durationIsValid]) {
_ptsCorrection = CMTimeAdd(_ptsCorrection, frame.duration90);
FQLog(LOG_W, @"dropped frame %d with duration %.3f ms", frame.frameNumber, frame.duration * 1000.0);
} else {
// count unknowns as 1 avg frametime
CFTimeInterval fps = (_framesIn > 1000) ? [self _unsafeEstimatedFramerate] : 0.0f;
CMTime oneFrame = (_framesIn > 1000) ? CMTimeMake((int)(90000.0f / fps), 90000) : kCMTimeZero;
_ptsCorrection = CMTimeAdd(_ptsCorrection, oneFrame);
FQLog(LOG_W, @"dropped frame %d with unknown duration, using %.3f ms (%.1f fps) instead",
frame.frameNumber, CMTimeGetSeconds(oneFrame) * 1000.0, fps);
}
}
- (int)_unsafeEnqueue:(Frame *)frame withDropTarget:(int)frameDropTarget {
int dropCount = 0;
// Always accept IDR frames, allow exceeding HWM
if (frame.frameType == FRAME_TYPE_IDR || _count < frameDropTarget) {
[self _pushFrame:frame];
_droppedLast = NO;
} else {
if (!_droppedLast) {
// alternate between: drop newest...
[self _noteDroppedFrame:frame];
dropCount = 1;
_droppedLast = YES;
} else {
// and: drop oldest & enqueue new
if ([self _peekFrame].frameType != FRAME_TYPE_IDR) {
Frame *oldest = [self _popFrame];
[oldest setDurationFromNext:[self _peekFrame]];
[self _noteDroppedFrame:oldest];
dropCount = 1;
}
[self _pushFrame:frame];
_droppedLast = NO;
}
}
// regardless of drop status, every enqueue is a frame
// for estimatedFramerate purposes
_framesIn++;
[_frameDropMetrics addValue:(float)dropCount];
// Stats displays the soft cap
_currentSoftCap = frameDropTarget;
return dropCount;
}
// enqueue with simple alternate-drop logic
- (int)enqueue:(Frame *)frame {
os_unfair_lock_lock(&_lock);
int dropCount = [self _unsafeEnqueue:frame withDropTarget:_highWaterMark];
os_unfair_lock_unlock(&_lock);
return dropCount;
}
// enqueue that is a bit more flexixble, using the same 500ms queue size history method as moonlight-qt.
- (int)enqueue:(Frame *)frame withSlackSize:(int)slack {
os_unfair_lock_lock(&_lock);
CFTimeInterval now = CACurrentMediaTime();
// new data point for queue health
[_queueSizeHistory addValue:(float)_count];
// The "target" initially starts as the size of the buffer chosen by the user. 1-5 default 2. We drop a frame
// when the queue size exceeds this amount.
int frameDropTarget = _highWaterMark;
// CFTimeInterval t0 = [_queueSizeHistory oldestTimestamp];
// if (now - t0 > 0.5f) {
// // Get the current drop percentage over the past 512 frames
// // TODO: design a better API e.g. [ImGuiPlots PLOT_DROPPED].
// float dropRate = [[ImGuiPlots sharedInstance].plots[PLOT_DROPPED].buffer averageValue];
// if (dropRate > 0.25f) {
// FQLog(LOG_I, @"queue is dropping at %.2f%%, forcing a drop", dropRate * 100.0);
// frameDropTarget--;
// } else if (_queueSizeHistory.minValue > 0 && _queueSizeHistory.minValue == _queueSizeHistory.maxValue) {
// // If the queue has been in the same state for the entire period, it's possible to get stuck there for a while.
// // Lower the FDT by 1 to force a frame to be dropped and hopefully unstick things.
// FQLog(LOG_I, @"queue stuck at %.0f, forcing a drop", _queueSizeHistory.minValue);
// frameDropTarget--;
// } else if (_queueSizeHistory.minValue < 1.0f) {
// // If the queue has cleared out at least once in the past history period, be more lenient about dropping frames.
// frameDropTarget += slack;
// // FQLog(LOG_I, @"allowing frameDropTarget of %d (history %.0f/%.0f/%.2f)", frameDropTarget,
// // _queueSizeHistory.minValue, _queueSizeHistory.maxValue, _queueSizeHistory.averageValue);
// }
//
// // TODO: New logic
// // Use timestamps, check initial interval is long enough
// // Smarter when checking min==max, a steady state can be fine if no frames are being dropped
// // If min==max && frame drops are >= 25%, force a drop
// // Maybe only look at >= 25%.
// // Test condition variables, manual present, no displaylink
// }
// original enqueue logic still applies if we're full
int dropCount = [self _unsafeEnqueue:frame withDropTarget:frameDropTarget];
os_unfair_lock_unlock(&_lock);
return dropCount;
}
// Allows the render loop to wait if the queue is empty
- (void)waitForEnqueue {
while (!self.paused && [self isEmpty]) {
dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1f * NSEC_PER_SEC)); // 100ms
dispatch_semaphore_wait(_frameSemaphore, timeout);
}
}
- (Frame *)dequeue {
os_unfair_lock_lock(&_lock);
Frame *frame = nil;
if (_count > 0) {
frame = [self _popFrame];
// compute duration from next
Frame *next = [self _peekFrame];
if (next) {
[frame setDurationFromNext:next];
}
FQLog(LOG_I, @"[<- %d / %f%@] dequeue frame, queue size %d",
frame.frameNumber, frame.pts,
[frame durationIsValid] ? [NSString stringWithFormat:@" dur %.3f ms", frame.duration * 1000.0] : @"",
_count);
}
os_unfair_lock_unlock(&_lock);
return frame;
}
- (void)dequeueWithTimeout:(CFTimeInterval)timeout
completion:(void (^)(Frame *frame))completion {
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0), ^{
Frame *frame = [self dequeueWithTimeoutSync:timeout];
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) {
completion(frame);
}
});
});
}
- (Frame *)dequeueWithTimeoutSync:(CFTimeInterval)timeout {
CFTimeInterval start = CACurrentMediaTime();
CFTimeInterval deadline = start + timeout;
int round = 0;
if (self.paused) {
return nil;
}
// Always attempt to dequeue at least once
do {
if (round > 0) {
usleep(100); // 0.1ms
}
Frame *frame = [self dequeue];
if (frame) {
return frame;
}
round++;
} while (CACurrentMediaTime() < deadline);
FQLog(LOG_I, @"dequeueWithTimeout timed out after %.3f ms", (CACurrentMediaTime() - start) * 1000.0);
return nil;
}
- (NSUInteger)count {
os_unfair_lock_lock(&_lock);
NSUInteger c = _count;
os_unfair_lock_unlock(&_lock);
return c;
}
- (BOOL)isEmpty {
return [self count] == 0;
}
- (void)clear {
os_unfair_lock_lock(&_lock);
// Release any frames still held in the ring buffer, otherwise their
// pixel/sample buffers stay retained for the lifetime of the singleton.
for (int i = 0; i < _capacity; i++) {
[_buffer replaceObjectAtIndex:i withObject:[NSNull null]];
}
_head = _tail = _count = 0;
_droppedLast = NO;
_ptsCorrection = CMTimeMake(0, 90000);
_frameDropMetrics = [[FloatBuffer alloc] initWithCapacity:512];
os_unfair_lock_unlock(&_lock);
}
- (CFTimeInterval)_unsafeEstimatedFramerate {
static CFTimeInterval lastTime = 0;
static int lastFrames = 0;
static CFTimeInterval estimate = 0;
CFTimeInterval now = CACurrentMediaTime();
if (now - lastTime > 1.0) {
estimate = (_framesIn - lastFrames) / (now - lastTime);
// FQLog(LOG_I, @"fps calc using framesIn %d - lastFrames %d / now %f - lastTime %f = %.1f fps",
// _framesIn, lastFrames, now, lastTime, estimate);
lastTime = now;
lastFrames = _framesIn;
}
return estimate;
}
- (CFTimeInterval)estimatedFramerate {
os_unfair_lock_lock(&_lock);
CFTimeInterval fps = [self _unsafeEstimatedFramerate];
os_unfair_lock_unlock(&_lock);
return fps;
}
- (int)currentSoftCap {
os_unfair_lock_lock(&_lock);
int cap = _currentSoftCap;
os_unfair_lock_unlock(&_lock);
return cap;
}
- (void)stopForOwner:(id)owner {
@synchronized (self) {
// Ignore stops from a previous session: cleanup is dispatched async and can
// land after a new renderer has already (re)started the queue.
if (_owner != nil && owner != _owner) {
Log(LOG_W, @"FrameQueue stop ignored: %p is not the current owner", owner);
return;
}
_owner = nil;
}
// new frames will no longer be coming in, make sure consumer side is not left waiting
self.paused = YES;
Log(LOG_I, @"FrameQueue stopped");
dispatch_semaphore_signal(_frameSemaphore);
// Don't keep the previous session's frames (and their pixel buffers) alive
[self clear];
}
- (void)startForOwner:(id)owner {
@synchronized (self) {
_owner = owner;
}
// Drop any frames left over from a previous renderer session; feeding them to a
// renderer with a different backend or video format can crash it.
[self clear];
self.paused = NO;
Log(LOG_I, @"FrameQueue started");
}
// For use with NSLog("%@", franeQueue);
- (NSString *)description {
__block NSMutableArray *parts = [NSMutableArray arrayWithCapacity:_count];
[self _enumerateFrames:^(Frame *frame, NSUInteger idx, BOOL *stop) {
[parts addObject:[frame description]];
}];
return [NSString stringWithFormat:@"[%@]", [parts componentsJoinedByString:@",\n"]];
}
@end