Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
12 changes: 12 additions & 0 deletions .github/workflows/extensive.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
20 changes: 12 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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).
4 changes: 4 additions & 0 deletions blackbox.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
}
Expand Down
16 changes: 13 additions & 3 deletions src/Async/Interceptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
Map,
Sequence,
Maybe,
Attempt,
SideEffect,
};

/**
Expand Down Expand Up @@ -64,7 +66,10 @@ public function remove(callable $listener): void
);
}

public function dispatch(Signal $signal): void
/**
* @return Attempt<SideEffect>
*/
public function dispatch(Signal $signal): Attempt
{
/** @psalm-suppress MixedArgumentTypeCoercion */
$info = Info::of(
Expand All @@ -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() => $_,
));
}
}
42 changes: 27 additions & 15 deletions src/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
Handler\Async,
Async\Interceptor,
};
use Innmind\Immutable\{
Attempt,
SideEffect,
};

final class Handler
{
Expand All @@ -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<SideEffect>
*/
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<SideEffect>
*/
#[\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);
}
}
32 changes: 26 additions & 6 deletions src/Handler/Async.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
Info,
Async\Interceptor,
};
use Innmind\Immutable\{
Attempt,
SideEffect,
};

/**
* @internal
Expand All @@ -35,19 +39,35 @@ public static function new(Handler $parent, ?Interceptor $interceptor): self

/**
* @param callable(Signal, Info): void $listener
*
* @return Attempt<SideEffect>
*/
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<SideEffect>
*/
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 $_;
});
}
}
89 changes: 61 additions & 28 deletions src/Handler/Main.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
Sequence,
Map,
Maybe,
Attempt,
SideEffect,
};

/**
Expand Down Expand Up @@ -45,65 +47,96 @@ public static function install(): self

/**
* @param callable(Signal, Info): void $listener
*
* @return Attempt<SideEffect>
*/
public function listen(Signal $signal, callable $listener): void
public function listen(Signal $signal, callable $listener): Attempt
{
if (!$this->installed) {
$this->wasAsync = \pcntl_async_signals();
\pcntl_async_signals(true);
$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<SideEffect>
*/
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<callable(Signal, Info): void>
* @return Attempt<Sequence<callable(Signal, Info): void>>
*/
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<Sequence<callable(Signal, Info): void>> */
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
Expand Down
Loading
Loading