Skip to content

Crash: uncaught AccountNotFoundException in FileUploadHelper.retryUploads kills the app process #17337

Closed

Description

@andreBurnt

Summary

FileUploadHelper.retryUploads runs on a CoroutineScope(Dispatchers.IO) with no CoroutineExceptionHandler. When createOwnCloudClient throws AccountUtils.AccountNotFoundException, nothing catches it. The exception reaches the default handler and the app process dies.

The retry is triggered passively (app start, connectivity change, charger plug-in, power-save toggle), so no user interaction is needed.

I found this while running a diagnostic pass over the public CI history plus a debug build from source. It reproduces on-device. The production trigger is narrower, and I've labelled those parts as hypotheses below rather than overstate them.

Stack (representative, 5 captured logcats, all identical)

FATAL EXCEPTION: DefaultDispatcher-worker-N
  AccountUtils$AccountNotFoundException: Account not found
    at AccountUtils.getBaseUrlForAccount(AccountUtils.java:72)
    at OwnCloudClientFactory.createOwnCloudClient(OwnCloudClientFactory.java:72)
    at FileUploadHelper$retryUploads$2.invokeSuspend(FileUploadHelper.kt:194)
    at FileUploadHelper.retryUploads(FileUploadHelper.kt:180)
    at FileUploadHelper$retryFailedUploads$1(FileUploadHelper.kt:141)

Mechanism (read from the code plus the pinned library)

AccountUtils.getBaseUrlForAccount throws AccountNotFoundException only when AccountManager.getUserData(account, "oc_base_url") returns null. (confirmed in the bytecode of android-library@72a95a99, the pinned commit)

retryUploads reaches it via createOwnCloudClient, behind a single guard: if (!currentAccount.isAnonymous(context)).

Not the obvious path. "The user removed their account" does not trigger this. With zero accounts, getCurrentAccount() returns the anonymous account, isAnonymous is true, and the crashing call is skipped. What it needs is an account row that exists while its oc_base_url user-data reads back null. Two ways that can happen, both hypotheses for production:

  1. Partially-provisioned account. The row is added during login before base-url is written. Anything that fires a retry in that window crashes.
  2. Removal race (TOCTOU). getCurrentAccount() snapshots getAccounts(), then getUserData() runs later on Dispatchers.IO. If the account is removed in between, getUserData returns null. (the worst crasher on my bench was the account-switching test)

Two aggravating details in the same function

ioScope = CoroutineScope(Dispatchers.IO) (FileUploadHelper.kt:77) has no CoroutineExceptionHandler, so any escape from ioScope.launch kills the process. A SupervisorJob alone would not fix this.

retryFailedUploadsSemaphore is released in a finally that runs when ioScope.launch returns, not when the coroutine completes. So the "already running" guard never guards the body, and a connectivity flap can run several retryUploads concurrently. That widens the race in (2).

Why it matters

The retry fires passively, with no user interaction. Sources: MainApp init (MainApp.java:628), the connectivity receiver on CONNECTIVITY_CHANGE / WIFI_STATE_CHANGE when connected, ACTION_POWER_CONNECTED, and the power-save receiver (all ReceiversHelper.java). A user in either state above who walks onto Wi-Fi or plugs in a charger loses the app process.

How reproducible

Bench batteries against a debug build. 17 iterations ended in Process crashed, across three test classes on two API levels:

Test class Device Crashed / iterations
DrawerActivityIT#switchAccountViaAccountList API-28 emulator 11 / 15 (0 passes)
ReceiveExternalFilesActivityIT API-29 emulator 4 / 25
LauncherActivityIT API-29 emulator 2 / 25

Two of the three classes (LauncherActivityIT, ReceiveExternalFilesActivityIT) carry no GrantPermissionRule. So this is independent of the CI permission-grant failures, not a downstream effect of them. In CI history it hides inside generic "Process crashed" noise.

Note on overlap with #17268: that PR modifies these three test classes, but it does not touch the production file (FileUploadHelper.kt). The crash path is orthogonal to the test fixes.

Suggested fix

  1. Catch AccountNotFoundException inside retryUploads (park/drop the affected uploads, log), or guard createOwnCloudClient with an account-exists check.
  2. Add a CoroutineExceptionHandler to ioScope as a backstop.
  3. Release the semaphore when the coroutine completes, not when it's launched.

Verification: a 15-iteration DrawerActivityIT#switchAccountViaAccountList loop on an API-28 AVD. Currently 11/15 process deaths, expect 0.

Evidence

Logcats plus iteration ledgers available on request. 5 full stacks captured, plus the per-loop ledgers. Happy to attach or link whatever's most useful.

cc @tobiasKaminsky, filing per your reply on the CI thread.

Activity

  1. alperozturk96 commented on Jul 24, 2026

    @alperozturk96
    Collaborator

    Hello

    Thank you for the report.

    Could you please try with this PR once the QA APK generated?

    #17373

  2. andreBurnt commented on Jul 28, 2026

    @andreBurnt
    Author

    Tested it. The fix works.

    On current master the crash still reproduces for me at 10%. On your PR it is gone.

    Setup

    API-28 google_apis arm64 AVD. Your own CI server container
    (ghcr.io/nextcloud/continuous-integration-shallow-server-php8.3:latest), brought up with
    configServer.sh, configNC.sh and occ upgrade. NC_TEST_SERVER_BASEURL=http://10.0.2.2:8080.

    The loop is the 4-class sequence from my original bench run, repeated each iteration:

    DialogFragmentIT#testGson
    SetOnlineStatusBottomSheetIT#open
    SetStatusMessageBottomSheetIT#open
    DrawerActivityIT#switchAccountViaAccountList
    

    Crashes are counted from logcat (FATAL EXCEPTION in com.nextcloud.client), not from test
    pass/fail. That distinction turned out to matter. See point 2 below.

    Result

    build runs FATAL EXCEPTION AccountNotFoundException reached tests passing
    merge-base 3a9f9d7f (current master) 60 6 (10%) 6 58/60
    PR head f01298f7 60 0 3 (all handled) 60/60

    The reproduced stack matches the report line for line:

    FATAL EXCEPTION: DefaultDispatcher-worker-2
    Process: com.nextcloud.client
    com.owncloud.android.lib.common.accounts.AccountUtils$AccountNotFoundException: Account not found
        at com.owncloud.android.lib.common.accounts.AccountUtils.getBaseUrlForAccount(AccountUtils.java:72)
        at com.owncloud.android.lib.common.OwnCloudClientFactory.createOwnCloudClient(OwnCloudClientFactory.java:72)
        at com.nextcloud.client.jobs.upload.FileUploadHelper$retryUploads$2.invokeSuspend(FileUploadHelper.kt:194)
        at com.nextcloud.client.jobs.upload.FileUploadHelper.retryUploads(FileUploadHelper.kt:180)
        at com.nextcloud.client.jobs.upload.FileUploadHelper$retryFailedUploads$1.invokeSuspend(FileUploadHelper.kt:141)
    

    Two things to know if you want to reproduce it

    1) It needs the whole class sequence, not the single test.

    I first ran DrawerActivityIT#switchAccountViaAccountList alone against master. 15 iterations,
    0 crashes, 15 passes. Run the four classes in sequence and it comes back at 10%.

    I have not isolated which of the earlier classes matters. The obvious reading is that they leave
    the account state the race needs, but that is a hypothesis, not something I measured.

    The practical point holds either way. A spot-check on the single test will tell you the bug is
    gone when it is not. That caught me on the first pass, so I re-ran everything with the original
    sequence.

    2) On master this is currently invisible to CI.

    Of the 6 crashing runs, 4 still reported the test as passing. The app process died and the
    runner still returned OK. Only 2 surfaced as Process crashed.

    That matches what I said in the report about it hiding inside generic crash noise. It is worse
    than I realised though. A green run is not evidence this did not happen.

    Corroboration: the fix on its own

    I wanted the fix isolated from everything else that has landed since I filed this.

    All three files you touch are byte-identical between cc36cfe5bf (the commit I originally built)
    and this PR's merge-base: DispatcherModule.kt, FileUploadHelper.kt, UploadListActivity.kt.
    So grafting them onto cc36cfe5bf applies your product delta and nothing else.

    build runs FATAL EXCEPTION
    cc36cfe5bf 60 9 (15%)
    cc36cfe5bf + only your three files 160 0

    Both of those arms fail every test on API-28 for an unrelated reason. GrantPermissionRule
    throws SecurityException: Unknown permission: android.permission.POST_NOTIFICATIONS, which is
    API-33+. That is the permission cluster from the report, and GrantTestPermissionRule on master
    already fixes it. There the tests are only the vehicle driving the app through the race.

    One thing I would flag

    The AccountNotFoundException is still being thrown with the fix in. 3 of 60 runs on the PR
    build, 15 of 160 on the isolated one. It is caught and logged now instead of killing the process:

    E FileUploadWorker: Cannot create client, account not found; skipping conflict handling
    com.owncloud.android.lib.common.accounts.AccountUtils$AccountNotFoundException: Account not found
    

    So this is containment, not root cause. For a crash fix that is a reasonable place to stop.

    An account row is still reaching retryUploads with a null oc_base_url. Those uploads now
    quietly skip conflict handling for that pass. I mention it so the provisioning/removal window
    does not get treated as closed.

    Related: the new @ApplicationScope CoroutineExceptionHandler never fired in any run. Zero
    Uncaught exception in application coroutine scope. The targeted catch gets there first, so the
    backstop stays a backstop.

    The duplicate-retry claim also checks out

    This one is measurable from your own log line. "skipping retryFailedUploads, already running"
    sits on the guard-rejection branch in both versions, and the string is byte-identical. So the
    counts compare directly.

    build runs runs where the guard engaged skipping lines
    master, Semaphore released at launch 60 0 0
    PR head, AtomicBoolean reset in the finally 60 60 218
    cc36cfe5bf, Semaphore 60 0 0
    cc36cfe5bf + your fix, AtomicBoolean 160 160 951

    In the old shape the guard never engaged once, across 120 runs on two builds. That is what the
    code predicts. retryFailedUploadsSemaphore.release() ran the moment ioScope.launch returned,
    so a re-entrant call always found the permit free and started another body.

    With the AtomicBoolean the re-entrant triggers are suppressed in every run, for the whole life
    of the coroutine.

    One check before you trust that zero. Both builds emit identical I MainApp : Log_OC lines
    (Conscrypt, enabled protocols, migrations). Debug logging was equally live in both. The zero is
    behaviour, not a silenced logger.

    One suggestion

    The PR does not add a test. Combined with point 2 above, a run can go green while the process
    dies, so nothing would catch this coming back.

    I can contribute one if that is useful. The cheap version asserts the guard holds across a
    re-entrant call. The faithful version stages an account with a null oc_base_url and asserts the
    retry survives it.

    The QA APK

    I installed the one you linked on a physical Galaxy S24 (Android 16, SDK 36). It installs
    alongside the store app as expected, launches, no crash.

    That is a shallow smoke test. The crash path is not reachable by hand, which is why the numbers
    above come from the instrumentation loop instead.

    Confirmed it is the right build: com.nextcloud.android.qa, versionCode and versionName 17373,
    signed CN=Tobias Kaminsky, O=Nextcloud GmbH.

    One small quirk in case it puzzles anyone later. qa.yml uploads with archive: false, so
    pulling the artifact through the API returns the APK bytes under a .zip name, not a zip holding
    an APK. Renaming it to .apk installs fine.


    Per-run JSONL ledgers and full logcats for every arm are on disk. Say which ones help and I will
    attach or link them.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions