- Move ASPICE docs to chanoraapp/docs submodule at docs/ - Move development docs to dev-docs/ (superpowers, offline-knowledge, impl-mapping) - Add AGENTS.md with project conventions for AI agents - Add impl-mapping.md (SAD component → source file mapping) - Archive completed plans to dev-docs/superpowers/plans/_archived/ - Remove AGENTS.md from .gitignore (now tracked)
690 lines
20 KiB
Markdown
690 lines
20 KiB
Markdown
# 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<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**
|
|
|
|
```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<std::net::SocketAddr, CoreError> { ... }
|
|
```
|
|
|
|
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<Mutex<ResolutionPrefetchCache>>,
|
|
```
|
|
|
|
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.
|