Skip to main content

epics_base_rs/server/database/
scan_index.rs

1use std::collections::BTreeSet;
2
3use crate::server::record::{PiniMode, RecordInstance, ScanList, ScanType};
4
5use super::PvDatabase;
6
7/// The scan buckets themselves. `bucket` is private to THIS module, so no
8/// other file in `database` can open a bucket and write it — the only
9/// transitions are [`PvDatabase::add_to_scan_list`] and
10/// [`PvDatabase::delete_from_scan_list`], which is C's arrangement:
11/// `addToList`/`deleteFromList` are `static` in `dbScan.c` and `scanAdd` /
12/// `scanDelete` (`dbScan.c:240`/`:308` at R7.0.10) are the whole public
13/// surface.
14pub(super) struct ScanIndex {
15    /// One bucket per scan list. Sized from the LOADED `menuScan`
16    /// ([`ScanList::count`]), not from a compile-time rate list, and built on
17    /// first use rather than at construction: the site's `menuScan.dbd` is
18    /// loaded after the database object exists, so sizing this eagerly would
19    /// freeze the menu before the loader could install one. C reaches the same
20    /// point by ordering — `dbLoadDatabase`, then `iocInit` → `initPeriodic`
21    /// sizes `papPeriodic`.
22    buckets: std::sync::OnceLock<
23        Box<[crate::runtime::sync::PriorityInheritanceMutex<BTreeSet<super::ScanKey>>]>,
24    >,
25    /// Cumulative over-runs per list — C `periodic_scan_list::overruns`
26    /// (`dbScan.c:95`), which `scanppl` prints beside the list it belongs to
27    /// (`dbScan.c:408-409`). It lives here for the same reason C puts it on
28    /// `periodic_scan_list`: one owner per rate holds both the list and its
29    /// over-run count, so the counter cannot drift away from the list it
30    /// counts. Only the periodic scan threads write it.
31    overruns: std::sync::OnceLock<Box<[std::sync::atomic::AtomicU64]>>,
32}
33
34impl ScanIndex {
35    pub(super) fn new() -> Self {
36        Self {
37            buckets: std::sync::OnceLock::new(),
38            overruns: std::sync::OnceLock::new(),
39        }
40    }
41
42    /// The bucket holding `list`'s records. Total — see [`ScanList::slot`].
43    fn bucket(
44        &self,
45        list: ScanList,
46    ) -> &crate::runtime::sync::PriorityInheritanceMutex<BTreeSet<super::ScanKey>> {
47        &self
48            .buckets
49            .get_or_init(|| {
50                (0..ScanList::count())
51                    .map(|_| crate::runtime::sync::PriorityInheritanceMutex::new(BTreeSet::new()))
52                    .collect()
53            })
54            .as_ref()[list.slot()]
55    }
56
57    /// This list's over-run counter — the same lazy sizing as [`Self::bucket`].
58    fn overrun(&self, list: ScanList) -> &std::sync::atomic::AtomicU64 {
59        &self
60            .overruns
61            .get_or_init(|| {
62                (0..ScanList::count())
63                    .map(|_| std::sync::atomic::AtomicU64::new(0))
64                    .collect()
65            })
66            .as_ref()[list.slot()]
67    }
68}
69
70impl PvDatabase {
71    /// C `addToList` (`dbScan.c:1074` at R7.0.10) — the ONE place a record
72    /// enters a scan bucket.
73    ///
74    /// C keeps it `static` so that `scanAdd` is the only way in; the port's
75    /// equivalent is this being the only `scan_index.bucket(..).lock()` write
76    /// outside the readers. It takes the bucket lock itself, exactly as C
77    /// takes `psl->lock`, and takes no registration lock: L46 belongs to
78    /// whichever owner called — [`Self::update_scan_index`], which acquires it,
79    /// or `add_record`, which is already inside it. Both used to open-code the
80    /// bucket write instead, which is how a transition could bypass its owner.
81    ///
82    /// A SCAN that names no list (`Passive`, or an index outside `menuScan`)
83    /// keys no bucket — C `scanAdd` refuses the same two, the latter with
84    /// "scanAdd detected illegal SCAN value".
85    pub(super) fn add_to_scan_list(
86        &self,
87        scan: ScanType,
88        phas: i16,
89        record_type: &str,
90        load_order: u64,
91        name: &str,
92    ) {
93        let Some(list) = scan.scan_list() else {
94            return;
95        };
96        self.inner
97            .scan_index
98            .bucket(list)
99            .lock()
100            .insert(super::ScanKey::new(phas, record_type, load_order, name));
101    }
102
103    /// C `deleteFromList` (`dbScan.c:1096` at R7.0.10) — the ONE place a
104    /// record leaves a scan bucket. Same ownership rules as
105    /// [`Self::add_to_scan_list`].
106    ///
107    /// Matches by record name alone: PHAS and load-order may be stale relative
108    /// to the entry actually present, and a stale secondary key would leave a
109    /// phantom entry behind.
110    pub(super) fn delete_from_scan_list(&self, scan: ScanType, name: &str) {
111        let Some(list) = scan.scan_list() else {
112            return;
113        };
114        self.inner
115            .scan_index
116            .bucket(list)
117            .lock()
118            .retain(|k| k.name != name);
119    }
120
121    /// Update scan index when a record's SCAN or PHAS field changes.
122    ///
123    /// Takes `registration_mutex` so the read of
124    /// the records map (to verify the record still exists) and the
125    /// scan_index mutation are atomic vs. concurrent `remove_record`.
126    ///
127    /// The `new_scan` / `new_phas` parameters
128    /// the caller passes are advisory only. After acquiring the
129    /// mutex we read the LIVE record's current scan/phas and insert
130    /// based on those. Pre-fix a put-then-update sequence could
131    /// race a remove+re-add of the same name: the caller's
132    /// `new_scan` reflected the old (now-removed) record's value;
133    /// inserting that under the fresh record's name produced a
134    /// stale scan-index entry pointing at a wrong scan rate. The
135    /// live-read makes the index strictly reflect the record's
136    /// current state at insert time.
137    ///
138    /// **Synchronous.** It had exactly two suspension points and neither was a
139    /// real one: the `registration_mutex` acquisition (L46) and two
140    /// `scan_index` write acquisitions (L8b) — both awaited before step 4.
141    /// Both are blocking PI mutexes now, so the whole scan-index update is a
142    /// bounded critical
143    /// section — which is what lets it run *inside* the L1 record-gate window
144    /// without putting an `.await` there. C reaches `scanAdd`/`scanDelete`
145    /// (`dbScan.c:241-330`) the same way, from inside `dbPut` under
146    /// `dbScanLock`.
147    pub fn update_scan_index(
148        &self,
149        name: &str,
150        old_scan: ScanType,
151        _new_scan: ScanType,
152        old_phas: i16,
153        _new_phas: i16,
154    ) {
155        let _gate = self.lock_registration("update_scan_index");
156        let _ = old_phas; // entry matched by name; PHAS not needed.
157        // 1) Remove the OLD entry the caller knew about — even if
158        // remove_record already swept it.
159        self.delete_from_scan_list(old_scan, name);
160        // 2) Look up the LIVE record under the mutex. If concurrent
161        // remove+re-add replaced the Arc with a fresh one whose
162        // scan differs from the caller's `_new_scan`, we re-insert
163        // based on the fresh record's state. The fresh record's
164        // own `add_record` call also registered its scan index, so
165        // duplicate-insertion of the same (phas, name) pair into
166        // the same scan bucket is a no-op (`BTreeSet::insert`
167        // returns false on present key).
168        let rec_arc = match self.inner.records.read().get(name).cloned() {
169            Some(r) => r,
170            None => return,
171        };
172        let (cur_scan, cur_phas, cur_type) = {
173            let inst = rec_arc.read();
174            (
175                inst.common.scan,
176                inst.common.phas,
177                inst.record.record_type(),
178            )
179        };
180        // Re-use the record's existing load-order sequence so the scan-index
181        // secondary key stays stable across SCAN/PHAS edits. A record loaded
182        // before should always scan before a later-loaded record at the same
183        // PHAS.
184        let seq = self.inner.load_order.load().get(name).copied().unwrap_or(0);
185        self.add_to_scan_list(cur_scan, cur_phas, cur_type, seq, name);
186    }
187
188    /// Count one over-run for `scan`'s list — C `ppsl->overruns++`
189    /// (`dbScan.c:827`). The periodic scan thread for that rate is the only
190    /// caller; a SCAN value naming no list has no counter to move.
191    pub(crate) fn record_scan_overrun(&self, scan: ScanType) {
192        if let Some(list) = scan.scan_list() {
193            self.inner
194                .scan_index
195                .overrun(list)
196                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
197        }
198    }
199
200    /// How many times `list`'s sweep has run past its deadline since boot —
201    /// what C's `scanppl` prints as `(%lu over-runs)` (`dbScan.c:408-409`).
202    pub(crate) fn scan_overruns(&self, list: crate::server::record::ScanList) -> u64 {
203        self.inner
204            .scan_index
205            .overrun(list)
206            .load(std::sync::atomic::Ordering::Relaxed)
207    }
208
209    /// A **snapshot** of a scan list, in C's order: PHAS, then DBD
210    /// record-type order, then `.db` load order — see `ScanKey`. The stable
211    /// same-PHAS FIFO is `addToList`; the order it is a FIFO over is
212    /// `buildScanLists`, and both are in the key.
213    ///
214    /// For reporting and counting only (`scanppl`, `dbstat`). A sweep that
215    /// PROCESSES the list must not use this: `dbProcess` can change the SCAN
216    /// field of an arbitrary number of records, so a snapshot processes
217    /// records the list no longer holds. `Self::scan_list_once` is the sweep.
218    ///
219    /// A SCAN value that names no list (`Passive`, or an index outside
220    /// `menuScan`) holds no records — there is no such bucket to look up.
221    pub async fn records_for_scan(&self, scan_type: ScanType) -> Vec<String> {
222        let Some(list) = scan_type.scan_list() else {
223            return Vec::new();
224        };
225        // One bucket's lock, held for the clone alone. C `scanList`
226        // (`dbScan.c:1007-1051`) also releases `psl->lock` around every
227        // `dbProcess` — but it releases it holding a cursor INTO the list and
228        // re-reads `ellNext` under the lock on the next step, so the two are
229        // not the same construction and this must not be read as parity with
230        // it. Detaching from the list is only sound because nothing here
231        // processes; [`Self::scan_list_once`] is what C's `scanList` is.
232        self.inner
233            .scan_index
234            .bucket(list)
235            .lock()
236            .iter()
237            .map(|k| k.name.clone())
238            .collect()
239    }
240
241    /// The scan key `name` would be inserted under RIGHT NOW — its live PHAS,
242    /// record type and load-order sequence. `None` if the record is gone.
243    fn live_scan_key(&self, name: &str) -> Option<super::ScanKey> {
244        let rec = self.get_record_no_resolve(name)?;
245        let (phas, record_type) = {
246            let inst = rec.read();
247            (inst.common.phas, inst.record.record_type())
248        };
249        let seq = self.inner.load_order.load().get(name).copied().unwrap_or(0);
250        Some(super::ScanKey::new(phas, record_type, seq, name))
251    }
252
253    /// A cursor over `list` as it stands at each step — see [`ScanCursor`].
254    pub(crate) fn scan_cursor(&self, list: ScanList) -> ScanCursor {
255        ScanCursor { list, at: None }
256    }
257
258    /// Sweep one scan list, processing every record still in it — C `scanList`
259    /// (`dbScan.c:998-1051`), the single owner of "walk a scan list and
260    /// process it". Every driver goes through here: the periodic threads, and
261    /// the event lists, whose `eventCallback` (`dbScan.c:459-465`) is a bare
262    /// `scanList` call.
263    pub(crate) async fn scan_list_once(&self, list: ScanList) {
264        let mut cursor = self.scan_cursor(list);
265        while let Some(name) = cursor.next(self) {
266            let mut visited = std::collections::HashSet::new();
267            let _ = self.process_record_with_links(&name, &mut visited, 0).await;
268        }
269    }
270
271    /// Get all record names whose `PINI` is **exactly** `mode`.
272    ///
273    /// C matches the menu index with `!=` (`iocInit.c:598`
274    /// `if (precord->pini != pphase->pini) return;`), so each `menuPini`
275    /// choice selects a disjoint set of records driven by a *different* pass:
276    /// `YES` at `initialProcess()` (`iocInit.c:656`), `RUN`/`RUNNING`/`PAUSE`/
277    /// `PAUSED` from `piniProcessHook` (`iocInit.c:629-646`). A `PINI=RUN`
278    /// record must NOT be processed by the `YES` pass.
279    ///
280    /// Snapshot the records map under the outer
281    /// read lock, then drop it before fanning out per-record reads.
282    /// Pre-fix the outer `records.read()` lock was held across every
283    /// `rec.read().await` — under contention with a pending
284    /// `add_record` (which now takes the registration_mutex →
285    /// records.write()), startup could stall while every PINI
286    /// record was inspected serially.
287    pub async fn pini_records(&self, mode: PiniMode) -> Vec<String> {
288        let mut result = Vec::new();
289        for (name, rec) in self.records_in_load_order().await {
290            if rec.read().common.pini == mode.to_u16() as i16 {
291                result.push(name);
292            }
293        }
294        result
295    }
296
297    /// Every record, in database **load order** — the port's analogue of C's
298    /// `iterateRecords`, which walks the record-type / record-instance lists in
299    /// the order the `.db` declared them. That order is what makes two
300    /// same-`PHAS` records process deterministically; `load_order` is one of
301    /// the `scan_index` sort keys for the same reason (see `ScanKey`, which
302    /// also carries the record-type ordinal C's `iterateRecords` walks first —
303    /// this PINI sweep does not, and that is a separate gap).
304    ///
305    /// Snapshots the map under the records read lock and releases it before the
306    /// caller takes any per-record lock.
307    async fn records_in_load_order(
308        &self,
309    ) -> Vec<(String, std::sync::Arc<parking_lot::RwLock<RecordInstance>>)> {
310        let snapshot: Vec<_> = {
311            let records = self.inner.records.read();
312            records
313                .iter()
314                .map(|(n, r)| (n.clone(), r.clone()))
315                .collect()
316        };
317        let mut keyed: Vec<_> = {
318            let load_order = self.inner.load_order.load();
319            snapshot
320                .into_iter()
321                .map(|(name, rec)| (load_order.get(&name).copied().unwrap_or(0), name, rec))
322                .collect()
323        };
324        keyed.sort_unstable_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1)));
325        keyed
326            .into_iter()
327            .map(|(_seq, name, rec)| (name, rec))
328            .collect()
329    }
330
331    /// C `piniProcess` (`iocInit.c:608-627`) — process every record whose
332    /// `PINI` is exactly `mode`, **in ascending `PHAS` order**, each with its
333    /// full link chain.
334    ///
335    /// The single owner of "run a PINI pass": `initialProcess()` calls it with
336    /// [`PiniMode::Yes`], and the `initHook` lifecycle calls it with
337    /// [`PiniMode::Run`] / [`PiniMode::Running`]. Every driver goes through
338    /// here, so neither the pass selection nor the phase ordering can diverge
339    /// between them.
340    ///
341    /// The sweep is C's, not a pre-sorted list: each pass over the database
342    /// processes the records at the current phase and, while doing so, finds
343    /// the *next* lowest `PHAS` still ahead of it. C spells this out
344    /// (`iocInit.c:614-619`) — "PHAS fields can be changed at runtime, so we
345    /// have to look for the lowest value of PHAS each time" — so a record whose
346    /// phase is raised by an earlier PINI record's processing is still picked
347    /// up in the correct later pass, which a snapshot-and-sort would miss.
348    pub async fn pini_process(&self, mode: PiniMode) {
349        // C `dbScan.h:34-35`: MAX_PHASE = SHRT_MAX, MIN_PHASE = SHRT_MIN. The
350        // phase cursors are `int` (`phaseData_t`), so `MAX_PHASE + 1` — the
351        // "no further phase found" sentinel — does not overflow.
352        const MIN_PHASE: i32 = i16::MIN as i32;
353        const NO_NEXT_PHASE: i32 = i16::MAX as i32 + 1;
354
355        let mut next = MIN_PHASE;
356        loop {
357            let this = next;
358            next = NO_NEXT_PHASE;
359            // `doRecordPini` (`iocInit.c:592-606`) over the record list. PINI
360            // and PHAS are read at the moment the record is visited, not
361            // snapshotted up front, so a PHAS that an earlier record's
362            // processing changed is honoured — the reason C re-scans rather
363            // than sorting once.
364            for (name, rec) in self.records_in_load_order().await {
365                let (pini, phas) = {
366                    let instance = rec.read();
367                    (instance.common.pini, i32::from(instance.common.phas))
368                };
369                if pini != mode.to_u16() as i16 {
370                    continue;
371                }
372                if phas == this {
373                    let mut visited = std::collections::HashSet::new();
374                    let _ = self.process_record_with_links(&name, &mut visited, 0).await;
375                } else if phas > this && phas < next {
376                    next = phas;
377                }
378            }
379            if next == NO_NEXT_PHASE {
380                return;
381            }
382        }
383    }
384
385    /// Process all records with `SCAN=Event`, regardless of `EVNT`.
386    ///
387    /// Back-compat entry point for the iocsh `postEvent` command,
388    /// whose handler currently drops the numeric event argument.
389    /// Prefer [`Self::post_event_named`] for C-correct per-event
390    /// routing — see `dbScan.c:548-552` `post_event` →
391    /// `postEvent(pevent_list[event])`.
392    pub async fn post_event(&self) {
393        // C `postEvent` (`dbScan.c:536-539`): an event posted while the
394        // facility is not running queues nothing at all.
395        if !crate::server::scan::scan_is_running() {
396            return;
397        }
398        if let Some(list) = ScanType::Event.scan_list() {
399            self.scan_list_once(list).await;
400        }
401    }
402
403    /// Process only the `SCAN=Event` records whose `EVNT` resolves to
404    /// `event_name`. Mirrors C `dbScan.c` event routing: each
405    /// `event_list` (`eventNameToHandle`) holds exactly the records
406    /// whose `EVNT` matches, and `postEvent` walks only that list.
407    ///
408    /// Event-name matching follows `eventNameToHandle` (`dbScan.c:469`):
409    /// surrounding whitespace is trimmed, and a numeric string with an
410    /// integer part in `[1,255]` is normalised to its integer form so
411    /// `"5"`, `" 5 "` and `"5.0"` all name the same event.
412    pub async fn post_event_named(&self, event_name: &str) {
413        // C `postEvent` (`dbScan.c:536-539`), reached through
414        // `post_event`/`eventNameToHandle` — the same gate, applied before
415        // the name lookup because C applies it before touching the list.
416        if !crate::server::scan::scan_is_running() {
417            return;
418        }
419        let want = normalize_event_name(event_name);
420        if want.is_empty() {
421            // `eventNameToHandle` returns NULL for "0"/empty — no event.
422            return;
423        }
424        let Some(list) = ScanType::Event.scan_list() else {
425            return;
426        };
427        // Same live cursor as every other sweep: C keeps one `scan_list` per
428        // (event, priority) and `eventCallback` hands it to `scanList`
429        // (`dbScan.c:459-465`), so a record whose SCAN changes mid-sweep leaves
430        // the walk here exactly as it does on a periodic list. The port keeps
431        // one Event list and filters by EVNT at the cursor instead.
432        let mut cursor = self.scan_cursor(list);
433        while let Some(name) = cursor.next(self) {
434            // Read the record's EVNT and compare against the posted
435            // event name. Records that do not match are skipped — a
436            // record configured `EVNT=5` only fires on event 5.
437            let evnt = match self.get_record(&name) {
438                Some(rec) => rec.read().common.evnt.clone(),
439                None => continue,
440            };
441            if normalize_event_name(&evnt) != want {
442                continue;
443            }
444            let mut visited = std::collections::HashSet::new();
445            let _ = self.process_record_with_links(&name, &mut visited, 0).await;
446        }
447    }
448}
449
450/// A cursor over a LIVE scan list — C `scanList`'s `pse` (`dbScan.c:998-1051`).
451///
452/// The invariant: **the sweep observes the list, it does not own a copy of it.**
453/// C re-reads `ellNext(&pse->node)` under `psl->lock` on every step and carries
454/// a cursor-repair walk that exists precisely because `dbProcess` can change
455/// the SCAN field of an arbitrary number of records mid-sweep. A snapshot taken
456/// once at the top of the tick processes records the list no longer holds.
457///
458/// The port's list is an ordered set, not a linked list, so "my element left
459/// the list" has one answer instead of C's three: the next key strictly greater
460/// than where the cursor stood. That subsumes C's prev/next repair
461/// (`dbScan.c:1030-1044`) — and, because the position is a key rather than a
462/// pointer, it has no counterpart to C's "too many changes, wait till the next
463/// period" (`:1045-1048`), which is an artefact of losing the place rather than
464/// a scanning rule.
465///
466/// The step re-reads the last record's CURRENT key before advancing, so a
467/// record that moved within this list (its own processing changed its PHAS) is
468/// advanced from where it is now — C's `pse->pscan_list == psl` branch
469/// (`:1023-1029`).
470pub(crate) struct ScanCursor {
471    list: ScanList,
472    /// Where the cursor stands: the key of the record it last handed out.
473    at: Option<super::ScanKey>,
474}
475
476impl ScanCursor {
477    /// The next record still in the list, or `None` at its end.
478    ///
479    /// Takes the bucket lock for the step only, never across processing — C
480    /// holds `psl->lock` for the cursor step and releases it around every
481    /// `dbProcess`.
482    pub(crate) fn next(&mut self, db: &PvDatabase) -> Option<String> {
483        use std::ops::Bound;
484
485        let resume = match self.at.take() {
486            None => None,
487            Some(was) => {
488                let live = db.live_scan_key(&was.name);
489                let moved_but_present = match &live {
490                    Some(k) => db.inner.scan_index.bucket(self.list).lock().contains(k),
491                    None => false,
492                };
493                Some(if moved_but_present {
494                    live.expect("checked present")
495                } else {
496                    was
497                })
498            }
499        };
500
501        let next = {
502            let bucket = db.inner.scan_index.bucket(self.list).lock();
503            match &resume {
504                None => bucket.iter().next().cloned(),
505                Some(at) => bucket
506                    .range((Bound::Excluded(at.clone()), Bound::Unbounded))
507                    .next()
508                    .cloned(),
509            }
510        };
511        self.at = next.clone();
512        next.map(|k| k.name)
513    }
514}
515
516/// Normalise an EPICS event name for routing comparison.
517///
518/// Mirrors `dbScan.c::eventNameToHandle` (`dbScan.c:469-533`):
519/// * leading/trailing whitespace is stripped;
520/// * a string that parses as a number with an integer part in
521///   `[1,255]` is canonicalised to that integer's decimal form
522///   (so numeric events from calc records match symbolic "5");
523/// * `"0"` (and anything that resolves to event 0) becomes empty —
524///   C's `eventNameToHandle` returns NULL for event 0.
525pub(crate) fn normalize_event_name(name: &str) -> String {
526    let trimmed = name.trim();
527    if trimmed.is_empty() {
528        return String::new();
529    }
530    if let Ok(num) = trimmed.parse::<f64>() {
531        if num >= 0.0 && num < 256.0 {
532            let int = num as i64;
533            if int < 1 {
534                // event 0 → no event
535                return String::new();
536            }
537            return int.to_string();
538        }
539        // Numeric but outside [0,256): fall through to literal match.
540    }
541    trimmed.to_string()
542}
543
544#[cfg(test)]
545mod tests {
546    use super::PvDatabase;
547    use super::normalize_event_name;
548    use crate::server::record::ScanType;
549
550    /// The ordering rule, stated as the thing that must hold rather than as
551    /// the record shape that once broke it: **`update_scan_index` takes L46
552    /// itself, so no caller may hold L46 when calling it.**
553    ///
554    /// A caller that breaks it used to park on itself forever, because
555    /// `PriorityInheritanceMutex` is not reentrant. The failure reached CI as
556    /// a 120-second timeout on whatever test happened to register a record —
557    /// a shape that reads as a load flake and hides which caller is at fault.
558    /// This pins the replacement: the violating call panics, immediately, and
559    /// names both ends.
560    ///
561    /// Deliberately expressed with a bare `lock_registration` + direct
562    /// `update_scan_index` pair and no record, no SIML and no SCAN field: the
563    /// rule belongs to the caller/owner contract, not to the one composition
564    /// (`add_loaded_record` → `rec_gbl_init_simm` → `apply_simm_scan_swap`)
565    /// that first exposed it. Any future tail added under a registration gate
566    /// trips this the same way.
567    #[test]
568    fn a_caller_holding_l46_cannot_reach_the_scan_index_owner() {
569        let db = PvDatabase::new();
570        let held = db.lock_registration("a_test_standing_in_for_a_registration_entry_point");
571
572        let violation = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
573            db.update_scan_index("ANY", ScanType::Passive, ScanType::SEC01, 0, 0);
574        }));
575
576        let payload = violation
577            .expect_err("holding L46 across update_scan_index must panic, not park the thread");
578        let msg = payload
579            .downcast_ref::<String>()
580            .map(String::as_str)
581            .or_else(|| payload.downcast_ref::<&str>().copied())
582            .unwrap_or("");
583        assert!(
584            msg.contains("not reentrant") && msg.contains("update_scan_index"),
585            "the panic must name the rule and the violating site, got: {msg}"
586        );
587        drop(held);
588    }
589
590    /// The other side of the same boundary — with no gate held, the owner
591    /// takes L46 itself and completes. Without this case the test above would
592    /// still pass if `update_scan_index` panicked unconditionally.
593    #[test]
594    fn the_scan_index_owner_takes_l46_itself_when_no_caller_holds_it() {
595        let db = PvDatabase::new();
596        db.update_scan_index("ANY", ScanType::Passive, ScanType::SEC01, 0, 0);
597    }
598
599    /// The gate is released on drop, including by unwinding out of a panic
600    /// between acquisitions. A leaked flag would make every later
601    /// registration on this thread panic and turn the tripwire into its own
602    /// outage.
603    #[test]
604    fn the_registration_gate_clears_on_drop_and_on_unwind() {
605        let db = PvDatabase::new();
606        drop(db.lock_registration("first"));
607        let _second = db.lock_registration("second");
608        drop(_second);
609
610        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
611            let _g = db.lock_registration("panics_while_held");
612            panic!("unwind with the gate live");
613        }));
614        drop(db.lock_registration("after_unwind"));
615    }
616
617    #[test]
618    fn event_name_numeric_normalisation() {
619        // Whitespace trimmed, numeric forms canonicalised.
620        assert_eq!(normalize_event_name(" 5 "), "5");
621        assert_eq!(normalize_event_name("5.0"), "5");
622        assert_eq!(normalize_event_name("5"), "5");
623        // Event 0 / empty → no event.
624        assert_eq!(normalize_event_name("0"), "");
625        assert_eq!(normalize_event_name(""), "");
626        assert_eq!(normalize_event_name("   "), "");
627        // Symbolic name preserved.
628        assert_eq!(normalize_event_name("myEvent"), "myEvent");
629        assert_eq!(normalize_event_name(" myEvent "), "myEvent");
630        // Numeric out of [0,256) is treated literally.
631        assert_eq!(normalize_event_name("999"), "999");
632    }
633}