From 6879bf63791c3422fd88095237bee508524b6959 Mon Sep 17 00:00:00 2001 From: Acaki Date: Sat, 8 Aug 2026 17:14:01 +0800 Subject: [PATCH] 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 --- VoidLink/Localization/Localizable.xcstrings | 256 +++++++++++------- VoidLink/Metal/MetalVideoRenderer.m | 37 +-- VoidLink/Metal/MetalView.h | 4 +- VoidLink/Metal/MetalView.m | 21 +- VoidLink/Metal/MetalViewController.h | 4 + VoidLink/Metal/MetalViewController.m | 33 ++- VoidLink/Stream/Connection.m | 4 + VoidLink/Stream/VideoDecoderRenderer.m | 36 ++- VoidLink/Utility/Frame.m | 6 +- VoidLink/Utility/FrameQueue.h | 4 +- VoidLink/Utility/FrameQueue.m | 33 ++- .../ViewControllers/SettingsViewController.m | 72 +++-- .../StreamFrameViewController.m | 6 +- 13 files changed, 344 insertions(+), 172 deletions(-) diff --git a/VoidLink/Localization/Localizable.xcstrings b/VoidLink/Localization/Localizable.xcstrings index 7d58ea11..e9ca2b23 100644 --- a/VoidLink/Localization/Localizable.xcstrings +++ b/VoidLink/Localization/Localizable.xcstrings @@ -5090,6 +5090,29 @@ } } }, + "Apply" : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "应用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "套用" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "套用" + } + } + } + }, "Arrow direction pad" : { "extractionState" : "manual", "localizations" : { @@ -8208,6 +8231,29 @@ } } }, + "Enable Metal Renderer?" : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用 Metal 渲染?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "啟用 Metal 渲染?" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "啟用 Metal 渲染?" + } + } + } + }, "enableOswForNativeTouchSwitchTip" : { "extractionState" : "manual", "localizations" : { @@ -9304,98 +9350,6 @@ } } }, - "Interpolation failed for an unknown reason." : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "未知原因导致的插帧失败" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "未知原因導致插幀失敗" - } - }, - "zh-HK" : { - "stringUnit" : { - "state" : "translated", - "value" : "未知原因導致插幀失敗" - } - } - } - }, - "Interpolation overhead is too high. Falling back to normal mode." : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "插帧性能开销过大,回退普通模式" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "插幀效能開銷過大,回退至一般模式" - } - }, - "zh-HK" : { - "stringUnit" : { - "state" : "translated", - "value" : "插幀效能開銷過大,回退至一般模式" - } - } - } - }, - "Interpolator error:" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "插帧器错误:" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "插幀器錯誤:" - } - }, - "zh-HK" : { - "stringUnit" : { - "state" : "translated", - "value" : "插幀器錯誤:" - } - } - } - }, - "Interpolator started." : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "插帧器已启动。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "插幀器已啟動。" - } - }, - "zh-HK" : { - "stringUnit" : { - "state" : "translated", - "value" : "插幀器已啟動。" - } - } - } - }, "framePacingStackTip" : { "extractionState" : "manual", "localizations" : { @@ -10883,6 +10837,98 @@ } } }, + "Interpolation failed for an unknown reason." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知原因导致的插帧失败" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知原因導致插幀失敗" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知原因導致插幀失敗" + } + } + } + }, + "Interpolation overhead is too high. Falling back to normal mode." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "插帧性能开销过大,回退普通模式" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "插幀效能開銷過大,回退至一般模式" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "插幀效能開銷過大,回退至一般模式" + } + } + } + }, + "Interpolator error:" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "插帧器错误:" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "插幀器錯誤:" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "插幀器錯誤:" + } + } + } + }, + "Interpolator started." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "插帧器已启动。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "插幀器已啟動。" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "插幀器已啟動。" + } + } + } + }, "Interval of Multi-Touch Move Events: %d μs" : { "extractionState" : "manual", "localizations" : { @@ -12193,25 +12239,25 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "\nHold up! \n\n- Standard render has better performance on most devices now.\n\n- Changing the rendering mode requires restarting the application to ensure a clean start.\n\n- Metal rendering is an experimental feature that may cause performance degradation on some devices.\n\n- Metal render currently is unable to handle HDR content correctly on most devices.\n\n- Metal rendering mode does not support PiP.\n\n- Tapping “Quit Now” means I acknowledge the above information." + "value" : "\nHold up! \n\n- Standard render has better performance on most devices now.\n\n- The change takes effect the next time you start a stream. No app restart is needed.\n\n- Metal rendering is an experimental feature that may cause performance degradation on some devices.\n\n- Metal render currently is unable to handle HDR content correctly on most devices.\n\n- Metal rendering mode does not support PiP.\n\n- Tapping “Apply” means I acknowledge the above information." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "\n等等!\n\n- 标准渲染模式目前在大多数设备上具有更好的性能。\n\n- 切换渲染模式需要重启应用程序,以确保一次干净的启动。\n\n- Metal 渲染是一项实验性功能,在某些设备上可能会导致性能下降。\n\n- Metal 渲染模式下不支持画中画。\n\n- 目前 Metal 渲染器在大多数设备上无法正确处理 HDR 内容。\n\n点击“立即退出”表示我已知晓并认可以上信息。" + "value" : "\n等等!\n\n- 标准渲染模式目前在大多数设备上具有更好的性能。\n\n- 渲染模式的更改将在下次开始串流时生效,无需重启应用程序。\n\n- Metal 渲染是一项实验性功能,在某些设备上可能会导致性能下降。\n\n- Metal 渲染模式下不支持画中画。\n\n- 目前 Metal 渲染器在大多数设备上无法正确处理 HDR 内容。\n\n点击“应用”表示我已知晓并认可以上信息。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "\n等等!\n\n- 目前在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 变更渲染模式需要重新启动应用程式,以确保干净的启动流程。\n\n- Metal 渲染为实验性功能,可能在某些装置上造成效能下降。\n\n- Metal 渲染模式下不支援画中画。\n\n- Metal 渲染器目前在大多数装置上无法正确处理 HDR 内容。\n\n点选「立即结束」表示我已了解并同意上述说明。" + "value" : "\n等等!\n\n- 目前在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 变更渲染模式将在下次开始串流时生效,无需重新启动应用程式。\n\n- Metal 渲染为实验性功能,可能在某些装置上造成效能下降。\n\n- Metal 渲染模式下不支援画中画。\n\n- Metal 渲染器目前在大多数装置上无法正确处理 HDR 内容。\n\n点选「套用」表示我已了解并同意上述说明。" } }, "zh-HK" : { "stringUnit" : { "state" : "translated", - "value" : "\n等等!\n\n- 目前在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 变更渲染模式需要重新启动应用程式,以确保干净的启动流程。\n\n- Metal 渲染为实验性功能,可能在某些装置上造成效能下降。\n\n- Metal 渲染模式下不支援画中画。\n\n- Metal 渲染器目前在大多数装置上无法正确处理 HDR 内容。\n\n点选「立即结束」表示我已了解并同意上述说明。" + "value" : "\n等等!\n\n- 目前在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 变更渲染模式将在下次开始串流时生效,无需重新启动应用程式。\n\n- Metal 渲染为实验性功能,可能在某些装置上造成效能下降。\n\n- Metal 渲染模式下不支援画中画。\n\n- Metal 渲染器目前在大多数装置上无法正确处理 HDR 内容。\n\n点选「套用」表示我已了解并同意上述说明。" } } } @@ -15619,25 +15665,25 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "- Standard render has better performance on most devices now.\n\n- Changing the rendering mode requires restarting the application to ensure a clean start.\n\n- Metal rendering is an experimental feature that may cause performance degradation on some devices.\n\n- Metal render currently is unable to handle HDR content correctly on most devices.\n\n- Metal rendering mode does not support PiP." + "value" : "- Standard render has better performance on most devices now.\n\n- Changing the rendering mode takes effect the next time you start a stream.\n\n- Metal rendering is an experimental feature that may cause performance degradation on some devices.\n\n- Metal render currently is unable to handle HDR content correctly on most devices.\n\n- Metal rendering mode does not support PiP." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "- 目前, 标准渲染模式在大多数设备上具有更好的性能。\n\n- 切换渲染模式需要重启应用程序,以确保一次干净的启动。\n\n- Metal 渲染是一项实验性功能,在某些设备上可能会导致性能下降。\n\n- Metal 渲染模式下不支持画中画。\n\n- 目前 Metal 渲染器在大多数设备上无法正确处理 HDR 内容。\n" + "value" : "- 目前, 标准渲染模式在大多数设备上具有更好的性能。\n\n- 渲染模式的更改将在下次开始串流时生效。\n\n- Metal 渲染是一项实验性功能,在某些设备上可能会导致性能下降。\n\n- Metal 渲染模式下不支持画中画。\n\n- 目前 Metal 渲染器在大多数设备上无法正确处理 HDR 内容。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "- 目前,在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 变更渲染模式需要重新启动应用程式,以确保干净的启动流程。\n\n- Metal 渲染为实验性功能,可能在某些装置上造成效能下降。\n\n- Metal 渲染模式下不支援画中画。\n\n- Metal 渲染器目前在大多数装置上无法正确处理 HDR 内容。" + "value" : "- 目前,在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 变更渲染模式将在下次开始串流时生效。\n\n- Metal 渲染为实验性功能,可能在某些装置上造成效能下降。\n\n- Metal 渲染模式下不支援画中画。\n\n- Metal 渲染器目前在大多数装置上无法正确处理 HDR 内容。" } }, "zh-HK" : { "stringUnit" : { "state" : "translated", - "value" : "- 目前,在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 变更渲染模式需要重新启动应用程式,以确保干净的启动流程。\n\n- Metal 渲染为实验性功能,可能在某些装置上造成效能下降。\n\n- Metal 渲染模式下不支援画中画。\n\n- Metal 渲染器目前在大多数装置上无法正确处理 HDR 内容。" + "value" : "- 目前,在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 变更渲染模式将在下次开始串流时生效。\n\n- Metal 渲染为实验性功能,可能在某些装置上造成效能下降。\n\n- Metal 渲染模式下不支援画中画。\n\n- Metal 渲染器目前在大多数装置上无法正确处理 HDR 内容。" } } } @@ -17550,25 +17596,25 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "\n- Standard render has better performance on most devices now.\n\n- Changing the rendering mode requires restarting the application to ensure a clean start." + "value" : "\n- Standard render has better performance on most devices now.\n\n- The change takes effect the next time you start a stream." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "\n- 标准渲染模式目前在大多数设备上具有更好的性能。\n\n- 更改渲染模式需要重启应用以确保干净启动。您是否要立即退出应用?" + "value" : "\n- 目前, 标准渲染模式在大多数设备上具有更好的性能。\n\n- 渲染模式的更改将在下次开始串流时生效。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "\n- 目前在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 更改渲染模式需要重新啟動應用程式以確保乾淨啟動。您是否要立即退出應用程式?" + "value" : "\n- 目前,在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 变更渲染模式将在下次开始串流时生效。" } }, "zh-HK" : { "stringUnit" : { "state" : "translated", - "value" : "\n- 目前在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 更改渲染模式需要重新啟動應用程式以確保乾淨啟動。您是否要立即退出應用程式?" + "value" : "\n- 目前,在大多数装置上,標準渲染模式具有较佳的效能。\n\n- 变更渲染模式将在下次开始串流时生效。" } } } @@ -19915,4 +19961,4 @@ } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/VoidLink/Metal/MetalVideoRenderer.m b/VoidLink/Metal/MetalVideoRenderer.m index 715499e4..55d72125 100644 --- a/VoidLink/Metal/MetalVideoRenderer.m +++ b/VoidLink/Metal/MetalVideoRenderer.m @@ -106,7 +106,17 @@ static const NSUInteger MaxFramesInFlight = 3; // best to tone-map Windows content static BOOL useEDR = YES; -CFStringRef __currentColorSpace; +// Name of the colorspace most recently applied to the layer, for the stats overlay. +// Class-level because the stats path only has access to the class; guarded by a lock +// since it is written on the render thread and read from other threads, and multiple +// renderer instances can briefly overlap during a session switch. +static NSString *__currentColorSpace; + +static void setCurrentColorSpaceName(NSString *name) { + @synchronized ([MetalVideoRenderer class]) { + __currentColorSpace = name; + } +} @implementation MetalVideoRenderer { dispatch_queue_t _sq; @@ -225,11 +235,9 @@ CFStringRef __currentColorSpace; if (_renderPassDescriptor) { _renderPassDescriptor = nil; } - - if (__currentColorSpace) { - CFRelease(__currentColorSpace); - __currentColorSpace = NULL; - } + + // Do NOT clear __currentColorSpace here: a new renderer instance may already be + // live (session switch) and have set its own value. } #if !TARGET_OS_TV @@ -405,9 +413,11 @@ CFStringRef __currentColorSpace; paramBuffer.cscParams = (fullRange ? k_CscParams_Bt709Full : k_CscParams_Bt709Lim); break; case COLORSPACE_REC_2020: { + // Frames without a format description (e.g. produced for the AVSB + // backend) must not crash us here CFDictionaryRef ext = [frame getFormatDescExtensions]; - CFStringRef frame_trc = CFDictionaryGetValue(ext, kCVImageBufferTransferFunctionKey); - if (CFEqual(frame_trc, kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ)) { + CFStringRef frame_trc = ext ? CFDictionaryGetValue(ext, kCVImageBufferTransferFunctionKey) : NULL; + if (frame_trc && CFEqual(frame_trc, kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ)) { isHDR = YES; newColorSpace = CGColorSpaceCreateWithName(_nonFullHdrColorSpace); newPixelFormat = _nonFullHdrPixelFormat; @@ -478,11 +488,7 @@ CFStringRef __currentColorSpace; }); } #endif - if (__currentColorSpace) { - CFRelease(__currentColorSpace); - __currentColorSpace = NULL; - } - __currentColorSpace = CGColorSpaceCopyName(newColorSpace); + setCurrentColorSpaceName(newColorSpace ? CFBridgingRelease(CGColorSpaceCopyName(newColorSpace)) : nil); CGColorSpaceRelease(newColorSpace); } @@ -904,10 +910,9 @@ CFStringRef __currentColorSpace; } + (NSString *)currentColorSpace { - if (__currentColorSpace) { - return (__bridge NSString *)__currentColorSpace; + @synchronized ([MetalVideoRenderer class]) { + return __currentColorSpace; } - return nil; } @end diff --git a/VoidLink/Metal/MetalView.h b/VoidLink/Metal/MetalView.h index 4564f51d..b93212b2 100644 --- a/VoidLink/Metal/MetalView.h +++ b/VoidLink/Metal/MetalView.h @@ -24,7 +24,9 @@ @interface MetalView : UIView @property (nonatomic, nonnull, readonly) CAMetalLayer *metalLayer; -@property (nonatomic, nullable) id delegate; +// weak: the delegate (MetalViewController) strongly owns this view; a strong +// reference here creates a retain cycle that leaks the whole Metal stack. +@property (nonatomic, weak, nullable) id delegate; @property (nonatomic) float framerate; - (void)initCommon; diff --git a/VoidLink/Metal/MetalView.m b/VoidLink/Metal/MetalView.m index ca967738..361d3e38 100644 --- a/VoidLink/Metal/MetalView.m +++ b/VoidLink/Metal/MetalView.m @@ -45,10 +45,20 @@ Log(LOG_I, @"[MetalView] sending renderThread a cancel message"); [_renderThread cancel]; Log(LOG_I, @"[MetalView] waiting on renderThread to finish"); - while (!_renderThread.isFinished) { + // Bounded wait only: shutdown typically runs on the main thread, and the render + // thread may be blocked in a dispatch_sync onto the main queue (layer colorspace + // changes). Spinning here forever would deadlock; if we time out, the thread + // exits on its own once the main queue is serviced again (it retains the view + // via its block, so this is safe). + CFTimeInterval deadline = CACurrentMediaTime() + 1.0; + while (!_renderThread.isFinished && CACurrentMediaTime() < deadline) { usleep(100); } - Log(LOG_I, @"[MetalView] renderThread has finished"); + if (_renderThread.isFinished) { + Log(LOG_I, @"[MetalView] renderThread has finished"); + } else { + Log(LOG_W, @"[MetalView] renderThread still busy after 1s, letting it exit asynchronously"); + } _renderThread = nil; } } @@ -68,6 +78,13 @@ return; } + // Never run two render loops at once (the view can move between windows, + // e.g. external display or re-parenting) + if (_renderThread) { + Log(LOG_W, @"[MetalView] movedToWindow with a live renderThread, restarting it"); + [self shutdown]; + } + // Render on a new thread _renderThread = [[NSThread alloc] initWithBlock:^{ while (![NSThread currentThread].isCancelled) { diff --git a/VoidLink/Metal/MetalViewController.h b/VoidLink/Metal/MetalViewController.h index c9264a39..c240f520 100644 --- a/VoidLink/Metal/MetalViewController.h +++ b/VoidLink/Metal/MetalViewController.h @@ -24,4 +24,8 @@ - (void)pauseRendering; - (void)resumeRendering; +// Idempotent teardown: stops the render thread, display link and renderer. +// Must be called when the stream session ends; also runs from viewDidDisappear. +- (void)shutdown; + @end diff --git a/VoidLink/Metal/MetalViewController.m b/VoidLink/Metal/MetalViewController.m index 2f4b56b9..d8496fe4 100644 --- a/VoidLink/Metal/MetalViewController.m +++ b/VoidLink/Metal/MetalViewController.m @@ -109,13 +109,18 @@ } - (void)waitToRenderTo:(nonnull CAMetalLayer *)layer { - // Skip waiting when renderer is paused - if (_renderer.isStopping) { + // Snapshot into a strong local: shutdown (main thread) can nil _renderer while + // the render thread is inside this method. + MetalVideoRenderer *renderer = _renderer; + + // Skip waiting when renderer is paused or gone + if (!renderer || renderer.isStopping) { + _frameSlotAcquired = NO; return; } if (@available(iOS 13.0, *)) { - _frameSlotAcquired = [_renderer waitToRenderTo:layer]; + _frameSlotAcquired = [renderer waitToRenderTo:layer]; } if (_frameSlotAcquired) { @@ -128,22 +133,27 @@ if (!_frameSlotAcquired) { return; } - CFTimeInterval timeout = (1.0f / _framerate) - _renderer.averageGPUTime; + // Snapshot for the same reason as in waitToRenderTo + MetalVideoRenderer *renderer = _renderer; + if (!renderer) { + return; + } + CFTimeInterval timeout = (1.0f / _framerate) - renderer.averageGPUTime; Frame *frame = [_frameQueue dequeueWithTimeoutSync:timeout]; - if (!_renderer.isStopping) { + if (!renderer.isStopping) { // Only render if not paused if (frame) { //if (@available(iOS 13.0, *)) { - [_renderer renderFrame:frame toLayer:layer]; + [renderer renderFrame:frame toLayer:layer]; //} } else { - dispatch_semaphore_signal([_renderer inFlightSemaphore]); + dispatch_semaphore_signal([renderer inFlightSemaphore]); } } else { // When paused, we still dequeue frames to prevent accumulation // but don't render them. Also sleep a bit to reduce CPU usage - dispatch_semaphore_signal([_renderer inFlightSemaphore]); + dispatch_semaphore_signal([renderer inFlightSemaphore]); usleep(100000); } } @@ -177,11 +187,18 @@ Log(LOG_I, @"[MetalViewController] viewDidDisappear"); + [self shutdown]; +} + +- (void)shutdown { if (_displayLink) { [_displayLink invalidate]; _displayLink = nil; } + // Stop the renderer before the render thread: isStopping makes the thread's + // renderFrame return early, so it can't enter the dispatch_sync-to-main path + // while we wait for it below. if (_renderer) { [_renderer shutdown]; _renderer = nil; diff --git a/VoidLink/Stream/Connection.m b/VoidLink/Stream/Connection.m index 0354a405..b51d6f8a 100644 --- a/VoidLink/Stream/Connection.m +++ b/VoidLink/Stream/Connection.m @@ -80,6 +80,10 @@ int DrDecoderSetup(int videoFormat, int width, int height, int redrawRate, void* void DrCleanup(void) { [renderer cleanup]; + // Drop the static reference so the old renderer (and its decoder resources) + // doesn't outlive the session; otherwise it stays alive until the next + // Connection init overwrites it, which can interleave with a new session. + renderer = nil; } -(BandwidthTracker *) getBwTracker diff --git a/VoidLink/Stream/VideoDecoderRenderer.m b/VoidLink/Stream/VideoDecoderRenderer.m index c812a425..24cc719d 100644 --- a/VoidLink/Stream/VideoDecoderRenderer.m +++ b/VoidLink/Stream/VideoDecoderRenderer.m @@ -68,6 +68,12 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size, FramePacingMode _framePacingMode; bool _enableTimebase; bool _asyncFrameDequeue; + + // Cached UIApplication background state. UIKit's applicationState must only be + // read on the main thread, but the decode path needs it on the VTDecoder queue. + // Updated on the main thread via notifications; a stale read here is harmless + // (it only gates metrics collection). + volatile BOOL _appInBackground; // CMTime playTime; // NSTimeInterval previousLinkTime; @@ -178,7 +184,7 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size, _needRequeuing = _queueSize>0; _frameQueue = [FrameQueue sharedInstance]; - [_frameQueue start]; + [_frameQueue startForOwner:self]; [_frameQueue setHighWaterMark:MAX(1, _queueSize)]; [self reinitializeDisplayLayer]; @@ -189,9 +195,29 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size, name:@"ScreenChanged" object:nil]; + // Renderer init runs on the main thread, so reading applicationState here is legal. + // The decode queue reads the cached flag instead (UIKit forbids off-main reads). + _appInBackground = ([UIApplication sharedApplication].applicationState == UIApplicationStateBackground); + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(appDidEnterBackground) + name:UIApplicationDidEnterBackgroundNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(appWillEnterForeground) + name:UIApplicationWillEnterForegroundNotification + object:nil]; + return self; } +- (void)appDidEnterBackground { + _appInBackground = YES; +} + +- (void)appWillEnterForeground { + _appInBackground = NO; +} + # pragma mark DisplayLink vsync callback - (void)setupWithVideoFormat:(int)videoFormat width:(int)videoWidth height:(int)videoHeight frameRate:(int)frameRate fullRange:(BOOL)fullRange request10BitCodec:(BOOL)request10BitCodec @@ -476,7 +502,7 @@ int DrSubmitDecodeUnit(PDECODE_UNIT decodeUnit); // we missed a callback // Log(LOG_W, @"*** slow frametime %.3f ms", frametime * 1000.0); } - if ([[UIApplication sharedApplication] applicationState] != UIApplicationStateBackground) { + if (!self->_appInBackground) { [[ImGuiPlots sharedInstance] observeFloat:PLOT_FRAMETIME value:frametime * 1000.0]; } } @@ -575,7 +601,7 @@ int DrSubmitDecodeUnit(PDECODE_UNIT decodeUnit); - (void)cleanup{ dispatch_async(dispatch_get_main_queue(), ^{ - [self->_frameQueue stop]; + [self->_frameQueue stopForOwner:self]; if (self->_renderingBackend == RENDER_AVSB) { [self->_displayLink invalidate]; @@ -1195,7 +1221,7 @@ int DrSubmitDecodeUnit(PDECODE_UNIT decodeUnit); framesDropped += [self->_frameQueue enqueue:outputFrame withSlackSize:3]; } - if ([[UIApplication sharedApplication] applicationState] != UIApplicationStateBackground) { + if (!self->_appInBackground) { static PlotMetrics frameQueueMetrics = {}; [[ImGuiPlots sharedInstance] observeFloatReturnMetrics:PLOT_QUEUED_FRAMES value:[self->_frameQueue count] plotMetrics:&frameQueueMetrics]; [self safeCopyMetricsTo:&self->_frameQueueMetrics from:&frameQueueMetrics]; @@ -1219,7 +1245,7 @@ int DrSubmitDecodeUnit(PDECODE_UNIT decodeUnit); int framesDropped = [self->_frameQueue enqueue:frame withSlackSize:3]; - if ([[UIApplication sharedApplication] applicationState] != UIApplicationStateBackground) { + if (!self->_appInBackground) { static PlotMetrics frameQueueMetrics = {}; [[ImGuiPlots sharedInstance] observeFloatReturnMetrics:PLOT_QUEUED_FRAMES value:[self->_frameQueue count] plotMetrics:&frameQueueMetrics]; [self safeCopyMetricsTo:&self->_frameQueueMetrics from:&frameQueueMetrics]; diff --git a/VoidLink/Utility/Frame.m b/VoidLink/Utility/Frame.m index f69c25af..c7c34707 100644 --- a/VoidLink/Utility/Frame.m +++ b/VoidLink/Utility/Frame.m @@ -108,7 +108,11 @@ static char FrameInterpolatedKey; - (CFDictionaryRef)getFormatDescExtensions { if (!_formatDescExt && _formatDesc) { - _formatDescExt = CFRetain(CMFormatDescriptionGetExtensions(_formatDesc)); + // Can legitimately be NULL, and CFRetain(NULL) crashes + CFDictionaryRef ext = CMFormatDescriptionGetExtensions(_formatDesc); + if (ext) { + _formatDescExt = CFRetain(ext); + } } return _formatDescExt; } diff --git a/VoidLink/Utility/FrameQueue.h b/VoidLink/Utility/FrameQueue.h index 610d7e0d..d70d8a60 100644 --- a/VoidLink/Utility/FrameQueue.h +++ b/VoidLink/Utility/FrameQueue.h @@ -29,8 +29,8 @@ NS_ASSUME_NONNULL_BEGIN - (CFTimeInterval)estimatedFramerate; - (int)currentSoftCap; - (void)waitForEnqueue; -- (void)start; -- (void)stop; +- (void)startForOwner:(id)owner; +- (void)stopForOwner:(id)owner; @end diff --git a/VoidLink/Utility/FrameQueue.m b/VoidLink/Utility/FrameQueue.m index f59e1281..d47177e6 100644 --- a/VoidLink/Utility/FrameQueue.m +++ b/VoidLink/Utility/FrameQueue.m @@ -24,6 +24,10 @@ 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 { @@ -301,7 +305,14 @@ - (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); } @@ -336,15 +347,31 @@ return cap; } -- (void)stop { +- (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)start { - // (re)start for a new renderer +- (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"); } diff --git a/VoidLink/ViewControllers/SettingsViewController.m b/VoidLink/ViewControllers/SettingsViewController.m index f40f9f24..a7501818 100644 --- a/VoidLink/ViewControllers/SettingsViewController.m +++ b/VoidLink/ViewControllers/SettingsViewController.m @@ -2651,39 +2651,55 @@ BOOL isCustomResolution(int resolutionSelected) { // Check if the rendering backend has actually changed if (previousBackend != sender.selectedSegmentIndex) { - // Show alert to prompt user to restart the app - NSString *message = [LocalizationHelper localizedStringForKey: sender.selectedSegmentIndex == 1 ? @"metalRenderTip" : @"standardRenderTip"]; - - UIAlertController *alertController = [UIAlertController alertControllerWithTitle:[LocalizationHelper localizedStringForKey:@"Restart Required"] - message:message - preferredStyle:UIAlertControllerStyleAlert]; - - UIAlertAction *quitAction = [UIAlertAction actionWithTitle:[LocalizationHelper localizedStringForKey:@"Quit Now"] - style:UIAlertActionStyleDefault - handler:^(UIAlertAction * _Nonnull action) { - + // The renderer is picked up fresh at every stream start, so the change takes + // effect on the next session — no app restart needed. + void (^applyChange)(void) = ^{ Settings* directSettings = [self->dataMan retrieveSettings]; directSettings.renderingBackend = [NSNumber numberWithInteger:sender.selectedSegmentIndex]; [self->dataMan saveData]; [self saveSettings]; - - exit(0); - }]; - - UIAlertAction *laterAction = [UIAlertAction actionWithTitle:[LocalizationHelper localizedStringForKey:@"Learn More"] - style:UIAlertActionStyleCancel - handler:^(UIAlertAction * _Nonnull action) { - self.renderingBackendSelector.selectedSegmentIndex = 0; + }; + + void (^revertChange)(void) = ^{ + self.renderingBackendSelector.selectedSegmentIndex = previousBackend; [self.renderingBackendSelector sendActionsForControlEvents:UIControlEventValueChanged]; - NSURL *url = [NSURL URLWithString:[LocalizationHelper localizedStringForKey:@"betterPerformanceLink"]]; - if ([[UIApplication sharedApplication] canOpenURL:url]) { - [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil]; - } - }]; - - [alertController addAction:laterAction]; - [alertController addAction:quitAction]; - [self presentViewController:alertController animated:YES completion:nil]; + }; + + if (sender.selectedSegmentIndex == RENDER_METAL) { + // Metal is experimental: confirm its caveats before applying + NSString *message = [LocalizationHelper localizedStringForKey:@"metalRenderTip"]; + + UIAlertController *alertController = [UIAlertController alertControllerWithTitle:[LocalizationHelper localizedStringForKey:@"Enable Metal Renderer?"] + message:message + preferredStyle:UIAlertControllerStyleAlert]; + + [alertController addAction:[UIAlertAction actionWithTitle:[LocalizationHelper localizedStringForKey:@"Apply"] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * _Nonnull action) { + applyChange(); + }]]; + + [alertController addAction:[UIAlertAction actionWithTitle:[LocalizationHelper localizedStringForKey:@"Learn More"] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * _Nonnull action) { + revertChange(); + NSURL *url = [NSURL URLWithString:[LocalizationHelper localizedStringForKey:@"betterPerformanceLink"]]; + if ([[UIApplication sharedApplication] canOpenURL:url]) { + [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil]; + } + }]]; + + [alertController addAction:[UIAlertAction actionWithTitle:[LocalizationHelper localizedStringForKey:@"Cancel"] + style:UIAlertActionStyleCancel + handler:^(UIAlertAction * _Nonnull action) { + revertChange(); + }]]; + + [self presentViewController:alertController animated:YES completion:nil]; + } else { + // Switching back to the standard renderer needs no confirmation + applyChange(); + } } } diff --git a/VoidLink/ViewControllers/StreamFrameViewController.m b/VoidLink/ViewControllers/StreamFrameViewController.m index f974420d..d51b0aec 100644 --- a/VoidLink/ViewControllers/StreamFrameViewController.m +++ b/VoidLink/ViewControllers/StreamFrameViewController.m @@ -1244,8 +1244,8 @@ static __weak StreamFrameViewController *VLSharedStreamFrameViewController = nil - (void)willMoveToParentViewController:(UIViewController *)parent { // Only cleanup when we're being destroyed if (parent == nil) { - _streamView = nil; [_streamView cleanUp]; + _streamView = nil; [_controllerSupport cleanup]; [UIApplication sharedApplication].idleTimerDisabled = NO; @@ -1255,6 +1255,10 @@ static __weak StreamFrameViewController *VLSharedStreamFrameViewController = nil _inactivityTimer = nil; } if (self.metalViewController) { + // Explicit shutdown: viewDidDisappear is not guaranteed to fire here + // (e.g. teardown while backgrounded), and it's what stops the render + // thread, display link and renderer. + [self.metalViewController shutdown]; [self.metalViewController.view removeFromSuperview]; [self.metalViewController removeFromParentViewController]; self.metalViewController = nil;