Skip to main content
LoopWatchdog detects a wedged asyncio loop and hands the process back to your supervisor before the gateway becomes a silent zombie.
Running praisonai gateway start? You don’t need to write any Python — the gateway wires the watchdog for you. Turn it on with any of three opt-in surfaces: add gateway.watchdog: { enabled: true } to gateway.yaml, pass --watchdog on the CLI, or set GatewayConfig(watchdog=True) from Python. See Using it from the gateway below.

Watchdog ladder

The gateway’s liveness coverage is a three-rung ladder — this page owns rungs 2 and 3; StartupWatchdog owns the front rung.
Looking for connection heartbeats? Gateway Liveness reaps silent client connections with PING/PONG frames. This page covers the event loop itself — when the process is up but the loop stops running. Each concern is orthogonal:

Using it from the gateway

Most operators run praisonai gateway start — turn the watchdog on with YAML or a CLI flag, no Python required.

Quick Start (custom runtime)

1

Simple usage (defaults)

Build an agent, then arm the watchdog on the loop that serves it.
2

Tune the cadence

Pass a LoopWatchdogPolicy to change how fast a wedge is caught.
3

Observe without exiting (dev / staging)

Use dump_only to catch a hang without killing the process.

How It Works

A dedicated OS thread probes the loop, so it keeps working precisely when the loop does not. The watchdog uses os._exit, not sys.exit, on a confirmed wedge. Normal interpreter shutdown runs Py_FinalizeEx, which would itself hang trying to join the wedged loop thread — so os._exit hands the process straight back to the supervisor, which restarts it cleanly.

Health signal

When the watchdog is configured, GET /health (or gateway.health() in-process) includes a watchdog block. The block is omitted entirely from health() when the watchdog isn’t configured — an always-on gateway sees no shape change.
The watchdog’s last_lag_ms property also feeds pressure.event_loop_lag_p99_ms on the Pressure Telemetry health block. Note the lag anchor pins to the oldest still-unacked probe, so under a multi-interval stall the reported lag reflects the full stall, not just the gap since the newest probe.

Shutdown-phase deadline mode

arm_deadline(seconds) fires on a wall-clock overrun independent of loop responsiveness, so a wedged teardown on a still-alive loop still gets an all-thread stack dump + bounded os._exit(75) instead of an external SIGKILL. Deadline mode is a sibling of the loop-probe mode — they cover different failure shapes.
Added in PraisonAI PR #5084 (issue #5079). Before this PR, the loop-probe watchdog was — correctly, per #3410 — disarmed the moment a deliberate stop() started, so a wedged teardown on a still-alive loop hung until systemd’s TimeoutStopSec fired SIGKILL, with no stack dump. Deadline mode closes that gap: the wrapper arms drain_timeout + shutdown_grace at the start of the stop path (and around the SIGTERM/SIGINT-driven teardown), cancels on clean completion, and self-exits with a diagnostic dump if the deadline elapses. It is fully opt-in and no-op when a watchdog is unconfigured; a cancel racing an in-flight expiry suppresses the destructive os._exit, and a stale generation after re-arm stays inert.

Configuration Options

LoopWatchdogPolicy holds every knob. Computed property: LoopWatchdog exposes a small surface:

YAML config keys

The gateway.watchdog block is a thin façade over LoopWatchdogPolicy for gateway operators. An invalid value (for example liveness_interval: 0) fails closed: the gateway logs a warning and runs without a watchdog.

CLI flag reference

  • --watchdog — enable the watchdog for this run (sets/overrides gateway.watchdog.enabled: true).
  • --watchdog-timeout <seconds> — total wedge budget in seconds. Derives liveness_interval = budget / liveness_strikes (keeps the default 3 strikes unless YAML overrides). --watchdog-timeout 15 → probe every 5s. --watchdog-timeout 6 → probe every 2s.

Precedence

Highest wins: --watchdog[-timeout] CLI flag → explicit gateway.watchdog.* YAML (block or scalar, including watchdog: false) → GatewayConfig(watchdog=…, watchdog_timeout=…) field → off. Missing values fall back down the chain — an unset CLI flag does not clobber enabled: true in YAML, and an explicit gateway.watchdog: false is not re-enabled by the GatewayConfig field.

Choosing a cadence

Match the probe interval to how fast a wedge must be caught. Shorter intervals bound recovery time but cost a scheduling roundtrip every N seconds; longer intervals reduce noise and false alerts under GC pauses.
A long stop-the-world GC or a big synchronous blocking call inside a coroutine looks like a wedge if it exceeds wedge_after_s. If you legitimately do 20-second sync work on the loop, either raise wedge_after_s above it or — better — push the work off the loop with run_in_executor / asyncio.to_thread.
faulthandler writes one per-thread traceback on a wedge. Read the loop thread first (typically MainThread or the asyncio worker) — that’s where the actual hang is. Other threads may just be blocked waiting on that one.

Fail-open guarantees

A misbehaving watchdog must never kill a healthy gateway.
  • arm() while already armed is a no-op.
  • disarm() when not armed is safe.
  • Any exception inside probe scheduling, dumping, or exiting is swallowed.
  • A closed / stopped loop is not a wedge — scheduling onto it is a normal shutdown, so the missed counter resets and the watchdog keeps failing open.
  • A disarm() racing an in-flight wedge suppresses the destructive os._exit — a deliberate shutdown never triggers a supervisor restart.

Common Patterns

Three ways teams wire the watchdog. Long-lived embedder (production): keep the defaults and wire the process to your existing supervisor (systemd / Kubernetes / pm2). EX_TEMPFAIL (75) is the same exit code the rest of the restart-intent protocol uses, so any supervisor that already restarts the gateway handles this too.
Diagnostic-only mode (staging): catch a hang without killing the process, inspect the dump, then flip to dump_and_exit in production.
Custom exit code for a bespoke supervisor: override exit_code if you can’t use 75.

Best Practices

systemd’s Restart=on-failure, launchd’s KeepAlive, Docker’s restart: unless-stopped, and Kubernetes all handle exit code 75 correctly. If your supervisor doesn’t, the watchdog will still dump stacks but the process won’t come back — either fix the supervisor or use on_wedge="dump_only" for diagnostics.
CLI flags survive restart via persisted start-flags, but a colleague reading gateway.yaml learns the watchdog is on. The YAML block is the discoverable configuration surface.
Short-lived CLI runs don’t need the watchdog — the process exits anyway. Arm it around gateways, bots, and other loops that must stay up for hours.
The watchdog is a daemon thread and never blocks shutdown, but disarm() also joins it cleanly and prevents any race with a shutdown-time wedge.
A 100ms interval false-positives under GC or big JSON serialisation. Keep probe_interval_s above your longest expected synchronous block on the loop.
systemd (Restart=on-failure) restarts on 75; a naive nohup does not. See Exit Codes for the full restart-intent contract.

Startup Watchdog

Rung 1: guard the pre-loop startup window before this watchdog can take over.

Gateway Liveness

Connection-level heartbeats that reap silent clients (disambiguation).

Exit Codes

The shared EX_TEMPFAIL (75) restart-intent protocol.

Forensics

What to keep on unhealthy exits.

Crash-Loop Guard

Guard against restart storms.

Restart Continuation

Re-drive interrupted turns after the restart.

Graceful Drain

Bound in-flight turns on shutdown — the drain window the deadline mode wraps.