1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
//! The one place the host-cancellation error is constructed.
//!
//! A cancellation that is thrown without dispatching `on_interrupt` handlers
//! is a cleanup hook that silently does not run. That drifted across eleven
//! call sites because nothing said they had to agree: three modules each grew
//! their own constructor for the identical payload, two of them sharing a
//! function name, and three more wrote it inline.
//!
//! Every cancellation now states, in this constructor's signature, whether
//! handlers ran for it or the reason they could not. `scripts/check_cancellation_owner.harn`
//! keeps the payload to this file so a twelfth site cannot compile past CI.
use crate::value::{VmError, VmValue};
/// The thrown payload.
///
/// This text is a wire contract, not an implementation detail: consumers match
/// it, one by prefix and one by full equality. It may not change shape without
/// changing them, which is tracked separately as narrowing those consumers to
/// a typed match.
const CANCELLATION_PAYLOAD: &str = "kind:cancelled:VM cancelled by host";
/// Why a cancellation could not run interrupt handlers.
///
/// Each variant is a claim about the call site that a reviewer can check, not
/// a catch-all. Adding one means a new reason genuinely exists.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NotDispatchedReason {
/// The observing code holds a cancellation token and no machine, so it has
/// nothing to dispatch through. Its caller dispatches instead.
NoMachineInScope,
/// The cancellation is being replayed from a recorded run. Handlers ran
/// when the run was live; running them again would repeat their effects.
ReplayPath,
}
impl NotDispatchedReason {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::NoMachineInScope => "no machine in scope",
Self::ReplayPath => "replay path",
}
}
}
/// Whether interrupt handlers ran for this cancellation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HandlerDispatch {
/// Handlers were dispatched through the machine's single owner before this
/// error was constructed.
Dispatched,
/// Handlers did not run, for the stated reason.
NotDispatched(NotDispatchedReason),
}
/// Construct the host-cancellation error.
///
/// The payload is identical for every caller. `dispatch` records what happened
/// to the interrupt handlers so a receipt can report it; it deliberately does
/// not reach the thrown value, because consumers match that value by text.
pub(crate) fn cancelled_error(dispatch: HandlerDispatch) -> VmError {
if let HandlerDispatch::NotDispatched(reason) = dispatch {
record_undispatched_cancellation(reason);
}
VmError::Thrown(VmValue::String(arcstr::ArcStr::from(CANCELLATION_PAYLOAD)))
}
/// The cancellation for an observer that holds no machine to dispatch through.
///
/// Named rather than spelled out at each site, because the three
/// token-holding families repeat it and the name says the same thing the
/// variant does. The frame that awaits the operation dispatches on its behalf.
pub(crate) fn cancelled_without_machine() -> VmError {
cancelled_error(HandlerDispatch::NotDispatched(
NotDispatchedReason::NoMachineInScope,
))
}
/// Whether this error is the host cancellation.
///
/// The payload is matched here and nowhere else, so a caller asking "was this
/// cancelled" does not have to re-type the text and drift from it.
pub(crate) fn is_cancellation(error: &VmError) -> bool {
matches!(
error,
VmError::Thrown(VmValue::String(message)) if message.as_str() == CANCELLATION_PAYLOAD
)
}
/// Note a cancellation that ran no handlers at a frame that is not itself
/// constructing the error, so the reason is still recorded once.
pub(crate) fn note_not_dispatched(reason: NotDispatchedReason) {
record_undispatched_cancellation(reason);
}
/// Note a cancellation that ran no handlers, so the count is visible rather
/// than inferred from silence.
fn record_undispatched_cancellation(reason: NotDispatchedReason) {
tracing::debug!(
target: "harn::cancellation",
reason = reason.as_str(),
"cancellation thrown without dispatching interrupt handlers",
);
}
#[cfg(test)]
mod tests {
use super::*;
/// The payload is a wire contract. If this assertion has to change, the two
/// consumers that match the text have to change in the same commit.
#[test]
fn payload_is_byte_identical_for_every_dispatch_outcome() {
let expected = "kind:cancelled:VM cancelled by host";
for dispatch in [
HandlerDispatch::Dispatched,
HandlerDispatch::NotDispatched(NotDispatchedReason::NoMachineInScope),
HandlerDispatch::NotDispatched(NotDispatchedReason::ReplayPath),
] {
match cancelled_error(dispatch) {
VmError::Thrown(VmValue::String(message)) => {
assert_eq!(message.as_str(), expected);
}
other => panic!("cancellation must stay runtime control flow, got {other:?}"),
}
}
}
}