use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::{Duration, Instant};
use crate::error::CaResult;
use crate::runtime::log::{ErrlogSevEnum, errlog_sev_printf};
use crate::runtime::task::{StackSizeClass, ThreadPriority, block_on_sync, spawn_dedicated_thread};
use crate::server::database::PvDatabase;
use crate::server::pv::ProcessVariable;
use crate::types::{EpicsValue, PvString};
pub const PUSH_INTERVAL: Duration = Duration::from_secs(1);
pub struct StatusPv {
name: String,
read: Box<dyn Fn() -> EpicsValue + Send>,
}
impl StatusPv {
pub fn new(name: impl Into<String>, read: impl Fn() -> EpicsValue + Send + 'static) -> Self {
Self {
name: name.into(),
read: Box::new(read),
}
}
pub fn name(&self) -> &str {
&self.name
}
}
fn reading(value: Option<f64>) -> EpicsValue {
EpicsValue::Double(value.unwrap_or(f64::NAN))
}
pub fn format_uptime(elapsed: Duration) -> String {
let total = elapsed.as_secs();
let secs = total % 60;
let mins = (total / 60) % 60;
let hours = (total / 3600) % 24;
match total / 86_400 {
0 => format!("{hours:02}:{mins:02}:{secs:02}"),
1 => format!("1 day, {hours:02}:{mins:02}:{secs:02}"),
days => format!("{days} days, {hours:02}:{mins:02}:{secs:02}"),
}
}
pub fn target_status_pvs(prefix: &str, started: Instant) -> Vec<StatusPv> {
use epics_rtems_boot::stats::{fd_usage, mem_usage};
vec![
StatusPv::new(format!("{prefix}:FD_CNT"), || {
reading(fd_usage().map(|fd| fd.used as f64))
}),
StatusPv::new(format!("{prefix}:FD_MAX"), || {
reading(fd_usage().map(|fd| fd.max as f64))
}),
StatusPv::new(format!("{prefix}:FD_FREE"), || {
reading(fd_usage().map(|fd| fd.free() as f64))
}),
StatusPv::new(format!("{prefix}:MEM_FREE"), || {
reading(mem_usage().free.map(|v| v as f64))
}),
StatusPv::new(format!("{prefix}:MEM_USED"), || {
reading(mem_usage().used.map(|v| v as f64))
}),
StatusPv::new(format!("{prefix}:MEM_MAX"), || {
reading(mem_usage().total().map(|v| v as f64))
}),
StatusPv::new(format!("{prefix}:MEM_BLK"), || {
reading(mem_usage().largest_free.map(|v| v as f64))
}),
StatusPv::new(format!("{prefix}:UPTIME"), move || {
EpicsValue::String(PvString::from_bytes(
format_uptime(started.elapsed()).into_bytes(),
))
}),
]
}
pub struct StatusPvs {
shutdown: Arc<AtomicBool>,
names: Vec<String>,
}
impl StatusPvs {
pub fn stop(&self) {
self.shutdown.store(true, Ordering::Release);
}
pub fn names(&self) -> &[String] {
&self.names
}
}
type Published = (Box<dyn Fn() -> EpicsValue + Send>, Arc<ProcessVariable>);
pub fn serve_status_pvs(db: Arc<PvDatabase>, pvs: Vec<StatusPv>) -> CaResult<StatusPvs> {
let mut published: Vec<Published> = Vec::with_capacity(pvs.len());
let mut names = Vec::with_capacity(pvs.len());
for pv in pvs {
let initial = (pv.read)();
let handle = block_on_sync(register(&db, &pv.name, initial))
.map_err(|e| crate::error::CaError::Protocol(format!("status PVs: {e}")))??;
names.push(pv.name);
published.push((pv.read, handle));
}
let shutdown = Arc::new(AtomicBool::new(false));
let worker_shutdown = shutdown.clone();
spawn_dedicated_thread(
"status-pv".to_string(),
ThreadPriority::Low,
StackSizeClass::Small,
move || push_loop(&published, &worker_shutdown),
)?;
Ok(StatusPvs { shutdown, names })
}
async fn register(
db: &PvDatabase,
name: &str,
initial: EpicsValue,
) -> CaResult<Arc<ProcessVariable>> {
db.add_pv(name, initial).await?;
db.find_pv(name).await.ok_or_else(|| {
crate::error::CaError::ChannelNotFound(format!(
"status PV {name} vanished between add_pv and find_pv"
))
})
}
fn push_loop(published: &[Published], shutdown: &AtomicBool) {
while !shutdown.load(Ordering::Acquire) {
if !push_once(published) {
errlog_sev_printf(
ErrlogSevEnum::Major,
"status PVs: this thread cannot drive a value publish (current-thread \
runtime); the status PVs are registered but will never update",
);
return;
}
thread::sleep(PUSH_INTERVAL);
}
}
fn push_once(published: &[Published]) -> bool {
let samples: Vec<(EpicsValue, &Arc<ProcessVariable>)> =
published.iter().map(|(read, pv)| (read(), pv)).collect();
block_on_sync(async {
for (value, pv) in samples {
pv.set(value);
}
})
.is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::DbFieldType;
use std::sync::Mutex;
use std::sync::atomic::AtomicU64;
fn database() -> Arc<PvDatabase> {
Arc::new(PvDatabase::new())
}
fn double(v: f64) -> impl Fn() -> EpicsValue + Send + 'static {
move || EpicsValue::Double(v)
}
#[test]
fn a_pushed_value_reaches_a_subscriber() {
let db = database();
let source = Arc::new(AtomicU64::new(7));
let reader = source.clone();
let pvs = serve_status_pvs(
db.clone(),
vec![StatusPv::new("T:COUNT", move || {
EpicsValue::Double(reader.load(Ordering::Relaxed) as f64)
})],
)
.expect("registration");
pvs.stop();
let pv = block_on_sync(db.find_pv("T:COUNT"))
.expect("plain thread")
.expect("the PV was registered");
const DBE_VALUE: u16 = 1;
let mut rx = pv
.add_subscriber(1, DbFieldType::Double, DBE_VALUE)
.expect("subscriber added");
source.store(42, Ordering::Relaxed);
assert!(push_once(&[(
Box::new(move || EpicsValue::Double(source.load(Ordering::Relaxed) as f64)),
pv.clone(),
)]));
assert!(
rx.try_recv().is_ok(),
"a pushed value must post to subscribers — this is the property a \
read hook would not have"
);
assert_eq!(
format!("{}", pv.get()),
"42",
"and the stored value must be the pushed sample, not the registered one"
);
}
#[test]
fn a_name_clash_is_reported_to_the_caller() {
let db = database();
let first = serve_status_pvs(db.clone(), vec![StatusPv::new("T:DUP", double(1.0))])
.expect("the first registration succeeds");
first.stop();
let second = serve_status_pvs(db, vec![StatusPv::new("T:DUP", double(2.0))]);
assert!(
second.is_err(),
"a status PV that collides with an existing name must fail the call, \
not fail silently on the pusher thread"
);
}
#[test]
fn a_pv_takes_its_type_from_its_readers_first_sample() {
let db = database();
let handle = serve_status_pvs(
db.clone(),
vec![
StatusPv::new("T:NUM", double(5.0)),
StatusPv::new("T:STR", || {
EpicsValue::String(PvString::from_bytes(b"00:00:07".to_vec()))
}),
],
)
.expect("registration");
handle.stop();
let num = block_on_sync(db.find_pv("T:NUM"))
.expect("plain thread")
.expect("registered");
let text = block_on_sync(db.find_pv("T:STR"))
.expect("plain thread")
.expect("registered");
assert!(matches!(num.get(), EpicsValue::Double(_)));
assert!(
matches!(text.get(), EpicsValue::String(_)),
"a string reader must produce a string PV; a double PV here would \
make UPTIME a number and break every site's stringin client"
);
assert_eq!(format!("{}", text.get()), "00:00:07");
}
#[test]
fn every_value_is_read_before_any_is_published() {
let db = database();
let handle = serve_status_pvs(
db.clone(),
vec![
StatusPv::new("T:FIRST", double(1.0)),
StatusPv::new("T:SECOND", double(2.0)),
],
)
.expect("registration");
handle.stop();
assert_eq!(handle.names(), &["T:FIRST", "T:SECOND"]);
let first = block_on_sync(db.find_pv("T:FIRST"))
.expect("plain thread")
.expect("registered");
let second = block_on_sync(db.find_pv("T:SECOND"))
.expect("plain thread")
.expect("registered");
let seen = Arc::new(Mutex::new(String::new()));
let observer = {
let seen = seen.clone();
let first = first.clone();
move || {
*seen.lock().expect("observation") = format!("{}", first.get());
EpicsValue::Double(2.0)
}
};
let published: Vec<Published> = vec![
(Box::new(double(9.0)), first.clone()),
(Box::new(observer), second),
];
assert!(push_once(&published));
assert_eq!(
*seen.lock().expect("observation"),
"1",
"the second value was read AFTER the first was published — the tick \
spans two instants instead of one"
);
assert_eq!(
format!("{}", first.get()),
"9",
"precondition: this tick's sample really was published by it"
);
}
#[test]
fn dropping_the_handle_does_not_stop_the_pusher() {
let db = database();
let ticks = Arc::new(AtomicU64::new(0));
let counter = ticks.clone();
let handle = serve_status_pvs(
db,
vec![StatusPv::new("T:TICKS", move || {
EpicsValue::Double(counter.fetch_add(1, Ordering::Relaxed) as f64)
})],
)
.expect("registration");
drop(handle);
let deadline = std::time::Instant::now() + PUSH_INTERVAL * 4;
while ticks.load(Ordering::Relaxed) < 3 && std::time::Instant::now() < deadline {
thread::sleep(Duration::from_millis(20));
}
assert!(
ticks.load(Ordering::Relaxed) >= 3,
"the pusher stopped when its handle was dropped; the status PVs are \
now frozen at whatever they last held"
);
}
#[test]
fn the_push_interval_is_never_sub_second() {
assert!(
PUSH_INTERVAL >= Duration::from_secs(1),
"a sub-second thread::sleep is the never-wakes case on the target's \
stock libc, and the target clock is quantised to whole seconds"
);
}
#[tokio::test]
async fn a_pusher_that_cannot_publish_retires() {
let db = Arc::new(PvDatabase::new());
db.add_pv("T:NOPE", EpicsValue::Double(0.0))
.await
.expect("add");
let pv = db.find_pv("T:NOPE").await.expect("registered");
assert!(
!push_once(&[(Box::new(double(1.0)), pv)]),
"on a current-thread runtime the publish cannot be driven"
);
let shutdown = AtomicBool::new(false);
push_loop(&[], &shutdown);
assert!(
!shutdown.load(Ordering::Acquire),
"the loop retired on its own, without the flag"
);
}
#[test]
fn the_uptime_string_matches_dev_ioc_stats_exactly() {
assert_eq!(format_uptime(Duration::from_secs(0)), "00:00:00");
assert_eq!(format_uptime(Duration::from_secs(7)), "00:00:07");
assert_eq!(format_uptime(Duration::from_secs(3_661)), "01:01:01");
assert_eq!(
format_uptime(Duration::from_secs(86_399)),
"23:59:59",
"the last second before the day part appears"
);
assert_eq!(
format_uptime(Duration::from_secs(86_400)),
"1 day, 00:00:00",
"singular, with a comma and a space — devIocStatsString.c:413-414"
);
assert_eq!(
format_uptime(Duration::from_secs(172_800)),
"2 days, 00:00:00"
);
assert_eq!(
format_uptime(Duration::from_secs(90_061)),
"1 day, 01:01:01"
);
}
#[test]
fn the_common_set_uses_the_upstream_names() {
let names: Vec<String> = target_status_pvs("IOC", Instant::now())
.iter()
.map(|pv| pv.name().to_string())
.collect();
assert_eq!(
names,
vec![
"IOC:FD_CNT",
"IOC:FD_MAX",
"IOC:FD_FREE",
"IOC:MEM_FREE",
"IOC:MEM_USED",
"IOC:MEM_MAX",
"IOC:MEM_BLK",
"IOC:UPTIME",
]
);
}
#[test]
fn an_absent_reading_publishes_nan_rather_than_zero() {
let pvs = target_status_pvs("IOC", Instant::now());
for pv in &pvs {
if pv.name().ends_with(":UPTIME") {
continue;
}
match (pv.read)() {
EpicsValue::Double(v) => assert!(
v.is_nan(),
"{} published {v} with no reading behind it; zero free \
descriptors and zero free heap are real readings on the \
target and must not be confused with an absent one",
pv.name()
),
other => panic!("{} is not a double: {other:?}", pv.name()),
}
}
}
#[test]
fn uptime_is_a_string_on_every_build() {
let pvs = target_status_pvs("IOC", Instant::now());
let uptime = pvs
.iter()
.find(|pv| pv.name() == "IOC:UPTIME")
.expect("UPTIME is in the common set");
assert!(matches!((uptime.read)(), EpicsValue::String(_)));
}
}