Skip to content

petekubiak/post-haste

Repository files navigation

Post-haste

A no_std, alloc-free async Rust library for creating modular projects.

Summary

The goal of this library is to provide a framework for a highly modularised code base. There are two core components around which this framework is based: the Agent and the Postmaster.

Agents

Functionality of the application is divided up between a number of modules, dubbed "Agents". Each Agent is expected to have a single responsibility. Post-haste provides the Agent trait, which defines the interface for Agents and gives them the ability to be integrated into the rest of the system. The defining features of an Agent are an instantiation function: create(), a task loop: run(), and an inbox.

The create() function is called automatically when the Agent is "registered" (see Registering Agents below). The body of this function should instantiate the Agent and populate it with any configuration it requires. The Config associated type is used to assist in this.

The run() function is the Agent's main loop. This is spawned as a standalone task in the executor, and is not expected to return (i.e. the lifetime of Agents is expected to be the same as the lifetime of the application). When an Agent is registered, it is assigned a mailbox. The receiving end of the mailbox (the inbox) is passed in as an argument to the run() function. In the vast majority of cases, the core logic of the Agent's loop will be to await messages arriving in its inbox and perform actions based on what is received.

The Postmaster

The postmaster provides the mechanism by which Agents are able to communicate, and by which data moves around the system.

Initialisation

In order to allow the Postmaster to be no_std and alloc-free, its logic requires knowledge about the project to function. Specifically, it needs to know the number of Agents which will be running and the payload structures which the messages will contain. To achieve this, the Postmaster logic must be written at compile-time by the init_postmaster!() macro. The two arguments to the macro are of course the Address type and the Payload type, both defined by your project. The init_postmaster!() macro takes an optional third argument, the default timeout that the Postmaster should use when sending messages in microseconds. If this optional argument is left out, the Postmaster will use a timeout of 1 ms (1000 us). For more information on message sending timeout, see Communicating with Agents below. The output of the macro is a postmater module, containing the Postmaster's public interface.

Registering Agents

Once you have defined an Agent type as described above, it is instantiated using the postmaster::register_agent!() macro. This macro takes the following arguments:

  • A handle to the executor's Spawner (only in Embassy)
  • The Address to which the instance will be registered
  • The type of Agent being instantiated
  • Config for the Agent in the form of an instance of its associated Config type
  • (Optional) The size of the Agent's message queue

Within this macro, the Agent's message queue is created, the Agent instance is created and a task is spawned for its main loop. The Agent can be considered active and ready to receive messages immediately following its registration.

Communicating with Agents

The standard way to communicate with an Agent is by sending it messages using the Postmaster. The postmaster module generated by init_postmaster!() provides a set of functions for this purpose. postmaster::message() takes source and destination addresses, plus a message payload, and returns a MessageBuilder. The MessageBuilder allows further configuration of how the message is sent (explained further below). Once configured, the message is sent by calling the MessageBuilder's send() function.

When sending a message, it may be configured with a "timeout" and a "delay". Upon attempting to send a message it may not be possible to immediately push the message onto the recipient's queue, for example if said queue is already full. This is the purpose of the timeout: the send() function returns a future which will resolve either when the message has been successfully posted, or when the timeout expires. By default, the timeout is 1 ms. Sending a message with a "delay" means that the send() function will immediately return, but the message will only be added to the recipient's queue after the delay is complete.

The postmaster module also contains a couple of shortcut functions for sending messages:

  • postmaster::send() which will attempt to send the message immediately with the default timeout of 1 ms.
  • postmaster::try_send() which will attempt to send the message immediately, but will not wait: it will return immediately.

In all cases, what the recipient receives when it accesses its inbox is a postmaster::Message struct, which contains the source address and the message payload.

Please note: the Message and Address associated types in the Agent trait correspond to the auto-generated Message type and the user-provided Address list respectively.

Other features

A high level overview of the Postmaster's diagnostics can be obtained using the postmaster::get_diagnostics() function. Currently this just contains a tally of the number of messages successfully sent, and the number of send failures since boot.

It is also possible to register a standalone mailbox on the system, without associating it with an Agent, using postmaster::register(). This might for example be used to communicate back to the main task of the project, or to provide a "debug" address for debug messages to be sent.

The default timeout used by the Postmaster when a message is sent with no specific timeout configuration can be changed using postmaster::set_timeout(), taking a value in microseconds.

Advanced configuration

Delayed message pool (Embassy only)

When using post-haste on bare metal targets with Embassy, delayed messages are held in a finite pool while they await the expiry of their delay duration. By default, the size of this pool is 8. If at any point the pool is full, any attempt to send a delayed message will result in a DelayedMessagePoolFull error, and the message will not be sent. The size of the pool can be modified by setting the DELAYED_MESSAGE_POOL_SIZE environment variable. Please note however that increasing the pool size will increase static memory usage.

Example usage

The following forms the core of the code layout for a baremetal project built upon post_haste (excluding any architecture-specific code and dependencies):

#![no_std]

// NOTE: This feature is currently required in order to generate the correct number of mailboxes based on the number of provided addresses (avoiding alloc)
// Therefore, the project must be compiled with the nightly compiler
#![feature(variant_count)]

use embassy_executor::Spawner;

use post_haste::agent::Agent;
use post_haste::init_postmaster;

/// The list of Agent addresses, used to identify the source and destination for messages.
/// Each Agent must have a unique address
enum Address {
  AgentA,
  AgentB,
  // ...
}

/// Top-level definition of messages used in the system.
enum Payloads {
  General(GeneralPayloads),
  // ...
}

/// A sub-category of messages (this heirachical ordering isn't necessary, but is highly recommended for organisation)
enum GeneralPayloads {
  Hello,
  // ...
}

// Generates the postmaster logic and initialises the postmaster for use within the project
init_postmaster!(Address, Payloads);

struct MyAgent {
  address: Address
}

impl Agent for MyAgent {
  type Address = Address;
  type Message = postmaster::Message;
  type Config = ();

  async fn create(address: Self::Address, _: Self::Config) -> Self {
    // Initialisation code goes here...
    Self { address }
  }

  async fn run(self, inbox: post_haste::agent::Inbox<Self::Message>) -> ! {
    loop {
      let received_message = inbox.recv().await.unwrap();
      match received_message {
        Payloads::Hello => postmaster::send(received_message.source, self.address, Payloads::Hello).await.unwrap();
        // ...
      }
    }
  }
}

#[embassy_executor::main]
async fn main(spawner: Spawner) {
  const QUEUE_SIZE: usize = 8;
  postmaster::register_agent!(spawner, AgentA, MyAgent, ());
  postmaster::register_agent!(spawner, AgentB, MyAgent, (), QUEUE_SIZE);

  loop {
    // ...
  }
}

While the framework was originally developed for no_std baremetal environments, it is also fully compatible with tokio.

  • tokio_basic.rs gives a very simple example of two Agents exchanging messages.
  • showcase.rs follows the same concept, but aims to demonstrate some useful patterns within the framework.

About

A no-std async Rust library for creating modular projects

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 2

  •  
  •