Skip to content

Commit 4386cdc

Browse files
committed
fix: Increase coverage.
1 parent b378b91 commit 4386cdc

11 files changed

+472
-47
lines changed

src/Core/Async/CancellationToken.php

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,7 @@ public function combineWith(CancellationTokenInterface ...$tokens): Cancellation
128128
}
129129

130130
/**
131-
* Cancel this token with an optional reason.
132-
*
133-
* This method should only be called by the token's owner (e.g., CancellationTokenSource).
134-
*
135-
* @param string|null $reason Optional reason for cancellation
136-
* @internal
131+
* {@inheritDoc}
137132
*/
138133
public function cancel(?string $reason = null): void
139134
{

src/Core/Async/CancellationTokenInterface.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,14 @@ public function waitForCancellation(): PromiseInterface;
7979
* @return CancellationTokenInterface A new token that represents the combination
8080
*/
8181
public function combineWith(CancellationTokenInterface ...$tokens): CancellationTokenInterface;
82+
83+
/**
84+
* Cancel this token with an optional reason.
85+
*
86+
* This method should only be called by the token's owner (e.g., CancellationTokenSource).
87+
*
88+
* @param string|null $reason Optional reason for cancellation
89+
* @internal
90+
*/
91+
public function cancel(?string $reason = null): void;
8292
}

src/Core/Async/CancellationTokenSource.php

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
*/
1919
class CancellationTokenSource
2020
{
21-
private CancellationToken $token;
21+
private CancellationTokenInterface $token;
2222
private bool $disposed = false;
2323

2424
/**
@@ -124,12 +124,7 @@ public static function combineTokens(CancellationTokenInterface ...$tokens): sel
124124
$source = new self();
125125

126126
// Replace the source's token with a combined token
127-
$combinedToken = CombinedCancellationToken::create(...$tokens);
128-
129-
// Use reflection to replace the token (this is an internal implementation)
130-
$reflection = new ReflectionClass($source);
131-
$tokenProperty = $reflection->getProperty('token');
132-
$tokenProperty->setValue($source, $combinedToken);
127+
$source->token = CombinedCancellationToken::create(...$tokens);
133128

134129
return $source;
135130
}

src/Core/Async/CombinedCancellationToken.php

Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ class CombinedCancellationToken implements CancellationTokenInterface
3737
/**
3838
* {@inheritDoc}
3939
*/
40-
public private(set) ?string $reason = null;
40+
private(set) ?string $reason = null;
4141

4242
/**
4343
* Create a new CombinedCancellationToken.
@@ -161,36 +161,9 @@ public function combineWith(CancellationTokenInterface ...$tokens): Cancellation
161161
}
162162

163163
/**
164-
* Set up cancellation handling for all source tokens.
165-
*/
166-
private function setupCancellationHandling(): void
167-
{
168-
foreach ($this->tokens as $token) {
169-
// Check if already canceled
170-
if ($token->isCancellationRequested()) {
171-
$this->handleCancellation($token->reason);
172-
return;
173-
}
174-
175-
// Register for future cancellation
176-
if ($token->canBeCanceled()) {
177-
$unregister = $token->register(function () use ($token): void {
178-
if (!$this->canceled) {
179-
$this->handleCancellation($token->reason);
180-
}
181-
});
182-
183-
$this->unregisterFunctions[] = $unregister;
184-
}
185-
}
186-
}
187-
188-
/**
189-
* Handle cancellation from one of the source tokens.
190-
*
191-
* @param string|null $reason The reason for cancellation
164+
* {@inheritDoc}
192165
*/
193-
private function handleCancellation(?string $reason): void
166+
public function cancel(?string $reason = null): void
194167
{
195168
if ($this->canceled) {
196169
return;
@@ -216,6 +189,31 @@ private function handleCancellation(?string $reason): void
216189
$this->unregisterFunctions = [];
217190
}
218191

192+
/**
193+
* Set up cancellation handling for all source tokens.
194+
*/
195+
private function setupCancellationHandling(): void
196+
{
197+
foreach ($this->tokens as $token) {
198+
// Check if already canceled
199+
if ($token->isCancellationRequested()) {
200+
$this->cancel($token->reason);
201+
return;
202+
}
203+
204+
// Register for future cancellation
205+
if ($token->canBeCanceled()) {
206+
$unregister = $token->register(function () use ($token): void {
207+
if (!$this->canceled) {
208+
$this->cancel($token->reason);
209+
}
210+
});
211+
212+
$this->unregisterFunctions[] = $unregister;
213+
}
214+
}
215+
}
216+
219217
/**
220218
* Clean up resources when the object is destroyed.
221219
*/

src/Core/Async/Promise.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ public function __construct(?callable $executor = null, ?callable $canceller = n
6060
if ($executor !== null) {
6161
try {
6262
$executor(
63-
fn(mixed $value) => $this->resolve($value),
64-
fn(Throwable $reason) => $this->reject($reason)
63+
$this->resolve(...),
64+
$this->reject(...)
6565
);
6666
} catch (Throwable $e) {
6767
$this->reject($e);
@@ -212,8 +212,8 @@ public function resolve(mixed $value): void
212212
// Handle thenable values (promises)
213213
if ($value instanceof PromiseInterface) {
214214
$value->then(
215-
fn(mixed $v) => $this->resolve($v),
216-
fn(Throwable $r) => $this->reject($r)
215+
$this->resolve(...),
216+
$this->reject(...)
217217
);
218218
return;
219219
}

tests/Feature/EventLoopTest.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,29 @@
342342
expect($executed)->toBeFalse();
343343
$this->assertPromiseRejectsWith($promise, CancellationException::class);
344344
});
345+
346+
it('allows early cancellation of async operations', function () {
347+
if (!Async::supportsFibers()) {
348+
$this->markTestSkipped('Fibers not supported in this PHP version');
349+
}
350+
351+
$loop = EventLoop::getInstance();
352+
$tokenSource = Async::createCancellationTokenSource();
353+
$executed = false;
354+
355+
$tokenSource->cancel('test cancellation');
356+
357+
$promise = $loop->async(function() use (&$executed, $tokenSource) {
358+
$tokenSource->getToken()->throwIfCancellationRequested();
359+
$executed = true;
360+
return 'completed';
361+
}, $tokenSource->getToken());
362+
363+
$this->runEventLoopBriefly();
364+
365+
expect($executed)->toBeFalse();
366+
$this->assertPromiseRejectsWith($promise, CancellationException::class);
367+
});
345368
});
346369

347370
describe('delay method', function () {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use ElementaryFramework\Core\Async\AggregateException;
6+
7+
describe('AggregateException', function () {
8+
it('exposes inner exceptions and their messages', function () {
9+
$e1 = new RuntimeException('first');
10+
$e2 = new InvalidArgumentException('second');
11+
12+
$agg = new AggregateException('many', [$e1, $e2]);
13+
14+
expect($agg->hasInnerExceptions())->toBeTrue()
15+
->and($agg->getInnerExceptionCount())->toBe(2)
16+
->and($agg->getInnerExceptions())->toBe([$e1, $e2])
17+
->and($agg->getInnerException(0))->toBe($e1)
18+
->and($agg->getInnerException(1))->toBe($e2)
19+
->and($agg->getInnerException(99))->toBeNull()
20+
->and($agg->getInnerExceptionMessages())->toBe(['first', 'second']);
21+
22+
$str = (string)$agg;
23+
expect($str)->toContain('Inner Exceptions:')
24+
->and($str)->toContain('[0] '.get_class($e1).': first')
25+
->and($str)->toContain('[1] '.get_class($e2).': second');
26+
});
27+
28+
it('formats empty inner exceptions string', function () {
29+
$agg = new AggregateException('none', []);
30+
expect($agg->getInnerExceptionsAsString())->toBe('No inner exceptions');
31+
});
32+
33+
it('creates from mixed array and flattens nested aggregates', function () {
34+
$inner = new AggregateException('inner', [new LogicException('L1')]);
35+
$agg = new AggregateException('outer', [new RuntimeException('R1'), $inner]);
36+
37+
$flat = $agg->flatten();
38+
expect($flat->getInnerExceptionCount())->toBe(2)
39+
->and($flat->getInnerException(0))->toBeInstanceOf(RuntimeException::class)
40+
->and($flat->getInnerException(1))->toBeInstanceOf(LogicException::class);
41+
42+
$from = AggregateException::fromArray([
43+
new DomainException('D1'),
44+
'oops',
45+
], 'mixed');
46+
47+
expect($from->getInnerExceptionCount())->toBe(2)
48+
->and($from->filterByType(DomainException::class))->toHaveCount(1)
49+
->and($from->containsType(DomainException::class))->toBeTrue()
50+
->and($from->containsType(OutOfBoundsException::class))->toBeFalse();
51+
52+
$messages = $from->getInnerExceptionMessages();
53+
expect($messages[0])->toBe('D1')
54+
->and($messages[1])->toContain('Exception at index 1: oops');
55+
});
56+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use ElementaryFramework\Core\Async\CancellationException;
6+
7+
describe('CancellationException', function () {
8+
it('creates timeout exception with proper message and code', function () {
9+
$e = CancellationException::timeout(123.5);
10+
expect($e)->toBeInstanceOf(CancellationException::class)
11+
->and($e->getMessage())->toBe('Operation timed out after 123.5 milliseconds')
12+
->and($e->getCode())->toBe(0);
13+
});
14+
15+
it('creates manual cancellation with custom reason', function () {
16+
$e = CancellationException::manual('Stopped by user');
17+
expect($e->getMessage())->toBe('Stopped by user')
18+
->and($e->getCode())->toBe(0);
19+
});
20+
21+
it('creates signal cancellation with signal code', function () {
22+
$e = CancellationException::signal(15);
23+
expect($e->getMessage())->toBe('Operation cancelled by signal 15')
24+
->and($e->getCode())->toBe(15);
25+
});
26+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use ElementaryFramework\Core\Async\CancellationToken;
6+
use ElementaryFramework\Core\Async\CancellationTokenInterface;
7+
use ElementaryFramework\Core\Async\CancellationTokenSource;
8+
9+
describe('CancellationTokenSource', function () {
10+
it('auto-cancels with timeout', function () {
11+
$source = CancellationTokenSource::withTimeout(10);
12+
$token = $source->getToken();
13+
expect($token->isCancellationRequested())->toBeFalse();
14+
15+
// Allow the scheduled cancellation to run
16+
\Tests\TestCase::assertTrue(true); // ensure TestCase loaded
17+
$this->runEventLoopBriefly(0.05);
18+
19+
expect($source->isCancellationRequested())->toBeTrue()
20+
->and($token->isCancellationRequested())->toBeTrue();
21+
});
22+
23+
it('combines tokens and cancels when any source cancels', function () {
24+
$a = new CancellationTokenSource();
25+
$b = new CancellationTokenSource();
26+
27+
$combined = CancellationTokenSource::combineTokens($a->getToken(), $b->getToken());
28+
$ct = $combined->getToken();
29+
30+
expect($ct->isCancellationRequested())->toBeFalse();
31+
$a->cancel('stop');
32+
expect($ct->isCancellationRequested())->toBeTrue();
33+
});
34+
35+
it('creates never and cancelled sources', function () {
36+
$never = CancellationTokenSource::never();
37+
expect($never->getToken()->canBeCanceled())->toBeFalse()
38+
->and($never->isCancellationRequested())->toBeFalse();
39+
40+
$cancelled = CancellationTokenSource::cancelled('done');
41+
expect($cancelled->isCancellationRequested())->toBeTrue()
42+
->and($cancelled->getToken()->isCancellationRequested())->toBeTrue();
43+
});
44+
45+
it('throws when disposed and methods are used', function () {
46+
$src = new CancellationTokenSource();
47+
$src->dispose();
48+
49+
expect(fn () => $src->getToken())->toThrow(RuntimeException::class)
50+
->and(fn () => $src->cancel())->toThrow(RuntimeException::class)
51+
->and(fn () => $src->isCancellationRequested())->toThrow(RuntimeException::class);
52+
});
53+
54+
it('creates source withSignal without errors (PCNTL guarded)', function () {
55+
// Use a common signal number; handler registration is guarded internally
56+
$src = CancellationTokenSource::withSignal(\defined('SIGINT') ? \SIGINT : 2);
57+
expect($src->getToken())->toBeInstanceOf(CancellationTokenInterface::class)
58+
->and($src->isCancellationRequested())->toBeFalse();
59+
});
60+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use ElementaryFramework\Core\Async\CancellationToken;
6+
use ElementaryFramework\Core\Async\CancellationTokenSource;
7+
use ElementaryFramework\Core\Async\CombinedCancellationToken;
8+
9+
describe('CombinedCancellationToken', function () {
10+
it('is cancelled when any source token cancels and invokes callbacks', function () {
11+
$a = new CancellationTokenSource();
12+
$b = new CancellationTokenSource();
13+
14+
$combined = CombinedCancellationToken::create($a->getToken(), $b->getToken());
15+
16+
$called = 0;
17+
$combined->register(function () use (&$called) { $called++; });
18+
19+
expect($combined->isCancellationRequested())->toBeFalse()
20+
->and($combined->canBeCanceled())->toBeTrue();
21+
22+
$b->cancel('stop');
23+
24+
expect($combined->isCancellationRequested())->toBeTrue()
25+
->and($called)->toBe(1);
26+
27+
// register after cancel should invoke immediately
28+
$immediate = 0;
29+
$combined->register(function () use (&$immediate) { $immediate++; });
30+
expect($immediate)->toBe(1);
31+
32+
// throwIfCancellationRequested should throw
33+
expect(fn () => $combined->throwIfCancellationRequested())
34+
->toThrow(\ElementaryFramework\Core\Async\CancellationException::class);
35+
});
36+
37+
it('waitForCancellation resolves when cancelled; never resolves when cannot be canceled', function () {
38+
$src = new CancellationTokenSource();
39+
$combined = CombinedCancellationToken::create($src->getToken());
40+
41+
$p = $combined->waitForCancellation();
42+
expect($p->isPending())->toBeTrue();
43+
44+
$src->cancel('go');
45+
$this->runEventLoopBriefly(0.02);
46+
expect($p->isFulfilled())->toBeTrue();
47+
48+
$never = CombinedCancellationToken::create(CancellationToken::never());
49+
$p2 = $never->waitForCancellation();
50+
expect($p2->isPending())->toBeTrue();
51+
});
52+
53+
it('combineWith merges additional tokens', function () {
54+
$a = new CancellationTokenSource();
55+
$b = new CancellationTokenSource();
56+
$c = new CancellationTokenSource();
57+
58+
$combined = CombinedCancellationToken::create($a->getToken());
59+
$merged = $combined->combineWith($b->getToken(), $c->getToken());
60+
61+
// Cancel one of the later tokens and expect merged to be cancelled
62+
$c->cancel('later');
63+
if (method_exists($merged, 'waitForCancellation')) {
64+
$this->runEventLoopBriefly(0.02);
65+
expect($merged->isCancellationRequested())->toBeTrue();
66+
}
67+
});
68+
});

0 commit comments

Comments
 (0)