From 360f462c6c816ea42890ac8e2f869f5cbf09f5f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 11 May 2026 08:16:43 +0200 Subject: [PATCH 1/9] perf(tauri): add minimal desktop transport benchmark Reintroduce the smallest Tauri-native event transport slice needed to compare it against the browser EventSource path on real sessions. Add a frontend/server benchmark hook so the desktop runtime can report end-to-end transport behavior without pulling in the broader UI performance changes from the original PR. # Conflicts: # packages/tauri-app/src-tauri/src/cli_manager.rs # Conflicts: # packages/tauri-app/src-tauri/src/main.rs --- packages/server/src/server/http-server.ts | 10 +- .../tauri-app/src-tauri/src/cli_manager.rs | 65 +- .../src-tauri/src/desktop_event_transport.rs | 724 ++++++++++++++++++ .../src/desktop_event_transport/assembler.rs | 501 ++++++++++++ .../src/desktop_event_transport/stream.rs | 185 +++++ .../src/desktop_event_transport/tests.rs | 575 ++++++++++++++ .../src/desktop_event_transport/transport.rs | 569 ++++++++++++++ packages/tauri-app/src-tauri/src/main.rs | 24 + .../ui/src/lib/event-transport-contract.ts | 78 ++ packages/ui/src/lib/event-transport.ts | 83 ++ packages/ui/src/lib/native/desktop-events.ts | 163 ++++ packages/ui/src/lib/server-events.ts | 154 +++- packages/ui/src/main.tsx | 27 + packages/ui/src/transport-bench.tsx | 145 ++++ 14 files changed, 3264 insertions(+), 39 deletions(-) create mode 100644 packages/tauri-app/src-tauri/src/desktop_event_transport.rs create mode 100644 packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs create mode 100644 packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs create mode 100644 packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs create mode 100644 packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs create mode 100644 packages/ui/src/lib/event-transport-contract.ts create mode 100644 packages/ui/src/lib/event-transport.ts create mode 100644 packages/ui/src/lib/native/desktop-events.ts create mode 100644 packages/ui/src/transport-bench.tsx diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index faa53d3fd..6b50490b6 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -87,6 +87,7 @@ export function createHttpServer(deps: HttpServerDeps) { const proxyLogger = deps.logger.child({ component: "proxy" }) const apiLogger = deps.logger.child({ component: "http" }) const sseLogger = deps.logger.child({ component: "sse" }) + const perfLogger = deps.logger.child({ component: "perf242" }) const sseClients = new Set<() => void>() const registerSseClient = (cleanup: () => void) => { @@ -199,7 +200,7 @@ export function createHttpServer(deps: HttpServerDeps) { const rawUrl = request.raw.url ?? request.url const pathname = (rawUrl.split("?")[0] ?? "").trim() - const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout"]) + const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout", "/api/perf-log"]) const publicPagePaths = new Set(["/login"]) if (deps.authManager.isTokenBootstrapEnabled()) { publicPagePaths.add("/auth/token") @@ -268,6 +269,13 @@ export function createHttpServer(deps: HttpServerDeps) { reply.code(404).send({ message: "UI bundle missing" }) }) + app.post("/api/perf-log", async (request, reply) => { + console.log("[perf242-route]", JSON.stringify(request.body ?? {})) + perfLogger.info(request.body ?? {}, "frontend perf log") + reply.code(204) + return null + }) + registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager }) registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger }) registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index e44828f55..98210b4ff 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -1,4 +1,5 @@ use crate::managed_node::resolve_bundled_node_binary; +use crate::desktop_event_transport::DesktopEventStreamConfig; use dirs::home_dir; use parking_lot::Mutex; use regex::Regex; @@ -185,12 +186,13 @@ fn kill_process_tree_windows(pid: u32, force: bool) -> bool { } fn navigate_main(app: &AppHandle, url: &str) { if let Some(win) = app.webview_windows().get("main") { - let mut display = url.to_string(); + let final_url = augment_launch_url(url); + let mut display = final_url.clone(); if let Some(hash_index) = display.find('#') { display.replace_range(hash_index + 1.., "[REDACTED]"); } log_line(&format!("navigating main to {display}")); - if let Ok(parsed) = Url::parse(url) { + if let Ok(parsed) = Url::parse(&final_url) { let _ = win.navigate(parsed); } else { log_line("failed to parse URL for navigation"); @@ -200,6 +202,23 @@ fn navigate_main(app: &AppHandle, url: &str) { } } +fn augment_launch_url(base_url: &str) -> String { + let launch_query = std::env::var("CODENOMAD_UI_LAUNCH_QUERY") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + let Some(launch_query) = launch_query else { + return base_url.to_string(); + }; + + if base_url.contains('?') { + return format!("{}&{}", base_url, launch_query.trim_start_matches('?')); + } + + format!("{}?{}", base_url, launch_query.trim_start_matches('?')) +} + fn extract_cookie_value(set_cookie: &str, name: &str) -> Option { let prefix = format!("{name}="); let cookie_kv = set_cookie.split(';').next()?.trim(); @@ -456,6 +475,8 @@ pub struct CliProcessManager { job: Arc>>, ready: Arc, bootstrap_token: Arc>>, + session_cookie: Arc>>, + auth_cookie_name: Arc>>, } impl CliProcessManager { @@ -467,6 +488,8 @@ impl CliProcessManager { job: Arc::new(Mutex::new(None)), ready: Arc::new(AtomicBool::new(false)), bootstrap_token: Arc::new(Mutex::new(None)), + session_cookie: Arc::new(Mutex::new(None)), + auth_cookie_name: Arc::new(Mutex::new(None)), } } @@ -475,6 +498,8 @@ impl CliProcessManager { self.stop()?; self.ready.store(false, Ordering::SeqCst); *self.bootstrap_token.lock() = None; + *self.session_cookie.lock() = None; + *self.auth_cookie_name.lock() = None; { let mut status = self.status.lock(); status.state = CliState::Starting; @@ -491,6 +516,8 @@ impl CliProcessManager { let job_arc = self.job.clone(); let ready_flag = self.ready.clone(); let token_arc = self.bootstrap_token.clone(); + let session_cookie_arc = self.session_cookie.clone(); + let auth_cookie_name_arc = self.auth_cookie_name.clone(); thread::spawn(move || { if let Err(err) = Self::spawn_cli( app.clone(), @@ -500,6 +527,8 @@ impl CliProcessManager { job_arc, ready_flag, token_arc, + session_cookie_arc, + auth_cookie_name_arc, dev, ) { log_line(&format!("cli spawn failed: {err}")); @@ -594,6 +623,7 @@ impl CliProcessManager { status.port = None; status.url = None; status.error = None; + *self.session_cookie.lock() = None; Ok(()) } @@ -602,6 +632,25 @@ impl CliProcessManager { self.status.lock().clone() } + pub fn desktop_event_stream_config(&self) -> Option { + let base_url = self.status.lock().url.clone()?; + let events_url = format!("{}/api/events", base_url.trim_end_matches('/')); + let client_id = format!("tauri-{}", std::process::id()); + let cookie_name = self + .auth_cookie_name + .lock() + .clone() + .unwrap_or_else(|| SESSION_COOKIE_NAME_PREFIX.to_string()); + + Some(DesktopEventStreamConfig { + base_url, + events_url, + client_id, + cookie_name, + session_cookie: self.session_cookie.lock().clone(), + }) + } + fn spawn_cli( app: AppHandle, status: Arc>, @@ -609,6 +658,8 @@ impl CliProcessManager { #[cfg(windows)] job_holder: Arc>>, ready: Arc, bootstrap_token: Arc>>, + session_cookie: Arc>>, + auth_cookie_name_holder: Arc>>, dev: bool, ) -> anyhow::Result<()> { log_line("resolving CLI entry"); @@ -619,6 +670,7 @@ impl CliProcessManager { resolution.runner, resolution.entry, host )); let auth_cookie_name = Arc::new(generate_auth_cookie_name()); + *auth_cookie_name_holder.lock() = Some(auth_cookie_name.as_str().to_string()); let args = resolution.build_args(dev, &host, auth_cookie_name.as_str()); log_line(&format!("CLI args: {:?}", args)); if dev { @@ -723,6 +775,7 @@ impl CliProcessManager { let app_clone = app.clone(); let ready_clone = ready.clone(); let token_clone = bootstrap_token.clone(); + let session_cookie_clone = session_cookie.clone(); let auth_cookie_name_clone = auth_cookie_name.clone(); thread::spawn(move || { @@ -742,6 +795,7 @@ impl CliProcessManager { let status = status_clone.clone(); let ready = ready_clone.clone(); let token = token_clone.clone(); + let session_cookie = session_cookie_clone.clone(); let auth_cookie_name = auth_cookie_name_clone.clone(); thread::spawn(move || { Self::process_stream( @@ -751,6 +805,7 @@ impl CliProcessManager { &status, &ready, &token, + &session_cookie, auth_cookie_name.as_str(), ); }); @@ -761,6 +816,7 @@ impl CliProcessManager { let status = status_clone.clone(); let ready = ready_clone.clone(); let token = token_clone.clone(); + let session_cookie = session_cookie_clone.clone(); let auth_cookie_name = auth_cookie_name_clone.clone(); thread::spawn(move || { Self::process_stream( @@ -770,6 +826,7 @@ impl CliProcessManager { &status, &ready, &token, + &session_cookie, auth_cookie_name.as_str(), ); }); @@ -894,6 +951,7 @@ impl CliProcessManager { status: &Arc>, ready: &Arc, bootstrap_token: &Arc>>, + session_cookie: &Arc>>, auth_cookie_name: &str, ) { let mut buffer = String::new(); @@ -946,6 +1004,7 @@ impl CliProcessManager { status, ready, bootstrap_token, + session_cookie, auth_cookie_name, url, ); @@ -963,6 +1022,7 @@ impl CliProcessManager { status: &Arc>, ready: &Arc, bootstrap_token: &Arc>>, + session_cookie: &Arc>>, auth_cookie_name: &str, base_url: String, ) { @@ -995,6 +1055,7 @@ impl CliProcessManager { log_line(&format!("failed to set session cookie: {err}")); navigate_main(app, &format!("{base_url}/login")); } else { + *session_cookie.lock() = Some(session_id.clone()); navigate_main(app, &base_url); } } diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport.rs new file mode 100644 index 000000000..49cc75521 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport.rs @@ -0,0 +1,724 @@ +use parking_lot::Mutex; +use reqwest::blocking::{Client, Response}; +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::io::{BufRead, BufReader}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::mpsc::{self, RecvTimeoutError, SyncSender}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tauri::{AppHandle, Emitter, Manager, Url}; + +mod assembler; +mod stream; +mod transport; + +use stream::*; +use transport::*; + +const EVENT_BATCH_NAME: &str = "desktop:event-batch"; +const EVENT_STATUS_NAME: &str = "desktop:event-stream-status"; +const FLUSH_INTERVAL_MS: u64 = 16; +const DELTA_STREAM_WINDOW_MS: u64 = 48; +const ACTIVE_STREAM_DISPLAY_WINDOW_MS: u64 = 16; +const ACTIVE_STREAM_DISPLAY_CHUNK_MAX: usize = 96; +const ACTIVE_STREAM_STORE_WINDOW_MS: u64 = 250; +const ACTIVE_STREAM_SNAPSHOT_WINDOW_MS: u64 = 200; +const ACTIVE_STREAM_HOLD_WINDOW_MS: u64 = 12; +const ACTIVE_SESSION_MAX_BATCH_EVENTS: usize = 64; +const MAX_BATCH_EVENTS: usize = 256; +const DEFAULT_RECONNECT_INITIAL_DELAY_MS: u64 = 1_000; +const DEFAULT_RECONNECT_MAX_DELAY_MS: u64 = 10_000; +const DEFAULT_RECONNECT_MULTIPLIER: f64 = 2.0; +const STREAM_CONNECT_TIMEOUT_MS: u64 = 5_000; +const STREAM_TCP_KEEPALIVE_MS: u64 = 30_000; +const STREAM_STALL_TIMEOUT_MS: u64 = 30_000; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DesktopEventStreamConfig { + pub base_url: String, + pub events_url: String, + pub client_id: String, + pub cookie_name: String, + pub session_cookie: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DesktopEventsStartRequest { + pub reconnect: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DesktopEventReconnectPolicy { + pub initial_delay_ms: Option, + pub max_delay_ms: Option, + pub multiplier: Option, + pub max_attempts: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopEventsStartResult { + pub started: bool, + pub generation: Option, + pub reason: Option, +} + +#[derive(Clone, Debug, PartialEq)] +struct ResolvedDesktopEventReconnectPolicy { + initial_delay_ms: u64, + max_delay_ms: u64, + multiplier: f64, + max_attempts: Option, +} + +impl ResolvedDesktopEventReconnectPolicy { + fn resolve(policy: Option<&DesktopEventReconnectPolicy>) -> Self { + let initial_delay_ms = policy + .and_then(|value| value.initial_delay_ms) + .unwrap_or(DEFAULT_RECONNECT_INITIAL_DELAY_MS) + .max(1); + let max_delay_ms = policy + .and_then(|value| value.max_delay_ms) + .unwrap_or(DEFAULT_RECONNECT_MAX_DELAY_MS) + .max(initial_delay_ms); + let multiplier = policy + .and_then(|value| value.multiplier) + .filter(|value| value.is_finite() && *value >= 1.0) + .unwrap_or(DEFAULT_RECONNECT_MULTIPLIER); + let max_attempts = policy + .and_then(|value| value.max_attempts) + .filter(|value| *value > 0); + + Self { + initial_delay_ms, + max_delay_ms, + multiplier, + max_attempts, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +struct DesktopEventTransportConfig { + stream: DesktopEventStreamConfig, + reconnect: ResolvedDesktopEventReconnectPolicy, +} + +impl DesktopEventTransportConfig { + fn new(stream: DesktopEventStreamConfig, request: &DesktopEventsStartRequest) -> Self { + Self { + stream, + reconnect: ResolvedDesktopEventReconnectPolicy::resolve(request.reconnect.as_ref()), + } + } +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct WorkspaceEventBatchPayload { + generation: u64, + sequence: u64, + emitted_at: u128, + events: Vec, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct DesktopEventStreamStatusPayload { + generation: u64, + state: &'static str, + reconnect_attempt: u32, + terminal: bool, + reason: Option, + next_delay_ms: Option, + status_code: Option, + stats: DesktopEventTransportStats, +} + +#[derive(Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +struct DesktopEventTransportStats { + raw_events: u64, + emitted_events: u64, + emitted_batches: u64, + delta_coalesces: u64, + snapshot_coalesces: u64, + status_coalesces: u64, + superseded_deltas_dropped: u64, +} + +struct DesktopEventTransportState { + stop: Option>, + config: Option, + active_target: Option, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct ActiveSessionTarget { + pub instance_id: String, + pub session_id: String, +} + +pub struct DesktopEventTransportManager { + state: Arc>, + generation: Arc, +} + +enum ReaderMessage { + Activity, + Event(Value), + End(Option), +} + +enum PendingEntry { + Delta { + key: String, + scope: String, + instance_id: String, + session_id: Option, + event: Value, + started_at: Instant, + }, + Status { + key: String, + event: Value, + }, + Snapshot { + key: String, + event: Value, + }, + Event(Value), +} + +enum EventDeliveryPolicy { + CoalesceDelta(String), + CoalesceStatus(String), + CoalesceSnapshot(String), + Passthrough, +} + +enum OpenStreamErrorKind { + Unauthorized, + Http, + Transport, +} + +struct OpenStreamError { + kind: OpenStreamErrorKind, + message: String, + status_code: Option, +} + +#[derive(Default)] +struct PendingBatch { + events: Vec, +} + +#[derive(Clone)] +struct ActiveTextDelta { + instance_id: String, + session_id: String, + message_id: String, + part_id: String, + delta: String, +} + +struct ActiveTextPartBuffer { + instance_id: String, + session_id: String, + message_id: String, + part_id: String, + display_pending: String, + store_pending: String, + last_display_emit: Instant, + last_store_emit: Instant, +} + +impl ActiveTextPartBuffer { + fn new(delta: ActiveTextDelta, now: Instant) -> Self { + Self { + instance_id: delta.instance_id, + session_id: delta.session_id, + message_id: delta.message_id, + part_id: delta.part_id, + display_pending: delta.delta.clone(), + store_pending: delta.delta, + last_display_emit: now, + last_store_emit: now, + } + } +} + +#[derive(Clone)] +struct ActiveTextSnapshot { + key: String, + instance_id: String, + session_id: String, + message_id: String, + part_id: String, + event: Value, +} + +struct BufferedTextSnapshot { + instance_id: String, + session_id: String, + message_id: String, + part_id: String, + event: Value, + buffered_at: Instant, +} + +#[derive(Default)] +struct ActiveTextAssembler { + parts: HashMap, +} + +#[derive(Default)] +struct ActiveTextSnapshotBuffer { + parts: HashMap, +} + +impl DesktopEventTransportManager { + pub fn new() -> Self { + Self { + state: Arc::new(Mutex::new(DesktopEventTransportState { + stop: None, + config: None, + active_target: None, + })), + generation: Arc::new(AtomicU64::new(0)), + } + } + + pub fn set_active_session_target(&self, target: Option) { + let mut state = self.state.lock(); + state.active_target = target; + } + + pub fn start( + &self, + app: AppHandle, + stream_config: Option, + request: Option, + ) -> DesktopEventsStartResult { + let Some(stream_config) = stream_config else { + return DesktopEventsStartResult { + started: false, + generation: None, + reason: Some("desktop event stream unavailable".to_string()), + }; + }; + + let request = request.unwrap_or_default(); + let transport_config = DesktopEventTransportConfig::new(stream_config, &request); + + let mut state = self.state.lock(); + if state.config.as_ref() == Some(&transport_config) { + if let Some(stop) = &state.stop { + if !stop.load(Ordering::SeqCst) { + return DesktopEventsStartResult { + started: true, + generation: Some(self.generation.load(Ordering::SeqCst)), + reason: None, + }; + } + } + } + + if let Some(stop) = state.stop.take() { + stop.store(true, Ordering::SeqCst); + } + + let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1; + let stop = Arc::new(AtomicBool::new(false)); + state.stop = Some(stop.clone()); + state.config = Some(transport_config.clone()); + let shared_state = self.state.clone(); + let shared_generation = self.generation.clone(); + drop(state); + + thread::spawn(move || { + run_transport_loop( + app, + shared_state, + shared_generation, + generation, + stop, + transport_config, + ) + }); + + DesktopEventsStartResult { + started: true, + generation: Some(generation), + reason: None, + } + } + + pub fn stop(&self) { + let mut state = self.state.lock(); + if let Some(stop) = state.stop.take() { + stop.store(true, Ordering::SeqCst); + } + state.config = None; + state.active_target = None; + self.generation.fetch_add(1, Ordering::SeqCst); + } +} + +fn classify_event(event: &Value) -> EventDeliveryPolicy { + if let Some(key) = delta_key(event) { + return EventDeliveryPolicy::CoalesceDelta(key); + } + + if let Some(key) = status_key(event) { + return EventDeliveryPolicy::CoalesceStatus(key); + } + + if let Some(key) = snapshot_key(event) { + return EventDeliveryPolicy::CoalesceSnapshot(key); + } + + EventDeliveryPolicy::Passthrough +} + +fn coalesced_payload_event<'a>(event: &'a Value) -> &'a Value { + if event.get("type").and_then(Value::as_str) == Some("instance.event") { + event.get("event").unwrap_or(event) + } else { + event + } +} + +fn coalesced_instance_id(event: &Value) -> &str { + event + .get("instanceId") + .and_then(Value::as_str) + .unwrap_or_default() +} + +fn event_session_id(event: &Value) -> Option<&str> { + let inner = coalesced_payload_event(event); + let inner_type = inner.get("type")?.as_str()?; + let props = inner.get("properties")?; + + match inner_type { + "session.updated" => props + .get("info") + .and_then(|info| info.get("id")) + .and_then(Value::as_str) + .or_else(|| { + props + .get("sessionID") + .or_else(|| props.get("sessionId")) + .and_then(Value::as_str) + }), + "message.updated" => props + .get("info") + .and_then(|info| info.get("sessionID").or_else(|| info.get("sessionId"))) + .and_then(Value::as_str), + "message.part.updated" => props + .get("part") + .and_then(|part| part.get("sessionID").or_else(|| part.get("sessionId"))) + .and_then(Value::as_str), + "message.part.delta" + | "message.removed" + | "message.part.removed" + | "session.compacted" + | "session.diff" + | "session.idle" + | "session.status" => props + .get("sessionID") + .or_else(|| props.get("sessionId")) + .and_then(Value::as_str), + _ => None, + } +} + +fn parse_active_text_delta( + event: &Value, + active_target: Option<&ActiveSessionTarget>, +) -> Option { + let active_target = active_target?; + let instance_id = coalesced_instance_id(event); + if instance_id != active_target.instance_id { + return None; + } + let inner = coalesced_payload_event(event); + if inner.get("type")?.as_str()? != "message.part.delta" { + return None; + } + + let props = inner.get("properties")?; + let field = props.get("field")?.as_str()?; + if field != "text" { + return None; + } + + let event_session = props + .get("sessionID") + .or_else(|| props.get("sessionId")) + .and_then(Value::as_str)?; + if event_session != active_target.session_id { + return None; + } + + Some(ActiveTextDelta { + instance_id: instance_id.to_string(), + session_id: event_session.to_string(), + message_id: props + .get("messageID") + .or_else(|| props.get("messageId")) + .and_then(Value::as_str)? + .to_string(), + part_id: props + .get("partID") + .or_else(|| props.get("partId")) + .and_then(Value::as_str)? + .to_string(), + delta: props.get("delta")?.as_str()?.to_string(), + }) +} + +fn make_assistant_stream_chunk_event(entry: &ActiveTextPartBuffer, delta: &str) -> Value { + serde_json::json!({ + "type": "instance.event", + "instanceId": entry.instance_id, + "event": { + "type": "assistant.stream.chunk", + "properties": { + "sessionID": entry.session_id, + "messageID": entry.message_id, + "partID": entry.part_id, + "field": "text", + "delta": delta, + } + } + }) +} + +fn make_message_part_delta_event(entry: &ActiveTextPartBuffer, delta: &str) -> Value { + serde_json::json!({ + "type": "instance.event", + "instanceId": entry.instance_id, + "event": { + "type": "message.part.delta", + "properties": { + "sessionID": entry.session_id, + "messageID": entry.message_id, + "partID": entry.part_id, + "field": "text", + "delta": delta, + } + } + }) +} + +fn parse_active_text_snapshot( + event: &Value, + active_target: Option<&ActiveSessionTarget>, +) -> Option { + let active_target = active_target?; + let instance_id = coalesced_instance_id(event); + if instance_id != active_target.instance_id { + return None; + } + + let inner = coalesced_payload_event(event); + if inner.get("type")?.as_str()? != "message.part.updated" { + return None; + } + + let part = inner.get("properties")?.get("part")?; + if part.get("type")?.as_str()? != "text" { + return None; + } + if part.get("text")?.as_str().is_none() { + return None; + } + + let event_session = part + .get("sessionID") + .or_else(|| part.get("sessionId")) + .and_then(Value::as_str)?; + if event_session != active_target.session_id { + return None; + } + + let message_id = part + .get("messageID") + .or_else(|| part.get("messageId")) + .and_then(Value::as_str)?; + let part_id = part.get("id")?.as_str()?; + + Some(ActiveTextSnapshot { + key: format!( + "{}:{}:{}:{}", + instance_id, event_session, message_id, part_id + ), + instance_id: instance_id.to_string(), + session_id: event_session.to_string(), + message_id: message_id.to_string(), + part_id: part_id.to_string(), + event: event.clone(), + }) +} + +fn snapshot_key(event: &Value) -> Option { + let instance_id = coalesced_instance_id(event); + let inner = coalesced_payload_event(event); + let inner_type = inner.get("type")?.as_str()?; + let props = inner.get("properties")?; + + match inner_type { + "message.part.updated" => { + let session_id = props + .get("part") + .and_then(|part| part.get("sessionID").or_else(|| part.get("sessionId"))) + .and_then(Value::as_str)?; + let message_id = props + .get("part") + .and_then(|part| part.get("messageID").or_else(|| part.get("messageId"))) + .and_then(Value::as_str)?; + let part_id = props + .get("part") + .and_then(|part| part.get("id")) + .and_then(Value::as_str)?; + + Some(format!( + "message.part.updated:{}:{}:{}:{}", + instance_id, session_id, message_id, part_id + )) + } + "message.updated" => { + let info = props.get("info")?; + let session_id = info + .get("sessionID") + .or_else(|| info.get("sessionId")) + .and_then(Value::as_str)?; + let message_id = info.get("id").and_then(Value::as_str)?; + + Some(format!( + "message.updated:{}:{}:{}", + instance_id, session_id, message_id + )) + } + "session.updated" | "session.status" => { + let session_id = props + .get("info") + .and_then(|info| info.get("id")) + .and_then(Value::as_str) + .or_else(|| { + props + .get("sessionID") + .or_else(|| props.get("sessionId")) + .and_then(Value::as_str) + })?; + + Some(format!("{}:{}:{}", inner_type, instance_id, session_id)) + } + _ => None, + } +} + +fn delta_scope(event: &Value) -> Option { + let instance_id = coalesced_instance_id(event); + let inner = coalesced_payload_event(event); + if inner.get("type")?.as_str()? != "message.part.delta" { + return None; + } + + let props = inner.get("properties")?; + let session_id = props + .get("sessionID") + .or_else(|| props.get("sessionId")) + .and_then(Value::as_str) + .unwrap_or_default(); + let message_id = props + .get("messageID") + .or_else(|| props.get("messageId")) + .and_then(Value::as_str)?; + let part_id = props + .get("partID") + .or_else(|| props.get("partId")) + .and_then(Value::as_str)?; + + Some(format!( + "message.part:{}:{}:{}:{}", + instance_id, session_id, message_id, part_id + )) +} + +fn delta_key(event: &Value) -> Option { + let scope = delta_scope(event)?; + let props = coalesced_payload_event(event).get("properties")?; + let field = props.get("field")?.as_str()?; + + Some(format!("{}:{}", scope, field)) +} + +fn snapshot_superseded_delta_scope(event: &Value) -> Option { + let instance_id = coalesced_instance_id(event); + let inner = coalesced_payload_event(event); + if inner.get("type")?.as_str()? != "message.part.updated" { + return None; + } + + let part = inner.get("properties")?.get("part")?; + let session_id = part + .get("sessionID") + .or_else(|| part.get("sessionId")) + .and_then(Value::as_str)?; + let message_id = part + .get("messageID") + .or_else(|| part.get("messageId")) + .and_then(Value::as_str)?; + let part_id = part.get("id")?.as_str()?; + + Some(format!( + "message.part:{}:{}:{}:{}", + instance_id, session_id, message_id, part_id + )) +} + +fn append_delta(target: &mut Value, event: &Value) { + let next_delta = coalesced_payload_event(event) + .get("properties") + .and_then(|value| value.get("delta")) + .and_then(Value::as_str) + .unwrap_or_default(); + + if let Some(existing_delta) = coalesced_payload_event_mut(target) + .and_then(|event| event.get_mut("properties")) + .and_then(Value::as_object_mut) + .and_then(|props| props.get_mut("delta")) + { + let combined = existing_delta.as_str().unwrap_or_default().to_string() + next_delta; + *existing_delta = Value::String(combined); + } +} + +fn coalesced_payload_event_mut(event: &mut Value) -> Option<&mut serde_json::Map> { + if event.get("type").and_then(Value::as_str) == Some("instance.event") { + event.get_mut("event").and_then(Value::as_object_mut) + } else { + event.as_object_mut() + } +} + +fn status_key(event: &Value) -> Option { + match event.get("type")?.as_str()? { + "instance.eventStatus" => Some(coalesced_instance_id(event).to_string()), + "session.status" => snapshot_key(event), + _ => None, + } +} + +#[cfg(test)] +mod tests; diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs new file mode 100644 index 000000000..82299452e --- /dev/null +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs @@ -0,0 +1,501 @@ +use super::*; + +impl PendingBatch { + pub(super) fn push(&mut self, event: Value, stats: &mut DesktopEventTransportStats) { + match classify_event(&event) { + EventDeliveryPolicy::CoalesceDelta(key) => { + let Some(scope) = delta_scope(&event) else { + self.events.push(PendingEntry::Event(event)); + return; + }; + + if let Some(PendingEntry::Delta { + key: existing_key, + event: existing_event, + .. + }) = self.events.last_mut() + { + if existing_key == &key { + append_delta(existing_event, &event); + stats.delta_coalesces = stats.delta_coalesces.saturating_add(1); + return; + } + } + + self.events.push(PendingEntry::Delta { + key, + scope, + instance_id: coalesced_instance_id(&event).to_string(), + session_id: event_session_id(&event).map(|value| value.to_string()), + event, + started_at: Instant::now(), + }); + } + EventDeliveryPolicy::CoalesceStatus(key) => { + if let Some(PendingEntry::Status { + key: existing_key, + event: existing_event, + }) = self.events.last_mut() + { + if existing_key == &key { + *existing_event = event; + stats.status_coalesces = stats.status_coalesces.saturating_add(1); + return; + } + } + + self.events.push(PendingEntry::Status { key, event }); + } + EventDeliveryPolicy::CoalesceSnapshot(key) => { + if let Some(part_scope) = snapshot_superseded_delta_scope(&event) { + let mut dropped = 0_u64; + while matches!( + self.events.last(), + Some(PendingEntry::Delta { scope, .. }) if scope == &part_scope + ) { + self.events.pop(); + dropped = dropped.saturating_add(1); + } + if dropped > 0 { + stats.superseded_deltas_dropped = + stats.superseded_deltas_dropped.saturating_add(dropped); + } + } + + if let Some(PendingEntry::Snapshot { + key: existing_key, + event: existing_event, + }) = self.events.last_mut() + { + if existing_key == &key { + *existing_event = event; + stats.snapshot_coalesces = stats.snapshot_coalesces.saturating_add(1); + return; + } + } + + self.events.push(PendingEntry::Snapshot { key, event }); + } + EventDeliveryPolicy::Passthrough => { + self.events.push(PendingEntry::Event(event)); + } + } + } + + pub(super) fn take_events(&mut self) -> Vec { + let pending = std::mem::take(&mut self.events); + pending + .into_iter() + .map(|entry| match entry { + PendingEntry::Delta { event, .. } => event, + PendingEntry::Status { event, .. } => event, + PendingEntry::Snapshot { event, .. } => event, + PendingEntry::Event(event) => event, + }) + .collect() + } + + pub(super) fn is_empty(&self) -> bool { + self.events.is_empty() + } + + pub(super) fn pending_len(&self) -> usize { + self.events.len() + } + + pub(super) fn should_hold_single_delta( + &self, + now: Instant, + active_target: Option<&ActiveSessionTarget>, + ) -> bool { + matches!( + self.events.as_slice(), + [PendingEntry::Delta { started_at, instance_id, session_id, .. }] + if now.duration_since(*started_at) < Duration::from_millis( + if active_target + .map(|target| { + target.instance_id.as_str() == instance_id.as_str() + && target.session_id.as_str() == session_id.as_deref().unwrap_or_default() + }) + .unwrap_or(false) + { + ACTIVE_STREAM_HOLD_WINDOW_MS + } else { + DELTA_STREAM_WINDOW_MS + } + ) + ) + } +} + +impl ActiveTextAssembler { + pub(super) fn absorb(&mut self, delta: ActiveTextDelta, now: Instant) -> Vec { + let key = format!( + "{}:{}:{}:{}", + delta.instance_id, delta.session_id, delta.message_id, delta.part_id + ); + + match self.parts.entry(key) { + std::collections::hash_map::Entry::Occupied(mut occupied) => { + let entry = occupied.get_mut(); + if entry.display_pending.is_empty() && entry.store_pending.is_empty() { + entry.instance_id = delta.instance_id.clone(); + entry.session_id = delta.session_id.clone(); + entry.message_id = delta.message_id.clone(); + entry.part_id = delta.part_id.clone(); + } + + entry.display_pending.push_str(&delta.delta); + entry.store_pending.push_str(&delta.delta); + Self::collect_due_for_part(entry, now) + } + std::collections::hash_map::Entry::Vacant(vacant) => { + let mut entry = ActiveTextPartBuffer::new(delta, now); + entry.last_display_emit = now + .checked_sub(Duration::from_millis(ACTIVE_STREAM_DISPLAY_WINDOW_MS)) + .unwrap_or(now); + let emitted = Self::collect_due_for_part(&mut entry, now); + vacant.insert(entry); + emitted + } + } + } + + pub(super) fn take_due(&mut self, now: Instant) -> Vec { + let mut emitted = Vec::new(); + let mut empty_keys = Vec::new(); + + for (key, entry) in self.parts.iter_mut() { + emitted.extend(Self::collect_due_for_part(entry, now)); + if entry.display_pending.is_empty() && entry.store_pending.is_empty() { + empty_keys.push(key.clone()); + } + } + + for key in empty_keys { + self.parts.remove(&key); + } + + emitted + } + + pub(super) fn flush_for_event(&mut self, event: &Value, now: Instant) -> Vec { + let instance_id = coalesced_instance_id(event); + let payload = coalesced_payload_event(event); + let event_type = payload.get("type").and_then(Value::as_str); + + match event_type { + Some("message.updated") | Some("message.removed") => { + let props = payload.get("properties"); + let session_id = event_session_id(event); + let message_id = props + .and_then(|value| { + value + .get("info") + .and_then(|info| info.get("id")) + .or_else(|| value.get("messageID")) + .or_else(|| value.get("messageId")) + }) + .and_then(Value::as_str); + if let (Some(session_id), Some(message_id)) = (session_id, message_id) { + return self.flush_message(instance_id, session_id, message_id, now); + } + } + Some("message.part.updated") | Some("message.part.removed") => { + let props = payload.get("properties"); + let session_id = event_session_id(event); + let message_id = props + .and_then(|value| { + value + .get("part") + .and_then(|part| { + part.get("messageID").or_else(|| part.get("messageId")) + }) + .or_else(|| value.get("messageID")) + .or_else(|| value.get("messageId")) + }) + .and_then(Value::as_str); + let part_id = props + .and_then(|value| { + value + .get("part") + .and_then(|part| part.get("id")) + .or_else(|| value.get("partID")) + .or_else(|| value.get("partId")) + }) + .and_then(Value::as_str); + if let (Some(session_id), Some(message_id), Some(part_id)) = + (session_id, message_id, part_id) + { + return self.flush_part(instance_id, session_id, message_id, part_id, now); + } + } + _ => {} + } + + Vec::new() + } + + pub(super) fn flush_message( + &mut self, + instance_id: &str, + session_id: &str, + message_id: &str, + now: Instant, + ) -> Vec { + let keys: Vec = self + .parts + .iter() + .filter(|(_, entry)| { + entry.instance_id == instance_id + && entry.session_id == session_id + && entry.message_id == message_id + }) + .map(|(key, _)| key.clone()) + .collect(); + + let mut emitted = Vec::new(); + for key in keys { + if let Some(mut entry) = self.parts.remove(&key) { + emitted.extend(Self::flush_all_for_part(&mut entry, now)); + } + } + emitted + } + + pub(super) fn flush_part( + &mut self, + instance_id: &str, + session_id: &str, + message_id: &str, + part_id: &str, + now: Instant, + ) -> Vec { + let key = format!("{}:{}:{}:{}", instance_id, session_id, message_id, part_id); + if let Some(mut entry) = self.parts.remove(&key) { + return Self::flush_all_for_part(&mut entry, now); + } + Vec::new() + } + + pub(super) fn flush_store_only_all(&mut self, now: Instant) -> Vec { + let mut emitted = Vec::new(); + for entry in self.parts.values_mut() { + if !entry.store_pending.is_empty() { + emitted.push(make_message_part_delta_event(entry, &entry.store_pending)); + entry.store_pending.clear(); + entry.last_store_emit = now; + } + entry.display_pending.clear(); + entry.last_display_emit = now; + } + self.parts.clear(); + emitted + } + + fn collect_due_for_part(entry: &mut ActiveTextPartBuffer, now: Instant) -> Vec { + let mut emitted = Vec::new(); + + // Display lane — emit preview chunks frequently (~16ms / 96 chars). + if !entry.display_pending.is_empty() + && (now.duration_since(entry.last_display_emit) + >= Duration::from_millis(ACTIVE_STREAM_DISPLAY_WINDOW_MS) + || entry.display_pending.len() >= ACTIVE_STREAM_DISPLAY_CHUNK_MAX) + { + emitted.push(make_assistant_stream_chunk_event( + entry, + &entry.display_pending, + )); + entry.display_pending.clear(); + entry.last_display_emit = now; + } + + // Store lane — emit canonical deltas infrequently (~250ms) to avoid + // flooding the JS reactive graph with store mutations that + // trigger expensive re-render cascades during active streaming. + // Explicit flush triggers (message.updated, message.part.updated, + // session change, disconnect) still flush immediately via + // flush_for_event / flush_all_for_part / flush_store_only_all. + if !entry.store_pending.is_empty() + && now.duration_since(entry.last_store_emit) + >= Duration::from_millis(ACTIVE_STREAM_STORE_WINDOW_MS) + { + emitted.push(make_message_part_delta_event(entry, &entry.store_pending)); + entry.store_pending.clear(); + entry.last_store_emit = now; + } + + emitted + } + + fn flush_all_for_part(entry: &mut ActiveTextPartBuffer, now: Instant) -> Vec { + let mut emitted = Vec::new(); + if !entry.display_pending.is_empty() { + emitted.push(make_assistant_stream_chunk_event( + entry, + &entry.display_pending, + )); + entry.display_pending.clear(); + entry.last_display_emit = now; + } + if !entry.store_pending.is_empty() { + emitted.push(make_message_part_delta_event(entry, &entry.store_pending)); + entry.store_pending.clear(); + entry.last_store_emit = now; + } + emitted + } +} + +impl ActiveTextSnapshotBuffer { + pub(super) fn buffer(&mut self, snapshot: ActiveTextSnapshot, now: Instant) { + match self.parts.entry(snapshot.key) { + std::collections::hash_map::Entry::Occupied(mut occupied) => { + let entry = occupied.get_mut(); + entry.instance_id = snapshot.instance_id; + entry.session_id = snapshot.session_id; + entry.message_id = snapshot.message_id; + entry.part_id = snapshot.part_id; + entry.event = snapshot.event; + } + std::collections::hash_map::Entry::Vacant(vacant) => { + vacant.insert(BufferedTextSnapshot { + instance_id: snapshot.instance_id, + session_id: snapshot.session_id, + message_id: snapshot.message_id, + part_id: snapshot.part_id, + event: snapshot.event, + buffered_at: now, + }); + } + } + } + + pub(super) fn take_due(&mut self, now: Instant) -> Vec { + let keys: Vec = self + .parts + .iter() + .filter(|(_, entry)| { + now.duration_since(entry.buffered_at) + >= Duration::from_millis(ACTIVE_STREAM_SNAPSHOT_WINDOW_MS) + }) + .map(|(key, _)| key.clone()) + .collect(); + + self.take_entries(keys) + } + + pub(super) fn flush_for_event(&mut self, event: &Value) -> Vec { + let instance_id = coalesced_instance_id(event); + let payload = coalesced_payload_event(event); + let event_type = payload.get("type").and_then(Value::as_str); + + match event_type { + Some("message.updated") | Some("message.removed") => { + let props = payload.get("properties"); + let session_id = event_session_id(event); + let message_id = props + .and_then(|value| { + value + .get("info") + .and_then(|info| info.get("id")) + .or_else(|| value.get("messageID")) + .or_else(|| value.get("messageId")) + }) + .and_then(Value::as_str); + if let (Some(session_id), Some(message_id)) = (session_id, message_id) { + return self.flush_message(instance_id, session_id, message_id); + } + } + Some("message.part.removed") => { + let props = payload.get("properties"); + let session_id = event_session_id(event); + let message_id = props + .and_then(|value| { + value + .get("part") + .and_then(|part| { + part.get("messageID").or_else(|| part.get("messageId")) + }) + .or_else(|| value.get("messageID")) + .or_else(|| value.get("messageId")) + }) + .and_then(Value::as_str); + let part_id = props + .and_then(|value| { + value + .get("part") + .and_then(|part| part.get("id")) + .or_else(|| value.get("partID")) + .or_else(|| value.get("partId")) + }) + .and_then(Value::as_str); + if let (Some(session_id), Some(message_id), Some(part_id)) = + (session_id, message_id, part_id) + { + return self.flush_part(instance_id, session_id, message_id, part_id); + } + } + _ => {} + } + + Vec::new() + } + + pub(super) fn flush_message( + &mut self, + instance_id: &str, + session_id: &str, + message_id: &str, + ) -> Vec { + let keys: Vec = self + .parts + .iter() + .filter(|(_, entry)| { + entry.instance_id == instance_id + && entry.session_id == session_id + && entry.message_id == message_id + }) + .map(|(key, _)| key.clone()) + .collect(); + + self.take_entries(keys) + } + + pub(super) fn flush_part( + &mut self, + instance_id: &str, + session_id: &str, + message_id: &str, + part_id: &str, + ) -> Vec { + let keys: Vec = self + .parts + .iter() + .filter(|(_, entry)| { + entry.instance_id == instance_id + && entry.session_id == session_id + && entry.message_id == message_id + && entry.part_id == part_id + }) + .map(|(key, _)| key.clone()) + .collect(); + + self.take_entries(keys) + } + + pub(super) fn flush_all(&mut self) -> Vec { + let keys: Vec = self.parts.keys().cloned().collect(); + self.take_entries(keys) + } + + fn take_entries(&mut self, keys: Vec) -> Vec { + let mut emitted = Vec::new(); + for key in keys { + if let Some(entry) = self.parts.remove(&key) { + emitted.push(entry.event); + } + } + emitted + } +} diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs new file mode 100644 index 000000000..e861a1b31 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs @@ -0,0 +1,185 @@ +use super::*; + +pub(super) fn build_stream_client() -> Result { + Client::builder() + .connect_timeout(Duration::from_millis(STREAM_CONNECT_TIMEOUT_MS)) + .tcp_keepalive(Duration::from_millis(STREAM_TCP_KEEPALIVE_MS)) + // Note: reqwest's blocking client doesn't expose a per-read timeout. + // The global `.timeout()` would kill the entire SSE stream, so we + // rely on: + // 1. tcp_keepalive to detect dead connections (OS will RST after + // several unacked probes, typically ~2 min). + // 2. Consumer-side stall detection (STREAM_STALL_TIMEOUT_MS). + // 3. Reader thread breaking on channel send error (consumer dropped). + .build() + .map_err(|error: reqwest::Error| OpenStreamError { + kind: OpenStreamErrorKind::Transport, + message: error.to_string(), + status_code: None, + }) +} + +pub(super) fn open_stream( + app: &AppHandle, + client: &Client, + config: &DesktopEventStreamConfig, +) -> Result { + let connection_id = generate_connection_id(); + let url = format!( + "{}?clientId={}&connectionId={}", + config.events_url, config.client_id, connection_id + ); + + let mut request = client.get(&url).header("Accept", "text/event-stream"); + + if let Some(session_cookie) = resolve_session_cookie(app, config) { + request = request.header( + "Cookie", + format!("{}={}", config.cookie_name, session_cookie), + ); + } + + let response = request.send().map_err(|error| OpenStreamError { + kind: OpenStreamErrorKind::Transport, + message: error.to_string(), + status_code: None, + })?; + + if response.status().is_success() { + return Ok(response); + } + + let status = response.status(); + let kind = if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) { + OpenStreamErrorKind::Unauthorized + } else { + OpenStreamErrorKind::Http + }; + + Err(OpenStreamError { + kind, + message: format!("desktop event stream unavailable ({status})"), + status_code: Some(status.as_u16()), + }) +} + +fn resolve_session_cookie(app: &AppHandle, config: &DesktopEventStreamConfig) -> Option { + read_session_cookie_from_webview(app, &config.base_url, &config.cookie_name) + .or_else(|| config.session_cookie.clone()) + .filter(|value| !value.is_empty()) +} + +fn generate_connection_id() -> String { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let tid = std::thread::current().id(); + format!("tauri-{}-{:?}", ts, tid) +} + +fn read_session_cookie_from_webview( + app: &AppHandle, + base_url: &str, + cookie_name: &str, +) -> Option { + let url = Url::parse(base_url).ok()?; + let host = url.host_str()?.to_ascii_lowercase(); + let path = url.path(); + let windows = app.webview_windows(); + let window = windows.get("main")?; + let cookies = window.cookies().ok()?; + cookies + .into_iter() + .filter(|cookie: &tauri::webview::cookie::Cookie<'static>| cookie.name() == cookie_name) + .filter(|cookie: &tauri::webview::cookie::Cookie<'static>| { + let Some(domain) = cookie.domain() else { + return true; + }; + + let normalized_domain = domain.trim_start_matches('.').to_ascii_lowercase(); + host == normalized_domain || host.ends_with(&format!(".{}", normalized_domain)) + }) + .filter(|cookie: &tauri::webview::cookie::Cookie<'static>| { + let Some(cookie_path) = cookie.path() else { + return true; + }; + + path.starts_with(cookie_path) + }) + .map(|cookie: tauri::webview::cookie::Cookie<'static>| cookie.value().to_string()) + .next() +} + +pub(super) fn read_sse( + response: Response, + tx: SyncSender, + stop: Arc, + generation_atomic: Arc, + generation: u64, +) { + let mut reader = BufReader::new(response); + let mut line = String::new(); + let mut data_lines: Vec = Vec::new(); + + loop { + if stop.load(Ordering::SeqCst) || !generation_matches(&generation_atomic, generation) { + let _ = tx.send(ReaderMessage::End(Some("stopped".to_string()))); + return; + } + + line.clear(); + match reader.read_line(&mut line) { + Ok(0) => { + if let Some(event) = parse_sse_payload(&data_lines) { + let _ = tx.send(ReaderMessage::Event(event)); + } + let _ = tx.send(ReaderMessage::End(Some("stream closed".to_string()))); + return; + } + Ok(_) => { + if tx.send(ReaderMessage::Activity).is_err() { + return; // consumer dropped — stop reading + } + let trimmed = line.trim_end_matches(['\r', '\n']); + if trimmed.is_empty() { + if let Some(event) = parse_sse_payload(&data_lines) { + if tx.send(ReaderMessage::Event(event)).is_err() { + return; // consumer dropped + } + } + data_lines.clear(); + continue; + } + + if trimmed.starts_with(':') { + continue; + } + + if let Some(data) = trimmed.strip_prefix("data:") { + data_lines.push(data.strip_prefix(' ').unwrap_or(data).to_string()); + } + } + Err(error) => { + if let Some(event) = parse_sse_payload(&data_lines) { + let _ = tx.send(ReaderMessage::Event(event)); + } + let _ = tx.send(ReaderMessage::End(Some(error.to_string()))); + return; + } + } + } +} + +fn parse_sse_payload(lines: &[String]) -> Option { + if lines.is_empty() { + return None; + } + + let payload = lines.join("\n").trim().to_string(); + if payload.is_empty() { + return None; + } + + serde_json::from_str::(&payload).ok() +} diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs new file mode 100644 index 000000000..e464ef8c9 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs @@ -0,0 +1,575 @@ +use super::*; +use serde_json::json; + +fn fresh_stats() -> DesktopEventTransportStats { + DesktopEventTransportStats::default() +} + +fn delta_event(delta: &str) -> Value { + json!({ + "type": "instance.event", + "instanceId": "inst-1", + "event": { + "type": "message.part.delta", + "properties": { + "sessionID": "sess-1", + "messageID": "msg-1", + "partID": "part-1", + "field": "text", + "delta": delta, + } + } + }) +} + +fn delta_event_for(part_id: &str, delta: &str) -> Value { + json!({ + "type": "instance.event", + "instanceId": "inst-1", + "event": { + "type": "message.part.delta", + "properties": { + "sessionID": "sess-1", + "messageID": "msg-1", + "partID": part_id, + "field": "text", + "delta": delta, + } + } + }) +} + +fn direct_delta_event(delta: &str) -> Value { + json!({ + "type": "message.part.delta", + "properties": { + "sessionID": "sess-1", + "messageID": "msg-1", + "partID": "part-1", + "field": "text", + "delta": delta, + } + }) +} + +fn direct_message_part_updated_event(text: &str) -> Value { + json!({ + "type": "message.part.updated", + "properties": { + "part": { + "id": "part-1", + "type": "text", + "text": text, + "sessionID": "sess-1", + "messageID": "msg-1" + } + } + }) +} + +fn message_part_updated_event(text: &str) -> Value { + json!({ + "type": "instance.event", + "instanceId": "inst-1", + "event": { + "type": "message.part.updated", + "properties": { + "part": { + "id": "part-1", + "type": "text", + "text": text, + "sessionID": "sess-1", + "messageID": "msg-1" + } + } + } + }) +} + +fn active_target() -> ActiveSessionTarget { + ActiveSessionTarget { + instance_id: "inst-1".to_string(), + session_id: "sess-1".to_string(), + } +} + +#[test] +fn coalesces_message_part_delta_events() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(delta_event("Hello"), &mut stats); + pending.push(delta_event(" world"), &mut stats); + + let events = pending.take_events(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0]["event"]["properties"]["delta"].as_str(), + Some("Hello world") + ); +} + +#[test] +fn last_write_wins_for_status_events() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push( + json!({ + "type": "instance.eventStatus", + "instanceId": "inst-1", + "status": "connecting" + }), + &mut stats, + ); + pending.push( + json!({ + "type": "instance.eventStatus", + "instanceId": "inst-1", + "status": "connected" + }), + &mut stats, + ); + + let events = pending.take_events(); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["status"].as_str(), Some("connected")); +} + +#[test] +fn last_write_wins_for_consecutive_snapshot_events() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(message_part_updated_event("Hello"), &mut stats); + pending.push(message_part_updated_event("Hello world"), &mut stats); + + let events = pending.take_events(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0]["event"]["properties"]["part"]["text"].as_str(), + Some("Hello world") + ); +} + +#[test] +fn interleaved_snapshot_keys_keep_order() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(message_part_updated_event("A1"), &mut stats); + pending.push( + json!({ + "type": "instance.event", + "instanceId": "inst-1", + "event": { + "type": "message.part.updated", + "properties": { + "part": { + "id": "part-2", + "type": "text", + "text": "B1", + "sessionID": "sess-1", + "messageID": "msg-1" + } + } + } + }), + &mut stats, + ); + pending.push(message_part_updated_event("A2"), &mut stats); + + let events = pending.take_events(); + assert_eq!(events.len(), 3); + assert_eq!( + events[0]["event"]["properties"]["part"]["id"].as_str(), + Some("part-1") + ); + assert_eq!( + events[1]["event"]["properties"]["part"]["id"].as_str(), + Some("part-2") + ); + assert_eq!( + events[2]["event"]["properties"]["part"]["text"].as_str(), + Some("A2") + ); +} + +#[test] +fn snapshot_replaces_trailing_deltas_for_same_part() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(delta_event("Hello"), &mut stats); + pending.push(message_part_updated_event("Hello world"), &mut stats); + + let events = pending.take_events(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0]["event"]["type"].as_str(), + Some("message.part.updated") + ); + assert_eq!( + events[0]["event"]["properties"]["part"]["text"].as_str(), + Some("Hello world") + ); +} + +#[test] +fn structural_events_force_coalesced_flush_before_append() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(delta_event("Hello"), &mut stats); + pending.push( + json!({ + "type": "instance.event", + "instanceId": "inst-1", + "event": { + "type": "message.updated", + "properties": { + "id": "msg-1" + } + } + }), + &mut stats, + ); + + let events = pending.take_events(); + assert_eq!(events.len(), 2); + assert_eq!( + events[0]["event"]["type"].as_str(), + Some("message.part.delta") + ); + assert_eq!(events[1]["event"]["type"].as_str(), Some("message.updated")); +} + +#[test] +fn interleaved_delta_keys_keep_order() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(delta_event_for("part-1", "A1"), &mut stats); + pending.push(delta_event_for("part-2", "B1"), &mut stats); + pending.push(delta_event_for("part-1", "A2"), &mut stats); + + let events = pending.take_events(); + assert_eq!(events.len(), 3); + assert_eq!( + events[0]["event"]["properties"]["partID"].as_str(), + Some("part-1") + ); + assert_eq!( + events[0]["event"]["properties"]["delta"].as_str(), + Some("A1") + ); + assert_eq!( + events[1]["event"]["properties"]["partID"].as_str(), + Some("part-2") + ); + assert_eq!( + events[1]["event"]["properties"]["delta"].as_str(), + Some("B1") + ); + assert_eq!( + events[2]["event"]["properties"]["partID"].as_str(), + Some("part-1") + ); + assert_eq!( + events[2]["event"]["properties"]["delta"].as_str(), + Some("A2") + ); +} + +#[test] +fn reconnect_delay_grows_and_caps() { + let policy = ResolvedDesktopEventReconnectPolicy { + initial_delay_ms: 100, + max_delay_ms: 500, + multiplier: 2.0, + max_attempts: None, + }; + + assert_eq!(compute_reconnect_delay_ms(1, &policy), 100); + assert_eq!(compute_reconnect_delay_ms(2, &policy), 200); + assert_eq!(compute_reconnect_delay_ms(3, &policy), 400); + assert_eq!(compute_reconnect_delay_ms(4, &policy), 500); +} + +#[test] +fn holds_single_delta_within_stream_window() { + let pending = PendingBatch { + events: vec![PendingEntry::Delta { + key: "delta-key".to_string(), + scope: "delta-scope".to_string(), + instance_id: "inst-1".to_string(), + session_id: Some("sess-1".to_string()), + event: delta_event("Hello"), + started_at: Instant::now(), + }], + }; + + assert!(pending.should_hold_single_delta(Instant::now(), None)); +} + +#[test] +fn flushes_single_delta_after_stream_window() { + let started_at = Instant::now() - Duration::from_millis(DELTA_STREAM_WINDOW_MS + 1); + let pending = PendingBatch { + events: vec![PendingEntry::Delta { + key: "delta-key".to_string(), + scope: "delta-scope".to_string(), + instance_id: "inst-1".to_string(), + session_id: Some("sess-1".to_string()), + event: delta_event("Hello"), + started_at, + }], + }; + + assert!(!pending.should_hold_single_delta(Instant::now(), None)); +} + +#[test] +fn active_session_uses_shorter_hold_window() { + // Delta aged past the active-stream window but within the base window. + // Active session should flush faster, so this should NOT be held. + let started_at = Instant::now() - Duration::from_millis(ACTIVE_STREAM_HOLD_WINDOW_MS + 10); + let pending = PendingBatch { + events: vec![PendingEntry::Delta { + key: "delta-key".to_string(), + scope: "delta-scope".to_string(), + instance_id: "inst-1".to_string(), + session_id: Some("sess-1".to_string()), + event: delta_event("Hello"), + started_at, + }], + }; + + let active_target = active_target(); + let other_target = ActiveSessionTarget { + instance_id: "inst-1".to_string(), + session_id: "sess-2".to_string(), + }; + + // Active session: uses ACTIVE_STREAM_HOLD_WINDOW_MS, so this stale delta is not held. + assert!(!pending.should_hold_single_delta(Instant::now(), Some(&active_target))); + // Non-matching session: still uses the wider base window. + assert!(pending.should_hold_single_delta(Instant::now(), Some(&other_target))); +} + +#[test] +fn active_session_holds_fresh_delta() { + // A very fresh delta should be held even for the active session's shorter window. + let started_at = Instant::now() - Duration::from_millis(5); + let pending = PendingBatch { + events: vec![PendingEntry::Delta { + key: "delta-key".to_string(), + scope: "delta-scope".to_string(), + instance_id: "inst-1".to_string(), + session_id: Some("sess-1".to_string()), + event: delta_event("Hello"), + started_at, + }], + }; + + let active_target = active_target(); + + assert!(pending.should_hold_single_delta(Instant::now(), Some(&active_target))); +} + +#[test] +fn assembler_emits_first_preview_chunk_immediately() { + let mut assembler = ActiveTextAssembler::default(); + let now = Instant::now(); + + let emitted = assembler.absorb( + ActiveTextDelta { + instance_id: "inst-1".to_string(), + session_id: "sess-1".to_string(), + message_id: "msg-1".to_string(), + part_id: "part-1".to_string(), + delta: "Hello".to_string(), + }, + now, + ); + + assert_eq!(emitted.len(), 1); + assert_eq!( + coalesced_payload_event(&emitted[0]) + .get("type") + .and_then(Value::as_str), + Some("assistant.stream.chunk") + ); + assert_eq!( + coalesced_payload_event(&emitted[0]) + .get("properties") + .and_then(|props| props.get("delta")) + .and_then(Value::as_str), + Some("Hello") + ); +} + +#[test] +fn snapshot_buffer_coalesces_updates_within_window() { + let mut buffer = ActiveTextSnapshotBuffer::default(); + let now = Instant::now(); + + buffer.buffer( + parse_active_text_snapshot(&message_part_updated_event("A"), Some(&active_target())) + .unwrap(), + now, + ); + buffer.buffer( + parse_active_text_snapshot(&message_part_updated_event("AB"), Some(&active_target())) + .unwrap(), + now + Duration::from_millis(40), + ); + buffer.buffer( + parse_active_text_snapshot(&message_part_updated_event("ABC"), Some(&active_target())) + .unwrap(), + now + Duration::from_millis(80), + ); + + let early = buffer.take_due(now + Duration::from_millis(ACTIVE_STREAM_SNAPSHOT_WINDOW_MS - 1)); + assert!(early.is_empty()); + + let emitted = + buffer.take_due(now + Duration::from_millis(ACTIVE_STREAM_SNAPSHOT_WINDOW_MS + 1)); + assert_eq!(emitted.len(), 1); + assert_eq!( + emitted[0]["event"]["properties"]["part"]["text"].as_str(), + Some("ABC") + ); +} + +#[test] +fn snapshot_buffer_flushes_latest_snapshot_before_message_update() { + let mut buffer = ActiveTextSnapshotBuffer::default(); + let now = Instant::now(); + + buffer.buffer( + parse_active_text_snapshot(&message_part_updated_event("Hello"), Some(&active_target())) + .unwrap(), + now, + ); + buffer.buffer( + parse_active_text_snapshot( + &message_part_updated_event("Hello world"), + Some(&active_target()), + ) + .unwrap(), + now + Duration::from_millis(25), + ); + + let flushed = buffer.flush_for_event(&json!({ + "type": "instance.event", + "instanceId": "inst-1", + "event": { + "type": "message.updated", + "properties": { + "info": { + "id": "msg-1", + "sessionID": "sess-1" + } + } + } + })); + + assert_eq!(flushed.len(), 1); + assert_eq!( + flushed[0]["event"]["properties"]["part"]["text"].as_str(), + Some("Hello world") + ); +} + +#[test] +fn assembler_keeps_first_delta_after_full_flush() { + let mut assembler = ActiveTextAssembler::default(); + let now = Instant::now(); + let delta = ActiveTextDelta { + instance_id: "inst-1".to_string(), + session_id: "sess-1".to_string(), + message_id: "msg-1".to_string(), + part_id: "part-1".to_string(), + delta: "Hello".to_string(), + }; + + let _ = assembler.absorb(delta.clone(), now); + let _ = assembler.flush_message("inst-1", "sess-1", "msg-1", now); + let _ = assembler.absorb( + ActiveTextDelta { + delta: " world".to_string(), + ..delta + }, + now, + ); + let emitted = assembler.flush_store_only_all(now + Duration::from_millis(1)); + + assert!(emitted.iter().any(|event| { + coalesced_payload_event(event) + .get("type") + .and_then(Value::as_str) + == Some("message.part.delta") + && coalesced_payload_event(event) + .get("properties") + .and_then(|props| props.get("delta")) + .and_then(Value::as_str) + == Some(" world") + })); +} + +#[test] +fn flush_store_only_all_preserves_canonical_text_without_preview() { + let mut assembler = ActiveTextAssembler::default(); + let now = Instant::now(); + let _ = assembler.absorb( + ActiveTextDelta { + instance_id: "inst-1".to_string(), + session_id: "sess-1".to_string(), + message_id: "msg-1".to_string(), + part_id: "part-1".to_string(), + delta: "Hello".to_string(), + }, + now, + ); + + let emitted = assembler.flush_store_only_all(now + Duration::from_millis(1)); + assert_eq!(emitted.len(), 1); + assert_eq!( + coalesced_payload_event(&emitted[0]) + .get("type") + .and_then(Value::as_str), + Some("message.part.delta") + ); + assert_eq!( + coalesced_payload_event(&emitted[0]) + .get("properties") + .and_then(|props| props.get("delta")) + .and_then(Value::as_str), + Some("Hello") + ); +} + +#[test] +fn coalesces_direct_message_part_delta_events() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(direct_delta_event("Hello"), &mut stats); + pending.push(direct_delta_event(" world"), &mut stats); + + let events = pending.take_events(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0]["properties"]["delta"].as_str(), + Some("Hello world") + ); +} + +#[test] +fn direct_snapshot_replaces_trailing_direct_deltas_for_same_part() { + let mut pending = PendingBatch::default(); + let mut stats = fresh_stats(); + pending.push(direct_delta_event("Hello"), &mut stats); + pending.push(direct_message_part_updated_event("Hello world"), &mut stats); + + let events = pending.take_events(); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["type"].as_str(), Some("message.part.updated")); + assert_eq!( + events[0]["properties"]["part"]["text"].as_str(), + Some("Hello world") + ); +} diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs new file mode 100644 index 000000000..0c7da3a29 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs @@ -0,0 +1,569 @@ +use super::*; + +pub(super) fn run_transport_loop( + app: AppHandle, + state: Arc>, + generation_atomic: Arc, + generation: u64, + stop: Arc, + config: DesktopEventTransportConfig, +) { + let mut reconnect_attempt = 0_u32; + let mut stats = DesktopEventTransportStats::default(); + + let client = match build_stream_client() { + Ok(client) => client, + Err(error) => { + emit_status( + &app, + generation, + "error", + 0, + true, + Some(error.message), + None, + None, + &stats, + ); + return; + } + }; + + loop { + if stop.load(Ordering::SeqCst) || !generation_matches(&generation_atomic, generation) { + break; + } + + emit_status( + &app, + generation, + "connecting", + reconnect_attempt, + false, + None, + None, + None, + &stats, + ); + + match open_stream(&app, &client, &config.stream) { + Ok(response) => { + reconnect_attempt = 0; + emit_status( + &app, + generation, + "connected", + reconnect_attempt, + false, + None, + None, + None, + &stats, + ); + + let disconnect_reason = consume_stream( + &app, + response, + &state, + &generation_atomic, + generation, + stop.clone(), + &mut stats, + ); + if stop.load(Ordering::SeqCst) + || !generation_matches(&generation_atomic, generation) + { + break; + } + + if !schedule_retry( + &app, + &generation_atomic, + generation, + stop.clone(), + &config.reconnect, + &mut reconnect_attempt, + "disconnected", + disconnect_reason, + None, + &stats, + ) { + break; + } + } + Err(error) => { + let state_name = match error.kind { + OpenStreamErrorKind::Unauthorized => "unauthorized", + OpenStreamErrorKind::Http | OpenStreamErrorKind::Transport => "error", + }; + + if !schedule_retry( + &app, + &generation_atomic, + generation, + stop.clone(), + &config.reconnect, + &mut reconnect_attempt, + state_name, + Some(error.message), + error.status_code, + &stats, + ) { + break; + } + } + } + } + + emit_status( + &app, + generation, + "stopped", + reconnect_attempt, + true, + None, + None, + None, + &stats, + ); +} + +fn schedule_retry( + app: &AppHandle, + generation_atomic: &Arc, + generation: u64, + stop: Arc, + policy: &ResolvedDesktopEventReconnectPolicy, + reconnect_attempt: &mut u32, + state_name: &'static str, + reason: Option, + status_code: Option, + stats: &DesktopEventTransportStats, +) -> bool { + *reconnect_attempt = reconnect_attempt.saturating_add(1); + let terminal = policy + .max_attempts + .map(|max_attempts| *reconnect_attempt >= max_attempts) + .unwrap_or(false); + let next_delay_ms = if terminal { + None + } else { + Some(compute_reconnect_delay_ms(*reconnect_attempt, policy)) + }; + + emit_status( + app, + generation, + state_name, + *reconnect_attempt, + terminal, + reason, + next_delay_ms, + status_code, + stats, + ); + + if terminal { + return false; + } + + if let Some(delay_ms) = next_delay_ms { + wait_with_cancellation(generation_atomic, generation, stop, delay_ms); + } + + true +} + +fn wait_with_cancellation( + generation_atomic: &Arc, + generation: u64, + stop: Arc, + delay_ms: u64, +) { + let mut remaining_ms = delay_ms; + while remaining_ms > 0 { + if stop.load(Ordering::SeqCst) || !generation_matches(generation_atomic, generation) { + return; + } + + let chunk_ms = remaining_ms.min(100); + thread::sleep(Duration::from_millis(chunk_ms)); + remaining_ms -= chunk_ms; + } +} + +fn consume_stream( + app: &AppHandle, + response: Response, + state: &Arc>, + generation_atomic: &Arc, + generation: u64, + stop: Arc, + stats: &mut DesktopEventTransportStats, +) -> Option { + let (tx, rx) = mpsc::sync_channel::(4096); + let reader_stop = stop.clone(); + let reader_generation_atomic = generation_atomic.clone(); + thread::spawn(move || { + read_sse( + response, + tx, + reader_stop, + reader_generation_atomic, + generation, + ) + }); + + let mut pending = PendingBatch::default(); + let mut active_text_assembler = ActiveTextAssembler::default(); + let mut active_text_snapshots = ActiveTextSnapshotBuffer::default(); + let mut sequence = 0_u64; + let mut last_active_target: Option = None; + let mut last_reader_activity = Instant::now(); + + loop { + if stop.load(Ordering::SeqCst) || !generation_matches(generation_atomic, generation) { + return Some("stopped".to_string()); + } + + match rx.recv_timeout(Duration::from_millis(FLUSH_INTERVAL_MS)) { + Ok(ReaderMessage::Activity) => { + last_reader_activity = Instant::now(); + } + Ok(ReaderMessage::Event(event)) => { + last_reader_activity = Instant::now(); + stats.raw_events = stats.raw_events.saturating_add(1); + + let now = Instant::now(); + let active_target = state.lock().active_target.clone(); + let max_batch_events = if active_target.is_some() { + ACTIVE_SESSION_MAX_BATCH_EVENTS + } else { + MAX_BATCH_EVENTS + }; + let mut should_flush_active = false; + if active_target != last_active_target { + for flushed in active_text_assembler.flush_store_only_all(now) { + pending.push(flushed, stats); + } + for flushed in active_text_snapshots.flush_all() { + pending.push(flushed, stats); + } + last_active_target = active_target.clone(); + } + + let due = active_text_assembler.take_due(now); + if !due.is_empty() { + should_flush_active = true; + } + for flushed in due { + pending.push(flushed, stats); + } + + let snapshot_due = active_text_snapshots.take_due(now); + if !snapshot_due.is_empty() { + should_flush_active = true; + } + for flushed in snapshot_due { + pending.push(flushed, stats); + } + + let flushes = active_text_assembler.flush_for_event(&event, now); + if !flushes.is_empty() { + should_flush_active = true; + } + for flushed in flushes { + pending.push(flushed, stats); + } + + let snapshot_flushes = active_text_snapshots.flush_for_event(&event); + if !snapshot_flushes.is_empty() { + should_flush_active = true; + } + for flushed in snapshot_flushes { + pending.push(flushed, stats); + } + + if let Some(snapshot) = parse_active_text_snapshot(&event, active_target.as_ref()) { + active_text_snapshots.buffer(snapshot, now); + + if should_flush_active { + emit_pending_batch( + app, + generation, + &mut pending, + &mut sequence, + generation_atomic, + stats, + ); + } + + if pending.pending_len() >= max_batch_events { + emit_pending_batch( + app, + generation, + &mut pending, + &mut sequence, + generation_atomic, + stats, + ); + } + continue; + } + + if let Some(delta) = parse_active_text_delta(&event, active_target.as_ref()) { + let assembled_events = active_text_assembler.absorb(delta, now); + if !assembled_events.is_empty() { + should_flush_active = true; + } + for assembled in assembled_events { + pending.push(assembled, stats); + } + + if should_flush_active { + emit_pending_batch( + app, + generation, + &mut pending, + &mut sequence, + generation_atomic, + stats, + ); + } + + if pending.pending_len() >= max_batch_events { + emit_pending_batch( + app, + generation, + &mut pending, + &mut sequence, + generation_atomic, + stats, + ); + } + continue; + } + + pending.push(event, stats); + if should_flush_active { + emit_pending_batch( + app, + generation, + &mut pending, + &mut sequence, + generation_atomic, + stats, + ); + } + if pending.pending_len() >= max_batch_events { + emit_pending_batch( + app, + generation, + &mut pending, + &mut sequence, + generation_atomic, + stats, + ); + } + } + Ok(ReaderMessage::End(reason)) => { + for flushed in active_text_assembler.take_due(Instant::now()) { + pending.push(flushed, stats); + } + for flushed in active_text_assembler.flush_store_only_all(Instant::now()) { + pending.push(flushed, stats); + } + for flushed in active_text_snapshots.take_due(Instant::now()) { + pending.push(flushed, stats); + } + for flushed in active_text_snapshots.flush_all() { + pending.push(flushed, stats); + } + if !pending.is_empty() { + emit_pending_batch( + app, + generation, + &mut pending, + &mut sequence, + generation_atomic, + stats, + ); + } + return reason; + } + Err(RecvTimeoutError::Timeout) => { + if last_reader_activity.elapsed() >= Duration::from_millis(STREAM_STALL_TIMEOUT_MS) + { + for flushed in active_text_assembler.take_due(Instant::now()) { + pending.push(flushed, stats); + } + for flushed in active_text_assembler.flush_store_only_all(Instant::now()) { + pending.push(flushed, stats); + } + for flushed in active_text_snapshots.take_due(Instant::now()) { + pending.push(flushed, stats); + } + for flushed in active_text_snapshots.flush_all() { + pending.push(flushed, stats); + } + if !pending.is_empty() { + sequence += 1; + emit_batch( + app, + generation, + &mut pending, + sequence, + generation_atomic, + stats, + ); + } + return Some("stream stalled".to_string()); + } + + for flushed in active_text_assembler.take_due(Instant::now()) { + pending.push(flushed, stats); + } + for flushed in active_text_snapshots.take_due(Instant::now()) { + pending.push(flushed, stats); + } + if !pending.is_empty() { + if pending.should_hold_single_delta( + Instant::now(), + state.lock().active_target.as_ref(), + ) { + continue; + } + emit_pending_batch( + app, + generation, + &mut pending, + &mut sequence, + generation_atomic, + stats, + ); + } + } + Err(RecvTimeoutError::Disconnected) => { + for flushed in active_text_assembler.take_due(Instant::now()) { + pending.push(flushed, stats); + } + for flushed in active_text_assembler.flush_store_only_all(Instant::now()) { + pending.push(flushed, stats); + } + for flushed in active_text_snapshots.take_due(Instant::now()) { + pending.push(flushed, stats); + } + for flushed in active_text_snapshots.flush_all() { + pending.push(flushed, stats); + } + if !pending.is_empty() { + emit_pending_batch( + app, + generation, + &mut pending, + &mut sequence, + generation_atomic, + stats, + ); + } + return Some("reader disconnected".to_string()); + } + } + } +} + +fn emit_pending_batch( + app: &AppHandle, + generation: u64, + pending: &mut PendingBatch, + sequence: &mut u64, + generation_atomic: &Arc, + stats: &mut DesktopEventTransportStats, +) { + if pending.is_empty() { + return; + } + + *sequence += 1; + emit_batch( + app, + generation, + pending, + *sequence, + generation_atomic, + stats, + ); +} + +fn emit_batch( + app: &AppHandle, + generation: u64, + pending: &mut PendingBatch, + sequence: u64, + generation_atomic: &Arc, + stats: &mut DesktopEventTransportStats, +) { + if !generation_matches(generation_atomic, generation) { + return; + } + + let events = pending.take_events(); + if events.is_empty() { + return; + } + + stats.emitted_batches = stats.emitted_batches.saturating_add(1); + stats.emitted_events = stats.emitted_events.saturating_add(events.len() as u64); + + let _ = app.emit( + EVENT_BATCH_NAME, + WorkspaceEventBatchPayload { + generation, + sequence, + emitted_at: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + events, + }, + ); +} + +fn emit_status( + app: &AppHandle, + generation: u64, + state_name: &'static str, + reconnect_attempt: u32, + terminal: bool, + reason: Option, + next_delay_ms: Option, + status_code: Option, + stats: &DesktopEventTransportStats, +) { + let _ = app.emit( + EVENT_STATUS_NAME, + DesktopEventStreamStatusPayload { + generation, + state: state_name, + reconnect_attempt, + terminal, + reason, + next_delay_ms, + status_code, + stats: stats.clone(), + }, + ); +} + +pub(super) fn generation_matches(generation_atomic: &Arc, generation: u64) -> bool { + generation_atomic.load(Ordering::SeqCst) == generation +} + +pub(super) fn compute_reconnect_delay_ms( + attempt: u32, + policy: &ResolvedDesktopEventReconnectPolicy, +) -> u64 { + let exponent = attempt.saturating_sub(1) as i32; + let scaled = (policy.initial_delay_ms as f64) * policy.multiplier.powi(exponent); + (scaled.round().max(policy.initial_delay_ms as f64) as u64).min(policy.max_delay_ms) +} diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 43fccc43a..7ad5d7c2a 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -3,11 +3,13 @@ #[allow(dead_code)] mod cert_manager; mod cli_manager; +mod desktop_event_transport; #[cfg(target_os = "linux")] mod linux_tls; mod managed_node; use cli_manager::{CliProcessManager, CliStatus}; +use desktop_event_transport::{DesktopEventTransportManager, DesktopEventsStartRequest, DesktopEventsStartResult}; use keepawake::KeepAwake; use serde::Deserialize; use serde_json::json; @@ -49,6 +51,7 @@ const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client"; pub struct AppState { pub manager: CliProcessManager, + pub desktop_events: DesktopEventTransportManager, pub wake_lock: Mutex>, pub zoom_level: Mutex, pub remote_origins: Mutex>, @@ -133,6 +136,7 @@ fn cli_get_status(state: tauri::State) -> CliStatus { #[tauri::command] fn cli_restart(app: AppHandle, state: tauri::State) -> Result { let dev_mode = is_dev_mode(); + state.desktop_events.stop(); state.manager.stop().map_err(|e| e.to_string())?; state .manager @@ -141,6 +145,21 @@ fn cli_restart(app: AppHandle, state: tauri::State) -> Result, + request: Option, +) -> DesktopEventsStartResult { + let config = state.manager.desktop_event_stream_config(); + state.desktop_events.start(app, config, request) +} + +#[tauri::command] +fn desktop_events_stop(state: tauri::State) { + state.desktop_events.stop(); +} + #[tauri::command] fn wake_lock_start( state: tauri::State, @@ -563,6 +582,7 @@ fn main() { .plugin(navigation_guard) .manage(AppState { manager: CliProcessManager::new(), + desktop_events: DesktopEventTransportManager::new(), wake_lock: Mutex::new(None), zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL), remote_origins: Mutex::new(HashMap::new()), @@ -617,6 +637,8 @@ fn main() { .invoke_handler(tauri::generate_handler![ cli_get_status, cli_restart, + desktop_events_start, + desktop_events_stop, wake_lock_start, wake_lock_stop, needs_local_certificate_install, @@ -722,6 +744,7 @@ fn main() { let app = app_handle.clone(); std::thread::spawn(move || { if let Some(state) = app.try_state::() { + state.desktop_events.stop(); let _ = state.manager.stop(); } app.exit(0); @@ -773,6 +796,7 @@ fn main() { let app = app_handle.clone(); std::thread::spawn(move || { if let Some(state) = app.try_state::() { + state.desktop_events.stop(); let _ = state.manager.stop(); } app.exit(0); diff --git a/packages/ui/src/lib/event-transport-contract.ts b/packages/ui/src/lib/event-transport-contract.ts new file mode 100644 index 000000000..f72301288 --- /dev/null +++ b/packages/ui/src/lib/event-transport-contract.ts @@ -0,0 +1,78 @@ +export interface DesktopEventTransportReconnectPolicy { + initialDelayMs: number + maxDelayMs: number + multiplier: number + maxAttempts?: number +} + +export interface DesktopEventTransportStartOptions { + reconnect?: Partial +} + +export type DesktopEventTransportState = + | "connecting" + | "connected" + | "disconnected" + | "unauthorized" + | "error" + | "stopped" + +export interface DesktopEventTransportStats { + rawEvents: number + emittedEvents: number + emittedBatches: number + deltaCoalesces: number + snapshotCoalesces: number + statusCoalesces: number + supersededDeltasDropped: number +} + +export interface DesktopEventTransportStatusPayload { + generation: number + state: DesktopEventTransportState + reconnectAttempt: number + terminal: boolean + reason?: string + nextDelayMs?: number + statusCode?: number + stats?: DesktopEventTransportStats +} + +export interface DesktopEventsStartResult { + started: boolean + generation?: number + reason?: string +} + +export interface DesktopEventActiveSessionTarget { + instanceId: string + sessionId: string +} + +export interface AssistantStreamChunkEvent { + type: "assistant.stream.chunk" + properties: { + sessionID: string + messageID: string + partID: string + field: "text" + delta: string + } +} + +export const DEFAULT_DESKTOP_EVENT_RECONNECT_POLICY: DesktopEventTransportReconnectPolicy = { + initialDelayMs: 1000, + maxDelayMs: 10000, + multiplier: 2, +} + +export function resolveDesktopEventTransportStartOptions( + options?: DesktopEventTransportStartOptions, +): Required { + return { + reconnect: { + ...DEFAULT_DESKTOP_EVENT_RECONNECT_POLICY, + ...options?.reconnect, + }, + } +} diff --git a/packages/ui/src/lib/event-transport.ts b/packages/ui/src/lib/event-transport.ts new file mode 100644 index 000000000..f114e2537 --- /dev/null +++ b/packages/ui/src/lib/event-transport.ts @@ -0,0 +1,83 @@ +import type { WorkspaceEventPayload } from "../../../server/src/api-types" +import { serverApi } from "./api-client" +import { + resolveDesktopEventTransportStartOptions, + type DesktopEventTransportStartOptions, +} from "./event-transport-contract" +import { getLogger } from "./logger" +import { runtimeEnv } from "./runtime-env" +import { connectTauriWorkspaceEvents } from "./native/desktop-events" + +const log = getLogger("sse") +const FORCE_BROWSER_TRANSPORT_STORAGE_KEY = "perf242-force-browser-events" + +export interface WorkspaceEventTransportCallbacks { + onBatch: (events: WorkspaceEventPayload[]) => void + onError?: () => void + onOpen?: () => void + onPing?: (payload: { ts?: number }) => void +} + +export interface WorkspaceEventConnection { + disconnect: () => void +} + +async function connectBrowserWorkspaceEvents( + callbacks: WorkspaceEventTransportCallbacks, +): Promise { + const source = serverApi.connectEvents((event) => { + callbacks.onBatch([event]) + }, callbacks.onError, callbacks.onPing) + source.onopen = () => callbacks.onOpen?.() + return { + disconnect() { + source.close() + }, + } +} + +function shouldForceBrowserTransport(): boolean { + if (typeof window === "undefined") return false + try { + const params = new URLSearchParams(window.location.search) + if (params.get("forceBrowserEvents") === "1") { + return true + } + return window.localStorage?.getItem(FORCE_BROWSER_TRANSPORT_STORAGE_KEY) === "1" + } catch { + return false + } +} + +export async function connectWorkspaceEvents( + callbacks: WorkspaceEventTransportCallbacks, + options?: DesktopEventTransportStartOptions, +): Promise { + if (runtimeEnv.host === "tauri" && !shouldForceBrowserTransport()) { + try { + const conn = await connectTauriWorkspaceEvents( + callbacks, + resolveDesktopEventTransportStartOptions(options), + ) + ;(globalThis as any).__TRANSPORT_TYPE = "rust-native" + log.info("Event transport: rust-native (desktop_event_transport)") + return conn + } catch (error) { + log.warn("Failed to start native desktop event transport, falling back to browser EventSource", error) + } + } else if (runtimeEnv.host === "tauri") { + log.info("Event transport: browser-eventsource forced by localStorage override") + } + + ;(globalThis as any).__TRANSPORT_TYPE = "browser-eventsource" + log.info(`Event transport: browser-eventsource (host=${runtimeEnv.host})`) + return connectBrowserWorkspaceEvents(callbacks) +} + +export type { + DesktopEventsStartResult, + DesktopEventTransportReconnectPolicy, + DesktopEventTransportStartOptions, + DesktopEventTransportState, + DesktopEventTransportStatusPayload, +} from "./event-transport-contract" diff --git a/packages/ui/src/lib/native/desktop-events.ts b/packages/ui/src/lib/native/desktop-events.ts new file mode 100644 index 000000000..b033e19a6 --- /dev/null +++ b/packages/ui/src/lib/native/desktop-events.ts @@ -0,0 +1,163 @@ +import { invoke } from "@tauri-apps/api/core" +import { listen } from "@tauri-apps/api/event" +import type { WorkspaceEventPayload } from "../../../../server/src/api-types" +import type { + DesktopEventActiveSessionTarget, + DesktopEventsStartResult, + DesktopEventTransportStartOptions, + DesktopEventTransportStatusPayload, +} from "../event-transport-contract" +import type { WorkspaceEventConnection, WorkspaceEventTransportCallbacks } from "../event-transport" +import { getLogger } from "../logger" + +const log = getLogger("sse") + +interface WorkspaceEventBatchPayload { + generation: number + sequence: number + emittedAt: number + events: WorkspaceEventPayload[] +} + +export async function connectTauriWorkspaceEvents( + callbacks: WorkspaceEventTransportCallbacks, + options: DesktopEventTransportStartOptions, +): Promise { + let closed = false + let opened = false + let expectedGeneration: number | null = null + let terminalErrorRaised = false + const pendingBatches: WorkspaceEventBatchPayload[] = [] + const pendingStatuses: DesktopEventTransportStatusPayload[] = [] + + const matchesGeneration = (generation: number) => expectedGeneration === generation + + const handleBatchPayload = (payload: WorkspaceEventBatchPayload) => { + if (!payload || !matchesGeneration(payload.generation)) return + + if (!opened) { + opened = true + callbacks.onOpen?.() + } + + const events = payload.events ?? [] + if (events.length === 0) { + return + } + + callbacks.onBatch(events) + } + + const handleStatusPayload = (payload: DesktopEventTransportStatusPayload) => { + if (!payload || !matchesGeneration(payload.generation)) return + + if (payload.state === "connected" && !opened) { + opened = true + callbacks.onOpen?.() + } + + if (payload.state === "unauthorized") { + log.warn("Native desktop event transport is waiting for authentication", { + reason: payload.reason, + reconnectAttempt: payload.reconnectAttempt, + nextDelayMs: payload.nextDelayMs, + stats: payload.stats, + }) + } else if (payload.state === "error") { + log.warn("Native desktop event transport reported an error", { + reason: payload.reason, + reconnectAttempt: payload.reconnectAttempt, + nextDelayMs: payload.nextDelayMs, + statusCode: payload.statusCode, + stats: payload.stats, + }) + } else if ((payload.state === "disconnected" || payload.state === "stopped") && payload.stats) { + log.info("Native desktop event transport stats", { + state: payload.state, + reconnectAttempt: payload.reconnectAttempt, + stats: payload.stats, + }) + } + + if (payload.state === "stopped") { + callbacks.onError?.() + return + } + + if (payload.terminal && !terminalErrorRaised) { + terminalErrorRaised = true + callbacks.onError?.() + } + } + + const flushPending = () => { + if (expectedGeneration === null) return + for (const payload of pendingStatuses.splice(0, pendingStatuses.length)) { + handleStatusPayload(payload) + } + for (const payload of pendingBatches.splice(0, pendingBatches.length)) { + handleBatchPayload(payload) + } + } + + const unlistenBatch = await listen("desktop:event-batch", (event) => { + if (closed) return + const payload = event.payload + if (!payload) return + if (expectedGeneration === null) { + pendingBatches.push(payload) + return + } + handleBatchPayload(payload) + }) + + const unlistenStatus = await listen("desktop:event-stream-status", (event) => { + if (closed) return + const payload = event.payload + if (!payload) return + if (expectedGeneration === null) { + pendingStatuses.push(payload) + return + } + handleStatusPayload(payload) + }) + + try { + const result = await invoke("desktop_events_start", { request: options }) + if (!result?.started) { + throw new Error(result?.reason ?? "desktop event transport unavailable") + } + expectedGeneration = result.generation ?? null + flushPending() + } catch (error) { + unlistenBatch() + unlistenStatus() + throw error + } + + return { + disconnect() { + if (closed) { + return + } + + closed = true + unlistenBatch() + unlistenStatus() + void invoke("desktop_events_stop").catch((error) => { + log.warn("Failed to stop native desktop event transport", error) + }) + }, + } +} + +export async function setTauriDesktopActiveSession(target: DesktopEventActiveSessionTarget | null): Promise { + try { + await invoke("desktop_events_set_active_session", { + instanceId: target?.instanceId ?? null, + sessionId: target?.sessionId ?? null, + }) + } catch (error) { + log.warn("Failed to update native desktop active session", error) + } +} diff --git a/packages/ui/src/lib/server-events.ts b/packages/ui/src/lib/server-events.ts index 833e6c2aa..0bbb1dc95 100644 --- a/packages/ui/src/lib/server-events.ts +++ b/packages/ui/src/lib/server-events.ts @@ -1,12 +1,38 @@ +import { batch as solidBatch } from "solid-js" import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../server/src/api-types" import { serverApi } from "./api-client" import { getClientIdentity } from "./client-identity" +import { connectWorkspaceEvents, type WorkspaceEventConnection } from "./event-transport" import { getLogger } from "./logger" const RETRY_BASE_DELAY = 1000 const RETRY_MAX_DELAY = 10000 const log = getLogger("sse") +type Perf242ServerEventMetrics = { + batchesReceived: number + eventsReceived: number + maxBatchSize: number +} + +let perf242ServerEventMetrics: Perf242ServerEventMetrics = { + batchesReceived: 0, + eventsReceived: 0, + maxBatchSize: 0, +} + +export function resetPerf242ServerEventMetrics() { + perf242ServerEventMetrics = { + batchesReceived: 0, + eventsReceived: 0, + maxBatchSize: 0, + } +} + +export function getPerf242ServerEventMetrics(): Perf242ServerEventMetrics { + return { ...perf242ServerEventMetrics } +} + function logSse(message: string, context?: Record) { if (context) { log.info(message, context) @@ -18,65 +44,121 @@ function logSse(message: string, context?: Record) { class ServerEvents { private handlers = new Map void>>() private openHandlers = new Set<() => void>() - private source: EventSource | null = null + private connection: WorkspaceEventConnection | null = null + private connectGeneration = 0 private retryDelay = RETRY_BASE_DELAY - private reconnectTimer: ReturnType | null = null + private retryTimer: ReturnType | null = null constructor() { - this.connect() + void this.connect() } - private connect() { - if (this.reconnectTimer !== null) { - clearTimeout(this.reconnectTimer) - this.reconnectTimer = null - } - if (this.source) { - this.source.close() + private async connect() { + const generation = ++this.connectGeneration + this.clearReconnectTimer() + + if (this.connection) { + this.connection.disconnect() + this.connection = null } + logSse("Connecting to backend events stream") - this.source = serverApi.connectEvents( - (event) => this.dispatch(event), - () => this.scheduleReconnect(), - (payload) => { - void serverApi - .sendClientConnectionPong({ - ...getClientIdentity(), - pingTs: payload.ts, - }) - .catch((error) => { - log.error("Failed to send client connection pong", error) - }) - }, - ) - this.source.onopen = () => { - logSse("Events stream connected") - this.retryDelay = RETRY_BASE_DELAY - this.openHandlers.forEach((handler) => handler()) + + try { + const connection = await connectWorkspaceEvents({ + onBatch: (events) => this.dispatchBatch(events), + onError: () => { + if (generation !== this.connectGeneration) { + return + } + this.scheduleReconnect() + }, + onOpen: () => { + if (generation !== this.connectGeneration) { + return + } + logSse("Events stream connected") + this.retryDelay = RETRY_BASE_DELAY + this.openHandlers.forEach((handler) => handler()) + }, + onPing: (payload) => { + void serverApi + .sendClientConnectionPong({ + ...getClientIdentity(), + pingTs: payload.ts, + }) + .catch((error) => { + log.error("Failed to send client connection pong", error) + }) + }, + }) + + if (generation !== this.connectGeneration) { + connection.disconnect() + return + } + + this.connection = connection + } catch (error) { + logSse("Events stream failed to connect, scheduling reconnect", { + error: error instanceof Error ? error.message : String(error), + }) + this.scheduleReconnect() } } private scheduleReconnect() { - if (this.reconnectTimer !== null) { + if (this.retryTimer) { return } - const source = this.source - this.source = null + + if (this.connection) { + this.connection.disconnect() + this.connection = null + } + logSse("Events stream disconnected, scheduling reconnect", { delayMs: this.retryDelay }) - this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null + this.retryTimer = setTimeout(() => { + this.retryTimer = null this.retryDelay = Math.min(this.retryDelay * 2, RETRY_MAX_DELAY) - this.connect() + void this.connect() }, this.retryDelay) - source?.close() + } + + private clearReconnectTimer() { + if (!this.retryTimer) { + return + } + + clearTimeout(this.retryTimer) + this.retryTimer = null } private dispatch(event: WorkspaceEventPayload) { - logSse(`event ${event.type}`) this.handlers.get("*")?.forEach((handler) => handler(event)) this.handlers.get(event.type)?.forEach((handler) => handler(event)) } + private dispatchBatch(events: WorkspaceEventPayload[]) { + if (events.length === 0) { + return + } + + perf242ServerEventMetrics.batchesReceived += 1 + perf242ServerEventMetrics.eventsReceived += events.length + perf242ServerEventMetrics.maxBatchSize = Math.max( + perf242ServerEventMetrics.maxBatchSize, + events.length, + ) + + logSse("event batch", { size: events.length }) + solidBatch(() => { + for (const event of events) { + this.dispatch(event) + } + }) + } + on(type: WorkspaceEventType | "*", handler: (event: WorkspaceEventPayload) => void): () => void { if (!this.handlers.has(type)) { this.handlers.set(type, new Set()) diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx index 4be1fc57e..b4e5bc946 100644 --- a/packages/ui/src/main.tsx +++ b/packages/ui/src/main.tsx @@ -1,5 +1,6 @@ import { render } from "solid-js/web" import App from "./App" +import TransportBench from "./transport-bench" import { ThemeProvider } from "./lib/theme" import { ConfigProvider } from "./stores/preferences" import { InstanceConfigProvider } from "./stores/instance-config" @@ -16,10 +17,35 @@ if (!root) { } const mount = root +const bootParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : new URLSearchParams() +const isPerf242TransportBench = + import.meta.env.VITE_PERF242_TRANSPORT_BENCH === "1" + || bootParams.get("perf242TransportBench") === "1" if (typeof document !== "undefined") { document.documentElement.dataset.runtimeHost = runtimeEnv.host document.documentElement.dataset.runtimePlatform = runtimeEnv.platform + + if (bootParams.get("perf242TransportBench") === "1") { + const payload = { + stage: "frontend-bootstrap", + host: runtimeEnv.host, + search: window.location.search, + } + + void fetch("/api/perf-log", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify(payload), + keepalive: true, + }).catch(() => { + console.info("[perf242] frontend-bootstrap", { + host: runtimeEnv.host, + search: window.location.search, + }) + }) + } } async function bootstrap() { @@ -54,6 +80,7 @@ async function bootstrap() { + {isPerf242TransportBench ? : null} diff --git a/packages/ui/src/transport-bench.tsx b/packages/ui/src/transport-bench.tsx new file mode 100644 index 000000000..84bf61de5 --- /dev/null +++ b/packages/ui/src/transport-bench.tsx @@ -0,0 +1,145 @@ +import { onMount } from "solid-js" +import { runtimeEnv } from "./lib/runtime-env" +import { getPerf242ServerEventMetrics, resetPerf242ServerEventMetrics } from "./lib/server-events" +import { selectInstanceTab } from "./stores/app-tabs" +import { createInstance, instances } from "./stores/instances" +import { + fetchSessions, + getSessions, + loadMessages, + runShellCommand, + setActiveParentSession, + setActiveSession, +} from "./stores/sessions" + +const benchParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : new URLSearchParams() +const PERF242_BENCH_FOLDER = benchParams.get("folder") || import.meta.env.VITE_PERF242_BENCH_FOLDER || "D:\\CodeNomad" +const PERF242_BENCH_SESSION_ID = benchParams.get("sessionId") || import.meta.env.VITE_PERF242_BENCH_SESSION_ID || "" +const PERF242_BENCH_BINARY = benchParams.get("binary") || import.meta.env.VITE_PERF242_BENCH_BINARY || "opencode" +const PERF242_BENCH_COMMAND = benchParams.get("command") || import.meta.env.VITE_PERF242_BENCH_COMMAND + || `node -e "for (let i = 1; i <= 400; i += 1) console.log('line ' + i)"` + +let perf242TransportBenchStarted = false + +function waitForMs(delayMs: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, delayMs)) +} + +async function waitForCondition(predicate: () => boolean, timeoutMs = 15000): Promise { + const start = performance.now() + while (performance.now() - start < timeoutMs) { + if (predicate()) return true + await waitForMs(100) + } + return predicate() +} + +async function emitPerf242Log(payload: Record): Promise { + console.info("[perf242]", payload) + try { + await fetch("/api/perf-log", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify(payload), + keepalive: true, + }) + } catch (error) { + console.warn("[perf242] failed to emit server log", { host: runtimeEnv.host, error }) + } +} + +export default function TransportBench() { + onMount(() => { + if (perf242TransportBenchStarted) return + perf242TransportBenchStarted = true + + void (async () => { + await emitPerf242Log({ + stage: "bench-init", + host: runtimeEnv.host, + folder: PERF242_BENCH_FOLDER, + sessionId: PERF242_BENCH_SESSION_ID, + }) + + if (!PERF242_BENCH_SESSION_ID) { + await emitPerf242Log({ stage: "bench-skipped", reason: "missing-session-id" }) + return + } + + let instanceId = Array.from(instances().values()).find((instance) => instance.folder === PERF242_BENCH_FOLDER)?.id + if (!instanceId) { + await emitPerf242Log({ stage: "create-instance", folder: PERF242_BENCH_FOLDER, binary: PERF242_BENCH_BINARY }) + instanceId = await createInstance(PERF242_BENCH_FOLDER, PERF242_BENCH_BINARY) + } + + selectInstanceTab(instanceId) + await emitPerf242Log({ stage: "instance-ready", instanceId }) + await fetchSessions(instanceId) + await emitPerf242Log({ stage: "sessions-fetched", instanceId, sessionCount: getSessions(instanceId).length }) + + const targetSession = getSessions(instanceId).find((session) => session.id === PERF242_BENCH_SESSION_ID) + if (!targetSession) { + await emitPerf242Log({ + stage: "bench-error", + reason: "session-not-found", + instanceId, + sessionId: PERF242_BENCH_SESSION_ID, + }) + return + } + + const parentSessionId = targetSession.parentId ?? targetSession.id + setActiveParentSession(instanceId, parentSessionId) + if (targetSession.id !== parentSessionId) { + setActiveSession(instanceId, targetSession.id) + } + + await emitPerf242Log({ stage: "session-selected", instanceId, sessionId: targetSession.id, parentSessionId }) + await loadMessages(instanceId, targetSession.id, true) + await emitPerf242Log({ stage: "messages-loaded", instanceId, sessionId: targetSession.id }) + await waitForMs(500) + + resetPerf242ServerEventMetrics() + await emitPerf242Log({ + stage: "start", + folder: PERF242_BENCH_FOLDER, + sessionId: targetSession.id, + transportType: (globalThis as any).__TRANSPORT_TYPE ?? "unknown", + command: PERF242_BENCH_COMMAND, + }) + + const startedAt = performance.now() + await runShellCommand(instanceId, targetSession.id, PERF242_BENCH_COMMAND) + + const sawWorking = await waitForCondition(() => { + const session = getSessions(instanceId).find((value) => value.id === targetSession.id) + return session?.status === "working" + }, 10000) + + const reachedIdle = await waitForCondition(() => { + const session = getSessions(instanceId).find((value) => value.id === targetSession.id) + return sawWorking ? session?.status === "idle" : false + }, 120000) + + await emitPerf242Log({ + stage: reachedIdle ? "complete" : "timeout", + sessionId: targetSession.id, + instanceId, + transportType: (globalThis as any).__TRANSPORT_TYPE ?? "unknown", + elapsedMs: Math.round((performance.now() - startedAt) * 10) / 10, + sawWorking, + reachedIdle, + metrics: getPerf242ServerEventMetrics(), + }) + })().catch(async (error) => { + await emitPerf242Log({ + stage: "error", + error: error instanceof Error ? error.stack ?? error.message : String(error), + }) + throw error + }) + }) + + return null +} From 090efe98fc60cd51bf665939c20acb82ad90e326 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 9 May 2026 15:29:40 +0200 Subject: [PATCH 2/9] fix(tauri): restore desktop transport heartbeat parity Handle named SSE ping events with explicit pong replies so the native transport survives the server heartbeat window, and remove the unused active-session fast path while keeping the benchmark harness authenticated. # Conflicts: # packages/tauri-app/src-tauri/src/cli_manager.rs --- packages/server/src/server/http-server.ts | 2 +- .../tauri-app/src-tauri/src/cli_manager.rs | 19 +- .../src-tauri/src/desktop_event_transport.rs | 266 +----------- .../src/desktop_event_transport/assembler.rs | 397 +----------------- .../src/desktop_event_transport/stream.rs | 49 ++- .../src/desktop_event_transport/tests.rs | 236 +---------- .../src/desktop_event_transport/transport.rs | 199 ++------- .../ui/src/lib/event-transport-contract.ts | 16 - packages/ui/src/lib/native/desktop-events.ts | 12 - packages/ui/src/transport-bench.tsx | 16 +- 10 files changed, 88 insertions(+), 1124 deletions(-) diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 6b50490b6..3d830376d 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -200,7 +200,7 @@ export function createHttpServer(deps: HttpServerDeps) { const rawUrl = request.raw.url ?? request.url const pathname = (rawUrl.split("?")[0] ?? "").trim() - const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout", "/api/perf-log"]) + const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout"]) const publicPagePaths = new Set(["/login"]) if (deps.authManager.isTokenBootstrapEnabled()) { publicPagePaths.add("/auth/token") diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 98210b4ff..2ae45addc 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -317,6 +317,15 @@ fn generate_auth_cookie_name() -> String { format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}") } +fn generate_transport_connection_id() -> String { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let tid = std::thread::current().id(); + format!("tauri-{}-{:?}", ts, tid) +} + const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json"; #[derive(Debug, Deserialize)] @@ -646,6 +655,7 @@ impl CliProcessManager { base_url, events_url, client_id, + connection_id: generate_transport_connection_id(), cookie_name, session_cookie: self.session_cookie.lock().clone(), }) @@ -1276,7 +1286,8 @@ fn resolve_dev_entry(_app: &AppHandle) -> Option { } fn resolve_prod_entry(_app: &AppHandle) -> Option { - let mut candidates = Vec::new(); + let base = workspace_root(); + let mut candidates = vec![base.as_ref().map(|p| p.join("packages/server/dist/bin.js"))]; if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { @@ -1294,12 +1305,6 @@ fn resolve_prod_entry(_app: &AppHandle) -> Option { } } - let base = workspace_root(); - candidates.push( - base.as_ref() - .map(|p| p.join("packages/server/dist/bin.js")), - ); - first_existing(candidates) } diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport.rs index 49cc75521..f9b0240c8 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport.rs @@ -3,7 +3,6 @@ use reqwest::blocking::{Client, Response}; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::collections::HashMap; use std::io::{BufRead, BufReader}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::{self, RecvTimeoutError, SyncSender}; @@ -23,12 +22,6 @@ const EVENT_BATCH_NAME: &str = "desktop:event-batch"; const EVENT_STATUS_NAME: &str = "desktop:event-stream-status"; const FLUSH_INTERVAL_MS: u64 = 16; const DELTA_STREAM_WINDOW_MS: u64 = 48; -const ACTIVE_STREAM_DISPLAY_WINDOW_MS: u64 = 16; -const ACTIVE_STREAM_DISPLAY_CHUNK_MAX: usize = 96; -const ACTIVE_STREAM_STORE_WINDOW_MS: u64 = 250; -const ACTIVE_STREAM_SNAPSHOT_WINDOW_MS: u64 = 200; -const ACTIVE_STREAM_HOLD_WINDOW_MS: u64 = 12; -const ACTIVE_SESSION_MAX_BATCH_EVENTS: usize = 64; const MAX_BATCH_EVENTS: usize = 256; const DEFAULT_RECONNECT_INITIAL_DELAY_MS: u64 = 1_000; const DEFAULT_RECONNECT_MAX_DELAY_MS: u64 = 10_000; @@ -42,6 +35,7 @@ pub struct DesktopEventStreamConfig { pub base_url: String, pub events_url: String, pub client_id: String, + pub connection_id: String, pub cookie_name: String, pub session_cookie: Option, } @@ -156,13 +150,6 @@ struct DesktopEventTransportStats { struct DesktopEventTransportState { stop: Option>, config: Option, - active_target: Option, -} - -#[derive(Clone, PartialEq, Eq)] -pub struct ActiveSessionTarget { - pub instance_id: String, - pub session_id: String, } pub struct DesktopEventTransportManager { @@ -173,6 +160,7 @@ pub struct DesktopEventTransportManager { enum ReaderMessage { Activity, Event(Value), + Ping(Value), End(Option), } @@ -180,8 +168,6 @@ enum PendingEntry { Delta { key: String, scope: String, - instance_id: String, - session_id: Option, event: Value, started_at: Instant, }, @@ -220,87 +206,17 @@ struct PendingBatch { events: Vec, } -#[derive(Clone)] -struct ActiveTextDelta { - instance_id: String, - session_id: String, - message_id: String, - part_id: String, - delta: String, -} - -struct ActiveTextPartBuffer { - instance_id: String, - session_id: String, - message_id: String, - part_id: String, - display_pending: String, - store_pending: String, - last_display_emit: Instant, - last_store_emit: Instant, -} - -impl ActiveTextPartBuffer { - fn new(delta: ActiveTextDelta, now: Instant) -> Self { - Self { - instance_id: delta.instance_id, - session_id: delta.session_id, - message_id: delta.message_id, - part_id: delta.part_id, - display_pending: delta.delta.clone(), - store_pending: delta.delta, - last_display_emit: now, - last_store_emit: now, - } - } -} - -#[derive(Clone)] -struct ActiveTextSnapshot { - key: String, - instance_id: String, - session_id: String, - message_id: String, - part_id: String, - event: Value, -} - -struct BufferedTextSnapshot { - instance_id: String, - session_id: String, - message_id: String, - part_id: String, - event: Value, - buffered_at: Instant, -} - -#[derive(Default)] -struct ActiveTextAssembler { - parts: HashMap, -} - -#[derive(Default)] -struct ActiveTextSnapshotBuffer { - parts: HashMap, -} - impl DesktopEventTransportManager { pub fn new() -> Self { Self { state: Arc::new(Mutex::new(DesktopEventTransportState { stop: None, config: None, - active_target: None, })), generation: Arc::new(AtomicU64::new(0)), } } - pub fn set_active_session_target(&self, target: Option) { - let mut state = self.state.lock(); - state.active_target = target; - } - pub fn start( &self, app: AppHandle, @@ -339,19 +255,11 @@ impl DesktopEventTransportManager { let stop = Arc::new(AtomicBool::new(false)); state.stop = Some(stop.clone()); state.config = Some(transport_config.clone()); - let shared_state = self.state.clone(); let shared_generation = self.generation.clone(); drop(state); thread::spawn(move || { - run_transport_loop( - app, - shared_state, - shared_generation, - generation, - stop, - transport_config, - ) + run_transport_loop(app, shared_generation, generation, stop, transport_config) }); DesktopEventsStartResult { @@ -367,7 +275,6 @@ impl DesktopEventTransportManager { stop.store(true, Ordering::SeqCst); } state.config = None; - state.active_target = None; self.generation.fetch_add(1, Ordering::SeqCst); } } @@ -403,173 +310,6 @@ fn coalesced_instance_id(event: &Value) -> &str { .unwrap_or_default() } -fn event_session_id(event: &Value) -> Option<&str> { - let inner = coalesced_payload_event(event); - let inner_type = inner.get("type")?.as_str()?; - let props = inner.get("properties")?; - - match inner_type { - "session.updated" => props - .get("info") - .and_then(|info| info.get("id")) - .and_then(Value::as_str) - .or_else(|| { - props - .get("sessionID") - .or_else(|| props.get("sessionId")) - .and_then(Value::as_str) - }), - "message.updated" => props - .get("info") - .and_then(|info| info.get("sessionID").or_else(|| info.get("sessionId"))) - .and_then(Value::as_str), - "message.part.updated" => props - .get("part") - .and_then(|part| part.get("sessionID").or_else(|| part.get("sessionId"))) - .and_then(Value::as_str), - "message.part.delta" - | "message.removed" - | "message.part.removed" - | "session.compacted" - | "session.diff" - | "session.idle" - | "session.status" => props - .get("sessionID") - .or_else(|| props.get("sessionId")) - .and_then(Value::as_str), - _ => None, - } -} - -fn parse_active_text_delta( - event: &Value, - active_target: Option<&ActiveSessionTarget>, -) -> Option { - let active_target = active_target?; - let instance_id = coalesced_instance_id(event); - if instance_id != active_target.instance_id { - return None; - } - let inner = coalesced_payload_event(event); - if inner.get("type")?.as_str()? != "message.part.delta" { - return None; - } - - let props = inner.get("properties")?; - let field = props.get("field")?.as_str()?; - if field != "text" { - return None; - } - - let event_session = props - .get("sessionID") - .or_else(|| props.get("sessionId")) - .and_then(Value::as_str)?; - if event_session != active_target.session_id { - return None; - } - - Some(ActiveTextDelta { - instance_id: instance_id.to_string(), - session_id: event_session.to_string(), - message_id: props - .get("messageID") - .or_else(|| props.get("messageId")) - .and_then(Value::as_str)? - .to_string(), - part_id: props - .get("partID") - .or_else(|| props.get("partId")) - .and_then(Value::as_str)? - .to_string(), - delta: props.get("delta")?.as_str()?.to_string(), - }) -} - -fn make_assistant_stream_chunk_event(entry: &ActiveTextPartBuffer, delta: &str) -> Value { - serde_json::json!({ - "type": "instance.event", - "instanceId": entry.instance_id, - "event": { - "type": "assistant.stream.chunk", - "properties": { - "sessionID": entry.session_id, - "messageID": entry.message_id, - "partID": entry.part_id, - "field": "text", - "delta": delta, - } - } - }) -} - -fn make_message_part_delta_event(entry: &ActiveTextPartBuffer, delta: &str) -> Value { - serde_json::json!({ - "type": "instance.event", - "instanceId": entry.instance_id, - "event": { - "type": "message.part.delta", - "properties": { - "sessionID": entry.session_id, - "messageID": entry.message_id, - "partID": entry.part_id, - "field": "text", - "delta": delta, - } - } - }) -} - -fn parse_active_text_snapshot( - event: &Value, - active_target: Option<&ActiveSessionTarget>, -) -> Option { - let active_target = active_target?; - let instance_id = coalesced_instance_id(event); - if instance_id != active_target.instance_id { - return None; - } - - let inner = coalesced_payload_event(event); - if inner.get("type")?.as_str()? != "message.part.updated" { - return None; - } - - let part = inner.get("properties")?.get("part")?; - if part.get("type")?.as_str()? != "text" { - return None; - } - if part.get("text")?.as_str().is_none() { - return None; - } - - let event_session = part - .get("sessionID") - .or_else(|| part.get("sessionId")) - .and_then(Value::as_str)?; - if event_session != active_target.session_id { - return None; - } - - let message_id = part - .get("messageID") - .or_else(|| part.get("messageId")) - .and_then(Value::as_str)?; - let part_id = part.get("id")?.as_str()?; - - Some(ActiveTextSnapshot { - key: format!( - "{}:{}:{}:{}", - instance_id, event_session, message_id, part_id - ), - instance_id: instance_id.to_string(), - session_id: event_session.to_string(), - message_id: message_id.to_string(), - part_id: part_id.to_string(), - event: event.clone(), - }) -} - fn snapshot_key(event: &Value) -> Option { let instance_id = coalesced_instance_id(event); let inner = coalesced_payload_event(event); diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs index 82299452e..f91bcb760 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/assembler.rs @@ -25,8 +25,6 @@ impl PendingBatch { self.events.push(PendingEntry::Delta { key, scope, - instance_id: coalesced_instance_id(&event).to_string(), - session_id: event_session_id(&event).map(|value| value.to_string()), event, started_at: Instant::now(), }); @@ -103,399 +101,12 @@ impl PendingBatch { self.events.len() } - pub(super) fn should_hold_single_delta( - &self, - now: Instant, - active_target: Option<&ActiveSessionTarget>, - ) -> bool { + pub(super) fn should_hold_single_delta(&self, now: Instant) -> bool { matches!( self.events.as_slice(), - [PendingEntry::Delta { started_at, instance_id, session_id, .. }] - if now.duration_since(*started_at) < Duration::from_millis( - if active_target - .map(|target| { - target.instance_id.as_str() == instance_id.as_str() - && target.session_id.as_str() == session_id.as_deref().unwrap_or_default() - }) - .unwrap_or(false) - { - ACTIVE_STREAM_HOLD_WINDOW_MS - } else { - DELTA_STREAM_WINDOW_MS - } - ) + [PendingEntry::Delta { started_at, .. }] + if now.duration_since(*started_at) + < Duration::from_millis(DELTA_STREAM_WINDOW_MS) ) } } - -impl ActiveTextAssembler { - pub(super) fn absorb(&mut self, delta: ActiveTextDelta, now: Instant) -> Vec { - let key = format!( - "{}:{}:{}:{}", - delta.instance_id, delta.session_id, delta.message_id, delta.part_id - ); - - match self.parts.entry(key) { - std::collections::hash_map::Entry::Occupied(mut occupied) => { - let entry = occupied.get_mut(); - if entry.display_pending.is_empty() && entry.store_pending.is_empty() { - entry.instance_id = delta.instance_id.clone(); - entry.session_id = delta.session_id.clone(); - entry.message_id = delta.message_id.clone(); - entry.part_id = delta.part_id.clone(); - } - - entry.display_pending.push_str(&delta.delta); - entry.store_pending.push_str(&delta.delta); - Self::collect_due_for_part(entry, now) - } - std::collections::hash_map::Entry::Vacant(vacant) => { - let mut entry = ActiveTextPartBuffer::new(delta, now); - entry.last_display_emit = now - .checked_sub(Duration::from_millis(ACTIVE_STREAM_DISPLAY_WINDOW_MS)) - .unwrap_or(now); - let emitted = Self::collect_due_for_part(&mut entry, now); - vacant.insert(entry); - emitted - } - } - } - - pub(super) fn take_due(&mut self, now: Instant) -> Vec { - let mut emitted = Vec::new(); - let mut empty_keys = Vec::new(); - - for (key, entry) in self.parts.iter_mut() { - emitted.extend(Self::collect_due_for_part(entry, now)); - if entry.display_pending.is_empty() && entry.store_pending.is_empty() { - empty_keys.push(key.clone()); - } - } - - for key in empty_keys { - self.parts.remove(&key); - } - - emitted - } - - pub(super) fn flush_for_event(&mut self, event: &Value, now: Instant) -> Vec { - let instance_id = coalesced_instance_id(event); - let payload = coalesced_payload_event(event); - let event_type = payload.get("type").and_then(Value::as_str); - - match event_type { - Some("message.updated") | Some("message.removed") => { - let props = payload.get("properties"); - let session_id = event_session_id(event); - let message_id = props - .and_then(|value| { - value - .get("info") - .and_then(|info| info.get("id")) - .or_else(|| value.get("messageID")) - .or_else(|| value.get("messageId")) - }) - .and_then(Value::as_str); - if let (Some(session_id), Some(message_id)) = (session_id, message_id) { - return self.flush_message(instance_id, session_id, message_id, now); - } - } - Some("message.part.updated") | Some("message.part.removed") => { - let props = payload.get("properties"); - let session_id = event_session_id(event); - let message_id = props - .and_then(|value| { - value - .get("part") - .and_then(|part| { - part.get("messageID").or_else(|| part.get("messageId")) - }) - .or_else(|| value.get("messageID")) - .or_else(|| value.get("messageId")) - }) - .and_then(Value::as_str); - let part_id = props - .and_then(|value| { - value - .get("part") - .and_then(|part| part.get("id")) - .or_else(|| value.get("partID")) - .or_else(|| value.get("partId")) - }) - .and_then(Value::as_str); - if let (Some(session_id), Some(message_id), Some(part_id)) = - (session_id, message_id, part_id) - { - return self.flush_part(instance_id, session_id, message_id, part_id, now); - } - } - _ => {} - } - - Vec::new() - } - - pub(super) fn flush_message( - &mut self, - instance_id: &str, - session_id: &str, - message_id: &str, - now: Instant, - ) -> Vec { - let keys: Vec = self - .parts - .iter() - .filter(|(_, entry)| { - entry.instance_id == instance_id - && entry.session_id == session_id - && entry.message_id == message_id - }) - .map(|(key, _)| key.clone()) - .collect(); - - let mut emitted = Vec::new(); - for key in keys { - if let Some(mut entry) = self.parts.remove(&key) { - emitted.extend(Self::flush_all_for_part(&mut entry, now)); - } - } - emitted - } - - pub(super) fn flush_part( - &mut self, - instance_id: &str, - session_id: &str, - message_id: &str, - part_id: &str, - now: Instant, - ) -> Vec { - let key = format!("{}:{}:{}:{}", instance_id, session_id, message_id, part_id); - if let Some(mut entry) = self.parts.remove(&key) { - return Self::flush_all_for_part(&mut entry, now); - } - Vec::new() - } - - pub(super) fn flush_store_only_all(&mut self, now: Instant) -> Vec { - let mut emitted = Vec::new(); - for entry in self.parts.values_mut() { - if !entry.store_pending.is_empty() { - emitted.push(make_message_part_delta_event(entry, &entry.store_pending)); - entry.store_pending.clear(); - entry.last_store_emit = now; - } - entry.display_pending.clear(); - entry.last_display_emit = now; - } - self.parts.clear(); - emitted - } - - fn collect_due_for_part(entry: &mut ActiveTextPartBuffer, now: Instant) -> Vec { - let mut emitted = Vec::new(); - - // Display lane — emit preview chunks frequently (~16ms / 96 chars). - if !entry.display_pending.is_empty() - && (now.duration_since(entry.last_display_emit) - >= Duration::from_millis(ACTIVE_STREAM_DISPLAY_WINDOW_MS) - || entry.display_pending.len() >= ACTIVE_STREAM_DISPLAY_CHUNK_MAX) - { - emitted.push(make_assistant_stream_chunk_event( - entry, - &entry.display_pending, - )); - entry.display_pending.clear(); - entry.last_display_emit = now; - } - - // Store lane — emit canonical deltas infrequently (~250ms) to avoid - // flooding the JS reactive graph with store mutations that - // trigger expensive re-render cascades during active streaming. - // Explicit flush triggers (message.updated, message.part.updated, - // session change, disconnect) still flush immediately via - // flush_for_event / flush_all_for_part / flush_store_only_all. - if !entry.store_pending.is_empty() - && now.duration_since(entry.last_store_emit) - >= Duration::from_millis(ACTIVE_STREAM_STORE_WINDOW_MS) - { - emitted.push(make_message_part_delta_event(entry, &entry.store_pending)); - entry.store_pending.clear(); - entry.last_store_emit = now; - } - - emitted - } - - fn flush_all_for_part(entry: &mut ActiveTextPartBuffer, now: Instant) -> Vec { - let mut emitted = Vec::new(); - if !entry.display_pending.is_empty() { - emitted.push(make_assistant_stream_chunk_event( - entry, - &entry.display_pending, - )); - entry.display_pending.clear(); - entry.last_display_emit = now; - } - if !entry.store_pending.is_empty() { - emitted.push(make_message_part_delta_event(entry, &entry.store_pending)); - entry.store_pending.clear(); - entry.last_store_emit = now; - } - emitted - } -} - -impl ActiveTextSnapshotBuffer { - pub(super) fn buffer(&mut self, snapshot: ActiveTextSnapshot, now: Instant) { - match self.parts.entry(snapshot.key) { - std::collections::hash_map::Entry::Occupied(mut occupied) => { - let entry = occupied.get_mut(); - entry.instance_id = snapshot.instance_id; - entry.session_id = snapshot.session_id; - entry.message_id = snapshot.message_id; - entry.part_id = snapshot.part_id; - entry.event = snapshot.event; - } - std::collections::hash_map::Entry::Vacant(vacant) => { - vacant.insert(BufferedTextSnapshot { - instance_id: snapshot.instance_id, - session_id: snapshot.session_id, - message_id: snapshot.message_id, - part_id: snapshot.part_id, - event: snapshot.event, - buffered_at: now, - }); - } - } - } - - pub(super) fn take_due(&mut self, now: Instant) -> Vec { - let keys: Vec = self - .parts - .iter() - .filter(|(_, entry)| { - now.duration_since(entry.buffered_at) - >= Duration::from_millis(ACTIVE_STREAM_SNAPSHOT_WINDOW_MS) - }) - .map(|(key, _)| key.clone()) - .collect(); - - self.take_entries(keys) - } - - pub(super) fn flush_for_event(&mut self, event: &Value) -> Vec { - let instance_id = coalesced_instance_id(event); - let payload = coalesced_payload_event(event); - let event_type = payload.get("type").and_then(Value::as_str); - - match event_type { - Some("message.updated") | Some("message.removed") => { - let props = payload.get("properties"); - let session_id = event_session_id(event); - let message_id = props - .and_then(|value| { - value - .get("info") - .and_then(|info| info.get("id")) - .or_else(|| value.get("messageID")) - .or_else(|| value.get("messageId")) - }) - .and_then(Value::as_str); - if let (Some(session_id), Some(message_id)) = (session_id, message_id) { - return self.flush_message(instance_id, session_id, message_id); - } - } - Some("message.part.removed") => { - let props = payload.get("properties"); - let session_id = event_session_id(event); - let message_id = props - .and_then(|value| { - value - .get("part") - .and_then(|part| { - part.get("messageID").or_else(|| part.get("messageId")) - }) - .or_else(|| value.get("messageID")) - .or_else(|| value.get("messageId")) - }) - .and_then(Value::as_str); - let part_id = props - .and_then(|value| { - value - .get("part") - .and_then(|part| part.get("id")) - .or_else(|| value.get("partID")) - .or_else(|| value.get("partId")) - }) - .and_then(Value::as_str); - if let (Some(session_id), Some(message_id), Some(part_id)) = - (session_id, message_id, part_id) - { - return self.flush_part(instance_id, session_id, message_id, part_id); - } - } - _ => {} - } - - Vec::new() - } - - pub(super) fn flush_message( - &mut self, - instance_id: &str, - session_id: &str, - message_id: &str, - ) -> Vec { - let keys: Vec = self - .parts - .iter() - .filter(|(_, entry)| { - entry.instance_id == instance_id - && entry.session_id == session_id - && entry.message_id == message_id - }) - .map(|(key, _)| key.clone()) - .collect(); - - self.take_entries(keys) - } - - pub(super) fn flush_part( - &mut self, - instance_id: &str, - session_id: &str, - message_id: &str, - part_id: &str, - ) -> Vec { - let keys: Vec = self - .parts - .iter() - .filter(|(_, entry)| { - entry.instance_id == instance_id - && entry.session_id == session_id - && entry.message_id == message_id - && entry.part_id == part_id - }) - .map(|(key, _)| key.clone()) - .collect(); - - self.take_entries(keys) - } - - pub(super) fn flush_all(&mut self) -> Vec { - let keys: Vec = self.parts.keys().cloned().collect(); - self.take_entries(keys) - } - - fn take_entries(&mut self, keys: Vec) -> Vec { - let mut emitted = Vec::new(); - for key in keys { - if let Some(entry) = self.parts.remove(&key) { - emitted.push(entry.event); - } - } - emitted - } -} diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs index e861a1b31..1cd5d00b9 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs @@ -24,10 +24,9 @@ pub(super) fn open_stream( client: &Client, config: &DesktopEventStreamConfig, ) -> Result { - let connection_id = generate_connection_id(); let url = format!( "{}?clientId={}&connectionId={}", - config.events_url, config.client_id, connection_id + config.events_url, config.client_id, config.connection_id ); let mut request = client.get(&url).header("Accept", "text/event-stream"); @@ -69,15 +68,6 @@ fn resolve_session_cookie(app: &AppHandle, config: &DesktopEventStreamConfig) -> .filter(|value| !value.is_empty()) } -fn generate_connection_id() -> String { - let ts = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - let tid = std::thread::current().id(); - format!("tauri-{}-{:?}", ts, tid) -} - fn read_session_cookie_from_webview( app: &AppHandle, base_url: &str, @@ -120,6 +110,7 @@ pub(super) fn read_sse( ) { let mut reader = BufReader::new(response); let mut line = String::new(); + let mut event_name: Option = None; let mut data_lines: Vec = Vec::new(); loop { @@ -131,9 +122,7 @@ pub(super) fn read_sse( line.clear(); match reader.read_line(&mut line) { Ok(0) => { - if let Some(event) = parse_sse_payload(&data_lines) { - let _ = tx.send(ReaderMessage::Event(event)); - } + let _ = flush_sse_frame(&tx, &event_name, &data_lines); let _ = tx.send(ReaderMessage::End(Some("stream closed".to_string()))); return; } @@ -143,11 +132,10 @@ pub(super) fn read_sse( } let trimmed = line.trim_end_matches(['\r', '\n']); if trimmed.is_empty() { - if let Some(event) = parse_sse_payload(&data_lines) { - if tx.send(ReaderMessage::Event(event)).is_err() { - return; // consumer dropped - } + if flush_sse_frame(&tx, &event_name, &data_lines).is_err() { + return; } + event_name = None; data_lines.clear(); continue; } @@ -156,14 +144,17 @@ pub(super) fn read_sse( continue; } + if let Some(name) = trimmed.strip_prefix("event:") { + event_name = Some(name.strip_prefix(' ').unwrap_or(name).to_string()); + continue; + } + if let Some(data) = trimmed.strip_prefix("data:") { data_lines.push(data.strip_prefix(' ').unwrap_or(data).to_string()); } } Err(error) => { - if let Some(event) = parse_sse_payload(&data_lines) { - let _ = tx.send(ReaderMessage::Event(event)); - } + let _ = flush_sse_frame(&tx, &event_name, &data_lines); let _ = tx.send(ReaderMessage::End(Some(error.to_string()))); return; } @@ -171,6 +162,22 @@ pub(super) fn read_sse( } } +fn flush_sse_frame( + tx: &SyncSender, + event_name: &Option, + lines: &[String], +) -> Result<(), ()> { + let Some(payload) = parse_sse_payload(lines) else { + return Ok(()); + }; + + if event_name.as_deref() == Some("codenomad.client.ping") { + tx.send(ReaderMessage::Ping(payload)).map_err(|_| ()) + } else { + tx.send(ReaderMessage::Event(payload)).map_err(|_| ()) + } +} + fn parse_sse_payload(lines: &[String]) -> Option { if lines.is_empty() { return None; diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs index e464ef8c9..d9ba344b9 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/tests.rs @@ -86,13 +86,6 @@ fn message_part_updated_event(text: &str) -> Value { }) } -fn active_target() -> ActiveSessionTarget { - ActiveSessionTarget { - instance_id: "inst-1".to_string(), - session_id: "sess-1".to_string(), - } -} - #[test] fn coalesces_message_part_delta_events() { let mut pending = PendingBatch::default(); @@ -295,14 +288,12 @@ fn holds_single_delta_within_stream_window() { events: vec![PendingEntry::Delta { key: "delta-key".to_string(), scope: "delta-scope".to_string(), - instance_id: "inst-1".to_string(), - session_id: Some("sess-1".to_string()), event: delta_event("Hello"), started_at: Instant::now(), }], }; - assert!(pending.should_hold_single_delta(Instant::now(), None)); + assert!(pending.should_hold_single_delta(Instant::now())); } #[test] @@ -312,235 +303,12 @@ fn flushes_single_delta_after_stream_window() { events: vec![PendingEntry::Delta { key: "delta-key".to_string(), scope: "delta-scope".to_string(), - instance_id: "inst-1".to_string(), - session_id: Some("sess-1".to_string()), - event: delta_event("Hello"), - started_at, - }], - }; - - assert!(!pending.should_hold_single_delta(Instant::now(), None)); -} - -#[test] -fn active_session_uses_shorter_hold_window() { - // Delta aged past the active-stream window but within the base window. - // Active session should flush faster, so this should NOT be held. - let started_at = Instant::now() - Duration::from_millis(ACTIVE_STREAM_HOLD_WINDOW_MS + 10); - let pending = PendingBatch { - events: vec![PendingEntry::Delta { - key: "delta-key".to_string(), - scope: "delta-scope".to_string(), - instance_id: "inst-1".to_string(), - session_id: Some("sess-1".to_string()), event: delta_event("Hello"), started_at, }], }; - let active_target = active_target(); - let other_target = ActiveSessionTarget { - instance_id: "inst-1".to_string(), - session_id: "sess-2".to_string(), - }; - - // Active session: uses ACTIVE_STREAM_HOLD_WINDOW_MS, so this stale delta is not held. - assert!(!pending.should_hold_single_delta(Instant::now(), Some(&active_target))); - // Non-matching session: still uses the wider base window. - assert!(pending.should_hold_single_delta(Instant::now(), Some(&other_target))); -} - -#[test] -fn active_session_holds_fresh_delta() { - // A very fresh delta should be held even for the active session's shorter window. - let started_at = Instant::now() - Duration::from_millis(5); - let pending = PendingBatch { - events: vec![PendingEntry::Delta { - key: "delta-key".to_string(), - scope: "delta-scope".to_string(), - instance_id: "inst-1".to_string(), - session_id: Some("sess-1".to_string()), - event: delta_event("Hello"), - started_at, - }], - }; - - let active_target = active_target(); - - assert!(pending.should_hold_single_delta(Instant::now(), Some(&active_target))); -} - -#[test] -fn assembler_emits_first_preview_chunk_immediately() { - let mut assembler = ActiveTextAssembler::default(); - let now = Instant::now(); - - let emitted = assembler.absorb( - ActiveTextDelta { - instance_id: "inst-1".to_string(), - session_id: "sess-1".to_string(), - message_id: "msg-1".to_string(), - part_id: "part-1".to_string(), - delta: "Hello".to_string(), - }, - now, - ); - - assert_eq!(emitted.len(), 1); - assert_eq!( - coalesced_payload_event(&emitted[0]) - .get("type") - .and_then(Value::as_str), - Some("assistant.stream.chunk") - ); - assert_eq!( - coalesced_payload_event(&emitted[0]) - .get("properties") - .and_then(|props| props.get("delta")) - .and_then(Value::as_str), - Some("Hello") - ); -} - -#[test] -fn snapshot_buffer_coalesces_updates_within_window() { - let mut buffer = ActiveTextSnapshotBuffer::default(); - let now = Instant::now(); - - buffer.buffer( - parse_active_text_snapshot(&message_part_updated_event("A"), Some(&active_target())) - .unwrap(), - now, - ); - buffer.buffer( - parse_active_text_snapshot(&message_part_updated_event("AB"), Some(&active_target())) - .unwrap(), - now + Duration::from_millis(40), - ); - buffer.buffer( - parse_active_text_snapshot(&message_part_updated_event("ABC"), Some(&active_target())) - .unwrap(), - now + Duration::from_millis(80), - ); - - let early = buffer.take_due(now + Duration::from_millis(ACTIVE_STREAM_SNAPSHOT_WINDOW_MS - 1)); - assert!(early.is_empty()); - - let emitted = - buffer.take_due(now + Duration::from_millis(ACTIVE_STREAM_SNAPSHOT_WINDOW_MS + 1)); - assert_eq!(emitted.len(), 1); - assert_eq!( - emitted[0]["event"]["properties"]["part"]["text"].as_str(), - Some("ABC") - ); -} - -#[test] -fn snapshot_buffer_flushes_latest_snapshot_before_message_update() { - let mut buffer = ActiveTextSnapshotBuffer::default(); - let now = Instant::now(); - - buffer.buffer( - parse_active_text_snapshot(&message_part_updated_event("Hello"), Some(&active_target())) - .unwrap(), - now, - ); - buffer.buffer( - parse_active_text_snapshot( - &message_part_updated_event("Hello world"), - Some(&active_target()), - ) - .unwrap(), - now + Duration::from_millis(25), - ); - - let flushed = buffer.flush_for_event(&json!({ - "type": "instance.event", - "instanceId": "inst-1", - "event": { - "type": "message.updated", - "properties": { - "info": { - "id": "msg-1", - "sessionID": "sess-1" - } - } - } - })); - - assert_eq!(flushed.len(), 1); - assert_eq!( - flushed[0]["event"]["properties"]["part"]["text"].as_str(), - Some("Hello world") - ); -} - -#[test] -fn assembler_keeps_first_delta_after_full_flush() { - let mut assembler = ActiveTextAssembler::default(); - let now = Instant::now(); - let delta = ActiveTextDelta { - instance_id: "inst-1".to_string(), - session_id: "sess-1".to_string(), - message_id: "msg-1".to_string(), - part_id: "part-1".to_string(), - delta: "Hello".to_string(), - }; - - let _ = assembler.absorb(delta.clone(), now); - let _ = assembler.flush_message("inst-1", "sess-1", "msg-1", now); - let _ = assembler.absorb( - ActiveTextDelta { - delta: " world".to_string(), - ..delta - }, - now, - ); - let emitted = assembler.flush_store_only_all(now + Duration::from_millis(1)); - - assert!(emitted.iter().any(|event| { - coalesced_payload_event(event) - .get("type") - .and_then(Value::as_str) - == Some("message.part.delta") - && coalesced_payload_event(event) - .get("properties") - .and_then(|props| props.get("delta")) - .and_then(Value::as_str) - == Some(" world") - })); -} - -#[test] -fn flush_store_only_all_preserves_canonical_text_without_preview() { - let mut assembler = ActiveTextAssembler::default(); - let now = Instant::now(); - let _ = assembler.absorb( - ActiveTextDelta { - instance_id: "inst-1".to_string(), - session_id: "sess-1".to_string(), - message_id: "msg-1".to_string(), - part_id: "part-1".to_string(), - delta: "Hello".to_string(), - }, - now, - ); - - let emitted = assembler.flush_store_only_all(now + Duration::from_millis(1)); - assert_eq!(emitted.len(), 1); - assert_eq!( - coalesced_payload_event(&emitted[0]) - .get("type") - .and_then(Value::as_str), - Some("message.part.delta") - ); - assert_eq!( - coalesced_payload_event(&emitted[0]) - .get("properties") - .and_then(|props| props.get("delta")) - .and_then(Value::as_str), - Some("Hello") - ); + assert!(!pending.should_hold_single_delta(Instant::now())); } #[test] diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs index 0c7da3a29..9ecf6bfd6 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs @@ -1,8 +1,23 @@ use super::*; +fn send_connection_pong(client: &Client, config: &DesktopEventStreamConfig, payload: &Value) { + let body = serde_json::json!({ + "clientId": config.client_id, + "connectionId": config.connection_id, + "pingTs": payload.get("ts").and_then(Value::as_u64), + }); + + let _ = client + .post(format!( + "{}/api/client-connections/pong", + config.base_url.trim_end_matches('/') + )) + .json(&body) + .send(); +} + pub(super) fn run_transport_loop( app: AppHandle, - state: Arc>, generation_atomic: Arc, generation: u64, stop: Arc, @@ -63,8 +78,9 @@ pub(super) fn run_transport_loop( let disconnect_reason = consume_stream( &app, + &client, + &config.stream, response, - &state, &generation_atomic, generation, stop.clone(), @@ -194,8 +210,9 @@ fn wait_with_cancellation( fn consume_stream( app: &AppHandle, + client: &Client, + stream_config: &DesktopEventStreamConfig, response: Response, - state: &Arc>, generation_atomic: &Arc, generation: u64, stop: Arc, @@ -215,10 +232,7 @@ fn consume_stream( }); let mut pending = PendingBatch::default(); - let mut active_text_assembler = ActiveTextAssembler::default(); - let mut active_text_snapshots = ActiveTextSnapshotBuffer::default(); let mut sequence = 0_u64; - let mut last_active_target: Option = None; let mut last_reader_activity = Instant::now(); loop { @@ -230,132 +244,16 @@ fn consume_stream( Ok(ReaderMessage::Activity) => { last_reader_activity = Instant::now(); } + Ok(ReaderMessage::Ping(payload)) => { + last_reader_activity = Instant::now(); + send_connection_pong(client, stream_config, &payload); + } Ok(ReaderMessage::Event(event)) => { last_reader_activity = Instant::now(); stats.raw_events = stats.raw_events.saturating_add(1); - let now = Instant::now(); - let active_target = state.lock().active_target.clone(); - let max_batch_events = if active_target.is_some() { - ACTIVE_SESSION_MAX_BATCH_EVENTS - } else { - MAX_BATCH_EVENTS - }; - let mut should_flush_active = false; - if active_target != last_active_target { - for flushed in active_text_assembler.flush_store_only_all(now) { - pending.push(flushed, stats); - } - for flushed in active_text_snapshots.flush_all() { - pending.push(flushed, stats); - } - last_active_target = active_target.clone(); - } - - let due = active_text_assembler.take_due(now); - if !due.is_empty() { - should_flush_active = true; - } - for flushed in due { - pending.push(flushed, stats); - } - - let snapshot_due = active_text_snapshots.take_due(now); - if !snapshot_due.is_empty() { - should_flush_active = true; - } - for flushed in snapshot_due { - pending.push(flushed, stats); - } - - let flushes = active_text_assembler.flush_for_event(&event, now); - if !flushes.is_empty() { - should_flush_active = true; - } - for flushed in flushes { - pending.push(flushed, stats); - } - - let snapshot_flushes = active_text_snapshots.flush_for_event(&event); - if !snapshot_flushes.is_empty() { - should_flush_active = true; - } - for flushed in snapshot_flushes { - pending.push(flushed, stats); - } - - if let Some(snapshot) = parse_active_text_snapshot(&event, active_target.as_ref()) { - active_text_snapshots.buffer(snapshot, now); - - if should_flush_active { - emit_pending_batch( - app, - generation, - &mut pending, - &mut sequence, - generation_atomic, - stats, - ); - } - - if pending.pending_len() >= max_batch_events { - emit_pending_batch( - app, - generation, - &mut pending, - &mut sequence, - generation_atomic, - stats, - ); - } - continue; - } - - if let Some(delta) = parse_active_text_delta(&event, active_target.as_ref()) { - let assembled_events = active_text_assembler.absorb(delta, now); - if !assembled_events.is_empty() { - should_flush_active = true; - } - for assembled in assembled_events { - pending.push(assembled, stats); - } - - if should_flush_active { - emit_pending_batch( - app, - generation, - &mut pending, - &mut sequence, - generation_atomic, - stats, - ); - } - - if pending.pending_len() >= max_batch_events { - emit_pending_batch( - app, - generation, - &mut pending, - &mut sequence, - generation_atomic, - stats, - ); - } - continue; - } - pending.push(event, stats); - if should_flush_active { - emit_pending_batch( - app, - generation, - &mut pending, - &mut sequence, - generation_atomic, - stats, - ); - } - if pending.pending_len() >= max_batch_events { + if pending.pending_len() >= MAX_BATCH_EVENTS { emit_pending_batch( app, generation, @@ -367,18 +265,6 @@ fn consume_stream( } } Ok(ReaderMessage::End(reason)) => { - for flushed in active_text_assembler.take_due(Instant::now()) { - pending.push(flushed, stats); - } - for flushed in active_text_assembler.flush_store_only_all(Instant::now()) { - pending.push(flushed, stats); - } - for flushed in active_text_snapshots.take_due(Instant::now()) { - pending.push(flushed, stats); - } - for flushed in active_text_snapshots.flush_all() { - pending.push(flushed, stats); - } if !pending.is_empty() { emit_pending_batch( app, @@ -394,18 +280,6 @@ fn consume_stream( Err(RecvTimeoutError::Timeout) => { if last_reader_activity.elapsed() >= Duration::from_millis(STREAM_STALL_TIMEOUT_MS) { - for flushed in active_text_assembler.take_due(Instant::now()) { - pending.push(flushed, stats); - } - for flushed in active_text_assembler.flush_store_only_all(Instant::now()) { - pending.push(flushed, stats); - } - for flushed in active_text_snapshots.take_due(Instant::now()) { - pending.push(flushed, stats); - } - for flushed in active_text_snapshots.flush_all() { - pending.push(flushed, stats); - } if !pending.is_empty() { sequence += 1; emit_batch( @@ -420,17 +294,8 @@ fn consume_stream( return Some("stream stalled".to_string()); } - for flushed in active_text_assembler.take_due(Instant::now()) { - pending.push(flushed, stats); - } - for flushed in active_text_snapshots.take_due(Instant::now()) { - pending.push(flushed, stats); - } if !pending.is_empty() { - if pending.should_hold_single_delta( - Instant::now(), - state.lock().active_target.as_ref(), - ) { + if pending.should_hold_single_delta(Instant::now()) { continue; } emit_pending_batch( @@ -444,18 +309,6 @@ fn consume_stream( } } Err(RecvTimeoutError::Disconnected) => { - for flushed in active_text_assembler.take_due(Instant::now()) { - pending.push(flushed, stats); - } - for flushed in active_text_assembler.flush_store_only_all(Instant::now()) { - pending.push(flushed, stats); - } - for flushed in active_text_snapshots.take_due(Instant::now()) { - pending.push(flushed, stats); - } - for flushed in active_text_snapshots.flush_all() { - pending.push(flushed, stats); - } if !pending.is_empty() { emit_pending_batch( app, diff --git a/packages/ui/src/lib/event-transport-contract.ts b/packages/ui/src/lib/event-transport-contract.ts index f72301288..e4d91629c 100644 --- a/packages/ui/src/lib/event-transport-contract.ts +++ b/packages/ui/src/lib/event-transport-contract.ts @@ -44,22 +44,6 @@ export interface DesktopEventsStartResult { reason?: string } -export interface DesktopEventActiveSessionTarget { - instanceId: string - sessionId: string -} - -export interface AssistantStreamChunkEvent { - type: "assistant.stream.chunk" - properties: { - sessionID: string - messageID: string - partID: string - field: "text" - delta: string - } -} - export const DEFAULT_DESKTOP_EVENT_RECONNECT_POLICY: DesktopEventTransportReconnectPolicy = { initialDelayMs: 1000, maxDelayMs: 10000, diff --git a/packages/ui/src/lib/native/desktop-events.ts b/packages/ui/src/lib/native/desktop-events.ts index b033e19a6..875a24e16 100644 --- a/packages/ui/src/lib/native/desktop-events.ts +++ b/packages/ui/src/lib/native/desktop-events.ts @@ -2,7 +2,6 @@ import { invoke } from "@tauri-apps/api/core" import { listen } from "@tauri-apps/api/event" import type { WorkspaceEventPayload } from "../../../../server/src/api-types" import type { - DesktopEventActiveSessionTarget, DesktopEventsStartResult, DesktopEventTransportStartOptions, DesktopEventTransportStatusPayload, @@ -150,14 +149,3 @@ export async function connectTauriWorkspaceEvents( }, } } - -export async function setTauriDesktopActiveSession(target: DesktopEventActiveSessionTarget | null): Promise { - try { - await invoke("desktop_events_set_active_session", { - instanceId: target?.instanceId ?? null, - sessionId: target?.sessionId ?? null, - }) - } catch (error) { - log.warn("Failed to update native desktop active session", error) - } -} diff --git a/packages/ui/src/transport-bench.tsx b/packages/ui/src/transport-bench.tsx index 84bf61de5..957012cf9 100644 --- a/packages/ui/src/transport-bench.tsx +++ b/packages/ui/src/transport-bench.tsx @@ -13,11 +13,19 @@ import { } from "./stores/sessions" const benchParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : new URLSearchParams() +const PERF242_BENCH_MODE = benchParams.get("mode") || "short" const PERF242_BENCH_FOLDER = benchParams.get("folder") || import.meta.env.VITE_PERF242_BENCH_FOLDER || "D:\\CodeNomad" -const PERF242_BENCH_SESSION_ID = benchParams.get("sessionId") || import.meta.env.VITE_PERF242_BENCH_SESSION_ID || "" +const PERF242_BENCH_SESSION_ID = + benchParams.get("sessionId") + || import.meta.env.VITE_PERF242_BENCH_SESSION_ID + || "ses_21feb15b3ffeLz3uRModK4KKnG" const PERF242_BENCH_BINARY = benchParams.get("binary") || import.meta.env.VITE_PERF242_BENCH_BINARY || "opencode" -const PERF242_BENCH_COMMAND = benchParams.get("command") || import.meta.env.VITE_PERF242_BENCH_COMMAND - || `node -e "for (let i = 1; i <= 400; i += 1) console.log('line ' + i)"` +const PERF242_SHORT_COMMAND = `node -e "for (let i = 1; i <= 400; i += 1) console.log('line ' + i)"` +const PERF242_LONG_COMMAND = `powershell -NoProfile -Command Start-Sleep -Seconds 70` +const PERF242_BENCH_COMMAND = + benchParams.get("command") + || import.meta.env.VITE_PERF242_BENCH_COMMAND + || (PERF242_BENCH_MODE === "long" ? PERF242_LONG_COMMAND : PERF242_SHORT_COMMAND) let perf242TransportBenchStarted = false @@ -120,7 +128,7 @@ export default function TransportBench() { const reachedIdle = await waitForCondition(() => { const session = getSessions(instanceId).find((value) => value.id === targetSession.id) return sawWorking ? session?.status === "idle" : false - }, 120000) + }, PERF242_BENCH_MODE === "long" ? 180000 : 120000) await emitPerf242Log({ stage: reachedIdle ? "complete" : "timeout", From 74fa1c3ad3a23299c2ef40ade9502c38222d7722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 1 May 2026 15:54:03 +0200 Subject: [PATCH 3/9] fix(bench): gate perf242 transport harness Require the explicit bench build flag before the UI harness can be activated, remove query-string command overrides, gate perf logging behind a runtime flag, and add a regression test for named SSE ping frames. --- packages/server/src/server/http-server.ts | 15 ++-- .../src/desktop_event_transport/stream.rs | 73 +++++++++++++++---- packages/ui/src/main.tsx | 7 +- packages/ui/src/transport-bench.tsx | 13 ++-- 4 files changed, 77 insertions(+), 31 deletions(-) diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 3d830376d..ad365cea7 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -88,6 +88,7 @@ export function createHttpServer(deps: HttpServerDeps) { const apiLogger = deps.logger.child({ component: "http" }) const sseLogger = deps.logger.child({ component: "sse" }) const perfLogger = deps.logger.child({ component: "perf242" }) + const perf242BenchEnabled = process.env.PERF242_TRANSPORT_BENCH === "1" const sseClients = new Set<() => void>() const registerSseClient = (cleanup: () => void) => { @@ -269,12 +270,14 @@ export function createHttpServer(deps: HttpServerDeps) { reply.code(404).send({ message: "UI bundle missing" }) }) - app.post("/api/perf-log", async (request, reply) => { - console.log("[perf242-route]", JSON.stringify(request.body ?? {})) - perfLogger.info(request.body ?? {}, "frontend perf log") - reply.code(204) - return null - }) + if (perf242BenchEnabled) { + app.post("/api/perf-log", async (request, reply) => { + console.log("[perf242-route]", JSON.stringify(request.body ?? {})) + perfLogger.info(request.body ?? {}, "frontend perf log") + reply.code(204) + return null + }) + } registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager }) registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger }) diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs index 1cd5d00b9..1992a1292 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs @@ -131,7 +131,7 @@ pub(super) fn read_sse( return; // consumer dropped — stop reading } let trimmed = line.trim_end_matches(['\r', '\n']); - if trimmed.is_empty() { + if handle_sse_line(trimmed, &mut event_name, &mut data_lines) { if flush_sse_frame(&tx, &event_name, &data_lines).is_err() { return; } @@ -139,19 +139,6 @@ pub(super) fn read_sse( data_lines.clear(); continue; } - - if trimmed.starts_with(':') { - continue; - } - - if let Some(name) = trimmed.strip_prefix("event:") { - event_name = Some(name.strip_prefix(' ').unwrap_or(name).to_string()); - continue; - } - - if let Some(data) = trimmed.strip_prefix("data:") { - data_lines.push(data.strip_prefix(' ').unwrap_or(data).to_string()); - } } Err(error) => { let _ = flush_sse_frame(&tx, &event_name, &data_lines); @@ -162,6 +149,31 @@ pub(super) fn read_sse( } } +fn handle_sse_line( + trimmed: &str, + event_name: &mut Option, + data_lines: &mut Vec, +) -> bool { + if trimmed.is_empty() { + return true; + } + + if trimmed.starts_with(':') { + return false; + } + + if let Some(name) = trimmed.strip_prefix("event:") { + *event_name = Some(name.strip_prefix(' ').unwrap_or(name).to_string()); + return false; + } + + if let Some(data) = trimmed.strip_prefix("data:") { + data_lines.push(data.strip_prefix(' ').unwrap_or(data).to_string()); + } + + false +} + fn flush_sse_frame( tx: &SyncSender, event_name: &Option, @@ -190,3 +202,36 @@ fn parse_sse_payload(lines: &[String]) -> Option { serde_json::from_str::(&payload).ok() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn named_ping_event_is_routed_to_ping_channel() { + let (tx, rx) = mpsc::sync_channel(1); + let mut event_name = None; + let mut data_lines = Vec::new(); + + assert!(!handle_sse_line( + "event: codenomad.client.ping", + &mut event_name, + &mut data_lines + )); + assert!(!handle_sse_line( + r#"data: {"ts":123}"#, + &mut event_name, + &mut data_lines + )); + assert!(handle_sse_line("", &mut event_name, &mut data_lines)); + + flush_sse_frame(&tx, &event_name, &data_lines).expect("ping frame should flush"); + + match rx.recv().expect("ping frame should be emitted") { + ReaderMessage::Ping(payload) => { + assert_eq!(payload.get("ts").and_then(Value::as_u64), Some(123)); + } + _ => panic!("expected ping frame"), + } + } +} diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx index b4e5bc946..86a29fbac 100644 --- a/packages/ui/src/main.tsx +++ b/packages/ui/src/main.tsx @@ -18,15 +18,16 @@ if (!root) { const mount = root const bootParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : new URLSearchParams() +const isPerf242TransportBenchBuild = import.meta.env.VITE_PERF242_TRANSPORT_BENCH === "1" const isPerf242TransportBench = - import.meta.env.VITE_PERF242_TRANSPORT_BENCH === "1" - || bootParams.get("perf242TransportBench") === "1" + isPerf242TransportBenchBuild + && bootParams.get("perf242TransportBench") === "1" if (typeof document !== "undefined") { document.documentElement.dataset.runtimeHost = runtimeEnv.host document.documentElement.dataset.runtimePlatform = runtimeEnv.platform - if (bootParams.get("perf242TransportBench") === "1") { + if (isPerf242TransportBench) { const payload = { stage: "frontend-bootstrap", host: runtimeEnv.host, diff --git a/packages/ui/src/transport-bench.tsx b/packages/ui/src/transport-bench.tsx index 957012cf9..290e8a956 100644 --- a/packages/ui/src/transport-bench.tsx +++ b/packages/ui/src/transport-bench.tsx @@ -13,19 +13,16 @@ import { } from "./stores/sessions" const benchParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : new URLSearchParams() -const PERF242_BENCH_MODE = benchParams.get("mode") || "short" -const PERF242_BENCH_FOLDER = benchParams.get("folder") || import.meta.env.VITE_PERF242_BENCH_FOLDER || "D:\\CodeNomad" +const PERF242_BENCH_MODE = benchParams.get("mode") === "long" ? "long" : "short" +const PERF242_BENCH_FOLDER = import.meta.env.VITE_PERF242_BENCH_FOLDER || "D:\\CodeNomad" const PERF242_BENCH_SESSION_ID = - benchParams.get("sessionId") - || import.meta.env.VITE_PERF242_BENCH_SESSION_ID + import.meta.env.VITE_PERF242_BENCH_SESSION_ID || "ses_21feb15b3ffeLz3uRModK4KKnG" -const PERF242_BENCH_BINARY = benchParams.get("binary") || import.meta.env.VITE_PERF242_BENCH_BINARY || "opencode" +const PERF242_BENCH_BINARY = import.meta.env.VITE_PERF242_BENCH_BINARY || "opencode" const PERF242_SHORT_COMMAND = `node -e "for (let i = 1; i <= 400; i += 1) console.log('line ' + i)"` const PERF242_LONG_COMMAND = `powershell -NoProfile -Command Start-Sleep -Seconds 70` const PERF242_BENCH_COMMAND = - benchParams.get("command") - || import.meta.env.VITE_PERF242_BENCH_COMMAND - || (PERF242_BENCH_MODE === "long" ? PERF242_LONG_COMMAND : PERF242_SHORT_COMMAND) + PERF242_BENCH_MODE === "long" ? PERF242_LONG_COMMAND : PERF242_SHORT_COMMAND let perf242TransportBenchStarted = false From 0049c7b21c53120e51ca046d7c8aab2e2652ad0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 1 May 2026 16:07:51 +0200 Subject: [PATCH 4/9] fix(tauri): authenticate desktop heartbeat pongs Reuse the desktop event transport session cookie for the native /api/client-connections/pong request so heartbeat updates follow the same auth contract as the SSE stream, and cover it with a request-header regression test. --- .../src/desktop_event_transport/stream.rs | 57 ++++++++++++++++--- .../src/desktop_event_transport/transport.rs | 16 ++++-- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs index 1992a1292..ef6c851f9 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/stream.rs @@ -1,4 +1,5 @@ use super::*; +use reqwest::blocking::RequestBuilder; pub(super) fn build_stream_client() -> Result { Client::builder() @@ -29,14 +30,11 @@ pub(super) fn open_stream( config.events_url, config.client_id, config.connection_id ); - let mut request = client.get(&url).header("Accept", "text/event-stream"); - - if let Some(session_cookie) = resolve_session_cookie(app, config) { - request = request.header( - "Cookie", - format!("{}={}", config.cookie_name, session_cookie), - ); - } + let request = attach_session_cookie( + client.get(&url).header("Accept", "text/event-stream"), + app, + config, + ); let response = request.send().map_err(|error| OpenStreamError { kind: OpenStreamErrorKind::Transport, @@ -68,6 +66,30 @@ fn resolve_session_cookie(app: &AppHandle, config: &DesktopEventStreamConfig) -> .filter(|value| !value.is_empty()) } +pub(super) fn attach_session_cookie( + request: RequestBuilder, + app: &AppHandle, + config: &DesktopEventStreamConfig, +) -> RequestBuilder { + attach_session_cookie_value( + request, + &config.cookie_name, + resolve_session_cookie(app, config).as_deref(), + ) +} + +fn attach_session_cookie_value( + request: RequestBuilder, + cookie_name: &str, + session_cookie: Option<&str>, +) -> RequestBuilder { + let Some(session_cookie) = session_cookie.filter(|value| !value.is_empty()) else { + return request; + }; + + request.header("Cookie", format!("{}={}", cookie_name, session_cookie)) +} + fn read_session_cookie_from_webview( app: &AppHandle, base_url: &str, @@ -234,4 +256,23 @@ mod tests { _ => panic!("expected ping frame"), } } + + #[test] + fn session_cookie_is_attached_to_requests() { + let request = attach_session_cookie_value( + Client::new().post("http://localhost/api/client-connections/pong"), + "codenomad_session", + Some("cookie-value"), + ) + .build() + .expect("request should build"); + + assert_eq!( + request + .headers() + .get("Cookie") + .and_then(|value| value.to_str().ok()), + Some("codenomad_session=cookie-value") + ); + } } diff --git a/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs b/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs index 9ecf6bfd6..5f0ed2314 100644 --- a/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs +++ b/packages/tauri-app/src-tauri/src/desktop_event_transport/transport.rs @@ -1,19 +1,25 @@ use super::*; -fn send_connection_pong(client: &Client, config: &DesktopEventStreamConfig, payload: &Value) { +fn send_connection_pong( + app: &AppHandle, + client: &Client, + config: &DesktopEventStreamConfig, + payload: &Value, +) { let body = serde_json::json!({ "clientId": config.client_id, "connectionId": config.connection_id, "pingTs": payload.get("ts").and_then(Value::as_u64), }); - let _ = client + let request = client .post(format!( "{}/api/client-connections/pong", config.base_url.trim_end_matches('/') )) - .json(&body) - .send(); + .json(&body); + + let _ = attach_session_cookie(request, app, config).send(); } pub(super) fn run_transport_loop( @@ -246,7 +252,7 @@ fn consume_stream( } Ok(ReaderMessage::Ping(payload)) => { last_reader_activity = Instant::now(); - send_connection_pong(client, stream_config, &payload); + send_connection_pong(app, client, stream_config, &payload); } Ok(ReaderMessage::Event(event)) => { last_reader_activity = Instant::now(); From 39d0ae182fa6dd5581c06e75701794ad2b191f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 9 May 2026 15:38:42 +0200 Subject: [PATCH 5/9] feat(settings): add Tauri transport fallback toggle Expose a device-level setting that lets Tauri users disable the native Rust desktop event transport and fall back to the browser EventSource path when debugging transport issues or comparing behavior. Persist the preference in UI settings, mirror it into localStorage so startup transport selection can read it synchronously, and restart the backend event stream immediately when the toggle changes so the new transport takes effect without a full app restart. Validation: npx tsc --noEmit --pretty -p packages/ui/tsconfig.json; npx tsc --noEmit --pretty -p packages/server/tsconfig.json; cargo test --no-run --- .../lib/desktop-event-transport-preference.ts | 25 ++++++++++++++++ packages/ui/src/lib/event-transport.ts | 11 +++++-- .../ui/src/lib/i18n/messages/en/settings.ts | 2 ++ .../ui/src/lib/i18n/messages/es/settings.ts | 2 ++ .../ui/src/lib/i18n/messages/fr/settings.ts | 2 ++ .../ui/src/lib/i18n/messages/he/settings.ts | 2 ++ .../ui/src/lib/i18n/messages/ja/settings.ts | 2 ++ .../src/lib/i18n/messages/zh-Hans/settings.ts | 2 ++ packages/ui/src/lib/server-events.ts | 13 ++++++++ .../ui/src/lib/settings/behavior-registry.ts | 18 ++++++++++- packages/ui/src/stores/preferences.tsx | 30 +++++++++++++++++++ 11 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/lib/desktop-event-transport-preference.ts diff --git a/packages/ui/src/lib/desktop-event-transport-preference.ts b/packages/ui/src/lib/desktop-event-transport-preference.ts new file mode 100644 index 000000000..1ee0ebcb6 --- /dev/null +++ b/packages/ui/src/lib/desktop-event-transport-preference.ts @@ -0,0 +1,25 @@ +export const TAURI_NATIVE_EVENT_TRANSPORT_STORAGE_KEY = "codenomad-use-tauri-native-event-transport" + +export function readUseTauriNativeEventTransportPreference(): boolean { + if (typeof window === "undefined") { + return true + } + + try { + return window.localStorage?.getItem(TAURI_NATIVE_EVENT_TRANSPORT_STORAGE_KEY) !== "0" + } catch { + return true + } +} + +export function writeUseTauriNativeEventTransportPreference(enabled: boolean): void { + if (typeof window === "undefined") { + return + } + + try { + window.localStorage?.setItem(TAURI_NATIVE_EVENT_TRANSPORT_STORAGE_KEY, enabled ? "1" : "0") + } catch { + // Ignore localStorage failures and keep the in-memory preference only. + } +} diff --git a/packages/ui/src/lib/event-transport.ts b/packages/ui/src/lib/event-transport.ts index f114e2537..ae0ad60a2 100644 --- a/packages/ui/src/lib/event-transport.ts +++ b/packages/ui/src/lib/event-transport.ts @@ -4,6 +4,7 @@ import { resolveDesktopEventTransportStartOptions, type DesktopEventTransportStartOptions, } from "./event-transport-contract" +import { readUseTauriNativeEventTransportPreference } from "./desktop-event-transport-preference" import { getLogger } from "./logger" import { runtimeEnv } from "./runtime-env" import { connectTauriWorkspaceEvents } from "./native/desktop-events" @@ -53,7 +54,9 @@ export async function connectWorkspaceEvents( callbacks: WorkspaceEventTransportCallbacks, options?: DesktopEventTransportStartOptions, ): Promise { - if (runtimeEnv.host === "tauri" && !shouldForceBrowserTransport()) { + const nativeDesktopTransportEnabled = readUseTauriNativeEventTransportPreference() + + if (runtimeEnv.host === "tauri" && nativeDesktopTransportEnabled && !shouldForceBrowserTransport()) { try { const conn = await connectTauriWorkspaceEvents( callbacks, @@ -66,7 +69,11 @@ export async function connectWorkspaceEvents( log.warn("Failed to start native desktop event transport, falling back to browser EventSource", error) } } else if (runtimeEnv.host === "tauri") { - log.info("Event transport: browser-eventsource forced by localStorage override") + log.info( + nativeDesktopTransportEnabled + ? "Event transport: browser-eventsource forced by localStorage override" + : "Event transport: browser-eventsource forced by settings", + ) } ;(globalThis as any).__TRANSPORT_TYPE = "browser-eventsource" diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index 35f3999c0..de4e0459d 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -155,6 +155,8 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.", "settings.behavior.keepUnseenSubagentIdle.title": "Keep subagent idle markers", "settings.behavior.keepUnseenSubagentIdle.subtitle": "Keep subagent idle markers visible until viewed instead of hiding them after 5 seconds.", + "settings.behavior.tauriNativeEventTransport.title": "Native Tauri event transport", + "settings.behavior.tauriNativeEventTransport.subtitle": "Use the Rust-native desktop event transport in Tauri. Disable this to fall back to the browser EventSource path.", "settings.behavior.promptVoiceInput.title": "Prompt voice input", "settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.", "settings.behavior.promptSubmit.title": "Enter to submit", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index a158ec511..6519d4a32 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -154,6 +154,8 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "Limpia automaticamente las sesiones en blanco al crear nuevas.", "settings.behavior.keepUnseenSubagentIdle.title": "Mantener marcadores idle de subagentes", "settings.behavior.keepUnseenSubagentIdle.subtitle": "Mantiene visibles los marcadores idle de subagentes hasta verlos, en lugar de ocultarlos despues de 5 segundos.", + "settings.behavior.tauriNativeEventTransport.title": "Transporte de eventos nativo de Tauri", + "settings.behavior.tauriNativeEventTransport.subtitle": "Usa el transporte de eventos de escritorio nativo en Rust dentro de Tauri. Desactivalo para volver a la ruta browser EventSource.", "settings.behavior.promptVoiceInput.title": "Prompt voice input", "settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.", "settings.behavior.promptSubmit.title": "Enter para enviar", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 722292878..bb3fbcecf 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -154,6 +154,8 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "Nettoyer automatiquement les sessions vides lors de la creation de nouvelles.", "settings.behavior.keepUnseenSubagentIdle.title": "Garder les marqueurs inactifs des sous-agents", "settings.behavior.keepUnseenSubagentIdle.subtitle": "Garde les marqueurs inactifs des sous-agents visibles jusqu'a consultation au lieu de les masquer apres 5 secondes.", + "settings.behavior.tauriNativeEventTransport.title": "Transport d'evenements natif Tauri", + "settings.behavior.tauriNativeEventTransport.subtitle": "Utiliser le transport d'evenements desktop natif Rust dans Tauri. Desactivez-le pour revenir au chemin browser EventSource.", "settings.behavior.promptVoiceInput.title": "Prompt voice input", "settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.", "settings.behavior.promptSubmit.title": "Entrer pour envoyer", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index cc8b59044..98ed8cbb7 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -153,6 +153,8 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "נקה אוטומטית סשנים ריקים בעת יצירת סשנים חדשים.", "settings.behavior.keepUnseenSubagentIdle.title": "השאר סמני idle של תתי-סוכנים", "settings.behavior.keepUnseenSubagentIdle.subtitle": "השאר סמני idle של תתי-סוכנים גלויים עד צפייה במקום להסתיר אותם אחרי 5 שניות.", + "settings.behavior.tauriNativeEventTransport.title": "תעבורת אירועים מקורית של Tauri", + "settings.behavior.tauriNativeEventTransport.subtitle": "השתמש בתעבורת האירועים השולחנית המקורית ב-Rust בתוך Tauri. כבה זאת כדי לחזור למסלול browser EventSource.", "settings.behavior.promptVoiceInput.title": "קלט קולי לפרומפט", "settings.behavior.promptVoiceInput.subtitle": "הצג את כפתור המיקרופון לקלט דיבור-לטקסט כאשר תכונת הקול מוגדרת.", "settings.behavior.promptSubmit.title": "Enter לשליחה", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index f764acbfc..3e1e26a8f 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -154,6 +154,8 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "新しいセッション作成時に空のセッションを自動的にクリーンアップします。", "settings.behavior.keepUnseenSubagentIdle.title": "サブエージェントの idle マーカーを保持", "settings.behavior.keepUnseenSubagentIdle.subtitle": "サブエージェントの idle マーカーを 5 秒後に隠さず、表示するまで残します。", + "settings.behavior.tauriNativeEventTransport.title": "Tauri ネイティブイベント転送", + "settings.behavior.tauriNativeEventTransport.subtitle": "Tauri で Rust ネイティブのデスクトップイベント転送を使います。無効にすると browser EventSource 経路に戻ります。", "settings.behavior.promptVoiceInput.title": "Prompt voice input", "settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.", "settings.behavior.promptSubmit.title": "Enterで送信", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index beae0a580..8287656f7 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -154,6 +154,8 @@ export const settingsMessages = { "settings.behavior.autoCleanup.subtitle": "创建新会话时自动清理空会话。", "settings.behavior.keepUnseenSubagentIdle.title": "保留子智能体 idle 标记", "settings.behavior.keepUnseenSubagentIdle.subtitle": "让子智能体 idle 标记保持可见直到查看,而不是 5 秒后隐藏。", + "settings.behavior.tauriNativeEventTransport.title": "Tauri 原生事件传输", + "settings.behavior.tauriNativeEventTransport.subtitle": "在 Tauri 中使用 Rust 原生桌面事件传输。禁用后将回退到浏览器 EventSource 路径。", "settings.behavior.promptVoiceInput.title": "Prompt voice input", "settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.", "settings.behavior.promptSubmit.title": "回车发送", diff --git a/packages/ui/src/lib/server-events.ts b/packages/ui/src/lib/server-events.ts index 0bbb1dc95..0953e5886 100644 --- a/packages/ui/src/lib/server-events.ts +++ b/packages/ui/src/lib/server-events.ts @@ -172,6 +172,19 @@ class ServerEvents { this.openHandlers.add(handler) return () => this.openHandlers.delete(handler) } + + restart(reason = "manual restart"): void { + this.retryDelay = RETRY_BASE_DELAY + this.clearReconnectTimer() + + if (this.connection) { + this.connection.disconnect() + this.connection = null + } + + logSse("Restarting backend events stream", { reason }) + void this.connect() + } } export const serverEvents = new ServerEvents() diff --git a/packages/ui/src/lib/settings/behavior-registry.ts b/packages/ui/src/lib/settings/behavior-registry.ts index 25bd13e4b..fd21ba51e 100644 --- a/packages/ui/src/lib/settings/behavior-registry.ts +++ b/packages/ui/src/lib/settings/behavior-registry.ts @@ -6,7 +6,7 @@ import type { } from "../../stores/preferences" import type { Command } from "../commands" import { tGlobal } from "../i18n" -import { isWebHost } from "../runtime-env" +import { isTauriHost, isWebHost } from "../runtime-env" export type BehaviorSettingKind = "toggle" | "enum" @@ -280,6 +280,22 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS } }, }, + ...(isTauriHost() + ? [ + { + kind: "toggle" as const, + id: "behavior.tauriNativeEventTransport", + titleKey: "settings.behavior.tauriNativeEventTransport.title", + subtitleKey: "settings.behavior.tauriNativeEventTransport.subtitle", + get: (p: Preferences) => Boolean(p.useTauriNativeEventTransport ?? true), + set: (next: boolean) => { + if (updatePreferences) { + updatePreferences({ useTauriNativeEventTransport: next }) + } + }, + }, + ] + : []), { kind: "toggle", id: "behavior.promptVoiceInput", diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index a39b07969..e86dbf6dd 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -1,5 +1,6 @@ import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js" import type { Accessor, ParentComponent } from "solid-js" +import { writeUseTauriNativeEventTransportPreference } from "../lib/desktop-event-transport-preference" import { storage, type OwnerBucket } from "../lib/storage" import type { RemoteServerProfile } from "../../../server/src/api-types" import { @@ -67,6 +68,7 @@ export interface UiSettings { showUsageMetrics: boolean autoCleanupBlankSessions: boolean keepUnseenSubagentIdleStatus: boolean + useTauriNativeEventTransport: boolean // OS notifications osNotificationsEnabled: boolean @@ -147,6 +149,7 @@ const defaultUiSettings: UiSettings = { showUsageMetrics: true, autoCleanupBlankSessions: true, keepUnseenSubagentIdleStatus: false, + useTauriNativeEventTransport: true, osNotificationsEnabled: false, osNotificationsAllowWhenVisible: false, @@ -188,6 +191,8 @@ function normalizeUiSettings(input?: Partial | null): UiSettings { autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultUiSettings.autoCleanupBlankSessions, keepUnseenSubagentIdleStatus: sanitized.keepUnseenSubagentIdleStatus ?? defaultUiSettings.keepUnseenSubagentIdleStatus, + useTauriNativeEventTransport: + sanitized.useTauriNativeEventTransport ?? defaultUiSettings.useTauriNativeEventTransport, osNotificationsEnabled: sanitized.osNotificationsEnabled ?? defaultUiSettings.osNotificationsEnabled, osNotificationsAllowWhenVisible: sanitized.osNotificationsAllowWhenVisible ?? defaultUiSettings.osNotificationsAllowWhenVisible, @@ -413,6 +418,7 @@ async function ensureLoaded(): Promise { setUiConfigBucket(uiCfg as any) setServerConfigBucket(srvCfg as any) setUiStateBucket(uiSt as any) + syncDesktopEventTransportPreference((uiCfg as UiConfigBucket | undefined)?.settings) setIsLoaded(true) }) .catch((error) => { @@ -436,6 +442,26 @@ async function patchConfigOwner(owner: string, patch: unknown) { if (owner === "server") setServerConfigBucket(updated as any) } +function syncDesktopEventTransportPreference(settings?: Partial | null) { + writeUseTauriNativeEventTransportPreference( + normalizeUiSettings(settings).useTauriNativeEventTransport, + ) +} + +function restartDesktopEventTransportPreferenceIfNeeded(previous: UiSettings, next: UiSettings) { + if (previous.useTauriNativeEventTransport === next.useTauriNativeEventTransport) { + return + } + + void import("../lib/server-events") + .then(({ serverEvents }) => { + serverEvents.restart("desktop transport preference changed") + }) + .catch((error) => { + log.error("Failed to restart backend events stream after desktop transport preference change", error) + }) +} + async function patchStateOwner(owner: string, patch: unknown) { await ensureLoaded() const updated = await storage.patchStateOwner(owner, patch) @@ -444,7 +470,10 @@ async function patchStateOwner(owner: string, patch: unknown) { function updateUiSettings(updates: Partial) { const current = uiConfigBucket() + const previousSettings = normalizeUiSettings(current.settings) const nextSettings = normalizeUiSettings({ ...(current.settings ?? {}), ...updates }) + syncDesktopEventTransportPreference(nextSettings) + restartDesktopEventTransportPreferenceIfNeeded(previousSettings, nextSettings) const patch = { settings: nextSettings } void patchConfigOwner("ui", patch).catch((error) => log.error("Failed to patch ui settings", error)) } @@ -825,6 +854,7 @@ export const ConfigProvider: ParentComponent = (props) => { const unsubUi = storage.onConfigOwnerChanged("ui", (bucket) => { setUiConfigBucket(bucket as any) + syncDesktopEventTransportPreference((bucket as UiConfigBucket | undefined)?.settings) setIsLoaded(true) }) const unsubServer = storage.onConfigOwnerChanged("server", (bucket) => { From 843a47c98d6c8741f576d30cc2454c0695233718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 10 May 2026 14:56:36 +0200 Subject: [PATCH 6/9] fix(settings): keep Tauri transport fallback local Move the Tauri native transport fallback toggle out of the shared UI config and treat it as a true device-local preference. The setting now lives only in local state plus localStorage, so browser, Electron, and other Tauri clients do not inherit a host-specific transport choice through storage broadcasts. While updating the selector, remove the hidden forceBrowserEvents override so the visible Tauri fallback setting becomes the only shipped transport-selection path outside the benchmark build gates. Validation: npx tsc --noEmit --pretty -p packages/ui/tsconfig.json; npx tsc --noEmit --pretty -p packages/server/tsconfig.json; cargo test --no-run --- packages/ui/src/App.tsx | 4 +++ .../settings/appearance-settings-section.tsx | 4 +++ packages/ui/src/lib/event-transport.ts | 22 ++---------- packages/ui/src/lib/hooks/use-commands.ts | 4 +++ .../ui/src/lib/settings/behavior-registry.ts | 8 ++--- packages/ui/src/stores/preferences.tsx | 36 +++++++++---------- 6 files changed, 36 insertions(+), 42 deletions(-) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 44f0d23fd..804375f20 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -77,6 +77,8 @@ const App: Component = () => { const { t } = useI18n() const { preferences, + useTauriNativeEventTransport, + setUseTauriNativeEventTransport, serverSettings, recordWorkspaceLaunch, toggleShowThinkingBlocks, @@ -444,6 +446,8 @@ const App: Component = () => { const { commands: paletteCommands, executeCommand } = useCommands({ preferences, + useTauriNativeEventTransport, + setUseTauriNativeEventTransport, toggleAutoCleanupBlankSessions, toggleShowThinkingBlocks, toggleKeyboardShortcutHints, diff --git a/packages/ui/src/components/settings/appearance-settings-section.tsx b/packages/ui/src/components/settings/appearance-settings-section.tsx index 281366d67..cfb039817 100644 --- a/packages/ui/src/components/settings/appearance-settings-section.tsx +++ b/packages/ui/src/components/settings/appearance-settings-section.tsx @@ -17,6 +17,8 @@ export const AppearanceSettingsSection: Component = () => { const { themeMode, setThemeMode } = useTheme() const { preferences, + useTauriNativeEventTransport, + setUseTauriNativeEventTransport, updatePreferences, toggleShowThinkingBlocks, toggleKeyboardShortcutHints, @@ -36,6 +38,8 @@ export const AppearanceSettingsSection: Component = () => { const behaviorSettings = createMemo(() => getBehaviorSettings({ preferences, + useTauriNativeEventTransport, + setUseTauriNativeEventTransport, updatePreferences, toggleShowThinkingBlocks, toggleKeyboardShortcutHints, diff --git a/packages/ui/src/lib/event-transport.ts b/packages/ui/src/lib/event-transport.ts index ae0ad60a2..cb548e728 100644 --- a/packages/ui/src/lib/event-transport.ts +++ b/packages/ui/src/lib/event-transport.ts @@ -10,7 +10,6 @@ import { runtimeEnv } from "./runtime-env" import { connectTauriWorkspaceEvents } from "./native/desktop-events" const log = getLogger("sse") -const FORCE_BROWSER_TRANSPORT_STORAGE_KEY = "perf242-force-browser-events" export interface WorkspaceEventTransportCallbacks { onBatch: (events: WorkspaceEventPayload[]) => void @@ -37,26 +36,13 @@ async function connectBrowserWorkspaceEvents( } } -function shouldForceBrowserTransport(): boolean { - if (typeof window === "undefined") return false - try { - const params = new URLSearchParams(window.location.search) - if (params.get("forceBrowserEvents") === "1") { - return true - } - return window.localStorage?.getItem(FORCE_BROWSER_TRANSPORT_STORAGE_KEY) === "1" - } catch { - return false - } -} - export async function connectWorkspaceEvents( callbacks: WorkspaceEventTransportCallbacks, options?: DesktopEventTransportStartOptions, ): Promise { const nativeDesktopTransportEnabled = readUseTauriNativeEventTransportPreference() - if (runtimeEnv.host === "tauri" && nativeDesktopTransportEnabled && !shouldForceBrowserTransport()) { + if (runtimeEnv.host === "tauri" && nativeDesktopTransportEnabled) { try { const conn = await connectTauriWorkspaceEvents( callbacks, @@ -69,11 +55,7 @@ export async function connectWorkspaceEvents( log.warn("Failed to start native desktop event transport, falling back to browser EventSource", error) } } else if (runtimeEnv.host === "tauri") { - log.info( - nativeDesktopTransportEnabled - ? "Event transport: browser-eventsource forced by localStorage override" - : "Event transport: browser-eventsource forced by settings", - ) + log.info("Event transport: browser-eventsource forced by settings") } ;(globalThis as any).__TRANSPORT_TYPE = "browser-eventsource" diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index 160cd87ff..734ceb4e7 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -29,6 +29,8 @@ function splitKeywords(key: string): string[] { export interface UseCommandsOptions { preferences: Accessor + useTauriNativeEventTransport: Accessor + setUseTauriNativeEventTransport: (next: boolean) => void toggleShowThinkingBlocks: () => void toggleKeyboardShortcutHints: () => void toggleShowMessageTimeline: () => void @@ -419,6 +421,8 @@ export function useCommands(options: UseCommandsOptions) { registerBehaviorCommands((command) => commandRegistry.register(command), { preferences: options.preferences, + useTauriNativeEventTransport: options.useTauriNativeEventTransport, + setUseTauriNativeEventTransport: options.setUseTauriNativeEventTransport, toggleShowThinkingBlocks: options.toggleShowThinkingBlocks, toggleKeyboardShortcutHints: options.toggleKeyboardShortcutHints, toggleShowMessageTimeline: options.toggleShowMessageTimeline, diff --git a/packages/ui/src/lib/settings/behavior-registry.ts b/packages/ui/src/lib/settings/behavior-registry.ts index fd21ba51e..b28229a12 100644 --- a/packages/ui/src/lib/settings/behavior-registry.ts +++ b/packages/ui/src/lib/settings/behavior-registry.ts @@ -35,6 +35,8 @@ export type BehaviorSetting = BehaviorToggleSetting | BehaviorEnumSetting export type BehaviorRegistryActions = { preferences: Accessor + useTauriNativeEventTransport: Accessor + setUseTauriNativeEventTransport: (next: boolean) => void updatePreferences?: (updates: Partial) => void toggleShowThinkingBlocks: () => void toggleKeyboardShortcutHints: () => void @@ -287,11 +289,9 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS id: "behavior.tauriNativeEventTransport", titleKey: "settings.behavior.tauriNativeEventTransport.title", subtitleKey: "settings.behavior.tauriNativeEventTransport.subtitle", - get: (p: Preferences) => Boolean(p.useTauriNativeEventTransport ?? true), + get: () => actions.useTauriNativeEventTransport(), set: (next: boolean) => { - if (updatePreferences) { - updatePreferences({ useTauriNativeEventTransport: next }) - } + actions.setUseTauriNativeEventTransport(next) }, }, ] diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index e86dbf6dd..80882930d 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -1,6 +1,9 @@ import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js" import type { Accessor, ParentComponent } from "solid-js" -import { writeUseTauriNativeEventTransportPreference } from "../lib/desktop-event-transport-preference" +import { + readUseTauriNativeEventTransportPreference, + writeUseTauriNativeEventTransportPreference, +} from "../lib/desktop-event-transport-preference" import { storage, type OwnerBucket } from "../lib/storage" import type { RemoteServerProfile } from "../../../server/src/api-types" import { @@ -68,7 +71,6 @@ export interface UiSettings { showUsageMetrics: boolean autoCleanupBlankSessions: boolean keepUnseenSubagentIdleStatus: boolean - useTauriNativeEventTransport: boolean // OS notifications osNotificationsEnabled: boolean @@ -149,7 +151,6 @@ const defaultUiSettings: UiSettings = { showUsageMetrics: true, autoCleanupBlankSessions: true, keepUnseenSubagentIdleStatus: false, - useTauriNativeEventTransport: true, osNotificationsEnabled: false, osNotificationsAllowWhenVisible: false, @@ -191,8 +192,6 @@ function normalizeUiSettings(input?: Partial | null): UiSettings { autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultUiSettings.autoCleanupBlankSessions, keepUnseenSubagentIdleStatus: sanitized.keepUnseenSubagentIdleStatus ?? defaultUiSettings.keepUnseenSubagentIdleStatus, - useTauriNativeEventTransport: - sanitized.useTauriNativeEventTransport ?? defaultUiSettings.useTauriNativeEventTransport, osNotificationsEnabled: sanitized.osNotificationsEnabled ?? defaultUiSettings.osNotificationsEnabled, osNotificationsAllowWhenVisible: sanitized.osNotificationsAllowWhenVisible ?? defaultUiSettings.osNotificationsAllowWhenVisible, @@ -393,6 +392,9 @@ const [uiConfigBucket, setUiConfigBucket] = createSignal({}) const [serverConfigBucket, setServerConfigBucket] = createSignal({}) const [uiStateBucket, setUiStateBucket] = createSignal({}) const [isLoaded, setIsLoaded] = createSignal(false) +const [useTauriNativeEventTransport, setUseTauriNativeEventTransportSignal] = createSignal( + readUseTauriNativeEventTransportPreference(), +) const uiSettings = createMemo(() => normalizeUiSettings(uiConfigBucket().settings)) const themePreference = createMemo(() => uiConfigBucket().theme ?? "system") @@ -418,7 +420,6 @@ async function ensureLoaded(): Promise { setUiConfigBucket(uiCfg as any) setServerConfigBucket(srvCfg as any) setUiStateBucket(uiSt as any) - syncDesktopEventTransportPreference((uiCfg as UiConfigBucket | undefined)?.settings) setIsLoaded(true) }) .catch((error) => { @@ -442,17 +443,14 @@ async function patchConfigOwner(owner: string, patch: unknown) { if (owner === "server") setServerConfigBucket(updated as any) } -function syncDesktopEventTransportPreference(settings?: Partial | null) { - writeUseTauriNativeEventTransportPreference( - normalizeUiSettings(settings).useTauriNativeEventTransport, - ) -} - -function restartDesktopEventTransportPreferenceIfNeeded(previous: UiSettings, next: UiSettings) { - if (previous.useTauriNativeEventTransport === next.useTauriNativeEventTransport) { +function setUseTauriNativeEventTransport(enabled: boolean): void { + if (useTauriNativeEventTransport() === enabled) { return } + setUseTauriNativeEventTransportSignal(enabled) + writeUseTauriNativeEventTransportPreference(enabled) + void import("../lib/server-events") .then(({ serverEvents }) => { serverEvents.restart("desktop transport preference changed") @@ -470,10 +468,7 @@ async function patchStateOwner(owner: string, patch: unknown) { function updateUiSettings(updates: Partial) { const current = uiConfigBucket() - const previousSettings = normalizeUiSettings(current.settings) const nextSettings = normalizeUiSettings({ ...(current.settings ?? {}), ...updates }) - syncDesktopEventTransportPreference(nextSettings) - restartDesktopEventTransportPreferenceIfNeeded(previousSettings, nextSettings) const patch = { settings: nextSettings } void patchConfigOwner("ui", patch).catch((error) => log.error("Failed to patch ui settings", error)) } @@ -743,6 +738,8 @@ void ensureLoaded().catch((error: unknown) => { interface ConfigContextValue { isLoaded: Accessor preferences: typeof preferences + useTauriNativeEventTransport: typeof useTauriNativeEventTransport + setUseTauriNativeEventTransport: typeof setUseTauriNativeEventTransport updatePreferences: typeof updatePreferences themePreference: typeof themePreference setThemePreference: typeof setThemePreference @@ -801,6 +798,8 @@ const ConfigContext = createContext() const configContextValue: ConfigContextValue = { isLoaded, preferences, + useTauriNativeEventTransport, + setUseTauriNativeEventTransport, updatePreferences, themePreference, setThemePreference, @@ -854,7 +853,6 @@ export const ConfigProvider: ParentComponent = (props) => { const unsubUi = storage.onConfigOwnerChanged("ui", (bucket) => { setUiConfigBucket(bucket as any) - syncDesktopEventTransportPreference((bucket as UiConfigBucket | undefined)?.settings) setIsLoaded(true) }) const unsubServer = storage.onConfigOwnerChanged("server", (bucket) => { @@ -888,6 +886,8 @@ export function useConfig(): ConfigContextValue { export { preferences, + useTauriNativeEventTransport, + setUseTauriNativeEventTransport, uiState, serverSettings, recentFolders, From 654b424def481b8f5bd464839d0ef13db9d57958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 16 May 2026 12:04:55 +0200 Subject: [PATCH 7/9] fix(bench): align transport harness message loading The transport benchmark harness still called loadMessages with the old boolean force flag, which broke UI typecheck after the session API moved to an options object. This keeps the PR242 benchmark code compatible with the current Pagec_tauri integration baseline instead of leaving a validation-only regression on the stack. The change is limited to the benchmark harness and preserves the existing forced reload behavior by switching to the current { force: true } call shape. This keeps the fix scoped to the branch that introduced the transport bench so downstream integrations can cherry-pick a normal commit without rewriting history. Validation: npm run typecheck --workspace @codenomad/ui --- packages/ui/src/transport-bench.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/transport-bench.tsx b/packages/ui/src/transport-bench.tsx index 290e8a956..e5e4aa581 100644 --- a/packages/ui/src/transport-bench.tsx +++ b/packages/ui/src/transport-bench.tsx @@ -101,7 +101,7 @@ export default function TransportBench() { } await emitPerf242Log({ stage: "session-selected", instanceId, sessionId: targetSession.id, parentSessionId }) - await loadMessages(instanceId, targetSession.id, true) + await loadMessages(instanceId, targetSession.id, { force: true }) await emitPerf242Log({ stage: "messages-loaded", instanceId, sessionId: targetSession.id }) await waitForMs(500) From e915985e01b1ea040f9060ce1d88635f13a65fc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 16 May 2026 17:25:56 +0200 Subject: [PATCH 8/9] refactor(tauri): remove temporary transport benchmark harness Drop the temporary in-app benchmark harness, perf logging endpoint, and batch metrics now that the native desktop transport has been validated on Windows and Linux. Keep the shipped PR focused on the transport implementation and the user-facing Tauri fallback setting rather than the instrumentation used during evaluation. The benchmark results are preserved in the PR description as historical validation, but the benchmark code itself no longer ships in the branch. Validation: npx tsc --noEmit --pretty -p packages/ui/tsconfig.json; npx tsc --noEmit --pretty -p packages/server/tsconfig.json; cargo test --no-run # Conflicts: # packages/ui/src/transport-bench.tsx --- packages/server/src/server/http-server.ts | 11 -- packages/ui/src/lib/event-transport.ts | 2 - packages/ui/src/lib/server-events.ts | 31 ----- packages/ui/src/main.tsx | 28 ---- packages/ui/src/transport-bench.tsx | 150 ---------------------- 5 files changed, 222 deletions(-) delete mode 100644 packages/ui/src/transport-bench.tsx diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index ad365cea7..faa53d3fd 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -87,8 +87,6 @@ export function createHttpServer(deps: HttpServerDeps) { const proxyLogger = deps.logger.child({ component: "proxy" }) const apiLogger = deps.logger.child({ component: "http" }) const sseLogger = deps.logger.child({ component: "sse" }) - const perfLogger = deps.logger.child({ component: "perf242" }) - const perf242BenchEnabled = process.env.PERF242_TRANSPORT_BENCH === "1" const sseClients = new Set<() => void>() const registerSseClient = (cleanup: () => void) => { @@ -270,15 +268,6 @@ export function createHttpServer(deps: HttpServerDeps) { reply.code(404).send({ message: "UI bundle missing" }) }) - if (perf242BenchEnabled) { - app.post("/api/perf-log", async (request, reply) => { - console.log("[perf242-route]", JSON.stringify(request.body ?? {})) - perfLogger.info(request.body ?? {}, "frontend perf log") - reply.code(204) - return null - }) - } - registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager }) registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger }) registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) diff --git a/packages/ui/src/lib/event-transport.ts b/packages/ui/src/lib/event-transport.ts index cb548e728..3c13c3d4c 100644 --- a/packages/ui/src/lib/event-transport.ts +++ b/packages/ui/src/lib/event-transport.ts @@ -48,7 +48,6 @@ export async function connectWorkspaceEvents( callbacks, resolveDesktopEventTransportStartOptions(options), ) - ;(globalThis as any).__TRANSPORT_TYPE = "rust-native" log.info("Event transport: rust-native (desktop_event_transport)") return conn } catch (error) { @@ -58,7 +57,6 @@ export async function connectWorkspaceEvents( log.info("Event transport: browser-eventsource forced by settings") } - ;(globalThis as any).__TRANSPORT_TYPE = "browser-eventsource" log.info(`Event transport: browser-eventsource (host=${runtimeEnv.host})`) return connectBrowserWorkspaceEvents(callbacks) } diff --git a/packages/ui/src/lib/server-events.ts b/packages/ui/src/lib/server-events.ts index 0953e5886..52545b356 100644 --- a/packages/ui/src/lib/server-events.ts +++ b/packages/ui/src/lib/server-events.ts @@ -9,30 +9,6 @@ const RETRY_BASE_DELAY = 1000 const RETRY_MAX_DELAY = 10000 const log = getLogger("sse") -type Perf242ServerEventMetrics = { - batchesReceived: number - eventsReceived: number - maxBatchSize: number -} - -let perf242ServerEventMetrics: Perf242ServerEventMetrics = { - batchesReceived: 0, - eventsReceived: 0, - maxBatchSize: 0, -} - -export function resetPerf242ServerEventMetrics() { - perf242ServerEventMetrics = { - batchesReceived: 0, - eventsReceived: 0, - maxBatchSize: 0, - } -} - -export function getPerf242ServerEventMetrics(): Perf242ServerEventMetrics { - return { ...perf242ServerEventMetrics } -} - function logSse(message: string, context?: Record) { if (context) { log.info(message, context) @@ -144,13 +120,6 @@ class ServerEvents { return } - perf242ServerEventMetrics.batchesReceived += 1 - perf242ServerEventMetrics.eventsReceived += events.length - perf242ServerEventMetrics.maxBatchSize = Math.max( - perf242ServerEventMetrics.maxBatchSize, - events.length, - ) - logSse("event batch", { size: events.length }) solidBatch(() => { for (const event of events) { diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx index 86a29fbac..4be1fc57e 100644 --- a/packages/ui/src/main.tsx +++ b/packages/ui/src/main.tsx @@ -1,6 +1,5 @@ import { render } from "solid-js/web" import App from "./App" -import TransportBench from "./transport-bench" import { ThemeProvider } from "./lib/theme" import { ConfigProvider } from "./stores/preferences" import { InstanceConfigProvider } from "./stores/instance-config" @@ -17,36 +16,10 @@ if (!root) { } const mount = root -const bootParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : new URLSearchParams() -const isPerf242TransportBenchBuild = import.meta.env.VITE_PERF242_TRANSPORT_BENCH === "1" -const isPerf242TransportBench = - isPerf242TransportBenchBuild - && bootParams.get("perf242TransportBench") === "1" if (typeof document !== "undefined") { document.documentElement.dataset.runtimeHost = runtimeEnv.host document.documentElement.dataset.runtimePlatform = runtimeEnv.platform - - if (isPerf242TransportBench) { - const payload = { - stage: "frontend-bootstrap", - host: runtimeEnv.host, - search: window.location.search, - } - - void fetch("/api/perf-log", { - method: "POST", - headers: { "content-type": "application/json" }, - credentials: "include", - body: JSON.stringify(payload), - keepalive: true, - }).catch(() => { - console.info("[perf242] frontend-bootstrap", { - host: runtimeEnv.host, - search: window.location.search, - }) - }) - } } async function bootstrap() { @@ -81,7 +54,6 @@ async function bootstrap() { - {isPerf242TransportBench ? : null} diff --git a/packages/ui/src/transport-bench.tsx b/packages/ui/src/transport-bench.tsx deleted file mode 100644 index e5e4aa581..000000000 --- a/packages/ui/src/transport-bench.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { onMount } from "solid-js" -import { runtimeEnv } from "./lib/runtime-env" -import { getPerf242ServerEventMetrics, resetPerf242ServerEventMetrics } from "./lib/server-events" -import { selectInstanceTab } from "./stores/app-tabs" -import { createInstance, instances } from "./stores/instances" -import { - fetchSessions, - getSessions, - loadMessages, - runShellCommand, - setActiveParentSession, - setActiveSession, -} from "./stores/sessions" - -const benchParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : new URLSearchParams() -const PERF242_BENCH_MODE = benchParams.get("mode") === "long" ? "long" : "short" -const PERF242_BENCH_FOLDER = import.meta.env.VITE_PERF242_BENCH_FOLDER || "D:\\CodeNomad" -const PERF242_BENCH_SESSION_ID = - import.meta.env.VITE_PERF242_BENCH_SESSION_ID - || "ses_21feb15b3ffeLz3uRModK4KKnG" -const PERF242_BENCH_BINARY = import.meta.env.VITE_PERF242_BENCH_BINARY || "opencode" -const PERF242_SHORT_COMMAND = `node -e "for (let i = 1; i <= 400; i += 1) console.log('line ' + i)"` -const PERF242_LONG_COMMAND = `powershell -NoProfile -Command Start-Sleep -Seconds 70` -const PERF242_BENCH_COMMAND = - PERF242_BENCH_MODE === "long" ? PERF242_LONG_COMMAND : PERF242_SHORT_COMMAND - -let perf242TransportBenchStarted = false - -function waitForMs(delayMs: number): Promise { - return new Promise((resolve) => window.setTimeout(resolve, delayMs)) -} - -async function waitForCondition(predicate: () => boolean, timeoutMs = 15000): Promise { - const start = performance.now() - while (performance.now() - start < timeoutMs) { - if (predicate()) return true - await waitForMs(100) - } - return predicate() -} - -async function emitPerf242Log(payload: Record): Promise { - console.info("[perf242]", payload) - try { - await fetch("/api/perf-log", { - method: "POST", - headers: { "content-type": "application/json" }, - credentials: "include", - body: JSON.stringify(payload), - keepalive: true, - }) - } catch (error) { - console.warn("[perf242] failed to emit server log", { host: runtimeEnv.host, error }) - } -} - -export default function TransportBench() { - onMount(() => { - if (perf242TransportBenchStarted) return - perf242TransportBenchStarted = true - - void (async () => { - await emitPerf242Log({ - stage: "bench-init", - host: runtimeEnv.host, - folder: PERF242_BENCH_FOLDER, - sessionId: PERF242_BENCH_SESSION_ID, - }) - - if (!PERF242_BENCH_SESSION_ID) { - await emitPerf242Log({ stage: "bench-skipped", reason: "missing-session-id" }) - return - } - - let instanceId = Array.from(instances().values()).find((instance) => instance.folder === PERF242_BENCH_FOLDER)?.id - if (!instanceId) { - await emitPerf242Log({ stage: "create-instance", folder: PERF242_BENCH_FOLDER, binary: PERF242_BENCH_BINARY }) - instanceId = await createInstance(PERF242_BENCH_FOLDER, PERF242_BENCH_BINARY) - } - - selectInstanceTab(instanceId) - await emitPerf242Log({ stage: "instance-ready", instanceId }) - await fetchSessions(instanceId) - await emitPerf242Log({ stage: "sessions-fetched", instanceId, sessionCount: getSessions(instanceId).length }) - - const targetSession = getSessions(instanceId).find((session) => session.id === PERF242_BENCH_SESSION_ID) - if (!targetSession) { - await emitPerf242Log({ - stage: "bench-error", - reason: "session-not-found", - instanceId, - sessionId: PERF242_BENCH_SESSION_ID, - }) - return - } - - const parentSessionId = targetSession.parentId ?? targetSession.id - setActiveParentSession(instanceId, parentSessionId) - if (targetSession.id !== parentSessionId) { - setActiveSession(instanceId, targetSession.id) - } - - await emitPerf242Log({ stage: "session-selected", instanceId, sessionId: targetSession.id, parentSessionId }) - await loadMessages(instanceId, targetSession.id, { force: true }) - await emitPerf242Log({ stage: "messages-loaded", instanceId, sessionId: targetSession.id }) - await waitForMs(500) - - resetPerf242ServerEventMetrics() - await emitPerf242Log({ - stage: "start", - folder: PERF242_BENCH_FOLDER, - sessionId: targetSession.id, - transportType: (globalThis as any).__TRANSPORT_TYPE ?? "unknown", - command: PERF242_BENCH_COMMAND, - }) - - const startedAt = performance.now() - await runShellCommand(instanceId, targetSession.id, PERF242_BENCH_COMMAND) - - const sawWorking = await waitForCondition(() => { - const session = getSessions(instanceId).find((value) => value.id === targetSession.id) - return session?.status === "working" - }, 10000) - - const reachedIdle = await waitForCondition(() => { - const session = getSessions(instanceId).find((value) => value.id === targetSession.id) - return sawWorking ? session?.status === "idle" : false - }, PERF242_BENCH_MODE === "long" ? 180000 : 120000) - - await emitPerf242Log({ - stage: reachedIdle ? "complete" : "timeout", - sessionId: targetSession.id, - instanceId, - transportType: (globalThis as any).__TRANSPORT_TYPE ?? "unknown", - elapsedMs: Math.round((performance.now() - startedAt) * 10) / 10, - sawWorking, - reachedIdle, - metrics: getPerf242ServerEventMetrics(), - }) - })().catch(async (error) => { - await emitPerf242Log({ - stage: "error", - error: error instanceof Error ? error.stack ?? error.message : String(error), - }) - throw error - }) - }) - - return null -} From 8e50a8ea5df481300a96a0c7c4b68b37b216b4a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 16 May 2026 18:00:24 +0200 Subject: [PATCH 9/9] fix(ui): ignore stale event stream connect failures Guard the async event-stream connect failure path with the same generation check used by the success callbacks so an older rejected connection attempt cannot tear down a newer healthy stream. This keeps Tauri restarts and other reconnect races from disconnecting the active transport after the local desktop transport preference changes or another connection attempt wins first. Validation: npx tsc --noEmit --pretty -p packages/ui/tsconfig.json; npx tsc --noEmit --pretty -p packages/server/tsconfig.json --- packages/ui/src/lib/server-events.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ui/src/lib/server-events.ts b/packages/ui/src/lib/server-events.ts index 52545b356..8f011011d 100644 --- a/packages/ui/src/lib/server-events.ts +++ b/packages/ui/src/lib/server-events.ts @@ -76,6 +76,10 @@ class ServerEvents { this.connection = connection } catch (error) { + if (generation !== this.connectGeneration) { + return + } + logSse("Events stream failed to connect, scheduling reconnect", { error: error instanceof Error ? error.message : String(error), })