Heartbeat

A heartbeat is a periodic signal between two components that indicates normal operation. When heartbeat messages stop arriving, the robot control assumes the originator has failed or shut down and transitions into an error state, stopping all motion. It is the dead man’s switch between your application and the robot.

Warning

The heartbeat functionality is not a replacement for properly configured and certified safety components. In the context of safety certification, the heartbeat is considered unsafe software.

General Concepts

The robot control allows an application to register itself as a heartbeat provider. Once a session is active, the robot control expects a beat within a configurable interval (1 s by default). If the interval is violated, the robot stops with error 1516.

Multiple providers can be registered concurrently, each identified by a session id and monitored independently. Alternatively, a unique session can be requested, which enforces that it is the only active provider. Registering any session while a unique one is active, or a unique session while any other is active, puts the robot control into an error state.

The library sends the beats from its own worker thread, the same thread that processes instructions. There is nothing to call periodically.

Using the Heartbeat in an Application

heartbeat() returns a builder; start() registers the session and returns a HeartbeatSession guard that stops the session gracefully when it is dropped. The guard borrows the robot, so it always ends before the robot is disconnected. For the same reason enable() and disable(), which take &mut self, cannot be called while it lives.

fn scoped_heartbeat(robot: &(impl Heartbeat + MovePtp)) -> Result<(), ExampleError> {
    info!("--- Scoped heartbeat ---");

    check_heartbeat(robot.heartbeat_state(), HeartbeatState::Inactive)?;
    {
        // Random session id, 1 s interval.
        let session = robot.heartbeat().start()?;
        info!("Heartbeat state: {:?}", session.state());
        check_heartbeat(session.state(), HeartbeatState::Alive)?;

        // Move while the heartbeat is active.
        robot
            .move_ptp(poses::home())
            .move_ptp(poses::vertical())
            .start()?
            .join()?;
    }
    check_heartbeat(robot.heartbeat_state(), HeartbeatState::Inactive)?;
    info!("Heartbeat session complete.");
    Ok(())
}

Builder options:

  • session_id(impl Into<HeartbeatSessionId>): use this id instead of a random one; a plain i32 converts. An id can be reused once its previous session stopped or died.

  • interval(Duration): the timeout after which the control assumes the heartbeat is dead. Default DEFAULT_HEARTBEAT_INTERVAL (1 s). The control counts whole milliseconds, and start() rejects an interval below 1 ms with RobotArmError::InvalidArgument.

  • unique(): require this to be the only session on the control.

  • kill_on_panic(PanicScope): which panics kill this session. OwnThread (default) is a panic that unwinds through the session’s guard; Process is a panic on any thread (see below).

When the program needs to know that the control confirmed the stop, call stop() on the guard instead of letting it drop. It consumes the guard and returns the control’s answer. A session that was already killed (see Reporting a Fault Yourself) is only cleared locally and stop() returns Ok(()) although the tracker stays armed; check session.state() first when that matters:

let session = robot.heartbeat().start()?;
robot.move_ptp(poses::home()).start()?.join()?;
session.stop()?;

When no scope fits, for example an HMI that toggles the heartbeat or a session that must keep running while the robot is disabled and enabled again after an error, call detach() on the guard. The session then runs until stop_heartbeat() is called, without the guard’s stop on drop and kill on panic:

fn detached_unique_heartbeat(robot: &(impl Heartbeat + MovePtp)) -> Result<(), ExampleError> {
    info!("--- Detached unique heartbeat ---");

    robot
        .heartbeat()
        .unique()
        .session_id(42)
        .interval(Duration::from_millis(500))
        .start()?
        .detach();

    robot.move_ptp(poses::home()).start()?.join()?;

    robot.stop_heartbeat()?;
    check_heartbeat(robot.heartbeat_state(), HeartbeatState::Inactive)?;
    Ok(())
}

Only one session of this library can be active per robot; a second start() fails with RobotArmError::NotAllowed.

Heartbeat States

session.state() and robot.heartbeat_state() report one of:

State

Description

Inactive

No session is active.

Alive

The session is running and beats are being sent.

Dead

The session was killed. Beats are no longer sent; the robot control times out into an error.

While a session is Dead, new instructions are rejected with RobotArmError::NotAllowed (“Heartbeat is dead”). To recover:

  1. Reset the robot error with VorausErrorHandler::reset_error() (see Resetting Errors).

  2. Drop the dead guard, or call stop_heartbeat() for a detached session.

  3. Start a new session if desired, and enable() the robot again.

Step 2 clears Dead locally at once, but it does not remove the tracker: the control still faults when the interval expires. Do not start a new session, in particular one with the same id, before that error was reset in step 1.

What the Heartbeat Detects

The robot control only knows whether beats arrive. The library stops sending them in these situations; anything else has to be reported by the application.

Detected without any application code

Not detected

The process ends in any way: killed, crashed, out of memory, std::process::exit, or a panic under panic = "abort"

A thread that ends by returning an Err

The network connection to the robot control is lost

An application thread that hangs while the worker loop is healthy

The library’s worker loop fails

A panic on another thread, with PanicScope::OwnThread

A panic that unwinds through the session’s guard

A panic on any thread, with PanicScope::Process

An explicit kill (below)

Reporting a Fault Yourself

Rust applications usually already supervise their threads: a JoinHandle::join() that returns Err, a std::thread::scope that unwinds, a tokio JoinError, a watchdog that notices a stalled queue. From that place, kill the heartbeat:

  • robot.kill_heartbeat() kills the session of one robot.

  • HeartbeatKiller::kill() kills every active session of the process and needs no robot in scope.

Killing does not remove the tracker from the robot control. The beats simply stop, the control times out, and the robot enters the error state. The session reads Dead:

fn killed_heartbeat(robot: &(impl Heartbeat + MovePtp)) -> Result<(), ExampleError> {
    info!("--- Killed heartbeat ---");

    let session = robot.heartbeat().start()?;

    // Simulate a fault detected by the application's own supervision.
    robot.kill_heartbeat();
    check_heartbeat(session.state(), HeartbeatState::Dead)?;

    if let Err(e) = robot.move_ptp(poses::home()).start() {
        info!("Expected: no instructions while the heartbeat is dead: {e}");
    } else {
        info!("Instruction accepted (unexpected).");
    }
    // Dropping the dead session cleans up locally; the robot error itself is
    // reset through the error handler (below).
    drop(session);
    Ok(())
}

/// Recovering from a dead heartbeat.
///
/// The control notices the missing beats only once the interval expires, so
/// wait for the error state first: a reset sent earlier finds nothing to
/// reset. After the reset the robot is enabled again, and a new session can
/// be started.
fn recover_from_dead_heartbeat(
    robot: &mut (impl Lifecycle + GetRobotState),
    error_handler: &VorausErrorHandler,
) -> Result<(), ExampleError> {
    let start = Instant::now();
    while robot.robot_state()? != RobotState::Error {
        if start.elapsed() >= ERROR_STATE_TIMEOUT {
            return Err(ExampleError::ErrorStateNotReached(ERROR_STATE_TIMEOUT));
        }
        sleep(Duration::from_millis(100));
    }
    error_handler.reset_error(&*robot)?;
    robot.enable()?;
    Ok(())
}

The guard behaves the same way when it is dropped while its thread is panicking: it kills the session instead of stopping it gracefully. A dead man’s switch must not be disarmed by the fault it exists to catch.

Opt-in Panic Monitoring

kill_on_panic(PanicScope::Process) installs a process-wide panic hook, once, that kills every session with that scope before chaining to the previously installed hook. Sessions with the default OwnThread scope keep beating, so two robots that work independently in one process are not stopped by a panic in each other’s threads; give both the Process scope when they must stop together. The hook is not installed by default because it only sees panics, and because a library should not silently own the process’s panic policy. If your application installs its own panic hook after a session started, it replaces this one; call HeartbeatKiller::kill(), which kills every session, from your hook.

fn heartbeat_killed_by_panic(robot: &impl Heartbeat) -> Result<(), ExampleError> {
    info!("--- Heartbeat killed by a panicking thread ---");

    let session = robot
        .heartbeat()
        .kill_on_panic(PanicScope::Process)
        .start()?;
    check_heartbeat(session.state(), HeartbeatState::Alive)?;

    // A worker thread of the application dies with a panic. The message it
    // prints below comes from the default panic reporting, which stays in
    // place because the heartbeat hook chains to it.
    let worker = spawn(panicking_worker);
    if worker.join().is_ok() {
        return Err(ExampleError::WorkerDidNotPanic);
    }

    check_heartbeat(session.state(), HeartbeatState::Dead)?;
    info!("Heartbeat state after the panic: {:?}", session.state());
    drop(session);
    Ok(())
}

/// A worker thread of the application that dies with a panic.
#[expect(
    clippy::panic,
    reason = "the panic is the fault that the panic monitoring example shows"
)]
fn panicking_worker() {
    panic!("simulated fault in an application thread");
}

Full Example

Trait Definition