Repository navigation
Description
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.
Activity
RobertZ2011 commentedon Jul 27, 2026
I'm particularly wary of this approach because I've dealt with bugs in the past that boiled down to this. I'm also not sure if there's a good, easy way to resolve these types of bugs if they do happen. However, I took a quick look, and it doesn't seem like ODP code is currently susceptible to this.
jerrysxie commentedon Jul 28, 2026
Just for clarification, the proposed solution would be something like this:
RobertZ2011 commentedon Jul 28, 2026
Correct, though in practice this code would be automatically generated through procedural macros. The
enum_dispatchcrate already provides an implementation of such macros, but it doesn't work with async, and it only works with traits and implementations that are in the same crate.[-]Enum dispatch proof of concept[/-][+]Reach parity with v0.1, enum dispatch to support different device types[/+][-]Reach parity with v0.1, enum dispatch to support different device types[/-][+]Reach parity with v0.1: enum dispatch to support different device types[/+]JamesHuard commentedon Jul 29, 2026
This first sentence is actually where I'd argue we've made a design misstep.
A device driver should have absolutely no-knowledge of the upper software constructs around it. A device driver's role is purely hardware-mechanics: never high-level mission logic. That's a mixing of responsibilities that results in non-composable, rigidly hierarchical systems.
If we're able to abstract an interface around a common set of similar device classes, then we have a trait with meaningful abstraction. The simplest example I can think of here is an accelerometer:
A service that utilizes an accel hardware component directly could utilize this. Note that, however, a service is far more likely to operate at the conceptual level of a full IMU solution: which may or may not be spread across multiple parts. Here's a hypothetical composition for a
MotionFuserservice-interface class:Note that as a hardware composer, i.e. the product that actually uses the service collections, I get to define dispatch as is appropriate for my building block composition:
Here, dispatch is entirely defined by the composition level; that's the only place with enough horizontal context to decide the right model.
If we're trying to extend behaviors across single software devices beyond their domain of responsibility, we inevitably end up with an unscalable enum "God object" that must have a composition condition for every single potential product combination of parts that anyone uses, ever. At that point, there's no reason to even use traits-- it's a frozen design that serves specifically and only the cases that it was prototyped on.
In your example, the issue here is that you're trying to abstract alongside a dispatch-mechanism level, but that mechanism is actually top-level contextually defined. There is no "one size fits all", because to get there you'd have to construct a virtual "God object" that knows every potential hardware and software system combination a priori.
Basically, we should not have a concept of
Lockableat all. It's forcing a dispatch design decision into a context where it's irrelevant. In my example above, it's entirely inappropriate forOrientationandDumbCompassto prescribe (via aLockabletrait) the method of dispatch between them. That is, fundamentally, a non-composable design because it restricts its use cases to exactly that mechanism, which as demonstrated is only 1 of 3 examples (and you can imagine spreading this out infinitely into the future).Design the composite parts (the "services") such that they have no knowledge of dispatch. Dispatch methodology can only be meaningfully decided from the outer most "bounding box" of any given system.
If you find there's current designs where "Device Drivers" are implementing traits shared across multiple "domains of responsibility", the immediate question should be: are these traits actually hardware-level traits, or do they define a "software concept" that should be abstracted on its own compositional level (i.e., it's own struct and crate or mod). I suspect many such cases, especially in the type-c/pd domain today, fall into the second class (meaning, we likely are doing "too much" at the hardware interface level, causing rigid hierarchy/anti-compositional patterns to bubble upwards).
RobertZ2011 commentedon Jul 30, 2026
Unless I'm misunderstanding, this is what we're currently doing. The PD traits define common operations, and any higher-level functionality is implemented on top of the traits. The only logic present in the trait implementations is the local logic required to implement the trait. The other big difference is that PD traits use composition to represent optional functionality. E.g.
MaxSinkVoltageis optional functionality on top of the minimum required inPd, but this is distinct from your example where you're implementing what I would call a composite device by combining unrelated traits.I disagree. There would not be a single "God object" that contains everything. You would only need to create dispatch enums for the traits and hardware you actually use.
E.g.
The dispatch enums are just the types that implement a specific trait. I would argue that this is just manually implemented dynamic dispatch. If we weren't using async then we'd be working with
&dyn Psu, but&dyn Psuis essentially a compiler generated&Psu.We might also be talking past each other a bit. Your example shows how to create a composite device from multiple real hardware devices, but that's not what I mean when I say dispatch. I'm talking about how to re-implement the functionality that would normally be provided by
&dyn SomeTraitwhen we cant use&dyn Psu` due to async. But I'm glad you brought it up because it is something that we need to consider in our design.Getting rid of
Lockablewould cause us to lose the ability to compose operations atomically and open us to hard to fix concurrency bugs. Let's say we have an IMU task containing this code:and a host communication task containing:
Without
Lockableor something similar, we cannot prevent the operations in these tasks from interleaving and interfering likeThis is a bug that I could 100% see an OEM encountering. I think that there are ways to mitigate the risk here through careful design of traits, but I don't think we could completely remove it. With
Lockablethe IMU code could be atomic and the interleaving couldn't happen. This would work with yourMyProductIMUstruct. It would just need to own the hardware that composes it and be contained in a mutex. However, this may be where your "God Object" might come into play. If everything inMyProductIMUcan accept a FW update thenMyProductIMUwould need to implement the functionality to dispatch the FW update to the correct device. There are situations where I can imagine this becoming unwieldy. It seems like we're running into a trade-off between ease of composition and ability to atomically compose operations.This should already be true. In an environment with an allocator, you should be able to drop in a
Mutex<Box<dyn SomeTrait>>and everything should just work. Dispatch is already an implementation detail; the issue is really betweenLockableand dispatch.We can dive in deeper, to be sure we're on the same page, but this should be generally true. There's very little code that isn't concerned with managing or controlling hardware state. This is something that was worse before the refactor. For example, the PD controller code used to contain a CFU implementation, but now the CFU-specific logic is contained in its own struct which interacts with the PD controller through a
FwUpdatetrait.asasine commentedon Jul 30, 2026
I haven't been able to fully respond to all points here but I do want to respond to one.
This isn't true. Any operation which requires atomicity should always be defined as a singular method [on a trait]. The method's signature,
fn -> impl Future, is conveying exactly what we want: a single asynchronous state machine, with exactly two visible states (pending and done), with an output when done. For the IMU read + reset example, reading all three should be a singlefn read_imu() -> impl Future<Output = ImuMeasurement>. If the duration of the method needs atomicity, it's an implementation detail, not part of the public API.JamesHuard commentedon Jul 30, 2026
When I said "god object" here, what I'm trying to say is that when we make this decision in the shared code itself, we're inherently limiting support to specifically the set of tested hardware compositions that are reflected by said enum.
enum Temp { A/B }shouldn't exist in any service or shared code, because that creates a limitation on how that shared code can be used. "God object" is what results if you follow this process to its extreme: everything in ODP could effectively be wrapped in a giantSupportedPlatformsenum, and "dispatched" from that. The composition in your example isn't really "composition" at the interface level, it's a heirarchy of supported hardware platforms implicitly linked together through these enums across the shared code.Another way to look at this is to say the enum is creating a mixing of domain responsibilities: the enum itself is simply a stand in for
a variant thing that does A. Writing a shared library that consumes said enum then becomes not a "library generic over A", but rather a library that "multiplexes over supported hardware variants, manages those variants behaviors, and also includes generic logic over A". Here we have two clearly delineated lines of responsibility being served by the same compositional convergence point: the crate is moving from a "compositional building block in a large toolbox" into "a fully implemented feature A, that may or may not be included as is in your project". These are importantly but subtly different designs. The former, what I'm referring to as a "compositional architecture", can scale to anyone's quirky hardware platform. The latter is inherently bound to only those explicit hardware platforms implemented and supported by ODP. It's not my mandate to say which is the right strategy for ODP, but from where I'm standing as a consumer of ODP, the former is the only option that realistically works for our product portfolio. We realistically can't survive more than one or two product cycles in the confines of the latter -- it's too rigidly hierarchical, and our hardware designs move too fast to support it.I'm suggesting that we shouldn't have
&dyn SomeTraitas a fundamental compositional tool for interfacing with our "services". What follows:is exactly an example of where I think domains of responsibility are getting coerced together and becoming a hierarchical model instead of a compositional one.
Your example is right on-- it's a perfect example where I think we've implicitly designed ourselves into a corner by smooshing responsibility boundaries into one singular "super object". Let's work with the imu example further, because the mangling of state you described happens as an artifact of "too much API" in a trait.
Have you noticed how there's virtually no traits for
initwhen it comes to things likeembedded-io? I don't believe this is an accident. How you configure something is inherently part specific. The IMU is a great example of this, because even within the same supplier and chip family you can have dramatic differences in hardware interface and operational sequencing (register behaviors and the like).Take the accel trait above as an example:
When a clever developer looks at this, they immediately think: "I've got an excellent interface to read to the accel, but I can do even more with my beautiful interface! Every chip needs some kind of startup sequence, and those that don't just make it empty. Let me expand it thusly:"
However, we've now just made a critical misstep in interface design. We've now declared that anyone who wants to use an
Accelmust necessarily manage init sequencing. That's a huge mix-up of domain responsibilities. Initialization is by necessity board specific -- you really can't generalize it meaningfully without imposing strict hierarchies on what sort of devices you can tolerate in your generic startup-system. Think through some circular dependencies to see how nasty this gets (what if I need to power on one part to use another, but in another system the opposite is true).What that means is we've just cut off any class of customer (or product hardware choice and design) who doesn't conform to our rigidly defined hierarchy of components in ODP from actually using any of our
impl Accelobjects in ODP. And, when you follow this line of thinking further, you'll see it becomes a tangling factor between even large-scoped domains: suddenly yourPDControllerinterface change ripples up into aimpl PowerPolicyDevicefunctional change, despite those supposedly being separate crates that can compile independent of one another. That's not a compositional model (as I'm using the terms), that's a hierarchical or layered on.The runtime question is actually implicitly answered in there, but let's explicitly work through that concept map too:
What if I have a device with state that's "sticky"? Well, that actually is easily solved by following the compositional rules for splitting interfaces above.
Let's say I have
IMUUserAandIMUUserB:Now let's look at what their domain of responsibility is:
IMUUserAis a simple data consumer of a generic Accelerometer measurement. It's domain of responsibility begins after the sample arrives and ends after it's performed some kind of transformation or fusion on it.IMUUserBis responsible for managing device configuration, destructive device-state, as well as some runtime behaviors likeIMUUserAIMUUserAis basically fine.IMUUserB, however, is the violating our compositional design principles here, and it's actually violating in multiple ways:PlatformTypeAccelVariant(which starts to look/smell a lot like the enums posted before) or it's composed one out of an equally-over-stepping trait interfacePlatformOperationsTypeAccelVariant+Accel.There's no real fully valid way to retain a toolbox like compositional model while including things like
PlatformType___constructs. Those are by their nature hardware specific. It's not that we can't have these things, it's that they have to live in the appropriate place: the product/product-family that uses them.So what happens if we fix these design violations? Well, funnily enough, it not only makes the code more re-usable, but it also eliminates the runtime race condition.
IMUUserAcan stay as it is. It's cleanly composable.IMUUserBhas to be broken up. It's doing two main things: servicingHostCommandrequests, and interfacing to a set of platform board-style configurations on a particular Accel/Accels that look sufficiently similar (hopefully you can see the issue here with how difficult it is to even capture accurately in a single sentence).So what do we do? Surely someone has to have the "top level knowledge" because their implicit to the
HostCommandrequest domain. You could argue maybe that set of requests is invalid, but I don't think we need to explore that route, because it is valid to say "[this crate] supports requests ofHostCommandtype".Let's just blindly apply our "one domain" rule:
Thus, we get something like:
But how do we make sure we don't hit an invalid runtime config now? How have we eliminated the issue? By the borrow checker :) --
For a system where we need to both process
HostCommands andIMUUserA, these are actually not trivially related: when the chip isreset, the device is no longer anAccel(measurement gatherer, ready to be read from). We're in software land now, so we can break this up however we want! Let's not get hung up on making singular objects. Instead, let's compose them based on their explicit relationships:What
Lockableis attempting to do is draw a boundary of abstraction betweentask_user_aandtask_user_b. This is an aesthetically pleasing goal; but unfortunately, it's not scalable to different hardware/product configurations and designs outside whatever "mold" that particular hierarchy has captured. To capture the same level of dynamics as the above, you'd be generating some kind of "God object" that represents every conceivably supported set of hardware variants your "generic" systems operate on. With that model, there's not really much reason to even use traits -- just have some top level "Board variant" enums, and call direct connective tissues that do their "board operations" in place as is defined by that particular configuration.Let's look at this really specifically, in case the above doesn't land clearly:
As an anti-example, let's run through a version with prescribed dispatch methodology via the shared
Lockabletrait. With that design, I can no longer actually support the same runtime scenarios: I've locked myself out of any software compositions that resolve up differently and have forced all of my "shared" code to now be conscious of this front-loaded dispatch decision. The exact case where this fork breaks down is when we're trying to compose at a smaller rung on the design "layering", rather than a higher one. In other words, it hurts us on two fronts: Any resources that might have shared contention (devices that can't be singularly "Locked" at a top level) can't be fed into these shared crates, and because I've now forced my shared crates to design around the concept of a shared resource, they can't compose with interfaces that don't have that restriction baked in.Here's code that demonstrates these two pitfalls:
Are you arguing that a
Lockableis not inherently a dispatch model? I'm struggling to understand what the distinction you're making is here, as I see the domain of responsibility forLockableas explicitly covering the expectation for how information is shared across contexts. That to me is synonymous with dispatch.It's not so much about the volume of code manipulating hardware state, but rather the way we've partitioned responsibility for that.
I don't at all doubt that ODPv2 made improvements on what we had previously. But I want to make sure it's clear that we haven't actually moved away from a hierarchical model. This isn't "composition" in a tool-box sense.
asasine commentedon Jul 30, 2026
@jerrysxie for what it's worth, I think a Discussion for proposals like this would be more conducive to parallel discussion like we're having, since you can have distinct replies to the main post, with each reply being its own thread with further replies. They're not enabled for this repo.