Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
b7df2de6a5
|
|||
|
2b3851f837
|
@@ -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,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user