fix(capture): honor stop during watch transient backoff (#193)

* fix(capture): honor stop during watch transient backoff

* test(capture): tighten transient stop proof and add proof script

* test(capture): tighten transient stop regression

* chore: complete main merge

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Sebastien Tardif 2026-06-23 16:09:05 -07:00 committed by GitHub
parent 1771d7db34
commit db5192bb37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 136 additions and 1 deletions

View File

@ -3,6 +3,7 @@
## [3.5.3] - 2026-06-13
### Fixed
- Watch captures now honor stop requests during transient ScreenCaptureKit retry backoff instead of waiting out the full delay. Thanks @SebTardif for #193.
- Peekaboo agent skill install and usage guidance now uses the current `skills/peekaboo` path, treats observed element IDs as opaque, and keeps screenshot artifacts in explicit temporary paths. Thanks @coygeek for #197.
- `peekaboo app list` now excludes accessory/background processes by default, while `--include-background` restores them as documented.
- Menu-extra clicks now reject items parked outside active displays by menu bar managers instead of moving the pointer to offscreen coordinates and reporting false success.

View File

@ -77,7 +77,8 @@ extension WatchCaptureSession {
}
// SCK can report a temporary TCC denial while another CLI capture is settling.
// Treat that as a dropped live frame; the next sample or fallback frame can recover.
try await Task.sleep(nanoseconds: delay)
let retryStart = Date()
try await self.sleep(ns: delay, since: retryStart)
continue
}
throw error

View File

@ -298,6 +298,70 @@ struct WatchCaptureSessionTests {
#expect(Date().timeIntervalSince(stopStarted) < 1)
}
@Test
@MainActor
func `Stop request wakes transient capture backoff`() async throws {
let png = Self.makePNG(size: CGSize(width: 20, height: 20))
let capture = StubTransientScreenCaptureService(result: png, size: CGSize(width: 20, height: 20))
let screens = StubScreenService()
let output = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
.appendingPathComponent("watch-transient-stop-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: output) }
let options = WatchCaptureOptions(
duration: 30,
idleFps: 5,
activeFps: 5,
changeThresholdPercent: 100,
heartbeatSeconds: 0,
quietMsToIdle: 0,
maxFrames: 10,
maxMegabytes: nil,
highlightChanges: false,
captureFocus: .auto,
resolutionCap: nil,
diffStrategy: .fast,
diffBudgetMs: nil)
let session = WatchCaptureSession(
dependencies: WatchCaptureDependencies(screenCapture: capture, screenService: screens),
configuration: WatchCaptureConfiguration(
scope: WatchScope(kind: .frontmost),
options: options,
outputRoot: output,
autoclean: WatchAutocleanConfig(minutes: 1, managed: false)))
let transientFailure = AsyncStream<Void>.makeStream()
capture.onTransientFailure = {
transientFailure.continuation.yield()
}
let task = Task { @MainActor in
try await session.run()
}
var sawTransientFailure = false
for await _ in transientFailure.stream {
sawTransientFailure = true
break
}
#expect(sawTransientFailure)
#expect(capture.attemptCount >= 1)
// Ensure requestStop() lands inside the 350ms transient backoff window.
try await Task.sleep(nanoseconds: 15_000_000)
let stopStarted = Date()
session.requestStop()
let result = try await task.value
let stopElapsed = Date().timeIntervalSince(stopStarted)
print("PROOF transient_stop_elapsed_ms=\(Int(stopElapsed * 1000))")
#expect(result.warnings.contains { $0.code == .transientCaptureFailure })
// Unfixed raw Task.sleep still waits ~350ms before the loop can observe stop.
#expect(stopElapsed < 0.08)
}
@Test
@MainActor
func `Task cancellation wakes cadence sleep`() async throws {
@ -469,6 +533,75 @@ struct WatchCaptureSessionTests {
// MARK: - Stubs
@MainActor
private final class StubTransientScreenCaptureService: ScreenCaptureServiceProtocol {
private let success: StubScreenCaptureService
private(set) var attemptCount = 0
var onTransientFailure: (() -> Void)?
private static let transientError = NSError(
domain: "com.apple.ScreenCaptureKit.SCStreamErrorDomain",
code: -3801,
userInfo: [
NSLocalizedDescriptionKey: "The user declined TCCs for application, window, display capture",
])
init(result: Data, size: CGSize) {
self.success = StubScreenCaptureService(result: result, size: size)
}
func captureScreen(
displayIndex _: Int?,
visualizerMode _: CaptureVisualizerMode,
scale _: CaptureScalePreference) async throws -> CaptureResult
{
throw Self.transientError
}
func captureWindow(
appIdentifier _: String,
windowIndex _: Int?,
visualizerMode _: CaptureVisualizerMode,
scale _: CaptureScalePreference) async throws -> CaptureResult
{
throw Self.transientError
}
func captureWindow(
windowID _: CGWindowID,
visualizerMode _: CaptureVisualizerMode,
scale _: CaptureScalePreference) async throws -> CaptureResult
{
throw Self.transientError
}
func captureFrontmost(
visualizerMode: CaptureVisualizerMode,
scale: CaptureScalePreference) async throws -> CaptureResult
{
self.attemptCount += 1
if self.attemptCount == 1 {
return try await self.success.captureFrontmost(
visualizerMode: visualizerMode,
scale: scale)
}
self.onTransientFailure?()
throw Self.transientError
}
func captureArea(
_: CGRect,
visualizerMode _: CaptureVisualizerMode,
scale _: CaptureScalePreference) async throws -> CaptureResult
{
throw Self.transientError
}
func hasScreenRecordingPermission() async -> Bool {
true
}
}
@MainActor
private final class StubScreenCaptureService: ScreenCaptureServiceProtocol {
private let resultData: Data