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
 14use std::{sync::Arc, time::Duration};
 15
 16use cda_interfaces::{
 17    HashMap,
 18    communication_control::{
 19        ActivationCause, CommunicationAccess, DisableCommunication, DisableError, DisableGuard,
 20        DisableReason, PostUpdateCommunicationMode,
 21    },
 22    http_protection::registry::{
 23        HttpProtectionConfig, HttpProtectionReason, HttpProtectionRegistry, HttpStatusCode,
 24        OwnedHttpProtection,
 25    },
 26    runtime_update_api::{
 27        ExecutionMode, ExecutionStatus, LockStateProvider, RuntimeReloaderPlugin,
 28        RuntimeUpdateError, RuntimeUpdateSecurityPlugin, UpdateCollections, UpdateExecution,
 29    },
 30    storage_api::{CollectionName, Storage},
 31};
 32use tokio::sync::RwLock;
 33
 34pub(crate) struct ExecutionParams<'a, S, R: ?Sized, T, L> {
 35    pub(crate) storage: &'a Arc<S>,
 36    pub(crate) security_handler: &'a Arc<T>,
 37    pub(crate) reload_handler: &'a Arc<R>,
 38    pub(crate) executions: &'a Arc<RwLock<HashMap<String, UpdateExecution>>>,
 39    pub(crate) communication_disable: &'a Arc<dyn DisableCommunication>,
 40    pub(crate) communication_access: &'a Arc<dyn CommunicationAccess>,
 41    pub(crate) http_protections: &'a HttpProtectionRegistry,
 42    pub(crate) update_retry_after: Duration,
 43    pub(crate) post_update_mode: PostUpdateCommunicationMode,
 44    pub(crate) mdd_decompress: bool,
 45    pub(crate) lock_state_provider: &'a L,
 46}
 47
 48fn http_protection_config_for_update(retry_after: Duration) -> HttpProtectionConfig {
 49    HttpProtectionConfig::new(
 50        HttpProtectionReason::UpdateInProgress,
 51        HttpStatusCode::CONFLICT,
 52        "Update in progress",
 53    )
 54    .with_exempt_routes(cda_sovd::routes_accessible_during_update())
 55    .with_retry_after(retry_after)
 56}
 57
 58pub(crate) async fn start_execution<S, R, T, L>(
 59    params: &ExecutionParams<'_, S, R, T, L>,
 60    mode: ExecutionMode,
 61) -> Result<String, RuntimeUpdateError>
 62where
 63    S: Storage + Send + Sync + 'static,
 64    R: RuntimeReloaderPlugin + ?Sized,
 65    T: RuntimeUpdateSecurityPlugin<L, S::CollectionHandle>,
 66    L: LockStateProvider,
 67{
 68    let (protection, disable_lease) = acquire_execution_guards(params).await?;
 69    let collections = match load_update_collections(&**params.storage).await {
 70        Ok(collections) => collections,
 71        Err(error) => return Err(reject_execution(error, protection, disable_lease).await),
 72    };
 73    let (protection, disable_lease) =
 74        validate_execution_preconditions(params, mode, &collections, protection, disable_lease)
 75            .await?;
 76    let execution_id = register_execution(params.executions, mode).await;
 77
 78    spawn_execution(
 79        mode,
 80        execution_id.clone(),
 81        Arc::clone(params.storage),
 82        Arc::clone(params.reload_handler),
 83        Arc::clone(params.executions),
 84        params.mdd_decompress,
 85        params.post_update_mode.clone(),
 86        Arc::clone(params.communication_access),
 87        disable_lease,
 88        protection,
 89    );
 90
 91    Ok(execution_id)
 92}
 93
 94async fn acquire_execution_guards<S, R: ?Sized, T, L>(
 95    params: &ExecutionParams<'_, S, R, T, L>,
 96) -> Result<(OwnedHttpProtection, Box<dyn DisableGuard>), RuntimeUpdateError> {
 97    let protection = params
 98        .http_protections
 99        .protect(http_protection_config_for_update(params.update_retry_after))
100        .map_err(|error| {
101            // Built in-process, so this is a programming error. Refuse the
102            // execution rather than run it unprotected.
103            RuntimeUpdateError::FatalError(format!(
104                "Invalid HTTP protection configuration for update: {error}"
105            ))
106        })?;
107    let disable_lease = params
108        .communication_disable
109        .disable(DisableReason::RuntimeUpdate)
110        .await
111        .map_err(|error| match error {
112            DisableError::Conflict => RuntimeUpdateError::ExecutionConflict,
113            DisableError::InUse => RuntimeUpdateError::OperationsInProgress(
114                "another operation is running (i.e. flash transfer)".to_string(),
115            ),
116            DisableError::Failed(failure) => {
117                RuntimeUpdateError::CommunicationFailure(failure.to_string())
118            }
119        })?;
120
121    Ok((protection, disable_lease))
122}
123
124async fn load_update_collections<S>(
125    storage: &S,
126) -> Result<UpdateCollections<S::CollectionHandle>, RuntimeUpdateError>
127where
128    S: Storage + Send + Sync + 'static,
129{
130    Ok(UpdateCollections {
131        pending_mdd: crate::operations::try_get_collection(
132            storage,
133            &CollectionName::DiagnosticDatabaseNextUpdate,
134        )
135        .await?,
136        current_mdd: crate::operations::try_get_collection(
137            storage,
138            &CollectionName::DiagnosticDatabase,
139        )
140        .await?,
141    })
142}
143
144async fn validate_execution_preconditions<S, R, T, L>(
145    params: &ExecutionParams<'_, S, R, T, L>,
146    mode: ExecutionMode,
147    collections: &UpdateCollections<S::CollectionHandle>,
148    protection: OwnedHttpProtection,
149    disable_lease: Box<dyn DisableGuard>,
150) -> Result<(OwnedHttpProtection, Box<dyn DisableGuard>), RuntimeUpdateError>
151where
152    S: Storage + Send + Sync + 'static,
153    R: RuntimeReloaderPlugin + ?Sized,
154    T: RuntimeUpdateSecurityPlugin<L, S::CollectionHandle>,
155    L: LockStateProvider,
156{
157    if let Err(error) = params
158        .security_handler
159        .check_apply_allowed(params.lock_state_provider, collections)
160        .await
161    {
162        return Err(reject_execution(error, protection, disable_lease).await);
163    }
164
165    if mode == ExecutionMode::Rollback {
166        match crate::storage::is_backup_empty(&**params.storage).await {
167            Ok(true) => {
168                return Err(reject_execution(
169                    RuntimeUpdateError::NoBackup,
170                    protection,
171                    disable_lease,
172                )
173                .await);
174            }
175            Err(error) => return Err(reject_execution(error, protection, disable_lease).await),
176            Ok(false) => {}
177        }
178    }
179
180    // For Apply: verify there is at least one pending NextUpdate collection before
181    // accepting the request. This mirrors the Rollback check above, and must happen
182    // before spawning the task so that the 404 is returned synchronously instead of
183    // 202 being sent with a later Failed status (execute_apply's own check runs
184    // inside the spawned task and is too late to affect the HTTP response).
185    if mode == ExecutionMode::Apply && collections.pending_mdd.is_none() {
186        return Err(reject_execution(
187            RuntimeUpdateError::NoPendingUpdate,
188            protection,
189            disable_lease,
190        )
191        .await);
192    }
193
194    Ok((protection, disable_lease))
195}
196
197async fn reject_execution(
198    error: RuntimeUpdateError,
199    protection: OwnedHttpProtection,
200    disable_lease: Box<dyn DisableGuard>,
201) -> RuntimeUpdateError {
202    // Resume first, drop the update protection second, as `spawn_execution`'s
203    // completion path does. The other order leaves a window with neither the
204    // protection nor a restored transport, where requests get a handler-level
205    // communication error instead of the `409` the caller retries against.
206    if let Err(release_failure) = disable_lease.release().await {
207        tracing::error!(
208            error = %error,
209            release_failure = %release_failure,
210            "Failed to resume transport after rejecting runtime update"
211        );
212    }
213    drop(protection);
214    error
215}
216
217async fn register_execution(
218    executions: &Arc<RwLock<HashMap<String, UpdateExecution>>>,
219    mode: ExecutionMode,
220) -> String {
221    let execution_id = uuid::Uuid::new_v4().to_string();
222    let mut execs = executions.write().await;
223    execs.retain(|_, execution| execution.status == ExecutionStatus::Running);
224    execs.insert(
225        execution_id.clone(),
226        UpdateExecution {
227            id: execution_id.clone(),
228            mode,
229            status: ExecutionStatus::Running,
230        },
231    );
232    execution_id
233}
234
235#[allow(
236    clippy::too_many_arguments,
237    reason = "Spawned task inputs must be owned"
238)]
[docs]239/// [[ dimpl~post-update-deferred-communication, Post-update deferred communication handling, dimpl ]]
240fn spawn_execution<S, R>(
241    mode: ExecutionMode,
242    execution_id: String,
243    storage: Arc<S>,
244    reload_handler: Arc<R>,
245    executions: Arc<RwLock<HashMap<String, UpdateExecution>>>,
246    mdd_decompress: bool,
247    post_update_mode: PostUpdateCommunicationMode,
248    communication_access: Arc<dyn CommunicationAccess>,
249    disable_lease: Box<dyn DisableGuard>,
250    protection: OwnedHttpProtection,
251) where
252    S: Storage + Send + Sync + 'static,
253    R: RuntimeReloaderPlugin + ?Sized,
254{
255    let supervised_executions = Arc::clone(&executions);
256    let supervised_execution_id = execution_id.clone();
257
258    let handle = cda_interfaces::spawn_named!(&format!("runtime-update-{mode:?}"), async move {
259        let result = execute_operation(mode, &*storage, &*reload_handler, mdd_decompress).await;
260
261        if let Err(error) = &result {
262            tracing::error!(
263                execution_id = %execution_id,
264                mode = ?mode,
265                error = %error,
266                "Runtime update execution failed"
267            );
268        }
269
270        // Only a successful Apply/Rollback defers. A failed update or a Cleanup
271        // leaves communication as it found it.
272        let should_defer = result.is_ok()
273            && matches!(mode, ExecutionMode::Apply | ExecutionMode::Rollback)
274            && matches!(post_update_mode, PostUpdateCommunicationMode::Deferred);
275        if should_defer {
276            drop(disable_lease);
277        } else {
278            if let Err(failure) = disable_lease.release().await {
279                tracing::error!(
280                    execution_id = %execution_id,
281                    mode = ?mode,
282                    error = %failure,
283                    "Failed to resume transport after runtime update"
284                );
285            }
286            // Releasing only restores what the lease displaced, so an update
287            // started from a deferred runtime comes back deferred. Requesting
288            // the configured end state through `CommunicationAccess` leaves
289            // `init_mode` the final word, and is a no-op when the release
290            // already resumed.
291            communication_access.request_activate(ActivationCause::DisableRelease);
292        }
293        drop(protection);
294
295        // Published last. A client resumes normal traffic once it reads the
296        // terminal status, so publishing before the release above would let a
297        // caller that saw `completed` get the update's `409` on its next
298        // request.
299        let mut map = executions.write().await;
300        if let Some(execution) = map.get_mut(&execution_id) {
301            execution.status = match result {
302                Ok(()) => ExecutionStatus::Completed,
303                Err(ref error) => ExecutionStatus::Failed(error.to_string()),
304            };
305        } else {
306            tracing::error!(
307                execution_id = %execution_id,
308                mode = ?mode,
309                "Runtime update execution completed without an execution record"
310            );
311        }
312        drop(map);
313    });
314
315    // A panic in `execute_operation` unwinds past the status write and leaves
316    // the execution record `Running` forever. This supervisor awaits the
317    // `JoinHandle` and marks the execution `Failed` when the task ended without
318    // a terminal status.
319    cda_interfaces::spawn_named!(&format!("runtime-update-{mode:?}-supervisor"), async move {
320        if let Err(join_error) = handle.await {
321            tracing::error!(
322                execution_id = %supervised_execution_id,
323                mode = ?mode,
324                error = %join_error,
325                "runtime update task ended without completing normally"
326            );
327            let mut map = supervised_executions.write().await;
328            if let Some(execution) = map.get_mut(&supervised_execution_id)
329                && execution.status == ExecutionStatus::Running
330            {
331                execution.status = ExecutionStatus::Failed(format!(
332                    "execution task ended abnormally: {join_error}"
333                ));
334            }
335        }
336    });
337}
338
339pub(crate) async fn get_execution_status(
340    executions: &Arc<RwLock<HashMap<String, UpdateExecution>>>,
341    execution_id: &str,
342) -> Option<UpdateExecution> {
343    let execs = executions.read().await;
344    execs.get(execution_id).cloned()
345}
346
347async fn execute_operation<S, R>(
348    mode: ExecutionMode,
349    storage: &S,
350    reload_handler: &R,
351    mdd_decompress: bool,
352) -> Result<(), RuntimeUpdateError>
353where
354    S: Storage + Send + Sync + 'static,
355    R: RuntimeReloaderPlugin + ?Sized,
356{
357    match mode {
358        ExecutionMode::Apply => {
359            crate::operations::apply::execute_apply(storage, reload_handler, mdd_decompress).await
360        }
361        ExecutionMode::Rollback => {
362            crate::operations::rollback::execute_rollback(storage, reload_handler).await
363        }
364        ExecutionMode::Cleanup => crate::operations::cleanup::execute_cleanup(storage).await,
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use std::{sync::Arc, time::Duration};
371
372    use cda_interfaces::{
373        HashMap,
374        communication_control::{
375            CommunicationAccess, PostUpdateCommunicationMode, TransportControl, TransportState,
376            error::CommControlError,
377        },
378        http_protection::registry::{HttpProtectionRegistry, HttpRestrictionGuard},
379        runtime_update_api::{ExecutionMode, ExecutionStatus, RuntimeUpdateError, UpdateExecution},
380        storage_api::CollectionName,
381    };
382    use cda_plugin_communication_management::lifecycle::{
383        communication_disable_for_test, disable::DisableCommunication,
384        enabled_communication_access_for_test,
385    };
386    use cda_storage::LocalStorage;
387    use tokio::sync::RwLock;
388
389    use crate::test_utils::{
390        MockLockProvider, MockSecurityHandler, NoopReloadHandler, StubTransport, make_storage,
391        write_test_file,
392    };
393
394    /// A transport whose resume (`enable`) parks until the test lets it through,
395    /// making the window between "the guards are coming down" and "they are
396    /// down" long enough to observe.
397    struct GatedTransport {
398        entered: tokio::sync::mpsc::UnboundedSender<()>,
399        gate: Arc<tokio::sync::Semaphore>,
400        transport: Arc<StubTransport>,
401    }
402
403    /// Test-side handle to [`GatedTransport`]'s resume.
404    struct ResumeGate {
405        entered: tokio::sync::mpsc::UnboundedReceiver<()>,
406        gate: Arc<tokio::sync::Semaphore>,
407    }
408
409    impl GatedTransport {
410        fn new() -> (Arc<Self>, ResumeGate) {
411            let (entered_tx, entered_rx) = tokio::sync::mpsc::unbounded_channel();
412            let gate = Arc::new(tokio::sync::Semaphore::new(0));
413            let transport = Arc::new(Self {
414                entered: entered_tx,
415                gate: Arc::clone(&gate),
416                transport: StubTransport::with_state(TransportState::Enabled),
417            });
418            let resume = ResumeGate {
419                entered: entered_rx,
420                gate,
421            };
422            (transport, resume)
423        }
424    }
425
426    impl ResumeGate {
427        /// Resolves once the resume has started and is parked on the gate.
428        async fn entered(&mut self) {
429            self.entered
430                .recv()
431                .await
432                .expect("transport resume must be reached");
433        }
434
435        /// Lets the parked resume run to completion.
436        fn release(&self) {
437            self.gate.add_permits(1);
438        }
439    }
440
441    #[async_trait::async_trait]
442    impl TransportControl for GatedTransport {
443        async fn enable(&self) -> Result<(), CommControlError> {
444            let _ = self.entered.send(());
445            let _permit = self
446                .gate
447                .acquire()
448                .await
449                .expect("gate must not be closed while a resume is parked on it");
450            self.transport.enable().await
451        }
452
453        async fn disable(&self) -> Result<(), CommControlError> {
454            self.transport.disable().await
455        }
456
457        async fn state(&self) -> TransportState {
458            self.transport.state().await
459        }
460    }
461
462    struct TestFixture {
463        storage: Arc<LocalStorage>,
464        security_handler: Arc<MockSecurityHandler>,
465        reload_handler: Arc<NoopReloadHandler>,
466        lock_provider: MockLockProvider,
467        executions: Arc<RwLock<HashMap<String, UpdateExecution>>>,
468        communication_disable: Arc<dyn DisableCommunication>,
469        communication_access: Arc<dyn CommunicationAccess>,
470        transport: Arc<StubTransport>,
471        http_restriction_manager: HttpProtectionRegistry,
472        _dir: tempfile::TempDir,
473    }
474
475    impl TestFixture {
476        fn params(
477            &self,
478        ) -> super::ExecutionParams<
479            '_,
480            LocalStorage,
481            NoopReloadHandler,
482            MockSecurityHandler,
483            MockLockProvider,
484        > {
485            super::ExecutionParams {
486                communication_access: &self.communication_access,
487                storage: &self.storage,
488                security_handler: &self.security_handler,
489                reload_handler: &self.reload_handler,
490                executions: &self.executions,
491                communication_disable: &self.communication_disable,
492                http_protections: &self.http_restriction_manager,
493                update_retry_after: Duration::from_secs(1),
494                post_update_mode: PostUpdateCommunicationMode::Enabled,
495                mdd_decompress: false,
496                lock_state_provider: &self.lock_provider,
497            }
498        }
499
500        fn params_with_post_update_mode(
501            &self,
502            post_update_mode: PostUpdateCommunicationMode,
503        ) -> super::ExecutionParams<
504            '_,
505            LocalStorage,
506            NoopReloadHandler,
507            MockSecurityHandler,
508            MockLockProvider,
509        > {
510            super::ExecutionParams {
511                post_update_mode,
512                ..self.params()
513            }
514        }
515    }
516
517    fn make_fixture() -> TestFixture {
518        let (storage, dir) = make_storage();
519        let transport = StubTransport::new();
520        let mgr = HttpProtectionRegistry::new();
521        let communication_disable =
522            communication_disable_for_test(Arc::<StubTransport>::clone(&transport), true);
523        TestFixture {
524            storage: Arc::new(storage),
525            security_handler: Arc::new(MockSecurityHandler::new()),
526            reload_handler: Arc::new(NoopReloadHandler),
527            lock_provider: MockLockProvider {
528                owner: Some("test-user".to_owned()),
529                has_conflicts: false,
530            },
531            executions: Arc::new(RwLock::new(HashMap::default())),
532            communication_disable,
533            communication_access: enabled_communication_access_for_test(),
534            transport,
535            http_restriction_manager: mgr,
536            _dir: dir,
537        }
538    }
539
540    async fn poll_until_terminal(
541        executions: &Arc<RwLock<HashMap<String, UpdateExecution>>>,
542        exec_id: &str,
543    ) -> ExecutionStatus {
544        let deadline = tokio::time::Instant::now()
545            .checked_add(tokio::time::Duration::from_secs(5))
546            .unwrap();
547        loop {
548            tokio::task::yield_now().await;
549            if let Some(exec) = super::get_execution_status(executions, exec_id).await
550                && exec.status != ExecutionStatus::Running
551            {
552                return exec.status;
553            }
554            assert!(
555                tokio::time::Instant::now() < deadline,
556                "Execution did not complete within 5 seconds"
557            );
558        }
559    }
560
561    #[tokio::test]
562    async fn start_execution_apply_returns_execution_id() {
563        let f = make_fixture();
564        write_test_file(
565            &f.storage,
566            &CollectionName::DiagnosticDatabaseNextUpdate,
567            "ecu.mdd",
568            b"mdd_data",
569        )
570        .await;
571
572        let exec_id = super::start_execution(&f.params(), ExecutionMode::Apply)
573            .await
574            .unwrap();
575        assert!(!exec_id.is_empty());
576
577        let status = super::get_execution_status(&f.executions, &exec_id).await;
578        assert!(status.is_some());
579    }
580
[docs]581    /// [[ test~deferred-post-update-apply, Successful apply leaves communication disabled in deferred post-update mode, test ]]
582    #[tokio::test]
583    async fn successful_apply_leaves_communication_disabled_in_deferred_mode() {
584        let f = make_fixture();
585        write_test_file(
586            &f.storage,
587            &CollectionName::DiagnosticDatabaseNextUpdate,
588            "ecu.mdd",
589            b"mdd_data",
590        )
591        .await;
592
593        let params = f.params_with_post_update_mode(PostUpdateCommunicationMode::Deferred);
594        let exec_id = super::start_execution(&params, ExecutionMode::Apply)
595            .await
596            .unwrap();
597        assert_eq!(
598            poll_until_terminal(&f.executions, &exec_id).await,
599            ExecutionStatus::Completed
600        );
601        assert_eq!(
602            TransportControl::state(&*f.transport).await,
603            TransportState::Disabled
604        );
605        assert!(!f.http_restriction_manager.is_active());
606    }
607
[docs]608    /// [[ test~deferred-post-update-rollback, Successful rollback leaves communication disabled in deferred post-update mode, test ]]
609    #[tokio::test]
610    async fn successful_rollback_leaves_communication_disabled_in_deferred_mode() {
611        let f = make_fixture();
612        write_test_file(
613            &f.storage,
614            &CollectionName::DiagnosticDatabaseBackup,
615            "ecu.mdd",
616            b"backup_data",
617        )
618        .await;
619
620        let params = f.params_with_post_update_mode(PostUpdateCommunicationMode::Deferred);
621        let exec_id = super::start_execution(&params, ExecutionMode::Rollback)
622            .await
623            .unwrap();
624        assert_eq!(
625            poll_until_terminal(&f.executions, &exec_id).await,
626            ExecutionStatus::Completed
627        );
628        assert_eq!(
629            TransportControl::state(&*f.transport).await,
630            TransportState::Disabled
631        );
632        assert!(!f.http_restriction_manager.is_active());
633    }
634
635    #[tokio::test]
636    async fn start_execution_rollback() {
637        let f = make_fixture();
638        write_test_file(
639            &f.storage,
640            &CollectionName::DiagnosticDatabaseBackup,
641            "ecu.mdd",
642            b"backup_data",
643        )
644        .await;
645
646        let exec_id = super::start_execution(&f.params(), ExecutionMode::Rollback)
647            .await
648            .unwrap();
649        assert!(!exec_id.is_empty());
650    }
651
652    #[tokio::test]
653    async fn start_execution_apply_with_no_pending_update_rejected_synchronously() {
654        let f = make_fixture();
655
656        // Nothing seeded into DiagnosticDatabaseNextUpdate:
657        // Apply must be rejected synchronously (i.e. `start_execution` itself returns
658        // an error) rather than accepted (202-equivalent execution id) only to fail
659        // later inside the spawned task.
660        let result = super::start_execution(&f.params(), ExecutionMode::Apply).await;
661
662        assert!(
663            matches!(result, Err(RuntimeUpdateError::NoPendingUpdate)),
664            "expected NoPendingUpdate, got: {result:?}"
665        );
666        assert!(
667            f.executions.read().await.is_empty(),
668            "no execution should have been recorded for a synchronously-rejected apply"
669        );
670        // Transport should be re-enabled (restriction removed) after the error path.
671        assert!(
672            !f.http_restriction_manager.is_active(),
673            "update restriction must be released after a synchronously-rejected apply"
674        );
675    }
676
677    #[tokio::test]
678    async fn start_execution_cleanup_succeeds() {
679        let f = make_fixture();
680        let exec_id = super::start_execution(&f.params(), ExecutionMode::Cleanup)
681            .await
682            .unwrap();
683        assert!(!exec_id.is_empty());
684
685        let status = poll_until_terminal(&f.executions, &exec_id).await;
686        assert_eq!(status, ExecutionStatus::Completed);
687    }
688
689    #[tokio::test]
690    async fn terminal_status_is_only_published_after_the_update_guards_are_released() {
691        // A client resumes ordinary requests as soon as it reads a terminal
692        // status, so that status must not appear while the update's `409`
693        // protection is still up. `GatedTransport` holds the resume open to make
694        // that window observable. With an instant transport the tail of the
695        // execution task runs in a single poll.
696        let (transport, mut resume) = GatedTransport::new();
697        let mut f = make_fixture();
698        f.communication_disable = communication_disable_for_test(transport, true);
699
700        let exec_id = super::start_execution(&f.params(), ExecutionMode::Cleanup)
701            .await
702            .unwrap();
703
704        resume.entered().await;
705        assert_eq!(
706            super::get_execution_status(&f.executions, &exec_id)
707                .await
708                .map(|execution| execution.status),
709            Some(ExecutionStatus::Running),
710            "the execution must still read as running while its guards are being released"
711        );
712        assert!(
713            f.http_restriction_manager.is_active(),
714            "sanity check: the update restriction is still up at this point"
715        );
716
717        resume.release();
718        let status = poll_until_terminal(&f.executions, &exec_id).await;
719        assert_eq!(status, ExecutionStatus::Completed);
720        assert!(
721            !f.http_restriction_manager.is_active(),
722            "update restriction must already be released once the execution reports a terminal \
723             status"
724        );
725    }
726
727    #[tokio::test]
728    async fn get_execution_status_unknown_id_returns_none() {
729        let f = make_fixture();
730        let result = super::get_execution_status(&f.executions, "nonexistent-id").await;
731        assert!(result.is_none());
732    }
733
734    #[tokio::test]
735    async fn start_execution_allowed_when_previous_completed() {
736        let f = make_fixture();
737        {
738            let mut execs = f.executions.write().await;
739            execs.insert(
740                "prev".to_string(),
741                UpdateExecution {
742                    id: "prev".to_string(),
743                    mode: ExecutionMode::Cleanup,
744                    status: ExecutionStatus::Completed,
745                },
746            );
747        }
748
749        let exec_id = super::start_execution(&f.params(), ExecutionMode::Cleanup)
750            .await
751            .unwrap();
752        assert!(!exec_id.is_empty());
753    }
754
755    #[tokio::test]
756    async fn previous_execution_removed_when_new_one_starts() {
757        let f = make_fixture();
758        let first_id = super::start_execution(&f.params(), ExecutionMode::Cleanup)
759            .await
760            .unwrap();
761
762        poll_until_terminal(&f.executions, &first_id).await;
763
764        let _second_id = super::start_execution(&f.params(), ExecutionMode::Cleanup)
765            .await
766            .unwrap();
767
768        let status = super::get_execution_status(&f.executions, &first_id).await;
769        assert!(
770            status.is_none(),
771            "expected first execution to be removed when second execution started"
772        );
773    }
774
775    #[tokio::test]
776    async fn execution_transitions_to_failed_on_error() {
777        let f = make_fixture();
778        write_test_file(
779            &f.storage,
780            &CollectionName::DiagnosticDatabaseNextUpdate,
781            "ecu.mdd",
782            b"mdd_data",
783        )
784        .await;
785        let failing_reload_handler = Arc::new(crate::test_utils::FailingReloadHandler);
786        let communication_access = enabled_communication_access_for_test();
787        let params = super::ExecutionParams {
788            communication_access: &communication_access,
789            storage: &f.storage,
790            security_handler: &f.security_handler,
791            reload_handler: &failing_reload_handler,
792            executions: &f.executions,
793            communication_disable: &f.communication_disable,
794            http_protections: &f.http_restriction_manager,
795            update_retry_after: Duration::from_secs(1),
796            post_update_mode: PostUpdateCommunicationMode::Enabled,
797            mdd_decompress: false,
798            lock_state_provider: &f.lock_provider,
799        };
800
801        let exec_id = super::start_execution(&params, ExecutionMode::Apply)
802            .await
803            .unwrap();
804
805        let status = poll_until_terminal(&f.executions, &exec_id).await;
806        assert!(matches!(status, ExecutionStatus::Failed(_)));
807        // After failure the spawned task re-enables, removing the restriction.
808        assert!(!f.http_restriction_manager.is_active());
809    }
810
811    #[tokio::test]
812    async fn execution_transitions_to_failed_when_task_panics() {
813        let f = make_fixture();
814        write_test_file(
815            &f.storage,
816            &CollectionName::DiagnosticDatabaseNextUpdate,
817            "ecu.mdd",
818            b"mdd_data",
819        )
820        .await;
821        let panicking_reload_handler = Arc::new(crate::test_utils::PanickingReloadHandler);
822        let communication_access = enabled_communication_access_for_test();
823        let params = super::ExecutionParams {
824            communication_access: &communication_access,
825            storage: &f.storage,
826            security_handler: &f.security_handler,
827            reload_handler: &panicking_reload_handler,
828            executions: &f.executions,
829            communication_disable: &f.communication_disable,
830            http_protections: &f.http_restriction_manager,
831            update_retry_after: Duration::from_secs(1),
832            post_update_mode: PostUpdateCommunicationMode::Enabled,
833            mdd_decompress: false,
834            lock_state_provider: &f.lock_provider,
835        };
836
837        let exec_id = super::start_execution(&params, ExecutionMode::Apply)
838            .await
839            .unwrap();
840
841        // Without the supervisor task the execution task's panic would leave no
842        // terminal status, and this would hang until poll_until_terminal's
843        // deadline.
844        let status = poll_until_terminal(&f.executions, &exec_id).await;
845        assert!(
846            matches!(status, ExecutionStatus::Failed(_)),
847            "expected Failed after the execution task panicked, got: {status:?}"
848        );
849    }
850}