Skip to content

Commit a560243

Browse files
committed
SMB: dedicated friendly copy for STATUS_DELETE_PENDING
- New typed variants `VolumeError::DeletePending` and `WriteOperationError::DeletePending` so the listing and write-error paths can carry the state end-to-end instead of falling through to the generic `IoError`. - `map_smb_error` dispatches on `err.status() == Some(NtStatus::DELETE_PENDING)` before the kind match (smb2 currently classifies it as `ErrorKind::Other`). - `kinds::delete_pending` gives users a transient "File is being removed" message that explains an open handle is keeping the file alive, plus a wait/restart suggestion. Replaces the misleading "disk needs attention" copy. - Tests in `smb.rs`, `volume_copy_tests.rs`, and `friendly_error/mod.rs` pin the mapping, the friendly copy shape, and the no-"error"/"failed" rule for the new variant.
1 parent be21beb commit a560243

9 files changed

Lines changed: 112 additions & 0 deletions

File tree

apps/desktop/src-tauri/src/file_system/volume/friendly_error/kinds.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,29 @@ pub(super) fn not_supported(raw_detail: String) -> FriendlyError {
206206
}
207207
}
208208

209+
/// `STATUS_DELETE_PENDING`: the file has been marked for deletion on the server
210+
/// but at least one open handle is keeping it alive. The file disappears the
211+
/// moment the last handle closes, so retry-after-a-moment is the right hint.
212+
pub(super) fn delete_pending(path_display: &str, raw_detail: String) -> FriendlyError {
213+
FriendlyError {
214+
category: ErrorCategory::Transient,
215+
title: "File is being removed".into(),
216+
explanation: format!(
217+
"`{}` is on its way out. The server marked it for deletion, but another \
218+
open handle is keeping it around until that handle closes.",
219+
path_display
220+
),
221+
suggestion: "Here's what to try:\n\
222+
- Wait a moment and try again — once the last handle closes, the file disappears\n\
223+
- Close any other apps that might have this file open\n\
224+
- If it sticks around, restart Cmdr to drop any handles it might still hold"
225+
.into(),
226+
raw_detail,
227+
retry_hint: true,
228+
action_kind: None,
229+
}
230+
}
231+
209232
pub(super) fn io_serious(path_display: &str, message: &str, raw_detail: String) -> FriendlyError {
210233
FriendlyError {
211234
category: ErrorCategory::Serious,

apps/desktop/src-tauri/src/file_system/volume/friendly_error/mod.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ mod tests {
211211
true,
212212
),
213213
(VolumeError::Cancelled("x".into()), ErrorCategory::Transient, true),
214+
(VolumeError::DeletePending("x".into()), ErrorCategory::Transient, true),
214215
(
215216
VolumeError::IoError {
216217
message: "x".into(),
@@ -296,6 +297,7 @@ mod tests {
296297
VolumeError::NotFound("x".into()),
297298
VolumeError::PermissionDenied("x".into()),
298299
VolumeError::ConnectionTimeout("x".into()),
300+
VolumeError::DeletePending("x".into()),
299301
VolumeError::IoError {
300302
message: "x".into(),
301303
raw_os_error: None,
@@ -357,6 +359,36 @@ mod tests {
357359
}
358360
}
359361

362+
#[test]
363+
fn delete_pending_uses_dedicated_copy() {
364+
let path = Path::new("/Volumes/share/photo.jpg");
365+
let err = VolumeError::DeletePending("Protocol error: STATUS_DELETE_PENDING during Create".into());
366+
let friendly = friendly_error_from_volume_error(&err, path);
367+
368+
assert_eq!(friendly.category, ErrorCategory::Transient);
369+
assert!(
370+
friendly.retry_hint,
371+
"DeletePending is transient — user should see a retry hint"
372+
);
373+
assert!(
374+
friendly.title.contains("being removed"),
375+
"DeletePending title should say the file is being removed, got: {:?}",
376+
friendly.title,
377+
);
378+
// The path is interpolated into the explanation so the user knows which file.
379+
assert!(
380+
friendly.explanation.contains("photo.jpg"),
381+
"DeletePending explanation should include the path, got: {:?}",
382+
friendly.explanation,
383+
);
384+
// raw_detail preserves the underlying NTSTATUS for the technical-details disclosure.
385+
assert!(
386+
friendly.raw_detail.contains("DELETE_PENDING"),
387+
"raw_detail should preserve the NTSTATUS code, got: {:?}",
388+
friendly.raw_detail,
389+
);
390+
}
391+
360392
// ── action_kind tests ───────────────────────────────────────────────
361393

362394
#[cfg(target_os = "macos")]

apps/desktop/src-tauri/src/file_system/volume/friendly_error/volume_error.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ pub fn friendly_error_from_volume_error(err: &VolumeError, path: &Path) -> Frien
4949
VolumeError::StorageFull { .. } => kinds::storage_full(raw),
5050
VolumeError::ConnectionTimeout(_) => kinds::connection_timeout(raw),
5151
VolumeError::Cancelled(_) => kinds::cancelled(raw),
52+
VolumeError::DeletePending(_) => kinds::delete_pending(&path_display, raw),
5253
VolumeError::IsADirectory(_) => FriendlyError {
5354
category: ErrorCategory::NeedsAction,
5455
title: "This is a folder, not a file".into(),

apps/desktop/src-tauri/src/file_system/volume/friendly_error/write_error.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ pub fn friendly_from_write_error(err: &crate::file_system::write_operations::Wri
142142
retry_hint: false,
143143
action_kind: None,
144144
},
145+
W::DeletePending { path } => kinds::delete_pending(path, raw),
145146
W::IoError { path, message } => kinds::io_serious(path, message, raw),
146147
}
147148
}

apps/desktop/src-tauri/src/file_system/volume/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,11 @@ pub enum VolumeError {
166166
Cancelled(String),
167167
/// The path is a directory, not a file (for example, SMB STATUS_FILE_IS_A_DIRECTORY).
168168
IsADirectory(String),
169+
/// The file is in `STATUS_DELETE_PENDING`: a delete has been requested on the server
170+
/// but at least one open handle is keeping the file alive. The file will disappear
171+
/// once the last handle closes; any new `Create` (stat, open, write) on the path
172+
/// fails with this status in the meantime. SMB-only today.
173+
DeletePending(String),
169174
IoError {
170175
message: String,
171176
raw_os_error: Option<i32>,
@@ -194,6 +199,7 @@ impl std::fmt::Display for VolumeError {
194199
Self::ConnectionTimeout(msg) => write!(f, "Connection timed out: {}", msg),
195200
Self::Cancelled(msg) => write!(f, "Cancelled: {}", msg),
196201
Self::IsADirectory(path) => write!(f, "Is a directory: {}", path),
202+
Self::DeletePending(path) => write!(f, "Delete pending: {}", path),
197203
Self::IoError { message, .. } => write!(f, "I/O error: {}", message),
198204
Self::FriendlyGit(err) => write!(f, "git: {}", err),
199205
}

apps/desktop/src-tauri/src/file_system/volume/smb.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,15 @@ fn fs_info_to_space_info(info: &smb2::client::tree::FsInfo) -> SpaceInfo {
134134
/// Converts an `smb2::Error` to `VolumeError`.
135135
fn map_smb_error(err: smb2::Error) -> VolumeError {
136136
use smb2::ErrorKind;
137+
use smb2::types::status::NtStatus;
138+
139+
// `STATUS_DELETE_PENDING` currently classifies as `ErrorKind::Other` in
140+
// smb2 (no typed variant yet), so we detect it via the raw NTSTATUS before
141+
// falling through to the generic kind match.
142+
if err.status() == Some(NtStatus::DELETE_PENDING) {
143+
return VolumeError::DeletePending(err.to_string());
144+
}
145+
137146
match err.kind() {
138147
ErrorKind::NotFound => VolumeError::NotFound(err.to_string()),
139148
ErrorKind::AlreadyExists => VolumeError::AlreadyExists(err.to_string()),
@@ -1989,6 +1998,26 @@ mod tests {
19891998
assert!(matches!(ve, VolumeError::NotFound(_)));
19901999
}
19912000

2001+
#[test]
2002+
fn map_smb_error_delete_pending() {
2003+
// STATUS_DELETE_PENDING surfaces when a delete has been requested but at
2004+
// least one open handle is keeping the file alive. smb2 currently classifies
2005+
// it as `ErrorKind::Other`, so `map_smb_error` must dispatch on the raw
2006+
// NTSTATUS to produce the typed `VolumeError::DeletePending` variant —
2007+
// otherwise the FE falls back to the generic "disk needs attention" copy
2008+
// instead of the transient "file is being removed" message.
2009+
let err = smb2::Error::Protocol {
2010+
status: smb2::types::status::NtStatus::DELETE_PENDING,
2011+
command: smb2::types::Command::Create,
2012+
};
2013+
let ve = map_smb_error(err);
2014+
assert!(
2015+
matches!(ve, VolumeError::DeletePending(_)),
2016+
"STATUS_DELETE_PENDING should map to VolumeError::DeletePending, got: {:?}",
2017+
ve,
2018+
);
2019+
}
2020+
19922021
#[test]
19932022
fn map_smb_error_access_denied() {
19942023
let err = smb2::Error::Protocol {

apps/desktop/src-tauri/src/file_system/write_operations/types.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,12 @@ pub enum WriteOperationError {
455455
path: String,
456456
message: String,
457457
},
458+
/// The file is in `STATUS_DELETE_PENDING` on the server: a delete was requested
459+
/// but at least one open handle is keeping it alive. Transient — clears when the
460+
/// last handle closes. SMB-only today.
461+
DeletePending {
462+
path: String,
463+
},
458464
/// Catch-all for genuinely unexpected IO errors.
459465
IoError {
460466
path: String,

apps/desktop/src-tauri/src/file_system/write_operations/volume_copy.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1619,6 +1619,9 @@ pub(super) fn map_volume_error(context_path: &str, e: VolumeError) -> WriteOpera
16191619
path,
16201620
message: "Is a directory".to_string(),
16211621
},
1622+
VolumeError::DeletePending(_) => WriteOperationError::DeletePending {
1623+
path: context_path.to_string(),
1624+
},
16221625
}
16231626
}
16241627

apps/desktop/src-tauri/src/file_system/write_operations/volume_copy_tests.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,17 @@ fn test_map_volume_error_not_supported() {
183183
);
184184
}
185185

186+
#[test]
187+
fn test_map_volume_error_delete_pending() {
188+
// STATUS_DELETE_PENDING surfaces when a delete was requested but an open
189+
// handle is keeping the file alive on the server. It MUST become a typed
190+
// `WriteOperationError::DeletePending` so the write-error event carries
191+
// the transient "file is being removed" friendly copy — not the generic
192+
// IoError fallback.
193+
let err = map_volume_error("/ctx", VolumeError::DeletePending("STATUS_DELETE_PENDING".to_string()));
194+
assert!(matches!(err, WriteOperationError::DeletePending { path } if path == "/ctx"));
195+
}
196+
186197
// ========================================
187198
// LocalPosixVolume integration tests
188199
// ========================================

0 commit comments

Comments
 (0)