Skip to content

Commit f1cb454

Browse files
sanityclaude
andauthored
fix(cli): activate contract migration in riverctl room operations (#193)
* fix(cli): activate contract migration in riverctl room operations The ensure_room_migrated() function was fully implemented but never called. When bundled WASM changed (e.g., after a release), riverctl would try to GET/UPDATE the new contract key which had no state yet, causing timeouts. Two bugs fixed: 1. Wire ensure_room_migrated() into get_room() and subscribe_and_stream() so migration runs automatically before any room operation. 2. Fix the migration-needed check: load_rooms() already regenerates the stored key to match current WASM, so comparing stored vs expected was always equal. Now checks previous_contract_key instead. Closes #192 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(cli): simplify migration log and test assertions - Replace unwrap_or("unknown") with unwrap() since we already early-return when previous_contract_key_str is None - Simplify test assertions using as_deref() pattern - Remove unnecessary mut binding in test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): remove double migration call in subscribe_and_stream subscribe_and_stream called ensure_room_migrated then get_room which also calls ensure_room_migrated. Removed the explicit call since get_room handles migration internally. Also added assertion that previous_contract_key is None for rooms with current WASM. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): address review findings from skeptical reviewer 1. Clear previous_contract_key after ALL successful migration paths (timeout and catch-all), not just the "already exists" path. Prevents redundant migration re-attempts on subsequent calls. 2. Restore "Room not found" guard in subscribe_and_stream that was lost when refactoring to use get_room for migration. 3. Fix misleading test comment about load_rooms behavior during add_room (file doesn't exist yet, so no regeneration occurs). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a462ff2 commit f1cb454

File tree

3 files changed

+88
-21
lines changed

3 files changed

+88
-21
lines changed

cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "riverctl"
3-
version = "0.1.50"
3+
version = "0.1.51"
44
edition = "2021"
55
authors = ["Freenet Project"]
66
description = "Command-line interface for River decentralized chat on Freenet"

cli/src/api.rs

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,10 @@ impl ApiClient {
353353
room_owner_key: &VerifyingKey,
354354
subscribe: bool,
355355
) -> Result<ChatRoomStateV1> {
356-
let contract_key = self.owner_vk_to_contract_key(room_owner_key);
356+
// Ensure room is migrated to the current contract version before any GET.
357+
// This handles the case where bundled WASM changed (e.g., after a release)
358+
// and no other client has migrated the state to the new contract key yet.
359+
let contract_key = self.ensure_room_migrated(room_owner_key).await?;
357360
info!("Getting room state for contract: {}", contract_key.id());
358361

359362
let get_request = ContractRequest::Get {
@@ -865,20 +868,23 @@ impl ApiClient {
865868

866869
let signing_key = SigningKey::from_bytes(&room_info.signing_key_bytes);
867870
let room_state = room_info.state.clone();
868-
let stored_contract_key_str = room_info.contract_key.clone();
869871
let previous_contract_key_str = room_info.previous_contract_key.clone();
870872

871-
// Check if migration is needed
872-
let expected_key_str = expected_key.id().to_string();
873-
if stored_contract_key_str == expected_key_str {
874-
// Already on current contract version
873+
// Check if migration is needed. load_rooms() already regenerates the
874+
// contract_key to match the current WASM and saves the old key in
875+
// previous_contract_key. If previous_contract_key is None, the room
876+
// is already on the current contract version.
877+
if previous_contract_key_str.is_none() {
875878
return Ok(expected_key);
876879
}
877880

881+
// Safe to unwrap: we returned early above when previous_contract_key_str is None.
882+
let prev_key_str = previous_contract_key_str.as_deref().unwrap();
883+
let new_key_display = expected_key.id().to_string();
878884
info!(
879885
"Room contract version changed, migrating: {} -> {}",
880-
&stored_contract_key_str[..12.min(stored_contract_key_str.len())],
881-
&expected_key_str[..12.min(expected_key_str.len())]
886+
&prev_key_str[..12.min(prev_key_str.len())],
887+
&new_key_display[..12.min(new_key_display.len())]
882888
);
883889

884890
// Try to GET from the new contract first - maybe someone else already migrated
@@ -925,6 +931,7 @@ impl ApiClient {
925931
&result,
926932
)
927933
.await;
934+
self.clear_previous_contract_key(room_owner_key)?;
928935
return Ok(result);
929936
}
930937
};
@@ -935,7 +942,6 @@ impl ApiClient {
935942
info!("New contract already exists, updating local reference");
936943
self.storage
937944
.update_contract_key(room_owner_key, &expected_key)?;
938-
// Clear previous_contract_key since migration is complete
939945
self.clear_previous_contract_key(room_owner_key)?;
940946
Ok(expected_key)
941947
}
@@ -961,6 +967,7 @@ impl ApiClient {
961967
&result,
962968
)
963969
.await;
970+
self.clear_previous_contract_key(room_owner_key)?;
964971
Ok(result)
965972
}
966973
}
@@ -1778,7 +1785,9 @@ impl ApiClient {
17781785
self.send_delta(room_owner_key, delta).await
17791786
}
17801787

1781-
/// Helper to send a delta to the network
1788+
/// Helper to send a delta to the network.
1789+
/// Assumes migration has already been triggered by the caller (via get_room
1790+
/// or ensure_room_migrated), so owner_vk_to_contract_key returns the correct key.
17821791
async fn send_delta(
17831792
&self,
17841793
room_owner_key: &VerifyingKey,
@@ -2408,16 +2417,11 @@ impl ApiClient {
24082417
initial_messages: usize,
24092418
format: OutputFormat,
24102419
) -> Result<()> {
2411-
// Get the contract key for the room
2412-
let room = self.storage.get_room(room_owner_key)?.ok_or_else(|| {
2420+
// Verify room exists in local storage before attempting to subscribe
2421+
self.storage.get_room(room_owner_key)?.ok_or_else(|| {
24132422
anyhow!("Room not found in local storage. You may need to create or join it first.")
24142423
})?;
24152424

2416-
let (_signing_key, _room_state, contract_key_str) = room;
2417-
// Parse the stored contract key string as a ContractInstanceId
2418-
let contract_instance_id = ContractInstanceId::try_from(contract_key_str.clone())
2419-
.map_err(|e| anyhow!("Invalid contract key: {}", e))?;
2420-
24212425
// Print header for human format
24222426
if matches!(format, OutputFormat::Human) {
24232427
eprintln!(
@@ -2431,9 +2435,10 @@ impl ApiClient {
24312435
let mut new_message_count = 0;
24322436
let start_time = std::time::Instant::now();
24332437

2434-
// Always fetch current room state to pre-populate seen_messages,
2435-
// so that the first subscription update (which may be a full state)
2436-
// doesn't dump all existing messages as "new".
2438+
// Fetch current room state to pre-populate seen_messages and trigger
2439+
// migration if needed (get_room calls ensure_room_migrated internally).
2440+
let contract_key = self.owner_vk_to_contract_key(room_owner_key);
2441+
let contract_instance_id = *contract_key.id();
24372442
{
24382443
let room_state = self.get_room(room_owner_key, false).await?;
24392444

cli/src/storage.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,58 @@ mod tests {
347347
);
348348
}
349349

350+
#[test]
351+
fn test_load_rooms_sets_previous_contract_key_on_mismatch() {
352+
let (storage, _temp_dir) = create_test_storage();
353+
let owner_sk = create_test_signing_key();
354+
let owner_vk = owner_sk.verifying_key();
355+
let state = create_test_state(&owner_sk);
356+
357+
// Store with a fake old contract key (simulating a WASM change)
358+
let fake_old_key = {
359+
let code = freenet_stdlib::prelude::ContractCode::from(vec![1u8; 100]);
360+
let params = freenet_stdlib::prelude::Parameters::from(vec![1u8]);
361+
ContractKey::from_params_and_code(params, &code)
362+
};
363+
storage
364+
.add_room(&owner_vk, &owner_sk, state, &fake_old_key)
365+
.unwrap();
366+
367+
// Verify initial state: add_room stores the fake_old_key verbatim (the file
368+
// doesn't exist yet when load_rooms runs inside add_room, so no regeneration).
369+
let raw_storage: RoomStorage =
370+
serde_json::from_str(&std::fs::read_to_string(&storage.storage_path).unwrap()).unwrap();
371+
let owner_key_str = bs58::encode(owner_vk.as_bytes()).into_string();
372+
assert_eq!(
373+
raw_storage.rooms[&owner_key_str].previous_contract_key,
374+
None
375+
);
376+
377+
// Now load_rooms should detect the mismatch and set previous_contract_key
378+
let loaded = storage.load_rooms().unwrap();
379+
let room_info = loaded.rooms.get(&owner_key_str).unwrap();
380+
381+
// The contract key should now be the current WASM-derived key
382+
let expected = expected_contract_key(&owner_vk);
383+
assert_eq!(room_info.contract_key, expected.id().to_string());
384+
385+
// previous_contract_key should be set to the fake old key
386+
assert_eq!(
387+
room_info.previous_contract_key.as_deref(),
388+
Some(fake_old_key.id().to_string().as_str())
389+
);
390+
391+
// Verify the update was persisted to disk
392+
let persisted: RoomStorage =
393+
serde_json::from_str(&std::fs::read_to_string(&storage.storage_path).unwrap()).unwrap();
394+
assert_eq!(
395+
persisted.rooms[&owner_key_str]
396+
.previous_contract_key
397+
.as_deref(),
398+
Some(fake_old_key.id().to_string().as_str())
399+
);
400+
}
401+
350402
#[test]
351403
fn test_storage_roundtrip() {
352404
let (storage, _temp_dir) = create_test_storage();
@@ -371,5 +423,15 @@ mod tests {
371423
retrieved_state.configuration.configuration.max_members,
372424
state.configuration.configuration.max_members
373425
);
426+
427+
// When WASM hasn't changed, previous_contract_key must be None
428+
// (ensures ensure_room_migrated returns early without network calls)
429+
let loaded = storage.load_rooms().unwrap();
430+
let owner_key_str = bs58::encode(owner_vk.as_bytes()).into_string();
431+
assert_eq!(
432+
loaded.rooms[&owner_key_str].previous_contract_key,
433+
None,
434+
"previous_contract_key should be None when WASM hasn't changed"
435+
);
374436
}
375437
}

0 commit comments

Comments
 (0)