1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
//! "I wrote VAL" and "I declared it defined" are two facts in C, and the
//! record's `process()` combines them differently per record type.
//!
//! A C dset returning 2 has ALWAYS written `prec->udf` itself first —
//! `devBiSoft.c:54-59`, `devMbbiSoft.c:55-60`, `devMbbiDirectSoft.c:55-60`,
//! `devBiDbState.c:67-70`, `devTimestamp.c:40-41` — and only then does the
//! record decide whether to write it again:
//!
//! | record | C `process()` after a `return 2` |
//! |-----------------------------------|--------------------------------------------------------|
//! | ai | re-derives: `else if (status==2) status=0;` sits BEFORE `if (status == 0) prec->udf = isnan(prec->val)` (`aiRecord.c:158-161`) |
//! | bi / mbbi / mbbiDirect | does not: `prec->udf = FALSE` is INSIDE `if (status == 0)` and the fold is after it (`biRecord.c:136-141`, `mbbiRecord.c:168-193`, `mbbiDirectRecord.c:152-166`) |
//! | longin / int64in | does not: `if (status==0) prec->udf = FALSE;` with no fold at all (`longinRecord.c:148`, `int64inRecord.c:144`) |
//!
//! So on those five the dset is the ONLY writer of `udf` for a `return 2`
//! cycle, and a port that always re-derived on "the device computed" could
//! not reproduce them: it would silently define a record whose dset said
//! nothing. Each arm below is one boundary of that pair, not one story.
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use epics_base_rs::error::CaResult;
use epics_base_rs::server::device_support::{DeviceReadOutcome, DeviceSupport, DeviceUdf};
use epics_base_rs::server::ioc_app::DeviceSupportContext;
use epics_base_rs::server::ioc_builder::IocBuilder;
use epics_base_rs::server::recgbl::alarm_status;
use epics_base_rs::server::record::{AlarmSeverity, Record};
use epics_base_rs::types::EpicsValue;
const REC: &str = "TEST:UDF";
/// What the scripted dset does on its one read: whether it writes VAL, and
/// what it says about `prec->udf`. The two are independent here precisely
/// because they are independent in C.
#[derive(Clone, Copy)]
struct Step {
wrote_val: bool,
udf: DeviceUdf,
}
struct ScriptedDevice {
step: Arc<Mutex<Option<Step>>>,
sourced: EpicsValue,
}
impl DeviceSupport for ScriptedDevice {
fn read(&mut self, record: &mut dyn Record) -> CaResult<DeviceReadOutcome> {
let step = self.step.lock().unwrap().take().expect("one read only");
if step.wrote_val {
record.put_field("VAL", self.sourced.clone())?;
Ok(DeviceReadOutcome::computed(step.udf))
} else {
Ok(DeviceReadOutcome::no_value(step.udf))
}
}
fn write(&mut self, _record: &mut dyn Record) -> CaResult<()> {
Ok(())
}
fn dtyp(&self) -> &str {
"scriptedUdf"
}
}
/// Build a fresh record of `record_type` — UDF starts at 1, the state
/// `iocInit` leaves behind — process it once through `step`, and return
/// `(UDF, SEVR, STAT)` as a client reads them.
async fn read_once(record_type: &str, sourced: EpicsValue, step: Step) -> (u8, AlarmSeverity, u16) {
let cell = Arc::new(Mutex::new(Some(step)));
// lsi sizes its VAL buffer from SIZV; the default would truncate the
// scripted string to nothing and the arm below would prove nothing.
let extra = if record_type == "lsi" {
r#" field(SIZV, "64")"#
} else {
""
};
let db = format!(r#"record({record_type}, "{REC}") {{ field(DTYP, "scriptedUdf"){extra} }}"#);
let (database, _) = IocBuilder::new()
.register_dynamic_device_support(move |ctx: &DeviceSupportContext| {
(ctx.dtyp == "scriptedUdf").then(|| {
Box::new(ScriptedDevice {
step: Arc::clone(&cell),
sourced: sourced.clone(),
}) as Box<dyn DeviceSupport>
})
})
.db_string(&db, &HashMap::new())
.unwrap()
.build()
.await
.unwrap();
assert_eq!(
database.get_record(REC).unwrap().read().common.udf,
1,
"a bare record starts undefined, or the arm below proves nothing"
);
let mut visited = HashSet::new();
database
.process_record_with_links(REC, &mut visited, 0)
.await
.unwrap();
let rec = database.get_record(REC).unwrap();
let inst = rec.read();
(inst.common.udf, inst.common.sevr, inst.common.stat)
}
/// The five record types whose `process()` leaves UDF to the dset, each with
/// a VAL its own field type accepts.
fn dset_owns_udf_records() -> Vec<(&'static str, EpicsValue)> {
vec![
("bi", EpicsValue::Enum(1)),
("mbbi", EpicsValue::Enum(1)),
("mbbiDirect", EpicsValue::Long(1)),
("longin", EpicsValue::Long(7)),
("int64in", EpicsValue::Int64(7)),
]
}
#[epics_macros_rs::epics_test]
async fn a_dset_that_wrote_val_and_declared_it_defined_defines_the_record() {
// C `devBiSoft.c::readLocked`: `prec->udf = FALSE; … return 2`.
for (ty, val) in dset_owns_udf_records() {
let (udf, sevr, _) = read_once(
ty,
val,
Step {
wrote_val: true,
udf: DeviceUdf::Defined,
},
)
.await;
assert_eq!(
udf, 0,
"{ty}: the dset's `prec->udf = FALSE` is the one that counts"
);
assert_eq!(sevr, AlarmSeverity::NoAlarm, "{ty}: defined ⇒ no UDF_ALARM");
}
}
#[epics_macros_rs::epics_test]
async fn a_dset_that_wrote_val_without_declaring_it_leaves_these_records_undefined() {
// The arm the port could not express before: `return 2` with no
// `prec->udf` write. On these five nothing else writes it, so the record
// stays undefined and `checkAlarms` raises UDF_ALARM at UDFS — even
// though VAL now holds a perfectly good number.
for (ty, val) in dset_owns_udf_records() {
let (udf, sevr, stat) = read_once(
ty,
val,
Step {
wrote_val: true,
udf: DeviceUdf::Untouched,
},
)
.await;
assert_eq!(
udf, 1,
"{ty}: `process()` must not re-derive UDF on a computed read"
);
assert_eq!(sevr, AlarmSeverity::Invalid, "{ty}: UDF ⇒ INVALID at UDFS");
assert_eq!(stat, alarm_status::UDF_ALARM, "{ty}");
}
}
#[epics_macros_rs::epics_test]
async fn ai_rederives_on_the_same_undeclared_computed_read() {
// Same dset statement, opposite record rule — which is the point of
// making it per-record. `aiRecord.c:159` folds the 2 into a 0 before
// `:161`, so ai re-derives from VAL and the record ends DEFINED.
let (udf, sevr, _) = read_once(
"ai",
EpicsValue::Double(42.0),
Step {
wrote_val: true,
udf: DeviceUdf::Untouched,
},
)
.await;
assert_eq!(udf, 0, "ai re-derives `udf = isnan(val)` = 0");
assert_eq!(sevr, AlarmSeverity::NoAlarm);
}
#[epics_macros_rs::epics_test]
async fn a_dset_can_declare_the_record_defined_without_writing_val() {
// The other diagonal: no value sourced, but the dset asserts the record
// is defined. C reaches this shape wherever a dset writes `prec->udf`
// and returns a status the record's gate rejects — `devTimestamp.c:61-63`
// is the mirror image (`prec->udf = TRUE; … return -1`). Nothing
// re-derives afterwards, so the dset's word stands.
for (ty, val) in dset_owns_udf_records() {
let _ = val;
let (udf, sevr, _) = read_once(
ty,
EpicsValue::Enum(0),
Step {
wrote_val: false,
udf: DeviceUdf::Defined,
},
)
.await;
assert_eq!(udf, 0, "{ty}: the dset declared it defined");
assert_eq!(sevr, AlarmSeverity::NoAlarm, "{ty}");
}
}
#[epics_macros_rs::epics_test]
async fn a_dset_that_sourced_nothing_and_said_nothing_leaves_udf_alone() {
// C `return -1` with no `prec->udf` write — `devAsynInt32.c:924-927`.
// The record was undefined going in and stays undefined.
for (ty, val) in dset_owns_udf_records() {
let _ = val;
let (udf, sevr, stat) = read_once(
ty,
EpicsValue::Enum(0),
Step {
wrote_val: false,
udf: DeviceUdf::Untouched,
},
)
.await;
assert_eq!(udf, 1, "{ty}: nothing sourced, nothing declared");
assert_eq!(sevr, AlarmSeverity::Invalid, "{ty}");
assert_eq!(stat, alarm_status::UDF_ALARM, "{ty}");
}
}
/// The string-valued input records whose `process()` likewise never writes
/// UDF on the device path — the only `prec->udf = FALSE` in each is the
/// simulated SIOL branch (`stringinRecord.c:211`, `lsiRecord.c:245`,
/// `eventRecord.c:191`). None of them raises UDF_ALARM either, so UDF here
/// is a silent flag and the assertion has to read it directly.
fn dset_owns_udf_string_records() -> Vec<(&'static str, EpicsValue)> {
vec![
("stringin", EpicsValue::String("ok".into())),
("lsi", EpicsValue::String("ok".into())),
("event", EpicsValue::String("ok".into())),
]
}
#[epics_macros_rs::epics_test]
async fn a_dset_that_stored_a_string_without_declaring_it_leaves_udf_set() {
// `devAsynOctet.c::callbackSiRead:918-932` is the live shape: `readIt`
// stores into `psi->val` whatever happens, but `psi->udf = 0` runs only
// inside `if (status == asynSuccess)`. A partial read on a transport
// error therefore leaves a truncated VAL in an UNDEFINED record.
for (ty, val) in dset_owns_udf_string_records() {
let (udf, sevr, _) = read_once(
ty,
val,
Step {
wrote_val: true,
udf: DeviceUdf::Untouched,
},
)
.await;
assert_eq!(
udf, 1,
"{ty}: `process()` must not define what the dset did not"
);
assert_eq!(sevr, AlarmSeverity::NoAlarm, "{ty}: no UDF_ALARM on these");
}
}
#[epics_macros_rs::epics_test]
async fn a_dset_that_stored_a_string_and_declared_it_defines_the_record() {
// The success half of the same callback: `status == asynSuccess`, so
// `psi->udf = 0` runs and the stored value is a defined reading.
for (ty, val) in dset_owns_udf_string_records() {
let (udf, sevr, _) = read_once(
ty,
val,
Step {
wrote_val: true,
udf: DeviceUdf::Defined,
},
)
.await;
assert_eq!(udf, 0, "{ty}: the dset declared it defined");
assert_eq!(sevr, AlarmSeverity::NoAlarm, "{ty}");
}
}