Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion .github/workflows/check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .php-cs-fixer.dist.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -19,7 +19,7 @@
->setParallelConfig(ParallelConfigFactory::detect())
->setCacheFile(__DIR__ . '/var/' . basename(__FILE__) . '.cache');

(new PhpCsFixerCodingStandard())->applyTo($config, [
new PhpCsFixerCodingStandard()->applyTo($config, [
// 'rule' => ['overridden' => 'config'],
]);

Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
}
],
"require": {
"php": "^8.3",
"php": "^8.4",
"amphp/amp": "^3.1"
},
"require-dev": {
Expand Down
41 changes: 41 additions & 0 deletions src/LazyOnce.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace Thesis\Sync;

use Amp\Cancellation;

/**
* @api
*
* @template-covariant T
*/
final class LazyOnce
{
/**
* @var \Closure(): T|Once<T>
*/
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);
}
}
62 changes: 34 additions & 28 deletions src/Once.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,55 +11,61 @@
/**
* @api
*
* @template T
* @template-covariant T
*/
final class Once
{
/**
* @var ?Future<T>
* @var ?Future<void>
*/
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;
Expand Down
135 changes: 90 additions & 45 deletions tests/OnceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<Once<*>|LazyOnce<*>> $class
*/
#[DataProvider('provideClasses')]
public function testItMemoizesValue(string $class): void
{
/** @var DeferredFuture<null> */
$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<Once<*>|LazyOnce<*>> $class
*/
#[DataProvider('provideClasses')]
public function testItMemoizesException(string $class): void
{
/** @var DeferredFuture<null> */
$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<Once<*>|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());
Expand All @@ -81,4 +79,51 @@ public function testFunctionIsFreedIfIsAliveIsNull(): void

self::assertNull($weakValue->get());
}

/**
* @param class-string<Once<*>|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<array{class-string}>
*/
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);
}
}