DEV Community

John Wick
John Wick

Posted on

Graceful Shutdown and Signal Handling in StayPresent

How python graceful shutdown works in StayPresent — SIGINT/SIGTERM handling, install_signal_handlers, and chaining your own signal handler.

Graceful Shutdown and Signal Handling in StayPresent

Deploy platforms and container orchestrators don't just kill your process when they want it to stop — they send a signal first and expect your application to clean up. A bot that ignores SIGTERM risks corrupted state, orphaned subprocesses, or a database write cut off mid-transaction. This guide covers exactly how python graceful shutdown works inside StayPresent, and how to hook your own cleanup logic into it.

Table of Contents

  1. Why Signals Matter for Deployment
  2. What Happens on SIGINT/SIGTERM by Default
  3. Shutdown During a Restart Backoff
  4. Chaining Your Own Signal Handler
  5. Disabling StayPresent's Signal Handling
  6. Signals and the Main Thread Requirement
  7. Multi-Bot Shutdown Behavior
  8. Full Example
  9. Best Practices
  10. Common Mistakes
  11. FAQs
  12. Conclusion

Why Signals Matter for Deployment

When Render, Railway, Docker, or systemctl stop needs your process to shut down — for a redeploy, a scale-down event, or a manual stop — the standard mechanism is a SIGTERM signal, giving the process a window to shut down cleanly before a harder SIGKILL follows. Ctrl+C in a terminal sends SIGINT, the interactive equivalent. Handling both properly is what separates "the bot stopped" from "the bot stopped correctly."

What Happens on SIGINT/SIGTERM by Default

By default (install_signal_handlers=True, StayPresent's own default), receiving SIGINT or SIGTERM triggers a clean, coordinated teardown:

import staypresent

staypresent.run("bot.py")
# Ctrl+C -> web server and bot process both shut down cleanly
Enter fullscreen mode Exit fullscreen mode

Both the web server and every running bot subprocess are terminated as part of this sequence — not just one or the other.

Shutdown During a Restart Backoff

There's a subtle race condition this has to handle correctly: what if a signal arrives while a crashed bot is sitting in its restart_delay backoff, waiting to respawn? StayPresent synchronizes shutdown and the pending respawn so that exactly one of them wins:

  • Either the respawn is cancelled before it happens, or
  • Shutdown terminates the freshly-respawned process too.

Either way, a shutdown can never leave an untracked, un-terminated bot process running in the background — which is exactly the kind of leak that's easy to introduce with hand-rolled process supervision and hard to notice until your host's process count creeps up over time.

The restart_delay wait itself is also interruptible — a signal arriving during it wakes the bot's monitor thread immediately, rather than shutdown being held up waiting out the full backoff period.

Chaining Your Own Signal Handler

If your own script already registers a handler for SIGINT/SIGTERM before calling run(), StayPresent doesn't silently discard it:

import signal
import staypresent

def my_cleanup(signum, frame):
    print("Flushing local cache before shutdown...")

signal.signal(signal.SIGTERM, my_cleanup)

staypresent.run("bot.py")
# On SIGTERM: StayPresent's own cleanup runs first (terminating bot
# processes, logging active cron pingers), THEN my_cleanup runs.
Enter fullscreen mode Exit fullscreen mode

This ordering matters: StayPresent's own teardown (bot processes, server, cron pinger visibility) happens first, and your previously-installed handler is called afterward — so your cleanup code can safely assume the bots are already stopped by the time it runs.

Disabling StayPresent's Signal Handling

Set install_signal_handlers=False if your script wants full, exclusive control over shutdown signaling — for example, if you're embedding staypresent.run() inside a larger application that already has its own signal-handling framework:

staypresent.run(
    "bot.py",
    install_signal_handlers=False,
)
Enter fullscreen mode Exit fullscreen mode

With this set, StayPresent won't install any SIGINT/SIGTERM handling of its own at all — shutdown behavior becomes entirely whatever your own code implements.

Signals and the Main Thread Requirement

Python signal handlers can only be registered on the main thread — this is a Python/OS-level constraint, not something StayPresent can work around. If run() is called from a non-main thread, a warning is logged and graceful signal handling is simply skipped rather than raising an exception:

import threading
import staypresent

def start():
    staypresent.run("bot.py")  # WARNING logged, signal handling skipped

threading.Thread(target=start).start()
Enter fullscreen mode Exit fullscreen mode

Multi-Bot Shutdown Behavior

With multiple bots (via bot_file as a list, bot_module as a list, or bots), a SIGINT/SIGTERM terminates the web server and every running bot process, not just one. Each bot's own crash-recovery-vs-shutdown race is handled independently and safely, using the same synchronization described above.

Full Example

import signal
import staypresent

def notify_shutdown(signum, frame):
    print("Bot(s) shutting down, notifying monitoring...")

signal.signal(signal.SIGTERM, notify_shutdown)
signal.signal(signal.SIGINT, notify_shutdown)

staypresent.web.json({"status": "running"})

staypresent.run(
    bots=[
        {"file": "telegram_bot.py"},
        {"file": "worker.py"},
    ],
    restart_on_crash=True,
)
Enter fullscreen mode Exit fullscreen mode

Best Practices

  • Register your own signal handlers before calling staypresent.run(), so they get chained rather than needing to be re-registered afterward.
  • Keep chained cleanup handlers fast — a slow handler delays how quickly the process actually exits after StayPresent's own teardown finishes.
  • Only set install_signal_handlers=False if you have a concrete reason to manage signals yourself; the default handles the bot-process-and-server coordination correctly out of the box.

Common Mistakes

  • Registering a signal handler after calling run(). StayPresent only chains handlers that were already installed before run() starts — anything registered afterward simply replaces StayPresent's own handler rather than being chained to it.
  • Assuming install_signal_handlers=False means "no shutdown happens." It just means StayPresent won't install its own signal handling — your own code is then fully responsible for triggering and coordinating a clean shutdown.
  • Calling run() from a background thread and being surprised graceful shutdown doesn't work. This is a hard Python constraint on where signal handlers can be registered, not a bug — call run() from the main thread if you need signal handling.

FAQs

Does SIGKILL trigger graceful shutdown?
No — SIGKILL can't be caught by any process, StayPresent included. It terminates immediately with no cleanup opportunity at all, which is why platforms typically send SIGTERM first and only escalate to SIGKILL after a grace period.

What happens to cron() pingers on shutdown?
They run on daemon threads and aren't explicitly stopped by run()'s shutdown sequence — they're simply logged for visibility and then torn down automatically when the process exits, since daemon threads don't block process exit.

Can I trigger the same shutdown sequence programmatically, without an actual OS signal?
Not directly — shutdown is driven by the signal handler itself; sending your own process a real SIGTERM (e.g. via os.kill(os.getpid(), signal.SIGTERM)) is the supported way to trigger it from within your own code.

Conclusion

Python graceful shutdown isn't just about catching Ctrl+C — it's about correctly coordinating a signal against an in-flight restart, terminating every bot process (not just one), and giving your own cleanup code a reliable place to run. StayPresent handles all three by default, while still leaving room to chain your own handler or opt out entirely.

pip install staypresent[prod]
Enter fullscreen mode Exit fullscreen mode

Top comments (0)