Skip to content

Commit e463fc9

Browse files
committed
fix(dav): release part-file lock when an upload fails
Signed-off-by: Git'Fellow <12234510+solracsf@users.noreply.github.com>
1 parent 39d2dc2 commit e463fc9

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
@@ -125,11 +125,16 @@ public function createFile($name, $data = null) {
125125
$node->acquireLock(ILockingProvider::LOCK_SHARED);
126126
$this->fileView->lockFile($this->path . '/' . $name . '.upload.part', ILockingProvider::LOCK_EXCLUSIVE);
127127

128-
$result = $node->put($data);
129-
130-
$this->fileView->unlockFile($this->path . '/' . $name . '.upload.part', ILockingProvider::LOCK_EXCLUSIVE);
131-
$node->releaseLock(ILockingProvider::LOCK_SHARED);
132-
return $result;
128+
try {
129+
return $node->put($data);
130+
} finally {
131+
// Always release the locks, even when the upload failed or was
132+
// interrupted. Otherwise a failed attempt leaves the exclusive
133+
// part-file lock behind and every later upload to the same path
134+
// keeps getting rejected with 423 until the lock TTL expires.
135+
$this->fileView->unlockFile($this->path . '/' . $name . '.upload.part', ILockingProvider::LOCK_EXCLUSIVE);
136+
$node->releaseLock(ILockingProvider::LOCK_SHARED);
137+
}
133138
} catch (StorageNotAvailableException $e) {
134139
throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage(), $e->getCode(), $e);
135140
} 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
@@ -25,6 +25,7 @@
2525
use OCP\Files\Mount\IMountPoint;
2626
use OCP\Files\Storage\IStorage;
2727
use OCP\Files\StorageNotAvailableException;
28+
use OCP\Lock\ILockingProvider;
2829
use PHPUnit\Framework\MockObject\MockObject;
2930
use Sabre\DAV\Exception\NotFound;
3031
use Test\Traits\UserTrait;
@@ -173,6 +174,47 @@ public function testDeleteFolderThrowsWhenDeletionFailed(): void {
173174
$dir->delete();
174175
}
175176

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

0 commit comments

Comments
 (0)