Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/c9b81060.md
Original file line number Diff line number Diff line change
@@ -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.
95 changes: 95 additions & 0 deletions Sources/cmdcmd/StressTest.swift
Original file line number Diff line number Diff line change
@@ -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..<iterations {
let windows: [SCWindow]
do {
let content = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true)
windows = content.windows.filter {
$0.owningApplication != nil &&
$0.frame.width >= 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()
}
}
42 changes: 38 additions & 4 deletions Sources/cmdcmd/Tile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down
16 changes: 16 additions & 0 deletions Sources/cmdcmd/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading