From 6b4737f4798507c8538ddff99927262a58b81911 Mon Sep 17 00:00:00 2001 From: lazerg Date: Tue, 3 Nov 2026 11:52:59 +0500 Subject: [PATCH] fix(lock): keep first saved TTL across nested shared locks Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: lazerg --- lib/private/Lock/MemcacheLockingProvider.php | 10 ++-- .../lib/Lock/MemcacheLockingProviderTest.php | 56 +++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/lib/private/Lock/MemcacheLockingProvider.php b/lib/private/Lock/MemcacheLockingProvider.php index 51a3ca26ec764..8aba1d6ce659f 100644 --- a/lib/private/Lock/MemcacheLockingProvider.php +++ b/lib/private/Lock/MemcacheLockingProvider.php @@ -64,10 +64,12 @@ public function isLocked(string $path, int $type): bool { public function acquireLock(string $path, int $type, ?string $readablePath = null): void { if ($type === self::LOCK_SHARED) { // save the old TTL to for `restoreTTL` - $this->oldTTLs[$path] = [ - 'ttl' => $this->getTTL($path), - 'time' => $this->timeFactory->getTime() - ]; + if ($this->getOwnSharedLockCount($path) === 0) { + $this->oldTTLs[$path] = [ + 'ttl' => $this->getTTL($path), + 'time' => $this->timeFactory->getTime() + ]; + } if (!$this->memcache->inc($path)) { throw new LockedException($path, null, $this->getExistingLockForException($path), $readablePath); } diff --git a/tests/lib/Lock/MemcacheLockingProviderTest.php b/tests/lib/Lock/MemcacheLockingProviderTest.php index 492f474c33ced..3d7025b6e54e2 100644 --- a/tests/lib/Lock/MemcacheLockingProviderTest.php +++ b/tests/lib/Lock/MemcacheLockingProviderTest.php @@ -12,7 +12,9 @@ use OC\Memcache\ArrayCache; use OCP\AppFramework\Utility\ITimeFactory; use OCP\IMemcache; +use OCP\IMemcacheTTL; use OCP\Lock\ILockingProvider; +use OCP\Lock\LockedException; use OCP\Server; class MemcacheLockingProviderTest extends LockingProvider { @@ -21,6 +23,8 @@ class MemcacheLockingProviderTest extends LockingProvider { */ private $memcache; + private int $now = 0; + /** * @return ILockingProvider */ @@ -36,4 +40,56 @@ protected function tearDown(): void { $this->memcache->clear(); parent::tearDown(); } + + public function testFailedExclusiveLockRestoresTTLAfterNestedSharedLock(): void { + $timeFactory = $this->createMock(ITimeFactory::class); + $timeFactory->method('getTime') + ->willReturnCallback(fn () => $this->now); + $memcache = new class($timeFactory) extends ArrayCache implements IMemcacheTTL { + private array $expires = []; + + public function __construct( + private ITimeFactory $timeFactory, + ) { + parent::__construct(); + } + + #[\Override] + public function setTTL(string $key, int $ttl) { + $this->expires[$key] = $this->timeFactory->getTime() + $ttl; + } + + #[\Override] + public function getTTL(string $key): int|false { + return isset($this->expires[$key]) ? $this->expires[$key] - $this->timeFactory->getTime() : false; + } + + #[\Override] + public function compareSetTTL(string $key, $value, int $ttl): bool { + if ($this->get($key) !== $value) { + return false; + } + $this->setTTL($key, $ttl); + return true; + } + }; + + $this->now = 1000; + $leaked = new MemcacheLockingProvider($memcache, $timeFactory); + $leaked->acquireLock('foo', ILockingProvider::LOCK_SHARED); + + $this->now = 2000; + $provider = new MemcacheLockingProvider($memcache, $timeFactory); + $provider->acquireLock('foo', ILockingProvider::LOCK_SHARED); + $provider->acquireLock('foo', ILockingProvider::LOCK_SHARED); + $provider->releaseLock('foo', ILockingProvider::LOCK_SHARED); + try { + $provider->changeLock('foo', ILockingProvider::LOCK_EXCLUSIVE); + $this->fail('Expected LockedException'); + } catch (LockedException) { + } + $provider->releaseLock('foo', ILockingProvider::LOCK_SHARED); + + $this->assertEquals(2600, $memcache->getTTL('foo')); + } }