Skip to content

Stop the task when a process is interrupted by a CTRL+C - #909

Merged
lyrixx merged 1 commit into
mainfrom
abort-on-interrupt
Oct 15, 2026
Merged

lyrixx merged 1 commit into
mainfrom
abort-on-interrupt

Conversation

@lyrixx

@lyrixx lyrixx commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

A CTRL+C usually reaches Castor itself, which stops. But a process run with the terminal (withTty(), toInteractive()) may put it in raw mode, like docker run -it does: only the process gets the CTRL+C, and it exits with the code 130. 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 one CTRL+C per 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 deploy went on with the deployment after a CTRL+C.

Changes

A process interrupted on its own stops the task

  • A process that exits with 130 while it has the terminal, or that is killed by a SIGINT Castor did not receive, throws a ProcessInterruptedException, whatever allowFailure. It is rendered as a short warning, and Castor exits with 130. Being an exception, the finally blocks of the task still run.
  • Without the terminal, Castor receives the CTRL+C itself: the exit code 130 stays a failure like any other, so capture(), pipes and PTY are not affected.
  • When SIGINT is trapped with withTrappedSignals(), the task goes on, as documented for the trap.

Castor exits with 128 + the signal number

  • When a signal stops Castor, it exits with 128 + the signal number (130 for SIGINT, 143 for SIGTERM, 138 for SIGUSR1...) instead of 0, whatever the signal, as Symfony 8.2 does. InterruptionListener sets this code before the other listeners and the onSignals handlers 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), InterruptionListener is redundant and can be removed: a TODO on 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 130 of the process is the only trace of the CTRL+C, and only ProcessRunner can see it. Comments in ProcessRunner say so, to keep this code when the listener goes away.

Trade-offs

  • The exit code 130 is a convention, not a proof of a CTRL+C: a process with the terminal that exits with 130 on its own stops the task too. The main case is an interactive shell, which exits with the code of its last command (130 after a CTRL+C at the prompt). Trapping SIGINT lets the task go on after it, the documentation says so.
  • parallel(): the jobs never get the terminal, so only the SIGINT case applies, and an interrupted job is reported like a failed one.
  • Windows: no TTY and no signals, nothing changes.

Tests

  • InterruptionTest runs Castor in a pseudo terminal with script, like ParallelJobsTuiTest. 6 of its 8 tests fail without the change, the 2 others check that the task goes on when it should.
  • Checked by hand in a pseudo terminal receiving a real 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 with 130 instead of 0.

Part of #907.

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
lyrixx force-pushed the abort-on-interrupt branch from a73e7a9 to cb6c154 Compare October 15, 2026 08:23
@lyrixx
lyrixx marked this pull request as ready for review October 15, 2026 08:23
@lyrixx
lyrixx requested a review from joelwurtz October 15, 2026 08:28
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
lyrixx force-pushed the abort-on-interrupt branch from cb6c154 to 4d1edd6 Compare October 15, 2026 08:36
@lyrixx
lyrixx merged commit ee4e428 into main Oct 15, 2026
12 checks passed
@lyrixx
lyrixx deleted the abort-on-interrupt branch October 15, 2026 13:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants