Skip to content

fix: run rule enforcement off the main thread to stop the rule-edit UI hang - #48

Merged
brendan-ch merged 1 commit into
mainfrom
chore/fix-hang-on-rule-edit
Jul 9, 2026
Merged

fix: run rule enforcement off the main thread to stop the rule-edit UI hang#48
brendan-ch merged 1 commit into
mainfrom
chore/fix-hang-on-rule-edit

Conversation

@brendan-ch

Copy link
Copy Markdown
Owner

Problem

Committing a rule edit — or editing an app list attached to a rule — could freeze the UI for tens of seconds. Device-confirmed via per-span timing instrumentation: the enforcement refresh ran synchronously on the main actor, and DeviceActivityCenter.startMonitoring blocks ~31s per current-day time-limit activity. An app-list selection change restarts both the block and warn activities → ~62s on the UI thread (refresh total: 62,396ms in the device log). Everything else on the path was <60ms.

Fix

Move all shield + DeviceActivity + uninstall I/O off the main thread. No enforcement-logic change.

  • New actor RuleEnforcementEngine performs the I/O from Sendable RuleSnapshotDTOs and serializes overlapping refreshes (edit / 30s loop / scenePhase) so they can't race on DeviceActivityCenter or the stored fingerprints.
  • RuleEnforcer stays @MainActor/@Observable but now does only the SwiftData-@Model work (pause expiry, day-start, snapshotting) on the main actor, awaits the engine, then publishes blockingRuleIDs back on main. refresh/pause/resume are now async; call sites wrap in Task.
  • RuleScheduler.sync and its plan helpers take [RuleSnapshotDTO] instead of @Model objects.
  • Marked the pure logic / naming / service types nonisolated + Sendable so they can run off-main: RulePolicy, RuleStatus, RuleActivation, RuleSchedule, Calendar+NextMidnight, MonitoringPlan, AppSelectionCodec, ShieldApplying, ActivityMonitoring, UsageReading, OpenSessionReading, RuleScheduler, and the app-group stores. RulePolicy.pause/resume stay @MainActor (they mutate the @Model); AppSettingsStore is not sent off-main — its one Bool is read on main and passed in.
  • Switched NotificationScheduler to the async UNUserNotificationCenter.add, clearing a pre-existing warning.

The underlying ~62s of DeviceActivity work still runs — it just no longer blocks the UI. Shrinking that work (avoiding the redundant current-day restart on selection changes) is a deliberate follow-up, not in this PR.

Test plan

  • Full suite green: 404 tests pass (unit + UI), 0 failures.
  • No build warnings (all 19 concurrency warnings the refactor could introduce are resolved; the NotificationScheduler async warning is fixed).
  • On-device (pending, maintainer): edit an app list / a rule's selection and tap the checkmark — the UI should stay responsive (no multi-second freeze).

Notes

  • project.pbxproj is intentionally not included — Xcode's automatic DEVELOPMENT_TEAM signing entry is kept out of version control.
  • Xcode's "Update to recommended settings" project nag is left alone; it applies build-setting changes via Xcode's own migration UI and doesn't belong bundled into this hang fix.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PoojuBiBZwpV5UqGuAcHNy

…I hang

Committing a rule or app-list edit could freeze the UI for tens of seconds.
The enforcement refresh ran synchronously on the main actor, and
DeviceActivityCenter.startMonitoring blocks ~31s per current-day time-limit
activity (device-confirmed via timing instrumentation). An app-list selection
change restarts both the block and warn activities — ~62s on the UI thread.

Move all shield + DeviceActivity + uninstall I/O off the main thread:

- Add `actor RuleEnforcementEngine`, which performs the I/O from Sendable
  `RuleSnapshotDTO`s and serializes overlapping refreshes (edit, 30s loop,
  scenePhase) so they can't race on DeviceActivityCenter.
- `RuleEnforcer` stays @MainActor/@observable but now does only the SwiftData
  @model work (pause expiry, day-start, snapshotting) on main, awaits the
  engine, and publishes `blockingRuleIDs` back on main. refresh/pause/resume
  are now async; call sites wrap in Task.
- `RuleScheduler.sync` and its plan helpers take `[RuleSnapshotDTO]` instead of
  `@Model` objects so they can run off-main.
- Mark the pure logic / naming / service types nonisolated + Sendable
  (RulePolicy, RuleStatus, RuleActivation, RuleSchedule, Calendar+NextMidnight,
  MonitoringPlan, AppSelectionCodec, ShieldApplying, ActivityMonitoring,
  UsageReading, OpenSessionReading, RuleScheduler, stores). RulePolicy.pause /
  resume stay @mainactor since they mutate the @model. AppSettingsStore is not
  sent off-main — its one Bool is read on main and passed in.

Also switch NotificationScheduler to the async UNUserNotificationCenter.add,
clearing a pre-existing warning. Behavior-preserving: all 404 tests pass, no
concurrency warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PoojuBiBZwpV5UqGuAcHNy
@brendan-ch
brendan-ch merged commit 4a7cd39 into main Jul 9, 2026
1 of 2 checks passed
@brendan-ch
brendan-ch deleted the chore/fix-hang-on-rule-edit branch July 9, 2026 03:23
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