Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions embedded-service/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub mod ipc;
pub mod keyboard;
pub mod power;
pub mod relay;
pub mod service;
pub mod sync;
pub mod type_c;

Expand Down
133 changes: 133 additions & 0 deletions embedded-service/src/service.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
//! This module contains helper traits and functions for services that run on the EC.

/// A trait for a service that can be run on the EC.
/// Implementations of RunnableService should have an init() function to construct the service that
/// returns a Runner, which the user is expected to spawn a task for.
pub trait RunnableService<'hw> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if a trait is the right design for this. If the service is in a mutex then this would require hold a mutex lock. I think the run code can end up on ServiceRunner, with each service implementing their own code on their own struct.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could definitely convert ServiceRunner into a trait, move the run() method to that, and make it an associated type or something if we wanted to do that. I'd originally started with something along those lines, but ran into some snags writing a macro that could generate an implementation against a type that may or may not be generic so I bailed on that approach. Might be fine to just have everyone implement their own, though?

I think we do need a uniformly-shaped run() method on the runner, though, and I think that means the runner needs an internal reference to the service that it's the runner for, which kind of locks us out of having the mutex be external, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been doing a bit of research on patterns for this, and stumbled across a few examples of this 'run token' pattern in embassy that I think are instructive - they all do things a little differently, though, and I'm interested in your thoughts on which is better for our use case:

(1) embassy-net is pretty similar to what I'm doing here. It yields a (Stack, Runner) tuple, and all methods on Stack take &self. This seems to me like a good fit for something that is maintaining its internal state on a separate 'thread' that it owns and is expected to be shared between multiple things (time-alarm comes to mind here - it's the way you get wall-clock time).

(2) embassy-usb doesn't have a separate Runner object - once you have the thing set up, you call run(&mut self) -> ! and then you can't call stuff on it ever again. I think this is a nonstarter if we want to continue with this direct async call approach, but @felipebalbi had some interesting thoughts on using ergot for communicating between services rather than direct async calls, and in that world, this might be viable. I took a quick glance at the ergot stuff and it looks like it uses a lot of 'static, though, so I'd want to make sure there's a path that doesn't require 'static for senders/receivers/topics before committing to that; more research required.

(3) cyw43 (wifi driver) takes a third approach - they return a (NetDriver, Control, Runner) triple. The Runner is run(self) -> !, and as far as I can tell, the NetDriver and Control objects both mostly require &mut self. This seems to me like a good fit for a driver for a hardware peripheral that is expected to be only used by a single higher-level abstraction - perhaps the USB-C stuff falls into that bucket?

Notably, in the two of these that don't just tie up the entire object forever (embassy-net and cyw43), both use interior mutability to share state between the runner and the control handle, even in cases where the control handle requires &mut references to do stuff to the object.

I think if we go with (1) it buys us a free-ish implementation of ServiceRunner (which I have currently in this PR), but perhaps isn't as good of a fit for driver stuff like (3), which might be closer to what the USB-C service is doing, and we may want to support both - I missed this nuance during my initial stab at this. The 'free' implementation is also only like 5 lines of code, so not a huge deal if we need to replicate something like it in each service. I'm leaning towards something like this:

  • Make ServiceRunner a trait rather than a concrete type
  • Remove the run() method from the RunnableService trait
  • Have an associated type on RunnableService that is the ServiceRunner type
  • Leave the decision of methods taking &mut self vs &self to the services - 'shared' services that are expecting to be talked to by a bunch of other components should take &self and use interior mutability to achieve that, and 'driver' services like the USB-C service should take &mut self and leave higher-level code to decide if they want to put a mutex around the handle or not
  • In all cases, the expectation is that the ServiceRunner implementation will use interior mutability / mutexes to share with the 'control handle' for the service without requiring an external mutex, but that's an implementation detail that's transparent to the users of these services.

I think this enables both (1) and (3), since the decision about mutability isn't really part of the RunnableService trait at all - it's just a matter of what you can do with the associated 'control handle'.

What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed an attempt at this to #726

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Different embassy crates having different patterns around this makes me more skeptical of a trait-based approach, especially at a global level. I think there's more room on a per-service basis, i.e. each service defines its own ServiceRunner trait. But I think ServiceRunner implementations will be different enough in terms of arguments and return values that there won't be enough commonality for a trait. Application code will want a runner that has fn run(_) -> ! while tests will want fn run(_). Likewise, test and application runners will require different arguments. I think we should implement our service runners first and then create a trait later if we find there's enough commonality.

Copy link
Contributor Author

@williampMSFT williampMSFT Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests should also be using runners with fn run(_) -> !, I think - they just need to select!() over a call to run() and a closure that runs their test code; when the test closure completes, then the select! drops the -> ! future and returns. We have examples of this approach in tad_test.rs.

I think ServiceRunner implementations should always have zero arguments (other than self)? The only thing instantiating those should be the service, when it returns from init(), and instantiation of the runner is out of scope for the ServiceRunner trait (unlike previously, when ServiceRunner was a type provided by this lib). Any arguments that the runner needs should be passed into the service at init time, and the fact that it gets handed off to the returned ServiceRunner impl is an implementation detail of the service that the user shouldn't have to care about. Runners taking no additional arguments is the same across all of the above patterns.

To be a bit more explicit about goals here, one of the things I'm trying to achieve is to not require the user to have service-specific knowledge of an arbitrary number of 'worker tasks' that each have their own quirks around what parameters they need - I view that as implementation details that we're currently leaking, and that leakage makes it difficult for end users to reason about what the service actually needs be used and how to uptake breaking changes. That's the thing I'm trying to make 'uniform'.

/// A token type used to restrict users from spawning more than one service runner. Services will generally
/// define this as a zero-sized type and only provide a constructor for it that is private to the service module,
/// which prevents users from constructing their own tokens and spawning multiple runners.
/// Most services should consider using the `impl_runner_creation_token!` macro to do this automatically.
type RunnerCreationToken;

/// Run the service event loop. This future never completes.
fn run(
&'hw self,
_creation_token: Self::RunnerCreationToken,
) -> impl core::future::Future<Output = crate::Never> + 'hw;
}

/// A handle that must be passed to a spawned task and `.run().await`'d to drive the service.
/// Dropping this without calling `runner.run().await` means the service will not process events
pub struct ServiceRunner<'hw, T: RunnableService<'hw>> {
service: &'hw T,
creation_token: T::RunnerCreationToken, // This token is used to ensure that only the service can create a runner for itself. It's probably a zero-sized type.
}

impl<'hw, T: RunnableService<'hw>> ServiceRunner<'hw, T> {
/// Runs the service event loop. This future never completes.
pub async fn run(self) -> crate::Never {
self.service.run(self.creation_token).await
}

/// Constructs a new service runner. This is something the service will do in its init function; users of
/// the service should not need to call this directly.
pub fn new(service: &'hw T, token: T::RunnerCreationToken) -> Self {
Self {
service,
creation_token: token,
}
}
}

/// Generates a default implementation of a runner creation token for a service. This token is used to ensure that
/// only the service can create a runner for itself, and therefore it can control the number of tasks that a user is
/// allowed to spawn to run the service (e.g. if the service is not designed to be run by multiple tasks, it can use
/// this token to prevent that).
///
/// Most services will want to use this macro to generate a simple zero-sized token type - it needs to be a macro invoked
/// in the service module rather than a generic type in this module because the constructor needs to be private to the
/// service module to prevent users from constructing their own tokens and spawning multiple runners.
///
/// Arguments:
/// - token_name: The name of the token type to generate.
#[macro_export]
macro_rules! impl_runner_creation_token {
($token_name:ident) => {
/// A token type used to restrict users from spawning more than one service runner.
pub struct $token_name {
_private: (),
}

impl $token_name {
fn new() -> Self {
Self { _private: () }
}
}
};
}

pub use impl_runner_creation_token;

/// Initializes a service, creates an embassy task to run it, and spawns that task.
///
/// This macro handles the boilerplate of:
/// 1. Creating a `static` [`OnceLock`](embassy_sync::once_lock::OnceLock) to hold the service
/// 2. Calling the service's `init()` method
/// 3. Defining an [`embassy_executor::task`] to run the service
/// 4. Spawning the task on the provided executor
///
/// Returns a Result<reference-to-service, Error> where Error is the error type of $service_ty::init().
///
/// Note that for a service to be supported, it must have the following properties: // TODO figure out if this should be a trait. Would require a single associated-type arg rather than letting each service define its own init list though...
/// 1. Implements the RunnableService trait
/// 2. Has an init() function with the following properties:
/// i. Takes as its first argument a &OnceLock<service_ty>
/// ii. Returns a Result<(reference-to-service, service-runner), Error> where the service-runner
/// is an instance of RunnableService.
///
/// Arguments
///
/// - spawner: An [`embassy_executor::Spawner`].
/// - service_ty: The service type, wrapped in brackets to allow generic arguments
/// (e.g. `[my_crate::Service<'static>]`).
/// - init_args: The arguments to pass to `Service::init()`, excluding the `OnceLock` argument, which is codegenned.
///
/// Example:
///
/// ```ignore
/// let time_service = embedded_services::spawn_service!(
/// time_alarm_task,
/// spawner,
/// [time_alarm_service::Service<'static>],
/// dt_clock, tz, ac_expiration, ac_policy, dc_expiration, dc_policy
/// ).expect("failed to initialize time_alarm service");
/// ```
#[macro_export]
macro_rules! spawn_service {
($spawner:expr, [ $($service_ty:tt)* ], $($init_args:expr),* $(,)?) => {
{
static SERVICE: embassy_sync::once_lock::OnceLock<$($service_ty)*> = embassy_sync::once_lock::OnceLock::new();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think there's a lot of benefit to a macro like this. It forces the use of 'static and OnceLock even though those might not be required in a given situation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The theory is that we repeat more-or-less exactly this thing like 10 times in soc-embedded-controller and also in just about every example (excluding integration tests), and I figure if we have to do it that often, then OEMs will also run into this problem and there's probably some value in a 'default setup helper' to make it easier to adopt / harder to screw up.

I don't think we'd want to (or be able to, for that matter) make this the only way to instantiate services, but I think it (or something sort-of like it, probably need to nail down the must_spawn() thing a bit) is likely what they'd want in the 95% case? Notably, integration tests fall into that 5% bucket, and I'd expect to continue doing nonstatic allocation in those. On hardware when you're using embassy, though, I think you're already priced into 'static/OnceLock because of how embassy tasks work, right?

match <$($service_ty)*>::init(
&SERVICE,
$($init_args),*
)
.await {
Ok((service_ref, runner)) => {
#[embassy_executor::task]
async fn service_task_fn(
runner: $crate::service::ServiceRunner<'static, $($service_ty)*>,
) {
runner.run().await;
}

$spawner.must_spawn(service_task_fn(runner));
Ok(service_ref)
},
Err(e) => Err(e)
}
}
};
}

pub use spawn_service;
59 changes: 36 additions & 23 deletions espi-service/src/espi_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,17 +75,46 @@ pub struct Service<RelayHandler: embedded_services::relay::mctp::RelayHandler> {
//

///////// COMMON FUNCTIONS ///////////

embedded_services::impl_runner_creation_token!(RunnerCreationToken);

impl<'hw, RelayHandler: embedded_services::relay::mctp::RelayHandler> embedded_services::service::RunnableService<'hw>
for Service<RelayHandler>
{
type RunnerCreationToken = RunnerCreationToken;
async fn run(&'hw self, _: Self::RunnerCreationToken) -> embedded_services::Never {
let mut espi = self.espi.lock().await;
loop {
let event = select(espi.wait_for_event(), self.host_tx_queue.receive()).await;

match event {
embassy_futures::select::Either::First(controller_event) => {
self.process_controller_event(&mut espi, controller_event)
.await
.unwrap_or_else(|e| {
error!("Critical error processing eSPI controller event: {:?}", e);
});
}
embassy_futures::select::Either::Second(host_msg) => {
self.process_response_to_host_routing(&mut espi, host_msg).await
}
}
}
}
}

impl<RelayHandler: embedded_services::relay::mctp::RelayHandler> Service<RelayHandler> {
// TODO a lot of the input lifetimes here have to be static because we have a dependency on the comms system, which requires
// that everything that talks over it is 'static. Once we eliminate that dependency, we should be able to relax these lifetimes.
pub async fn init(
service_storage: &'static OnceLock<Self>,
mut espi: espi::Espi<'static>,
relay_handler: RelayHandler,
) -> &'static Self {
) -> Result<(&'static Self, embedded_services::service::ServiceRunner<'static, Self>), core::convert::Infallible>
{
espi.wait_for_plat_reset().await;

let result = service_storage.get_or_init(|| Service {
let service = service_storage.get_or_init(|| Service {
endpoint: DEPRECATED_comms::Endpoint::uninit(DEPRECATED_comms::EndpointID::External(
DEPRECATED_comms::External::Host,
)),
Expand All @@ -94,30 +123,14 @@ impl<RelayHandler: embedded_services::relay::mctp::RelayHandler> Service<RelayHa
relay_handler,
});

DEPRECATED_comms::register_endpoint(result, &result.endpoint)
DEPRECATED_comms::register_endpoint(service, &service.endpoint)
.await
.unwrap();
result
}

pub(crate) async fn run_service(&self) -> ! {
let mut espi = self.espi.lock().await;
loop {
let event = select(espi.wait_for_event(), self.host_tx_queue.receive()).await;

match event {
embassy_futures::select::Either::First(controller_event) => {
self.process_controller_event(&mut espi, controller_event)
.await
.unwrap_or_else(|e| {
error!("Critical error processing eSPI controller event: {:?}", e);
});
}
embassy_futures::select::Either::Second(host_msg) => {
self.process_response_to_host_routing(&mut espi, host_msg).await
}
}
}
Ok((
service,
embedded_services::service::ServiceRunner::new(service, RunnerCreationToken::new()),
))
}

// TODO The notification system was not actually used, so this is currently dead code.
Expand Down
3 changes: 0 additions & 3 deletions espi-service/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,5 @@ mod espi_service;
#[cfg(not(test))]
mod mctp;

#[cfg(not(test))]
pub mod task;

#[cfg(not(test))]
pub use espi_service::*;
12 changes: 0 additions & 12 deletions espi-service/src/task.rs

This file was deleted.

19 changes: 5 additions & 14 deletions examples/rt685s-evk/src/bin/time_alarm.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
#![no_std]
#![no_main]

use embassy_sync::once_lock::OnceLock;
use embedded_mcu_hal::{
Nvram,
time::{Datetime, Month, UncheckedDatetime},
Expand All @@ -24,25 +23,17 @@ async fn main(spawner: embassy_executor::Spawner) {
embedded_services::init().await;
info!("services initialized");

static TIME_SERVICE: OnceLock<time_alarm_service::Service> = OnceLock::new();
let time_service = time_alarm_service::Service::init(
&TIME_SERVICE,
let time_service = embedded_services::spawn_service!(
spawner,
[time_alarm_service::Service<'static>],
dt_clock,
tz,
ac_expiration,
ac_policy,
dc_expiration,
dc_policy,
dc_policy
)
.await
.expect("Failed to initialize time-alarm service");

#[embassy_executor::task]
async fn time_alarm_task(service: &'static time_alarm_service::Service<'static>) {
time_alarm_service::task::run_service(service).await
}

spawner.must_spawn(time_alarm_task(time_service));
.expect("Failed to spawn time alarm service");

use embedded_services::relay::mctp::impl_odp_mctp_relay_handler;
impl_odp_mctp_relay_handler!(
Expand Down
35 changes: 21 additions & 14 deletions time-alarm-service/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ use embedded_services::GlobalRawMutex;
use embedded_services::{info, warn};
use time_alarm_service_messages::*;

pub mod task;
mod timer;
use timer::Timer;

Expand Down Expand Up @@ -124,7 +123,7 @@ impl<'hw> Service<'hw> {
ac_policy_storage: &'hw mut dyn NvramStorage<'hw, u32>,
dc_expiration_storage: &'hw mut dyn NvramStorage<'hw, u32>,
dc_policy_storage: &'hw mut dyn NvramStorage<'hw, u32>,
) -> Result<&'hw Service<'hw>, DatetimeClockError> {
) -> Result<(&'hw Self, embedded_services::service::ServiceRunner<'hw, Self>), DatetimeClockError> {
info!("Starting time-alarm service task");

let service = service_storage.get_or_init(|| Service {
Expand Down Expand Up @@ -160,7 +159,10 @@ impl<'hw> Service<'hw> {
service.timers.ac_timer.start(&service.clock_state, true)?;
service.timers.dc_timer.start(&service.clock_state, false)?;

Ok(service)
Ok((
service,
embedded_services::service::ServiceRunner::new(service, RunnerCreationToken::new()),
))
}

/// Query clock capabilities. Analogous to ACPI TAD's _GRT method.
Expand Down Expand Up @@ -263,17 +265,6 @@ impl<'hw> Service<'hw> {
}
}

pub(crate) async fn run_service(&'hw self) -> ! {
loop {
embassy_futures::select::select3(
self.handle_power_source_updates(),
self.handle_timer(AcpiTimerId::AcPower),
self.handle_timer(AcpiTimerId::DcPower),
)
.await;
}
}

async fn handle_power_source_updates(&'hw self) -> ! {
loop {
let new_power_source = self.power_source_signal.wait().await;
Expand Down Expand Up @@ -311,6 +302,22 @@ impl<'hw> Service<'hw> {
}
}

embedded_services::impl_runner_creation_token!(RunnerCreationToken);

impl<'hw> embedded_services::service::RunnableService<'hw> for Service<'hw> {
type RunnerCreationToken = RunnerCreationToken;
async fn run(&'hw self, _: Self::RunnerCreationToken) -> embedded_services::Never {
loop {
embassy_futures::select::select3(
self.handle_power_source_updates(),
self.handle_timer(AcpiTimerId::AcPower),
self.handle_timer(AcpiTimerId::DcPower),
)
.await;
}
}
}

impl<'hw> embedded_services::relay::mctp::RelayServiceHandlerTypes for Service<'hw> {
type RequestType = AcpiTimeAlarmRequest;
type ResultType = AcpiTimeAlarmResult;
Expand Down
9 changes: 0 additions & 9 deletions time-alarm-service/src/task.rs

This file was deleted.

Loading
Loading