use std::collections::HashMap;
use std::sync::Arc;
use reqwest::Client;
use tokio::sync::{RwLock, mpsc, watch};
use tracing::{debug, error, info, warn};
use crate::error::{Error, Result};
use crate::session::SessionHandle;
use crate::streaming::protocol::{Frame, parse_line, parse_ok_block};
use crate::streaming::reconnect::{AutoReconnect, StreamingEvent};
use crate::streaming::subscription::Registry;
struct LsStream {
resp: reqwest::Response,
buf: String,
}
impl LsStream {
fn new(resp: reqwest::Response) -> Self {
Self {
resp,
buf: String::new(),
}
}
async fn next_line(&mut self) -> Option<Result<String>> {
loop {
if let Some(nl_pos) = self.buf.find('\n') {
let line = self.buf[..nl_pos].trim_end_matches('\r').to_owned();
self.buf.drain(..=nl_pos);
return Some(Ok(line));
}
match self.resp.chunk().await {
Ok(Some(bytes)) => {
self.buf.push_str(&String::from_utf8_lossy(&bytes));
}
Ok(None) => {
return None;
}
Err(e) => {
return Some(Err(Error::Http(e)));
}
}
}
}
}
pub(crate) struct CreateParams {
pub(crate) endpoint: String,
pub(crate) username: String,
pub(crate) password: String,
pub(crate) registry: Registry,
pub(crate) shutdown_tx: watch::Sender<bool>,
pub(crate) policy: AutoReconnect,
pub(crate) event_tx: Option<mpsc::Sender<StreamingEvent>>,
pub(crate) session_handle: SessionHandle,
}
#[derive(Debug, Clone)]
pub(crate) struct LsConnection {
pub(crate) client: Client,
pub(crate) endpoint: String,
pub(crate) username: String,
pub(crate) password: String,
pub(crate) session_id: String,
pub(crate) control_address: Option<String>,
}
pub(crate) type SharedConn = Arc<RwLock<LsConnection>>;
struct RequestSnapshot {
client: Client,
control_url: String,
session_id: String,
}
impl LsConnection {
pub(crate) async fn create(params: CreateParams) -> Result<SharedConn> {
let CreateParams {
endpoint,
username,
password,
registry,
shutdown_tx,
policy,
event_tx,
session_handle,
} = params;
let client = Client::builder().build().map_err(Error::Http)?;
let url = format!("{endpoint}/lightstreamer/create_session.txt");
debug!(%url, "opening Lightstreamer session");
let mut form = HashMap::new();
form.insert("LS_op2", "create");
form.insert("LS_cid", "mgQkwtwdysogQz2BJ4Ji kOj2Bg");
form.insert("LS_adapter_set", "DEFAULT");
form.insert("LS_user", username.as_str());
form.insert("LS_password", password.as_str());
form.insert("LS_polling", "false");
let resp = client
.post(&url)
.form(&form)
.send()
.await
.map_err(Error::Http)?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.bytes().await.map_err(Error::Http)?;
let snippet = String::from_utf8_lossy(&body).into_owned();
return Err(Error::Auth(format!(
"Lightstreamer create_session failed ({status}): {snippet}"
)));
}
let (session_id, control_address, stream) = read_ok_block(resp).await?;
info!(%session_id, "Lightstreamer session created");
let conn: SharedConn = Arc::new(RwLock::new(LsConnection {
client,
endpoint,
username,
password,
session_id,
control_address,
}));
let registry2 = registry.clone();
let conn2 = Arc::clone(&conn);
let mut shutdown_rx = shutdown_tx.subscribe();
tokio::spawn(async move {
tokio::select! {
_ = shutdown_rx.changed() => {
debug!("Lightstreamer read-loop: shutdown signal received");
}
() = read_loop(stream, registry2, conn2, policy, event_tx, session_handle) => {}
}
});
Ok(conn)
}
fn control_base_url(&self) -> String {
match &self.control_address {
None => self.endpoint.clone(),
Some(addr) => {
if addr.starts_with("http://") || addr.starts_with("https://") {
addr.clone()
} else {
format!("https://{addr}")
}
}
}
}
fn request_snapshot(&self) -> RequestSnapshot {
RequestSnapshot {
client: self.client.clone(),
control_url: format!("{}/lightstreamer/control.txt", self.control_base_url()),
session_id: self.session_id.clone(),
}
}
async fn bind_inner(&self) -> Result<LsStream> {
let url = format!("{}/lightstreamer/bind_session.txt", self.endpoint);
debug!(%url, session_id = %self.session_id, "binding Lightstreamer session");
let mut params = HashMap::new();
params.insert("LS_session", self.session_id.as_str());
params.insert("LS_polling", "false");
let resp = self
.client
.post(&url)
.form(¶ms)
.send()
.await
.map_err(Error::Http)?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.bytes().await.map_err(Error::Http)?;
return Err(Error::Auth(format!(
"Lightstreamer bind_session failed ({status}): {}",
String::from_utf8_lossy(&body)
)));
}
Ok(LsStream::new(resp))
}
async fn create_session_inner(&self) -> Result<(LsConnection, LsStream)> {
let url = format!("{}/lightstreamer/create_session.txt", self.endpoint);
debug!(%url, "re-creating Lightstreamer session after END");
let mut params = HashMap::new();
params.insert("LS_op2", "create");
params.insert("LS_cid", "mgQkwtwdysogQz2BJ4Ji kOj2Bg");
params.insert("LS_adapter_set", "DEFAULT");
params.insert("LS_user", self.username.as_str());
params.insert("LS_password", self.password.as_str());
params.insert("LS_polling", "false");
let resp = self
.client
.post(&url)
.form(¶ms)
.send()
.await
.map_err(Error::Http)?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.bytes().await.map_err(Error::Http)?;
return Err(Error::Auth(format!(
"Lightstreamer create_session (reconnect) failed ({status}): {}",
String::from_utf8_lossy(&body)
)));
}
let (session_id, control_address, stream) = read_ok_block(resp).await?;
let new_conn = LsConnection {
client: self.client.clone(),
endpoint: self.endpoint.clone(),
username: self.username.clone(),
password: self.password.clone(),
session_id,
control_address,
};
Ok((new_conn, stream))
}
}
pub(crate) async fn control(
conn: &SharedConn,
op: &str,
item_index: usize,
item_name: &str,
fields: &str,
mode: &str,
) -> Result<()> {
let snap = conn.read().await.request_snapshot();
let item_index_str = item_index.to_string();
let mut params = HashMap::new();
params.insert("LS_session", snap.session_id.as_str());
params.insert("LS_op", op);
params.insert("LS_table", item_index_str.as_str());
params.insert("LS_id", item_name);
params.insert("LS_schema", fields);
params.insert("LS_mode", mode);
let resp = snap
.client
.post(&snap.control_url)
.form(¶ms)
.send()
.await
.map_err(Error::Http)?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.bytes().await.map_err(Error::Http)?;
return Err(Error::Auth(format!(
"Lightstreamer control failed ({status}): {}",
String::from_utf8_lossy(&body)
)));
}
let body = resp.bytes().await.map_err(Error::Http)?;
classify_control_body(&String::from_utf8_lossy(&body))
}
pub(crate) async fn unsubscribe(conn: &SharedConn, item_index: usize) -> Result<()> {
let snap = conn.read().await.request_snapshot();
let item_index_str = item_index.to_string();
let mut params = HashMap::new();
params.insert("LS_session", snap.session_id.as_str());
params.insert("LS_op", "delete");
params.insert("LS_table", item_index_str.as_str());
let resp = snap
.client
.post(&snap.control_url)
.form(¶ms)
.send()
.await
.map_err(Error::Http)?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.bytes().await.map_err(Error::Http)?;
return Err(Error::Auth(format!(
"Lightstreamer unsubscribe failed ({status}): {}",
String::from_utf8_lossy(&body)
)));
}
Ok(())
}
async fn bind(conn: &SharedConn) -> Result<LsStream> {
let snapshot = conn.read().await.clone();
snapshot.bind_inner().await
}
async fn create_session(conn: &SharedConn) -> Result<(LsConnection, LsStream)> {
let snapshot = conn.read().await.clone();
snapshot.create_session_inner().await
}
fn classify_control_body(body: &str) -> Result<()> {
let first_token = body.split_whitespace().next().unwrap_or("");
if first_token == "ERROR" || body.trim_start().starts_with("SYNC ERROR") {
return Err(Error::Streaming(format!(
"Lightstreamer control returned ERROR: {}",
body.trim()
)));
}
Ok(())
}
async fn read_ok_block(resp: reqwest::Response) -> Result<(String, Option<String>, LsStream)> {
let mut stream = LsStream::new(resp);
let mut header_lines: Vec<String> = Vec::new();
let mut header_done = false;
loop {
match stream.next_line().await {
Some(Ok(line)) => {
if line.is_empty() {
header_done = true;
break;
}
header_lines.push(line);
}
Some(Err(e)) => return Err(e),
None => break, }
}
if !header_done {
return Err(Error::Auth("Lightstreamer OK block not received".into()));
}
let block = format!("{}\r\n\r\n", header_lines.join("\r\n"));
let (session_id, extras) = parse_ok_block(&block).ok_or_else(|| {
Error::Auth(format!(
"Lightstreamer session error: {}",
header_lines.join(", ")
))
})?;
let control_address = extras
.into_iter()
.find(|(k, _)| k == "ControlAddress")
.map(|(_, v)| v);
Ok((session_id, control_address, stream))
}
#[derive(Debug, PartialEq, Eq)]
enum BindFailureAction {
Reconnect,
Disconnect,
}
fn bind_failure_action(policy_enabled: bool) -> BindFailureAction {
if policy_enabled {
BindFailureAction::Reconnect
} else {
BindFailureAction::Disconnect
}
}
enum DrainOutcome {
Rebind,
SessionEnded { reason: Option<String> },
Terminate,
}
async fn read_loop(
mut stream: LsStream,
registry: Registry,
conn: SharedConn,
policy: AutoReconnect,
event_tx: Option<mpsc::Sender<StreamingEvent>>,
session_handle: SessionHandle,
) {
loop {
match drain_stream(&mut stream, ®istry, &conn).await {
DrainOutcome::Rebind => {
debug!("attempting bind_session");
match bind(&conn).await {
Ok(new_stream) => {
stream = new_stream;
resubscribe_all(&conn, ®istry).await;
let rebound_id = conn.read().await.session_id.clone();
info!(session_id = %rebound_id, "Lightstreamer session rebound");
}
Err(e) => {
error!(error = %e, "Lightstreamer bind_session failed");
let reason = format!("bind_session failed: {e}");
match bind_failure_action(policy.enabled) {
BindFailureAction::Disconnect => {
emit_event(
event_tx.as_ref(),
StreamingEvent::Disconnected {
reason: Some(reason),
},
)
.await;
return;
}
BindFailureAction::Reconnect => {
let reconnected = attempt_reconnect(
&conn,
&mut stream,
®istry,
&policy,
event_tx.as_ref(),
&session_handle,
Some(reason),
)
.await;
if !reconnected {
return;
}
}
}
}
}
}
DrainOutcome::SessionEnded { reason } => {
if !policy.enabled {
debug!(reason = ?reason, "END received; auto-reconnect disabled");
emit_event(
event_tx.as_ref(),
StreamingEvent::Disconnected {
reason: reason.clone(),
},
)
.await;
return;
}
let reconnected = attempt_reconnect(
&conn,
&mut stream,
®istry,
&policy,
event_tx.as_ref(),
&session_handle,
reason,
)
.await;
if !reconnected {
return;
}
}
DrainOutcome::Terminate => {
return;
}
}
}
}
async fn attempt_reconnect(
conn: &SharedConn,
stream: &mut LsStream,
registry: &Registry,
policy: &AutoReconnect,
event_tx: Option<&mpsc::Sender<StreamingEvent>>,
session_handle: &SessionHandle,
initial_reason: Option<String>,
) -> bool {
let mut attempt: u32 = 0;
let mut last_error = initial_reason
.clone()
.unwrap_or_else(|| "session ended".to_owned());
loop {
attempt += 1;
if let Some(max) = policy.max_attempts
&& attempt > max
{
error!(
attempts = attempt - 1,
"Lightstreamer auto-reconnect: max attempts exceeded; giving up"
);
emit_event(
event_tx,
StreamingEvent::ReconnectFailed {
attempts: attempt - 1,
error: last_error,
},
)
.await;
return false;
}
let backoff = policy.backoff_for_attempt(attempt);
warn!(
attempt,
backoff_ms = backoff.as_millis(),
reason = %last_error,
"Lightstreamer auto-reconnect: will retry"
);
tokio::time::sleep(backoff).await;
let api = session_handle.session_api();
let session_kind = if session_handle
.session
.snapshot()
.await
.tokens
.refresh
.is_some()
{
"v3"
} else {
"v2"
};
let refresh_result = if session_kind == "v3" {
match api.login().await {
Ok(_) => api.read(true).await.map(|_| ()),
Err(e) => Err(e),
}
} else {
api.login_v2().await.map(|_| ())
};
if let Err(e) = refresh_result {
last_error = format!("session refresh failed ({session_kind}): {e}");
warn!(
attempt,
session_kind,
error = %e,
"Lightstreamer auto-reconnect: token refresh failed"
);
continue; }
let state = session_handle.session.snapshot().await;
let Some(streaming) = state.tokens.streaming.as_ref() else {
format!("no streaming tokens after {session_kind} refresh").clone_into(&mut last_error);
warn!(attempt, "Lightstreamer auto-reconnect: {}", last_error);
continue;
};
let new_password = format!("CST-{}|XST-{}", streaming.cst, streaming.x_security_token);
conn.write().await.password = new_password;
match create_session(conn).await {
Ok((new_conn, new_stream)) => {
let new_session_id = new_conn.session_id.clone();
info!(
attempt,
session_id = %new_session_id,
"Lightstreamer auto-reconnect: new session established"
);
*conn.write().await = new_conn;
*stream = new_stream;
resubscribe_all(conn, registry).await;
emit_event(event_tx, StreamingEvent::Reconnected { attempt }).await;
return true;
}
Err(e) => {
last_error = format!("create_session failed: {e}");
warn!(
attempt,
error = %e,
"Lightstreamer auto-reconnect: create_session failed"
);
}
}
}
}
async fn resubscribe_all(conn: &SharedConn, registry: &Registry) {
let subs = registry.snapshot_for_resubscribe();
for (idx, name, fields, mode) in subs {
if let Err(e) = control(conn, "add", idx, &name, &fields, mode).await {
warn!(error = %e, "failed to re-subscribe {name} after reconnect");
}
}
}
async fn emit_event(event_tx: Option<&mpsc::Sender<StreamingEvent>>, event: StreamingEvent) {
if let Some(tx) = event_tx {
let _ = tx.send(event).await;
}
}
async fn drain_stream(
stream: &mut LsStream,
registry: &Registry,
conn: &SharedConn,
) -> DrainOutcome {
loop {
match stream.next_line().await {
Some(Ok(line)) => {
let frame = parse_line(line.as_bytes());
match handle_frame(frame, registry, conn).await {
FrameAction::Continue => {}
FrameAction::Rebind => return DrainOutcome::Rebind,
FrameAction::SessionEnded { reason } => {
return DrainOutcome::SessionEnded { reason };
}
FrameAction::Terminate => return DrainOutcome::Terminate,
}
}
Some(Err(e)) => {
error!(error = %e, "Lightstreamer stream error");
return DrainOutcome::Rebind;
}
None => {
debug!("Lightstreamer stream EOF; will rebind");
return DrainOutcome::Rebind;
}
}
}
}
enum FrameAction {
Continue,
Rebind,
SessionEnded { reason: Option<String> },
Terminate,
}
async fn handle_frame(frame: Frame, registry: &Registry, conn: &SharedConn) -> FrameAction {
match frame {
Frame::Update { item_index, fields } => {
let alive = registry.apply_update(item_index, &fields);
if !alive {
registry.remove(item_index);
let _ = unsubscribe(conn, item_index).await;
}
FrameAction::Continue
}
Frame::Probe => {
debug!("PROBE received");
FrameAction::Continue
}
Frame::Loop => {
info!("LOOP received — rebinding session");
FrameAction::Rebind
}
Frame::SyncError => {
warn!("SYNC ERROR received — rebinding and re-subscribing");
FrameAction::Rebind
}
Frame::Error { code, message } => {
error!(code = %code, message = %message, "Lightstreamer ERROR");
FrameAction::Terminate
}
Frame::End { cause } => {
info!(cause = ?cause, "Lightstreamer END — session terminated by server");
FrameAction::SessionEnded {
reason: cause.clone(),
}
}
Frame::Ok { session_id } if !session_id.is_empty() => {
debug!(%session_id, "Lightstreamer OK (bind acknowledged)");
conn.write().await.session_id = session_id;
FrameAction::Continue
}
Frame::Ok { .. } | Frame::Unknown(_) => FrameAction::Continue,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn control_body_error_returns_streaming_err() {
let err = classify_control_body("ERROR\n22\nunknown session")
.expect_err("ERROR body must be an error");
assert!(matches!(err, Error::Streaming(_)));
assert!(!err.is_auth(), "control ERROR must not be is_auth()");
assert!(!err.is_rate_limited());
}
#[test]
fn control_body_sync_error_returns_streaming_err() {
let err = classify_control_body("SYNC ERROR").expect_err("SYNC ERROR is fatal");
assert!(matches!(err, Error::Streaming(_)));
assert!(!err.is_auth());
}
#[test]
fn control_body_ok_returns_ok() {
assert!(classify_control_body("OK").is_ok());
}
#[test]
fn control_body_numeric_table_ok_returns_ok() {
assert!(classify_control_body("1,1|...").is_ok());
}
#[test]
fn control_body_empty_returns_ok() {
assert!(classify_control_body("").is_ok());
}
fn test_conn(session_id: &str, control_address: Option<&str>) -> LsConnection {
LsConnection {
client: Client::new(),
endpoint: "https://demo.example".to_owned(),
username: "acct".to_owned(),
password: "CST-a|XST-b".to_owned(),
session_id: session_id.to_owned(),
control_address: control_address.map(str::to_owned),
}
}
#[tokio::test]
async fn request_snapshot_reads_current_session_after_swap() {
let conn: SharedConn = Arc::new(RwLock::new(test_conn("S-OLD", Some("ctrl-old"))));
let before = conn.read().await.request_snapshot();
assert_eq!(before.session_id, "S-OLD");
assert_eq!(
before.control_url,
"https://ctrl-old/lightstreamer/control.txt"
);
*conn.write().await = test_conn("S-NEW", Some("ctrl-new"));
let after = conn.read().await.request_snapshot();
assert_eq!(after.session_id, "S-NEW");
assert_eq!(
after.control_url,
"https://ctrl-new/lightstreamer/control.txt"
);
}
#[tokio::test]
async fn request_snapshot_falls_back_to_endpoint_without_control_address() {
let conn: SharedConn = Arc::new(RwLock::new(test_conn("S1", None)));
let snap = conn.read().await.request_snapshot();
assert_eq!(
snap.control_url,
"https://demo.example/lightstreamer/control.txt"
);
}
#[test]
fn bind_failure_routes_to_reconnect_when_policy_enabled() {
assert_eq!(bind_failure_action(true), BindFailureAction::Reconnect);
}
#[test]
fn bind_failure_emits_disconnected_when_policy_disabled() {
assert_eq!(bind_failure_action(false), BindFailureAction::Disconnect);
}
}