authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 14:55:33-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 15:03:17-07:00
logac922b129d8b93bb2ff2565a0bcd42758be4a87a
tree59f3ec798f1881f91873b23b9632122904219566
parent7a9721e96ff69cc09d9bceaca1504d938b96b699
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: A server that drops or refuses the connection reads as Not connected, not Unable to sync

SMB errors were wrapped as io::ErrorKind::Other, so a server that went away (smb2's Disconnected, a refused or unreachable socket, a timeout) showed as a failed sync instead of not connected, and the background sync did not take it for a lost connection. They now keep a kind that says so. Assisted-by: claude-opus-5.5

3 files changed, 54 insertions(+), 11 deletions(-)

crates/notebook/src/smb/directory.rs+1-1
......@@ -140,7 +140,7 @@ impl Client {
140140 {
141141 io::Error::new(io::ErrorKind::Unsupported, error)
142142 }
143 _ => io::Error::other(error),
143 _ => super::io_error(error),
144144 })?;
145145 // However the task ends, the connection failing or the client retiring its runtime,
146146 // `changed` hears that the watch did.
crates/notebook/src/smb/mod.rs+22-10
......@@ -77,7 +77,7 @@ impl Client {
7777 let mut connection = sign_in(address, &credentials, timeout).await?;
7878 let tree = Tree::connect(&mut connection, share)
7979 .await
80 .map_err(io::Error::other)?;
80 .map_err(io_error)?;
8181 Ok::<_, io::Error>((connection, tree))
8282 })
8383 .await
......@@ -150,7 +150,7 @@ impl Client {
150150 };
151151 let frame = frame
152152 .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))
153 .and_then(|result| result.map_err(io::Error::other))
153 .and_then(|result| result.map_err(io_error))
154154 .inspect_err(|_| self.retire())?;
155155 self.unpack(&self.body(command, frame)?)
156156 }
......@@ -184,15 +184,13 @@ impl Client {
184184 };
185185 let frames = frames
186186 .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))
187 .and_then(|result| result.map_err(io::Error::other))
187 .and_then(|result| result.map_err(io_error))
188188 .inspect_err(|_| self.retire())?;
189189 Ok(frames
190190 .into_iter()
191191 .zip(requests)
192192 .map(|(frame, (command, _))| {
193 let frame = frame
194 .map_err(io::Error::other)
195 .inspect_err(|_| self.retire())?;
193 let frame = frame.map_err(io_error).inspect_err(|_| self.retire())?;
196194 self.body(*command, frame)
197195 })
198196 .collect())
......@@ -568,7 +566,7 @@ pub fn shares(
568566 let mut connection = sign_in(address, &credentials, timeout).await?;
569567 let shares = smb2::client::list_shares(&mut connection)
570568 .await
571 .map_err(io::Error::other);
569 .map_err(io_error);
572570 connection.mark_dead();
573571 shares
574572 })
......@@ -578,6 +576,20 @@ pub fn shares(
578576 Ok(shares.into_iter().map(|share| share.name).collect())
579577}
580578
579/// `error` inside an `io::Error` whose kind says whether the server was reached: a lost or
580/// unanswered connection is `NotConnected` or `TimedOut`, and a socket's failure keeps its kind.
581fn io_error(error: smb2::Error) -> io::Error {
582 let kind = match &error {
583 smb2::Error::Io(source) => source.kind(),
584 error => match error.kind() {
585 smb2::ErrorKind::ConnectionLost => io::ErrorKind::NotConnected,
586 smb2::ErrorKind::TimedOut => io::ErrorKind::TimedOut,
587 _ => io::ErrorKind::Other,
588 },
589 };
590 io::Error::new(kind, error)
591}
592
581593/// A negotiated session with the server at `address`, signed in with `credentials`.
582594async fn sign_in(
583595 address: &str,
......@@ -586,14 +598,14 @@ async fn sign_in(
586598) -> io::Result<Connection> {
587599 let mut connection = Connection::connect(address, timeout)
588600 .await
589 .map_err(io::Error::other)?;
601 .map_err(io_error)?;
590602 connection.set_compression_requested(false);
591603 if let Err(error) = connection.negotiate().await {
592604 connection.mark_dead();
593605 return Err(if speaks_only_smb1(address, timeout).await {
594606 io::Error::new(io::ErrorKind::Unsupported, Refusal::Smb1)
595607 } else {
596 io::Error::other(error)
608 io_error(error)
597609 });
598610 }
599611 Session::setup(
......@@ -603,7 +615,7 @@ async fn sign_in(
603615 credentials.domain,
604616 )
605617 .await
606 .map_err(io::Error::other)?;
618 .map_err(io_error)?;
607619 Ok(connection)
608620}
609621
crates/notebook/src/smb/tests.rs+31
......@@ -1047,3 +1047,34 @@ fn a_torn_read_is_contention() {
10471047 let error = snapshot(torn, 1 << 20).unwrap_err();
10481048 assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
10491049}
1050
1051#[test]
1052fn unreachable_server_is_not_connected() {
1053 use crate::session::{SyncState, SyncStatus};
1054 let state = |error: smb2::Error| {
1055 SyncStatus {
1056 synced: Some(1),
1057 error: Some(io_error(error)),
1058 queued: 1,
1059 }
1060 .state()
1061 };
1062 for kind in [
1063 io::ErrorKind::ConnectionRefused,
1064 io::ErrorKind::HostUnreachable,
1065 io::ErrorKind::TimedOut,
1066 ] {
1067 assert_eq!(
1068 state(smb2::Error::Io(kind.into())),
1069 SyncState::NotConnected,
1070 "{kind}"
1071 );
1072 }
1073 assert_eq!(state(smb2::Error::Disconnected), SyncState::NotConnected);
1074 assert_eq!(state(smb2::Error::Timeout), SyncState::NotConnected);
1075 let denied = smb2::Error::Protocol {
1076 status: smb2::types::status::NtStatus(0xc0000022),
1077 command: Command::Create,
1078 };
1079 assert_eq!(io_error(denied).kind(), io::ErrorKind::Other);
1080}