What kind(s) of breakpoint do we want?
It would be nice to get some form of "breakpoint" operation stabilized in some form. Currently the candidate public API is core::arch::breakpoint. Tracking issue: https://github.com/rust-lang/rust/issues/133724.
However, discussion close to stabilization revealed that different people have subtly but decisively different ideas of what the term "breakpoint" even means.
- There's the "default-abort" kind of breakpoint, also sometimes called yeet1 in our discussions. This breakpoint can be used in a
mylib::assert!kind of macro. In terms of soundness reasoning it is equivalent to anabort_immediate, but in terms of compilation it does its best to generate code that will cause an attached debugger, if any, to halt execution to let the user inspect the state, and it makes it such that in a debugger, one can (at one's own risk) choose to continue beyond the breakpoint. - There's the "may-continue" kind of breakpoint, also sometimes called yeet2 in our discussions. In terms of soundness reasoning it is equivalent to "non-deterministically either abort execution or just continue", but in terms of compilation it does its best to generate code that will cause an attached debugger, if any, to halt execution and give the user of the debugger a chance to inspect the current state. It is always safe for the user of the debugger to continue beyond this breakpoint.
These were dubbed yeet1 and yeet2 in this great summary. On most targets, the compiler would do the exact same thing for both of them, the difference is only in who is to blame should there be UB after continuing from a breakpoint: default-abort / yeet1 says that continuing is unsafe and happens at the risk of of whoever controls the debugger, may-continue / yeet2 says that continuing is safe and the programmer who wrote the code is responsible for ensuring that the following code does not cause UB. Accordingly, the default behavior of Miri would differ between yeet1 and yeet2.
It is worth emphasizing that yeet2 is still an optimization barrier. UB that may occur after a breakpoint is not allowed to time-travel to before the breakpoint under any breakpoint semantics. (This follows from the fact that yeet2 may abort the program.) This makes inserting breakpoints into the program a viable tool for debugging UB, under both semantics.
The fact that the compiler treats both of them the same most of the time can make it hard to talk about the difference between the two. In my eyes, the best litmus test we found for this is the mylib::assert! test: do you think that an implementation of assert! that uses breakpoint instead of abort/panic! is correct? Yeet2 says "clearly not", yeet1 says "clearly yes". The current docs for our operation say that yes it can be used instead of abort ("in normal execution with no debug tooling involved this will not continue executing"), so the docs represent yeet1 (though it could be made more clear that continuing in a debugger, while possible, is considered to "void the warranty" so to speak).
Another way to frame the difference between these two is to consider what a portable version of the operation would do, in case where an instruction with the right semantics is not available. For instance, on Armv5T, Armv6 the BKPT instruction is not guaranteed to interrupt execution.
- yeet1 must abort, so the best it could do is
BKPT; call abort. - yeet2 could compile to just
BKPT.
And finally, there is one more related version of the yeet1 vs yeet2 question, not quite equivalent but strongly correlated: the implementation of yeet1/yeet2 generally ends up triggering an interrupt (e.g. int3 triggering #BR on x86) that the OS then handles, sometimes in a way that can be customized e.g. via signal handlers. Are those required to abort the process? Almost all do (see below), but do we say that it is unsound if they don't? yeet1 would impose such a requirement on all OSes, bare-metal environments, and signal handlers. It is not clear that this is already a requirement today: to my knowledge, Rust is totally sound even if the OS were to always-continue in that case. This requirement, even without yeet1 existing as an operation, also has the consequence that mashing "continue" in a debugger becomes unsafe as one may accidentally continue past an asm!("int3") that was meant as a hard-abort. (Some mitigation techniques would be less effective as they rely on spraying int3 instructions to abort the process when something goes wrong, but that can only matter after UB already happened so it is not a soundness question. Mitigations are best-effort. It is clear that the interrupt should abort the program to ensure that the mitigation is effective, but that does not mean that it must abort.)
If we look at the use of the term "breakpoint" and adjacent terms elsewhere, we find:
- Debuggers themselves provide the option to add breakpoints. It is always safe to remove them again, so this corresponds to yeet2.
- Windows docs say that "DebugBreak and __debugbreak have the same effect as setting a breakpoint at that location". I would read this as saying that this is yeet2, not yeet1.
- C++ has a
std::breakpointoperation. Its behavior is mostly implementation-defined and it does not say that the program will typically abort, so portable C++ code can arguably not use this as-if it was yeet1. This is reinforced by the phrasing "temporarily stop program execution" (emphasis mine) and by using "infrequent non-critical condition" (emphasis mine) as the first example. Note however that this text is just in cppref, the standard literally just says "The semantics of this function are implementation-defined", plus a non-normative note that includes the sentence "If there is no debugger present, execution of the program can end abnormally" (emphasis mine). All of this sounds like yeet2 to me. Neither GCC nor clang seem to actually define what their implementation does at the moment, so C++ code written specifically for GCC/clang also cannot usestd::breakpointas a way to abort the program in my eyes. But C++ likes to blur the line between "logic bug" and "UB" so it's hard to be sure about this. - Zig has
@breakpoint, which explicitly says "execution may continue after this point if the program is resumed". This is very clear about being yeet2. - Python has a
breakpointbuilt-in function. This defaults topdb.set_trace, but can be overridden in various ways, including with thePYTHONBREAKPOINTenvironment variable. Setting that variable to 0 causesbreakpointto become a NOP and there is no warning that this could lead to programs going wrong in terrible ways, so that seems to be an explicitly supported albeit non-default configuration. A quick github search shows a bunch of real-world uses of that configuration. Programmers therefore have to treat this like yeet2. - Go has a
runtime.Breakpointfunction that is documented as "Breakpoint executes a breakpoint trap". That largely defines the term in terms of itself so it doesn't give us much insight. - LLVM's
debugtrap(which is what we use to implementarch::breakpointat the moment) is not documented precisely enough to tell the difference. This document analyzes the instructions LLVM uses fordebugtrapon various architectures. Not all of them are a valid implementation of yeet1: on Armv5T and Armv6, "software cannot rely on the Prefetch Abort exception occurring". In particular, this means that our current implementation ofarch::breakpoint()does not match our docs. I do not know if LLVM intends to make the guarantee we document fordebugtrap(LLVM issue), but it's not a documented guarantee at the moment so it's clearly fishy for us to make claims about the intrinsic that are not backed by what LLVM documents. Implementing yeet1 will therefore require working with LLVM to ensure that the backend indeed provides the guarantees we want to make, or rolling our own implementation instead of usingdebugtrap. - On the hardware instruction side, many instruction sets have a "breakpoint" instruction, called EBREAK, BRK, BKPT, BRKPT, BREAK or similar. x86 uses
int3but that is often referred to as a breakpoint as well. Those instructions interrupt execution and put the OS in charge; the ISA does not care if the OS continues execution or aborts. OSes typically default to aborting the process if no debugger is attached, but that behavior can sometimes be overwritten, e.g. with a signal handler. For bare-metal systems, behavior is up to the interrupt handler. As mentioned above, on older ARM the instruction MAY continue execution, depending on vendor hardware configuration (the OS cannot intervene). Old x86 environments (DOS or BIOS mode) sometimes choose to continue the program (details). AVR does have a BREAK instruction that is not meant to be used by application software, and whose behavior depends on the chip (some chips document that it becomes a NOP when no debugger is attached).
In the discussion, most people had a yeet2 understanding of breakpoints, in line with the Windows and C++ docs ("corresponds to what one sets in a debugger; may abort without a debugger but is not guaranteed to abort"), but Josh Triplett understands a "breakpoint" to mean yeet1 ("something that I can use instead of abort"); he also wrote the current docs for arch::breakpoint. Some people said always continuing in a debugger should be fine (i.e., it's always a bug in the program if something goes wrong then); that makes yeet1 impossible so Josh obviously disagrees. We don't have representative data for how many people would implicitly assume "breakpoint" to mean yeet1, but the survey above found no such case (the docs either say that execution may continue, or that it may abort, but never that it must abort; ISAs say that the interrupt handler is in charge but obviously do not mandate what happens in the interrupt handler).
So... how do we resolve this? Defining what "breakpoint" means for Rust, and especially the soundness question, i.e., whether it is sound to use a breakpoint in lieu of abort, seems like a lang question to me; deciding how to expose that operation and under which name is a libs question.
- If we provide only a single operation, what should it be called and what semantics should it have? Given the fact that the term has different meaning to different people in the discussion, it might be wise to avoid the use of the unqualified "breakpoint", so e.g. if we decided to stabilize yeet1 we could call it
breakpoint_abortor so to make it clear that this is different from e.g. C++std::breakpoint. If we decided to stabilize yeet2 we could call itresumable_breakpointto avoid surprises for people that share Josh's understanding of the term. An operation with yeet2 semantics may also be better located incore::hintthancore::arch. - If we do provide yeet1, how do we deal with the environments where the abort guarantee is not given (DOS, BIOS, Armv5T, Armv6)? These are quite niche these days but at least the ARM ones occur in tier 2 targets, and it is conceivable that one would use the headless x86 target for DOS/BIOS programming. Without C++ or LLVM making such a guarantee, the burden of ensuring it truly aborts is entirely on us.
- Or we could provide a different operation altogether, maybe along the lines of "whatever instruction the current ISA calls a breakpoint" (with the operation either not existing or having some defined fallback when there is no such instruction, e.g. on wasm). Let's call that yeet3. The placement in
archmakes most sense for this operation IMO. That would not have precedent in other languages / APIs that I am aware of -- a C++ compiler could make such a guarantee as part of its "implementation-defined" semantics forstd::breakpoint, but I am not aware of any compiler that actually documents such a guarantee. Portable code could not rely on this operation aborting (e.g. on Armv5T, Armv6), but target-specific code could (e.g. on x86-64, aarch64, if we require bare-metal code to abort on the corresponding interrupts and assuming no x86-64 environment behaves like DOS/BIOS). - We could also provide more than one operation. If we end up with yeet1 and yeet2, they would compile the same most of the time, but compile different on Armv5T, Armv6 and behave different in Miri by default.
And what about the soundness of having the OS / signal handler / debugger continue past some arbitrary int3 (or equivalent instruction for other targets)?
- If we provide some form of yeet1 that implies that the OS must abort-by-default on
int3. yeet3 alone does not imply that, but if people want to use yeet3 to get yeet1 semantics on specific targets that still mandates the OS to abort-by-default. - If we only provide yeet2, both choices are possible. The question still matters as people may do
asm!("int3")when they mean "abort" and whether or not that is sound depends on this question. (And it does seem like this would indeed not be sound in a DOS/BIOS environment.) However, we could defer answering this question to a later point, it doesn't have to be packaged with the "breakpoint" decision.
Personally I would prefer if we had core::hint::resumable_breakpoint with yeet2 semantics, matching the precedent from other languages but with a more explicit name. No other language seems to intend their "breakpoint" to be used as a means to abort the program on a fatal assertion failure; I don't see a strong enough reason to diverge from that precedent. Given that LLVM does not currently document that debugtrap must abort, and given that we found an example where LLVM emits instruction that the ISA vendor says may not reliable trigger an exception (Armv5T, Armv6), I am also concerned about the cat-and-mouse game we'd have to play to implement yeet1. For the usecase of "abort the process but make it easy to inspect the state in a debugger", I wonder if abort_immediate cannot fulfill that role -- maybe only for debug builds, if there is a conflict between "abort as fast as possible" and debuggability.
I feel uneasy about making the assumption that int3 and friends always abort, though I admit the counterexamples are quite niche. So my preference would be to say that they should abort, which is useful for mitigations that want to spray memory with a 1-byte instruction, but without saying that they must abort. This would imply no yeet1, and yeet3 could not be used for mylib::assert! (only for mylib::debug_assert!). This is also more friendly to debugger users who do not have to worry about using "continue" on a breakpoint -- when there's a mix of user-set breakpoints (which are safe to continue) and breakpoints embedded in the program, it could otherwise become quite easy to accidentally continue past the wrong kind of breakpoint.
But in the end I can live with basically every outcome here as long as we are clear about the assumptions we are making for the environment Rust programs run in, and the guarantees we provide to our users. yeet3 is not very clear about either so I like it the least.
Source: rust-lang/rust