Skip to content

fix(python): Shut workers down without raising from signal handlers - #764

Merged
untitaker merged 1 commit into
mainfrom
fix/signal-safe-worker-shutdown
Jul 28, 2026
Merged

fix(python): Shut workers down without raising from signal handlers#764
untitaker merged 1 commit into
mainfrom
fix/signal-safe-worker-shutdown

Conversation

@untitaker

Copy link
Copy Markdown
Member

Replaces #762.

The bug

Both worker signal handlers raised KeyboardInterrupt. That unwinds at an arbitrary bytecode, and the worker's shutdown event is a multiprocessing.Event that both workers also use as their backoff sleep (fetch_task, _send_update_task, _send_results).

Interrupting multiprocessing.Event.wait() lands inside Condition.wait(), which releases the lock N times, sleeps on a semaphore, then reacquires in a finally. An interrupt during that finally leaves the lock unheld while Event.wait's enclosing with block still releases it, or leaves _sleeping_count/_woken_count skewed so the next notify() trips one of its three asserts.

Why not just set the event from the handler

That is what #762 does, and it swaps the corruption for a hang. Event.set() — both the threading and multiprocessing flavours — takes a non-reentrant lock. A signal arriving while the same thread is inside wait() holding that lock makes the handler block on a lock only that thread can release. The process is then unkillable by SIGTERM, so k8s waits out the grace period and SIGKILLs, losing exactly the in-flight tasks the change was meant to protect. The multiprocessing flavour is worse still: another process can be holding it.

The fix

Python runs signal handlers on the main thread between bytecodes, so a plain attribute assignment is safe and anything that takes a lock is not. Handlers now only do self._shutdown_signal.request(), which assigns a bool. The loops act on it.

  • The worker shutdown event becomes ShutdownSignal, wrapping a threading.Event. It was never shared with child processes — unlike the pool's own _shutdown_event — so it never needed to be a multiprocessing.Event.
  • Sleeps poll the flag every SHUTDOWN_POLL_INTERVAL_SEC (0.5s). The event is still there so a shutdown noticed on the main thread wakes the result thread immediately, and is only ever set from normal code.
  • The push worker polls server.wait_for_termination(timeout=...) instead of relying on a server.stop() call from inside the handler. grpc's stop() takes its own locks and is not safe there either.
  • The pull worker returns 0 instead of re-raising KeyboardInterrupt, so cli.py gets an exit code.
  • The push worker skips server.start() if shutdown was already requested, and only stops a server it started.
  • A pull worker that is shutting down no longer asks for another task, either in fetch_task() or via update_task's fetch_next.

The last three are carried over from #762.

Known gap

A SIGTERM arriving during a blocking get_task() or the client's all-hosts-unavailable time.sleep() (worker/client.py:371, up to 20s) is not noticed until that call returns. The old KeyboardInterrupt did cut those short. Fixing it properly means putting a deadline on those calls, which is a separate change.

Tests

ShutdownSignal unit tests cover the timeout, both wakeup paths, and a real SIGALRM handler firing while wait() is sleeping. Worker tests cover clean SIGTERM exit in pull and push mode, shutdown before server.start(), shutdown during warmup, and the two fetch-suppression paths.

ref SENTRY-5MB5

🤖 Generated with Claude Code

Both worker signal handlers raised KeyboardInterrupt. That unwinds at an
arbitrary bytecode, and the worker's shutdown event is a
multiprocessing.Event that both workers also use as their backoff sleep.
Interrupting multiprocessing.Event.wait() can leave its condition
variable's lock unheld while the enclosing `with` block still releases
it, or leave the sleeping/woken counters skewed so the next notify()
trips one of its asserts.

Signal handlers now only assign a bool, which takes no lock, and the
loops act on it. Calling Event.set() from a handler is not an option
either: it takes a non-reentrant lock, so a signal arriving while the
same thread is inside wait() deadlocks the process outright.

Consequences:

- The worker shutdown event is now a ShutdownSignal wrapping a
  threading.Event. It was never shared with child processes, unlike the
  pool's own _shutdown_event, so it does not need to be a
  multiprocessing.Event.
- Sleeps poll the flag every SHUTDOWN_POLL_INTERVAL_SEC, and the push
  worker polls wait_for_termination() rather than being unblocked by a
  server.stop() call from inside the handler.
- The pull worker returns 0 instead of re-raising KeyboardInterrupt, so
  the CLI gets an exit code.
- The push worker skips server.start() if shutdown was already
  requested, and only stops a server it started.
- A pull worker that is shutting down no longer asks for another task,
  in fetch_task() or via update_task's fetch_next.

A SIGTERM arriving during a blocking get_task() or the client's
all-hosts-unavailable sleep is still not noticed until that call
returns. Putting a deadline on those calls is a separate change.

ref SENTRY-5MB5
@untitaker
untitaker requested a review from a team as a code owner July 23, 2026 14:46

def test_start_does_not_serve_when_shutdown_during_warmup() -> None:
from grpc_health.v1 import health_pb2
@contextlib.contextmanager

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is some complicated testing. I'm OK with it but we are getting to edge of "human-readable".

@linear-code

linear-code Bot commented Jul 27, 2026

Copy link
Copy Markdown

STREAM-1649

@untitaker
untitaker merged commit 07fe102 into main Jul 28, 2026
30 checks passed
@untitaker
untitaker deleted the fix/signal-safe-worker-shutdown branch July 28, 2026 15:43
@sentry-taskbroker-fast-revert-bot

Copy link
Copy Markdown

PR reverted: e9d1494

sentry-taskbroker-fast-revert-bot Bot pushed a commit that referenced this pull request Jul 29, 2026
…ndlers (#764)"

This reverts commit 07fe102.

Co-authored-by: untitaker <837573+untitaker@users.noreply.github.com>
untitaker added a commit that referenced this pull request Aug 25, 2026
…784)

* fix(python): Shut workers down without raising from signal handlers

Reland of #764 (reverted in e9d1494) with the bug that forced the revert
fixed.

Signal handlers now only flip a bool instead of raising KeyboardInterrupt.
Raising unwinds at an arbitrary bytecode and can leave locks held by the
interrupted code in a broken state, which is where the
`ValueError: semaphore or lock released too many times` crashes came from.
Anything that takes a lock, including `Event.set()` and `server.stop()`, is
also unsafe to call from a handler, so `ShutdownSignal.request()` does
nothing but assign.

The previous attempt exited on its own shortly after startup because the
serve loop read the return value of
`grpc.Server.wait_for_termination(timeout=...)` as "the server terminated".
It actually returns True when the timeout elapsed, i.e. while the server is
healthy, and False once it has terminated - the inverse of `Event.wait()`.
A healthy worker therefore broke out of the loop after one poll interval and
shut down cleanly with exit code 0.

The serve loop no longer looks at that return value at all. It sleeps on
`ShutdownSignal.wait()`, so our own flag is the only thing that can end it.

ref STREAM-1649

* fix(python): Address review on shutdown handling

- Notice a gRPC server that terminated on its own again. Polling only the
  shutdown flag meant an internally failed server left the parent running
  with live children and a green health check. Read
  wait_for_termination(timeout=...) with its real semantics: True means the
  timeout elapsed and the server is still up, False means it terminated.

- Drop a pull-mode task claimed while shutting down. get_task() blocks with
  no deadline, so SIGTERM can land mid-RPC; handing the activation to a
  child claims work we won't run, which then has to expire on the broker
  before anyone else picks it up.

* test(python): Make the shutdown tests actually observe the exit condition

Verified by reintroducing the inverted boolean and re-running: previously
one test failed, one hung until timeout, and the SIGTERM test passed
regardless. Now all three fail fast.

- test_push_start_exits_cleanly_on_sigterm fired SIGTERM from the first
  poll, so the loop exited after one iteration whichever way the exit
  condition was read. That is how the inverted boolean got through review.
  Deliver the signal on the third poll instead, so surviving to it proves
  the condition is being exercised.

- test_push_start_exits_when_server_terminates_unexpectedly spun forever
  when the loop ignored a terminated server. Bail out after a few polls so
  it fails with a message instead of hanging CI.

* test(python): Pin the property ShutdownSignal actually exists for

Mutation check: making request() also call _event.set() -- which destroys
the whole point of the class, since Event.set() takes the lock a signal
handler must not touch -- passed all 18 shutdown tests.

The two wakeup tests look like they cover this but cannot: one allows <5s,
loose enough to pass whether request() polls or wakes instantly, and the
other goes through set(), which the mutation makes identical to request().

Assert the contract directly instead: request() flips the bool and leaves
the event alone, set() sets it. The mutation now fails.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants