Skip to main content

asyn_rs/interpose/
mod.rs

1#![allow(dead_code)]
2//! Interpose (middleware) framework for layered I/O processing.
3//!
4//! Currently implements octet-level interpose only. The pattern is designed
5//! so that other interface types (e.g., `int32`) can follow the same structure.
6//!
7//! # Architecture
8//!
9//! An [`OctetInterposeStack`] holds a chain of [`OctetInterpose`] layers.
10//! When I/O is dispatched, an `InterposeChain` cursor walks the stack
11//! from outermost to innermost, finally reaching the base driver (which
12//! implements [`OctetNext`]).
13
14pub mod com;
15pub mod delay;
16pub mod echo;
17pub mod eos;
18pub mod flush;
19
20use bitflags::bitflags;
21
22use crate::error::AsynResult;
23use crate::user::AsynUser;
24
25bitflags! {
26    /// End-of-message reason flags (mirrors C asyn's asynEomReason).
27    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
28    pub struct EomReason: u32 {
29        /// Transfer completed because byte count was reached.
30        const CNT = 0x01;
31        /// Transfer completed because EOS character was detected.
32        const EOS = 0x02;
33        /// Transfer completed because END indicator (e.g. EOI) was asserted.
34        const END = 0x04;
35    }
36}
37
38/// Result of an octet read operation.
39#[derive(Debug, Clone)]
40pub struct OctetReadResult {
41    /// Number of bytes actually transferred into the buffer.
42    pub nbytes_transferred: usize,
43    /// Reason(s) the read terminated.
44    pub eom_reason: EomReason,
45}
46
47/// The transfer an octet read completed *before* it failed — C's
48/// `*nbytesTransfered` / `*eomReason`, which `asynOctet::read` writes out
49/// even when it returns a failing `asynStatus`
50/// (`asynInterposeEos.c:242-253`).
51///
52/// This owns the bytes rather than pointing at the caller's buffer: it rides
53/// inside [`crate::error::AsynError::PartialRead`], so the data and the
54/// status are one value and a `?` on the read cannot deliver the failure
55/// while dropping the bytes. Every dispatch hop between the interpose and a
56/// record (`port_actor` → `PortHandle` → device support / `SyncIO`) hands the
57/// error on by value, so the transfer arrives with it.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct PartialOctetRead {
60    /// The bytes transferred into the caller's buffer before the failure.
61    pub data: Vec<u8>,
62    /// The end-of-message reason accumulated up to the failure.
63    pub eom_reason: EomReason,
64}
65
66impl PartialOctetRead {
67    /// C's `*nbytesTransfered` for this failed read.
68    pub fn nbytes_transferred(&self) -> usize {
69        self.data.len()
70    }
71}
72
73/// "Next layer" interface — implemented by both the base driver adapter
74/// and by `InterposeChain` to allow recursive dispatch.
75pub trait OctetNext: Send + Sync {
76    fn read(&mut self, user: &AsynUser, buf: &mut [u8]) -> AsynResult<OctetReadResult>;
77    fn write(&mut self, user: &mut AsynUser, data: &[u8]) -> AsynResult<usize>;
78    fn flush(&mut self, user: &mut AsynUser) -> AsynResult<()>;
79}
80
81/// Interpose layer for octet (byte-stream) I/O.
82///
83/// Each layer receives the `next` handle to delegate to the layer below.
84pub trait OctetInterpose: Send + Sync {
85    fn read(
86        &mut self,
87        user: &AsynUser,
88        buf: &mut [u8],
89        next: &mut dyn OctetNext,
90    ) -> AsynResult<OctetReadResult>;
91
92    fn write(
93        &mut self,
94        user: &mut AsynUser,
95        data: &[u8],
96        next: &mut dyn OctetNext,
97    ) -> AsynResult<usize>;
98
99    fn flush(&mut self, user: &mut AsynUser, next: &mut dyn OctetNext) -> AsynResult<()>;
100
101    /// Told the port's device model when the layer is installed. Default no-op;
102    /// only a layer that holds per-device state needs it.
103    ///
104    /// C creates one interpose instance per (port, addr) — the addr is an
105    /// argument of `asynInterposeEosConfig` (asynInterposeEos.c:84-110) — so a
106    /// layer's state is per device by construction. A Rust stack is per port, so
107    /// a layer with device state keys it by the `asynUser`'s addr instead, and
108    /// [`crate::port::eos_device_key`] needs this flag to know whether the port
109    /// has devices to key by at all.
110    fn attach_port(&mut self, _multi_device: bool) {}
111
112    /// Notify the layer of an input end-of-string change *for the device the
113    /// `asynUser` addressed*. Default no-op; only EOS-aware layers
114    /// (`eos::EosInterpose`) act on it. C asyn routes `setInputEos` through
115    /// every interpose via `pasynOctet->setInputEos`, `asynUser` and all
116    /// (asynInterposeEos.c:288); this is the Rust equivalent so a runtime IEOS
117    /// change reaches the installed EOS interpose on the right device.
118    fn set_input_eos(&mut self, _addr: i32, _eos: &[u8]) {}
119
120    /// Notify the layer of an output end-of-string change (see
121    /// [`Self::set_input_eos`]). Default no-op.
122    fn set_output_eos(&mut self, _addr: i32, _eos: &[u8]) {}
123
124    /// Drop every piece of state scoped to the *current* link, because the
125    /// port's connection state just changed (connected → disconnected or
126    /// back). Default no-op: a layer whose state is pure configuration
127    /// (`delay`, `flush`, `echo`) survives a reconnect unchanged.
128    ///
129    /// C parity: `asynInterposeEos.c:110` registers `eosInExceptionHandler`
130    /// via `exceptionCallbackAdd`, and `:142-151` clears `inBufHead`,
131    /// `inBufTail` and `eosInMatch` on `asynExceptionConnect` — which
132    /// `exceptionConnect` (asynManager.c:2158) *and* `exceptionDisconnect`
133    /// (asynManager.c:2185) both fire, so the reset happens on either edge.
134    /// [`crate::port::PortDriverBase::set_connected`] is the Rust owner of
135    /// that transition and drives this hook for the whole stack.
136    fn connection_changed(&mut self) {}
137}
138
139/// The key of the port's own interpose chain — C's `pport->dpc`, the
140/// `dpCommon` every addr that names no device resolves to
141/// (`findDpCommon`/`findInterface`, asynManager.c:536-551, 1493-1501).
142pub const PORT_CHAIN: i32 = -1;
143
144/// The octet interpose layers of one port — C's `dpCommon.interposeInterfaceList`,
145/// which exists once per *device* as well as once per port.
146///
147/// `interposeInterface` takes an `addr` (asynManager.c:2190-2220): `addr >= 0` on
148/// a multi-device port puts the layer on that DEVICE's list (:2202-2206), and
149/// `findInterface` resolves a request device-first, port-second (:1493-1501). So
150/// `asynInterposeDelay("gpib", 4, 0.01)` slows device 4 and nothing else — a
151/// single port-wide chain slowed every device on the bus (R15-48).
152///
153/// A device's chain **shadows** the port's rather than extending it, because
154/// that is what C builds: a layer installed on a device whose list was empty
155/// takes its `pPrev` from the *driver's* `interfaceList` (:2211-2215), not from
156/// the port's interposes, so it delegates straight down to the driver.
157pub struct OctetInterposeStack {
158    /// [`PORT_CHAIN`] plus one entry per device that has an interpose of its own.
159    chains: std::collections::BTreeMap<i32, Vec<Box<dyn OctetInterpose>>>,
160    /// The port's device model, handed to every layer at install time
161    /// ([`OctetInterpose::attach_port`]) and what decides whether an addr can
162    /// name a device at all — C `locateDevice` returns none for a port that is
163    /// not `ASYN_MULTIDEVICE` (asynManager.c:574).
164    multi_device: bool,
165}
166
167impl OctetInterposeStack {
168    pub fn new(multi_device: bool) -> Self {
169        Self {
170            chains: std::collections::BTreeMap::new(),
171            multi_device,
172        }
173    }
174
175    /// Install an interpose layer on the device `addr` names — C
176    /// `interposeInterface` (asynManager.c:2190-2220). `addr < 0`, or any addr on
177    /// a port that is not multi-device, installs on the port itself
178    /// ([`crate::port::eos_device_key`], C's `locateDevice` at :574).
179    ///
180    /// The new layer *becomes* that `dpCommon`'s octet interface (C overwrites the
181    /// interpose node's `pasynInterface` with it, :2217) and the interface it
182    /// displaced — the previously installed interpose at the same level, or the
183    /// driver's own interface when there was none — becomes the one it delegates
184    /// down to (C hands it back as `pPrev`, :2209-2215).
185    ///
186    /// So the **last layer installed is the outermost**: a caller enters it first,
187    /// and it calls down through the earlier layers to the driver. An
188    /// `asynInterposeEcho` installed from iocsh after the driver's configure-time
189    /// EOS layer therefore sits *above* EOS, exactly as in C. Dispatch walks index
190    /// 0 first, so the new layer goes to the front.
191    pub fn install(&mut self, addr: i32, mut layer: Box<dyn OctetInterpose>) {
192        layer.attach_port(self.multi_device);
193        self.chains
194            .entry(crate::port::eos_device_key(self.multi_device, addr))
195            .or_default()
196            .insert(0, layer);
197    }
198
199    /// The chain a request on `addr` runs through — C `findInterface`
200    /// (asynManager.c:1493-1501): the device's own interposes if it has any,
201    /// otherwise the port's.
202    fn chain_key(&self, addr: i32) -> i32 {
203        let key = crate::port::eos_device_key(self.multi_device, addr);
204        if key != PORT_CHAIN && self.chains.get(&key).is_some_and(|c| !c.is_empty()) {
205            key
206        } else {
207            PORT_CHAIN
208        }
209    }
210
211    fn chain_mut(&mut self, addr: i32) -> Option<&mut Vec<Box<dyn OctetInterpose>>> {
212        let key = self.chain_key(addr);
213        self.chains.get_mut(&key).filter(|c| !c.is_empty())
214    }
215
216    /// Total number of interpose layers on the port, across every device's chain
217    /// — what `asynReport` counts (asynManager.c:993-1005 walks each `dpCommon`'s
218    /// list).
219    pub fn len(&self) -> usize {
220        self.chains.values().map(Vec::len).sum()
221    }
222
223    pub fn is_empty(&self) -> bool {
224        self.len() == 0
225    }
226
227    /// Number of layers on the chain the given addr resolves to.
228    pub fn len_for(&self, addr: i32) -> usize {
229        self.chains
230            .get(&self.chain_key(addr))
231            .map_or(0, |c| c.len())
232    }
233
234    /// Forward an input EOS change to the chain the addressed device resolves to.
235    /// EOS-aware layers update that device's terminator; others ignore it (trait
236    /// default). C routes `setInputEos` through the `asynOctet` interface
237    /// `findInterface` returned for that `asynUser`, so it reaches exactly the
238    /// layers that will serve the device's reads.
239    pub fn set_input_eos(&mut self, addr: i32, eos: &[u8]) {
240        if let Some(chain) = self.chain_mut(addr) {
241            for layer in chain {
242                layer.set_input_eos(addr, eos);
243            }
244        }
245    }
246
247    /// Forward an output EOS change (see [`Self::set_input_eos`]).
248    pub fn set_output_eos(&mut self, addr: i32, eos: &[u8]) {
249        if let Some(chain) = self.chain_mut(addr) {
250            for layer in chain {
251                layer.set_output_eos(addr, eos);
252            }
253        }
254    }
255
256    /// Tell every layer, on every device's chain, that the port's connection
257    /// state changed, so any link-scoped state (read-ahead buffers, partial
258    /// terminator match position) is dropped before the new link delivers its
259    /// first byte. Mirrors C's per-interpose `asynExceptionConnect` handlers
260    /// (`asynInterposeEos.c:142-151`); the only caller is the transition owner
261    /// [`crate::port::PortDriverBase::set_connected`], and the link it moved is
262    /// the one under *all* of them.
263    pub fn connection_changed(&mut self) {
264        for chain in self.chains.values_mut() {
265            for layer in chain {
266                layer.connection_changed();
267            }
268        }
269    }
270
271    /// Dispatch a read through the addressed device's interpose chain, ending at
272    /// `base`.
273    pub fn dispatch_read(
274        &mut self,
275        user: &AsynUser,
276        buf: &mut [u8],
277        base: &mut dyn OctetNext,
278    ) -> AsynResult<OctetReadResult> {
279        let addr = user.addr;
280        let Some(layers) = self.chain_mut(addr) else {
281            return base.read(user, buf);
282        };
283        InterposeChain {
284            layers: layers.as_mut_slice(),
285            base,
286        }
287        .read(user, buf)
288    }
289
290    /// Dispatch a write through the addressed device's interpose chain.
291    pub fn dispatch_write(
292        &mut self,
293        user: &mut AsynUser,
294        data: &[u8],
295        base: &mut dyn OctetNext,
296    ) -> AsynResult<usize> {
297        let addr = user.addr;
298        let Some(layers) = self.chain_mut(addr) else {
299            return base.write(user, data);
300        };
301        InterposeChain {
302            layers: layers.as_mut_slice(),
303            base,
304        }
305        .write(user, data)
306    }
307
308    /// Dispatch a flush through the addressed device's interpose chain.
309    pub fn dispatch_flush(
310        &mut self,
311        user: &mut AsynUser,
312        base: &mut dyn OctetNext,
313    ) -> AsynResult<()> {
314        let addr = user.addr;
315        let Some(layers) = self.chain_mut(addr) else {
316            return base.flush(user);
317        };
318        InterposeChain {
319            layers: layers.as_mut_slice(),
320            base,
321        }
322        .flush(user)
323    }
324}
325
326impl Default for OctetInterposeStack {
327    /// A stack on a single-device port — the common case, and the one where
328    /// every addr collapses onto one EOS entry.
329    fn default() -> Self {
330        Self::new(false)
331    }
332}
333
334/// Cursor that walks the interpose stack via recursive `split_first_mut`.
335struct InterposeChain<'a> {
336    layers: &'a mut [Box<dyn OctetInterpose>],
337    base: &'a mut dyn OctetNext,
338}
339
340impl OctetNext for InterposeChain<'_> {
341    fn read(&mut self, user: &AsynUser, buf: &mut [u8]) -> AsynResult<OctetReadResult> {
342        if let Some((first, rest)) = self.layers.split_first_mut() {
343            let mut next = InterposeChain {
344                layers: rest,
345                base: self.base,
346            };
347            first.read(user, buf, &mut next)
348        } else {
349            self.base.read(user, buf)
350        }
351    }
352
353    fn write(&mut self, user: &mut AsynUser, data: &[u8]) -> AsynResult<usize> {
354        if let Some((first, rest)) = self.layers.split_first_mut() {
355            let mut next = InterposeChain {
356                layers: rest,
357                base: self.base,
358            };
359            first.write(user, data, &mut next)
360        } else {
361            self.base.write(user, data)
362        }
363    }
364
365    fn flush(&mut self, user: &mut AsynUser) -> AsynResult<()> {
366        if let Some((first, rest)) = self.layers.split_first_mut() {
367            let mut next = InterposeChain {
368                layers: rest,
369                base: self.base,
370            };
371            first.flush(user, &mut next)
372        } else {
373            self.base.flush(user)
374        }
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use crate::user::AsynUser;
382
383    /// A base driver that just records calls.
384    struct MockBase {
385        read_data: Vec<u8>,
386        written: Vec<u8>,
387        flushed: bool,
388    }
389
390    impl MockBase {
391        fn new(data: &[u8]) -> Self {
392            Self {
393                read_data: data.to_vec(),
394                written: Vec::new(),
395                flushed: false,
396            }
397        }
398    }
399
400    impl OctetNext for MockBase {
401        fn read(&mut self, _user: &AsynUser, buf: &mut [u8]) -> AsynResult<OctetReadResult> {
402            let n = self.read_data.len().min(buf.len());
403            buf[..n].copy_from_slice(&self.read_data[..n]);
404            Ok(OctetReadResult {
405                nbytes_transferred: n,
406                eom_reason: EomReason::CNT,
407            })
408        }
409
410        fn write(&mut self, _user: &mut AsynUser, data: &[u8]) -> AsynResult<usize> {
411            self.written.extend_from_slice(data);
412            Ok(data.len())
413        }
414
415        fn flush(&mut self, _user: &mut AsynUser) -> AsynResult<()> {
416            self.flushed = true;
417            Ok(())
418        }
419    }
420
421    /// A simple pass-through interpose layer.
422    struct PassthroughInterpose;
423
424    impl OctetInterpose for PassthroughInterpose {
425        fn read(
426            &mut self,
427            user: &AsynUser,
428            buf: &mut [u8],
429            next: &mut dyn OctetNext,
430        ) -> AsynResult<OctetReadResult> {
431            next.read(user, buf)
432        }
433        fn write(
434            &mut self,
435            user: &mut AsynUser,
436            data: &[u8],
437            next: &mut dyn OctetNext,
438        ) -> AsynResult<usize> {
439            next.write(user, data)
440        }
441        fn flush(&mut self, user: &mut AsynUser, next: &mut dyn OctetNext) -> AsynResult<()> {
442            next.flush(user)
443        }
444    }
445
446    /// An interpose that uppercases data on write.
447    struct UppercaseInterpose;
448
449    impl OctetInterpose for UppercaseInterpose {
450        fn read(
451            &mut self,
452            user: &AsynUser,
453            buf: &mut [u8],
454            next: &mut dyn OctetNext,
455        ) -> AsynResult<OctetReadResult> {
456            next.read(user, buf)
457        }
458        fn write(
459            &mut self,
460            user: &mut AsynUser,
461            data: &[u8],
462            next: &mut dyn OctetNext,
463        ) -> AsynResult<usize> {
464            let upper: Vec<u8> = data.iter().map(|b| b.to_ascii_uppercase()).collect();
465            next.write(user, &upper)
466        }
467        fn flush(&mut self, user: &mut AsynUser, next: &mut dyn OctetNext) -> AsynResult<()> {
468            next.flush(user)
469        }
470    }
471
472    #[test]
473    fn test_empty_stack_passthrough() {
474        let mut stack = OctetInterposeStack::new(false);
475        let mut base = MockBase::new(b"hello");
476        let user = AsynUser::default();
477        let mut buf = [0u8; 32];
478
479        let result = stack.dispatch_read(&user, &mut buf, &mut base).unwrap();
480        assert_eq!(result.nbytes_transferred, 5);
481        assert_eq!(&buf[..5], b"hello");
482    }
483
484    #[test]
485    fn test_single_passthrough_layer() {
486        let mut stack = OctetInterposeStack::new(false);
487        stack.install(-1, Box::new(PassthroughInterpose));
488
489        let mut base = MockBase::new(b"world");
490        let user = AsynUser::default();
491        let mut buf = [0u8; 32];
492
493        let result = stack.dispatch_read(&user, &mut buf, &mut base).unwrap();
494        assert_eq!(result.nbytes_transferred, 5);
495        assert_eq!(&buf[..5], b"world");
496    }
497
498    #[test]
499    fn test_uppercase_interpose_write() {
500        let mut stack = OctetInterposeStack::new(false);
501        stack.install(-1, Box::new(UppercaseInterpose));
502
503        let mut base = MockBase::new(b"");
504        let mut user = AsynUser::default();
505
506        let n = stack
507            .dispatch_write(&mut user, b"hello", &mut base)
508            .unwrap();
509        assert_eq!(n, 5);
510        assert_eq!(&base.written, b"HELLO");
511    }
512
513    #[test]
514    fn test_multi_layer_chain() {
515        let mut stack = OctetInterposeStack::new(false);
516        stack.install(-1, Box::new(PassthroughInterpose));
517        stack.install(-1, Box::new(UppercaseInterpose));
518        assert_eq!(stack.len(), 2);
519
520        let mut base = MockBase::new(b"");
521        let mut user = AsynUser::default();
522
523        // UppercaseInterpose (installed last, so outermost) -> Passthrough -> base
524        stack.dispatch_write(&mut user, b"test", &mut base).unwrap();
525        assert_eq!(&base.written, b"TEST");
526    }
527
528    #[test]
529    fn test_flush_dispatch() {
530        let mut stack = OctetInterposeStack::new(false);
531        stack.install(-1, Box::new(PassthroughInterpose));
532
533        let mut base = MockBase::new(b"");
534        let mut user = AsynUser::default();
535
536        stack.dispatch_flush(&mut user, &mut base).unwrap();
537        assert!(base.flushed);
538    }
539
540    /// R9-56. C `interposeInterface` (asynManager.c:2209-2217) makes each newly
541    /// installed layer the port's octet interface and hands it the one it
542    /// displaced to call down into, so the *last* install is the *outermost*.
543    /// This stack appended and dispatched from index 0, so a later install landed
544    /// *innermost* — the exact inverse. Under the inverted rule an
545    /// `asynInterposeEcho`/`asynInterposeDelay` installed from iocsh sank *below*
546    /// the EOS layer the driver installs at configure time, when C puts it above.
547    #[test]
548    fn the_last_layer_installed_is_the_outermost() {
549        /// Marks the payload with its own tag on the way down, so the base's
550        /// buffer records the order the layers ran in.
551        struct Tag(u8);
552        impl OctetInterpose for Tag {
553            fn read(
554                &mut self,
555                user: &AsynUser,
556                buf: &mut [u8],
557                next: &mut dyn OctetNext,
558            ) -> AsynResult<OctetReadResult> {
559                next.read(user, buf)
560            }
561            fn write(
562                &mut self,
563                user: &mut AsynUser,
564                data: &[u8],
565                next: &mut dyn OctetNext,
566            ) -> AsynResult<usize> {
567                let mut tagged = vec![self.0];
568                tagged.extend_from_slice(data);
569                next.write(user, &tagged)
570            }
571            fn flush(&mut self, user: &mut AsynUser, next: &mut dyn OctetNext) -> AsynResult<()> {
572                next.flush(user)
573            }
574        }
575
576        let mut stack = OctetInterposeStack::new(false);
577        stack.install(-1, Box::new(Tag(b'A')));
578        stack.install(-1, Box::new(Tag(b'B')));
579        stack.install(-1, Box::new(Tag(b'C')));
580
581        let mut base = MockBase::new(b"");
582        let mut user = AsynUser::default();
583        stack.dispatch_write(&mut user, b"x", &mut base).unwrap();
584
585        // C's chain is C -> B -> A -> driver: the caller enters the last-installed
586        // layer first, and each calls down into the one it displaced, so the
587        // driver sees the tags in install order. The inverted stack ran A first
588        // and delivered b"CBAx".
589        assert_eq!(&base.written, b"ABCx");
590    }
591
592    /// R15-48: an interpose installed with an `addr` serves that DEVICE, not the
593    /// whole port.
594    ///
595    /// C `interposeInterface(portName, addr, ...)` puts the layer on the device's
596    /// `dpCommon.interposeInterfaceList` when `addr >= 0` (asynManager.c:2202-2206),
597    /// and `findInterface` resolves a request device-first, port-second
598    /// (:1493-1501). Both iocsh interposes pass their addr
599    /// (asynInterposeEcho.c:176, asynInterposeDelay.c:187) — so
600    /// `asynInterposeDelay("gpib",4,0.01)` slows device 4 and nothing else. The
601    /// stack was one port-wide chain, so it slowed every device on the bus.
602    ///
603    /// One case per boundary: the addressed device, an unaddressed sibling, the
604    /// port-level chain as fallback, and the single-device port that collapses
605    /// every addr onto one chain.
606    #[test]
607    fn a_device_addressed_interpose_serves_only_that_device() {
608        struct Tag(u8);
609        impl OctetInterpose for Tag {
610            fn read(
611                &mut self,
612                user: &AsynUser,
613                buf: &mut [u8],
614                next: &mut dyn OctetNext,
615            ) -> AsynResult<OctetReadResult> {
616                next.read(user, buf)
617            }
618            fn write(
619                &mut self,
620                user: &mut AsynUser,
621                data: &[u8],
622                next: &mut dyn OctetNext,
623            ) -> AsynResult<usize> {
624                let mut tagged = vec![self.0];
625                tagged.extend_from_slice(data);
626                next.write(user, &tagged)
627            }
628            fn flush(&mut self, user: &mut AsynUser, next: &mut dyn OctetNext) -> AsynResult<()> {
629                next.flush(user)
630            }
631        }
632
633        let write_from = |stack: &mut OctetInterposeStack, addr: i32| {
634            let mut base = MockBase::new(b"");
635            let mut user = AsynUser::new(0).with_addr(addr);
636            stack.dispatch_write(&mut user, b"x", &mut base).unwrap();
637            base.written
638        };
639
640        // A multi-device port: a layer on device 4, another on device 2.
641        let mut stack = OctetInterposeStack::new(true);
642        stack.install(4, Box::new(Tag(b'D')));
643        stack.install(2, Box::new(Tag(b'E')));
644
645        assert_eq!(
646            write_from(&mut stack, 4),
647            b"Dx",
648            "device 4 runs its own layer"
649        );
650        assert_eq!(
651            write_from(&mut stack, 2),
652            b"Ex",
653            "device 2 runs its own layer"
654        );
655        assert_eq!(
656            write_from(&mut stack, 7),
657            b"x",
658            "a device with no interpose of its own runs none — C's findInterface \
659             falls back to the port's list, which is empty here"
660        );
661        assert_eq!(stack.len(), 2, "two layers on the port, one per device");
662        assert_eq!(stack.len_for(4), 1);
663        assert_eq!(stack.len_for(7), 0);
664
665        // The port's own chain (addr < 0) is what every unaddressed device falls
666        // back to — C `findInterface`'s second lookup (:1499-1501).
667        stack.install(PORT_CHAIN, Box::new(Tag(b'P')));
668        assert_eq!(
669            write_from(&mut stack, 7),
670            b"Px",
671            "a device with no chain of its own falls back to the port's"
672        );
673        assert_eq!(
674            write_from(&mut stack, 4),
675            b"Dx",
676            "...and a device WITH one keeps running only its own: C gives it the \
677             driver's interface as pPrev, not the port's interposes (:2211-2215)"
678        );
679
680        // A port that never declared ASYN_MULTIDEVICE has no devices to key by:
681        // C's `locateDevice` returns none for it (:574), so every addr — the
682        // iocsh default 0 included — lands on the port itself.
683        let mut single = OctetInterposeStack::new(false);
684        single.install(0, Box::new(Tag(b'S')));
685        assert_eq!(write_from(&mut single, 0), b"Sx");
686        assert_eq!(write_from(&mut single, 4), b"Sx");
687        assert_eq!(write_from(&mut single, -1), b"Sx");
688    }
689}