1111use std:: collections:: HashMap ;
1212use std:: sync:: atomic:: { AtomicU64 , Ordering } ;
1313use std:: sync:: { Arc , Mutex } ;
14- use std:: time:: Instant ;
14+ use std:: time:: { Duration , Instant } ;
1515
1616use anyhow:: Result ;
17- use tracing:: { error, info, info_span, warn} ;
17+ use tracing:: { debug , error, info, info_span, warn} ;
1818
1919use crate :: adapter:: { AdapterRouter , ChannelRef , ChatAdapter , MessageRef } ;
2020use crate :: acp:: ContentBlock ;
@@ -255,6 +255,12 @@ impl Dispatcher {
255255// consumer_loop
256256// ---------------------------------------------------------------------------
257257
258+ /// Idle timeout for per-thread consumer tasks. When no message arrives within
259+ /// this window the consumer exits, allowing `per_thread` map cleanup on the
260+ /// next `submit` (via `SendError` → `try_evict_locked`). Prevents unbounded
261+ /// task/memory growth from one-shot thread keys (e.g. Slack non-thread messages).
262+ const CONSUMER_IDLE_TIMEOUT : Duration = Duration :: from_secs ( 300 ) ; // 5 min
263+
258264#[ allow( clippy:: too_many_arguments) ]
259265async fn consumer_loop (
260266 thread_key : String ,
@@ -271,13 +277,26 @@ async fn consumer_loop(
271277
272278 loop {
273279 // I1: block until at least one message arrives (zero latency for first message).
280+ // Idle timeout: if no message arrives within CONSUMER_IDLE_TIMEOUT the
281+ // consumer exits, freeing the task and mpsc. The next `submit` for this
282+ // thread_key will observe `SendError`, evict the stale entry, and lazily
283+ // spawn a fresh consumer (§2.5 generation check prevents mis-eviction).
274284 let first = match pending. take ( ) {
275285 Some ( msg) => msg,
276- None => match rx. recv ( ) . await {
277- Some ( msg) => msg,
278- // All senders dropped → either shutdown() cleared the map, or
279- // cancel_buffered() removed our entry. Exit the loop.
280- None => break ,
286+ None => match tokio:: time:: timeout ( CONSUMER_IDLE_TIMEOUT , rx. recv ( ) ) . await {
287+ Ok ( Some ( msg) ) => msg,
288+ Ok ( None ) => {
289+ // All senders dropped → shutdown() or cancel_buffered().
290+ break ;
291+ }
292+ Err ( _elapsed) => {
293+ debug ! (
294+ thread_key = %thread_key,
295+ channel = %thread_channel. channel_id,
296+ "consumer idle timeout, exiting"
297+ ) ;
298+ break ;
299+ }
281300 } ,
282301 } ;
283302
@@ -340,19 +359,23 @@ async fn dispatch_batch(
340359 )
341360 . await ;
342361
343- // Collect per-event observability data.
362+ // Collect per-event observability data (before consuming the batch) .
344363 let tokens_per_event: Vec < usize > = batch. iter ( ) . map ( |m| m. estimated_tokens ) . collect ( ) ;
345364 let wait_ms: Vec < u128 > = batch
346365 . iter ( )
347366 . map ( |m| m. arrived_at . elapsed ( ) . as_millis ( ) )
348367 . collect ( ) ;
349- let senders: Vec < & str > = batch. iter ( ) . map ( |m| m. sender_name . as_str ( ) ) . collect ( ) ;
368+ let senders: Vec < String > = batch. iter ( ) . map ( |m| m. sender_name . clone ( ) ) . collect ( ) ;
369+
370+ // Anchor reactions on the last message in the batch (before consuming).
371+ let trigger_msg = batch. last ( ) . unwrap ( ) . trigger_msg . clone ( ) ;
350372
351373 // Pack all arrival events into one Vec<ContentBlock> (§3.3).
374+ // Uses into_iter() to avoid deep-copying extra_blocks (may contain base64 image data).
352375 let mut content_blocks: Vec < ContentBlock > = Vec :: new ( ) ;
353- for msg in & batch {
376+ for msg in batch {
354377 let mut event_blocks =
355- AdapterRouter :: pack_arrival_event ( & msg. sender_json , & msg. prompt , msg. extra_blocks . clone ( ) ) ;
378+ AdapterRouter :: pack_arrival_event ( & msg. sender_json , & msg. prompt , msg. extra_blocks ) ;
356379 content_blocks. append ( & mut event_blocks) ;
357380 }
358381 let packed_block_count = content_blocks. len ( ) ;
@@ -367,8 +390,6 @@ async fn dispatch_batch(
367390 return ;
368391 }
369392
370- // Anchor reactions on the last message in the batch.
371- let trigger_msg = batch. last ( ) . unwrap ( ) . trigger_msg . clone ( ) ;
372393 let reactions_config = router. reactions_config ( ) . clone ( ) ;
373394 let reactions = Arc :: new ( StatusReactionController :: new (
374395 reactions_config. enabled ,
0 commit comments