From cb8433e29f25ee749f5e76ea886629d6cc7f5aeb Mon Sep 17 00:00:00 2001 From: Valentin Udaltsov Date: Sat, 4 Apr 2026 18:43:34 +0300 Subject: [PATCH 1/3] New Once implementation for 0.2.0 --- .env | 2 +- .github/workflows/check.yaml | 2 +- .php-cs-fixer.dist.php | 4 +- composer.json | 2 +- src/Once.php | 62 ++++++++++++++----------- tests/OnceTest.php | 90 ++++++++++++++++++------------------ 6 files changed, 85 insertions(+), 77 deletions(-) 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/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..83095e3 100644 --- a/tests/OnceTest.php +++ b/tests/OnceTest.php @@ -4,71 +4,54 @@ namespace Thesis\Sync; -use Amp\DeferredFuture; use PHPUnit\Framework\Attributes\CoversClass; 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\assertNull; +use function PHPUnit\Framework\assertSame; #[CoversClass(Once::class)] final class OnceTest extends TestCase { - public function testReturnsOnceSameValue(): void + public function testItMemoizesValue(): void { - /** @var DeferredFuture */ - $deferred = new DeferredFuture(); - $once = new Once(static function () use ($deferred): string { - $deferred->getFuture()->await(); + $once = new Once(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 + public function testItMemoizesException(): 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 Once(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()); + /** @phpstan-ignore deadCode.unreachable */ + assertSame($error1, $error2); } - public function testFunctionIsFreedIfIsAliveIsNull(): void + public function testItFreesFunctionWhenComplete(): void { $value = new \stdClass(); $weakValue = \WeakReference::create($value); @@ -81,4 +64,23 @@ public function testFunctionIsFreedIfIsAliveIsNull(): void self::assertNull($weakValue->get()); } + + public function testItIsGarbageCollected(): void + { + $enabled = gc_enabled(); + + if ($enabled) { + gc_disable(); + } + + try { + $weakOnce = \WeakReference::create(new Once(static fn() => true)); + + assertNull($weakOnce->get()); + } finally { + if ($enabled) { + gc_enable(); + } + } + } } From a79ceee474836d55c851c8f1b9d716a013011bb1 Mon Sep 17 00:00:00 2001 From: Valentin Udaltsov Date: Sat, 4 Apr 2026 23:21:14 +0300 Subject: [PATCH 2/3] Add LazyOnce --- src/LazyOnce.php | 45 ++++++++++++++++++++++++++++++ tests/OnceTest.php | 69 ++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 105 insertions(+), 9 deletions(-) create mode 100644 src/LazyOnce.php diff --git a/src/LazyOnce.php b/src/LazyOnce.php new file mode 100644 index 0000000..9928584 --- /dev/null +++ b/src/LazyOnce.php @@ -0,0 +1,45 @@ + + */ + private \Closure|Once $state; + + public bool $isInitialized { + get => $this->state instanceof Once; + } + + /** + * @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/tests/OnceTest.php b/tests/OnceTest.php index 83095e3..aa33b61 100644 --- a/tests/OnceTest.php +++ b/tests/OnceTest.php @@ -5,20 +5,29 @@ namespace Thesis\Sync; 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\assertInstanceOf; 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 testItMemoizesValue(): void + /** + * @param class-string|LazyOnce<*>> $class + */ + #[DataProvider('provideClasses')] + public function testItMemoizesValue(string $class): void { - $once = new Once(static function (): string { + $once = new $class(static function (): string { delay(0.01); return random_bytes(8); @@ -33,9 +42,13 @@ public function testItMemoizesValue(): void assertSame($value1, $value2); } - public function testItMemoizesException(): void + /** + * @param class-string|LazyOnce<*>> $class + */ + #[DataProvider('provideClasses')] + public function testItMemoizesException(string $class): void { - $once = new Once(static function (): never { + $once = new $class(static function (): never { delay(0.01); throw new \RuntimeException(random_bytes(8)); @@ -47,15 +60,18 @@ public function testItMemoizesException(): void async(static fn() => $once->await()), ])[0]; - /** @phpstan-ignore deadCode.unreachable */ assertSame($error1, $error2); } - public function testItFreesFunctionWhenComplete(): 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()); @@ -65,7 +81,11 @@ public function testItFreesFunctionWhenComplete(): void self::assertNull($weakValue->get()); } - public function testItIsGarbageCollected(): void + /** + * @param class-string|LazyOnce<*>> $class + */ + #[DataProvider('provideClasses')] + public function testItIsGarbageCollected(string $class): void { $enabled = gc_enabled(); @@ -74,7 +94,7 @@ public function testItIsGarbageCollected(): void } try { - $weakOnce = \WeakReference::create(new Once(static fn() => true)); + $weakOnce = \WeakReference::create(new $class(static fn() => true)); assertNull($weakOnce->get()); } finally { @@ -83,4 +103,35 @@ public function testItIsGarbageCollected(): void } } } + + /** + * @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, &$once): string { + assertInstanceOf(LazyOnce::class, $once); + assertTrue($once->isInitialized); + + $called = true; + + return 'value'; + }); + + assertFalse($called); + assertFalse($once->isInitialized); + + $once->await(); + + /** @phpstan-ignore function.impossibleType */ + assertTrue($called); + assertTrue($once->isInitialized); + } } From 3da5136e78ab8ef8791eab2dd484a30c819bc4a8 Mon Sep 17 00:00:00 2001 From: Valentin Udaltsov Date: Sun, 5 Apr 2026 10:04:12 +0300 Subject: [PATCH 3/3] Remove LazyOnce::$isInitialized --- src/LazyOnce.php | 4 ---- tests/OnceTest.php | 10 +--------- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/src/LazyOnce.php b/src/LazyOnce.php index 9928584..9d697cc 100644 --- a/src/LazyOnce.php +++ b/src/LazyOnce.php @@ -18,10 +18,6 @@ final class LazyOnce */ private \Closure|Once $state; - public bool $isInitialized { - get => $this->state instanceof Once; - } - /** * @param-later-invoked-callable $function * @param callable(): T $function diff --git a/tests/OnceTest.php b/tests/OnceTest.php index aa33b61..bc33c8d 100644 --- a/tests/OnceTest.php +++ b/tests/OnceTest.php @@ -12,7 +12,6 @@ use function Amp\Future\await; use function Amp\Future\awaitAll; use function PHPUnit\Framework\assertFalse; -use function PHPUnit\Framework\assertInstanceOf; use function PHPUnit\Framework\assertNull; use function PHPUnit\Framework\assertSame; use function PHPUnit\Framework\assertTrue; @@ -116,22 +115,15 @@ public static function provideClasses(): iterable public function testLazyOnceIsLazy(): void { $called = false; - $once = new LazyOnce(static function () use (&$called, &$once): string { - assertInstanceOf(LazyOnce::class, $once); - assertTrue($once->isInitialized); - + $once = new LazyOnce(static function () use (&$called): void { $called = true; - - return 'value'; }); assertFalse($called); - assertFalse($once->isInitialized); $once->await(); /** @phpstan-ignore function.impossibleType */ assertTrue($called); - assertTrue($once->isInitialized); } }