Skip to content

Fix an overlay crash and two dead settings (pre-release audit, wave 2)#535

Merged
FuJacob merged 1 commit into
mainfrom
fix/overlay-and-lifecycle-hardening
Jun 2, 2026
Merged

Fix an overlay crash and two dead settings (pre-release audit, wave 2)#535
FuJacob merged 1 commit into
mainfrom
fix/overlay-and-lifecycle-hardening

Conversation

@FuJacob
Copy link
Copy Markdown
Owner

@FuJacob FuJacob commented Jun 2, 2026

Summary

Wave 2 of the pre-release audit. Two fixes: a crash and two silently-dead settings.

  1. Crash on a non-finite caret rect. Misbehaving host AX (Chromium/Electron, mid-scroll or under load) can return NaN/Inf caret bounds. The existing guards (isNull, != .zero, isEmpty) do not reject non-finite values, so they flowed into NSWindow.setFrame, which raises NSInvalidArgumentException and crashes the app on the hottest path (every suggestion shown). Now rejected at the AX ingest boundary: cocoaRect / validatedCocoaTextRect collapse a non-finite rect to .zero (which also removes a latent Int(.nan) trap in OverlayState.detail), plus a last-resort finite guard before each OverlayController.setFrame.
  2. Two Settings controls were silently dead. AppDelegate built the graph as a local let environment and copied the services out, but never retained the environment. The environment owns the only subscriptions for the focus-poll-interval setting (→ updatePollInterval) and the global-toggle hotkey rebind (→ refreshToggleTap); both were cancelled when the environment deallocated as init returned, so changing the poll interval did nothing and rebinding the hotkey did nothing until the next relaunch. Now the environment is retained for the app lifetime.

Validation

xcodebuild ... test ... -only-testing:CotabbyTests/AXTextGeometryResolverTests
# ** TEST SUCCEEDED **  (new: finite-check rejects NaN/Inf; non-finite AX rect collapses to .zero)
swiftlint --strict   # exit 0

The AppDelegate retain is verified by trace (app-lifecycle code is not unit-tested); the caret guard is unit-tested.

Linked issues

None. Pre-release hardening, wave 2.

Risk / rollout notes

  • The caret guard is purely defensive: a non-finite rect that previously crashed now produces no overlay for that frame (graceful). Finite rects are unaffected.
  • Retaining the environment adds no retain cycle (the services were already retained directly; subscriptions use [weak self]/[weak ...]).
  • Deferred, flagged: PermissionManager / PermissionGuidanceController have isolated deinits (the macOS 14 double-free class), but they are app-lifetime singletons that never dealloc, so it is latent only and the fix is non-trivial (those deinits do real cleanup, unlike the empty-nonisolated deinit precedent).

Greptile Summary

This PR fixes two independent bugs found during a pre-release audit: a crash caused by non-finite (NaN/Inf) AX caret rects flowing into NSWindow.setFrame, and two Settings controls (focus poll interval, global-toggle hotkey rebind) that were silently non-functional because the CotabbyAppEnvironment owning their Combine subscriptions was immediately deallocated.

  • Crash fix: rectHasFiniteComponents is added as an ingest-boundary guard in cocoaRect and validatedCocoaTextRect, collapsing non-finite rects to .zero before they reach layout math or NSPanel.setFrame; last-resort guards are also added directly before both setFrame call sites in OverlayController.
  • Settings fix: AppDelegate now holds private let environment: CotabbyAppEnvironment, keeping the environment (and its cancellables) alive for the app's full lifetime so the poll-interval and hotkey-rebind subscriptions are never prematurely cancelled.
  • Tests: New unit tests cover rectHasFiniteComponents rejection and validatedCocoaTextRect/cocoaRect collapse for NaN inputs; the CGFloat.infinity and CGRect.null cases are not yet exercised in the collapse test.

Confidence Score: 4/5

Safe to merge; both fixes are narrowly scoped defensive changes with no new logic paths for the happy-path.

The crash fix and settings-lifetime fix are both correct. The subtle behavior change around CGRect.null collapsing to .zero is worth a quick caller audit but does not affect observed code paths.

Callers of cocoaRect and validatedCocoaTextRect that may check .isNull on the returned rect, given the new CGRect.null to .zero collapse behavior.

Important Files Changed

Filename Overview
Cotabby/Support/AXHelper.swift Adds rectHasFiniteComponents guard at the top of cocoaRect and validatedCocoaTextRect; minor side-effect: CGRect.null (origin = +∞) now collapses to .zero instead of propagating as the null rect
Cotabby/Services/UI/OverlayController.swift Adds last-resort finite checks before NSPanel.setFrame in both inline and mirror paths; straightforward defensive guard
Cotabby/App/Core/AppDelegate.swift Promotes environment to a private let instance property so the CotabbyAppEnvironment's Combine subscriptions are not prematurely cancelled; correct and low-risk
CotabbyTests/AXTextGeometryResolverTests.swift Adds tests for rectHasFiniteComponents (NaN + infinity) and validatedCocoaTextRect/cocoaRect collapse; CGFloat.infinity case is not tested in the collapse test, and CGRect.null collapse to .zero is untested

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    AX[Host AX: BoundsForRange / AXFrame] --> ingest["cocoaRect / validatedCocoaTextRect\n(AX ingest boundary)"]
    ingest -->|non-finite NaN/Inf| zero[Return .zero]
    ingest -->|finite| geom[Layout geometry math]
    geom --> frame["panelFrame (inline) / panelFrame (mirror)"]
    frame -->|non-finite last-resort| skip[Log warning, skip setFrame]
    frame -->|finite| setFrame["NSPanel.setFrame → orderFrontRegardless"]
    zero --> overlaySkip[Overlay suppressed — no crash]
    skip --> overlaySkip
Loading

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "Fix an overlay crash and two dead settin..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

1. Crash on a non-finite caret rect. Some host AX implementations (Chromium,
   Electron, mid-scroll or under load) return NaN/Inf bounds. The existing guards
   (isNull, != .zero, isEmpty) do not reject non-finite rects, so the value flowed
   into NSWindow.setFrame, which raises NSInvalidArgumentException and crashes the
   app on the hottest path (every suggestion shown). Reject non-finite rects at the
   AX ingest boundary (cocoaRect / validatedCocoaTextRect now collapse them to
   .zero, which also removes a latent Int(.nan) trap in OverlayState.detail), plus
   a last-resort finite guard before each OverlayController.setFrame.

2. Two Settings controls were silently dead. AppDelegate built the dependency graph
   as a local `let environment` and copied the individual services out, but never
   retained the environment itself. The environment owns the only subscriptions
   wiring the focus-poll-interval setting (to updatePollInterval) and the
   global-toggle hotkey rebind (to refreshToggleTap); when the environment
   deallocated as init returned, both were cancelled, so changing the poll interval
   did nothing and rebinding the hotkey did nothing until the next relaunch. Retain
   the environment for the app lifetime.

Deliberately deferred: PermissionManager / PermissionGuidanceController have
isolated deinits (the macOS 14 double-free class), but they are app-lifetime
singletons that never dealloc, so this is latent only and the fix is non-trivial
(the deinits do real cleanup, unlike the empty nonisolated-deinit precedent).
@FuJacob FuJacob merged commit 0f7ad67 into main Jun 2, 2026
4 checks passed
Comment on lines +593 to 596
guard rectHasFiniteComponents(rect) else {
return .zero
}
guard !rect.isNull, rect != .zero else {
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.

P2 CGRect.null silently collapses to .zero

CGRect.null has its origin at (+∞, +∞) in CoreGraphics, so rectHasFiniteComponents(CGRect.null) returns false and both cocoaRect and validatedCocoaTextRect now return .zero for a null rect. Previously the guard !rect.isNull branch caught it and returned the null rect itself. Any downstream caller that distinguishes null from zero (e.g. an isNull check) will now see .zero instead. Worth verifying that every caller only guards with != .zero (not .isNull) so no silent pass-through occurs after this change.

Fix in Codex Fix in Claude Code

Comment on lines +159 to +164
func test_validatedCocoaTextRect_collapsesNonFiniteRectToZero() {
// A NaN/Inf AX rect must collapse to .zero, never propagate toward NSWindow.setFrame (a crash).
let nan = CGRect(x: CGFloat.nan, y: 10, width: 20, height: 14)
XCTAssertEqual(AXHelper.validatedCocoaTextRect(fromAccessibilityRect: nan, anchorFrame: nil), .zero)
XCTAssertEqual(AXHelper.cocoaRect(fromAccessibilityRect: nan), .zero)
}
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.

P2 CGFloat.infinity and CGRect.null not covered by collapse test

test_validatedCocoaTextRect_collapsesNonFiniteRectToZero only exercises a NaN input. Adding a CGFloat.infinity case and a CGRect.null case (which now also returns .zero due to the new finite guard) would round out coverage and document the newly observed behavior for CGRect.null.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code

@FuJacob FuJacob deleted the fix/overlay-and-lifecycle-hardening branch June 2, 2026 05:52
@FuJacob FuJacob mentioned this pull request Jun 2, 2026
Merged
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