fix(socket): follow HTTP 3xx during WebSocket handshake (OPENHUMAN-TAURI-9X) (#1547)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
oxoxDev
2026-05-12 19:57:18 -07:00
committed by GitHub
co-authored by Claude Opus 4.7 Steven Enamakel
parent ad11a08e54
commit 29f4b02d3b
4 changed files with 449 additions and 9 deletions
+1
View File
@@ -244,6 +244,7 @@ mod tests {
webhook_router: RwLock::new(None),
status: RwLock::new(ConnectionStatus::Disconnected),
socket_id: RwLock::new(None),
error: RwLock::new(None),
})
}
+22 -1
View File
@@ -51,6 +51,10 @@ pub(super) struct SharedState {
pub(super) status: RwLock<ConnectionStatus>,
/// Socket ID assigned by the server.
pub(super) socket_id: RwLock<Option<String>>,
/// Last user-visible connection warning surfaced through `SocketState.error`
/// (e.g. "backend redirected ws→wss; update BACKEND_URL"). Cleared on every
/// successful handshake and on disconnect.
pub(super) error: RwLock<Option<String>>,
}
// ---------------------------------------------------------------------------
@@ -82,6 +86,7 @@ impl SocketManager {
webhook_router: RwLock::new(None),
status: RwLock::new(ConnectionStatus::Disconnected),
socket_id: RwLock::new(None),
error: RwLock::new(None),
}),
emit_tx: tokio::sync::Mutex::new(None),
shutdown_tx: tokio::sync::Mutex::new(None),
@@ -105,7 +110,7 @@ impl SocketManager {
SocketState {
status: *self.shared.status.read(),
socket_id: self.shared.socket_id.read().clone(),
error: None,
error: self.shared.error.read().clone(),
}
}
@@ -133,6 +138,7 @@ impl SocketManager {
log::info!("[socket] Connecting to {}", url);
*self.shared.status.write() = ConnectionStatus::Connecting;
*self.shared.error.write() = None;
emit_state_change(&self.shared);
let (emit_tx, emit_rx) = mpsc::unbounded_channel::<String>();
@@ -165,6 +171,7 @@ impl SocketManager {
}
*self.shared.status.write() = ConnectionStatus::Disconnected;
*self.shared.socket_id.write() = None;
*self.shared.error.write() = None;
emit_state_change(&self.shared);
log::debug!("[socket] Disconnected");
Ok(())
@@ -247,6 +254,18 @@ mod tests {
assert_eq!(state.socket_id.as_deref(), Some("sid-abc"));
}
#[test]
fn get_state_surfaces_stored_error_to_callers() {
let mgr = SocketManager::new();
*mgr.shared.error.write() =
Some("backend redirected ws→wss; update BACKEND_URL".to_string());
let state = mgr.get_state();
assert_eq!(
state.error.as_deref(),
Some("backend redirected ws→wss; update BACKEND_URL")
);
}
#[tokio::test]
async fn emit_without_connection_errors_without_panic() {
let mgr = SocketManager::new();
@@ -269,6 +288,7 @@ mod tests {
webhook_router: RwLock::new(None),
status: RwLock::new(ConnectionStatus::Connecting),
socket_id: RwLock::new(None),
error: RwLock::new(None),
};
// Must not panic even with all default state.
emit_state_change(&shared);
@@ -280,6 +300,7 @@ mod tests {
webhook_router: RwLock::new(None),
status: RwLock::new(ConnectionStatus::Connected),
socket_id: RwLock::new(Some("x".into())),
error: RwLock::new(None),
};
// Pure logging — must not touch state or panic.
emit_server_event(&shared, "any.event", json!({}));
+180 -8
View File
@@ -6,7 +6,10 @@ use futures_util::{SinkExt, StreamExt};
use serde_json::json;
use tokio::sync::{mpsc, watch};
use tokio::time::{Duration, Instant};
use tokio_tungstenite::{connect_async, tungstenite::Message as WsMessage};
use tokio_tungstenite::{
connect_async,
tungstenite::{http::StatusCode, Error as WsError, Message as WsMessage},
};
use crate::api::models::socket::ConnectionStatus;
@@ -14,6 +17,14 @@ use super::event_handlers::{handle_sio_event, parse_sio_event};
use super::manager::{emit_state_change, SharedState};
use super::types::{ConnectionOutcome, WsStream};
/// Maximum HTTP redirect hops to follow during a single WebSocket connect attempt.
///
/// Cloudflare and similar edges return a single 301 (e.g. when the configured
/// `BACKEND_URL` is `http://...` and the server only serves the upgrade over TLS)
/// before the upgrade succeeds. Three hops is enough headroom for chained
/// redirects while still bounding pathological loops.
const MAX_REDIRECT_HOPS: u8 = 3;
// ---------------------------------------------------------------------------
// Background loop
// ---------------------------------------------------------------------------
@@ -30,6 +41,13 @@ pub(super) async fn ws_loop(
let mut backoff = Duration::from_millis(1000);
let max_backoff = Duration::from_secs(30);
// `ws_url` is the *resolved* socket URL we're currently connecting to.
// If the backend responds with an HTTP 3xx during the upgrade (typical when
// BACKEND_URL is configured as `http://` and the edge forces TLS), we
// follow the Location header and pin the resolved URL here so subsequent
// reconnects skip the redirect round-trip entirely.
let mut ws_url = crate::api::socket::websocket_url(&url);
loop {
if *shutdown_rx.borrow() {
break;
@@ -40,7 +58,7 @@ pub(super) async fn ws_loop(
emit_state_change(&shared);
let outcome = run_connection(
&url,
&mut ws_url,
&token,
&shared,
&mut emit_rx,
@@ -93,21 +111,27 @@ pub(super) async fn ws_loop(
// ---------------------------------------------------------------------------
/// Run a single WebSocket connection through handshake and event loop.
///
/// `ws_url` is taken by mutable reference so that any HTTP redirect we follow
/// during the upgrade (see `connect_with_redirects`) is pinned for the next
/// reconnect attempt — we don't want to re-hit the redirect every time the
/// loop backs off and retries.
async fn run_connection(
url: &str,
ws_url: &mut String,
token: &str,
shared: &Arc<SharedState>,
emit_rx: &mut mpsc::UnboundedReceiver<String>,
shutdown_rx: &mut watch::Receiver<bool>,
internal_tx: &mpsc::UnboundedSender<String>,
) -> ConnectionOutcome {
// 1. Build WebSocket URL (appends /socket.io/?EIO=4&transport=websocket)
let ws_url = crate::api::socket::websocket_url(url);
log::info!("[socket] WS URL: {}", ws_url);
// 2. Connect via WebSocket (uses rustls TLS for wss://)
let (ws_stream, _response) = match connect_async(&ws_url).await {
Ok(r) => r,
// 2. Connect via WebSocket (uses rustls TLS for wss://). Follow HTTP 3xx
// redirects up to MAX_REDIRECT_HOPS so a `http://` config behind a
// Cloudflare-style edge that 301s to `https://` connects cleanly
// instead of looping at error-level forever.
let ws_stream = match connect_with_redirects(ws_url, shared).await {
Ok(stream) => stream,
Err(e) => return ConnectionOutcome::Failed(format!("WebSocket connect: {e}")),
};
@@ -404,6 +428,154 @@ fn handle_sio_packet(
}
}
// ---------------------------------------------------------------------------
// Redirect-following connect
// ---------------------------------------------------------------------------
/// Connect to `ws_url`, following HTTP 3xx redirects up to `MAX_REDIRECT_HOPS`.
///
/// Plain `connect_async` returns an error on any non-`101 Switching Protocols`
/// response, so a Cloudflare-style `http://… → https://…` 301 (which happens
/// whenever `BACKEND_URL` is configured without TLS) used to be fatal — the
/// reconnect loop would hammer the same dead URL forever at error level.
///
/// On each redirect we:
/// 1. resolve the `Location` header against the current URL (handles relative
/// Location values),
/// 2. upgrade the scheme so the next attempt is still a WebSocket
/// (`http` → `ws`, `https` → `wss`; `ws`/`wss` pass through),
/// 3. mutate `ws_url` in place so the redirect target is pinned for
/// subsequent reconnects (no need to re-hit the redirect every retry),
/// 4. record a one-shot warning in `SharedState.error` the first time we
/// follow a redirect so the UI can surface "your `BACKEND_URL` is stale".
///
/// On non-redirect failures the original error is returned and the caller
/// counts it toward the exponential backoff like before.
async fn connect_with_redirects(
ws_url: &mut String,
shared: &Arc<SharedState>,
) -> Result<WsStream, WsError> {
let original = ws_url.clone();
for hop in 0..=MAX_REDIRECT_HOPS {
match connect_async(ws_url.as_str()).await {
Ok((stream, _response)) => return Ok(stream),
Err(WsError::Http(response)) if is_redirect_status(response.status()) => {
if hop == MAX_REDIRECT_HOPS {
log::error!(
"[socket] Exceeded {MAX_REDIRECT_HOPS} redirect hops starting from {original}; giving up"
);
return Err(WsError::Http(response));
}
let location = match extract_location_header(&response) {
Some(loc) => loc,
None => {
log::error!(
"[socket] Redirect {} from {ws_url} missing Location header",
response.status()
);
return Err(WsError::Http(response));
}
};
let next_url = match resolve_redirect_target(ws_url, &location) {
Ok(url) => url,
Err(e) => {
log::error!(
"[socket] Cannot follow redirect to {location} from {ws_url}: {e}"
);
return Err(WsError::Http(response));
}
};
log::warn!(
"[socket] Server redirected ({}) {} → {}",
response.status(),
ws_url,
next_url
);
// Only persist a stale-BACKEND_URL warning for permanent
// redirects (301 / 308). Temporary redirects (302 / 307) say
// "this time, go elsewhere" — the configured BACKEND_URL is
// still correct, and surfacing a "please update config" hint
// for a transient hop would be misleading. Per CodeRabbit
// review on PR #1547.
if matches!(
response.status(),
StatusCode::MOVED_PERMANENTLY | StatusCode::PERMANENT_REDIRECT
) {
record_redirect_warning(shared, &original, &next_url);
}
*ws_url = next_url;
}
Err(e) => return Err(e),
}
}
// Unreachable: the loop either returns Ok, returns the redirect error after
// exhausting hops, or returns a non-redirect Err.
unreachable!("connect_with_redirects exited loop without returning")
}
/// Statuses we treat as "follow the Location and retry".
///
/// 308 (Permanent Redirect) and 307 (Temporary Redirect) explicitly preserve
/// the method; 301/302 historically do too for upgrade requests in practice.
/// Anything else (300, 304, ...) stays an error.
fn is_redirect_status(status: StatusCode) -> bool {
matches!(
status,
StatusCode::MOVED_PERMANENTLY
| StatusCode::FOUND
| StatusCode::TEMPORARY_REDIRECT
| StatusCode::PERMANENT_REDIRECT
)
}
fn extract_location_header(
response: &tokio_tungstenite::tungstenite::http::Response<Option<Vec<u8>>>,
) -> Option<String> {
response
.headers()
.get(tokio_tungstenite::tungstenite::http::header::LOCATION)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
}
/// Resolve `location` against `current_ws_url` and rewrite the scheme so the
/// result is still a valid WebSocket URL.
///
/// `location` may be absolute (`https://host/path?q=1`) or relative
/// (`/socket.io/?EIO=4`). We use the `url` crate's relative-URL parser to do
/// the join the same way browsers do, then map `http`→`ws` / `https`→`wss`.
fn resolve_redirect_target(current_ws_url: &str, location: &str) -> Result<String, String> {
let base = url::Url::parse(current_ws_url).map_err(|e| format!("invalid current URL: {e}"))?;
let resolved = base
.join(location)
.map_err(|e| format!("invalid Location {location:?}: {e}"))?;
let upgraded_scheme = match resolved.scheme() {
"http" => "ws",
"https" => "wss",
"ws" | "wss" => resolved.scheme(),
other => return Err(format!("unsupported scheme in Location: {other}")),
};
let mut next = resolved.clone();
next.set_scheme(upgraded_scheme)
.map_err(|_| format!("failed to set scheme {upgraded_scheme} on {resolved}"))?;
Ok(next.to_string())
}
/// Persist a one-shot, user-visible warning that the backend redirected the
/// configured socket URL. Subsequent redirects in the same connect attempt
/// don't overwrite — the first hop carries the actionable signal.
fn record_redirect_warning(shared: &Arc<SharedState>, original: &str, resolved: &str) {
let mut slot = shared.error.write();
if slot.is_some() {
return;
}
*slot = Some(format!(
"Backend redirected {original}{resolved}. Update BACKEND_URL to the resolved URL to avoid the extra hop."
));
}
#[cfg(test)]
#[path = "ws_loop_tests.rs"]
mod tests;
+246
View File
@@ -1,14 +1,125 @@
use super::*;
use parking_lot::RwLock;
use tokio_tungstenite::tungstenite::http::{header::LOCATION, Response, StatusCode};
fn make_shared() -> Arc<SharedState> {
Arc::new(SharedState {
webhook_router: RwLock::new(None),
status: RwLock::new(ConnectionStatus::Connected),
socket_id: RwLock::new(None),
error: RwLock::new(None),
})
}
// ── Redirect resolution (the real fix for OPENHUMAN-TAURI-9X) ──
#[test]
fn resolve_redirect_upgrades_http_to_ws_for_absolute_location() {
// Cloudflare's exact behaviour: ws://host/path → 301 Location: https://host:443/path.
// We must rewrite https→wss so connect_async sees a WebSocket URL.
let next = resolve_redirect_target(
"ws://api.tinyhumans.ai/socket.io/?EIO=4&transport=websocket",
"https://api.tinyhumans.ai:443/socket.io/?EIO=4&transport=websocket",
)
.expect("scheme upgrade");
assert!(
next.starts_with("wss://api.tinyhumans.ai"),
"expected wss:// after upgrade, got {next}"
);
assert!(next.contains("/socket.io/?EIO=4&transport=websocket"));
}
#[test]
fn resolve_redirect_handles_relative_location_against_current_url() {
// RFC 7230 allows a relative Location — must be resolved against the
// request URL, not treated as an error.
let next = resolve_redirect_target(
"ws://api.example.com/socket.io/?EIO=4&transport=websocket",
"/v2/socket.io/?EIO=4&transport=websocket",
)
.expect("relative resolve");
assert_eq!(
next,
"ws://api.example.com/v2/socket.io/?EIO=4&transport=websocket"
);
}
#[test]
fn resolve_redirect_preserves_ws_and_wss_schemes_verbatim() {
let next = resolve_redirect_target("wss://a.example/socket.io/", "wss://b.example/socket.io/")
.unwrap();
assert!(next.starts_with("wss://b.example"));
}
#[test]
fn resolve_redirect_rejects_unsupported_scheme() {
let err = resolve_redirect_target("wss://a.example/socket.io/", "ftp://elsewhere/socket.io/")
.unwrap_err();
assert!(err.contains("ftp"), "{err}");
}
#[test]
fn redirect_status_matches_only_followable_codes() {
assert!(is_redirect_status(StatusCode::MOVED_PERMANENTLY));
assert!(is_redirect_status(StatusCode::FOUND));
assert!(is_redirect_status(StatusCode::TEMPORARY_REDIRECT));
assert!(is_redirect_status(StatusCode::PERMANENT_REDIRECT));
// 304 / 300 / 4xx / 5xx all stay errors that the backoff loop handles.
assert!(!is_redirect_status(StatusCode::NOT_MODIFIED));
assert!(!is_redirect_status(StatusCode::MULTIPLE_CHOICES));
assert!(!is_redirect_status(StatusCode::BAD_REQUEST));
assert!(!is_redirect_status(StatusCode::BAD_GATEWAY));
}
#[test]
fn extract_location_header_returns_value_when_present() {
let resp = Response::builder()
.status(StatusCode::MOVED_PERMANENTLY)
.header(LOCATION, "https://api.example.com/socket.io/")
.body(None)
.unwrap();
assert_eq!(
extract_location_header(&resp).as_deref(),
Some("https://api.example.com/socket.io/")
);
}
#[test]
fn extract_location_header_returns_none_when_missing() {
let resp = Response::builder()
.status(StatusCode::MOVED_PERMANENTLY)
.body(None)
.unwrap();
assert!(extract_location_header(&resp).is_none());
}
#[test]
fn redirect_warning_is_recorded_once_and_pinned_to_first_hop() {
// First call records original→resolved. Second call (a second hop in the
// same attempt) must NOT overwrite — the first warning carries the
// user-actionable signal (your configured BACKEND_URL is stale).
let shared = make_shared();
record_redirect_warning(
&shared,
"ws://api.example.com/socket.io/",
"wss://api.example.com/socket.io/",
);
let first = shared.error.read().clone().unwrap();
assert!(first.contains("ws://api.example.com"));
assert!(first.contains("wss://api.example.com"));
record_redirect_warning(
&shared,
"wss://api.example.com/socket.io/",
"wss://api.example.com/v2/socket.io/",
);
let after_second = shared.error.read().clone().unwrap();
assert_eq!(
after_second, first,
"second redirect must not overwrite the first warning"
);
}
// ── handle_eio_message ─────────────────────────────────────────
#[test]
@@ -383,3 +494,138 @@ fn connect_behavior_variants_are_distinct() {
ConnectBehavior::GarbageOpenPacket => {}
}
}
// ── End-to-end redirect-follow (the real fix for the 301 noise) ──
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// Spawn a one-shot HTTP/1.1 server that replies with a 301 redirect to
/// `location` and closes — used to prove that `connect_with_redirects`
/// follows the redirect end-to-end through `connect_async` instead of
/// surfacing the 301 as a recurring error.
async fn spawn_mock_301_redirect(location: String) -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.expect("accept");
// Drain the incoming upgrade request so the client doesn't see RST.
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf).await;
let response = format!(
"HTTP/1.1 301 Moved Permanently\r\n\
Location: {location}\r\n\
Content-Length: 0\r\n\
Connection: close\r\n\r\n"
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.shutdown().await;
});
addr
}
/// Driver-level proof: when the configured URL responds with a 301 pointing
/// at a working Engine.IO server, `ws_loop` follows the redirect, completes
/// the handshake, and records a one-shot warning in `SharedState.error` so
/// the UI can surface the stale-config signal.
#[tokio::test]
async fn ws_loop_follows_301_to_working_backend() {
// 1. Real EIO server on `ws://127.0.0.1:PORT`.
let (fwd_tx, mut fwd_rx) = mpsc::unbounded_channel::<String>();
let real_addr = spawn_mock_eio_server(ConnectBehavior::Ack, fwd_tx).await;
let real_ws_url = format!("ws://{real_addr}/socket.io/?EIO=4&transport=websocket");
// 2. Redirect server that 301s every request to the real EIO server.
let redirect_addr = spawn_mock_301_redirect(real_ws_url.clone()).await;
// 3. Drive `ws_loop` with the redirect address as the base URL. This is
// exactly the production failure mode: BACKEND_URL points at a host
// that 301s the WebSocket upgrade.
let shared = make_shared();
*shared.status.write() = ConnectionStatus::Disconnected;
let (emit_tx, emit_rx) = mpsc::unbounded_channel::<String>();
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let internal_tx = emit_tx.clone();
drop(emit_tx);
let loop_shared = Arc::clone(&shared);
let handle = tokio::spawn(async move {
ws_loop(
format!("http://{redirect_addr}"),
"redirect-test-token".into(),
loop_shared,
emit_rx,
shutdown_rx,
internal_tx,
)
.await;
});
// The SIO CONNECT frame arriving on the *real* server proves the redirect
// was followed and the WebSocket handshake completed against the redirect
// target — not the redirect host.
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(5);
let mut saw_connect = false;
while tokio::time::Instant::now() < deadline {
if let Ok(Some(frame)) =
tokio::time::timeout(tokio::time::Duration::from_millis(200), fwd_rx.recv()).await
{
if frame.starts_with("40") && frame.contains("redirect-test-token") {
saw_connect = true;
break;
}
}
}
assert!(
saw_connect,
"redirect was not followed — SIO CONNECT never reached the real EIO server"
);
for _ in 0..50 {
if *shared.status.read() == ConnectionStatus::Connected {
break;
}
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
}
assert_eq!(*shared.status.read(), ConnectionStatus::Connected);
let warning = shared.error.read().clone();
assert!(
warning
.as_deref()
.map(|w| w.contains("redirected") && w.contains("BACKEND_URL"))
.unwrap_or(false),
"expected redirect warning in SharedState.error, got {warning:?}"
);
let _ = shutdown_tx.send(true);
let _ = tokio::time::timeout(tokio::time::Duration::from_secs(5), handle).await;
}
/// 301 without a Location header is unrecoverable — must surface as a real
/// error and not loop forever attempting to follow nothing.
#[tokio::test]
async fn connect_with_redirects_fails_when_location_missing() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.expect("accept");
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf).await;
// 301 but no Location header.
let _ = stream
.write_all(
b"HTTP/1.1 301 Moved Permanently\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.await;
let _ = stream.shutdown().await;
});
let shared = make_shared();
let mut url = format!("ws://{addr}/socket.io/?EIO=4&transport=websocket");
let err = connect_with_redirects(&mut url, &shared)
.await
.expect_err("must surface failure when Location is absent");
assert!(matches!(err, WsError::Http(_)));
// No warning recorded because the redirect was never actually followed.
assert!(shared.error.read().is_none());
}