Repository navigation
Crash: uncaught AccountNotFoundException in FileUploadHelper.retryUploads kills the app process #17337
Description
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:
- Partially-provisioned account. The row is added during login before base-url is written. Anything that fires a retry in that window crashes.
- Removal race (TOCTOU).
getCurrentAccount()snapshotsgetAccounts(), thengetUserData()runs later onDispatchers.IO. If the account is removed in between,getUserDatareturnsnull. (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
- Catch
AccountNotFoundExceptioninsideretryUploads(park/drop the affected uploads, log), or guardcreateOwnCloudClientwith an account-exists check. - Add a
CoroutineExceptionHandlertoioScopeas a backstop. - 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
alperozturk96 commentedon Jul 24, 2026
Hello
Thank you for the report.
Could you please try with this PR once the QA APK generated?
#17373
andreBurnt commentedon Jul 28, 2026
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_apisarm64 AVD. Your own CI server container(
ghcr.io/nextcloud/continuous-integration-shallow-server-php8.3:latest), brought up withconfigServer.sh,configNC.shandocc 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:
Crashes are counted from logcat (
FATAL EXCEPTIONincom.nextcloud.client), not from testpass/fail. That distinction turned out to matter. See point 2 below.
Result
FATAL EXCEPTIONAccountNotFoundExceptionreached3a9f9d7f(current master)f01298f7The reproduced stack matches the report line for line:
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#switchAccountViaAccountListalone 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
cc36cfe5bfapplies your product delta and nothing else.FATAL EXCEPTIONcc36cfe5bfcc36cfe5bf+ only your three filesBoth of those arms fail every test on API-28 for an unrelated reason.
GrantPermissionRulethrows
SecurityException: Unknown permission: android.permission.POST_NOTIFICATIONS, which isAPI-33+. That is the permission cluster from the report, and
GrantTestPermissionRuleon masteralready fixes it. There the tests are only the vehicle driving the app through the race.
One thing I would flag
The
AccountNotFoundExceptionis still being thrown with the fix in. 3 of 60 runs on the PRbuild, 15 of 160 on the isolated one. It is caught and logged now instead of killing the process:
So this is containment, not root cause. For a crash fix that is a reasonable place to stop.
An account row is still reaching
retryUploadswith a nulloc_base_url. Those uploads nowquietly skip conflict handling for that pass. I mention it so the provisioning/removal window
does not get treated as closed.
Related: the new
@ApplicationScopeCoroutineExceptionHandlernever fired in any run. ZeroUncaught exception in application coroutine scope. The targeted catch gets there first, so thebackstop 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.
skippinglinesSemaphorereleased atlaunchAtomicBooleanreset in thefinallycc36cfe5bf,Semaphorecc36cfe5bf+ your fix,AtomicBooleanIn the old shape the guard never engaged once, across 120 runs on two builds. That is what the
code predicts.
retryFailedUploadsSemaphore.release()ran the momentioScope.launchreturned,so a re-entrant call always found the permit free and started another body.
With the
AtomicBooleanthe re-entrant triggers are suppressed in every run, for the whole lifeof 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_urland asserts theretry 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 versionName17373,signed
CN=Tobias Kaminsky, O=Nextcloud GmbH.One small quirk in case it puzzles anyone later.
qa.ymluploads witharchive: false, sopulling the artifact through the API returns the APK bytes under a
.zipname, not a zip holdingan APK. Renaming it to
.apkinstalls 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.