use std::collections::VecDeque;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex, MutexGuard};
use std::time::Instant;
use super::PvDatabase;
pub const BKPT_ON_MASK: u8 = 0x01;
pub const BKPT_OFF_MASK: u8 = 0xFE;
pub const BKPT_PRINT_MASK: u8 = 0x02;
pub const BKPT_PRINT_OFF_MASK: u8 = 0xFD;
const MAX_EP_COUNT: u64 = 99_999;
#[derive(Debug, Clone)]
struct EntryPoint {
name: String,
count: u64,
first_seen: Instant,
scheduled: bool,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum Before {
Run,
Skip,
}
struct LockSet {
id: u64,
stopped_at: Option<String>,
current_ep: Option<String>,
breakpoints: Vec<String>,
ep_queue: Vec<EntryPoint>,
step: bool,
cont: Option<Arc<AtomicU64>>,
ex: Arc<BinarySemaphore>,
}
#[derive(Default)]
struct BinarySemaphore {
full: Mutex<bool>,
cv: Condvar,
}
impl BinarySemaphore {
fn signal(&self) {
let mut full = self.full.lock().expect("breakpoint semaphore poisoned");
*full = true;
self.cv.notify_one();
}
fn wait(&self) {
let mut full = self.full.lock().expect("breakpoint semaphore poisoned");
while !*full {
full = self.cv.wait(full).expect("breakpoint semaphore poisoned");
}
*full = false;
}
}
fn resume_continuation(cont: Option<&Arc<AtomicU64>>) {
let Some(taskid) = cont else { return };
let id = taskid.load(Ordering::Relaxed);
if id != 0 {
crate::runtime::task::resume_thread(id);
}
}
pub struct BreakpointTable {
stack: Mutex<Stack>,
printer: Mutex<Option<Arc<dyn Fn(&str) + Send + Sync>>>,
}
#[derive(Default)]
struct Stack {
sets: VecDeque<LockSet>,
last: Option<u64>,
}
thread_local! {
static CONTINUATION_FOR: std::cell::RefCell<Option<u64>> =
const { std::cell::RefCell::new(None) };
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum BkptError {
AlreadySet,
NotSet,
NotFound(String),
NotStopped,
NoneStopped,
Logic,
}
impl BkptError {
pub fn message(&self) -> String {
match self {
Self::AlreadySet => " BKPT> Breakpoint already set in this record".into(),
Self::NotSet => " BKPT> No breakpoint set in this record".into(),
Self::NotFound(name) => format!(" BKPT> Record {name} not found"),
Self::NotStopped => " BKPT> Currently not stopped in this lockset".into(),
Self::NoneStopped => " BKPT> No records are currently stopped".into(),
Self::Logic => " BKPT> Logic Error in dbd()".into(),
}
}
}
#[derive(Debug, PartialEq, Clone)]
pub enum StatLine {
LockSet {
id: u64,
stopped_at: Option<String>,
breakpoint_count: usize,
task: Option<u64>,
},
Entrypoint {
name: String,
count: u64,
elapsed_secs: f64,
},
Breakpoint { name: String, autoprint: bool },
}
impl StatLine {
pub fn render(&self) -> String {
match self {
Self::LockSet {
id,
stopped_at: Some(rec),
breakpoint_count,
task,
} => format!(
"LSet: {id} Stopped at: {:<28.28} #B: {:05} T: {}",
rec,
breakpoint_count,
render_task(*task),
),
Self::LockSet {
id,
stopped_at: None,
breakpoint_count,
task,
} => format!(
"LSet: {id} #B: {:05} T: {}",
breakpoint_count,
render_task(*task),
),
Self::Entrypoint {
name,
count,
elapsed_secs,
} => format!(
" Entrypoint: {:<28.28} #C: {:05} C/S: {:7.1}",
name, count, elapsed_secs
),
Self::Breakpoint { name, autoprint } => format!(
" Breakpoint: {:<28.28}{}",
name,
if *autoprint { " (ap)" } else { "" }
),
}
}
}
fn render_task(task: Option<u64>) -> String {
match task {
Some(id) => format!("0x{id:x}"),
None => "(nil)".into(),
}
}
fn lockset_id(db: &PvDatabase, record: &str) -> Option<u64> {
let name = canonical_record_name(db, record)?;
db.lock_set_of(&name).map(|set| set.id)
}
fn canonical_record_name(db: &PvDatabase, name: &str) -> Option<String> {
let name = db.resolve_alias(name).unwrap_or_else(|| name.to_string());
db.get_record_no_resolve(&name).map(|_| name)
}
impl BreakpointTable {
pub fn new() -> Self {
Self {
stack: Mutex::new(Stack::default()),
printer: Mutex::new(None),
}
}
pub fn set_printer(&self, printer: Arc<dyn Fn(&str) + Send + Sync>) {
*self.printer.lock().expect("breakpoint printer poisoned") = Some(printer);
}
fn lock(&self) -> MutexGuard<'_, Stack> {
self.stack.lock().expect("breakpoint stack poisoned")
}
pub fn is_empty(&self) -> bool {
self.lock().sets.is_empty()
}
pub fn before_process(&self, db: &PvDatabase, record: &str) -> Before {
{
let mut stack = self.lock();
let Some(idx) = stack.index_of_record(db, record) else {
return Before::Run;
};
let (disa, disv, pact) = match db.get_record(record) {
Some(rec) => {
let inst = rec.read();
(inst.common.disa, inst.common.disv, inst.is_processing())
}
None => return Before::Run,
};
if disa == disv {
return Before::Run;
}
let id = stack.sets[idx].id;
let mine = stack.sets[idx].cont.is_none()
|| CONTINUATION_FOR.with(|c| *c.borrow() == Some(id));
if !mine {
stack.sets[idx].note_entrypoint(record, !pact);
if !pact {
let ex = stack.sets[idx].ex.clone();
drop(stack);
ex.signal();
}
return Before::Skip;
}
if pact {
return Before::Skip;
}
if record_bkpt(db, record) & BKPT_ON_MASK != 0 {
stack.sets[idx].step = true;
}
if !stack.sets[idx].step {
return Before::Run;
}
stack.sets[idx].stopped_at = Some(record.to_string());
let ep = stack.sets[idx].current_ep.clone().unwrap_or_default();
println!("\n BKPT> Stopped at: {record} within Entrypoint: {ep}\n-> ");
let node = stack.sets.remove(idx).expect("index just used");
stack.sets.push_front(node);
};
crate::runtime::task::suspend_self();
Before::Run
}
pub fn after_process(&self, db: &PvDatabase, record: &str) {
if record_bkpt(db, record) & BKPT_PRINT_MASK == 0 {
return;
}
{
let mut stack = self.lock();
if stack.index_of_record(db, record).is_none() {
return;
}
}
let printer = self
.printer
.lock()
.expect("breakpoint printer poisoned")
.clone();
if let Some(printer) = printer {
printer(record);
}
}
pub fn set(
&self,
db: &PvDatabase,
record: &str,
spawn: impl FnOnce(u64, Arc<BinarySemaphoreHandle>),
) -> Result<(), BkptError> {
let Some(name) = canonical_record_name(db, record) else {
return Err(BkptError::NotFound(record.to_string()));
};
if record_bkpt(db, &name) & BKPT_ON_MASK != 0 {
return Err(BkptError::AlreadySet);
}
let Some(id) = lockset_id(db, &name) else {
return Err(BkptError::NotFound(record.to_string()));
};
let mut stack = self.lock();
let idx = match stack.sets.iter().position(|s| s.id == id) {
Some(i) => i,
None => {
stack.sets.push_back(LockSet {
id,
stopped_at: None,
current_ep: None,
breakpoints: Vec::new(),
ep_queue: Vec::new(),
step: false,
cont: None,
ex: Arc::new(BinarySemaphore::default()),
});
stack.sets.len() - 1
}
};
stack.sets[idx].breakpoints.push(name.clone());
set_record_bkpt(db, &name, |b| b | BKPT_ON_MASK);
if stack.sets[idx].cont.is_none() {
let taskid = Arc::new(AtomicU64::new(0));
stack.sets[idx].cont = Some(taskid.clone());
let handle = Arc::new(BinarySemaphoreHandle {
ex: stack.sets[idx].ex.clone(),
taskid,
});
drop(stack);
spawn(id, handle);
}
Ok(())
}
pub fn clear(&self, db: &PvDatabase, record: &str) -> Result<(), BkptError> {
let Some(name) = canonical_record_name(db, record) else {
return Err(BkptError::NotFound(record.to_string()));
};
if record_bkpt(db, &name) & BKPT_ON_MASK == 0 {
return Err(BkptError::NotSet);
}
let mut stack = self.lock();
let mut found = stack.index_of_record(db, &name);
if let Some(i) = found {
if !stack.sets[i].breakpoints.iter().any(|b| b == &name) {
found = None;
}
}
let Some(idx) = found else {
set_record_bkpt(db, &name, |b| b & BKPT_OFF_MASK);
return Err(BkptError::Logic);
};
stack.sets[idx].breakpoints.retain(|b| b != &name);
set_record_bkpt(db, &name, |b| b & BKPT_OFF_MASK);
if stack.sets[idx].breakpoints.is_empty() {
let ex = stack.sets[idx].ex.clone();
let cont = stack.sets[idx].cont.clone();
stack.sets[idx].step = false;
drop(stack);
resume_continuation(cont.as_ref());
ex.signal();
}
Ok(())
}
pub fn cont(&self, db: &PvDatabase, record: Option<&str>) -> Result<Option<String>, BkptError> {
self.resume(db, record, false)
}
pub fn step(&self, db: &PvDatabase, record: Option<&str>) -> Result<Option<String>, BkptError> {
self.resume(db, record, true)
}
fn resume(
&self,
db: &PvDatabase,
record: Option<&str>,
stepping: bool,
) -> Result<Option<String>, BkptError> {
let mut stack = self.lock();
let (idx, _) = stack.find_cont_node(db, record)?;
let id = stack.sets[idx].id;
let announce = if record.is_none() && stack.last != Some(id) {
stack.sets[idx].stopped_at.as_ref().map(|s| {
if stepping {
format!(" BKPT> Stepping: {s}")
} else {
format!(" BKPT> Continuing: {s}")
}
})
} else {
None
};
stack.last = Some(id);
if !stepping {
stack.sets[idx].step = false;
}
let cont = stack.sets[idx].cont.clone();
drop(stack);
resume_continuation(cont.as_ref());
Ok(announce)
}
pub fn status(&self, db: &PvDatabase) -> Vec<StatLine> {
let now = Instant::now();
let stack = self.lock();
let mut out = Vec::new();
for set in &stack.sets {
out.push(StatLine::LockSet {
id: set.id,
stopped_at: set.stopped_at.clone(),
breakpoint_count: set.breakpoints.len(),
task: set
.cont
.as_ref()
.map(|t| t.load(Ordering::Relaxed))
.filter(|&id| id != 0),
});
if set.stopped_at.is_some() {
for ep in &set.ep_queue {
let elapsed = now.saturating_duration_since(ep.first_seen).as_secs_f64();
if elapsed != 0.0 {
out.push(StatLine::Entrypoint {
name: ep.name.clone(),
count: ep.count,
elapsed_secs: elapsed,
});
}
}
}
for bp in &set.breakpoints {
out.push(StatLine::Breakpoint {
name: bp.clone(),
autoprint: record_bkpt(db, bp) & BKPT_PRINT_MASK != 0,
});
}
}
out
}
pub fn print_target(&self, db: &PvDatabase, record: Option<&str>) -> Result<String, BkptError> {
let stack = self.lock();
let (_, target) = stack.find_cont_node(db, record)?;
Ok(target)
}
}
impl Default for BreakpointTable {
fn default() -> Self {
Self::new()
}
}
impl LockSet {
fn note_entrypoint(&mut self, record: &str, schedule: bool) {
match self.ep_queue.iter_mut().find(|e| e.name == record) {
Some(ep) => {
if ep.count < MAX_EP_COUNT {
ep.count += 1;
}
if schedule {
ep.scheduled = true;
}
}
None => self.ep_queue.push(EntryPoint {
name: record.to_string(),
count: 1,
first_seen: Instant::now(),
scheduled: schedule,
}),
}
}
}
impl Stack {
fn index_of_record(&mut self, db: &PvDatabase, record: &str) -> Option<usize> {
if self.sets.is_empty() {
return None;
}
let id = lockset_id(db, record)?;
self.sets.iter().position(|s| s.id == id)
}
fn find_cont_node(
&self,
db: &PvDatabase,
record: Option<&str>,
) -> Result<(usize, String), BkptError> {
match record {
None => self
.sets
.iter()
.position(|s| s.stopped_at.is_some())
.map(|i| {
(
i,
self.sets[i]
.stopped_at
.clone()
.expect("position matched Some"),
)
})
.ok_or(BkptError::NoneStopped),
Some(name) => {
let canonical = canonical_record_name(db, name)
.ok_or_else(|| BkptError::NotFound(name.to_string()))?;
let id = lockset_id(db, &canonical).ok_or(BkptError::NotStopped)?;
let idx = self
.sets
.iter()
.position(|s| s.id == id)
.ok_or(BkptError::NotStopped)?;
if self.sets[idx].stopped_at.is_none() {
return Err(BkptError::NotStopped);
}
Ok((idx, canonical))
}
}
}
}
fn record_bkpt(db: &PvDatabase, record: &str) -> u8 {
db.get_record(record)
.map(|r| r.read().common.bkpt)
.unwrap_or(0)
}
fn set_record_bkpt(db: &PvDatabase, record: &str, f: impl FnOnce(u8) -> u8) {
if let Some(rec) = db.get_record(record) {
let mut inst = rec.write();
inst.common.bkpt = f(inst.common.bkpt);
}
}
pub fn toggle_autoprint(db: &PvDatabase, record: &str) -> Result<String, BkptError> {
let Some(name) = canonical_record_name(db, record) else {
return Err(BkptError::NotFound(record.to_string()));
};
let on = record_bkpt(db, &name) & BKPT_PRINT_MASK == 0;
set_record_bkpt(db, &name, |b| {
if on {
b | BKPT_PRINT_MASK
} else {
b & BKPT_PRINT_OFF_MASK
}
});
Ok(if on {
format!(" BKPT> Auto print on for record {name}")
} else {
format!(" BKPT> Auto print off for record {name}")
})
}
pub struct BinarySemaphoreHandle {
ex: Arc<BinarySemaphore>,
taskid: Arc<AtomicU64>,
}
fn claim_continuation(id: u64, taskid: &AtomicU64) {
CONTINUATION_FOR.with(|c| *c.borrow_mut() = Some(id));
taskid.store(crate::runtime::task::current_thread_id(), Ordering::Relaxed);
}
pub fn continuation_loop(db: PvDatabase, id: u64, handle: Arc<BinarySemaphoreHandle>) -> String {
claim_continuation(id, &handle.taskid);
loop {
handle.ex.wait();
let Some(table) = db.breakpoints() else { break };
let scheduled: Vec<String> = {
let stack = table.lock();
let Some(set) = stack.sets.iter().find(|s| s.id == id) else {
break;
};
set.ep_queue
.iter()
.filter(|e| e.scheduled)
.map(|e| e.name.clone())
.collect()
};
for entry in scheduled {
{
let mut stack = table.lock();
let Some(set) = stack.sets.iter_mut().find(|s| s.id == id) else {
break;
};
set.current_ep = Some(entry.clone());
}
let _ = db.process_record_for_breakpoint(&entry);
let mut stack = table.lock();
if let Some(set) = stack.sets.iter_mut().find(|s| s.id == id) {
if let Some(ep) = set.ep_queue.iter_mut().find(|e| e.name == entry) {
ep.scheduled = false;
}
set.step = false;
}
}
let mut stack = table.lock();
let Some(idx) = stack.sets.iter().position(|s| s.id == id) else {
break;
};
stack.sets[idx].stopped_at = None;
if stack.sets[idx].breakpoints.is_empty() {
stack.sets.remove(idx);
break;
}
}
CONTINUATION_FOR.with(|c| *c.borrow_mut() = None);
db.retire_breakpoints_if_idle();
format!("\n BKPT> End debug of lockset {id}\n-> ")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::records::ai::AiRecord;
use std::collections::HashSet;
async fn db_with(records: &[&str]) -> PvDatabase {
let db = PvDatabase::new();
for name in records {
db.add_record(name, Box::new(AiRecord::new(0.0)))
.await
.expect("add_record");
}
db.build_lock_sets();
db
}
fn set_flnk(db: &PvDatabase, from: &str, to: &str) {
{
let rec = db.get_record(from).expect("record");
let mut inst = rec.write();
inst.common.flnk = to.to_string();
inst.parsed_flnk = crate::server::record::parse_link_field(
to,
crate::server::record::LinkFieldType::Fwd,
);
}
db.build_lock_sets();
}
#[tokio::test]
async fn the_lockset_id_is_the_one_record_lock_maintains() {
let db = db_with(&["BP:a", "BP:b", "BP:c", "BP:lone"]).await;
set_flnk(&db, "BP:a", "BP:b");
set_flnk(&db, "BP:b", "BP:c");
let dblsr = |name: &str| db.lock_set_of(name).expect("a set after iocInit");
let want = dblsr("BP:a").id;
for seed in ["BP:a", "BP:b", "BP:c"] {
assert_eq!(lockset_id(&db, seed), Some(want), "seed {seed}");
}
let mut members = dblsr("BP:c").members;
members.sort();
assert_eq!(members, ["BP:a", "BP:b", "BP:c"]);
assert_ne!(lockset_id(&db, "BP:lone"), Some(want));
}
#[tokio::test]
async fn a_ca_link_does_not_widen_the_lockset() {
let db = db_with(&["BP:a", "BP:b"]).await;
set_flnk(&db, "BP:a", "ca://BP:b");
assert_ne!(
lockset_id(&db, "BP:a"),
lockset_id(&db, "BP:b"),
"a ca:// forward link is a dbCa link, not a lock-set merge"
);
}
#[tokio::test]
async fn a_record_without_a_lockset_has_no_id() {
let db = PvDatabase::new();
db.add_record("BP:pre", Box::new(AiRecord::new(0.0)))
.await
.expect("add_record");
assert_eq!(lockset_id(&db, "BP:pre"), None, "before build_lock_sets");
assert_eq!(lockset_id(&db, "BP:absent"), None, "no such record");
let table = BreakpointTable::new();
assert_eq!(
table.set(&db, "BP:pre", |_, _| unreachable!("no set, no thread")),
Err(BkptError::NotFound("BP:pre".to_string()))
);
}
#[tokio::test]
async fn a_second_dbb_and_a_dbd_without_a_breakpoint_refuse() {
let db = db_with(&["BP:a"]).await;
let table = BreakpointTable::new();
table.set(&db, "BP:a", |_, _| {}).expect("first dbb");
assert_eq!(
table.set(&db, "BP:a", |_, _| {}),
Err(BkptError::AlreadySet)
);
table.clear(&db, "BP:a").expect("dbd");
assert_eq!(table.clear(&db, "BP:a"), Err(BkptError::NotSet));
assert_eq!(table.lock().sets.len(), 1);
assert!(table.lock().sets[0].breakpoints.is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_real_continuation_thread_retires_the_lockset_on_the_last_dbd() {
let db = db_with(&["BP:a"]).await;
let table = db.breakpoints_or_install();
let joiner = {
let owned = db.clone();
let cell: Arc<Mutex<Option<std::thread::JoinHandle<String>>>> =
Arc::new(Mutex::new(None));
let out = cell.clone();
table
.set(&db, "BP:a", move |key, ex| {
*cell.lock().expect("cell") = Some(std::thread::spawn(move || {
continuation_loop(owned, key, ex)
}));
})
.expect("dbb");
out.lock().expect("cell").take().expect("spawned")
};
table.clear(&db, "BP:a").expect("dbd");
let closing = joiner.join().expect("join");
assert!(
closing.contains("End debug of lockset"),
"C `:628` prints the closing line, got {closing:?}"
);
assert!(table.is_empty());
assert!(
db.breakpoints().is_none(),
"the observer goes with the last lock set, so the hot path is a None load"
);
}
#[tokio::test]
async fn a_marked_record_whose_lockset_is_gone_is_cleared_and_reported() {
let db = db_with(&["BP:a"]).await;
set_record_bkpt(&db, "BP:a", |b| b | BKPT_ON_MASK);
assert_eq!(
BreakpointTable::new().clear(&db, "BP:a"),
Err(BkptError::Logic),
"the BKPT bit is set but this table never saw the record"
);
assert_eq!(
record_bkpt(&db, "BP:a") & BKPT_ON_MASK,
0,
"the bit is cleared anyway, so the record is not left marked"
);
}
#[tokio::test]
async fn every_named_command_refuses_a_missing_record() {
let db = db_with(&["BP:a"]).await;
let table = BreakpointTable::new();
let missing = BkptError::NotFound("BP:nope".into());
assert_eq!(table.set(&db, "BP:nope", |_, _| {}).unwrap_err(), missing);
assert_eq!(table.clear(&db, "BP:nope").unwrap_err(), missing);
assert_eq!(toggle_autoprint(&db, "BP:nope").unwrap_err(), missing);
assert_eq!(table.cont(&db, Some("BP:nope")).unwrap_err(), missing);
}
#[tokio::test]
async fn breakpoints_group_by_lockset_not_by_record() {
let db = db_with(&["BP:a", "BP:b", "BP:far"]).await;
set_flnk(&db, "BP:a", "BP:b");
let table = BreakpointTable::new();
table.set(&db, "BP:a", |_, _| {}).expect("dbb a");
table.set(&db, "BP:b", |_, _| {}).expect("dbb b");
assert_eq!(table.lock().sets.len(), 1);
assert_eq!(table.lock().sets[0].breakpoints, ["BP:a", "BP:b"]);
table.set(&db, "BP:far", |_, _| {}).expect("dbb far");
assert_eq!(table.lock().sets.len(), 2);
}
#[tokio::test]
async fn a_record_outside_every_breakpointed_lockset_just_runs() {
let db = db_with(&["BP:a", "BP:far"]).await;
let table = BreakpointTable::new();
table.set(&db, "BP:a", |_, _| {}).expect("dbb");
assert_eq!(table.before_process(&db, "BP:far"), Before::Run);
assert!(table.lock().sets[0].ep_queue.is_empty());
}
#[tokio::test]
async fn a_foreign_entry_is_queued_counted_and_skipped() {
let db = db_with(&["BP:a"]).await;
let table = BreakpointTable::new();
table.set(&db, "BP:a", |_, _| {}).expect("dbb");
assert_eq!(table.before_process(&db, "BP:a"), Before::Skip);
assert_eq!(table.before_process(&db, "BP:a"), Before::Skip);
{
let stack = table.lock();
let ep = &stack.sets[0].ep_queue[0];
assert_eq!(
(ep.name.as_str(), ep.count, ep.scheduled),
("BP:a", 2, true)
);
}
db.get_record("BP:a").expect("record").read().enter_pact();
{
let mut stack = table.lock();
stack.sets[0].ep_queue[0].scheduled = false;
}
assert_eq!(table.before_process(&db, "BP:a"), Before::Skip);
let stack = table.lock();
let ep = &stack.sets[0].ep_queue[0];
assert_eq!(
(ep.count, ep.scheduled),
(3, false),
"pact is checked after queuing, so the count moves and the schedule does not"
);
}
#[tokio::test]
async fn a_disabled_record_runs_and_is_not_queued() {
let db = db_with(&["BP:a"]).await;
let table = BreakpointTable::new();
table.set(&db, "BP:a", |_, _| {}).expect("dbb");
{
let rec = db.get_record("BP:a").expect("record");
let mut inst = rec.write();
inst.common.disa = 1;
inst.common.disv = 1;
}
assert_eq!(table.before_process(&db, "BP:a"), Before::Run);
assert!(table.lock().sets[0].ep_queue.is_empty());
}
#[test]
fn the_execution_semaphore_banks_a_signal() {
let ex = Arc::new(BinarySemaphore::default());
ex.signal();
ex.signal();
ex.wait(); assert!(
!*ex.full.lock().expect("sem"),
"epicsEventSignal is binary: two signals before one wait are one pass"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_stop_holds_until_dbc_releases_it() {
let db = db_with(&["BP:a"]).await;
let table = Arc::new(BreakpointTable::new());
table.set(&db, "BP:a", |_, _| {}).expect("dbb");
let taskid = table.lock().sets[0]
.cont
.clone()
.expect("dbb installs the taskid cell");
let (tx, rx) = std::sync::mpsc::channel();
let stopper = {
let (table, db, tx) = (table.clone(), db.clone(), tx.clone());
let set_id = lockset_id(&db, "BP:a").expect("lock set");
crate::runtime::task::spawn_dedicated_thread(
"bkptCont".to_string(),
crate::runtime::task::ThreadPriority::ScanLow,
crate::runtime::task::StackSizeClass::Small,
move || {
claim_continuation(set_id, &taskid);
tx.send("entered").expect("send");
assert_eq!(table.before_process(&db, "BP:a"), Before::Run);
tx.send("resumed").expect("send");
},
)
.expect("spawn")
};
assert_eq!(rx.recv().expect("entered"), "entered");
assert_eq!(
rx.recv_timeout(std::time::Duration::from_millis(250)),
Err(std::sync::mpsc::RecvTimeoutError::Timeout),
"the breakpoint holds the thread until dbc"
);
assert!(table.lock().sets[0].stopped_at.is_some());
assert_eq!(
table.cont(&db, None).expect("dbc"),
Some(" BKPT> Continuing: BP:a".to_string())
);
assert_eq!(rx.recv().expect("resumed"), "resumed");
stopper.join().expect("join");
assert!(
!table.lock().sets[0].step,
"dbc clears stepping; dbs would not"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn epics_thread_resume_continues_a_stopped_record() {
let db = db_with(&["BP:a"]).await;
let table = Arc::new(BreakpointTable::new());
table.set(&db, "BP:a", |_, _| {}).expect("dbb");
let taskid = table.lock().sets[0]
.cont
.clone()
.expect("dbb installs the taskid cell");
let (tx, rx) = std::sync::mpsc::channel();
let stopper = {
let (table, db, tx) = (table.clone(), db.clone(), tx.clone());
let set_id = lockset_id(&db, "BP:a").expect("lock set");
let taskid = taskid.clone();
crate::runtime::task::spawn_dedicated_thread(
"bkptCont".to_string(),
crate::runtime::task::ThreadPriority::ScanLow,
crate::runtime::task::StackSizeClass::Small,
move || {
claim_continuation(set_id, &taskid);
tx.send("entered").expect("send");
assert_eq!(table.before_process(&db, "BP:a"), Before::Run);
tx.send("resumed").expect("send");
},
)
.expect("spawn")
};
assert_eq!(rx.recv().expect("entered"), "entered");
let id = taskid.load(Ordering::Relaxed);
assert_ne!(id, 0, "the thread publishes the handle dbstat prints");
let row = (0..500)
.find_map(|_| {
let row = crate::runtime::task::thread_by_id(id).expect("row");
row.is_suspended().then_some(row).or_else(|| {
std::thread::sleep(std::time::Duration::from_millis(10));
None
})
})
.expect("a stopped record must show the thread suspended");
assert!(
row.show_line().ends_with(" SUSPEND"),
"epicsThreadShowAll must read SUSPEND while stopped, got {:?}",
row.show_line()
);
assert!(table.lock().sets[0].stopped_at.is_some());
assert!(
crate::runtime::task::resume_thread(id),
"epicsThreadResume must find the thread suspended"
);
assert_eq!(rx.recv().expect("resumed"), "resumed");
stopper.join().expect("join");
assert!(
!crate::runtime::task::thread_by_id(id).is_some_and(|t| t.is_suspended()),
"the resumed thread is no longer suspended"
);
}
#[tokio::test]
async fn resume_announces_only_a_change_of_default_lockset() {
let db = db_with(&["BP:a"]).await;
let table = BreakpointTable::new();
table.set(&db, "BP:a", |_, _| {}).expect("dbb");
table.lock().sets[0].stopped_at = Some("BP:a".into());
assert_eq!(
table.step(&db, None).expect("dbs"),
Some(" BKPT> Stepping: BP:a".to_string())
);
table.lock().sets[0].stopped_at = Some("BP:a".into());
assert_eq!(
table.step(&db, None).expect("dbs"),
None,
"same lock set as last time: silent"
);
table.lock().sets[0].stopped_at = Some("BP:a".into());
assert_eq!(
table.cont(&db, Some("BP:a")).expect("dbc"),
None,
"the named form never announces"
);
}
#[tokio::test]
async fn resume_refuses_when_nothing_is_stopped() {
let db = db_with(&["BP:a", "BP:far"]).await;
let table = BreakpointTable::new();
assert_eq!(table.cont(&db, None), Err(BkptError::NoneStopped));
table.set(&db, "BP:a", |_, _| {}).expect("dbb");
assert_eq!(table.cont(&db, Some("BP:a")), Err(BkptError::NotStopped));
assert_eq!(
table.cont(&db, Some("BP:far")),
Err(BkptError::NotStopped),
"a record in no breakpointed lock set is 'not stopped in this lockset'"
);
}
#[tokio::test]
async fn dbp_targets_the_named_record_and_otherwise_the_stopped_one() {
let db = db_with(&["BP:a", "BP:b"]).await;
set_flnk(&db, "BP:a", "BP:b");
let table = BreakpointTable::new();
table.set(&db, "BP:a", |_, _| {}).expect("dbb");
table.lock().sets[0].stopped_at = Some("BP:b".into());
assert_eq!(table.print_target(&db, None).expect("dbp"), "BP:b");
assert_eq!(table.print_target(&db, Some("BP:a")).expect("dbp"), "BP:a");
}
#[tokio::test]
async fn dbap_toggles_and_reports_both_ways() {
let db = db_with(&["BP:a"]).await;
assert_eq!(
toggle_autoprint(&db, "BP:a").expect("dbap"),
" BKPT> Auto print on for record BP:a"
);
assert_eq!(record_bkpt(&db, "BP:a") & BKPT_PRINT_MASK, BKPT_PRINT_MASK);
assert_eq!(
toggle_autoprint(&db, "BP:a").expect("dbap"),
" BKPT> Auto print off for record BP:a"
);
assert_eq!(record_bkpt(&db, "BP:a") & BKPT_PRINT_MASK, 0);
}
#[tokio::test]
async fn auto_print_is_silent_without_a_breakpoint_in_the_lockset() {
let db = db_with(&["BP:a", "BP:far"]).await;
let table = BreakpointTable::new();
let seen = Arc::new(Mutex::new(Vec::new()));
{
let seen = seen.clone();
table.set_printer(Arc::new(move |n: &str| {
seen.lock().expect("seen").push(n.to_string())
}));
}
toggle_autoprint(&db, "BP:far").expect("dbap");
table.after_process(&db, "BP:far");
assert!(seen.lock().expect("seen").is_empty(), "no lock set at all");
table.set(&db, "BP:a", |_, _| {}).expect("dbb");
table.after_process(&db, "BP:far");
assert!(
seen.lock().expect("seen").is_empty(),
"a different lock set holds the breakpoint"
);
toggle_autoprint(&db, "BP:a").expect("dbap");
table.after_process(&db, "BP:a");
assert_eq!(*seen.lock().expect("seen"), ["BP:a"]);
toggle_autoprint(&db, "BP:a").expect("dbap");
table.after_process(&db, "BP:a");
assert_eq!(seen.lock().expect("seen").len(), 1);
}
#[tokio::test]
async fn dbstat_renders_c_line_shapes() {
let db = db_with(&["BP:a"]).await;
let table = BreakpointTable::new();
table.set(&db, "BP:a", |_, _| {}).expect("dbb");
toggle_autoprint(&db, "BP:a").expect("dbap");
let lines = table.status(&db);
let id = db.lock_set_of("BP:a").expect("a set after iocInit").id;
assert_eq!(
lines[0].render(),
format!("LSet: {id} #B: 00001 T: (nil)")
);
assert_eq!(
lines[1].render(),
" Breakpoint: BP:a (ap)"
);
assert_eq!(
lines.len(),
2,
"entry points print only for a stopped lock set"
);
table.lock().sets[0].stopped_at = Some("BP:a".into());
let stopped = table.status(&db);
assert_eq!(
stopped[0].render(),
format!("LSet: {id} Stopped at: BP:a #B: 00001 T: (nil)")
);
let (a, b) = (lines[0].render(), stopped[0].render());
assert_eq!(
a.find("#B:").expect("a"),
b.find("#B:").expect("b"),
"the two LSet lines must agree on the #B column"
);
assert_eq!(
StatLine::Entrypoint {
name: "BP:a".into(),
count: 7,
elapsed_secs: 12.25,
}
.render(),
" Entrypoint: BP:a #C: 00007 C/S: 12.2"
);
}
fn await_stop(table: &BreakpointTable, pred: impl Fn(Option<&str>) -> bool) -> Option<String> {
for _ in 0..400 {
{
let stack = table.lock();
let at = stack.sets.front().and_then(|s| s.stopped_at.clone());
if pred(at.as_deref()) {
return at;
}
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
None
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_breakpoint_stops_a_chain_and_dbs_steps_into_the_flnk_target() {
let db = db_with(&["BPX:a", "BPX:b"]).await;
set_flnk(&db, "BPX:a", "BPX:b");
let table = db.breakpoints_or_install();
let joiner = {
let owned = db.clone();
let cell: Arc<Mutex<Option<std::thread::JoinHandle<String>>>> =
Arc::new(Mutex::new(None));
let out = cell.clone();
table
.set(&db, "BPX:a", move |key, ex| {
*cell.lock().expect("cell") = Some(std::thread::spawn(move || {
continuation_loop(owned, key, ex)
}));
})
.expect("dbb");
out.lock().expect("cell").take().expect("spawned")
};
db.process_record_with_links("BPX:a", &mut HashSet::new(), 0)
.await
.expect("foreign entry falls out of dbProcess");
assert_eq!(
await_stop(&table, |at| at == Some("BPX:a")).as_deref(),
Some("BPX:a"),
"the breakpoint stops the chain at its own record"
);
let stat = table.status(&db);
assert!(
stat[0].render().contains("Stopped at: BPX:a"),
"dbstat names the stopped record, got {:?}",
stat[0].render()
);
table.step(&db, None).expect("dbs");
assert_eq!(
await_stop(&table, |at| at == Some("BPX:b")).as_deref(),
Some("BPX:b"),
"dbs steps INTO the FLNK target, mid-chain"
);
assert!(
table.status(&db)[0].render().contains("Stopped at: BPX:b"),
"dbstat follows the step"
);
table.cont(&db, None).expect("dbc");
assert_eq!(
await_stop(&table, |at| at.is_none()),
None,
"the chain completes and nothing is stopped"
);
table.clear(&db, "BPX:a").expect("dbd");
joiner.join().expect("join");
assert!(db.breakpoints().is_none());
}
#[tokio::test]
async fn dbstat_and_dblsr_agree_on_the_set_id() {
let db = db_with(&["BP:a", "BP:b"]).await;
set_flnk(&db, "BP:b", "BP:a");
let dblsr = db.lock_set_of("BP:a").expect("a set after iocInit").id;
assert_eq!(db.lock_set_of("BP:b").expect("same set").id, dblsr);
let first = BreakpointTable::new();
first.set(&db, "BP:b", |_, _| {}).expect("dbb");
let from_b = first.lock().sets[0].id;
assert!(
first.status(&db)[0]
.render()
.starts_with(&format!("LSet: {dblsr}"))
);
first.clear(&db, "BP:b").expect("dbd");
let second = BreakpointTable::new();
second.set(&db, "BP:a", |_, _| {}).expect("dbb");
assert_eq!(second.lock().sets[0].id, from_b);
assert_eq!(from_b, dblsr, "dbstat prints dblsr's id");
}
}