Skip to content

Reach parity with v0.1: enum dispatch to support different device types #927

Description

@RobertZ2011

Summary

High-level code needs to be able to interact with multiple different types of hardware (e.g. power code needs to interact with a PD port and a DC jack), but Rust forces us into a single concrete type. v0.1 code supported this, but we need to determine how this is handled in v0.2.

Background

Interacting with a service requires a device driver to implement one or more traits defined by the service. We want services to be able to interact with multiple different types of devices at the same time, i.e. the power policy needs to coordinate with all PSUs on the system even though one may be a DC barrel jack while another is a Type-C port. This is futher complicated by our use of async code, which normally requires boxing futures on the heap to allow for fully generic async code. We decided to address this by using enum dispatch, allowing code to be generic over only the enum dispatch type. However, the enum_dispatch crate doesn't support async and only works with traits and implementations that are in the same crate.

Additionally, hardware devices might need to be shared with mulitple services, or between ODP code and OEM code. ODP provides the Lockable trait as an abstraction over mutex-like objects. This allows service implementations to be generic over Lockable<Inner: SomeTrait>. When code needs to call a trait function, Lockable::lock is called to yield a guard object which implements DerefMut<Target: SomeTrait>, deref_mut is implicitly called to yield the end &mut impl Sometrait reference.

With enum dispatch, the above approach fails at the DerefMut step because we would need to return a reference to the dispatch type, as demonstrated in this example:

enum LockableDispatch<'a> {
    TypeA(&'a Mutex<TypeA>),
    TypeB(&'a Mutex<TypeB>).
}

enum GuardDispatch<'a> {
    TypeA(MutexGuard<TypeA>),
    TypeA(MutexGuard<TypeB>),
}

enum ReferenceDispatch<'a> {
    TypeA(&mut TypeA),
    TypeB(&mut TypeB),
}

impl<'a> DerefMut<Target = Dispatch<'a>> for GuardDispatch<'a> {
    type Target = ReferenceDispatch<'a>;

    fn deref_mut(&mut self) -> &Self::Target {
        // Can't construct a `ReferenceDispatch` instance because we have to return a reference
        // Can't store a `ReferenceDispatch` instance because that won't pass the borrow checker
    }
}

Proposed Solution

Change Lockable so that the guard type is what implements SomeTrait instead of being required to implement DerefMut<Target = SomeTrait>. In the above example, this would move the enum dispatch implementation to the GuardDispatch type instead of needing another layer of indirection through DerefMut. This does lead to a complication when not using enum dispatch, which relies on DerefMut. This would be addressed through blanket impls on embassy guard types like impl<'a> SomeTrait for embassy_sync::mutex::MutexGuard<'a, T> where T: SomeTrait { ... }, or a blanket impl like impl SomeTrait for T where T: DerefMut<Target = SomeTrait> { ... }

Alternatives Considered

Put Dispatch Enum inside of the Lockable

In this approach the dispatch enum would be placed in the Lockable instead. With the following Dispatch type:

enum Dispatch {
    TypeA(TypeA),
    TypeB(TypeB),
}

The flow would then be Lockable::lock -> Guard<Dispatch> -> &mut Dispatch. This avoids the issue with DerefMut because the enum dispatch type is already in the guard. However, this precludes sharing among multiple services because only a single enum dispatch type would have access to the hardware. Similarly, OEM code would no longer have individual access to the hardware, access would have to be done through the aggregate Dispatch type.

Dispatch Before Locking

In this approach the SomeTrait implementation would instead be responsible for locking an inner mutex type and call the appropriate function, like so:

struct TypeA {
    lock: Mutex<TypeAInner>,
}

impl SomeTrait for TypeA {
    async fn some_func(&self) {
        self.lock().await.some_func().await;
    }
}

The downside to this approach is that we lose the ability to compose operations atomically. This could allow for concurrency bugs where task A and B are executing functions, which get interleaved and lead to incorrect behavior.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions