From 94723fc20ded01e696cd65d117b17b8da502ea10 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 17 Nov 2025 14:42:44 +0100 Subject: [PATCH 01/10] require php 8.4 --- .github/workflows/ci.yml | 10 ++++------ CHANGELOG.md | 6 ++++++ composer.json | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 189105d..779f162 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,13 +4,11 @@ on: [push, pull_request] jobs: blackbox: - uses: innmind/github-workflows/.github/workflows/black-box-matrix.yml@main + uses: innmind/github-workflows/.github/workflows/black-box-matrix.yml@next coverage: - uses: innmind/github-workflows/.github/workflows/coverage-matrix.yml@main + uses: innmind/github-workflows/.github/workflows/coverage-matrix.yml@next secrets: inherit psalm: - uses: innmind/github-workflows/.github/workflows/psalm-matrix.yml@main + uses: innmind/github-workflows/.github/workflows/psalm-matrix.yml@next cs: - uses: innmind/github-workflows/.github/workflows/cs.yml@main - with: - php-version: '8.2' + uses: innmind/github-workflows/.github/workflows/cs.yml@next diff --git a/CHANGELOG.md b/CHANGELOG.md index b6a2a5b..8b4f03c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Changed + +- Requires PHP `8.4` + ## 4.1.1 - 2025-08-20 ### Fixed diff --git a/composer.json b/composer.json index 84c94cc..d9fa0e2 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ "issues": "http://github.com/Innmind/Signals/issues" }, "require": { - "php": "~8.2", + "php": "~8.4", "innmind/immutable": "~5.18" }, "autoload": { From 23c15763342bbb2f644931e0a9b09edcf1687697 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Mon, 17 Nov 2025 14:43:15 +0100 Subject: [PATCH 02/10] update dependencies --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d9fa0e2..6cdc4ac 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ }, "require": { "php": "~8.4", - "innmind/immutable": "~5.18" + "innmind/immutable": "dev-next" }, "autoload": { "psr-4": { From c87346ab13fa69170360c5cf6f501b4c78fcdc8a Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Thu, 11 Dec 2025 13:51:59 +0100 Subject: [PATCH 03/10] make Handler::async() static for api consistency with other packages --- src/Handler.php | 34 ++++++++++++++++++---------------- tests/HandlerTest.php | 4 ++-- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/Handler.php b/src/Handler.php index 1e985fc..6c6a447 100644 --- a/src/Handler.php +++ b/src/Handler.php @@ -28,6 +28,24 @@ public static function main(): self return new self(Main::install()); } + /** + * 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 + */ + #[\NoDiscard] + public static function async( + self $signals, + ?Interceptor $interceptor = null, + ): self { + return new self( + Async::new($signals, $interceptor), + ); + } + /** * @param callable(Signal, Info): void $listener */ @@ -43,20 +61,4 @@ public function remove(callable $listener): void { $this->implementation->remove($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 - */ - #[\NoDiscard] - public function async(?Interceptor $interceptor = null): self - { - return new self( - Async::new($this, $interceptor), - ); - } } diff --git a/tests/HandlerTest.php b/tests/HandlerTest.php index f8623ee..c76dabb 100644 --- a/tests/HandlerTest.php +++ b/tests/HandlerTest.php @@ -125,7 +125,7 @@ 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) { @@ -145,7 +145,7 @@ 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) { From cd9a03a850c071b58686a3f172e9dde279159838 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 7 Feb 2026 13:47:35 +0100 Subject: [PATCH 04/10] tag dependencies --- .github/workflows/ci.yml | 8 ++++---- CHANGELOG.md | 1 + composer.json | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 779f162..2f3eecb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,11 +4,11 @@ on: [push, pull_request] jobs: blackbox: - uses: innmind/github-workflows/.github/workflows/black-box-matrix.yml@next + uses: innmind/github-workflows/.github/workflows/black-box-matrix.yml@main coverage: - uses: innmind/github-workflows/.github/workflows/coverage-matrix.yml@next + uses: innmind/github-workflows/.github/workflows/coverage-matrix.yml@main secrets: inherit psalm: - uses: innmind/github-workflows/.github/workflows/psalm-matrix.yml@next + uses: innmind/github-workflows/.github/workflows/psalm-matrix.yml@main cs: - uses: innmind/github-workflows/.github/workflows/cs.yml@next + uses: innmind/github-workflows/.github/workflows/cs.yml@main diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b4f03c..0110243 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changed - Requires PHP `8.4` +- Requires `innmind/immutable:~6.0` ## 4.1.1 - 2025-08-20 diff --git a/composer.json b/composer.json index 6cdc4ac..416e600 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ }, "require": { "php": "~8.4", - "innmind/immutable": "dev-next" + "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" } From 7dd96665eb34c2c768bdc4001d264c992f5e9746 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 7 Feb 2026 13:48:23 +0100 Subject: [PATCH 05/10] add extensive CI --- .github/workflows/extensive.yml | 12 ++++++++++++ blackbox.php | 4 ++++ 2 files changed, 16 insertions(+) create mode 100644 .github/workflows/extensive.yml 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/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 From 2c685891b10c40481186420f69f2ea288bd962a0 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 7 Feb 2026 14:38:42 +0100 Subject: [PATCH 06/10] make signal (un)installing return an Attempt --- CHANGELOG.md | 2 + src/Handler.php | 18 +++++++-- src/Handler/Async.php | 32 +++++++++++++--- src/Handler/Main.php | 89 +++++++++++++++++++++++++++++-------------- tests/HandlerTest.php | 57 +++++++++++++++------------ 5 files changed, 136 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0110243..2cf70e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - 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 diff --git a/src/Handler.php b/src/Handler.php index 6c6a447..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 { @@ -48,17 +52,23 @@ public static function async( /** * @param callable(Signal, Info): void $listener + * + * @return Attempt */ - public function listen(Signal $signal, callable $listener): void + #[\NoDiscard] + public function listen(Signal $signal, callable $listener): Attempt { - $this->implementation->listen($signal, $listener); + return $this->implementation->listen($signal, $listener); } /** * @param callable(Signal, Info): void $listener + * + * @return Attempt */ - public function remove(callable $listener): void + #[\NoDiscard] + public function remove(callable $listener): Attempt { - $this->implementation->remove($listener); + 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 c76dabb..29b44a0 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,13 +86,16 @@ 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 @@ -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()); @@ -128,10 +137,10 @@ public function testAsyncHandlers(): BlackBox\Proof $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); @@ -152,12 +161,12 @@ public function testRemovedAsyncListenersAreNotCalled(): BlackBox\Proof $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); From c81218cce37c0a55b84b8c5e95fa1e91d3c3415e Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 7 Feb 2026 14:43:09 +0100 Subject: [PATCH 07/10] make Interceptor::dispatch() return an Attempt --- src/Async/Interceptor.php | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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() => $_, + )); } } From 00653df39950c62fd0b1b4d8e92e920eda18759b Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 7 Feb 2026 14:44:11 +0100 Subject: [PATCH 08/10] fix readme --- README.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9feb756..e39c2c1 100644 --- a/README.md +++ b/README.md @@ -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). From 8109b047dc7e1dddd1aa238ab891f2e5e14209ea Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 7 Feb 2026 14:44:37 +0100 Subject: [PATCH 09/10] fix ci badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e39c2c1..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) From 6e17758df14d5b14843ea650ee2655623756ff41 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Sat, 7 Feb 2026 14:48:18 +0100 Subject: [PATCH 10/10] display the order of calls when invalid count --- tests/HandlerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/HandlerTest.php b/tests/HandlerTest.php index 29b44a0..ede14c1 100644 --- a/tests/HandlerTest.php +++ b/tests/HandlerTest.php @@ -99,7 +99,7 @@ public function testRemoveSignal() \sleep(2); // wait for child to stop - $this->assertSame(1, $count); + $this->assertSame(1, $count, \implode(', ', $order)); $this->assertSame(['second'], $order); }