Compare commits

..

2 Commits

Author SHA1 Message Date
ddidderr b7df2de6a5 fix(download): emit failure events on early-returns and update UI transition
Address backend early-return paths that were silently exiting without emitting a
terminal event to the UI, and align the UI transition to "Downloading" with the
actual start of the chunk transfer.

- Added `DownloadGameFilesFailed` event emissions to `handlers.rs` in the
  unhandled early-return branches (when resolved file descriptions are empty or
  when no trusted peers are found without a local copy). This prevents the UI
  from getting stuck in a checking state.
- Updated the frontend `'game-download-pre'` listener to keep the status in
  `CheckingPeers` during peer majority size validation, and let the UI switch
  to `Downloading` only upon `'game-download-begin'`.
- Added clarifying comments explaining the safety and semantic roles of both
  listeners.

Test Plan:
- Run all unit tests to ensure no regressions: `just test`
- Compile and build the Tauri project: `just build`
2026-05-19 22:59:36 +02:00
ddidderr 2b3851f837 fix(ui): keep peer-check state backend-driven
Downloading a game could keep showing "Checking peers" while the backend was
already transferring files. The frontend owned a five-second fallback that could
invent a no-peers error during a valid long download, then return the action to
Download until install began.

Remove that frontend timer and make the peer lifecycle authoritative instead.
The UI now treats CheckingPeers as only an optimistic click response, ignores it
if a real operation is already in progress, and switches to Downloading when the
existing game-download-pre bridge reports that peer metadata was found.

A review found one backend path that previously had no terminal event: candidate
peers existed, but every peer detail request failed before GotGameFiles. That
path now emits DownloadGameFilesFailed so the UI can leave CheckingPeers without
falling back to a frontend guess.

Test Plan:
- just fmt
- just clippy
- just test
- just build
- git diff --check

Refs: local review P2
2026-05-19 22:23:27 +02:00
2 changed files with 72 additions and 48 deletions
+53
View File
@@ -185,6 +185,9 @@ async fn fetch_game_details_from_peers<F, Fut>(
} }
} else { } else {
log::warn!("Failed to retrieve game files for {id} from any peer"); log::warn!("Failed to retrieve game files for {id} from any peer");
if let Err(e) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.clone() }) {
log::error!("Failed to send DownloadGameFilesFailed event: {e}");
}
} }
} }
@@ -248,6 +251,9 @@ pub async fn handle_download_game_files_command(
log::error!( log::error!(
"No validated file descriptions available to download game {id}; request metadata first" "No validated file descriptions available to download game {id}; request metadata first"
); );
if let Err(send_err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id }) {
log::error!("Failed to send DownloadGameFilesFailed event: {send_err}");
}
return; return;
} }
@@ -274,6 +280,11 @@ pub async fn handle_download_game_files_command(
} }
} else { } else {
log::error!("No trusted peers available after majority validation for game {id}"); log::error!("No trusted peers available after majority validation for game {id}");
if let Err(send_err) =
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.clone() })
{
log::error!("Failed to send DownloadGameFilesFailed event: {send_err}");
}
} }
return; return;
} }
@@ -1171,6 +1182,48 @@ mod tests {
); );
} }
#[tokio::test]
async fn failed_peer_detail_fetch_emits_terminal_download_failure() {
let first_addr = addr(12_020);
let second_addr = addr(12_021);
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
let (tx, mut rx) = mpsc::unbounded_channel();
let fetched_peers = Arc::new(Mutex::new(Vec::new()));
fetch_game_details_from_peers(
vec![first_addr, second_addr],
"game".to_string(),
peer_game_db,
tx.clone(),
{
let fetched_peers = fetched_peers.clone();
move |peer_addr, _game_id, _peer_game_db| {
let fetched_peers = fetched_peers.clone();
async move {
fetched_peers
.lock()
.expect("fetched peer list should not be poisoned")
.push(peer_addr);
Err::<Vec<GameFileDescription>, _>(eyre::eyre!("detail fetch failed"))
}
}
},
)
.await;
assert_eq!(
*fetched_peers
.lock()
.expect("fetched peer list should not be poisoned"),
vec![first_addr, second_addr]
);
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::DownloadGameFilesFailed { id } if id == "game"
));
assert_no_event(&mut rx).await;
}
#[tokio::test] #[tokio::test]
async fn update_request_skips_local_manifest_even_when_download_exists() { async fn update_request_skips_local_manifest_even_when_download_exists() {
let temp = TempDir::new("lanspread-handler-latest-peer"); let temp = TempDir::new("lanspread-handler-latest-peer");
@@ -10,12 +10,11 @@ import {
} from '../lib/types'; } from '../lib/types';
import { import {
activeStatusById, activeStatusById,
isInProgress,
mergeGameUpdate, mergeGameUpdate,
normalizeGamesListPayload, normalizeGamesListPayload,
} from '../lib/gameState'; } from '../lib/gameState';
const CHECKING_PEERS_TIMEOUT_MS = 5000;
interface PendingPatch { interface PendingPatch {
install_status?: InstallStatus; install_status?: InstallStatus;
downloaded?: boolean; downloaded?: boolean;
@@ -47,8 +46,7 @@ const applyPatch = (game: Game, patch: PendingPatch): Game => {
* Owns the games list and reflects every backend event (download/install/ * Owns the games list and reflects every backend event (download/install/
* uninstall/remove lifecycle, peer count) into local React state. Returns a * uninstall/remove lifecycle, peer count) into local React state. Returns a
* fire-and-forget `markChecking` helper so action calls can immediately show a * fire-and-forget `markChecking` helper so action calls can immediately show a
* "Checking peers…" state with an automatic fall-back if the backend never * "Checking peers…" state until the backend emits the authoritative outcome.
* emits a follow-up event.
*/ */
export interface UseGamesResult { export interface UseGamesResult {
games: Game[]; games: Game[];
@@ -57,57 +55,29 @@ export interface UseGamesResult {
requestGames: () => Promise<void>; requestGames: () => Promise<void>;
markChecking: (id: string) => void; markChecking: (id: string) => void;
markInstalling: (id: string) => void; markInstalling: (id: string) => void;
cancelChecking: (id: string) => void;
} }
export const useGames = (rescanGameDir: () => void): UseGamesResult => { export const useGames = (rescanGameDir: () => void): UseGamesResult => {
const [games, setGames] = useState<Game[]>([]); const [games, setGames] = useState<Game[]>([]);
const [totalPeerCount, setTotalPeerCount] = useState(0); const [totalPeerCount, setTotalPeerCount] = useState(0);
const checkingTimeouts = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const rescanRef = useRef(rescanGameDir); const rescanRef = useRef(rescanGameDir);
rescanRef.current = rescanGameDir; rescanRef.current = rescanGameDir;
const cancelChecking = useCallback((id: string) => { const markChecking = useCallback((id: string) => {
const t = checkingTimeouts.current[id]; setGames(prev => prev.map(item =>
if (t !== undefined) { item.id === id && !isInProgress(item.install_status)
clearTimeout(t); ? applyPatch(item, { install_status: InstallStatus.CheckingPeers, clearStatus: true })
delete checkingTimeouts.current[id]; : item
} ));
}, []); }, []);
const markChecking = useCallback((id: string) => {
cancelChecking(id);
setGames(prev => prev.map(item =>
item.id === id
? { ...item, install_status: InstallStatus.CheckingPeers }
: item,
));
checkingTimeouts.current[id] = setTimeout(() => {
setGames(prev => prev.map(item => {
if (item.id !== id || item.install_status !== InstallStatus.CheckingPeers) {
return item;
}
return {
...item,
install_status: item.installed
? InstallStatus.Installed
: InstallStatus.NotInstalled,
status_message: 'No peers currently have this game.',
status_level: 'error',
};
}));
delete checkingTimeouts.current[id];
}, CHECKING_PEERS_TIMEOUT_MS);
}, [cancelChecking]);
const markInstalling = useCallback((id: string) => { const markInstalling = useCallback((id: string) => {
cancelChecking(id);
setGames(prev => prev.map(item => setGames(prev => prev.map(item =>
item.id === id item.id === id
? applyPatch(item, { install_status: InstallStatus.Installing, clearStatus: true }) ? applyPatch(item, { install_status: InstallStatus.Installing, clearStatus: true })
: item, : item,
)); ));
}, [cancelChecking]); }, []);
const requestGames = useCallback(async () => { const requestGames = useCallback(async () => {
try { try {
@@ -130,7 +100,6 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => {
message: string, message: string,
{ triggerRescan = false }: { triggerRescan?: boolean } = {}, { triggerRescan = false }: { triggerRescan?: boolean } = {},
) => { ) => {
cancelChecking(id);
setGames(prev => prev.map(item => item.id === id setGames(prev => prev.map(item => item.id === id
? { ? {
...item, ...item,
@@ -163,15 +132,22 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => {
}); });
})); }));
// 'game-download-pre' confirms peer metadata was found. The backend may still
// reject the download during majority validation (which now emits a terminal fail event),
// so keep showing CheckingPeers until 'game-download-begin' reports that transfer started.
unlisteners.push(await listen('game-download-pre', (e) => {
const id = e.payload as string;
updateById(id, { install_status: InstallStatus.CheckingPeers, clearStatus: true });
}));
// 'game-download-begin' signals consensus size validation has completed and file transfer has started.
unlisteners.push(await listen('game-download-begin', (e) => { unlisteners.push(await listen('game-download-begin', (e) => {
const id = e.payload as string; const id = e.payload as string;
cancelChecking(id);
updateById(id, { install_status: InstallStatus.Downloading, clearStatus: true }); updateById(id, { install_status: InstallStatus.Downloading, clearStatus: true });
})); }));
unlisteners.push(await listen('game-download-finished', (e) => { unlisteners.push(await listen('game-download-finished', (e) => {
const id = e.payload as string; const id = e.payload as string;
cancelChecking(id);
updateById(id, { install_status: InstallStatus.Installing, clearStatus: true }); updateById(id, { install_status: InstallStatus.Installing, clearStatus: true });
})); }));
@@ -193,13 +169,11 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => {
unlisteners.push(await listen('game-install-begin', (e) => { unlisteners.push(await listen('game-install-begin', (e) => {
const id = e.payload as string; const id = e.payload as string;
cancelChecking(id);
updateById(id, { install_status: InstallStatus.Installing, clearStatus: true }); updateById(id, { install_status: InstallStatus.Installing, clearStatus: true });
})); }));
unlisteners.push(await listen('game-install-finished', (e) => { unlisteners.push(await listen('game-install-finished', (e) => {
const id = e.payload as string; const id = e.payload as string;
cancelChecking(id);
updateById(id, { updateById(id, {
install_status: InstallStatus.Installed, install_status: InstallStatus.Installed,
installed: true, installed: true,
@@ -274,10 +248,8 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => {
return () => { return () => {
cancelled = true; cancelled = true;
unlisteners.forEach(fn => fn()); unlisteners.forEach(fn => fn());
Object.values(checkingTimeouts.current).forEach(clearTimeout);
checkingTimeouts.current = {};
}; };
}, [cancelChecking]); }, []);
return { return {
games, games,
@@ -286,6 +258,5 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => {
requestGames, requestGames,
markChecking, markChecking,
markInstalling, markInstalling,
cancelChecking,
}; };
}; };