You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Correctness and performance issues in watchers, sync and workers #333
An audit of the app's watchers, sync, workers and widget found the correctness and performance issues below. Each item will be fixed in its own PR, linked next to it.
Correctness
Session watcher replays all history when the last event's timestamp has no fractional seconds. Fix: fix(watcher): parse server timestamps of any fractional precision #334. SessionEventWatcher (and the legacy UsageStatsWatcher path) parse the last stored event's timestamp with SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX"). aw-server-rust (chrono) omits the fraction when it is zero (2026-10-08T10:00:00Z), which throws ParseException. The watcher then treats the bucket as empty, queries usage events from 0, and every replayed session is inserted again because the heartbeat merge refuses events older than the last one. Roughly 1 in 1000 sessions starts on a whole second.
Unlock events are duplicated on every ingest run. Fix: fix(watcher): resume unlock ingest from the unlock bucket's last event #335. Unlocks are re-read from the start of the last session rather than from the unlock bucket's own last event. Every run re-sends all unlocks in that window; only the newest merges, the older ones are inserted again, so unlock counts inflate with each hourly alarm, app open and widget refresh.
Two datastores can be opened concurrently in the JNI layer. Fix: fix(android): open the JNI datastore exactly once aw-server-rust#801 (needs an aw-server-rust submodule bump here once merged). openDatastore() in aw-server/src/android/mod.rs initialises an unsynchronised static mut DATASTORE. Several threads call it at startup (WebWatcher, MediaWatcher, SessionEventWatcher, the server thread, migrations), so two Datastore::new calls can race, giving two workers with separate SQLite connections and separate heartbeat caches.
Sessions of 4 hours or more are silently dropped. Fix: fix(parser): end sessions at screen-off instead of dropping long ones #336. SessionParser discards any session at or above MAX_REASONABLE_SESSION_DURATION (4h), losing genuine long sessions (navigation, video, reading). The cap exists to guard against missing PAUSE events, but the screen turning off is a better bound.
The in-progress foreground session is never counted until it ends. Fix: fix(watcher): count the in-progress foreground session #337. The parser does not emit the trailing open session, so a 2-hour session in one app is invisible to the dashboard, widget and alerts until the user leaves it.
Cancelling a sync allows a second concurrent sync. Fix: fix(sync): keep the in-flight guard while a cancelled sync is running #339. SyncInterface.cancel() clears the shared syncInFlight flag immediately, but interrupting the executor does not stop the native syncBoth, so a new sync can start while the old one is still running.
Sync runs twice per 15-minute interval. Fix: fix(sync): stop syncing twice per interval #340. SyncScheduler's Handler chain and the AlarmManager fallback both fire every 15 minutes; syncInFlight only prevents overlap, so each cycle can do two full syncs and two SAF mirrors. A stop/start while a sync is in flight can also leave two Handler chains running.
Media events are not merged when two players are active. Fix: fix(media-watcher): write playback as discrete per-player segments #343. With two media sessions playing, heartbeats for different data alternate in one bucket, so they never merge and each 15s poll inserts new zero-duration events.
The app can crash with SIGBUS on first launch. BackgroundService rewrites bucket hostnames in sqlite.db through Android's SQLite while the Rust datastore, which bundles its own SQLite, may already have the same WAL database open (watchers open it over JNI before the server starts; the only guard is serverStarted). Each library resizes the shared -shm mapping under the other and the process dies with BUS_ADRERR in walIndexAppend. This is the intermittent Test release variant (R8 smoke) CI failure. Fix: fix(startup): rewrite bucket hostnames before anything opens the datastore #354.
WebWatcher walks the accessibility tree on every content-change event. Fix: perf(web-watcher): rate-limit page-title lookups #345. Every browser typeWindowContentChanged event runs findWebView (up to 2000 binder calls), including for Firefox where it can never match.
Session ingest does redundant work per run. Fix: perf(watcher): cut redundant work from each session ingest run #348. One getApplicationInfo binder call per session (no cache), two full passes over queryEvents (sessions and unlocks), and createBucketHelper serialises every bucket on each of its two calls.
NotifyWorker's preferences grow without bound. Fix: fix(notify): drop alert-trigger keys from previous days #350. A new triggered_<category>_<hash>_<date> key is written per alert per day and never removed; SharedPreferences loads the whole file on each access.
An audit of the app's watchers, sync, workers and widget found the correctness and performance issues below. Each item will be fixed in its own PR, linked next to it.
Correctness
Session watcher replays all history when the last event's timestamp has no fractional seconds. Fix: fix(watcher): parse server timestamps of any fractional precision #334.
SessionEventWatcher(and the legacyUsageStatsWatcherpath) parse the last stored event's timestamp withSimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX"). aw-server-rust (chrono) omits the fraction when it is zero (2026-10-08T10:00:00Z), which throwsParseException. The watcher then treats the bucket as empty, queries usage events from 0, and every replayed session is inserted again because the heartbeat merge refuses events older than the last one. Roughly 1 in 1000 sessions starts on a whole second.Unlock events are duplicated on every ingest run. Fix: fix(watcher): resume unlock ingest from the unlock bucket's last event #335. Unlocks are re-read from the start of the last session rather than from the unlock bucket's own last event. Every run re-sends all unlocks in that window; only the newest merges, the older ones are inserted again, so unlock counts inflate with each hourly alarm, app open and widget refresh.
Two datastores can be opened concurrently in the JNI layer. Fix: fix(android): open the JNI datastore exactly once aw-server-rust#801 (needs an
aw-server-rustsubmodule bump here once merged).openDatastore()inaw-server/src/android/mod.rsinitialises an unsynchronisedstatic mut DATASTORE. Several threads call it at startup (WebWatcher, MediaWatcher, SessionEventWatcher, the server thread, migrations), so twoDatastore::newcalls can race, giving two workers with separate SQLite connections and separate heartbeat caches.Sessions of 4 hours or more are silently dropped. Fix: fix(parser): end sessions at screen-off instead of dropping long ones #336.
SessionParserdiscards any session at or aboveMAX_REASONABLE_SESSION_DURATION(4h), losing genuine long sessions (navigation, video, reading). The cap exists to guard against missing PAUSE events, but the screen turning off is a better bound.The in-progress foreground session is never counted until it ends. Fix: fix(watcher): count the in-progress foreground session #337. The parser does not emit the trailing open session, so a 2-hour session in one app is invisible to the dashboard, widget and alerts until the user leaves it.
Category alerts are evaluated against stale data. Fix: fix(notify): ingest new usage events before checking alert thresholds #338.
NotifyWorkerqueries category time without first ingesting new usage events, so alerts lag by up to the hourly (inexact) ingest alarm.Cancelling a sync allows a second concurrent sync. Fix: fix(sync): keep the in-flight guard while a cancelled sync is running #339.
SyncInterface.cancel()clears the sharedsyncInFlightflag immediately, but interrupting the executor does not stop the nativesyncBoth, so a new sync can start while the old one is still running.Sync runs twice per 15-minute interval. Fix: fix(sync): stop syncing twice per interval #340.
SyncScheduler's Handler chain and the AlarmManager fallback both fire every 15 minutes;syncInFlightonly prevents overlap, so each cycle can do two full syncs and two SAF mirrors. A stop/start while a sync is in flight can also leave two Handler chains running.RustInterface.serverStartedis not volatile. Fix: fix(server): make RustInterface.serverStarted volatile #341. It is written on the main thread and polled in a loop by the hostname-rewrite thread, which may never observe the change.Browser sessions are split by the keyboard and other overlay windows. Fix: fix(web-watcher): don't split browser sessions on keyboard and overlay events #342.
WebWatcherends the session on any event from a different window id, so typing in the browser (IME window) or a popup ends and restarts the visit.Media events are not merged when two players are active. Fix: fix(media-watcher): write playback as discrete per-player segments #343. With two media sessions playing, heartbeats for different data alternate in one bucket, so they never merge and each 15s poll inserts new zero-duration events.
The app can crash with SIGBUS on first launch. BackgroundService rewrites bucket hostnames in
sqlite.dbthrough Android's SQLite while the Rust datastore, which bundles its own SQLite, may already have the same WAL database open (watchers open it over JNI before the server starts; the only guard isserverStarted). Each library resizes the shared-shmmapping under the other and the process dies withBUS_ADRERRinwalIndexAppend. This is the intermittentTest release variant (R8 smoke)CI failure. Fix: fix(startup): rewrite bucket hostnames before anything opens the datastore #354.Performance
logBrowserEventcalls the blocking JNIheartbeaton the service's main thread, the same pattern that caused thecreateBucketANR in Blank white screen and app not working #261.typeWindowContentChangedevent runsfindWebView(up to 2000 binder calls), including for Firefox where it can never match.BackgroundService.onCreateconstructsRustInterfaceon the main thread. Fix: perf(service): construct RustInterface off the main thread #346.loadLibrary+ JNI initialise run on the main thread during service creation, which fix(android): harden startup paths that turned a slow datastore into a hang #262 moved off-thread everywhere else.mirrorDirectorycopies all files in full regardless of whether they changed, sotest.dbis rewritten every 15 minutes.getApplicationInfobinder call per session (no cache), two full passes overqueryEvents(sessions and unlocks), andcreateBucketHelperserialises every bucket on each of its two calls.updateAllWidgetsre-queries and re-reads settings for every placed widget instead of once.triggered_<category>_<hash>_<date>key is written per alert per day and never removed; SharedPreferences loads the whole file on each access.shareExportcallsreadContent(), undoing the streaming from fix(export): stream large JSON downloads to a cache file #304 on that path.