diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index df0c79f..1137e0e 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -64,7 +64,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] + php: ['8.4', '8.5'] type: ['Phpunit', 'Phpunit Lowest'] include: - php: 'latest' diff --git a/composer.json b/composer.json index 3c96fd1..8b50270 100644 --- a/composer.json +++ b/composer.json @@ -33,11 +33,12 @@ ], "homepage": "https://github.com/malkusch/lock", "require": { - "php": ">=7.4 <8.5", + "php": ">=8.2 <8.6", "psr/log": "^1.0 || ^2.0 || ^3.0", "symfony/polyfill-php80": "^1.28" }, "require-dev": { + "cweagans/composer-patches": "^1.7", "ext-igbinary": "*", "ext-lzf": "*", "ext-memcached": "*", @@ -47,7 +48,6 @@ "ext-pdo_sqlite": "*", "ext-redis": "*", "ext-sysvsem": "*", - "eloquent/liberator": "^2.0 || ^3.0", "ergebnis/composer-normalize": "^2.13", "ergebnis/phpunit-slow-test-detector": "^2.9", "friendsofphp/php-cs-fixer": "^3.0", @@ -57,7 +57,7 @@ "phpstan/phpstan": "^2.0", "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^9.5.25 || ^10.0 || ^11.0", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", "predis/predis": "^1.1.8 || ^2.0", "spatie/async": "^1.5" }, @@ -86,9 +86,17 @@ }, "config": { "allow-plugins": { + "cweagans/composer-patches": true, "ergebnis/composer-normalize": true, "phpstan/extension-installer": true }, "sort-packages": true + }, + "extra": { + "patches": { + "spatie/async": { + "Fix ArrayAccess offsetExists signature": "patches/spatie-async-offsetexists.patch" + } + } } } diff --git a/patches/spatie-async-offsetexists.patch b/patches/spatie-async-offsetexists.patch new file mode 100644 index 0000000..8113879 --- /dev/null +++ b/patches/spatie-async-offsetexists.patch @@ -0,0 +1,12 @@ +diff --git a/src/Pool.php b/src/Pool.php +index 3ed9c41..4d1f7c8 100644 +--- a/src/Pool.php ++++ b/src/Pool.php +@@ -248,7 +248,7 @@ class Pool implements ArrayAccess, IteratorAggregate + $this->failed[$process->getPid()] = $process; + } + +- public function offsetExists($offset): bool ++ public function offsetExists(mixed $offset): bool + { + // TODO diff --git a/src/Mutex/DistributedMutex.php b/src/Mutex/DistributedMutex.php index 8ccc862..e84c182 100644 --- a/src/Mutex/DistributedMutex.php +++ b/src/Mutex/DistributedMutex.php @@ -57,7 +57,13 @@ protected function acquireWithToken(string $key, float $expireTimeout) $exception = null; foreach ($this->getMutexesInRandomOrder() as $index => $mutex) { try { - if ($this->acquireMutex($mutex, $key, $acquireTimeout - (microtime(true) - $startTs), $expireTimeout)) { + $remainingTimeout = $acquireTimeout - (microtime(true) - $startTs); + // Prevent INF/NAN from being passed to acquireMutex + if (is_infinite($remainingTimeout) || is_nan($remainingTimeout) || $remainingTimeout < 0) { + $remainingTimeout = 0.0; + } + + if ($this->acquireMutex($mutex, $key, $remainingTimeout, $expireTimeout)) { $acquiredIndexes[] = $index; } } catch (LockAcquireException $exception) { diff --git a/src/Util/LockUtil.php b/src/Util/LockUtil.php index d07add1..c850aa5 100644 --- a/src/Util/LockUtil.php +++ b/src/Util/LockUtil.php @@ -71,11 +71,20 @@ public function castFloatToInt(float $value): int */ public function formatTimeout(float $value): string { + // Handle NaN explicitly (normalize to a safe numeric string) + if (\is_nan($value)) { + return 'NAN'; + } + + // Handle infinities explicitly + if (!\is_finite($value)) { + return $value > 0 ? 'INF' : '-INF'; + } + $res = (string) round($value, 6); - if (\is_finite($value) && strpos($res, '.') === false) { + if (strpos($res, '.') === false) { $res .= '.0'; } - return $res; } } diff --git a/tests/Mutex/FlockMutexTest.php b/tests/Mutex/FlockMutexTest.php index cd147d1..e6ecb30 100644 --- a/tests/Mutex/FlockMutexTest.php +++ b/tests/Mutex/FlockMutexTest.php @@ -4,10 +4,11 @@ namespace Malkusch\Lock\Tests\Mutex; -use Eloquent\Liberator\Liberator; +require_once __DIR__ . '/../TestAccess.php'; use Malkusch\Lock\Exception\DeadlineException; use Malkusch\Lock\Exception\LockAcquireTimeoutException; use Malkusch\Lock\Mutex\FlockMutex; +use Malkusch\Lock\Tests\TestAccess; use Malkusch\Lock\Util\LockUtil; use Malkusch\Lock\Util\PcntlTimeout; use PHPUnit\Framework\Attributes\DataProvider; @@ -28,7 +29,7 @@ protected function setUp(): void $this->file = LockUtil::getInstance()->makeRandomTemporaryFilePath('flock'); touch($this->file); - $this->mutex = Liberator::liberate(new FlockMutex(fopen($this->file, 'r'), 1)); // @phpstan-ignore assign.propertyType + $this->mutex = new FlockMutex(fopen($this->file, 'r'), 1); } #[\Override] @@ -40,14 +41,12 @@ protected function tearDown(): void } /** - * @param FlockMutex::STRATEGY_* $strategy - * - * @dataProvider provideTimeoutableStrategiesCases + * @throws \Throwable */ #[DataProvider('provideTimeoutableStrategiesCases')] public function testCodeExecutedOutsideLockIsNotThrown(string $strategy): void { - $this->mutex->strategy = $strategy; // @phpstan-ignore property.private + (new TestAccess($this->mutex))->setProperty('strategy', $strategy); self::assertTrue($this->mutex->synchronized(static function () { // @phpstan-ignore staticMethod.alreadyNarrowedType usleep(1100 * 1000); @@ -57,9 +56,7 @@ public function testCodeExecutedOutsideLockIsNotThrown(string $strategy): void } /** - * @param FlockMutex::STRATEGY_* $strategy - * - * @dataProvider provideTimeoutableStrategiesCases + * @throws \Throwable */ #[DataProvider('provideTimeoutableStrategiesCases')] public function testAcquireTimeoutOccurs(string $strategy): void @@ -67,7 +64,7 @@ public function testAcquireTimeoutOccurs(string $strategy): void $anotherResource = fopen($this->file, 'r'); flock($anotherResource, \LOCK_EX); - $this->mutex->strategy = $strategy; // @phpstan-ignore property.private + (new TestAccess($this->mutex))->setProperty('strategy', $strategy); $this->expectException(LockAcquireTimeoutException::class); $this->expectExceptionMessage('Lock acquire timeout of 1.0 seconds has been exceeded'); @@ -101,8 +98,10 @@ public function testNoTimeoutWaitsForever(): void $anotherResource = fopen($this->file, 'r'); flock($anotherResource, \LOCK_EX); - $this->mutex->strategy = \Closure::bind(static fn () => FlockMutex::STRATEGY_BLOCK, null, FlockMutex::class)(); // @phpstan-ignore property.private - + (new TestAccess($this->mutex))->setProperty( + 'strategy', + \Closure::bind(static fn () => FlockMutex::STRATEGY_BLOCK, null, FlockMutex::class)() + ); $timebox = new PcntlTimeout(1); $this->expectException(DeadlineException::class); diff --git a/tests/Mutex/MutexConcurrencyTest.php b/tests/Mutex/MutexConcurrencyTest.php index 76a39e9..d8b78a1 100644 --- a/tests/Mutex/MutexConcurrencyTest.php +++ b/tests/Mutex/MutexConcurrencyTest.php @@ -4,7 +4,7 @@ namespace Malkusch\Lock\Tests\Mutex; -use Eloquent\Liberator\Liberator; +require_once __DIR__ . '/../TestAccess.php'; use Malkusch\Lock\Mutex\DistributedMutex; use Malkusch\Lock\Mutex\FlockMutex; use Malkusch\Lock\Mutex\MemcachedMutex; @@ -13,9 +13,9 @@ use Malkusch\Lock\Mutex\PostgreSQLMutex; use Malkusch\Lock\Mutex\RedisMutex; use Malkusch\Lock\Mutex\SemaphoreMutex; +use Malkusch\Lock\Tests\TestAccess; use Malkusch\Lock\Util\LockUtil; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Constraint\IsType; use PHPUnit\Framework\TestCase; use Predis\Client as PredisClient; use Spatie\Async\Pool; @@ -66,8 +66,6 @@ private function fork(int $concurrency, \Closure $code): void * @param \Closure(0|1): int $code The counter code * @param \Closure(float): Mutex $mutexFactory * @param \Closure(): void $setUp - * - * @dataProvider provideHighContentionCases */ #[DataProvider('provideHighContentionCases')] public function testHighContention(\Closure $code, \Closure $mutexFactory, ?\Closure $setUp = null): void @@ -124,8 +122,6 @@ static function () use ($filename) { * Tests that five processes run sequentially. * * @param \Closure(float): Mutex $mutexFactory - * - * @dataProvider provideExecutionIsSerializedWhenLockedCases */ #[DataProvider('provideExecutionIsSerializedWhenLockedCases')] public function testExecutionIsSerializedWhenLocked(\Closure $mutexFactory): void @@ -163,19 +159,19 @@ public static function provideExecutionIsSerializedWhenLockedCases(): iterable if (extension_loaded('pcntl')) { yield 'flockWithTimoutPcntl' => [static function ($timeout) use ($filename) { $file = fopen($filename, 'w'); - $lock = Liberator::liberate(new FlockMutex($file, $timeout)); - $lock->strategy = \Closure::bind(static fn () => FlockMutex::STRATEGY_PCNTL, null, FlockMutex::class)(); // @phpstan-ignore property.notFound + $lock = new FlockMutex($file, $timeout); + (new TestAccess($lock))->setProperty('strategy', \Closure::bind(static fn () => FlockMutex::STRATEGY_PCNTL, null, FlockMutex::class)()); - return $lock->popsValue(); + return (new TestAccess($lock))->popsValue(); }]; } yield 'flockWithTimoutLoop' => [static function ($timeout) use ($filename) { $file = fopen($filename, 'w'); - $lock = Liberator::liberate(new FlockMutex($file, $timeout)); - $lock->strategy = \Closure::bind(static fn () => FlockMutex::STRATEGY_LOOP, null, FlockMutex::class)(); // @phpstan-ignore property.notFound + $lock = new FlockMutex($file, $timeout); + (new TestAccess($lock))->setProperty('strategy', \Closure::bind(static fn () => FlockMutex::STRATEGY_LOOP, null, FlockMutex::class)()); - return $lock->popsValue(); + return (new TestAccess($lock))->popsValue(); }]; if (extension_loaded('sysvsem')) { @@ -185,7 +181,7 @@ public static function provideExecutionIsSerializedWhenLockedCases(): iterable $semaphore, self::logicalOr( self::isInstanceOf(\SysvSemaphore::class), - new IsType(IsType::TYPE_RESOURCE) + TestAccess::phpunitIsType('resource') ) ); diff --git a/tests/Mutex/MutexTest.php b/tests/Mutex/MutexTest.php index 626c962..d8d36fc 100644 --- a/tests/Mutex/MutexTest.php +++ b/tests/Mutex/MutexTest.php @@ -4,7 +4,7 @@ namespace Malkusch\Lock\Tests\Mutex; -use Eloquent\Liberator\Liberator; +require_once __DIR__ . '/../TestAccess.php'; use Malkusch\Lock\Mutex\AbstractLockMutex; use Malkusch\Lock\Mutex\AbstractSpinlockMutex; use Malkusch\Lock\Mutex\DistributedMutex; @@ -16,6 +16,7 @@ use Malkusch\Lock\Mutex\PostgreSQLMutex; use Malkusch\Lock\Mutex\RedisMutex; use Malkusch\Lock\Mutex\SemaphoreMutex; +use Malkusch\Lock\Tests\TestAccess; use org\bovigo\vfs\vfsStream; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DoesNotPerformAssertions; @@ -114,19 +115,19 @@ public static function provideMutexFactoriesCases(): iterable if (extension_loaded('pcntl')) { yield 'flockWithTimoutPcntl' => [static function () { $file = fopen(vfsStream::url('test/lock'), 'w'); - $lock = Liberator::liberate(new FlockMutex($file, 3)); - $lock->strategy = \Closure::bind(static fn () => FlockMutex::STRATEGY_PCNTL, null, FlockMutex::class)(); // @phpstan-ignore property.notFound + $lock = new FlockMutex($file, 3); + (new TestAccess($lock))->setProperty('strategy', \Closure::bind(static fn () => FlockMutex::STRATEGY_PCNTL, null, FlockMutex::class)()); - return $lock->popsValue(); + return (new TestAccess($lock))->popsValue(); }]; } yield 'flockWithTimoutLoop' => [static function () { $file = fopen(vfsStream::url('test/lock'), 'w'); - $lock = Liberator::liberate(new FlockMutex($file, 3)); - $lock->strategy = \Closure::bind(static fn () => FlockMutex::STRATEGY_LOOP, null, FlockMutex::class)(); // @phpstan-ignore property.notFound + $lock = new FlockMutex($file, 3); + (new TestAccess($lock))->setProperty('strategy', \Closure::bind(static fn () => FlockMutex::STRATEGY_LOOP, null, FlockMutex::class)()); - return $lock->popsValue(); + return (new TestAccess($lock))->popsValue(); }]; if (extension_loaded('sysvsem')) { diff --git a/tests/Mutex/PostgreSQLMutexTest.php b/tests/Mutex/PostgreSQLMutexTest.php index 4cfe932..5f5af41 100644 --- a/tests/Mutex/PostgreSQLMutexTest.php +++ b/tests/Mutex/PostgreSQLMutexTest.php @@ -4,10 +4,9 @@ namespace Malkusch\Lock\Tests\Mutex; -use Eloquent\Liberator\Liberator; use Malkusch\Lock\Exception\LockAcquireTimeoutException; use Malkusch\Lock\Mutex\PostgreSQLMutex; -use PHPUnit\Framework\Constraint\IsType; +use Malkusch\Lock\Tests\TestAccess; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -26,7 +25,8 @@ protected function setUp(): void $this->pdo = $this->createMock(\PDO::class); - $this->mutex = Liberator::liberate(new PostgreSQLMutex($this->pdo, 'test-one-negative-key')); // @phpstan-ignore assign.propertyType + $this->mutex = new PostgreSQLMutex($this->pdo, 'test-one-negative-key'); + $this->mutex = new TestAccess($this->mutex); // @phpstan-ignore assign.propertyType } private function isPhpunit9x(): bool @@ -46,7 +46,7 @@ public function testAcquireLock(): void $statement->expects(self::once()) ->method('execute') ->with(self::logicalAnd( - new IsType(IsType::TYPE_ARRAY), + TestAccess::phpunitIsType('array'), self::countOf(2), self::callback(function (...$arguments) { if ($this->isPhpunit9x()) { // https://github.com/sebastianbergmann/phpunit/issues/5891 @@ -64,7 +64,7 @@ public function testAcquireLock(): void [533558444, -1716795572] )); - \Closure::bind(static fn ($mutex) => $mutex->lock(), null, PostgreSQLMutex::class)($this->mutex); + \Closure::bind(static fn($mutex) => $mutex->lock(), null, PostgreSQLMutex::class)($this->mutex); } public function testReleaseLock(): void @@ -79,7 +79,7 @@ public function testReleaseLock(): void $statement->expects(self::once()) ->method('execute') ->with(self::logicalAnd( - new IsType(IsType::TYPE_ARRAY), + TestAccess::phpunitIsType('array'), self::countOf(2), self::callback(function (...$arguments) { if ($this->isPhpunit9x()) { // https://github.com/sebastianbergmann/phpunit/issues/5891 @@ -112,7 +112,7 @@ public function testAcquireTimeoutOccurs(): void $statement->expects(self::atLeastOnce()) ->method('execute') ->with(self::logicalAnd( - new IsType(IsType::TYPE_ARRAY), + TestAccess::phpunitIsType('array'), self::countOf(2), self::callback(function (...$arguments) { if ($this->isPhpunit9x()) { // https://github.com/sebastianbergmann/phpunit/issues/5891 diff --git a/tests/Mutex/RedisMutexTest.php b/tests/Mutex/RedisMutexTest.php index e63c753..b9d98e0 100644 --- a/tests/Mutex/RedisMutexTest.php +++ b/tests/Mutex/RedisMutexTest.php @@ -9,9 +9,9 @@ use Malkusch\Lock\Exception\MutexException; use Malkusch\Lock\Mutex\DistributedMutex; use Malkusch\Lock\Mutex\RedisMutex; +use Malkusch\Lock\Tests\TestAccess; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhpExtension; -use PHPUnit\Framework\Constraint\IsType; use PHPUnit\Framework\TestCase; use Predis\ClientInterface as PredisClientInterface; @@ -228,12 +228,12 @@ public function testAcquireExpireTimeoutLimit(): void $client->expects(self::once()) ->method('set') - ->with('php-malkusch-lock:test', new IsType(IsType::TYPE_STRING), 'PX', 31_557_600_000_000, 'NX') + ->with('php-malkusch-lock:test', TestAccess::phpunitIsType('string'), 'PX', 31_557_600_000_000, 'NX') ->willReturnSelf(); $client->expects(self::once()) ->method('eval') - ->with(self::anything(), 1, 'php-malkusch-lock:test', new IsType(IsType::TYPE_STRING)) + ->with(self::anything(), 1, 'php-malkusch-lock:test', TestAccess::phpunitIsType('string')) ->willReturn(true); $this->mutex->synchronized(static function () {}); @@ -242,8 +242,6 @@ public function testAcquireExpireTimeoutLimit(): void /** * @param \Redis::SERIALIZER_* $serializer * @param \Redis::COMPRESSION_* $compressor - * - * @dataProvider provideSerializersAndCompressorsCases */ #[DataProvider('provideSerializersAndCompressorsCases')] public function testSerializersAndCompressors(int $serializer, int $compressor): void diff --git a/tests/TestAccess.php b/tests/TestAccess.php new file mode 100644 index 0000000..06ba3af --- /dev/null +++ b/tests/TestAccess.php @@ -0,0 +1,142 @@ +object = $object; + } + + /** + * Gets a private/protected property on the wrapped object. + */ + public function getProperty(string $property): mixed + { + $accessor = \Closure::bind( + function (string $property) { + // @phpstan-ignore-next-line + return $this->{$property}; + }, + $this->object, + $this->object + ); + + return $accessor($property); + } + + /** + * Sets a private/protected property on the wrapped object. + */ + public function setProperty(string $property, mixed $value): void + { + $accessor = \Closure::bind( + function (string $property, mixed $value): void { + // @phpstan-ignore-next-line + $this->{$property} = $value; + }, + $this->object, + $this->object + ); + + $accessor($property, $value); + } + + /** + * Proxy calls to inaccessible methods on the wrapped object. + */ + public function callMethod(string $method, array $args = []): mixed + { + $caller = \Closure::bind( + function (string $method, array $args): mixed { + // @phpstan-ignore-next-line + return $this->{$method}(...$args); + }, + $this->object, + $this->object + ); + + return $caller($method, $args); + } + + /** + * Proxy calls to inaccessible methods on the wrapped object. + * + * @param array $args + */ + public function __call(string $method, array $args): mixed + { + return $this->callMethod($method, $args); + } + + /** + * Proxy access to inaccessible properties on the wrapped object. + */ + public function __get(string $property): mixed + { + return $this->getProperty($property); + } + + /** + * Proxy setting of inaccessible properties on the wrapped object. + */ + public function __set(string $property, mixed $value): void + { + $this->setProperty($property, $value); + } + + /** + * Returns the wrapped object. + */ + public function popsValue(): object + { + return $this->object; + } + + /** + * Returns a PHPUnit IsType constraint compatible with multiple PHPUnit versions. + */ + public static function phpunitIsType(string $type): IsType + { + $normalized = strtolower($type); + + if (class_exists(NativeType::class)) { + $map = [ + 'array' => NativeType::Array, + 'bool' => NativeType::Bool, + 'boolean' => NativeType::Bool, + 'callable' => NativeType::Callable, + 'double' => NativeType::Float, + 'float' => NativeType::Float, + 'int' => NativeType::Int, + 'integer' => NativeType::Int, + 'iterable' => NativeType::Iterable, + 'null' => NativeType::Null, + 'numeric' => NativeType::Numeric, + 'object' => NativeType::Object, + 'resource' => NativeType::Resource, + 'scalar' => NativeType::Scalar, + 'string' => NativeType::String, + ]; + + return new IsType($map[$normalized] ?? $type); + } + + // @phpstan-ignore-next-line PHPUnit 9 expects string type names here. + return new IsType($normalized); + } +}