Files
chanora/docs/superpowers/plans/2026-05-28-chanora-server-prefetch-crate.md
T

20 KiB

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:

#   crates/chanora_prefetch — server-resolution prefetch cache/policy
#   crates/chanora_bridge/       — Flutter/Rust typed DTOs + glue
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:

[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:

//! 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<ServerPrefetchEntry>,
    last_failure: Option<String>,
}

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<SocketAddr> {
        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<Mutex<ServerPrefetchCache>>,
}

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<SocketAddr> {
        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<SocketAddr, ServerPrefetchError> {
    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
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:

chanora_resolver = { path = "../../crates/chanora_resolver" }

with:

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:

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<std::net::SocketAddr, CoreError> { ... }

Add this import near the other crate imports:

use chanora_prefetch::ServerPrefetcher;
  • Step 3: Replace the session field and constructor initialization

Change the ChanoraSession field from:

resolution_prefetch: Arc<Mutex<ResolutionPrefetchCache>>,

to:

server_prefetch: ServerPrefetcher,

Change the constructor initialization from:

resolution_prefetch: Arc::new(Mutex::new(ResolutionPrefetchCache::default())),

to:

server_prefetch: ServerPrefetcher::new(),
  • Step 4: Delegate prefetch and fresh-match lookup

Replace prefetch_server_resolution with:

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:

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:

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"))]:

#[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:

#[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:

[dev-dependencies]
chanora_prefetch = { path = "../../crates/chanora_prefetch", features = ["test-support"] }

Then core test setup should look like:

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
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:

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:

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
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:

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:

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:

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:

resolution prefetch started
resolution prefetch result
connect using prefetched resolution
using prefetched server address

Expected UI hierarchy includes:

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:

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:

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:

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.