Skip to main content

bacnet_server/
fault_detection.rs

1//! Fault detection / reliability evaluation.
2//!
3//! The [`FaultDetector`] periodically invokes each object's opt-in,
4//! object-owned reliability evaluation hook.
5
6use std::collections::HashMap;
7use std::sync::Mutex;
8
9use bacnet_objects::database::ObjectDatabase;
10use bacnet_objects::traits::ReliabilityEvaluation;
11use bacnet_types::enums::Reliability;
12use bacnet_types::primitives::ObjectIdentifier;
13
14/// A reliability change detected by the fault detector.
15#[derive(Debug, Clone, PartialEq)]
16pub struct ReliabilityChange {
17    /// The object whose reliability changed.
18    pub object_id: ObjectIdentifier,
19    /// Previous reliability value.
20    pub old_reliability: Reliability,
21    /// New reliability value.
22    pub new_reliability: Reliability,
23}
24
25/// Fault detection engine.
26///
27/// Call [`FaultDetector::evaluate`] periodically (the bundled server uses a
28/// 10-second interval) against the object database. Every object is visited;
29/// objects opt in by overriding
30/// [`BACnetObject::evaluate_reliability_internal`](bacnet_objects::traits::BACnetObject::evaluate_reliability_internal).
31/// The default object hook is a no-op.
32pub struct FaultDetector {
33    /// Timeout after which an object is considered to have a communication
34    /// failure. Set to `None` to disable communication-failure detection.
35    pub comm_timeout: Option<std::time::Duration>,
36    warned_internal_failures: Mutex<HashMap<ObjectIdentifier, String>>,
37}
38
39impl Default for FaultDetector {
40    fn default() -> Self {
41        Self {
42            comm_timeout: Some(std::time::Duration::from_secs(60)),
43            warned_internal_failures: Mutex::new(HashMap::new()),
44        }
45    }
46}
47
48impl FaultDetector {
49    /// Create a new fault detector with the given communication timeout.
50    pub fn new(comm_timeout: Option<std::time::Duration>) -> Self {
51        Self {
52            comm_timeout,
53            warned_internal_failures: Mutex::new(HashMap::new()),
54        }
55    }
56
57    fn should_warn_internal_failure(&self, oid: ObjectIdentifier, error: &str) -> bool {
58        let mut warned = self
59            .warned_internal_failures
60            .lock()
61            .unwrap_or_else(|poisoned| poisoned.into_inner());
62        if warned.get(&oid).is_some_and(|previous| previous == error) {
63            false
64        } else {
65            warned.insert(oid, error.to_owned());
66            true
67        }
68    }
69
70    fn clear_internal_failure(&self, oid: &ObjectIdentifier) {
71        self.warned_internal_failures
72            .lock()
73            .unwrap_or_else(|poisoned| poisoned.into_inner())
74            .remove(oid);
75    }
76
77    fn prune_internal_failures(&self, db: &ObjectDatabase) {
78        self.warned_internal_failures
79            .lock()
80            .unwrap_or_else(|poisoned| poisoned.into_inner())
81            .retain(|oid, _| db.get(oid).is_some());
82    }
83
84    /// Evaluate reliability for all objects in the database.
85    ///
86    /// Each object owns both the decision and any mutation. Engineering
87    /// `Min_Pres_Value` / `Max_Pres_Value` metadata is not a reliability fault
88    /// algorithm. Only a successful hook result of
89    /// [`ReliabilityEvaluation::Changed`] produces a [`ReliabilityChange`].
90    /// Hook errors are rate-limited per object and produce no change record.
91    ///
92    /// Returns a list of changes that object hooks successfully applied.
93    pub fn evaluate(&self, db: &mut ObjectDatabase) -> Vec<ReliabilityChange> {
94        let mut changes = Vec::new();
95        db.for_each_object_mut(|oid, object| {
96            if object.reliability_evaluation_inhibited_internal() {
97                self.clear_internal_failure(&oid);
98                return;
99            }
100            match object.evaluate_reliability_internal() {
101                Ok(ReliabilityEvaluation::Unchanged) => {
102                    self.clear_internal_failure(&oid);
103                }
104                Ok(ReliabilityEvaluation::Changed {
105                    old_reliability,
106                    new_reliability,
107                }) => {
108                    self.clear_internal_failure(&oid);
109                    changes.push(ReliabilityChange {
110                        object_id: oid,
111                        old_reliability,
112                        new_reliability,
113                    });
114                }
115                Err(error) => {
116                    let error_text = error.to_string();
117                    if self.should_warn_internal_failure(oid, &error_text) {
118                        tracing::warn!(
119                            object = %oid,
120                            error = %error,
121                            "Fault detection object-owned reliability evaluation failed"
122                        );
123                    }
124                }
125            }
126        });
127        self.prune_internal_failures(db);
128        changes
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use std::borrow::Cow;
135    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
136    use std::sync::Arc;
137
138    use bacnet_objects::analog::{AnalogInputObject, AnalogOutputObject, AnalogValueObject};
139    use bacnet_objects::traits::BACnetObject;
140    use bacnet_types::enums::{EventState, ObjectType, PropertyIdentifier};
141    use bacnet_types::error::Error;
142    use bacnet_types::primitives::PropertyValue;
143    use tracing::span::{Attributes, Id, Record};
144    use tracing::{Event, Level, Metadata, Subscriber};
145
146    use super::*;
147
148    const HOOK_ERROR: &str = "test reliability hook failed";
149
150    struct WarningCounter {
151        warnings: Arc<AtomicUsize>,
152    }
153
154    impl Subscriber for WarningCounter {
155        fn enabled(&self, metadata: &Metadata<'_>) -> bool {
156            *metadata.level() == Level::WARN
157        }
158
159        fn new_span(&self, _span: &Attributes<'_>) -> Id {
160            Id::from_u64(1)
161        }
162
163        fn record(&self, _span: &Id, _values: &Record<'_>) {}
164
165        fn record_follows_from(&self, _span: &Id, _follows: &Id) {}
166
167        fn event(&self, event: &Event<'_>) {
168            if *event.metadata().level() == Level::WARN {
169                self.warnings.fetch_add(1, Ordering::SeqCst);
170            }
171        }
172
173        fn enter(&self, _span: &Id) {}
174
175        fn exit(&self, _span: &Id) {}
176    }
177
178    fn with_warning_counter(test: impl FnOnce(&AtomicUsize)) {
179        let warnings = Arc::new(AtomicUsize::new(0));
180        let subscriber = WarningCounter {
181            warnings: Arc::clone(&warnings),
182        };
183        tracing::subscriber::with_default(subscriber, || test(warnings.as_ref()));
184    }
185
186    fn read_reliability(db: &ObjectDatabase, oid: ObjectIdentifier) -> u32 {
187        match db
188            .get(&oid)
189            .unwrap()
190            .read_property(PropertyIdentifier::RELIABILITY, None)
191            .unwrap()
192        {
193            PropertyValue::Enumerated(value) => value,
194            other => panic!("expected Enumerated Reliability, got {other:?}"),
195        }
196    }
197
198    struct OptInReliabilityObject {
199        oid: ObjectIdentifier,
200        name: String,
201        reliability: Reliability,
202        target_reliability: Reliability,
203        fail: Arc<AtomicBool>,
204    }
205
206    impl OptInReliabilityObject {
207        fn new(
208            instance: u32,
209            name: &str,
210            target_reliability: Reliability,
211            fail: Arc<AtomicBool>,
212        ) -> Self {
213            Self {
214                oid: ObjectIdentifier::new(ObjectType::BINARY_INPUT, instance).unwrap(),
215                name: name.to_owned(),
216                reliability: Reliability::NO_FAULT_DETECTED,
217                target_reliability,
218                fail,
219            }
220        }
221    }
222
223    impl BACnetObject for OptInReliabilityObject {
224        fn object_identifier(&self) -> ObjectIdentifier {
225            self.oid
226        }
227
228        fn object_name(&self) -> &str {
229            &self.name
230        }
231
232        fn read_property(
233            &self,
234            property: PropertyIdentifier,
235            _array_index: Option<u32>,
236        ) -> Result<PropertyValue, Error> {
237            if property == PropertyIdentifier::RELIABILITY {
238                Ok(PropertyValue::Enumerated(self.reliability.to_raw()))
239            } else {
240                Err(Error::Encoding("test property is unsupported".into()))
241            }
242        }
243
244        fn write_property(
245            &mut self,
246            _property: PropertyIdentifier,
247            _array_index: Option<u32>,
248            _value: PropertyValue,
249            _priority: Option<u8>,
250        ) -> Result<(), Error> {
251            Err(Error::Encoding("test object is read-only".into()))
252        }
253
254        fn property_list(&self) -> Cow<'static, [PropertyIdentifier]> {
255            Cow::Borrowed(&[PropertyIdentifier::RELIABILITY])
256        }
257
258        fn evaluate_reliability_internal(&mut self) -> Result<ReliabilityEvaluation, Error> {
259            if self.fail.load(Ordering::SeqCst) {
260                return Err(Error::Encoding(HOOK_ERROR.into()));
261            }
262            if self.reliability == self.target_reliability {
263                return Ok(ReliabilityEvaluation::Unchanged);
264            }
265
266            let old_reliability = self.reliability;
267            self.reliability = self.target_reliability;
268            Ok(ReliabilityEvaluation::Changed {
269                old_reliability,
270                new_reliability: self.reliability,
271            })
272        }
273    }
274
275    struct InhibitedProbeObject {
276        oid: ObjectIdentifier,
277        calls: Arc<AtomicUsize>,
278    }
279
280    impl BACnetObject for InhibitedProbeObject {
281        fn object_identifier(&self) -> ObjectIdentifier {
282            self.oid
283        }
284
285        fn object_name(&self) -> &str {
286            "inhibited-probe"
287        }
288
289        fn read_property(
290            &self,
291            _property: PropertyIdentifier,
292            _array_index: Option<u32>,
293        ) -> Result<PropertyValue, Error> {
294            Err(Error::Encoding("test property is unsupported".into()))
295        }
296
297        fn write_property(
298            &mut self,
299            _property: PropertyIdentifier,
300            _array_index: Option<u32>,
301            _value: PropertyValue,
302            _priority: Option<u8>,
303        ) -> Result<(), Error> {
304            Err(Error::Encoding("test object is read-only".into()))
305        }
306
307        fn property_list(&self) -> Cow<'static, [PropertyIdentifier]> {
308            Cow::Borrowed(&[])
309        }
310
311        fn reliability_evaluation_inhibited_internal(&self) -> bool {
312            true
313        }
314
315        fn evaluate_reliability_internal(&mut self) -> Result<ReliabilityEvaluation, Error> {
316            self.calls.fetch_add(1, Ordering::SeqCst);
317            Err(Error::Encoding(HOOK_ERROR.into()))
318        }
319    }
320
321    #[test]
322    fn inhibited_object_skips_hook_and_clears_stale_failure_warning() {
323        let detector = FaultDetector::default();
324        let oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 77).unwrap();
325        let calls = Arc::new(AtomicUsize::new(0));
326        let mut db = ObjectDatabase::new();
327        db.add(Box::new(InhibitedProbeObject {
328            oid,
329            calls: Arc::clone(&calls),
330        }))
331        .unwrap();
332        assert!(detector.should_warn_internal_failure(oid, HOOK_ERROR));
333
334        assert!(detector.evaluate(&mut db).is_empty());
335        assert_eq!(calls.load(Ordering::SeqCst), 0, "inhibited hook was called");
336        assert!(
337            detector.should_warn_internal_failure(oid, HOOK_ERROR),
338            "inhibition must clear an old warning so recovery cannot leave stale state"
339        );
340    }
341
342    #[test]
343    fn identical_internal_failure_warning_is_suppressed_until_state_changes() {
344        let detector = FaultDetector::default();
345        let oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1).unwrap();
346
347        assert!(detector.should_warn_internal_failure(oid, "first error"));
348        assert!(!detector.should_warn_internal_failure(oid, "first error"));
349        assert!(detector.should_warn_internal_failure(oid, "different error"));
350        assert!(!detector.should_warn_internal_failure(oid, "different error"));
351
352        detector.clear_internal_failure(&oid);
353        assert!(detector.should_warn_internal_failure(oid, "first error"));
354    }
355
356    #[test]
357    fn stock_analog_engineering_bounds_never_change_reliability() {
358        let mut db = ObjectDatabase::new();
359
360        let mut ai = AnalogInputObject::new(1, "AI-1", 62).unwrap();
361        ai.set_min_pres_value(0.0);
362        ai.set_max_pres_value(100.0);
363        ai.set_present_value(200.0);
364        let ai_oid = ai.object_identifier();
365        db.add(Box::new(ai)).unwrap();
366
367        let mut ao = AnalogOutputObject::new(1, "AO-1", 62).unwrap();
368        ao.set_min_pres_value(0.0);
369        ao.set_max_pres_value(100.0);
370        ao.write_property_from(
371            PropertyIdentifier::PRESENT_VALUE,
372            None,
373            PropertyValue::Real(150.0),
374            Some(8),
375            &crate::command_source::test_origin(),
376        )
377        .unwrap();
378        let ao_oid = ao.object_identifier();
379        db.add(Box::new(ao)).unwrap();
380
381        let mut av = AnalogValueObject::new(1, "AV-1", 62).unwrap();
382        av.set_min_pres_value(0.0);
383        av.set_max_pres_value(100.0);
384        av.set_relinquish_default(-10.0).unwrap();
385        let av_oid = av.object_identifier();
386        db.add(Box::new(av)).unwrap();
387
388        let changes = FaultDetector::default().evaluate(&mut db);
389
390        assert!(changes.is_empty());
391        for oid in [ai_oid, ao_oid, av_oid] {
392            assert_eq!(
393                read_reliability(&db, oid),
394                Reliability::NO_FAULT_DETECTED.to_raw()
395            );
396        }
397        let ao = db.get(&ao_oid).unwrap();
398        assert_eq!(
399            ao.read_property(PropertyIdentifier::PRESENT_VALUE, None)
400                .unwrap(),
401            PropertyValue::Real(150.0)
402        );
403        assert_eq!(
404            ao.read_property(PropertyIdentifier::PRIORITY_ARRAY, Some(8))
405                .unwrap(),
406            PropertyValue::Real(150.0)
407        );
408    }
409
410    #[test]
411    fn configured_ai_and_av_changes_are_recorded_and_seen_by_intrinsic_fault_observation() {
412        let mut db = ObjectDatabase::new();
413
414        let mut ai = AnalogInputObject::new(1, "AI-1", 62).unwrap();
415        ai.configure_fault_out_of_range(10.0, 20.0).unwrap();
416        ai.set_present_value(21.0);
417        let ai_oid = ai.object_identifier();
418        db.add(Box::new(ai)).unwrap();
419
420        let mut av = AnalogValueObject::new(1, "AV-1", 62).unwrap();
421        av.configure_fault_out_of_range(10.0, 20.0).unwrap();
422        av.write_property_from(
423            PropertyIdentifier::PRESENT_VALUE,
424            None,
425            PropertyValue::Real(9.0),
426            Some(8),
427            &crate::command_source::test_origin(),
428        )
429        .unwrap();
430        let av_oid = av.object_identifier();
431        db.add(Box::new(av)).unwrap();
432
433        let detector = FaultDetector::default();
434        let changes = detector.evaluate(&mut db);
435        assert_eq!(changes.len(), 2);
436        assert!(changes.contains(&ReliabilityChange {
437            object_id: ai_oid,
438            old_reliability: Reliability::NO_FAULT_DETECTED,
439            new_reliability: Reliability::OVER_RANGE,
440        }));
441        assert!(changes.contains(&ReliabilityChange {
442            object_id: av_oid,
443            old_reliability: Reliability::NO_FAULT_DETECTED,
444            new_reliability: Reliability::UNDER_RANGE,
445        }));
446        assert!(detector.evaluate(&mut db).is_empty());
447
448        for oid in [ai_oid, av_oid] {
449            let proposal = db
450                .get_mut(&oid)
451                .unwrap()
452                .evaluate_intrinsic_reporting()
453                .expect("range Reliability must be observed as an intrinsic fault");
454            assert_eq!(proposal.change.from, EventState::NORMAL);
455            assert_eq!(proposal.change.to, EventState::FAULT);
456        }
457    }
458
459    // The public sensor setters intentionally retain their debug assertions.
460    // This release-only test exercises the production path where those setters
461    // can receive non-finite application data without changing their API.
462    #[cfg(not(debug_assertions))]
463    #[test]
464    fn non_finite_configured_input_create_no_change_and_keep_owned_faults() {
465        let mut ai = AnalogInputObject::new(1, "AI-1", 62).unwrap();
466        ai.configure_fault_out_of_range(10.0, 20.0).unwrap();
467        ai.set_present_value(21.0);
468        ai.evaluate_reliability_internal().unwrap();
469        ai.set_present_value(f32::NAN);
470        let ai_oid = ai.object_identifier();
471
472        let mut db = ObjectDatabase::new();
473        db.add(Box::new(ai)).unwrap();
474        let detector = FaultDetector::default();
475
476        assert!(detector.evaluate(&mut db).is_empty());
477        assert!(detector.evaluate(&mut db).is_empty());
478        assert_eq!(
479            read_reliability(&db, ai_oid),
480            Reliability::OVER_RANGE.to_raw()
481        );
482
483        let ai = db.get_mut(&ai_oid).unwrap();
484        ai.write_property(
485            PropertyIdentifier::OUT_OF_SERVICE,
486            None,
487            PropertyValue::Boolean(true),
488            None,
489        )
490        .unwrap();
491        ai.write_property_from(
492            PropertyIdentifier::PRESENT_VALUE,
493            None,
494            PropertyValue::Real(9.0),
495            None,
496            &crate::command_source::test_origin(),
497        )
498        .unwrap();
499        ai.write_property(
500            PropertyIdentifier::OUT_OF_SERVICE,
501            None,
502            PropertyValue::Boolean(false),
503            None,
504        )
505        .unwrap();
506        let changes = detector.evaluate(&mut db);
507        assert_eq!(changes.len(), 1);
508        assert!(changes.contains(&ReliabilityChange {
509            object_id: ai_oid,
510            old_reliability: Reliability::OVER_RANGE,
511            new_reliability: Reliability::UNDER_RANGE,
512        }));
513    }
514
515    #[test]
516    fn existing_nonzero_in_service_reliability_is_preserved() {
517        let mut ai = AnalogInputObject::new(1, "AI-1", 62).unwrap();
518        ai.set_min_pres_value(0.0);
519        ai.set_max_pres_value(100.0);
520        ai.set_present_value(50.0);
521        ai.set_reliability_internal(Reliability::NO_SENSOR).unwrap();
522        let oid = ai.object_identifier();
523        let mut db = ObjectDatabase::new();
524        db.add(Box::new(ai)).unwrap();
525
526        assert!(FaultDetector::default().evaluate(&mut db).is_empty());
527        assert_eq!(read_reliability(&db, oid), Reliability::NO_SENSOR.to_raw());
528    }
529
530    #[test]
531    fn out_of_service_client_write_and_restore_remain_object_owned() {
532        let mut ai = AnalogInputObject::new(1, "AI-1", 62).unwrap();
533        ai.set_reliability_internal(Reliability::OVER_RANGE)
534            .unwrap();
535        let oid = ai.object_identifier();
536        let mut db = ObjectDatabase::new();
537        db.add(Box::new(ai)).unwrap();
538
539        let object = db.get_mut(&oid).unwrap();
540        object
541            .write_property(
542                PropertyIdentifier::OUT_OF_SERVICE,
543                None,
544                PropertyValue::Boolean(true),
545                None,
546            )
547            .unwrap();
548        object
549            .write_property(
550                PropertyIdentifier::RELIABILITY,
551                None,
552                PropertyValue::Enumerated(Reliability::NO_SENSOR.to_raw()),
553                None,
554            )
555            .unwrap();
556
557        assert!(FaultDetector::default().evaluate(&mut db).is_empty());
558        assert_eq!(read_reliability(&db, oid), Reliability::NO_SENSOR.to_raw());
559
560        db.get_mut(&oid)
561            .unwrap()
562            .write_property(
563                PropertyIdentifier::OUT_OF_SERVICE,
564                None,
565                PropertyValue::Boolean(false),
566                None,
567            )
568            .unwrap();
569        assert_eq!(read_reliability(&db, oid), Reliability::OVER_RANGE.to_raw());
570    }
571
572    #[test]
573    fn only_hook_returned_changed_creates_a_change_record() {
574        let fail = Arc::new(AtomicBool::new(false));
575        let object = OptInReliabilityObject::new(1, "custom-hook", Reliability::NO_SENSOR, fail);
576        let oid = object.object_identifier();
577        let mut db = ObjectDatabase::new();
578        db.add(Box::new(object)).unwrap();
579        let detector = FaultDetector::default();
580        assert!(detector.should_warn_internal_failure(oid, HOOK_ERROR));
581
582        assert_eq!(
583            detector.evaluate(&mut db),
584            vec![ReliabilityChange {
585                object_id: oid,
586                old_reliability: Reliability::NO_FAULT_DETECTED,
587                new_reliability: Reliability::NO_SENSOR,
588            }]
589        );
590        assert_eq!(read_reliability(&db, oid), Reliability::NO_SENSOR.to_raw());
591        assert!(detector.should_warn_internal_failure(oid, HOOK_ERROR));
592        assert!(detector.evaluate(&mut db).is_empty());
593        assert!(detector.should_warn_internal_failure(oid, HOOK_ERROR));
594    }
595
596    #[test]
597    fn failing_hook_warning_is_rate_limited_and_restored_after_recovery() {
598        let fail = Arc::new(AtomicBool::new(true));
599        let object = OptInReliabilityObject::new(
600            1,
601            "failing-hook",
602            Reliability::NO_SENSOR,
603            Arc::clone(&fail),
604        );
605        let oid = object.object_identifier();
606        let mut db = ObjectDatabase::new();
607        db.add(Box::new(object)).unwrap();
608        let detector = FaultDetector::default();
609
610        with_warning_counter(|warnings| {
611            assert!(detector.evaluate(&mut db).is_empty());
612            assert_eq!(warnings.load(Ordering::SeqCst), 1);
613            assert_eq!(
614                read_reliability(&db, oid),
615                Reliability::NO_FAULT_DETECTED.to_raw()
616            );
617
618            assert!(detector.evaluate(&mut db).is_empty());
619            assert_eq!(warnings.load(Ordering::SeqCst), 1);
620
621            fail.store(false, Ordering::SeqCst);
622            assert_eq!(
623                detector.evaluate(&mut db),
624                vec![ReliabilityChange {
625                    object_id: oid,
626                    old_reliability: Reliability::NO_FAULT_DETECTED,
627                    new_reliability: Reliability::NO_SENSOR,
628                }]
629            );
630            assert_eq!(warnings.load(Ordering::SeqCst), 1);
631
632            fail.store(true, Ordering::SeqCst);
633            assert!(detector.evaluate(&mut db).is_empty());
634            assert_eq!(warnings.load(Ordering::SeqCst), 2);
635
636            fail.store(false, Ordering::SeqCst);
637            assert!(detector.evaluate(&mut db).is_empty());
638            assert_eq!(warnings.load(Ordering::SeqCst), 2);
639
640            fail.store(true, Ordering::SeqCst);
641            assert!(detector.evaluate(&mut db).is_empty());
642            assert_eq!(warnings.load(Ordering::SeqCst), 3);
643        });
644    }
645
646    #[test]
647    fn deleted_failure_warning_is_pruned_and_oid_reuse_warns() {
648        let fail = Arc::new(AtomicBool::new(true));
649        let object = OptInReliabilityObject::new(
650            1,
651            "removed-failing-hook",
652            Reliability::NO_FAULT_DETECTED,
653            Arc::clone(&fail),
654        );
655        let oid = object.object_identifier();
656        let mut db = ObjectDatabase::new();
657        db.add(Box::new(object)).unwrap();
658        let detector = FaultDetector::default();
659
660        with_warning_counter(|warnings| {
661            assert!(detector.evaluate(&mut db).is_empty());
662            assert_eq!(warnings.load(Ordering::SeqCst), 1);
663
664            assert!(detector.evaluate(&mut db).is_empty());
665            assert_eq!(warnings.load(Ordering::SeqCst), 1);
666
667            assert!(db.remove(&oid).unwrap().is_some());
668            assert!(detector.evaluate(&mut db).is_empty());
669            assert_eq!(warnings.load(Ordering::SeqCst), 1);
670
671            db.add(Box::new(OptInReliabilityObject::new(
672                1,
673                "replacement-failing-hook",
674                Reliability::NO_FAULT_DETECTED,
675                fail,
676            )))
677            .unwrap();
678            assert!(detector.evaluate(&mut db).is_empty());
679            assert_eq!(warnings.load(Ordering::SeqCst), 2);
680        });
681    }
682}