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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

- Mutation testing now counts code that tests execute without naming it in
`#[Covers]` (`infection --with-uncovered`, template `PublicVisibility`/
`ProtectedVisibility` switches and default-parameter signature ignore). New
tests pin the re-raise of a second signal under the default disposition (an
in-process run plus a forked child that must die of the signal) and the
fallback for a previous handler that is not callable from the scope.
Behaviour-neutral `src/` change: `SignalScope::listen()`/`run()` signatures are
split one parameter per line and the default signal set moved to a private
constant `DEFAULT_SIGNALS` (still `[SIGTERM, SIGINT]`).

## 0.1.0 — 2026-10-15

- Initial release: `SignalScope` (`listen()`, `run()`, `context()`,
Expand Down
5 changes: 4 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@
],
"cs": "php-cs-fixer fix --dry-run --diff --config=.php-cs-fixer.php",
"cs:fix": "php-cs-fixer fix --diff --config=.php-cs-fixer.php",
"mutation": "infection --threads=max",
"mutation": [
"Composer\\Config::disableProcessTimeout",
"infection --threads=max --with-uncovered"
],
"psalm": "psalm --no-cache",
"rector": "rector process --dry-run",
"rector:fix": "rector process",
Expand Down
24 changes: 23 additions & 1 deletion infection.json5
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,28 @@
"testFramework": "testo",
"minMsi": 95,
"mutators": {
"@default": true
"@default": true,
// Constructor/method parameter declarations with a default value
// (`private int $x = 100,`, `bool $strict = false,`): the mutant sits on a
// signature line with no executable opcode under pcov, so it is reported Not
// Covered whatever the tests do. Only a type-prefixed `$name = default,` line
// matches; a body line cannot (no type before the variable). Do NOT add ^ or $:
// Infection wraps the pattern as ^-\s*REGEX$ over the removed diff line.
// Verified 2026-11-01 by diffing the survivor lists with and without it:
// bulkhead 6, circuit-breaker 13, json-ld 19 mutants removed, all of them
// such signature lines; Escaped and Timed Out unchanged.
"global-ignoreSourceCodeByRegex": [
"(?:(?:private|protected|public|readonly)\\s+)*\\??[A-Za-z_\\\\][\\w\\\\|]*\\s+\\$\\w+\\s*=\\s*[^,;]+,"
],
// Off: with --with-uncovered a PublicVisibility mutant sits on a parameterless
// method's signature line, which has no executable opcode under pcov/xdebug,
// so it is reported Not Covered and can never be killed, whatever #[Covers]
// says. Measured on pinned-http-client 2026-11-01: 4 of 4 such mutants were
// Not Covered, none killed.
"PublicVisibility": false,
// Off for the same reason: a ProtectedVisibility mutant on a protected method's
// signature line is Not Covered and cannot be killed. Measured on
// understudy-phpunit 2026-11-01: 4 of 4 such mutants were Not Covered.
"ProtectedVisibility": false
}
}
15 changes: 11 additions & 4 deletions src/SignalScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
*/
final class SignalScope
{
private const array DEFAULT_SIGNALS = [\SIGTERM, \SIGINT];

/**
* Signals currently owned by an active scope; one process, one handler table.
*
Expand Down Expand Up @@ -53,8 +55,10 @@ private function __construct(
* @throws \InvalidArgumentException on an empty, duplicate or uncatchable signal
* @throws \LogicException when another active scope owns one of the signals
*/
public static function listen(Context $parent, array $signals = [\SIGTERM, \SIGINT]): self
{
public static function listen(
Context $parent,
array $signals = self::DEFAULT_SIGNALS,
): self {
$signals = self::validated($signals);

foreach ($signals as $signal) {
Expand Down Expand Up @@ -97,8 +101,11 @@ public static function listen(Context $parent, array $signals = [\SIGTERM, \SIGI
*
* @return T
*/
public static function run(Context $parent, callable $work, array $signals = [\SIGTERM, \SIGINT]): mixed
{
public static function run(
Context $parent,
callable $work,
array $signals = self::DEFAULT_SIGNALS,
): mixed {
$scope = self::listen($parent, $signals);

try {
Expand Down
53 changes: 53 additions & 0 deletions tests/SignalScopeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,53 @@ public function anIgnoredSignalStaysIgnored(): void
Assert::true($scope->context()->isCancelled());
}

public function aHandlerNotCallableFromTheScopeCountsAsTheDefaultDisposition(): void
{
$name = 'privateHandler';
pcntl_signal(\SIGUSR1, [$this, $name]);
$this->send(\SIGUSR1);
Assert::same($this->previous, [\SIGUSR1]);

$scope = SignalScope::listen(Context::background(), [\SIGUSR1]);
$scope->restore();

Assert::same(pcntl_signal_get_handler(\SIGUSR1), \SIG_DFL);
$this->privateHandler(\SIGUSR2);
Assert::same($this->previous, [\SIGUSR1, \SIGUSR2]);
}

public function aSecondSignalWithTheDefaultDispositionIsReRaised(): void
{
$scope = SignalScope::listen(Context::background(), [\SIGWINCH]);

$this->send(\SIGWINCH);
$this->send(\SIGWINCH);

Assert::same(pcntl_signal_get_handler(\SIGWINCH), \SIG_DFL);
Assert::same($scope->signal(), \SIGWINCH);

// The kernel ignores SIGWINCH by default, so the re-raise above is
// harmless but unobservable. Infection maps a mutant to the tests that
// execute its line in this process, so the observable check (a forked
// child that must die of the re-raised signal) lives in the same test.
$pid = pcntl_fork();
if ($pid === 0) {
try {
pcntl_signal(\SIGUSR1, \SIG_DFL);
SignalScope::listen(Context::background(), [\SIGUSR1]);
$this->send(\SIGUSR1);
$this->send(\SIGUSR1);
} finally {
posix_kill(posix_getpid(), \SIGKILL);
}
}

pcntl_waitpid($pid, $status);

Assert::true(pcntl_wifsignaled($status));
Assert::same(pcntl_wtermsig($status), \SIGUSR1);
}

public function sighupCanBeHandled(): void
{
$scope = SignalScope::listen(Context::background(), [\SIGHUP]);
Expand Down Expand Up @@ -377,6 +424,12 @@ public function anInvalidSetLeavesNothingInstalled(): void
}
}

/** Registered by name: private, so not callable from SignalScope's scope. */
private function privateHandler(int $signal): void
{
$this->previous[] = $signal;
}

private function childCount(Context $context): int
{
$state = (new \ReflectionProperty(Context::class, 'state'))->getValue($context);
Expand Down
Loading