Exit code 143 in GitHub Actions
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.
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 143Reproduced on a Latchkey runner
Linux 6.17.0-1019-aws x86_64
GNU bash, version 5.2.21(1)-release (x86_64-pc-linux-gnu)
step shell pid 2178, process group 2173
case 1 worker pid 2190, no handler
Terminated
SIGTERM, no handler -> exit 143
case 2 worker pid 2191, no handler
/home/runner/.latchkey-job-Wxpjuz/_temp/f09396c1-7434-4357-8f6e-fb0cc0ec30f3.sh: line 33: 2191 Killed setsid --wait bash -c 'echo "case 2 worker pid $$, no handler"; kill -KILL $$; sleep 5'
SIGKILL -> exit 137
case 3 worker pid 2192, handler installed
case 3 worker: caught SIGTERM, flushing results
SIGTERM, trapped -> exit 0
timeout 2 sleep 30 -> exit 124
/home/runner/.latchkey-job-Wxpjuz/_temp/f09396c1-7434-4357-8f6e-fb0cc0ec30f3.sh: line 47: 2195 Killed setsid --wait timeout --signal=KILL 2 sleep 30
timeout --signal=KILL -> exit 137
case 5 worker pid 2199, waiting
case 5 parent: sending SIGTERM to pid 2199, in its own session
SIGTERM to a child -> exit 143
##[error]Process completed with exit code 143.
[latchkey-bash-wrapper] BEGIN sidecar POST (boot_wait=30s max_time=320s url=http://localhost/diagnose socket=/run/latchkey-self-heal/sock)
[latchkey-bash-wrapper] END sidecar POST ok (attempts=1 http=200)The runner diagnosed the failure and did not retry it; this failure needs the fix below.
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.
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
- Open the run page and look for a cancellation banner and what triggered it.
- Check for a
concurrencygroup withcancel-in-progress, and whether a newer commit landed mid-run. - If neither applies, look for
timeout-minuteson 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.
- name: Run the suite
run: |
cleanup() { echo "stopping, flushing results"; cp -r ./partial-results /tmp/out || true; exit 1; }
trap cleanup TERM INT
npm testSet 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.
jobs:
e2e:
timeout-minutes: 45
steps:
- run: ./scripts/wait-for-service.sh
timeout-minutes: 5 # the step that hangs when the service is downStop 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.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}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.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true # the older run of this branch ends with SIGTERMStep 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.
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: 15When 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.
This is also the point at which 143 and exit code 137 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.
How to prevent it
- Scope concurrency groups per pull request, not per repository.
- Put
timeout-minuteson 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.
Frequently asked questions
Why do GitHub Actions jobs time out?
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?
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?
Does trapping SIGTERM make the job pass?
Related guides
References
- signal(7): standard signals and their default actions
- GNU Bash manual: exit status and the 128+N convention
- GitHub Actions: workflow cancellation reference
- GitHub Actions: usage limits for jobs and workflow runs
- subosito/flutter-action issue 368: exit code 143 on a private repository
- GitHub Actions documentation