From 22aa12d047a3c9558d0dde5a00c84474a3df9eab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 22 Oct 2026 23:02:36 +0200 Subject: [PATCH] iOS: Keep the TV's layer until its output's removal has been handled The add request holds the layer unretained, and the swapchain only takes a reference once it's handled. If the TV disconnected (or the setting changed) before that, the window and the layer were freed with the add still queued. The view controller now keeps a reference for the requests, released after Native_RemoveOutputAndWait, off the main thread. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/ExternalDisplaySceneDelegate.mm | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/ios/ExternalDisplaySceneDelegate.mm b/ios/ExternalDisplaySceneDelegate.mm index 9d78203c8f..6dcdea29b5 100644 --- a/ios/ExternalDisplaySceneDelegate.mm +++ b/ios/ExternalDisplaySceneDelegate.mm @@ -42,6 +42,8 @@ static bool g_launched = false; // Adds the view's layer as a Game output once it has a size, and keeps the output's size up to date. @interface ExternalDisplayViewController : UIViewController @property (nonatomic) int outputId; +// A reference to the layer for the output's requests, which hold it unretained. Released once it's removed. +@property (nonatomic) void *retainedLayer; @end @implementation ExternalDisplayViewController { @@ -73,7 +75,8 @@ static bool g_launched = false; } Draw::SwapchainDesc desc{}; desc.winsys = WINDOWSYSTEM_METAL_EXT; - desc.data1 = (__bridge void *)self.view.layer; + self.retainedLayer = (void *)CFBridgingRetain(self.view.layer); + desc.data1 = self.retainedLayer; desc.width = width; desc.height = height; desc.tag = "ExternalDisplay"; @@ -137,8 +140,16 @@ static bool g_launched = false; } ExternalDisplayViewController *viewController = (ExternalDisplayViewController *)self.window.rootViewController; if (viewController.outputId) { - // The swapchain keeps the layer alive until the removal has gone through. - Native_RemoveOutput(viewController.outputId); + // The window and its layer go now, but the add may still be queued, so our reference to the layer has to + // stay until the removal has been handled. Waited for off the main thread, which the render thread may + // be waiting on. If that times out, the layer is kept, as the add may still come. + const int outputId = viewController.outputId; + void *layer = viewController.retainedLayer; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ + if (Native_RemoveOutputAndWait(outputId)) { + CFRelease(layer); + } + }); } self.window = nil; }