fix(properties): harden progress and torrent diagnostics

This commit is contained in:
NimBold
2026-08-07 13:32:52 +03:30
parent e402603edb
commit facb7b3300
16 changed files with 291 additions and 45 deletions
+48 -17
View File
@@ -8494,6 +8494,8 @@ async fn verify_torrent_data(
let metadata = crate::torrent::parse_torrent_bytes(&bytes)?;
crate::torrent::validate_info_hash(item.torrent_info_hash.as_deref(), &metadata.info_hash)?;
let restore_status = item.status.as_str().to_string();
let restore_status_value = item.status;
let restore_has_been_dispatched = item.has_been_dispatched;
let destination = verification_destination(&app_handle, &item)?;
if matches!(item.status, crate::ipc::DownloadStatus::Paused) {
detach_download_for_reconfigure_locked(
@@ -8508,6 +8510,11 @@ async fn verify_torrent_data(
{
return Err("Torrent lifecycle changed; pause it before verifying its data".to_string());
}
// Allocate the internally-created generation after detaching any retained
// paused lifecycle, so the value includes every cancellation watermark
// observed during that transition. Reservation still remains the atomic
// acceptance point immediately before ownership registration.
let verification_generation = state.queue_manager.next_enqueue_generation(&id).await?;
let enqueue_item = queue::EnqueueItem {
id: item.id.clone(),
queue_id: item
@@ -8559,7 +8566,7 @@ async fn verify_torrent_data(
torrent_file_allocation: item.torrent_file_allocation.clone(),
torrent_verify_only: Some(true),
torrent_verify_restore_status: Some(restore_status.clone()),
lifecycle_generation: None,
lifecycle_generation: Some(verification_generation.to_string()),
};
{
@@ -8589,9 +8596,10 @@ async fn verify_torrent_data(
enqueue_download_locked(&app_handle, state.inner(), enqueue_item, &control_guard).await
{
// Roll back only this verification marker, and only while the row is
// still the queued verification lifecycle. Never restore the stale
// full array captured before enqueue: frontend persistence or another
// command may have changed unrelated rows in the meantime.
// still this verification lifecycle (queued or already restored by a
// renderer snapshot). Never restore the stale full array captured
// before enqueue: frontend persistence or another command may have
// changed unrelated rows in the meantime.
let rollback_result = (|| {
let mut connection = database.lock()?;
crate::db::mutate_download(
@@ -8599,18 +8607,20 @@ async fn verify_torrent_data(
&id,
database.is_portable(),
|object| {
let is_verification_marker = object
.get("status")
.and_then(serde_json::Value::as_str)
== Some("queued")
&& object
.get("torrentVerifyOnly")
.and_then(serde_json::Value::as_bool)
== Some(true)
let has_verification_marker = object
.get("torrentVerifyOnly")
.and_then(serde_json::Value::as_bool)
== Some(true)
&& object
.get("torrentVerifyRestoreStatus")
.and_then(serde_json::Value::as_str)
== Some(restore_status.as_str());
let current_status = object
.get("status")
.and_then(serde_json::Value::as_str);
let is_verification_marker = has_verification_marker
&& (current_status == Some("queued")
|| current_status == Some(restore_status.as_str()));
// The native marker is an additional persistence fence,
// not a prerequisite for rollback. A renderer snapshot
// may have already acknowledged and removed that native
@@ -8621,18 +8631,39 @@ async fn verify_torrent_data(
"status".to_string(),
serde_json::json!(restore_status),
);
match restore_has_been_dispatched {
Some(value) => {
object.insert(
"hasBeenDispatched".to_string(),
serde_json::json!(value),
);
}
None => {
object.remove("hasBeenDispatched");
}
}
object.remove("torrentVerifyOnly");
object.remove("torrentVerifyRestoreStatus");
object.remove("torrentVerifyNative");
}
Ok(())
Ok(is_verification_marker)
},
)
})();
if let Err(rollback_error) = rollback_result {
return Err(format!(
"{error}; failed to roll back Torrent verification state: {rollback_error}"
));
let rollback_applied = match rollback_result {
Ok(applied) => applied,
Err(rollback_error) => {
return Err(format!(
"{error}; failed to roll back Torrent verification state: {rollback_error}"
));
}
};
if rollback_applied {
use tauri::Emitter;
let _ = app_handle.emit(
"download-state",
crate::ipc::DownloadStateEvent::new(&id, restore_status_value),
);
}
return Err(error.to_string());
}
+36
View File
@@ -1615,6 +1615,21 @@ impl<R: tauri::Runtime> QueueManager<R> {
.or_insert(generation);
}
/// Return a generation newer than every observed or cancelled enqueue for
/// an internally-created lifecycle. The caller still passes this value to
/// `reserve_enqueue_generation`, which performs the atomic ownership and
/// cancellation checks before the enqueue is committed.
pub async fn next_enqueue_generation(&self, id: &str) -> Result<u64, String> {
let cancellations = self.enqueue_cancellations.lock().await;
let generations = self.enqueue_generations.lock().await;
let previous_generation = generations.get(id).copied().unwrap_or_default();
let cancelled_generation = cancellations.get(id).copied().unwrap_or_default();
previous_generation
.max(cancelled_generation)
.checked_add(1)
.ok_or_else(|| "Download lifecycle generation exhausted".to_string())
}
/// Atomically reserve an ID after rejecting cancelled or replayed generations.
/// The returned watermark must be passed to `rollback_enqueue_reservation`
/// if ownership registration fails before the task is committed.
@@ -8184,6 +8199,27 @@ mod tests {
assert!(item.into_task().payload.torrent_remove_unselected_file);
}
#[tokio::test]
async fn internally_created_enqueue_advances_past_cancelled_generation() {
let app = tauri::test::mock_builder()
.build(tauri::test::mock_context(tauri::test::noop_assets()))
.expect("mock app");
let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner));
manager.cancel_enqueue_generation("torrent", 4).await;
let generation = manager
.next_enqueue_generation("torrent")
.await
.expect("a fresh generation should be available");
assert_eq!(generation, 5);
manager
.reserve_enqueue_generation("torrent", generation)
.await
.expect("the fresh generation should not be rejected as stale");
assert_eq!(manager.registered_lifecycle_generation("torrent").await, Some(5));
}
#[test]
fn torrent_options_reject_invalid_seed_values() {
let mut options = serde_json::Map::new();