|
| 1 | +use std::cell::RefCell; |
1 | 2 | use std::collections::HashSet; |
2 | | -use std::io::{Read as _, Seek as _}; |
3 | | -use std::sync::Mutex; |
| 3 | +use std::sync::{Arc, Mutex, OnceLock}; |
4 | 4 |
|
5 | 5 | use rstest::rstest; |
6 | 6 |
|
7 | 7 | use super::store; |
8 | 8 | use crate::meta::{MetaStore, NewWebhookDelivery, WebhookDeliveryAttempt, WebhookDeliveryStatus, WebhookEventIntent}; |
9 | 9 |
|
| 10 | +thread_local! { |
| 11 | + /// Where this thread's events go while it holds a [`Captured`], and nowhere otherwise. |
| 12 | + static CAPTURE: RefCell<Option<Arc<Mutex<Vec<u8>>>>> = const { RefCell::new(None) }; |
| 13 | +} |
| 14 | + |
| 15 | +/// A writer that hands each event to the capture belonging to the thread that raised it, so one |
| 16 | +/// subscriber serves every test at once. |
| 17 | +struct ThreadCapture; |
| 18 | + |
| 19 | +impl std::io::Write for ThreadCapture { |
| 20 | + fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { |
| 21 | + CAPTURE.with_borrow(|sink| { |
| 22 | + if let Some(sink) = sink { |
| 23 | + sink.lock().unwrap().extend_from_slice(buf); |
| 24 | + } |
| 25 | + }); |
| 26 | + Ok(buf.len()) |
| 27 | + } |
| 28 | + |
| 29 | + fn flush(&mut self) -> std::io::Result<()> { |
| 30 | + Ok(()) |
| 31 | + } |
| 32 | +} |
| 33 | + |
| 34 | +impl tracing_subscriber::fmt::MakeWriter<'_> for ThreadCapture { |
| 35 | + type Writer = Self; |
| 36 | + |
| 37 | + fn make_writer(&self) -> Self { |
| 38 | + Self |
| 39 | + } |
| 40 | +} |
| 41 | + |
| 42 | +/// The events this thread raises, until it drops. |
| 43 | +/// |
| 44 | +/// The subscriber is installed once for the whole binary rather than per test, because a callsite |
| 45 | +/// decides its interest the first time any thread executes it and every thread reads that decision |
| 46 | +/// afterwards. A per-test subscriber leaves that decision to whichever thread arrives first: one |
| 47 | +/// holding no subscriber at all resolves the callsite against nothing, caches `never`, and silences |
| 48 | +/// the callsite for the tests that are asserting on it. A subscriber that outlives every test cannot |
| 49 | +/// be the one missing when that question is asked. |
| 50 | +struct Captured(Arc<Mutex<Vec<u8>>>); |
| 51 | + |
| 52 | +impl Captured { |
| 53 | + fn install() -> Self { |
| 54 | + static SUBSCRIBER: OnceLock<()> = OnceLock::new(); |
| 55 | + SUBSCRIBER.get_or_init(|| { |
| 56 | + let subscriber = tracing_subscriber::fmt() |
| 57 | + .with_ansi(false) |
| 58 | + .without_time() |
| 59 | + .with_writer(ThreadCapture) |
| 60 | + .finish(); |
| 61 | + tracing::subscriber::set_global_default(subscriber) |
| 62 | + .expect("the test binary installs one global subscriber"); |
| 63 | + }); |
| 64 | + let sink = Arc::new(Mutex::new(Vec::new())); |
| 65 | + CAPTURE.with_borrow_mut(|slot| *slot = Some(Arc::clone(&sink))); |
| 66 | + Self(sink) |
| 67 | + } |
| 68 | + |
| 69 | + fn output(&self) -> String { |
| 70 | + String::from_utf8(self.0.lock().unwrap().clone()).expect("the fmt subscriber writes utf-8") |
| 71 | + } |
| 72 | +} |
| 73 | + |
| 74 | +impl Drop for Captured { |
| 75 | + fn drop(&mut self) { |
| 76 | + CAPTURE.with_borrow_mut(|slot| *slot = None); |
| 77 | + } |
| 78 | +} |
| 79 | + |
10 | 80 | fn none() -> HashSet<(String, String)> { |
11 | 81 | HashSet::new() |
12 | 82 | } |
@@ -346,31 +416,22 @@ fn test_webhook_queue_scan_skips_and_cleans_damaged_rows(#[case] damage: QueueDa |
346 | 416 |
|
347 | 417 | let store = MetaStore::open_existing(&path).unwrap(); |
348 | 418 | assert_eq!(store.next_webhook_delivery_at().unwrap(), Some(20)); |
349 | | - let mut capture = tempfile::tempfile().unwrap(); |
350 | | - let subscriber = tracing_subscriber::fmt() |
351 | | - .with_ansi(false) |
352 | | - .without_time() |
353 | | - .with_writer(Mutex::new(capture.try_clone().unwrap())) |
354 | | - .finish(); |
355 | | - tracing::subscriber::with_default(subscriber, || { |
356 | | - for _ in 0..2 { |
357 | | - assert_eq!( |
358 | | - store |
359 | | - .list_due_webhook_deliveries(100, 10, &none()) |
360 | | - .unwrap() |
361 | | - .into_iter() |
362 | | - .map(|record| record.id) |
363 | | - .collect::<Vec<_>>(), |
364 | | - std::slice::from_ref(&healthy) |
365 | | - ); |
366 | | - } |
367 | | - }); |
| 419 | + let captured = Captured::install(); |
| 420 | + for _ in 0..2 { |
| 421 | + assert_eq!( |
| 422 | + store |
| 423 | + .list_due_webhook_deliveries(100, 10, &none()) |
| 424 | + .unwrap() |
| 425 | + .into_iter() |
| 426 | + .map(|record| record.id) |
| 427 | + .collect::<Vec<_>>(), |
| 428 | + std::slice::from_ref(&healthy) |
| 429 | + ); |
| 430 | + } |
368 | 431 | if matches!(damage, QueueDamage::InvalidJson) { |
369 | 432 | assert_eq!(store.get_webhook_delivery(damaged.as_deref().unwrap()).unwrap(), None); |
370 | 433 | } |
371 | | - capture.rewind().unwrap(); |
372 | | - let mut output = String::new(); |
373 | | - capture.read_to_string(&mut output).unwrap(); |
| 434 | + let output = captured.output(); |
374 | 435 | assert_eq!(output.matches("discarding damaged webhook queue rows").count(), 1); |
375 | 436 | assert!(output.contains(&format!("{count}=1")), "{output}"); |
376 | 437 | assert!(!output.contains("unbounded-corrupt-identifier")); |
@@ -457,3 +518,26 @@ fn test_list_due_separates_same_target_name_across_indexes() { |
457 | 518 | let ids: Vec<&str> = due.iter().map(|record| record.id.as_str()).collect(); |
458 | 519 | assert_eq!(ids, [first.as_str(), second.as_str()]); |
459 | 520 | } |
| 521 | + |
| 522 | +/// The interleaving that silenced this file's captures: a thread holding no subscriber is the first to |
| 523 | +/// execute a callsite, which is when its interest is decided for every thread that follows. A capture |
| 524 | +/// that only exists for the length of one test is absent at that moment; the binary's subscriber is |
| 525 | +/// not, so the event still reaches the thread asserting on it. |
| 526 | +#[test] |
| 527 | +fn test_a_capture_survives_a_neighbour_reaching_the_callsite_first() { |
| 528 | + let captured = Captured::install(); |
| 529 | + std::thread::spawn(unsubscribed_first_event).join().unwrap(); |
| 530 | + unsubscribed_first_event(); |
| 531 | + |
| 532 | + assert!( |
| 533 | + captured.output().contains("callsite registered by a bare thread"), |
| 534 | + "a thread with no subscriber decided this callsite for everyone: {:?}", |
| 535 | + captured.output() |
| 536 | + ); |
| 537 | +} |
| 538 | + |
| 539 | +/// A callsite of its own, so the test asserts on a first execution rather than on one some earlier test |
| 540 | +/// already resolved. |
| 541 | +fn unsubscribed_first_event() { |
| 542 | + tracing::warn!(target: "peryx::webhook", "callsite registered by a bare thread"); |
| 543 | +} |
0 commit comments