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}