fix(core): report receiver failures to sender

This commit is contained in:
2026-07-24 14:52:09 +02:00
parent 0448137d84
commit 425500ecf2
25 changed files with 430 additions and 22 deletions

View File

@@ -88,6 +88,7 @@ enum ReceiverDeliveryStatus: Equatable, Sendable {
case refused case refused
case expired case expired
case completed case completed
case failed
case unknown case unknown
} }

View File

@@ -512,6 +512,7 @@ private extension ReceiverRequest {
case "refused": return .refused case "refused": return .refused
case "expired": return .expired case "expired": return .expired
case "completed": return .completed case "completed": return .completed
case "failed": return .failed
default: return .unknown default: return .unknown
} }
} }

View File

@@ -237,6 +237,7 @@ private struct ReceiverRow: View {
let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: "transfer_nearby_device") let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: "transfer_nearby_device")
let showLive = sendProgress != nil && receiver.status != .completed let showLive = sendProgress != nil && receiver.status != .completed
&& receiver.status != .refused && receiver.status != .expired && receiver.status != .refused && receiver.status != .expired
&& receiver.status != .failed
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text(name).font(VniType.bodyLarge).lineLimit(1) Text(name).font(VniType.bodyLarge).lineLimit(1)
@@ -397,6 +398,7 @@ extension ReceiverDeliveryStatus {
case .refused: return "transfer_receiver_refused" case .refused: return "transfer_receiver_refused"
case .expired: return "transfer_receiver_expired" case .expired: return "transfer_receiver_expired"
case .completed: return "transfer_receiver_completed" case .completed: return "transfer_receiver_completed"
case .failed: return "transfer_receiver_failed"
case .unknown: return "transfer_receiver_unknown" case .unknown: return "transfer_receiver_unknown"
} }
} }
@@ -404,7 +406,7 @@ extension ReceiverDeliveryStatus {
func statusColor(_ colors: VniDropColors) -> Color { func statusColor(_ colors: VniDropColors) -> Color {
switch self { switch self {
case .completed: return colors.brandDefault case .completed: return colors.brandDefault
case .refused, .expired: return colors.destructiveDefault case .refused, .expired, .failed: return colors.destructiveDefault
default: return colors.foregroundLighter default: return colors.foregroundLighter
} }
} }

View File

@@ -16581,6 +16581,66 @@
} }
} }
}, },
"transfer_receiver_failed": {
"comment": "Receiver status: the transfer failed.",
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "needs_review",
"value": "Übertragung fehlgeschlagen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Transfer failed"
}
},
"es": {
"stringUnit": {
"state": "needs_review",
"value": "Transferencia fallida"
}
},
"fr": {
"stringUnit": {
"state": "needs_review",
"value": "Transfert échoué"
}
},
"it": {
"stringUnit": {
"state": "needs_review",
"value": "Trasferimento non riuscito"
}
},
"nl": {
"stringUnit": {
"state": "needs_review",
"value": "Overdracht mislukt"
}
},
"pl": {
"stringUnit": {
"state": "needs_review",
"value": "Przesyłanie nie powiodło się"
}
},
"pt": {
"stringUnit": {
"state": "needs_review",
"value": "A transferência falhou"
}
},
"ru": {
"stringUnit": {
"state": "needs_review",
"value": "Передача не удалась"
}
}
}
},
"transfer_receiver_expired": { "transfer_receiver_expired": {
"comment": "Receiver status: the request expired.", "comment": "Receiver status: the request expired.",
"extractionState": "manual", "extractionState": "manual",

View File

@@ -8,7 +8,10 @@ use uuid::Uuid;
use crate::{ use crate::{
access_policy::{AccessPolicy, APPROVAL_SESSION_TTL_MS}, access_policy::{AccessPolicy, APPROVAL_SESSION_TTL_MS},
event_hub::EventHub, event_hub::EventHub,
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, RequestTransfer}, handshake::{
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse,
RequestTransfer,
},
repository::{ReceiverRequestInsert, Repository}, repository::{ReceiverRequestInsert, Repository},
transfer_state::ReceiverRequestStatus, transfer_state::ReceiverRequestStatus,
util::now_ms, util::now_ms,
@@ -70,6 +73,46 @@ impl ApprovalService {
} }
} }
pub(crate) async fn fail_delivery(
&self,
remote_endpoint_id: String,
receipt: DeliveryFailureReceipt,
) -> DeliveryReceiptResponse {
let token_hash = receipt_token_hash(&receipt.token);
match self
.repository
.fail_receiver_delivery(
&receipt.request_id,
receipt.transfer_id,
&remote_endpoint_id,
&token_hash,
&receipt.reason,
)
.await
{
Ok(()) => {
self.event_hub.emit_transfer(
receipt.transfer_id,
"send",
"delivery",
"receiver-failed",
json!({
"request_id": receipt.request_id,
"remote_endpoint_id": remote_endpoint_id,
"reason": receipt.reason,
}),
);
DeliveryReceiptResponse::Recorded
}
Err(error) => {
tracing::warn!(%error, "rejected receiver delivery failure");
DeliveryReceiptResponse::Rejected {
reason: "invalid-receipt".to_string(),
}
}
}
}
pub(crate) fn new( pub(crate) fn new(
repository: Repository, repository: Repository,
event_hub: Arc<EventHub>, event_hub: Arc<EventHub>,

View File

@@ -72,6 +72,14 @@ impl ProtocolHandler for HandshakeService {
.await; .await;
let _ = tx.send(response).await; let _ = tx.send(response).await;
} }
HandshakeMessage::ReportDeliveryFailure(message) => {
let WithChannels { inner, tx, .. } = message;
let response = self
.approval
.fail_delivery(remote_endpoint_id.clone(), inner)
.await;
let _ = tx.send(response).await;
}
} }
} }
@@ -109,6 +117,13 @@ impl HandshakeClient {
) -> Result<DeliveryReceiptResponse, irpc::Error> { ) -> Result<DeliveryReceiptResponse, irpc::Error> {
self.inner.rpc(receipt).await self.inner.rpc(receipt).await
} }
pub(crate) async fn report_delivery_failure(
&self,
receipt: DeliveryFailureReceipt,
) -> Result<DeliveryReceiptResponse, irpc::Error> {
self.inner.rpc(receipt).await
}
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -140,6 +155,14 @@ pub(crate) struct DeliveryReceipt {
pub(crate) token: String, pub(crate) token: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct DeliveryFailureReceipt {
pub(crate) request_id: String,
pub(crate) transfer_id: u64,
pub(crate) token: String,
pub(crate) reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) enum DeliveryReceiptResponse { pub(crate) enum DeliveryReceiptResponse {
Recorded, Recorded,
@@ -153,4 +176,6 @@ enum HandshakeProtocol {
RequestTransfer(RequestTransfer), RequestTransfer(RequestTransfer),
#[rpc(tx=oneshot::Sender<DeliveryReceiptResponse>)] #[rpc(tx=oneshot::Sender<DeliveryReceiptResponse>)]
ReportDelivery(DeliveryReceipt), ReportDelivery(DeliveryReceipt),
#[rpc(tx=oneshot::Sender<DeliveryReceiptResponse>)]
ReportDeliveryFailure(DeliveryFailureReceipt),
} }

View File

@@ -20,7 +20,7 @@ use crate::{
util::now_ms, util::now_ms,
}; };
const SCHEMA_VERSION: i64 = 6; const SCHEMA_VERSION: i64 = 7;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct Repository { pub(crate) struct Repository {
@@ -86,6 +86,7 @@ pub(crate) struct PendingDeliveryReceipt {
pub(crate) request_id: String, pub(crate) request_id: String,
pub(crate) sender_transfer_id: u64, pub(crate) sender_transfer_id: u64,
pub(crate) token: String, pub(crate) token: String,
pub(crate) failure_reason: Option<String>,
} }
pub(crate) struct PendingDeliveryReceiptInsert<'a> { pub(crate) struct PendingDeliveryReceiptInsert<'a> {
@@ -94,6 +95,7 @@ pub(crate) struct PendingDeliveryReceiptInsert<'a> {
pub(crate) request_id: &'a str, pub(crate) request_id: &'a str,
pub(crate) sender_transfer_id: u64, pub(crate) sender_transfer_id: u64,
pub(crate) token: &'a str, pub(crate) token: &'a str,
pub(crate) failure_reason: Option<&'a str>,
} }
impl Repository { impl Repository {
@@ -294,12 +296,24 @@ impl Repository {
sender_blob_ticket TEXT NOT NULL, sender_blob_ticket TEXT NOT NULL,
sender_transfer_id INTEGER NOT NULL, sender_transfer_id INTEGER NOT NULL,
token TEXT NOT NULL, token TEXT NOT NULL,
failure_reason TEXT,
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
); );
"#, "#,
) )
.execute(&self.pool) .execute(&self.pool)
.await?; .await?;
let receipt_columns = sqlx::query("PRAGMA table_info(pending_delivery_receipts)")
.fetch_all(&self.pool)
.await?;
if !receipt_columns
.iter()
.any(|row| row.get::<String, _>(1) == "failure_reason")
{
sqlx::query("ALTER TABLE pending_delivery_receipts ADD COLUMN failure_reason TEXT")
.execute(&self.pool)
.await?;
}
sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}")) sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))
.execute(&self.pool) .execute(&self.pool)
@@ -561,13 +575,14 @@ impl Repository {
r#" r#"
INSERT INTO pending_delivery_receipts ( INSERT INTO pending_delivery_receipts (
request_id, local_transfer_id, sender_blob_ticket, request_id, local_transfer_id, sender_blob_ticket,
sender_transfer_id, token, created_at sender_transfer_id, token, failure_reason, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(request_id) DO UPDATE SET ON CONFLICT(request_id) DO UPDATE SET
local_transfer_id = excluded.local_transfer_id, local_transfer_id = excluded.local_transfer_id,
sender_blob_ticket = excluded.sender_blob_ticket, sender_blob_ticket = excluded.sender_blob_ticket,
sender_transfer_id = excluded.sender_transfer_id, sender_transfer_id = excluded.sender_transfer_id,
token = excluded.token token = excluded.token,
failure_reason = excluded.failure_reason
"#, "#,
) )
.bind(receipt.request_id) .bind(receipt.request_id)
@@ -575,6 +590,7 @@ impl Repository {
.bind(receipt.sender_blob_ticket) .bind(receipt.sender_blob_ticket)
.bind(to_db_id(receipt.sender_transfer_id)?) .bind(to_db_id(receipt.sender_transfer_id)?)
.bind(receipt.token) .bind(receipt.token)
.bind(receipt.failure_reason)
.bind(now_ms()) .bind(now_ms())
.execute(&mut *transaction) .execute(&mut *transaction)
.await?; .await?;
@@ -582,13 +598,46 @@ impl Repository {
Ok(()) Ok(())
} }
pub(crate) async fn queue_failed_delivery_receipt(
&self,
receipt: PendingDeliveryReceiptInsert<'_>,
) -> Result<()> {
let Some(reason) = receipt.failure_reason else {
anyhow::bail!("failed delivery receipt requires a reason");
};
sqlx::query(
r#"
INSERT INTO pending_delivery_receipts (
request_id, local_transfer_id, sender_blob_ticket,
sender_transfer_id, token, failure_reason, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(request_id) DO UPDATE SET
local_transfer_id = excluded.local_transfer_id,
sender_blob_ticket = excluded.sender_blob_ticket,
sender_transfer_id = excluded.sender_transfer_id,
token = excluded.token,
failure_reason = excluded.failure_reason
"#,
)
.bind(receipt.request_id)
.bind(to_db_id(receipt.local_transfer_id)?)
.bind(receipt.sender_blob_ticket)
.bind(to_db_id(receipt.sender_transfer_id)?)
.bind(receipt.token)
.bind(reason)
.bind(now_ms())
.execute(&self.pool)
.await?;
Ok(())
}
pub(crate) async fn list_pending_delivery_receipts( pub(crate) async fn list_pending_delivery_receipts(
&self, &self,
) -> Result<Vec<PendingDeliveryReceipt>> { ) -> Result<Vec<PendingDeliveryReceipt>> {
let rows = sqlx::query( let rows = sqlx::query(
r#" r#"
SELECT local_transfer_id, sender_blob_ticket, request_id, SELECT local_transfer_id, sender_blob_ticket, request_id,
sender_transfer_id, token sender_transfer_id, token, failure_reason
FROM pending_delivery_receipts FROM pending_delivery_receipts
ORDER BY created_at ASC ORDER BY created_at ASC
"#, "#,
@@ -603,6 +652,7 @@ impl Repository {
request_id: row.get("request_id"), request_id: row.get("request_id"),
sender_transfer_id: row.get::<i64, _>("sender_transfer_id") as u64, sender_transfer_id: row.get::<i64, _>("sender_transfer_id") as u64,
token: row.get("token"), token: row.get("token"),
failure_reason: row.get("failure_reason"),
}) })
.collect()) .collect())
} }
@@ -883,6 +933,56 @@ impl Repository {
} }
} }
pub(crate) async fn fail_receiver_delivery(
&self,
id: &str,
transfer_id: u64,
remote_endpoint_id: &str,
token_hash: &str,
reason: &str,
) -> Result<()> {
let result = sqlx::query(
r#"
UPDATE receiver_requests
SET status = 'failed', reason = ?1
WHERE id = ?2 AND transfer_id = ?3 AND remote_endpoint_id = ?4 AND receipt_token_hash = ?5
AND status = 'accepted'
"#,
)
.bind(reason)
.bind(id)
.bind(to_db_id(transfer_id)?)
.bind(remote_endpoint_id)
.bind(token_hash)
.execute(&self.pool)
.await?;
if result.rows_affected() == 1 {
return Ok(());
}
let already_recorded = sqlx::query(
r#"
SELECT EXISTS(
SELECT 1 FROM receiver_requests
WHERE id = ?1 AND transfer_id = ?2 AND remote_endpoint_id = ?3
AND receipt_token_hash = ?4 AND status = 'failed'
)
"#,
)
.bind(id)
.bind(to_db_id(transfer_id)?)
.bind(remote_endpoint_id)
.bind(token_hash)
.fetch_one(&self.pool)
.await?
.get::<i64, _>(0)
!= 0;
if already_recorded {
Ok(())
} else {
anyhow::bail!("delivery failure did not match an accepted receiver request")
}
}
pub(crate) async fn expire_pending_receiver_requests(&self, reason: &str) -> Result<u64> { pub(crate) async fn expire_pending_receiver_requests(&self, reason: &str) -> Result<u64> {
let result = sqlx::query( let result = sqlx::query(
r#" r#"

View File

@@ -4,7 +4,9 @@ use serde_json::json;
use super::{filter_peer_addr_for_relay_mode, CoreInner}; use super::{filter_peer_addr_for_relay_mode, CoreInner};
use crate::{ use crate::{
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeService}, handshake::{
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeService,
},
repository::PendingDeliveryReceipt, repository::PendingDeliveryReceipt,
ticket::parse_persisted_sender_address, ticket::parse_persisted_sender_address,
}; };
@@ -96,13 +98,29 @@ impl CoreInner {
} }
}; };
let client = HandshakeService::client(self.endpoint.clone(), sender_addr); let client = HandshakeService::client(self.endpoint.clone(), sender_addr);
let receipt = DeliveryReceipt { let request_id = pending.request_id.clone();
request_id: pending.request_id.clone(), let response = tokio::time::timeout(DELIVERY_RECEIPT_TIMEOUT, async {
if let Some(reason) = pending.failure_reason {
client
.report_delivery_failure(DeliveryFailureReceipt {
request_id,
transfer_id: pending.sender_transfer_id, transfer_id: pending.sender_transfer_id,
token: pending.token, token: pending.token,
}; reason,
match tokio::time::timeout(DELIVERY_RECEIPT_TIMEOUT, client.report_delivery(receipt)).await })
{ .await
} else {
client
.report_delivery(DeliveryReceipt {
request_id,
transfer_id: pending.sender_transfer_id,
token: pending.token,
})
.await
}
})
.await;
match response {
Ok(Ok(DeliveryReceiptResponse::Recorded)) => { Ok(Ok(DeliveryReceiptResponse::Recorded)) => {
if let Err(error) = self if let Err(error) = self
.repository .repository

View File

@@ -1,7 +1,7 @@
use std::{ use std::{
io, io,
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Arc, sync::{Arc, Mutex},
}; };
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@@ -222,6 +222,10 @@ impl CoreInner {
self.persist_receive_start(transfer_id, &parsed, receiver_name.as_deref()) self.persist_receive_start(transfer_id, &parsed, receiver_name.as_deref())
.await .await
.map_err(VnidropError::repository)?; .map_err(VnidropError::repository)?;
let persisted_sender_address =
encode_persisted_sender_address(parsed.blob_ticket.addr())
.context("failed to encode sender address for delivery receipt")?;
let delivery_receipt = Arc::new(Mutex::new(None));
// Cancellation is cooperative: it stops our receive future and marks // Cancellation is cooperative: it stops our receive future and marks
// local state while lower-level Iroh work unwinds naturally. // local state while lower-level Iroh work unwinds naturally.
let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
@@ -237,7 +241,14 @@ impl CoreInner {
); );
let (result, cancelled) = tokio::select! { let (result, cancelled) = tokio::select! {
result = self.receive_inner(transfer_id, parsed, target, receiver_name) => { result = self.receive_inner(
transfer_id,
parsed,
target,
receiver_name,
persisted_sender_address.clone(),
delivery_receipt.clone(),
) => {
(result.map_err(VnidropError::transfer), false) (result.map_err(VnidropError::transfer), false)
}, },
_ = &mut shutdown_rx => (Err(VnidropError::cancelled("transfer cancelled")), true), _ = &mut shutdown_rx => (Err(VnidropError::cancelled("transfer cancelled")), true),
@@ -267,6 +278,34 @@ impl CoreInner {
) )
.await; .await;
} }
let receipt = delivery_receipt.lock().expect("delivery_receipt").take();
if let Some(receipt) = receipt {
let reason = if cancelled { "cancelled" } else { error.code() };
match self
.repository
.queue_failed_delivery_receipt(PendingDeliveryReceiptInsert {
local_transfer_id: transfer_id,
sender_blob_ticket: &persisted_sender_address,
request_id: &receipt.request_id,
sender_transfer_id: receipt.transfer_id,
token: &receipt.token,
failure_reason: Some(reason),
})
.await
{
Ok(()) => self.delivery_receipt_notify.notify_one(),
Err(queue_error) => {
tracing::warn!(%queue_error, "failed to queue delivery failure receipt");
self.emit_transfer(
transfer_id,
"receive",
"delivery",
"receipt-failed",
json!({ "reason": queue_error.to_string() }),
);
}
}
}
} }
result.map_err(anyhow::Error::new) result.map_err(anyhow::Error::new)
} }
@@ -277,6 +316,8 @@ impl CoreInner {
parsed: ParsedTransferTicket, parsed: ParsedTransferTicket,
target: ReceiveTarget, target: ReceiveTarget,
receiver_name: Option<String>, receiver_name: Option<String>,
persisted_sender_address: String,
pending_delivery_receipt: Arc<Mutex<Option<DeliveryReceipt>>>,
) -> Result<()> { ) -> Result<()> {
if let ReceiveTarget::Directory(output_dir) = &target { if let ReceiveTarget::Directory(output_dir) = &target {
tokio::fs::create_dir_all(output_dir) tokio::fs::create_dir_all(output_dir)
@@ -284,8 +325,6 @@ impl CoreInner {
.map_err(VnidropError::filesystem)?; .map_err(VnidropError::filesystem)?;
} }
let sender_addr = parsed.blob_ticket.addr().clone(); let sender_addr = parsed.blob_ticket.addr().clone();
let persisted_sender_address = encode_persisted_sender_address(&sender_addr)
.context("failed to encode sender address for delivery receipt")?;
self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({})); self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({}));
// Every VniDrop ticket carries metadata and must complete the handshake. // Every VniDrop ticket carries metadata and must complete the handshake.
@@ -297,6 +336,9 @@ impl CoreInner {
receiver_name.as_deref(), receiver_name.as_deref(),
) )
.await?; .await?;
*pending_delivery_receipt
.lock()
.expect("pending_delivery_receipt") = Some(delivery_receipt.clone());
let connection = self let connection = self
.endpoint .endpoint
.connect(sender_addr.clone(), iroh_blobs::ALPN) .connect(sender_addr.clone(), iroh_blobs::ALPN)
@@ -373,9 +415,14 @@ impl CoreInner {
request_id: &delivery_receipt.request_id, request_id: &delivery_receipt.request_id,
sender_transfer_id: delivery_receipt.transfer_id, sender_transfer_id: delivery_receipt.transfer_id,
token: &delivery_receipt.token, token: &delivery_receipt.token,
failure_reason: None,
}) })
.await .await
.map_err(VnidropError::repository)?; .map_err(VnidropError::repository)?;
pending_delivery_receipt
.lock()
.expect("pending_delivery_receipt")
.take();
drop(download_tag); drop(download_tag);
self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({})); self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({}));
self.delivery_receipt_notify.notify_one(); self.delivery_receipt_notify.notify_one();

View File

@@ -66,7 +66,7 @@ async fn received_artifacts_survive_history_deletion() {
async fn persists_transfers_and_events_across_reopen() { async fn persists_transfers_and_events_across_reopen() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap(); let repository = Repository::open(temp.path()).await.unwrap();
assert_eq!(repository.schema_version().await.unwrap(), 6); assert_eq!(repository.schema_version().await.unwrap(), 7);
repository repository
.insert_transfer(transfer( .insert_transfer(transfer(
7, 7,
@@ -135,6 +135,7 @@ async fn receive_completion_persists_delivery_receipt_until_recorded() {
request_id: "request-93", request_id: "request-93",
sender_transfer_id: 39, sender_transfer_id: 39,
token: "receipt-token", token: "receipt-token",
failure_reason: None,
}) })
.await .await
.unwrap(); .unwrap();
@@ -221,6 +222,68 @@ async fn receiver_request_can_only_be_resolved_once() {
assert!(requests[0].completed_at.is_some()); assert!(requests[0].completed_at.is_some());
} }
#[tokio::test]
async fn authenticated_delivery_failure_marks_accepted_receiver_failed() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_receiver_request(ReceiverRequestInsert {
id: "request-failed",
transfer_id: 78,
remote_endpoint_id: "node-a",
transfer_name: "demo",
receiver_name: Some("receiver"),
receiver_device_name: None,
app_version: "0.1.0",
})
.await
.unwrap();
repository
.update_receiver_request_status("request-failed", ReceiverRequestStatus::Accepted, None)
.await
.unwrap();
repository
.set_receiver_receipt_token("request-failed", "token-hash")
.await
.unwrap();
repository
.fail_receiver_delivery(
"request-failed",
78,
"node-a",
"token-hash",
"destination_exists",
)
.await
.unwrap();
repository
.fail_receiver_delivery(
"request-failed",
78,
"node-a",
"token-hash",
"destination_exists",
)
.await
.unwrap();
assert!(repository
.fail_receiver_delivery(
"request-failed",
78,
"node-b",
"token-hash",
"destination_exists",
)
.await
.is_err());
let requests = repository.list_receiver_requests(78).await.unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].status, "failed");
assert_eq!(requests[0].reason.as_deref(), Some("destination_exists"));
}
#[tokio::test] #[tokio::test]
async fn startup_expiration_is_idempotent_for_pending_requests() { async fn startup_expiration_is_idempotent_for_pending_requests() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
@@ -582,7 +645,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() {
pool.close().await; pool.close().await;
let repository = Repository::open(temp.path()).await.unwrap(); let repository = Repository::open(temp.path()).await.unwrap();
assert_eq!(repository.schema_version().await.unwrap(), 6); assert_eq!(repository.schema_version().await.unwrap(), 7);
let stored = repository.list_transfers().await.unwrap().remove(0); let stored = repository.list_transfers().await.unwrap().remove(0);
assert_eq!(stored.transfer_id, 7); assert_eq!(stored.transfer_id, 7);
assert_eq!(stored.local_id, "legacy-7-send"); assert_eq!(stored.local_id, "legacy-7-send");

View File

@@ -164,6 +164,7 @@ fn startup_processes_persisted_delivery_receipts() {
request_id: "request-94", request_id: "request-94",
sender_transfer_id: 49, sender_transfer_id: 49,
token: "receipt-token", token: "receipt-token",
failure_reason: None,
}) })
.await .await
.unwrap(); .unwrap();

View File

@@ -87,6 +87,7 @@ pub(crate) enum ReceiverRequestStatus {
Refused, Refused,
Expired, Expired,
Completed, Completed,
Failed,
} }
impl ReceiverRequestStatus { impl ReceiverRequestStatus {
@@ -97,6 +98,7 @@ impl ReceiverRequestStatus {
Self::Refused => "refused", Self::Refused => "refused",
Self::Expired => "expired", Self::Expired => "expired",
Self::Completed => "completed", Self::Completed => "completed",
Self::Failed => "failed",
} }
} }
} }
@@ -111,6 +113,7 @@ impl TryFrom<&str> for ReceiverRequestStatus {
"refused" => Ok(Self::Refused), "refused" => Ok(Self::Refused),
"expired" => Ok(Self::Expired), "expired" => Ok(Self::Expired),
"completed" => Ok(Self::Completed), "completed" => Ok(Self::Completed),
"failed" => Ok(Self::Failed),
_ => bail!("unknown receiver request status: {value}"), _ => bail!("unknown receiver request status: {value}"),
} }
} }

View File

@@ -24,6 +24,30 @@ fn wait_for_sender_transfer_event(sender: &TestNode, transfer_id: u64, kind: &st
} }
} }
fn wait_for_receiver_status(
sender: &TestNode,
transfer_id: u64,
status: &str,
) -> vnidrop::ReceiverRequest {
let started = Instant::now();
loop {
if let Some(request) = sender
.core
.list_receiver_requests(transfer_id)
.unwrap()
.into_iter()
.find(|request| request.status == status)
{
return request;
}
assert!(
started.elapsed() < Duration::from_secs(5),
"timed out waiting for receiver status {status}"
);
std::thread::sleep(Duration::from_millis(10));
}
}
#[test] #[test]
fn transfers_file_between_two_cores() { fn transfers_file_between_two_cores() {
let source_dir = tempfile::tempdir().unwrap(); let source_dir = tempfile::tempdir().unwrap();
@@ -162,4 +186,11 @@ fn receive_refuses_to_overwrite_existing_destination() {
&& event.kind == "failed" && event.kind == "failed"
&& event.data_json.contains("\"code\":\"destination_exists\"") && event.data_json.contains("\"code\":\"destination_exists\"")
})); }));
let failed = wait_for_receiver_status(&sender, share.transfer_id, "failed");
assert_eq!(failed.reason.as_deref(), Some("destination_exists"));
assert!(sender.sink.events().iter().any(|event| {
event.transfer_id == Some(share.transfer_id)
&& event.phase == "delivery"
&& event.kind == "receiver-failed"
}));
} }

View File

@@ -274,6 +274,7 @@
<string name="transfer_no_receivers">Noch niemand hat diese Übertragung angefragt.</string> <string name="transfer_no_receivers">Noch niemand hat diese Übertragung angefragt.</string>
<string name="transfer_receiver_accepted">Genehmigt wartet auf Abschluss</string> <string name="transfer_receiver_accepted">Genehmigt wartet auf Abschluss</string>
<string name="transfer_receiver_completed">Erfolgreich empfangen</string> <string name="transfer_receiver_completed">Erfolgreich empfangen</string>
<string name="transfer_receiver_failed">Übertragung fehlgeschlagen</string>
<string name="transfer_receiver_expired">Anfrage abgelaufen</string> <string name="transfer_receiver_expired">Anfrage abgelaufen</string>
<string name="transfer_receiver_refused">Anfrage abgelehnt</string> <string name="transfer_receiver_refused">Anfrage abgelehnt</string>
<string name="transfer_receiver_requested">Wartet auf Ihre Genehmigung</string> <string name="transfer_receiver_requested">Wartet auf Ihre Genehmigung</string>

View File

@@ -274,6 +274,7 @@
<string name="transfer_no_receivers">Nadie ha solicitado aún esta transferencia.</string> <string name="transfer_no_receivers">Nadie ha solicitado aún esta transferencia.</string>
<string name="transfer_receiver_accepted">Aprobado: esperando a que se complete</string> <string name="transfer_receiver_accepted">Aprobado: esperando a que se complete</string>
<string name="transfer_receiver_completed">Recibido correctamente</string> <string name="transfer_receiver_completed">Recibido correctamente</string>
<string name="transfer_receiver_failed">Transferencia fallida</string>
<string name="transfer_receiver_expired">Solicitud caducada</string> <string name="transfer_receiver_expired">Solicitud caducada</string>
<string name="transfer_receiver_refused">Solicitud rechazada</string> <string name="transfer_receiver_refused">Solicitud rechazada</string>
<string name="transfer_receiver_requested">Esperando su aprobación</string> <string name="transfer_receiver_requested">Esperando su aprobación</string>

View File

@@ -274,6 +274,7 @@
<string name="transfer_no_receivers">Personne na encore demandé ce transfert.</string> <string name="transfer_no_receivers">Personne na encore demandé ce transfert.</string>
<string name="transfer_receiver_accepted">Approuvé — en attente de la fin</string> <string name="transfer_receiver_accepted">Approuvé — en attente de la fin</string>
<string name="transfer_receiver_completed">Reçu avec succès</string> <string name="transfer_receiver_completed">Reçu avec succès</string>
<string name="transfer_receiver_failed">Transfert échoué</string>
<string name="transfer_receiver_expired">Demande expirée</string> <string name="transfer_receiver_expired">Demande expirée</string>
<string name="transfer_receiver_refused">Demande refusée</string> <string name="transfer_receiver_refused">Demande refusée</string>
<string name="transfer_receiver_requested">En attente de votre approbation</string> <string name="transfer_receiver_requested">En attente de votre approbation</string>

View File

@@ -274,6 +274,7 @@
<string name="transfer_no_receivers">Nessuno ha ancora richiesto questo trasferimento.</string> <string name="transfer_no_receivers">Nessuno ha ancora richiesto questo trasferimento.</string>
<string name="transfer_receiver_accepted">Approvato: in attesa del completamento</string> <string name="transfer_receiver_accepted">Approvato: in attesa del completamento</string>
<string name="transfer_receiver_completed">Ricevuto correttamente</string> <string name="transfer_receiver_completed">Ricevuto correttamente</string>
<string name="transfer_receiver_failed">Trasferimento non riuscito</string>
<string name="transfer_receiver_expired">Richiesta scaduta</string> <string name="transfer_receiver_expired">Richiesta scaduta</string>
<string name="transfer_receiver_refused">Richiesta rifiutata</string> <string name="transfer_receiver_refused">Richiesta rifiutata</string>
<string name="transfer_receiver_requested">In attesa della sua approvazione</string> <string name="transfer_receiver_requested">In attesa della sua approvazione</string>

View File

@@ -274,6 +274,7 @@
<string name="transfer_no_receivers">Nog niemand heeft deze overdracht aangevraagd.</string> <string name="transfer_no_receivers">Nog niemand heeft deze overdracht aangevraagd.</string>
<string name="transfer_receiver_accepted">Goedgekeurd — wachten op voltooiing</string> <string name="transfer_receiver_accepted">Goedgekeurd — wachten op voltooiing</string>
<string name="transfer_receiver_completed">Succesvol ontvangen</string> <string name="transfer_receiver_completed">Succesvol ontvangen</string>
<string name="transfer_receiver_failed">Overdracht mislukt</string>
<string name="transfer_receiver_expired">Verzoek verlopen</string> <string name="transfer_receiver_expired">Verzoek verlopen</string>
<string name="transfer_receiver_refused">Verzoek geweigerd</string> <string name="transfer_receiver_refused">Verzoek geweigerd</string>
<string name="transfer_receiver_requested">Wachten op uw goedkeuring</string> <string name="transfer_receiver_requested">Wachten op uw goedkeuring</string>

View File

@@ -274,6 +274,7 @@
<string name="transfer_no_receivers">Nikt jeszcze nie poprosił o ten transfer.</string> <string name="transfer_no_receivers">Nikt jeszcze nie poprosił o ten transfer.</string>
<string name="transfer_receiver_accepted">Zatwierdzono — oczekiwanie na ukończenie</string> <string name="transfer_receiver_accepted">Zatwierdzono — oczekiwanie na ukończenie</string>
<string name="transfer_receiver_completed">Odebrano pomyślnie</string> <string name="transfer_receiver_completed">Odebrano pomyślnie</string>
<string name="transfer_receiver_failed">Przesyłanie nie powiodło się</string>
<string name="transfer_receiver_expired">Prośba wygasła</string> <string name="transfer_receiver_expired">Prośba wygasła</string>
<string name="transfer_receiver_refused">Prośba odrzucona</string> <string name="transfer_receiver_refused">Prośba odrzucona</string>
<string name="transfer_receiver_requested">Oczekiwanie na Twoje zatwierdzenie</string> <string name="transfer_receiver_requested">Oczekiwanie na Twoje zatwierdzenie</string>

View File

@@ -274,6 +274,7 @@
<string name="transfer_no_receivers">Ainda ninguém pediu esta transferência.</string> <string name="transfer_no_receivers">Ainda ninguém pediu esta transferência.</string>
<string name="transfer_receiver_accepted">Aprovado — a aguardar conclusão</string> <string name="transfer_receiver_accepted">Aprovado — a aguardar conclusão</string>
<string name="transfer_receiver_completed">Recebido com sucesso</string> <string name="transfer_receiver_completed">Recebido com sucesso</string>
<string name="transfer_receiver_failed">A transferência falhou</string>
<string name="transfer_receiver_expired">Pedido expirado</string> <string name="transfer_receiver_expired">Pedido expirado</string>
<string name="transfer_receiver_refused">Pedido recusado</string> <string name="transfer_receiver_refused">Pedido recusado</string>
<string name="transfer_receiver_requested">A aguardar a sua aprovação</string> <string name="transfer_receiver_requested">A aguardar a sua aprovação</string>

View File

@@ -274,6 +274,7 @@
<string name="transfer_no_receivers">Никто ещё не запросил эту передачу.</string> <string name="transfer_no_receivers">Никто ещё не запросил эту передачу.</string>
<string name="transfer_receiver_accepted">Одобрено — ожидание завершения</string> <string name="transfer_receiver_accepted">Одобрено — ожидание завершения</string>
<string name="transfer_receiver_completed">Успешно получено</string> <string name="transfer_receiver_completed">Успешно получено</string>
<string name="transfer_receiver_failed">Передача не удалась</string>
<string name="transfer_receiver_expired">Запрос истёк</string> <string name="transfer_receiver_expired">Запрос истёк</string>
<string name="transfer_receiver_refused">Запрос отклонён</string> <string name="transfer_receiver_refused">Запрос отклонён</string>
<string name="transfer_receiver_requested">Ожидание вашего одобрения</string> <string name="transfer_receiver_requested">Ожидание вашего одобрения</string>

View File

@@ -274,6 +274,7 @@
<string name="transfer_no_receivers">Nobody has requested this transfer yet.</string> <string name="transfer_no_receivers">Nobody has requested this transfer yet.</string>
<string name="transfer_receiver_accepted">Approved — waiting for completion</string> <string name="transfer_receiver_accepted">Approved — waiting for completion</string>
<string name="transfer_receiver_completed">Received successfully</string> <string name="transfer_receiver_completed">Received successfully</string>
<string name="transfer_receiver_failed">Transfer failed</string>
<string name="transfer_receiver_expired">Request expired</string> <string name="transfer_receiver_expired">Request expired</string>
<string name="transfer_receiver_refused">Request refused</string> <string name="transfer_receiver_refused">Request refused</string>
<string name="transfer_receiver_requested">Waiting for your approval</string> <string name="transfer_receiver_requested">Waiting for your approval</string>

View File

@@ -117,6 +117,7 @@ enum class ReceiverDeliveryStatus {
Refused, Refused,
Expired, Expired,
Completed, Completed,
Failed,
Unknown, Unknown,
} }

View File

@@ -433,6 +433,7 @@ private fun ReceiverRequest.toModel(): ReceiverRequestModel = ReceiverRequestMod
"refused" -> ReceiverDeliveryStatus.Refused "refused" -> ReceiverDeliveryStatus.Refused
"expired" -> ReceiverDeliveryStatus.Expired "expired" -> ReceiverDeliveryStatus.Expired
"completed" -> ReceiverDeliveryStatus.Completed "completed" -> ReceiverDeliveryStatus.Completed
"failed" -> ReceiverDeliveryStatus.Failed
else -> ReceiverDeliveryStatus.Unknown else -> ReceiverDeliveryStatus.Unknown
}, },
reason = reason, reason = reason,

View File

@@ -409,13 +409,15 @@ private fun receiverStatusText(status: ReceiverDeliveryStatus) = stringResource(
ReceiverDeliveryStatus.Refused -> Res.string.transfer_receiver_refused ReceiverDeliveryStatus.Refused -> Res.string.transfer_receiver_refused
ReceiverDeliveryStatus.Expired -> Res.string.transfer_receiver_expired ReceiverDeliveryStatus.Expired -> Res.string.transfer_receiver_expired
ReceiverDeliveryStatus.Completed -> Res.string.transfer_receiver_completed ReceiverDeliveryStatus.Completed -> Res.string.transfer_receiver_completed
ReceiverDeliveryStatus.Failed -> Res.string.transfer_receiver_failed
ReceiverDeliveryStatus.Unknown -> Res.string.transfer_receiver_unknown ReceiverDeliveryStatus.Unknown -> Res.string.transfer_receiver_unknown
}) })
@Composable @Composable
private fun receiverStatusColor(status: ReceiverDeliveryStatus) = when (status) { private fun receiverStatusColor(status: ReceiverDeliveryStatus) = when (status) {
ReceiverDeliveryStatus.Completed -> LocalVniDropColors.current.brandDefault ReceiverDeliveryStatus.Completed -> LocalVniDropColors.current.brandDefault
ReceiverDeliveryStatus.Refused, ReceiverDeliveryStatus.Expired -> LocalVniDropColors.current.destructiveDefault ReceiverDeliveryStatus.Refused, ReceiverDeliveryStatus.Expired, ReceiverDeliveryStatus.Failed ->
LocalVniDropColors.current.destructiveDefault
else -> LocalVniDropColors.current.foregroundLighter else -> LocalVniDropColors.current.foregroundLighter
} }