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
//! End-to-end wiring for the `stdio` built-in device support (base
//! `devStdio.c`): an `lso`/`printf`/`stringout` record with `DTYP="stdio"`
//! routes through the pre-registered builtin dynamic factory and gets a
//! `StdioDeviceSupport` wired from its INST_IO `OUT` stream name. The DTYP is
//! not a soft channel (`is_soft_dtyp("stdio")` is false), so without the
//! factory the record would have no device at all.
//!
//! Each test drives a record through the real `process()` path with
//! `OUT="@errlog"` and captures the `errlog` `tracing` sink, asserting the
//! record's VAL string was actually printed — not merely that processing left
//! the record un-alarmed. That distinction matters: all three records carry
//! VAL in two shapes (`stringout` a String, `lso`/`printf` a char array) and
//! `printf` only reaches the device-write path once it is in
//! `can_device_write()`, so a "not INVALID" assertion alone would pass even
//! when nothing is printed.
use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::sync::{Arc, Mutex};
use epics_base_rs::server::ioc_builder::IocBuilder;
use epics_base_rs::server::record::AlarmSeverity;
use epics_base_rs::types::EpicsValue;
use tracing::subscriber::DefaultGuard;
use tracing_subscriber::fmt::MakeWriter;
/// A `tracing` writer that appends every formatted event into a shared buffer
/// so a test can read back what the `errlog` sink received.
#[derive(Clone, Default)]
struct CaptureBuf(Arc<Mutex<Vec<u8>>>);
impl CaptureBuf {
fn contents(&self) -> String {
String::from_utf8_lossy(&self.0.lock().unwrap()).into_owned()
}
}
impl Write for CaptureBuf {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> MakeWriter<'a> for CaptureBuf {
type Writer = CaptureBuf;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
/// Install a thread-local subscriber capturing the `errlog` `tracing` sink for
/// the lifetime of the returned guard. `#[epics_macros_rs::epics_test]` runs the body and every
/// `.await` on a single (current-thread) runtime, so the thread-local default
/// stays active across `process_record_with_links`, where the `stdio` device's
/// `errlog_printf` is emitted inline.
fn capture_errlog() -> (DefaultGuard, CaptureBuf) {
let buf = CaptureBuf::default();
let subscriber = tracing_subscriber::fmt()
.with_writer(buf.clone())
.with_max_level(tracing::Level::INFO)
.with_target(false)
.without_time()
.finish();
let guard = tracing::subscriber::set_default(subscriber);
(guard, buf)
}
/// Build a one-record IOC and process the named record, returning its alarm
/// severity afterwards.
async fn build_and_process(db_content: &str, record: &str) -> AlarmSeverity {
let (db, _) = IocBuilder::new()
.db_string(db_content, &HashMap::new())
.unwrap()
.build()
.await
.unwrap();
let mut visited = HashSet::new();
db.process_record_with_links(record, &mut visited, 0)
.await
.unwrap();
let rec = db.get_record(record).expect("record exists");
let inst = rec.read();
inst.common.sevr
}
/// `stringout` carries a String VAL; processing must route it to the stdio
/// device and print VAL to the `errlog` stream, leaving the record un-alarmed.
#[epics_macros_rs::epics_test]
async fn stdio_stringout_prints_val_to_errlog() {
let (_guard, buf) = capture_errlog();
let sevr = build_and_process(
r#"record(stringout, "SO_ERR") {
field(DTYP, "stdio")
field(OUT, "@errlog")
field(VAL, "stringout to errlog")
}"#,
"SO_ERR",
)
.await;
assert_ne!(
sevr,
AlarmSeverity::Invalid,
"valid stdio stringout must not be INVALID"
);
assert!(
buf.contents().contains("stringout to errlog"),
"errlog sink should contain the stringout VAL; got: {:?}",
buf.contents()
);
}
/// `lso` carries a `CharArray` VAL (not a String). A String-only `write()`
/// would print nothing for it; this test fails on that regression because the
/// VAL never reaches the `errlog` sink.
#[epics_macros_rs::epics_test]
async fn stdio_lso_prints_char_array_val_to_errlog() {
let (_guard, buf) = capture_errlog();
let (db, _) = IocBuilder::new()
.db_string(
r#"record(lso, "LSO_ERR") {
field(DTYP, "stdio")
field(OUT, "@errlog")
}"#,
&HashMap::new(),
)
.unwrap()
.build()
.await
.unwrap();
// lso VAL is a long-string char array; the db loader parses
// `field(VAL,"text")` as a scalar DBF_CHAR, so set VAL through the runtime
// put path (which accepts a `CharArray`) rather than a static db field.
db.put_pv_no_process(
"LSO_ERR.VAL",
EpicsValue::CharArray(b"lso to errlog".to_vec()),
)
.await
.unwrap();
let mut visited = HashSet::new();
db.process_record_with_links("LSO_ERR", &mut visited, 0)
.await
.unwrap();
let rec = db.get_record("LSO_ERR").expect("record exists");
let inst = rec.read();
assert_ne!(
inst.common.sevr,
AlarmSeverity::Invalid,
"valid stdio lso must not be INVALID"
);
assert!(
buf.contents().contains("lso to errlog"),
"errlog sink should contain the lso (char-array) VAL; got: {:?}",
buf.contents()
);
}
/// `printf` formats FMT into VAL (a `CharArray`); its device write IS its only
/// output mechanism (`printfRecord.c:388` calls `write_string` unconditionally).
/// This fails on the regression where `printf` is absent from
/// `can_device_write()` and so never reaches `dev.write()`.
#[epics_macros_rs::epics_test]
async fn stdio_printf_routes_to_dev_write_and_prints_to_errlog() {
let (_guard, buf) = capture_errlog();
let sevr = build_and_process(
r#"record(printf, "PF_ERR") {
field(DTYP, "stdio")
field(OUT, "@errlog")
field(FMT, "printf to errlog")
}"#,
"PF_ERR",
)
.await;
assert_ne!(
sevr,
AlarmSeverity::Invalid,
"valid stdio printf must not be INVALID"
);
assert!(
buf.contents().contains("printf to errlog"),
"errlog sink should contain the printf VAL (proves printf reaches dev.write); got: {:?}",
buf.contents()
);
}
/// C registers a stdio dset only for `lso`/`printf`/`stringout`; a `stringin`
/// with `DTYP="stdio"` has no matching support. The Rust device's record-type
/// gate Errs in `init()`, so `wire_device_to_record` flags the record INVALID
/// at build time — proving the stdio device attached and gated, rather than
/// being silently accepted as a soft channel.
#[epics_macros_rs::epics_test]
async fn stdio_wrong_record_type_is_invalid() {
let db_content = r#"
record(stringin, "SI_STDIO") {
field(DTYP, "stdio")
field(INP, "@stdout")
}
"#;
let (db, _) = IocBuilder::new()
.db_string(db_content, &HashMap::new())
.unwrap()
.build()
.await
.unwrap();
let rec = db.get_record("SI_STDIO").expect("record exists");
let inst = rec.read();
assert_eq!(
inst.common.sevr,
AlarmSeverity::Invalid,
"stringin with DTYP=stdio must be INVALID (no stdio device support for stringin)"
);
}