1/*
2 * SPDX-FileCopyrightText: 2026 Copyright (c) Contributors to the Eclipse Foundation
3 *
4 * See the NOTICE file(s) distributed with this work for additional
5 * information regarding copyright ownership.
6 *
7 * This program and the accompanying materials are made available under the
8 * terms of the Apache License Version 2.0 which is available at
9 * https://www.apache.org/licenses/LICENSE-2.0
10 *
11 * SPDX-License-Identifier: Apache-2.0
12 */
13
14//! Default communication plugin: applies `init_mode` policy.
15//!
16//! [`DefaultCommunicationPlugin`] is the only place `init_mode` policy is
17//! applied. The framework itself never inspects `init_mode`, except for the
18//! `Always`-mode startup call.
19//!
20//! Required mode semantics:
21//!
22//! | Mode | `activate()` | first ECU request | `trigger_detection()` |
23//! |-------------|--------------------------------|---------------------------------|-------------------------|
24//! | `Always` | joins or repeats current state | requests use current readiness | detect while enabled |
25//! | `OnDemand` | initializes the whole vehicle | returns pending, triggers once | detect while enabled |
26//! | `Disabled` | rejected, no network activity | returns pending, never triggers | detect while enabled |
27//!
28//! Only the first two columns vary by mode. `trigger_detection()` never
29//! consults `init_mode`, so under `Disabled` communication has to be enabled out
30//! of band before anything can detect.
31//!
32//! Other plugins can be substituted via `Setup::with_communication_plugin`.
33
34use std::sync::Arc;
35
36use async_trait::async_trait;
37use cda_interfaces::communication_control::{
38 ActivationCause, CommunicationError, CommunicationGuard, CommunicationInitMode,
39 CommunicationLifecycle, CommunicationOperation, CommunicationOperationFailure,
40 CommunicationState, CommunicationVariantDetection, DetectionCause, VariantDetectionMode,
41};
42
43use super::{CommunicationPlugin, CommunicationPluginBuilder};
44use crate::lifecycle::{
45 controller::CommunicationHandle,
46 disable::{DisableError, DisableLease, DisableReason},
47};
48
49/// Default communication plugin: applies `init_mode` policy on top of the
50/// framework-serialized lifecycle handle.
[docs] 51/// [[ dimpl~default-communication-policy, Default deferred communication policy, dimpl ]]
52pub struct DefaultCommunicationPlugin {
53 handle: CommunicationHandle,
54 mode: CommunicationInitMode,
55}
56
57impl DefaultCommunicationPlugin {
58 /// Creates the default plugin for the given `init_mode`. It delegates all
59 /// lifecycle operations to the framework handle and worker.
60 #[must_use]
61 pub fn new(handle: CommunicationHandle, mode: CommunicationInitMode) -> Self {
62 Self { handle, mode }
63 }
64}
65
66#[async_trait]
67impl CommunicationPlugin for DefaultCommunicationPlugin {
68 fn state(&self) -> CommunicationState {
69 self.handle.state()
70 }
71
72 fn acquire(&self) -> Result<CommunicationGuard, CommunicationError> {
73 self.handle.acquire()
74 }
75
76 async fn activate(
77 &self,
78 cause: ActivationCause,
79 ) -> Result<CommunicationState, CommunicationOperationFailure> {
80 match self.mode {
81 CommunicationInitMode::Always | CommunicationInitMode::OnDemand => {
82 tracing::debug!(?cause, mode = ?self.mode, "Activating communication");
83 self.handle.enable_and_detect().await
84 }
85 // `Disabled` authorizes no bring-up through this plugin.
86 CommunicationInitMode::Disabled => {
87 tracing::debug!(?cause, "activate rejected: init_mode is Disabled");
88 Err(CommunicationOperationFailure::ModeDisabled {
89 operation: CommunicationOperation::EnableAndDetect,
90 })
91 }
92 }
93 }
94
95 fn request_activate(&self, cause: ActivationCause) -> CommunicationState {
96 match self.mode {
97 CommunicationInitMode::Always | CommunicationInitMode::OnDemand => {
98 tracing::debug!(?cause, mode = ?self.mode, "Requesting activation (non-blocking)");
99 self.handle.request_enable_and_detect()
100 }
101 // See the `Disabled` arm of `activate` above.
102 CommunicationInitMode::Disabled => {
103 tracing::debug!(?cause, "request_activate rejected: init_mode is Disabled");
104 self.handle.state()
105 }
106 }
107 }
108
109 async fn trigger_detection(
110 &self,
111 cause: DetectionCause,
112 ) -> Result<CommunicationState, CommunicationOperationFailure> {
113 // TODO: topology rediscovery (#490).
114
115 tracing::debug!(?cause, mode = ?self.mode, "Triggering whole-vehicle detection");
116
117 self.handle.redetect().await
118 }
119
120 async fn disable(&self, reason: DisableReason) -> Result<DisableLease, DisableError> {
121 self.handle.disable(reason).await
122 }
123
124 async fn register_lifecycle_hook(
125 &self,
126 initializer: Arc<dyn CommunicationLifecycle>,
127 ) -> Result<(), CommunicationOperationFailure> {
128 self.handle.register_lifecycle_hook(initializer).await
129 }
130
131 async fn register_variant_detection(
132 &self,
133 detector: Arc<dyn CommunicationVariantDetection>,
134 ) -> Result<(), CommunicationOperationFailure> {
135 self.handle.register_variant_detection(detector).await
136 }
137
138 fn variant_detection(&self) -> VariantDetectionMode {
139 self.handle.variant_detection()
140 }
141}
142
143#[async_trait]
144impl cda_interfaces::Shutdown for DefaultCommunicationPlugin {
145 async fn shutdown(&self) {
146 self.handle.shutdown().await;
147 }
148}
149
150/// Factory for the default communication plugin.
151#[derive(Default)]
152pub struct DefaultCommunicationPluginBuilder;
153
154impl CommunicationPluginBuilder for DefaultCommunicationPluginBuilder {
155 type Plugin = DefaultCommunicationPlugin;
156 type Error = std::convert::Infallible;
157
158 fn build(
159 self,
160 handle: CommunicationHandle,
161 mode: CommunicationInitMode,
162 ) -> impl Future<Output = Result<Self::Plugin, Self::Error>> + Send {
163 std::future::ready(Ok(DefaultCommunicationPlugin::new(handle, mode)))
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
170
171 use async_trait::async_trait;
172 use cda_interfaces::communication_control::{
173 CommControlError, TransportControl, TransportState,
174 };
175
176 use super::*;
177 use crate::lifecycle::controller::test_utils::communication_handle_new;
178
179 struct RecordingControl {
180 enable_count: AtomicUsize,
181 disable_count: AtomicUsize,
182 fail_enable: AtomicBool,
183 }
184
185 #[async_trait]
186 impl TransportControl for RecordingControl {
187 async fn enable(&self) -> Result<(), CommControlError> {
188 if self.fail_enable.load(Ordering::Relaxed) {
189 return Err(CommControlError::InitFailed("simulated failure".to_owned()));
190 }
191 self.enable_count.fetch_add(1, Ordering::Relaxed);
192 Ok(())
193 }
194 async fn disable(&self) -> Result<(), CommControlError> {
195 self.disable_count.fetch_add(1, Ordering::Relaxed);
196 Ok(())
197 }
198 async fn state(&self) -> TransportState {
199 TransportState::Disabled
200 }
201 }
202
203 /// Returns the plugin, its transport, and a second handle to the same
204 /// runtime.
205 ///
206 /// The extra handle stands in for whatever brings communication up out of
207 /// band, which under `init_mode = Disabled` is the only way to reach an
208 /// enabled transport.
209 fn plugin_with_mode(
210 mode: CommunicationInitMode,
211 ) -> (
212 DefaultCommunicationPlugin,
213 Arc<RecordingControl>,
214 CommunicationHandle,
215 ) {
216 let control = Arc::new(RecordingControl {
217 enable_count: AtomicUsize::new(0),
218 disable_count: AtomicUsize::new(0),
219 fail_enable: AtomicBool::new(false),
220 });
221 let handle = communication_handle_new(Arc::clone(&control) as Arc<dyn TransportControl>);
222 (
223 DefaultCommunicationPlugin::new(handle.clone(), mode),
224 control,
225 handle,
226 )
227 }
228
229 /// Both admitting modes bring the transport up for the cause they admit.
230 /// `Disabled` refuses, covered separately below.
[docs]231 /// [[ test~deferred-admitting-modes-activate, Eager and on-demand modes admit communication activation, test ]]
232 #[tokio::test]
233 async fn admitting_modes_activate_the_transport() {
234 for (mode, cause) in [
235 (CommunicationInitMode::Always, ActivationCause::Startup),
236 (CommunicationInitMode::OnDemand, ActivationCause::Explicit),
237 ] {
238 let (plugin, control, _handle) = plugin_with_mode(mode);
239 assert_eq!(
240 plugin.activate(cause).await,
241 Ok(CommunicationState::Enabled),
242 "{mode:?}"
243 );
244 assert_eq!(control.enable_count.load(Ordering::Relaxed), 1, "{mode:?}");
245 }
246 }
247
248 /// Ordinary `activate()` in `Disabled` mode is rejected without any network
249 /// activity. The transport must never see an `enable()` call.
[docs]250 /// [[ test~deferred-disabled-rejects-activation, Disabled mode rejects communication activation, test ]]
251 #[tokio::test]
252 async fn disabled_mode_activate_rejected_without_network_activity() {
253 let (plugin, control, _handle) = plugin_with_mode(CommunicationInitMode::Disabled);
254 assert_eq!(
255 plugin.activate(ActivationCause::Explicit).await,
256 Err(CommunicationOperationFailure::ModeDisabled {
257 operation: CommunicationOperation::EnableAndDetect
258 })
259 );
260 assert_eq!(
261 control.enable_count.load(Ordering::Relaxed),
262 0,
263 "Disabled mode must not touch the transport on an ordinary activate()"
264 );
265 assert_eq!(plugin.state(), CommunicationState::Disabled);
266 }
267
[docs]268 /// [[ test~deferred-request-activation-policy, Nonblocking activation follows the configured initialization mode, test ]]
269 #[tokio::test]
270 async fn request_activate_follows_init_mode() {
271 for mode in [
272 CommunicationInitMode::Always,
273 CommunicationInitMode::OnDemand,
274 ] {
275 let (plugin, control, handle) = plugin_with_mode(mode);
276 assert!(matches!(
277 plugin.request_activate(ActivationCause::DiagnosticRequest),
278 CommunicationState::Enabling(CommunicationOperation::EnableAndDetect)
279 | CommunicationState::Enabled
280 ));
281 tokio::time::timeout(std::time::Duration::from_secs(5), async {
282 while handle.state() != CommunicationState::Enabled {
283 tokio::task::yield_now().await;
284 }
285 })
286 .await
287 .expect("activation did not complete");
288 assert_eq!(control.enable_count.load(Ordering::Relaxed), 1, "{mode:?}");
289 }
290
291 let (plugin, control, _handle) = plugin_with_mode(CommunicationInitMode::Disabled);
292 assert_eq!(
293 plugin.request_activate(ActivationCause::DiagnosticRequest),
294 CommunicationState::Disabled
295 );
296 tokio::task::yield_now().await;
297 assert_eq!(control.enable_count.load(Ordering::Relaxed), 0);
298 }
299
300 /// `trigger_detection()` never brings a transport up, so `Disabled` mode is
301 /// inert on every path this plugin exposes: widening a detection request
302 /// into an activation would produce exactly the unauthorized network traffic
303 /// the mode exists to prevent.
304 #[tokio::test]
305 async fn disabled_mode_is_inert_on_every_path_including_detection() {
306 let (plugin, control, handle) = plugin_with_mode(CommunicationInitMode::Disabled);
307
308 // Ordinary activation is inert.
309 assert!(plugin.activate(ActivationCause::Explicit).await.is_err());
310 assert_eq!(control.enable_count.load(Ordering::Relaxed), 0);
311 assert_eq!(plugin.state(), CommunicationState::Disabled);
312
313 // So is detection: refused without touching the transport.
314 assert_eq!(
315 plugin.trigger_detection(DetectionCause::Explicit).await,
316 Err(CommunicationOperationFailure::TransitionFailure {
317 operation: CommunicationOperation::Detect
318 })
319 );
320 assert_eq!(
321 control.enable_count.load(Ordering::Relaxed),
322 0,
323 "a detection request must never enable the transport"
324 );
325 assert_eq!(plugin.state(), CommunicationState::Disabled);
326
327 // Once something else enables communication, detection is served.
328 assert_eq!(handle.enable().await, Ok(CommunicationState::Enabled));
329 assert_eq!(
330 plugin.trigger_detection(DetectionCause::Explicit).await,
331 Ok(CommunicationState::Enabled)
332 );
333 assert_eq!(control.enable_count.load(Ordering::Relaxed), 1);
334 }
335
336 /// From `Enabled`, `trigger_detection` detects without touching the
337 /// transport, in every `init_mode`.
338 #[tokio::test]
339 async fn trigger_detection_redetects_while_enabled_in_every_init_mode() {
340 for mode in [
341 CommunicationInitMode::Always,
342 CommunicationInitMode::OnDemand,
343 CommunicationInitMode::Disabled,
344 ] {
345 let (plugin, control, handle) = plugin_with_mode(mode);
346
347 // Out of band, since `Disabled` authorizes no bring-up here.
348 assert_eq!(
349 handle.enable_and_detect().await,
350 Ok(CommunicationState::Enabled)
351 );
352 assert_eq!(control.enable_count.load(Ordering::Relaxed), 1, "{mode:?}");
353
354 assert_eq!(
355 plugin.trigger_detection(DetectionCause::Explicit).await,
356 Ok(CommunicationState::Enabled),
357 "{mode:?}: detection must be allowed while already enabled"
358 );
359 assert_eq!(
360 control.enable_count.load(Ordering::Relaxed),
361 1,
362 "{mode:?}: re-detection must not re-enable the transport"
363 );
364 assert_eq!(control.disable_count.load(Ordering::Relaxed), 0, "{mode:?}");
365 }
366 }
367
368 /// `Error(_)` is a downed transport too, so detection is refused there as
369 /// well. Recovery is an activation, which only `activate()` performs.
370 #[tokio::test]
371 async fn trigger_detection_refused_from_error_in_every_init_mode() {
372 for mode in [
373 CommunicationInitMode::Always,
374 CommunicationInitMode::OnDemand,
375 CommunicationInitMode::Disabled,
376 ] {
377 let (plugin, control, handle) = plugin_with_mode(mode);
378 control.fail_enable.store(true, Ordering::Relaxed);
379 assert!(handle.enable_and_detect().await.is_err(), "{mode:?}");
380 assert!(matches!(plugin.state(), CommunicationState::Error(_)));
381
382 control.fail_enable.store(false, Ordering::Relaxed);
383 assert_eq!(
384 plugin.trigger_detection(DetectionCause::Explicit).await,
385 Err(CommunicationOperationFailure::TransitionFailure {
386 operation: CommunicationOperation::Detect
387 }),
388 "{mode:?}: detection must not recover a broken transport"
389 );
390 assert_eq!(
391 control.enable_count.load(Ordering::Relaxed),
392 0,
393 "{mode:?}: no successful enable may have happened"
394 );
395 }
396 }
397
398 /// An exclusive disable lease outranks detection. The request is refused
399 /// rather than re-enabling a transport somebody else took down.
400 #[tokio::test]
401 async fn trigger_detection_rejected_while_disable_lease_held() {
402 let (plugin, control, _handle) = plugin_with_mode(CommunicationInitMode::OnDemand);
403 assert_eq!(
404 plugin.activate(ActivationCause::Explicit).await,
405 Ok(CommunicationState::Enabled)
406 );
407 let lease = plugin.disable(DisableReason::RuntimeUpdate).await.unwrap();
408
409 assert_eq!(
410 plugin.trigger_detection(DetectionCause::Explicit).await,
411 Err(CommunicationOperationFailure::DisableLeaseHeld {
412 operation: CommunicationOperation::Detect
413 })
414 );
415 assert_eq!(control.enable_count.load(Ordering::Relaxed), 1);
416
417 assert_eq!(lease.release().await, Ok(CommunicationState::Enabled));
418 }
419
420 /// Repeated `trigger_detection()` calls join the in-flight variant detection
421 /// rather than starting a second one.
422 #[tokio::test]
423 async fn repeated_trigger_detection_coalesces() {
424 let (plugin, control, handle) = plugin_with_mode(CommunicationInitMode::Disabled);
425 assert_eq!(
426 handle.enable_and_detect().await,
427 Ok(CommunicationState::Enabled)
428 );
429
430 let plugin = Arc::new(plugin);
431 let a = {
432 let plugin = Arc::clone(&plugin);
433 tokio::spawn(async move { plugin.trigger_detection(DetectionCause::Explicit).await })
434 };
435 let b = {
436 let plugin = Arc::clone(&plugin);
437 tokio::spawn(async move { plugin.trigger_detection(DetectionCause::Explicit).await })
438 };
439 assert_eq!(a.await.unwrap(), Ok(CommunicationState::Enabled));
440 assert_eq!(b.await.unwrap(), Ok(CommunicationState::Enabled));
441 assert_eq!(control.enable_count.load(Ordering::Relaxed), 1);
442 }
443}