fix: run rule enforcement off the main thread to stop the rule-edit UI hang - #48
Merged
Conversation
…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
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
refreshran synchronously on the main actor, andDeviceActivityCenter.startMonitoringblocks ~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,396msin 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.
actor RuleEnforcementEngineperforms the I/O from SendableRuleSnapshotDTOs and serializes overlapping refreshes (edit / 30s loop / scenePhase) so they can't race onDeviceActivityCenteror the stored fingerprints.RuleEnforcerstays@MainActor/@Observablebut now does only the SwiftData-@Modelwork (pause expiry, day-start, snapshotting) on the main actor,awaits the engine, then publishesblockingRuleIDsback on main.refresh/pause/resumeare nowasync; call sites wrap inTask.RuleScheduler.syncand its plan helpers take[RuleSnapshotDTO]instead of@Modelobjects.nonisolated+Sendableso 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/resumestay@MainActor(they mutate the@Model);AppSettingsStoreis not sent off-main — its oneBoolis read on main and passed in.NotificationSchedulerto the asyncUNUserNotificationCenter.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
NotificationSchedulerasync warning is fixed).Notes
project.pbxprojis intentionally not included — Xcode's automaticDEVELOPMENT_TEAMsigning entry is kept out of version control.🤖 Generated with Claude Code
https://claude.ai/code/session_01PoojuBiBZwpV5UqGuAcHNy