Skip to main content

nord_usb/
session.rs

1//! The transaction wrapper every operation runs inside.
2//!
3//! Each operation is enclosed by the same exchange sequence, independent of what the
4//! operation does:
5//!
6//! ```text
7//! O18 I22, O22 I26, [ operation ], O22 I42, O18 I22, O18 I22
8//! ```
9//!
10//! (Payload bytes. Captures quote frame lengths, which are 40 higher — that is the
11//! sniffer's Darwin header, not anything on the wire.)
12//!
13//! Closing is explicit rather than in `Drop`: `Drop` is neither async nor fallible, so a
14//! failed close there would be swallowed where a half-open transaction may leave the
15//! device in an odd state. `Drop` only complains, in debug builds.
16
17use std::marker::PhantomData;
18use std::time::Duration;
19
20use crate::error::{Error, Result};
21use crate::transport::Transport;
22use crate::wire::{cmd, ui, Message, ObjectClass, Service};
23
24/// Read-only capability. Cannot reach any operation that mutates the device.
25#[derive(Debug)]
26pub struct ReadOnly;
27
28/// Read-write capability, reachable only through an explicit escalation.
29#[derive(Debug)]
30pub struct ReadWrite;
31
32/// How many queued [`cmd::CHANGED`] notifications one response read will drain before
33/// giving up. A cap, not a protocol fact: it exists so a device streaming
34/// notifications cannot pin the host in the read loop forever.
35pub const DRAIN_CAP: usize = 32;
36
37/// Device status meaning "the session you are using is no longer valid".
38///
39/// Seen when a previous run left a session open, and after a session reset. It is
40/// recoverable without touching the instrument: see [`Session::open`].
41pub const STALE_SESSION: u32 = 0x12;
42
43/// Per-frame write liveness bound.
44pub const WRITE_LIMIT: Duration = Duration::from_secs(10);
45
46/// Default per-frame read liveness bound; callers may override it per session.
47pub const READ_LIMIT: Duration = Duration::from_secs(30);
48
49pub struct Session<'t, T: Transport, C = ReadOnly> {
50    // `Option` rather than a plain `&mut` so the capability escalation can move the
51    // borrow out: a type implementing `Drop` cannot be destructured.
52    transport: Option<&'t mut T>,
53    class: ObjectClass,
54    closed: bool,
55    device_changed: bool,
56    read_limit: Duration,
57    _capability: PhantomData<C>,
58}
59
60impl<'t, T: Transport> Session<'t, T, ReadOnly> {
61    /// Open a transaction scoped to one [`ObjectClass`].
62    ///
63    /// The class matters: `STATUS` and the addressing operations all report on
64    /// whichever class was opened, so opening the wrong one yields correct-looking
65    /// numbers about the wrong thing.
66    pub async fn open(transport: &'t mut T, class: ObjectClass) -> Result<Self> {
67        let mut s = Self {
68            transport: Some(transport),
69            class,
70            closed: false,
71            device_changed: false,
72            read_limit: READ_LIMIT,
73            _capability: PhantomData,
74        };
75
76        // ⚠️ An abandoned UI session makes every slot appear empty. Confirmed on hardware.
77        s.handshake().await?;
78
79        let opened = s.open_class(class).await;
80
81        // ⚠️ This covers an abandoned *class* session only. An abandoned **UI** session
82        // reports every slot as empty without an error; [`recover`] handles that case.
83        let opened = match opened {
84            Err(Error::DeviceStatus(STALE_SESSION)) => {
85                if let Err(error) = s.discard_stale_session().await {
86                    s.release().await;
87                    return Err(error);
88                }
89                s.open_class(class).await
90            }
91            other => other,
92        };
93
94        match opened {
95            Ok(_) => Ok(s),
96            Err(e) => {
97                // The HELLO landed, so the UI session is open and must be released.
98                s.release().await;
99                Err(match e {
100                    Error::DeviceStatus(status) if status != STALE_SESSION => {
101                        Error::ClassRefused { class, status }
102                    }
103                    other => other,
104                })
105            }
106        }
107    }
108
109    /// The UI half of opening: `HELLO` and its reply.
110    async fn handshake(&mut self) -> Result<()> {
111        let hello = Message::new(Service::Ui, ui::SUBSYSTEM, ui::HELLO, Vec::new());
112        if let Err(e) = self.notify(&hello).await {
113            self.closed = true; // the write itself failed: the device never saw the HELLO
114            return Err(e);
115        }
116        if let Err(e) = self.response_to(ui::HELLO).await {
117            // The write landed, so the device may already be holding the UI session
118            // even though its reply was unusable.
119            self.release().await;
120            return Err(e);
121        }
122        Ok(())
123    }
124
125    async fn open_class(&mut self, class: ObjectClass) -> Result<()> {
126        self.request(
127            Service::Program,
128            10,
129            cmd::SESSION_OPEN,
130            &class.to_raw().to_be_bytes(),
131        )
132        .await
133        .map(|_| ())
134    }
135
136    /// Tell the device to drop a session it still thinks is open.
137    ///
138    /// Sent **bare** — no `HELLO`, no open — because the machinery that would wrap it is
139    /// exactly what the device is refusing. Confirmed on hardware. An instrument that
140    /// answers `0x12` to everything is well again immediately afterwards.
141    async fn discard_stale_session(&mut self) -> Result<()> {
142        let close = Message::new(Service::Program, 10, cmd::SESSION_CLOSE, Vec::new());
143        self.notify(&close).await?;
144        // Its reply is uninteresting — the point is the side effect — but it must be
145        // taken off the wire, or it would be read as the answer to the next request.
146        let _ = self.read_frame().await?;
147        Ok(())
148    }
149
150    /// Escalate to a session that can mutate the device.
151    pub fn allow_destructive_writes(mut self) -> Session<'t, T, ReadWrite> {
152        let transport = self.transport.take();
153        let (class, closed, device_changed) = (self.class, self.closed, self.device_changed);
154        let read_limit = self.read_limit;
155        // The husk is about to drop and no longer owns the transaction.
156        self.closed = true;
157        Session {
158            transport,
159            class,
160            closed,
161            device_changed,
162            read_limit,
163            _capability: PhantomData,
164        }
165    }
166}
167
168impl<T: Transport, C> Session<'_, T, C> {
169    pub fn class(&self) -> ObjectClass {
170        self.class
171    }
172
173    /// Whether an unsolicited [`cmd::CHANGED`] notification arrived during this
174    /// session.
175    ///
176    /// The device queues one on its own when its contents change outside the session —
177    /// a front-panel STORE, for instance — and `Session::request` drains it rather than
178    /// mistaking it for a reply. `true` means the instrument changed under us: state
179    /// read earlier in this session may be stale.
180    pub fn instrument_changed(&self) -> bool {
181        self.device_changed
182    }
183
184    /// Override the per-frame [`READ_LIMIT`] for this session, including its close.
185    pub fn set_read_limit(&mut self, limit: Duration) {
186        self.read_limit = limit;
187    }
188
189    /// One frame from the device, honoring [`Self::set_read_limit`].
190    ///
191    /// `Ok(None)` means the limit passed with nothing read. The transport has already
192    /// cancelled the outstanding transfer by then, so the session is still in step.
193    async fn read_frame(&mut self) -> Result<Option<Message>> {
194        self.read_frame_with_limit(self.read_limit).await
195    }
196
197    async fn read_frame_with_limit(&mut self, limit: Duration) -> Result<Option<Message>> {
198        self.read_frame_as(limit, Message::decode_response).await
199    }
200
201    async fn read_frame_as(
202        &mut self,
203        limit: Duration,
204        decode: fn(&[u8]) -> Result<Message>,
205    ) -> Result<Option<Message>> {
206        let transport = self
207            .transport
208            .as_mut()
209            .ok_or_else(|| Error::Transport("session has no transport".into()))?;
210
211        let raw = match transport
212            .read_timeout(crate::transport::READ_BUFFER, limit)
213            .await?
214        {
215            Some(raw) => raw,
216            None => return Ok(None),
217        };
218        decode(&raw).map(Some)
219    }
220
221    /// Send an arbitrary command and return whatever comes back, enforcing nothing.
222    ///
223    /// For reverse-engineering commands that have no typed operation yet. Unlike
224    /// `Session::request` this accepts a reply that is not `command + 1` and a non-zero
225    /// status, because on an undocumented command both are results rather than faults —
226    /// a device that does not implement one still answers, with a status saying so.
227    /// `Ok(None)` means it said nothing within `limit`.
228    /// Call [`Self::commit_with_read_limit`] with the same limit to bound cleanup too.
229    ///
230    /// Queued [`cmd::CHANGED`] notifications are drained as in `Session::request`, so a
231    /// front-panel STORE cannot be mistaken for the probe's answer.
232    ///
233    /// # Warning
234    ///
235    /// This sends bytes no capture has ever shown the device being sent. Unknown
236    /// commands have been reported to leave instrument firmware in a state only a power
237    /// cycle clears, and a write-shaped command reaching a real object destroys it.
238    /// Probe read-shaped commands, on backed-up content, or not at all.
239    pub async fn probe(
240        &mut self,
241        service: Service,
242        subsystem: u32,
243        command: u32,
244        args: &[u8],
245        limit: Duration,
246    ) -> Result<Option<Message>> {
247        let response = command.checked_add(1).ok_or_else(|| {
248            Error::InvalidArgument("command 0xffffffff has no response code".into())
249        })?;
250        let req = Message::new(service, subsystem, command, args.to_vec());
251        self.notify(&req).await?;
252
253        let mut drained = 0;
254        loop {
255            let Some(resp) = self.read_probe_frame_with_limit(limit).await? else {
256                return Ok(None);
257            };
258            if resp.command == cmd::CHANGED && resp.command != response && drained < DRAIN_CAP {
259                drained += 1;
260                self.device_changed = true;
261                continue;
262            }
263            return Ok(Some(resp));
264        }
265    }
266
267    async fn read_probe_frame_with_limit(&mut self, limit: Duration) -> Result<Option<Message>> {
268        self.read_frame_as(limit, Message::decode_probe).await
269    }
270
271    /// Send one request and read its response, enforcing the framing invariants: the
272    /// reply must be `command + 1`, and must report success.
273    pub(crate) async fn request(
274        &mut self,
275        service: Service,
276        subsystem: u32,
277        command: u32,
278        args: &[u8],
279    ) -> Result<Message> {
280        let req = Message::new(service, subsystem, command, args.to_vec());
281        self.notify(&req).await?;
282        self.response_to(command).await
283    }
284
285    /// Read the reply to `command`, enforcing the framing invariants: it must carry
286    /// `command + 1` and must report success.
287    ///
288    /// Unsolicited [`cmd::CHANGED`] notifications are drained (up to [`DRAIN_CAP`])
289    /// rather than mistaken for the reply. Any other failure to produce a usable,
290    /// matching reply is a desync: nothing read after it can be paired with its
291    /// request, so the transaction is released before the error is reported.
292    async fn response_to(&mut self, command: u32) -> Result<Message> {
293        let expected = command.checked_add(1).ok_or_else(|| {
294            Error::InvalidArgument("command 0xffffffff has no response code".into())
295        })?;
296        let mut drained = 0;
297        loop {
298            let resp = match self.read_frame().await {
299                Ok(Some(resp)) => resp,
300                // A timed-out request desynchronizes replies, but cancellation may let close land.
301                Ok(None) => {
302                    self.release().await;
303                    return Err(Error::Transport(format!(
304                        "no reply to command {command:#04x} within the session's read limit"
305                    )));
306                }
307                Err(e) => {
308                    self.release().await;
309                    return Err(e);
310                }
311            };
312
313            if resp.command != expected {
314                if resp.command == cmd::CHANGED && drained < DRAIN_CAP {
315                    drained += 1;
316                    self.device_changed = true;
317                    continue;
318                }
319                self.release().await;
320                return Err(Error::UnexpectedResponse {
321                    expected,
322                    got: resp.command,
323                });
324            }
325            return match resp.status() {
326                // A refusal is not a desync: request and reply are still in step, the
327                // session stays usable, and the caller still owes it a close.
328                Some(0) => Ok(resp),
329                Some(code) => Err(Error::DeviceStatus(code)),
330                None => {
331                    self.release().await;
332                    Err(Error::Truncated { got: 0, need: 4 })
333                }
334            };
335        }
336    }
337
338    /// Best-effort, idempotent release after a failed exchange.
339    ///
340    /// ⚠️ `HELLO` without `GOODBYE` wedges inventory reads. Release failures do not
341    /// replace the operation's original error.
342    async fn release(&mut self) {
343        if self.closed {
344            return;
345        }
346        self.closed = true;
347        let goodbye = Message::new(Service::Ui, ui::SUBSYSTEM, ui::GOODBYE, Vec::new());
348        if self.notify(&goodbye).await.is_err() {
349            return;
350        }
351        let _ = self.read_frame().await;
352    }
353
354    /// Send a fire-and-forget message without waiting for a reply.
355    ///
356    /// The UI progress strings ([`ui::label`], [`ui::percent`]) are sent this way: the
357    /// device never acknowledges them, so routing them through [`Self::request`] would
358    /// block forever on a response that never comes.
359    ///
360    pub(crate) async fn notify(&mut self, msg: &Message) -> Result<()> {
361        let transport = self
362            .transport
363            .as_mut()
364            .ok_or_else(|| Error::Transport("session has no transport".into()))?;
365        let encoded = msg.encode();
366        if transport.write_timeout(&encoded, WRITE_LIMIT).await? {
367            Ok(())
368        } else {
369            Err(Error::Transport(format!(
370                "the device did not accept command {:#04x} within {}s: its bulk endpoints \
371                 are stalled, and a power cycle is the only way out — `nord device recover` \
372                 cannot help, because that frame cannot be delivered either",
373                msg.command,
374                WRITE_LIMIT.as_secs()
375            )))
376        }
377    }
378
379    /// Run the closing exchanges. Always prefer this over dropping.
380    pub async fn commit(mut self) -> Result<()> {
381        self.close().await
382    }
383
384    pub(crate) async fn commit_observing_changed(mut self) -> (Result<()>, bool) {
385        let result = self.close().await;
386        (result, self.device_changed)
387    }
388
389    async fn close(&mut self) -> Result<()> {
390        // A failed exchange already released the session and reported its error.
391        if self.closed {
392            return Ok(());
393        }
394        // Mark first so a failed close surfaces as `Err` instead of a Drop assertion.
395        self.closed = true;
396        if let Err(e) = self
397            .request(Service::Program, 10, cmd::SESSION_CLOSE, &[])
398            .await
399        {
400            // ⚠️ A refused close must still say GOODBYE; its failure does not replace
401            // the class-close error.
402            let _ = self
403                .request(Service::Ui, ui::SUBSYSTEM, ui::GOODBYE, &[])
404                .await;
405            return Err(e);
406        }
407        self.request(Service::Ui, ui::SUBSYSTEM, ui::GOODBYE, &[])
408            .await?;
409        Ok(())
410    }
411
412    /// Commit with a bounded close for exploratory probes.
413    /// The consumed session's ordinary read behavior is unchanged.
414    pub async fn commit_with_read_limit(mut self, limit: Duration) -> Result<()> {
415        self.read_limit = limit;
416        self.close().await
417    }
418
419    /// Abandon the transaction without running the closing exchanges.
420    pub fn abort(mut self) {
421        self.closed = true;
422    }
423}
424
425impl<T: Transport, C> Drop for Session<'_, T, C> {
426    fn drop(&mut self) {
427        // ⚠️ Asserting during an unwind aborts the process, burying the panic that is
428        // the actual finding.
429        debug_assert!(
430            self.closed || std::thread::panicking(),
431            "Session dropped without commit()/abort() — the device may be left \
432             mid-transaction. Close it explicitly."
433        );
434    }
435}
436
437// Where a panic aborts rather than unwinds, the panic these tests observe would take
438// the test binary with it.
439#[cfg(all(test, panic = "unwind"))]
440mod tests {
441    use super::*;
442
443    struct Silent;
444
445    impl Transport for Silent {
446        async fn write(&mut self, _buf: &[u8]) -> Result<()> {
447            Ok(())
448        }
449
450        async fn read(&mut self, _max: usize) -> Result<Vec<u8>> {
451            Err(Error::Transport("the test device says nothing".into()))
452        }
453    }
454
455    /// A panic inside a session must arrive at the caller as itself. The `Drop`
456    /// assertion firing during the unwind would abort the process instead.
457    #[test]
458    fn a_panic_inside_a_session_is_not_replaced_by_the_drop_assertion() {
459        let mut transport = Silent;
460        let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
461            let _session: Session<'_, Silent, ReadOnly> = Session {
462                transport: Some(&mut transport),
463                class: ObjectClass::Program,
464                closed: false,
465                device_changed: false,
466                read_limit: READ_LIMIT,
467                _capability: PhantomData,
468            };
469            panic!("the operation failed");
470        }))
471        .expect_err("the closure panics");
472
473        assert_eq!(
474            *panic.downcast::<&str>().expect("the original payload"),
475            "the operation failed"
476        );
477    }
478}