Repository navigation
Stop the task when a process is interrupted by a CTRL+C - #909
Merged
Merged
Conversation
nicolas-grekas
added a commit
to symfony/symfony
that referenced
this pull request
Oct 14, 2026
…nal stops the application (lyrixx) This PR was merged into the 8.2 branch. Discussion ---------- [Console] Exit with 128 + the signal number when a signal stops the application | Q | A | ------------- | --- | Branch? | 8.2 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | - | License | MIT When the application has an event dispatcher (always the case with FrameworkBundle), it handles `SIGINT`, `SIGQUIT`, `SIGTERM`… and exits with the exit code of the `ConsoleSignalEvent`, which starts at `0`. So a Ctrl+C on a command exits with `0`: ```bash bin/console app:import && bin/console app:publish # Ctrl+C during the import: the publication runs anyway ``` A CI job, a job runner or a script reports a success for a command that was interrupted. The exit on signal was added in #37827 to simulate PHP's default behavior, and kept in #49529 ("By default, we exit, like PHP does by default on `SIGINT`, `SIGTERM`"). But a PHP process killed by one of these signals is not seen as a success: the shell reports `128 + the signal number`. That's what Messenger already does by hand when `messenger:consume` is force-quit (#65633), and what Castor had to add with a listener (jolicode/castor#909, #907). This PR makes `128 + the signal number` the default exit code when a signal stops the application (e.g. `130` for `SIGINT`, `143` for `SIGTERM`, `138` for `SIGUSR1`): - `ConsoleSignalEvent::getExitCode()` starts with it, so `console.signal` listeners still decide with `setExitCode()` and `abortExit()`; - `SignalableCommandInterface::handleSignal()` receives it as `$previousExitCode`, with or without a dispatcher; - `ConsoleTerminateEvent` gets it, as before. This applies to every signal: a command that gives a meaning to a signal aborts the exit, so this code only matters when the signal stops the command. For instance, `kill -USR1` stops `messenger:consume` in the middle of a message: it now exits with `138` instead of `0`. It's a behavior change, hence 8.2 and an UPGRADE note. systemd sees a death by `SIGTERM` as a clean stop, but an exit status of `143` as a failure: a command run as a service that does not handle `SIGTERM` now ends up `failed` after `systemctl stop`. The fix is to stop the command gracefully on `SIGTERM` (as `messenger:consume` does, so it is not affected), to add `SuccessExitStatus=143` to the unit, or to call `$event->setExitCode(0)` in a `console.signal` listener. Killing the process with the signal instead of calling `exit()` would avoid this, but it would skip destructors and output buffers. Commits ------- 7f267e1 [Console] Exit with 128 + the signal number when a signal stops the application
lyrixx
force-pushed
the
abort-on-interrupt
branch
from
October 15, 2026 08:23
a73e7a9 to
cb6c154
Compare
lyrixx
marked this pull request as ready for review
October 15, 2026 08:23
A process with the terminal (TTY) may put it in raw mode, like `docker run -it`: it gets the CTRL+C alone, and exits with the code 130. When the failures were allowed, the task carried on to its next step. Such a process, or one killed by a SIGINT Castor did not receive, now throws a ProcessInterruptedException, whatever allowFailure, unless SIGINT is trapped. Castor also exits with 128 + the number of the signal when a signal stops it, instead of 0, whatever the signal. Symfony Console 8.2 does it on its own (symfony/symfony#66950), so InterruptionListener can be removed once Castor requires it. The rest is still needed: Castor receives no signal when a process in raw mode gets the CTRL+C.
lyrixx
force-pushed
the
abort-on-interrupt
branch
from
October 15, 2026 08:36
cb6c154 to
4d1edd6
Compare
pyrech
approved these changes
Oct 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A
CTRL+Cusually reaches Castor itself, which stops. But a process run with the terminal (withTty(),toInteractive()) may put it in raw mode, likedocker run -itdoes: only the process gets theCTRL+C, and it exits with the code130. When the failures are allowed (withAllowFailure(),exit_code(),toInteractive()), the task then carried on to its next step, so a task running several tools needed oneCTRL+Cper tool. It showed up in jolicode/docker-starter#453.Besides, when Castor itself receives the signal, Symfony Console exits with the code
0:castor build && castor deploywent on with the deployment after aCTRL+C.Changes
A process interrupted on its own stops the task
130while it has the terminal, or that is killed by aSIGINTCastor did not receive, throws aProcessInterruptedException, whateverallowFailure. It is rendered as a short warning, and Castor exits with130. Being an exception, thefinallyblocks of the task still run.CTRL+Citself: the exit code130stays a failure like any other, socapture(), pipes and PTY are not affected.SIGINTis trapped withwithTrappedSignals(), the task goes on, as documented for the trap.Castor exits with 128 + the signal number
130forSIGINT,143forSIGTERM,138forSIGUSR1...) instead of0, whatever the signal, as Symfony 8.2 does.InterruptionListenersets this code before the other listeners and theonSignalshandlers of the task, which still decide.Symfony 8.2
symfony/symfony#66950 makes Symfony Console 8.2 exit with 128 + the signal number on its own. Once Castor requires
symfony/console^8.2(#911),InterruptionListeneris redundant and can be removed: aTODOon the class says so. Its test stays, since it checks the behavior, not the listener.The first part of this PR is still needed with Symfony 8.2. When a process puts the terminal in raw mode, Castor receives no signal at all, so Symfony Console has nothing to react to: the exit code
130of the process is the only trace of theCTRL+C, and onlyProcessRunnercan see it. Comments inProcessRunnersay so, to keep this code when the listener goes away.Trade-offs
130is a convention, not a proof of aCTRL+C: a process with the terminal that exits with130on its own stops the task too. The main case is an interactive shell, which exits with the code of its last command (130after aCTRL+Cat the prompt). TrappingSIGINTlets the task go on after it, the documentation says so.parallel(): the jobs never get the terminal, so only theSIGINTcase applies, and an interrupted job is reported like a failed one.Tests
InterruptionTestruns Castor in a pseudo terminal withscript, likeParallelJobsTuiTest. 6 of its 8 tests fail without the change, the 2 others check that the task goes on when it should.CTRL+C, before and after the change:docker run --rm -it --init alpine sleep 20, a child in raw mode,exit_code(), PTY and pipes, with and without a trap. On a docker-starter task running two container commands, the second one no longer runs, and Castor exits with130instead of0.Part of #907.