diff --git a/crates/lanspread-db/src/content_manifest/digest.rs b/crates/lanspread-db/src/content_manifest/digest.rs index d89d706..6ff0c17 100644 --- a/crates/lanspread-db/src/content_manifest/digest.rs +++ b/crates/lanspread-db/src/content_manifest/digest.rs @@ -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) diff --git a/crates/lanspread-peer-cli/scripts/run_extended_scenarios.py b/crates/lanspread-peer-cli/scripts/run_extended_scenarios.py index 50c95f5..8b229a4 100644 --- a/crates/lanspread-peer-cli/scripts/run_extended_scenarios.py +++ b/crates/lanspread-peer-cli/scripts/run_extended_scenarios.py @@ -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( diff --git a/crates/lanspread-peer/src/download/ownership.rs b/crates/lanspread-peer/src/download/ownership.rs index 44b3d07..eadff90 100644 --- a/crates/lanspread-peer/src/download/ownership.rs +++ b/crates/lanspread-peer/src/download/ownership.rs @@ -856,7 +856,9 @@ fn decode_lower_hex_key(key: &str, prefix: &str) -> eyre::Result> { } 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)?) diff --git a/crates/lanspread-proto/src/lib.rs b/crates/lanspread-proto/src/lib.rs index 8d89953..bb28c03 100644 --- a/crates/lanspread-proto/src/lib.rs +++ b/crates/lanspread-proto/src/lib.rs @@ -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])?; } diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs index 3d51eec..aa05182 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs @@ -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::(); 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}"); } diff --git a/crates/lanspread-tauri-deno-ts/src/components/modals/SettingsDialog.tsx b/crates/lanspread-tauri-deno-ts/src/components/modals/SettingsDialog.tsx index 56f6626..594d4e8 100644 --- a/crates/lanspread-tauri-deno-ts/src/components/modals/SettingsDialog.tsx +++ b/crates/lanspread-tauri-deno-ts/src/components/modals/SettingsDialog.tsx @@ -19,6 +19,7 @@ import { interface Props { settings: UISettings; gameDir: string; + gameDirectoryError: string | null; hasGameDirectory: boolean; onPickDirectory: () => void; onChange: (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} /> + {gameDirectoryError && ( +
+ Could not set game folder: {gameDirectoryError} +
+ )} => }, }); +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 => export const useGameDirectory = (backendPolicyReady = true) => { const [gameDir, setAcceptedGameDir] = useState(''); const [ready, setReady] = useState(false); + const [error, setError] = useState(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, diff --git a/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx b/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx index 68fed45..caa6b84 100644 --- a/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx +++ b/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx @@ -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} )} + {gameDirectoryError && ( +
+ Could not set game folder: {gameDirectoryError} +
+ )} {identityDiagnostic.snapshot.diagnostic === "ephemeral" && (
{EPHEMERAL_IDENTITY_NOTICE} @@ -406,6 +412,7 @@ export const MainWindow = () => { windowAsyncScope.adopt(pickDirectory())} onChange={setSetting} diff --git a/organize/testing/PEER_CLI_SCENARIOS.md b/organize/testing/PEER_CLI_SCENARIOS.md index 8241dea..be76916 100644 --- a/organize/testing/PEER_CLI_SCENARIOS.md +++ b/organize/testing/PEER_CLI_SCENARIOS.md @@ -57,6 +57,7 @@ for deterministic local runs; mDNS/macvlan remains an environment smoke path. | S47 | Multi-archive streamed install order | Source and client run alone on an internal network using `fixture-multi/cnctw`, with two root `.eti` archives named to require sorted processing. | The client roster/raw view carry the exact multi-profile `ContentId`, and every verified file is bound to the named source's authenticated `PeerId`. Paths arrive in root archive sort order, both payloads install under `local/`, final state is local-only installed, and no root archive/sentinel is committed. | | S48 | Call to Play direct-author late join | Alice creates a call; Bob publishes RSVP/chat intents against its generated `CallId`. Alice and Bob remain connected while Charlie starts on a separate internal-only Docker network shared with Alice; Bob joins it only after Charlie's first pull. | Alice retains the exact Bob-inclusive view before and after Charlie's first pull, while Charlie's authenticated peer set is exactly Alice and its replacement contains only Alice's generated event ID. After the direct Bob pull it contains the exact three-event union once. Bob's departure converges to exactly Alice plus the creator event; Alice's departure then converges to an empty peer set and view. | | S49 | Terminal Call to Play direct-author late join | Alice creates and later starts a call; Bob publishes ready/chat intents. Alice and Bob remain connected while Charlie first pulls Alice across an internal-only Docker network, then Bob joins that network for a direct pull. | Alice retains the exact four-event view throughout the first pull; Charlie is authenticated only to Alice and sees create/start without Bob-owned IDs. Pulling Bob produces the exact four-event terminal view once; creator departure removes the complete call after bounded liveness convergence while Charlie's authenticated peer set remains exactly Bob. | +| S50 | Game-directory acknowledgement and recovery | A peer starts with catalog game `ggoo`, rejects a missing directory, then switches to a second valid directory containing the same game and switches back. | The missing-directory command returns an explicit error and leaves the previous local library unchanged. Each valid change returns the exact accepted canonical path and emits a refreshed `local-library-changed` snapshot, proving the configured root is actually committed in both directions. | ## Version-Skew Contract