1use std::collections::VecDeque;
8use std::sync::{Arc, RwLock};
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13use crate::ipc::KernelMessage;
14use crate::process::Pid;
15
16pub const DEFAULT_DLQ_CAPACITY: usize = 10_000;
18
19#[non_exhaustive]
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub enum DeadLetterReason {
23 TargetNotFound {
25 pid: Pid,
27 },
28 InboxFull {
30 pid: Pid,
32 },
33 Timeout {
35 duration_ms: u64,
37 },
38 GovernanceDenied {
40 reason: String,
42 },
43 AgentExited {
45 pid: Pid,
47 },
48}
49
50impl std::fmt::Display for DeadLetterReason {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 match self {
53 Self::TargetNotFound { pid } => write!(f, "target_not_found(pid={pid})"),
54 Self::InboxFull { pid } => write!(f, "inbox_full(pid={pid})"),
55 Self::Timeout { duration_ms } => write!(f, "timeout({duration_ms}ms)"),
56 Self::GovernanceDenied { reason } => write!(f, "governance_denied({reason})"),
57 Self::AgentExited { pid } => write!(f, "agent_exited(pid={pid})"),
58 }
59 }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct DeadLetter {
65 pub message: KernelMessage,
67 pub reason: DeadLetterReason,
69 pub timestamp: DateTime<Utc>,
71 pub retry_count: u32,
73}
74
75pub struct DeadLetterQueue {
84 letters: RwLock<VecDeque<DeadLetter>>,
85 max_size: usize,
86 #[cfg(feature = "exochain")]
88 chain: Option<Arc<crate::chain::ChainManager>>,
89}
90
91impl DeadLetterQueue {
92 pub fn new(max_size: usize) -> Self {
94 Self {
95 letters: RwLock::new(VecDeque::with_capacity(max_size.min(1024))),
96 max_size,
97 #[cfg(feature = "exochain")]
98 chain: None,
99 }
100 }
101
102 pub fn with_default_capacity() -> Self {
104 Self::new(DEFAULT_DLQ_CAPACITY)
105 }
106
107 #[cfg(feature = "exochain")]
112 pub fn with_chain(mut self, cm: Arc<crate::chain::ChainManager>) -> Self {
113 self.chain = Some(cm);
114 self
115 }
116
117 pub fn intake(&self, message: KernelMessage, reason: DeadLetterReason) {
123 #[cfg(feature = "exochain")]
125 if let Some(ref cm) = self.chain {
126 use crate::chain::ChainLoggable;
127 let event = crate::chain::IpcDeadLetterEvent {
128 message_id: message.id.clone(),
129 from_pid: message.from,
130 target: format!("{:?}", message.target),
131 payload_type: message.payload.type_name().to_owned(),
132 reason: format!("{reason}"),
133 timestamp: Utc::now(),
134 };
135 cm.append_loggable(&event);
136 }
137
138 let letter = DeadLetter {
139 message,
140 reason,
141 timestamp: Utc::now(),
142 retry_count: 0,
143 };
144
145 let mut letters = self.letters.write().expect("DLQ write lock poisoned");
146 while letters.len() >= self.max_size {
147 letters.pop_front();
148 }
149 letters.push_back(letter);
150 }
151
152 pub fn len(&self) -> usize {
154 self.letters.read().expect("DLQ read lock poisoned").len()
155 }
156
157 pub fn is_empty(&self) -> bool {
159 self.len() == 0
160 }
161
162 pub fn capacity(&self) -> usize {
164 self.max_size
165 }
166
167 pub fn query_by_target(&self, pid: Pid) -> Vec<DeadLetter> {
169 let letters = self.letters.read().expect("DLQ read lock poisoned");
170 letters
171 .iter()
172 .filter(|l| match &l.reason {
173 DeadLetterReason::TargetNotFound { pid: p } => *p == pid,
174 DeadLetterReason::InboxFull { pid: p } => *p == pid,
175 DeadLetterReason::AgentExited { pid: p } => *p == pid,
176 _ => {
177 matches!(&l.message.target, crate::ipc::MessageTarget::Process(p) if *p == pid)
179 }
180 })
181 .cloned()
182 .collect()
183 }
184
185 pub fn query_by_reason(&self, reason_name: &str) -> Vec<DeadLetter> {
187 let letters = self.letters.read().expect("DLQ read lock poisoned");
188 letters
189 .iter()
190 .filter(|l| {
191 let variant = match &l.reason {
192 DeadLetterReason::TargetNotFound { .. } => "TargetNotFound",
193 DeadLetterReason::InboxFull { .. } => "InboxFull",
194 DeadLetterReason::Timeout { .. } => "Timeout",
195 DeadLetterReason::GovernanceDenied { .. } => "GovernanceDenied",
196 DeadLetterReason::AgentExited { .. } => "AgentExited",
197 };
198 variant == reason_name
199 })
200 .cloned()
201 .collect()
202 }
203
204 pub fn query_by_time_range(
206 &self,
207 since: DateTime<Utc>,
208 until: DateTime<Utc>,
209 ) -> Vec<DeadLetter> {
210 let letters = self.letters.read().expect("DLQ read lock poisoned");
211 letters
212 .iter()
213 .filter(|l| l.timestamp >= since && l.timestamp <= until)
214 .cloned()
215 .collect()
216 }
217
218 pub fn take_for_retry(&self, msg_id: &str) -> Option<DeadLetter> {
222 let mut letters = self.letters.write().expect("DLQ write lock poisoned");
223 let idx = letters.iter().position(|l| l.message.id == msg_id)?;
224 let mut letter = letters.remove(idx)?;
225 letter.retry_count += 1;
226 Some(letter)
227 }
228
229 pub fn re_add(&self, letter: DeadLetter) {
231 let mut letters = self.letters.write().expect("DLQ write lock poisoned");
232 while letters.len() >= self.max_size {
233 letters.pop_front();
234 }
235 letters.push_back(letter);
236 }
237
238 pub fn snapshot(&self) -> Vec<DeadLetter> {
240 self.letters
241 .read()
242 .expect("DLQ read lock poisoned")
243 .iter()
244 .cloned()
245 .collect()
246 }
247
248 pub fn clear(&self) {
250 self.letters
251 .write()
252 .expect("DLQ write lock poisoned")
253 .clear();
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use crate::ipc::{MessagePayload, MessageTarget};
261
262 fn make_msg(from: Pid, target: Pid) -> KernelMessage {
263 KernelMessage::new(
264 from,
265 MessageTarget::Process(target),
266 MessagePayload::Text("test".into()),
267 )
268 }
269
270 #[test]
271 fn intake_and_len() {
272 let dlq = DeadLetterQueue::new(100);
273 assert!(dlq.is_empty());
274
275 dlq.intake(
276 make_msg(0, 99),
277 DeadLetterReason::TargetNotFound { pid: 99 },
278 );
279 assert_eq!(dlq.len(), 1);
280 }
281
282 #[test]
283 fn fifo_eviction_at_capacity() {
284 let dlq = DeadLetterQueue::new(3);
285
286 for i in 0..5u64 {
287 dlq.intake(make_msg(0, i), DeadLetterReason::TargetNotFound { pid: i });
288 }
289
290 assert_eq!(dlq.len(), 3);
291 let snap = dlq.snapshot();
293 let target_pids: Vec<Pid> = snap
294 .iter()
295 .filter_map(|l| match &l.reason {
296 DeadLetterReason::TargetNotFound { pid } => Some(*pid),
297 _ => None,
298 })
299 .collect();
300 assert_eq!(target_pids, vec![2, 3, 4]);
301 }
302
303 #[test]
304 fn query_by_target() {
305 let dlq = DeadLetterQueue::new(100);
306 dlq.intake(
307 make_msg(0, 10),
308 DeadLetterReason::TargetNotFound { pid: 10 },
309 );
310 dlq.intake(make_msg(0, 20), DeadLetterReason::InboxFull { pid: 20 });
311 dlq.intake(make_msg(0, 10), DeadLetterReason::AgentExited { pid: 10 });
312
313 let results = dlq.query_by_target(10);
314 assert_eq!(results.len(), 2);
315 }
316
317 #[test]
318 fn query_by_reason() {
319 let dlq = DeadLetterQueue::new(100);
320 dlq.intake(make_msg(0, 1), DeadLetterReason::TargetNotFound { pid: 1 });
321 dlq.intake(make_msg(0, 2), DeadLetterReason::InboxFull { pid: 2 });
322 dlq.intake(make_msg(0, 3), DeadLetterReason::TargetNotFound { pid: 3 });
323
324 let results = dlq.query_by_reason("TargetNotFound");
325 assert_eq!(results.len(), 2);
326 let results = dlq.query_by_reason("InboxFull");
327 assert_eq!(results.len(), 1);
328 }
329
330 #[test]
331 fn query_by_time_range() {
332 let dlq = DeadLetterQueue::new(100);
333 let before = Utc::now();
334 dlq.intake(make_msg(0, 1), DeadLetterReason::TargetNotFound { pid: 1 });
335 let after = Utc::now();
336
337 let results = dlq.query_by_time_range(before, after);
338 assert_eq!(results.len(), 1);
339
340 let future = after + chrono::Duration::hours(1);
342 let far_future = future + chrono::Duration::hours(1);
343 let results = dlq.query_by_time_range(future, far_future);
344 assert!(results.is_empty());
345 }
346
347 #[test]
348 fn take_for_retry_increments_count() {
349 let dlq = DeadLetterQueue::new(100);
350 let msg = make_msg(0, 1);
351 let msg_id = msg.id.clone();
352 dlq.intake(msg, DeadLetterReason::TargetNotFound { pid: 1 });
353
354 let letter = dlq.take_for_retry(&msg_id).unwrap();
355 assert_eq!(letter.retry_count, 1);
356 assert!(dlq.is_empty());
357 }
358
359 #[test]
360 fn take_for_retry_not_found() {
361 let dlq = DeadLetterQueue::new(100);
362 assert!(dlq.take_for_retry("nonexistent").is_none());
363 }
364
365 #[test]
366 fn re_add_after_failed_retry() {
367 let dlq = DeadLetterQueue::new(100);
368 let msg = make_msg(0, 1);
369 let msg_id = msg.id.clone();
370 dlq.intake(msg, DeadLetterReason::TargetNotFound { pid: 1 });
371
372 let letter = dlq.take_for_retry(&msg_id).unwrap();
373 assert_eq!(letter.retry_count, 1);
374 dlq.re_add(letter);
375 assert_eq!(dlq.len(), 1);
376
377 let snap = dlq.snapshot();
378 assert_eq!(snap[0].retry_count, 1);
379 }
380
381 #[test]
382 fn clear_removes_all() {
383 let dlq = DeadLetterQueue::new(100);
384 for i in 0..5 {
385 dlq.intake(make_msg(0, i), DeadLetterReason::TargetNotFound { pid: i });
386 }
387 assert_eq!(dlq.len(), 5);
388 dlq.clear();
389 assert!(dlq.is_empty());
390 }
391
392 #[test]
393 fn governance_denied_reason() {
394 let dlq = DeadLetterQueue::new(100);
395 dlq.intake(
396 make_msg(0, 1),
397 DeadLetterReason::GovernanceDenied {
398 reason: "policy violation".into(),
399 },
400 );
401
402 let results = dlq.query_by_reason("GovernanceDenied");
403 assert_eq!(results.len(), 1);
404 match &results[0].reason {
405 DeadLetterReason::GovernanceDenied { reason } => {
406 assert_eq!(reason, "policy violation");
407 }
408 _ => panic!("expected GovernanceDenied"),
409 }
410 }
411
412 #[test]
413 fn timeout_reason() {
414 let dlq = DeadLetterQueue::new(100);
415 dlq.intake(
416 make_msg(0, 1),
417 DeadLetterReason::Timeout { duration_ms: 5000 },
418 );
419
420 let results = dlq.query_by_reason("Timeout");
421 assert_eq!(results.len(), 1);
422 }
423
424 #[test]
425 fn reason_display() {
426 let reason = DeadLetterReason::TargetNotFound { pid: 42 };
427 assert_eq!(format!("{reason}"), "target_not_found(pid=42)");
428
429 let reason = DeadLetterReason::InboxFull { pid: 7 };
430 assert_eq!(format!("{reason}"), "inbox_full(pid=7)");
431 }
432
433 #[test]
434 fn dead_letter_serde_roundtrip() {
435 let letter = DeadLetter {
436 message: make_msg(0, 1),
437 reason: DeadLetterReason::TargetNotFound { pid: 1 },
438 timestamp: Utc::now(),
439 retry_count: 2,
440 };
441 let json = serde_json::to_string(&letter).unwrap();
442 let restored: DeadLetter = serde_json::from_str(&json).unwrap();
443 assert_eq!(restored.retry_count, 2);
444 assert_eq!(restored.message.id, letter.message.id);
445 }
446
447 #[cfg(feature = "exochain")]
448 #[test]
449 fn intake_logs_to_chain() {
450 let cm = std::sync::Arc::new(crate::chain::ChainManager::new(0, 100));
451 let initial_len = cm.len();
452
453 let dlq = DeadLetterQueue::new(100).with_chain(cm.clone());
454 dlq.intake(
455 make_msg(0, 99),
456 DeadLetterReason::TargetNotFound { pid: 99 },
457 );
458
459 assert_eq!(dlq.len(), 1);
460 assert_eq!(cm.len(), initial_len + 1);
461
462 let events = cm.tail(1);
463 assert_eq!(events[0].source, "ipc");
464 assert_eq!(events[0].kind, "ipc.dead_letter");
465
466 let payload = events[0].payload.as_ref().unwrap();
467 assert_eq!(payload["from_pid"], 0);
468 assert!(
469 payload["reason"]
470 .as_str()
471 .unwrap()
472 .contains("target_not_found")
473 );
474 }
475
476 #[cfg(feature = "exochain")]
477 #[test]
478 fn intake_without_chain_does_not_log() {
479 let dlq = DeadLetterQueue::new(100);
481 dlq.intake(
482 make_msg(0, 1),
483 DeadLetterReason::Timeout { duration_ms: 1000 },
484 );
485 assert_eq!(dlq.len(), 1);
486 }
487}