# Chanora Server Prefetch Crate Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Move server-resolution prefetch policy from `chanora_core` into a focused crate named `chanora_prefetch` without changing Flutter APIs, resolver behavior, protocol dialing behavior, or Android connect UX. **Architecture:** Add `crates/chanora_prefetch` as a workspace member. The new crate owns normalization, single-entry TTL cache, generation rejection, resolver-backed async warming, and fresh exact-match lookup. `chanora_core` keeps the trust boundary for `ConnectConfig.resolved_address`, using the prefetcher only to populate one-time dial config while keeping stored/reconnect config sanitized. **Tech Stack:** Rust workspace, `tokio`, `tracing`, `thiserror`, `chanora_resolver`, `chanora_core`, `chanora_protocol`, Flutter Android smoke via ADB. --- ## File Structure - Create `crates/chanora_prefetch/Cargo.toml`: package metadata and dependencies. - Create `crates/chanora_prefetch/src/lib.rs`: public `ServerPrefetcher`, public `ServerPrefetchError`, internal cache entry/state, resolver-backed prefetch logic, and unit tests. - Modify `Cargo.toml`: add `crates/chanora_prefetch` to workspace members and update the workspace layout comment. - Modify `core/chanora_core/Cargo.toml`: replace the direct `chanora_resolver` dependency with `chanora_prefetch`. - Modify `core/chanora_core/src/lib.rs`: remove private prefetch cache/resolver helpers, add `ServerPrefetcher`, delegate `prefetch_server_resolution`, and keep connect config sanitization tests. - Modify `Cargo.lock`: generated by `cargo test`/`cargo check` after adding the crate. --- ### Task 1: Add `chanora_prefetch` Crate With Cache Policy Tests **Files:** - Modify: `Cargo.toml` - Create: `crates/chanora_prefetch/Cargo.toml` - Create: `crates/chanora_prefetch/src/lib.rs` - [ ] **Step 1: Add the workspace member and package files** In top-level `Cargo.toml`, update the layout comment and members: ```toml # crates/chanora_prefetch — server-resolution prefetch cache/policy # crates/chanora_bridge/ — Flutter/Rust typed DTOs + glue ``` ```toml members = [ "core/chanora_core", "crates/chanora_protocol", "crates/chanora_state", "crates/chanora_audio", "crates/chanora_storage", "crates/chanora_diagnostics", "crates/chanora_prefetch", "crates/chanora_bridge", "crates/chanora_resolver", ] ``` Create `crates/chanora_prefetch/Cargo.toml`: ```toml [package] name = "chanora_prefetch" description = "Chanora — server-address prefetch cache and policy built on chanora_resolver." version.workspace = true edition.workspace = true rust-version.workspace = true authors.workspace = true license.workspace = true repository.workspace = true publish.workspace = true [dependencies] chanora_resolver = { path = "../chanora_resolver" } thiserror.workspace = true tracing.workspace = true tokio = { version = "1", features = ["sync", "rt", "macros"] } [features] test-support = [] ``` Create `crates/chanora_prefetch/src/lib.rs` with the initial crate implementation and tests: ```rust //! Server-address prefetch cache and policy for Chanora. //! //! This crate owns speculative server-resolution warming. It does not //! decide whether a connection should use a prefetched address; callers //! must still apply their own trust boundary before dialing. use std::net::SocketAddr; use std::sync::Arc; use std::time::{Duration, Instant}; use thiserror::Error; use tokio::sync::Mutex; use tracing::{info, warn}; const SERVER_PREFETCH_TTL: Duration = Duration::from_secs(120); #[derive(Debug, Error)] pub enum ServerPrefetchError { #[error("resolver initialization failed: {0}")] ResolverInit(String), } #[derive(Debug, Clone)] struct ServerPrefetchEntry { normalized_host: String, resolved_address: SocketAddr, completed_at: Instant, generation: u64, } #[derive(Debug, Default)] struct ServerPrefetchCache { latest_generation: u64, entry: Option, last_failure: Option, } impl ServerPrefetchCache { fn begin(&mut self, host: &str) -> u64 { if normalize_host(host).is_empty() { return self.latest_generation; } self.latest_generation = self.latest_generation.saturating_add(1); self.last_failure = None; self.latest_generation } fn store_success( &mut self, generation: u64, host: &str, resolved_address: SocketAddr, completed_at: Instant, ) { if generation != self.latest_generation { return; } self.entry = Some(ServerPrefetchEntry { normalized_host: normalize_host(host), resolved_address, completed_at, generation, }); self.last_failure = None; } fn store_failure(&mut self, generation: u64, error: String) { if generation != self.latest_generation { return; } self.last_failure = Some(error); } fn fresh_match(&self, host: &str, now: Instant) -> Option { let normalized = normalize_host(host); let entry = self.entry.as_ref()?; if entry.normalized_host != normalized { return None; } if entry.generation != self.latest_generation { return None; } if now.duration_since(entry.completed_at) > SERVER_PREFETCH_TTL { return None; } Some(entry.resolved_address) } } #[derive(Debug, Clone, Default)] pub struct ServerPrefetcher { cache: Arc>, } impl ServerPrefetcher { pub fn new() -> Self { Self::default() } pub async fn prefetch(&self, host: String) -> Result<(), ServerPrefetchError> { let normalized = normalize_host(&host); if normalized.is_empty() { return Ok(()); } let generation = { let mut cache = self.cache.lock().await; cache.begin(&normalized) }; let cache = self.cache.clone(); tokio::spawn(async move { info!(target: "chanora_prefetch", host = %normalized, "resolution prefetch started"); let result = resolve_socket(&normalized).await; let mut guard = cache.lock().await; match result { Ok(addr) => { info!( target: "chanora_prefetch", host = %normalized, resolved = %addr, "resolution prefetch result" ); guard.store_success(generation, &normalized, addr, Instant::now()); } Err(err) => { warn!( target: "chanora_prefetch", host = %normalized, error = %err, "resolution prefetch failed" ); guard.store_failure(generation, err.to_string()); } } }); Ok(()) } pub async fn fresh_match(&self, host: &str) -> Option { let resolved = { let cache = self.cache.lock().await; cache.fresh_match(host, Instant::now()) }; match resolved { Some(addr) => { info!( target: "chanora_prefetch", host = %host, resolved = %addr, "connect using prefetched resolution" ); Some(addr) } None => { info!(target: "chanora_prefetch", host = %host, "connect prefetch miss or stale"); None } } } #[cfg(any(test, feature = "test-support"))] pub async fn begin_for_test(&self, host: &str) -> u64 { let mut cache = self.cache.lock().await; cache.begin(host) } #[cfg(any(test, feature = "test-support"))] pub async fn store_success_for_test( &self, generation: u64, host: &str, resolved_address: SocketAddr, completed_at: Instant, ) { let mut cache = self.cache.lock().await; cache.store_success(generation, host, resolved_address, completed_at); } #[cfg(any(test, feature = "test-support"))] pub async fn latest_generation_for_test(&self) -> u64 { let cache = self.cache.lock().await; cache.latest_generation } } fn normalize_host(host: &str) -> String { host.trim().to_lowercase() } async fn resolve_socket(host: &str) -> Result { let resolver = chanora_resolver::ChanoraResolver::new() .map_err(|err| ServerPrefetchError::ResolverInit(err.to_string()))?; let resolved = resolver .resolve_client_address(host) .await .map_err(|err| ServerPrefetchError::ResolverInit(err.to_string()))?; Ok(resolved.connection_addr()) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn fresh_exact_match_returns_socket_address() { let prefetcher = ServerPrefetcher::new(); let generation = prefetcher.begin_for_test(" Example.COM ").await; let addr = "127.0.0.1:9987".parse().unwrap(); prefetcher .store_success_for_test(generation, "example.com", addr, Instant::now()) .await; assert_eq!(prefetcher.fresh_match("example.com").await, Some(addr)); assert_eq!(prefetcher.fresh_match(" EXAMPLE.com ").await, Some(addr)); } #[tokio::test] async fn stale_entries_are_ignored() { let prefetcher = ServerPrefetcher::new(); let generation = prefetcher.begin_for_test("example.com").await; let addr = "127.0.0.1:9987".parse().unwrap(); prefetcher .store_success_for_test( generation, "example.com", addr, Instant::now() - SERVER_PREFETCH_TTL - Duration::from_secs(1), ) .await; assert_eq!(prefetcher.fresh_match("example.com").await, None); } #[tokio::test] async fn different_hosts_are_ignored() { let prefetcher = ServerPrefetcher::new(); let generation = prefetcher.begin_for_test("example.com").await; let addr = "127.0.0.1:9987".parse().unwrap(); prefetcher .store_success_for_test(generation, "example.com", addr, Instant::now()) .await; assert_eq!(prefetcher.fresh_match("other.example.com").await, None); } #[tokio::test] async fn stale_generation_completions_are_ignored() { let prefetcher = ServerPrefetcher::new(); let old_generation = prefetcher.begin_for_test("old.example.com").await; let _new_generation = prefetcher.begin_for_test("new.example.com").await; let old_addr = "127.0.0.1:9987".parse().unwrap(); prefetcher .store_success_for_test(old_generation, "old.example.com", old_addr, Instant::now()) .await; assert_eq!(prefetcher.fresh_match("old.example.com").await, None); } #[tokio::test] async fn blank_hosts_do_not_update_generation() { let prefetcher = ServerPrefetcher::new(); let before = prefetcher.latest_generation_for_test().await; prefetcher.prefetch(" ".to_string()).await.unwrap(); assert_eq!(prefetcher.latest_generation_for_test().await, before); } } ``` - [ ] **Step 2: Run new crate tests** Run: `cargo test -p chanora_prefetch --lib` Expected: the new crate compiles and all 5 tests pass. - [ ] **Step 3: Commit Task 1** ```bash git add Cargo.toml Cargo.lock crates/chanora_prefetch git commit -m "feat(prefetch): add server prefetch crate" ``` --- ### Task 2: Wire Core to `ServerPrefetcher` **Files:** - Modify: `core/chanora_core/Cargo.toml` - Modify: `core/chanora_core/src/lib.rs` - [ ] **Step 1: Replace the core dependency** In `core/chanora_core/Cargo.toml`, replace: ```toml chanora_resolver = { path = "../../crates/chanora_resolver" } ``` with: ```toml chanora_prefetch = { path = "../../crates/chanora_prefetch" } ``` - [ ] **Step 2: Remove private prefetch cache implementation from core** In `core/chanora_core/src/lib.rs`, remove these private items: ```rust const RESOLUTION_PREFETCH_TTL: Duration = Duration::from_secs(120); #[derive(Debug, Clone)] struct ResolutionPrefetchEntry { ... } #[derive(Debug, Default)] struct ResolutionPrefetchCache { ... } impl ResolutionPrefetchCache { ... } fn normalize_prefetch_host(host: &str) -> String { ... } async fn resolve_prefetch_socket(host: &str) -> Result { ... } ``` Add this import near the other crate imports: ```rust use chanora_prefetch::ServerPrefetcher; ``` - [ ] **Step 3: Replace the session field and constructor initialization** Change the `ChanoraSession` field from: ```rust resolution_prefetch: Arc>, ``` to: ```rust server_prefetch: ServerPrefetcher, ``` Change the constructor initialization from: ```rust resolution_prefetch: Arc::new(Mutex::new(ResolutionPrefetchCache::default())), ``` to: ```rust server_prefetch: ServerPrefetcher::new(), ``` - [ ] **Step 4: Delegate prefetch and fresh-match lookup** Replace `prefetch_server_resolution` with: ```rust pub async fn prefetch_server_resolution(&self, host: String) -> Result<(), CoreError> { self.server_prefetch .prefetch(host) .await .map_err(|err| CoreError::Protocol(chanora_protocol::ProtocolError::DnsFailed { host: "prefetch".to_string(), reason: err.to_string(), })) } ``` Replace `apply_prefetched_resolution` with: ```rust async fn apply_prefetched_resolution(&self, cfg: &mut ConnectConfig) { cfg.resolved_address = None; let host = cfg.address.trim(); if host.is_empty() { return; } if let Some(addr) = self.server_prefetch.fresh_match(host).await { cfg.resolved_address = Some(addr); } } ``` Keep `prepare_connect_configs` unchanged except that it calls the updated `apply_prefetched_resolution`. - [ ] **Step 5: Update core tests to use the prefetcher seam** Keep these existing core tests: ```rust session_clears_untrusted_prefetched_resolution_on_cache_miss connect_config_without_prefetched_resolution_clears_only_resolved_address connect_config_preparation_keeps_prefetched_address_out_of_stored_config ``` Replace any direct access to `session.resolution_prefetch` with calls to public test-support methods on `ServerPrefetcher`. In `crates/chanora_prefetch/src/lib.rs`, keep these methods gated with `#[cfg(any(test, feature = "test-support"))]`: ```rust #[cfg(any(test, feature = "test-support"))] pub async fn store_success_for_test( &self, generation: u64, host: &str, resolved_address: SocketAddr, completed_at: Instant, ) ``` and: ```rust #[cfg(any(test, feature = "test-support"))] pub async fn begin_for_test(&self, host: &str) -> u64 ``` Enable the feature for `chanora_core` tests by adding this dev-dependency in `core/chanora_core/Cargo.toml`: ```toml [dev-dependencies] chanora_prefetch = { path = "../../crates/chanora_prefetch", features = ["test-support"] } ``` Then core test setup should look like: ```rust let generation = session.server_prefetch.begin_for_test("example.com").await; session .server_prefetch .store_success_for_test(generation, "example.com", cached, std::time::Instant::now()) .await; ``` - [ ] **Step 6: Run core tests and fix compile errors** Run: `cargo test -p chanora_core --lib` Expected: all core lib tests pass. - [ ] **Step 7: Commit Task 2** ```bash git add Cargo.toml Cargo.lock core/chanora_core/Cargo.toml core/chanora_core/src/lib.rs crates/chanora_prefetch git commit -m "refactor(core): use server prefetch crate" ``` --- ### Task 3: Remove Duplicated Core Cache Tests and Verify Protocol Is Unchanged **Files:** - Modify: `core/chanora_core/src/lib.rs` - Inspect only: `crates/chanora_protocol/src/adapter.rs` - [ ] **Step 1: Remove core tests moved to the prefetch crate** Delete these tests from `core/chanora_core/src/lib.rs` because they now belong in `chanora_prefetch`: ```rust resolution_prefetch_cache_returns_fresh_exact_match resolution_prefetch_cache_ignores_stale_entries resolution_prefetch_cache_ignores_different_hosts resolution_prefetch_cache_ignores_stale_generation_completion ``` Do not delete core trust-boundary tests: ```rust session_uses_fresh_prefetched_resolution_for_connect_config session_clears_untrusted_prefetched_resolution_on_cache_miss connect_config_without_prefetched_resolution_clears_only_resolved_address connect_config_preparation_keeps_prefetched_address_out_of_stored_config ``` - [ ] **Step 2: Confirm protocol source is untouched** Run: `git diff -- crates/chanora_protocol/src/adapter.rs` Expected: no diff. If there is a diff, revert only accidental protocol edits by manually restoring the changed lines from `HEAD`; do not use destructive git checkout/reset. - [ ] **Step 3: Run combined Rust tests** Run: `cargo test -p chanora_prefetch -p chanora_core -p chanora_protocol --lib` Expected: all tests pass. - [ ] **Step 4: Commit Task 3** ```bash git add core/chanora_core/src/lib.rs Cargo.lock git commit -m "test(core): keep prefetch tests at crate boundary" ``` --- ### Task 4: Final Verification and Android Smoke **Files:** - No source edits expected. - [ ] **Step 1: Run full focused Rust verification** Run: `cargo test -p chanora_resolver -p chanora_prefetch -p chanora_protocol -p chanora_core --lib` Expected: all tests pass. - [ ] **Step 2: Run focused Flutter tests** Run from `apps/chanora_flutter`: ```bash flutter test test/services/server_resolution_prefetch_scheduler_test.dart test/services/connection_phase_state_test.dart test/services/ts3_server_link_test.dart ``` Expected: all tests pass. - [ ] **Step 3: Build Android APK** Run from `apps/chanora_flutter`: ```bash flutter build apk --debug ``` Expected: build succeeds and produces `build/app/outputs/flutter-apk/app-debug.apk`. - [ ] **Step 4: Run fresh-launch Android prefetch/connect smoke** Run from `apps/chanora_flutter` with an emulator/device attached: ```bash adb install -r "build/app/outputs/flutter-apk/app-debug.apk" adb shell am force-stop app.chanora.chanora_flutter adb logcat -c adb shell monkey -p app.chanora.chanora_flutter -c android.intent.category.LAUNCHER 1 sleep 5 adb shell input tap 354 1363 sleep 8 adb logcat -d -v time | rg "resolution prefetch|prefetched|client request resolution|server address resolved|FATAL|AndroidRuntime|ANR" adb shell uiautomator dump /sdcard/window.xml adb exec-out cat /sdcard/window.xml ``` Expected logs include: ```text resolution prefetch started resolution prefetch result connect using prefetched resolution using prefetched server address ``` Expected UI hierarchy includes: ```text Vigorous Pro Leave server Default Channel ChanoraBeta ``` No app `FATAL`, app `AndroidRuntime` crash, or `ANR` lines should appear. `AndroidRuntime` lines from `monkey` or `uiautomator` are not app crashes. - [ ] **Step 5: Run one channel switch smoke** With the app still connected, run: ```bash adb logcat -c adb shell input tap 300 1705 sleep 2 adb shell input tap 300 1465 sleep 2 adb logcat -d -v time | rg "voice_join|client_move|FATAL|AndroidRuntime|ANR" adb shell uiautomator dump /sdcard/window.xml adb exec-out cat /sdcard/window.xml ``` Expected logs include successful moves to channel IDs similar to: ```text voice_join accepted by server client_move resolved by authoritative self channel change ``` Expected final UI has `ChanoraBeta` under `Default Channel`. - [ ] **Step 6: Check final git state** Run: ```bash git status --short git log --oneline -5 ``` Expected: only unrelated untracked `config.json` remains, unless the user has added other unrelated work. The latest commits should be the prefetch crate split commits. --- ## Self-Review - Spec coverage: the plan adds `chanora_prefetch`, moves cache policy and resolver-backed warming there, keeps core as trust boundary, preserves Flutter/protocol behavior, and includes Android connect smoke verification. - Placeholder scan: no `TBD`, `TODO`, or unspecified edge handling remains. - Type consistency: the plan uses `ServerPrefetcher`, `ServerPrefetchError`, `prefetch`, and `fresh_match` consistently across crate and core tasks.