feat(p2p): Phase 3.5 dual-path QUIC race + GUI call-flow debug logs
Two features in one commit because they ship and test together:
Phase 3.5 closes the hole-punching loop and the call-flow debug
logs give the user live visibility into every step of a call so
real-hardware testing of the new P2P path is debuggable.
## Phase 3.5 — dual-path QUIC connect race
Completes the hole-punching work Phase 3 scaffolded. On receiving
a CallSetup with peer_direct_addr, the client now actually races a
direct QUIC handshake against the relay dial and uses whichever
completes first. Symmetric role assignment avoids the two-conns-
per-call problem:
- Both peers compare `own_reflex_addr` vs `peer_reflex_addr`
lexicographically.
- Smaller addr → **Acceptor** (A-role): builds a server-capable
dual endpoint, awaits an incoming QUIC session. Does NOT dial.
- Larger addr → **Dialer** (D-role): builds a client-only
endpoint, dials the peer's addr with `call-<id>` SNI. Does NOT
listen.
- Both sides always dial the relay in parallel as fallback.
- `tokio::select!` with `biased` preference for direct, `tokio::pin!`
so each branch can await the losing opposite as fallback.
- Direct timeout 2s, relay fallback timeout 5s (so 7s worst case
from CallSetup to "no media path" error).
New crate module `wzp_client::dual_path::{race, WinningPath}`
(moved here from desktop/src-tauri so it's testable from a
workspace test). `determine_role` in `wzp_client::reflect` is
pure-function and unit-tested.
### CallEngine integration
- New `pre_connected_transport: Option<Arc<QuinnTransport>>` arg
on both android + desktop `CallEngine::start` branches. Skips
the internal wzp_transport::connect step when Some. Backward-
compat: None keeps Phase 0 relay-only behavior.
- `connect` Tauri command reads own_reflex_addr from SignalState,
computes role, runs the race, passes the winning transport
into CallEngine. If ANY input is missing (no peer addr, no own
addr, equal addrs), falls back to classic relay path —
identical to pre-Phase-3.5 behavior.
### Tests (9 new, all passing)
- 6 unit tests for `determine_role` truth table in
`wzp-client/src/reflect.rs` (smaller=Acceptor, larger=Dialer,
port-only diff, equal, missing-side, symmetry)
- 3 integration tests in `crates/wzp-client/tests/dual_path.rs`:
* `dual_path_direct_wins_on_loopback` — two-endpoint test
rig, Dialer wins direct path vs loopback mock relay
* `dual_path_relay_wins_when_direct_is_dead` — dead peer
port, 2s direct timeout, relay fallback wins
* `dual_path_errors_cleanly_when_both_paths_dead` — <10s
error, no hang
## GUI call-flow debug logs
Runtime-toggled structured events at every step of a call so the
user can see where a call progressed or stalled on real hardware.
Modeled on the existing DRED_VERBOSE_LOGS pattern.
### Rust side
- `static CALL_DEBUG_LOGS: AtomicBool` + `emit_call_debug(&app,
step, details)` helper. Always logs via `tracing::info!`
(logcat always has a copy); GUI Tauri `call-debug-log` event
only fires when the flag is on.
- Tauri commands `set_call_debug_logs` / `get_call_debug_logs`.
### Instrumented steps (24 emit_call_debug sites)
- `register_signal`: start, identity loaded, endpoint created,
connect failed/ok, RegisterPresence sent, ack received/failed,
recv loop spawning
- Recv loop: CallRinging, DirectCallOffer (w/ caller_reflexive_addr),
DirectCallAnswer (w/ callee_reflexive_addr), CallSetup (w/
peer_direct_addr), Hangup
- `place_call`: start, reflect query start/ok/none, offer sent,
send failed
- `answer_call`: start, reflect query start/ok/none or privacy
skip, answer sent, send failed
- `connect`: start, dual_path_race_start (w/ role), won (w/
path), failed, skipped (w/ reasons), call_engine_starting/
started/failed
### JS side
- New `callDebugLogs: boolean` field on Settings type.
- Boot-time hydrate of the Rust flag from localStorage so the
choice survives restarts (like `dredDebugLogs`).
- Settings panel: new "Call flow debug logs" checkbox alongside
the DRED toggle.
- New "Call Debug Log" section that ONLY shows when the flag is
on. Rolling in-memory buffer of the last 200 events, rendered
as monospace `HH:MM:SS.mmm step {details}` lines with auto-
scroll and a Clear button.
- `listen("call-debug-log", ...)` subscribed at app startup,
appends to the buffer, re-renders on every event.
Full workspace test goes from 404 → 413 passing. Clippy clean
on touched crates.
PRD: .taskmaster/docs/prd_phase35_dual_path_race.txt
Tasks: 61-69 all completed
Next: APK + desktop build carrying everything — Phase 2 NAT
detect, Phase 3 advertising, Phase 3.5 dual-path + call debug
logs, plus the earlier Android first-join diagnostics — so the
user can validate the P2P path on real hardware with live
per-step visibility into where any failures happen.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,10 @@ const sRoom = document.getElementById("s-room") as HTMLInputElement;
|
||||
const sAlias = document.getElementById("s-alias") as HTMLInputElement;
|
||||
const sOsAec = document.getElementById("s-os-aec") as HTMLInputElement;
|
||||
const sDredDebug = document.getElementById("s-dred-debug") as HTMLInputElement;
|
||||
const sCallDebug = document.getElementById("s-call-debug") as HTMLInputElement;
|
||||
const sCallDebugSection = document.getElementById("s-call-debug-section") as HTMLDivElement;
|
||||
const sCallDebugLogEl = document.getElementById("s-call-debug-log") as HTMLDivElement;
|
||||
const sCallDebugClearBtn = document.getElementById("s-call-debug-clear") as HTMLButtonElement;
|
||||
const sReflectedAddr = document.getElementById("s-reflected-addr") as HTMLSpanElement;
|
||||
const sReflectBtn = document.getElementById("s-reflect-btn") as HTMLButtonElement;
|
||||
const sNatType = document.getElementById("s-nat-type") as HTMLSpanElement;
|
||||
@@ -150,6 +154,12 @@ interface Settings {
|
||||
/// reconstruction + classical-PLC logs and adds DRED counters to the
|
||||
/// recv heartbeat. Off in normal mode keeps logcat clean.
|
||||
dredDebugLogs: boolean;
|
||||
/// Phase 3.5: when true, every step of a call's lifecycle (register,
|
||||
/// reflect query, offer/answer, relay setup, dual-path race, engine
|
||||
/// start, media) emits a `call-debug-log` Tauri event that this UI
|
||||
/// renders into the rolling Debug Log panel in settings. Off in
|
||||
/// normal mode keeps the GUI quiet but logcat always has a copy.
|
||||
callDebugLogs: boolean;
|
||||
}
|
||||
|
||||
function loadSettings(): Settings {
|
||||
@@ -163,6 +173,7 @@ function loadSettings(): Settings {
|
||||
selectedRelay: 0, room: "general", alias: "",
|
||||
osAec: true, agc: true, quality: "auto", recentRooms: [],
|
||||
dredDebugLogs: false,
|
||||
callDebugLogs: false,
|
||||
};
|
||||
try {
|
||||
const raw = localStorage.getItem("wzp-settings");
|
||||
@@ -413,10 +424,52 @@ function renderRecentRooms(rooms: RecentRoom[]) {
|
||||
// ── Init ──
|
||||
applySettings();
|
||||
setTimeout(pingAllRelays, 300);
|
||||
// Hydrate the Rust DRED verbose-logs flag from saved settings on boot so
|
||||
// the choice survives app restarts without needing the user to reopen
|
||||
// the settings panel.
|
||||
// Hydrate the Rust DRED + call-debug verbose-logs flags from saved
|
||||
// settings on boot so the choice survives app restarts without
|
||||
// needing the user to reopen the settings panel.
|
||||
invoke("set_dred_verbose_logs", { enabled: !!loadSettings().dredDebugLogs }).catch(() => {});
|
||||
invoke("set_call_debug_logs", { enabled: !!loadSettings().callDebugLogs }).catch(() => {});
|
||||
|
||||
// ── Phase 3.5: call-flow debug log rolling buffer ─────────────────
|
||||
// Backend emits `call-debug-log` events at every step of the call
|
||||
// lifecycle when the flag is on. We keep a cap-200 ring here and
|
||||
// render into the Settings panel's Debug Log section.
|
||||
interface CallDebugEntry {
|
||||
ts_ms: number;
|
||||
step: string;
|
||||
details: any;
|
||||
}
|
||||
const CALL_DEBUG_MAX = 200;
|
||||
const callDebugBuffer: CallDebugEntry[] = [];
|
||||
|
||||
function renderCallDebugLog() {
|
||||
// Skip the render if the section isn't visible — cheap guard on
|
||||
// hot path, repainted each time the user opens settings.
|
||||
if (sCallDebugSection.style.display === "none") return;
|
||||
const lines = callDebugBuffer.map((e) => {
|
||||
const iso = new Date(e.ts_ms).toISOString().slice(11, 23); // HH:MM:SS.mmm
|
||||
const details = e.details && Object.keys(e.details).length > 0
|
||||
? " " + JSON.stringify(e.details)
|
||||
: "";
|
||||
return `${iso} ${e.step}${details}`;
|
||||
});
|
||||
sCallDebugLogEl.textContent = lines.join("\n");
|
||||
sCallDebugLogEl.scrollTop = sCallDebugLogEl.scrollHeight;
|
||||
}
|
||||
|
||||
listen("call-debug-log", (event: any) => {
|
||||
const entry: CallDebugEntry = event.payload;
|
||||
callDebugBuffer.push(entry);
|
||||
if (callDebugBuffer.length > CALL_DEBUG_MAX) {
|
||||
callDebugBuffer.shift();
|
||||
}
|
||||
renderCallDebugLog();
|
||||
});
|
||||
|
||||
sCallDebugClearBtn.addEventListener("click", () => {
|
||||
callDebugBuffer.length = 0;
|
||||
sCallDebugLogEl.textContent = "";
|
||||
});
|
||||
|
||||
// Load fingerprint + alias + git hash + render identicon
|
||||
interface AppInfo { git_hash: string; alias: string; fingerprint: string; data_dir: string }
|
||||
@@ -730,6 +783,11 @@ function openSettings() {
|
||||
const s = loadSettings();
|
||||
sRoom.value = s.room; sAlias.value = s.alias; sOsAec.checked = s.osAec;
|
||||
sDredDebug.checked = !!s.dredDebugLogs;
|
||||
sCallDebug.checked = !!s.callDebugLogs;
|
||||
// Show the debug-log panel only when the user has the flag on —
|
||||
// keeps the settings panel short in normal use.
|
||||
sCallDebugSection.style.display = s.callDebugLogs ? "" : "none";
|
||||
renderCallDebugLog();
|
||||
const qi = qualityToIndex(s.quality || "auto");
|
||||
sQuality.value = String(qi);
|
||||
updateQualityUI(qi);
|
||||
@@ -874,10 +932,14 @@ settingsSave.addEventListener("click", () => {
|
||||
s.room = sRoom.value; s.alias = sAlias.value; s.osAec = sOsAec.checked;
|
||||
s.quality = QUALITY_STEPS[parseInt(sQuality.value)] || "auto";
|
||||
s.dredDebugLogs = sDredDebug.checked;
|
||||
s.callDebugLogs = sCallDebug.checked;
|
||||
saveSettingsObj(s);
|
||||
// Push the new flag to the Rust side immediately so the next encoded
|
||||
// frame already honors it without waiting for an app restart.
|
||||
// Push the new flags to the Rust side immediately so the next
|
||||
// frame / call already honors them without waiting for a restart.
|
||||
invoke("set_dred_verbose_logs", { enabled: s.dredDebugLogs }).catch(() => {});
|
||||
invoke("set_call_debug_logs", { enabled: s.callDebugLogs }).catch(() => {});
|
||||
// Reveal or hide the debug-log panel based on the new setting.
|
||||
sCallDebugSection.style.display = s.callDebugLogs ? "" : "none";
|
||||
roomInput.value = s.room; aliasInput.value = s.alias; osAecCheckbox.checked = s.osAec;
|
||||
renderRecentRooms(s.recentRooms);
|
||||
closeSettings();
|
||||
|
||||
Reference in New Issue
Block a user