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}