skip to content

Why does Ctrl-C during a blocking socket.socket.recv raise KeyboardInterrupt instead of resuming the read?

level: juniorimportance: should knowfreq 35%

answer

  1. Two handlers, at two layers
  2. The kernel unblocks the call first
  3. Python code runs between bytecodes
  4. A raising handler beats the retry
  5. Default SIGINT handler raises

basics

~20 s

Ctrl-C delivers SIGINT, and Python's default SIGINT handler raises KeyboardInterrupt. Since Python 3.5 the interpreter reissues an interrupted call only when the handler returns normally; a handler that raises aborts the retry, so the exception leaves recv.

solid answer

~40 s

Ctrl-C sends SIGINT to the process. The C-level handler CPython installs does almost nothing: it records that the signal arrived and returns, which makes the blocked `recv` fail at the kernel level with `EINTR`. The interpreter then runs the Python-level handler at the next bytecode boundary, and for SIGINT the default handler raises `KeyboardInterrupt`. Since Python 3.5 (PEP 475) the interpreter reissues a call a signal interrupted, but only if the Python handler returned normally; when it raises, the exception wins and the call is abandoned. That is why the traceback points inside the read rather than the read quietly continuing. Practically it means every blocking stdlib call is an exception site, and code that wraps a wait in a bare `except:` makes Ctrl-C look broken.

code

python · 29 lines
python
import os
import signal
import socket
import threading
import time


class Deadline(Exception):
    pass


def on_signal(signum, frame):
    raise Deadline("stop waiting")


signal.signal(signal.SIGUSR1, on_signal)
sock_a, sock_b = socket.socketpair()


def interrupt() -> None:
    time.sleep(0.2)
    os.kill(os.getpid(), signal.SIGUSR1)


threading.Thread(target=interrupt).start()
try:
    sock_a.recv(1)
except Deadline:
    print("handler exception aborted the blocking recv")

go deeper

for a junior

Be ready to say that Ctrl-C sends SIGINT, that Python's default handler for it raises KeyboardInterrupt, and that the exception comes out of whatever call was blocked. Knowing to use try/finally around blocking waits is the practical takeaway.

for a middle

Explain the two layers: a tiny C handler that only records the signal, and your Python handler that runs at the next bytecode boundary. Then state the rule — the interrupted call is reissued unless the handler raises.

for a senior

Show that you design for it: every blocking call is an exception site, bare except clauses break Ctrl-C, and only the main thread runs handlers, so unblocking worker threads needs an explicit wakeup rather than hope.

for a principal

Own the convention across a codebase: which exception types may be caught around waits, whether shutdown is signalled by a raising handler or by a flag plus a wakeup, and how that choice interacts with libraries you do not control.

## Two handlers, not one When you press Ctrl-C in a terminal, the tty driver sends `SIGINT` to the foreground process group. Inside CPython that signal is handled twice, at two different layers, and keeping the layers apart is the whole answer. The first layer is the C signal handler the interpreter installs. A real POSIX signal handler runs asynchronously, on whatever stack the process happened to be on, and almost nothing is safe to do there — you cannot allocate, take the interpreter's locks, or run Python code. So CPython's C handler is deliberately trivial: it records "signal number N arrived" in a flag array, sets a global "something is pending" trip, optionally writes a byte to a wakeup file descriptor, and returns. The second layer is the Python-level handler — the callable you registered with `signal.signal`, or the default one the interpreter installed for `SIGINT`. It is ordinary Python code, so it can only run where ordinary Python code runs: between bytecode instructions, in the main thread, when the eval loop next checks the pending flag. ## Why the blocked call fails first While the process is parked inside `recv`, no bytecode is executing, so the pending flag cannot be checked. The kernel resolves that for us: when a signal with a handler is delivered to a thread blocked in an interruptible system call, the call returns early with the error `EINTR` — errno 4, exposed in Python as `errno.EINTR`. CPython registers Python-level handlers in interruptible mode precisely so that this happens promptly, rather than letting the kernel silently restart the call and delay your handler for as long as the read blocks. So the sequence is: signal delivered → C handler sets a flag → `recv` fails with `EINTR` → the interpreter, back in its own code, notices the pending flag and calls the Python-level handler → and only then does it decide what to do with the interrupted call. ## The retry rule and its one exception Since Python 3.5, PEP 475 made that last decision automatic: reissue the call. That is why hand-written `while True: try: ... except InterruptedError: continue` loops around stdlib calls are dead code on any supported Python. The rule has one exception, and it is the case in the question. The retry only happens if the Python-level handler **returned normally**. If the handler raises, the interpreter does not reissue the call — the handler's exception propagates out of whatever call was interrupted. The default `SIGINT` handler raises `KeyboardInterrupt`, so Ctrl-C during a blocked `recv` surfaces as a `KeyboardInterrupt` whose traceback points at the `recv` line. Nothing about `recv` is special here; the same holds for `time.sleep`, `queue.Queue.get`, `threading.Event.wait`, `os.read`, a `select.select` wait or a `subprocess.Popen.wait`. ## Consequences you will actually hit **Every blocking call is an exception site.** Anything holding a resource across a wait needs a `try`/`finally` or a context manager; "the read will just come back" is not true. **`except Exception` does not swallow Ctrl-C — a bare `except:` does.** `KeyboardInterrupt` inherits from `BaseException`, not from `Exception`, specifically so that routine error handling leaves it alone. But `except:` with no class, or `except BaseException`, catches it, and a loop that does that around a wait will restart the wait and make the terminal look frozen. The same trap catches you with a *custom* handler that raises a normal `Exception` subclass: that one **is** swallowed by `except Exception`. **Only the main thread is involved.** The Python-level handler always runs in the main thread. If a worker thread is the one blocked in `recv`, its call is still interrupted at the kernel level, but the `KeyboardInterrupt` is raised in the main thread — the worker's call is simply retried and keeps blocking. That asymmetry surprises people who expect Ctrl-C to unblock every thread. **Data can already be gone.** The kernel only returns `EINTR` when a call has made no progress; a partially completed transfer reports what it managed instead. Combined with an exception cutting the call short, a hand-rolled protocol loop can end up out of step if it assumes it either fully succeeded or did nothing. The short version to say out loud: the kernel unblocks the call, the interpreter runs your handler, and a handler that raises beats the automatic retry.

  • Does `except Exception:` around the read catch that KeyboardInterrupt?
    No. `KeyboardInterrupt` derives from `BaseException`, deliberately outside the `Exception` hierarchy, so ordinary error handling does not swallow an interrupt. A bare `except:` or `except BaseException:` does catch it, and that is how people accidentally make Ctrl-C stop working. Note the asymmetry: a custom handler that raises an ordinary `Exception` subclass **is** swallowed by `except Exception:`, which is a far easier mistake to make.
  • If a worker thread is blocked in the read, where does the KeyboardInterrupt appear?
    In the main thread. Python-level signal handlers only ever run in the main thread, so that is where the default `SIGINT` handler raises. The worker's own call is interrupted at the kernel level, then transparently reissued, so it keeps blocking. Unblocking workers needs an explicit mechanism — closing the socket, a shutdown flag the worker polls, or a wakeup written to something the worker is selecting on.
  • Why does the C-level handler do so little?
    Because it runs asynchronously, on an arbitrary stack, possibly mid-allocation and mid-lock. Only a small set of async-signal-safe operations is legal there, and running Python code — which allocates and takes interpreter locks — is not among them. Recording a flag and returning lets the real work happen at a well-defined point, between bytecodes, where the interpreter's state is consistent.

The C handler is a receptionist who only writes your name on a pad; the Python handler is the person who actually reads the pad, and they only look up between tasks.

saying these in an interview costs you the question

  • Says the interrupted read resumes where it left off
  • Thinks Python code runs inside the C signal handler
  • Believes except Exception catches KeyboardInterrupt
  • Claims Python 3 never aborts an interrupted call
  • Expects Ctrl-C to raise inside every thread

context