asyn_rs/error.rs
1/// Status codes matching C asyn's asynStatus enum.
2#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3pub enum AsynStatus {
4 Success,
5 Timeout,
6 Overflow,
7 Error,
8 Disconnected,
9 Disabled,
10}
11
12/// Error type for asyn-rs operations.
13#[derive(Debug, thiserror::Error)]
14pub enum AsynError {
15 #[error("asyn: {status:?} - {message}")]
16 Status { status: AsynStatus, message: String },
17
18 /// An octet read that failed *after* transferring bytes into the
19 /// caller's buffer.
20 ///
21 /// C parity: `asynOctet::read` reports `*nbytesTransfered` and
22 /// `*eomReason` **together with** a failing `asynStatus` — the EOS
23 /// interpose breaks out of its accumulation loop on a lower-layer error
24 /// and still runs the common tail
25 /// (`asynInterposeEos.c:242-253`: null-terminate, `*eomReason = eom`,
26 /// `*nbytesTransfered = nRead`, `return status`). A device that emits a
27 /// partial line and then goes quiet therefore reaches the record as
28 /// `asynTimeout` *plus* the bytes it did send; `asynRecord` commits both
29 /// (`asynRecord.c:1591,1627`: `eomr` and `nord` are assigned regardless
30 /// of status).
31 ///
32 /// [`AsynError::Status`] alone cannot express that: `?` on a
33 /// partially-filled read discards the count and the eom reason, and the
34 /// bytes already written into the caller's buffer become unrecoverable
35 /// because the interpose's `in_buf_tail` has advanced past them — and
36 /// because every dispatch hop above the driver (`port_actor` →
37 /// `PortHandle` → device support) owns its own buffer, which `?` drops.
38 /// Carrying the bytes *in* the error is what makes the transfer and the
39 /// status one value: a consumer cannot take the failure without also
40 /// being handed everything the device did send. Build this with
41 /// [`AsynError::with_partial_read`] rather than by hand, and read it back
42 /// with [`AsynError::partial_read`].
43 ///
44 /// The carrier **wraps** the failure it decorates instead of copying its
45 /// status and message out of it: flattening would erase which *kind* of
46 /// failure it was, and callers legitimately ask that question — the
47 /// drivers' fatal-transport test ([`AsynError::is_fatal_transport`]) tears
48 /// the link down for a real errno ([`AsynError::Io`]) but leaves it up for
49 /// a timeout, so a flattened `Io` (a `recv` that returned `ECONNRESET`
50 /// after the EOS interpose had already buffered half a line) would silently
51 /// stop disconnecting. Every question about the underlying failure —
52 /// [`AsynError::status`], [`AsynError::message`],
53 /// [`AsynError::is_transport_io`] — is answered *through* the carrier.
54 #[error("{source} (after {} partial bytes)", partial.nbytes_transferred())]
55 PartialRead {
56 /// The failure that ended the transfer, intact.
57 source: Box<AsynError>,
58 /// The bytes transferred before the failure and the end-of-message
59 /// reason accumulated up to that point — the `*nbytesTransfered` /
60 /// `*eomReason` pair C writes out alongside the error.
61 partial: crate::interpose::PartialOctetRead,
62 },
63
64 /// An octet write that failed *after* the device accepted bytes.
65 ///
66 /// C parity: `asynOctet::write` reports `*nbytesTransfered` **together
67 /// with** a failing `asynStatus`, at every layer of the write chain —
68 /// `drvAsynSerialPort.c::writeIt` (`:849`) assigns
69 /// `*nbytesTransfered = numchars - nleft` on the way out of the loop no
70 /// matter whether it broke on `asynTimeout` or on a fatal `write()` errno;
71 /// `asynInterposeEcho.c::writeIt` (`:88`) and `asynInterposeDelay.c` (`:52`)
72 /// assign `*nbytesTransfered = transfered` on every break; and
73 /// `asynInterposeEos.c::writeIt` (`:196`) clamps the lower layer's count to
74 /// the caller's `numchars` and returns it beside the status. `asynRecord`
75 /// commits the result unconditionally — `nawt = nbytesTransfered`
76 /// (asynRecord.c:1547) runs *before* the status check at `:1551` — so a
77 /// half-written command lands `NAWT=3` next to its `Write error, nout=3`
78 /// diagnostic.
79 ///
80 /// [`AsynError::Status`] alone cannot express that: `?` on a partially
81 /// accepted write discards the count, and every hop above the driver
82 /// (`port_actor` → `PortHandle` → record/device support) only sees the
83 /// status. This is the write-side twin of [`AsynError::PartialRead`], for
84 /// the same reason: carrying the count *in* the error makes the transfer
85 /// and the status one value, so a consumer cannot take the failure without
86 /// being handed how far the device got. Build it with
87 /// [`AsynError::with_partial_write`] and read it back with
88 /// [`AsynError::partial_write`]. Like [`AsynError::PartialRead`] it wraps
89 /// the underlying failure rather than flattening it, so the transport
90 /// classifiers still see the errno.
91 #[error("{source} (after {nbytes} partial bytes written)")]
92 PartialWrite {
93 /// The failure that ended the write, intact.
94 source: Box<AsynError>,
95 /// The bytes the device accepted before the failure — C's
96 /// `*nbytesTransfered` on the error path.
97 nbytes: usize,
98 },
99
100 /// The request waited in the port queue past the deadline its
101 /// `queueRequest` was given, so it was removed and **never ran**.
102 ///
103 /// C `queueTimeoutCallback` (asynManager.c:647-700): the timer
104 /// `queueRequest(pasynUser, priority, timeout)` armed at enqueue
105 /// (asynManager.c:1617-1623) fires while `isQueued` is still true, the
106 /// request is unlinked from the port's queue list, and the caller's
107 /// `timeoutUser` runs **instead of** its `processUser`. Only a caller that
108 /// asked for a queue deadline can see this — device support passes
109 /// `queueRequest(..., 0.0)` (devAsynInt32.c:838) and arms no timer at all,
110 /// while `asynRecord` passes `QUEUE_TIMEOUT` = 10 s for both its process and
111 /// its special requests (asynRecord.c:71,343,572).
112 ///
113 /// Distinct from `AsynStatus::Timeout`, which means the driver *did* run and
114 /// the device did not answer in time. C reports this one as plain
115 /// `asynError` (asynRecord.c:919-926), which is what [`AsynError::status`]'s
116 /// default branch gives it.
117 #[error("queueRequest timeout on port {port}")]
118 QueueTimeout { port: String },
119
120 /// The port's queue gate refused the request, so it was **never queued and
121 /// never ran** — C's `queueRequest` returning `asynDisabled` /
122 /// `asynDisconnected` (asynManager.c:1541-1552).
123 ///
124 /// Distinct from an [`AsynError::Status`] carrying the same `AsynStatus`,
125 /// and that distinction is the whole point: in C the two arrive by different
126 /// routes and mean opposite things. A refusal is `queueRequest`'s *return
127 /// value* — the callback never runs, so nothing it implies happened: no
128 /// bytes moved, no option was written, no readback, no `monitorStatus`
129 /// (asynRecord.c:571-576 reports `pasynUser->errorMessage` and frees the
130 /// user). A driver error arrives *inside* the callback, which did run and
131 /// whose tail C still executes. Collapsing them into one variant made a
132 /// refused `special()` report as a callback that ran (R14-46).
133 ///
134 /// Built only by the gate owner ([`crate::port::PortDriverBase::check_queue`])
135 /// and asked about through [`AsynError::never_ran`].
136 #[error("asyn: {status:?} - {message}")]
137 QueueRefused { status: AsynStatus, message: String },
138
139 #[error("port not found: {0}")]
140 PortNotFound(String),
141
142 #[error("port already registered: {0}")]
143 PortAlreadyRegistered(String),
144
145 #[error("param not found: {0}")]
146 ParamNotFound(String),
147
148 /// C parity: `asynParamAlreadyExists` —
149 /// `paramList::createParam` (`asynPortDriver.cpp:126-138`) returns
150 /// this status when a second `createParam(name, ...)` arrives with
151 /// the same name. The `asynPortDriver::createParam` wrapper
152 /// (`asynPortDriver.cpp:991-1011`) translates it to `asynError`
153 /// with an `asynPrint(ASYN_TRACE_ERROR, ...)` log line. The lax
154 /// Rust [`ParamList::create_param`](crate::param::ParamList::create_param) silently returns the existing
155 /// index to match the idempotent build pattern used by
156 /// `ad-core-rs`/`ad-plugins-rs` (e.g. `ADDriverParams::create`
157 /// after `NDArrayDriverParams::create`); use
158 /// [`ParamList::create_param_strict`](crate::param::ParamList::create_param_strict) when you need C parity for
159 /// the duplicate-name error.
160 #[error("param already exists: {0}")]
161 ParamAlreadyExists(String),
162
163 #[error("param index out of range: {0}")]
164 ParamIndexOutOfRange(usize),
165
166 /// C parity: `asynParamUndefined` —
167 /// `paramVal::getInteger/getInteger64/getDouble/getUInt32/getString`
168 /// throws `ParamValNotDefined` when the value has never been set, and
169 /// `paramList::getInteger/...` translates that to `asynParamUndefined`
170 /// (`asynPortDriver/asynPortDriver.cpp:301-401,543-566`). The lax Rust
171 /// getters (`ParamList::get_int32` etc.) return the type default
172 /// (`0`, `0.0`, `""`) silently — that mirrors many existing call sites
173 /// that use `.unwrap_or(...)`. Use the `_strict` variants
174 /// (`ParamList::get_int32_strict` etc.) to surface this status the way
175 /// C reportGetParamErrors does.
176 #[error("param undefined: index {0}")]
177 ParamUndefined(usize),
178
179 #[error("type mismatch: expected {expected}, got {actual}")]
180 TypeMismatch {
181 expected: &'static str,
182 actual: &'static str,
183 },
184
185 #[error("interface not supported: {0}")]
186 InterfaceNotSupported(String),
187
188 #[error("address out of range: {0}")]
189 AddressOutOfRange(i32),
190
191 #[error("already subscribed")]
192 AlreadySubscribed,
193
194 /// An option key the port does not implement — C's trailing
195 /// `else if (epicsStrCaseCmp(key, "") != 0)` arm, in `setOption` and
196 /// `getOption` alike (drvAsynSerialPort.c:594-597, :1171;
197 /// drvAsynSerialPortWin32.c:341-344; drvAsynIPPort.c:902-905). The text is
198 /// C's, verbatim: it is what reaches the operator through
199 /// `pasynUser->errorMessage` and lands in the record's ERRS.
200 #[error("Unsupported key \"{0}\"")]
201 OptionNotFound(String),
202
203 #[error("invalid link syntax: {0}")]
204 InvalidLinkSyntax(String),
205
206 #[error("downcast failed: stored type does not match requested type")]
207 DowncastFailed,
208
209 #[error("IO: {0}")]
210 Io(#[from] std::io::Error),
211}
212
213impl AsynError {
214 /// The `asynStatus` this error carries — the single owner of the
215 /// error → status mapping.
216 ///
217 /// Every consumer that classifies a failure by status (record alarm
218 /// mapping, fatal-transport detection, protocol reply status) MUST go
219 /// through this instead of matching [`AsynError::Status`] directly:
220 /// a bare match silently misclassifies every other status-carrying
221 /// variant (that is exactly how [`AsynError::PartialRead`] would have
222 /// downgraded a timeout to a generic error). Variants that carry no
223 /// status take C's generic `asynError`, matching the
224 /// `asynStatusToEpicsAlarm` default branch (asynEpicsUtils.c:234-266).
225 pub fn status(&self) -> AsynStatus {
226 match self {
227 AsynError::Status { status, .. } | AsynError::QueueRefused { status, .. } => *status,
228 AsynError::PartialRead { source, .. } | AsynError::PartialWrite { source, .. } => {
229 source.status()
230 }
231 _ => AsynStatus::Error,
232 }
233 }
234
235 /// The driver/interpose diagnostic behind this error — C's
236 /// `pasynUser->errorMessage`, which every `reportError` call site splices
237 /// into `ERRS`. Reads *through* the partial carriers, so a failed transfer
238 /// reports the same text whether or not it moved bytes first.
239 pub fn message(&self) -> String {
240 match self {
241 AsynError::Status { message, .. } | AsynError::QueueRefused { message, .. } => {
242 message.clone()
243 }
244 AsynError::PartialRead { source, .. } | AsynError::PartialWrite { source, .. } => {
245 source.message()
246 }
247 other => other.to_string(),
248 }
249 }
250
251 /// Re-stamp a gate refusal as one: the request was rejected by
252 /// `queueRequest` and never ran. The gate owner
253 /// ([`crate::port::PortDriverBase::check_queue`]) is the only caller — the
254 /// same checks reached any other way (a driver's own `check_ready` inside a
255 /// request that *is* running) stay [`AsynError::Status`], because there the
256 /// callback did run.
257 pub(crate) fn into_queue_refusal(self) -> AsynError {
258 match self {
259 AsynError::Status { status, message } => AsynError::QueueRefused { status, message },
260 other => other,
261 }
262 }
263
264 /// True iff the request never ran at all — the port's queue gate refused it
265 /// ([`AsynError::QueueRefused`]) or it sat past its deadline and was removed
266 /// ([`AsynError::QueueTimeout`]).
267 ///
268 /// The single owner of that question, and the one every caller of a queued
269 /// request must ask before doing anything a *completed* request implies.
270 /// C draws the line structurally: both outcomes are decided before
271 /// `processUser` is ever dispatched — `queueRequest` returns the refusal
272 /// (asynManager.c:1541-1552) and `queueTimeoutCallback` runs `timeoutUser`
273 /// *instead of* `processUser` (:647-700) — so no bytes moved, no option or
274 /// EOS was written, no connect was attempted, and none of the follow-up work
275 /// a completed request implies (asynRecord's `setOption` → `getOptions`
276 /// fall-through, its `monitorStatus` tail) may run.
277 pub fn never_ran(&self) -> bool {
278 self.is_queue_refused() || self.is_queue_timeout()
279 }
280
281 /// True iff the port's queue gate refused the request — see
282 /// [`AsynError::QueueRefused`]. Ask [`AsynError::never_ran`] unless you
283 /// specifically need to tell a refusal from a queue timeout.
284 pub fn is_queue_refused(&self) -> bool {
285 match self {
286 AsynError::QueueRefused { .. } => true,
287 AsynError::PartialRead { source, .. } | AsynError::PartialWrite { source, .. } => {
288 source.is_queue_refused()
289 }
290 _ => false,
291 }
292 }
293
294 /// True iff the request never ran because it sat in the port queue past its
295 /// deadline — C's `queueTimeoutCallback` outcome, see
296 /// [`AsynError::QueueTimeout`].
297 ///
298 /// The single owner of that test. Callers that arm a queue deadline MUST ask
299 /// through this rather than matching the variant, and MUST NOT treat the
300 /// failure as an I/O result: C runs `timeoutUser` *instead of* `processUser`,
301 /// so nothing the request would have done happened — no bytes moved, no
302 /// option was written, and none of the follow-up work a completed request
303 /// implies (asynRecord's `setOption` → `getOptions` fall-through) may run.
304 pub fn is_queue_timeout(&self) -> bool {
305 match self {
306 AsynError::QueueTimeout { .. } => true,
307 AsynError::PartialRead { source, .. } | AsynError::PartialWrite { source, .. } => {
308 source.is_queue_timeout()
309 }
310 _ => false,
311 }
312 }
313
314 /// True when the failure came from the OS transport itself — a real errno
315 /// on the fd/socket, not a timeout and not a higher-layer complaint.
316 ///
317 /// C's drivers decide this at the errno itself, calling `closeConnection`
318 /// right where `read`/`write` failed (drvAsynIPPort.c:642-651,
319 /// drvAsynSerialPort.c:836-845) while returning `asynTimeout` with the link
320 /// intact on a poll expiry. Rust re-derives the decision one layer up, in
321 /// [`AsynError::is_fatal_transport`], so the errno has to survive the trip:
322 /// ask *through* the partial carriers rather than matching
323 /// [`AsynError::Io`] by variant, or a half-transferred `ECONNRESET` reads
324 /// as non-fatal and leaves a dead socket reporting `connected` forever.
325 pub fn is_transport_io(&self) -> bool {
326 match self {
327 AsynError::Io(_) => true,
328 AsynError::PartialRead { source, .. } | AsynError::PartialWrite { source, .. } => {
329 source.is_transport_io()
330 }
331 _ => false,
332 }
333 }
334
335 /// This failure means the link is dead and the driver must tear the
336 /// connection down — C's `closeConnection` contract: a real errno on the
337 /// fd/socket, or an explicit disconnect, but **not** a timeout (C returns
338 /// `asynTimeout` with the link intact and lets the next transfer retry).
339 ///
340 /// The single owner of that test for every octet driver (`drvAsynIPPort`,
341 /// `drvAsynSerialPort`, its Win32 twin), which each used to keep a private
342 /// copy — and each copy independently proxied "real errno" through
343 /// `matches!(e, AsynError::Io(_))`, a variant match that the
344 /// partial-transfer carriers defeat.
345 pub fn is_fatal_transport(&self) -> bool {
346 matches!(self.status(), AsynStatus::Disconnected) || self.is_transport_io()
347 }
348
349 /// The partial octet transfer delivered before this error, if any —
350 /// C's `*nbytesTransfered` / `*eomReason` / caller-buffer contents on the
351 /// failure path.
352 ///
353 /// Every consumer of an octet read MUST consult this on the error path:
354 /// C `asynRecord::performOctetIO` (asynRecord.c:1591-1629) and
355 /// `devAsynOctet::readIt` (devAsynOctet.c:693-717) both publish the
356 /// transfer regardless of the returned status, so an error branch that
357 /// looks only at the status silently drops device data C delivers.
358 pub fn partial_read(&self) -> Option<&crate::interpose::PartialOctetRead> {
359 match self {
360 AsynError::PartialRead { partial, .. } => Some(partial),
361 _ => None,
362 }
363 }
364
365 /// Attach a partial octet transfer to a failing read. This is the only way
366 /// to build [`AsynError::PartialRead`], so the underlying failure can never
367 /// be lost in the conversion — it is wrapped, not copied.
368 ///
369 /// Re-attaching overwrites the count: in a stacked interpose chain the
370 /// outermost layer is the one that filled the caller's buffer, so its count
371 /// is the authoritative `*nbytesTransfered`. The original source is kept —
372 /// re-wrapping must not bury it one layer deeper each hop.
373 pub fn with_partial_read(self, partial: crate::interpose::PartialOctetRead) -> Self {
374 let source = match self {
375 AsynError::PartialRead { source, .. } => source,
376 other => Box::new(other),
377 };
378 AsynError::PartialRead { source, partial }
379 }
380
381 /// The bytes the device accepted before this write failed — C's
382 /// `*nbytesTransfered` on a failing `asynOctet::write`. `None` means the
383 /// layer reported no transfer, which is C's pre-call
384 /// `nbytesTransfered = 0` (asynRecord.c:1526) left untouched: zero bytes.
385 ///
386 /// Every consumer of an octet write MUST consult this on the error path:
387 /// C `asynRecord::performOctetIO` publishes `nawt = nbytesTransfered`
388 /// before it looks at the status (asynRecord.c:1547-1551), so an error
389 /// branch that reports only "0 written" contradicts what the device
390 /// actually received.
391 pub fn partial_write(&self) -> Option<usize> {
392 match self {
393 AsynError::PartialWrite { nbytes, .. } => Some(*nbytes),
394 _ => None,
395 }
396 }
397
398 /// Attach the accepted-byte count to a failing write. This is the only way
399 /// to build [`AsynError::PartialWrite`], so the underlying failure can
400 /// never be lost in the conversion — it is wrapped, not copied.
401 ///
402 /// Re-attaching overwrites the count: in a stacked interpose chain the
403 /// outermost layer is the one that owns the caller's `numchars` (the EOS
404 /// interpose must not report its appended terminator bytes), so its count
405 /// is the authoritative `*nbytesTransfered`. The original source is kept.
406 pub fn with_partial_write(self, nbytes: usize) -> Self {
407 let source = match self {
408 AsynError::PartialWrite { source, .. } => source,
409 other => Box::new(other),
410 };
411 AsynError::PartialWrite { source, nbytes }
412 }
413}
414
415pub type AsynResult<T> = Result<T, AsynError>;