diff --git a/.env b/.env index bc024b6..7cf1cee 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ # Put env variables defaults here # Override locally in gitignored .env.local -PHP_IMAGE_VERSION=8.3 +PHP_IMAGE_VERSION=8.4 diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 069a2c3..a7d7bb1 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -28,7 +28,7 @@ jobs: strategy: &strategy fail-fast: false matrix: - php: [ 8.3, 8.4, 8.5 ] + php: [ 8.4, 8.5 ] deps: [ lowest, highest ] steps: - uses: actions/checkout@v6 diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index e9c9810..7558c91 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -7,7 +7,7 @@ use PhpCsFixer\Runner\Parallel\ParallelConfigFactory; use PHPyh\CodingStandard\PhpCsFixerCodingStandard; -$config = (new Config()) +$config = new Config() ->setFinder( Finder::create() ->in(__DIR__ . '/src') @@ -19,7 +19,7 @@ ->setParallelConfig(ParallelConfigFactory::detect()) ->setCacheFile(__DIR__ . '/var/' . basename(__FILE__) . '.cache'); -(new PhpCsFixerCodingStandard())->applyTo($config, [ +new PhpCsFixerCodingStandard()->applyTo($config, [ // 'rule' => ['overridden' => 'config'], ]); diff --git a/composer.json b/composer.json index 8afdac9..32d4c08 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ } ], "require": { - "php": "^8.3", + "php": "^8.4", "amphp/amp": "^3.1" }, "require-dev": { diff --git a/src/LazyOnce.php b/src/LazyOnce.php new file mode 100644 index 0000000..9d697cc --- /dev/null +++ b/src/LazyOnce.php @@ -0,0 +1,41 @@ + + */ + private \Closure|Once $state; + + /** + * @param-later-invoked-callable $function + * @param callable(): T $function + */ + public function __construct(callable $function) + { + $this->state = $function(...); + } + + /** + * @return T + */ + public function await(?Cancellation $cancellation = null): mixed + { + if ($this->state instanceof \Closure) { + $this->state = new Once($this->state); + } + + return $this->state->await($cancellation); + } +} diff --git a/src/Once.php b/src/Once.php index c8cbc9f..808bcd9 100644 --- a/src/Once.php +++ b/src/Once.php @@ -11,55 +11,61 @@ /** * @api * - * @template T + * @template-covariant T */ final class Once { /** - * @var ?Future + * @var ?Future */ - private ?Future $future = null; + private ?Future $state; /** * @var T - * @phpstan-ignore property.uninitialized + * @phpstan-ignore property.uninitializedReadonly */ - private mixed $value; - - private bool $isResolved = false; + private readonly mixed $value; /** - * @param \Closure(): T $function - * @param ?\Closure(T): bool $isAlive + * @phpstan-ignore property.uninitializedReadonly */ - public function __construct( - private \Closure $function, - private readonly ?\Closure $isAlive = null, - ) {} + private readonly \Throwable $error; /** - * @return T + * @param-later-invoked-callable $function + * @param callable(): T $function */ - public function await(?Cancellation $cancellation = null): mixed + public function __construct(callable $function) { - if ($this->isResolved && ($this->isAlive === null || ($this->isAlive)($this->value))) { - return $this->value; - } + $weakThis = \WeakReference::create($this); - $this->isResolved = false; + /** @phpstan-ignore assign.propertyType */ + $this->state = async(static function () use ($function, $weakThis): void { + $once = $weakThis->get(); - $this->future ??= async($this->function); + if ($once === null) { + return; + } - try { - $this->value = $this->future->await($cancellation); - } finally { - $this->future = null; - } + try { + $once->value = $function(); + } catch (\Throwable $error) { + $once->error = $error; + } finally { + $once->state = null; + } + }); + } - $this->isResolved = true; + /** + * @return T + */ + public function await(?Cancellation $cancellation = null): mixed + { + $this->state?->await($cancellation); - if ($this->isAlive === null) { - $this->function = static fn() => throw new \LogicException('Function has been freed from memory'); + if (isset($this->error)) { + throw $this->error; } return $this->value; diff --git a/tests/OnceTest.php b/tests/OnceTest.php index 47190d4..bc33c8d 100644 --- a/tests/OnceTest.php +++ b/tests/OnceTest.php @@ -4,75 +4,73 @@ namespace Thesis\Sync; -use Amp\DeferredFuture; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use function Amp\async; +use function Amp\delay; +use function Amp\Future\await; +use function Amp\Future\awaitAll; +use function PHPUnit\Framework\assertFalse; +use function PHPUnit\Framework\assertNull; +use function PHPUnit\Framework\assertSame; +use function PHPUnit\Framework\assertTrue; #[CoversClass(Once::class)] +#[CoversClass(LazyOnce::class)] final class OnceTest extends TestCase { - public function testReturnsOnceSameValue(): void + /** + * @param class-string|LazyOnce<*>> $class + */ + #[DataProvider('provideClasses')] + public function testItMemoizesValue(string $class): void { - /** @var DeferredFuture */ - $deferred = new DeferredFuture(); - $once = new Once(static function () use ($deferred): string { - $deferred->getFuture()->await(); + $once = new $class(static function (): string { + delay(0.01); return random_bytes(8); }); - $future1 = async(static fn() => $once->await()); - $future2 = async(static fn() => $once->await()); - async(static function () use ($deferred, $future1, $future2): void { - self::assertFalse($future1->isComplete()); - self::assertFalse($future2->isComplete()); + /** @phpstan-ignore offsetAccess.notFound, offsetAccess.notFound */ + [$value1, $value2] = await([ + async(static fn() => $once->await()), + async(static fn() => $once->await()), + ]); - $deferred->complete(); - - self::assertSame($future1->await(), $future2->await()); - })->await(); + assertSame($value1, $value2); } - public function testWorksWithNull(): void + /** + * @param class-string|LazyOnce<*>> $class + */ + #[DataProvider('provideClasses')] + public function testItMemoizesException(string $class): void { - /** @var DeferredFuture */ - $deferred = new DeferredFuture(); - $once = new Once(static fn(): null => $deferred->getFuture()->await()); - $future = async(static fn() => $once->await()); - - async(static function () use ($deferred, $future): void { - self::assertFalse($future->isComplete()); + $once = new $class(static function (): never { + delay(0.01); - $deferred->complete(); + throw new \RuntimeException(random_bytes(8)); + }); - self::assertNull($future->await()); - })->await(); - } + /** @phpstan-ignore offsetAccess.notFound, offsetAccess.notFound */ + [$error1, $error2] = awaitAll([ + async(static fn() => $once->await()), + async(static fn() => $once->await()), + ])[0]; - public function testIsAlive(): void - { - $once = new Once( - static function (): int { - /** @var int */ - static $i = 0; - - return ++$i; - }, - static fn(int $i): bool => $i > 1, - ); - - self::assertSame(1, $once->await()); - self::assertSame(2, $once->await()); - self::assertSame(2, $once->await()); - self::assertSame(2, $once->await()); + assertSame($error1, $error2); } - public function testFunctionIsFreedIfIsAliveIsNull(): void + /** + * @param class-string|LazyOnce<*>> $class + */ + #[DataProvider('provideClasses')] + public function testItFreesFunctionWhenComplete(string $class): void { $value = new \stdClass(); $weakValue = \WeakReference::create($value); - $once = new Once(static fn() => $value::class); + $once = new $class(static fn() => $value::class); unset($value); self::assertNotNull($weakValue->get()); @@ -81,4 +79,51 @@ public function testFunctionIsFreedIfIsAliveIsNull(): void self::assertNull($weakValue->get()); } + + /** + * @param class-string|LazyOnce<*>> $class + */ + #[DataProvider('provideClasses')] + public function testItIsGarbageCollected(string $class): void + { + $enabled = gc_enabled(); + + if ($enabled) { + gc_disable(); + } + + try { + $weakOnce = \WeakReference::create(new $class(static fn() => true)); + + assertNull($weakOnce->get()); + } finally { + if ($enabled) { + gc_enable(); + } + } + } + + /** + * @return \Generator + */ + public static function provideClasses(): iterable + { + yield [Once::class]; + yield [LazyOnce::class]; + } + + public function testLazyOnceIsLazy(): void + { + $called = false; + $once = new LazyOnce(static function () use (&$called): void { + $called = true; + }); + + assertFalse($called); + + $once->await(); + + /** @phpstan-ignore function.impossibleType */ + assertTrue($called); + } }