use super::{
backoff_for, backoff_for_test, classify_sendoutcome, parse_sendoutcome,
run_partial_forward_loop, sanitize_errmsg, send_final_with_retry, session_dispatch_key,
ApprovalBroker, BridgeStop, HubClient, ReplySender, SendOutcome, SessionDispatcher,
MAX_BACKOFF_SECS,
};
use crate::bridge::config::BridgeApp;
use crate::ilink::types::{
HubExt, MessageItem, SendMessageRequest, SendMessageResponse, TextItem, WeixinMessage,
};
use anyhow::Result;
use futures_util::future::BoxFuture;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
fn make_msg(ctx: &str, session_name: &str) -> WeixinMessage {
WeixinMessage {
context_token: Some(ctx.into()),
ilink_hub_ext: Some(HubExt {
session_id: Some(String::new()),
session_name: Some(session_name.into()),
cli_session_id: None,
a2a_call_id: None,
a2a_depth: None,
usage: None,
}),
item_list: Some(std::sync::Arc::new(vec![MessageItem {
item_type: Some(1),
text_item: Some(TextItem {
text: Some("hello".into()),
}),
..Default::default()
}])),
from_user_id: Some("user1".into()),
..Default::default()
}
}
fn make_fast_app() -> BridgeApp {
BridgeApp::parse_yaml(
r#"
command: echo
args: []
stdin: none
timeout_secs: 5
"#,
)
.unwrap()
}
fn fake_client() -> HubClient {
HubClient::new("http://127.0.0.1:1".into(), "test-token".into()).expect("test http client")
}
fn make_stop_tx() -> tokio::sync::watch::Sender<Option<BridgeStop>> {
tokio::sync::watch::channel(None).0
}
#[test]
fn key_combines_ctx_and_session_name() {
assert_eq!(
session_dispatch_key(&make_msg("ctx-123", "feat-a")),
"ctx-123:feat-a"
);
}
#[test]
fn key_defaults_session_name_when_ext_absent() {
let msg = WeixinMessage {
context_token: Some("ctx-x".into()),
ilink_hub_ext: None,
..Default::default()
};
assert_eq!(session_dispatch_key(&msg), "ctx-x:default");
}
#[test]
fn key_uses_empty_string_when_ctx_absent() {
let msg = WeixinMessage {
context_token: None,
ilink_hub_ext: None,
..Default::default()
};
assert_eq!(session_dispatch_key(&msg), ":default");
}
#[test]
fn key_differs_for_different_session_names() {
let a = make_msg("ctx", "session-a");
let b = make_msg("ctx", "session-b");
assert_ne!(session_dispatch_key(&a), session_dispatch_key(&b));
}
#[test]
fn key_differs_for_different_ctx_tokens() {
let a = make_msg("ctx-1", "default");
let b = make_msg("ctx-2", "default");
assert_ne!(session_dispatch_key(&a), session_dispatch_key(&b));
}
#[tokio::test]
async fn same_key_reuses_single_sender() {
let disp = SessionDispatcher::new(
fake_client(),
Arc::new(make_fast_app()),
make_stop_tx(),
CancellationToken::new(),
ApprovalBroker::new(),
);
let msg = make_msg("ctx-a", "default");
disp.dispatch(msg.clone()).await;
disp.dispatch(msg.clone()).await;
assert_eq!(disp.sender_keys(), vec!["ctx-a:default"]);
}
#[tokio::test]
async fn different_ctx_tokens_get_separate_senders() {
let disp = SessionDispatcher::new(
fake_client(),
Arc::new(make_fast_app()),
make_stop_tx(),
CancellationToken::new(),
ApprovalBroker::new(),
);
disp.dispatch(make_msg("ctx-a", "default")).await;
disp.dispatch(make_msg("ctx-b", "default")).await;
assert_eq!(disp.sender_keys(), vec!["ctx-a:default", "ctx-b:default"]);
}
#[tokio::test]
async fn different_session_names_get_separate_senders() {
let disp = SessionDispatcher::new(
fake_client(),
Arc::new(make_fast_app()),
make_stop_tx(),
CancellationToken::new(),
ApprovalBroker::new(),
);
disp.dispatch(make_msg("ctx-a", "feature-x")).await;
disp.dispatch(make_msg("ctx-a", "feature-y")).await;
assert_eq!(
disp.sender_keys(),
vec!["ctx-a:feature-x", "ctx-a:feature-y"]
);
}
#[tokio::test]
async fn three_distinct_sessions_create_three_senders() {
let disp = SessionDispatcher::new(
fake_client(),
Arc::new(make_fast_app()),
make_stop_tx(),
CancellationToken::new(),
ApprovalBroker::new(),
);
disp.dispatch(make_msg("ctx-1", "default")).await;
disp.dispatch(make_msg("ctx-2", "default")).await;
disp.dispatch(make_msg("ctx-1", "feature-a")).await;
assert_eq!(
disp.sender_keys(),
vec!["ctx-1:default", "ctx-1:feature-a", "ctx-2:default"]
);
}
#[tokio::test]
async fn repeated_same_key_does_not_grow_sender_map() {
let disp = SessionDispatcher::new(
fake_client(),
Arc::new(make_fast_app()),
make_stop_tx(),
CancellationToken::new(),
ApprovalBroker::new(),
);
let msg = make_msg("ctx-x", "s1");
for _ in 0..5 {
disp.dispatch(msg.clone()).await;
}
assert_eq!(disp.sender_keys().len(), 1);
}
#[tokio::test]
async fn dead_sender_triggers_new_worker_on_next_dispatch() {
let disp = SessionDispatcher::new(
fake_client(),
Arc::new(make_fast_app()),
make_stop_tx(),
CancellationToken::new(),
ApprovalBroker::new(),
);
let msg = make_msg("ctx-z", "default");
disp.dispatch(msg.clone()).await;
{
let mut senders = disp.senders.lock().expect("senders poisoned");
senders.remove("ctx-z:default");
}
assert_eq!(disp.sender_keys().len(), 0);
disp.dispatch(msg.clone()).await;
assert_eq!(disp.sender_keys(), vec!["ctx-z:default"]);
}
#[test]
fn senders_lock_recovery_after_poison_yields_inner_state() {
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
let senders: Arc<Mutex<HashMap<String, &'static str>>> = Arc::new(Mutex::new(HashMap::new()));
senders.lock().unwrap().insert("k1".to_string(), "v1");
let poisoned_clone = Arc::clone(&senders);
let join = std::thread::spawn(move || {
let _g = poisoned_clone.lock().expect("acquired");
panic!("intentional poison");
});
let _ = join.join();
let mut guard = senders.lock().unwrap_or_else(|e| e.into_inner());
assert_eq!(guard.get("k1"), Some(&"v1"));
guard.insert("k2".to_string(), "v2");
assert_eq!(guard.len(), 2, "recovery must allow normal mutation");
}
#[test]
fn parse_empty_body_is_sent() {
assert_eq!(parse_sendoutcome(""), Ok(SendOutcome::Sent));
assert_eq!(parse_sendoutcome(" \n\t "), Ok(SendOutcome::Sent));
}
#[test]
fn parse_ret_zero_is_sent() {
let body = r#"{"ret":0}"#;
assert_eq!(parse_sendoutcome(body), Ok(SendOutcome::Sent));
}
#[test]
fn parse_ret_none_is_sent() {
let body = r#"{}"#;
assert_eq!(parse_sendoutcome(body), Ok(SendOutcome::Sent));
}
#[test]
fn parse_ret_negative_two_is_throttled() {
let body = r#"{"ret":-2,"errmsg":"rate limited"}"#;
match parse_sendoutcome(body).unwrap() {
SendOutcome::Throttled { ret, errmsg } => {
assert_eq!(ret, -2);
assert_eq!(errmsg.as_deref(), Some("rate limited"));
}
other => panic!("expected Throttled, got {:?}", other),
}
}
#[test]
fn parse_ret_other_non_zero_is_err() {
let body = r#"{"ret":1,"errmsg":"oops"}"#;
match parse_sendoutcome(body) {
Err((1, Some(m))) => assert_eq!(m, "oops"),
other => panic!("expected Err((1, Some(..))), got {:?}", other),
}
let body = r#"{"ret":-99,"errmsg":"unknown"}"#;
match parse_sendoutcome(body) {
Err((-99, Some(m))) => assert_eq!(m, "unknown"),
other => panic!("expected Err((-99, Some(..))), got {:?}", other),
}
}
#[test]
fn parse_unparseable_body_falls_back_to_sent() {
assert_eq!(parse_sendoutcome("not json"), Ok(SendOutcome::Sent));
assert_eq!(parse_sendoutcome(r#"{"ret": "#), Ok(SendOutcome::Sent));
}
#[test]
fn adversarial_ret_negative_two_never_becomes_sent() {
for body in [
r#"{"ret":-2}"#,
r#"{"ret":-2,"errmsg":""}"#,
r#"{"ret":-2,"errmsg":"hi"}"#,
r#" {"ret":-2} "#,
r#"{"ret":-2,"errmsg":"a]lo t of\\n junk"}"#,
] {
match parse_sendoutcome(body).unwrap() {
SendOutcome::Throttled { ret: -2, .. } => {}
other => panic!("ret=-2 body {:?} misclassified as {:?}", body, other),
}
}
}
#[test]
fn adversarial_large_payload_does_not_panic() {
let big = "x".repeat(4 * 1024 * 1024);
let body = format!(r#"{{"ret":0,"errmsg":"{}"}}"#, big);
let res = parse_sendoutcome(&body);
let _ = res;
}
#[test]
fn adversarial_nested_garbage_does_not_panic() {
for body in [
r#"{"ret":{"deeply":{"nested":[1,2,3]}}}"#,
r#"{"ret":-2.5}"#,
r#"{"ret":-2,"errmsg":12345}"#,
r#"{} broken"#,
] {
let _ = parse_sendoutcome(body);
}
}
#[test]
fn sanitize_strips_control_chars() {
let dirty = "before\r\nafter\tend\x1b[31mred\x1b[0m";
let cleaned = sanitize_errmsg(Some(dirty)).unwrap();
assert!(!cleaned.contains('\r'));
assert!(!cleaned.contains('\n'));
assert!(!cleaned.contains('\t'));
assert!(!cleaned.contains('\x1b'));
assert!(cleaned.contains("before"));
assert!(cleaned.contains("red"));
}
#[test]
fn sanitize_caps_length() {
let huge = "a".repeat(10_000);
let cleaned = sanitize_errmsg(Some(&huge)).unwrap();
assert_eq!(cleaned.len(), 256);
}
#[test]
fn sanitize_handles_none_and_empty() {
assert!(sanitize_errmsg(None).is_none());
assert!(sanitize_errmsg(Some("")).is_none());
assert!(sanitize_errmsg(Some("\r\n\t")).is_none());
}
#[test]
fn sanitize_preserves_printable_unicode() {
let s = "你好, world! 🌏 — dash";
assert_eq!(sanitize_errmsg(Some(s)).as_deref(), Some(s));
}
#[test]
fn classify_three_categories() {
let none_resp = SendMessageResponse {
ret: None,
errmsg: None,
};
let zero_resp = SendMessageResponse {
ret: Some(0),
errmsg: None,
};
let tmo_resp = SendMessageResponse {
ret: Some(-2),
errmsg: Some("rl".into()),
};
assert_eq!(classify_sendoutcome(None), SendOutcome::Sent);
assert_eq!(classify_sendoutcome(Some(&none_resp)), SendOutcome::Sent);
assert_eq!(classify_sendoutcome(Some(&zero_resp)), SendOutcome::Sent);
assert_eq!(
classify_sendoutcome(Some(&tmo_resp)),
SendOutcome::Throttled {
ret: -2,
errmsg: Some("rl".into())
}
);
}
#[test]
fn outcome_clone_works() {
let original = SendOutcome::Throttled {
ret: -2,
errmsg: Some("x".into()),
};
let cloned = original.clone();
assert_eq!(original, cloned);
}
#[test]
fn backoff_sequence_matches_spec() {
let expected_secs = [5u64, 10, 20, 40, 60, 60, 60, 60, 60, 60];
let actual: Vec<u64> = (0..expected_secs.len() as u32)
.map(|a| backoff_for(a).as_secs())
.collect();
assert_eq!(actual, expected_secs);
}
#[test]
fn backoff_clamps_at_cap_for_large_attempt() {
assert_eq!(backoff_for(10).as_secs(), MAX_BACKOFF_SECS);
assert_eq!(backoff_for(20).as_secs(), MAX_BACKOFF_SECS);
}
#[test]
fn backoff_does_not_overflow_at_u32_max() {
let d = backoff_for(u32::MAX);
assert_eq!(d.as_secs(), MAX_BACKOFF_SECS);
}
#[test]
fn backoff_is_non_decreasing() {
let mut prev = backoff_for(0);
for a in 1..50 {
let cur = backoff_for(a);
assert!(
cur >= prev,
"backoff regressed at attempt {a}: {:?} < {:?}",
cur,
prev
);
prev = cur;
}
}
use std::time::Instant;
#[derive(Clone)]
struct ScriptedSender {
script: Arc<Mutex<Vec<Result<SendOutcome>>>>,
log: Arc<Mutex<Vec<String>>>,
timestamps: Arc<Mutex<Vec<Instant>>>,
loop_outcome: Arc<Mutex<Option<SendOutcome>>>,
}
impl ScriptedSender {
fn new(script: Vec<Result<SendOutcome>>) -> Self {
Self {
script: Arc::new(Mutex::new(script)),
log: Arc::new(Mutex::new(Vec::new())),
timestamps: Arc::new(Mutex::new(Vec::new())),
loop_outcome: Arc::new(Mutex::new(None)),
}
}
fn new_loop(outcome: SendOutcome) -> Self {
Self {
script: Arc::new(Mutex::new(Vec::new())),
log: Arc::new(Mutex::new(Vec::new())),
timestamps: Arc::new(Mutex::new(Vec::new())),
loop_outcome: Arc::new(Mutex::new(Some(outcome))),
}
}
fn sent_count(&self) -> usize {
self.log.lock().unwrap().len()
}
fn sent_texts(&self) -> Vec<String> {
self.log.lock().unwrap().clone()
}
fn sent_timestamps(&self) -> Vec<Instant> {
self.timestamps.lock().unwrap().clone()
}
fn record_and_next(&self, label: String) -> Result<SendOutcome> {
self.log.lock().unwrap().push(label);
self.timestamps.lock().unwrap().push(Instant::now());
if let Some(outcome) = self.loop_outcome.lock().unwrap().clone() {
Ok(outcome)
} else {
let mut script = self.script.lock().unwrap();
if script.is_empty() {
panic!(
"ScriptedSender script exhausted; use new_loop for persistent-throttle tests"
);
}
script.remove(0)
}
}
}
impl ReplySender for ScriptedSender {
fn send_reply(
&self,
_ctx: &str,
text: &str,
_from_user: &str,
_session_name: &str,
) -> BoxFuture<'_, Result<SendOutcome>> {
let next = self.record_and_next(text.to_string());
Box::pin(async move { next })
}
fn send_request(&self, _req: SendMessageRequest) -> BoxFuture<'_, Result<SendOutcome>> {
let next = self.record_and_next("<request>".to_string());
Box::pin(async move { next })
}
}
fn spawn_test_loop<S: ReplySender>(
sender: S,
) -> (
watch::Sender<Option<String>>,
CancellationToken,
tokio::task::JoinHandle<()>,
) {
spawn_test_loop_with_budget(sender, Duration::from_secs(3600))
}
fn test_backoff(attempt: u32) -> Duration {
backoff_for_test(attempt, Duration::from_millis(5), Duration::from_millis(40))
}
fn spawn_test_loop_with_budget<S: ReplySender>(
sender: S,
max_total: Duration,
) -> (
watch::Sender<Option<String>>,
CancellationToken,
tokio::task::JoinHandle<()>,
) {
let (tx, rx) = watch::channel::<Option<String>>(None);
let shutdown = CancellationToken::new();
let handle = tokio::spawn(run_partial_forward_loop(
sender,
rx,
"ctx".into(),
"from".into(),
"sess".into(),
shutdown.clone(),
test_backoff,
max_total,
));
(tx, shutdown, handle)
}
#[tokio::test]
async fn partial_three_throttles_then_success_delivers_latest_content() {
let scripted = ScriptedSender::new(vec![
Ok(SendOutcome::Throttled {
ret: -2,
errmsg: Some("rl".into()),
}),
Ok(SendOutcome::Throttled {
ret: -2,
errmsg: Some("rl".into()),
}),
Ok(SendOutcome::Throttled {
ret: -2,
errmsg: Some("rl".into()),
}),
Ok(SendOutcome::Sent),
Ok(SendOutcome::Sent),
]);
let (tx, shutdown, handle) = spawn_test_loop(scripted.clone());
let probe = scripted.clone();
tx.send(Some("v1".into())).unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
tx.send(Some("v2".into())).unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
tx.send(Some("v3".into())).unwrap();
for _ in 0..200 {
if probe.sent_count() >= 4 {
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
drop(tx);
handle.await.unwrap();
let _ = shutdown;
let texts = probe.sent_texts();
assert!(
texts.len() >= 4,
"expected at least 4 sends (3 throttled + ≥1 success), got {texts:?}"
);
assert_eq!(
texts.last().unwrap(),
"v3",
"final successful send must be the latest buffered content; got {texts:?}"
);
let stamps = probe.sent_timestamps();
assert!(stamps.len() >= 4, "expected ≥4 timestamp entries");
let gaps: Vec<Duration> = stamps
.iter()
.take(4)
.collect::<Vec<_>>()
.windows(2)
.map(|w| w[1].duration_since(*w[0]))
.collect();
assert_eq!(gaps.len(), 3);
assert!(
gaps[0] >= Duration::from_millis(4),
"first retry must wait at least ~5ms (initial backoff), got {:?}",
gaps[0]
);
for i in 1..gaps.len() {
assert!(
gaps[i] >= gaps[i - 1].saturating_sub(Duration::from_millis(1)),
"backoff regressed at gap[{i}]: {:?} < {:?} (full gaps = {:?})",
gaps[i],
gaps[i - 1],
gaps
);
}
}
#[tokio::test]
async fn partial_single_chunk_throttled_then_success_buffers_until_clear() {
let scripted = ScriptedSender::new(vec![
Ok(SendOutcome::Throttled {
ret: -2,
errmsg: Some("rl".into()),
}),
Ok(SendOutcome::Sent),
]);
let (tx, _shutdown, handle) = spawn_test_loop(scripted.clone());
let probe = scripted.clone();
tx.send(Some("hello".into())).unwrap();
for _ in 0..200 {
if probe.sent_count() >= 2 {
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
drop(tx);
handle.await.unwrap();
let texts = probe.sent_texts();
assert_eq!(texts, vec!["hello", "hello"]);
}
#[tokio::test]
async fn partial_chunk_overwritten_during_backoff_drops_stale_fragment() {
let scripted = ScriptedSender::new(vec![
Ok(SendOutcome::Throttled {
ret: -2,
errmsg: None,
}),
Ok(SendOutcome::Throttled {
ret: -2,
errmsg: None,
}),
Ok(SendOutcome::Throttled {
ret: -2,
errmsg: None,
}),
Ok(SendOutcome::Throttled {
ret: -2,
errmsg: None,
}),
Ok(SendOutcome::Sent),
Ok(SendOutcome::Sent),
]);
let (tx, _shutdown, handle) = spawn_test_loop(scripted.clone());
let probe = scripted.clone();
tx.send(Some("v1".into())).unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
tx.send(Some("v2".into())).unwrap();
for _ in 0..2000 {
if probe.sent_texts().iter().any(|t| t == "v2") {
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
drop(tx);
handle.await.unwrap();
let texts = probe.sent_texts();
assert_eq!(
texts.last().unwrap(),
"v2",
"final delivered content must be the latest buffered chunk; got {texts:?}"
);
let first_v2_idx = texts
.iter()
.position(|t| t == "v2")
.expect("v2 must be sent at least once");
for (i, t) in texts.iter().enumerate().skip(first_v2_idx) {
assert_eq!(
t, "v2",
"stale v1 must not be re-sent after v2 overwrote it (send #{i} = {:?}); full log: {texts:?}",
t
);
}
assert!(
texts.iter().any(|t| t == "v1"),
"expected at least one v1 send before the overwrite; got {texts:?}"
);
let stamps = probe.sent_timestamps();
assert!(stamps.len() >= 2);
let gap_before_v2 = stamps[first_v2_idx].duration_since(stamps[0]);
if first_v2_idx + 1 < stamps.len() {
let gap_after_v2 = stamps[first_v2_idx + 1].duration_since(stamps[first_v2_idx]);
assert!(
gap_after_v2 + Duration::from_millis(2) >= gap_before_v2,
"overwrite reset attempt (gap_after_v2={:?} < gap_before_v2={:?}); \
gap_before_v2 corresponds to attempt=1 (10ms), \
gap_after_v2 corresponds to attempt=2 (20ms)",
gap_after_v2,
gap_before_v2
);
}
}
#[tokio::test]
async fn partial_persistent_throttle_caps_retry_at_max_backoff() {
let scripted = ScriptedSender::new_loop(SendOutcome::Throttled {
ret: -2,
errmsg: None,
});
let (tx, shutdown, handle) = spawn_test_loop(scripted.clone());
let probe = scripted.clone();
for i in 0..3 {
tx.send(Some(format!("chunk-{i}"))).unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
}
for _ in 0..400 {
if probe.sent_count() >= 4 {
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
shutdown.cancel();
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
let texts = probe.sent_texts();
assert!(
texts.len() >= 4,
"expected at least 4 retry attempts under persistent throttle, got {texts:?}"
);
assert_eq!(
texts.last().unwrap(),
"chunk-2",
"most recent chunk must be the one being retried"
);
let stamps = probe.sent_timestamps();
assert!(stamps.len() >= 4, "expected ≥4 timestamp entries");
let gaps: Vec<Duration> = stamps
.iter()
.take(4)
.collect::<Vec<_>>()
.windows(2)
.map(|w| w[1].duration_since(*w[0]))
.collect();
assert_eq!(gaps.len(), 3);
assert!(
gaps[0] >= Duration::from_millis(4),
"first retry must wait at least ~5ms (initial backoff), got {:?}",
gaps[0]
);
for i in 1..gaps.len() {
assert!(
gaps[i] >= gaps[i - 1].saturating_sub(Duration::from_millis(1)),
"backoff regressed at gap[{i}]: {:?} < {:?} (full gaps = {:?})",
gaps[i],
gaps[i - 1],
gaps
);
}
let mut doubled = false;
for i in 1..gaps.len() {
if gaps[i].as_micros() >= (gaps[i - 1].as_micros() * 14) / 10
&& gaps[i] < gaps[i - 1].saturating_mul(3)
{
doubled = true;
break;
}
}
assert!(
doubled,
"expected at least one roughly-2× gap (exponential shape); got {:?}",
gaps
);
}
#[tokio::test]
async fn partial_err_drops_buffer_and_continues_serving_new_chunks() {
let scripted = ScriptedSender::new(vec![
Err(anyhow::anyhow!("hub down")),
Ok(SendOutcome::Sent),
]);
let (tx, _shutdown, handle) = spawn_test_loop(scripted.clone());
let probe = scripted.clone();
tx.send(Some("first".into())).unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
tx.send(Some("second".into())).unwrap();
for _ in 0..200 {
if probe.sent_count() >= 2 {
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
drop(tx);
handle.await.unwrap();
let texts = probe.sent_texts();
assert_eq!(texts, vec!["first", "second"]);
}
#[tokio::test]
async fn partial_shutdown_during_backoff_exits_cleanly() {
let scripted = ScriptedSender::new_loop(SendOutcome::Throttled {
ret: -2,
errmsg: None,
});
let (tx, shutdown, handle) = spawn_test_loop(scripted.clone());
let probe = scripted.clone();
tx.send(Some("stuck".into())).unwrap();
for _ in 0..200 {
if probe.sent_count() >= 1 {
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
assert!(
probe.sent_count() >= 1,
"loop should have attempted at least one send before we cancel"
);
shutdown.cancel();
let joined = tokio::time::timeout(Duration::from_secs(2), handle).await;
assert!(joined.is_ok(), "loop must exit within 2s of shutdown");
drop(tx);
}
#[tokio::test]
async fn partial_chunks_arrived_after_sender_continues_normal_path() {
let scripted = ScriptedSender::new(vec![
Ok(SendOutcome::Sent),
Ok(SendOutcome::Sent),
Ok(SendOutcome::Sent),
]);
let (tx, _shutdown, handle) = spawn_test_loop(scripted.clone());
let probe = scripted.clone();
for i in 0..3 {
tx.send(Some(format!("c{i}"))).unwrap();
tokio::time::sleep(Duration::from_millis(25)).await;
}
for _ in 0..200 {
if probe.sent_count() >= 3 {
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
drop(tx);
handle.await.unwrap();
let texts = probe.sent_texts();
assert_eq!(texts, vec!["c0", "c1", "c2"]);
}
#[test]
fn reply_sender_impl_attaches_session_name_to_hub_ext() {
fn assert_reply_sender<S: ReplySender>(_: &S) {}
let client = fake_client();
assert_reply_sender(&client);
}
fn dummy_req() -> SendMessageRequest {
SendMessageRequest::reply("ctx".to_string(), "final".to_string(), "user")
}
#[tokio::test(start_paused = true)]
async fn final_reply_throttled_thrice_then_delivered() {
let scripted = ScriptedSender::new(vec![
Ok(SendOutcome::Throttled {
ret: -2,
errmsg: Some("rl".into()),
}),
Ok(SendOutcome::Throttled {
ret: -2,
errmsg: None,
}),
Ok(SendOutcome::Throttled {
ret: -2,
errmsg: None,
}),
Ok(SendOutcome::Sent),
]);
let shutdown = CancellationToken::new();
let res = send_final_with_retry(
&scripted,
dummy_req(),
test_backoff,
Duration::from_secs(3600),
&shutdown,
"final reply",
)
.await;
assert!(res.is_ok(), "delivery after retries must return Ok");
assert_eq!(
scripted.sent_count(),
4,
"expected 3 throttled attempts + 1 successful send"
);
}
#[tokio::test]
async fn final_reply_transport_error_retried_then_succeeds() {
let scripted = ScriptedSender::new(vec![
Err(anyhow::anyhow!("connection reset")),
Err(anyhow::anyhow!("connection reset")),
Ok(SendOutcome::Sent),
]);
let shutdown = CancellationToken::new();
let res = send_final_with_retry(
&scripted,
dummy_req(),
test_backoff,
Duration::from_secs(3600),
&shutdown,
"final reply",
)
.await;
assert!(
res.is_ok(),
"transport errors retried until success must return Ok"
);
assert_eq!(
scripted.sent_count(),
3,
"two transport failures + one successful send = 3 total attempts"
);
}
#[tokio::test]
async fn final_reply_transport_error_budget_exhausted_gives_up() {
let scripted = ScriptedSender::new(vec![Err(anyhow::anyhow!("connection reset"))]);
let shutdown = CancellationToken::new();
let res = send_final_with_retry(
&scripted,
dummy_req(),
test_backoff,
Duration::ZERO,
&shutdown,
"final reply",
)
.await;
assert!(
res.is_ok(),
"budget-exhausted transport error must give up with Ok, not propagate Err"
);
assert_eq!(
scripted.sent_count(),
1,
"exactly one attempt before budget expired"
);
}
#[tokio::test]
async fn final_reply_persistent_throttle_gives_up_within_budget() {
let scripted = ScriptedSender::new_loop(SendOutcome::Throttled {
ret: -2,
errmsg: None,
});
let shutdown = CancellationToken::new();
let res = tokio::time::timeout(
Duration::from_secs(5),
send_final_with_retry(
&scripted,
dummy_req(),
test_backoff,
Duration::from_millis(30),
&shutdown,
"final reply",
),
)
.await;
assert!(
res.is_ok(),
"helper must return within the timeout (no infinite spin under persistent throttle)"
);
assert!(
res.unwrap().is_ok(),
"give-up returns Ok so the caller continues cleanly"
);
assert!(
scripted.sent_count() >= 1,
"expected at least one send attempt before giving up"
);
}
#[tokio::test]
async fn final_reply_shutdown_during_backoff_returns_promptly() {
let scripted = ScriptedSender::new_loop(SendOutcome::Throttled {
ret: -2,
errmsg: None,
});
let probe = scripted.clone();
let shutdown = CancellationToken::new();
let shutdown_for_task = shutdown.clone();
let task = tokio::spawn(async move {
send_final_with_retry(
&scripted,
dummy_req(),
test_backoff,
Duration::from_secs(3600),
&shutdown_for_task,
"final reply",
)
.await
});
tokio::time::sleep(Duration::from_millis(15)).await;
shutdown.cancel();
let res = tokio::time::timeout(Duration::from_secs(2), task).await;
assert!(res.is_ok(), "task must finish promptly after shutdown");
assert!(res.unwrap().unwrap().is_ok());
let _ = probe.sent_count();
}
#[tokio::test]
async fn partial_persistent_throttle_gives_up_then_serves_new_chunk() {
let scripted = ScriptedSender::new_loop(SendOutcome::Throttled {
ret: -2,
errmsg: None,
});
let (tx, shutdown, handle) =
spawn_test_loop_with_budget(scripted.clone(), Duration::from_millis(30));
let probe = scripted.clone();
tx.send(Some("chunk-0".to_string())).unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
let after_giveup = probe.sent_count();
tx.send(Some("chunk-1".to_string())).unwrap();
tokio::time::sleep(Duration::from_millis(120)).await;
shutdown.cancel();
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
let total = probe.sent_count();
assert!(
after_giveup >= 1,
"loop must attempt at least once before giving up, got {after_giveup}"
);
assert!(
total > after_giveup,
"a fresh chunk after give-up must trigger new send attempts ({total} !> {after_giveup})"
);
}