diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 189105d..2f3eecb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,5 +12,3 @@ jobs: uses: innmind/github-workflows/.github/workflows/psalm-matrix.yml@main cs: uses: innmind/github-workflows/.github/workflows/cs.yml@main - with: - php-version: '8.2' diff --git a/.github/workflows/extensive.yml b/.github/workflows/extensive.yml new file mode 100644 index 0000000..257f139 --- /dev/null +++ b/.github/workflows/extensive.yml @@ -0,0 +1,12 @@ +name: Extensive CI + +on: + push: + tags: + - '*' + paths: + - '.github/workflows/extensive.yml' + +jobs: + blackbox: + uses: innmind/github-workflows/.github/workflows/extensive.yml@main diff --git a/CHANGELOG.md b/CHANGELOG.md index b6a2a5b..2cf70e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [Unreleased] + +### Changed + +- Requires PHP `8.4` +- Requires `innmind/immutable:~6.0` +- `Innmind\Signals\Handler::listen()` now returns an `Innmind\Immutable\Attempt` +- `Innmind\Signals\Handler::remove()` now returns an `Innmind\Immutable\Attempt` + ## 4.1.1 - 2025-08-20 ### Fixed diff --git a/README.md b/README.md index 9feb756..e5c6ce0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Signals -[![Build Status](https://github.com/innmind/signals/workflows/CI/badge.svg?branch=master)](https://github.com/innmind/signals/actions?query=workflow%3ACI) +[![CI](https://github.com/Innmind/Signals/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/Innmind/Signals/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/innmind/signals/branch/develop/graph/badge.svg)](https://codecov.io/gh/innmind/signals) [![Type Coverage](https://shepherd.dev/github/innmind/signals/coverage.svg)](https://shepherd.dev/github/innmind/signals) @@ -23,16 +23,20 @@ use Innmind\Signals\{ $handler = Handler::main(); // automatically enable async signal on first `->listen()` call -$handler->listen(Signal::interrupt, function(Signal $signal, Info $info): void { - echo 'foo'; -}); -$handler->listen(Signal::interrupt, function(Signal $signal, Info $info): void { - echo 'bar'; -}); +$handler + ->listen(Signal::interrupt, function(Signal $signal, Info $info): void { + echo 'foo'; + }) + ->unwrap(); +$handler + ->listen(Signal::interrupt, function(Signal $signal, Info $info): void { + echo 'bar'; + }) + ->unwrap(); // do some logic here ``` When above script is executed in a terminal and you do a `ctrl + c` to stop the process it will print `foobar` instead of stopping the script. -If for some reason you need to remove a handler (for example when a child process ended) you can call `$handler->remove($listener)` (remove the listener for all signals). +If for some reason you need to remove a handler (for example when a child process ended) you can call `$handler->remove($listener)->unwrap()` (remove the listener for all signals). diff --git a/blackbox.php b/blackbox.php index 50022e2..67a2468 100644 --- a/blackbox.php +++ b/blackbox.php @@ -10,6 +10,10 @@ }; Application::new($argv) + ->when( + \getenv('BLACKBOX_SET_SIZE') !== false, + static fn(Application $app) => $app->scenariiPerProof((int) \getenv('BLACKBOX_SET_SIZE')), + ) ->when( \getenv('ENABLE_COVERAGE') !== false, static fn(Application $app) => $app diff --git a/composer.json b/composer.json index 84c94cc..416e600 100644 --- a/composer.json +++ b/composer.json @@ -15,8 +15,8 @@ "issues": "http://github.com/Innmind/Signals/issues" }, "require": { - "php": "~8.2", - "innmind/immutable": "~5.18" + "php": "~8.4", + "innmind/immutable": "~6.0" }, "autoload": { "psr-4": { @@ -29,7 +29,7 @@ } }, "require-dev": { - "innmind/static-analysis": "^1.2.1", + "innmind/static-analysis": "~1.3", "innmind/black-box": "~6.5", "innmind/coding-standard": "~2.0" } diff --git a/src/Async/Interceptor.php b/src/Async/Interceptor.php index 7a220b9..5289cce 100644 --- a/src/Async/Interceptor.php +++ b/src/Async/Interceptor.php @@ -11,6 +11,8 @@ Map, Sequence, Maybe, + Attempt, + SideEffect, }; /** @@ -64,7 +66,10 @@ public function remove(callable $listener): void ); } - public function dispatch(Signal $signal): void + /** + * @return Attempt + */ + public function dispatch(Signal $signal): Attempt { /** @psalm-suppress MixedArgumentTypeCoercion */ $info = Info::of( @@ -74,11 +79,16 @@ public function dispatch(Signal $signal): void Maybe::nothing(), Maybe::nothing(), ); - $_ = $this + + return $this ->handlers ->get($signal) ->toSequence() ->flatMap(static fn($listeners) => $listeners) - ->foreach(static fn($listen) => $listen($signal, $info)); + ->map(static fn($listen) => static fn() => $listen($signal, $info)) + ->sink(SideEffect::identity) + ->attempt(static fn($_, $listen) => Attempt::of($listen)->map( + static fn() => $_, + )); } } diff --git a/src/Handler.php b/src/Handler.php index 1e985fc..2a42664 100644 --- a/src/Handler.php +++ b/src/Handler.php @@ -8,6 +8,10 @@ Handler\Async, Async\Interceptor, }; +use Innmind\Immutable\{ + Attempt, + SideEffect, +}; final class Handler { @@ -29,34 +33,42 @@ public static function main(): self } /** - * @param callable(Signal, Info): void $listener + * This is intended to build a child handler inside a Fiber. + * The interceptor allows to emulate a signals to send a fake signal to + * instruct the fiber to terminate. + * + * @internal + * @psalm-mutation-free */ - public function listen(Signal $signal, callable $listener): void - { - $this->implementation->listen($signal, $listener); + #[\NoDiscard] + public static function async( + self $signals, + ?Interceptor $interceptor = null, + ): self { + return new self( + Async::new($signals, $interceptor), + ); } /** * @param callable(Signal, Info): void $listener + * + * @return Attempt */ - public function remove(callable $listener): void + #[\NoDiscard] + public function listen(Signal $signal, callable $listener): Attempt { - $this->implementation->remove($listener); + return $this->implementation->listen($signal, $listener); } /** - * This is intended to build a child handler inside a Fiber. - * The interceptor allows to emulate a signals to send a fake signal to - * instruct the fiber to terminate. + * @param callable(Signal, Info): void $listener * - * @internal - * @psalm-mutation-free + * @return Attempt */ #[\NoDiscard] - public function async(?Interceptor $interceptor = null): self + public function remove(callable $listener): Attempt { - return new self( - Async::new($this, $interceptor), - ); + return $this->implementation->remove($listener); } } diff --git a/src/Handler/Async.php b/src/Handler/Async.php index 9211e86..3939282 100644 --- a/src/Handler/Async.php +++ b/src/Handler/Async.php @@ -9,6 +9,10 @@ Info, Async\Interceptor, }; +use Innmind\Immutable\{ + Attempt, + SideEffect, +}; /** * @internal @@ -35,19 +39,35 @@ public static function new(Handler $parent, ?Interceptor $interceptor): self /** * @param callable(Signal, Info): void $listener + * + * @return Attempt */ - public function listen(Signal $signal, callable $listener): void + public function listen(Signal $signal, callable $listener): Attempt { - $this->parent->listen($signal, $listener); - $this->interceptor?->listen($signal, $listener); + return $this + ->parent + ->listen($signal, $listener) + ->map(function($_) use ($signal, $listener) { + $this->interceptor?->listen($signal, $listener); + + return $_; + }); } /** * @param callable(Signal, Info): void $listener + * + * @return Attempt */ - public function remove(callable $listener): void + public function remove(callable $listener): Attempt { - $this->parent->remove($listener); - $this->interceptor?->remove($listener); + return $this + ->parent + ->remove($listener) + ->map(function($_) use ($listener) { + $this->interceptor?->remove($listener); + + return $_; + }); } } diff --git a/src/Handler/Main.php b/src/Handler/Main.php index 6ca4f99..dcdfb89 100644 --- a/src/Handler/Main.php +++ b/src/Handler/Main.php @@ -11,6 +11,8 @@ Sequence, Map, Maybe, + Attempt, + SideEffect, }; /** @@ -45,8 +47,10 @@ public static function install(): self /** * @param callable(Signal, Info): void $listener + * + * @return Attempt */ - public function listen(Signal $signal, callable $listener): void + public function listen(Signal $signal, callable $listener): Attempt { if (!$this->installed) { $this->wasAsync = \pcntl_async_signals(); @@ -54,56 +58,85 @@ public function listen(Signal $signal, callable $listener): void $this->installed = true; } - $handlers = $this->installSignal($signal); - $this->handlers = ($this->handlers)( - $signal, - ($handlers)($listener) - ); + return $this + ->installSignal($signal) + ->map(function($handlers) use ($signal, $listener) { + $this->handlers = ($this->handlers)( + $signal, + ($handlers)($listener), + ); + + return SideEffect::identity; + }); } /** * @param callable(Signal, Info): void $listener + * + * @return Attempt */ - public function remove(callable $listener): void + public function remove(callable $listener): Attempt { $handlers = $this->handlers->map( static fn($_, $listeners) => $listeners->exclude( static fn($callable) => $callable === $listener, ), ); - $_ = $handlers->foreach(static function($signal, $listeners): void { - if ($listeners->empty()) { - \pcntl_signal($signal->toInt(), \SIG_DFL); // restore default handler - } - }); - $this->handlers = $handlers->exclude( - static fn($_, $listeners) => $listeners->empty(), - ); - if ($this->handlers->empty()) { - $this->installed = false; - \pcntl_async_signals($this->wasAsync); - } + return $handlers + ->toSequence() + ->sink(SideEffect::identity) + ->attempt(static function($_, $installed) { + if ($installed->value()->empty()) { + $uninstalled = \pcntl_signal( + $installed->key()->toInt(), + \SIG_DFL, + ); // restore default handler + + if (!$uninstalled) { + return Attempt::error(new \RuntimeException('Failed to restore default handler')); + } + } + + return Attempt::result($_); + }) + ->map(function($_) use ($handlers) { + $this->handlers = $handlers->exclude( + static fn($_, $listeners) => $listeners->empty(), + ); + + return $_; + }) + ->map(function($_) { + if ($this->handlers->empty()) { + $this->installed = false; + \pcntl_async_signals($this->wasAsync); + } + + return $_; + }); } /** - * @return Sequence + * @return Attempt> */ - private function installSignal(Signal $signal): Sequence + private function installSignal(Signal $signal): Attempt { return $this ->handlers ->get($signal) - ->otherwise(function() use ($signal) { - \pcntl_signal($signal->toInt(), function($signo, $siginfo): void { + ->attempt(static fn() => new \Exception('Signal not installed')) + ->recover(function() use ($signal) { + $installed = \pcntl_signal($signal->toInt(), function($signo, $siginfo): void { $this->dispatch(Signal::of($signo), $siginfo); }); - /** @var Maybe> */ - return Maybe::nothing(); - }) - ->toSequence() - ->flatMap(static fn($listeners) => $listeners); + if (!$installed) { + return Attempt::error(new \RuntimeException('Failed to install signal listener')); + } + + return Attempt::result(Sequence::of()); + }); } private function dispatch(Signal $signal, mixed $info): void diff --git a/tests/HandlerTest.php b/tests/HandlerTest.php index f8623ee..ede14c1 100644 --- a/tests/HandlerTest.php +++ b/tests/HandlerTest.php @@ -8,6 +8,7 @@ Signal, Async\Interceptor, }; +use Innmind\Immutable\SideEffect; use Innmind\BlackBox\{ PHPUnit\BlackBox, PHPUnit\Framework\TestCase, @@ -36,19 +37,24 @@ public function testAllListenersAreCalledInOrderOnSignal() $this->fork(); - $this->assertNull($handlers->listen(Signal::child, function($signal) use (&$order, &$count): void { - static $handled = false; - - if ($handled) { - return; - } - - $handled = true; - $this->assertSame(Signal::child, $signal); - $order[] = 'first'; - ++$count; - })); - $handlers->listen(Signal::child, function($signal) use (&$order, &$count): void { + $this->assertInstanceOf( + SideEffect::class, + $handlers + ->listen(Signal::child, function($signal) use (&$order, &$count): void { + static $handled = false; + + if ($handled) { + return; + } + + $handled = true; + $this->assertSame(Signal::child, $signal); + $order[] = 'first'; + ++$count; + }) + ->unwrap(), + ); + $_ = $handlers->listen(Signal::child, function($signal) use (&$order, &$count): void { static $handled = false; if ($handled) { @@ -59,7 +65,7 @@ public function testAllListenersAreCalledInOrderOnSignal() $this->assertSame(Signal::child, $signal); $order[] = 'second'; ++$count; - }); + })->unwrap(); \sleep(2); // wait for child to stop @@ -80,17 +86,20 @@ public function testRemoveSignal() $order[] = 'first'; ++$count; }; - $handlers->listen(Signal::child, $first); - $handlers->listen(Signal::child, function($signal) use (&$order, &$count): void { + $_ = $handlers->listen(Signal::child, $first)->unwrap(); + $_ = $handlers->listen(Signal::child, function($signal) use (&$order, &$count): void { $this->assertSame(Signal::child, $signal); $order[] = 'second'; ++$count; - }); - $this->assertNull($handlers->remove($first)); + })->unwrap(); + $this->assertInstanceOf( + SideEffect::class, + $handlers->remove($first)->unwrap(), + ); \sleep(2); // wait for child to stop - $this->assertSame(1, $count); + $this->assertSame(1, $count, \implode(', ', $order)); $this->assertSame(['second'], $order); } @@ -107,8 +116,8 @@ public function testDefaultHandlerRestoredWhenAllListenersRemovedForASignal() $order[] = 'first'; ++$count; }; - $handlers->listen(Signal::child, $listener); - $handlers->remove($listener); + $_ = $handlers->listen(Signal::child, $listener)->unwrap(); + $_ = $handlers->remove($listener)->unwrap(); $this->assertSame($wasAsync, \pcntl_async_signals()); @@ -125,13 +134,13 @@ public function testAsyncHandlers(): BlackBox\Proof ->prove(function($signal) { $main = Handler::main(); $interceptor = Interceptor::new(); - $async = $main->async($interceptor); + $async = Handler::async($main, $interceptor); $called = false; - $async->listen($signal, function($in) use ($signal, &$called) { + $_ = $async->listen($signal, function($in) use ($signal, &$called) { $this->assertSame($signal, $in); $called = true; - }); + })->unwrap(); $interceptor->dispatch($signal); $this->assertTrue($called); @@ -145,19 +154,19 @@ public function testRemovedAsyncListenersAreNotCalled(): BlackBox\Proof ->prove(function($signal) { $main = Handler::main(); $interceptor = Interceptor::new(); - $async = $main->async($interceptor); + $async = Handler::async($main, $interceptor); $called = 0; $listener = function($in) use ($signal, &$called) { $this->assertSame($signal, $in); ++$called; }; - $async->listen($signal, $listener); + $_ = $async->listen($signal, $listener)->unwrap(); $interceptor->dispatch($signal); $this->assertSame(1, $called); - $async->remove($listener); + $_ = $async->remove($listener)->unwrap(); $interceptor->dispatch($signal); $this->assertSame(1, $called);