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}