Event-Loop Watchdog

Detect and self-recover from a wedged asyncio loop in long-lived gateways

LoopWatchdog detects a wedged asyncio loop and hands the process back to your supervisor before the gateway becomes a silent zombie.

Diagram

Event-Loop Watchdog

probe

ack

yes

no × N

🧵 OS thread

🔁 asyncio loop

💓 alive?

⏳ next probe

🚨 dump + exit 75

👷 supervisor restart

Watchdog ladder

The gateway’s liveness coverage is a three-rung ladder — this page owns rungs 2 and 3; StartupWatchdog owns the front rung.

Diagram

🚀 1. Startup
StartupWatchdog

🔁 2. Running loop
LoopWatchdog probe

🛑 3. Shutdown
LoopWatchdog arm_deadline

RungClass / modeCoversDocs
1. StartupStartupWatchdogImports, config/secret load, first connect() — before the loop existsStartup Watchdog
2. Running loopLoopWatchdog (probe mode)The event loop while it servesBelow
3. ShutdownLoopWatchdog.arm_deadlineWedged teardown after stop() / SIGTERMShutdown-phase deadline mode

Using it from the gateway

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

YAML (recommended)

Add a watchdog block under gateway and start as usual.

yaml
# gateway.yaml
gateway:
  watchdog:
    enabled: true
    liveness_interval: 5      # seconds between loop probes
    liveness_strikes: 3       # hard-exit after N consecutive misses (wedge_after ≈ 15s)
    dump_file: /var/log/praisonai/loop-wedge.log   # optional; stderr always gets the dump
bash
praisonai gateway start --config gateway.yaml

The gateway arms the watchdog right before _server.serve() and disarms it in finally and on stop().

CLI flags

Pass --watchdog to enable it for a single run — the flags override the matching YAML fields.

bash
# Enable with defaults (probe every 5s, wedge after ~15s)
praisonai gateway start --config gateway.yaml --watchdog

# Set a specific wedge budget (splits into 3 strikes → probe interval = budget / 3)
praisonai gateway start --config gateway.yaml --watchdog --watchdog-timeout 15

# Works without a YAML file too (no-config WebSocket-only mode)
praisonai gateway start --watchdog

restart replays these flags automatically via the persisted start-flags — you do not re-pass them.

Python (via GatewayConfig)

Wiring up the gateway from Python? Turn the same event-loop watchdog on with GatewayConfig — no CLI flag and no YAML block required.

python
from praisonaiagents.gateway import GatewayConfig

config = GatewayConfig(
    watchdog=True,
    watchdog_timeout=30,   # optional; leave None for the default budget
)
# start the gateway with this config (Python-first)

This enables the same watchdog the YAML gateway.watchdog: block and the --watchdog flag enable. watchdog_timeout mirrors the --watchdog-timeout CLI flag; when set it must be > 0 (ValueError otherwise) and wins over the budget derived from a watchdog: block.

Python (custom runtime)

Embedding your own asyncio runtime? Arm the watchdog on the loop directly.

python
import asyncio
from praisonaiagents import Agent
from praisonaiagents.gateway import LoopWatchdog

agent = Agent(name="Support", instructions="Reply to users.")

async def serve():
    # ... your long-lived gateway/serving loop here ...
    await asyncio.sleep(3600)

loop = asyncio.new_event_loop()
watchdog = LoopWatchdog()          # probe every 5s, wedge after 3 misses (~15s)
watchdog.arm(loop)
try:
    loop.run_until_complete(serve())
finally:
    watchdog.disarm()

Quick Start (custom runtime)

  1. Simple usage (defaults)

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

    python
    import asyncio
    from praisonaiagents import Agent
    from praisonaiagents.gateway import LoopWatchdog
    
    agent = Agent(name="Support", instructions="Reply to users.")
    
    async def serve():
        # ... your long-lived gateway/serving loop here ...
        await asyncio.sleep(3600)
    
    loop = asyncio.new_event_loop()
    watchdog = LoopWatchdog()          # probe every 5s, wedge after 3 misses (~15s)
    watchdog.arm(loop)
    try:
        loop.run_until_complete(serve())
    finally:
        watchdog.disarm()
  2. Tune the cadence

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

    python
    from praisonaiagents.gateway import LoopWatchdog, LoopWatchdogPolicy
    
    watchdog = LoopWatchdog(LoopWatchdogPolicy(
        probe_interval_s=2.0,               # probe every 2s
        missed_probes_before_wedged=5,      # wedge only after ~10s of silence
        dump_file="/var/log/praisonai/loop-wedge.log",
    ))
  3. Observe without exiting (dev / staging)

    Use dump_only to catch a hang without killing the process.

    python
    from praisonaiagents.gateway import LoopWatchdog, LoopWatchdogPolicy
    
    watchdog = LoopWatchdog(LoopWatchdogPolicy(on_wedge="dump_only"))
    watchdog.arm(loop)
    # ... later ...
    if watchdog.wedged:
        print("loop hung at least once; check the stack dump on stderr")

How It Works

A dedicated OS thread probes the loop, so it keeps working precisely when the loop does not.

Diagram
sequenceDiagram
    participant Sup as Supervisor
    participant App as Embedder
    participant WD as LoopWatchdog<br/>(OS thread)
    participant Loop as asyncio loop

    App->>WD: arm(loop)
    loop every probe_interval_s
        WD->>Loop: call_soon_threadsafe(_ack)
        alt loop is healthy
            Loop-->>WD: _ack() runs
            Note over WD: missed = 0
        else loop is wedged
            Note over Loop: _ack never runs
            Note over WD: missed += 1
        end
    end
    Note over WD: missed >= threshold
    WD->>WD: faulthandler.dump_traceback(all_threads)
    WD->>Sup: os._exit(75)
    Sup-->>App: restart process
PieceOwnerRole
LoopWatchdogPolicyCore (pure)Configuration: cadence, threshold, on-wedge action, exit code, dump file
LoopWatchdog.arm(loop)Embedder callSpawns daemon OS thread praisonai-loop-watchdog
Watchdog threadRuns off-loopSchedules _ack via call_soon_threadsafe every interval
faulthandler.dump_traceback(all_threads=True)StdlibAll-thread stack dump on wedge
os._exit(75)StdlibBypasses Py_FinalizeEx — supervisor sees EX_TEMPFAIL
GATEWAY_RESTART_EXIT_CODEProtocolThe 75 constant, shared with the restart-intent codes

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.

FieldTypeDescription
watchdog.enabledboolAlways true when the block is present (implied by the block existing at all).
watchdog.armedbooltrue while _server.serve() is running; flips to false on graceful stop().
watchdog.wedge_after_sfloatliveness_interval * liveness_strikes — the effective wedge budget.

The block is omitted entirely from health() when the watchdog isn’t configured — an always-on gateway sees no shape change.


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.

Diagram
graph LR
    subgraph "Shutdown-phase deadline"
        Stop[🛑 stop() / SIGTERM] --> Arm[⏱️ arm_deadline<br/>drain_timeout + grace]
        Arm --> Drain[🧹 ordered teardown]
        Drain --> Done{Clean by<br/>deadline?}
        Done -->|Yes| Cancel[✅ cancel — no exit]
        Done -->|No| Dump[🚨 dump all-thread stacks]
        Dump --> Exit[💥 os._exit(75)]
        Exit --> Sup[👷 supervisor restart]
    end

    classDef signal fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef process fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef decision fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef success fill:#10B981,stroke:#7C90A0,color:#fff
    classDef warn fill:#6366F1,stroke:#7C90A0,color:#fff

    class Stop signal
    class Arm,Drain process
    class Done decision
    class Cancel success
    class Dump,Exit warn
    class Sup success

Deadline mode is a sibling of the loop-probe mode — they cover different failure shapes.

ConcernLoop-probe modeShutdown-phase deadline mode
What is dead?The event loop itself (process is a zombie)A teardown step while the loop is still responsive
Detected how?Missed OS-thread probes on the loopWall-clock overrun of drain_timeout + grace
Armed byarm(loop) in the embedder / _server.serve() in the gatewayarm_deadline(deadline_s) in stop() and in the supervisor-driven teardown finally
Disarmed bydisarm() after serve() returns / in stop()cancel() in stop()‘s finally — clean stops never self-exit
Runs onDedicated praisonai-loop-watchdog daemon threadDedicated praisonai-shutdown-watchdog daemon thread

YAML (recommended)

Add a shutdown_grace key to the same gateway.watchdog block. This is the extra budget added on top of drain_timeout before the deadline fires.

yaml
# gateway.yaml
gateway:
  watchdog:
    enabled: true
    liveness_interval: 5
    liveness_strikes: 3
    shutdown_grace: 5      # seconds beyond drain_timeout before the deadline fires
    dump_file: /var/log/praisonai/loop-wedge.log

If drain_timeout: 10 (see Graceful Drain) and shutdown_grace: 5, the deadline fires ~15 seconds into a wedged shutdown — well before systemd’s default TimeoutStopSec=90 reaches SIGKILL.

Python stop(grace=…)

stop() accepts a per-call grace override that wins over policy.shutdown_grace_s.

python
import asyncio
from praisonai_bot.gateway.server import Gateway

gateway = Gateway.from_yaml("gateway.yaml")
asyncio.create_task(gateway.serve())

# ... later, on shutdown:
await gateway.stop(drain_timeout=30, grace=10)
# → the deadline watchdog fires at ~40s if any teardown step is wedged

Passing grace=None (the default) uses policy.shutdown_grace_s — set once in gateway.yaml and stops don’t need to specify it.

Custom runtime arm_deadline

Embedding your own asyncio runtime? Wrap your shutdown in an arm_deadline / cancel pair.

python
import asyncio
from praisonaiagents.gateway import LoopWatchdog, LoopWatchdogPolicy

watchdog = LoopWatchdog(LoopWatchdogPolicy(shutdown_grace_s=5.0))
watchdog.arm(loop)  # loop-probe mode during serving

async def shutdown(drain_timeout=10.0):
    watchdog.arm_deadline(drain_timeout + watchdog.policy.shutdown_grace_s)
    try:
        await ordered_teardown()      # your adapter.disconnect(), WS drain, DB close, ...
    finally:
        watchdog.cancel()             # clean stops never self-exit

# ...
loop.run_until_complete(shutdown())
watchdog.disarm()

Both modes share the same _dump_stacks + os._exit(exit_code) machinery and the same on_wedge="dump_only" diagnostic override.


Configuration Options

LoopWatchdogPolicy holds every knob.

OptionTypeDefaultDescription
probe_interval_sfloat5.0Seconds between liveness probes scheduled onto the loop. Also the cadence at which the watchdog thread wakes. Must be finite and > 0 (raises ValueError on 0, negative, NaN, or inf).
missed_probes_before_wedgedint3Number of consecutive probes that must fail to round-trip within their budget before the loop is declared wedged. Must be >= 1.
on_wedge"dump_and_exit" \"dump_only""dump_and_exit"What to do once a wedge is confirmed. "dump_and_exit" dumps all-thread stacks then calls os._exit(exit_code); "dump_only" dumps stacks but leaves the process running.
exit_codeint75 (GATEWAY_RESTART_EXIT_CODE)Exit code used when on_wedge == "dump_and_exit". Defaults to EX_TEMPFAIL so a supervisor restarts the process.
dump_fileOptional[str]NoneOptional path to also write the stack dump to (appended, line-buffered). None writes to stderr only.
shutdown_grace_sfloat5.0Extra budget (seconds) added beyond a caller’s drain_timeout when arming the shutdown-phase deadline via arm_deadline. Must be finite and >= 0 (zero is allowed; negative, NaN, inf raise ValueError). Only consulted by shutdown-phase deadline mode; the loop-probe mode ignores it.

Computed property:

PropertyReturnsDescription
wedge_after_sfloatprobe_interval_s * missed_probes_before_wedged — approximate stall before a wedge is declared (defaults: ~15s).

LoopWatchdog exposes a small surface:

Method / propertyDescription
LoopWatchdog(policy=None)Construct with an explicit LoopWatchdogPolicy or accept the defaults.
arm(loop)Start watching loop from a daemon OS thread named praisonai-loop-watchdog. Idempotent and fail-open.
disarm()Stop watching. Safe to call multiple times / when not armed. Joins the worker (best-effort) with a probe_interval_s + 1.0s timeout.
armedTrue iff the worker thread is alive.
wedgedTrue once a wedge has been confirmed (mainly useful for dump_only).
arm_deadline(deadline_s)Start a wall-clock shutdown deadline on a dedicated daemon thread (praisonai-shutdown-watchdog). Non-positive / non-finite deadline_s is a silent no-op. Idempotent while armed. See Shutdown-phase deadline mode.
cancel()Cancel an armed shutdown deadline. Sets the current generation’s stop event (suppressing an in-flight expiry even mid-dump). Safe when not armed.
deadline_armedTrue iff a shutdown-phase deadline is currently armed.
deadline_expiredTrue once a shutdown deadline actually fired (mainly useful for on_wedge="dump_only" diagnostics). Cancelled generations do not flip this.

YAML config keys

The gateway.watchdog block is a thin façade over LoopWatchdogPolicy for gateway operators.

YAML key (under gateway.watchdog.*)TypeDefaultMaps toDescription
enabledbool \"true"/"yes"/"on"false—Master switch. Omit or set to false and the gateway does not create a watchdog at all — zero cost.
liveness_intervalfloat5.0LoopWatchdogPolicy.probe_interval_sSeconds between probes scheduled onto the loop.
liveness_strikesint3LoopWatchdogPolicy.missed_probes_before_wedgedConsecutive missed probes before a wedge is declared.
dump_filestr \nullnullLoopWatchdogPolicy.dump_fileOptional path to also write the stack dump to. null writes to stderr only.
shutdown_gracefloat5.0LoopWatchdogPolicy.shutdown_grace_sExtra budget (seconds) beyond drain_timeout before the shutdown-phase deadline fires. Only active when the wrapper’s stop path or supervisor-driven teardown runs.

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.

Diagram

set

unset

enabled: true

explicit false

absent

watchdog=True

watchdog=False

⚙️ –watchdog / –watchdog-timeout

✅ Watchdog config

📄 gateway.watchdog.*

⏹️ Off — zero cost

🐍 GatewayConfig watchdog


Choosing a cadence

Match the probe interval to how fast a wedge must be caught.

Diagram

< 5s (realtime chat)

~15s (default)

~60s (batch)

❓ How fast must a wedge be caught?

Latency budget

probe_interval_s=1, missed=3
wedge in ~3s

probe_interval_s=5, missed=3
wedge in ~15s

probe_interval_s=10, missed=6
wedge in ~60s

Shorter intervals bound recovery time but cost a scheduling roundtrip every N seconds; longer intervals reduce noise and false alerts under GC pauses.


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.

python
from praisonaiagents.gateway import LoopWatchdog

watchdog = LoopWatchdog()          # defaults trip at ~15s
watchdog.arm(loop)

Diagnostic-only mode (staging): catch a hang without killing the process, inspect the dump, then flip to dump_and_exit in production.

python
from praisonaiagents.gateway import LoopWatchdog, LoopWatchdogPolicy

watchdog = LoopWatchdog(LoopWatchdogPolicy(
    on_wedge="dump_only",
    dump_file="/var/log/praisonai/loop-wedge.log",
))
watchdog.arm(loop)

Custom exit code for a bespoke supervisor: override exit_code if you can’t use 75.

python
from praisonaiagents.gateway import LoopWatchdog, LoopWatchdogPolicy

watchdog = LoopWatchdog(LoopWatchdogPolicy(exit_code=42))
watchdog.arm(loop)

Best Practices

Turn it on wherever a supervisor can restart the process

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.

Prefer YAML over CLI flags for permanent deployments

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.

Only arm in long-lived processes

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.

Always pair arm(loop) with disarm() in finally

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.

Do not shorten the interval below your longest sync block

A 100ms interval false-positives under GC or big JSON serialisation. Keep probe_interval_s above your longest expected synchronous block on the loop.

Make sure your supervisor restarts on EX_TEMPFAIL

systemd (Restart=on-failure) restarts on 75; a naive nohup does not. See Exit Codes for the full restart-intent contract.


Related