diff --git a/.changeset/c9b81060.md b/.changeset/c9b81060.md new file mode 100644 index 0000000..a7da197 --- /dev/null +++ b/.changeset/c9b81060.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Fix a crash on the overlay's first open on macOS 26 when several windows are visible. The capture-setup calls used by each tile are now serialized so the framework no longer sees overlapping inits. diff --git a/Sources/cmdcmd/StressTest.swift b/Sources/cmdcmd/StressTest.swift new file mode 100644 index 0000000..648baff --- /dev/null +++ b/Sources/cmdcmd/StressTest.swift @@ -0,0 +1,95 @@ +// Debug-only stress harness for the SCContentFilter / SCStream init race +// behind issue #18. Run with: cmdcmd --stress [--serialize] [--iterations N]. +// Mirrors the concurrent setup pattern from Tile.start() (and fans it out +// 4x with a parallel SCShareableContent refresher). Could not reproduce the +// crash on macOS 15.7.4 / M4 Pro; intended for confirmation on macOS 26. +import Foundation +import ScreenCaptureKit +import CoreVideo +import CoreMedia + +final class StressTarget: NSObject, SCStreamDelegate, SCStreamOutput { + func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {} +} + +enum StressTest { + private static let setupQueue = DispatchQueue(label: "stress.setup") + private static let sampleQueue = DispatchQueue(label: "stress.sample") + + static func run(serialize: Bool, iterations: Int) async { + print("StressTest: serialize=\(serialize) iterations=\(iterations)") + fflush(stdout) + + let prewarm = Task.detached(priority: .utility) { + while !Task.isCancelled { + _ = try? await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true) + } + } + + for i in 0..= 200 && $0.frame.height >= 200 + } + } catch { + print("iter \(i) content fetch failed: \(error)") + continue + } + + await withTaskGroup(of: Void.self) { group in + // Fan out to 4x the natural concurrency to widen the race window. + for _ in 0..<4 { + for w in windows { + group.addTask { + await runOne(window: w, serialize: serialize) + } + } + } + } + if i % 10 == 0 { print("iter \(i) windows=\(windows.count)"); fflush(stdout) } + } + + prewarm.cancel() + print("StressTest survived \(iterations) iterations") + fflush(stdout) + exit(0) + } + + private static func runOne(window: SCWindow, serialize: Bool) async { + let filter: SCContentFilter + if serialize { + filter = await withCheckedContinuation { cont in + setupQueue.async { + cont.resume(returning: SCContentFilter(desktopIndependentWindow: window)) + } + } + } else { + filter = SCContentFilter(desktopIndependentWindow: window) + } + + let config = SCStreamConfiguration() + config.width = 512 + config.height = 512 + config.pixelFormat = kCVPixelFormatType_32BGRA + config.minimumFrameInterval = CMTime(value: 1, timescale: 30) + config.queueDepth = 3 + + let target = StressTarget() + let stream: SCStream + if serialize { + stream = await withCheckedContinuation { cont in + setupQueue.async { + cont.resume(returning: SCStream(filter: filter, configuration: config, delegate: target)) + } + } + } else { + stream = SCStream(filter: filter, configuration: config, delegate: target) + } + try? stream.addStreamOutput(target, type: .screen, sampleHandlerQueue: sampleQueue) + try? await stream.startCapture() + try? await stream.stopCapture() + } +} diff --git a/Sources/cmdcmd/Tile.swift b/Sources/cmdcmd/Tile.swift index d1b9fa6..5b13429 100644 --- a/Sources/cmdcmd/Tile.swift +++ b/Sources/cmdcmd/Tile.swift @@ -15,6 +15,38 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { private static let ciContext = CIContext(options: [.useSoftwareRenderer: false]) private static let cacheQueue = DispatchQueue(label: "cmdcmd.tile.cache", qos: .utility) + // SCContentFilter / SCStream init have crashed (objc_retain of an + // SCWindow during concurrent copyWithZone) when tiles install in + // parallel. Serialize the synchronous setup; async work still overlaps. + private static let setupQueue = DispatchQueue(label: "cmdcmd.tile.setup", qos: .userInteractive) + + private static func makeFilter(for window: SCWindow) async -> SCContentFilter { + await withCheckedContinuation { cont in + setupQueue.async { + cont.resume(returning: SCContentFilter(desktopIndependentWindow: window)) + } + } + } + + private static func makeStream( + filter: SCContentFilter, + configuration: SCStreamConfiguration, + output: Tile, + sampleHandlerQueue: DispatchQueue + ) async throws -> SCStream { + try await withCheckedThrowingContinuation { cont in + setupQueue.async { + let stream = SCStream(filter: filter, configuration: configuration, delegate: output) + do { + try stream.addStreamOutput(output, type: .screen, sampleHandlerQueue: sampleHandlerQueue) + cont.resume(returning: stream) + } catch { + cont.resume(throwing: error) + } + } + } + } + static func cachedFrame(for id: CGWindowID) -> CGImage? { cacheLock.lock(); defer { cacheLock.unlock() } return frameCache[id] @@ -409,7 +441,8 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { func snapshot() async { if cancelled || hasRenderedLiveFrame { return } - let filter = SCContentFilter(desktopIndependentWindow: scWindow) + let filter = await Tile.makeFilter(for: scWindow) + if cancelled || hasRenderedLiveFrame { return } let config = captureConfig(maxDim: currentThumbMaxDim()) do { let image = try await captureImageSafely(filter: filter, config: config) @@ -431,14 +464,15 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { func start() async { if cancelled { return } - let filter = SCContentFilter(desktopIndependentWindow: scWindow) + let filter = await Tile.makeFilter(for: scWindow) + if cancelled { return } let config = captureConfig() config.minimumFrameInterval = CMTime(value: 1, timescale: 30) config.queueDepth = 3 do { - let s = SCStream(filter: filter, configuration: config, delegate: self) - try s.addStreamOutput(self, type: .screen, sampleHandlerQueue: queue) + let s = try await Tile.makeStream(filter: filter, configuration: config, output: self, sampleHandlerQueue: queue) + if cancelled { return } try await s.startCapture() if cancelled { try? await s.stopCapture() diff --git a/Sources/cmdcmd/main.swift b/Sources/cmdcmd/main.swift index 2d4b640..6fe62bd 100644 --- a/Sources/cmdcmd/main.swift +++ b/Sources/cmdcmd/main.swift @@ -17,6 +17,22 @@ if let i = args.firstIndex(of: "--render-iconset"), i + 1 < args.count { let app = NSApplication.shared +if args.contains("--stress") { + NSApp.setActivationPolicy(.accessory) + app.finishLaunching() + let serialize = args.contains("--serialize") + let iterations: Int = { + if let i = args.firstIndex(of: "--iterations"), i + 1 < args.count, let n = Int(args[i + 1]) { + return n + } + return 500 + }() + Task.detached(priority: .userInitiated) { + await StressTest.run(serialize: serialize, iterations: iterations) + } + RunLoop.main.run() +} + final class AppDelegate: NSObject, NSApplicationDelegate { let updaterController = SPUStandardUpdaterController( startingUpdater: true,