feat(peer): coordinate outbound transfers with local game mutations
Updating or removing a local game rewrites its on-disk files. Peers that were mid-download of that game would keep streaming bytes from files that are being deleted or replaced, handing them a corrupt or stale copy. There was also no authoritative notion of which game version a peer should serve or accept, so a peer could serve whatever happened to be on disk and downloaders could aggregate files from peers running mismatched versions. This introduces a reader-writer coordination scheme between outbound file transfers (readers) and local mutation operations (writers), and gates both serving and downloading on an authoritative game catalog version. Reader-writer coordination: - Track active outbound transfers per game in a shared `OutboundTransfers` map of (id, CancellationToken), threaded through `Ctx`/`PeerCtx` and registered by a `TransferGuard` in the stream service. The guard is registered *before* the serve-eligibility check to close a TOCTOU window where a writer could miss an in-flight reader. - `stream_file_bytes` now honors a cancellation token at every await point (file read, network send, stream close) via `tokio::select!`, so a transfer aborts promptly instead of hanging on a stalled receiver. - `begin_operation` marks a game active first, then cancels its outbound transfers and waits for the count to reach zero before any Updating/RemovingDownload work touches the filesystem. - Active games are now hidden from library snapshots entirely while an operation is in flight, instead of freezing their last announced state, so peers stop discovering a game that is being mutated. Authoritative version catalog: - Replace the `HashSet<String>` catalog with `GameCatalog`, mapping each game id to its expected version (from the bundled game.db / ETI data). - Serving requires the local `version.ini` to match the catalog version (`local_download_matches_catalog`); peer selection, file aggregation, and majority size validation all filter on the expected version (`peers_with_expected_version`, `aggregated_game_files`, and friends). User-visible changes: - The GUI shows confirmation dialogs before Update and Remove, and surfaces a sharing-status indicator on game cards and the detail modal. - A new `OutboundTransferCountChanged` event lets the UI reflect live outbound transfer activity. Test Plan: - just test - just frontend-test - just clippy
This commit is contained in:
@@ -3,6 +3,7 @@ import { JSX, KeyboardEvent } from 'react';
|
||||
import { Game } from '../../lib/types';
|
||||
import { CoverAspect } from '../../hooks/useSettings';
|
||||
import { formatBytes } from '../../lib/format';
|
||||
import { hasNewerLocalVersion } from '../../lib/gameState';
|
||||
|
||||
import { GameCover } from './GameCover';
|
||||
import { StateChip } from '../StateChip';
|
||||
@@ -42,6 +43,14 @@ export const GameCard = ({
|
||||
onOpen(game);
|
||||
}
|
||||
};
|
||||
const newerThanExpected = hasNewerLocalVersion(game);
|
||||
const hasOutbound = game.active_outbound_transfers !== undefined && game.active_outbound_transfers > 0;
|
||||
const statusMessage = hasOutbound
|
||||
? `Sharing to ${game.active_outbound_transfers} peer${game.active_outbound_transfers === 1 ? '' : 's'}`
|
||||
: (game.status_message ?? (newerThanExpected ? 'Newer than expected' : ''));
|
||||
const statusLevel = hasOutbound
|
||||
? 'info'
|
||||
: (game.status_level ?? (newerThanExpected ? 'warning' : undefined));
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -66,8 +75,8 @@ export const GameCard = ({
|
||||
<div className="card-meta">
|
||||
{metaSeparator(formatBytes(game.size), game.genre || null)}
|
||||
</div>
|
||||
<div className={`card-status${game.status_level === 'error' ? ' is-error' : ''}`}>
|
||||
{game.status_message ?? ''}
|
||||
<div className={`card-status${statusLevel ? ` is-${statusLevel}` : ''}`}>
|
||||
{statusMessage}
|
||||
</div>
|
||||
<ActionButton
|
||||
game={game}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { StateChip } from '../StateChip';
|
||||
import { ActionButton } from '../ActionButton';
|
||||
|
||||
import { Game, InstallStatus } from '../../lib/types';
|
||||
import { deriveState, isInProgress } from '../../lib/gameState';
|
||||
import { deriveState, hasNewerLocalVersion, isInProgress } from '../../lib/gameState';
|
||||
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
|
||||
|
||||
interface Props {
|
||||
@@ -59,6 +59,18 @@ export const GameDetailModal = ({
|
||||
|| game.installed
|
||||
|| game.install_status === InstallStatus.Downloading
|
||||
|| game.install_status === InstallStatus.Installing;
|
||||
const newerThanExpected = hasNewerLocalVersion(game);
|
||||
const newerStatus = newerThanExpected
|
||||
? `Local version ${formatEtiVersion(game.local_version)} is newer than expected ${formatEtiVersion(game.eti_game_version)}.`
|
||||
: undefined;
|
||||
const hasOutbound = game.active_outbound_transfers !== undefined && game.active_outbound_transfers > 0;
|
||||
const outboundStatus = hasOutbound
|
||||
? `Sharing to ${game.active_outbound_transfers} peer${game.active_outbound_transfers === 1 ? '' : 's'}.`
|
||||
: undefined;
|
||||
const statusMessage = outboundStatus ?? game.status_message ?? newerStatus;
|
||||
const statusLevel = hasOutbound
|
||||
? 'info'
|
||||
: (game.status_level ?? (newerStatus ? 'warning' : undefined));
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<button className="modal-close" type="button" onClick={onClose} aria-label="Close">
|
||||
@@ -95,7 +107,7 @@ export const GameDetailModal = ({
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Version</div>
|
||||
<div className="meta-value meta-mono">
|
||||
{formatEtiVersion(game.local_version ?? game.eti_game_version)}
|
||||
{formatEtiVersion(game.eti_game_version ?? game.local_version)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="meta-cell">
|
||||
@@ -108,9 +120,9 @@ export const GameDetailModal = ({
|
||||
<p className="modal-desc">{description}</p>
|
||||
)}
|
||||
|
||||
{game.status_message && (
|
||||
<p className={`modal-status${game.status_level === 'error' ? ' is-error' : ''}`}>
|
||||
{game.status_message}
|
||||
{statusMessage && (
|
||||
<p className={`modal-status${statusLevel ? ` is-${statusLevel}` : ''}`}>
|
||||
{statusMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ask } from '@tauri-apps/plugin-dialog';
|
||||
|
||||
import { type UseGamesResult } from './useGames';
|
||||
import { type UISettings } from './useSettings';
|
||||
@@ -69,6 +70,14 @@ export const useGameActions = (
|
||||
|
||||
const update = useCallback(async (id: string) => {
|
||||
try {
|
||||
const game = games.games.find(item => item.id === id);
|
||||
if (game && game.active_outbound_transfers && game.active_outbound_transfers > 0) {
|
||||
const confirmed = await ask(
|
||||
`Peers are currently downloading this game from you. Updating will abort their downloads. Do you want to proceed?`,
|
||||
{ title: 'Active Transfers in Progress', kind: 'warning' }
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
const success = await invoke<boolean>('update_game', {
|
||||
id,
|
||||
language: settings.language,
|
||||
@@ -90,11 +99,19 @@ export const useGameActions = (
|
||||
|
||||
const removeDownload = useCallback(async (id: string) => {
|
||||
try {
|
||||
const game = games.games.find(item => item.id === id);
|
||||
if (game && game.active_outbound_transfers && game.active_outbound_transfers > 0) {
|
||||
const confirmed = await ask(
|
||||
`Peers are currently downloading this game from you. Removing game files will abort their downloads. Do you want to proceed?`,
|
||||
{ title: 'Active Transfers in Progress', kind: 'warning' }
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
await invoke('remove_downloaded_game', { id });
|
||||
} catch (err) {
|
||||
console.error('remove_downloaded_game failed:', err);
|
||||
}
|
||||
}, []);
|
||||
}, [games]);
|
||||
|
||||
const cancelDownload = useCallback(async (id: string) => {
|
||||
try {
|
||||
|
||||
@@ -88,17 +88,30 @@ export const isUnavailable = (game: Game): boolean =>
|
||||
&& game.peer_count === 0
|
||||
&& game.install_status === InstallStatus.NotInstalled;
|
||||
|
||||
const parseVersionStamp = (version: string | undefined): number | null => {
|
||||
if (!version || !/^\d{8}$/.test(version)) return null;
|
||||
const parsed = parseInt(version, 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
};
|
||||
|
||||
export const compareVersionStamps = (
|
||||
left: string | undefined,
|
||||
right: string | undefined,
|
||||
): number | null => {
|
||||
const parsedLeft = parseVersionStamp(left);
|
||||
const parsedRight = parseVersionStamp(right);
|
||||
if (parsedLeft === null || parsedRight === null) return null;
|
||||
return parsedLeft - parsedRight;
|
||||
};
|
||||
|
||||
export const hasNewerLocalVersion = (game: Game): boolean =>
|
||||
(compareVersionStamps(game.local_version, game.eti_game_version) ?? 0) > 0;
|
||||
|
||||
export const needsUpdate = (game: Game): boolean => {
|
||||
if (!game.installed) return false;
|
||||
const peer = game.eti_game_version;
|
||||
const local = game.local_version;
|
||||
if (!local && peer) return true;
|
||||
if (local && peer) {
|
||||
const l = parseInt(local, 10);
|
||||
const p = parseInt(peer, 10);
|
||||
if (!Number.isNaN(l) && !Number.isNaN(p)) return p > l;
|
||||
}
|
||||
return false;
|
||||
if (game.peer_count <= 0) return false;
|
||||
if (!game.local_version && game.eti_game_version) return true;
|
||||
return (compareVersionStamps(game.eti_game_version, game.local_version) ?? 0) > 0;
|
||||
};
|
||||
|
||||
/** What pressing the card's main action button should do, given the state. */
|
||||
|
||||
@@ -21,7 +21,7 @@ export enum ActiveOperationKind {
|
||||
RemovingDownload = 'RemovingDownload',
|
||||
}
|
||||
|
||||
export type StatusLevel = 'info' | 'error';
|
||||
export type StatusLevel = 'info' | 'warning' | 'error';
|
||||
|
||||
export interface DownloadProgress {
|
||||
downloaded_bytes: number;
|
||||
@@ -59,6 +59,7 @@ export interface Game {
|
||||
download_progress?: DownloadProgress;
|
||||
peer_count: number;
|
||||
can_host_server?: boolean;
|
||||
active_outbound_transfers?: number;
|
||||
}
|
||||
|
||||
export interface ActiveOperation {
|
||||
|
||||
@@ -739,6 +739,12 @@
|
||||
.card-status.is-error {
|
||||
color: #f87171;
|
||||
}
|
||||
.card-status.is-warning {
|
||||
color: #fbbf24;
|
||||
}
|
||||
.card-status.is-info {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.density-compact .card-body {
|
||||
padding: 9px 10px 10px;
|
||||
@@ -1383,6 +1389,16 @@
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
.modal-status.is-warning {
|
||||
color: #fbbf24;
|
||||
border-color: rgba(245, 158, 11, 0.4);
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
}
|
||||
.modal-status.is-info {
|
||||
color: #60a5fa;
|
||||
border-color: rgba(96, 165, 250, 0.4);
|
||||
background: rgba(96, 165, 250, 0.08);
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user