Full restore goes on where it stopped; SMB recovers from a failed read
CI / unit (8.2) (push) Has been cancelled
CI / unit (8.3) (push) Has been cancelled
CI / unit (8.4) (push) Has been cancelled
CI / psalm (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / integration (mariadb, 32) (push) Has been cancelled
CI / integration (mariadb, 33) (push) Has been cancelled
CI / integration (mariadb, 34) (push) Has been cancelled
CI / integration (mariadb, 35) (push) Has been cancelled
CI / integration (pgsql, 32) (push) Has been cancelled
CI / integration (pgsql, 33) (push) Has been cancelled
CI / integration (pgsql, 34) (push) Has been cancelled
CI / integration (pgsql, 35) (push) Has been cancelled
CI / integration (sqlite, 32) (push) Has been cancelled
CI / integration (sqlite, 33) (push) Has been cancelled
CI / integration (sqlite, 34) (push) Has been cancelled
CI / integration (sqlite, 35) (push) Has been cancelled
CI / integration-targets (push) Has been cancelled
CI / integration-user (32) (push) Has been cancelled
CI / integration-user (33) (push) Has been cancelled
CI / integration-user (34) (push) Has been cancelled
CI / integration-user (35) (push) Has been cancelled
CI / integration-full-restore (push) Has been cancelled

The DR test of 1.0.2 over SMB stopped after 392 GiB of 983: the connection to the Storage Box
dropped, and the smbclient extension then returns a string of length -1 for a failed read (it
stores smbc_read()'s -1 in an unsigned length). The read loop counted that as data and went
backwards forever at 100% CPU. Reads over SMB are now checked; a failed one is read again the
usual way over a new connection.

backup:restore:full that runs again (--force) leaves the data files already in place with the
snapshot's size and modification time, as a backup takes them as unchanged, and reads ahead only
what is still missing: after 392 GiB it goes on instead of starting over.
This commit is contained in:
2026-11-05 08:18:50 +01:00
parent dce93aa75e
commit 029b3b9cc7
5 changed files with 142 additions and 29 deletions
+69 -15
View File
@@ -412,7 +412,8 @@ final class StorageBackend implements IBackend, IReadsAhead {
}
}
} catch (\Throwable) {
// read the usual way below, which reports what is wrong
// read the usual way below, which reports what is wrong, over a new connection
$this->smbReconnect();
}
$fh = $this->get($path);
try {
@@ -555,7 +556,8 @@ final class StorageBackend implements IBackend, IReadsAhead {
try {
$state->lseek($fh, $offset, SEEK_SET, $url);
for ($got = 0; $got < $length;) {
$chunk = (string)$state->read($fh, min(self::SMB_READ, $length - $got), $url);
$want = min(self::SMB_READ, $length - $got);
$chunk = self::smbRead($state, $fh, $want, $url);
if ($chunk === '') {
throw new BackendException('Short read in ' . $url);
}
@@ -590,22 +592,67 @@ final class StorageBackend implements IBackend, IReadsAhead {
} catch (\Throwable) {
return null;
}
$data = '';
try {
$data = '';
do {
/** @psalm-suppress MixedMethodCall */
$chunk = (string)$state->read($fh, self::SMB_READ, $url);
$chunk = self::smbRead($state, $fh, self::SMB_READ, $url);
$data .= $chunk;
} while ($chunk !== '');
return $data;
} catch (\Throwable) {
return null;
} finally {
try {
/** @psalm-suppress MixedMethodCall */
$state->close($fh, $url);
} catch (\Throwable) {
}
$data = null;
}
try {
/** @psalm-suppress MixedMethodCall */
$state->close($fh, $url);
} catch (\Throwable) {
}
if ($data === null) {
$this->smbReconnect(); // a read failed: the usual way reads it over a new connection
}
return $data;
}
/**
* One read of at most $bytes through the native state, checked. When libsmbclient fails a read
* (seen when the connection to the server dropped during a long restore), the smbclient
* extension does not return false but a string whose length is -1, because it stores
* smbc_read()'s -1 in an unsigned length; icewind passes it on. Counting that as data made
* the read loop go backwards forever at 100% CPU (DR test 2026-11-05, after 392 GiB).
*
* @param resource $fh
* @psalm-suppress MixedMethodCall, MixedArgument the state is icewind's NativeState
*/
private static function smbRead(object $state, $fh, int $bytes, string $url): string {
$chunk = $state->read($fh, $bytes, $url);
$n = is_string($chunk) ? strlen($chunk) : -1;
if ($n < 0 || $n > $bytes) {
throw new BackendException('Read failed in ' . $url);
}
return $chunk;
}
/**
* After a failed read: drop icewind's connection state, so the next read (the usual way, or
* the next range) connects again instead of failing on a connection that is gone.
*
* @psalm-suppress UndefinedClass icewind/smb comes with files_external, not with this app
*/
private function smbReconnect(): void {
if (!$this->storage instanceof \OCA\Files_External\Lib\Storage\SMB) {
return;
}
try {
(function (): void {
/** @psalm-suppress UndefinedThisPropertyFetch */
$share = $this->share;
if ($share instanceof \Icewind\SMB\Native\NativeShare) {
(function (): void {
/** @psalm-suppress UndefinedThisPropertyAssignment */
$this->state = null;
})->call($share);
}
})->call($this->storage);
} catch (\Throwable) {
}
}
@@ -657,12 +704,15 @@ final class StorageBackend implements IBackend, IReadsAhead {
try {
foreach ($abs as $i => [$path, $offset, $length]) {
$fh = null;
$out = false;
try {
$chunks = $this->smbChunks($path, $offset, $length);
$out = $chunks === null ? false : fopen($file, 'w+b');
if ($chunks !== null && $out !== false) {
foreach ($chunks as $chunk) {
fwrite($out, $chunk);
if (fwrite($out, $chunk) !== strlen($chunk)) {
throw new BackendException('Cannot write to the spool');
}
}
$fh = ftell($out) === $length && rewind($out) ? $out : null;
if ($fh === null) {
@@ -670,7 +720,11 @@ final class StorageBackend implements IBackend, IReadsAhead {
}
}
} catch (\Throwable) {
$fh = null; // read the usual way, which reports why
if (is_resource($out)) {
fclose($out);
}
$fh = null; // read the usual way, which reports why, over a new connection
$this->smbReconnect();
} finally {
@unlink($file); // an open stream keeps its data
}