Skip to content

cua-driver: focus-suppression hardening (LIFO + per-entry deadline + observability proof) - #1539

Open
hoang17 wants to merge 2 commits into
trycua:mainfrom
hoang17:fix/focus-suppression-hardening
Open

cua-driver: focus-suppression hardening (LIFO + per-entry deadline + observability proof)#1539
hoang17 wants to merge 2 commits into
trycua:mainfrom
hoang17:fix/focus-suppression-hardening

Conversation

@hoang17

@hoang17 hoang17 commented May 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #1521 (the four-layer focus-handle leak fix that landed in v0.2.0). On self-review I caught five remaining gaps in the Swift implementation; this PR closes them while keeping the API surface fully backwards compatible.

# Gap Fix
1 Wildcard-vs-wildcard ambiguity — entries.values.first picked match by undefined dictionary-iteration order Strictly-increasing sequence per entry; activation matching uses max(by: sequence) so latest intent wins (LIFO)
2 No way to opt out of the 5 s deadline for legitimately long suppressions Optional maxLifetimeOverrideNs parameter on withSuppression / leaseSuppression / beginSuppression
3 _forceReapForTesting() was public — leading underscore is a hint, not a wall Tightened to internal; tests already use @testable import CuaDriverCore
4 Layer 4 (os.Logger warnings) was claimed but never automatically verified New e2e test that triggers the warning, shells out to log show --predicate 'subsystem == "io.trycua.cua-driver"', asserts the message + origin tag appear
5 The lazy-janitor / add() race (#8 from self-review) was never explained Multi-paragraph doc comment on startJanitorIfNeeded() showing the three self-healing paths that make the race correctness-neutral

Also discovered along the way: log show --start rejects ISO-8601 with timezone and silently produces empty output — wants YYYY-MM-DD HH:MM:SS local-time. The Layer-4 test had to work around this; documented inline so the next person who reaches for ISO8601DateFormatter doesn't lose an hour.

Why these matter

Gap 1 is the biggest. During the LaunchAppTool crossfade (testCrossfadeOfTwoLeasesHasNoSuppressionGap in #1521) two leases coexist by design — placeholder wildcard + pid-specific entry — and both match the target's activation. With dictionary-order the dispatcher chose one of two restoreTo apps non-deterministically. Same bug shape as the original v0.1.9 leak: latent because both choices happen to be priorFrontmost in the LaunchAppTool case, but a third caller layering on top would have introduced visible flakiness.

Gap 2 matters because the layer-3 deadline is a leak ceiling, not a runtime budget. Tightening it to ≤5 s globally would force-evict legitimate 6 s launch handoffs mid-flight; loosening it globally would weaken the leak ceiling. Per-entry override threads the needle.

Gap 4 matters because Layer 4 is the advertised contract — "future leak warnings will surface in log show" was stated in the original PR description but never proved. A subsystem-string typo or os.Logger init regression would silently disable the safety net's diagnostics. The test runs in <2 s, gated behind LayerFourLogVerify=1 so CI sandboxes that lack unified-log capture skip cleanly. Verified locally on macOS 14 — the contract holds end-to-end.

Test coverage

41/41 pass; 1 skipped (the Layer-4 e2e is opt-in for CI). Total runtime <2 s. Net delta: +4 tests + 1 opt-in test.

  • testWildcardLifoTiebreakBySequence — two concurrent wildcards coexist; structural prerequisite for LIFO is validated.
  • testMaxLifetimeOverrideHonored — entry survives a reap pass after the dispatcher default but before the per-entry override.
  • testWithSuppressionOverrideKeepsEntryAlive — override propagates through the closure-scoped API, not just leaseSuppression.
  • testStructHoldingLeaseReleasesOnDrop — models the WindowChangeDetector.Snapshot / ClickTool early-return pattern directly: struct holds a SuppressionLease, gets copied + dropped without explicit release, must still see the entry evicted via lease deinit. Catches the case where someone copies the snapshot/detect pattern to a new tool and forgets to call detect.
  • testLayerFourLoggerSurfacesInUnifiedLog (opt-in) — e2e proof of Layer 4.

swift build clean. Docstring coverage stays at 100% on the touched file.

Backwards compatibility

API addition only. The new maxLifetimeOverrideNs parameter is optional with a nil default that preserves prior behaviour. The LIFO tiebreak is internal to the dispatcher and only changes behaviour in the previously-ambiguous multi-match case (which was non-deterministic by spec). Visibility tightening on _forceReapForTesting is source-compatible because no public Swift caller could plausibly have been using a function tagged "for testing".

Follow-up: Rust port has the same gap

While reviewing for parity I noticed libs/cua-driver-rs/crates/platform-macos/src/focus_steal.rs::handle_activation has a structurally matching wildcard-vs-wildcard gap — snapshot_matches returns all matching pids and the activation loop calls restore_focus(pid) once per match, so the last activate() wins by chance. Same root cause, same fix. Happy to open a paired PR against libs/cua-driver-rs/ once this one lands so the two implementations stay in lockstep — let me know if you'd prefer a draft now or after merge.

Summary by CodeRabbit

  • New Features

    • Added optional lifetime override parameter to focus-suppression registration APIs for greater control over suppression duration.
  • Bug Fixes

    • Resolved focus-suppression ambiguity by implementing deterministic LIFO selection when multiple suppressions overlap.
    • Improved focus-suppression diagnostic logging in unified system logs for better troubleshooting and debugging.

Review Change Stack

…observability proof)

Follow-up to trycua#1521 closing the remaining gaps surfaced in self-review:

1. **Wildcard-vs-wildcard LIFO tiebreak.** When multiple entries
   matched the same NSWorkspace activation (two overlapping wildcards
   from different callers, or wildcard + pid-specific during the
   LaunchAppTool crossfade), `entries.values.first` picked one by
   dictionary-iteration order — undefined for `[UUID: Entry]`. The
   chosen `restoreTo` was effectively random. Now every entry stamps
   a strictly-increasing sequence at insertion; activation matching
   selects `max(by: sequence)` so the most-recently-registered intent
   wins. Models the conceptual "stack of suppression intentions"
   correctly: caller B layered on top of caller A overrides A for
   the duration of the overlap.

2. **Per-entry maxLifetimeOverrideNs.** The 5s default deadline is
   the layer-3 leak ceiling, not the expected runtime. Callers whose
   suppression window legitimately exceeds 5s (e.g. multi-second
   launch handoffs that wait for a network-mounted bundle) had no
   path other than disabling the safety net process-wide. Added
   optional override parameter to withSuppression / leaseSuppression
   / beginSuppression — the dispatcher uses
   `maxLifetimeOverrideNs ?? maxLifetimeNs` so existing callers are
   unaffected and new callers can pin a tighter or looser bound per
   entry.

3. **Tightened _forceReapForTesting() to internal.** Was public for
   the test seam — leading underscore was a convention hint, not a
   wall. Switched to internal with `@testable import CuaDriverCore`
   (already how the test target works) so production callers
   genuinely cannot reach it.

4. **End-to-end Layer-4 verification.** Added
   testLayerFourLoggerSurfacesInUnifiedLog: triggers the leak-
   suspicion warning, then shells out to `log show --predicate
   'subsystem == "io.trycua.cua-driver"'` and asserts the message
   appears with the expected origin tag. Catches subsystem-string
   typos, os.Logger init failures, and future SDK regressions that
   silently drop logs from unsigned helpers. Gated behind
   `LayerFourLogVerify=1` so CI sandboxes without unified-log
   privilege skip cleanly. Verified locally on macOS 14 — the
   contract holds.

   Also discovered + worked around a real `log show` gotcha: the
   `--start` flag rejects ISO-8601 with timezone, wants
   `YYYY-MM-DD HH:MM:SS` local-time. The earlier ISO formatter
   silently produced empty output. Documented inline.

5. **Documented trycua#8 (lazy-janitor / add() race) explicitly.** The
   race is correctness-neutral: even with no janitor, the activation
   observer's reapExpired() call still evicts on every OS focus
   change; layer 3's deadline keeps the worst-case bounded; and
   add() always restarts the janitor. Added a multi-paragraph doc
   comment to startJanitorIfNeeded() explaining the three self-
   healing paths so future readers don't reach for a permanent
   background task as a "safer" alternative.

Test coverage: +4 new tests (14 total before, 18 after).
- testWildcardLifoTiebreakBySequence — two concurrent wildcards
  coexist; structural prerequisite for LIFO is validated.
- testMaxLifetimeOverrideHonored — entry survives a reap pass after
  the dispatcher default deadline but before the override.
- testWithSuppressionOverrideKeepsEntryAlive — the override propagates
  through the closure-scoped API, not just leaseSuppression.
- testStructHoldingLeaseReleasesOnDrop — models the
  WindowChangeDetector.Snapshot/ClickTool early-return pattern
  directly: a struct holding a SuppressionLease, copied + dropped,
  must still see the entry evicted via lease deinit.
- testLayerFourLoggerSurfacesInUnifiedLog (skipped on CI by
  default) — the e2e proof for layer 4.

Docstring coverage on the touched file remains 100%. swift build
clean. swift test green: 41/41 (1 skipped — opt-in layer-4 e2e).

API addition only — fully backwards compatible. The new parameters
are optional with `nil` defaults; the LIFO tiebreak is internal to
the dispatcher and only changes behaviour in the previously-
ambiguous case.

Note: the Rust port (libs/cua-driver-rs/) has a structurally
matching wildcard-vs-wildcard gap in
`platform-macos/src/focus_steal.rs::handle_activation` —
`snapshot_matches` returns all matching pids and the activation
loop calls `restore_focus(pid)` for each. Same root cause, same
fix. Happy to put up a paired Rust PR once this lands.
@vercel

vercel Bot commented May 17, 2026

Copy link
Copy Markdown
Contributor

@hoang17 is attempting to deploy a commit to the Cua Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented May 17, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bdac0f24-2b17-44b4-b1d2-2e4e5cf07bd5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR hardens focus-suppression behavior in cua-driver by introducing per-entry lifetime overrides and deterministic LIFO tie-breaking via sequence counters when multiple wildcard suppressions match a single activation, tightens testing API visibility, and adds comprehensive test coverage including end-to-end unified-log diagnostics.

Changes

Focus Suppression Hardening

Layer / File(s) Summary
Public API Expansion for Lifetime Override and Visibility Tightening
libs/cua-driver/Sources/CuaDriverCore/Focus/SystemFocusStealPreventer.swift
withSuppression, leaseSuppression, and beginSuppression now accept an optional maxLifetimeOverrideNs parameter with nil defaults, and _forceReapForTesting() visibility is tightened from public to internal.
Dispatcher Data Model and Sequence Counter Initialization
libs/cua-driver/Sources/CuaDriverCore/Focus/SystemFocusStealPreventer.swift
Entry struct adds a monotonic sequence field and maxLifetimeOverrideNs field; dispatcher initializes a sequenceCounter to generate unique monotonically-increasing sequence values for LIFO tie-breaking.
Entry Registration with Deadline Computation and Sequence Assignment
libs/cua-driver/Sources/CuaDriverCore/Focus/SystemFocusStealPreventer.swift
The add() registration method accepts maxLifetimeOverrideNs, computes entry deadline from the override or dispatcher default, assigns the current sequenceCounter value to Entry.sequence, and increments the counter. Expanded janitor documentation describes race-scenario self-healing behavior.
LIFO Activation Selection via Sequence-Based Tie-Breaking
libs/cua-driver/Sources/CuaDriverCore/Focus/SystemFocusStealPreventer.swift
handleActivation reworked to select restoreTo via LIFO tie-break: among entries matching an activation, the entry with the highest sequence (most recently registered) is chosen, replacing prior candidate-ordering behavior.
Behavioral Tests for LIFO, Lifetime Override, and ARC Cleanup
libs/cua-driver/Tests/FocusStealPreventerTests/FocusStealPreventerTests.swift
New tests verify LIFO tie-breaking when multiple wildcard suppressions match, maxLifetimeOverrideNs extends entry lifetime beyond dispatcher default, withSuppression respects override during body scope, and struct deinit cleanup releases SuppressionLease without explicit release() call.
End-to-End Unified Log Diagnostic Test
libs/cua-driver/Tests/FocusStealPreventerTests/FocusStealPreventerTests.swift
Conditionally-executed test (via LayerFourLogVerify environment variable) creates suppression leases, waits for log flushing, spawns log show with subsystem predicate and time cutoff, and asserts expected warning marker and origin appear in unified logs for io.trycua.cua-driver.
Changelog Entry
changelog/2026-05-17.md
Changelog documents the focus-suppression hardening, LIFO tie-breaking via sequence counter, optional maxLifetimeOverrideNs parameter, _forceReapForTesting() visibility tightening, unified-log diagnostic test, and notes backwards compatibility.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • trycua/cua#1521: Introduced SuppressionLease, deadline-based eviction, and logger diagnostics in the same SystemFocusStealPreventer dispatcher flow that this PR builds upon with LIFO tie-breaking and lifetime overrides.

Poem

🐇 A sequence grows tall, like carrots in a row,
Each lease gets a number to help tie-breaks flow,
When wildcards collide in the focus-suppress dance,
LIFO picks newest—the latest gets the chance!
With overrides set, and logs shining bright,
The hardenest driver now works just right.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the three main changes: LIFO tie-breaking for wildcard ambiguity, per-entry deadline override support, and observability improvements via unified log testing.
Docstring Coverage ✅ Passed Docstring coverage is 92.31% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@libs/cua-driver/Tests/FocusStealPreventerTests/FocusStealPreventerTests.swift`:
- Around line 311-350: The test testWildcardLifoTiebreakBySequence only checks
coexistence/removal but never asserts which wildcard wins; update it so the two
leases have distinguishable restoreTo values (e.g., selfAppA vs selfAppB) and
then invoke the selection path (call the public method that triggers winner
selection — e.g., SystemFocusStealPreventer.handleActivation or add a short
test-only seam like simulateActivation/chooseWinner that returns the chosen
lease) and assert the chosen restoreTo is the later lease (LIFO: the second
one). If handleActivation is not accessible, add a minimal test seam on
SystemFocusStealPreventer that exposes the dispatcher’s match/selection (e.g.,
chooseWinner(forTarget:)) and use that in the test to assert the second lease is
selected.
- Around line 493-584: The test testLayerFourLoggerSurfacesInUnifiedLog
currently runs `/usr/bin/log` and ignores its exit status; after
process.waitUntilExit() inspect process.terminationStatus (and/or detect known
error text in output like privilege/parse errors) and if the command failed due
to unsupported unified-log capture or lack of privileges call XCTSkip with an
explanatory message instead of proceeding to assertions; update the block after
process.waitUntilExit() that reads pipe/fileHandleForReading to perform this
check and skip, so the subsequent XCTAssertTrue checks only run when log show
succeeded.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 12885f63-438f-451e-95b3-682e0b7cc188

📥 Commits

Reviewing files that changed from the base of the PR and between d7e89b3 and d822a41.

📒 Files selected for processing (3)
  • changelog/2026-05-17.md
  • libs/cua-driver/Sources/CuaDriverCore/Focus/SystemFocusStealPreventer.swift
  • libs/cua-driver/Tests/FocusStealPreventerTests/FocusStealPreventerTests.swift

Comment thread libs/cua-driver/Tests/FocusStealPreventerTests/FocusStealPreventerTests.swift Outdated
…test + graceful Layer-4 skip

Two correct CodeRabbit findings, both fixed at the contract level:

1. **LIFO test only verified coexistence, not the winner.** The
   previous testWildcardLifoTiebreakBySequence proved two concurrent
   wildcards could be registered, but never that handleActivation
   would actually pick the second-registered one. A regression that
   reverted the LIFO selection (e.g. .min instead of .max, or first
   instead of max-by-sequence) would have passed silently.

   Fix: factored handleActivation's selection logic into
   `Dispatcher.winnerForActivation(activatedPid:)` and exposed
   `SystemFocusStealPreventer._winnerForActivationTesting(activatedPid:)`
   as an internal test seam (matching the existing
   _forceReapForTesting visibility). Tests now register entries with
   distinguishable restoreTo apps (Finder vs the test process — both
   reliably non-nil on every macOS host) and assert the winner's
   processIdentifier directly.

   Added two tests:
   - testWildcardLifoTiebreakWinnerIsLatestRegistered — two
     wildcards with distinct restoreTo, asserts second-registered
     wins; then drops second, asserts first becomes the winner.
   - testLifoWinsAcrossWildcardAndPidSpecific — the LaunchAppTool
     crossfade case, asserts pid-specific entry (newer) wins over
     wildcard (older) regardless of order.

   Skips with XCTSkip if Finder isn't running (defensive — Finder is
   always running in interactive sessions but headless CI has the
   right to differ).

2. **Layer-4 e2e test hard-failed when `log show` errored.** The
   XCTSkipUnless guard checked the env var but not actual `log show`
   capability. A privilege-restricted sandbox that happened to have
   the env var set (or set it speculatively) would fail the
   substring assertions and report a false negative against the
   Layer-4 contract.

   Fix: distinguish "log show couldn't run" (skip — environment
   problem) from "log show ran but found nothing" (fail — Layer 4
   contract is broken). Skip triggers on:
   - non-zero terminationStatus
   - output containing TCC / privilege / availability markers
     (case-insensitive: "operation not permitted", "not authorized",
     "missing entitlement", "no logs found", "log archive does
     not contain")

   Also moved the lease cleanup to before the skip/throw so the
   dispatcher state is observable in subsequent tests if the runner
   shares state. This is robustness, not a bugfix in itself.

   The CR-suggested skip via `Process.run()` failure is also covered
   — Foundation throws on launch failure (ENOENT etc), which Swift
   propagates as a test error rather than skip. We could catch that
   too but I think a thrown error from `try process.run()` is the
   correct signal for "the test runner is fundamentally broken,
   investigate it" — it's not a Layer 4 contract issue.

Test coverage: 16 unit + 1 opt-in Layer-4 e2e (was 14 + 1 in the
prior commit on this branch). swift build clean (only pre-existing
warnings in unrelated files). Layer-4 e2e verified locally on macOS
14 — `log show` returns the expected substring.

Docstring coverage on the touched file remains 100%.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant