Skip to content

Thread Watchdog

Under Construction

This page describes a planned design and is not yet implemented. Details may change.

Every PEROVSAT application thread must periodically prove it is still making progress. System Health tracks these check-ins and flags any thread that goes silent for too long. This catches a thread that has hung, crashed, or stalled on a blocked resource — failures the MCU-wide watchdog cannot see, because the kernel itself is still running.

Why not the Zephyr Task WDT

Zephyr's task watchdog (task_wdt) requires every thread to register and feed its own channel. That pushes responsibility onto each thread and means the watchdog only knows about threads that remember to sign up.

System Health already starts every application thread (children are defined with delay K_FOREVER and launched via k_thread_start()). Because it owns the start path, it also owns the roster — starting a thread is the registration. No thread has to register itself, and the monitored set can never drift from the set that was actually started.

This watchdog therefore uses plain kernel primitives — a message queue and the uptime clock — not task_wdt.

How it works

A single shared message queue carries heartbeats. Each thread sends one at the top of its loop:

struct health_heartbeat {
    uint8_t  thread_id;   /* index into the monitored-thread roster */
    uint32_t uptime_ms;   /* k_uptime_get_32() at send time */
};

/* In each worker loop. K_NO_WAIT so the watchdog never stalls the thread. */
health_checkin(MON_PAYLOAD);

A check-in is sent once per completed loop iteration, so it proves real forward progress rather than just that the thread is scheduled. It is sent with K_NO_WAIT: the watchdog must never block the thread it is watching.

System Health drains the queue every tick and records each thread's last-seen time.

Detecting a stuck thread

Detection is stateless. On every tick, System Health compares the current time against each thread's last check-in:

elapsed       = (uint32_t)(now - last_seen[t]);   /* wrap-safe subtraction */
missed_cycles = elapsed / epoch[t];               /* epoch[t] must be non-zero */

if (missed_cycles >= max_missed_cycles[t])
    report_fault(t);
missed_cycles State
0 Healthy — checked in this window
1 One window missed — tolerated as jitter
max_missed_cycles Declared not working

The comparison is always against the current clock, evaluated on System Health's own tick — never against the thread's next heartbeat. A dead thread sends nothing, so waiting for a next heartbeat would wait forever; only the advancing clock reveals the silence.

Because the threshold is counted in epochs (the thread's own expected interval) rather than in System Health loops, it stays correct no matter how often System Health polls. Comms with a 10-minute epoch and Payload with a 2-second epoch get the same tolerance semantics.

Startup grace

When System Health starts a thread it arms last_seen[t] = now and marks the thread active. Two consequences:

  • A thread that has not been started yet is not monitored, so it cannot raise a false fault.
  • A freshly started thread gets a full window to complete its one-time init (device readiness, buffers) before its first check-in is due.

Configuration

Each monitored thread carries its watchdog parameters in the central roster, alongside its priority and stack size:

Field Meaning
epoch_ms Expected maximum interval between check-ins
max_missed_cycles Missed windows tolerated before a fault
startup_grace_ms Extra time allowed for the first check-in

Relation to the FDIR layers

This is the innermost, finest-grained watchdog layer: it isolates a single failed thread. It complements the coarser layers above it — the MCU internal watchdog resets the chip if System Health itself dies, and the EPS heartbeat power-cycles the OBC as a last resort.