Skip to content

Commit a19279c

Browse files
committed
feat: add timelock
1 parent f00f895 commit a19279c

File tree

6 files changed

+192
-5
lines changed

6 files changed

+192
-5
lines changed

packages/clock/src/GenericClock.php

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,9 @@ public function milliseconds(): int
4141

4242
public function sleep(int|Duration $milliseconds): void
4343
{
44-
if ($milliseconds instanceof Duration) {
45-
$milliseconds = (int) $milliseconds->getTotalMilliseconds();
46-
}
47-
48-
usleep($milliseconds * MILLISECONDS_PER_SECOND);
44+
usleep(match (true) {
45+
is_int($milliseconds) => $milliseconds * MILLISECONDS_PER_SECOND,
46+
$milliseconds instanceof Duration => (int) $milliseconds->getTotalMicroseconds(),
47+
});
4948
}
5049
}

packages/cryptography/composer.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
"tempest/container": "dev-main",
99
"tempest/support": "dev-main"
1010
},
11+
"suggest": {
12+
"tempest/clock": "For time-lock support"
13+
},
1114
"autoload": {
1215
"psr-4": {
1316
"Tempest\\Cryptography\\": "src"
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
namespace Tempest\Cryptography;
4+
5+
use Tempest\Clock\Clock;
6+
use Tempest\DateTime\Duration;
7+
8+
final class Timelock
9+
{
10+
public function __construct(
11+
private readonly Clock $clock,
12+
) {}
13+
14+
/**
15+
* Whether or not a time-locked operation can return early.
16+
*/
17+
public bool $canReturnEarly = false;
18+
19+
/**
20+
* Performs an operation that is time-locked to a specific duration.
21+
*
22+
* @template TCallReturnType
23+
*
24+
* @param (callable($this): TCallReturnType) $callback
25+
* @param Duration $duration
26+
* @return TCallReturnType
27+
*/
28+
public function invoke(callable $callback, Duration $duration): mixed
29+
{
30+
$exception = null;
31+
$start = microtime(as_float: true);
32+
33+
try {
34+
$result = $callback($this);
35+
} catch (\Throwable $thrown) {
36+
$exception = $thrown;
37+
}
38+
39+
$remainderInMicroseconds = intval($duration->getTotalMicroseconds() - ((microtime(true) - $start) * 1_000_000));
40+
41+
if (! $this->canReturnEarly && $remainderInMicroseconds > 0) {
42+
$this->clock->sleep(Duration::microseconds($remainderInMicroseconds));
43+
}
44+
45+
if ($exception) {
46+
throw $exception;
47+
}
48+
49+
return $result;
50+
}
51+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
namespace Tempest\Cryptography;
4+
5+
use Tempest\Clock\Clock;
6+
use Tempest\Container\Container;
7+
use Tempest\Container\Initializer;
8+
9+
final class TimelockInitializer implements Initializer
10+
{
11+
public function initialize(Container $container): Timelock
12+
{
13+
return new Timelock($container->get(Clock::class));
14+
}
15+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
namespace Tempest\Cryptography\Tests;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use Tempest\Clock\Clock;
7+
use Tempest\Clock\GenericClock;
8+
use Tempest\Clock\MockClock;
9+
use Tempest\Cryptography\Timelock;
10+
use Tempest\DateTime\Duration;
11+
12+
final class TimelockTest extends TestCase
13+
{
14+
protected function setUp(): void
15+
{
16+
parent::setUp();
17+
18+
if (! interface_exists(Clock::class)) {
19+
$this->markTestSkipped('The Clock interface is not available. This test requires the `tempest/clock` package.');
20+
}
21+
}
22+
23+
public function test_callback_is_executed(): void
24+
{
25+
$clock = new GenericClock();
26+
$result = new Timelock($clock)->invoke(fn () => 'hello', Duration::zero());
27+
28+
$this->assertSame('hello', $result);
29+
}
30+
31+
public function test_locks_for_duration(): void
32+
{
33+
$clock = new GenericClock();
34+
$start = microtime(true);
35+
36+
$timelock = new Timelock($clock);
37+
$timelock->invoke(fn () => null, Duration::milliseconds(100));
38+
39+
$elapsed = microtime(true) - $start;
40+
41+
$this->assertGreaterThanOrEqual(0.1, $elapsed, 'The timelock did not wait for the specified duration.');
42+
$this->assertLessThan(0.2, $elapsed, 'The timelock waited for too long.');
43+
}
44+
45+
public function test_return_early(): void
46+
{
47+
$clock = new GenericClock();
48+
$timelock = new Timelock($clock);
49+
50+
$start = microtime(true);
51+
$timelock->invoke(
52+
callback: fn (Timelock $lock) => $lock->canReturnEarly = true,
53+
duration: Duration::milliseconds(100),
54+
);
55+
$elapsed = microtime(true) - $start;
56+
57+
$this->assertLessThan(0.1, $elapsed, 'The timelock did not return early as expected.');
58+
}
59+
60+
public function test_throws_exception_after_delay(): void
61+
{
62+
$clock = new GenericClock();
63+
$timelock = new Timelock($clock);
64+
65+
$start = microtime(true);
66+
67+
try {
68+
$timelock->invoke(
69+
callback: fn () => throw new \RuntimeException('This is an error.'),
70+
duration: Duration::milliseconds(100),
71+
);
72+
} catch (\RuntimeException) {
73+
$elapsed = microtime(true) - $start;
74+
$this->assertGreaterThanOrEqual(0.1, $elapsed, 'The exception was thrown before the timelock duration elapsed.');
75+
}
76+
}
77+
78+
public function test_uses_clock_to_sleep(): void
79+
{
80+
$clock = new MockClock();
81+
$timelock = new Timelock($clock);
82+
83+
$ms = $clock->timestamp()->getMilliseconds();
84+
85+
$timelock->invoke(
86+
callback: fn () => null,
87+
duration: Duration::milliseconds(300),
88+
);
89+
90+
$elapsed = $clock->timestamp()->getMilliseconds() - $ms;
91+
92+
$this->assertSame(300, $elapsed);
93+
}
94+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
namespace Tests\Tempest\Integration\Cryptography;
4+
5+
use Tempest\Cryptography\Timelock;
6+
use Tempest\DateTime\Duration;
7+
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
8+
9+
final class TimelockTest extends FrameworkIntegrationTestCase
10+
{
11+
public function test_uses_mocked_clock(): void
12+
{
13+
$clock = $this->clock();
14+
$timelock = $this->container->get(Timelock::class);
15+
16+
$ms = $clock->timestamp()->getMilliseconds();
17+
$timelock->invoke(
18+
callback: fn () => null,
19+
duration: Duration::milliseconds(10_000),
20+
);
21+
$elapsed = $clock->timestamp()->getMilliseconds() - $ms;
22+
23+
$this->assertSame(10_000, $elapsed);
24+
}
25+
}

0 commit comments

Comments
 (0)