Skip to content

aw-sync: per-peer and per-bucket errors abort the whole sync pass (and burn the supervisor's restart budget) #688

Description

@ErikBjare

(Rewritten — the original framing claimed aw-sync exits with no supervision to catch it. That was wrong: exiting is the intended failure signal and both bundles implement the supervisor side. The real defect is narrower and is described below. See the comment thread for what changed.)

Exiting non-zero on failure is a deliberate and reasonable contract — the module signals failure, the supervisor surfaces it — and both bundles implement it properly:

  • aw-qt (aw_qt/trayicon.py): check_module_status() polls manager.get_unexpected_stops() every 5s, auto-restarts up to MAX_AUTO_RESTARTS = 3 within RESTART_WINDOW_SECONDS = 600, shows a tray notification per restart, and past the budget opens show_module_failed_dialog() with the module's log in the detailed text plus a manual Restart button.
  • aw-tauri (src-tauri/src/manager.rs): same idea with exponential backoff — restart_count < 3, delay 2^(restart_count+1) seconds, and an alert window that updates in place when the module recovers.

So for a whole-run failure — sync dir missing, config unreadable, aw-server unreachable — the current behaviour is right and should stay.

The actual defect: no error classification

aw-sync promotes every error to a whole-process failure, including errors scoped to a single peer or a single bucket.

loop {
    if let Err(e) = sync::sync_run(client, &sync_spec, mode) {
        error!("Error during sync cycle: {}", e);
        return Err(e);          // any error, whatever its scope
    }

and one rung down, pull_all propagates per host:

for host in hostnames {
    pull(&host, client)?
}

so a single unreadable peer aborts the pass and every peer after it in iteration order is skipped. #686 preserves this shape (for remote in selected { pull_db(...)?; }).

Why supervision makes this worse, not better

A persistent per-peer error becomes a crash loop. On aw-tauri the restart budget is spent in about 14 seconds (2 + 4 + 8), on aw-qt in three polls; then the user gets a modal and sync stops entirely. One permanently-bad peer database — a Syncthing conflict file, a db written by a newer schema, a truncated file — therefore takes down sync for all peers, permanently, rather than costing you that one peer's data.

Without a supervisor the same bug just stops the daemon once. With one it escalates to "sync is broken and disabled, here is a dialog". The contract is fine; routing peer-scoped errors into it is what hurts.

A concrete trigger: #669 replaced an unwrap() with a refusal when a bucket has hostname = "unknown" and there is no source device ID to substitute — correct in itself, since src_did is None on every pull. But that is a per-bucket condition returning Err from the whole cycle. (Scanned 60+ staging dbs on a real mesh and found no such bucket, so this particular trigger is currently latent — the shape is the point.)

Note that Android already does this correctly

The JNI path never exits a process; syncBoth returns {"success": false, "error": ...}, and aw-android#251 surfaces last-completion status in the UI. The CLI is the odd one out.

Suggested fix

Classify errors by scope rather than treating them uniformly:

  • Fatal / whole-run — bad sync dir, unreadable config, server unreachable past a retry budget. Keep exiting; the supervisors handle it well. Ideally detect these before entering the loop so the failure is immediate and legible rather than arriving one cycle in.
  • Per-peer — unreadable, corrupt, or schema-incompatible peer db. Log, skip that peer, continue the pass, and include it in the end-of-pass summary.
  • Per-bucket — the various TODOs, FIXMEs #669 provenance refusal and similar. Skip the bucket, sync the rest.
  • Report the non-fatal ones somewhere durable so "sync is running but two peers are failing" is visible: aw-sync status (feat(aw-sync): add status doctor command and fail-loud empty-pull warnings #687) is the natural home, and a last-completion-status file would let aw-qt/aw-tauri show it without the module having to die to be heard.

The distinction that matters: the daemon should only exit for conditions that a restart could plausibly fix. A corrupt peer file is not one of those, and restarting into it three times before giving up is strictly worse than skipping it.

Related: #682, #684 (silent failures), #686 (shares the per-peer ?), ActivityWatch/aw-android#220.

cc @TimeToBuildBob

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions