coven_foundation/task_location.rs
1//! Where a panic coven catches began (api.md E15).
2//!
3//! A panic's payload holds only its text: `std::panic::Location` reaches the
4//! panic hook alone. So each of coven's catches marks its thread as catching
5//! while its work runs, and a hook coven adds in front of the process's own
6//! writes where a panic began into the innermost mark on the panicking
7//! thread. The catch takes it back with the payload, so the location is part
8//! of what the catch returns. Work run as its own task or on the blocking
9//! pool runs under such a catch on its own thread, and its bug, location
10//! included, travels through the join as a value.
11//!
12//! The hook is the one piece of state the process shares, and it is safe with
13//! many threads: it is added once, chained in front of whatever hook was set
14//! before, which it calls in turn, and it touches only the panicking thread's
15//! own mark. A panic unwinds only on the thread that raised it, so the mark it
16//! writes is the one the catch around it reads. A panic outside every catch
17//! of coven's finds no mark and is left to the hooks after coven's.
18
19use std::any::Any;
20use std::cell::Cell;
21use std::panic::{catch_unwind, AssertUnwindSafe, Location, PanicHookInfo};
22use std::sync::Once;
23
24/// Where in the code a bug showed itself: the file, line and column of the
25/// panic, or of the code that found the bug and made it.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct SourceLocation {
28 /// The source file, as the compiler named it.
29 pub file: String,
30 /// The line in it, from 1.
31 pub line: u32,
32 /// The column in that line, from 1.
33 pub column: u32,
34}
35
36impl SourceLocation {
37 /// The place `location` names.
38 pub(crate) fn of(location: &Location<'_>) -> Self {
39 Self { file: location.file().to_string(), line: location.line(), column: location.column() }
40 }
41}
42
43impl std::fmt::Display for SourceLocation {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 write!(f, "{}:{}:{}", self.file, self.line, self.column)
46 }
47}
48
49/// What a thread's innermost catch of coven's has seen.
50enum Mark {
51 /// No catch of coven's is running on this thread.
52 Unmarked,
53 /// One is, running `operation`, and `location` is where the latest panic
54 /// inside it began, once one has.
55 Catching { operation: &'static str, location: Option<SourceLocation> },
56}
57
58thread_local! {
59 /// This thread's innermost catch of coven's, which the hook writes and
60 /// the catch reads back. A catch inside another keeps the outer one's
61 /// mark aside and puts it back as it ends.
62 static MARK: Cell<Mark> = const { Cell::new(Mark::Unmarked) };
63}
64
65/// Adds coven's hook in front of the process's, once.
66static CHAINED: Once = Once::new();
67
68/// `work`'s value, or the payload of its panic and where that panic began.
69/// While `work` runs, this catch is the thread's innermost, named
70/// `operation`.
71///
72/// The location is `None` only for an unwind coven's hook didn't see begin:
73/// one resumed with `std::panic::resume_unwind` from a panic elsewhere, or a
74/// panic after the app replaced the process's hook with one that doesn't call
75/// the hook it replaced.
76pub(crate) fn catch_located<R>(
77 operation: &'static str,
78 work: impl FnOnce() -> R,
79) -> Result<R, (Box<dyn Any + Send>, Option<SourceLocation>)> {
80 chain_hook();
81 let outer = swap_mark(Mark::Catching { operation, location: None });
82 let caught = catch_unwind(AssertUnwindSafe(work));
83 // `outer` is `None` once the thread's locals are gone, as its last
84 // destructors run: then there is no mark to restore or read.
85 let inner = outer.and_then(swap_mark);
86 caught.map_err(|payload| match inner {
87 Some(Mark::Catching { location, .. }) => (payload, location),
88 Some(Mark::Unmarked) | None => (payload, None),
89 })
90}
91
92/// The operation of the innermost catch of coven's running on this thread,
93/// or `None` outside every one: for the app's own panic hook, to say what
94/// the panicking work was doing. A panic a catch inside that work handles
95/// itself, such as a task tokio runs on this thread and catches, still
96/// names it. It never panics, so a hook can call it: a panic inside a hook
97/// ends the process.
98pub fn catching_operation() -> Option<&'static str> {
99 MARK.try_with(|place| {
100 let mark = place.replace(Mark::Unmarked);
101 let operation = match &mark {
102 Mark::Catching { operation, .. } => Some(*operation),
103 Mark::Unmarked => None,
104 };
105 place.set(mark);
106 operation
107 })
108 .ok()
109 .flatten()
110}
111
112/// Put `mark` in this thread's place and return what was there, or `None`
113/// when the thread's locals are already gone.
114fn swap_mark(mark: Mark) -> Option<Mark> {
115 MARK.try_with(|place| place.replace(mark)).ok()
116}
117
118/// Add coven's hook in front of the process's, once. A thread that is
119/// panicking can't set a hook, and std would panic again inside the unwind,
120/// which ends the process, so a catch entered during an unwind leaves it to
121/// the next catch.
122fn chain_hook() {
123 if std::thread::panicking() {
124 return;
125 }
126 CHAINED.call_once(|| {
127 let earlier = std::panic::take_hook();
128 std::panic::set_hook(Box::new(move |panic| {
129 mark_panic(panic);
130 earlier(panic);
131 }));
132 });
133}
134
135/// Write where `panic` began into this thread's innermost catch, if one of
136/// coven's is running. A panic inside the hook ends the process, so nothing
137/// here can panic: the mark is a `Cell`, taken and put back whole.
138fn mark_panic(panic: &PanicHookInfo<'_>) {
139 let Some(location) = panic.location() else {
140 return;
141 };
142 #[expect(
143 clippy::let_underscore_must_use,
144 reason = "a thread whose locals are gone is exiting, and runs no catch of coven's"
145 )]
146 let _ = MARK.try_with(|place| {
147 if let Mark::Catching { operation, .. } = place.replace(Mark::Unmarked) {
148 place.set(Mark::Catching { operation, location: Some(SourceLocation::of(location)) });
149 }
150 });
151}