Skip to content

Commit b258cbe

Browse files
authored
feat: allow #[RunInFiber] and #[RunInRevolt] on free functions (#295)
Assisted-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e40a499 commit b258cbe

4 files changed

Lines changed: 79 additions & 2 deletions

File tree

bridge/revolt/src/RunInRevolt.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,6 @@
3939
*
4040
* @api
4141
*/
42-
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
42+
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)]
4343
#[FallbackInterceptor(RunInRevoltInterceptor::class)]
4444
final readonly class RunInRevolt implements Interceptable {}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Bridge\Revolt\Self;
6+
7+
use Revolt\EventLoop;
8+
use Testo\Assert;
9+
use Testo\Bridge\Revolt\Internal\RunInRevoltInterceptor;
10+
use Testo\Bridge\Revolt\RunInRevolt;
11+
use Testo\Codecov\Covers;
12+
use Testo\Filter\Group;
13+
use Testo\Test;
14+
15+
/**
16+
* The suspend/resume round-trip only completes because the free function itself runs on the Revolt
17+
* loop — a bare fiber with no loop would leave the timer's resume unfired.
18+
*/
19+
#[Test]
20+
#[RunInRevolt]
21+
#[Group('async')]
22+
#[Covers(RunInRevolt::class)]
23+
#[Covers(RunInRevoltInterceptor::class)]
24+
function functionLevelRunsOnTheLoop(): void
25+
{
26+
Assert::notNull(\Fiber::getCurrent());
27+
28+
$suspension = EventLoop::getSuspension();
29+
EventLoop::delay(0.001, static fn() => $suspension->resume('resumed'));
30+
31+
Assert::same($suspension->suspend(), 'resumed');
32+
}

plugin/fiber/src/RunInFiber.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
*
4646
* @api
4747
*/
48-
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
48+
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)]
4949
#[FallbackInterceptor(RunInFiberInterceptor::class)]
5050
#[FallbackInterceptor(CoroutineScopeInterceptor::class)]
5151
final readonly class RunInFiber implements Interceptable
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Fiber\Self;
6+
7+
use Testo\Assert;
8+
use Testo\Codecov\Covers;
9+
use Testo\Fiber\Coroutine;
10+
use Testo\Fiber\Internal\CoroutineScopeInterceptor;
11+
use Testo\Fiber\Internal\RunInFiberInterceptor;
12+
use Testo\Fiber\RunInFiber;
13+
use Testo\Filter\Group;
14+
use Testo\Test;
15+
16+
/**
17+
* `spawn()` only schedules the coroutine — it takes its first step once the body yields at `await()`,
18+
* so the recorded order is body-first, and observing it at all proves the scheduler drives a fiber
19+
* opened for a free function.
20+
*/
21+
#[Test]
22+
#[RunInFiber]
23+
#[Group('async')]
24+
#[Covers(RunInFiber::class)]
25+
#[Covers(RunInFiberInterceptor::class)]
26+
#[Covers(CoroutineScopeInterceptor::class)]
27+
function functionLevelRunsInAFiber(): void
28+
{
29+
Assert::notNull(\Fiber::getCurrent());
30+
31+
$log = [];
32+
33+
$task = Coroutine::spawn(static function () use (&$log): int {
34+
$log[] = 'coroutine:start';
35+
\Fiber::suspend();
36+
$log[] = 'coroutine:resumed';
37+
38+
return 42;
39+
});
40+
41+
$log[] = 'body';
42+
43+
Assert::same($task->await(), 42);
44+
Assert::same($log, ['body', 'coroutine:start', 'coroutine:resumed']);
45+
}

0 commit comments

Comments
 (0)