Skip to main content

mtp_mount/
device.rs

1//! Opening (and reopening) the MTP device behind the mount.
2//!
3//! The filesystem never opens a device itself: it asks a [`DeviceOpener`]. That
4//! keeps the reconnect path honest (the same code opens the device the first
5//! time and after a cable glitch) and lets tests hand the mount a device they
6//! can make vanish.
7
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::Arc;
10
11use mtp_rs::mtp::MtpDevice;
12
13/// Source of MTP devices for a mount.
14///
15/// Implementations block; they're called from a FUSE callback thread with a
16/// tokio handle to drive the async `mtp-rs` calls on.
17pub trait DeviceOpener: Send + Sync {
18    /// Opens the device this mount belongs to. Called once at startup and again
19    /// on every reconnect attempt, so it must always resolve to the *same*
20    /// physical device (match on serial number, not "first available").
21    fn open(&self, rt: &tokio::runtime::Handle) -> Result<MtpDevice, mtp_rs::Error>;
22
23    /// How to name the device in user-facing messages.
24    fn describe(&self) -> String;
25}
26
27/// Opens a USB MTP device by serial number.
28///
29/// The serial is read off the device that was opened at startup even when the
30/// user didn't pass `-d`, so a reconnect can't wander onto a different phone
31/// that happens to be plugged in. Devices that report no serial fall back to
32/// "first available", which is the best that's on offer.
33pub struct UsbOpener {
34    serial: Option<String>,
35    unplug: UnplugSwitch,
36}
37
38impl UsbOpener {
39    pub fn new(serial: Option<String>, unplug: UnplugSwitch) -> Self {
40        Self { serial, unplug }
41    }
42}
43
44impl DeviceOpener for UsbOpener {
45    fn open(&self, rt: &tokio::runtime::Handle) -> Result<MtpDevice, mtp_rs::Error> {
46        if self.unplug.is_unplugged() {
47            return Err(mtp_rs::Error::Disconnected);
48        }
49        match &self.serial {
50            Some(serial) => rt.block_on(MtpDevice::open_by_serial(serial)),
51            None => rt.block_on(MtpDevice::open_first()),
52        }
53    }
54
55    fn describe(&self) -> String {
56        match &self.serial {
57            Some(serial) => format!("device {serial}"),
58            None => "the device".to_string(),
59        }
60    }
61}
62
63/// A pretend USB cable, shared between the mount and whoever wants to yank it.
64///
65/// While it's unplugged every MTP operation fails with
66/// [`mtp_rs::Error::Disconnected`] and reopening fails too, which is exactly
67/// what a real cable glitch looks like from inside the filesystem.
68///
69/// This exists because `mtp-rs` can't simulate a disconnect: its virtual device
70/// keeps serving an already-open [`MtpDevice`] even after the device is removed
71/// from the discovery registry, so the reconnect path would otherwise be
72/// untestable without hardware. Production code never flips it; the cost is one
73/// relaxed atomic load per MTP operation.
74#[derive(Clone, Debug, Default)]
75pub struct UnplugSwitch(Arc<AtomicBool>);
76
77impl UnplugSwitch {
78    /// Pretend the cable came out.
79    #[allow(dead_code)] // used by integration tests via lib.rs, not by the bin
80    pub fn unplug(&self) {
81        self.0.store(true, Ordering::SeqCst);
82    }
83
84    /// Pretend the cable went back in.
85    #[allow(dead_code)] // used by integration tests via lib.rs, not by the bin
86    pub fn replug(&self) {
87        self.0.store(false, Ordering::SeqCst);
88    }
89
90    /// Whether the cable is currently out.
91    pub fn is_unplugged(&self) -> bool {
92        self.0.load(Ordering::Relaxed)
93    }
94}
95
96/// Whether an error means "this session is gone", as opposed to a normal
97/// operation failure the caller should see.
98///
99/// [`mtp_rs::Error::DeviceReset`] belongs here too: the device is still plugged
100/// in, but `mtp-rs` reset it in software to recover, so the session (and every
101/// handle in it) is dead and the cure is the same reopen.
102///
103/// **Don't replace this with `mtp_rs::Error::is_disconnected()`.** That
104/// predicate is `Disconnected` alone, deliberately excluding `DeviceReset`
105/// (there the device is still there, so a consumer that drops it from a sidebar
106/// would be throwing away a live device). A mount asks a different question:
107/// "does this session need a reopen?", and after a reset it does. Swapping in
108/// the narrower predicate would leave the mount answering every call with the
109/// dead session's handles. `NoDevice` is likewise ours to keep, so a device
110/// that's gone by the time we reopen still walks the reconnect path.
111/// Pinned by `link_loss_is_broader_than_mtp_rs_is_disconnected`.
112pub fn is_link_lost(error: &mtp_rs::Error) -> bool {
113    matches!(
114        error,
115        mtp_rs::Error::Disconnected | mtp_rs::Error::DeviceReset | mtp_rs::Error::NoDevice
116    )
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn switch_starts_plugged_in() {
125        let switch = UnplugSwitch::default();
126        assert!(!switch.is_unplugged());
127    }
128
129    #[test]
130    fn switch_is_shared_between_clones() {
131        let switch = UnplugSwitch::default();
132        let remote = switch.clone();
133        remote.unplug();
134        assert!(switch.is_unplugged());
135        remote.replug();
136        assert!(!switch.is_unplugged());
137    }
138
139    #[test]
140    fn session_loss_triggers_reconnect_but_plain_failures_do_not() {
141        assert!(is_link_lost(&mtp_rs::Error::Disconnected));
142        assert!(is_link_lost(&mtp_rs::Error::DeviceReset));
143        assert!(!is_link_lost(&mtp_rs::Error::NotFound));
144        assert!(!is_link_lost(&mtp_rs::Error::AccessDenied));
145        assert!(!is_link_lost(&mtp_rs::Error::Timeout));
146    }
147
148    /// `mtp-rs`'s `is_disconnected()` answers "is the device gone?", which isn't
149    /// the question a mount asks. Ours is "does the session need a reopen?", and
150    /// a software reset needs one while the device is still plugged in. If this
151    /// ever stops failing on the `DeviceReset` line, the two questions have
152    /// converged and only then is swapping the predicates safe.
153    #[test]
154    fn link_loss_is_broader_than_mtp_rs_is_disconnected() {
155        assert!(mtp_rs::Error::Disconnected.is_disconnected());
156        assert!(!mtp_rs::Error::DeviceReset.is_disconnected());
157        assert!(!mtp_rs::Error::NoDevice.is_disconnected());
158
159        assert!(is_link_lost(&mtp_rs::Error::DeviceReset));
160        assert!(is_link_lost(&mtp_rs::Error::NoDevice));
161    }
162}