created initial rust based design draft for mw::diag#78
created initial rust based design draft for mw::diag#78MonikaKumari26 wants to merge 1 commit intoeclipse-opensovd:mainfrom
Conversation
docs/design/rust_abstraction_layer_api_for_user_applications.puml
Outdated
Show resolved
Hide resolved
docs/design/rust_abstraction_layer_api_for_user_applications.puml
Outdated
Show resolved
Hide resolved
| {abstract} +unlock(&mut self) -> Result<()> | ||
| {abstract} +kind(&self) -> DiagnosticEntityKind | ||
| {abstract} +supported_modes(&self) -> Result<Vec<DiagnosticEntityMode>> | ||
| {abstract} +apply_mode(&mut self, mode_id: &str, mode_value: &str, expiration_timeout: Option<Duration>) -> Result<()> |
There was a problem hiding this comment.
since we were discussing async APIs, maybe it would make sense here to use one
There was a problem hiding this comment.
Makes sense to be async since applying a diagnostic mode might take time. supported_modes() could be also async if querying supported modes requires reading from vehicle systems.
docs/design/rust_abstraction_layer_api_for_user_applications.puml
Outdated
Show resolved
Hide resolved
docs/design/rust_abstraction_layer_api_for_user_applications.puml
Outdated
Show resolved
Hide resolved
docs/design/rust_abstraction_layer_api_for_user_applications.puml
Outdated
Show resolved
Hide resolved
| +request_results(&self, params: ByteSlice) -> Result<ByteVec> | ||
| } | ||
|
|
||
| enum UDSResponseCode { |
There was a problem hiding this comment.
the assigned enumerator values are not really rust-compliant, am I right?
There was a problem hiding this comment.
I assigned values to match the protocol's hex codes, but anyway I have removed the manual assignments and let the compiler handle the discriminant, which is much more idiomatic for Rust. I have added as_byte() and from_byte() which can handle the conversion.
| +get_write_did_mut(&mut self, id: &str) -> Option<&mut dyn WriteDataByIdentifier> | ||
| +get_routine_mut(&mut self, id: &str) -> Option<&mut dyn RoutineControl> | ||
| +get_service_mut(&mut self, id: &str) -> Option<&mut dyn UdsService> |
There was a problem hiding this comment.
Actually this provides clear distinction between read-only and mutating operations. Here we need &mut self and return &mut dyn Trait for several UDS methods because the underlying trait methods require mutable access to implement their functionality correctly.
- get_write_did_mut: WriteDataByIdentifier::write() mutates state
- get_routine_mut: RoutineControl::start()/stop() mutate state
- get_service_mut: UdsService::handle_message() mutates state
As per Rust borrow rules: returning &mut T requires &mut self.
docs/design/rust_abstraction_layer_api_for_user_applications.puml
Outdated
Show resolved
Hide resolved
lh-sag
left a comment
There was a problem hiding this comment.
@MonikaKumari26 thanks for your contribution!
docs/design/rust_abstraction_layer_api_for_user_applications.puml
Outdated
Show resolved
Hide resolved
docs/design/rust_abstraction_layer_api_for_user_applications.puml
Outdated
Show resolved
Hide resolved
docs/design/rust_abstraction_layer_api_for_user_applications.puml
Outdated
Show resolved
Hide resolved
| type DataGroupId = String | ||
| } | ||
|
|
||
| entity JsonSchema { |
There was a problem hiding this comment.
This can actually be a json type.
There was a problem hiding this comment.
Changed from String to JsonValue (which is serde_json::Value).
docs/design/rust_abstraction_layer_api_for_user_applications.puml
Outdated
Show resolved
Hide resolved
docs/design/rust_abstraction_layer_api_for_user_applications.puml
Outdated
Show resolved
Hide resolved
| } | ||
| } | ||
|
|
||
| package "mw::diag::uds" { |
There was a problem hiding this comment.
I would be good to have this either as a separate create or guard it with a feature flag. There will be SOVD uses who do not want to use this feature.
There was a problem hiding this comment.
Its good idea. I have updated the design to use cargo feature flags for modularity, SOVD-only users get smaller binaries (no UDS code compiled). Maintaining single crate keeps dependency management simple.
There was a problem hiding this comment.
I would be good to have this either as a separate create or guard it with a feature flag. There will be SOVD uses who do not want to use this feature.
That's a great idea!
a87978f to
e9e5e95
Compare
|
@MonikaKumari26 I suggest to move this PR out of draft status to get more feedback. |
e9e5e95 to
82693a9
Compare
darkwisebear
left a comment
There was a problem hiding this comment.
Not done, but maybe these comments are helpful still. One general comment: This library allocates a lot. We need to see whether we can cut back on allocations (or avoid them somehow?) and we need to introduce an Allocator trait, as the canonical one from std isn't stabilized yet.
|
|
||
| fn register_vin_reader() -> Result<DiagnosticServicesCollection> { | ||
| DiagnosticServicesCollectionBuilder::new() | ||
| .with_read_did("0xF190", VinReader) |
There was a problem hiding this comment.
This is deployment specific information. Would it make sense to move this to a config file so that here, we only give an abstract identifier?
There was a problem hiding this comment.
I have replaced hard coded values with configuration variables just to demonstrate the pattern. changes have been pushed.
|
|
||
| fn register_config_writer() -> Result<DiagnosticServicesCollection> { | ||
| DiagnosticServicesCollectionBuilder::new() | ||
| .with_write_did("0xF191", ConfigWriter) |
There was a problem hiding this comment.
Swapped hard coded values for config variables.
|
|
||
| fn register_my_routine() -> Result<DiagnosticServicesCollection> { | ||
| DiagnosticServicesCollectionBuilder::new() | ||
| .with_routine("0xFF00", MyRoutine { state: 0 }) |
| ReadOnlyDataResource, DiagnosticServicesCollectionBuilder, | ||
| DiagnosticEntity, JsonDataReply | ||
| }; | ||
| use opensovd_diag::sovd::types::SOVDResult; |
There was a problem hiding this comment.
| use opensovd_diag::sovd::types::SOVDResult; | |
| use opensovd_diag::sovd::Result; |
I think we should still use the canonical Result type even for custom Result aliases, plus we should not expose an additional types module but (re-)export it from sovd. If one wanted to distinguish between std::result::Result and SOVD Result, they could simply use opensovd_diag::sovd; and use sovd::Result.
There was a problem hiding this comment.
Using the canonical Result type while re-exporting it from the sovd module keeps the API much cleaner. I removed the separate "types" module exposure and set up the re-export so users can simply use sovd::Result. Considered this in puml file as well.
| -vendor_message: String | ||
| } | ||
|
|
||
| class "Result<T>" as SovdResult { |
There was a problem hiding this comment.
| class "Result<T>" as SovdResult { | |
| class "Result<T>" as Result { |
There was a problem hiding this comment.
I have refactored to use the canonical Result and updated the exports.
| Both can be enabled independently or together. | ||
| end note | ||
|
|
||
| class SovdError { |
There was a problem hiding this comment.
| class SovdError { | |
| class Error { |
There was a problem hiding this comment.
Considered previous changes for this also.
| +with_read_resource<T: ReadOnlyDataResource + Send + Sync + 'static>(self, id: impl Into<String>, resource: T) -> Self | ||
| +with_write_resource<T: WritableDataResource + Send + Sync + 'static>(self, id: impl Into<String>, resource: T) -> Self | ||
| +with_data_resource<T: DataResource + Send + Sync + 'static>(self, id: impl Into<String>, resource: T) -> Self | ||
| +with_operation<T: Operation + Send + Sync + 'static>(self, id: impl Into<String>, operation: T) -> Self |
There was a problem hiding this comment.
What's the reason for these types to be Sync?
There was a problem hiding this comment.
My understanding is that these references must be Sync to allow concurrent read access from multiple threads, ensuring safe shared diagnostic operations and no compiler errors when shared via &T.
There was a problem hiding this comment.
But that'd only be relevant if I wanted to execute the same operation concurrently. Is that really the case? Imo executing an operation should be exclusive, which means that T needs to be Send (so that an instance of T can be executed in different threads), but not Sync (because no concurrent execution is required).
There was a problem hiding this comment.
Regarding the concurrency constraints, I initially used uniform Send + Sync requirement for simplicity, but I have now refined this to be more precise based on resource behavior:
- Operation Trait: Retained Send + Sync. Since it exposes both &self and &mut self methods, Sync is required to allow concurrent status queries via immutable references.
- Write-only resources/services, RoutineControl and UdsService to be Send only, as they don't benefit from concurrent access.
- Read-only Resources: Retained Send + Sync to explicitly support concurrent reads.
Design may need further adjustments if these Sync requirements conflict with specific downstream use cases.
There was a problem hiding this comment.
&self / &mut self are static ownership properties that help the compiler to find out whether it's possible to allow for modifications of the instance state in a certain context (imo &mut should have been called &unique).
In conjunction with concurrency traits, it helps the compiler to know whether copying a reference across thread boundaries preserves these properties. However, in case of e.g. Operation types, even if they're Sync, the method signature of e.g. execute requires &mut self, so you statically demand a unique ownership. This either requires a mutex or something similar to go from shared to unique ownership if this exclusivity is enforced at runtime, or even a static unique ownership, which is hard to obtain unless you completely handle all diag events in a single thread. But since you already demand unique ownership, requiring the type to be Sync is asked too much as you do not need to synchronize anyway if ownership is already unique.
The bottom line for me is that if we have &mut self methods, requiring Sync doesn't help at all. Sync only makes sense if most / all methods are &self so that you can call them from multiple threads truly concurrently. Deciding this means to think whether synchronization shall be done by all implementors of Operation and the like (which makes these types Sync, but allows for fine grained synchronization if required), or whether the framework will take care, which makes implementing the diagnostic structs easier, but the synchronization will be more coarse grained.
| end note | ||
|
|
||
| class "DiagnosticServicesCollectionBuilder<'entity>" as SovdDiagnosticServicesCollectionBuilder { | ||
| +new(entity: &'entity dyn DiagnosticEntity) -> Self |
There was a problem hiding this comment.
This signature requires the user to hand over a borrow. While this sounds off, to increase the flexibility, here it makes sense to acquire ownership of the entity. This way, any type (and not just references) could be used here, which includes references, but also things like direct ownership or using an Arc<dyn DiagnosticEntity> which might in practice be the one with the best usability.
If you want the type to also work with a non-'static lifetime, you can do it like that:
class "DiagnosticServicesCollectionBuilder<'a, T: DiagnosticEntity + 'a>" as SovdDiagnosticServicesCollectionBuilder {There was a problem hiding this comment.
@darkwisebear so you are suggesting that the builder shall own the DiagnosticEntity in some regard? The intention was actually that the DiagnosticEntity owns the DiagnosticServicesCollection which itself just has a reference back to the DiagnosticEntity
There was a problem hiding this comment.
Eww, but isn't that a circular dependency and we all hate them for good reasons?
There was a problem hiding this comment.
Ok, so here's a proposal on how we could resolve the situation. Assumptions for the proposed design:
- Type erasure shall be done so that
DiagnosticServicesCollection/DiagnosticServicesCollectionBuilderdo not need type parameters. - The user shall be able to build a
DiagnosticServicesCollectionusing either a type implementingDiagnosticEntity, or some reference to a type implementingDiagnosticEntity.
The following changes would need to be applied:
entity: &'entity dyn DiagnosticEntity⇒entity: Box<dyn DiagnosticEntity + 'entity>. This way,DiagnosticServicesCollectioncan store any type that implementsDiagnosticEntityby value. Since the trait bound also has a lifetime bound, we can still use references whose lifetimes get tracked by the compiler, like&orCowor the like.- As we will provide an
impl DiagnosticEntityfor&'_ T,Arc<T>,Weak<T>(where the latter will return an error in case the instance already got deleted), the user may hand over any of those types to theDiagnosticServicesCollectionBuilder. new(entity: &'entity dyn DiagnosticEntity) -> Self⇒new<T: DiagnosticEntity + 'entity>(entity: T) -> Selfto accept any type that's suitable. Thenewimplementation will then box the given entity and do the type erasure.
There was a problem hiding this comment.
Thanks for this suggestion.. I am pivoting to the Box<dyn DiagnosticEntity + 'entity> approach to allow flexible ownership patterns and avoid the entity holding a reference to the collection. Based on my understanding, the user is now responsible for choosing their storage strategy (owned, &T, Arc, Weak) by selecting which type they pass to new().
To ensure I am on the right track, a few clarifications :
-
The change from &'entity dyn DiagnosticEntity to Box<dyn DiagnosticEntity + 'entity> adds heap allocation. Is this acceptable for the performance requirements?
-
For the Blanket Implementations: Should impl DiagnosticEntity for &T/Arc/Weak be:
- Provided by the crate (mandatory for all three)
- Optional via feature flags (to enable /disable them)
- Just documented as examples for users to implement (Currently I have documented them as examples).
There was a problem hiding this comment.
@MonikaKumari26 My comments on your questions:
- That's acceptable since this is happening during initialization. If this turned out to be a problem, we could replace this by smallbox.
- These implementations should be provided by the crate. I don't think that this needs a feature flag since these are smallish implementations that do not add overhead if not used and also shouldn't contribute to compile times / rlib sizes that much.
There was a problem hiding this comment.
Thanks for the clarifications.. I have updated the Design Goals document to reflect these points.
82693a9 to
887af25
Compare
| } | ||
|
|
||
| fn register_vin_reader(did_identifier: &str) -> Result<DiagnosticServicesCollection> { | ||
| // did_identifier would come from configuration (e.g., "0xF190" for VIN) |
There was a problem hiding this comment.
I'd rather have something like "VehicleIdentificationNumber", with a deployment specific mapping to some UDS ID. Just putting the hex number in a string isn't cutting it.
There was a problem hiding this comment.
Sure.. I have done it in rust-idiomatic way to move away from raw hex strings. Added VehicleDataIdentifier enum with semantic names and implemented an as_uds_did() method to handle the mapping to specific UDS IDs.
887af25 to
7825f43
Compare
lh-sag
left a comment
There was a problem hiding this comment.
@MonikaKumari26 if you want broader feedback I would advise you to move this PR from draft to final.
| } | ||
|
|
||
| impl WritableDataResource for ConfigResource { | ||
| fn put(&mut self, request: DiagnosticRequest) -> sovd::Result<()> { |
There was a problem hiding this comment.
IMHO get/set is the common nomenclature.
There was a problem hiding this comment.
I have deliberately chosen get/put to align with HTTP/REST conventions. GET for read operation
and PUT for the update/write operation.
There was a problem hiding this comment.
For operation you chose e.g. stop() instead of DELETE. Should the name not match the intent rather than the HTTP method? The latter is terminology from the transport protocol. This would make the naming schema consistent e.g. read/write, start/stop etc
There was a problem hiding this comment.
You are right regarding the consistency of the naming convention. I will rename get() to read() and put() to write() to align with the existing UDS API patterns (ReadDataByIdentifier::read() and WriteDataByIdentifier::write()). This transition ensures the naming is intent-based rather than tied to specific transport protocols.
| <<Debug, Clone>> | ||
| +new(data: JsonValue) -> Self | ||
|
|
||
| +with_headers(self, headers: Headers) -> Self |
There was a problem hiding this comment.
I am not sure whether we should send http headers as-is to the diaglib. Instead we can have properties one can send along. Header could contain credentials/tokens and not needed data. I would therefore opt for generic properties instead.
There was a problem hiding this comment.
Agreed. I will replace the headers field with a generic properties: IndexMap<String, String>. This lets us filter out credentials/tokens and only pass along relevant, safe metadata. But this makes the API protocol-independent. Is this approach acceptable or not sure, are there SOVD specific requirements that would need HTTP header access?
| +build(self) -> Result<DiagnosticServicesCollection<'entity>> | ||
| -- | ||
| -entity: Box<dyn DiagnosticEntity + Send + Sync + 'entity> | ||
| -read_resources: HashMap<ResourceIdentifier, Box<dyn ReadOnlyDataResource + Send + Sync>> |
There was a problem hiding this comment.
If we want to support or even care for a stable order we should consider IndexMap instead. This would allows for a stable result if one requests /v1/apps/app_id/data
There was a problem hiding this comment.
I have switched from HashMap to IndexMap for deterministic iteration order.
| entity: Arc<dyn DiagnosticEntity + Send + Sync + 'entity> | ||
| ) -> sovd::Result<DiagnosticServicesCollection<'entity>> { | ||
| DiagnosticServicesCollectionBuilder::new(entity) | ||
| .with_data_resource("parameter", ParameterResource { value: 0 }) |
There was a problem hiding this comment.
I miss the schema. Should this be provided by the Resource itself as it will will return the data for the request.
There was a problem hiding this comment.
To address your schema concern, I have added schema parameters to the builder registration methods. This approach provides protocol compliance without mandating specific serialization patterns.
|
|
||
| impl WritableDataResource for ParameterResource { | ||
| fn put(&mut self, request: DiagnosticRequest) -> sovd::Result<()> { | ||
| if let Some(value) = request.data.get("parameter").and_then(|v| v.as_i64()) { |
There was a problem hiding this comment.
Good example. Please consider adding serde, which will provide better ergonomics.
There was a problem hiding this comment.
Noted as future enhancement.
|
|
||
| impl ReadOnlyDataResource for ParameterResource { | ||
| fn get(&self) -> sovd::Result<JsonDataReply> { | ||
| Ok(JsonDataReply::new(json!({ "parameter": self.value }))) |
There was a problem hiding this comment.
With an indirection we could support type safety by requiring the type to be serializable. This would also improve api ergonomics.
There was a problem hiding this comment.
Thanks for this suggestion !! I understand the benefits of using generic serialization with type parameters. For the MVP, I have chosen to keep the current design. However, I agree this would improve ergonomics. I have noted this as a future enhancement.
| - **Mixed traits** with both `&self` and `&mut self` methods (`DiagnosticEntity`, `Operation`, `DataIdentifier`, `DataResource`): Require `Send + Sync + 'static` because they have immutable methods that could be called concurrently (e.g., `Operation::info(&self)`, `Operation::status(&self)`) | ||
| - **Mutable-only traits** (`RoutineControl`, `UdsService`): Require only `Send + 'static` because they only have `&mut self` methods | ||
| - **Trait Objects**: All traits marked with `<<object-safe>>` can be used as `dyn Trait` (e.g., `Box<dyn ReadDataByIdentifier + Send + Sync>`) | ||
| - **Feature Flags**: Both "sovd" and "uds" modules are optional and independently toggleable via Cargo features |
There was a problem hiding this comment.
I would assert that at least one feature is set. And do you plan to have a default-feature e.g. sovd?
There was a problem hiding this comment.
Regarding default features, I have chosen not to set a default in order to align with our design philosophy (SOVD-first development: newly written applications should use native SOVD APIs instead of legacy UDS APIs). By requiring an explicit choice between sovd, uds or both, we ensure that developers prioritize native SOVD APIs. From a technical standpoint, this 'opt-in' model allows users to minimize binary size and improve compilation speeds by including only the code they need.
To ensure the crate remains functional, I have implemented a compile-time assertion to verify that at least one protocol feature is enabled.
| **Important**: This design is intended for automated code generation. Key constraints: | ||
|
|
||
| - **Rust Edition**: 2021+ (minimum required) | ||
| - **Async Strategy**: All trait methods are currently **synchronous**. While shown in design for API clarity, the current implementation does not use async. Future versions may add async support using `#[async_trait]` macro. |
There was a problem hiding this comment.
IMHO it is easier to have async fn first and support blocking on top. What is the rational to start with sync first?
There was a problem hiding this comment.
I think that the original statement in the document is too generic. We need to look at the methods one by one and determine their nature:
Operation
Most methods query state; these methods are nonblocking and there is imo no reason to make them async, not even in a second step, as such data acquisition should not require any long running activities. The same is true for the execute method: This one also isn't supposed to block. If it does need to do something that takes longer, it is supposed to spawn a concurrent task or thread and return with a pending state.
The only notable exception in my eyes is stop, because I could imagine that stopping a task might incur some waiting. I'd need to check the SOVD spec whether or not we'd need to delay the response of this method until the task really stopped.
ReadableDataResource
I think it makes sense to make the get call async, as it is absolutely thinkable that data acquisition will be done asynchronously by the implementation.
WritableDataResource
Here, the same argumentation as for the execute call holds, unless it is required by the spec to also tell whether setting the data actually happened (aka a positive response not just confirms data reception but also confirms proper changing of the inner state). In that case, the put method also needs to be async.
There was a problem hiding this comment.
Your implications about the system might fit for yours but we need an async interface for ours. It is just a matter of how we implement it.
There was a problem hiding this comment.
I don't think so. Let's take Operation: If you opt for an API design (and that's how it looks) where you map SOVD requests 1:1 to API calls, the resulting API and the way how you implement the trait should give you an idea about what color your function is.
If you disagree with my assessments, please give your view of that particular method and why it should or shouldn't be async, or you also mention why the suggested API design philosophy (have a thin layer above the REST calls) is not the correct one.
There was a problem hiding this comment.
Yes. And our operations/API is fully async this does not mean write will work differently as you suggested but our core is async. This is completely orthogonal to SOVD.
Please note that I do not enforce either way sync or async, just that we have an async interface as our system underneath work differently than yours.
There was a problem hiding this comment.
Imo the API should already be usable for you as well without having to jump through hoops; either we will have a truly async method anyway (e.g. get of ReadableDataResource), or the methods are ordinary ones without suspension points, but these shall be (see my comment above) nonblocking, so that they can also be called in an async context. So even if you are fully async, this shouldn't prevent you from being able to implement this API.
On a side note, we shouldn't actually talk about "your system" or "our system". In the end we want to build something that can be used in many different contexts, and if there are contexts where the API is not a good fit, I'd need to understand what exactly isn't fitting so that we can develop a common solution that fits.
There was a problem hiding this comment.
On a side note, we shouldn't actually talk about "your system" or "our system". In the end we want to build something that can be used in many different contexts, and if there are contexts where the API is not a good fit, I'd need to understand what exactly isn't fitting so that we can develop a common solution that fits.
We do agree here. But since our systems differs we are biased, I doubt we would have argued if this is not the case. Therefore my initial remark was whether we have a fully sync and async interface.
Where we differ is having a mixed one e.g. I do not see why for example Operation::status should not be colored async. The question about async/sync interface is independent of how SOVD works.
I will join next weeks UDS2SOVD meeting in case you want to discuss this outside of this PR.
|
|
||
| ## Code Generation Guidelines | ||
|
|
||
| **Important**: This design is intended for automated code generation. Key constraints: |
There was a problem hiding this comment.
What do you mean by "intended for automated code generation"?
There was a problem hiding this comment.
It signifies that the logic defined here can be consumed by user to produce executable code. I have tried to ensures that the implementation aligned with the design specifications, reducing error during the coding phase.
|
|
||
| class "SerializedDataByIdentifier<T>" as SerializedDataByIdentifier { | ||
| <<generic>> | ||
| where T: UdsSerialize + UdsDeserialize + Clone + Send + Sync + 'static |
There was a problem hiding this comment.
Why T has to be clone here ??
|
|
||
| class "SerializedReadDataByIdentifier<T>" as SerializedReadDataByIdentifier { | ||
| <<generic>> | ||
| where T: UdsSerialize + Send + Sync + 'static |
There was a problem hiding this comment.
Why does T need to be Send + Sync + 'static?
There was a problem hiding this comment.
Send looks sufficient because the collection may be moved between threads, but services are accessed sequentially. Sync will be removed as there is no concurrent access to services in practice. static required for type erasure.
| interface Operation <<trait>> { | ||
| API definition for SOVD operations | ||
|
|
||
| .. standard SOVD capabilities .. | ||
| {abstract} +async info(&self, request: DiagnosticRequest) -> Result<OperationInfoReply> | ||
| {abstract} +async status(&self, request: DiagnosticRequest) -> Result<OperationStatusReply> | ||
| {abstract} +async execute(&mut self, request: DiagnosticRequest) -> Result<ExecuteOperationReply> | ||
| {abstract} +async resume(&mut self, request: DiagnosticRequest) -> Result<ExecuteOperationReply> | ||
| {abstract} +async reset(&mut self, request: DiagnosticRequest) -> Result<ExecuteOperationReply> | ||
| {abstract} +async stop(&mut self, request: DiagnosticRequest) -> Result<()> | ||
|
|
There was a problem hiding this comment.
The API must follow synchronous non-blocking pattern here. The implementation should avoid the overhead of a multi-threaded async runtime while also ensuring functions do not halt execution while waiting. Instead, operations should return immediately with a status (WouldBlock or InProgress), requiring the caller to poll for completion.
| -start_handler: Box<dyn Fn(T) -> DiagResult<T> + Send + Sync> | ||
| -stop_handler: Box<dyn Fn(T) -> DiagResult<T> + Send + Sync> | ||
| -results_handler: Box<dyn Fn(T) -> DiagResult<T> + Send + Sync> |
There was a problem hiding this comment.
The current design has three separate Box for handlers, which is inefficient. Instead, creating a single trait that the user implements, requiring only one Box.
Related
Notes for Reviewers