1use super::IncomingMessage;
6use chrono::{DateTime, Utc};
7
8use axum::{
9 Router,
10 extract::State,
11 http::StatusCode,
12 response::IntoResponse,
13 routing::{get, post},
14};
15use hmac::{Hmac, Mac};
16use sha2::Sha256;
17use std::collections::HashMap;
18use std::io::Write;
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21use std::time::{SystemTime, UNIX_EPOCH};
22use subtle::ConstantTimeEq;
23
24#[derive(serde::Deserialize, serde::Serialize)]
25struct GhUser {
26 login: String,
27}
28
29type HmacSha256 = Hmac<Sha256>;
30
31const DEFAULT_DEDUP_WINDOW_SECS: u64 = 2 * 60 * 60;
33const DEFAULT_MAX_SEEN_DELIVERIES: usize = 50_000;
35
36#[derive(Debug, serde::Serialize, Clone, Copy)]
42pub struct DedupMetrics {
43 pub seen_count: usize,
45 pub duplicate_count: u64,
47 pub inserted_count: u64,
49 pub evicted_count: u64,
51}
52
53struct DedupInner {
54 entries: HashMap<String, u64>,
56 duplicate_count: u64,
57 inserted_count: u64,
58 evicted_count: u64,
59}
60
61#[derive(Clone)]
69pub struct DedupStore {
70 inner: Arc<tokio::sync::Mutex<DedupInner>>,
71 file_path: Option<PathBuf>,
73 window_secs: u64,
74 max_size: usize,
75 metrics_enabled: bool,
76}
77
78impl std::fmt::Debug for DedupStore {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 f.debug_struct("DedupStore")
81 .field("file_path", &self.file_path)
82 .field("window_secs", &self.window_secs)
83 .field("max_size", &self.max_size)
84 .field("metrics_enabled", &self.metrics_enabled)
85 .finish_non_exhaustive()
86 }
87}
88
89impl DedupStore {
90 #[cfg(test)]
92 pub fn new_in_memory(window_secs: u64, max_size: usize) -> Self {
93 Self::new_in_memory_with_metrics(window_secs, max_size, true)
94 }
95
96 pub fn new_in_memory_with_metrics(
98 window_secs: u64,
99 max_size: usize,
100 metrics_enabled: bool,
101 ) -> Self {
102 Self {
103 inner: Arc::new(tokio::sync::Mutex::new(DedupInner {
104 entries: HashMap::new(),
105 duplicate_count: 0,
106 inserted_count: 0,
107 evicted_count: 0,
108 })),
109 file_path: None,
110 window_secs,
111 max_size,
112 metrics_enabled,
113 }
114 }
115
116 #[cfg(test)]
122 pub fn new_filebacked(path: PathBuf, window_secs: u64, max_size: usize) -> Self {
123 Self::new_filebacked_with_metrics(path, window_secs, max_size, true)
124 }
125
126 pub fn new_filebacked_with_metrics(
128 path: PathBuf,
129 window_secs: u64,
130 max_size: usize,
131 metrics_enabled: bool,
132 ) -> Self {
133 let entries = load_dedup_file(&path, window_secs);
134 tracing::info!(
135 path = %path.display(),
136 loaded = entries.len(),
137 "loaded webhook dedup state from file"
138 );
139 Self {
140 inner: Arc::new(tokio::sync::Mutex::new(DedupInner {
141 entries,
142 duplicate_count: 0,
143 inserted_count: 0,
144 evicted_count: 0,
145 })),
146 file_path: Some(path),
147 window_secs,
148 max_size,
149 metrics_enabled,
150 }
151 }
152
153 pub async fn check_and_insert(&self, delivery_id: &str) -> bool {
158 const MAX_FLUSH_ATTEMPTS: u32 = 3;
159
160 let now_secs = SystemTime::now()
161 .duration_since(UNIX_EPOCH)
162 .unwrap_or_default()
163 .as_secs();
164
165 let (is_new, flush_entries) = {
166 let mut inner = self.inner.lock().await;
167 let cutoff = now_secs.saturating_sub(self.window_secs);
169 let mut expired = 0usize;
170 if self.metrics_enabled {
171 let before = inner.entries.len();
172 inner.entries.retain(|_, ts| *ts > cutoff);
173 expired = before.saturating_sub(inner.entries.len());
174 } else {
175 inner.entries.retain(|_, ts| *ts > cutoff);
176 }
177
178 if inner.entries.contains_key(delivery_id) {
179 if self.metrics_enabled {
180 inner.duplicate_count += 1;
181 inner.evicted_count += expired as u64;
182 }
183 (false, None)
184 } else {
185 inner.entries.insert(delivery_id.to_string(), now_secs);
186 if self.metrics_enabled {
187 inner.inserted_count += 1;
188 }
189 let evicted_size = enforce_size_cap(&mut inner.entries, self.max_size);
191 if self.metrics_enabled {
192 inner.evicted_count += expired as u64;
193 inner.evicted_count += evicted_size as u64;
194 }
195 let snap = self.file_path.is_some().then(|| inner.entries.clone());
197 (true, snap)
198 }
199 };
200
201 if let (Some(path), Some(entries)) = (&self.file_path, flush_entries) {
207 let path = path.clone();
208 let path_for_log = path.clone();
209 let entries = Arc::new(entries);
210 let mut attempt = 0u32;
211 loop {
212 attempt += 1;
213 let path_clone = path.clone();
214 let entries_clone = Arc::clone(&entries);
215 match tokio::task::spawn_blocking(move || {
216 flush_dedup_file(&path_clone, &entries_clone)
217 })
218 .await
219 {
220 Ok(Ok(())) => break,
221 Ok(Err(e)) if attempt < MAX_FLUSH_ATTEMPTS => {
222 let delay = std::time::Duration::from_millis(100u64 << (attempt - 1));
223 tracing::warn!(
224 ?e,
225 attempt,
226 path = %path_for_log.display(),
227 "failed to flush webhook dedup state, retrying"
228 );
229 tokio::time::sleep(delay).await;
230 }
231 Ok(Err(e)) => {
232 tracing::warn!(
233 ?e,
234 path = %path_for_log.display(),
235 "failed to flush webhook dedup state after {MAX_FLUSH_ATTEMPTS} attempts"
236 );
237 break;
238 }
239 Err(e) => {
240 tracing::warn!(?e, "spawn_blocking failed for flush_dedup_file");
241 break;
242 }
243 }
244 }
245 }
246
247 is_new
248 }
249
250 pub async fn metrics(&self) -> DedupMetrics {
252 let inner = self.inner.lock().await;
253 DedupMetrics {
254 seen_count: inner.entries.len(),
255 duplicate_count: if self.metrics_enabled {
256 inner.duplicate_count
257 } else {
258 0
259 },
260 inserted_count: if self.metrics_enabled {
261 inner.inserted_count
262 } else {
263 0
264 },
265 evicted_count: if self.metrics_enabled {
266 inner.evicted_count
267 } else {
268 0
269 },
270 }
271 }
272
273 pub fn metrics_enabled(&self) -> bool {
274 self.metrics_enabled
275 }
276}
277
278fn enforce_size_cap(entries: &mut HashMap<String, u64>, max_size: usize) -> usize {
280 if entries.len() <= max_size {
281 return 0;
282 }
283 let mut sorted: Vec<(String, u64)> = entries.iter().map(|(k, v)| (k.clone(), *v)).collect();
284 sorted.sort_by_key(|(_, t)| *t);
285 let overflow = sorted.len().saturating_sub(max_size);
286 for (key, _) in sorted.into_iter().take(overflow) {
287 entries.remove(&key);
288 }
289 overflow
290}
291
292#[cfg(test)]
295fn prune_dedup_inner(inner: &mut DedupInner, now_secs: u64, window_secs: u64, max_size: usize) {
296 let cutoff = now_secs.saturating_sub(window_secs);
297 inner.entries.retain(|_, ts| *ts > cutoff);
298 enforce_size_cap(&mut inner.entries, max_size);
299}
300
301fn load_dedup_file(path: &Path, window_secs: u64) -> HashMap<String, u64> {
303 #[derive(serde::Deserialize)]
304 struct FileFormat {
305 entries: HashMap<String, u64>,
306 }
307
308 let content = match std::fs::read_to_string(path) {
309 Ok(c) => c,
310 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return HashMap::new(),
311 Err(e) => {
312 tracing::warn!(?e, path = %path.display(), "failed to read webhook dedup file, starting fresh");
313 return HashMap::new();
314 }
315 };
316
317 let parsed: FileFormat = match serde_json::from_str(&content) {
318 Ok(p) => p,
319 Err(e) => {
320 tracing::warn!(?e, path = %path.display(), "failed to parse webhook dedup file, starting fresh");
321 return HashMap::new();
322 }
323 };
324
325 let now_secs = SystemTime::now()
326 .duration_since(UNIX_EPOCH)
327 .unwrap_or_default()
328 .as_secs();
329 let cutoff = now_secs.saturating_sub(window_secs);
330 parsed
331 .entries
332 .into_iter()
333 .filter(|(_, ts)| *ts > cutoff)
334 .collect()
335}
336
337fn flush_dedup_file(path: &Path, entries: &HashMap<String, u64>) -> std::io::Result<()> {
339 #[derive(serde::Serialize)]
340 struct FileFormat<'a> {
341 entries: &'a HashMap<String, u64>,
342 }
343
344 let content = serde_json::to_string(&FileFormat { entries })
345 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
346 atomic_write(path, &content)
347}
348
349fn atomic_write(path: &Path, content: &str) -> std::io::Result<()> {
350 if let Some(parent) = path.parent() {
351 std::fs::create_dir_all(parent)?;
352 }
353
354 let tmp_path = path.with_extension(format!("db.tmp.{}", uuid::Uuid::new_v4()));
355 let mut file = std::fs::File::create(&tmp_path)?;
356 file.write_all(content.as_bytes())?;
357 file.sync_all()?;
358 std::fs::rename(&tmp_path, path)?;
359 if let Some(parent) = path.parent()
360 && let Ok(dir) = std::fs::File::open(parent)
361 && let Err(e) = dir.sync_all()
362 {
363 tracing::warn!(?e, path = %parent.display(), "failed to sync parent directory after dedup file rename");
364 }
365 Ok(())
366}
367
368#[derive(Clone)]
373struct WebhookState {
374 secret: String,
375 repo: String,
376 tx: tokio::sync::mpsc::Sender<IncomingMessage>,
377 dedup_store: DedupStore,
378}
379
380#[derive(serde::Deserialize, serde::Serialize)]
381struct WebhookPayload {
382 action: Option<String>,
383 issue: Option<IssuePayload>,
384 #[serde(rename = "pull_request")]
385 pr: Option<PullRequestPayload>,
386 comment: Option<CommentPayload>,
387 review: Option<ReviewPayload>,
388}
389
390#[derive(serde::Deserialize, serde::Serialize)]
391struct IssuePayload {
392 number: u64,
393 title: String,
394 labels: Vec<LabelPayload>,
395}
396
397#[derive(serde::Deserialize, serde::Serialize)]
398struct LabelPayload {
399 name: String,
400}
401
402#[derive(serde::Deserialize, serde::Serialize)]
403struct PullRequestPayload {
404 number: u64,
405 title: String,
406 body: Option<String>,
407 action: Option<String>,
408}
409
410#[derive(serde::Deserialize, serde::Serialize)]
411struct CommentPayload {
412 id: u64,
413 body: String,
414 user: GhUser,
415 created_at: Option<DateTime<Utc>>,
416}
417
418#[derive(serde::Deserialize, serde::Serialize)]
419struct ReviewPayload {
420 id: u64,
421 state: String,
422 body: Option<String>,
423 user: GhUser,
424}
425
426fn verify_signature(secret: &str, payload: &[u8], signature: &str) -> bool {
427 let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
428 return false;
429 };
430 mac.update(payload);
431 let result = mac.finalize().into_bytes();
432
433 let expected = format!("sha256={result:x}");
434 if expected.len() != signature.len() {
435 return false;
436 }
437 bool::from(expected.as_bytes().ct_eq(signature.as_bytes()))
439}
440
441fn parse_github_event(
442 payload: &WebhookPayload,
443 event_type: &str,
444 _repo: &str,
445) -> Option<IncomingMessage> {
446 let timestamp = chrono::Utc::now();
447
448 match event_type {
449 "issues" => {
450 if let (Some(action), Some(issue)) = (&payload.action, &payload.issue)
451 && (action == "opened" || action == "labeled")
452 {
453 let labels: Vec<String> = issue.labels.iter().map(|l| l.name.clone()).collect();
454 return Some(IncomingMessage {
455 channel: "github".to_string(),
456 id: format!("issue-{}-{}", issue.number, action),
457 thread_id: issue.number.to_string(),
458 author: "github".to_string(),
459 body: format!("Issue {}: {}", action, issue.title),
460 timestamp,
461 metadata: serde_json::json!({
462 "event": "issues",
463 "action": action,
464 "issue_number": issue.number,
465 "labels": labels
466 }),
467 topic_id: None,
468 });
469 }
470 }
471 "pull_request" => {
472 if let (Some(action), Some(pr)) = (&payload.action, &payload.pr) {
473 let relevant_actions = ["opened", "synchronize", "labeled", "ready_for_review"];
474 if relevant_actions.contains(&action.as_str()) {
475 return Some(IncomingMessage {
476 channel: "github".to_string(),
477 id: format!("pr-{}-{}", pr.number, action),
478 thread_id: pr.number.to_string(),
479 author: "github".to_string(),
480 body: format!("PR {}: {}", action, pr.title),
481 timestamp,
482 metadata: serde_json::json!({
483 "event": "pull_request",
484 "action": action,
485 "pr_number": pr.number,
486 "pr_title": pr.title
487 }),
488 topic_id: None,
489 });
490 }
491 }
492 }
493 "issue_comment" => {
494 if let (Some(action), Some(comment), Some(issue)) =
495 (&payload.action, &payload.comment, &payload.issue)
496 && action == "created"
497 {
498 return Some(IncomingMessage {
499 channel: "github".to_string(),
500 id: format!("comment-{}", comment.id),
501 thread_id: issue.number.to_string(),
502 author: comment.user.login.clone(),
503 body: comment.body.clone(),
504 timestamp: comment.created_at.unwrap_or(timestamp),
505 metadata: serde_json::json!({
506 "event": "issue_comment",
507 "action": action,
508 "issue_number": issue.number
509 }),
510 topic_id: None,
511 });
512 }
513 }
514 "pull_request_review" => {
515 if let (Some(action), Some(review), Some(pr)) =
516 (&payload.action, &payload.review, &payload.pr)
517 && action == "submitted"
518 {
519 return Some(IncomingMessage {
520 channel: "github".to_string(),
521 id: format!("review-{}", review.id),
522 thread_id: pr.number.to_string(),
523 author: review.user.login.clone(),
524 body: review
525 .body
526 .clone()
527 .unwrap_or_else(|| format!("Review {}", review.state)),
528 timestamp,
529 metadata: serde_json::json!({
530 "event": "pull_request_review",
531 "action": action,
532 "pr_number": pr.number,
533 "review_state": review.state
534 }),
535 topic_id: None,
536 });
537 }
538 }
539 _ => {}
540 }
541
542 None
543}
544
545async fn handle_webhook(
546 State(state): State<WebhookState>,
547 headers: axum::http::HeaderMap,
548 body: axum::body::Bytes,
549) -> impl IntoResponse {
550 let signature = headers
551 .get("x-hub-signature-256")
552 .and_then(|v| v.to_str().ok())
553 .unwrap_or("");
554
555 if !state.secret.is_empty() && !verify_signature(&state.secret, &body, signature) {
556 tracing::warn!("webhook signature verification failed");
557 return (StatusCode::UNAUTHORIZED, "Invalid signature");
558 }
559
560 let delivery_id = headers
563 .get("x-github-delivery")
564 .and_then(|v| v.to_str().ok())
565 .unwrap_or("")
566 .to_string();
567
568 if !delivery_id.is_empty() && !state.dedup_store.check_and_insert(&delivery_id).await {
569 if state.dedup_store.metrics_enabled() {
570 let metrics = state.dedup_store.metrics().await;
571 tracing::debug!(
572 delivery_id = %delivery_id,
573 seen_count = metrics.seen_count,
574 duplicate_count = metrics.duplicate_count,
575 "duplicate webhook delivery, skipping"
576 );
577 } else {
578 tracing::debug!(delivery_id = %delivery_id, "duplicate webhook delivery, skipping");
579 }
580 return (StatusCode::OK, "OK");
581 }
582
583 let payload: WebhookPayload = match serde_json::from_slice(&body) {
584 Ok(p) => p,
585 Err(e) => {
586 tracing::warn!(?e, "failed to parse webhook payload");
587 return (StatusCode::BAD_REQUEST, "Invalid JSON");
588 }
589 };
590
591 let event_type = headers
592 .get("x-github-event")
593 .and_then(|v| v.to_str().ok())
594 .unwrap_or("");
595
596 if let Some(msg) = parse_github_event(&payload, event_type, &state.repo)
597 && state.tx.send(msg).await.is_err()
598 {
599 tracing::warn!("webhook event channel receiver dropped");
600 }
601
602 (StatusCode::OK, "OK")
603}
604
605async fn webhook_health(State(state): State<WebhookState>) -> impl IntoResponse {
609 let metrics = state.dedup_store.metrics().await;
610 axum::Json(serde_json::json!({
611 "ok": true,
612 "seen_count": metrics.seen_count,
613 "duplicate_count": metrics.duplicate_count
614 }))
615}
616
617pub async fn check_webhook_health(port: u16) -> (bool, Option<String>) {
628 let client = match reqwest::Client::builder()
629 .timeout(std::time::Duration::from_secs(5))
630 .build()
631 {
632 Ok(c) => c,
633 Err(e) => {
634 tracing::warn!(?e, "failed to build HTTP client for webhook health check");
635 return (false, Some(format!("failed to build HTTP client: {e}")));
636 }
637 };
638 let url = format!("http://localhost:{port}/health");
639 match client.get(&url).send().await {
640 Ok(response) if response.status().is_success() => {
641 if let Ok(body) = response.json::<serde_json::Value>().await {
643 let seen = body
644 .get("seen_count")
645 .and_then(serde_json::Value::as_u64)
646 .unwrap_or(0);
647 let dupes = body
648 .get("duplicate_count")
649 .and_then(serde_json::Value::as_u64)
650 .unwrap_or(0);
651 tracing::debug!(
652 seen_count = seen,
653 duplicate_count = dupes,
654 "webhook health ok"
655 );
656 }
657 (true, None)
658 }
659 Ok(response) => {
660 let reason = format!("health endpoint returned {}", response.status());
661 (false, Some(reason))
662 }
663 Err(e) => (false, Some(format!("health check request failed: {e}"))),
664 }
665}
666
667pub fn is_transient_bind_error(e: &anyhow::Error) -> bool {
670 for cause in e.chain() {
671 if let Some(io_err) = cause.downcast_ref::<std::io::Error>() {
672 return matches!(
673 io_err.kind(),
674 std::io::ErrorKind::AddrInUse | std::io::ErrorKind::AddrNotAvailable
675 );
676 }
677 }
678 false
679}
680
681pub fn webhook_backoff_delay(attempt: u32) -> std::time::Duration {
686 let base_ms: u64 = 500;
687 let max_ms: u64 = 30_000;
688 let exp_ms = base_ms.saturating_mul(1u64 << (attempt.saturating_sub(1)).min(6));
689 let bounded_ms = exp_ms.min(max_ms);
690 let jitter_ms = u64::from(
691 std::time::SystemTime::now()
692 .duration_since(std::time::UNIX_EPOCH)
693 .unwrap_or_default()
694 .subsec_millis(),
695 ) % 500;
696 std::time::Duration::from_millis(bounded_ms + jitter_ms)
697}
698
699pub async fn start_webhook_server(
709 port: u16,
710 secret: String,
711 repo: String,
712 tx: tokio::sync::mpsc::Sender<IncomingMessage>,
713) -> anyhow::Result<()> {
714 let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await?;
715
716 let window_secs = crate::config::get("webhook.dedup_window_seconds")
717 .ok()
718 .and_then(|v| v.parse::<u64>().ok())
719 .unwrap_or(DEFAULT_DEDUP_WINDOW_SECS);
720
721 let max_size = crate::config::get("webhook.max_seen_deliveries")
722 .ok()
723 .and_then(|v| v.parse::<usize>().ok())
724 .unwrap_or(DEFAULT_MAX_SEEN_DELIVERIES);
725
726 let store_type =
727 crate::config::get("webhook.dedup_store").unwrap_or_else(|_| "memory".to_string());
728
729 let metrics_enabled = crate::config::get("webhook.dedup_metrics").map_or(true, |v| v == "true");
730
731 let dedup_store = if store_type == "file" {
732 let path = crate::config::get("webhook.dedup_path").ok().map_or_else(
733 || {
734 crate::home::orch_home()
735 .unwrap_or_else(|_| PathBuf::from("/tmp"))
736 .join("webhook_dedup.db")
737 },
738 PathBuf::from,
739 );
740
741 match tokio::task::spawn_blocking(move || {
746 DedupStore::new_filebacked_with_metrics(path, window_secs, max_size, metrics_enabled)
747 })
748 .await
749 {
750 Ok(store) => store,
751 Err(e) => {
752 return Err(anyhow::anyhow!(
753 "failed to initialize file-backed dedup store: {e}"
754 ));
755 }
756 }
757 } else {
758 DedupStore::new_in_memory_with_metrics(window_secs, max_size, metrics_enabled)
759 };
760
761 tracing::info!(
762 port,
763 dedup_store = %store_type,
764 dedup_window_secs = window_secs,
765 max_seen_deliveries = max_size,
766 dedup_metrics = metrics_enabled,
767 "starting webhook server"
768 );
769
770 let state = WebhookState {
771 secret,
772 repo,
773 tx,
774 dedup_store,
775 };
776
777 let app = Router::new()
778 .route("/health", get(webhook_health))
779 .route("/webhook", post(handle_webhook))
780 .with_state(state);
781
782 axum::serve(listener, app).await?;
783
784 Ok(())
785}
786
787#[cfg(test)]
788mod tests {
789 use super::*;
790
791 #[test]
792 fn test_verify_signature_valid() {
793 let secret = "test-secret";
794 let payload = br#"{"action":"opened"}"#;
795 let signature = "sha256=e7f446e3b1c1c8e7b8c8e7b8c8e7b8c8e7b8c8e7b8c8e7b8c8e7b8c8e7b8c8";
796
797 let result = verify_signature(secret, payload, signature);
798 assert!(!result, "Invalid signature format should fail");
799 }
800
801 #[test]
802 fn test_verify_signature_invalid() {
803 let secret = "test-secret";
804 let payload = br#"{"action":"opened"}"#;
805 let signature = "sha256=invalidsignature";
806
807 let result = verify_signature(secret, payload, signature);
808 assert!(!result, "Invalid signature should return false");
809 }
810
811 #[test]
812 fn test_verify_signature_empty_secret() {
813 let secret = "";
814 let payload = br#"{"action":"opened"}"#;
815 let signature = "sha256=anysignature";
816
817 let result = verify_signature(secret, payload, signature);
818 assert!(!result, "Empty secret should return false");
819 }
820
821 #[test]
822 fn test_parse_github_event_issue_opened() {
823 let payload = WebhookPayload {
824 action: Some("opened".to_string()),
825 issue: Some(IssuePayload {
826 number: 42,
827 title: "Test issue".to_string(),
828 labels: vec![],
829 }),
830 pr: None,
831 comment: None,
832 review: None,
833 };
834
835 let msg = parse_github_event(&payload, "issues", "owner/repo");
836 assert!(msg.is_some());
837
838 let msg = msg.unwrap();
839 assert_eq!(msg.channel, "github");
840 assert_eq!(msg.thread_id, "42");
841 assert!(msg.body.contains("opened"));
842 assert!(msg.body.contains("Test issue"));
843 }
844
845 #[test]
846 fn test_parse_github_event_issue_labeled() {
847 let payload = WebhookPayload {
848 action: Some("labeled".to_string()),
849 issue: Some(IssuePayload {
850 number: 42,
851 title: "Test issue".to_string(),
852 labels: vec![
853 LabelPayload {
854 name: "bug".to_string(),
855 },
856 LabelPayload {
857 name: "urgent".to_string(),
858 },
859 ],
860 }),
861 pr: None,
862 comment: None,
863 review: None,
864 };
865
866 let msg = parse_github_event(&payload, "issues", "owner/repo");
867 assert!(msg.is_some());
868
869 let msg = msg.unwrap();
870 assert_eq!(msg.thread_id, "42");
871 let labels = msg.metadata.get("labels").unwrap().as_array().unwrap();
872 assert_eq!(labels.len(), 2);
873 }
874
875 #[test]
876 fn test_parse_github_event_pr_opened() {
877 let payload = WebhookPayload {
878 action: Some("opened".to_string()),
879 issue: None,
880 pr: Some(PullRequestPayload {
881 number: 101,
882 title: "New feature".to_string(),
883 body: Some("Description".to_string()),
884 action: Some("opened".to_string()),
885 }),
886 comment: None,
887 review: None,
888 };
889
890 let msg = parse_github_event(&payload, "pull_request", "owner/repo");
891 assert!(msg.is_some());
892
893 let msg = msg.unwrap();
894 assert_eq!(msg.thread_id, "101");
895 assert!(msg.body.contains("opened"));
896 assert!(msg.body.contains("New feature"));
897 }
898
899 #[test]
900 fn test_parse_github_event_pr_synchronize() {
901 let payload = WebhookPayload {
902 action: Some("synchronize".to_string()),
903 issue: None,
904 pr: Some(PullRequestPayload {
905 number: 101,
906 title: "New feature".to_string(),
907 body: Some("Description".to_string()),
908 action: Some("synchronize".to_string()),
909 }),
910 comment: None,
911 review: None,
912 };
913
914 let msg = parse_github_event(&payload, "pull_request", "owner/repo");
915 assert!(msg.is_some());
916
917 let msg = msg.unwrap();
918 assert!(msg.body.contains("synchronize"));
919 }
920
921 #[test]
922 fn test_parse_github_event_ignored_action() {
923 let payload = WebhookPayload {
924 action: Some("closed".to_string()),
925 issue: Some(IssuePayload {
926 number: 42,
927 title: "Test issue".to_string(),
928 labels: vec![],
929 }),
930 pr: None,
931 comment: None,
932 review: None,
933 };
934
935 let msg = parse_github_event(&payload, "issues", "owner/repo");
936 assert!(msg.is_none(), "closed action should be ignored");
937 }
938
939 #[test]
940 fn test_parse_github_event_unknown_type() {
941 let payload = WebhookPayload {
942 action: Some("created".to_string()),
943 issue: None,
944 pr: None,
945 comment: None,
946 review: None,
947 };
948
949 let msg = parse_github_event(&payload, "unknown_event", "owner/repo");
950 assert!(msg.is_none());
951 }
952
953 #[test]
954 fn test_parse_github_event_issue_comment() {
955 let payload = WebhookPayload {
956 action: Some("created".to_string()),
957 issue: Some(IssuePayload {
958 number: 42,
959 title: "Test issue".to_string(),
960 labels: vec![],
961 }),
962 pr: None,
963 comment: Some(CommentPayload {
964 id: 12345,
965 body: "This is a comment".to_string(),
966 user: GhUser {
967 login: "testuser".to_string(),
968 },
969 created_at: Some(
970 chrono::DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
971 .unwrap()
972 .with_timezone(&chrono::Utc),
973 ),
974 }),
975 review: None,
976 };
977
978 let msg = parse_github_event(&payload, "issue_comment", "owner/repo");
979 assert!(msg.is_some());
980
981 let msg = msg.unwrap();
982 assert_eq!(msg.id, "comment-12345", "ID should use actual comment ID");
983 assert_eq!(msg.author, "testuser");
984 assert_eq!(msg.body, "This is a comment");
985 assert_eq!(msg.thread_id, "42");
986 assert_eq!(
987 msg.metadata.get("issue_number").unwrap().as_u64().unwrap(),
988 42
989 );
990 }
991
992 #[test]
993 fn test_parse_github_event_issue_comment_without_issue_ignored() {
994 let payload = WebhookPayload {
995 action: Some("created".to_string()),
996 issue: None,
997 pr: None,
998 comment: Some(CommentPayload {
999 id: 12345,
1000 body: "Orphan comment".to_string(),
1001 user: GhUser {
1002 login: "testuser".to_string(),
1003 },
1004 created_at: None,
1005 }),
1006 review: None,
1007 };
1008
1009 let msg = parse_github_event(&payload, "issue_comment", "owner/repo");
1010 assert!(msg.is_none(), "comment without issue should be ignored");
1011 }
1012
1013 #[test]
1014 fn test_parse_github_event_pr_review_submitted() {
1015 let payload = WebhookPayload {
1016 action: Some("submitted".to_string()),
1017 issue: None,
1018 pr: Some(PullRequestPayload {
1019 number: 55,
1020 title: "Add feature".to_string(),
1021 body: Some("Description".to_string()),
1022 action: None,
1023 }),
1024 comment: None,
1025 review: Some(ReviewPayload {
1026 id: 98765,
1027 state: "changes_requested".to_string(),
1028 body: Some("Please fix the tests".to_string()),
1029 user: GhUser {
1030 login: "reviewer".to_string(),
1031 },
1032 }),
1033 };
1034
1035 let msg = parse_github_event(&payload, "pull_request_review", "owner/repo");
1036 assert!(msg.is_some());
1037
1038 let msg = msg.unwrap();
1039 assert_eq!(msg.id, "review-98765", "ID should use actual review ID");
1040 assert_eq!(msg.thread_id, "55");
1041 assert_eq!(msg.author, "reviewer");
1042 assert_eq!(msg.body, "Please fix the tests");
1043 assert_eq!(
1044 msg.metadata.get("review_state").unwrap().as_str().unwrap(),
1045 "changes_requested"
1046 );
1047 }
1048
1049 #[test]
1050 fn test_parse_github_event_pr_review_non_submitted_ignored() {
1051 let payload = WebhookPayload {
1052 action: Some("dismissed".to_string()),
1053 issue: None,
1054 pr: Some(PullRequestPayload {
1055 number: 55,
1056 title: "Add feature".to_string(),
1057 body: None,
1058 action: None,
1059 }),
1060 comment: None,
1061 review: Some(ReviewPayload {
1062 id: 98765,
1063 state: "dismissed".to_string(),
1064 body: None,
1065 user: GhUser {
1066 login: "reviewer".to_string(),
1067 },
1068 }),
1069 };
1070
1071 let msg = parse_github_event(&payload, "pull_request_review", "owner/repo");
1072 assert!(msg.is_none(), "dismissed review should be ignored");
1073 }
1074
1075 #[test]
1076 fn test_verify_signature_correct() {
1077 let secret = "mysecret";
1078 let payload = br#"{"action":"opened"}"#;
1079
1080 let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
1082 mac.update(payload);
1083 let result = mac.finalize().into_bytes();
1084 let signature = format!("sha256={result:x}");
1085
1086 assert!(
1087 verify_signature(secret, payload, &signature),
1088 "correct signature should verify"
1089 );
1090 }
1091
1092 #[test]
1097 fn test_dedup_store_is_send_sync() {
1098 fn assert_send_sync<T: Send + Sync>() {}
1099 assert_send_sync::<DedupStore>();
1100 }
1101
1102 #[tokio::test]
1103 async fn test_dedup_store_new_entry_is_accepted() {
1104 let store = DedupStore::new_in_memory(7200, 50_000);
1105 assert!(
1106 store.check_and_insert("delivery-1").await,
1107 "first insertion should return true"
1108 );
1109 }
1110
1111 #[tokio::test]
1112 async fn test_dedup_store_duplicate_is_rejected() {
1113 let store = DedupStore::new_in_memory(7200, 50_000);
1114 assert!(store.check_and_insert("delivery-1").await);
1115 assert!(
1116 !store.check_and_insert("delivery-1").await,
1117 "duplicate should return false"
1118 );
1119 }
1120
1121 #[tokio::test]
1122 async fn test_dedup_store_metrics_track_counts() {
1123 let store = DedupStore::new_in_memory(7200, 50_000);
1124
1125 store.check_and_insert("d1").await;
1126 store.check_and_insert("d2").await;
1127 store.check_and_insert("d1").await; let m = store.metrics().await;
1130 assert_eq!(m.seen_count, 2, "two unique IDs stored");
1131 assert_eq!(m.inserted_count, 2, "two insertions");
1132 assert_eq!(m.duplicate_count, 1, "one duplicate");
1133 }
1134
1135 #[tokio::test]
1136 async fn test_dedup_store_caps_size() {
1137 let max = 10usize;
1138 let store = DedupStore::new_in_memory(7200, max);
1139
1140 for i in 0..(max + 5) {
1142 store.check_and_insert(&format!("d-{i}")).await;
1143 }
1144
1145 let m = store.metrics().await;
1146 assert!(
1147 m.seen_count <= max,
1148 "seen_count {} should not exceed max {}",
1149 m.seen_count,
1150 max
1151 );
1152 }
1153
1154 #[tokio::test]
1155 async fn test_dedup_store_filebacked_persists_across_instances() {
1156 let dir = tempfile::tempdir().unwrap();
1157 let path = dir.path().join("dedup.json");
1158
1159 {
1161 let store = DedupStore::new_filebacked(path.clone(), 7200, 50_000);
1162 store.check_and_insert("delivery-persisted").await;
1163 }
1164
1165 {
1167 let store = DedupStore::new_filebacked(path.clone(), 7200, 50_000);
1168 assert!(
1169 !store.check_and_insert("delivery-persisted").await,
1170 "entry loaded from file should be treated as duplicate"
1171 );
1172 }
1173 }
1174
1175 #[tokio::test]
1176 async fn test_dedup_store_filebacked_expired_entries_not_loaded() {
1177 let dir = tempfile::tempdir().unwrap();
1178 let path = dir.path().join("dedup.json");
1179
1180 let content = r#"{"entries":{"old-delivery":0}}"#;
1182 std::fs::write(&path, content).unwrap();
1183
1184 let store = DedupStore::new_filebacked(path, 1, 50_000);
1186 assert!(
1187 store.check_and_insert("old-delivery").await,
1188 "expired entry should not block new delivery with same ID"
1189 );
1190 }
1191
1192 #[test]
1193 fn test_prune_dedup_inner_caps_size() {
1194 let mut inner = DedupInner {
1195 entries: HashMap::new(),
1196 duplicate_count: 0,
1197 inserted_count: 0,
1198 evicted_count: 0,
1199 };
1200 let now_secs: u64 = 1_700_000_000;
1202
1203 for i in 0..(DEFAULT_MAX_SEEN_DELIVERIES + 10) {
1204 inner.entries.insert(format!("id-{i}"), now_secs - i as u64);
1205 }
1206
1207 let large_window = (DEFAULT_MAX_SEEN_DELIVERIES + 100) as u64;
1210 prune_dedup_inner(
1211 &mut inner,
1212 now_secs,
1213 large_window,
1214 DEFAULT_MAX_SEEN_DELIVERIES,
1215 );
1216
1217 assert_eq!(inner.entries.len(), DEFAULT_MAX_SEEN_DELIVERIES);
1218 assert!(
1219 inner.entries.contains_key("id-0"),
1220 "newest entry should remain"
1221 );
1222 assert!(
1223 !inner
1224 .entries
1225 .contains_key(&format!("id-{}", DEFAULT_MAX_SEEN_DELIVERIES + 9)),
1226 "oldest entries should be evicted"
1227 );
1228 }
1229
1230 #[tokio::test]
1234 async fn test_webhook_handler_issues_opened() {
1235 use axum::body::Body;
1236 use axum::http::Request;
1237 use tower::util::ServiceExt; let (tx, mut rx) = tokio::sync::mpsc::channel::<IncomingMessage>(16);
1240
1241 let state = WebhookState {
1242 secret: String::new(), repo: "owner/repo".to_string(),
1244 tx,
1245 dedup_store: DedupStore::new_in_memory(7200, 50_000),
1246 };
1247
1248 let app = Router::new()
1249 .route("/webhook", post(handle_webhook))
1250 .with_state(state);
1251
1252 let body = serde_json::json!({
1253 "action": "opened",
1254 "issue": {
1255 "number": 99,
1256 "title": "New bug report",
1257 "labels": [{"name": "bug"}]
1258 }
1259 });
1260
1261 let request = Request::builder()
1262 .method("POST")
1263 .uri("/webhook")
1264 .header("content-type", "application/json")
1265 .header("x-github-event", "issues")
1266 .body(Body::from(serde_json::to_vec(&body).unwrap()))
1267 .unwrap();
1268
1269 let response = app.oneshot(request).await.unwrap();
1270 assert_eq!(response.status(), StatusCode::OK);
1271
1272 let msg = rx.try_recv().expect("should receive webhook message");
1274 assert_eq!(msg.channel, "github");
1275 assert_eq!(msg.thread_id, "99");
1276 assert_eq!(
1277 msg.metadata.get("event").unwrap().as_str().unwrap(),
1278 "issues"
1279 );
1280 assert_eq!(
1281 msg.metadata.get("action").unwrap().as_str().unwrap(),
1282 "opened"
1283 );
1284 }
1285
1286 #[tokio::test]
1288 async fn test_webhook_handler_issue_comment() {
1289 use axum::body::Body;
1290 use axum::http::Request;
1291 use tower::ServiceExt;
1292
1293 let (tx, mut rx) = tokio::sync::mpsc::channel::<IncomingMessage>(16);
1294
1295 let state = WebhookState {
1296 secret: String::new(),
1297 repo: "owner/repo".to_string(),
1298 tx,
1299 dedup_store: DedupStore::new_in_memory(7200, 50_000),
1300 };
1301
1302 let app = Router::new()
1303 .route("/webhook", post(handle_webhook))
1304 .with_state(state);
1305
1306 let body = serde_json::json!({
1307 "action": "created",
1308 "issue": {
1309 "number": 42,
1310 "title": "Existing issue",
1311 "labels": []
1312 },
1313 "comment": {
1314 "id": 12345,
1315 "body": "@orch please fix this",
1316 "user": {"login": "contributor"},
1317 "created_at": "2024-06-15T12:00:00Z"
1318 }
1319 });
1320
1321 let request = Request::builder()
1322 .method("POST")
1323 .uri("/webhook")
1324 .header("content-type", "application/json")
1325 .header("x-github-event", "issue_comment")
1326 .body(Body::from(serde_json::to_vec(&body).unwrap()))
1327 .unwrap();
1328
1329 let response = app.oneshot(request).await.unwrap();
1330 assert_eq!(response.status(), StatusCode::OK);
1331
1332 let msg = rx.try_recv().expect("should receive comment message");
1333 assert_eq!(msg.thread_id, "42", "thread_id should be the issue number");
1334 assert_eq!(msg.author, "contributor");
1335 assert!(msg.body.contains("@orch"));
1336 }
1337
1338 #[tokio::test]
1340 async fn test_webhook_handler_pr_review_submitted() {
1341 use axum::body::Body;
1342 use axum::http::Request;
1343 use tower::ServiceExt;
1344
1345 let (tx, mut rx) = tokio::sync::mpsc::channel::<IncomingMessage>(16);
1346
1347 let state = WebhookState {
1348 secret: String::new(),
1349 repo: "owner/repo".to_string(),
1350 tx,
1351 dedup_store: DedupStore::new_in_memory(7200, 50_000),
1352 };
1353
1354 let app = Router::new()
1355 .route("/webhook", post(handle_webhook))
1356 .with_state(state);
1357
1358 let body = serde_json::json!({
1359 "action": "submitted",
1360 "pull_request": {
1361 "number": 77,
1362 "title": "Add feature X"
1363 },
1364 "review": {
1365 "id": 98765,
1366 "state": "changes_requested",
1367 "body": "Fix the failing tests",
1368 "user": {"login": "maintainer"}
1369 }
1370 });
1371
1372 let request = Request::builder()
1373 .method("POST")
1374 .uri("/webhook")
1375 .header("content-type", "application/json")
1376 .header("x-github-event", "pull_request_review")
1377 .body(Body::from(serde_json::to_vec(&body).unwrap()))
1378 .unwrap();
1379
1380 let response = app.oneshot(request).await.unwrap();
1381 assert_eq!(response.status(), StatusCode::OK);
1382
1383 let msg = rx.try_recv().expect("should receive review message");
1384 assert_eq!(msg.thread_id, "77");
1385 assert_eq!(msg.author, "maintainer");
1386 assert_eq!(
1387 msg.metadata.get("review_state").unwrap().as_str().unwrap(),
1388 "changes_requested"
1389 );
1390 }
1391
1392 #[tokio::test]
1394 async fn test_webhook_handler_rejects_invalid_signature() {
1395 use axum::body::Body;
1396 use axum::http::Request;
1397 use tower::ServiceExt;
1398
1399 let (tx, mut rx) = tokio::sync::mpsc::channel::<IncomingMessage>(16);
1400
1401 let state = WebhookState {
1402 secret: "real-secret".to_string(),
1403 repo: "owner/repo".to_string(),
1404 tx,
1405 dedup_store: DedupStore::new_in_memory(7200, 50_000),
1406 };
1407
1408 let app = Router::new()
1409 .route("/webhook", post(handle_webhook))
1410 .with_state(state);
1411
1412 let body = serde_json::json!({
1413 "action": "opened",
1414 "issue": {
1415 "number": 1,
1416 "title": "Should be rejected",
1417 "labels": []
1418 }
1419 });
1420
1421 let request = Request::builder()
1422 .method("POST")
1423 .uri("/webhook")
1424 .header("content-type", "application/json")
1425 .header("x-github-event", "issues")
1426 .header("x-hub-signature-256", "sha256=invalid")
1427 .body(Body::from(serde_json::to_vec(&body).unwrap()))
1428 .unwrap();
1429
1430 let response = app.oneshot(request).await.unwrap();
1431 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1432
1433 assert!(
1435 rx.try_recv().is_err(),
1436 "no message should be sent for invalid signature"
1437 );
1438 }
1439
1440 #[tokio::test]
1443 async fn test_webhook_deduplication_same_delivery_id() {
1444 use axum::body::Body;
1445 use axum::http::Request;
1446 use tower::ServiceExt;
1447
1448 let (tx, mut rx) = tokio::sync::mpsc::channel::<IncomingMessage>(16);
1449
1450 let dedup_store = DedupStore::new_in_memory(7200, 50_000);
1452
1453 let body_json = serde_json::json!({
1454 "action": "opened",
1455 "issue": {
1456 "number": 200,
1457 "title": "Deduplicated issue",
1458 "labels": []
1459 }
1460 });
1461 let body_bytes = serde_json::to_vec(&body_json).unwrap();
1462
1463 {
1465 let state = WebhookState {
1466 secret: String::new(),
1467 repo: "owner/repo".to_string(),
1468 tx: tx.clone(),
1469 dedup_store: dedup_store.clone(),
1470 };
1471 let app = Router::new()
1472 .route("/webhook", post(handle_webhook))
1473 .with_state(state);
1474 let request = Request::builder()
1475 .method("POST")
1476 .uri("/webhook")
1477 .header("content-type", "application/json")
1478 .header("x-github-event", "issues")
1479 .header("x-github-delivery", "abc-123-unique-id")
1480 .body(Body::from(body_bytes.clone()))
1481 .unwrap();
1482 let response = app.oneshot(request).await.unwrap();
1483 assert_eq!(response.status(), StatusCode::OK);
1484 }
1485
1486 let msg = rx
1488 .try_recv()
1489 .expect("first delivery should produce a message");
1490 assert_eq!(msg.thread_id, "200");
1491 assert!(rx.try_recv().is_err(), "no second message yet");
1492
1493 {
1495 let state = WebhookState {
1496 secret: String::new(),
1497 repo: "owner/repo".to_string(),
1498 tx: tx.clone(),
1499 dedup_store: dedup_store.clone(),
1500 };
1501 let app = Router::new()
1502 .route("/webhook", post(handle_webhook))
1503 .with_state(state);
1504 let request = Request::builder()
1505 .method("POST")
1506 .uri("/webhook")
1507 .header("content-type", "application/json")
1508 .header("x-github-event", "issues")
1509 .header("x-github-delivery", "abc-123-unique-id")
1510 .body(Body::from(body_bytes.clone()))
1511 .unwrap();
1512 let response = app.oneshot(request).await.unwrap();
1513 assert_eq!(response.status(), StatusCode::OK);
1515 }
1516
1517 assert!(
1519 rx.try_recv().is_err(),
1520 "duplicate delivery should not produce a second message"
1521 );
1522
1523 let m = dedup_store.metrics().await;
1525 assert_eq!(m.inserted_count, 1, "one unique delivery inserted");
1526 assert_eq!(m.duplicate_count, 1, "one duplicate rejected");
1527 assert_eq!(m.seen_count, 1, "one entry in store");
1528
1529 {
1531 let state = WebhookState {
1532 secret: String::new(),
1533 repo: "owner/repo".to_string(),
1534 tx: tx.clone(),
1535 dedup_store: dedup_store.clone(),
1536 };
1537 let app = Router::new()
1538 .route("/webhook", post(handle_webhook))
1539 .with_state(state);
1540 let request = Request::builder()
1541 .method("POST")
1542 .uri("/webhook")
1543 .header("content-type", "application/json")
1544 .header("x-github-event", "issues")
1545 .header("x-github-delivery", "xyz-456-different-id")
1546 .body(Body::from(body_bytes))
1547 .unwrap();
1548 let response = app.oneshot(request).await.unwrap();
1549 assert_eq!(response.status(), StatusCode::OK);
1550 }
1551
1552 let msg2 = rx
1553 .try_recv()
1554 .expect("different delivery ID should produce a message");
1555 assert_eq!(msg2.thread_id, "200");
1556
1557 let m = dedup_store.metrics().await;
1559 assert_eq!(m.inserted_count, 2, "two unique deliveries inserted total");
1560 assert_eq!(m.duplicate_count, 1, "still one duplicate");
1561 }
1562
1563 #[tokio::test]
1565 async fn test_health_endpoint_returns_json_metrics() {
1566 use axum::body::Body;
1567 use axum::http::Request;
1568 use tower::ServiceExt;
1569
1570 let (tx, _rx) = tokio::sync::mpsc::channel::<IncomingMessage>(16);
1571 let dedup_store = DedupStore::new_in_memory(7200, 50_000);
1572
1573 dedup_store.check_and_insert("health-test-1").await;
1575 dedup_store.check_and_insert("health-test-1").await;
1576
1577 let state = WebhookState {
1578 secret: String::new(),
1579 repo: "owner/repo".to_string(),
1580 tx,
1581 dedup_store,
1582 };
1583
1584 let app = Router::new()
1585 .route("/health", get(webhook_health))
1586 .with_state(state);
1587
1588 let request = Request::builder()
1589 .method("GET")
1590 .uri("/health")
1591 .body(Body::empty())
1592 .unwrap();
1593
1594 let response = app.oneshot(request).await.unwrap();
1595 assert_eq!(response.status(), StatusCode::OK);
1596
1597 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1598 .await
1599 .unwrap();
1600 let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
1601
1602 assert_eq!(json["ok"], true);
1603 assert_eq!(json["seen_count"], 1);
1604 assert_eq!(json["duplicate_count"], 1);
1605 }
1606
1607 #[test]
1609 fn test_is_transient_bind_error_addr_in_use() {
1610 let io_err = std::io::Error::from(std::io::ErrorKind::AddrInUse);
1611 let err = anyhow::anyhow!(io_err);
1612 assert!(is_transient_bind_error(&err));
1613 }
1614
1615 #[test]
1617 fn test_is_transient_bind_error_other_error() {
1618 let io_err = std::io::Error::from(std::io::ErrorKind::ConnectionRefused);
1619 let err = anyhow::anyhow!(io_err);
1620 assert!(!is_transient_bind_error(&err));
1621 }
1622
1623 #[test]
1625 fn test_webhook_backoff_delay_cap() {
1626 let delay = webhook_backoff_delay(10);
1629 assert!(
1630 delay.as_millis() <= 30_500,
1631 "delay exceeded 30 s cap + jitter: {delay:?}"
1632 );
1633 assert!(
1634 delay.as_millis() >= 500,
1635 "delay should be at least base: {delay:?}"
1636 );
1637 }
1638
1639 #[tokio::test]
1646 async fn test_start_webhook_server_port_in_use() {
1647 let holder = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
1649 let port = holder.local_addr().unwrap().port();
1650
1651 let result = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await;
1653 drop(holder);
1654
1655 let io_err = result.expect_err("expected EADDRINUSE on duplicate bind");
1656 let anyhow_err = anyhow::anyhow!(io_err);
1657 assert!(
1658 is_transient_bind_error(&anyhow_err),
1659 "EADDRINUSE should be classified as transient: {anyhow_err}"
1660 );
1661 }
1662}