Skip to content

fix(dav): Prevent race condition in useDAVFiles - #2511

Merged
susnux merged 1 commit into
nextcloud-libraries:mainfrom
DerDreschner:fix/race-conditions
Sep 17, 2026
Merged

susnux merged 1 commit into
nextcloud-libraries:mainfrom
DerDreschner:fix/race-conditions

Conversation

@DerDreschner

Copy link
Copy Markdown
Contributor

This PR fixes a race condition that occurs under high load in our CI runners using playwright.

Notice:   1 failed
    [chrome] › tests/playwright/e2e/files/live-photos.spec.ts:50:3 › Files: Live photos › 'Show hidden files' is enabled › Copies both files when copying the .mov 
  42 passed (8.0m)

  1) [chrome] › tests/playwright/e2e/files/live-photos.spec.ts:50:3 › Files: Live photos › 'Show hidden files' is enabled › Copies both files when copying the .mov 

    Test timeout of 30000ms exceeded.

    Error: page.waitForResponse: Test ended.

       at ../support/sections/CopyMoveDialogPage.ts:68

      66 |
      67 | 	private async confirm(label: string, method: 'COPY' | 'MOVE'): Promise<void> {
    > 68 | 		const done = this.page.waitForResponse((r) => r.request().method() === method
         | 		                       ^
      69 | 			&& /\/(remote|public)\.php\/dav\/files\//.test(r.url()))
      70 | 		await this.confirmButton(label).click()
      71 | 		await done
        at CopyMoveDialogPage.confirm (/home/runner/actions-runner/_work/server/server/tests/playwright/support/sections/CopyMoveDialogPage.ts:68:26)
        at CopyMoveDialogPage.copyToCurrentFolder (/home/runner/actions-runner/_work/server/server/tests/playwright/support/sections/CopyMoveDialogPage.ts:76:14)
        at /home/runner/actions-runner/_work/server/server/tests/playwright/e2e/files/live-photos.spec.ts:52:25

    Error Context: test-results/files-live-photos-Files-Li-c0514--files-when-copying-the-mov-chrome/error-context.md

Here is a summary of the issue that Claude created:

Root cause (confirmed end-to-end)

A race in @nextcloud/dialogs' useDAVFiles (lib/composables/dav.ts). loadDAVFiles aborts the previous load and starts a new one, but the aborted load's finally block unconditionally ran:

finally {
  abortController = undefined
  isLoading.value = false   // ← clobbers the NEWER load's state
}

So when fast navigation supersedes a load, the aborted one flips isLoading back to false while the newer load is still in flight and folder is still null. In FilePicker.vue, the confirm button is only disabled while isLoading — so in that window it's enabled with currentFolder = null, and confirming yields an empty selection → pickNodes() throws FilePickerClosed("No nodes selected")moveOrCopyAction catches it as "user cancelled" → no COPY is ever sent → the test's waitForResponse hangs to the timeout. On a fast machine the load wins the race (passes); under load (0.2 CPU / slow CI) the abort wins (fails). That's exactly the .mov failure — and why it's intermittent and file-agnostic.

Fix (branch fix/race-conditions in nextcloud-dialogs)

lib/composables/dav.ts — capture this invocation's controller and only clear shared state if it's still the current load:

finally {
  if (abortController === thisAbortController) {
    abortController = undefined
    isLoading.value = false
  }
}

Now a superseded load leaves isLoading true until the real load finishes, so the FilePicker never confirms with a null folder. Added a regression test in dav.spec.ts ("a superseded (aborted) load does not clear the loading state of the newer load").

🤖 AI (if applicable)

  • The content of this PR was partly or fully generated using AI

@DerDreschner

Copy link
Copy Markdown
Contributor Author

@susnux : Please review, I have no rights on the nextcloud-libraries repos.

Comment thread lib/composables/dav.ts
Comment on lines +101 to +104
if (abortController === thisAbortController) {
abortController = undefined
isLoading.value = false
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this can not just be simplified in general to only this change:

			if (abortController && abortController.signal.aborted) {
				abortController = undefined
				isLoading.value = false
			}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll check that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, that makes the tests stall as the condition is never being reached and isLoading is never set to false.

The issue here is that the shared abortController state is being reset on each call of loadDAVFiles. This means the abortController instance being checked in the finally block isn't the same as the one transferred over to the get[...]Nodes functions. So, it's never being aborted.

I'll push a new version that makes the issue a bit clearer when reading the code.

@DerDreschner
DerDreschner force-pushed the fix/race-conditions branch from 4f8848a to fef6bd6 Compare July 18, 2026 13:50

@susnux susnux left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure I like the new version more this is even more code for just the simple issue of having the abort controller removed and tracking this state.

I think in that case your previous code was much easier:

@@ -71,17 +71,17 @@ export function useDAVFiles(
 			abortController = undefined
 		}
 
-		abortController = new AbortController()
+		const localAbortController = abortController = new AbortController()
 		isLoading.value = true
 		try {
 			if (currentView.value === 'favorites') {
-				files.value = await getFavoriteNodes({ client, path: currentPath.value, signal: abortController.signal })
+				files.value = await getFavoriteNodes({ client, path: currentPath.value, signal: localAbortController.signal })
 				folder.value = null
 			} else if (currentView.value === 'recent') {
-				files.value = await getRecentNodes({ client, signal: abortController.signal })
+				files.value = await getRecentNodes({ client, signal: localAbortController.signal })
 				folder.value = null
 			} else {
-				const content = await getNodes({ client, path: currentPath.value, signal: abortController.signal })
+				const content = await getNodes({ client, path: currentPath.value, signal: localAbortController.signal })
 				folder.value = content.folder
 				files.value = content.contents
 			}
@@ -92,6 +92,11 @@ export function useDAVFiles(
 			}
 			throw error
 		} finally {
+			if (localAbortController.signal.aborted && abortController !== localAbortController) {
+				// it was aborted by another call, so we don't want to set the loading state to false
+				return
+			}
+
 			abortController = undefined
 			isLoading.value = false
 		}

Also the added comments here are mostly useless (looks like AI putting too much words somewhere 😅 ) as they just explain whats visible already from the code meaning they only will get outdated overtime causing confusing instead of clarifying things not already explained by the code itself.

Assisted-by: ClaudeCode:claude-fable-5
Signed-off-by: David Dreschner <david.dreschner@nextcloud.com>
@susnux
susnux force-pushed the fix/race-conditions branch from fef6bd6 to 3dc5c7b Compare September 17, 2026 13:13
@susnux susnux added bug Something isn't working 3. to review labels Sep 17, 2026
@susnux
susnux merged commit 0f0b489 into nextcloud-libraries:main Sep 17, 2026
10 checks passed
@susnux

susnux commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

/backport to stable6

@codecov

codecov Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 61.74%. Comparing base (e6d0eb9) to head (3dc5c7b).
⚠️ Report is 12 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2511      +/-   ##
==========================================
+ Coverage   61.16%   61.74%   +0.57%     
==========================================
  Files          15       15              
  Lines         479      481       +2     
  Branches      105      104       -1     
==========================================
+ Hits          293      297       +4     
  Misses        165      165              
+ Partials       21       19       -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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

Labels

3. to review bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants