diff --git a/crates/common/src/cache/reload.rs b/crates/common/src/cache/reload.rs index 6efa4f0..b32c12b 100644 --- a/crates/common/src/cache/reload.rs +++ b/crates/common/src/cache/reload.rs @@ -13,7 +13,7 @@ use crate::{ storage::Storage, telemetry::Telemetry, }, - ipc::{QueueEvent, RegistryChange}, + ipc::{BroadcastEvent, QueueEvent, RegistryChange}, network::security::{BlockedIps, IpWithTtl}, }; use ahash::AHashMap; @@ -232,3 +232,201 @@ fn error_object(error: &Error) -> Option { Error::Internal { object_id, .. } => *object_id, } } + +// inbuxa: upstream applied a registry write to the running settings only on +// an explicit x:Action ReloadSettings (Directory and Authentication aside), so +// a new MtaDeliverySchedule, say, stayed unknown ("Queue strategy not found") +// until someone reloaded. Writes to objects the settings are built from now +// reload them, here and across the cluster, as ReloadSettings does. + +/// Coalesces the full reloads that registry writes trigger: a write waits for +/// a reload that started after it was stored, and joins one if it can, so a +/// burst of writes costs a reload or two rather than one each. +#[derive(Default)] +pub struct SettingsReloadGate { + requested: std::sync::atomic::AtomicU64, + state: tokio::sync::Mutex, +} + +#[derive(Default)] +struct SettingsReloadState { + completed: u64, + refused: Option, +} + +/// The reload a write to `object` calls for: the object to reload, or None +/// when the running settings don't hold that object (accounts, domains and +/// other data read as needed, stores, which take a restart, and objects with +/// reload actions of their own, such as applications). +pub fn write_reload_target(object: ObjectType) -> Option { + match object { + ObjectType::Certificate => Some(ObjectType::Certificate), + ObjectType::MemoryLookupKey + | ObjectType::MemoryLookupKeyValue + | ObjectType::HttpLookup + | ObjectType::StoreLookup => Some(ObjectType::StoreLookup), + ObjectType::BlockedIp | ObjectType::AllowedIp => Some(ObjectType::BlockedIp), + ObjectType::AcmeProvider + | ObjectType::AddressBook + | ObjectType::AiModel + | ObjectType::Asn + | ObjectType::Authentication + | ObjectType::Cache + | ObjectType::Calendar + | ObjectType::CalendarAlarm + | ObjectType::CalendarScheduling + | ObjectType::ClusterRole + | ObjectType::DataRetention + | ObjectType::Directory + | ObjectType::DkimReportSettings + | ObjectType::DmarcReportSettings + | ObjectType::DnsResolver + | ObjectType::DsnReportSettings + | ObjectType::Email + | ObjectType::EventTracingLevel + | ObjectType::FileStorage + | ObjectType::Http + | ObjectType::HttpForm + | ObjectType::Imap + | ObjectType::Jmap + | ObjectType::Metrics + | ObjectType::MtaConnectionStrategy + | ObjectType::MtaDeliverySchedule + | ObjectType::MtaExtensions + | ObjectType::MtaHook + | ObjectType::MtaInboundSession + | ObjectType::MtaInboundThrottle + | ObjectType::MtaMilter + | ObjectType::MtaOutboundStrategy + | ObjectType::MtaOutboundThrottle + | ObjectType::MtaQueueQuota + | ObjectType::MtaRoute + | ObjectType::MtaStageAuth + | ObjectType::MtaStageConnect + | ObjectType::MtaStageData + | ObjectType::MtaStageEhlo + | ObjectType::MtaStageMail + | ObjectType::MtaStageRcpt + | ObjectType::MtaSts + | ObjectType::MtaTlsStrategy + | ObjectType::MtaVirtualQueue + | ObjectType::NetworkListener + | ObjectType::OidcProvider + | ObjectType::ReportSettings + | ObjectType::Search + | ObjectType::Security + | ObjectType::SenderAuth + | ObjectType::Sharing + | ObjectType::SieveSystemInterpreter + | ObjectType::SieveSystemScript + | ObjectType::SieveUserInterpreter + | ObjectType::SieveUserScript + | ObjectType::SpamClassifier + | ObjectType::SpamDnsblServer + | ObjectType::SpamDnsblSettings + | ObjectType::SpamFileExtension + | ObjectType::SpamPyzor + | ObjectType::SpamRule + | ObjectType::SpamSettings + | ObjectType::SpamTag + | ObjectType::SpfReportSettings + | ObjectType::SystemSettings + | ObjectType::TaskManager + | ObjectType::TlsReportSettings + | ObjectType::Tracer + | ObjectType::WebDav + | ObjectType::WebHook => Some(object), + _ => None, + } +} + +impl Server { + /// Applies a stored registry write to `object` to the running settings, + /// and on success tells the other nodes to do the same. Returns None when + /// the write needs no reload, Some(Ok(())) when it was applied, and + /// Some(Err(reason)) when the reload was refused (the write stays stored; + /// ReloadSettings reports the same errors). + pub async fn reload_after_write(&self, object: ObjectType) -> Option> { + let target = write_reload_target(object)?; + let change = RegistryChange::Reload(target); + + if matches!( + target, + ObjectType::Certificate | ObjectType::StoreLookup | ObjectType::BlockedIp + ) { + // Cheap, and limited to their own objects + let result = self.reload_and_broadcast(change).await; + return Some(result); + } + + let gate = &self.inner.data.settings_reload; + let ticket = gate + .requested + .fetch_add(1, std::sync::atomic::Ordering::SeqCst) + + 1; + let mut state = gate.state.lock().await; + if state.completed >= ticket { + // A reload that started after this write was stored has run + return Some(state.refused.clone().map_or(Ok(()), Err)); + } + let covers = gate.requested.load(std::sync::atomic::Ordering::SeqCst); + let result = self.reload_and_broadcast(change).await; + state.completed = covers; + state.refused = result.clone().err(); + Some(result) + } + + async fn reload_and_broadcast(&self, change: RegistryChange) -> Result<(), String> { + match Box::pin(self.reload_registry(change)).await { + Ok(reload) if !reload.has_errors() => { + reload.log(); + self.cluster_broadcast(BroadcastEvent::RegistryChange(change)) + .await; + Ok(()) + } + Ok(reload) => { + reload.log(); + let reason = describe_reload_errors(&reload.errors); + trc::event!( + Registry(trc::RegistryEvent::BuildWarning), + Details = "Settings didn't reload after a registry write", + Reason = reason.clone(), + ); + Err(reason) + } + Err(err) => { + let reason = err.to_string(); + trc::error!(err.details("Failed to reload settings after a registry write")); + Err(reason) + } + } + } +} + +/// inbuxa: a refused reload's errors in a sentence: the first one, naming its +/// object, and how many more there are. +pub fn describe_reload_errors(errors: &[Error]) -> String { + let mut description = match errors.first() { + Some(Error::Build { object_id, message }) => format!("{object_id}: {message}"), + Some(Error::Validation { object_id, errors }) => format!( + "{object_id}: {}", + errors + .iter() + .map(|err| err.to_string()) + .collect::>() + .join("; ") + ), + Some(Error::Internal { + object_id: Some(object_id), + error, + }) => format!("{object_id}: {error}"), + Some(Error::Internal { error, .. }) => error.to_string(), + Some(Error::NotFound { object_id }) => format!("{object_id} was not found"), + None => String::new(), + }; + let more = errors.len().saturating_sub(1); + if more > 0 { + description.push_str(&format!(" ({more} more in the server log.)")); + } + description +} diff --git a/crates/common/src/config/inner.rs b/crates/common/src/config/inner.rs index 094e73e..325aacc 100644 --- a/crates/common/src/config/inner.rs +++ b/crates/common/src/config/inner.rs @@ -93,6 +93,7 @@ impl Data { registry_id_gen: id_generator.clone(), span_id_gen: id_generator, queue_status: true.into(), + settings_reload: Default::default(), applications, logos: Default::default(), smtp_connectors: TlsConnectors::try_new().failed("Failed to build TLS connectors"), @@ -235,6 +236,7 @@ impl Default for Data { span_id_gen: Default::default(), registry_id_gen: Default::default(), queue_status: true.into(), + settings_reload: Default::default(), applications: WebApplications::new(), logos: Default::default(), smtp_connectors: TlsConnectors::try_new().unwrap(), diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index ad0180a..d9538da 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -161,6 +161,8 @@ pub struct Data { pub span_id_gen: SnowflakeIdGenerator, pub registry_id_gen: SnowflakeIdGenerator, pub queue_status: AtomicBool, + // inbuxa: coalesces the settings reloads registry writes trigger + pub settings_reload: cache::reload::SettingsReloadGate, pub applications: WebApplications, pub logos: Mutex, LogoCache>>, diff --git a/crates/jmap-proto/src/method/set.rs b/crates/jmap-proto/src/method/set.rs index 445f91f..3fd5828 100644 --- a/crates/jmap-proto/src/method/set.rs +++ b/crates/jmap-proto/src/method/set.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::ahash_is_empty; @@ -71,6 +73,23 @@ pub struct SetResponse { #[serde(rename = "notDestroyed")] #[serde(skip_serializing_if = "VecMap::is_empty")] pub not_destroyed: VecMap, SetError>, + + // inbuxa: on a registry write that changes the running settings, whether + // the server applied it + #[serde(rename = "x:settingsReload")] + #[serde(skip_serializing_if = "Option::is_none")] + pub settings_reload: Option, +} + +/// inbuxa: the settings reload that followed a registry write. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SettingsReload { + /// The running settings (here and, through the cluster, on every node) + /// include the write. + pub applied: bool, + /// Why they don't, when they don't. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, } impl<'de, T: JmapObject> DeserializeArguments<'de> for SetRequest<'de, T> { @@ -199,6 +218,7 @@ impl SetResponse { not_created: VecMap::new(), not_updated: VecMap::new(), not_destroyed: VecMap::new(), + settings_reload: None, }) } else { Err(trc::JmapEvent::RequestTooLarge.into_err()) diff --git a/crates/jmap/src/registry/mapping/action.rs b/crates/jmap/src/registry/mapping/action.rs index 800a889..2b0eb80 100644 --- a/crates/jmap/src/registry/mapping/action.rs +++ b/crates/jmap/src/registry/mapping/action.rs @@ -580,29 +580,9 @@ async fn dmarc_troubleshoot( /// settings weren't applied; upstream passed on the first error's bare message /// ("Invalid address: ..."), which read like a problem with the request. fn reload_refused(errors: Vec) -> SetError { - use registry::types::error::Error; - let more = errors.len().saturating_sub(1); - let mut description = match errors.first() { - Some(Error::Build { object_id, message }) => format!("{object_id}: {message}"), - Some(Error::Validation { object_id, errors }) => format!( - "{object_id}: {}", - errors - .iter() - .map(|err| err.to_string()) - .collect::>() - .join("; ") - ), - Some(Error::Internal { - object_id: Some(object_id), - error, - }) => format!("{object_id}: {error}"), - Some(Error::Internal { error, .. }) => error.to_string(), - Some(Error::NotFound { object_id }) => format!("{object_id} was not found"), - None => String::new(), - }; - description.insert_str(0, "Settings were not reloaded. "); - if more > 0 { - description.push_str(&format!(" ({more} more in the server log.)")); - } + let description = format!( + "Settings were not reloaded. {}", + common::cache::reload::describe_reload_errors(&errors) + ); map_bootstrap_error(errors).with_description(description) } diff --git a/crates/jmap/src/registry/set.rs b/crates/jmap/src/registry/set.rs index 1244219..0eda5fd 100644 --- a/crates/jmap/src/registry/set.rs +++ b/crates/jmap/src/registry/set.rs @@ -38,7 +38,7 @@ use directory::core::secret::{hash_secret, is_password_hash}; use http_proto::HttpSessionData; use jmap_proto::{ error::set::{SetError, SetErrorType}, - method::set::{SetRequest, SetResponse}, + method::set::{SetRequest, SetResponse, SettingsReload}, object::registry::Registry, references::resolve::ResolveCreatedReference, request::{IntoValid, MaybeInvalid}, @@ -931,34 +931,28 @@ impl RegistrySet for Server { } }; - // inbuxa: DIR-17: a directory or the server default applies on the - // next request, here and on every node - if matches!( - object_type, - ObjectType::Directory | ObjectType::Authentication - ) && let Ok(response) = &result + // inbuxa: a write to an object the running settings are built from + // applies at once, here and on every node (DIR-17 did this for + // directories and the server default; now it covers every such object) + let mut result = result; + if let Ok(response) = &mut result && (!response.created.is_empty() || !response.updated.is_empty() || !response.destroyed.is_empty()) + && let Some(reload) = self.reload_after_write(object_type).await { - let change = common::ipc::RegistryChange::Reload(ObjectType::Directory); - match Box::pin(self.reload_registry(change)).await { - Ok(reload) if !reload.has_errors() => { - self.cluster_broadcast(common::ipc::BroadcastEvent::RegistryChange(change)) - .await; - } - Ok(reload) => { - // inbuxa: name what stopped it - reload.log(); - trc::event!( - Registry(trc::RegistryEvent::BuildWarning), - Details = "Settings didn't reload after a directory change", - ) - } - Err(err) => { - trc::error!(err.details("Failed to reload directories")); - } - } + response.settings_reload = Some(match reload { + Ok(()) => SettingsReload { + applied: true, + description: None, + }, + Err(reason) => SettingsReload { + applied: false, + description: Some(format!( + "Saved, but the running settings were not reloaded. {reason}" + )), + }, + }); } result } diff --git a/tests/src/cluster/broadcast.rs b/tests/src/cluster/broadcast.rs index 7fb2f6f..541a73a 100644 --- a/tests/src/cluster/broadcast.rs +++ b/tests/src/cluster/broadcast.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -11,6 +13,7 @@ use crate::{ server::TestServerBuilder, }, }; +use common::BuildServer; use imap_proto::ResponseType; use registry::{ schema::{ @@ -18,7 +21,8 @@ use registry::{ prelude::{ObjectType, Property, SocketAddr}, structs::{ ClusterListenerGroup, ClusterListenerGroupProperties, ClusterRole, ClusterTaskGroup, - Coordinator, Imap, NatsCoordinator, NetworkListener, RedisStore, + Coordinator, Imap, MtaDeliverySchedule, MtaVirtualQueue, NatsCoordinator, + NetworkListener, RedisStore, }, }, types::map::Map, @@ -209,6 +213,45 @@ pub async fn cluster_tests() { Some("John Doe") ); + // inbuxa: a settings write applies on every node, no ReloadSettings + let queue_id = admin + .registry_create_object(MtaVirtualQueue { + name: "clusterq".into(), + threads_per_node: 1, + description: None, + }) + .await; + admin + .registry_create_object(MtaDeliverySchedule { + name: "cluster-autoreload".into(), + queue_id, + ..Default::default() + }) + .await; + for (node_id, test) in servers.iter().enumerate() { + let started = std::time::Instant::now(); + while !test + .server + .inner + .build_server() + .core + .smtp + .queue + .queue_strategy + .contains_key("cluster-autoreload") + { + assert!( + started.elapsed() < std::time::Duration::from_secs(5), + "node {node_id} didn't pick up the new delivery schedule" + ); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + println!( + "Node {node_id} has the new delivery schedule after {} ms", + started.elapsed().as_millis() + ); + } + // Run IMAP idle tests across nodes let mut node1_client = imap_client("jdoe@example.com", "this is john's secret", 1).await; let mut node2_client = imap_client("jdoe@example.com", "this is john's secret", 2).await; diff --git a/tests/src/smtp/inbound/mod.rs b/tests/src/smtp/inbound/mod.rs index a3048dc..a6ae1de 100644 --- a/tests/src/smtp/inbound/mod.rs +++ b/tests/src/smtp/inbound/mod.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::utils::server::TestServer; @@ -40,15 +42,23 @@ pub mod vrfy; const EVENT_TIMEOUT: Duration = Duration::from_secs(5); impl TestServer { + // inbuxa: registry writes reload the settings, and each reload sends the + // queue a ReloadSettings; read_event, try_read_event and assert_no_events + // pass over those (expect_reload_settings still waits for one) pub async fn read_event(&mut self) -> QueueEvent { - if let Some(event) = self.queue_events.pop_front() { - return event; + while let Some(event) = self.queue_events.pop_front() { + if !event.is_reload_settings() { + return event; + } } - match tokio::time::timeout(EVENT_TIMEOUT, self.queue_rx.recv()).await { - Ok(Some(event)) => event, - Ok(None) => panic!("Channel closed."), - Err(_) => panic!("No queue event received."), + loop { + match tokio::time::timeout(EVENT_TIMEOUT, self.queue_rx.recv()).await { + Ok(Some(event)) if event.is_reload_settings() => (), + Ok(Some(event)) => return event, + Ok(None) => panic!("Channel closed."), + Err(_) => panic!("No queue event received."), + } } } @@ -78,26 +88,39 @@ impl TestServer { } pub async fn try_read_event(&mut self) -> Option { - if let Some(event) = self.queue_events.pop_front() { - return Some(event); + while let Some(event) = self.queue_events.pop_front() { + if !event.is_reload_settings() { + return Some(event); + } } - match tokio::time::timeout(EVENT_TIMEOUT, self.queue_rx.recv()).await { - Ok(Some(event)) => Some(event), - Ok(None) => panic!("Channel closed."), - Err(_) => None, + loop { + match tokio::time::timeout(EVENT_TIMEOUT, self.queue_rx.recv()).await { + Ok(Some(event)) if event.is_reload_settings() => (), + Ok(Some(event)) => return Some(event), + Ok(None) => panic!("Channel closed."), + Err(_) => return None, + } } } pub fn assert_no_events(&mut self) { - if let Some(event) = self.queue_events.pop_front() { + if let Some(event) = self + .queue_events + .iter() + .find(|event| !event.is_reload_settings()) + { panic!("Expected empty queue but got {event:?}"); } + self.queue_events.clear(); - match self.queue_rx.try_recv() { - Err(TryRecvError::Empty) => (), - Ok(event) => panic!("Expected empty queue but got {event:?}"), - Err(err) => panic!("Queue error: {err:?}"), + loop { + match self.queue_rx.try_recv() { + Ok(event) if event.is_reload_settings() => (), + Err(TryRecvError::Empty) => break, + Ok(event) => panic!("Expected empty queue but got {event:?}"), + Err(err) => panic!("Queue error: {err:?}"), + } } } diff --git a/tests/src/system/auto_reload.rs b/tests/src/system/auto_reload.rs new file mode 100644 index 0000000..c5cc36a --- /dev/null +++ b/tests/src/system/auto_reload.rs @@ -0,0 +1,189 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// inbuxa: a registry write to an object the running settings are built from +// applies without an x:Action ReloadSettings, and the set response says so. + +use crate::utils::{ + jmap::JmapResponse, + server::{TestServer, TestServerBuilder}, +}; +use common::BuildServer; +use registry::schema::{ + enums::TracingLevel, + prelude::ObjectType, + structs::{ + CertificateManagement, DkimManagement, DnsManagement, Domain, Expression, + MtaDeliverySchedule, MtaStageAuth, MtaVirtualQueue, Tracer, TracerStdout, + }, +}; +use serde_json::Value; + +#[tokio::test(flavor = "multi_thread")] +pub async fn settings_reload_tests() { + let mut test = TestServerBuilder::new("settings_reload_tests") + .await + .with_default_listeners() + .await + .with_object(MtaStageAuth { + require: Expression { + else_: "false".to_string(), + ..Default::default() + }, + ..Default::default() + }) + .await + .build() + .await; + + let admin = test + .create_user_account( + "admin", + "admin@example.org", + "these_pretzels_are_making_me_thirsty", + &[], + "Admin", + ) + .await; + test.account("admin") + .assign_roles_to_account(admin.id(), &["user", "system"]) + .await; + test.insert_account(admin); + + test_write_applies(&test).await; + + if test.is_reset() { + test.temp_dir.delete(); + } +} + +async fn test_write_applies(test: &TestServer) { + println!("Running settings reload after registry writes..."); + let admin = test.account("admin@example.org"); + + // A delivery schedule is in use as soon as it is saved + let response = admin + .registry_create([MtaVirtualQueue { + name: "autorld".into(), + threads_per_node: 2, + description: None, + }]) + .await; + assert_applied(&response); + let queue_id = response.created_id(0); + assert!(!has_schedule(test, "autoreload-schedule")); + let response = admin + .registry_create([MtaDeliverySchedule { + name: "autoreload-schedule".into(), + queue_id, + ..Default::default() + }]) + .await; + assert_applied(&response); + assert!(has_schedule(test, "autoreload-schedule")); + + // Destroyed, it's gone at once too + let schedule_id = response.created_id(0); + let response = admin + .registry_destroy(ObjectType::MtaDeliverySchedule, [schedule_id]) + .await; + assert_applied(&response); + assert!(!has_schedule(test, "autoreload-schedule")); + + // Concurrent writes all end up in the running settings + let names = (0..8) + .map(|i| format!("autoreload-{i}")) + .collect::>(); + let mut writes = Vec::new(); + for name in &names { + writes.push(admin.registry_create([MtaDeliverySchedule { + name: name.clone(), + queue_id, + ..Default::default() + }])); + } + let mut schedule_ids = Vec::new(); + for response in futures::future::join_all(writes).await { + assert_applied(&response); + schedule_ids.push(response.created_id(0)); + } + for name in &names { + assert!(has_schedule(test, name), "{name} missing"); + } + // Several objects in one request: one reload + let response = admin + .registry_destroy(ObjectType::MtaDeliverySchedule, schedule_ids.iter()) + .await; + assert_applied(&response); + for name in &names { + assert!(!has_schedule(test, name), "{name} still present"); + } + + // A write whose reload fails is stored, and the response says the + // settings weren't reloaded: only one console tracer is allowed. + let response = admin + .registry_create([ + Tracer::Stdout(TracerStdout { + enable: true, + level: TracingLevel::Error, + ..Default::default() + }), + Tracer::Stdout(TracerStdout { + enable: true, + level: TracingLevel::Error, + ..Default::default() + }), + ]) + .await; + let reload = settings_reload(&response).expect("x:settingsReload missing"); + assert_eq!(reload["applied"], Value::Bool(false), "{response:?}"); + let description = reload["description"].as_str().unwrap_or_default(); + assert!( + description.starts_with("Saved, but the running settings were not reloaded. ") + && description.contains("Only one console tracer is allowed"), + "{description}" + ); + let tracer_ids = [response.created_id(0), response.created_id(1)]; + let response = admin + .registry_destroy(ObjectType::Tracer, tracer_ids.iter()) + .await; + assert_applied(&response); + + // Data that isn't part of the running settings doesn't reload them + let response = admin + .registry_create([Domain { + name: "autoreload.example.org".into(), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + ..Default::default() + }]) + .await; + assert!(settings_reload(&response).is_none(), "{response:?}"); +} + +fn settings_reload(response: &JmapResponse) -> Option<&Value> { + response.pointer("/methodResponses/0/1/x:settingsReload") +} + +fn assert_applied(response: &JmapResponse) { + assert_eq!( + settings_reload(response), + Some(&serde_json::json!({"applied": true})), + "{response:?}" + ); +} + +fn has_schedule(test: &TestServer, name: &str) -> bool { + test.server + .inner + .build_server() + .core + .smtp + .queue + .queue_strategy + .contains_key(name) +} diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index 8912e07..b48db1c 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -11,6 +11,7 @@ pub mod authentication; pub mod ai; pub mod ai_calibration; pub mod authorization; +pub mod auto_reload; // inbuxa: registry writes apply at once pub mod branding; pub mod crypto; pub mod delivery;