//! Typed commands and a single-owner worker for application operations. use std::collections::VecDeque; use std::ffi::OsString; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TrySendError}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; use crate::app::Application; use crate::app::{ FirstIssueAttempt, FirstIssueDeliveryOutcome, FirstIssueRetry, RestartChoice, RestartReport, RestorePreview, }; use crate::backup::{ArchiveSummary, BackupReason, HistoricalApplyStatus}; use crate::config::SettingsFile; use crate::delivery::files::{FileOutputError, PreparedDestination}; use crate::delivery::{QrCodeOutput, QrError}; use crate::domain::{ClientAddresses, PublicKey}; use crate::runtime::RuntimeObservation; use crate::vault::source::SelectedFileImportReport; use crate::vault::source::SourceImportReport; use super::state::{OperationKind, PendingOperation, SessionView}; pub const EVENT_QUEUE_CAPACITY: usize = 16; pub const COMMAND_QUEUE_CAPACITY: usize = 4; pub const DEFAULT_REFRESH_INTERVAL: Duration = Duration::from_secs(2); pub const DEFAULT_READ_TIMEOUT: Duration = Duration::from_millis(500); #[derive(Clone, Debug, Eq, PartialEq)] pub enum UiCommand { CreateClient { alias: String, manual_addresses: Option, created_utc: Option, }, RenameClient { identity: PublicKey, alias: String, }, DeleteClient { identity: PublicKey, }, DiscardDraft, Commit, CreateBackup, ListBackups, ImportSelectedFile { file_name: OsString, }, PreviewRestart, Restart(RestartChoice), PrepareDelivery { identity: PublicKey, path: PathBuf, include_qr: bool, reissue: bool, attempt: FirstIssueAttempt, }, DeliverFirstIssue { attempt: FirstIssueAttempt, overwrite_confirmed: bool, prepared_generation: u64, }, PreviewRestore { file_name: String, }, CancelPreparedDelivery, CancelRestorePreview, ConfirmRestore { preview_generation: u64, }, } /// Secret-bearing text is carried only by a non-Debug, non-Clone owner and /// zeroized when the worker finishes or drops the queued request. pub struct SecretText(zeroize::Zeroizing); impl SecretText { pub fn new(value: String) -> Self { Self(zeroize::Zeroizing::new(value)) } pub fn expose(&self) -> &str { self.0.as_str() } } pub enum SensitiveCommand { PrepareInitialSetup(SecretText), ReissueClient { identity: PublicKey, password: SecretText, overwrite_confirmed: bool, prepared_generation: u64, }, } impl UiCommand { pub const fn kind(&self) -> OperationKind { match self { Self::CreateClient { .. } => OperationKind::CreateClient, Self::RenameClient { .. } => OperationKind::RenameClient, Self::DeleteClient { .. } => OperationKind::DeleteClient, Self::DiscardDraft => OperationKind::DiscardDraft, Self::Commit => OperationKind::Commit, Self::CreateBackup => OperationKind::CreateBackup, Self::ListBackups => OperationKind::ListBackups, Self::ImportSelectedFile { .. } => OperationKind::ImportSelectedFile, Self::PreviewRestart => OperationKind::PreviewRestart, Self::Restart(_) => OperationKind::Restart, Self::PrepareDelivery { .. } | Self::DeliverFirstIssue { .. } => { OperationKind::DeliverClient } Self::PreviewRestore { .. } | Self::CancelRestorePreview | Self::ConfirmRestore { .. } => OperationKind::Restore, Self::CancelPreparedDelivery => OperationKind::DeliverClient, } } const fn changes_view(&self) -> bool { matches!( self, Self::CreateClient { .. } | Self::RenameClient { .. } | Self::DeleteClient { .. } | Self::DiscardDraft | Self::Commit | Self::ImportSelectedFile { .. } | Self::Restart(_) | Self::ConfirmRestore { .. } ) } } impl SensitiveCommand { const fn kind(&self) -> OperationKind { match self { Self::PrepareInitialSetup(_) => OperationKind::InitialSetup, Self::ReissueClient { .. } => OperationKind::DeliverClient, } } const fn changes_view(&self) -> bool { matches!(self, Self::PrepareInitialSetup(_)) } } #[derive(Debug)] pub enum CommandResult { ClientCreated(PublicKey), ClientRenamed, ClientDeleted { record_removed: bool }, DraftDiscarded, Committed(PathBuf), BackupCreated(PathBuf), Backups(Vec), SelectedFileImported(SelectedFileImportReport), InitialSetupPrepared(SourceImportReport), RestartPreview(RestartPreviewSummary), Restarted(RestartReport), DeliveryDestination(DeliveryDestinationSummary), FirstIssue(FirstIssueSummary), Reissued(ReissueSummary), RestorePreview(RestorePreviewSummary), Restored(PathBuf), Cancelled, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct RestartPreviewSummary { pub has_draft: bool, pub interface: String, pub systemd_unit: String, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct DeliveryDestinationSummary { pub identity: PublicKey, pub path: PathBuf, pub overwrite_required: bool, pub include_qr: bool, pub reissue: bool, pub attempt: FirstIssueAttempt, pub prepared_generation: u64, } #[derive(Debug)] pub struct FirstIssueSummary { pub identity: PublicKey, pub status: FirstIssueStatus, pub retry: Option, pub activation_warning: bool, pub qr: QrStatus, pub qr_output: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum FirstIssueStatus { Delivered, PublishedButBookkeepingUncertain, Failed(FileOutputError), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum QrStatus { NotRequested, Ready, Failed(QrError), } #[derive(Debug)] pub struct ReissueSummary { pub activation_warning: bool, pub qr: QrStatus, pub qr_output: Option, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct RestorePreviewSummary { pub file_name: String, pub created_at_utc: String, pub reason: BackupReason, pub historical_apply: HistoricalApplyStatus, pub changes: crate::app::RestoreChangeSummary, pub preview_generation: u64, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct OperationError { pub message: String, } #[derive(Debug)] pub enum WorkerEvent { SessionReady(SessionView), OperationStarted(PendingOperation), OperationFinished { operation: PendingOperation, result: Result, view: Box, }, Statistics { generation: u64, observation: RuntimeObservation, }, ShutdownComplete, } /// The UI never shares its Application with the input/render thread. Tests may /// inject a delayed/failing executor; production uses `ApplicationExecutor`. pub trait OperationExecutor: Send + 'static { fn execute( &mut self, app: &mut Application, command: UiCommand, ) -> Result; fn execute_sensitive( &mut self, _app: &mut Application, _command: SensitiveCommand, ) -> Result { Err(OperationError { message: "sensitive operation is unavailable".to_owned(), }) } fn execute_at( &mut self, app: &mut Application, command: UiCommand, _generation: u64, ) -> Result { self.execute(app, command) } fn execute_sensitive_at( &mut self, app: &mut Application, command: SensitiveCommand, _generation: u64, ) -> Result { self.execute_sensitive(app, command) } } enum DeliveryMode { FirstIssue, Reissue, } struct PreparedDestinationContext { identity: PublicKey, include_qr: bool, mode: DeliveryMode, attempt: FirstIssueAttempt, generation: u64, destination: PreparedDestination, } struct RestorePreviewContext { _archive_name: String, generation: u64, preview: RestorePreview, } #[derive(Default)] pub struct ApplicationExecutor { prepared_destination: Option, restore_preview: Option, } impl OperationExecutor for ApplicationExecutor { fn execute( &mut self, app: &mut Application, command: UiCommand, ) -> Result { self.execute_at(app, command, 0) } fn execute_at( &mut self, app: &mut Application, command: UiCommand, generation: u64, ) -> Result { let delivery_continuation = matches!(command, UiCommand::DeliverFirstIssue { .. }); let restore_continuation = matches!( command, UiCommand::ConfirmRestore { .. } | UiCommand::CancelRestorePreview ); if !delivery_continuation && !matches!(&command, UiCommand::CancelPreparedDelivery) { self.prepared_destination = None; } if !restore_continuation && !matches!(&command, UiCommand::PreviewRestore { .. }) { self.restore_preview = None; } let result = match command { UiCommand::CreateClient { alias, manual_addresses, created_utc, } => app .prepare_new_client(&alias, manual_addresses, created_utc.as_deref()) .map(CommandResult::ClientCreated), UiCommand::RenameClient { identity, alias } => app .rename_client(identity, &alias) .map(|()| CommandResult::ClientRenamed), UiCommand::DeleteClient { identity } => app .delete_client(identity) .map(|record_removed| CommandResult::ClientDeleted { record_removed }), UiCommand::DiscardDraft => app.discard_draft().map(|()| CommandResult::DraftDiscarded), UiCommand::Commit => app.commit().map(CommandResult::Committed), UiCommand::CreateBackup => app.create_manual_backup().map(CommandResult::BackupCreated), UiCommand::ListBackups => app.list_backups().map(CommandResult::Backups), UiCommand::ImportSelectedFile { file_name } => app .import_selected_file(&file_name) .map(CommandResult::SelectedFileImported), UiCommand::PreviewRestart => app.preview_restart().map(|preview| { CommandResult::RestartPreview(RestartPreviewSummary { has_draft: preview.has_draft(), interface: preview.interface().to_owned(), systemd_unit: preview.systemd_unit().to_owned(), }) }), UiCommand::Restart(choice) => { let result = app .preview_restart() .map(|preview| app.restart(preview, choice)); result.map(CommandResult::Restarted) } UiCommand::PrepareDelivery { identity, path, include_qr, reissue, attempt, } => app .prepare_first_issue_destination(path) .map_err(crate::app::AppError::FileOutput) .map(|destination| { let summary = DeliveryDestinationSummary { identity, path: destination.path().to_path_buf(), overwrite_required: destination.overwrite_confirmation_required(), include_qr, reissue, attempt, prepared_generation: generation, }; self.prepared_destination = Some(PreparedDestinationContext { identity, include_qr, mode: if reissue { DeliveryMode::Reissue } else { DeliveryMode::FirstIssue }, attempt, generation, destination, }); CommandResult::DeliveryDestination(summary) }), UiCommand::DeliverFirstIssue { attempt, overwrite_confirmed, prepared_generation, } => { let prepared = self .prepared_destination .take() .ok_or_else(|| OperationError { message: "No prepared output destination is available.".to_owned(), })?; if !matches!(prepared.mode, DeliveryMode::FirstIssue) || prepared.attempt != attempt || !destination_generation_matches( prepared.generation, prepared_generation, generation, ) { return Err(OperationError { message: "Prepared output destination is stale; select it again." .to_owned(), }); } let identity = prepared.identity; let include_qr = prepared.include_qr; let mut destination = prepared.destination; if destination.overwrite_confirmation_required() { if !overwrite_confirmed { return Err(OperationError { message: "Overwrite confirmation was not supplied.".to_owned(), }); } destination = destination.confirm_overwrite().map_err(operation_error)?; } if include_qr { app.deliver_first_issue_file_and_qr(identity, attempt, destination) .map(|outcome| match outcome { FirstIssueDeliveryOutcome::Delivered(delivery) => { let (qr, qr_output) = qr_result(delivery.qr); CommandResult::FirstIssue(FirstIssueSummary { identity, status: FirstIssueStatus::Delivered, retry: None, activation_warning: delivery.activation_warning, qr, qr_output, }) } FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain { value, .. } => { let (qr, qr_output) = qr_result(value.qr); CommandResult::FirstIssue(FirstIssueSummary { identity, status: FirstIssueStatus::PublishedButBookkeepingUncertain, retry: None, activation_warning: value.activation_warning, qr, qr_output, }) } FirstIssueDeliveryOutcome::Failed { error, retry, activation_warning, } => CommandResult::FirstIssue(FirstIssueSummary { identity, status: FirstIssueStatus::Failed(error), retry: Some(retry), activation_warning, qr: QrStatus::NotRequested, qr_output: None, }), }) } else { app.deliver_first_issue_file(identity, attempt, destination) .map(|outcome| match outcome { FirstIssueDeliveryOutcome::Delivered(delivery) => { CommandResult::FirstIssue(FirstIssueSummary { identity, status: FirstIssueStatus::Delivered, retry: None, activation_warning: delivery.activation_warning, qr: QrStatus::NotRequested, qr_output: None, }) } FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain { value, .. } => CommandResult::FirstIssue(FirstIssueSummary { identity, status: FirstIssueStatus::PublishedButBookkeepingUncertain, retry: None, activation_warning: value.activation_warning, qr: QrStatus::NotRequested, qr_output: None, }), FirstIssueDeliveryOutcome::Failed { error, retry, activation_warning, } => CommandResult::FirstIssue(FirstIssueSummary { identity, status: FirstIssueStatus::Failed(error), retry: Some(retry), activation_warning, qr: QrStatus::NotRequested, qr_output: None, }), }) } } UiCommand::PreviewRestore { file_name } => { app.preview_restore(&file_name).map(|preview| { let changes = preview.change_summary(app.saved_bytes()); let summary = RestorePreviewSummary { file_name: file_name.clone(), created_at_utc: preview.created_at_utc().to_owned(), reason: preview.reason(), historical_apply: preview.historical_apply().clone(), changes, preview_generation: generation, }; self.restore_preview = Some(RestorePreviewContext { _archive_name: file_name, generation, preview, }); CommandResult::RestorePreview(summary) }) } UiCommand::CancelPreparedDelivery => { self.prepared_destination = None; Ok(CommandResult::Cancelled) } UiCommand::CancelRestorePreview => { self.restore_preview = None; Ok(CommandResult::Cancelled) } UiCommand::ConfirmRestore { preview_generation } => { let context = self.restore_preview.take().ok_or_else(|| OperationError { message: "Restore preview is no longer available; select the archive again." .to_owned(), })?; if !restore_preview_generation_matches( context.generation, preview_generation, generation, ) { return Err(OperationError { message: "Restore preview is stale; select the archive again.".to_owned(), }); } app.confirm_restore(context.preview) .map(CommandResult::Restored) } }; result.map_err(|error| OperationError { message: error.to_string(), }) } fn execute_sensitive( &mut self, app: &mut Application, command: SensitiveCommand, ) -> Result { match command { SensitiveCommand::PrepareInitialSetup(password) => app .prepare_initial_setup_from_sources(password.expose()) .map(CommandResult::InitialSetupPrepared) .map_err(|error| OperationError { message: error.to_string(), }), SensitiveCommand::ReissueClient { identity, password, overwrite_confirmed, prepared_generation, } => { let prepared = self .prepared_destination .take() .ok_or_else(|| OperationError { message: "No prepared output destination is available.".to_owned(), })?; if prepared.identity != identity || !matches!(prepared.mode, DeliveryMode::Reissue) || prepared.generation != prepared_generation { return Err(OperationError { message: "Prepared output destination is stale; select it again." .to_owned(), }); } let include_qr = prepared.include_qr; let mut destination = prepared.destination; if destination.overwrite_confirmation_required() { if !overwrite_confirmed { return Err(OperationError { message: "Overwrite confirmation was not supplied.".to_owned(), }); } destination = destination.confirm_overwrite().map_err(operation_error)?; } let delivery = app .reissue_client_file(identity, password.expose(), destination, include_qr) .map_err(|error| OperationError { message: error.to_string(), })?; let (qr, qr_output) = match delivery.qr { None => (QrStatus::NotRequested, None), Some(result) => qr_result(result), }; Ok(CommandResult::Reissued(ReissueSummary { activation_warning: delivery.activation_warning, qr, qr_output, })) } } } fn execute_sensitive_at( &mut self, app: &mut Application, command: SensitiveCommand, generation: u64, ) -> Result { match &command { SensitiveCommand::ReissueClient { prepared_generation, .. } if *prepared_generation != generation => { return Err(OperationError { message: "Prepared output destination is stale; select it again.".to_owned(), }); } _ => {} } if matches!(command, SensitiveCommand::PrepareInitialSetup(_)) { self.prepared_destination = None; self.restore_preview = None; } self.execute_sensitive(app, command) } } fn operation_error(error: impl std::fmt::Display) -> OperationError { OperationError { message: error.to_string(), } } fn destination_generation_matches(stored: u64, requested: u64, current: u64) -> bool { stored == requested && stored == current } fn restore_preview_generation_matches(stored: u64, requested: u64, current: u64) -> bool { stored == requested && current == requested.saturating_add(1) } fn qr_result(result: Result) -> (QrStatus, Option) { match result { Ok(output) => (QrStatus::Ready, Some(output)), Err(error) => (QrStatus::Failed(error), None), } } pub trait StatisticsReader: Send + 'static { /// Implementations must return within `timeout`; unavailable runtime data /// is an observation, not a failure or inferred offline status. fn read(&mut self, interface: &str, timeout: Duration) -> RuntimeObservation; } pub struct UnavailableStatistics; impl StatisticsReader for UnavailableStatistics { fn read(&mut self, _interface: &str, _timeout: Duration) -> RuntimeObservation { RuntimeObservation { observed_at: None, interface: crate::runtime::InterfaceObservation::Unavailable, } } } /// Read-only Linux WireGuard observation adapter. Failures remain unavailable /// observations and never alter the application's applied-state marker. pub struct LinuxStatisticsReader; impl StatisticsReader for LinuxStatisticsReader { fn read(&mut self, interface: &str, timeout: Duration) -> RuntimeObservation { match crate::runtime::transport::observe_interface( interface, timeout, crate::runtime::netlink::DecodeLimits::default(), ) { Ok(observation) => observation, Err(crate::runtime::transport::TransportError::NoDevice) => RuntimeObservation { observed_at: None, interface: crate::runtime::InterfaceObservation::Missing, }, Err(_) => RuntimeObservation { observed_at: None, interface: crate::runtime::InterfaceObservation::Unavailable, }, } } } pub struct WorkerConfig { pub refresh_interval: Duration, pub read_timeout: Duration, } impl Default for WorkerConfig { fn default() -> Self { Self { refresh_interval: DEFAULT_REFRESH_INTERVAL, read_timeout: DEFAULT_READ_TIMEOUT, } } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum SubmitError { Busy, QueueFull, ShuttingDown, RequestIdsExhausted, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ShutdownProgress { Pending, Stopped, Disconnected, } enum WorkerMessage { Execute(PendingOperation, WorkerCommand), Refresh, Shutdown, } enum WorkerCommand { Public(UiCommand), Sensitive(SensitiveCommand), } /// Handle stays live while bounded shutdown is pending. Call `shutdown` again /// to collect the final operation result and join only after its completion /// event; never discard the handle while a protected operation is submitted. #[must_use = "retain the worker and keep rendering progress until shutdown completes"] pub struct ApplicationWorker { sender: SyncSender, events: Receiver, pending_events: VecDeque, busy: Arc, stop_requested: Arc, shutting_down: bool, shutdown_complete: bool, next_request_id: AtomicU64, generation: Arc, refresh_pending: Arc, join: Option>, } impl ApplicationWorker { pub fn spawn( app: Application, statistics: impl StatisticsReader, config: WorkerConfig, ) -> std::io::Result { Self::spawn_with_executor(app, ApplicationExecutor::default(), statistics, config) } pub fn spawn_with_executor( app: Application, executor: impl OperationExecutor, statistics: impl StatisticsReader, config: WorkerConfig, ) -> std::io::Result { let (sender, receiver) = mpsc::sync_channel(COMMAND_QUEUE_CAPACITY); let (event_sender, events) = mpsc::sync_channel(EVENT_QUEUE_CAPACITY); let busy = Arc::new(AtomicBool::new(false)); let worker_busy = Arc::clone(&busy); let stop_requested = Arc::new(AtomicBool::new(false)); let worker_stop_requested = Arc::clone(&stop_requested); let generation = Arc::new(AtomicU64::new(0)); let worker_generation = Arc::clone(&generation); let refresh_pending = Arc::new(AtomicBool::new(false)); let worker_refresh_pending = Arc::clone(&refresh_pending); let join = thread::Builder::new() .name("wireguard-admin-app".to_owned()) .spawn(move || { run_worker( app, WorkerLoop { executor: Box::new(executor), statistics: Box::new(statistics), receiver, events: event_sender, busy: worker_busy, stop_requested: worker_stop_requested, generation: worker_generation, refresh_pending: worker_refresh_pending, config, }, ); })?; Ok(Self { sender, events, pending_events: VecDeque::with_capacity(EVENT_QUEUE_CAPACITY), busy, stop_requested, shutting_down: false, shutdown_complete: false, next_request_id: AtomicU64::new(1), generation, refresh_pending, join: Some(join), }) } pub fn submit(&self, command: UiCommand) -> Result { let kind = command.kind(); let changes_view = command.changes_view(); self.submit_request(WorkerCommand::Public(command), kind, changes_view) } pub fn submit_sensitive( &self, command: SensitiveCommand, ) -> Result { let kind = command.kind(); let changes_view = command.changes_view(); self.submit_request(WorkerCommand::Sensitive(command), kind, changes_view) } fn submit_request( &self, command: WorkerCommand, kind: OperationKind, changes_view: bool, ) -> Result { if self.shutting_down { return Err(SubmitError::ShuttingDown); } if self .busy .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .is_err() { return Err(SubmitError::Busy); } let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed); if request_id == u64::MAX { self.busy.store(false, Ordering::Release); return Err(SubmitError::RequestIdsExhausted); } let generation = if changes_view { self.generation .fetch_add(1, Ordering::AcqRel) .saturating_add(1) } else { self.generation.load(Ordering::Acquire) }; let operation = PendingOperation { request_id, kind, generation, }; match self .sender .try_send(WorkerMessage::Execute(operation, command)) { Ok(()) => Ok(operation), Err(TrySendError::Full(_)) => { self.busy.store(false, Ordering::Release); Err(SubmitError::QueueFull) } Err(TrySendError::Disconnected(_)) => { self.busy.store(false, Ordering::Release); Err(SubmitError::ShuttingDown) } } } /// Coalesce refresh requests. Periodic refresh also runs at the configured /// interval; neither path can queue an unbounded number of samples. pub fn request_statistics(&self) -> Result<(), SubmitError> { if self.shutting_down { return Err(SubmitError::ShuttingDown); } if self.refresh_pending.swap(true, Ordering::AcqRel) { return Ok(()); } match self.sender.try_send(WorkerMessage::Refresh) { Ok(()) => Ok(()), Err(TrySendError::Full(_)) => Ok(()), Err(TrySendError::Disconnected(_)) => Err(SubmitError::ShuttingDown), } } pub fn try_receive(&mut self) -> Option { self.pending_events .pop_front() .or_else(|| self.events.try_recv().ok()) } pub fn current_generation(&self) -> u64 { self.generation.load(Ordering::Acquire) } /// Request shutdown and wait only for the supplied bound. `Pending` means /// an accepted operation may still be saving or restarting; keep the UI and /// this handle alive and call again to observe its result. pub fn shutdown(&mut self, timeout: Duration) -> ShutdownProgress { if self.shutdown_complete { return ShutdownProgress::Stopped; } if !self.shutting_down { self.shutting_down = true; self.stop_requested.store(true, Ordering::Release); // The atomic flag wakes the worker's drain logic even when a full // bounded queue cannot accept a shutdown message. let _ = self.sender.try_send(WorkerMessage::Shutdown); } let deadline = Instant::now() + timeout; loop { let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { return ShutdownProgress::Pending; } match self.events.recv_timeout(remaining) { Ok(WorkerEvent::ShutdownComplete) => { self.shutdown_complete = true; if let Some(join) = self.join.take() { let _ = join.join(); } return ShutdownProgress::Stopped; } Ok(event) => self.retain_pending_event(event), Err(RecvTimeoutError::Timeout) => return ShutdownProgress::Pending, Err(RecvTimeoutError::Disconnected) => return ShutdownProgress::Disconnected, } } } fn retain_pending_event(&mut self, event: WorkerEvent) { if self.pending_events.len() < EVENT_QUEUE_CAPACITY { self.pending_events.push_back(event); return; } if let Some(index) = self .pending_events .iter() .position(|queued| matches!(queued, WorkerEvent::Statistics { .. })) { self.pending_events.remove(index); self.pending_events.push_back(event); } } } struct WorkerLoop { executor: Box, statistics: Box, receiver: Receiver, events: SyncSender, busy: Arc, stop_requested: Arc, generation: Arc, refresh_pending: Arc, config: WorkerConfig, } fn run_worker(mut app: Application, worker: WorkerLoop) { let WorkerLoop { mut executor, mut statistics, receiver, events, busy, stop_requested, generation, refresh_pending, config, } = worker; let mut next_refresh = Instant::now() + config.refresh_interval; let initial_view = SessionView::from_application(&app, generation.load(Ordering::Acquire)); if events .send(WorkerEvent::SessionReady(initial_view)) .is_err() { return; } loop { let now = Instant::now(); let until_refresh = next_refresh.saturating_duration_since(now); let message = if stop_requested.load(Ordering::Acquire) { match receiver.try_recv() { Ok(message) => Some(message), Err(mpsc::TryRecvError::Empty | mpsc::TryRecvError::Disconnected) => break, } } else if refresh_pending.swap(false, Ordering::AcqRel) || until_refresh.is_zero() { None } else { match receiver.recv_timeout(until_refresh) { Ok(message) => Some(message), Err(RecvTimeoutError::Timeout) => None, Err(RecvTimeoutError::Disconnected) => break, } }; match message { Some(WorkerMessage::Execute(operation, command)) => { let _ = events.send(WorkerEvent::OperationStarted(operation)); let result = match command { WorkerCommand::Public(command) => { executor.execute_at(&mut app, command, operation.generation) } WorkerCommand::Sensitive(command) => { executor.execute_sensitive_at(&mut app, command, operation.generation) } }; let view = SessionView::from_application(&app, operation.generation); if events .send(WorkerEvent::OperationFinished { operation, result, view: Box::new(view), }) .is_err() { break; } busy.store(false, Ordering::Release); } Some(WorkerMessage::Refresh) => {} Some(WorkerMessage::Shutdown) => break, None => { let before = generation.load(Ordering::Acquire); let interface = std::str::from_utf8(&app.saved_bytes().settings) .ok() .and_then(|text| SettingsFile::parse_toml(text).ok()) .and_then(|settings| settings.validate().ok()) .map(|settings| settings.interface_name); let observation = interface.map_or_else(RuntimeObservation::unknown, |interface| { statistics.read(&interface, config.read_timeout) }); let after = generation.load(Ordering::Acquire); if before == after { let _ = events.try_send(WorkerEvent::Statistics { generation: after, observation, }); } next_refresh = Instant::now() + config.refresh_interval; } } } let _ = events.send(WorkerEvent::ShutdownComplete); } #[cfg(test)] mod tests { use super::*; use std::fs; use std::net::{Ipv4Addr, Ipv6Addr}; use std::os::unix::fs::PermissionsExt; use std::sync::atomic::AtomicUsize; use std::sync::mpsc::{self, Receiver}; use crate::config::SettingsFile; use crate::domain::Client; use crate::persistence::SavedStore; use crate::vault::WireGuardPrivateKey; #[test] fn prepared_delivery_and_restore_confirmations_are_bound_to_the_reviewed_generation() { assert!(destination_generation_matches(8, 8, 8)); assert!(!destination_generation_matches(8, 9, 8)); assert!(!destination_generation_matches(8, 8, 9)); assert!(restore_preview_generation_matches(8, 8, 9)); assert!(!restore_preview_generation_matches(7, 8, 9)); assert!(!restore_preview_generation_matches(8, 8, 8)); } #[test] fn prepared_delivery_is_cleared_by_cancel_and_unrelated_commands() { let (_root, mut app) = application(); let identity = PublicKey::from_bytes([44; 32]); let mut executor = ApplicationExecutor::default(); let path = _root.path().join("prepared.conf"); let prepare = || UiCommand::PrepareDelivery { identity, path: path.clone(), include_qr: false, reissue: false, attempt: FirstIssueAttempt::Initial, }; let deliver = || UiCommand::DeliverFirstIssue { attempt: FirstIssueAttempt::Initial, overwrite_confirmed: false, prepared_generation: 7, }; assert!(matches!( executor.execute_at(&mut app, prepare(), 7), Ok(CommandResult::DeliveryDestination(_)) )); assert!(matches!( executor.execute_at(&mut app, UiCommand::CancelPreparedDelivery, 7), Ok(CommandResult::Cancelled) )); assert_eq!( executor .execute_at(&mut app, deliver(), 7) .unwrap_err() .message, "No prepared output destination is available." ); assert!(matches!( executor.execute_at(&mut app, prepare(), 7), Ok(CommandResult::DeliveryDestination(_)) )); assert!(matches!( executor.execute_at(&mut app, UiCommand::ListBackups, 7), Ok(CommandResult::Backups(_)) )); assert_eq!( executor .execute_at(&mut app, deliver(), 7) .unwrap_err() .message, "No prepared output destination is available." ); } struct FakeStatistics { started: Option>, release: Option>, calls: Arc, } impl StatisticsReader for FakeStatistics { fn read(&mut self, _interface: &str, _timeout: Duration) -> RuntimeObservation { self.calls.fetch_add(1, Ordering::SeqCst); if let Some(started) = self.started.take() { let _ = started.send(()); } if let Some(release) = self.release.take() { let _ = release.recv(); } RuntimeObservation::unknown() } } struct FakeExecutor { started: mpsc::SyncSender<()>, release: Receiver<()>, calls: Arc, } impl OperationExecutor for FakeExecutor { fn execute( &mut self, _app: &mut Application, command: UiCommand, ) -> Result { self.calls.fetch_add(1, Ordering::SeqCst); let _ = self.started.send(()); let _ = self.release.recv(); if matches!(command, UiCommand::Commit) { Err(OperationError { message: "synthetic commit failure".to_owned(), }) } else { Ok(CommandResult::DraftDiscarded) } } } fn application() -> (tempfile::TempDir, Application) { let root = tempfile::tempdir().unwrap(); let settings_text = r#"interface_name = "wg0" server_config_path = "wg0.conf" client_source_directory = "clients" server_endpoint = "vpn.example.org:51820" ipv4_allocation_range = "10.8.0.0/24" ipv6_allocation_range = "fd80::/64" dns_addresses = ["10.8.0.1"] systemd_unit = "wg-quick@wg0.service" restart_timeout_seconds = 10 "#; let settings = SettingsFile::parse_toml(settings_text) .unwrap() .validate() .unwrap() .resolve_paths(root.path()); fs::write( root.path().join(crate::config::SETTINGS_FILE_NAME), settings_text, ) .unwrap(); let server_key = WireGuardPrivateKey::generate().unwrap(); let client_key = WireGuardPrivateKey::generate().unwrap(); let client = Client::new( client_key.public_key(), "fixture", ClientAddresses { ipv4: Ipv4Addr::new(10, 8, 0, 2), ipv6: Some("fd80::2".parse::().unwrap()), }, ) .unwrap(); let server = format!( "[Interface]\nPrivateKey = {}\nAddress = 10.8.0.1/24, fd80::1/64\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.8.0.2/32, fd80::2/128\n", server_key.to_base64(), client.identity ); let server_path = root.path().join("wg0.conf"); fs::write(&server_path, server).unwrap(); fs::set_permissions(&server_path, fs::Permissions::from_mode(0o600)).unwrap(); let store = SavedStore::new(root.path(), settings).unwrap(); let app = Application::open_from_saved_store(store).unwrap(); (root, app) } #[test] fn command_execution_is_serialized_and_delayed_work_keeps_ui_submit_nonblocking() { let (started_tx, started_rx) = mpsc::sync_channel(1); let (release_tx, release_rx) = mpsc::sync_channel(1); let calls = Arc::new(AtomicUsize::new(0)); let executor = FakeExecutor { started: started_tx, release: release_rx, calls: Arc::clone(&calls), }; let (_root, app) = application(); let mut worker = ApplicationWorker::spawn_with_executor( app, executor, UnavailableStatistics, WorkerConfig { refresh_interval: Duration::from_secs(60), read_timeout: Duration::from_millis(20), }, ) .unwrap(); let operation = worker.submit(UiCommand::Commit).unwrap(); started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); assert_eq!( worker.submit(UiCommand::DiscardDraft), Err(SubmitError::Busy) ); let started = Instant::now(); assert!(worker.try_receive().is_some()); assert!(started.elapsed() < Duration::from_millis(100)); release_tx.send(()).unwrap(); let deadline = Instant::now() + Duration::from_secs(1); let mut finished = false; while Instant::now() < deadline { if let Some(WorkerEvent::OperationFinished { operation: actual, result, .. }) = worker.try_receive() { assert_eq!(actual, operation); assert_eq!(result.unwrap_err().message, "synthetic commit failure"); finished = true; break; } thread::yield_now(); } assert!(finished); assert_eq!(calls.load(Ordering::SeqCst), 1); assert_eq!( worker.shutdown(Duration::from_secs(1)), ShutdownProgress::Stopped ); } #[test] fn shutdown_timeout_reports_pending_until_submitted_operation_finishes() { let (started_tx, started_rx) = mpsc::sync_channel(1); let (release_tx, release_rx) = mpsc::sync_channel(1); let executor = FakeExecutor { started: started_tx, release: release_rx, calls: Arc::new(AtomicUsize::new(0)), }; let (_root, app) = application(); let mut worker = ApplicationWorker::spawn_with_executor( app, executor, UnavailableStatistics, WorkerConfig { refresh_interval: Duration::from_secs(60), read_timeout: Duration::from_millis(20), }, ) .unwrap(); worker.submit(UiCommand::Commit).unwrap(); started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); let before = Instant::now(); assert_eq!( worker.shutdown(Duration::from_millis(20)), ShutdownProgress::Pending ); assert!(before.elapsed() < Duration::from_millis(250)); release_tx.send(()).unwrap(); assert_eq!( worker.shutdown(Duration::from_secs(1)), ShutdownProgress::Stopped ); let mut saw_completion = false; while let Some(event) = worker.try_receive() { saw_completion |= matches!(event, WorkerEvent::OperationFinished { .. }); } assert!( saw_completion, "submitted work outcome must remain observable" ); } #[test] fn shutdown_with_full_queue_returns_within_bound_while_statistics_is_delayed() { let (started_tx, started_rx) = mpsc::sync_channel(1); let (release_tx, release_rx) = mpsc::sync_channel(1); let (_root, app) = application(); let mut worker = ApplicationWorker::spawn( app, FakeStatistics { started: Some(started_tx), release: Some(release_rx), calls: Arc::new(AtomicUsize::new(0)), }, WorkerConfig { refresh_interval: Duration::from_secs(60), read_timeout: Duration::from_millis(50), }, ) .unwrap(); worker.request_statistics().unwrap(); started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); while worker.sender.try_send(WorkerMessage::Refresh).is_ok() {} assert!(matches!( worker.sender.try_send(WorkerMessage::Refresh), Err(TrySendError::Full(_)) )); let before = Instant::now(); assert_eq!( worker.shutdown(Duration::from_millis(20)), ShutdownProgress::Pending ); assert!(before.elapsed() < Duration::from_millis(250)); release_tx.send(()).unwrap(); assert_eq!( worker.shutdown(Duration::from_secs(1)), ShutdownProgress::Stopped ); } #[test] fn delayed_statistics_are_bounded_and_do_not_advance_generation() { let (started_tx, started_rx) = mpsc::sync_channel(1); let (release_tx, release_rx) = mpsc::sync_channel(1); let calls = Arc::new(AtomicUsize::new(0)); let (_root, app) = application(); let mut worker = ApplicationWorker::spawn( app, FakeStatistics { started: Some(started_tx), release: Some(release_rx), calls: Arc::clone(&calls), }, WorkerConfig { refresh_interval: Duration::from_secs(60), read_timeout: Duration::from_millis(50), }, ) .unwrap(); worker.request_statistics().unwrap(); started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); for _ in 0..100 { worker.request_statistics().unwrap(); } assert_eq!(worker.current_generation(), 0); release_tx.send(()).unwrap(); let deadline = Instant::now() + Duration::from_secs(1); while calls.load(Ordering::SeqCst) < 2 && Instant::now() < deadline { thread::yield_now(); } assert_eq!( calls.load(Ordering::SeqCst), 2, "100 refresh requests coalesce to one pending read" ); assert_eq!( worker.shutdown(Duration::from_secs(1)), ShutdownProgress::Stopped ); } #[test] fn statistics_started_before_a_mutation_are_discarded_as_stale() { let (stats_started_tx, stats_started_rx) = mpsc::sync_channel(1); let (stats_release_tx, stats_release_rx) = mpsc::sync_channel(1); let (operation_started_tx, operation_started_rx) = mpsc::sync_channel(1); let (operation_release_tx, operation_release_rx) = mpsc::sync_channel(1); let (_root, app) = application(); let mut worker = ApplicationWorker::spawn_with_executor( app, FakeExecutor { started: operation_started_tx, release: operation_release_rx, calls: Arc::new(AtomicUsize::new(0)), }, FakeStatistics { started: Some(stats_started_tx), release: Some(stats_release_rx), calls: Arc::new(AtomicUsize::new(0)), }, WorkerConfig { refresh_interval: Duration::from_secs(60), read_timeout: Duration::from_millis(50), }, ) .unwrap(); worker.request_statistics().unwrap(); stats_started_rx .recv_timeout(Duration::from_secs(1)) .unwrap(); let operation = worker.submit(UiCommand::Commit).unwrap(); assert_eq!(operation.generation, 1); stats_release_tx.send(()).unwrap(); operation_started_rx .recv_timeout(Duration::from_secs(1)) .unwrap(); operation_release_tx.send(()).unwrap(); let deadline = Instant::now() + Duration::from_secs(1); let mut saw_finish = false; while Instant::now() < deadline { if let Some(event) = worker.try_receive() { match event { WorkerEvent::Statistics { generation, .. } => { assert_ne!(generation, 0, "pre-mutation statistics must be dropped"); } WorkerEvent::OperationFinished { operation: finished, view, .. } => { assert_eq!(finished, operation); assert_eq!(view.generation, 1); saw_finish = true; break; } WorkerEvent::SessionReady(_) | WorkerEvent::OperationStarted(_) | WorkerEvent::ShutdownComplete => {} } } else { thread::yield_now(); } } assert!(saw_finish); assert_eq!( worker.shutdown(Duration::from_secs(1)), ShutdownProgress::Stopped ); } }