Summary
Android sync (syncBoth) OOM-crashes the native aw-server-rust process because the event-fetch side of sync_one() is unbounded, while the write side is already batched.
Confirmed as the dominant crash cluster in ActivityWatch/aw-android#176 — SIGABRT / std::sys::unix::abort in libaw_server.so, ~22 reports in current vitals, and very likely the root cause behind other unattributed native crashes given how sync-heavy the Android app is.
How sync works today (for context — there's no user-facing setting or trigger)
SyncScheduler.kt starts a Handler loop ~1 min after BackgroundService boots, then re-fires syncBothAsync() every 15 min (mobile/.../SyncScheduler.kt:15,38,49,95-106), with an AlarmManager fallback (SyncAlarmReceiver.kt:26) in case the process/Handler dies.
- It is fully automatic — no on-demand button, no foreground/background trigger, no settings screen entry (confirmed no
sync references in AWPreferences.kt / AuthSettingsActivity.kt).
syncBothAsync() → JNI syncBoth(port, hostname) (SyncInterface.kt:59,86-99, single-flight guarded) → Java_..._syncBoth in aw-server-rust/aw-sync/src/android.rs:162-212 → pull_all() / push_with_hostname() (sync_wrapper.rs) → sync_run() → sync_datastores() → sync_one() in aw-server-rust/aw-sync/src/sync.rs.
Root cause
sync_one() fetches events with get_events(bucket_id, resume_sync_at, None, None) — limit: None (sync.rs:321-333). Both Datastore::get_events (local) and AwClient::get_events (HTTP) treat None as "return everything," so the entire event range since the last sync point loads into one Vec<Event> in memory, unbounded. There's already a TODO acknowledging this at sync.rs:323:
// TODO: Fetch at most ~5,000 events at a time (or so, to avoid timeout from huge buckets)
Only the write side is batched — BATCH_SIZE: usize = 5000 (sync.rs:351) is used when inserting into ds_to. The read/fetch side is not. A bucket with a large backlog (device offline a while, or a high-frequency AFK/window bucket) bulk-loads unbounded events → native OOM abort, which surfaces on Android as a libaw_server.so crash since aw-sync shares the process/address space with aw-server.
Fix
- Primary: implement the TODO — page
sync_one()'s fetch loop in chunks (e.g. 5,000 events, matching the existing write BATCH_SIZE) instead of one unbounded get_events() call.
- Short-term Android mitigation (if the primary fix takes longer): cap sync lookback window (e.g. last N days) via a JNI param, to bound worst-case allocation on mobile specifically.
Related
Summary
Android sync (
syncBoth) OOM-crashes the nativeaw-server-rustprocess because the event-fetch side ofsync_one()is unbounded, while the write side is already batched.Confirmed as the dominant crash cluster in ActivityWatch/aw-android#176 —
SIGABRT/std::sys::unix::abortinlibaw_server.so, ~22 reports in current vitals, and very likely the root cause behind other unattributed native crashes given how sync-heavy the Android app is.How sync works today (for context — there's no user-facing setting or trigger)
SyncScheduler.ktstarts a Handler loop ~1 min afterBackgroundServiceboots, then re-firessyncBothAsync()every 15 min (mobile/.../SyncScheduler.kt:15,38,49,95-106), with anAlarmManagerfallback (SyncAlarmReceiver.kt:26) in case the process/Handler dies.syncreferences inAWPreferences.kt/AuthSettingsActivity.kt).syncBothAsync()→ JNIsyncBoth(port, hostname)(SyncInterface.kt:59,86-99, single-flight guarded) →Java_..._syncBothinaw-server-rust/aw-sync/src/android.rs:162-212→pull_all()/push_with_hostname()(sync_wrapper.rs) →sync_run()→sync_datastores()→sync_one()inaw-server-rust/aw-sync/src/sync.rs.Root cause
sync_one()fetches events withget_events(bucket_id, resume_sync_at, None, None)—limit: None(sync.rs:321-333). BothDatastore::get_events(local) andAwClient::get_events(HTTP) treatNoneas "return everything," so the entire event range since the last sync point loads into oneVec<Event>in memory, unbounded. There's already a TODO acknowledging this atsync.rs:323:// TODO: Fetch at most ~5,000 events at a time (or so, to avoid timeout from huge buckets)Only the write side is batched —
BATCH_SIZE: usize = 5000(sync.rs:351) is used when inserting intods_to. The read/fetch side is not. A bucket with a large backlog (device offline a while, or a high-frequency AFK/window bucket) bulk-loads unbounded events → native OOM abort, which surfaces on Android as alibaw_server.socrash since aw-sync shares the process/address space with aw-server.Fix
sync_one()'s fetch loop in chunks (e.g. 5,000 events, matching the existing writeBATCH_SIZE) instead of one unboundedget_events()call.Related