From 4692d72d77ec040a7d6405bba642d32b8a3c1795 Mon Sep 17 00:00:00 2001 From: Phlabry Date: Sun, 27 Sep 2026 02:22:19 -0500 Subject: [PATCH 1/2] Issue #179 solved: report TCP connect failures to the GUI A failed TcpStream::connect only reached stderr, and the parent kept the dead thread handle, so every later Connect TCP was rejected with "TCP thread already exists" until Disconnect TCP was pressed. The thread now sends Disconnected with the reason, the update handler shows it in the error banner, and reap_tcp_thread clears the dead handle so a retry works. --- README.md | 2 +- relay/src/state.rs | 23 +++++++++++- relay/src/update.rs | 91 ++++++++++++++++++++++++++++++++++++--------- 3 files changed, 96 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index b625082..0ebd8ad 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ In the relay's window: | Symptom | Cause | What to do | |---|---|---| -| `Connect TCP` appears to do nothing and the status stays `false` | The connection failed. No error is displayed, and pressing `Connect TCP` again fails silently because the previous thread still exists. | Press `Disconnect TCP` first, then `Connect TCP`. This is the most common first-time snag. | +| `Connect TCP` fails and the status stays `false` | Nothing is listening at that address, or the address is wrong | Read the reason in the error banner and the event log, fix the address, then press `Connect TCP` again. `Disconnect TCP` first is no longer needed | | `:( No Baton Connection` | The plugin is not loaded, or its window is closed | Check `baton_debug.log` in your temp folder (`%TEMP%` on Windows). Every connection attempt is logged there | | Data stops part-way through a flight | The "Positional Flight Data" window was closed or hidden (Windows/Linux only) | Reopen it | | The relay runs but never picks up the plugin | The socket is already in use. A second relay instance, or a stale `baton.sock`. The relay logs this once, then gives up silently. | `Disconnect IPC`, then `Connect IPC`. Or restart the relay | diff --git a/relay/src/state.rs b/relay/src/state.rs index d15ad31..27c8010 100644 --- a/relay/src/state.rs +++ b/relay/src/state.rs @@ -553,8 +553,14 @@ impl State { stream } Err(e) => { - human_log("TCP", &format!("Connection failed: {}", e)); - bail!("Failed to connect to TCP"); + let reason = format!("TCP connection failed: {}", e); + human_log("TCP", &reason); + let _ = child_bichannel.set_is_conn_to_endpoint(false); + // Report it so the GUI can show the failure and drop this + // thread, instead of the error only reaching stderr. + let _ = child_bichannel + .send_to_parent(FromTcpThreadMessage::Disconnected(reason.clone())); + bail!(reason); } }; @@ -679,6 +685,19 @@ impl State { Ok(res?) } + // Drops a TCP thread that has already exited, so tcp_connect is allowed + // to start a new one. The join runs detached because the thread returned + // an error and its Result is of no use here. + pub fn reap_tcp_thread(&mut self) { + if let Some(handle) = self.tcp_thread_handle.take() { + spawn(move || { + let _ = handle.join(); + }); + } + self.tcp_bichannel = None; + self.tcp_connected = false; + } + pub fn is_tcp_connected(&self) -> bool { if let Some(status) = self .tcp_bichannel diff --git a/relay/src/update.rs b/relay/src/update.rs index 84f1abe..378a87b 100644 --- a/relay/src/update.rs +++ b/relay/src/update.rs @@ -67,23 +67,30 @@ pub(crate) fn update(state: &mut State, message: Message) -> Task { } // process TCP messages - if let Some(tcp_bichannel) = &state.tcp_bichannel { - for msg in tcp_bichannel.received_messages() { - match msg { - FromTcpThreadMessage::Connected => { - state.log_event("TCP connected to iMotions".into()); - state.tcp_connected = true; - } - FromTcpThreadMessage::Disconnected(reason) => { - state.log_event(format!("TCP disconnected: {}", reason)); - state.tcp_connected = false; - } - FromTcpThreadMessage::Sent(bytes) => { - state.on_tcp_packet_sent(bytes); - } - FromTcpThreadMessage::SendError(err) => { - state.log_event(format!("TCP send error: {}", err)); - } + let tcp_messages = state + .tcp_bichannel + .as_ref() + .map(|bichannel| bichannel.received_messages()) + .unwrap_or_default(); + for msg in tcp_messages { + match msg { + FromTcpThreadMessage::Connected => { + state.log_event("TCP connected to iMotions".into()); + state.tcp_connected = true; + } + FromTcpThreadMessage::Disconnected(reason) => { + state.log_event(format!("TCP disconnected: {}", reason)); + state.error_message = Some(reason); + state.tcp_connected = false; + // The thread has exited. Clear it so Connect TCP works + // again without pressing Disconnect TCP first. + state.reap_tcp_thread(); + } + FromTcpThreadMessage::Sent(bytes) => { + state.on_tcp_packet_sent(bytes); + } + FromTcpThreadMessage::SendError(err) => { + state.log_event(format!("TCP send error: {}", err)); } } } @@ -346,6 +353,56 @@ mod tests { let _ = state.tcp_disconnect(); } + // Pumps the Update message the GUI subscription would send, so queued + // thread messages are processed. + fn pump_update(state: &mut State, ticks: usize) { + for _ in 0..ticks { + let _ = update(state, Message::Update); + sleep(StdDuration::from_millis(10)); + } + } + + // A port nothing listens on, obtained by binding and then dropping. + fn closed_port_addr() -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind failed"); + let addr = listener.local_addr().expect("local_addr failed").to_string(); + drop(listener); + addr + } + + #[test] + fn failed_connect_surfaces_an_error_and_allows_a_retry() { + let mut state = State::default(); + state + .tcp_connect(closed_port_addr()) + .expect("tcp_connect should spawn a thread"); + + pump_update(&mut state, 200); + + let error = state.error_message.clone().expect("no error surfaced"); + assert!( + error.starts_with("TCP connection failed:"), + "unexpected error: {error}" + ); + assert!(!state.tcp_connected); + assert!(state.tcp_thread_handle.is_none(), "dead thread was kept"); + assert!( + state.event_log.iter().any(|e| e.contains("TCP disconnected")), + "failure missing from the event log: {:?}", + state.event_log + ); + + // The retry is the point: this used to fail with "TCP thread already + // exists" until Disconnect TCP was pressed. + let listener = TcpListener::bind("127.0.0.1:0").expect("bind failed"); + let addr = listener.local_addr().expect("local_addr failed").to_string(); + state.tcp_connect(addr).expect("retry was rejected"); + let _ = listener.accept().expect("accept failed"); + assert!(wait_until(|| state.is_tcp_connected()), "retry never connected"); + + let _ = state.tcp_disconnect(); + } + #[test] fn send_packet_without_tcp_reports_an_error() { let mut state = State::default(); From cbdf947dc2e7b709fa7b91530ba23312ea352022 Mon Sep 17 00:00:00 2001 From: Phlabry Date: Sun, 27 Sep 2026 23:59:33 -0500 Subject: [PATCH 2/2] Address review on #179: clear the failure banner after a retry The banner set from FromTcpThreadMessage::Disconnected stayed on screen after a later connect succeeded, so the window could show "TCP connection failed: Connection refused" while TCP was connected. tcp_connect now clears error_message once the thread spawns, which covers both the Connect TCP button and a saved address picked from the dropdown. The retry test asserts the banner is gone, and it fails without the change. --- relay/src/state.rs | 3 +++ relay/src/update.rs | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/relay/src/state.rs b/relay/src/state.rs index 27c8010..84dcf30 100644 --- a/relay/src/state.rs +++ b/relay/src/state.rs @@ -659,6 +659,9 @@ impl State { }); self.tcp_thread_handle = Some(tcp_thread_handle); + // Clear any banner left by an earlier failure, so a successful retry + // does not keep showing the old error. + self.error_message = None; Ok(()) } diff --git a/relay/src/update.rs b/relay/src/update.rs index 378a87b..c9ae4fb 100644 --- a/relay/src/update.rs +++ b/relay/src/update.rs @@ -399,6 +399,11 @@ mod tests { state.tcp_connect(addr).expect("retry was rejected"); let _ = listener.accept().expect("accept failed"); assert!(wait_until(|| state.is_tcp_connected()), "retry never connected"); + assert!( + state.error_message.is_none(), + "stale failure banner after a successful retry: {:?}", + state.error_message + ); let _ = state.tcp_disconnect(); }