Skip to main content

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>;