Skip to content

WOPI PutFile fails with 500 (uncaught TypeError from files_versions) leading to save failures and conflict hints #5770

@juliusknorr

Description

@juliusknorr

Summary

On a production system (Nextcloud server 34, COOLWSD 25.04, richdocuments) saves from Collabora intermittently fail. Collabora reports a conflict on the file and then fails to save. Investigation shows two distinct but related symptoms:

  1. Save failure (HTTP 500): A TypeError thrown inside the files_versions write hook during putContent() escapes WopiController::putFile() uncaught and returns a bare 500 {"message":"Error"} to Collabora.
  2. Conflict hint: Collabora's own timestamp-based conflict detection raises documentconflict — partly as a downstream effect of the failed/retried saves, partly via the WOPI timestamp comparison.

Update — the 500 is a known nextcloud/server bug, already fixed on master: nextcloud/server#60842 "fix(files_versions): guard null path in event listeners" (milestone NC 35, merged 2026-06-11) guards the five callers of getPathForNode() from passing null downstream — it explicitly addresses this basicOperation(): Argument #2 ($path) must be of type string, null given in public-link/groupfolder scenarios. Backports are in flight, including stable34 (#61189), stable33 (#61188), stable32 (#61187), stable31 (#61329). The production version 34.0.0.11 predates the stable34 backport, which is why it still crashes. (An earlier, related but different null fix was #51609, NC 32.) The richdocuments-side hardening below is still worthwhile so a hook \Error no longer surfaces as an uncaught 500.

Logs

Nextcloud (richdocuments), on POST …/wopi/files/12049115_…/contents:

Uncaught error: OC\Files\View::basicOperation(): Argument #2 ($path) must be of type string, null given,
called in …/View.php on line 525 in file …/View.php line 1204

Previous (root) exception:

TypeError: OC\Files\View::basicOperation(): Argument #2 ($path) must be of type string, null given
#0 …/View.php(525): OC\Files\View->basicOperation()
#1 …/Filesystem.php(496): OC\Files\View->file_exists()
#2 …/apps/files_versions/lib/Storage.php(165): OC\Files\Filesystem::file_exists()
#3 …/apps/files_versions/lib/Listener/FileEventsListener.php(210): OCA\Files_Versions\Storage::store()
#4 …/apps/files_versions/lib/Listener/FileEventsListener.php(83): …->write_hook()
…
#15 …/apps/richdocuments/lib/Controller/WopiController.php(665): OC\Files\Node\File->putContent()
#16 …/apps/richdocuments/lib/Controller/WopiController.php(935): {closure …putFile():665}()
#17 …/apps/richdocuments/lib/Controller/WopiController.php(912): …->retryOperation()
#18 …/lib/private/Files/Lock/LockManager.php(73): {closure …wrappedFilesystemOperation():911}()
#19 …/apps/richdocuments/lib/Controller/WopiController.php(915): …->runInScope()
#20 …/apps/richdocuments/lib/Controller/WopiController.php(665): …->wrappedFilesystemOperation()
#21 …/lib/private/AppFramework/Http/Dispatcher.php(165): …->putFile()

COOLWSD (same docKey, note the two access tokens):

WOPI::PutFile … access_token=wrsr… : 500 (Internal Server Error) Internal Server Error: {"message":"Error"}
ERR  Unexpected response to WOPI::PutFile. Cannot upload file to WOPI storage … 500 …
ERR  Failed to upload docKey … Notifying client.
WOPI::PutFile … access_token=0C6W… : 200 (OK) OK: {"LastModifiedTime":"2026-06-16T12:21:56.000000Z"}
… After failing to upload […], the size on WOPI host matches our uploaded last uploaded size: 1804864 bytes.
  We will assume this is our last uploaded version and synchronize the timestamp …

The wrsr… token consistently returns 500; the 0C6W… token consistently returns 200 for the same document broker (docbroker_5f67).

Root cause of the 500

  1. WopiController::putFile() calls $file->putContent($content) (lib/Controller/WopiController.php:663).
  2. putContent fires the filesystem pre-write hook before writing.
  3. files_versions listens: FileEventsListener::write_hook() computes $path = $this->getPathForNode($node) and passes it to Storage::store($path). On stable34 there is no null check; getPathForNode() can return null (e.g. public-link / groupfolder contexts). Storage::store() then runs Filesystem::file_exists($path) at Storage.php:165.
  4. Filesystem::file_exists(null)View::basicOperation(…, null)TypeError.
  5. A TypeError extends \Error, not \Exception, so the catch (\Exception $e) in putFile() (lib/Controller/WopiController.php:677) does not catch it. It escapes to the AppFramework dispatcher → generic 500 {"message":"Error"}. → fixed upstream by fix(files_versions): guard null path in event listeners server#60842.

Why it is per-token / intermittent

getPathForNode() resolves the node path against the current user, falling back to the owner. richdocuments sets setUserScope($wopi->getEditorUid()) / setFilesystemScope($wopi->getUserForFileAccess()) (lib/Controller/WopiController.php:633-634, :967-970) and fetches the node from getUserForFileAccess()'s folder. For a token where the editor context differs from the owner (guest/share/groupfolder editor — see Wopi::getUserForFileAccess(), lib/Db/Wopi.php:158), the path resolution can fail and return null. The owner's token resolves fine, which is why one token 500s and another succeeds.

The conflict hint

This is a separate Collabora mechanism (verified in CollaboraOnline/online.mirror, wsd/DocumentBroker.cpp). The user-visible conflict is handleDocumentConflict()error: cmd=load kind=documentconflict, triggered in download() / CheckFileInfo handling when the storage LastModifiedTime differs from what Collabora recorded after its last save (and it is not mid-upload):

if (!_storageManager.getLastModifiedServerTimeString().empty() &&
    !fileInfo.getLastModifiedServerTimeString().empty() &&
    _storageManager.getLastModifiedServerTimeString() != fileInfo.getLastModifiedServerTimeString()) {
    // … "Document has been modified behind our back. Informing all clients" → handleDocumentConflict();
}

richdocuments returns LastModifiedTime as Helper::toISO8601($file->getMTime()) in both CheckFileInfo (lib/Controller/WopiController.php:196) and the PutFile response (:673). Reasons the timestamp can mismatch and trigger a conflict:

  • The save failures themselves: the failing token's content is never written (it throws in the pre-write hook), while the retry on the other token succeeds and bumps mtime. The failing session then sees a changed mtime → conflict.
  • richdocuments' own guard: putFile() returns 409 COOLStatusCode=1010 (COOL_STATUS_DOC_CHANGED) when X-COOL-WOPI-TimestamptoISO8601(getMTime()) (lib/Controller/WopiController.php:645-651).
  • Second-precision timestamps: toISO8601() builds from integer seconds and always formats .000000 (lib/Helper.php:64-69, see its own TODO: Be more precise…). Sub-second/1-second drift is enough for a string mismatch.
  • Another writer (other editor/session, sync client, server-side rewrite) or object/S3 storage mtime behavior changing mtime between save and CheckFileInfo.

Suggested fixes

  1. Primary (nextcloud/server): done on master via #60842; ensure the stable34 backport (#61189) lands so deployments on 34.x get it. This is the real fix for the 500. -> [stable34] fix(files_versions): guard null path in event listeners server#61189 as backport
  2. richdocuments hardening: widen the catch in WopiController::putFile() to \Throwable so an \Error from a hook is handled and logged with context instead of escaping as an uncaught 500. (Does not make the failing save succeed, but avoids the bare 500 and improves diagnostics.)
  3. Optional: revisit Helper::toISO8601() second-only precision to reduce spurious timestamp-mismatch conflicts.

Environment

  • Nextcloud server: 34.0.0.11
  • COOLWSD: 25.04.8.3
  • richdocuments: as deployed on cloud.nextcloud.com

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions