Skip to content

[Bug]: PaginatePlugin TypeError (XMLWriter::writeRaw(): null given) when pagination cache entries are evicted #62538

Description

@Rogue404

⚠️ This issue respects the following points: ⚠️

Bug description

OCA\DAV\Paginate\PaginatePlugin::onMethod() passes cached entries straight to XMLWriter::writeRaw() without checking for null, producing an uncaught TypeError and an HTTP 500 whenever the pagination cache has lost individual entries.

PaginateCache stores one cache entry per result row plus a separate count key:

// apps/dav/lib/Paginate/PaginateCache.php (store)
foreach ($items as $item) {
// Add small margin to avoid fetching valid count and then expired entries
$this->cache->set($cacheKey . $count, $item, self::TTL + 60);
++$count;
}
$this->cache->set($cacheKey . self::CACHE_COUNT_SUFFIX, $count, self::TTL);
The TTL + 60 margin defends against time-based expiry only. It does not defend against eviction (the cache dropping entries under memory pressure) or against an individual set() silently failing. When either happens, the count key can outlive the entries it counts, so:

// apps/dav/lib/Paginate/PaginateCache.php (get)
$nbItems = $this->cache->get($cacheKey . self::CACHE_COUNT_SUFFIX);
if (!$nbItems || $offset > $nbItems) {
return [];
}
$lastItem = min($nbItems, $offset + $count);
for ($i = $offset; $i < $lastItem; ++$i) {
yield $this->cache->get($cacheKey . $i); // <-- null for an evicted entry
}
…yields null, which reaches:

// apps/dav/lib/Paginate/PaginatePlugin.php (onMethod)
foreach ($items as $item) {
$writer->writeRaw($item); // <-- TypeError on null
}
This is reachable on any instance where the cache backing PaginateCache can evict. It is especially easy to hit when memcache.distributed is not configured, because OC\Memcache\Factory falls back to the local cache:

// lib/private/Memcache/Factory.php
if (!$distributedCacheClass) {
$distributedCacheClass = $localCacheClass;
}
…so with the common memcache.local => APCu setup and APCu's default apc.shm_size = 32M, an hour-long TTL (PaginateCache::TTL = 3600) with one entry per result row will evict under normal load. Affects SEARCH, PROPFIND and REPORT — all three are bound to onMethod().

We observed 88 occurrences over 10 days across 5 users, all from Nextcloud iOS clients (33.1.x and 34.0.0).

Steps to reproduce

1.Configure 'memcache.local' => '\OC\Memcache\APCu' and leave memcache.distributed unset.
2.Leave apc.shm_size at the 32M default.
3.Generate enough cache churn that APCu evicts some pagination entries while the count key survives (repeated searches by several users over the 1-hour TTL will do it).
4.The next paginated request for that token returns HTTP 500.

Expected behavior

The server should never emit a TypeError. If a cached page is incomplete, it should be treated like an unknown or expired token — fall through and re-run the query — so the client receives a complete, correct result.

It should specifically not skip the null entries: that would return a silently truncated multistatus, i.e. the user would see a file listing with items missing and no error shown.

Nextcloud Server version

34

Operating system

Debian/Ubuntu

PHP engine version

PHP 8.3

Web server

Nginx

Database engine version

MySQL

Is this bug present after an update or on a fresh install?

Upgraded to a MAJOR version (ex. 31 to 32)

Are you using the Nextcloud Server Encryption module?

Encryption is Disabled

What user-backends are you using?

  • Default user-backend (database)
  • LDAP/ Active Directory
  • SSO - SAML
  • Other

Configuration report

List of activated Apps

Nextcloud Signing status

Nextcloud Logs

{
  "level": 3,
  "app": "webdav",
  "method": "SEARCH",
  "url": "/remote.php/dav",
  "message": "XMLWriter::writeRaw(): Argument #1 ($content) must be of type string, null given",
  "userAgent": "Mozilla/5.0 (iOS) Nextcloud-iOS/34.0.0",
  "version": "34.0.1.2",
  "exception": {
    "Exception": "TypeError",
    "Message": "XMLWriter::writeRaw(): Argument #1 ($content) must be of type string, null given",
    "File": "/var/www/nextcloud/apps/dav/lib/Paginate/PaginatePlugin.php",
    "Line": 140,
    "Trace": [
      { "file": "apps/dav/lib/Paginate/PaginatePlugin.php", "line": 140, "function": "writeRaw", "class": "XMLWriter" },
      { "file": "3rdparty/sabre/event/lib/WildcardEmitterTrait.php", "line": 89, "function": "onMethod", "class": "OCA\\DAV\\Paginate\\PaginatePlugin" },
      { "file": "3rdparty/sabre/dav/lib/DAV/Server.php", "line": 472, "function": "emit", "class": "Sabre\\DAV\\Server" },
      { "file": "apps/dav/lib/Connector/Sabre/Server.php", "line": 215, "function": "invokeMethod", "class": "Sabre\\DAV\\Server" },
      { "file": "apps/dav/lib/Server.php", "line": 430, "function": "start", "class": "OCA\\DAV\\Connector\\Sabre\\Server" }
    ]
  }
}

Additional info

Suggested fix — materialise the page, and if any entry is missing, bail out before the response is mutated so the request falls through to a fresh query (identical to the existing unknown-token path, which already works):

$items = iterator_to_array($this->cache->get($url, $token, $offset, $count), false);

if (in_array(null, $items, true)) {
// The count key can outlive individual entries (eviction, or a failed store).
// Emitting them crashes writeRaw(); skipping them would silently return an
// incomplete listing. Treat it as an unknown/expired token instead.
\OCP\Server::get(\Psr\Log\LoggerInterface::class)->warning(
'Paginated DAV cache incomplete; falling back to a fresh search',
['app' => 'dav', 'url' => $url, 'offset' => $offset, 'count' => $count]
);

return null;

}
Returning null here continues sabre's method:* emit chain, so the real handler runs — the same path taken today when PaginateCache::exists() returns false. Note the guard must come before $response->setStatus(207) and the header calls, otherwise the response is already partially mutated when it falls through.

We are running this as a local patch and are happy to open a PR if the approach looks right.

A second, arguably deeper question: PaginateCache relies on entries surviving for their TTL, which the admin docs explicitly say cache backends do not guarantee (the docs recommend Redis for memcache.locking precisely because "data can disappear from the cache at any time"). Even with the guard above, a large paginated result will silently restart from a fresh query whenever it is evicted. It may be worth either documenting that pagination wants a durable cache, or storing the count alongside the entries so partial loss is detectable without scanning.

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    Status
    To triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions