Skip to content

Commit 599007d

Browse files
Merge pull request #62215 from nextcloud/backport/61863/stable33
[stable33] fix(dav): release part-file lock when an upload fails
2 parents c6d90b2 + 041918c commit 599007d

2 files changed

Lines changed: 52 additions & 5 deletions

File tree

apps/dav/lib/Connector/Sabre/Directory.php

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,11 +123,16 @@ public function createFile($name, $data = null) {
123123
$node->acquireLock(ILockingProvider::LOCK_SHARED);
124124
$this->fileView->lockFile($this->path . '/' . $name . '.upload.part', ILockingProvider::LOCK_EXCLUSIVE);
125125

126-
$result = $node->put($data);
127-
128-
$this->fileView->unlockFile($this->path . '/' . $name . '.upload.part', ILockingProvider::LOCK_EXCLUSIVE);
129-
$node->releaseLock(ILockingProvider::LOCK_SHARED);
130-
return $result;
126+
try {
127+
return $node->put($data);
128+
} finally {
129+
// Always release the locks, even when the upload failed or was
130+
// interrupted. Otherwise a failed attempt leaves the exclusive
131+
// part-file lock behind and every later upload to the same path
132+
// keeps getting rejected with 423 until the lock TTL expires.
133+
$this->fileView->unlockFile($this->path . '/' . $name . '.upload.part', ILockingProvider::LOCK_EXCLUSIVE);
134+
$node->releaseLock(ILockingProvider::LOCK_SHARED);
135+
}
131136
} catch (StorageNotAvailableException $e) {
132137
throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage(), $e->getCode(), $e);
133138
} catch (InvalidPathException $ex) {

apps/dav/tests/unit/Connector/Sabre/DirectoryTest.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use OCP\Files\Mount\IMountPoint;
2525
use OCP\Files\Storage\IStorage;
2626
use OCP\Files\StorageNotAvailableException;
27+
use OCP\Lock\ILockingProvider;
2728
use PHPUnit\Framework\MockObject\MockObject;
2829
use Sabre\DAV\Exception\NotFound;
2930
use Test\Traits\UserTrait;
@@ -178,6 +179,47 @@ public function testDeleteFolderThrowsWhenDeletionFailed(): void {
178179
$dir->delete();
179180
}
180181

182+
/**
183+
* A failed or interrupted upload must not leave the exclusive part-file
184+
* lock behind. Otherwise every later upload to the same path keeps getting
185+
* rejected with "423 Locked" until the lock TTL expires (up to an hour),
186+
* createFile() therefore releases the locks in a finally block.
187+
*/
188+
public function testCreateFileReleasesPartFileLockOnFailure(): void {
189+
$name = 'foo.txt';
190+
191+
$this->view->method('getRelativePath')->willReturnArgument(0);
192+
$this->view->method('getAbsolutePath')->willReturnArgument(0);
193+
$this->view->method('isCreatable')->willReturn(true);
194+
// the target does not exist yet
195+
$this->view->method('getFileInfo')->willReturn(false);
196+
// make File::put() fail right after the locks have been acquired
197+
$this->view->method('resolvePath')->willReturn([null, null]);
198+
199+
$released = [];
200+
$this->view->method('unlockFile')
201+
->willReturnCallback(function (string $path, int $type) use (&$released): bool {
202+
$released[] = [$path, $type];
203+
return true;
204+
});
205+
206+
$dir = new Directory($this->view, $this->info);
207+
$partLockPath = $dir->getPath() . '/' . $name . '.upload.part';
208+
209+
try {
210+
$dir->createFile($name, 'test data');
211+
$this->fail('Expected the failing upload to throw');
212+
} catch (\Sabre\DAV\Exception\ServiceUnavailable) {
213+
// expected: File::put() cannot resolve the storage
214+
}
215+
216+
$this->assertContains(
217+
[$partLockPath, ILockingProvider::LOCK_EXCLUSIVE],
218+
$released,
219+
'The exclusive .upload.part lock must be released after a failed upload',
220+
);
221+
}
222+
181223
public function testGetChildren(): void {
182224
$info1 = $this->createMock(FileInfo::class);
183225
$info2 = $this->createMock(FileInfo::class);

0 commit comments

Comments
 (0)