fix(desktop): stop screen recording drops when frontmost is helper app (#6640)#6644
fix(desktop): stop screen recording drops when frontmost is helper app (#6640)#6644
Conversation
…aptureable window Fixes #6640. The active window resolver cached (appName, nil, nil) snapshots when the frontmost app was a helper with no captureable window (LogiPluginService, Dock, UserNotificationCenter, etc.) or when CGWindowList transiently returned nothing. That poisoned lastActiveWindowSnapshot for the full TTL and every subsequent caller saw a nil windowID, silently breaking capture for up to 2s per transition. - Guard the cache write on resolved.windowID != nil so helper-app transitions never overwrite a good snapshot. - When the resolver returns a nil-windowID result and a still-valid cached snapshot with a non-nil windowID exists, return the cached snapshot as a "last known good captureable context" for up to activeWindowCacheTTL. - Rate-limit the fallback log to once per streak instead of once per frame. - Add internal test seams (_resolverOverrideForTests, _seedActiveWindowCacheForTests, _peekActiveWindowCacheForTests, _resetActiveWindowCacheForTests) so the behavior can be exercised deterministically without touching NSWorkspace/CGWindowList. - Add ScreenCaptureResolverCacheTests covering happy path, nil-window fresh/expired/ no-cache, timeout-path fresh/no-cache, and recovery after fallback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR fixes a silent screen recording dropout on macOS when the frontmost application transitions through helper processes (LogiPluginService, Dock, UserNotificationCenter) that have no capturable window. The core change guards cache writes in Confidence Score: 5/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant CF as captureFrame
participant WM as WindowMonitor
participant SCS as ScreenCaptureService
participant Cache as lastActiveWindowSnapshot
participant Resolver as resolveActiveWindowInfoWithTimeout
CF->>WM: getActiveWindowInfoAsync()
WM->>SCS: getActiveWindowInfoAsync()
SCS->>SCS: axStateLock — check isActiveWindowResolutionInFlight
alt resolution already in flight
SCS->>Cache: getCachedActiveWindowSnapshot()
Cache-->>SCS: cached (non-nil wID) or nil
SCS-->>CF: cached or (nil,nil,nil)
else start new resolution
SCS->>Resolver: resolveActiveWindowInfoWithTimeout()
Resolver-->>SCS: (appName, title, windowID?) [may nil on helper app]
alt "windowID != nil (real window)"
SCS->>Cache: write snapshot (appName, title, windowID)
SCS->>SCS: "isInNilWindowFallbackStreak = false"
SCS-->>CF: (appName, title, windowID)
else "windowID == nil (helper app or timeout)"
SCS->>Cache: getCachedActiveWindowSnapshot() — TTL check
alt cache fresh (within 2s)
SCS->>SCS: rate-limit log (once per streak)
SCS-->>CF: (cachedAppName, cachedTitle, cachedWindowID)
else cache expired or empty
SCS-->>CF: raw resolver result or (nil,nil,nil)
end
end
end
CF->>CF: captureWindowCGImage(windowID)
alt windowGone
CF->>SCS: captureActiveWindowCGImage() [re-resolves]
end
|
| if let resolved { | ||
| // Nil-windowID resolution with no usable cache — return the raw result so | ||
| // callers can apply their own exclusion/metadata handling. | ||
| return resolved | ||
| } | ||
|
|
||
| log("ScreenCaptureService: Active window lookup timed out with no cached fallback") | ||
| return (nil, nil, nil) |
There was a problem hiding this comment.
isInNilWindowFallbackStreak not reset on the expired-cache / no-cache path
When the resolver returns a nil-windowID result and the cache is either expired or absent, execution falls through to return resolved / return (nil,nil,nil) without ever resetting isInNilWindowFallbackStreak. If a prior call left the flag true (e.g. a successful fallback streak while the cache was still fresh), and the cache then expires while the helper app is still frontmost, the very first log for the next fresh-cache fallback streak will be suppressed — the rate-limiter already thinks it's mid-streak.
This is a cosmetic logging issue only (not a correctness bug), but it's worth resetting the flag when no fallback is actually taken so the streak tracks only active fallback windows:
// No usable cache and resolver returned a nil-windowID result —
// return raw result and clear any stale streak state.
if let resolved {
axStateLock.withLock { isInNilWindowFallbackStreak = false }
return resolved
}
axStateLock.withLock { isInNilWindowFallbackStreak = false }
log("ScreenCaptureService: Active window lookup timed out with no cached fallback")
return (nil, nil, nil)…ster feedback Adds P7/P8 coverage requested by the omi-pr-review-tester: - testNilWindowFallbackStreakIsSetOnFirstFallbackAndIdempotent - testSuccessfulResolutionClearsNilWindowFallbackStreak - testResetActiveWindowCacheClearsAllState Exposes _peekNilWindowFallbackStreakForTests, _peekIsResolutionInFlightForTests, _forceResolutionInFlightForTests, and _forceNilWindowFallbackStreakForTests as internal @testable helpers so the streak flag and in-flight flag can be asserted deterministically without refactoring the service. All 10 tests now pass via xcrun swift test --filter ScreenCaptureResolverCacheTests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Live Test Evidence — CP9A (L1) + CP9B (L2)Test bundle:
Changed-path coverage checklistAll 8 changed executable paths in
L1 synthesisAll 8 changed paths (P1–P8) exercised against the built binary. Build succeeded in 17.77s, app launched to pid 67924, all 10 unit tests passed (0 failures), and both fallback branches fired in the runtime log from the same compiled ScreenCaptureService class that the live app loads. No untested paths. L2 synthesisApp connected to prod backend ( by AI for @beastoin |
Summary
Fixes #6640 — the macOS desktop app silently stopped screen recording after transitions through helper apps that have no captureable window (LogiPluginService, Dock, UserNotificationCenter). This was the dominant failure mode in the reported user log (73× "No active window ID found" vs. 11× audio/video capture failure).
Root cause
ScreenCaptureService.getActiveWindowInfoAsync()unconditionally wrote the resolver's result intolastActiveWindowSnapshot, including(appName, nil, nil)tuples. When the frontmost app was a helper with no captureable window, every read for the nextactiveWindowCacheTTL(2s) returned a nilwindowID,captureActiveWindowAsync()hitguard let windowIDand logged"No active window ID found", and the in-flight capture dropped without advancing any recovery path.Fix
resolved.windowID != nilso helper-app transitions never overwrite a good snapshot.activeWindowCacheTTLwhen the resolver returns a nil-windowID result and a still-valid cached snapshot exists. This preserves recording across brief transitions through LogiPluginService / Dock / Notification Center._resolverOverrideForTests,_seedActiveWindowCacheForTests,_peekActiveWindowCacheForTests,_resetActiveWindowCacheForTests— small@testablehelpers so the behavior can be exercised deterministically without touchingNSWorkspace/CGWindowListCopyWindowInfo.ProactiveAssistantsPlugin.captureFrameandScreenCaptureServicecache block to reflect the new "last captureable context" policy.This is a deliberate policy change, not a pure cache-write fix: for up to 2s after the frontmost becomes a helper app,
captureFramewill consume the previous app'sappName/windowTitle/windowIDrather than the literal current frontmost. That is acceptable here because:captureFrameonly usewindowID.captureFramereaders (exclusion check, video-call throttle, context-switch detection, metadata) should behave as if the user were still on the last captureable window during transient helper-app gaps.What this does NOT touch
captureFrameinProactiveAssistantsPlugin(Nik's recent.success/.windowGone/.failedfix in 187e022 / ffb545c).handleRepeatedCaptureFailurespermission re-test logic.isMonitoringstaying true through recovery — separate issue for a future PR).Test plan
Unit tests (new file:
desktop/Desktop/Tests/ScreenCaptureResolverCacheTests.swift, 7 cases, all passing):testSuccessfulResolutionOverwritesCachetestNilWindowIDResolutionDoesNotPoisonFreshCachetestNilWindowIDResolutionWithNoCacheReturnsResolvedAsIstestExpiredCacheIsNotReusedForNilWindowResolutiontestTimeoutPathWithFreshCacheStillFallsBacktestTimeoutPathWithNoCacheReturnsAllNiltestSuccessfulResolutionAfterFallbackOverwritesCacheRun (pre-existing unrelated test target errors in
DateValidationTests.swift,FloatingBarVoiceResponseSettingsTests.swift,SubscriptionPlanCatalogMergerTests.swiftblockswift teston main — those are not introduced by this PR and I validated my tests in isolation by temporarily disabling the broken files):Compile check: full package builds clean via
xcrun swift build -c debug --package-path Desktop.Live test (CP9A / CP9B): to follow in this PR — will build
fix-screen-cap-6640named bundle, run standalone, and verify the fallback path is taken when frontmost is a helper app.Risks / edge cases
appName/windowTitleduring helper-app transitions: consumed bycaptureFrameexclusion check, video-call throttle, and context-switch detection. Acceptable: all of those should treat the last captureable window as current during brief transient gaps. Bounded by 2s TTL.captureWindowCGImage(windowID:)will return.windowGone, andcaptureFramewill fall through tocaptureActiveWindowCGImage()which re-resolves. Under the 1s capture interval and 2s cache TTL this edge is bounded and does not hit the 5-failure threshold before the cache expires (confirmed via Codex review).Checklist
Fixes #6640