- 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)
900 lines
27 KiB
Markdown
900 lines
27 KiB
Markdown
# Server Resolution Prefetch 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:** Add invisible server-address resolution prefetch so the active host field can warm Rust resolver state before Connect without changing connection semantics.
|
|
|
|
**Architecture:** Flutter schedules debounced prefetch calls for the active host field only. Rust owns resolution prefetch state, TTL, exact-match validation, and connect-time reuse. The protocol layer accepts an optional already-resolved socket address so Connect can skip resolver work only when the core cache says it is safe.
|
|
|
|
**Tech Stack:** Flutter/Dart, flutter_rust_bridge generated bindings, Rust async Tokio, `chanora_core`, `chanora_protocol`, `chanora_resolver`, Flutter widget/service tests, Rust unit tests.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
- Modify `core/chanora_core/src/lib.rs`: add a session-owned prefetch cache, prefetch API, connect-time cache lookup, and unit tests for TTL/exact-match/generation behavior.
|
|
- Modify `crates/chanora_protocol/src/adapter.rs`: add `resolved_address: Option<SocketAddr>` to `ConnectConfig` and skip `resolve_server_socket()` when present.
|
|
- Modify `crates/chanora_bridge/src/api.rs`: add `prefetch_server_resolution(host: String)` bridge function and pass cache-aware connects through `ChanoraSession`.
|
|
- Regenerate `crates/chanora_bridge/src/frb_generated.rs`: generated Rust bridge bindings.
|
|
- Regenerate `apps/chanora_flutter/lib/src/rust/api.dart`, `api.freezed.dart`, and `frb_generated.dart`: generated Dart bridge bindings.
|
|
- Create `apps/chanora_flutter/lib/services/server_resolution_prefetch_scheduler.dart`: small testable debounce helper for host-field prefetch scheduling.
|
|
- Modify `apps/chanora_flutter/lib/main.dart`: wire `_hostCtl` listener, settings-loaded prefetch, and disposal.
|
|
- Create `apps/chanora_flutter/test/services/server_resolution_prefetch_scheduler_test.dart`: Flutter/Dart tests for debounce and empty-host behavior.
|
|
|
|
Keep all prefetch UI invisible. Do not prefetch bookmarks in bulk.
|
|
|
|
---
|
|
|
|
### Task 1: Protocol Config Accepts Prefetched Socket
|
|
|
|
**Files:**
|
|
- Modify: `crates/chanora_protocol/src/adapter.rs`
|
|
|
|
- [ ] **Step 1: Add a failing protocol config test**
|
|
|
|
Add this test inside the existing `#[cfg(test)] mod tests` in `crates/chanora_protocol/src/adapter.rs`:
|
|
|
|
```rust
|
|
#[test]
|
|
fn connect_config_can_carry_prefetched_socket_address() {
|
|
let addr: std::net::SocketAddr = "127.0.0.1:9987".parse().unwrap();
|
|
let cfg = ConnectConfig {
|
|
address: "example.com".to_string(),
|
|
nickname: "Tester".to_string(),
|
|
password: None,
|
|
identity: None,
|
|
ready_timeout: Duration::from_secs(1),
|
|
resolved_address: Some(addr),
|
|
};
|
|
|
|
assert_eq!(cfg.resolved_address, Some(addr));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test to verify it fails**
|
|
|
|
Run: `cargo test -p chanora_protocol connect_config_can_carry_prefetched_socket_address`
|
|
|
|
Expected: FAIL to compile with a message like `struct ConnectConfig has no field named resolved_address`.
|
|
|
|
- [ ] **Step 3: Add the minimal config field**
|
|
|
|
Update `ConnectConfig` in `crates/chanora_protocol/src/adapter.rs`:
|
|
|
|
```rust
|
|
#[derive(Debug, Clone)]
|
|
pub struct ConnectConfig {
|
|
/// Server address: `hostname[:port]` or TSDNS name.
|
|
pub address: String,
|
|
/// Optional already-resolved socket address from core's invisible
|
|
/// prefetch cache. When present, the protocol layer skips address
|
|
/// resolution but still opens a normal TS3 connection only after
|
|
/// the user requested Connect.
|
|
pub resolved_address: Option<std::net::SocketAddr>,
|
|
/// Nickname to use on the server.
|
|
pub nickname: String,
|
|
/// Optional server password.
|
|
pub password: Option<String>,
|
|
/// Optional pre-existing identity (base64 string accepted by
|
|
/// `tsclientlib::Identity::new_from_str`). If `None`, a fresh
|
|
/// identity is generated and **not persisted** — production callers
|
|
/// should provide one from secure identity storage.
|
|
pub identity: Option<String>,
|
|
/// How long to wait for the initial state snapshot before
|
|
/// returning `ProtocolError::Timeout`.
|
|
pub ready_timeout: Duration,
|
|
}
|
|
|
|
impl Default for ConnectConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
address: String::new(),
|
|
resolved_address: None,
|
|
nickname: "Chanora".to_string(),
|
|
password: None,
|
|
identity: None,
|
|
ready_timeout: Duration::from_secs(10),
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run the focused test**
|
|
|
|
Run: `cargo test -p chanora_protocol connect_config_can_carry_prefetched_socket_address`
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add crates/chanora_protocol/src/adapter.rs
|
|
git commit -m "feat(protocol): accept prefetched server address"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Protocol Connect Skips Resolver On Prefetch Hit
|
|
|
|
**Files:**
|
|
- Modify: `crates/chanora_protocol/src/adapter.rs`
|
|
|
|
- [ ] **Step 1: Add a failing helper test**
|
|
|
|
Add a private helper next to `resolve_server_socket()` only after this failing test is added. First add this test in `#[cfg(test)] mod tests`:
|
|
|
|
```rust
|
|
#[test]
|
|
fn server_socket_from_config_prefers_prefetched_address() {
|
|
let addr: std::net::SocketAddr = "127.0.0.1:9987".parse().unwrap();
|
|
let cfg = ConnectConfig {
|
|
address: "example.com".to_string(),
|
|
resolved_address: Some(addr),
|
|
nickname: "Tester".to_string(),
|
|
password: None,
|
|
identity: None,
|
|
ready_timeout: Duration::from_secs(1),
|
|
};
|
|
|
|
assert_eq!(server_socket_from_config(&cfg), Some(addr));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test to verify it fails**
|
|
|
|
Run: `cargo test -p chanora_protocol server_socket_from_config_prefers_prefetched_address`
|
|
|
|
Expected: FAIL to compile with `cannot find function server_socket_from_config`.
|
|
|
|
- [ ] **Step 3: Add the helper and wire connection task**
|
|
|
|
Add this helper near `resolve_server_socket()`:
|
|
|
|
```rust
|
|
fn server_socket_from_config(cfg: &ConnectConfig) -> Option<SocketAddr> {
|
|
cfg.resolved_address
|
|
}
|
|
```
|
|
|
|
Find the call in `connection_task` that currently resolves the address, shaped like:
|
|
|
|
```rust
|
|
let server = resolve_server_socket(&cfg.address).await?;
|
|
```
|
|
|
|
Replace it with:
|
|
|
|
```rust
|
|
let server = match server_socket_from_config(&cfg) {
|
|
Some(addr) => {
|
|
info!(
|
|
target: "chanora_protocol",
|
|
input = %cfg.address,
|
|
resolved = %addr,
|
|
"using prefetched server address"
|
|
);
|
|
addr
|
|
}
|
|
None => resolve_server_socket(&cfg.address).await?,
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 4: Run protocol tests**
|
|
|
|
Run: `cargo test -p chanora_protocol server_socket_from_config_prefers_prefetched_address`
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Run broader protocol tests**
|
|
|
|
Run: `cargo test -p chanora_protocol`
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add crates/chanora_protocol/src/adapter.rs
|
|
git commit -m "fix(protocol): reuse prefetched server address"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Core Prefetch Cache Data Model
|
|
|
|
**Files:**
|
|
- Modify: `core/chanora_core/src/lib.rs`
|
|
|
|
- [ ] **Step 1: Write failing cache tests**
|
|
|
|
Add these tests inside `#[cfg(test)] mod tests` in `core/chanora_core/src/lib.rs`:
|
|
|
|
```rust
|
|
#[test]
|
|
fn resolution_prefetch_cache_returns_fresh_exact_match() {
|
|
let mut cache = ResolutionPrefetchCache::default();
|
|
let now = std::time::Instant::now();
|
|
let addr: std::net::SocketAddr = "127.0.0.1:9987".parse().unwrap();
|
|
|
|
let generation = cache.begin(" Example.COM ");
|
|
cache.store_success(generation, " Example.COM ", addr, now);
|
|
|
|
assert_eq!(cache.fresh_match("example.com", now), Some(addr));
|
|
}
|
|
|
|
#[test]
|
|
fn resolution_prefetch_cache_ignores_stale_entries() {
|
|
let mut cache = ResolutionPrefetchCache::default();
|
|
let now = std::time::Instant::now();
|
|
let addr: std::net::SocketAddr = "127.0.0.1:9987".parse().unwrap();
|
|
|
|
let generation = cache.begin("example.com");
|
|
cache.store_success(generation, "example.com", addr, now - RESOLUTION_PREFETCH_TTL - std::time::Duration::from_secs(1));
|
|
|
|
assert_eq!(cache.fresh_match("example.com", now), None);
|
|
}
|
|
|
|
#[test]
|
|
fn resolution_prefetch_cache_ignores_different_hosts() {
|
|
let mut cache = ResolutionPrefetchCache::default();
|
|
let now = std::time::Instant::now();
|
|
let addr: std::net::SocketAddr = "127.0.0.1:9987".parse().unwrap();
|
|
|
|
let generation = cache.begin("example.com");
|
|
cache.store_success(generation, "example.com", addr, now);
|
|
|
|
assert_eq!(cache.fresh_match("other.example.com", now), None);
|
|
}
|
|
|
|
#[test]
|
|
fn resolution_prefetch_cache_ignores_stale_generation_completion() {
|
|
let mut cache = ResolutionPrefetchCache::default();
|
|
let now = std::time::Instant::now();
|
|
let first: std::net::SocketAddr = "127.0.0.1:9987".parse().unwrap();
|
|
let second: std::net::SocketAddr = "127.0.0.2:9987".parse().unwrap();
|
|
|
|
let old_generation = cache.begin("example.com");
|
|
let new_generation = cache.begin("example.com");
|
|
cache.store_success(new_generation, "example.com", second, now);
|
|
cache.store_success(old_generation, "example.com", first, now);
|
|
|
|
assert_eq!(cache.fresh_match("example.com", now), Some(second));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run: `cargo test -p chanora_core resolution_prefetch_cache --lib`
|
|
|
|
Expected: FAIL to compile with missing `ResolutionPrefetchCache` and `RESOLUTION_PREFETCH_TTL`.
|
|
|
|
- [ ] **Step 3: Add the cache model**
|
|
|
|
Add near `NetworkDiagnostics` in `core/chanora_core/src/lib.rs`:
|
|
|
|
```rust
|
|
const RESOLUTION_PREFETCH_TTL: Duration = Duration::from_secs(120);
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct ResolutionPrefetchEntry {
|
|
normalized_host: String,
|
|
resolved_address: std::net::SocketAddr,
|
|
completed_at: std::time::Instant,
|
|
generation: u64,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct ResolutionPrefetchCache {
|
|
latest_generation: u64,
|
|
entry: Option<ResolutionPrefetchEntry>,
|
|
last_failure: Option<String>,
|
|
}
|
|
|
|
impl ResolutionPrefetchCache {
|
|
fn begin(&mut self, host: &str) -> u64 {
|
|
self.latest_generation = self.latest_generation.saturating_add(1);
|
|
self.last_failure = None;
|
|
let _ = normalize_prefetch_host(host);
|
|
self.latest_generation
|
|
}
|
|
|
|
fn store_success(
|
|
&mut self,
|
|
generation: u64,
|
|
host: &str,
|
|
resolved_address: std::net::SocketAddr,
|
|
completed_at: std::time::Instant,
|
|
) {
|
|
if generation != self.latest_generation {
|
|
return;
|
|
}
|
|
self.entry = Some(ResolutionPrefetchEntry {
|
|
normalized_host: normalize_prefetch_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: std::time::Instant) -> Option<std::net::SocketAddr> {
|
|
let normalized = normalize_prefetch_host(host);
|
|
let entry = self.entry.as_ref()?;
|
|
if entry.normalized_host != normalized {
|
|
return None;
|
|
}
|
|
if now.duration_since(entry.completed_at) > RESOLUTION_PREFETCH_TTL {
|
|
return None;
|
|
}
|
|
Some(entry.resolved_address)
|
|
}
|
|
}
|
|
|
|
fn normalize_prefetch_host(host: &str) -> String {
|
|
host.trim().to_lowercase()
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run focused core tests**
|
|
|
|
Run: `cargo test -p chanora_core resolution_prefetch_cache --lib`
|
|
|
|
Expected: all four tests PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add core/chanora_core/src/lib.rs
|
|
git commit -m "feat(core): add resolution prefetch cache"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Core Prefetch API And Connect Cache Lookup
|
|
|
|
**Files:**
|
|
- Modify: `core/chanora_core/src/lib.rs`
|
|
|
|
- [ ] **Step 1: Write failing session tests**
|
|
|
|
Add this test in `#[cfg(test)] mod tests` in `core/chanora_core/src/lib.rs`:
|
|
|
|
```rust
|
|
#[tokio::test]
|
|
async fn session_uses_fresh_prefetched_resolution_for_connect_config() {
|
|
let session = ChanoraSession::new();
|
|
let addr: std::net::SocketAddr = "127.0.0.1:9987".parse().unwrap();
|
|
|
|
{
|
|
let mut cache = session.resolution_prefetch.lock().await;
|
|
let generation = cache.begin("example.com");
|
|
cache.store_success(generation, "example.com", addr, std::time::Instant::now());
|
|
}
|
|
|
|
let mut cfg = ConnectConfig::default();
|
|
cfg.address = " example.com ".to_string();
|
|
session.apply_prefetched_resolution(&mut cfg).await;
|
|
|
|
assert_eq!(cfg.resolved_address, Some(addr));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test to verify it fails**
|
|
|
|
Run: `cargo test -p chanora_core session_uses_fresh_prefetched_resolution_for_connect_config --lib`
|
|
|
|
Expected: FAIL to compile with missing `resolution_prefetch` and `apply_prefetched_resolution`.
|
|
|
|
- [ ] **Step 3: Add session field and constructor initialization**
|
|
|
|
Add to `ChanoraSession`:
|
|
|
|
```rust
|
|
/// Invisible server-address prefetch cache. Warmed by Flutter typing
|
|
/// but validated by Rust before Connect can reuse it.
|
|
resolution_prefetch: Arc<Mutex<ResolutionPrefetchCache>>,
|
|
```
|
|
|
|
Initialize in `ChanoraSession::new()`:
|
|
|
|
```rust
|
|
resolution_prefetch: Arc::new(Mutex::new(ResolutionPrefetchCache::default())),
|
|
```
|
|
|
|
- [ ] **Step 4: Add connect-time cache lookup helper**
|
|
|
|
Add methods inside `impl ChanoraSession` before `connect()`:
|
|
|
|
```rust
|
|
async fn apply_prefetched_resolution(&self, cfg: &mut ConnectConfig) {
|
|
let host = cfg.address.trim();
|
|
if host.is_empty() {
|
|
return;
|
|
}
|
|
let resolved = {
|
|
let cache = self.resolution_prefetch.lock().await;
|
|
cache.fresh_match(host, std::time::Instant::now())
|
|
};
|
|
match resolved {
|
|
Some(addr) => {
|
|
info!(
|
|
target: "chanora_core",
|
|
host = %host,
|
|
resolved = %addr,
|
|
"connect using prefetched resolution"
|
|
);
|
|
cfg.resolved_address = Some(addr);
|
|
}
|
|
None => {
|
|
info!(target: "chanora_core", host = %host, "connect prefetch miss or stale");
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
In `connect()`, after identity resolution and before `ProtocolClient::connect(cfg.clone())`, add:
|
|
|
|
```rust
|
|
self.apply_prefetched_resolution(&mut cfg).await;
|
|
```
|
|
|
|
- [ ] **Step 5: Add prefetch scheduling API in core**
|
|
|
|
Add this method in `impl ChanoraSession`:
|
|
|
|
```rust
|
|
pub async fn prefetch_server_resolution(&self, host: String) -> Result<(), CoreError> {
|
|
let normalized = normalize_prefetch_host(&host);
|
|
if normalized.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let generation = {
|
|
let mut cache = self.resolution_prefetch.lock().await;
|
|
cache.begin(&normalized)
|
|
};
|
|
let cache = self.resolution_prefetch.clone();
|
|
tokio::spawn(async move {
|
|
info!(target: "chanora_core", host = %normalized, "resolution prefetch started");
|
|
let result = resolve_prefetch_socket(&normalized).await;
|
|
let mut guard = cache.lock().await;
|
|
match result {
|
|
Ok(addr) => {
|
|
info!(
|
|
target: "chanora_core",
|
|
host = %normalized,
|
|
resolved = %addr,
|
|
"resolution prefetch result"
|
|
);
|
|
guard.store_success(generation, &normalized, addr, std::time::Instant::now());
|
|
}
|
|
Err(err) => {
|
|
warn!(
|
|
target: "chanora_core",
|
|
host = %normalized,
|
|
error = %err,
|
|
"resolution prefetch failed"
|
|
);
|
|
guard.store_failure(generation, err.to_string());
|
|
}
|
|
}
|
|
});
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
Add this helper outside `impl ChanoraSession`:
|
|
|
|
```rust
|
|
async fn resolve_prefetch_socket(host: &str) -> Result<std::net::SocketAddr, CoreError> {
|
|
let resolver = chanora_resolver::ChanoraResolver::new()
|
|
.map_err(|err| CoreError::Protocol(chanora_protocol::ProtocolError::DnsFailed {
|
|
host: host.to_string(),
|
|
reason: format!("resolver initialization failed: {err}"),
|
|
}))?;
|
|
let resolved = resolver
|
|
.resolve_client_address(host)
|
|
.await
|
|
.map_err(|err| CoreError::Protocol(chanora_protocol::ProtocolError::DnsFailed {
|
|
host: host.to_string(),
|
|
reason: err.to_string(),
|
|
}))?;
|
|
resolved
|
|
.parse::<std::net::SocketAddr>()
|
|
.map_err(|err| CoreError::Protocol(chanora_protocol::ProtocolError::DnsFailed {
|
|
host: host.to_string(),
|
|
reason: format!("resolver returned invalid socket address '{resolved}': {err}"),
|
|
}))
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Run focused core tests**
|
|
|
|
Run: `cargo test -p chanora_core session_uses_fresh_prefetched_resolution_for_connect_config --lib`
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 7: Run core tests**
|
|
|
|
Run: `cargo test -p chanora_core --lib`
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add core/chanora_core/src/lib.rs
|
|
git commit -m "feat(core): prefetch server resolution"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Bridge API Exposes Prefetch
|
|
|
|
**Files:**
|
|
- Modify: `crates/chanora_bridge/src/api.rs`
|
|
- Regenerate: `crates/chanora_bridge/src/frb_generated.rs`
|
|
- Regenerate: `apps/chanora_flutter/lib/src/rust/api.dart`
|
|
- Regenerate: `apps/chanora_flutter/lib/src/rust/api.freezed.dart`
|
|
- Regenerate: `apps/chanora_flutter/lib/src/rust/frb_generated.dart`
|
|
|
|
- [ ] **Step 1: Add bridge function**
|
|
|
|
Add after `connect()` in `crates/chanora_bridge/src/api.rs`:
|
|
|
|
```rust
|
|
/// Warm server address resolution for the active host field. This is
|
|
/// intentionally fire-and-forget from the UI perspective: it schedules
|
|
/// Rust-side prefetch work and never opens a TS3 session.
|
|
pub async fn prefetch_server_resolution(host: String) -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async move { session().prefetch_server_resolution(host).await })
|
|
.await
|
|
.map_err(|e| task_join_error("prefetch_server_resolution", e))??;
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Regenerate flutter_rust_bridge bindings**
|
|
|
|
Run from repo root:
|
|
|
|
```bash
|
|
flutter_rust_bridge_codegen generate
|
|
```
|
|
|
|
Expected: generated files update and `apps/chanora_flutter/lib/src/rust/api.dart` contains:
|
|
|
|
```dart
|
|
Future<void> prefetchServerResolution({required String host}) => RustLib
|
|
.instance
|
|
.api
|
|
.crateApiPrefetchServerResolution(host: host);
|
|
```
|
|
|
|
- [ ] **Step 3: Run bridge/core compile check**
|
|
|
|
Run: `cargo test -p chanora_bridge --lib`
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 4: Run Flutter analyzer smoke check**
|
|
|
|
Run: `cd apps/chanora_flutter && flutter analyze`
|
|
|
|
Expected: no new errors related to generated bindings.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add crates/chanora_bridge/src/api.rs crates/chanora_bridge/src/frb_generated.rs apps/chanora_flutter/lib/src/rust/api.dart apps/chanora_flutter/lib/src/rust/api.freezed.dart apps/chanora_flutter/lib/src/rust/frb_generated.dart
|
|
git commit -m "feat(bridge): expose resolution prefetch"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Flutter Prefetch Scheduler Helper
|
|
|
|
**Files:**
|
|
- Create: `apps/chanora_flutter/lib/services/server_resolution_prefetch_scheduler.dart`
|
|
- Create: `apps/chanora_flutter/test/services/server_resolution_prefetch_scheduler_test.dart`
|
|
|
|
- [ ] **Step 1: Write failing scheduler tests**
|
|
|
|
Create `apps/chanora_flutter/test/services/server_resolution_prefetch_scheduler_test.dart`:
|
|
|
|
```dart
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:chanora_flutter/services/server_resolution_prefetch_scheduler.dart';
|
|
|
|
void main() {
|
|
test('debounces host edits and prefetches latest trimmed host', () async {
|
|
final calls = <String>[];
|
|
final scheduler = ServerResolutionPrefetchScheduler(
|
|
delay: const Duration(milliseconds: 20),
|
|
prefetch: (host) async => calls.add(host),
|
|
);
|
|
|
|
scheduler.schedule(' first.example.com ');
|
|
scheduler.schedule(' second.example.com ');
|
|
await Future<void>.delayed(const Duration(milliseconds: 35));
|
|
|
|
expect(calls, ['second.example.com']);
|
|
scheduler.dispose();
|
|
});
|
|
|
|
test('skips empty hosts', () async {
|
|
final calls = <String>[];
|
|
final scheduler = ServerResolutionPrefetchScheduler(
|
|
delay: const Duration(milliseconds: 10),
|
|
prefetch: (host) async => calls.add(host),
|
|
);
|
|
|
|
scheduler.schedule(' ');
|
|
await Future<void>.delayed(const Duration(milliseconds: 25));
|
|
|
|
expect(calls, isEmpty);
|
|
scheduler.dispose();
|
|
});
|
|
|
|
test('dispose cancels pending prefetch', () async {
|
|
final calls = <String>[];
|
|
final scheduler = ServerResolutionPrefetchScheduler(
|
|
delay: const Duration(milliseconds: 30),
|
|
prefetch: (host) async => calls.add(host),
|
|
);
|
|
|
|
scheduler.schedule('example.com');
|
|
scheduler.dispose();
|
|
await Future<void>.delayed(const Duration(milliseconds: 45));
|
|
|
|
expect(calls, isEmpty);
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run: `cd apps/chanora_flutter && flutter test test/services/server_resolution_prefetch_scheduler_test.dart`
|
|
|
|
Expected: FAIL to compile with missing `server_resolution_prefetch_scheduler.dart`.
|
|
|
|
- [ ] **Step 3: Implement scheduler**
|
|
|
|
Create `apps/chanora_flutter/lib/services/server_resolution_prefetch_scheduler.dart`:
|
|
|
|
```dart
|
|
import 'dart:async';
|
|
|
|
typedef ServerResolutionPrefetch = Future<void> Function(String host);
|
|
|
|
class ServerResolutionPrefetchScheduler {
|
|
ServerResolutionPrefetchScheduler({
|
|
required this.prefetch,
|
|
this.delay = const Duration(milliseconds: 700),
|
|
});
|
|
|
|
final ServerResolutionPrefetch prefetch;
|
|
final Duration delay;
|
|
|
|
Timer? _timer;
|
|
bool _disposed = false;
|
|
|
|
void schedule(String rawHost) {
|
|
if (_disposed) return;
|
|
_timer?.cancel();
|
|
final host = rawHost.trim();
|
|
if (host.isEmpty) return;
|
|
_timer = Timer(delay, () {
|
|
if (_disposed) return;
|
|
unawaited(prefetch(host));
|
|
});
|
|
}
|
|
|
|
void dispose() {
|
|
_disposed = true;
|
|
_timer?.cancel();
|
|
_timer = null;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run scheduler tests**
|
|
|
|
Run: `cd apps/chanora_flutter && flutter test test/services/server_resolution_prefetch_scheduler_test.dart`
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Format Dart files**
|
|
|
|
Run: `cd apps/chanora_flutter && dart format lib/services/server_resolution_prefetch_scheduler.dart test/services/server_resolution_prefetch_scheduler_test.dart`
|
|
|
|
Expected: files formatted, no errors.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add apps/chanora_flutter/lib/services/server_resolution_prefetch_scheduler.dart apps/chanora_flutter/test/services/server_resolution_prefetch_scheduler_test.dart
|
|
git commit -m "feat(ui): add resolution prefetch scheduler"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Wire Prefetch Scheduler Into Home UI
|
|
|
|
**Files:**
|
|
- Modify: `apps/chanora_flutter/lib/main.dart`
|
|
|
|
- [ ] **Step 1: Add imports and state fields**
|
|
|
|
In `apps/chanora_flutter/lib/main.dart`, add:
|
|
|
|
```dart
|
|
import 'services/server_resolution_prefetch_scheduler.dart';
|
|
```
|
|
|
|
Inside `_BetaHomeState`, add:
|
|
|
|
```dart
|
|
late final ServerResolutionPrefetchScheduler _resolutionPrefetch;
|
|
```
|
|
|
|
- [ ] **Step 2: Initialize scheduler and listener**
|
|
|
|
In `initState()`, before `_eventsSub = rust.eventsStream().listen(_onEvent);`, add:
|
|
|
|
```dart
|
|
_resolutionPrefetch = ServerResolutionPrefetchScheduler(
|
|
prefetch: (host) => rust.prefetchServerResolution(host: host),
|
|
);
|
|
_hostCtl.addListener(_onHostEdited);
|
|
```
|
|
|
|
Add method in `_BetaHomeState`:
|
|
|
|
```dart
|
|
void _onHostEdited() {
|
|
_resolutionPrefetch.schedule(_hostCtl.text);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Schedule prefetch after settings load**
|
|
|
|
In `_loadUiSettings()`, after setting host/nickname from settings and before the `catch`, add:
|
|
|
|
```dart
|
|
if (settings.host.isNotEmpty) {
|
|
_resolutionPrefetch.schedule(settings.host);
|
|
}
|
|
```
|
|
|
|
Ensure this is not duplicated if an existing `_hostCtl.text = settings.host;` listener already schedules it. If both paths fire, keep only the explicit `_resolutionPrefetch.schedule(settings.host);` and temporarily remove/re-add the listener around `_hostCtl.text = settings.host`, or accept the duplicate because scheduler debounce collapses it. Prefer accepting the duplicate for minimal change.
|
|
|
|
- [ ] **Step 4: Dispose scheduler and listener**
|
|
|
|
In `dispose()`, before `_hostCtl.dispose();`, add:
|
|
|
|
```dart
|
|
_hostCtl.removeListener(_onHostEdited);
|
|
_resolutionPrefetch.dispose();
|
|
```
|
|
|
|
- [ ] **Step 5: Run focused Flutter tests**
|
|
|
|
Run: `cd apps/chanora_flutter && flutter test test/services/server_resolution_prefetch_scheduler_test.dart test/services/connection_phase_state_test.dart`
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Run analyzer**
|
|
|
|
Run: `cd apps/chanora_flutter && flutter analyze`
|
|
|
|
Expected: no new analyzer errors.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add apps/chanora_flutter/lib/main.dart
|
|
git commit -m "feat(ui): prefetch active server address"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: End-To-End Verification On Android
|
|
|
|
**Files:**
|
|
- No source changes expected unless verification finds a bug.
|
|
|
|
- [ ] **Step 1: Run focused Rust tests**
|
|
|
|
Run: `cargo test -p chanora_resolver -p chanora_protocol -p chanora_core --lib`
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 2: Run focused Flutter tests**
|
|
|
|
Run: `cd 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: PASS.
|
|
|
|
- [ ] **Step 3: Build Android debug APK**
|
|
|
|
Run: `cd apps/chanora_flutter && flutter build apk --debug`
|
|
|
|
Expected: `✓ Built build/app/outputs/flutter-apk/app-debug.apk`.
|
|
|
|
- [ ] **Step 4: Install and launch through ADB**
|
|
|
|
Run from `apps/chanora_flutter`:
|
|
|
|
```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
|
|
```
|
|
|
|
Expected: install success and app launches.
|
|
|
|
- [ ] **Step 5: Allow prefetch, connect, and inspect logs**
|
|
|
|
Run from repo root:
|
|
|
|
```bash
|
|
sleep 2
|
|
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"
|
|
```
|
|
|
|
Expected:
|
|
|
|
- No app `FATAL` lines.
|
|
- Logs include `resolution prefetch started` and either `resolution prefetch result` or safe failure.
|
|
- If prefetch completed before tap, logs include `connect using prefetched resolution` and protocol logs include `using prefetched server address`.
|
|
- If prefetch did not complete before tap, connect still succeeds through normal resolver path.
|
|
|
|
- [ ] **Step 6: Inspect UI hierarchy**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
adb shell uiautomator dump /sdcard/window.xml
|
|
adb exec-out cat /sdcard/window.xml
|
|
```
|
|
|
|
Expected: connected server view with channel count, not stuck on `Synchronizing...`.
|
|
|
|
- [ ] **Step 7: Final status check**
|
|
|
|
Run: `git status --short`
|
|
|
|
Expected: no unintended source changes. Unrelated existing `config.json` may remain untracked and must not be committed.
|
|
|
|
---
|
|
|
|
## Self-Review
|
|
|
|
Spec coverage:
|
|
|
|
- Active-field-only prefetch: Tasks 6 and 7.
|
|
- Rust-owned resolver cache and exact-match validation: Tasks 3 and 4.
|
|
- 2-minute TTL: Task 3.
|
|
- Invisible UI: Tasks 6 and 7 avoid any UI status changes.
|
|
- No bookmark fan-out: Task 7 wires only `_hostCtl` and settings-loaded host.
|
|
- No TS3 session before Connect: Task 4 only resolves address; Task 2 only uses resolved address during actual protocol connect.
|
|
- Diagnostics: Tasks 2 and 4 add logs for prefetch and cache hit/miss.
|
|
- Testing and Android smoke: Task 8.
|
|
|
|
Completeness scan: no incomplete markers are used. Each code-changing task includes concrete code snippets and commands.
|
|
|
|
Type consistency: `resolved_address` is added to protocol `ConnectConfig`, core uses `cfg.resolved_address`, bridge exposes `prefetch_server_resolution`, and Dart uses generated `prefetchServerResolution`.
|