nextcloud / nextcloud/ios

Race condition leading to photos not deleted from camera roll

Open
#4,307 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
Dominant language
Swift
Stars
2.5k
Forks
1k
Avg merge
2d 18h
Merged PRs (30d)
13

Description

Bug Report

How to use GitHub
  • Please use the 👍 reaction to show that you are affected by the same issue.
  • Please don't comment if you have no relevant information to add. It's just extra noise for everyone subscribed to this issue.
  • Subscribe to receive notifications on status change and new comments.

Steps to reproduce
  1. Enable auto upload and give app all necessary authorizations
  2. foreground app once
  3. use phone normally, take several photos
  4. foreground app later
Expected behaviour

Pictures get deleted from the camera roll

Actual behaviour

Some pictures don't get deleted

Logs

See below

Reasoning or why should it be changed/implemented?

Relieve user from the pain of comparing hundreds of picturesbetween phone and server, then manually deleting them from Camera Roll

Analysis

Environment data

Analysis

Code references are against upstream master at
204f9e23b1 (2026-08-23). Apart from one line noted below, every log line quoted here is upstream's own
logging; no diagnostic additions were needed to see this.

Device: iPhone 12 mini, iOS 26.6.1, auto-upload on, app backgrounded (not force-quit) during the evening in question. iOS 18.4

Summary

iOS fired the app's BGAppRefreshTask and BGProcessingTask in the same second. Both handlers call
NCAutoUpload.shared.autoUploadBackgroundSync() with no mutual exclusion, so two discovery passes ran at
once: each found the same three new photos, each queued them, and each dispatched every queued row — twelve
background upload tasks for three files. The duplicates collided at the server (423 Locked), and
uploadComplete's fallback path, reached when a completing task's own row is already gone, deletes every
row for that file name, so the local "uploaded" record for files that had genuinely landed on the server
was erased. Afterwards the tester found the queue empty and two of the three photos still in the camera
roll, while all three were downloadable from the server.

The two tasks firing together is not rare: it happened six times in six days of logs.


Platform contract

Apple documents the two task types as independent mechanisms with different scheduling rules, and makes
no statement that their launch handlers are serialised with respect to each other —
BGProcessingTask:

Processing tasks run only when the device is idle. The system terminates any background processing
tasks running when the user starts using the device. Background refresh tasks aren't affected.

register(forTaskWithIdentifier:using:launchHandler:):

A queue for executing the task. Pass nil to use a default background queue. The system runs the block
of code for the launch handler when it launches the app in the background.

The app registers both identifiers on one serial queue (AppDelegate.swift:27,95,104
DispatchQueue(label: "com.nextcloud.bgTaskQueue")), which serialises only the launch-handler blocks;
each block spawns a Swift Task and returns immediately, so the work itself runs concurrently. Nothing in
the contract prevents that, and the log shows it happening.

423 Locked is RFC 4918 §11.3: "The 423 (Locked)
status code means the source or destination resource of a method is locked." The Nextcloud server
returned it to the second, third and fourth concurrent PUTs of the same path.


Where the code departs from it

Both handlers call the same entry point with no coordination:

  • iOSClient/Refresh/AppDelegate+AppRefresh.swift:49-50let refreshTask = Task { … await NCAutoUpload.shared.autoUploadBackgroundSync() … }
  • iOSClient/Processor/AppDelegate+AppProcessing.swift:40-49let processingTask = Task { … await NCAutoUpload.shared.autoUploadBackgroundSync() … }

NCAutoUpload is class NCAutoUpload: NSObject (NCAutoUpload.swift:12), not an actor, and
autoUploadBackgroundSync() (:291) has no re-entrancy guard. Its discovery step checks "not already
queued" before inserting rows, but two passes running that check at the same moment both see an empty
queue and both insert; its dispatch step then reads all pending rows, so each pass dispatches both
copies.

The deletion that follows is in iOSClient/Networking/NCNetworking+NextcloudKitDelegate.swift:142-143
(and again at :168):

guard let metadata = await NCManageDatabase.shared.getMetadataAsync(predicate: NSPredicate(format: "serverUrl == %@ AND fileName == %@ AND sessionTaskIdentifier == %d", serverUrl, fileName, task.taskIdentifier)) else {
    await NCManageDatabase.shared.deleteMetadataAsync(predicate: NSPredicate(format: "fileName == %@ AND serverUrl == %@", fileName, serverUrl))
    return
}

When a completing task cannot find its own row (already removed by a sibling duplicate's completion), the
fallback deletes every row with that file name and folder — including a duplicate that has already
reached status == Normal — so the record that the file was uploaded is gone.


Field evidence — log.txt, 2026-09-10 evening

Three photos taken 19:30:28–19:30:29. The app was backgrounded. At 19:56:31 both tasks fire:

19:56:31  [BGT] Start refresh task
19:56:31  [BGT] Start processing task
19:56:32  [DEBUG] Automatic upload, new 3 assets found         ← discovery, pass 1
19:56:32  [DEBUG] Automatic upload, new 3 assets found         ← discovery, pass 2: same 3 assets
19:56:32  [BGSYNC] Auto upload found 3 new items
19:56:32  [BGSYNC] Auto upload found 3 new items
19:56:33  [DEBUG] Uploading file 26-09-10 19-30-28 0280.heic with taskIdentifier 1
19:56:33  [DEBUG] Uploading file 26-09-10 19-30-28 0280.heic with taskIdentifier 2
19:56:34  [DEBUG] Uploading file 26-09-10 19-30-28 0280.heic with taskIdentifier 3
19:56:34  [DEBUG] Uploading file 26-09-10 19-30-28 0280.heic with taskIdentifier 4
19:56:34  [DEBUG] Uploading file 26-09-10 19-30-28 0281.heic with taskIdentifier 5
19:56:34  [DEBUG] Uploading file 26-09-10 19-30-28 0281.heic with taskIdentifier 6
19:56:34  [DEBUG] Uploading file 26-09-10 19-30-28 0281.heic with taskIdentifier 7
19:56:34  [DEBUG] Uploading file 26-09-10 19-30-28 0281.heic with taskIdentifier 8
19:56:34  [DEBUG] Uploading file 26-09-10 19-30-29 0282.heic with taskIdentifier 9
19:56:35  [DEBUG] Uploading file 26-09-10 19-30-29 0282.heic with taskIdentifier 10
19:56:35  [DEBUG] Uploading file 26-09-10 19-30-29 0282.heic with taskIdentifier 11
19:56:35  [DEBUG] Uploading file 26-09-10 19-30-29 0282.heic with taskIdentifier 12
19:56:38  [BGT] Stop processing task

Four upload tasks per photo: two passes × two duplicate rows each. Twenty-four minutes later the
collisions surface:

20:20:39  [ERROR] Upload file: …/26-09-10 19-30-29 0282.heic, result: error 423
20:20:39  [ERROR] Upload file: …/26-09-10 19-30-28 0280.heic, result: error 423
20:20:39  [ERROR] Upload file: …/26-09-10 19-30-28 0281.heic, result: error 423
20:20:39  [ERROR] Upload file: …/26-09-10 19-30-28 0281.heic, result: error 423
20:20:39  [ERROR] Upload file: …/26-09-10 19-30-29 0282.heic, result: error 423

No [SUCCESS] Uploaded file: line for any of the three files appears anywhere in the log — the success
path never recorded them. Yet two hours later all three are fetched from the server by the app's own
media browsing, proving the uploads had in fact succeeded:

22:14:25  [SUCCESS] Downloaded file: …/26-09-10 19-30-28 0280.heic
22:14:28  [SUCCESS] Downloaded file: …/26-09-10 19-30-28 0281.heic
22:14:29  [SUCCESS] Downloaded file: …/26-09-10 19-30-29 0282.heic

Tester's observation at the time (20:19, verbatim): "I can confirm that there's nothing in the queue,
either error, waiting, or being uploaded. But there are 2 remaining pictures in Camera Roll (281 & 282)."
So: files on the server, no local record of it, "remove after upload" never offered for two of them.

How often the two tasks coincide

Same-second Start refresh task + Start processing task pairs across the six days of logs:

Date / time Log
2026-09-10 19:56:31 log.txt — the incident above
2026-09-11 15:13:48 log-2 / log-3
2026-09-13 08:05:21 log-5b
2026-09-15 15:00:55 log-g
2026-09-15 16:09:50 log-g
2026-09-15 20:54:34 log-i (plus a location trigger in the same second — three callers)
With a single-flight guard on autoUploadBackgroundSync() (all later logs)

Every subsequent coincidence produced one pass and one back-off line (the only non-upstream log line in
this report), and result: error 423 never appears again in any log:

2026-09-11 15:13:48  [BGT] Start refresh task
2026-09-11 15:13:48  [BGT] Start processing task
2026-09-11 15:13:48  [BGSYNC] Auto upload background sync already running, skipping
2026-09-11 15:13:48  [BGSYNC] Auto upload found 0 new items
2026-09-15 16:09:50  [BGT] Start processing task
2026-09-15 16:09:50  [BGT] Start refresh task
2026-09-15 16:09:50  [BGSYNC] Auto upload background sync already running, skipping
2026-09-15 16:09:51  [BGSYNC] Auto upload found 7 new items          ← one pass, 7 uploads, no duplicates
2026-09-15 20:54:34  [BGT] Start refresh task
2026-09-15 20:54:34  [BGT] Start processing task
2026-09-15 20:54:34  [BGSYNC] Auto upload background sync already running, skipping
2026-09-15 20:54:34  [BGSYNC] Auto upload background sync already running, skipping
2026-09-15 20:54:34  [BGSYNC] Auto upload found 4 new items          ← three callers, one pass
log.txt (09-10/11) all 16 later logs (09-11 → 09-16)
Same-second task pairs 1 5
Automatic upload, new N assets found duplicated yes (×2) never
Upload tasks per photo 4 1
result: error 423 5 0

Test conditions

No scripted test — this was an ordinary evening with the app backgrounded after use. Three photos taken
at 19:30, phone left alone, both background tasks fired at 19:56:31, tester opened the app around 20:08
and checked the transfer queue and camera roll (observation quoted above), then browsed the server folder
at 22:14.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with iOSClient/Refresh/AppDelegate+AppRefresh.swift and iOSClient/Processor/AppDelegate+AppProcessing.swift, then trace autoUploadBackgroundSync() in NCAutoUpload.swift. Check the completion and fallback paths in iOSClient/Networking/NCNetworking+NextcloudKitDelegate.swift. Done means concurrent background triggers do not create duplicate queue rows or uploads, and successfully uploaded photos retain the local record needed for camera-roll deletion.

Written by the indexing model from the issue text.

Assessment

Tech stack
ios, swift
Domain
mobile
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
70/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.