# Exit code 143 in GitHub Actions

> Exit code 143 in GitHub Actions means SIGTERM reached your step, not a crash. Tell cancellation, a step timeout and a reclaimed machine apart.

Source: https://latchkey.dev/learn/failures/exit-code-143-sigterm-in-ci  
Updated: 2026-09-20

Exit code 143 in GitHub Actions is a process that was terminated by SIGTERM, which is 128 plus signal 15, and not an error your code raised. Something outside the program asked it to stop: a canceled run, a step timeout, a container shutting down, or a machine being taken away.

## What this error means

The step stops partway through, usually mid-output, and the job ends on exit code 143. There is no assertion and no stack trace, because the process was never given the chance to finish and report one. Sometimes a cancellation banner explains it, sometimes a step timeout is named, and sometimes nothing does. The number itself carries the answer: the GNU Bash manual documents that "when a command terminates on a fatal signal whose number is N, Bash uses the value 128+N as the exit status", and 143 is 128 plus 15, the signal number of SIGTERM.

```Reproduction output, latchkey-small runner, 20 September 2026
SIGTERM, no handler   -> exit 143
SIGKILL               -> exit 137
SIGTERM, trapped      -> exit 0
timeout 2 sleep 30    -> exit 124
timeout --signal=KILL -> exit 137
SIGTERM to a child    -> exit 143
```

## Common causes

### The run was canceled

A newer push under a `cancel-in-progress` concurrency group, someone pressing cancel, or a required check that failed and took the run with it. The documented sequence reaches your process as SIGTERM 7.5 seconds after the SIGINT it probably ignored.

### A timeout-minutes limit elapsed

A step or job timeout terminates the same way. This is the benign case: the limit is one you chose, and the step it fired on is the one to look at.

### The machine was reclaimed or drained

Spot instances are taken back with notice measured in seconds, and a node upgrade drains the pods on it. Your job was making progress; the machine went away. In our experience this is the variant that looks random, because it tracks the cloud provider rather than the repository.

### Something inside the job sent it

A `docker stop`, a `kill` in a wrapper script, a test runner terminating its own workers, or `timeout --signal=TERM`. Easy to confirm and easy to miss, because the sender is a line of your own that nobody reads as a signal.

## How to fix it

### Find out whether the run was canceled before changing anything

1. Open the run page and look for a cancellation banner and what triggered it.
2. Check for a `concurrency` group with `cancel-in-progress`, and whether a newer commit landed mid-run.
3. If neither applies, look for `timeout-minutes` on the step or job, then work down to the machine.

### Trap SIGTERM so the job stops deliberately

A handler turns an abrupt 143 into a clean exit with partial results saved. Our run measured exactly that: the trapped case exited 0. Use it to flush logs and upload what you have, knowing it changes the reporting rather than the interruption.

```.github/workflows/ci.yml
- name: Run the suite
  run: |
    cleanup() { echo "stopping, flushing results"; cp -r ./partial-results /tmp/out || true; exit 1; }
    trap cleanup TERM INT
    npm test
```

### Set timeouts that mean something

Put `timeout-minutes` on the steps that can hang, not on the job alone, so the failure names the step. 360 minutes is both the job default and the largest value a step accepts.

```.github/workflows/ci.yml
jobs:
  e2e:
    timeout-minutes: 45
    steps:
      - run: ./scripts/wait-for-service.sh
        timeout-minutes: 5     # the step that hangs when the service is down
```

### Stop competing with yourself

If your concurrency rules cancel long jobs on every push, scope the group more narrowly, or drop `cancel-in-progress` on the workflows whose results you actually want.

```.github/workflows/ci.yml
concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
```

## How to prevent it

- Scope concurrency groups per pull request, not per repository.
- Put `timeout-minutes` on the steps that can hang, not only on the job.
- Trap SIGTERM in long jobs so partial results survive an interruption.
- Keep long, uninterruptible work off preemptible capacity.

## Read the number before you read the log

Six terminations ran in one step on a Latchkey `latchkey-small` runner on 20 September 2026, and the codes in the block above are what the shell recorded for each. Between them they cover almost every red build that is not your code.

One thing about that script is worth knowing first: every signal was sent to a child of its own, under `setsid --wait` in a separate session. An earlier attempt signaled a process in the step's own process group and terminated the step itself at that line, which is why the isolation exists and is its own fair demonstration of what a cancellation does to a job.

SIGTERM with no handler gave 143. SIGKILL gave 137, and it is the one a process cannot argue with: signal(7) records that SIGKILL "cannot be caught, blocked, or ignored". SIGTERM with a handler that flushed and exited cleanly gave 0, so a job that traps the signal can hide this failure entirely. GNU `timeout` gave 124 even though it sends SIGTERM, because it reports its own status rather than the signal it delivered.

So 143 tells you precisely one thing: a polite stop request arrived and the process did not handle it. Who sent it is the rest of this page.

## Cancellation is the most common sender

When a run is canceled, GitHub does not simply kill it. The documented sequence sends "SIGINT/Ctrl-C to the step's entry process" and waits 7500 ms, then sends "SIGTERM/Ctrl-Break to the process, then wait for 2500 ms for the process to exit", and only then "the runner kills the process tree". There is a five minute cap after which "the server will forcibly terminate all jobs and steps marked for cancellation".

That ordering explains the shape of the log: a program that ignores SIGINT but not SIGTERM dies 7.5 seconds after the cancel, with 143 and a truncated last line.

The cancel is often something you set up: a `concurrency` group with `cancel-in-progress` stops the older run the moment a newer commit lands on the same branch, which is the single most common source of surprise 143s in a busy repository.

```.github/workflows/ci.yml
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true   # the older run of this branch ends with SIGTERM
```

## Step timeouts, job timeouts and the numbers that matter

A `timeout-minutes` on a step or a job ends it the same way a cancellation does, and the step reports 143 unless it handled the signal. The defaults are worth knowing before you change any of them: 360 minutes is the default for a job and the ceiling GitHub enforces for a step, and GitHub's own limits documentation states that on hosted runners "each job in a workflow can run for up to 6 hours of execution time" while on self-hosted runners "each job in a workflow can run for up to 5 days of execution time".

So a job left at its default on a hosted runner cannot exceed six hours, and a self-hosted job that hangs sits there for days. A `timeout-minutes` on the steps that can hang turns that into a cheap 143 that names the step which stopped.

```.github/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 30          # the job, not just a step
    steps:
      - uses: actions/checkout@v7
      - run: npm ci
        timeout-minutes: 10        # the step most likely to hang
      - run: npm test
        timeout-minutes: 15
```

## When nothing in GitHub sent it

If the run was not canceled and no timeout is named, the signal came from below the workflow. A `docker stop` sends SIGTERM before escalating, so a step that stops a container and reads its status sees 143. A supervisor script, a test harness that kills workers, or a `timeout --signal=TERM` in your own code all do the same.

The other family is infrastructure. A spot machine gets a termination notice, or a Kubernetes node is drained and sends SIGTERM to the pods on it. Your job was healthy and the machine underneath it was not going to be there. That variant usually leaves a second message in the log, covered in [the runner has received a shutdown signal](/learn/failures/runner-received-a-shutdown-signal).

This is also the point at which 143 and [exit code 137](/learn/failures/exit-code-137-in-github-actions) part company. If the log shows 137 with no cancellation and no timeout, the kernel out-of-memory killer is the first suspect, and raising memory ceilings is the wrong move for a 143.

## FAQ

### Why do GitHub Actions jobs time out?

Either a `timeout-minutes` you set elapsed, or the job hit the platform limit: GitHub documents six hours per job on hosted runners and five days on self-hosted. A step hanging on a service or a lock uses all of that before the limit ends the job with 143.

### Is it possible to run a job longer than 6 hours on a self-hosted runner?

Yes. GitHub's limits documentation gives self-hosted runners up to five days of execution time per job, against six hours on hosted runners. That is the reason to set an explicit `timeout-minutes`: without one, a hung self-hosted job holds a machine for days.

### What is the difference between exit code 143 and exit code 137?

143 is SIGTERM, a request the process could have handled. 137 is SIGKILL, which cannot be caught at all. We measured both in one step: the same worker exited 143 on SIGTERM and 137 on SIGKILL. On a runner, 137 usually means the kernel out-of-memory killer, so the two point at different fixes.

### Does trapping SIGTERM make the job pass?

It changes what the job reports, not what happened. Our trapped case exited 0, so a handler that exits successfully will turn a canceled or timed-out job green, which is almost never what you want. Trap the signal to save partial results and then exit nonzero, so the interruption is still visible.

## References

- [signal(7): standard signals and their default actions](https://man7.org/linux/man-pages/man7/signal.7.html)
- [GNU Bash manual: exit status and the 128+N convention](https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html)
- [GitHub Actions: workflow cancellation reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-cancellation)
- [GitHub Actions: usage limits for jobs and workflow runs](https://docs.github.com/en/actions/reference/limits)
- [subosito/flutter-action issue 368: exit code 143 on a private repository](https://github.com/subosito/flutter-action/issues/368)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
