fix(tauri): make game-directory changes observable
Game-directory selection could reach update_game_directory and then fail before peer startup, while the frontend only logged the rejected invoke. Preserve the last accepted root until peer acknowledgement, surface backend rejection in the settings and main-window UI, and avoid holding the published control lock across runtime replies. Startup preflight remains fail-closed; synchronous setup stays lexically owned through scoped_blocking so cancellation cannot strand a partially published runtime. Add peer-cli scenario S50 to verify invalid changes preserve the existing library and valid changes acknowledge and refresh the library in both directions. Modernize the fixed-size hex decoders to satisfy the current workspace Clippy lint without changing their behavior. Test Plan: - `just fmt` -- passed - `just clippy` -- passed - `just test` -- passed - `just frontend-test` -- passed - `deno task build` -- passed - `just peer-cli-tests S50` -- passed - `just build` -- passed - `git diff --cached --check` -- passed
This commit is contained in:
1 parent
3bbfe919d2
commit
a49b51d3d8
9 files changed
+118
-8
No files matched your search
@@ -101,7 +101,7 @@ fn parse_lower_hex(value: &str) -> eyre::Result<[u8; DIGEST_BYTES]> {
|
||||
}
|
||||
|
||||
let mut decoded = [0_u8; DIGEST_BYTES];
|
||||
for (output, pair) in decoded.iter_mut().zip(encoded.chunks_exact(2)) {
|
||||
for (output, pair) in decoded.iter_mut().zip(encoded.as_chunks::<2>().0) {
|
||||
*output = (decode_nibble(pair[0])? << 4) | decode_nibble(pair[1])?;
|
||||
}
|
||||
Ok(decoded)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the peer-cli scenarios S1-S49 through Docker."""
|
||||
"""Run the peer-cli scenarios S1-S50 through Docker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -425,6 +425,7 @@ class Runner:
|
||||
("S47", self.s47_multi_archive_streams_in_sorted_order),
|
||||
("S48", self.s48_call_to_play_replication_and_late_join),
|
||||
("S49", self.s49_terminal_call_to_play_late_join),
|
||||
("S50", self.s50_game_directory_acknowledgement),
|
||||
]
|
||||
|
||||
for scenario_id, scenario in scenarios:
|
||||
@@ -2983,6 +2984,61 @@ class Runner:
|
||||
"departure removed the complete call despite the live participant author"
|
||||
)
|
||||
|
||||
def s50_game_directory_acknowledgement(self) -> str:
|
||||
initial_games_dir = self.fixture_root / "s50-initial"
|
||||
copy_game("ggoo", initial_games_dir)
|
||||
peer = self.peer("s50-client", games_dir=initial_games_dir)
|
||||
wait_local_game(peer, "ggoo", downloaded=True, installed=False)
|
||||
|
||||
rejected = peer.send(
|
||||
{"cmd": "set-game-dir", "path": "/games/does-not-exist"},
|
||||
expect_error=True,
|
||||
)
|
||||
error = rejected.get("error")
|
||||
if not isinstance(error, str) or "game directory" not in error.lower():
|
||||
raise ScenarioError(f"invalid game-directory rejection was not explicit: {rejected}")
|
||||
if {
|
||||
game["id"]
|
||||
for game in peer.list_games()["local"]
|
||||
if game.get("downloaded") or game.get("installed")
|
||||
} != {"ggoo"}:
|
||||
raise ScenarioError("rejected game-directory change altered the previous library")
|
||||
|
||||
peer.docker_exec("mkdir", "-p", "/games/alternate")
|
||||
peer.docker_exec("cp", "-a", "/games/ggoo", "/games/alternate/ggoo")
|
||||
alternate_start = len(peer.output)
|
||||
accepted = peer.send(
|
||||
{"cmd": "set-game-dir", "path": "/games/alternate"}
|
||||
)
|
||||
if accepted.get("data") != {
|
||||
"accepted": True,
|
||||
"path": "/games/alternate",
|
||||
}:
|
||||
raise ScenarioError(f"valid game-directory acknowledgement was not exact: {accepted}")
|
||||
peer.wait_for(
|
||||
event_is("local-library-changed"),
|
||||
timeout=20,
|
||||
description="alternate local-library-changed",
|
||||
waiter=LineWaiter(alternate_start),
|
||||
)
|
||||
wait_local_game(peer, "ggoo", downloaded=True, installed=False)
|
||||
|
||||
original_start = len(peer.output)
|
||||
restored = peer.send({"cmd": "set-game-dir", "path": "/games"})
|
||||
if restored.get("data") != {"accepted": True, "path": "/games"}:
|
||||
raise ScenarioError(f"original game-directory acknowledgement was not exact: {restored}")
|
||||
peer.wait_for(
|
||||
event_is("local-library-changed"),
|
||||
timeout=20,
|
||||
description="original local-library-changed",
|
||||
waiter=LineWaiter(original_start),
|
||||
)
|
||||
wait_local_game(peer, "ggoo", downloaded=True, installed=False)
|
||||
return (
|
||||
"invalid directory changes were rejected without mutation, then valid directory "
|
||||
"changes acknowledged and refreshed the local library in both directions"
|
||||
)
|
||||
|
||||
|
||||
def run(command: list[str], description: str) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(
|
||||
|
||||
@@ -856,7 +856,9 @@ fn decode_lower_hex_key(key: &str, prefix: &str) -> eyre::Result<Vec<u8>> {
|
||||
}
|
||||
encoded
|
||||
.as_bytes()
|
||||
.chunks_exact(2)
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|chunk| {
|
||||
let digits = std::str::from_utf8(chunk)?;
|
||||
Ok(u8::from_str_radix(digits, 16)?)
|
||||
|
||||
@@ -273,7 +273,7 @@ fn decode_fixed_lower_hex(value: &str) -> Option<[u8; NONCE_BYTE_LENGTH]> {
|
||||
}
|
||||
|
||||
let mut output = [0_u8; NONCE_BYTE_LENGTH];
|
||||
for (decoded, encoded) in output.iter_mut().zip(value.as_bytes().chunks_exact(2)) {
|
||||
for (decoded, encoded) in output.iter_mut().zip(value.as_bytes().as_chunks::<2>().0) {
|
||||
*decoded =
|
||||
(decode_lower_hex_nibble(encoded[0])? << 4) | decode_lower_hex_nibble(encoded[1])?;
|
||||
}
|
||||
|
||||
@@ -2980,7 +2980,14 @@ async fn update_game_directory(
|
||||
&app_invoke,
|
||||
initial_local_network_sharing,
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(|error| {
|
||||
log::error!(
|
||||
"Failed to accept game directory {}: {error}",
|
||||
games_folder.display()
|
||||
);
|
||||
error
|
||||
})?;
|
||||
let accepted_path = accepted_games_folder.to_string_lossy().into_owned();
|
||||
let accepted_path_changed = current_path != accepted_path;
|
||||
|
||||
@@ -3936,7 +3943,12 @@ where
|
||||
return Err("peer runtime ownership slot is already occupied".to_string());
|
||||
}
|
||||
|
||||
*slot = Some(start()?);
|
||||
// Peer startup performs finite filesystem and identity I/O before it
|
||||
// returns. Keep that work lexically owned by this invoke while handing
|
||||
// other Tokio workers off to the executor instead of blocking one of
|
||||
// them. `scoped_blocking` has no cancellation point, so a cancelled
|
||||
// invoke cannot abandon a half-created runtime in the ownership slot.
|
||||
*slot = Some(scoped_blocking(start)?);
|
||||
Ok(slot)
|
||||
}
|
||||
|
||||
@@ -3949,9 +3961,14 @@ async fn ensure_peer_started(
|
||||
let state = app_handle.state::<LanSpreadState>();
|
||||
let mut peer_ctrl = state.peer_ctrl.write().await;
|
||||
|
||||
if let Some(peer_ctrl) = peer_ctrl.as_ref() {
|
||||
if let Some(peer_sender) = peer_ctrl.as_ref().cloned() {
|
||||
// Do not hold the publication lock while waiting for the peer's
|
||||
// acknowledgement. Startup keeps the lock until publication below so
|
||||
// cancellation cannot strand a runtime without its control channel;
|
||||
// an already-published runtime needs only its cloned sender.
|
||||
drop(peer_ctrl);
|
||||
let (reply, result) = oneshot::channel();
|
||||
peer_ctrl
|
||||
peer_sender
|
||||
.send(PeerCommand::SetGameDir {
|
||||
path: games_folder.to_path_buf(),
|
||||
reply,
|
||||
@@ -4013,9 +4030,11 @@ async fn ensure_peer_started(
|
||||
);
|
||||
let sender = handle.sender();
|
||||
*peer_ctrl = Some(sender);
|
||||
drop(peer_ctrl);
|
||||
*local_peer_id_slot = Some(local_peer_id);
|
||||
drop(installation_identity_slot);
|
||||
drop(local_peer_id_slot);
|
||||
drop(peer_runtime);
|
||||
if let Err(error) = publish_identity_diagnostic(app_handle, identity_durability).await {
|
||||
log::error!("Failed to publish identity persistence diagnostic: {error}");
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
interface Props {
|
||||
settings: UISettings;
|
||||
gameDir: string;
|
||||
gameDirectoryError: string | null;
|
||||
hasGameDirectory: boolean;
|
||||
onPickDirectory: () => void;
|
||||
onChange: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
|
||||
@@ -116,6 +117,7 @@ const GameFolderField = ({ path, isValid, onPickDirectory }: GameFolderFieldProp
|
||||
export const SettingsDialog = ({
|
||||
settings,
|
||||
gameDir,
|
||||
gameDirectoryError,
|
||||
hasGameDirectory,
|
||||
onPickDirectory,
|
||||
onChange,
|
||||
@@ -205,6 +207,11 @@ export const SettingsDialog = ({
|
||||
onPickDirectory={onPickDirectory}
|
||||
/>
|
||||
</Row>
|
||||
{gameDirectoryError && (
|
||||
<div className="settings-inline-error" role="status">
|
||||
Could not set game folder: {gameDirectoryError}
|
||||
</div>
|
||||
)}
|
||||
<Row label="Grid density" hint="How tightly cards are packed">
|
||||
<SegmentedRadio
|
||||
value={settings.density}
|
||||
|
||||
@@ -22,6 +22,16 @@ const requestGameDirectory = (requestedPath: string): Promise<string> =>
|
||||
},
|
||||
});
|
||||
|
||||
const describeGameDirectoryError = (error: unknown): string => {
|
||||
if (typeof error === 'string' && error.trim()) return error;
|
||||
if (error instanceof Error && error.message.trim()) return error.message;
|
||||
if (typeof error === 'object' && error !== null && 'message' in error) {
|
||||
const message = (error as { message: unknown }).message;
|
||||
if (typeof message === 'string' && message.trim()) return message;
|
||||
}
|
||||
return 'The backend rejected the selected game folder.';
|
||||
};
|
||||
|
||||
/**
|
||||
* Owns the backend-accepted game directory. Both restored and newly selected
|
||||
* paths enter frontend state only after the backend returns its canonical path.
|
||||
@@ -29,6 +39,7 @@ const requestGameDirectory = (requestedPath: string): Promise<string> =>
|
||||
export const useGameDirectory = (backendPolicyReady = true) => {
|
||||
const [gameDir, setAcceptedGameDir] = useState('');
|
||||
const [ready, setReady] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const acceptedGameDirRef = useRef('');
|
||||
const mountedRef = useRef(false);
|
||||
const acceptingRef = useRef(false);
|
||||
@@ -57,9 +68,13 @@ export const useGameDirectory = (backendPolicyReady = true) => {
|
||||
acceptedGameDirRef.current = acceptedPath;
|
||||
if (mountedRef.current && acceptingRef.current) {
|
||||
setAcceptedGameDir(acceptedPath);
|
||||
setError(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(failureMessage, error);
|
||||
if (mountedRef.current && acceptingRef.current) {
|
||||
setError(describeGameDirectoryError(error));
|
||||
}
|
||||
}
|
||||
});
|
||||
updateQueueRef.current = update;
|
||||
@@ -70,9 +85,11 @@ export const useGameDirectory = (backendPolicyReady = true) => {
|
||||
useEffect(() => {
|
||||
if (!backendPolicyReady) {
|
||||
setReady(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
setReady(false);
|
||||
setError(null);
|
||||
return startAdmittedPersistenceEffect(
|
||||
windowPersistenceScope,
|
||||
shutdown,
|
||||
@@ -135,6 +152,7 @@ export const useGameDirectory = (backendPolicyReady = true) => {
|
||||
return {
|
||||
gameDir,
|
||||
ready,
|
||||
error,
|
||||
hasGameDirectory: gameDir !== '',
|
||||
setGameDir,
|
||||
rescan,
|
||||
|
||||
@@ -141,6 +141,7 @@ export const MainWindow = () => {
|
||||
const {
|
||||
gameDir,
|
||||
ready: gameDirectoryReady,
|
||||
error: gameDirectoryError,
|
||||
hasGameDirectory,
|
||||
setGameDir,
|
||||
rescan,
|
||||
@@ -315,6 +316,11 @@ export const MainWindow = () => {
|
||||
{localNetworkSharing.error}
|
||||
</div>
|
||||
)}
|
||||
{gameDirectoryError && (
|
||||
<div className="network-notice is-error" role="status">
|
||||
Could not set game folder: {gameDirectoryError}
|
||||
</div>
|
||||
)}
|
||||
{identityDiagnostic.snapshot.diagnostic === "ephemeral" && (
|
||||
<div className="network-notice identity-notice" role="status">
|
||||
{EPHEMERAL_IDENTITY_NOTICE}
|
||||
@@ -406,6 +412,7 @@ export const MainWindow = () => {
|
||||
<SettingsDialog
|
||||
settings={settings}
|
||||
gameDir={gameDir}
|
||||
gameDirectoryError={gameDirectoryError}
|
||||
hasGameDirectory={hasGameDirectory}
|
||||
onPickDirectory={() => windowAsyncScope.adopt(pickDirectory())}
|
||||
onChange={setSetting}
|
||||
|
||||
Reference in new issue
Block a user